From 39d7c01b7b45d7dee62eade97996d3cd325262d4 Mon Sep 17 00:00:00 2001 From: Saqeb Akhter Date: Fri, 16 Oct 2026 11:02:46 +0400 Subject: [PATCH 01/96] fix(terminal): make native scrolling consistent and responsive Track fractional wheel gestures, bound unsent input, enable tracked Codex scrolling and send the first native report immediately after tab selection. Refs: https://github.com/johoja12/Codeman/issues/7 --- src/web/public/terminal-tile.js | 27 +++- src/web/public/terminal-ui.js | 147 +++++++++++++------- test/terminal-scroll-routing.test.ts | 194 ++++++++++++++++++++++++--- test/terminal-tile-scroll.test.ts | 50 ++++--- test/terminal-touch-tap.test.ts | 20 +-- 5 files changed, 342 insertions(+), 96 deletions(-) diff --git a/src/web/public/terminal-tile.js b/src/web/public/terminal-tile.js index 4850929b..8002e44a 100644 --- a/src/web/public/terminal-tile.js +++ b/src/web/public/terminal-tile.js @@ -229,6 +229,7 @@ } async connect() { + this._wheelNativeStarted = false; const savedFontSize = this.fontSize ?? parseInt(localStorage.getItem('codeman-font-size'), 10); this.terminal = new Terminal({ theme: { ...global.codemanCurrentXtermTheme() }, @@ -1089,19 +1090,23 @@ if (this._destroyed || !this.terminal || !ev) return false; const app = global.app; const input = global.CodemanTerminalInput; - if (!app?._shouldForwardWheelToApp || !input?.sgrWheelReports || !input.wheelDeltaWholeLines) return false; + if (!app?._shouldForwardWheelToApp || !input?.sgrWheelReports || !input.wheelTicksForGesture) return false; // xterm's own encoder forwards the wheel while the CLI's tracking reaches // it, and its alt-scroll owns the alternate buffer, as in the primary pane. const tracking = this.terminal.modes?.mouseTrackingMode; if (tracking && tracking !== 'none') return false; if (this.terminal.buffer?.active?.type === 'alternate') return false; if (!app._shouldForwardWheelToApp(ev, { terminal: this.terminal, sessionId: this.sessionId })) return false; + if (ev.ctrlKey || Math.abs(ev.deltaX || 0) > Math.abs(ev.deltaY || 0)) return true; // SGR coordinates address the live screen, so a report from a scrolled-up // viewport would hit-test another row: snap home first (_forwardScrollToApp). if (!app._terminalViewportAtBottom?.(this.terminal)) this.terminal.scrollToBottom?.(); - const lines = input.wheelDeltaWholeLines(ev, this.terminal.rows); + const lines = input.wheelDeltaNativeLines(ev, this.terminal.rows); + if (this._wheelTickGesture?.dir && this._wheelTickGesture.dir !== Math.sign(lines)) this._scrollBytes = ''; + const step = input.wheelTicksForGesture(this._wheelTickGesture, lines, performance.now()); + this._wheelTickGesture = step.state; const pos = app._clientPointToCell?.(ev.clientX, ev.clientY, this.terminal); - const bytes = input.sgrWheelReports(lines, pos); + const bytes = input.sgrWheelReports(step.ticks, pos); if (bytes) this._queueScrollBytes(bytes); return true; } @@ -1201,6 +1206,8 @@ // session, which is usually not the active one: a page key would move its // selector. Consumed, so xterm does not scroll the rows under it either. if (app.tabAlerts?.get(this.sessionId) === 'action') return true; + if (ev.ctrlKey || Math.abs(ev.deltaX || 0) > Math.abs(ev.deltaY || 0)) return true; + if (this._pageKeyGesture?.dir && this._pageKeyGesture.dir !== Math.sign(lines)) this._scrollBytes = ''; const step = input.pageKeysForGesture(this._pageKeyGesture, ev, lines, this.terminal.rows, performance.now()); this._pageKeyGesture = step.state; if (step.keys) this._queueScrollBytes(step.keys); @@ -1208,7 +1215,7 @@ } // Coalesces the scroll bytes (SGR wheel reports and page keys alike) into - // one send per 40 ms, bounded at 512 bytes so a fling cannot build a backlog + // one send per 40 ms, bounded by report count so a fling cannot build a backlog // that keeps scrolling after it stops. A narrow // twin of the primary pane's _queueScrollBytes / _flushWheelSgrQueue // (terminal-ui.js; keep the two in step), which flushes to the active @@ -1216,8 +1223,16 @@ // session, over this pane's socket while it is open. _queueScrollBytes(data) { if (!data || this._destroyed) return; - if (this._scrollBytes.length > 512) return; - this._scrollBytes += data; + this._scrollBytes = global.CodemanTerminalInput.coalesceScrollBytes(this._scrollBytes, data); + if (data.startsWith('\x1b[<') && !this._wheelNativeStarted) { + this._wheelNativeStarted = true; + if (this._scrollFlushTimer) clearTimeout(this._scrollFlushTimer); + this._scrollFlushTimer = null; + const bytes = this._scrollBytes; + this._scrollBytes = ''; + global.app?._sendInputEphemeral?.(this.sessionId, bytes); + return; + } if (this._scrollFlushTimer) return; this._scrollFlushTimer = setTimeout(() => { this._scrollFlushTimer = null; diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index cb7d0561..3e78470e 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -98,10 +98,6 @@ const PAGE_KEY_GESTURE_GAP_MS = 150; // ...but not for sub-row jitter (0.1 row, ~2px)... const PAGE_KEY_MIN_START_ROWS = 0.1; - // ...and only for a trackpad-sized opening event. A mouse-wheel notch (100px, - // 4 rows) accumulates toward half a screen as before; paging at once on it made - // slow notches, each its own gesture past the gap, page a full screen apiece. - const PAGE_KEY_IMMEDIATE_MAX_ROWS = 2; // A pre-paid page's debt outlives the gesture gap for this long when the next // event goes the SAME way, so slow small wheel ticks (40 px every 250 ms, a // Firefox 1-line notch) pay it back instead of each pre-paying a page of its own. @@ -121,6 +117,14 @@ : delta / 25; // DOM_DELTA_PIXEL (Chrome/WebKit, and every trackpad) } + // A wheel event is at most one native tick: a common mouse notch is + // deltaY=100px (or Firefox's 3 lines), not four/three separate notches. + // Small touchpad events retain their fractional distance and accumulate in + // wheelTicksForGesture. Touch drags bypass this wheel-only normalization. + function wheelDeltaNativeLines(ev, rows) { + return Math.max(-1, Math.min(1, wheelDeltaLines(ev, rows))); + } + // The same travel rounded to whole lines for the SGR wheel reports: a pure // horizontal swipe is 0 (nothing to send), and anything else moves at least // one line, so the small pixel deltas of a precision touchpad still scroll. @@ -147,6 +151,35 @@ return `\x1b[<${btn};${pos.col};${pos.row}M`.repeat(ticks); } + // Fractional travel belongs to one direction and one recent gesture. Unlike + // rounding every event up, splitting 25px into ten touchpad events still sends + // one tick. Excess fling travel is discarded, never replayed after the finger + // stops. Both the primary pane (including touch) and tiles use this adapter. + function wheelTicksForGesture(state, lines, now) { + if (!Number.isFinite(lines) || !lines) return { state: state || {}, ticks: 0 }; + const dir = Math.sign(lines); + const fresh = !state || state.dir !== dir || now - state.lastAt > PAGE_KEY_GESTURE_GAP_MS; + const total = (fresh ? 0 : state.pending || 0) + lines; + const whole = Math.trunc(total + Math.sign(total) * 1e-9); + return { + state: { dir, lastAt: now, pending: Math.abs(total - whole) < 1e-9 ? 0 : total - whole }, + ticks: whole === 0 ? 0 : Math.sign(whole) * Math.min(Math.abs(whole), SGR_WHEEL_MAX_TICKS), + }; + } + + // Only unsent scroll input can be cancelled. Keep at most five SGR reports + // or three page keys per flush, and replace an obsolete direction immediately. + // Local and remote transports share the same bounded gesture queue. + function coalesceScrollBytes(queued, data) { + const direction = (bytes) => bytes?.match(/\x1b\[<(64|65);|\x1b\[(5|6)~/)?.[0]; + const previous = direction(queued); + const next = direction(data); + const combined = previous && previous === next ? queued + data : data; + const reports = combined.match(/\x1b\[<(?:64|65);\d+;\d+M|\x1b\[[56]~/g) || []; + const limit = combined.startsWith('\x1b[<') ? SGR_WHEEL_MAX_TICKS : PAGE_KEY_MAX_PER_BATCH; + return reports.slice(-limit).join(''); + } + // Rows of travel per page key for a terminal `rows` tall (half a screen). function pageKeyRows(rows) { return Math.max(2, Math.round((rows || 24) * PAGE_KEY_SCREEN_FRACTION)); @@ -181,15 +214,15 @@ // PAGE_KEY_GESTURE_GAP_MS, stretched to PAGE_KEY_DEBT_MS while a pre-paid // page is still owed in the same direction. One contact is never split by // time, so a slow drag (one line per 170 ms) is one gesture; - // - a new gesture's trackpad-sized opening event (at least - // PAGE_KEY_MIN_START_ROWS, under PAGE_KEY_IMMEDIATE_MAX_ROWS) pages AT ONCE, + // - a new gesture's ordinary wheel/touch opening event (at least + // PAGE_KEY_MIN_START_ROWS, under one page of travel) pages AT ONCE, // pre-paid: the half screen it skipped is owed back by the rest of the // gesture, so the rate stays one page per half screen. A debt left by a // pre-paid page dies with its gesture; // - a direction change inside a gesture (a reversal within the gap, or // within the same contact) drops a pre-paid debt and accumulates, never // pre-paying, so a +/-3 px trackpad wobble cannot page back and forth; - // - everything else, a wheel notch included, accumulates through + // - larger openings and subsequent events accumulate through // pageKeysForTravel, carried across notches. // Returns { state, keys } with keys '' when nothing is due. function pageKeysForGesture(state, ev, lines, rows, now) { @@ -215,13 +248,14 @@ gestureStart = quiet > (prepaid && !reversed ? PAGE_KEY_DEBT_MS : PAGE_KEY_GESTURE_GAP_MS); } if (gestureStart || reversed) { - // A pre-paid debt belongs to its gesture and its direction. - if (prepaid) pending = 0; + // Travel and debt belong to their direction; never make a reversal pay + // off movement accumulated going the other way. + if (prepaid || reversed) pending = 0; prepaid = false; } if (gestureStart) { const size = Math.abs(lines); - if (size >= PAGE_KEY_MIN_START_ROWS && size < Math.min(perPage, PAGE_KEY_IMMEDIATE_MAX_ROWS)) { + if (size >= PAGE_KEY_MIN_START_ROWS && size < perPage) { // Pre-pay one page; the skipped travel is owed back (pending carries the opposite sign). return { state: { pending: lines - dir * perPage, lastAt: now, dir, prepaid: true, touchId }, @@ -389,13 +423,15 @@ PAGE_KEY_MAX_PER_BATCH, wheelDeltaLines, wheelDeltaWholeLines, + wheelDeltaNativeLines, SGR_WHEEL_MAX_TICKS, sgrWheelReports, + wheelTicksForGesture, + coalesceScrollBytes, pageKeysForTravel, pageKeysForGesture, PAGE_KEY_GESTURE_GAP_MS, PAGE_KEY_MIN_START_ROWS, - PAGE_KEY_IMMEDIATE_MAX_ROWS, PAGE_KEY_DEBT_MS, TUI_PROMPT_DEFAULT_ROWS_FROM_BOTTOM, MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR, @@ -1110,9 +1146,10 @@ Object.assign(CodemanApp.prototype, { if (this.terminal?.buffer?.active?.type === 'alternate') return; ev.preventDefault(); ev.stopPropagation(); + if (ev.ctrlKey || (!ev.shiftKey && Math.abs(ev.deltaX) > Math.abs(ev.deltaY))) return; if (this._shouldForwardWheelToApp(ev)) { this._logScrollRouting('forward-sgr'); - this._forwardScrollToApp(ev.clientX, ev.clientY, this._wheelScrollLines(ev)); + this._forwardScrollToApp(ev.clientX, ev.clientY, this._wheelScrollNativeLines(ev)); return; } // Local scrolling accumulates FRACTIONAL lines: a macOS trackpad emits @@ -5780,23 +5817,10 @@ Object.assign(CodemanApp.prototype, { return true; }, - // Wheel forwarding gate for the container wheel handler: no Shift override, - // xterm's own encoder dormant, viewport at the bottom, and a TUI VERIFIED to - // scroll its transcript on SGR wheel reports, which today is claude 2.1.187+ - // with mouse tracking on (fullscreen) and nothing else (older Claude Code - // captures wheel as select-menu option navigation, an unknown version is - // treated as older, and inline Claude ignores it). Gemini and codex are - // strip modes too but keep the local wheel — taps/clicks are still forwarded - // for them (harmless no-ops at worst). - // - // Codex USED to forward here and was the #227 regression (DodgyBadger, Codex - // latest / Chrome / Win11: dead wheel in codex, working scrollbar drag). - // Measured on 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 — it runs an inline viewport (`alternate_on=0`) and pushes - // its transcript into the terminal's own scrollback (tmux `history_size` - // grows), so there is no in-app pager to drive and local scrollback IS the - // codex transcript. Forwarding therefore swallowed every tick. + // Forward only to CLIs that explicitly enable mouse tracking. Fullscreen + // Codex 0.163.0 was verified against canonical tmux captures: one SGR report + // scrolls one transcript row. Inline Codex (tracking off) must retain local + // history/fallback behavior; unconditional forwarding caused the old #227 bug. // Wheel delta → whole scroll lines. macOS trackpads turn Shift+two-finger // scroll into a HORIZONTAL wheel (deltaY≈0, deltaX carries the magnitude), and // Shift routes the wheel to local scrollback (_shouldForwardWheelToApp returns @@ -5815,6 +5839,10 @@ Object.assign(CodemanApp.prototype, { return window.CodemanTerminalInput.wheelDeltaWholeLines(ev, this.terminal?.rows); }, + _wheelScrollNativeLines(ev) { + return window.CodemanTerminalInput.wheelDeltaNativeLines(ev, this.terminal?.rows); + }, + /** Unrounded variant for the smooth local-scroll path, which accumulates * sub-line fractions across events instead of forcing every tiny trackpad * delta to a whole ±1 line. Same unit handling and Shift-axis trap. The @@ -5848,8 +5876,9 @@ Object.assign(CodemanApp.prototype, { if (mode && mode !== 'none') return false; const session = this.sessions?.get(target.sessionId || this.activeSessionId); const sessionMode = session?.mode || 'claude'; - if (sessionMode !== 'claude') return false; - if (!this._cliVersionAtLeast(session?.cliVersion, '2.1.187')) return false; + if (sessionMode !== 'claude' && sessionMode !== 'codex') return false; + if (sessionMode === 'codex' && this.tabAlerts?.get(target.sessionId || this.activeSessionId) === 'action') return false; + if (sessionMode === 'claude' && !this._cliVersionAtLeast(session?.cliVersion, '2.1.187')) return false; // Only while Claude is actually listening for the mouse. In its default // inline renderer (2.1.280 measured: alternate_on=0, mouse_any_flag=0) the // transcript lives in real scrollback, like codex, and SGR wheel reports are @@ -5892,25 +5921,45 @@ Object.assign(CodemanApp.prototype, { if (!this.activeSessionId || !lines) return; const pos = this._clientPointToCell(clientX, clientY); if (!pos) return; - this._queueScrollBytes(window.CodemanTerminalInput.sgrWheelReports(lines, pos)); + const input = window.CodemanTerminalInput; + const state = this._wheelTickSession === this.activeSessionId ? this._wheelTickGesture : null; + if (state?.dir && state.dir !== Math.sign(lines)) this._wheelSgrQueue = ''; + const step = input.wheelTicksForGesture(state, lines, performance.now()); + this._wheelTickSession = this.activeSessionId; + this._wheelTickGesture = step.state; + this._queueScrollBytes(input.sgrWheelReports(step.ticks, pos)); }, /** - * Shared 40ms coalescer for every byte a scroll gesture sends to the PTY (SGR + * Coalesces scroll input into 40ms batches after the first native report (SGR * wheel reports and the PageUp/PageDown fallback alike). Each flush becomes a * tmux send-keys server-side, so per-event writes would spawn a process storm - * on a single flick; the queue is bounded so a wild scroll can't build a + * on a single flick; report counts are bounded so a wild scroll can't build a * backlog that keeps scrolling after the finger stops. * * A TerminalTile keeps its own narrow twin (TerminalTile._queueScrollBytes, - * terminal-tile.js: same 40ms window, same 512-byte bound) because this queue + * terminal-tile.js: same 40ms window and report limits) because this queue * flushes to the active session only; keep the two in step. */ _queueScrollBytes(data) { if (!data || !this.activeSessionId) return; - const queued = this._wheelSgrQueue || ''; - if (queued.length > 512) return; - this._wheelSgrQueue = queued + data; + // The first native report of a newly selected tab must not wait behind + // replay/atlas work for a 40ms timer (measured 60-250ms in practice). + // Selection generation also covers A -> B -> A when B was never scrolled. + const firstNativeForVisit = data.startsWith('\x1b[<') && + (this._wheelNativeStartSession !== this.activeSessionId || + this._wheelNativeStartGeneration !== this._selectGeneration); + const queued = !firstNativeForVisit && this._wheelQueueSession === this.activeSessionId + ? this._wheelSgrQueue || '' : ''; + this._wheelQueueSession = this.activeSessionId; + this._wheelSgrQueue = window.CodemanTerminalInput.coalesceScrollBytes(queued, data); + if (firstNativeForVisit) { + this._wheelNativeStartSession = this.activeSessionId; + this._wheelNativeStartGeneration = this._selectGeneration; + if (this._wheelSgrFlushTimer) clearTimeout(this._wheelSgrFlushTimer); + this._flushWheelSgrQueue(); + return; + } if (this._wheelSgrFlushTimer) return; this._wheelSgrFlushTimer = setTimeout(() => this._flushWheelSgrQueue(), 40); }, @@ -5930,9 +5979,8 @@ Object.assign(CodemanApp.prototype, { * 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; - * - `codex`, which draws on tmux's alternate screen too (0.157.1, 0.160), which - * the full strip hides from xterm, and pages on PageUp/PageDown while SGR - * wheel reports do nothing. + * - `codex` with no observed mouse tracking. Native tracked fullscreen + * Codex uses fractional SGR reports; this remains its fallback capability. * * ⚠️ Which modes page is read from `window.__codemanTranscriptPageKeys`, the * list the server builds from the `transcriptPageKeys` CAPABILITY, never an id @@ -5964,8 +6012,8 @@ Object.assign(CodemanApp.prototype, { * 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 - * one that never fills the buffer and never accepts the wheel (opencode, codex: - * both ignore SGR wheel reports, #227, and page on PageUp/PageDown). Before + * one that never fills the buffer and does not accept the wheel (OpenCode, + * or Codex without observed mouse tracking). Those modes page instead. 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. * This is not wheel forwarding: it sends plain keys, and only over a hollow buffer. @@ -5975,8 +6023,7 @@ Object.assign(CodemanApp.prototype, { * scrollback is never touched. Shift is excluded on purpose: it is the explicit * "give me local scrollback" gesture and must keep that meaning. * - * The FIRST event of a trackpad-sized gesture (opening under - * PAGE_KEY_IMMEDIATE_MAX_ROWS) pages at once. Waiting for half a screen of + * The FIRST event of a deliberate sub-page gesture pages at once. Waiting for half a screen of * travel (19 rows, ~475px on a 38-row pane) meant an ordinary trackpad flick * never sent a key at all. That page is pre-paid: the travel it skipped is * owed back by the rest of the gesture, so the rate stays one page per @@ -5984,8 +6031,8 @@ Object.assign(CodemanApp.prototype, { * (PAGE_KEY_GESTURE_GAP_MS, or PAGE_KEY_DEBT_MS while a same-direction page is * still owed), a reversal after the gap or a tab switch starts a new gesture; * one contact is never split by time, and a quick reversal never pre-pays. A - * larger opening event is a wheel notch and accumulates as before, so slow - * notches still page once per `perPage` rows. + * wheel opening below the page threshold also pages immediately. Subsequent events + * repay the skipped travel rather than paging once per notch. * * Consumed WITHOUT paging: a pinch (`ctrlKey`, how Chrome reports a trackpad * pinch), a mostly horizontal swipe (both deltas present, |deltaX| > |deltaY|; @@ -6009,6 +6056,7 @@ Object.assign(CodemanApp.prototype, { */ _maybePageCliTranscript(ev, lines) { if (!lines || ev?.shiftKey || !this.activeSessionId) return false; + if (ev?.ctrlKey || Math.abs(ev?.deltaX || 0) > Math.abs(ev?.deltaY || 0)) return this._localScrollbackIsHollow(); if (!this._localScrollbackIsHollow()) return false; // An open dialog in THIS pane's session (the active one): a page key would // move its selector. Consumed, so the buffer does not scroll under it either. @@ -6021,6 +6069,7 @@ Object.assign(CodemanApp.prototype, { this._pageKeySession = this.activeSessionId; this._pageKeyGesture = null; } + if (this._pageKeyGesture?.dir && this._pageKeyGesture.dir !== Math.sign(lines)) this._wheelSgrQueue = ''; const step = window.CodemanTerminalInput.pageKeysForGesture( this._pageKeyGesture, ev, @@ -6071,7 +6120,9 @@ Object.assign(CodemanApp.prototype, { // Ephemeral (fire-and-forget): wheel reports are loss-tolerant, so they skip // the durable seq/ACK queue — no localStorage churn, no "Nb queued" flicker // in the connection indicator on every scroll tick. - if (data && this.activeSessionId) this._sendInputEphemeral(this.activeSessionId, data); + if (data && this.activeSessionId && this._wheelQueueSession === this.activeSessionId) { + this._sendInputEphemeral(this.activeSessionId, data); + } }, // Desktop counterpart of the touchend tap branch: hand-encode an SGR report diff --git a/test/terminal-scroll-routing.test.ts b/test/terminal-scroll-routing.test.ts index 3001db84..bb8efdae 100644 --- a/test/terminal-scroll-routing.test.ts +++ b/test/terminal-scroll-routing.test.ts @@ -45,6 +45,7 @@ function loadTerminalUiHarness( performance: clock, requestAnimationFrame: (_fn: () => void) => 1, setTimeout: (_fn: () => void) => 1, + clearTimeout: vi.fn(), Blob: function Blob() {}, URL: { createObjectURL: () => 'blob:yield', revokeObjectURL: () => {} }, Worker: function Worker(this: any) { @@ -210,14 +211,12 @@ describe('PageUp/PageDown fallback for a hollow local buffer (issue #205 round 2 app._maybePageCliTranscript({ shiftKey: false }, 1); // after a pause: a PageDown at once app._maybePageCliTranscript({ shiftKey: false }, 0.05); // sub-row jitter in the same gesture: nothing app._flushWheelSgrQueue(); - expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[5~\x1b[5~\x1b[6~' }]); + expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[6~' }]); }); - it('lets wheel notches accumulate instead of paging a full screen on each one', () => { - // A 100 px notch is 4 rows. Only a trackpad-sized opening event (under 2 rows) - // pages at once; a notch adds up toward half a screen as it always did, so - // slow notches (each one its own gesture by the 150 ms gap) still page once - // per 18 rows here, not once per notch. + it('answers the first wheel notch and amortizes the page over subsequent notches', () => { + // The first 100px notch responds immediately. Later notches repay the + // pre-paid page, including when they arrive past the normal gesture gap. for (const gapMs of [250, 40]) { const { app, sent, clock } = hollowApp(); let now = 1_000; @@ -350,8 +349,7 @@ describe('PageUp/PageDown fallback for a hollow local buffer (issue #205 round 2 }); it('pages a Codex transcript only while its local scrollback is empty', () => { - // Codex is never sent SGR wheel reports (it ignores them); this is the - // separate PageUp/PageDown fallback, which codex does honour. + // Codex without observed mouse tracking retains its PageUp/PageDown fallback. const { app, sent } = hollowApp(); app.sessions = new Map([['sess-1', { mode: 'codex' }]]); @@ -535,7 +533,7 @@ describe('page-key gestures across slow touch drags, slow wheel ticks and jitter } // The opening event is a fresh gesture and pages; no reversal inside the // gap pre-pays, so the wobble sends nothing after it. - expect(p.keys()).toBe(UP); + expect(p.keys()).toBe(''); // obsolete queued direction cancelled before its flush }); it('still pages a deliberate reversal at once after a pause', () => { @@ -543,7 +541,7 @@ describe('page-key gestures across slow touch drags, slow wheel ticks and jitter p.app._maybePageCliTranscript({ shiftKey: false, deltaY: -25 }, -1); p.advance(200); p.app._maybePageCliTranscript({ shiftKey: false, deltaY: 25 }, 1); - expect(p.keys()).toBe(UP + DOWN); + expect(p.keys()).toBe(DOWN); }); it('does not let a reversal inherit the other direction’s debt', () => { @@ -552,10 +550,10 @@ describe('page-key gestures across slow touch drags, slow wheel ticks and jitter p.advance(16); // Downward travel just short of a page: the upward debt must not count toward it. p.app._maybePageCliTranscript({ shiftKey: false, deltaY: 25 * 18 }, 18); - expect(p.keys()).toBe(UP); + expect(p.keys()).toBe(''); // reversal discards the unsent UP p.advance(16); p.app._maybePageCliTranscript({ shiftKey: false, deltaY: 25 }, 1); - expect(p.keys()).toBe(UP + DOWN); + expect(p.keys()).toBe(DOWN); }); it('ends the one-second debt lifetime once the debt is repaid, exactly or overpaid', () => { @@ -643,8 +641,8 @@ describe('pageKeysForGesture, the pure gesture rules both panes share', () => { expect(steps.map((s) => s.keys)).toEqual([UP, '', '', UP]); }); - it('accumulates a notch-sized opening event, and carries plain travel across notches', () => { - // Five 4-row notches 400 ms apart: each is a new gesture, none pages alone. + it('responds immediately to a notch without paging for every later notch', () => { + // Five 4-row notches 400ms apart share the pre-paid page debt. const steps = run([ [-4, 0], [-4, 400], @@ -742,13 +740,13 @@ describe('the paging gates asked for another pane (a TerminalTile)', () => { const event = { shiftKey: false, ...extra }; const step = pageKeysForGesture(state, event, lines, 36, 1_000); state = step.state; - expected += step.keys; + expected = windowRef.CodemanTerminalInput.coalesceScrollBytes(expected, step.keys) || expected; app._maybePageCliTranscript(event, lines); } app._flushWheelSgrQueue(); expect(app._pageKeyGesture).toEqual(state); expect(expected).toContain('\x1b[5~'); - expect(expected).toContain('\x1b[6~'); + expect(expected).not.toContain('\x1b[6~'); expect(sent).toEqual([{ id: 'sess-1', data: expected }]); }); @@ -829,3 +827,167 @@ describe('scroll routing diagnostic (issue #205 round 2)', () => { expect(logs[0]).toContain('cliVersion=unknown'); }); }); + +describe('consistent scroll gesture adapters', () => { + it('makes ten small Claude deltas equal one combined delta', () => { + const { wheelTicksForGesture } = loadTerminalUiHarness().windowRef.CodemanTerminalInput; + let state; + let ticks = 0; + for (let i = 0; i < 10; i++) { + const step = wheelTicksForGesture(state, -0.12, i * 8); + state = step.state; + ticks += step.ticks; + } + expect(ticks).toBe(-1); + let decimalState; + let decimalTicks = 0; + for (let i = 0; i < 10; i++) { + const step = wheelTicksForGesture(decimalState, 0.1, i * 8); + decimalState = step.state; + decimalTicks += step.ticks; + } + expect(decimalTicks).toBe(1); + expect(wheelTicksForGesture(null, -1.2, 0).ticks).toBe(ticks); + }); + + it('drops fractional movement on reversal, inactivity and session changes', () => { + const { wheelTicksForGesture } = loadTerminalUiHarness().windowRef.CodemanTerminalInput; + const state = wheelTicksForGesture(null, 0.9, 0).state; + expect(wheelTicksForGesture(state, -0.2, 10).ticks).toBe(0); + expect(wheelTicksForGesture(state, 0.2, 200).ticks).toBe(0); + const { app } = hollowApp(); + app._clientPointToCell = () => ({ col: 1, row: 1 }); + app._sendSyntheticSgrWheel(0, 0, 0.9); + app.activeSessionId = 'other'; + app._sendSyntheticSgrWheel(0, 0, 0.2); + expect(app._wheelSgrQueue || '').toBe(''); + }); + + it('answers the first ordinary Codex wheel event, without paging every event', () => { + const { app, sent } = hollowApp({ mode: 'codex', rows: 43 }); + app._maybePageCliTranscript({ deltaY: -100 }, -4); + app._flushWheelSgrQueue(); + expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[5~' }]); + for (let i = 0; i < 5; i++) app._maybePageCliTranscript({ deltaY: -100 }, -4); + app._flushWheelSgrQueue(); + expect(sent).toHaveLength(1); + }); + + it('bounds a sustained burst and cancels obsolete direction before transport', () => { + const { coalesceScrollBytes } = loadTerminalUiHarness().windowRef.CodemanTerminalInput; + const up = '\x1b[<64;1;1M'; + const down = '\x1b[<65;1;1M'; + let queued = ''; + for (let i = 0; i < 100; i++) queued = coalesceScrollBytes(queued, up); + expect(queued).toBe(up.repeat(5)); + expect(coalesceScrollBytes(queued, down)).toBe(down); + expect(coalesceScrollBytes('\x1b[5~'.repeat(3), '\x1b[5~')).toBe('\x1b[5~'.repeat(3)); + expect(coalesceScrollBytes('\x1b[5~', '\x1b[6~')).toBe('\x1b[6~'); + }); + + it('does not deliver a queued wheel batch to a newly selected session', () => { + const { app, sent } = hollowApp(); + app._queueScrollBytes('\x1b[5~'); + app.activeSessionId = 'other'; + app._flushWheelSgrQueue(); + expect(sent).toEqual([]); + }); + + it('sends the first native report synchronously on every tab visit, batching subsequent reports', () => { + const { app, sent } = hollowApp(); + const up = '\x1b[<64;1;1M'; + app._selectGeneration = 1; + app._queueScrollBytes(up); + expect(sent).toEqual([{ id: 'sess-1', data: up }]); + app._queueScrollBytes(up); + app._queueScrollBytes(up); + expect(sent).toHaveLength(1); + app._flushWheelSgrQueue(); + expect(sent.at(-1)).toEqual({ id: 'sess-1', data: up.repeat(2) }); + // Returning A -> B -> A must reset onset even if B was never scrolled. + app._queueScrollBytes(up); // previous visit has an unsent report + app._selectGeneration = 3; + app._queueScrollBytes(up); + expect(sent).toHaveLength(3); + expect(sent.at(-1)).toEqual({ id: 'sess-1', data: up }); + app._queueScrollBytes(up); // unsent A report must never leak to B + app.activeSessionId = 'other'; + app._selectGeneration = 4; + app._queueScrollBytes(up); + expect(sent.at(-1)).toEqual({ id: 'other', data: up }); + app._flushWheelSgrQueue(); + expect(sent).toHaveLength(4); + }); + + it('cancels unsent Claude ticks even when reversed travel is sub-line', () => { + const { app, sent } = hollowApp(); + app._clientPointToCell = () => ({ col: 1, row: 1 }); + app._sendSyntheticSgrWheel(0, 0, -1); // leading report is already delivered + app._sendSyntheticSgrWheel(0, 0, -2); + app._sendSyntheticSgrWheel(0, 0, 0.2); + app._flushWheelSgrQueue(); + expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[<64;1;1M' }]); + }); +}); + +describe('native Codex scrolling instead of an initial page jump', () => { + it('forwards only when Codex explicitly requests mouse tracking', () => { + for (const cliMouseTracking of [undefined, false, true]) { + const { app } = hollowApp({ mode: 'codex', cliMouseTracking }); + expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(cliMouseTracking === true); + expect(app._shouldForwardWheelToApp({ shiftKey: true })).toBe(false); + } + }); + + it('preserves the Codex dialog guard for primary and tiled routing', () => { + const { app } = hollowApp({ mode: 'codex', cliMouseTracking: true }); + app.sessions.set('tile', { mode: 'codex', cliMouseTracking: true }); + app.tabAlerts = new Map([['sess-1', 'action']]); + expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false); + expect(app._shouldForwardWheelToApp({ shiftKey: false }, { sessionId: 'tile' })).toBe(true); + app.tabAlerts.set('tile', 'action'); + expect(app._shouldForwardWheelToApp({ shiftKey: false }, { sessionId: 'tile' })).toBe(false); + }); + + it('keeps local-history opt-out and native xterm mouse routing', () => { + const { app } = hollowApp({ mode: 'codex', cliMouseTracking: true }); + app.loadAppSettingsFromStorage = () => ({ terminalWheelLocalScrollback: true }); + expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false); + app.loadAppSettingsFromStorage = () => ({}); + app.terminal.modes.mouseTrackingMode = 'drag'; + expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false); + }); +}); + +describe('one native tick per wheel event', () => { + it('normalizes coarse Chrome, Firefox and page-unit wheel events without guessing the device', () => { + const { wheelDeltaNativeLines } = loadTerminalUiHarness().windowRef.CodemanTerminalInput; + for (const [deltaY, deltaMode] of [ + [100, 0], + [120, 0], + [1000, 0], + [3, 1], + [1, 2], + ]) { + expect(wheelDeltaNativeLines({ deltaY, deltaMode }, 43)).toBe(1); + expect(wheelDeltaNativeLines({ deltaY: -deltaY, deltaMode }, 43)).toBe(-1); + } + }); + + it('keeps fractional touchpad distance and primary/tile normalization identical', () => { + const { app, windowRef } = hollowApp({ mode: 'codex', cliMouseTracking: true }); + const { wheelDeltaNativeLines, wheelTicksForGesture } = windowRef.CodemanTerminalInput; + let state; + let ticks = 0; + for (let i = 0; i < 10; i++) { + const ev = { deltaY: -3, deltaMode: 0 }; + const lines = wheelDeltaNativeLines(ev, 43); + expect(lines).toBe(-0.12); + expect(app._wheelScrollNativeLines(ev)).toBe(lines); + const step = wheelTicksForGesture(state, lines, i * 10); + state = step.state; + ticks += step.ticks; + } + expect(ticks).toBe(-1); + }); +}); diff --git a/test/terminal-tile-scroll.test.ts b/test/terminal-tile-scroll.test.ts index 9629e926..aca5d7e2 100644 --- a/test/terminal-tile-scroll.test.ts +++ b/test/terminal-tile-scroll.test.ts @@ -232,7 +232,7 @@ describe('a tile pages a hollow buffer through the primary pane gates', () => { expect(up.preventDefault).toHaveBeenCalled(); expect(up.stopPropagation).toHaveBeenCalled(); - expect(ws.inputFrames()).toEqual([]); // coalesced, not sent per event + expect(ws.inputFrames()).toEqual([]); // fallback page reports remain batched expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]); // no seq: never persisted mount.fire('wheel', wheelLines(term.rows / 2)); @@ -319,27 +319,37 @@ describe('a fullscreen Claude tile forwards the wheel to the CLI as SGR reports' expect(up.preventDefault).toHaveBeenCalled(); expect(up.stopPropagation).toHaveBeenCalled(); - expect(ws.inputFrames()).toEqual([]); // coalesced, not sent per event - expect(flushed(ws)).toEqual([{ t: 'i', d: UP.repeat(3) }]); // no seq: never persisted + expect(ws.inputFrames()).toEqual([{ t: 'i', d: UP }]); // first report never waits for a timer + expect(flushed(ws)).toEqual([{ t: 'i', d: UP }]); // no seq: never persisted mount.fire('wheel', wheelLines(2, AT)); - expect(flushed(ws).at(-1)).toEqual({ t: 'i', d: DOWN.repeat(2) }); + expect(flushed(ws).at(-1)).toEqual({ t: 'i', d: DOWN }); }); - it('forwards a Chrome-on-Windows notch (100 px) as four ticks and caps a fling at five', async () => { + it('uses fractional native scrolling for a tracked Codex tile, never PageUp', async () => { + const { ws, mount } = await connectTile(makeApp({ 's-tile': { mode: 'codex', cliMouseTracking: true } }), { + mode: 'codex', + }); + for (let i = 0; i < 10; i++) mount.fire('wheel', wheel(-3, AT)); + expect(flushed(ws)).toEqual([{ t: 'i', d: UP }]); + }); + + it('normalizes a pixel wheel event to one native tick, including accelerated deltas', async () => { const { ws, mount } = await connectTile(makeApp({ 's-tile': FULLSCREEN }), { mode: 'claude' }); mount.fire('wheel', wheel(-100, AT)); - expect(flushed(ws)).toEqual([{ t: 'i', d: UP.repeat(4) }]); + expect(flushed(ws)).toEqual([{ t: 'i', d: UP }]); mount.fire('wheel', wheelLines(-1000, AT)); - expect(flushed(ws).at(-1)).toEqual({ t: 'i', d: UP.repeat(5) }); + expect(flushed(ws).at(-1)).toEqual({ t: 'i', d: UP }); }); - it('moves a precision touchpad delta one line, and consumes a pure horizontal swipe without sending', async () => { + it('accumulates precision touchpad deltas and consumes a pure horizontal swipe without sending', async () => { const { ws, mount } = await connectTile(makeApp({ 's-tile': FULLSCREEN }), { mode: 'claude' }); - mount.fire('wheel', wheel(-3, AT)); // 0.12 of a line + mount.fire('wheel', wheel(-3, AT)); // 0.12 of a line must not round up + expect(flushed(ws)).toEqual([]); + for (let i = 0; i < 8; i++) mount.fire('wheel', wheel(-3, AT)); expect(flushed(ws)).toEqual([{ t: 'i', d: UP }]); const sideways = wheel(0, { ...AT, deltaX: 120 }); @@ -348,14 +358,19 @@ describe('a fullscreen Claude tile forwards the wheel to the CLI as SGR reports' expect(flushed(ws)).toHaveLength(1); }); - it('coalesces the reports of wheels within 40 ms into one frame', async () => { + it('sends the first report immediately and coalesces subsequent wheels within 40 ms', async () => { const { ws, mount } = await connectTile(makeApp({ 's-tile': FULLSCREEN }), { mode: 'claude' }); mount.fire('wheel', wheelLines(-2, AT)); vi.advanceTimersByTime(20); mount.fire('wheel', wheelLines(-2, AT)); - expect(flushed(ws)).toEqual([{ t: 'i', d: UP.repeat(4) }]); + mount.fire('wheel', wheelLines(-2, AT)); + expect(ws.inputFrames()).toEqual([{ t: 'i', d: UP }]); + expect(flushed(ws)).toEqual([ + { t: 'i', d: UP }, + { t: 'i', d: UP.repeat(2) }, + ]); }); it('snaps a scrolled-up viewport home before reporting, since SGR rows address the live screen', async () => { @@ -436,14 +451,14 @@ describe('a fullscreen Claude tile forwards the wheel to the CLI as SGR reports' const primary = makeApp({ other: FULLSCREEN }, 'other') as App & { terminal: unknown; _sendSyntheticSgrWheel(x: number, y: number, lines: number): void; - _wheelScrollLines(ev: unknown): number; + _wheelScrollNativeLines(ev: unknown): number; _flushWheelSgrQueue(): void; }; const sent: string[] = []; primary._sendInputEphemeral = (_id: string, data: string) => sent.push(data); primary.terminal = new FakeTerminal({}); for (const ev of sequence) { - primary._sendSyntheticSgrWheel(ev.clientX, ev.clientY, primary._wheelScrollLines(ev)); + primary._sendSyntheticSgrWheel(ev.clientX, ev.clientY, primary._wheelScrollNativeLines(ev)); primary._flushWheelSgrQueue(); } @@ -531,11 +546,11 @@ describe('page-key travel, cap and coalescing', () => { FakeFit.proposed = { cols: 80, rows: 36 }; }); - it('accumulates sub-page travel: two wheels of 10 lines are one PageUp', async () => { + it('answers sub-page travel immediately and repays it on the next event', async () => { const { ws, mount } = await connectTile(makeApp({ 's-tile': { mode: 'opencode' } })); mount.fire('wheel', wheelLines(-10)); - expect(flushed(ws)).toEqual([]); + expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]); mount.fire('wheel', wheelLines(-10)); expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]); @@ -707,7 +722,6 @@ describe("gesture handling is the primary pane's, through the shared pure helper primary._flushWheelSgrQueue(); expect(tileBytes).toContain(PAGE_UP); - expect(tileBytes).toContain(PAGE_DOWN); expect(tileBytes).toBe(sent.join('')); }); @@ -736,7 +750,7 @@ describe("gesture handling is the primary pane's, through the shared pure helper .map((f) => f.d) .join(''); // One page for the slow ticks, one for the wobble's opening event, nothing more. - expect(tileBytes).toBe(PAGE_UP + PAGE_UP); + expect(tileBytes).toBe(''); // rapid reversals cancel unsent movement const primary = makeApp({ other: { mode: 'codex' } }, 'other') as App & { terminal: unknown; @@ -895,7 +909,7 @@ describe('rows the tile pushed above the screen itself are not history', () => { const first = wheelLines(-6); mount.fire('wheel', first); expect(first.preventDefault).toHaveBeenCalled(); - expect(flushed(ws)).toEqual([]); + expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]); mount.fire('wheel', wheelLines(-6)); expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]); }); diff --git a/test/terminal-touch-tap.test.ts b/test/terminal-touch-tap.test.ts index 94005e2c..66a25cab 100644 --- a/test/terminal-touch-tap.test.ts +++ b/test/terminal-touch-tap.test.ts @@ -26,6 +26,7 @@ function loadTerminalUiHarness() { performance: { now: () => now }, requestAnimationFrame: (_fn: () => void) => 1, setTimeout: (_fn: () => void) => 1, + clearTimeout: vi.fn(), Blob: function Blob() {}, URL: { createObjectURL: () => 'blob:yield', @@ -789,7 +790,7 @@ describe('terminal touch tap mouse guard', () => { expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(true); }); - it('wheel: only claude forwards — codex and gemini keep the local wheel', () => { + it('wheel: tracked fullscreen Codex forwards; inline Codex and Gemini do not', () => { const { app } = loadTerminalUiHarness(); app.activeSessionId = 'sess-1'; app.terminal = { @@ -803,10 +804,10 @@ describe('terminal touch tap mouse guard', () => { // (the codex transcript lives there — inline viewport, no in-app pager) sat unused. app.sessions = new Map([['sess-1', { mode: 'codex' }]]); expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false); - // Tracking on and a high version, so only the mode check can say no: without - // them the gate is false for claude too and this would pin nothing. - app.sessions = new Map([['sess-1', { mode: 'codex', cliVersion: '9.9.9', cliMouseTracking: true }]]); // no version rescues it - expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false); + // Current fullscreen Codex requests tracking and supports native row scrolling. + // Its version is not published as Claude-specific cliInfo; observed tracking gates it. + app.sessions = new Map([['sess-1', { mode: 'codex', cliMouseTracking: true }]]); + expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(true); app.sessions = new Map([['sess-1', { mode: 'gemini', cliVersion: '9.9.9', cliMouseTracking: true }]]); // unverified TUI expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false); @@ -871,13 +872,16 @@ describe('terminal touch tap mouse guard', () => { app._sendSyntheticSgrWheel(50, 50, -2); // 2 ticks up app._sendSyntheticSgrWheel(50, 50, 9); // capped at 5 ticks down - expect(sent).toEqual([]); // nothing until the flush timer fires + expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[<64;7;4M'.repeat(2) }]); // leading report is immediate app._flushWheelSgrQueue(); - expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[<64;7;4M'.repeat(2) + '\x1b[<65;7;4M'.repeat(5) }]); + expect(sent).toEqual([ + { id: 'sess-1', data: '\x1b[<64;7;4M'.repeat(2) }, + { id: 'sess-1', data: '\x1b[<65;7;4M'.repeat(5) }, + ]); app._flushWheelSgrQueue(); // queue drained — no duplicate send - expect(sent).toHaveLength(1); + expect(sent).toHaveLength(2); }); it('forwarded scrolls (wheel AND touch) snap the viewport home first, then encode SGR ticks', () => { From ef2001b1c114fd6efb321124b07d0c58ffce775d Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Sun, 11 Oct 2026 11:00:34 +0800 Subject: [PATCH 02/96] fix(test): make a bare vitest invocation use the isolated CI config `npx vitest run` with no --config found no config file, so test/setup.ts never ran and HOME was not redirected: the suite wrote fixtures (clis.json with a test entry, users.json, webhook.json, intents.json, .env, settings.json values) into the developer's real ~/.codeman, and a running Codeman then loaded them on its next restart. A root vitest.config.ts re-exports config/vitest.ci.config.ts, so the default invocation is the isolated one; npm test and the browser/mobile/perf scripts keep passing their own --config. The shared temp-HOME teardown also retries on ENOTEMPTY, which failed whole files intermittently after every test had passed. --- .changeset/b569450a.md | 5 +++++ test/setup.ts | 4 +++- test/test-env-isolation.test.ts | 13 +++++++++++++ vitest.config.ts | 8 ++++++++ 4 files changed, 29 insertions(+), 1 deletion(-) create mode 100644 .changeset/b569450a.md create mode 100644 vitest.config.ts diff --git a/.changeset/b569450a.md b/.changeset/b569450a.md new file mode 100644 index 00000000..578f328d --- /dev/null +++ b/.changeset/b569450a.md @@ -0,0 +1,5 @@ +--- +"aicodeman": patch +--- + +Test suite: a bare `npx vitest run` now resolves the isolated CI config instead of running with no setup file (which wrote fixtures into the real `~/.codeman`), and the shared temp-HOME teardown retries on `ENOTEMPTY`. diff --git a/test/setup.ts b/test/setup.ts index 0ef7e35b..7a546103 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -121,7 +121,9 @@ afterAll(async () => { if (originalPlaywrightBrowsersPath === undefined) delete process.env.PLAYWRIGHT_BROWSERS_PATH; else process.env.PLAYWRIGHT_BROWSERS_PATH = originalPlaywrightBrowsersPath; - rmSync(testHome, { recursive: true, force: true }); + // maxRetries: a straggling writer (a server persisting on shutdown) can recreate a file between + // rmSync's readdir and rmdir, which fails the whole file with ENOTEMPTY after every test passed. + rmSync(testHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); }); // afterAll never fires for a fully-skipped test file (no tests execute), which diff --git a/test/test-env-isolation.test.ts b/test/test-env-isolation.test.ts index 9ea5e0c7..93cafc47 100644 --- a/test/test-env-isolation.test.ts +++ b/test/test-env-isolation.test.ts @@ -78,3 +78,16 @@ describe('test environment isolation', () => { expect(deleted).toEqual(STRIPPED_ENV_VARS.map(([name]) => name).sort()); }); }); + +describe('a bare vitest invocation is still isolated', () => { + // Without a root config, `npx vitest run` loads no setup file, so HOME is never redirected and + // the suite writes fixtures into the developer's real ~/.codeman. Pin that the root config + // exists and carries the setup file, rather than trusting everyone to pass --config. + it('has a root vitest.config.ts that loads test/setup.ts', () => { + const root = fileURLToPath(new URL('..', import.meta.url)); + const src = readFileSync(`${root}vitest.config.ts`, 'utf8'); + expect(src).toContain('./config/vitest.ci.config'); + const ci = readFileSync(`${root}config/vitest.ci.config.ts`, 'utf8'); + expect(ci).toContain("setupFiles: ['./test/setup.ts']"); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 00000000..58fedb7a --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,8 @@ +/** + * Bare `vitest` / `npx vitest run` (no `--config`) resolves THIS file. Without it there is no + * config at all, so no `setupFiles`: `test/setup.ts` never runs, HOME is not redirected, and the + * suite reads and writes the developer's real `~/.codeman` (settings, clis.json, users.json, + * .env, ...). It points at the CI gate so the default invocation is always the isolated one; + * `npm test`, `test:browser`, `test:mobile` and `test:perf` still pass their own `--config`. + */ +export { default } from './config/vitest.ci.config'; From 686ce5ac02c6fae7bafeb15a8b5fef7d75c3ed09 Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 11 Oct 2026 12:53:11 +0800 Subject: [PATCH 03/96] docs(test): a bare vitest run is isolated now, and why it mattered CLAUDE.md and Contributing said raw npx vitest skips the config; state what happened when it did (fixtures written into the real ~/.codeman) and that the root vitest.config.ts now makes a bare run isolated, with the test that pins it. --- CLAUDE.md | 2 +- docs/wiki/Contributing.md | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 207548c2..e6f2f9b2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -331,7 +331,7 @@ npm run test:all # literally everything; fails ~87 tests on a clean master ⚠️ **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 -- `, `npm run test:browser -- `, `npm run test:perf -- `). Vitest treats "no files matched a filter" as success, so read the file count, not just the colour. -Raw `npx vitest` skips the config (and with it `setup.ts`); always use `npm test --` or pass `--config`. +A raw `npx vitest` used to find no config, so `setup.ts` never ran and the suite wrote fixtures into the REAL `~/.codeman` (a `clis.json` with a test entry, `users.json`, `webhook.json`, `.env`, settings values: it looks exactly like "my settings were reset"). The root `vitest.config.ts` now re-exports `config/vitest.ci.config.ts`, so a bare run is isolated too, and `test/test-env-isolation.test.ts` pins that. Still prefer `npm test --`, and pass `--config config/vitest.config.ts` (or the browser/mobile/perf configs) deliberately for the suites the gate leaves out. **Config**: Vitest with `globals: true`, `fileParallelism: false`. Timeout 30s, teardown 60s. `config/vitest.config.ts` is the everything-config behind `test:all`; `config/vitest.ci.config.ts` is the gate and derives its excludes from `config/test-suites.ts`, which is also what `vitest.browser.config.ts` and `vitest.perf.config.ts` derive their includes from — so the exclusions and the runners cannot drift apart. Keep shared options in sync across them. diff --git a/docs/wiki/Contributing.md b/docs/wiki/Contributing.md index 171203b7..ccb6b87c 100644 --- a/docs/wiki/Contributing.md +++ b/docs/wiki/Contributing.md @@ -53,7 +53,9 @@ 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. -`npm test` runs the same suite CI runs, so a green run locally means a green run there. It +`npm test` runs the same suite CI runs, so a green run locally means a green run there. A bare +`npx vitest run` resolves the same isolated config (a root `vitest.config.ts`), so it never +touches your real `~/.codeman`; it did before that file existed. `npm test` also 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 From 1802bafc74cc554381c097d287bbb717ead010cf Mon Sep 17 00:00:00 2001 From: Codeman maintainer Date: Sun, 11 Oct 2026 13:05:31 +0800 Subject: [PATCH 04/96] fix(test): keep removing the temp HOME until a straggling CLI goes quiet maxRetries alone was not enough: on a machine with a real agent CLI installed, a test's code path probes or spawns `claude`, which flushes its own ~/.claude.json and ~/.claude/backups/ into the temp HOME a moment after the test ends, so rmSync raced it and failed the whole file with ENOTEMPTY after every test had passed (about one run in three for session-routes-claude-model; CI has no CLI). Retry the removal for up to ~5s, and if it never goes quiet leave the folder to the exit backstop instead of failing a file that passed. 12 consecutive runs of that file: no failure. --- test/setup.ts | 17 ++++++++++++++--- 1 file changed, 14 insertions(+), 3 deletions(-) diff --git a/test/setup.ts b/test/setup.ts index 7a546103..e4f67be7 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -121,9 +121,20 @@ afterAll(async () => { if (originalPlaywrightBrowsersPath === undefined) delete process.env.PLAYWRIGHT_BROWSERS_PATH; else process.env.PLAYWRIGHT_BROWSERS_PATH = originalPlaywrightBrowsersPath; - // maxRetries: a straggling writer (a server persisting on shutdown) can recreate a file between - // rmSync's readdir and rmdir, which fails the whole file with ENOTEMPTY after every test passed. - rmSync(testHome, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }); + // A straggling writer can recreate files between rmSync's readdir and rmdir and fail the whole file + // with ENOTEMPTY after every test has passed. The usual culprit is a real agent CLI on a developer + // machine (the `claude` a test's code path probes or spawns) flushing its own `~/.claude.json` and + // `~/.claude/backups/` into the temp HOME a moment after the test ends; CI has no such CLI. Keep + // removing until it goes quiet, and if it never does, leave the temp dir to the `exit` backstop + // below: a leaked temp folder must not fail a test file that passed. + for (let attempt = 0; attempt < 20; attempt++) { + try { + rmSync(testHome, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 }); + break; + } catch { + await new Promise((resolve) => setTimeout(resolve, 250)); + } + } }); // afterAll never fires for a fully-skipped test file (no tests execute), which From 710ed73585f3aa3141423543e6ef8dd7fc09003f Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:58:16 +0800 Subject: [PATCH 05/96] fix(test): a real home is never "the test HOME" to the deletion guards safeRmHomeTree and isUnderTestHome answered "is this path under process.env.HOME?". With test/setup.ts skipped (a bare vitest) HOME is the developer's real home, so every real file passed, which is how the real linked-cases registry was deleted on 2026-10-10. They now also require HOME to be the throwaway codeman-vitest-* directory setup.ts creates, and the shared route harness throws when it is not, so every route test refuses to start against a real ~/.codeman rather than just the one file that checked. --- test/mocks/index.ts | 10 ++- test/mocks/test-helpers.ts | 79 ++++++++++++++------- test/routes/_route-test-utils.ts | 6 +- test/routes/case-custom-path-routes.test.ts | 4 +- test/test-home-guard.test.ts | 78 ++++++++++++++++++++ 5 files changed, 149 insertions(+), 28 deletions(-) create mode 100644 test/test-home-guard.test.ts diff --git a/test/mocks/index.ts b/test/mocks/index.ts index 2a696c2d..29bc0c8e 100644 --- a/test/mocks/index.ts +++ b/test/mocks/index.ts @@ -7,5 +7,13 @@ export { MockSession, createMockSession, terminalOutputs } from './mock-session.js'; export { MockStateStore } from './mock-state-store.js'; -export { waitForEvent, createDeferred, safeRmHomeTree, isUnderTestHome } from './test-helpers.js'; +export { + waitForEvent, + createDeferred, + safeRmHomeTree, + isUnderTestHome, + isSandboxedHome, + assertSandboxedHome, + TEST_HOME_PREFIX, +} from './test-helpers.js'; export { createMockRouteContext, type MockRouteContext } from './mock-route-context.js'; diff --git a/test/mocks/test-helpers.ts b/test/mocks/test-helpers.ts index edf13015..c930e0a2 100644 --- a/test/mocks/test-helpers.ts +++ b/test/mocks/test-helpers.ts @@ -3,7 +3,7 @@ */ import { rmSync } from 'node:fs'; -import { resolve } from 'node:path'; +import { basename, resolve } from 'node:path'; /** Wait for an EventEmitter to emit a specific event, with timeout */ export function waitForEvent( @@ -39,37 +39,68 @@ export function createDeferred(): { } /** - * Delete a directory tree, but ONLY when it lives inside the test HOME. - * - * SAFETY (2026-08-29): `test/setup.ts` redirects `process.env.HOME` to a - * throwaway dir and `os.homedir()` follows it, so a `rmSync(CASES_DIR, - * recursive)` normally lands inside the fixture. This gate is defense in depth - * for the day that stops being true (a test that runs outside setup.ts, an - * env override that anchors a path elsewhere): it refuses to delete anything - * not under the redirected `process.env.HOME`, so the failure mode is a - * leftover temp dir rather than a deleted PRODUCTION `~/codeman-cases`. - * Lexical `resolve()` is used because the leaf often does not exist and - * `realpathSync` would throw. + * Prefix of the throwaway HOME `test/setup.ts` creates (`mkdtempSync(join(tmpdir(), 'codeman-vitest-'))`). + * Pinned by test/test-env-isolation.test.ts, so renaming one without the other fails there. */ -export function safeRmHomeTree(path: string): void { +export const TEST_HOME_PREFIX = 'codeman-vitest-'; + +/** + * True only when `process.env.HOME` is the throwaway directory `test/setup.ts` made. + * + * ⚠️ This is what the helpers below hang on, and it exists because "is the path under HOME" + * is a vacuous question when setup never ran: a bare `vitest` invocation has no setupFiles, + * HOME is the developer's REAL home, and every real file is then "under the test HOME". That is + * how a bare run on 2026-10-10 deleted the real linked-cases registry through a guard that was + * written to prevent exactly that. The temp directory's name is the only thing a real home + * cannot have. + */ +export function isSandboxedHome(): boolean { const home = process.env.HOME; - if (!home) return; - const target = resolve(path); - const root = resolve(home); - if (target === root || target.startsWith(root + '/')) { - rmSync(target, { recursive: true, force: true }); + return !!home && basename(resolve(home)).startsWith(TEST_HOME_PREFIX); +} + +/** + * Throw unless the suite is running under `test/setup.ts`'s temp HOME. The shared route harness + * calls this, so every route test refuses to start against a real `~/.codeman`. + */ +export function assertSandboxedHome(what = 'this test'): void { + if (!isSandboxedHome()) { + throw new Error( + `${what} reads and writes Codeman's data files and must run under test/setup.ts's temp HOME ` + + `(HOME=${process.env.HOME ?? '(unset)'}). Run it with \`npm test\` or ` + + `\`npx vitest run --config config/vitest.ci.config.ts \`, never with a bare vitest.` + ); } } /** - * True when `path` resolves strictly inside `process.env.HOME` (or to it). - * Same rationale as `safeRmHomeTree`; use for guarded non-recursive deletes - * (single files like `linked-cases.json`) so they can never touch prod state. + * Delete a directory tree, but ONLY when it lives inside the test HOME. + * + * SAFETY (2026-08-29, tightened 2026-10-12): `test/setup.ts` redirects `process.env.HOME` to a + * throwaway dir and `os.homedir()` follows it, so a `rmSync(CASES_DIR, recursive)` normally + * lands inside the fixture. This gate is defense in depth for the day that stops being true + * (a test that runs outside setup.ts, an env override that anchors a path elsewhere): it + * refuses to delete anything unless HOME IS the throwaway dir ({@link isSandboxedHome}) and the + * target is under it, so the failure mode is a leftover temp dir rather than a deleted + * PRODUCTION `~/codeman-cases`. The first version only checked "under HOME", which a run + * without setup.ts satisfied for the user's real home. + * Lexical `resolve()` is used because the leaf often does not exist and + * `realpathSync` would throw. + */ +export function safeRmHomeTree(path: string): void { + if (!isUnderTestHome(path)) return; + rmSync(resolve(path), { recursive: true, force: true }); +} + +/** + * True when `path` resolves strictly inside the throwaway test HOME (or to it), and false + * whenever HOME is anything else. Same rationale as `safeRmHomeTree`; use for guarded + * non-recursive deletes (single files like `linked-cases.json`) so they can never touch + * prod state. */ export function isUnderTestHome(path: string): boolean { - const home = process.env.HOME; - if (!home) return false; + if (!isSandboxedHome()) return false; + const root = resolve(process.env.HOME as string); const target = resolve(path); - const root = resolve(home); return target === root || target.startsWith(root + '/'); } diff --git a/test/routes/_route-test-utils.ts b/test/routes/_route-test-utils.ts index 42a4c291..d254bfa0 100644 --- a/test/routes/_route-test-utils.ts +++ b/test/routes/_route-test-utils.ts @@ -6,7 +6,7 @@ */ import Fastify, { type FastifyInstance } from 'fastify'; import fastifyCookie from '@fastify/cookie'; -import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js'; +import { assertSandboxedHome, createMockRouteContext, type MockRouteContext } from '../mocks/index.js'; import { installRouteErrorHandler } from '../../src/web/route-error-handler.js'; import { MAX_ROUTE_PARAM_LENGTH } from '../../src/web/route-helpers.js'; @@ -31,6 +31,10 @@ export async function createRouteTestHarness( registerFn: (app: FastifyInstance, ctx: any) => void, ctxOptions?: { sessionId?: string; authUser?: { username: string; role: 'admin' | 'user' } } ): Promise { + // Route tests write the linked-cases, docker and settings files. Refuse to build a harness against + // a real home (a bare vitest has no setupFiles), instead of finding out from a wiped registry. + assertSandboxedHome('A route test'); + const app = Fastify({ logger: false, routerOptions: { maxParamLength: MAX_ROUTE_PARAM_LENGTH } }); // Register cookie plugin — some routes access req.cookies diff --git a/test/routes/case-custom-path-routes.test.ts b/test/routes/case-custom-path-routes.test.ts index 9db507d1..b49d703e 100644 --- a/test/routes/case-custom-path-routes.test.ts +++ b/test/routes/case-custom-path-routes.test.ts @@ -24,12 +24,12 @@ import { basename, join } from 'node:path'; import { createRouteTestHarness } from './_route-test-utils.js'; import { registerCaseRoutes } from '../../src/web/routes/case-routes.js'; import { dataPath } from '../../src/config/instance.js'; -import { safeRmHomeTree } from '../mocks/index.js'; +import { isSandboxedHome, safeRmHomeTree } from '../mocks/index.js'; const LINKED = () => dataPath('linked-cases.json'); const CASES_DIR = () => join(homedir(), 'codeman-cases'); /** test/setup.ts's temp HOME (its prefix is pinned by test/test-env-isolation.test.ts). */ -const sandboxed = () => basename(homedir()).startsWith('codeman-vitest-'); +const sandboxed = () => isSandboxedHome(); let workDir = ''; const work = () => workDir; const linked = (): Record => (existsSync(LINKED()) ? JSON.parse(readFileSync(LINKED(), 'utf8')) : {}); diff --git a/test/test-home-guard.test.ts b/test/test-home-guard.test.ts new file mode 100644 index 00000000..6481df99 --- /dev/null +++ b/test/test-home-guard.test.ts @@ -0,0 +1,78 @@ +/** + * @fileoverview The test-only deletion guards must treat a REAL home as "not the test HOME". + * + * `safeRmHomeTree` and `isUnderTestHome` were written so a test could never delete production + * state, and they asked "is this path under `process.env.HOME`?". With `test/setup.ts` skipped + * (a bare `vitest`), HOME is the developer's real home, so that answer is "yes" for every real + * file, and on 2026-10-10 they approved wiping the real linked-cases registry. They now also + * require HOME to be the throwaway directory setup.ts made. + * + * Port: none. Everything happens in a temp tree; HOME is stubbed for the duration of a test. + */ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { + TEST_HOME_PREFIX, + assertSandboxedHome, + isSandboxedHome, + isUnderTestHome, + safeRmHomeTree, +} from './mocks/index.js'; +import { createRouteTestHarness } from './routes/_route-test-utils.js'; + +const made: string[] = []; +/** A stand-in for a developer's REAL home: a real directory with real files, not a sandbox name. */ +function fakeRealHome(): string { + const dir = mkdtempSync(join(tmpdir(), 'not-the-sandbox-')); + made.push(dir); + mkdirSync(join(dir, '.codeman')); + writeFileSync(join(dir, '.codeman', 'linked-cases.json'), '{"keep":"/me"}'); + return dir; +} + +afterEach(() => { + vi.unstubAllEnvs(); + for (const d of made.splice(0)) rmSync(d, { recursive: true, force: true }); +}); + +describe('test HOME guards', () => { + it('this suite really runs under the sandboxed HOME', () => { + expect(isSandboxedHome()).toBe(true); + expect(() => assertSandboxedHome()).not.toThrow(); + }); + + it('pins the prefix test/setup.ts creates', () => { + expect(TEST_HOME_PREFIX).toBe('codeman-vitest-'); + }); + + it('safeRmHomeTree does not delete anything under a real home', () => { + const home = fakeRealHome(); + vi.stubEnv('HOME', home); + safeRmHomeTree(join(home, '.codeman')); + expect(existsSync(join(home, '.codeman', 'linked-cases.json'))).toBe(true); + }); + + it('isUnderTestHome is false for a real home, even for a file inside it', () => { + const home = fakeRealHome(); + vi.stubEnv('HOME', home); + expect(isSandboxedHome()).toBe(false); + expect(isUnderTestHome(join(home, '.codeman', 'linked-cases.json'))).toBe(false); + }); + + it('still deletes inside the real sandbox', () => { + const inside = mkdtempSync(join(process.env.HOME as string, 'guard-')); + writeFileSync(join(inside, 'f'), 'x'); + expect(isUnderTestHome(inside)).toBe(true); + safeRmHomeTree(inside); + expect(existsSync(inside)).toBe(false); + }); + + it('the route harness refuses to start against a real home', async () => { + const home = fakeRealHome(); + vi.stubEnv('HOME', home); + await expect(createRouteTestHarness(() => undefined)).rejects.toThrow(/temp HOME/); + expect(existsSync(join(home, '.codeman', 'linked-cases.json'))).toBe(true); + }); +}); From 8cb3441dd9f8d7f50877a80697fe162a01bbd839 Mon Sep 17 00:00:00 2001 From: Saqeb Akhter Date: Sun, 18 Oct 2026 11:23:05 +0400 Subject: [PATCH 06/96] fix(terminal): address scrolling review and preserve upstream speed policies Fix transport-timer regression coverage, zero-delta tile handling and capability documentation. Record isolated Codex 0.163.0 transcript and approval evidence; defer event/batch rate and coarse paging policy changes. Refs: https://github.com/johoja12/Codeman/issues/7 --- CLAUDE.md | 2 +- docs/architecture-invariants.md | 8 ++- src/config/cli-registry/schema.ts | 2 +- src/config/cli-registry/stock.ts | 4 +- src/config/cli-registry/types.ts | 8 +-- src/web/public/terminal-tile.js | 9 +-- src/web/public/terminal-ui.js | 88 ++++++----------------- test/cli-registry-schema.test.ts | 9 +++ test/terminal-scroll-routing.test.ts | 103 ++++++++++++++++++--------- test/terminal-tile-scroll.test.ts | 38 ++++++---- test/terminal-touch-tap.test.ts | 26 +++---- 11 files changed, 158 insertions(+), 139 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index adb38400..51fa95e5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -204,7 +204,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Full-scrollback replay**: `GET /api/sessions/:id/terminal?full=1` returns the whole tmux scrollback ALONE (`source='mux-full-history'`), superseding the byte buffer. First load of each non-shell TUI session requests it (`_fullHistoryLoaded`); Shell selection, drop recovery, clear and a tile's load use `SHELL_LOAD_QUERY` (constants.js), a bounded window of tmux's RENDERED history (`?full=1&tail=1MiB&alt=recording&lines=10000`), never the raw byte recording, whose full-screen repaints replayed at another pane height push copies of their top row into scrollback; the route reports a client `lines=` bound below the history ceiling that left lines in tmux as `truncationReason: 'tail'`, and `alt=recording` falls back to the visible frame over the tailed byte recording when the pane is in the alternate screen (a nested tmux in a remote or Docker case keeps no tmux history; vim/less take it too while open). A Shell scroll-to-top pulls a bounded `?full=1&tail=` window (a window no longer than the browser's buffer is skipped before the downgrade guard, so it never marks the session exhausted); the unbounded pull stays behind **Load full history**. A Shell split-pane Pane B has its own copy of the bounded pull against its own xterm (`TerminalTile._pullHistory`, terminal-tile.js); keep the two in step. → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions) ⚠️ The capture ends with a RELATIVE cursor move back to the pane's caret (never `CUP`), so no line-deleting transform may run over it; those skips key on `isFullCapture`, never on `?full=1` alone. ⚠️ A re-pull must never shrink the buffer (`_replayWouldShrinkBuffer()`). ⚠️ `captureCols`/`captureRows` are absent when no frame was positioned: test `Number.isFinite`, never truthiness. ⚠️ A frame dropped at the 128 KiB render cap MUST be recovered, and the recovery verifies itself: `_scheduleDroppedOutputRecovery` re-arms (bounded by `DROP_RECOVERY_MAX_ATTEMPTS`) while `_onSessionNeedsRefresh` reports no repaint, but never after a capture-fetch `'deadline'`. → [architecture-invariants#full-scrollback-replay](docs/architecture-invariants.md#full-scrollback-replay) -**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. ⚠️ A `TerminalTile` (grid tile, split Pane B) forwards the wheel as SGR reports, pages it and reports clicks through these SAME gates with itself as the target (its terminal, its session, never `activeSessionId`), and scrolls Shift+wheel itself (xterm makes it a horizontal no-op); never copy the mode lists into terminal-tile.js. 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 only with observed `cliMouseTracking`: **Claude ≥ 2.1.187 or tracked Codex**. Codex 0.163.0 fullscreen was measured with `alternate_on=1`, `mouse_any_flag=1`: one SGR report moves one transcript row; see the fresh measurement in architecture-invariants. Inline/untracked Codex retains local history or the capability-gated paging fallback. ⚠️ `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. ⚠️ A `TerminalTile` (grid tile, split Pane B) forwards the wheel as SGR reports, pages it and reports clicks through these SAME gates with itself as the target (its terminal, its session, never `activeSessionId`), and scrolls Shift+wheel itself (xterm makes it a horizontal no-op); never copy the mode lists into terminal-tile.js. 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 1836d1bf..fe8f5167 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -225,11 +225,15 @@ Further detail: the `: ` form (`w3-myapp: fix the login redirect` ⚠️ **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. +**Wheel forwarding requires observed mouse tracking: Claude ≥ 2.1.187 or tracked Codex** (`terminal-ui.js:_shouldForwardWheelToApp`). The original #227 measurement remains valid for **Codex 0.147.0 inline**: `alternate_on=0`, `mouse_any_flag=0`, growing tmux history, and SGR wheel reports do nothing. Claude 2.1.280 inline similarly reports `alternate_on=0`, `mouse_any_flag=0`; forwarding there swallows gestures, so it keeps local scrollback. Fullscreen Claude (`CLAUDE_CODE_NO_FLICKER=1` or `"tui": "fullscreen"`) enables mouse tracking and accepts reports. **Fresh Codex 0.163.0 fullscreen measurement (2026-10-18, isolated local tmux pane)**: `alternate_on=1`, `mouse_any_flag=1`; one `\x1b[<64;10;10M` report moves the transcript one row upward, and `\x1b[<65;10;10M` restores the original screen. This is capability evidence, not a minimum-version promise: untracked Codex (including the maintainer's 0.162.0 measurement with `mouse_any_flag=0`) keeps local history or the hollow-pane paging fallback. Codex does not publish the Claude-only CLI version field, so its gate requires the observed flag rather than a Claude version. The registry records this as `wheelForward.mode: 'mouse-tracking'`; Claude remains `version-gated`. A stale/false flag after startup cannot authorize forwarding. ⚠️ A strip mode alone is not evidence that a CLI consumes SGR reports. Hand-encoded SGR taps remain independently gated by `_shouldReportMouseToCli()`; the older #325 no-op measurement describes untracked inline Codex and must not be generalized to newer tracked fullscreen renderers. Shift/local-history opt-outs and xterm's native mouse/alternate-screen handling remain intact. + +Codex 0.163.0 approval check in that isolated read-only/on-request session: the "Would you like to run the following command?" prompt remained on `1. Yes, proceed` after SGR wheel-up and wheel-down over both the transcript and approval option rows 36/37; the requested marker file was not created. The background transcript can scroll while the approval prompt remains fixed. Codex has no Claude hook alert, so a `tabAlerts === 'action'` test cannot detect this prompt and is not a native-wheel safety gate. Native Codex owns its approval selection; do not mistake a hook-only unit fixture for approval-dialog evidence. + +Native wheel travel retains upstream pixel/line/page units (100 px = 4 lines; Firefox 3-line events = 3 lines); no one-tick-per-event policy is introduced here. Fractional deltas accumulate within one recent direction, rather than rounding every tiny event up. The first native report of each tab visit bypasses the 40 ms batching timer; later reports coalesce and only unsent bytes can be cancelled on reversal. Every queued batch belongs to its originating session, so switching tabs cannot send its bytes into the newly selected pane. Zero-delta events leave pending movement untouched. Tiles use the same helpers and their own queue/session. **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 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()`: the `transcriptPageKeys` capability, 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). ⚠️ The first event of a trackpad-sized gesture (opening under 2 rows) pages AT ONCE and the travel it skipped is owed back by the rest of the gesture (one page per half screen still). Waiting for half a screen first meant an ordinary trackpad flick (well under 19 rows on a 38-row pane) sent nothing at all. A larger opening event is a mouse-wheel notch (100 px is 4 rows) and accumulates as before, or slow notches, each its own gesture past the gap, would page a full screen apiece. ⚠️ What starts a NEW gesture (and so may pre-pay) is narrow, because every boundary is another free page: (1) a TOUCH CONTACT is one gesture, never split by time: the touch paths tag every call, touchmove and momentum frames alike, with `touchId` (one per touchstart), and a different id (or a wheel event after a touch) is a boundary. touchmove only calls once per whole cell of finger travel, so a slow drag arrives as single lines further apart than any gap; on the gap alone 12 lines at 170 ms each sent 12 PageUps (measured through the real listeners, `test/terminal-touch-paging.browser.test.ts`). (2) Outside a contact, 150 ms of quiet (`PAGE_KEY_GESTURE_GAP_MS`), stretched to one second (`PAGE_KEY_DEBT_MS`) while a pre-paid page is still owed in the SAME direction, so slow small wheel ticks (40 px every 250 ms, a Firefox 1-line notch) pay the debt back instead of each pre-paying a page; after a second of quiet the debt lapses and the next small tick pages at once again. The debt also ends the moment travel repays it (reaches or crosses zero): the rest is plain travel and the 150 ms gap applies again. (3) A direction change inside a gesture (within the gap, or within one contact) drops a pre-paid debt (it belongs to the other direction) and accumulates WITHOUT pre-paying, so a +/-3 px trackpad wobble pages once for its opening event and then nothing; a reversal after the gap is a new gesture and pages at once. A tab switch also starts a new gesture. ⚠️ These gesture rules (first-event page, contact and debt lifetimes, reversal handling, notch accumulation, pinch and sideways-swipe consume) are ONE pure helper, `CodemanTerminalInput.pageKeysForGesture(state, ev, lines, rows, now)`, which both panes call with their own state; never re-derive them in a pane. ⚠️ Three gestures are consumed WITHOUT paging: a pinch (`ctrlKey`, Chrome's trackpad-pinch wheel events), a mostly horizontal swipe (both deltas present and `|deltaX| > |deltaY|`; the touch path locks its own axis first), and anything while the pane's OWN session has the alert `'action'` (a pending `permission_prompt` or `elicitation_dialog`, `updateTabAlertFromHooks` in app.js), so a page key never lands in an open dialog's selector; the primary pane logs that case as `page-keys-dialog-open` so a wheel dead under a stale alert leaves a `[scroll]` line. The dialog check is the one rule outside the helper, because it is per pane: the primary pane reads `tabAlerts` for `activeSessionId`, a tile for its own `sessionId` (usually not the active one). ⚠️ Which modes page is the registry's `capabilities.transcriptPageKeys`, published as `window.__codemanTranscriptPageKeys`, never an id check in terminal-ui.js. Today that is claude, codex and opencode. Codex is on it although it is never forwarded the wheel: newer codex draws on tmux's alternate screen, which the full strip hides from xterm, so its pane is hollow too, and PageUp pages its transcript while SGR wheel reports still do nothing. Which version made the switch is not pinned down: alt-screen drawing was observed on 0.157.1 (local) and 0.160 (remote), while 0.147.0 still draws inline; nothing between 0.147.0 and 0.157.1 has been checked. This is the page-key fallback, NOT wheel forwarding, and an older inline codex keeps real local history (`baseY > 0`) so the guard never fires for it. `shell`/`pi` own real terminal scrollback and are never paged, and 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()`. **Tiles** (a grid tile, the split's Pane B: `TerminalTile`) page the wheel through these same gates, called with the tile's own terminal and session (`_localScrollbackIsHollow(target)`, `_shouldForwardWheelToApp(ev, target)`) and the pure `CodemanTerminalInput.pageKeysForGesture` on the tile's own state, so the mode rule and the gesture rules stay in terminal-ui.js alone. ⚠️ A tile's `baseY` is rarely 0 even when hollow: its first capture is taken at the PTY's previous, taller size and its row-shrinking fits push rows up, so it passes `localRows` with those rows discounted (`TerminalTile._localRows`). ⚠️ So a tile can be hollow with its viewport still up in those rows (Shift+PageUp, a scrollbar drag, a wheel during the first replay), and it pages only while `_terminalViewportAtBottom(tile.terminal)` holds: otherwise every wheel, wheel-down included, was paged and swallowed, the stale rows stayed on screen and the click report (which refuses an off-bottom viewport) went dead too. Off the bottom the wheel stays xterm's, so a wheel-down brings the viewport home. The primary pane needs no such gate: hollow there means `baseY === 0`, which is always at the bottom. Tiles also FORWARD the wheel to Claude's fullscreen renderer as SGR reports (`TerminalTile._maybeForwardWheelToCli`: the same `_shouldForwardWheelToApp(ev, target)` gate, the cell from `_clientPointToCell(x, y, tile.terminal)`, the bytes from the pure `CodemanTerminalInput.sgrWheelReports` the primary pane's `_sendSyntheticSgrWheel` also uses, flushed through the tile's own coalescer to the tile's session, never the primary queue, which flushes to `activeSessionId`); before that a fullscreen Claude tile left the wheel to xterm, which scrolled only stale replayed frames while Claude's transcript never moved. ⚠️ Shift+wheel in a tile is scrolled by the tile itself (`_maybeScrollLocalOnShift`, `scrollLines()` on the dominant axis), as in the primary pane: xterm's own scroller turns a Shift+vertical wheel into a horizontal one off macOS and Chrome on Windows sends Shift+wheel as `deltaX`, so left to xterm the gesture was dead. Tiles have no touch path. Tests: `test/terminal-scroll-routing.test.ts`, `test/terminal-tile-scroll.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()`: the `transcriptPageKeys` capability, 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). ⚠️ The first event of a trackpad-sized gesture (opening under 2 rows) pages AT ONCE and the travel it skipped is owed back by the rest of the gesture (one page per half screen still). Waiting for half a screen first meant an ordinary trackpad flick (well under 19 rows on a 38-row pane) sent nothing at all. A larger opening event is a mouse-wheel notch (100 px is 4 rows) and accumulates as before, or slow notches, each its own gesture past the gap, would page a full screen apiece. ⚠️ What starts a NEW gesture (and so may pre-pay) is narrow, because every boundary is another free page: (1) a TOUCH CONTACT is one gesture, never split by time: the touch paths tag every call, touchmove and momentum frames alike, with `touchId` (one per touchstart), and a different id (or a wheel event after a touch) is a boundary. touchmove only calls once per whole cell of finger travel, so a slow drag arrives as single lines further apart than any gap; on the gap alone 12 lines at 170 ms each sent 12 PageUps (measured through the real listeners, `test/terminal-touch-paging.browser.test.ts`). (2) Outside a contact, 150 ms of quiet (`PAGE_KEY_GESTURE_GAP_MS`), stretched to one second (`PAGE_KEY_DEBT_MS`) while a pre-paid page is still owed in the SAME direction, so slow small wheel ticks (40 px every 250 ms, a Firefox 1-line notch) pay the debt back instead of each pre-paying a page; after a second of quiet the debt lapses and the next small tick pages at once again. The debt also ends the moment travel repays it (reaches or crosses zero): the rest is plain travel and the 150 ms gap applies again. (3) A direction change inside a gesture (within the gap, or within one contact) drops a pre-paid debt (it belongs to the other direction) and accumulates WITHOUT pre-paying, so a +/-3 px trackpad wobble pages once for its opening event and then nothing; a reversal after the gap is a new gesture and pages at once. A tab switch also starts a new gesture. ⚠️ These gesture rules (first-event page, contact and debt lifetimes, reversal handling, notch accumulation, pinch and sideways-swipe consume) are ONE pure helper, `CodemanTerminalInput.pageKeysForGesture(state, ev, lines, rows, now)`, which both panes call with their own state; never re-derive them in a pane. ⚠️ Three gestures are consumed WITHOUT paging: a pinch (`ctrlKey`, Chrome's trackpad-pinch wheel events), a mostly horizontal swipe (both deltas present and `|deltaX| > |deltaY|`; the touch path locks its own axis first), and anything while the pane's OWN session has the alert `'action'` (a pending `permission_prompt` or `elicitation_dialog`, `updateTabAlertFromHooks` in app.js), so a page key never lands in an open dialog's selector; the primary pane logs that case as `page-keys-dialog-open` so a wheel dead under a stale alert leaves a `[scroll]` line. The dialog check is the one rule outside the helper, because it is per pane: the primary pane reads `tabAlerts` for `activeSessionId`, a tile for its own `sessionId` (usually not the active one). ⚠️ Which modes page is the registry's `capabilities.transcriptPageKeys`, published as `window.__codemanTranscriptPageKeys`, never an id check in terminal-ui.js. Today that is claude, codex and opencode. Codex retains this fallback when mouse tracking has not been observed: alternate-screen drawing is stripped from xterm and can leave a hollow pane, while PageUp still pages the CLI transcript. Tracked fullscreen Codex 0.163.0 forwards SGR reports first, as measured above. Which version made the switch is not pinned down: alt-screen drawing was observed on 0.157.1 (local) and 0.160 (remote), while 0.147.0 still draws inline; nothing between 0.147.0 and 0.157.1 has been checked. This is the page-key fallback, NOT wheel forwarding, and an older inline codex keeps real local history (`baseY > 0`) so the guard never fires for it. `shell`/`pi` own real terminal scrollback and are never paged, and 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()`. **Tiles** (a grid tile, the split's Pane B: `TerminalTile`) page the wheel through these same gates, called with the tile's own terminal and session (`_localScrollbackIsHollow(target)`, `_shouldForwardWheelToApp(ev, target)`) and the pure `CodemanTerminalInput.pageKeysForGesture` on the tile's own state, so the mode rule and the gesture rules stay in terminal-ui.js alone. ⚠️ A tile's `baseY` is rarely 0 even when hollow: its first capture is taken at the PTY's previous, taller size and its row-shrinking fits push rows up, so it passes `localRows` with those rows discounted (`TerminalTile._localRows`). ⚠️ So a tile can be hollow with its viewport still up in those rows (Shift+PageUp, a scrollbar drag, a wheel during the first replay), and it pages only while `_terminalViewportAtBottom(tile.terminal)` holds: otherwise every wheel, wheel-down included, was paged and swallowed, the stale rows stayed on screen and the click report (which refuses an off-bottom viewport) went dead too. Off the bottom the wheel stays xterm's, so a wheel-down brings the viewport home. The primary pane needs no such gate: hollow there means `baseY === 0`, which is always at the bottom. Tiles also FORWARD the wheel to tracked Claude/Codex fullscreen renderers as SGR reports (`TerminalTile._maybeForwardWheelToCli`: the same `_shouldForwardWheelToApp(ev, target)` gate, the cell from `_clientPointToCell(x, y, tile.terminal)`, the bytes from the pure `CodemanTerminalInput.sgrWheelReports` the primary pane's `_sendSyntheticSgrWheel` also uses, flushed through the tile's own coalescer to the tile's session, never the primary queue, which flushes to `activeSessionId`); before that a fullscreen Claude tile left the wheel to xterm, which scrolled only stale replayed frames while Claude's transcript never moved. ⚠️ Shift+wheel in a tile is scrolled by the tile itself (`_maybeScrollLocalOnShift`, `scrollLines()` on the dominant axis), as in the primary pane: xterm's own scroller turns a Shift+vertical wheel into a horizontal one off macOS and Chrome on Windows sends Shift+wheel as `deltaX`, so left to xterm the gesture was dead. Tiles have no touch path. Tests: `test/terminal-scroll-routing.test.ts`, `test/terminal-tile-scroll.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/src/config/cli-registry/schema.ts b/src/config/cli-registry/schema.ts index 8bdcc281..82d7a614 100644 --- a/src/config/cli-registry/schema.ts +++ b/src/config/cli-registry/schema.ts @@ -295,7 +295,7 @@ const capabilitiesSchema = z 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() }) + .object({ mode: z.enum(['never', 'version-gated', 'mouse-tracking']), minVersion: z.string().max(20).optional() }) .strict(), // Whether a hollow pane's wheel/touch scroll may be turned into PageUp/PageDown for // the CLI to page its own transcript. Distinct from wheelForward (SGR reports). diff --git a/src/config/cli-registry/stock.ts b/src/config/cli-registry/stock.ts index 3075e36f..dc1b1c21 100644 --- a/src/config/cli-registry/stock.ts +++ b/src/config/cli-registry/stock.ts @@ -733,8 +733,8 @@ const CODEX: CliEntry = { transcript: 'codex-rollout', altScreen: 'strip-full', echo: { policy: 'predict', anchor: { kind: 'cursor' }, predictProfile: 'codex' }, - wheelForward: { mode: 'never' }, // #227: codex ignores SGR wheel reports, never forward - // ...but it DOES page its transcript on PageUp/PageDown. Measured on 0.157.1 (local, + wheelForward: { mode: 'mouse-tracking' }, // 0.163.0 fullscreen: measured SGR scrolling; require observed tracking + // Without observed mouse tracking, retain PageUp/PageDown fallback. Measured on 0.157.1 (local, // alternate screen inside tmux, so xterm holds no history) and 0.160 (remote): PageUp // moved the visible transcript while SGR wheel reports and local scrolling did nothing. // This is the page-key fallback for a hollow pane, not wheel forwarding. diff --git a/src/config/cli-registry/types.ts b/src/config/cli-registry/types.ts index 51eab76e..dcbc4897 100644 --- a/src/config/cli-registry/types.ts +++ b/src/config/cli-registry/types.ts @@ -513,8 +513,8 @@ export interface CliCapabilities { /** Names a PREDICT_PROFILES key. Unknown or absent degrades to 'buffer', never to broken. */ predictProfile?: string; }; - /** Forwarding the wheel to the CLI's own transcript. 'never' keeps local scrollback. */ - wheelForward: { mode: 'never' | 'version-gated'; minVersion?: string }; + /** Wheel forwarding policy. Every enabled policy also requires observed CLI mouse tracking. */ + wheelForward: { mode: 'never' | 'version-gated' | 'mouse-tracking'; minVersion?: string }; /** * The CLI pages its OWN transcript on PageUp/PageDown, so a wheel or touch scroll over * a pane with no local history can be turned into those keys instead of doing nothing @@ -523,8 +523,8 @@ export interface CliCapabilities { * * ⚠ NOT wheel forwarding. `wheelForward` above sends SGR mouse wheel REPORTS; this sends * plain keys, and only when the local buffer is hollow (normal buffer, `baseY === 0`). - * A CLI can ignore one and honour the other: codex ignores SGR wheel reports (so its - * `wheelForward` stays 'never') yet pages on PageUp/PageDown. + * A CLI can ignore one and honour the other: Codex without observed mouse tracking + * uses this fallback, while fullscreen Codex 0.163.0 accepts SGR wheel reports. * * Absent means no paging, so a CLI nobody has measured keeps plain local scrolling. */ diff --git a/src/web/public/terminal-tile.js b/src/web/public/terminal-tile.js index 8002e44a..33d3a903 100644 --- a/src/web/public/terminal-tile.js +++ b/src/web/public/terminal-tile.js @@ -51,7 +51,7 @@ * * @dependency vendor/xterm.js, vendor/xterm-addon-fit.js * @dependency constants.js (window.CodemanTerminalFont, window.CodemanFetchDeadline, DEFAULT_SCROLLBACK, TERMINAL_TAIL_SIZE, SHELL_LOAD_BASE_QUERY, SHELL_LOAD_QUERY, TERMINAL_CHUNK_SIZE) - * @dependency terminal-ui.js (codemanCurrentXtermTheme, codemanCurrentSkinIsLight, CodemanTerminalInput.shouldSuppressTerminalQueryResponse/isTerminalFocusOrMouseReport/wheelDeltaLines/wheelDeltaWholeLines/sgrWheelReports/pageKeysForGesture, app._shouldForwardWheelToApp/_localScrollbackIsHollow/_terminalViewportAtBottom/_clientPointToCell/_handleDesktopTerminalClick, app.tabAlerts) + * @dependency terminal-ui.js (codemanCurrentXtermTheme, codemanCurrentSkinIsLight, CodemanTerminalInput.shouldSuppressTerminalQueryResponse/isTerminalFocusOrMouseReport/wheelDeltaLines/wheelTicksForGesture/coalesceScrollBytes/sgrWheelReports/pageKeysForGesture, app._shouldForwardWheelToApp/_localScrollbackIsHollow/_terminalViewportAtBottom/_clientPointToCell/_handleDesktopTerminalClick, app.tabAlerts) * @dependency terminal-keycode229-recovery.js (window.CodemanKeyCode229Recovery, optional: absent, xterm's own textarea handling stands) * @loadorder 7.4 of 16, loaded after terminal-ui.js and before terminal-split.js */ @@ -1101,7 +1101,8 @@ // SGR coordinates address the live screen, so a report from a scrolled-up // viewport would hit-test another row: snap home first (_forwardScrollToApp). if (!app._terminalViewportAtBottom?.(this.terminal)) this.terminal.scrollToBottom?.(); - const lines = input.wheelDeltaNativeLines(ev, this.terminal.rows); + const lines = input.wheelDeltaLines(ev, this.terminal.rows); + if (!lines) return true; if (this._wheelTickGesture?.dir && this._wheelTickGesture.dir !== Math.sign(lines)) this._scrollBytes = ''; const step = input.wheelTicksForGesture(this._wheelTickGesture, lines, performance.now()); this._wheelTickGesture = step.state; @@ -1215,8 +1216,8 @@ } // Coalesces the scroll bytes (SGR wheel reports and page keys alike) into - // one send per 40 ms, bounded by report count so a fling cannot build a backlog - // that keeps scrolling after it stops. A narrow + // one send per 40 ms after the leading native report, preserving upstream + // report density and cancelling only unsent movement on reversal. A narrow // twin of the primary pane's _queueScrollBytes / _flushWheelSgrQueue // (terminal-ui.js; keep the two in step), which flushes to the active // session only. Sent ephemeral (no seq, never persisted) to THIS pane's diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 3e78470e..632c1a12 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -98,6 +98,10 @@ const PAGE_KEY_GESTURE_GAP_MS = 150; // ...but not for sub-row jitter (0.1 row, ~2px)... const PAGE_KEY_MIN_START_ROWS = 0.1; + // ...and only for a trackpad-sized opening event. A mouse-wheel notch (100px, + // 4 rows) accumulates toward half a screen as before; paging at once on it made + // slow notches, each its own gesture past the gap, page a full screen apiece. + const PAGE_KEY_IMMEDIATE_MAX_ROWS = 2; // A pre-paid page's debt outlives the gesture gap for this long when the next // event goes the SAME way, so slow small wheel ticks (40 px every 250 ms, a // Firefox 1-line notch) pay it back instead of each pre-paying a page of its own. @@ -117,25 +121,7 @@ : delta / 25; // DOM_DELTA_PIXEL (Chrome/WebKit, and every trackpad) } - // A wheel event is at most one native tick: a common mouse notch is - // deltaY=100px (or Firefox's 3 lines), not four/three separate notches. - // Small touchpad events retain their fractional distance and accumulate in - // wheelTicksForGesture. Touch drags bypass this wheel-only normalization. - function wheelDeltaNativeLines(ev, rows) { - return Math.max(-1, Math.min(1, wheelDeltaLines(ev, rows))); - } - - // The same travel rounded to whole lines for the SGR wheel reports: a pure - // horizontal swipe is 0 (nothing to send), and anything else moves at least - // one line, so the small pixel deltas of a precision touchpad still scroll. - // The body of the primary pane's _wheelScrollLines. - function wheelDeltaWholeLines(ev, rows) { - const lines = wheelDeltaLines(ev, rows); - if (!lines) return 0; - return Math.round(lines) || (lines > 0 ? 1 : -1); - } - - // Ticks one gesture batch may report: Claude applies its own scroll-speed + // Ticks one input event may report: Claude applies its own scroll-speed // multiplier and acceleration on top, so a bigger batch only overshoots. const SGR_WHEEL_MAX_TICKS = 5; @@ -167,17 +153,16 @@ }; } - // Only unsent scroll input can be cancelled. Keep at most five SGR reports - // or three page keys per flush, and replace an obsolete direction immediately. - // Local and remote transports share the same bounded gesture queue. + // Only unsent scroll input can be cancelled. Preserve upstream report density + // within each batch, and replace an obsolete direction immediately. + // Local and remote transports share the same direction-aware gesture queue. function coalesceScrollBytes(queued, data) { const direction = (bytes) => bytes?.match(/\x1b\[<(64|65);|\x1b\[(5|6)~/)?.[0]; const previous = direction(queued); const next = direction(data); const combined = previous && previous === next ? queued + data : data; const reports = combined.match(/\x1b\[<(?:64|65);\d+;\d+M|\x1b\[[56]~/g) || []; - const limit = combined.startsWith('\x1b[<') ? SGR_WHEEL_MAX_TICKS : PAGE_KEY_MAX_PER_BATCH; - return reports.slice(-limit).join(''); + return reports.join(''); } // Rows of travel per page key for a terminal `rows` tall (half a screen). @@ -214,15 +199,15 @@ // PAGE_KEY_GESTURE_GAP_MS, stretched to PAGE_KEY_DEBT_MS while a pre-paid // page is still owed in the same direction. One contact is never split by // time, so a slow drag (one line per 170 ms) is one gesture; - // - a new gesture's ordinary wheel/touch opening event (at least - // PAGE_KEY_MIN_START_ROWS, under one page of travel) pages AT ONCE, + // - a new gesture's trackpad-sized opening event (at least + // PAGE_KEY_MIN_START_ROWS, under PAGE_KEY_IMMEDIATE_MAX_ROWS) pages AT ONCE, // pre-paid: the half screen it skipped is owed back by the rest of the // gesture, so the rate stays one page per half screen. A debt left by a // pre-paid page dies with its gesture; // - a direction change inside a gesture (a reversal within the gap, or // within the same contact) drops a pre-paid debt and accumulates, never // pre-paying, so a +/-3 px trackpad wobble cannot page back and forth; - // - larger openings and subsequent events accumulate through + // - everything else, a wheel notch included, accumulates through // pageKeysForTravel, carried across notches. // Returns { state, keys } with keys '' when nothing is due. function pageKeysForGesture(state, ev, lines, rows, now) { @@ -248,14 +233,13 @@ gestureStart = quiet > (prepaid && !reversed ? PAGE_KEY_DEBT_MS : PAGE_KEY_GESTURE_GAP_MS); } if (gestureStart || reversed) { - // Travel and debt belong to their direction; never make a reversal pay - // off movement accumulated going the other way. - if (prepaid || reversed) pending = 0; + // A pre-paid debt belongs to its gesture and its direction. + if (prepaid) pending = 0; prepaid = false; } if (gestureStart) { const size = Math.abs(lines); - if (size >= PAGE_KEY_MIN_START_ROWS && size < perPage) { + if (size >= PAGE_KEY_MIN_START_ROWS && size < Math.min(perPage, PAGE_KEY_IMMEDIATE_MAX_ROWS)) { // Pre-pay one page; the skipped travel is owed back (pending carries the opposite sign). return { state: { pending: lines - dir * perPage, lastAt: now, dir, prepaid: true, touchId }, @@ -422,8 +406,6 @@ PAGE_KEY_SCREEN_FRACTION, PAGE_KEY_MAX_PER_BATCH, wheelDeltaLines, - wheelDeltaWholeLines, - wheelDeltaNativeLines, SGR_WHEEL_MAX_TICKS, sgrWheelReports, wheelTicksForGesture, @@ -1149,7 +1131,7 @@ Object.assign(CodemanApp.prototype, { if (ev.ctrlKey || (!ev.shiftKey && Math.abs(ev.deltaX) > Math.abs(ev.deltaY))) return; if (this._shouldForwardWheelToApp(ev)) { this._logScrollRouting('forward-sgr'); - this._forwardScrollToApp(ev.clientX, ev.clientY, this._wheelScrollNativeLines(ev)); + this._forwardScrollToApp(ev.clientX, ev.clientY, this._wheelScrollLinesFloat(ev)); return; } // Local scrolling accumulates FRACTIONAL lines: a macOS trackpad emits @@ -5817,32 +5799,6 @@ Object.assign(CodemanApp.prototype, { return true; }, - // Forward only to CLIs that explicitly enable mouse tracking. Fullscreen - // Codex 0.163.0 was verified against canonical tmux captures: one SGR report - // scrolls one transcript row. Inline Codex (tracking off) must retain local - // history/fallback behavior; unconditional forwarding caused the old #227 bug. - // Wheel delta → whole scroll lines. macOS trackpads turn Shift+two-finger - // scroll into a HORIZONTAL wheel (deltaY≈0, deltaX carries the magnitude), and - // Shift routes the wheel to local scrollback (_shouldForwardWheelToApp returns - // false on Shift). So under Shift, read whichever axis dominates — otherwise - // deltaY≈0 collapses to a fixed ±1 line/tick and the gesture can't page through - // history on a trackpad (issue #154). Non-Shift and mouse-wheel paths are - // unchanged (they carry deltaY). The `|| ±1` keeps sub-25px deltas moving. - // - // `deltaMode` says what UNIT the delta is in, and ignoring it made every - // non-pixel browser scroll ~4x too slowly: Firefox reports DOM_DELTA_LINE (1) - // with deltaY≈3 per notch, so the pixel math rounded to 0 and fell through to - // the ±1 fallback — one line per notch, versus 4-5 for Chrome's ~110px. In - // Claude mode the same value also capped the forwarded SGR report at one tick. - _wheelScrollLines(ev) { - // Pure horizontal swipe: 0, never the ±1 fallback (wheelDeltaWholeLines). - return window.CodemanTerminalInput.wheelDeltaWholeLines(ev, this.terminal?.rows); - }, - - _wheelScrollNativeLines(ev) { - return window.CodemanTerminalInput.wheelDeltaNativeLines(ev, this.terminal?.rows); - }, - /** Unrounded variant for the smooth local-scroll path, which accumulates * sub-line fractions across events instead of forcing every tiny trackpad * delta to a whole ±1 line. Same unit handling and Shift-axis trap. The @@ -5877,7 +5833,6 @@ Object.assign(CodemanApp.prototype, { const session = this.sessions?.get(target.sessionId || this.activeSessionId); const sessionMode = session?.mode || 'claude'; if (sessionMode !== 'claude' && sessionMode !== 'codex') return false; - if (sessionMode === 'codex' && this.tabAlerts?.get(target.sessionId || this.activeSessionId) === 'action') return false; if (sessionMode === 'claude' && !this._cliVersionAtLeast(session?.cliVersion, '2.1.187')) return false; // Only while Claude is actually listening for the mouse. In its default // inline renderer (2.1.280 measured: alternate_on=0, mouse_any_flag=0) the @@ -5934,8 +5889,8 @@ Object.assign(CodemanApp.prototype, { * Coalesces scroll input into 40ms batches after the first native report (SGR * wheel reports and the PageUp/PageDown fallback alike). Each flush becomes a * tmux send-keys server-side, so per-event writes would spawn a process storm - * on a single flick; report counts are bounded so a wild scroll can't build a - * backlog that keeps scrolling after the finger stops. + * on a single flick. Per-event report limits preserve the upstream wheel/touch + * scaling; a change of direction discards only movement that is still unsent. * * A TerminalTile keeps its own narrow twin (TerminalTile._queueScrollBytes, * terminal-tile.js: same 40ms window and report limits) because this queue @@ -6023,7 +5978,8 @@ Object.assign(CodemanApp.prototype, { * scrollback is never touched. Shift is excluded on purpose: it is the explicit * "give me local scrollback" gesture and must keep that meaning. * - * The FIRST event of a deliberate sub-page gesture pages at once. Waiting for half a screen of + * The FIRST event of a trackpad-sized gesture (opening under + * PAGE_KEY_IMMEDIATE_MAX_ROWS) pages at once. Waiting for half a screen of * travel (19 rows, ~475px on a 38-row pane) meant an ordinary trackpad flick * never sent a key at all. That page is pre-paid: the travel it skipped is * owed back by the rest of the gesture, so the rate stays one page per @@ -6031,8 +5987,8 @@ Object.assign(CodemanApp.prototype, { * (PAGE_KEY_GESTURE_GAP_MS, or PAGE_KEY_DEBT_MS while a same-direction page is * still owed), a reversal after the gap or a tab switch starts a new gesture; * one contact is never split by time, and a quick reversal never pre-pays. A - * wheel opening below the page threshold also pages immediately. Subsequent events - * repay the skipped travel rather than paging once per notch. + * larger opening event is a wheel notch and accumulates as before, so slow + * notches still page once per `perPage` rows. * * Consumed WITHOUT paging: a pinch (`ctrlKey`, how Chrome reports a trackpad * pinch), a mostly horizontal swipe (both deltas present, |deltaX| > |deltaY|; diff --git a/test/cli-registry-schema.test.ts b/test/cli-registry-schema.test.ts index 156d315b..e6dae7d5 100644 --- a/test/cli-registry-schema.test.ts +++ b/test/cli-registry-schema.test.ts @@ -435,3 +435,12 @@ describe('capability shapes', () => { expect(paging.sort()).toEqual(['claude', 'codex', 'opencode']); }); }); + +describe('observed mouse-tracking wheel policy', () => { + it('advertises and validates tracked Codex forwarding without a fictitious minimum version', () => { + const codex = STOCK_CLIS.find((e) => e.id === 'codex')!; + expect(codex.capabilities.wheelForward).toEqual({ mode: 'mouse-tracking' }); + expect(CliEntrySchema.safeParse(codex).success).toBe(true); + expect(codex.capabilities.transcriptPageKeys).toBe(true); + }); +}); diff --git a/test/terminal-scroll-routing.test.ts b/test/terminal-scroll-routing.test.ts index bb8efdae..786d4aae 100644 --- a/test/terminal-scroll-routing.test.ts +++ b/test/terminal-scroll-routing.test.ts @@ -36,7 +36,18 @@ function loadTerminalUiHarness( // terminal-ui.js hangs CodemanTerminalInput off window; tests read it there. const windowRef: Record<string, any> = { ...windowGlobals }; // Mutable so a test can move time (gesture gaps); defaults to a frozen clock. - const clock = { now: () => 1_000 }; + const timers = new Map<number, { due: number; fn: () => void }>(); + let timerId = 0; + const clock = { + now: () => 1_000, + flushTimers: () => { + for (const [id, timer] of timers) + if (timer.due <= clock.now()) { + timers.delete(id); + timer.fn(); + } + }, + }; const context = vm.createContext({ window: windowRef, CodemanApp, @@ -44,8 +55,12 @@ function loadTerminalUiHarness( _crashDiag: { log: vi.fn() }, performance: clock, requestAnimationFrame: (_fn: () => void) => 1, - setTimeout: (_fn: () => void) => 1, - clearTimeout: vi.fn(), + setTimeout: (fn: () => void, ms = 0) => { + const id = ++timerId; + timers.set(id, { due: clock.now() + ms, fn }); + return id; + }, + clearTimeout: (id: number) => timers.delete(id), Blob: function Blob() {}, URL: { createObjectURL: () => 'blob:yield', revokeObjectURL: () => {} }, Worker: function Worker(this: any) { @@ -206,24 +221,30 @@ describe('PageUp/PageDown fallback for a hollow local buffer (issue #205 round 2 app._maybePageCliTranscript({ shiftKey: false }, -1); // first event: one PageUp app._maybePageCliTranscript({ shiftKey: false }, 1); // reversal inside the gap: accumulates, no page now += 200; + clock.flushTimers(); app._maybePageCliTranscript({ shiftKey: false }, -1); // a reversal after the gap: a PageUp at once now += 1_100; + clock.flushTimers(); app._maybePageCliTranscript({ shiftKey: false }, 1); // after a pause: a PageDown at once app._maybePageCliTranscript({ shiftKey: false }, 0.05); // sub-row jitter in the same gesture: nothing app._flushWheelSgrQueue(); - expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[6~' }]); + expect(sent).toEqual([ + { id: 'sess-1', data: '\x1b[5~' }, + { id: 'sess-1', data: '\x1b[6~' }, + ]); }); - it('answers the first wheel notch and amortizes the page over subsequent notches', () => { - // The first 100px notch responds immediately. Later notches repay the - // pre-paid page, including when they arrive past the normal gesture gap. - for (const gapMs of [250, 40]) { + it('accumulates coarse wheel notches across pauses instead of prepaying each opening', () => { + // Coarse events accumulate as on master, even across pauses; they do not + // pre-pay a full page each time a new gesture starts. + for (const gapMs of [250, 40, 1200]) { const { app, sent, clock } = hollowApp(); let now = 1_000; clock.now = () => now; for (let i = 0; i < 5; i++) { expect(app._maybePageCliTranscript({ shiftKey: false, deltaY: -100 }, -4)).toBe(true); now += gapMs; + clock.flushTimers(); } app._flushWheelSgrQueue(); expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[5~' }]); @@ -446,7 +467,14 @@ describe('page-key gestures across slow touch drags, slow wheel ticks and jitter h.app._flushWheelSgrQueue(); return h.sent.map((s) => s.data).join(''); }; - return { ...h, keys, advance: (ms: number) => (now += ms) }; + return { + ...h, + keys, + advance: (ms: number) => { + now += ms; + h.clock.flushTimers(); + }, + }; } const count = (keys: string, key: string) => keys.split(key).length - 1; @@ -533,7 +561,7 @@ describe('page-key gestures across slow touch drags, slow wheel ticks and jitter } // The opening event is a fresh gesture and pages; no reversal inside the // gap pre-pays, so the wobble sends nothing after it. - expect(p.keys()).toBe(''); // obsolete queued direction cancelled before its flush + expect(p.keys()).toBe(''); // the opening report was cancelled before its 40ms flush }); it('still pages a deliberate reversal at once after a pause', () => { @@ -541,7 +569,7 @@ describe('page-key gestures across slow touch drags, slow wheel ticks and jitter p.app._maybePageCliTranscript({ shiftKey: false, deltaY: -25 }, -1); p.advance(200); p.app._maybePageCliTranscript({ shiftKey: false, deltaY: 25 }, 1); - expect(p.keys()).toBe(DOWN); + expect(p.keys()).toBe(UP + DOWN); }); it('does not let a reversal inherit the other direction’s debt', () => { @@ -863,28 +891,43 @@ describe('consistent scroll gesture adapters', () => { expect(app._wheelSgrQueue || '').toBe(''); }); - it('answers the first ordinary Codex wheel event, without paging every event', () => { + it('preserves accumulated coarse Codex wheel fallback travel', () => { const { app, sent } = hollowApp({ mode: 'codex', rows: 43 }); app._maybePageCliTranscript({ deltaY: -100 }, -4); app._flushWheelSgrQueue(); - expect(sent).toEqual([{ id: 'sess-1', data: '\x1b[5~' }]); + expect(sent).toEqual([]); for (let i = 0; i < 5; i++) app._maybePageCliTranscript({ deltaY: -100 }, -4); app._flushWheelSgrQueue(); expect(sent).toHaveLength(1); }); - it('bounds a sustained burst and cancels obsolete direction before transport', () => { + it('preserves sustained report density and cancels obsolete direction before transport', () => { const { coalesceScrollBytes } = loadTerminalUiHarness().windowRef.CodemanTerminalInput; const up = '\x1b[<64;1;1M'; const down = '\x1b[<65;1;1M'; let queued = ''; for (let i = 0; i < 100; i++) queued = coalesceScrollBytes(queued, up); - expect(queued).toBe(up.repeat(5)); + expect(queued).toBe(up.repeat(100)); expect(coalesceScrollBytes(queued, down)).toBe(down); - expect(coalesceScrollBytes('\x1b[5~'.repeat(3), '\x1b[5~')).toBe('\x1b[5~'.repeat(3)); + expect(coalesceScrollBytes('\x1b[5~'.repeat(3), '\x1b[5~')).toBe('\x1b[5~'.repeat(4)); expect(coalesceScrollBytes('\x1b[5~', '\x1b[6~')).toBe('\x1b[6~'); }); + it('preserves all 120 reports of a 30-frame touch fling with real batch deadlines', () => { + const { app, sent, clock } = hollowApp(); + let now = 1000; + clock.now = () => now; + app._clientPointToCell = () => ({ col: 1, row: 1 }); + for (let frame = 0; frame < 30; frame++) { + app._sendSyntheticSgrWheel(0, 0, -4); + now += 16; + clock.flushTimers(); + } + now += 40; + clock.flushTimers(); + expect(sent.map((s) => s.data).join('')).toBe('\x1b[<64;1;1M'.repeat(120)); + }); + it('does not deliver a queued wheel batch to a newly selected session', () => { const { app, sent } = hollowApp(); app._queueScrollBytes('\x1b[5~'); @@ -939,16 +982,6 @@ describe('native Codex scrolling instead of an initial page jump', () => { } }); - it('preserves the Codex dialog guard for primary and tiled routing', () => { - const { app } = hollowApp({ mode: 'codex', cliMouseTracking: true }); - app.sessions.set('tile', { mode: 'codex', cliMouseTracking: true }); - app.tabAlerts = new Map([['sess-1', 'action']]); - expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false); - expect(app._shouldForwardWheelToApp({ shiftKey: false }, { sessionId: 'tile' })).toBe(true); - app.tabAlerts.set('tile', 'action'); - expect(app._shouldForwardWheelToApp({ shiftKey: false }, { sessionId: 'tile' })).toBe(false); - }); - it('keeps local-history opt-out and native xterm mouse routing', () => { const { app } = hollowApp({ mode: 'codex', cliMouseTracking: true }); app.loadAppSettingsFromStorage = () => ({ terminalWheelLocalScrollback: true }); @@ -959,9 +992,9 @@ describe('native Codex scrolling instead of an initial page jump', () => { }); }); -describe('one native tick per wheel event', () => { +describe('native wheel travel preserves upstream units', () => { it('normalizes coarse Chrome, Firefox and page-unit wheel events without guessing the device', () => { - const { wheelDeltaNativeLines } = loadTerminalUiHarness().windowRef.CodemanTerminalInput; + const { wheelDeltaLines } = loadTerminalUiHarness().windowRef.CodemanTerminalInput; for (const [deltaY, deltaMode] of [ [100, 0], [120, 0], @@ -969,21 +1002,25 @@ describe('one native tick per wheel event', () => { [3, 1], [1, 2], ]) { - expect(wheelDeltaNativeLines({ deltaY, deltaMode }, 43)).toBe(1); - expect(wheelDeltaNativeLines({ deltaY: -deltaY, deltaMode }, 43)).toBe(-1); + expect(wheelDeltaLines({ deltaY, deltaMode }, 43)).toBe( + deltaMode === 0 ? deltaY / 25 : deltaMode === 1 ? deltaY : deltaY * 43 + ); + expect(wheelDeltaLines({ deltaY: -deltaY, deltaMode }, 43)).toBe( + -(deltaMode === 0 ? deltaY / 25 : deltaMode === 1 ? deltaY : deltaY * 43) + ); } }); it('keeps fractional touchpad distance and primary/tile normalization identical', () => { const { app, windowRef } = hollowApp({ mode: 'codex', cliMouseTracking: true }); - const { wheelDeltaNativeLines, wheelTicksForGesture } = windowRef.CodemanTerminalInput; + const { wheelDeltaLines, wheelTicksForGesture } = windowRef.CodemanTerminalInput; let state; let ticks = 0; for (let i = 0; i < 10; i++) { const ev = { deltaY: -3, deltaMode: 0 }; - const lines = wheelDeltaNativeLines(ev, 43); + const lines = wheelDeltaLines(ev, 43); expect(lines).toBe(-0.12); - expect(app._wheelScrollNativeLines(ev)).toBe(lines); + expect(app._wheelScrollLinesFloat(ev)).toBe(lines); const step = wheelTicksForGesture(state, lines, i * 10); state = step.state; ticks += step.ticks; diff --git a/test/terminal-tile-scroll.test.ts b/test/terminal-tile-scroll.test.ts index aca5d7e2..e403132a 100644 --- a/test/terminal-tile-scroll.test.ts +++ b/test/terminal-tile-scroll.test.ts @@ -319,11 +319,11 @@ describe('a fullscreen Claude tile forwards the wheel to the CLI as SGR reports' expect(up.preventDefault).toHaveBeenCalled(); expect(up.stopPropagation).toHaveBeenCalled(); - expect(ws.inputFrames()).toEqual([{ t: 'i', d: UP }]); // first report never waits for a timer - expect(flushed(ws)).toEqual([{ t: 'i', d: UP }]); // no seq: never persisted + expect(ws.inputFrames()).toEqual([{ t: 'i', d: UP.repeat(3) }]); // first report never waits for a timer + expect(flushed(ws)).toEqual([{ t: 'i', d: UP.repeat(3) }]); // no seq: never persisted mount.fire('wheel', wheelLines(2, AT)); - expect(flushed(ws).at(-1)).toEqual({ t: 'i', d: DOWN }); + expect(flushed(ws).at(-1)).toEqual({ t: 'i', d: DOWN.repeat(2) }); }); it('uses fractional native scrolling for a tracked Codex tile, never PageUp', async () => { @@ -334,14 +334,14 @@ describe('a fullscreen Claude tile forwards the wheel to the CLI as SGR reports' expect(flushed(ws)).toEqual([{ t: 'i', d: UP }]); }); - it('normalizes a pixel wheel event to one native tick, including accelerated deltas', async () => { + it('preserves coarse pixel wheel travel and caps individual accelerated events', async () => { const { ws, mount } = await connectTile(makeApp({ 's-tile': FULLSCREEN }), { mode: 'claude' }); mount.fire('wheel', wheel(-100, AT)); - expect(flushed(ws)).toEqual([{ t: 'i', d: UP }]); + expect(flushed(ws)).toEqual([{ t: 'i', d: UP.repeat(4) }]); mount.fire('wheel', wheelLines(-1000, AT)); - expect(flushed(ws).at(-1)).toEqual({ t: 'i', d: UP }); + expect(flushed(ws).at(-1)).toEqual({ t: 'i', d: UP.repeat(5) }); }); it('accumulates precision touchpad deltas and consumes a pure horizontal swipe without sending', async () => { @@ -366,7 +366,18 @@ describe('a fullscreen Claude tile forwards the wheel to the CLI as SGR reports' mount.fire('wheel', wheelLines(-2, AT)); mount.fire('wheel', wheelLines(-2, AT)); - expect(ws.inputFrames()).toEqual([{ t: 'i', d: UP }]); + expect(ws.inputFrames()).toEqual([{ t: 'i', d: UP.repeat(2) }]); + expect(flushed(ws)).toEqual([ + { t: 'i', d: UP.repeat(2) }, + { t: 'i', d: UP.repeat(4) }, + ]); + }); + + it('does not discard queued reports when a zero-delta wheel arrives', async () => { + const { ws, mount } = await connectTile(makeApp({ 's-tile': FULLSCREEN }), { mode: 'claude' }); + mount.fire('wheel', wheelLines(-1, AT)); + mount.fire('wheel', wheelLines(-2, AT)); + mount.fire('wheel', wheel(0, AT)); expect(flushed(ws)).toEqual([ { t: 'i', d: UP }, { t: 'i', d: UP.repeat(2) }, @@ -451,14 +462,14 @@ describe('a fullscreen Claude tile forwards the wheel to the CLI as SGR reports' const primary = makeApp({ other: FULLSCREEN }, 'other') as App & { terminal: unknown; _sendSyntheticSgrWheel(x: number, y: number, lines: number): void; - _wheelScrollNativeLines(ev: unknown): number; + _wheelScrollLinesFloat(ev: unknown): number; _flushWheelSgrQueue(): void; }; const sent: string[] = []; primary._sendInputEphemeral = (_id: string, data: string) => sent.push(data); primary.terminal = new FakeTerminal({}); for (const ev of sequence) { - primary._sendSyntheticSgrWheel(ev.clientX, ev.clientY, primary._wheelScrollNativeLines(ev)); + primary._sendSyntheticSgrWheel(ev.clientX, ev.clientY, primary._wheelScrollLinesFloat(ev)); primary._flushWheelSgrQueue(); } @@ -546,11 +557,11 @@ describe('page-key travel, cap and coalescing', () => { FakeFit.proposed = { cols: 80, rows: 36 }; }); - it('answers sub-page travel immediately and repays it on the next event', async () => { + it('accumulates coarse sub-page travel before sending one page', async () => { const { ws, mount } = await connectTile(makeApp({ 's-tile': { mode: 'opencode' } })); mount.fire('wheel', wheelLines(-10)); - expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]); + expect(flushed(ws)).toEqual([]); mount.fire('wheel', wheelLines(-10)); expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]); @@ -614,6 +625,7 @@ describe("gesture handling is the primary pane's, through the shared pure helper clock.now = () => now; return (ms: number) => { now += ms; + vi.advanceTimersByTime(ms); }; } @@ -750,7 +762,7 @@ describe("gesture handling is the primary pane's, through the shared pure helper .map((f) => f.d) .join(''); // One page for the slow ticks, one for the wobble's opening event, nothing more. - expect(tileBytes).toBe(''); // rapid reversals cancel unsent movement + expect(tileBytes).toBe(PAGE_UP); // the first slow tick flushed before the later wobble const primary = makeApp({ other: { mode: 'codex' } }, 'other') as App & { terminal: unknown; @@ -909,7 +921,7 @@ describe('rows the tile pushed above the screen itself are not history', () => { const first = wheelLines(-6); mount.fire('wheel', first); expect(first.preventDefault).toHaveBeenCalled(); - expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]); + expect(flushed(ws)).toEqual([]); mount.fire('wheel', wheelLines(-6)); expect(flushed(ws)).toEqual([{ t: 'i', d: PAGE_UP }]); }); diff --git a/test/terminal-touch-tap.test.ts b/test/terminal-touch-tap.test.ts index 66a25cab..3cf86a49 100644 --- a/test/terminal-touch-tap.test.ts +++ b/test/terminal-touch-tap.test.ts @@ -738,17 +738,17 @@ describe('terminal touch tap mouse guard', () => { app.terminal = { rows: 40 }; // DOM_DELTA_PIXEL (Chrome/WebKit, and every trackpad): ~110px per notch. - expect(app._wheelScrollLines({ deltaY: 110, deltaX: 0, deltaMode: 0, shiftKey: false })).toBe(4); + expect(app._wheelScrollLinesFloat({ deltaY: 110, deltaX: 0, deltaMode: 0, shiftKey: false })).toBe(4.4); // DOM_DELTA_LINE (Firefox mouse wheel): deltaY is already lines. Read as // pixels this rounded to 0 and fell through to the ±1 fallback. - expect(app._wheelScrollLines({ deltaY: 3, deltaX: 0, deltaMode: 1, shiftKey: false })).toBe(3); - expect(app._wheelScrollLines({ deltaY: -3, deltaX: 0, deltaMode: 1, shiftKey: false })).toBe(-3); + expect(app._wheelScrollLinesFloat({ deltaY: 3, deltaX: 0, deltaMode: 1, shiftKey: false })).toBe(3); + expect(app._wheelScrollLinesFloat({ deltaY: -3, deltaX: 0, deltaMode: 1, shiftKey: false })).toBe(-3); // DOM_DELTA_PAGE: one page is one screenful. - expect(app._wheelScrollLines({ deltaY: 1, deltaX: 0, deltaMode: 2, shiftKey: false })).toBe(40); + expect(app._wheelScrollLinesFloat({ deltaY: 1, deltaX: 0, deltaMode: 2, shiftKey: false })).toBe(40); // A pure horizontal swipe must not fall through to a phantom -1. - expect(app._wheelScrollLines({ deltaY: 0, deltaX: 90, deltaMode: 0, shiftKey: false })).toBe(0); + expect(app._wheelScrollLinesFloat({ deltaY: 0, deltaX: 90, deltaMode: 0, shiftKey: false })).toBe(0); // Shift + macOS trackpad reports the magnitude on deltaX (issue #154). - expect(app._wheelScrollLines({ deltaY: 0, deltaX: -100, deltaMode: 0, shiftKey: true })).toBe(-4); + expect(app._wheelScrollLinesFloat({ deltaY: 0, deltaX: -100, deltaMode: 0, shiftKey: true })).toBe(-4); }); it('wheel: gates claude forwarding on CLI version 2.1.187+ (unknown or older stays local)', () => { @@ -838,19 +838,19 @@ describe('terminal touch tap mouse guard', () => { const { app } = loadTerminalUiHarness(); // Plain vertical wheel: unchanged, driven by deltaY. - expect(app._wheelScrollLines({ shiftKey: false, deltaX: 0, deltaY: 100 })).toBe(4); - expect(app._wheelScrollLines({ shiftKey: false, deltaX: 0, deltaY: -50 })).toBe(-2); + expect(app._wheelScrollLinesFloat({ shiftKey: false, deltaX: 0, deltaY: 100 })).toBe(4); + expect(app._wheelScrollLinesFloat({ shiftKey: false, deltaX: 0, deltaY: -50 })).toBe(-2); // Shift on a macOS trackpad: deltaY≈0, deltaX carries direction+magnitude. // Old code collapsed this to a fixed -1; now it tracks the horizontal delta. - expect(app._wheelScrollLines({ shiftKey: true, deltaX: -100, deltaY: 0 })).toBe(-4); // scroll up - expect(app._wheelScrollLines({ shiftKey: true, deltaX: 75, deltaY: 0 })).toBe(3); // scroll down + expect(app._wheelScrollLinesFloat({ shiftKey: true, deltaX: -100, deltaY: 0 })).toBe(-4); // scroll up + expect(app._wheelScrollLinesFloat({ shiftKey: true, deltaX: 75, deltaY: 0 })).toBe(3); // scroll down // Shift with a real vertical wheel (mouse): deltaY dominates, deltaX ignored. - expect(app._wheelScrollLines({ shiftKey: true, deltaX: 2, deltaY: 100 })).toBe(4); + expect(app._wheelScrollLinesFloat({ shiftKey: true, deltaX: 2, deltaY: 100 })).toBe(4); - // Sub-25px delta still nudges one line in the gesture's direction. - expect(app._wheelScrollLines({ shiftKey: true, deltaX: -5, deltaY: 0 })).toBe(-1); + // Sub-25px deltas retain their fractional travel. + expect(app._wheelScrollLinesFloat({ shiftKey: true, deltaX: -5, deltaY: 0 })).toBe(-0.2); }); it('wheel: encodes SGR 64/65 ticks, caps per event, and coalesces into one flush', () => { From 08e57ed7084835c48f544b021058e32b1fcfae35 Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Thu, 15 Oct 2026 11:18:57 +0800 Subject: [PATCH 07/96] feat(file-viewer): resize and expand the window, back/forward between previewed files --- .changeset/file-viewer-window.md | 5 + src/web/public/index.html | 4 + src/web/public/panels-ui.js | 147 ++++++++++++++++++++- src/web/public/styles.css | 42 +++++- test/file-preview-window.test.ts | 220 +++++++++++++++++++++++++++++++ 5 files changed, 413 insertions(+), 5 deletions(-) create mode 100644 .changeset/file-viewer-window.md create mode 100644 test/file-preview-window.test.ts diff --git a/.changeset/file-viewer-window.md b/.changeset/file-viewer-window.md new file mode 100644 index 00000000..e3b3ae98 --- /dev/null +++ b/.changeset/file-viewer-window.md @@ -0,0 +1,5 @@ +--- +'aicodeman': minor +--- + +The File Viewer window can be resized and expanded, and jumping between files now has Back and Forward. Drag the corner grip to resize it, or use the Expand button to fill the screen (remembered across opens). Following a link from one document to another adds it to a list that the ← and → buttons, or Alt+← and Alt+→, walk through; opening something new from the middle of the list drops the forward entries, and closing the viewer clears it. Leaving unsaved edits still asks first. diff --git a/src/web/public/index.html b/src/web/public/index.html index abbd2cd9..46ac8247 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -614,6 +614,8 @@ <div class="file-preview-header"> <span class="file-preview-title" id="filePreviewTitle">file.ts</span> <div class="file-preview-actions"> + <button class="btn-icon-sm" id="filePreviewBackBtn" onclick="app.stepFilePreviewHistory(-1)" title="Back (Alt+←)" aria-label="Back to the previous file" hidden disabled>←</button> + <button class="btn-icon-sm" id="filePreviewForwardBtn" onclick="app.stepFilePreviewHistory(1)" title="Forward (Alt+→)" aria-label="Forward to the next file" hidden disabled>→</button> <button class="btn-icon-sm" id="filePreviewPrevBtn" onclick="app.stepFolderPreview(-1)" title="Previous file in folder (←)" aria-label="Previous file in folder" hidden>‹</button> <button class="btn-icon-sm" id="filePreviewNextBtn" onclick="app.stepFolderPreview(1)" title="Next file in folder (→)" aria-label="Next file in folder" hidden>›</button> <button class="btn-icon-sm" id="filePreviewFolderBtn" onclick="app.openFolderOfPreview()" title="Show folder" aria-label="Show folder" hidden><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg></button> @@ -624,6 +626,7 @@ <button class="btn-icon-sm file-preview-edit-btn" id="filePreviewEditBtn" onclick="app.enterFilePreviewEdit()" title="Edit file" aria-label="Edit file" hidden><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M17 3a2.85 2.83 0 1 1 4 4L7.5 20.5 2 22l1.5-5.5z"/></svg></button> <button class="btn-icon-sm" onclick="app.copyFilePreviewContent()" title="Copy content">⎘</button> <button class="btn-icon-sm" id="filePreviewDetachBtn" onclick="app.detachFilePreview()" title="Open in new tab" aria-label="Open in new tab" hidden><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/><polyline points="15 3 21 3 21 9"/><line x1="10" y1="14" x2="21" y2="3"/></svg></button> + <button class="btn-icon-sm" id="filePreviewExpandBtn" onclick="app.toggleFilePreviewExpanded()" title="Expand" aria-label="Expand" aria-pressed="false"><svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="15 3 21 3 21 9"/><polyline points="9 21 3 21 3 15"/><line x1="21" y1="3" x2="14" y2="10"/><line x1="3" y1="21" x2="10" y2="14"/></svg></button> <button class="btn-icon-sm" onclick="app.closeFilePreview()" title="Close">×</button> </div> </div> @@ -635,6 +638,7 @@ <button class="file-preview-editbar-btn file-preview-editbar-btn--save" id="filePreviewSaveBtn" onclick="app.saveFilePreviewEdit()" disabled>Save</button> </div> <div class="file-preview-footer" id="filePreviewFooter"></div> + <div class="file-preview-resize" id="filePreviewResize" title="Drag to resize" aria-hidden="true"></div> </div> </div> diff --git a/src/web/public/panels-ui.js b/src/web/public/panels-ui.js index fa7f6bb0..5d5d4f97 100644 --- a/src/web/public/panels-ui.js +++ b/src/web/public/panels-ui.js @@ -33,8 +33,11 @@ const FILE_PREVIEW_PREF_KEYS = { htmlRendered: 'codeman:filePreviewHtmlRendered', lineNumbers: 'codeman:filePreviewLineNumbers', wrap: 'codeman:filePreviewWrap', + expanded: 'codeman:filePreviewExpanded', }; -const FILE_PREVIEW_PREF_DEFAULTS = { mdRendered: true, htmlRendered: true, lineNumbers: false, wrap: true }; +const FILE_PREVIEW_PREF_DEFAULTS = { mdRendered: true, htmlRendered: true, lineNumbers: false, wrap: true, expanded: false }; +// Back/forward list of previews opened from one overlay session (links between documents). +const FILE_PREVIEW_HISTORY_MAX = 50; const AWAY_DIGEST_SECTIONS = [ ['needsAttention', 'Needs Attention'], ['completed', 'Completed'], @@ -4108,6 +4111,9 @@ Object.assign(CodemanApp.prototype, { if (!overlay || !bodyEl) return; + this._recordFilePreviewHistory(filePath, sessionId, attachmentId, options); + this._bindFilePreviewChrome(); + // Only the newest load may write: paging ←/→ through a folder starts the next // load while the previous one still awaits its fetch, and its late answer would // put the previous file's text under the next file's title. @@ -4133,6 +4139,7 @@ Object.assign(CodemanApp.prototype, { // Show overlay with loading state overlay.classList.add('visible'); + this._applyFilePreviewExpanded(); titleEl.textContent = filePath; bodyEl.innerHTML = '<div class="binary-message">Loading...</div>'; footerEl.textContent = ''; @@ -4537,6 +4544,142 @@ Object.assign(CodemanApp.prototype, { this._openFolderGridFile(next); }, + // ═══════════════════════════════════════════════════════════════ + // File Viewer window: back/forward between previews, expand, resize + // ═══════════════════════════════════════════════════════════════ + + /** + * Remember a preview in the back/forward list. A navigation that came FROM the + * list (stepFilePreviewHistory) is not recorded again, and neither is a reload + * of the file already showing (the MD/Page toggles reopen the same file). Opening + * something new while standing mid-list drops the forward entries, as a browser does. + */ + _recordFilePreviewHistory(filePath, sessionId, attachmentId, options) { + const h = (this.filePreviewHistory = this.filePreviewHistory || { entries: [], index: -1 }); + if (!this._filePreviewHistoryStepping) { + const cur = h.entries[h.index]; + const same = + cur && cur.filePath === filePath && cur.sessionId === sessionId && (cur.attachmentId || null) === (attachmentId || null); + if (!same) { + h.entries.length = h.index + 1; + h.entries.push({ filePath, sessionId, attachmentId: attachmentId || null, options }); + if (h.entries.length > FILE_PREVIEW_HISTORY_MAX) h.entries.shift(); + h.index = h.entries.length - 1; + } + } + this._updateFilePreviewHistoryNav(); + }, + + /** Back (-1) / forward (+1) through the previews opened in this overlay session. */ + stepFilePreviewHistory(delta) { + const h = this.filePreviewHistory; + if (!h) return; + const target = h.index + delta; + if (target < 0 || target >= h.entries.length) return; + if (this.filePreviewEdit?.dirty && !confirm('Discard unsaved changes?')) return; + h.index = target; + const e = h.entries[target]; + // openFilePreview records synchronously, before its first await, so the flag + // only needs to cover the call itself. + this._filePreviewHistoryStepping = true; + try { + return this.openFilePreview(e.filePath, e.sessionId, e.attachmentId, e.options || {}); + } finally { + this._filePreviewHistoryStepping = false; + } + }, + + /** Buttons show once there is somewhere to go; each is disabled at its end of the list. */ + _updateFilePreviewHistoryNav() { + const h = this.filePreviewHistory; + const multi = !!h && h.entries.length > 1; + const back = this.$('filePreviewBackBtn'); + const fwd = this.$('filePreviewForwardBtn'); + if (back) { + back.hidden = !multi; + back.disabled = !multi || h.index <= 0; + } + if (fwd) { + fwd.hidden = !multi; + fwd.disabled = !multi || h.index >= h.entries.length - 1; + } + }, + + /** Expand button: fill the screen, remembered across opens. */ + toggleFilePreviewExpanded() { + this._setFilePreviewPref('expanded', !this._filePreviewPref('expanded')); + this._applyFilePreviewExpanded(); + }, + + _applyFilePreviewExpanded() { + const on = this._filePreviewPref('expanded'); + const win = this.$('filePreviewOverlay')?.querySelector?.('.file-preview-window'); + win?.classList?.toggle('expanded', on); + const btn = this.$('filePreviewExpandBtn'); + if (btn) { + btn.setAttribute('aria-pressed', on ? 'true' : 'false'); + const label = on ? 'Restore size' : 'Expand'; + btn.title = label; + btn.setAttribute('aria-label', label); + } + }, + + /** + * One-time wiring for the window chrome: Alt+←/→ for back/forward (capture phase + * so the terminal behind the overlay never types the arrow) and the corner grip. + */ + _bindFilePreviewChrome() { + if (this._filePreviewChromeBound) return; + this._filePreviewChromeBound = true; + document.addEventListener( + 'keydown', + (ev) => { + if (!ev.altKey || ev.ctrlKey || ev.metaKey || ev.shiftKey) return; + if (ev.key !== 'ArrowLeft' && ev.key !== 'ArrowRight') return; + if (!this.$('filePreviewOverlay')?.classList.contains('visible')) return; + const t = ev.target; + if (t && (t.isContentEditable || (/^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName) && !t.classList?.contains('xterm-helper-textarea')))) return; + const h = this.filePreviewHistory; + const delta = ev.key === 'ArrowLeft' ? -1 : 1; + if (!h || h.index + delta < 0 || h.index + delta >= h.entries.length) return; + ev.preventDefault(); + ev.stopPropagation(); + this.stepFilePreviewHistory(delta); + }, + true + ); + this._bindFilePreviewResize(); + }, + + /** Drag the corner grip to size the window; the window is centred, so the delta counts twice. */ + _bindFilePreviewResize() { + const grip = this.$('filePreviewResize'); + const win = grip?.parentElement; + if (!grip || !win) return; + grip.addEventListener('pointerdown', (ev) => { + ev.preventDefault(); + const startX = ev.clientX; + const startY = ev.clientY; + const startW = win.offsetWidth; + const startH = win.offsetHeight; + grip.setPointerCapture?.(ev.pointerId); + const move = (e) => { + const w = Math.max(320, Math.min(window.innerWidth * 0.96, startW + 2 * (e.clientX - startX))); + const h = Math.max(240, Math.min(window.innerHeight * 0.94, startH + 2 * (e.clientY - startY))); + win.style.width = `${Math.round(w)}px`; + win.style.height = `${Math.round(h)}px`; + }; + const up = () => { + grip.removeEventListener('pointermove', move); + grip.removeEventListener('pointerup', up); + grip.removeEventListener('pointercancel', up); + }; + grip.addEventListener('pointermove', move); + grip.addEventListener('pointerup', up); + grip.addEventListener('pointercancel', up); + }); + }, + /** "Show folder" in the preview: the grid of the previewed file's folder. */ openFolderOfPreview() { const target = this.filePreviewFolderTarget; @@ -4617,6 +4760,8 @@ Object.assign(CodemanApp.prototype, { // Back on the grid (if one is open underneath): its current tile stays marked. if (this.folderGrid) this.folderGrid.index = null; this._updateFolderPreviewNav(null); + this.filePreviewHistory = null; + this._updateFilePreviewHistoryNav(); const overlay = this.$('filePreviewOverlay'); if (overlay) { overlay.classList.remove('visible'); diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 624364d9..4f3b926e 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -12223,10 +12223,15 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat } .file-preview-window { - width: 80vw; - max-width: 900px; - height: 80vh; - max-height: 700px; + position: relative; + width: min(80vw, 900px); + height: min(80vh, 700px); + /* A dragged size (inline width/height) and the expanded state may exceed the + default 900x700, but never the screen. */ + max-width: 96vw; + max-height: 94vh; + min-width: min(320px, 96vw); + min-height: min(240px, 94vh); background: var(--floating-bg); backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px); @@ -12238,6 +12243,35 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat overflow: hidden; } +/* Expanded: fills the screen (a dragged size is ignored while it is on). */ +.file-preview-window.expanded { + width: 96vw !important; + height: 94vh !important; +} + +/* Corner grip (panels-ui.js _bindFilePreviewResize). The window is centred, so a + drag moves both edges: the handler doubles the pointer delta to keep the corner + under the pointer. Touch devices use the Expand button instead. */ +.file-preview-resize { + position: absolute; + right: 0; + bottom: 0; + width: 16px; + height: 16px; + cursor: nwse-resize; + touch-action: none; + background: linear-gradient(135deg, transparent 50%, var(--text-muted, #888) 50%, var(--text-muted, #888) 58%, transparent 58%, transparent 72%, var(--text-muted, #888) 72%, var(--text-muted, #888) 80%, transparent 80%); + opacity: 0.6; +} + +.file-preview-resize:hover { + opacity: 1; +} + +.file-preview-window.expanded .file-preview-resize { + display: none; +} + .file-preview-header { display: flex; align-items: center; diff --git a/test/file-preview-window.test.ts b/test/file-preview-window.test.ts new file mode 100644 index 00000000..cbbb2858 --- /dev/null +++ b/test/file-preview-window.test.ts @@ -0,0 +1,220 @@ +/** + * @fileoverview File Viewer window: back/forward between previews, expand, resize. + * + * Links between documents open the next file in the same overlay with no way back. + * Pinned here: + * 1. jumping between files fills a back/forward list and the buttons reveal, + * 2. back/forward reopen the right file WITHOUT recording a new entry, + * 3. opening a file mid-list drops the forward entries, + * 4. reopening the file already showing (MD/Page toggles) adds nothing, + * 5. closing the preview clears the list and hides the buttons, + * 6. unsaved edits ask before leaving, + * 7. Expand toggles the window class and the pref, and is re-applied on open, + * 8. the markup carries the buttons and the resize grip. + * + * Loaded via `vm` against a stub app, same harness style as file-preview-detach.test.ts. + */ + +import { readFileSync } from 'node:fs'; +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'); +const panelsJs = readFileSync(resolve(PUBLIC, 'panels-ui.js'), 'utf8'); +const indexHtml = readFileSync(resolve(PUBLIC, 'index.html'), 'utf8'); + +function btn() { + const attrs: Record<string, string> = {}; + return { + hidden: true, + disabled: true, + title: '', + setAttribute: (k: string, v: string) => (attrs[k] = v), + attrs, + }; +} + +function loadApp(confirmResult = true) { + const CodemanApp = function CodemanApp(this: unknown) {} as unknown as new () => Record<string, unknown>; + const store: Record<string, string> = {}; + const context = vm.createContext({ + CodemanApp, + console: { ...console, warn: vi.fn(), error: vi.fn() }, + localStorage: { + getItem: (k: string) => store[k] ?? null, + setItem: (k: string, v: string) => (store[k] = v), + removeItem: () => {}, + }, + escapeHtml: (s: string) => String(s), + CodemanBase: { base: '', url: (p: string) => p }, + rendersAsPage: () => false, + document: { getElementById: () => null, addEventListener: vi.fn() }, + window: { addEventListener: vi.fn(), open: vi.fn() }, + setTimeout, + clearTimeout, + confirm: vi.fn(() => confirmResult), + fetch: () => { + throw new Error('fetch not stubbed'); + }, + }); + vm.runInContext(panelsJs, context, { filename: 'panels-ui.js' }); + + const winClasses = new Set<string>(); + const windowEl = { + classList: { + toggle: (c: string, on: boolean) => (on ? winClasses.add(c) : winClasses.delete(c)), + contains: (c: string) => winClasses.has(c), + }, + }; + const overlayClasses = new Set<string>(['visible']); + const overlay = { + classList: { + add: (c: string) => overlayClasses.add(c), + remove: (c: string) => overlayClasses.delete(c), + contains: (c: string) => overlayClasses.has(c), + }, + querySelector: () => windowEl, + }; + const back = btn(); + const fwd = btn(); + const expand = btn(); + const elements: Record<string, unknown> = { + filePreviewBody: { innerHTML: '', querySelectorAll: () => [], querySelector: () => null }, + filePreviewOverlay: overlay, + filePreviewTitle: { textContent: '' }, + filePreviewFooter: { textContent: '' }, + filePreviewDetachBtn: { hidden: true }, + filePreviewBackBtn: back, + filePreviewForwardBtn: fwd, + filePreviewExpandBtn: expand, + }; + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const app = new CodemanApp() as Record<string, any>; + app.$ = (id: string) => elements[id] ?? null; + app._resetFilePreviewEdit = () => {}; + app._isExternalPreviewPath = () => false; + app.showToast = vi.fn(); + app.filePreviewContent = ''; + const confirmFn = (context as { confirm: ReturnType<typeof vi.fn> }).confirm; + return { app, overlay, back, fwd, expand, winClasses, store, confirmFn }; +} + +describe('file viewer back/forward', () => { + it('stays hidden for one file and reveals once a second one is opened', async () => { + const { app, back, fwd } = loadApp(); + await app.openFilePreview('/ws/a.pdf', 's1'); + expect(back.hidden).toBe(true); + + await app.openFilePreview('/ws/b.pdf', 's1'); + expect(back.hidden).toBe(false); + expect(back.disabled).toBe(false); + expect(fwd.hidden).toBe(false); + expect(fwd.disabled).toBe(true); + }); + + it('steps back and forward without recording the step as a new entry', async () => { + const { app, back, fwd } = loadApp(); + await app.openFilePreview('/ws/a.pdf', 's1'); + await app.openFilePreview('/ws/b.pdf', 's1'); + await app.openFilePreview('/ws/c.pdf', 's1'); + + await app.stepFilePreviewHistory(-1); + expect(app.filePreviewHistory.entries.map((e: { filePath: string }) => e.filePath)).toEqual([ + '/ws/a.pdf', + '/ws/b.pdf', + '/ws/c.pdf', + ]); + expect(app.filePreviewHistory.index).toBe(1); + expect(app.filePreviewDetachUrl).toContain(encodeURIComponent('/ws/b.pdf')); + expect(fwd.disabled).toBe(false); + + await app.stepFilePreviewHistory(-1); + expect(back.disabled).toBe(true); + await app.stepFilePreviewHistory(-1); // at the start: a no-op + expect(app.filePreviewHistory.index).toBe(0); + + await app.stepFilePreviewHistory(1); + expect(app.filePreviewHistory.index).toBe(1); + }); + + it('drops the forward entries when a new file is opened mid-list', async () => { + const { app } = loadApp(); + await app.openFilePreview('/ws/a.pdf', 's1'); + await app.openFilePreview('/ws/b.pdf', 's1'); + await app.stepFilePreviewHistory(-1); + await app.openFilePreview('/ws/c.pdf', 's1'); + + expect(app.filePreviewHistory.entries.map((e: { filePath: string }) => e.filePath)).toEqual([ + '/ws/a.pdf', + '/ws/c.pdf', + ]); + expect(app.filePreviewHistory.index).toBe(1); + }); + + it('does not add an entry when the file already showing is reopened', async () => { + const { app } = loadApp(); + await app.openFilePreview('/ws/a.pdf', 's1'); + await app.openFilePreview('/ws/a.pdf', 's1'); + expect(app.filePreviewHistory.entries).toHaveLength(1); + }); + + it('clears the list and hides the buttons on close', async () => { + const { app, back, fwd } = loadApp(); + await app.openFilePreview('/ws/a.pdf', 's1'); + await app.openFilePreview('/ws/b.pdf', 's1'); + app.closeFilePreview(); + + expect(app.filePreviewHistory).toBeNull(); + expect(back.hidden).toBe(true); + expect(fwd.hidden).toBe(true); + }); + + it('asks before leaving unsaved edits and stays put when declined', async () => { + const { app, confirmFn } = loadApp(false); + await app.openFilePreview('/ws/a.pdf', 's1'); + await app.openFilePreview('/ws/b.pdf', 's1'); + app.filePreviewEdit = { dirty: true }; + + await app.stepFilePreviewHistory(-1); + + expect(confirmFn).toHaveBeenCalled(); + expect(app.filePreviewHistory.index).toBe(1); + }); +}); + +describe('file viewer expand', () => { + it('toggles the window class, the pref and the button state', () => { + const { app, winClasses, store, expand } = loadApp(); + + app.toggleFilePreviewExpanded(); + expect(winClasses.has('expanded')).toBe(true); + expect(store['codeman:filePreviewExpanded']).toBe('1'); + expect(expand.attrs['aria-pressed']).toBe('true'); + expect(expand.title).toBe('Restore size'); + + app.toggleFilePreviewExpanded(); + expect(winClasses.has('expanded')).toBe(false); + expect(expand.attrs['aria-pressed']).toBe('false'); + }); + + it('re-applies a remembered expanded state when a preview opens', async () => { + const { app, winClasses, store } = loadApp(); + store['codeman:filePreviewExpanded'] = '1'; + + await app.openFilePreview('/ws/a.pdf', 's1'); + + expect(winClasses.has('expanded')).toBe(true); + }); +}); + +describe('file viewer window markup', () => { + it('carries the back, forward and expand buttons and the resize grip', () => { + for (const id of ['filePreviewBackBtn', 'filePreviewForwardBtn', 'filePreviewExpandBtn', 'filePreviewResize']) { + expect(indexHtml).toContain(`id="${id}"`); + } + expect(indexHtml).toContain('app.stepFilePreviewHistory(-1)'); + expect(indexHtml).toContain('app.stepFilePreviewHistory(1)'); + }); +}); From 1b6c2664b69f5681110b8cfc206fb6aed26fe3f3 Mon Sep 17 00:00:00 2001 From: Saqeb Akhter <saqeb.akhter@gmail.com> Date: Sun, 18 Oct 2026 13:59:28 +0400 Subject: [PATCH 08/96] fix(terminal): prepay isolated small wheel ticks without amplifying streams Address the remaining PR #645 review: preserve shell Ctrl/sideways local scrolling, restore the paging threshold export, document tile leading-report lifetime, and update internal and user scrolling documentation. Refs: https://github.com/johoja12/Codeman/issues/7 --- docs/architecture-invariants.md | 4 +- docs/wiki/Agent-CLIs.md | 6 +- docs/wiki/The-Dashboard.md | 8 +- docs/wiki/Troubleshooting.md | 4 +- src/web/public/terminal-tile.js | 2 + src/web/public/terminal-ui.js | 28 +++-- test/terminal-scroll-routing.test.ts | 52 ++++++++- test/terminal-tile-scroll.browser.test.ts | 131 +++++++++++++++++++++- test/terminal-tile-scroll.test.ts | 26 ++++- 9 files changed, 232 insertions(+), 29 deletions(-) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index fe8f5167..a24c9250 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -229,9 +229,9 @@ Further detail: the `<prefix>: <title>` form (`w3-myapp: fix the login redirect` Codex 0.163.0 approval check in that isolated read-only/on-request session: the "Would you like to run the following command?" prompt remained on `1. Yes, proceed` after SGR wheel-up and wheel-down over both the transcript and approval option rows 36/37; the requested marker file was not created. The background transcript can scroll while the approval prompt remains fixed. Codex has no Claude hook alert, so a `tabAlerts === 'action'` test cannot detect this prompt and is not a native-wheel safety gate. Native Codex owns its approval selection; do not mistake a hook-only unit fixture for approval-dialog evidence. -Native wheel travel retains upstream pixel/line/page units (100 px = 4 lines; Firefox 3-line events = 3 lines); no one-tick-per-event policy is introduced here. Fractional deltas accumulate within one recent direction, rather than rounding every tiny event up. The first native report of each tab visit bypasses the 40 ms batching timer; later reports coalesce and only unsent bytes can be cancelled on reversal. Every queued batch belongs to its originating session, so switching tabs cannot send its bytes into the newly selected pane. Zero-delta events leave pending movement untouched. Tiles use the same helpers and their own queue/session. +Native wheel travel retains upstream pixel/line/page units (100 px = 4 lines; Firefox 3-line events = 3 lines); no one-tick-per-event policy is introduced here. A fresh native gesture at or above 0.1 row (2.5px) pre-pays one tick when its opening travel is under a row, so an isolated 4px mouse click responds. The signed remainder is debt repaid by subsequent same-direction events: ten 2.5px events still total one tick. Debt/fractions expire after a 150ms gap; a reversal inside that gap discards the old debt without another pre-payment. Sub-threshold jitter does not pre-pay. The first native report of each tab visit bypasses the 40 ms batching timer; later reports coalesce and only unsent bytes can be cancelled on reversal. Every queued batch belongs to its originating session, so switching tabs cannot send its bytes into the newly selected pane. Zero-delta events leave pending movement untouched. Tiles use the same helpers and their own queue/session. -**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`. +**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 or tracked Codex, with observed `cliMouseTracking`; see the native measurement above), the plain wheel AND touch drags forward as coalesced SGR reports (`_forwardScrollToApp` → `_sendSyntheticSgrWheel`, 40ms batches after the first native report, 5-tick per-event cap; no aggregate byte/tick limit). 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). `_wheelScrollLinesFloat()` 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 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()`: the `transcriptPageKeys` capability, 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). ⚠️ The first event of a trackpad-sized gesture (opening under 2 rows) pages AT ONCE and the travel it skipped is owed back by the rest of the gesture (one page per half screen still). Waiting for half a screen first meant an ordinary trackpad flick (well under 19 rows on a 38-row pane) sent nothing at all. A larger opening event is a mouse-wheel notch (100 px is 4 rows) and accumulates as before, or slow notches, each its own gesture past the gap, would page a full screen apiece. ⚠️ What starts a NEW gesture (and so may pre-pay) is narrow, because every boundary is another free page: (1) a TOUCH CONTACT is one gesture, never split by time: the touch paths tag every call, touchmove and momentum frames alike, with `touchId` (one per touchstart), and a different id (or a wheel event after a touch) is a boundary. touchmove only calls once per whole cell of finger travel, so a slow drag arrives as single lines further apart than any gap; on the gap alone 12 lines at 170 ms each sent 12 PageUps (measured through the real listeners, `test/terminal-touch-paging.browser.test.ts`). (2) Outside a contact, 150 ms of quiet (`PAGE_KEY_GESTURE_GAP_MS`), stretched to one second (`PAGE_KEY_DEBT_MS`) while a pre-paid page is still owed in the SAME direction, so slow small wheel ticks (40 px every 250 ms, a Firefox 1-line notch) pay the debt back instead of each pre-paying a page; after a second of quiet the debt lapses and the next small tick pages at once again. The debt also ends the moment travel repays it (reaches or crosses zero): the rest is plain travel and the 150 ms gap applies again. (3) A direction change inside a gesture (within the gap, or within one contact) drops a pre-paid debt (it belongs to the other direction) and accumulates WITHOUT pre-paying, so a +/-3 px trackpad wobble pages once for its opening event and then nothing; a reversal after the gap is a new gesture and pages at once. A tab switch also starts a new gesture. ⚠️ These gesture rules (first-event page, contact and debt lifetimes, reversal handling, notch accumulation, pinch and sideways-swipe consume) are ONE pure helper, `CodemanTerminalInput.pageKeysForGesture(state, ev, lines, rows, now)`, which both panes call with their own state; never re-derive them in a pane. ⚠️ Three gestures are consumed WITHOUT paging: a pinch (`ctrlKey`, Chrome's trackpad-pinch wheel events), a mostly horizontal swipe (both deltas present and `|deltaX| > |deltaY|`; the touch path locks its own axis first), and anything while the pane's OWN session has the alert `'action'` (a pending `permission_prompt` or `elicitation_dialog`, `updateTabAlertFromHooks` in app.js), so a page key never lands in an open dialog's selector; the primary pane logs that case as `page-keys-dialog-open` so a wheel dead under a stale alert leaves a `[scroll]` line. The dialog check is the one rule outside the helper, because it is per pane: the primary pane reads `tabAlerts` for `activeSessionId`, a tile for its own `sessionId` (usually not the active one). ⚠️ Which modes page is the registry's `capabilities.transcriptPageKeys`, published as `window.__codemanTranscriptPageKeys`, never an id check in terminal-ui.js. Today that is claude, codex and opencode. Codex retains this fallback when mouse tracking has not been observed: alternate-screen drawing is stripped from xterm and can leave a hollow pane, while PageUp still pages the CLI transcript. Tracked fullscreen Codex 0.163.0 forwards SGR reports first, as measured above. Which version made the switch is not pinned down: alt-screen drawing was observed on 0.157.1 (local) and 0.160 (remote), while 0.147.0 still draws inline; nothing between 0.147.0 and 0.157.1 has been checked. This is the page-key fallback, NOT wheel forwarding, and an older inline codex keeps real local history (`baseY > 0`) so the guard never fires for it. `shell`/`pi` own real terminal scrollback and are never paged, and 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()`. **Tiles** (a grid tile, the split's Pane B: `TerminalTile`) page the wheel through these same gates, called with the tile's own terminal and session (`_localScrollbackIsHollow(target)`, `_shouldForwardWheelToApp(ev, target)`) and the pure `CodemanTerminalInput.pageKeysForGesture` on the tile's own state, so the mode rule and the gesture rules stay in terminal-ui.js alone. ⚠️ A tile's `baseY` is rarely 0 even when hollow: its first capture is taken at the PTY's previous, taller size and its row-shrinking fits push rows up, so it passes `localRows` with those rows discounted (`TerminalTile._localRows`). ⚠️ So a tile can be hollow with its viewport still up in those rows (Shift+PageUp, a scrollbar drag, a wheel during the first replay), and it pages only while `_terminalViewportAtBottom(tile.terminal)` holds: otherwise every wheel, wheel-down included, was paged and swallowed, the stale rows stayed on screen and the click report (which refuses an off-bottom viewport) went dead too. Off the bottom the wheel stays xterm's, so a wheel-down brings the viewport home. The primary pane needs no such gate: hollow there means `baseY === 0`, which is always at the bottom. Tiles also FORWARD the wheel to tracked Claude/Codex fullscreen renderers as SGR reports (`TerminalTile._maybeForwardWheelToCli`: the same `_shouldForwardWheelToApp(ev, target)` gate, the cell from `_clientPointToCell(x, y, tile.terminal)`, the bytes from the pure `CodemanTerminalInput.sgrWheelReports` the primary pane's `_sendSyntheticSgrWheel` also uses, flushed through the tile's own coalescer to the tile's session, never the primary queue, which flushes to `activeSessionId`); before that a fullscreen Claude tile left the wheel to xterm, which scrolled only stale replayed frames while Claude's transcript never moved. ⚠️ Shift+wheel in a tile is scrolled by the tile itself (`_maybeScrollLocalOnShift`, `scrollLines()` on the dominant axis), as in the primary pane: xterm's own scroller turns a Shift+vertical wheel into a horizontal one off macOS and Chrome on Windows sends Shift+wheel as `deltaX`, so left to xterm the gesture was dead. Tiles have no touch path. Tests: `test/terminal-scroll-routing.test.ts`, `test/terminal-tile-scroll.test.ts`, `test/claude-cli-version-cache.test.ts`. diff --git a/docs/wiki/Agent-CLIs.md b/docs/wiki/Agent-CLIs.md index 037fe1e1..2326c533 100644 --- a/docs/wiki/Agent-CLIs.md +++ b/docs/wiki/Agent-CLIs.md @@ -145,9 +145,9 @@ Two behaviours that are deliberate and worth knowing: a `/` opens a live-filtering picker, arrows edit server-side state. Buffering keystrokes until Enter starved it, so Codex paints each keystroke at the predicted cell while the bytes on the wire stay byte-identical to what you typed. -- **The wheel is not forwarded** into its transcript. Codex ignores the mouse reports - Codeman would send, so forwarding produced a dead wheel. Scrolling in a Codex session is - local scrollback. +- **Wheel forwarding follows mouse tracking.** Codex with mouse tracking on gets the wheel + forwarded like fullscreen Claude; otherwise it pages its own transcript when local history + is empty, or scrolls locally. - **Work detection is Codex's own.** Codex declares its `›` composer glyph and its `esc to interrupt` working line, so it gets the same screen-checked idle detection Claude does; before 1.26.1 every Codex session reported idle for its whole life. Codex diff --git a/docs/wiki/The-Dashboard.md b/docs/wiki/The-Dashboard.md index e621e14b..25780ffb 100644 --- a/docs/wiki/The-Dashboard.md +++ b/docs/wiki/The-Dashboard.md @@ -226,10 +226,12 @@ Worth knowing: - **Wheel and touch scrolling** are forwarded into Claude's own transcript when a recent 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 + Codex with mouse tracking on gets the wheel forwarded like fullscreen Claude; otherwise + it pages its transcript or scrolls locally. Claude's default inline view keeps its history + in the terminal and scrolls locally. `Shift+Wheel` is always local scrollback. OpenCode's wheel and swipes page its own conversation - (PageUp/PageDown), and so do newer Codex versions, which draw on the alternate screen - and leave the terminal no history of its own (an older Codex that draws inline keeps + (PageUp/PageDown), as do untracked fullscreen Codex versions that leave the terminal + no history of its own (an older Codex that draws inline keeps its history in the terminal and scrolls locally). The first small movement of a gesture pages at once, then one page per half screen of travel; one finger on the screen is one gesture however slowly it moves. In a grid tile or the split view's diff --git a/docs/wiki/Troubleshooting.md b/docs/wiki/Troubleshooting.md index 41c64ef4..d208bfca 100644 --- a/docs/wiki/Troubleshooting.md +++ b/docs/wiki/Troubleshooting.md @@ -173,8 +173,8 @@ Things to try: ### The wheel does nothing in a Codex session -Codex ignores the mouse reports that forwarding would send, so Codeman does not forward -there. Scrolling is local, and `Shift+Wheel` behaves the same way. +Codex with mouse tracking on gets the wheel forwarded like fullscreen Claude; otherwise +it pages its transcript or scrolls locally, while `Shift+Wheel` always uses local scrollback. ### Selected text is invisible on a light skin diff --git a/src/web/public/terminal-tile.js b/src/web/public/terminal-tile.js index 33d3a903..16d8136e 100644 --- a/src/web/public/terminal-tile.js +++ b/src/web/public/terminal-tile.js @@ -1225,6 +1225,8 @@ _queueScrollBytes(data) { if (!data || this._destroyed) return; this._scrollBytes = global.CodemanTerminalInput.coalesceScrollBytes(this._scrollBytes, data); + // Tiles have a fixed session: the leading report is once per connect(), + // unlike the primary pane, which resets it on every tab selection. if (data.startsWith('\x1b[<') && !this._wheelNativeStarted) { this._wheelNativeStarted = true; if (this._scrollFlushTimer) clearTimeout(this._scrollFlushTimer); diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 632c1a12..2d215434 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -137,15 +137,20 @@ return `\x1b[<${btn};${pos.col};${pos.row}M`.repeat(ticks); } - // Fractional travel belongs to one direction and one recent gesture. Unlike - // rounding every event up, splitting 25px into ten touchpad events still sends - // one tick. Excess fling travel is discarded, never replayed after the finger - // stops. Both the primary pane (including touch) and tiles use this adapter. + // A deliberate opening event pre-pays a tick, so a lone small mouse click + // responds immediately. The signed remainder is debt repaid by subsequent + // same-direction travel: ten 0.1-line events still total one tick. Debt and + // fractions expire with a 150ms gesture gap or reversal; a reversal within + // the gesture accumulates without another pre-payment (like page gestures). function wheelTicksForGesture(state, lines, now) { if (!Number.isFinite(lines) || !lines) return { state: state || {}, ticks: 0 }; const dir = Math.sign(lines); - const fresh = !state || state.dir !== dir || now - state.lastAt > PAGE_KEY_GESTURE_GAP_MS; - const total = (fresh ? 0 : state.pending || 0) + lines; + const fresh = !state || typeof state.lastAt !== 'number' || now - state.lastAt > PAGE_KEY_GESTURE_GAP_MS; + const reversed = state?.dir && state.dir !== dir; + if (fresh && Math.abs(lines) >= PAGE_KEY_MIN_START_ROWS && Math.abs(lines) < 1) { + return { state: { dir, lastAt: now, pending: lines - dir }, ticks: dir }; + } + const total = (fresh || reversed ? 0 : state.pending || 0) + lines; const whole = Math.trunc(total + Math.sign(total) * 1e-9); return { state: { dir, lastAt: now, pending: Math.abs(total - whole) < 1e-9 ? 0 : total - whole }, @@ -414,6 +419,7 @@ pageKeysForGesture, PAGE_KEY_GESTURE_GAP_MS, PAGE_KEY_MIN_START_ROWS, + PAGE_KEY_IMMEDIATE_MAX_ROWS, PAGE_KEY_DEBT_MS, TUI_PROMPT_DEFAULT_ROWS_FROM_BOTTOM, MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR, @@ -1113,7 +1119,7 @@ Object.assign(CodemanApp.prototype, { // and stops propagation, keeping xterm's scroller out of it entirely. // Local scrolling goes through terminal.scrollLines() — buffer-level, so // it keeps working after resets — with our own deltaMode normalization - // (_wheelScrollLines) covering Firefox's line-unit wheels. Two cases still + // (_wheelScrollLinesFloat) covering Firefox's line-unit wheels. Two cases still // belong to xterm and are passed through untouched: // - mouseTrackingMode active: xterm's own encoder forwards the wheel to // the PTY (htop/vim with mouse on in a shell pane); @@ -1128,8 +1134,8 @@ Object.assign(CodemanApp.prototype, { if (this.terminal?.buffer?.active?.type === 'alternate') return; ev.preventDefault(); ev.stopPropagation(); - if (ev.ctrlKey || (!ev.shiftKey && Math.abs(ev.deltaX) > Math.abs(ev.deltaY))) return; if (this._shouldForwardWheelToApp(ev)) { + if (ev.ctrlKey || Math.abs(ev.deltaX || 0) > Math.abs(ev.deltaY || 0)) return; this._logScrollRouting('forward-sgr'); this._forwardScrollToApp(ev.clientX, ev.clientY, this._wheelScrollLinesFloat(ev)); return; @@ -5867,9 +5873,9 @@ Object.assign(CodemanApp.prototype, { // trackpad emits dozens of wheel events per second and each send becomes a // tmux send-keys on the server — unbatched, a single flick would spawn a // process storm. Per-event tick count is capped (Claude applies its own - // scroll-speed multiplier and acceleration on top), and the queue is bounded - // so a wild scroll can't build a backlog that keeps scrolling after the finger - // stops. Flushed via _sendInputEphemeral — loss-tolerant, off the durable queue. + // scroll-speed multiplier and acceleration on top). Batching is bounded by + // the 40ms window and the per-event cap, not an aggregate byte/tick limit. + // Flushed via _sendInputEphemeral — loss-tolerant, off the durable queue. // The encoding is the pure CodemanTerminalInput.sgrWheelReports, which a // TerminalTile calls with its own cell (TerminalTile._maybeForwardWheelToCli). _sendSyntheticSgrWheel(clientX, clientY, lines) { diff --git a/test/terminal-scroll-routing.test.ts b/test/terminal-scroll-routing.test.ts index 786d4aae..c5ba96ea 100644 --- a/test/terminal-scroll-routing.test.ts +++ b/test/terminal-scroll-routing.test.ts @@ -559,8 +559,8 @@ describe('page-key gestures across slow touch drags, slow wheel ticks and jitter p.app._maybePageCliTranscript({ shiftKey: false, deltaY: dy }, dy / 25); p.advance(16); } - // The opening event is a fresh gesture and pages; no reversal inside the - // gap pre-pays, so the wobble sends nothing after it. + // A reversal inside the 40ms batch window cancels the unsent opening + // PageUp; subsequent in-gesture reversals do not pre-pay another page. expect(p.keys()).toBe(''); // the opening report was cancelled before its 40ms flush }); @@ -857,6 +857,44 @@ describe('scroll routing diagnostic (issue #205 round 2)', () => { }); describe('consistent scroll gesture adapters', () => { + it('keeps the shared paging opening threshold exported', () => { + expect(loadTerminalUiHarness().windowRef.CodemanTerminalInput.PAGE_KEY_IMMEDIATE_MAX_ROWS).toBe(2); + }); + + it('pre-pays an isolated deliberate opening tick and repays its signed debt', () => { + const { wheelTicksForGesture } = loadTerminalUiHarness().windowRef.CodemanTerminalInput; + const opening = wheelTicksForGesture(null, -0.16, 0); // a 4px mouse click + expect(opening.ticks).toBe(-1); + expect(opening.state.pending).toBeCloseTo(0.84); + const continued = wheelTicksForGesture(opening.state, -0.16, 20); + expect(continued.ticks).toBe(0); + expect(continued.state.pending).toBeCloseTo(0.68); + expect(wheelTicksForGesture(null, 0.04, 0).ticks).toBe(0); // sub-threshold jitter + }); + + it('sends each slow 4px click, in either direction, while ten 2.5px events total one tick', () => { + const { wheelTicksForGesture } = loadTerminalUiHarness().windowRef.CodemanTerminalInput; + for (const direction of [-1, 1]) { + let slowState; + const slowTicks = []; + for (let i = 0; i < 5; i++) { + const step = wheelTicksForGesture(slowState, direction * 0.16, i * 300); + slowState = step.state; + slowTicks.push(step.ticks); + } + expect(slowTicks).toEqual(Array(5).fill(direction)); + let fineState; + const fineTicks = []; + for (let i = 0; i < 10; i++) { + const step = wheelTicksForGesture(fineState, direction * 0.1, i * 8); + fineState = step.state; + fineTicks.push(step.ticks); + } + expect(fineTicks).toEqual([direction, ...Array(9).fill(0)]); + expect(fineState.pending).toBe(0); + } + }); + it('makes ten small Claude deltas equal one combined delta', () => { const { wheelTicksForGesture } = loadTerminalUiHarness().windowRef.CodemanTerminalInput; let state; @@ -882,13 +920,17 @@ describe('consistent scroll gesture adapters', () => { const { wheelTicksForGesture } = loadTerminalUiHarness().windowRef.CodemanTerminalInput; const state = wheelTicksForGesture(null, 0.9, 0).state; expect(wheelTicksForGesture(state, -0.2, 10).ticks).toBe(0); - expect(wheelTicksForGesture(state, 0.2, 200).ticks).toBe(0); - const { app } = hollowApp(); + expect(wheelTicksForGesture(state, 0.2, 200).ticks).toBe(1); + const { app, sent } = hollowApp(); app._clientPointToCell = () => ({ col: 1, row: 1 }); app._sendSyntheticSgrWheel(0, 0, 0.9); app.activeSessionId = 'other'; app._sendSyntheticSgrWheel(0, 0, 0.2); - expect(app._wheelSgrQueue || '').toBe(''); + expect(sent).toEqual([ + { id: 'sess-1', data: '\x1b[<65;1;1M' }, + { id: 'other', data: '\x1b[<65;1;1M' }, + ]); + expect(app._wheelTickGesture.pending).toBeCloseTo(-0.8); }); it('preserves accumulated coarse Codex wheel fallback travel', () => { diff --git a/test/terminal-tile-scroll.browser.test.ts b/test/terminal-tile-scroll.browser.test.ts index cfeccbdb..9dfb7d69 100644 --- a/test/terminal-tile-scroll.browser.test.ts +++ b/test/terminal-tile-scroll.browser.test.ts @@ -276,6 +276,16 @@ describe('TerminalTile wheel paging in a real browser', () => { const down = `\x1b[<65;${expected.col};${expected.row}M`; expect(after.sent.map(([, data]) => data)).toEqual([up.repeat(4), down.repeat(2)]); + // A slow macOS mouse click is only ~4px. Each isolated click must report, + // not lose its fraction during the 300ms pause between clicks. + await page.waitForTimeout(300); + for (let click = 0; click < 5; click++) { + await page.mouse.wheel(0, -4); + await page.waitForTimeout(300); + } + const afterSlow = await snap(); + expect(afterSlow.sent.slice(after.sent.length).map(([, data]) => data)).toEqual(Array(5).fill(up)); + // Shift+wheel is the explicit "local scrollback" gesture: scrolled locally // by the tile (xterm alone turns it into a horizontal no-op), unforwarded. await page.keyboard.down('Shift'); @@ -283,7 +293,7 @@ describe('TerminalTile wheel paging in a real browser', () => { await page.keyboard.up('Shift'); await page.waitForTimeout(150); const shifted = await snap(); - expect(shifted.sent.length).toBe(after.sent.length); + expect(shifted.sent.length).toBe(afterSlow.sent.length); expect(shifted.viewportY).toBeLessThan(before.baseY); } finally { await page.evaluate((id) => { @@ -297,4 +307,123 @@ describe('TerminalTile wheel paging in a real browser', () => { }, PROBE_ID); } }); + + it('preserves local Ctrl/sideways scrolling and forwards small native clicks without amplifying touchpad streams', async () => { + const id = 'primary-wheel-probe'; + await page.evaluate(async (id) => { + const a = (window as any).app; + const t = a.terminal; + (window as any).__primaryWheelProbe = { + sent: [], + activeId: a.activeSessionId, + realEphemeral: a._sendInputEphemeral, + point: a._clientPointToCell, + }; + a._sendInputEphemeral = (sid: string, data: string) => (window as any).__primaryWheelProbe.sent.push([sid, data]); + a._clientPointToCell = () => ({ col: 1, row: 1 }); + a.sessions.set(id, { id, mode: 'shell' }); + a.activeSessionId = id; + t.reset(); + await new Promise<void>((resolve) => + t.write(Array.from({ length: 100 }, (_, i) => `LOCAL_${i}\r\n`).join(''), resolve) + ); + t.scrollToBottom(); + }, id); + const fire = async (options: { deltaY: number; deltaX?: number; ctrlKey?: boolean }) => { + await page.evaluate((options) => { + const t = (window as any).app.terminal; + t.element.dispatchEvent(new WheelEvent('wheel', { ...options, deltaMode: 0, bubbles: true, cancelable: true })); + }, options); + await page.waitForTimeout(80); // actual 40ms batch and animation frames + }; + try { + for (const options of [ + { deltaY: -25, ctrlKey: true }, + { deltaY: -25, deltaX: 100 }, + ]) { + const before = await page.evaluate(() => { + const t = (window as any).app.terminal; + t.scrollToBottom(); + return t.buffer.active.viewportY; + }); + expect(before).toBeGreaterThan(0); + await fire(options); + const after = await page.evaluate(() => (window as any).app.terminal.buffer.active.viewportY); + expect(after).toBe(before - 1); // local shell behavior matches master + } + for (const mode of ['claude', 'codex']) { + await page.evaluate( + ({ id, mode }) => { + const a = (window as any).app; + a.sessions.set(id, { + id, + mode, + cliVersion: mode === 'claude' ? '2.1.300' : undefined, + cliMouseTracking: true, + }); + a._wheelTickGesture = null; + a._wheelTickSession = null; + a._selectGeneration++; + (window as any).__primaryWheelProbe.sent.length = 0; + }, + { id, mode } + ); + for (const options of [ + { deltaY: -25, ctrlKey: true }, + { deltaY: -25, deltaX: 100 }, + ]) + await fire(options); + expect(await page.evaluate(() => (window as any).__primaryWheelProbe.sent.length)).toBe(0); + for (let click = 0; click < 5; click++) { + await fire({ deltaY: -4 }); + await page.waitForTimeout(220); + } + expect(await page.evaluate(() => (window as any).__primaryWheelProbe.sent)).toEqual( + Array.from({ length: 5 }, () => [id, '\x1b[<64;1;1M']) + ); + await page.waitForTimeout(200); + await page.evaluate(() => { + (window as any).__primaryWheelProbe.sent.length = 0; + }); + await page.evaluate(() => { + const t = (window as any).app.terminal; + for (let event = 0; event < 10; event++) + t.element.dispatchEvent( + new WheelEvent('wheel', { + deltaY: -2.5, + deltaMode: 0, + bubbles: true, + cancelable: true, + }) + ); + }); + await page.waitForTimeout(80); + expect(await page.evaluate(() => (window as any).__primaryWheelProbe.sent)).toEqual([[id, '\x1b[<64;1;1M']]); + } + await page.evaluate((id) => { + const a = (window as any).app; + a.sessions.set(id, { id, mode: 'codex', cliMouseTracking: false }); + a.terminal.clear(); + a.terminal.scrollToBottom(); + (window as any).__primaryWheelProbe.sent.length = 0; + }, id); + expect(await page.evaluate(() => (window as any).app.terminal.buffer.active.baseY)).toBe(0); + for (const options of [ + { deltaY: -25, ctrlKey: true }, + { deltaY: -25, deltaX: 100 }, + ]) + await fire(options); + expect(await page.evaluate(() => (window as any).__primaryWheelProbe.sent.length)).toBe(0); + } finally { + await page.evaluate((id) => { + const a = (window as any).app, + p = (window as any).__primaryWheelProbe; + a.activeSessionId = p.activeId; + a._sendInputEphemeral = p.realEphemeral; + a._clientPointToCell = p.point; + a.sessions.delete(id); + delete (window as any).__primaryWheelProbe; + }, id); + } + }); }); diff --git a/test/terminal-tile-scroll.test.ts b/test/terminal-tile-scroll.test.ts index e403132a..62f8f8e0 100644 --- a/test/terminal-tile-scroll.test.ts +++ b/test/terminal-tile-scroll.test.ts @@ -344,11 +344,33 @@ describe('a fullscreen Claude tile forwards the wheel to the CLI as SGR reports' expect(flushed(ws).at(-1)).toEqual({ t: 'i', d: UP.repeat(5) }); }); + it('forwards an isolated small wheel event and five slow clicks for Claude and tracked Codex', async () => { + for (const mode of ['claude', 'codex']) { + let now = 1000; + clock.now = () => now; + const session = mode === 'claude' ? FULLSCREEN : { mode, cliMouseTracking: true }; + const { ws, mount } = await connectTile(makeApp({ 's-tile': session }), { mode }); + mount.fire('wheel', wheel(-4, AT)); + expect(flushed(ws)).toEqual([{ t: 'i', d: UP }]); + for (let i = 1; i < 5; i++) { + now += 300; + vi.advanceTimersByTime(300); + mount.fire('wheel', wheel(-4, AT)); + flushed(ws); + } + expect(ws.inputFrames().map((f) => f.d)).toEqual(Array(5).fill(UP)); + // Separate pane/visit: ten 2.5px events produce only the pre-paid tick. + const fine = await connectTile(makeApp({ 's-tile': session }), { mode }); + for (let i = 0; i < 10; i++) fine.mount.fire('wheel', wheel(-2.5, AT)); + expect(flushed(fine.ws)).toEqual([{ t: 'i', d: UP }]); + } + }); + it('accumulates precision touchpad deltas and consumes a pure horizontal swipe without sending', async () => { const { ws, mount } = await connectTile(makeApp({ 's-tile': FULLSCREEN }), { mode: 'claude' }); - mount.fire('wheel', wheel(-3, AT)); // 0.12 of a line must not round up - expect(flushed(ws)).toEqual([]); + mount.fire('wheel', wheel(-3, AT)); // pre-pay one tick, then repay it + expect(flushed(ws)).toEqual([{ t: 'i', d: UP }]); for (let i = 0; i < 8; i++) mount.fire('wheel', wheel(-3, AT)); expect(flushed(ws)).toEqual([{ t: 'i', d: UP }]); From 2cd0d82544ea9e34cf805464946ada7f39a38c15 Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Sun, 18 Oct 2026 18:03:54 +0800 Subject: [PATCH 09/96] fix(file-viewer): phones ignore expand and the grip, folder paging follows history, swallow Alt+arrows Under 600px the window stays full screen whatever Expand or a drag says, and both controls are hidden. Stepping onto a folder-grid entry goes through the grid so its index follows. Alt+left/right is swallowed at the ends of the list too. Adds tests for the key handler, the grip clamp and the history/grid case, zh-CN titles and wiki lines. --- docs/wiki/Keyboard-Shortcuts.md | 1 + docs/wiki/Working-With-Files.md | 5 + src/web/public/i18n.js | 7 ++ src/web/public/mobile.css | 13 +++ src/web/public/panels-ui.js | 13 ++- test/file-preview-window.test.ts | 163 ++++++++++++++++++++++++++++++- 6 files changed, 197 insertions(+), 5 deletions(-) diff --git a/docs/wiki/Keyboard-Shortcuts.md b/docs/wiki/Keyboard-Shortcuts.md index 768085c1..66212940 100644 --- a/docs/wiki/Keyboard-Shortcuts.md +++ b/docs/wiki/Keyboard-Shortcuts.md @@ -10,6 +10,7 @@ Press `Ctrl+?` in the app for the same list in a floating overlay. | ------------------------------- | --------------------------------------------------------------- | | `Ctrl+K` (also `Cmd+K`, `Alt+K`)| Find an open session or start a new one. | | `Ctrl+Tab` | Next session. | +| `Alt+←` / `Alt+→` | File Viewer window: back / forward between previewed files. | | `Alt+[` / `Alt+]` | Previous / next tab. | | `Alt+1` to `Alt+9` | Switch to tab N. Physical keys, so macOS Option layouts work. | | `Ctrl+Shift+{` / `Ctrl+Shift+}` | Move the active tab left / right. | diff --git a/docs/wiki/Working-With-Files.md b/docs/wiki/Working-With-Files.md index ef7c5697..4135e9d9 100644 --- a/docs/wiki/Working-With-Files.md +++ b/docs/wiki/Working-With-Files.md @@ -27,6 +27,11 @@ stream rather than server memory). Sensitive paths (`.env`, anything matching credentials, `~/.ssh`, AWS credentials) are blocked from download, and SVG and HTML are served as downloads rather than rendered, so they cannot execute in the page. +The preview window has **Back** and **Forward** buttons (also `Alt+←` and `Alt+→`) once you +have opened a second file, so a link into another document is no longer one way. **Expand** +fills the screen and is remembered per device, and the corner grip resizes the window. On a +phone the window is always full screen, so Expand and the grip are hidden there. + Closing the preview pauses and unloads any playing media. A video that keeps playing after you close the panel means you are on an old version. diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index 14db1489..1dc592fa 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -1039,6 +1039,13 @@ 'Source type filter': '来源类型筛选', 'Copy content': '复制内容', 'Edit file': '编辑文件', + 'Back (Alt+←)': '后退 (Alt+←)', + 'Forward (Alt+→)': '前进 (Alt+→)', + 'Back to the previous file': '返回上一个文件', + 'Forward to the next file': '前进到下一个文件', + Expand: '展开', + 'Restore size': '恢复大小', + 'Drag to resize': '拖动以调整大小', 'Rendered markdown': '渲染 Markdown', 'Line numbers': '行号', 'Wrap lines': '自动换行', diff --git a/src/web/public/mobile.css b/src/web/public/mobile.css index 675b02c0..6dcd57d2 100644 --- a/src/web/public/mobile.css +++ b/src/web/public/mobile.css @@ -2298,6 +2298,19 @@ html.mobile-init .file-browser-panel { border-right: none; } + /* Expand and the resize grip would only shrink the full-screen window (96vw x 94vh, + or a px size left by a drag in landscape), so a phone ignores both. */ + .file-preview-window, + .file-preview-window.expanded { + width: 100vw !important; + height: var(--app-height, 100vh) !important; + } + + #filePreviewExpandBtn, + .file-preview-resize { + display: none; + } + .file-preview-editbar { padding-bottom: calc(0.4rem + var(--safe-area-bottom)); } diff --git a/src/web/public/panels-ui.js b/src/web/public/panels-ui.js index 5d5d4f97..a7d14b54 100644 --- a/src/web/public/panels-ui.js +++ b/src/web/public/panels-ui.js @@ -4583,6 +4583,13 @@ Object.assign(CodemanApp.prototype, { // only needs to cover the call itself. this._filePreviewHistoryStepping = true; try { + // A folder-grid page goes through the grid so index, lastIndex and the highlighted + // tile follow; otherwise the next ‹ › would continue from the newer file. + const folderFile = e.options?.folderFile; + if (folderFile && folderFile.folderId === this.folderGrid?.folderId) { + const index = this.folderGrid.files.findIndex((f) => f.name === folderFile.name); + if (index !== -1) return this._openFolderGridFile(index); + } return this.openFilePreview(e.filePath, e.sessionId, e.attachmentId, e.options || {}); } finally { this._filePreviewHistoryStepping = false; @@ -4639,11 +4646,13 @@ Object.assign(CodemanApp.prototype, { if (!this.$('filePreviewOverlay')?.classList.contains('visible')) return; const t = ev.target; if (t && (t.isContentEditable || (/^(INPUT|TEXTAREA|SELECT)$/.test(t.tagName) && !t.classList?.contains('xterm-helper-textarea')))) return; + // Swallowed even at the ends of the list: otherwise the terminal behind the overlay + // types it as word-left/right, and on a header button the browser's own Alt+← goes Back. + ev.preventDefault(); + ev.stopPropagation(); const h = this.filePreviewHistory; const delta = ev.key === 'ArrowLeft' ? -1 : 1; if (!h || h.index + delta < 0 || h.index + delta >= h.entries.length) return; - ev.preventDefault(); - ev.stopPropagation(); this.stepFilePreviewHistory(delta); }, true diff --git a/test/file-preview-window.test.ts b/test/file-preview-window.test.ts index cbbb2858..26875efe 100644 --- a/test/file-preview-window.test.ts +++ b/test/file-preview-window.test.ts @@ -10,7 +10,11 @@ * 5. closing the preview clears the list and hides the buttons, * 6. unsaved edits ask before leaving, * 7. Expand toggles the window class and the pref, and is re-applied on open, - * 8. the markup carries the buttons and the resize grip. + * 8. the markup carries the buttons and the resize grip, + * 9. stepping onto a folder-grid page moves the grid's own index with it, + * 10. Alt+←/→ steps, ignores other chords and text fields, and is swallowed at the ends, + * 11. the corner grip doubles the pointer delta and clamps the size, + * 12. phones ignore Expand and the grip (mobile.css). * * Loaded via `vm` against a stub app, same harness style as file-preview-detach.test.ts. */ @@ -50,7 +54,7 @@ function loadApp(confirmResult = true) { CodemanBase: { base: '', url: (p: string) => p }, rendersAsPage: () => false, document: { getElementById: () => null, addEventListener: vi.fn() }, - window: { addEventListener: vi.fn(), open: vi.fn() }, + window: { addEventListener: vi.fn(), open: vi.fn(), innerWidth: 1000, innerHeight: 800 }, setTimeout, clearTimeout, confirm: vi.fn(() => confirmResult), @@ -98,7 +102,12 @@ function loadApp(confirmResult = true) { app.showToast = vi.fn(); app.filePreviewContent = ''; const confirmFn = (context as { confirm: ReturnType<typeof vi.fn> }).confirm; - return { app, overlay, back, fwd, expand, winClasses, store, confirmFn }; + const keydown = () => { + const calls = (context as { document: { addEventListener: ReturnType<typeof vi.fn> } }).document.addEventListener + .mock.calls; + return calls.find((c: unknown[]) => c[0] === 'keydown')?.[1] as (ev: unknown) => void; + }; + return { app, overlay, back, fwd, expand, winClasses, store, confirmFn, elements, overlayClasses, keydown }; } describe('file viewer back/forward', () => { @@ -218,3 +227,151 @@ describe('file viewer window markup', () => { expect(indexHtml).toContain('app.stepFilePreviewHistory(1)'); }); }); + +describe('file viewer folder paging in the history', () => { + it('moves the grid index with a back step, so the next page continues from there', async () => { + const { app } = loadApp(); + const names = ['a', 'b', 'c', 'd'].map((n) => ({ name: `${n}.pdf`, size: 1 })); + app.folderGrid = { + folderId: 'f1', + path: '/ws', + sessionId: 's1', + files: names, + folders: [], + index: null, + lastIndex: null, + }; + app._openFolderGridFile(0); + app._openFolderGridFile(1); + app._openFolderGridFile(2); + + await app.stepFilePreviewHistory(-1); + expect(app.folderGrid.index).toBe(1); + expect(app.folderGrid.lastIndex).toBe(1); + + app.stepFolderPreview(1); + const paths = app.filePreviewHistory.entries.map((e: { filePath: string }) => e.filePath); + expect(paths).toEqual(['/ws/a.pdf', '/ws/b.pdf', '/ws/c.pdf']); + expect(app.folderGrid.index).toBe(2); + }); + + it('falls back to a plain open when the entry is from another folder', async () => { + const { app } = loadApp(); + app.folderGrid = { + folderId: 'f1', + path: '/ws', + sessionId: 's1', + files: [{ name: 'a.pdf', size: 1 }], + folders: [], + index: 0, + lastIndex: 0, + }; + await app.openFilePreview('/other/x.pdf', 's1', null, { folderFile: { folderId: 'f2', name: 'x.pdf' } }); + await app.openFilePreview('/ws/y.pdf', 's1'); + await app.stepFilePreviewHistory(-1); + expect(app.filePreviewHistory.index).toBe(0); + expect(app.filePreviewHistory.entries).toHaveLength(2); + }); +}); + +describe('file viewer Alt+arrow chord', () => { + const key = (over: Record<string, unknown> = {}) => ({ + altKey: true, + ctrlKey: false, + metaKey: false, + shiftKey: false, + key: 'ArrowLeft', + target: { tagName: 'DIV' }, + preventDefault: vi.fn(), + stopPropagation: vi.fn(), + ...over, + }); + + async function setup() { + const h = loadApp(); + await h.app.openFilePreview('/ws/a.pdf', 's1'); + await h.app.openFilePreview('/ws/b.pdf', 's1'); + h.app._bindFilePreviewChrome(); + const step = vi.spyOn(h.app, 'stepFilePreviewHistory'); + return { ...h, step, fire: h.keydown() }; + } + + it('steps back and forward and swallows the chord', async () => { + const { fire, step } = await setup(); + const ev = key(); + fire(ev); + expect(step).toHaveBeenCalledWith(-1); + expect(ev.preventDefault).toHaveBeenCalled(); + expect(ev.stopPropagation).toHaveBeenCalled(); + }); + + it('swallows the chord at the ends of the list without stepping', async () => { + const { fire, step } = await setup(); + const fwd = key({ key: 'ArrowRight' }); // already at the newest entry + fire(fwd); + expect(step).not.toHaveBeenCalled(); + expect(fwd.preventDefault).toHaveBeenCalled(); + expect(fwd.stopPropagation).toHaveBeenCalled(); + }); + + it.each([ + ['a plain arrow', { altKey: false }], + ['Shift+Alt', { shiftKey: true }], + ['Ctrl+Alt', { ctrlKey: true }], + ['another key', { key: 'ArrowUp' }], + ['a textarea target', { target: { tagName: 'TEXTAREA', classList: { contains: () => false } } }], + ])('leaves %s alone', async (_name, over) => { + const { fire, step } = await setup(); + const ev = key(over); + fire(ev); + expect(step).not.toHaveBeenCalled(); + expect(ev.preventDefault).not.toHaveBeenCalled(); + }); + + it('leaves the chord alone while the overlay is hidden', async () => { + const { fire, step, overlayClasses } = await setup(); + overlayClasses.delete('visible'); + const ev = key(); + fire(ev); + expect(step).not.toHaveBeenCalled(); + expect(ev.preventDefault).not.toHaveBeenCalled(); + }); +}); + +describe('file viewer resize grip', () => { + function drag(to: [number, number]) { + const { app, elements } = loadApp(); + const listeners: Record<string, (e: unknown) => void> = {}; + const win = { offsetWidth: 400, offsetHeight: 300, style: {} as Record<string, string> }; + elements.filePreviewResize = { + parentElement: win, + setPointerCapture: () => {}, + addEventListener: (t: string, fn: (e: unknown) => void) => (listeners[t] = fn), + removeEventListener: () => {}, + }; + app._bindFilePreviewResize(); + listeners.pointerdown({ preventDefault: () => {}, clientX: 100, clientY: 100, pointerId: 1 }); + listeners.pointermove({ clientX: 100 + to[0], clientY: 100 + to[1] }); + return win.style; + } + + it('doubles the pointer delta, because the window is centred', () => { + expect(drag([100, 50])).toMatchObject({ width: '600px', height: '400px' }); + }); + + it('clamps to the minimum and to 96% x 94% of the viewport', () => { + expect(drag([-500, -500])).toMatchObject({ width: '320px', height: '240px' }); + expect(drag([900, 900])).toMatchObject({ width: '960px', height: '752px' }); + }); +}); + +describe('file viewer on phones', () => { + it('overrides Expand and any dragged size, and hides both controls, under 600px', () => { + const css = readFileSync(resolve(PUBLIC, 'mobile.css'), 'utf8'); + const phone = css.slice(css.indexOf('@media (max-width: 599px)')); + expect(phone).toMatch( + /\.file-preview-window,\s*\.file-preview-window\.expanded\s*\{[^}]*width: 100vw !important;[^}]*height: var\(--app-height, 100vh\) !important;/ + ); + expect(phone).toMatch(/#filePreviewExpandBtn,\s*\.file-preview-resize\s*\{\s*display: none;/); + }); +}); From 971192ce02d4cd88a296fdea54b9558ad241f5f0 Mon Sep 17 00:00:00 2001 From: Ludo Fourrage <fourrage@hotmail.com> Date: Sun, 18 Oct 2026 17:00:18 +0000 Subject: [PATCH 10/96] fix(terminal): forward touch scrolling for native SGR mouse tracking --- src/web/public/terminal-ui.js | 16 ++++++- test/terminal-scroll-routing.test.ts | 35 ++++++++++++++ test/terminal-touch-paging.browser.test.ts | 54 ++++++++++++++++++++++ 3 files changed, 103 insertions(+), 2 deletions(-) diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index ef062070..e44967b7 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -1168,7 +1168,7 @@ Object.assign(CodemanApp.prototype, { // Momentum phase — convert pixel velocity to lines const lines = Math.round(velocity / cellHeight()); if (lines !== 0) { - if (this._shouldForwardWheelToApp({ shiftKey: false })) { + if (this._shouldForwardTouchToApp()) { // Flick momentum keeps feeding the CLI's transcript from the last // touch point; the 40ms coalescer batches the per-frame reports. this._forwardScrollToApp(touchLastX, touchLastY, lines); @@ -1347,7 +1347,7 @@ Object.assign(CodemanApp.prototype, { const ch = cellHeight(); const lines = Math.trunc(pixelAccum / ch); if (lines !== 0) { - if (this._shouldForwardWheelToApp({ shiftKey: false })) { + if (this._shouldForwardTouchToApp()) { this._logScrollRouting('forward-sgr'); this._forwardScrollToApp(touchLastX, touchLastY, lines); } else if (!this._maybePageCliTranscript({ shiftKey: false, touchId: touchContactId }, lines)) { @@ -5833,6 +5833,18 @@ Object.assign(CodemanApp.prototype, { // `target` ({ terminal, sessionId }) asks the question for a TerminalTile: // its own terminal's tracking mode and its own session, never the active one. // Either field left out means the primary pane's. + // xterm encodes native wheel events when mouse tracking is enabled, but + // our touch listeners consume finger drags themselves. Forward those + // explicitly: local scrollLines() cannot move an alternate-screen buffer. + _shouldForwardTouchToApp() { + if (this.loadAppSettingsFromStorage?.()?.terminalWheelLocalScrollback) return false; + const mode = this.terminal?.modes?.mouseTrackingMode; + // The existing synthetic sender uses SGR; tracking alone does not imply + // that encoding (legacy/X10 applications must not receive SGR bytes). + const sgr = this.terminal?._core?.coreMouseService?.activeEncoding === 'SGR'; + return !!(mode && mode !== 'none' && mode !== 'x10' && sgr) || this._shouldForwardWheelToApp({ shiftKey: false }); + }, + _shouldForwardWheelToApp(ev, target = {}) { if (ev.shiftKey) return false; // Opt-out (App Settings → Input → "Wheel scrolls local history"): pin the diff --git a/test/terminal-scroll-routing.test.ts b/test/terminal-scroll-routing.test.ts index 4c3bcad2..6293a21c 100644 --- a/test/terminal-scroll-routing.test.ts +++ b/test/terminal-scroll-routing.test.ts @@ -877,3 +877,38 @@ describe('scroll routing diagnostic (issue #205 round 2)', () => { expect(logs[0]).toContain('cliVersion=unknown'); }); }); + +describe('touch scrolling when xterm owns native mouse tracking', () => { + it.each(['vt200', 'drag', 'any'])('forwards touch for %s tracking even for a shell session', (mode) => { + const { app } = hollowApp({ mode: 'shell' }); + app.terminal.modes.mouseTrackingMode = mode; + app.terminal._core = { coreMouseService: { activeEncoding: 'SGR' } }; + app.terminal.buffer.active.type = 'alternate'; + expect(app._shouldForwardWheelToApp({ shiftKey: false })).toBe(false); + expect(app._shouldForwardTouchToApp()).toBe(true); + }); + + it('does not send SGR to a legacy-protocol or X10 application', () => { + const { app } = hollowApp({ mode: 'shell' }); + app.terminal.modes.mouseTrackingMode = 'vt200'; + app.terminal._core = { coreMouseService: { activeEncoding: 'DEFAULT' } }; + expect(app._shouldForwardTouchToApp()).toBe(false); + app.terminal._core.coreMouseService.activeEncoding = 'SGR'; + app.terminal.modes.mouseTrackingMode = 'x10'; + expect(app._shouldForwardTouchToApp()).toBe(false); + }); + + it('keeps the explicit local-history preference for touch', () => { + const { app } = hollowApp({ mode: 'shell' }); + app.terminal.modes.mouseTrackingMode = 'any'; + app.loadAppSettingsFromStorage = () => ({ terminalWheelLocalScrollback: true }); + expect(app._shouldForwardTouchToApp()).toBe(false); + }); + + it('uses normal CLI routing when xterm mouse tracking is off', () => { + const { app } = hollowApp({ cliVersion: '2.1.187', cliMouseTracking: true }); + expect(app._shouldForwardTouchToApp()).toBe(true); + app.sessions.get('sess-1').cliMouseTracking = false; + expect(app._shouldForwardTouchToApp()).toBe(false); + }); +}); diff --git a/test/terminal-touch-paging.browser.test.ts b/test/terminal-touch-paging.browser.test.ts index 4dcaaba0..5fb41a7c 100644 --- a/test/terminal-touch-paging.browser.test.ts +++ b/test/terminal-touch-paging.browser.test.ts @@ -144,4 +144,58 @@ describe('touch paging of a hollow Codex pane in a real browser', () => { expect(second.keys).toBe(UP + UP); await touch('touchend', x, y0 + Math.ceil(cell)); }, 60000); + it.each([390, 820])('forwards drag and momentum with native mouse tracking at %ipx', async (width) => { + await page.setViewportSize({ width, height: 900 }); + await page.evaluate(async (id) => { + const app = (window as any).app; + app.sessions.set(id, { id, name: id, mode: 'shell', status: 'idle' }); + app.activeSessionId = id; + app.loadAppSettingsFromStorage = () => ({ terminalWheelLocalScrollback: false }); + app._wheelSgrQueue = ''; + const sent: string[] = []; + app._sendInputEphemeral = (_id: string, data: string) => sent.push(data); + (window as any).__mouseTouchProbe = { sent }; + await new Promise<void>((resolve) => app.terminal.write('\x1b[?1049h\x1b[?1003h\x1b[?1006h', resolve)); + }, PROBE_ID); + const point = await page.evaluate(() => { + const app = (window as any).app; + const r = document.getElementById('terminalContainer')!.getBoundingClientRect(); + return { + x: r.left + 30, + y: r.top + 200, + mode: app.terminal.modes.mouseTrackingMode, + buffer: app.terminal.buffer.active.type, + }; + }); + expect(point.mode).toBe('any'); + expect(point.buffer).toBe('alternate'); + await touch('touchstart', point.x, point.y); + await touch('touchmove', point.x, point.y - 60); + const drag = await page.evaluate(() => { + (window as any).app._flushWheelSgrQueue(); + return (window as any).__mouseTouchProbe.sent.join(''); + }); + expect(drag).toMatch(/\x1b\[<65;\d+;\d+M/); + await touch('touchend', point.x, point.y - 60); + await page.waitForTimeout(150); + const momentum = await page.evaluate(() => { + (window as any).app._flushWheelSgrQueue(); + return (window as any).__mouseTouchProbe.sent.join(''); + }); + expect(momentum.length).toBeGreaterThan(drag.length); + // Stop momentum before changing the requested mouse encoding. + await touch('touchstart', point.x, point.y); + await touch('touchcancel', point.x, point.y); + await page.evaluate(async () => { + const app = (window as any).app; + app._wheelSgrQueue = ''; + (window as any).__mouseTouchProbe.sent.length = 0; + await new Promise<void>((resolve) => app.terminal.write('\x1b[?1006l', resolve)); + }); + await touch('touchstart', point.x, point.y); + await touch('touchmove', point.x, point.y - 60); + await page.waitForTimeout(60); + expect(await page.evaluate(() => (window as any).__mouseTouchProbe.sent.join(''))).toBe(''); + await touch('touchcancel', point.x, point.y); + }); }); From 6f785e116f422e791b4fe1f255a9c066fdb59b66 Mon Sep 17 00:00:00 2001 From: JD <jd@jds.haus> Date: Sun, 18 Oct 2026 13:36:10 -0400 Subject: [PATCH 11/96] fix(response-viewer): open markdown links to file paths in the preview, not as a same-origin URL Codex cites files as [label](/abs/path). The chat view gave those links target=_blank, so a click opened <origin>/abs/path and landed on a Fastify 404. Path-shaped hrefs now take the linkified-path shape the file preview already opens; scheme, protocol-relative and fragment links are unchanged. --- docs/wiki/Mobile-Guide.md | 5 +- docs/wiki/Working-With-Files.md | 3 +- src/web/public/app.js | 28 ++++++++++ test/response-viewer-file-links.test.ts | 71 +++++++++++++++++++++++++ 4 files changed, 104 insertions(+), 3 deletions(-) diff --git a/docs/wiki/Mobile-Guide.md b/docs/wiki/Mobile-Guide.md index 11a78292..820710ad 100644 --- a/docs/wiki/Mobile-Guide.md +++ b/docs/wiki/Mobile-Guide.md @@ -134,8 +134,9 @@ already writing in the editor. - **Tap a link** in terminal output and it opens in a new tab. Same for a link in an agent's answer in the response viewer — it opens a tab rather than navigating the dashboard away, which on a phone would unload the whole session view. -- **Tap a file path** an agent printed and the file-preview overlay opens (Markdown rendered, - HTML as a page); a log or other plain-text path opens the log viewer. Works in scrolled-up +- **Tap a file path** an agent printed, or a link to one in its answer (Codex writes + `[report](/tmp/report.json)`), and the file-preview overlay opens (Markdown rendered, HTML + as a page); a log or other plain-text path opens the log viewer. Works in scrolled-up transcript too. - A tap on the prose *beside* a link still places the cursor as usual, and a tap on a dialog's numbered choice still answers the dialog even when the row contains a path — the dialog wins, diff --git a/docs/wiki/Working-With-Files.md b/docs/wiki/Working-With-Files.md index ef7c5697..df94e8d6 100644 --- a/docs/wiki/Working-With-Files.md +++ b/docs/wiki/Working-With-Files.md @@ -84,7 +84,8 @@ File paths in a session are links. That works in two places: - **In the terminal**, on any absolute path an agent prints. - **In the response viewer**, where paths are usually written as prose or in backticks. They - render as underlined monospace links. + render as underlined monospace links. A Markdown link whose target is a path + (`[report](/tmp/run/report.json)`, the way Codex cites files) opens the same way. Clicking one opens it in the preview: images and PDFs render, video and audio play with a working scrub bar, documents convert, text shows inline, Markdown renders and HTML opens as a diff --git a/src/web/public/app.js b/src/web/public/app.js index 62716497..6d53cb4d 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -2966,6 +2966,34 @@ class CodemanApp { // out of the middle of rendering a message. if (typeof findFilePathLinks !== 'function') return; + // A markdown link whose destination is a path (`[report](/home/me/run/x.json)`, + // Codex's usual way of citing a file) resolved against Codeman's own origin + // and opened a 404 in a new tab. Give it the linkified-path shape instead; + // links with a scheme or `//host` keep their own behaviour. + for (const a of root.querySelectorAll('a[href]')) { + // The URL parser drops tabs and newlines, so `http ://` is still http. + const href = (a.getAttribute('href') || '').replace(/[\t\n\r]/g, ''); + if (/^[a-z][a-z0-9+.-]*:/i.test(href) || href.startsWith('//')) continue; + // `#L42`, `?plain=1` and a `:42:7` suffix name a spot in the file, not the + // file. The suffix is cut before decoding, so an encoded `%3A` stays a name. + let path = href.split('#')[0].split('?')[0].replace(/:\d+(?::\d+)?$/, ''); + try { + path = decodeURIComponent(path); + } catch { + /* malformed escape: keep the path as written */ + } + // A bare `#fragment` (and every `.rv-path`, whose href is `#`) ends up empty. + if (!path) continue; + a.className = 'rv-path'; + a.dataset.path = path; + a.dataset.linkified = '1'; + if (path.endsWith('/') || findFilePathLinks(path).some((f) => f.folder && f.path === path)) a.dataset.folder = '1'; + a.title = path; + a.setAttribute('href', '#'); + a.removeAttribute('target'); + a.removeAttribute('rel'); + } + // Collect first: replacing a node while the walker is positioned on it // invalidates the traversal. const walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT); diff --git a/test/response-viewer-file-links.test.ts b/test/response-viewer-file-links.test.ts index a771ad7c..c30d2fd3 100644 --- a/test/response-viewer-file-links.test.ts +++ b/test/response-viewer-file-links.test.ts @@ -110,6 +110,77 @@ describe('response viewer file-path linkifier', () => { expect(root.querySelector('a')!.getAttribute('href')).toBe('https://example.com/tmp/shot.png'); }); + it('turns a markdown link to a path into a preview link, not a same-origin navigation', () => { + // Codex cites files as `[label](/abs/path)`: left alone, the href resolved + // against Codeman's origin and opened a 404 JSON page in a new tab. + const path = '/home/me/llm-bench/runs/crash-20261017/incident-summary.json'; + const root = linkify( + `<p><a href="${path}" target="_blank" rel="noopener noreferrer">Our incident record</a> and ` + + `<a href="src/a%20b.ts:42:7">a b</a>, <a href="docs/#L3">docs</a>, <a href="/tmp/renders">renders</a>, ` + + `<a href="/tmp/release%3A12">release</a></p>` + ); + + const links = paths(root); + expect(links.map((a) => a.getAttribute('data-path'))).toEqual([ + path, + 'src/a b.ts', + 'docs/', + '/tmp/renders', + // An encoded colon is part of the name, never a line suffix. + '/tmp/release:12', + ]); + for (const a of links) { + expect(a.getAttribute('href')).toBe('#'); + expect(a.hasAttribute('target')).toBe(false); + expect(a.hasAttribute('rel')).toBe(false); + expect(a.getAttribute('data-linkified')).toBe('1'); + } + expect(links[0].textContent).toBe('Our incident record'); + // A trailing slash and a rooted folder path without one both open the folder grid + // (`:` ends a folder match in findFilePathLinks, so `release:12` previews instead). + expect(links.map((a) => a.hasAttribute('data-folder'))).toEqual([false, false, true, true, false]); + }); + + it('opens a clicked markdown path link in the file preview through the click delegate', () => { + const app = Object.create(CodemanApp.prototype) as { + activeSessionId: string; + openFilePreview: ReturnType<typeof vi.fn>; + openFolderGrid: ReturnType<typeof vi.fn>; + _linkifyFilePaths(root: unknown): void; + _bindResponseViewerInteractions(body: unknown): void; + }; + app.activeSessionId = 'sess-1'; + app.openFilePreview = vi.fn(); + app.openFolderGrid = vi.fn(); + const body = document.createElement('div'); + body.innerHTML = '<p><a href="/home/me/run/summary.json" target="_blank">Our incident record</a></p>'; + app._linkifyFilePaths(body); + app._bindResponseViewerInteractions(body); + + const click = new dom.window.MouseEvent('click', { bubbles: true, cancelable: true }); + body.querySelector('a')!.dispatchEvent(click); + + expect(click.defaultPrevented).toBe(true); + expect(app.openFilePreview).toHaveBeenCalledWith('/home/me/run/summary.json', 'sess-1'); + expect(app.openFolderGrid).not.toHaveBeenCalled(); + }); + + it('keeps web, mail, protocol-relative and fragment links as they are', () => { + const hrefs = ['https://example.com/a.json', 'mailto:me@example.com', '//cdn.example.com/x.js', '#section']; + const root = linkify(`<p>${hrefs.map((h) => `<a href="${h}">x</a>`).join(' ')}</p>`); + + expect(paths(root)).toHaveLength(0); + expect(Array.from(root.querySelectorAll('a')).map((a) => a.getAttribute('href'))).toEqual(hrefs); + }); + + it('reads a scheme split by a tab as the URL the browser would open', () => { + // The URL parser drops tabs, so this is http://localhost:5173/a, never a relative file. + const root = linkify('<p><a href="http ://localhost:5173/a">local</a></p>'); + + expect(paths(root)).toHaveLength(0); + expect(root.querySelector('a')!.getAttribute('href')).toBe('http\t://localhost:5173/a'); + }); + it('leaves text with no path untouched', () => { const root = linkify('<p>Ratio 3/4 on 2026/08/16, see the app</p>'); From 71a51df2e285aebd5bb929d6b322299b27287bcb Mon Sep 17 00:00:00 2001 From: JD <jd@jds.haus> Date: Sun, 18 Oct 2026 13:09:06 -0400 Subject: [PATCH 12/96] feat(sessions): keep an idle session's prompt cache warm with a guarded keep-alive turn Claude Code lets a subscription's one-hour prompt cache expire after an hour idle, and the first turn back re-writes the whole conversation at the cache-write rate; nearly every gap over an hour goes cold, while the saving is concentrated in gaps of one to four hours. Codeman now sends one short keep-alive turn five minutes before the expiry Claude Code reports on its statusline, so the next real prompt still reads the conversation from cache, and stops two hours (configurable) after the last real prompt. Codeman owns the idle policy for these sessions, as agreed in Discussion #554: a launch with keep-warm on also carries idleCompaction: false in its one --settings JSON, so Claude Code's own idle compaction cannot replace the conversation with a summary a minute before the nudge. It is decided once per launch (keep-warm on, the statusLine exporter injected, Claude Code 2.1.290 or newer, an unknown version fails closed like --name), recorded on the mux record, and picked up by any relaunch; a launch with keep-warm off is byte-identical. A session switched on after launch is kept warm best-effort and the readout says so. Every guard lives in one pure rulebook: the opt-in (an agent-spawned session does not inherit the app default), a launch that reports cache state, a warm 1h cache with enough tokens at stake and no compaction this gap, a live pane, no usage-limit pause, no respawn, Ralph, task or waiting agent, idle, no open dialog, an empty composer and no recent keystroke. The nudge is a submit and moves lastSubmitAt, which the response viewer's conversation correlation needs; the cap counts from its own persisted lastRealPromptAt instead. Read My Mind drops the exact nudge text, and the turn's stop hook and idle prompt open pre-acknowledged so nothing alerts. The last cache report and the clock survive a restart, so the timer re-arms. --- CLAUDE.md | 2 + docs/api-reference.md | 10 + docs/architecture-invariants.md | 4 + docs/wiki/Keeping-Agents-Running.md | 10 +- docs/wiki/Settings-Reference.md | 4 +- src/config/cli-registry/stock.ts | 7 +- src/hooks-config.ts | 16 + src/intent-store.ts | 4 + src/mux-interface.ts | 13 + src/prompt-cache-keepwarm.ts | 337 +++++++++++ src/session-cli-registry-bridge.ts | 32 +- src/session-submit-verifier.ts | 22 + src/session.ts | 113 +++- src/tmux-manager.ts | 61 +- src/types/session.ts | 42 +- src/web/approval-inbox.ts | 10 +- src/web/ports/index.ts | 1 + src/web/ports/keep-warm-port.ts | 10 + src/web/public/app.js | 1 + src/web/public/constants.js | 44 +- src/web/public/i18n.js | 13 + src/web/public/index.html | 28 + src/web/public/session-ui.js | 57 ++ src/web/public/settings-ui.js | 6 + src/web/routes/hook-event-routes.ts | 11 +- src/web/routes/reboot-restore-routes.ts | 2 + src/web/routes/session-routes.ts | 46 +- src/web/routes/status-telemetry-routes.ts | 16 +- src/web/routes/system-routes.ts | 17 +- src/web/schemas.ts | 7 + src/web/server.ts | 64 ++- src/web/session-listener-wiring.ts | 7 + src/web/sse-events.ts | 7 +- test/cli-registry-spawn-golden.test.ts | 24 + test/keep-warm-i18n.test.ts | 68 +++ test/keep-warm-launch.test.ts | 158 +++++ test/mocks/mock-route-context.ts | 3 + test/mocks/mock-session.ts | 26 +- test/prompt-cache-keepwarm.test.ts | 571 +++++++++++++++++++ test/prompt-cache-status.test.ts | 169 +++++- test/routes/hook-event-routes.test.ts | 50 ++ test/routes/session-routes-keep-warm.test.ts | 76 +++ 42 files changed, 2131 insertions(+), 38 deletions(-) create mode 100644 src/prompt-cache-keepwarm.ts create mode 100644 src/web/ports/keep-warm-port.ts create mode 100644 test/keep-warm-i18n.test.ts create mode 100644 test/keep-warm-launch.test.ts create mode 100644 test/prompt-cache-keepwarm.test.ts create mode 100644 test/routes/session-routes-keep-warm.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index bb78756b..51ba4dec 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -158,6 +158,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Plan-usage chip** (`showPlanUsageLimits`, per-device: desktop default **ON**, handhelds OFF): resolve DISPLAY only through `planUsageChipEnabled()` (settings-ui.js). The same setting is the server-side COLLECTION switch, read fresh by `readPlanUsageTelemetryEnabled()` (hooks-config.ts) at every claude create/respawn. ⚠️ An ABSENT key reads as ON in the reader; `GET /api/settings` must never write. ⚠️ A save sends `showPlanUsageLimits` ONLY when it flips the chip on that device (`planUsageCollectionFlip()`), or a phone switches collection off for every desktop. Claude data comes from the statusLine exporter, injected as an EPHEMERAL `claude --settings` flag (`resolveStatusLineCliCommand`), never written to disk, WRAPPING a user's own statusLine, posting to `POST /api/status-telemetry`. Codex comes from a read-only `account/rateLimits/read` poll (main bucket only). The statusline's `prompt_cache` object (Claude Code v2.1.251+) is kept by `StatusTelemetrySchema` (every documented scalar field, null-tolerant; `parsePromptCache()` in usage-telemetry.ts normalizes only the stored subset) and shown in the footer as `cache:until HH:MM` / `cache:cold` (only when the user has no statusline of their own) and in Session Options → Respawn (`#promptCacheStatus`, fed by `Session.promptCache` in `toState()` plus the change-only `session:promptCache` SSE event, worded by the pure `CodemanPromptCache.format`): warm-until/cold, the re-cache stake, the hit rate, the last miss's causes, and a `Context compacted at HH:MM` note after a compaction or a tool-result clearing (stamped on the first warm report whose `recache_tokens_if_cold` is null after one that carried it, never from `expected_rebuilds`, which Claude Code bumps only at the next request; NEVER an alert). ⚠️ `Session.setPromptCache()` stamps `compactedAt` onto a NEW object (never mutating the parsed report) and the route broadcasts the STORED object; the last report is restored on restart (constructor `promptCache`, like `displayModel`) and the readout is time-aware (a passed `expiresAt` reads cold, not stale-warm). It is the source of truth for the cache TTL, never the plan tier. → [architecture-invariants#plan-usage-chip-statusline-telemetry](docs/architecture-invariants.md#plan-usage-chip-statusline-telemetry), `docs/usage-limits-display-plan.md` +**Prompt-cache keep-warm** (`src/prompt-cache-keepwarm.ts`; per-session opt-in on the Respawn tab, app-wide default `promptCacheKeepWarm` + cap `promptCacheKeepWarmMaxHours` in App Settings → System → Automation, SYNCED, default OFF / 2 h; step 2 of Discussion #554): one keep-alive turn (`KEEP_WARM_NUDGE`, under 100 chars) five minutes before an idle claude session's cache would expire, so the next prompt reads the conversation from cache instead of re-writing it at 2x. Codeman owns the idle policy for these sessions: a launch with keep-warm on also carries `"idleCompaction": false`, folded LAST into the ONE `--settings` JSON (`session-cli-registry-bridge.ts`), decided once per create/respawn by `decideIdleCompactionOptOut()` (tmux-manager.ts: keep-warm on via `keepWarmOn()`, exporter `injected`, claude's `idleCompactionSetting` gate at 2.1.290, fail-closed like `--name`) and recorded as `MuxSession.idleCompactionOff`; a keep-warm-off launch stays byte-identical (`test/cli-registry-spawn-golden.test.ts`). Switched on after launch it is best-effort and the readout says idle compaction may still run until the next launch. ⚠️ The deadline is `Session.promptCache.expiresAt` from the statusline, never idle detection or the plan tier; a `5m` TTL is a hard stop, and so is a launch whose `statusLineExporter` is not `injected`. ⚠️ Every guard lives in the pure `decideKeepWarm()` (effective opt-in, where an agent-spawned session (`parentSessionId`) does not inherit the app default; exporter injected; warm; no `compactedAt` this gap; tokens at stake; live pane (pid AND no `paneExit`); no limit pause (auto-resume's, or a limit message on screen); no respawn/Ralph/task/waiter; inside the cap since `lastRealPromptAt`; idle; no dialog; EMPTY composer via `composerIsEmpty()`, continuation lines included; no recent keystroke); add a guard there, never in the service. ⚠️ The nudge IS a submit and moves `lastSubmitAt` (the response viewer's conversation correlation needs it); the window `beginKeepAliveTurn()` opens keeps it off `lastRealPromptAt` (the cap's own persisted clock) and opens its `stop`/`idle_prompt` hooks pre-acknowledged (`preAcknowledged` on `notePrompt`), so nothing alerts; a `fromUser` write, or a `prompt_submitted` hook whose prompt is not the nudge, ends that window. Read My Mind drops its exact text (`isCapturablePrompt`). → [architecture-invariants#prompt-cache-keep-warm](docs/architecture-invariants.md#prompt-cache-keep-warm) + **Orchestrator**: State machine that turns a user goal into a phased plan and drives it to completion: `idle → planning → approval → executing → verifying → (replanning) → completed/failed`. `OrchestratorLoop` (engine) delegates plan generation to `orchestrator-planner` and per-phase verification gates to `orchestrator-verifier`, executing phases via team agents/`task-queue`. State persists under the `orchestrator` key in `state.json`. Distinct from Ralph (single-session autonomous loop) — orchestrator coordinates multi-phase, multi-agent execution. See `docs/orchestrator-loop-architecture.md`. **Cron (`CronJob`s)**: saved, named jobs on a recurring schedule (`once`/`interval`/`daily`/`weekly`) with per-job run history. ⚠️ **Distinct from the legacy `ScheduledRun`** (`/api/scheduled`, a run-now duration-bounded loop); the two never interact and keep separate `Scheduled*` / `Cron*` names. `CronService` **reuses the existing session layer** rather than rebuilding tmux logic. Next-run math is pure and unit-tested in `cron-time.ts` (server-local timezone). The schedule is advanced BEFORE launch so a slow launch cannot re-trigger. → [architecture-invariants#cron-jobs](docs/architecture-invariants.md#cron-jobs), `docs/cron-discovery.md` diff --git a/docs/api-reference.md b/docs/api-reference.md index c0350a66..77b5a8ae 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1152,6 +1152,16 @@ Result (`data`): The result carries server **names** only, never `env` values, `headers` or file content. Each changed file keeps its previous content as `<file>.codeman-bak` (overwritten by each sync); a file that receives servers carrying `env` or `headers` is left mode `0600`. +## Prompt-cache keep-warm + +Claude Code reports the main conversation's prompt-cache state on its statusline (v2.1.251+); the exporter keeps it per session as `promptCache` on `GET /api/sessions` (`{ warm, ttl, expiresAt, recacheTokensIfCold, misses, lastMissCauses }`, epoch ms). The keep-warm sends one keep-alive turn shortly before an idle session's cache would expire; `keepWarmEnabled` (absent = the app-wide `promptCacheKeepWarm` setting applies, which a session spawned by another session does not inherit), the live `keepWarm` status and `lastRealPromptAt` (epoch ms of the last submit that was not a keep-alive, the cap's clock) ride the same session state. A keep-alive is a submit, so it moves `lastSubmitAt` like any prompt. A claude launch with keep-warm on also carries `"idleCompaction": false` in its `--settings` JSON (Claude Code 2.1.290 or newer). + +| Method | Path | Body | Notes | +| ------ | ---------------------------------- | ------------------------------------ | ----- | +| `POST` | `/api/v1/sessions/:id/keep-warm` | `{ "enabled": true \| false \| null }` | Sets the session's choice; `null` clears it so the app-wide default applies. `200` with `{ keepWarm: { enabled, status } }`. `400 INVALID_INPUT` for a CLI that reports no cache telemetry on its statusline (anything but Claude today). | + +`status` is `{ enabled, idleCompactionOff, state, nextCheckAt?, reason?, nudges, lastNudgeAt?, budgetEndsAt? }` (`idleCompactionOff`: whether the session's current launch carries that opt-out) with `state` one of `off`, `armed` (waiting for the due time), `waiting` (due, deferred by a guard named in `reason`), `nudged` (sent, waiting for the next cache report) or `stopped` (`reason` says why). Two SSE events carry changes: `session:promptCache` (`{ sessionId, ...promptCache }`) and `session:keepWarm` (`{ sessionId, ...status }`), both change-only. The keep-alive turn's `hook:stop` and `hook:idle_prompt` broadcasts carry `acknowledgedReason: "cache keep-alive"`, and no push is sent for them. + ## Webhook notifications Posts the Web Push events to ntfy, Slack, Discord or a generic JSON URL (Settings → Notifications). Off by default. The webhook URL is a bearer secret (anyone holding a Slack/Discord URL can post as it), so it lives in `~/.codeman/webhook.json` (0600), is **never returned**, and is kept out of `settings.json`. All three routes answer `403` for a non-admin in multi-user mode. diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index a50539c8..98273ed1 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -159,6 +159,10 @@ Session create additionally pre-seeds the skill's §0 preamble cache (`seedAgent Further detail: the handheld OFF default comes from the mobile block in `getDefaultSettings()`. `planUsageChipEnabled()` backs the two call sites that must never disagree (the App Settings checkbox and the chip's visibility). The absent-key-reads-as-ON rule mirrors `readWorkspaceHooksEnabled()`. The same statusline payload carries a `prompt_cache` object since Claude Code v2.1.251 (`warm`, `ttl`, `expires_at` in epoch seconds, `recache_tokens_if_cold`, `misses`, `expected_rebuilds`, `hit_ratio`, `last_miss_at`, `last_miss_cause`); `StatusTelemetrySchema` lists every documented scalar field null-tolerant and keeps `causes` as `unknown` (the object gains fields across CLI versions and several are documented as possibly null, so a stricter shape would 400 the whole post and blank the footer — the recurring `.optional()`/null trap), while `parsePromptCache()` (usage-telemetry.ts) normalizes only the subset Codeman stores, so a hostile localhost payload cannot bloat state. The footer shows it as `cache:until HH:MM` (server-local clock, never a countdown: the footer only re-renders on statusline triggers) while warm and `cache:cold` otherwise. Claude re-runs the statusline when `expires_at` passes, so the footer flips to cold on its own. ⚠️ A user's OWN statusline wins the footer (the exporter then posts in the background and prints theirs), so the state ALSO lives on `Session.promptCache` (`setPromptCache()`, in `toState()` for the Session Options → Respawn readout `#promptCacheStatus`, worded by the pure `CodemanPromptCache.format` in constants.js, which also shows the hit rate, the last miss's `causes`, and — after a compaction (`/compact` or Claude Code's own idle compaction) or a tool-result clearing, the first warm report whose `recache_tokens_if_cold` is null after one that carried it — a `Context compacted at HH:MM` note that is NEVER an alert) and the route broadcasts `session:promptCache` (the STORED object, so the compaction stamp rides along) only when the report CHANGES (the statusline fires per assistant message). ⚠️ `setPromptCache()` returns a NEW object when it stamps `compactedAt`, never mutating the parser's output, which is what keeps broadcasting the stored object meaningful; the stamp needs a KNOWN prior `recache_tokens_if_cold` (a first report already inside the null window, such as a mid-session restart's, gets none), is carried while the window holds and dropped at the next request, and never reads `expected_rebuilds`: Claude Code's tracker clears the window and counts the expected rebuild in that same next request, so no single report carries both (2.1.294-2.1.296), and `adoptClaudeSessionId()` drops the whole report on a conversation switch so a post-`/clear` pane never shows the old prefix's warm-until. The last report is restored on a Codeman restart (the constructor's `promptCache`, set directly so it is not re-stamped, like `displayModel`) so the readout is not blank until the pane's next render; the readout is time-aware, so a value whose `expiresAt` has passed reads as cold rather than as a stale warm (and `parsePromptCache` validates the converted-to-ms expiry against the Date range, so a huge `expires_at` cannot render `NaN:NaN`). ⚠️ A launch without the exporter never reports, so `TmuxManager._resolveStatusLine()` records why on the mux record at every create and respawn (`MuxSession.statusLine`, published as `SessionState.statusLineExporter`: `injected`, `collection-off`, `workspace-statusline`, `remote`) and the readout names the cause instead of "not reported yet" forever. It is a spawn-time fact: flipping Plan Usage on later changes nothing until the next launch. ⚠️ This object, not the plan tier, is the source of truth for the TTL: Claude Code requests the hour only for a subscription within plan usage, and drops to five minutes on an API key, a cloud provider or extra-usage credits. +### Prompt-cache keep-warm + +**Prompt-cache keep-warm** (`src/prompt-cache-keepwarm.ts`; per-session opt-in `keepWarmEnabled` on the Respawn tab, app-wide default `promptCacheKeepWarm` and cap `promptCacheKeepWarmMaxHours` under App Settings → System → Automation, both SYNCED, default OFF and 2 h; step 2 of Discussion #554): shortly before an idle claude session's prompt cache would expire, Codeman sends ONE keep-alive turn (`KEEP_WARM_NUDGE`, under 100 characters so Claude Code never reads the burst as a paste) so the next real prompt still reads the conversation from cache. Measured on two sessions of ~110k tokens: the nudge read 109,831 tokens from cache and wrote 70, replied `ok` in three seconds with no tool use, and a real turn seven minutes past the original expiry stayed a hit while the un-nudged control re-wrote 89,882 tokens with `last_miss_cause: ttl_expired_1h`; the payoff on real usage is concentrated in gaps of one to four hours, which is why there is a cap after the last real prompt rather than an indefinite loop (2 h by default: the maintainer's simulation over 30 days of gaps came out slightly cheaper than 3 h). ⚠️ **Codeman owns the idle policy for a keep-warm session.** Claude Code 2.1.289+ can compact an idle 1h-cache session by itself at 90% of the TTL (a server-flagged experiment, `fireAtFraction` tunable down to 0.5), which on the default timing always lands a minute before the nudge and leaves only a summary to keep warm. So a launch with keep-warm on carries `"idleCompaction": false`, folded LAST into the ONE `--settings` JSON with ultracode, the advisor and the statusLine exporter (`buildSpawnCommandFromRegistry`; never a second `--settings` flag, Claude Code takes one), so a keep-warm-off launch is byte-identical to one from before keep-warm (`test/cli-registry-spawn-golden.test.ts`). The `--settings` flag outranks user, project and local settings, which is why the key is conditional: everywhere else it would take Claude Code's own behaviour away with no way back. `decideIdleCompactionOptOut()` (tmux-manager.ts, pure) decides it once per create and respawn: keep-warm on (`keepWarmOn()`: the session's choice, else the app default, read fresh by `readKeepWarmSettings()`), the exporter `injected` (without `prompt_cache` reports keep-warm cannot run, and the opt-out alone would only cost the user the idle compaction), and claude's `idleCompactionSetting` gate (`capabilities.gates`, 2.1.290, the first release whose settings schema has the key; 2.1.289 already idle-compacts with no opt-out), fail-closed through `launchGatePasses()` exactly like `--name`: an unknown version means no key, and a log line says so. The result is recorded on the mux record (`MuxSession.idleCompactionOff`, like `statusLine`, persisted in mux-sessions.json) and published in the keep-warm status as `idleCompactionOff`; a session switched on after launch is kept warm best-effort (a relaunch to install the opt-out would cost exactly the re-write being avoided) and its readout says Claude Code's idle compaction may still run until its next launch, which any relaunch (dead-pane recovery, a custom-model `restartCli()`) fixes for free since every relaunch re-decides. ⚠️ The deadline is the statusline's `prompt_cache.expires_at` (`Session.promptCache`), never idle detection or the plan tier, and a `5m` lifetime is a hard stop (a nudge every four minutes costs more than the miss it prevents; API-key users get the hour from `CLAUDE_CODE_PROMPT_CACHE_TTL=1h` in `envOverrides`). ⚠️ `decideKeepWarm()` is the ONE rulebook and every guard lives in it, tested as a table: the effective opt-in (`keepWarmOn()`; a session an agent spawned, `parentSessionId`, does not inherit the app default, since an idle worker that reported done looks exactly like one that will be prompted again and most never are; its own switch still works), the launch's `statusLineExporter` is `injected` (which also stops every CLI without cache telemetry; the toggle line says why in the readout's own `PROMPT_CACHE_NOT_COLLECTED` words), a warm cache with an expiry, no `compactedAt` this gap (a compaction already replaced the context, and the stamp is dropped at the next request, which re-opens the next gap), not a `5m` lifetime, at least `KEEP_WARM_MIN_TOKENS` at stake, a live pane (a pid AND no recorded `paneExit`: an agent that exited in a `remain-on-exit` pane keeps its pid), no usage-limit pause (auto-resume's, or, read off the same pane capture as the composer, a usage-limit message whose reset is still ahead, since auto-resume tracks pauses only while it is on), no automation (respawn running, a Ralph loop active, a task assigned, or an agent long-polling `wait`), inside the cap measured from `lastRealPromptAt` (else `createdAt`), due at `expires_at` minus `KEEP_WARM_LEAD_MS` but not within `KEEP_WARM_LAST_CHANCE_MS`, idle, no open permission or question dialog, an EMPTY composer (`composerIsEmpty()` in session-submit-verifier.ts reads the last glyph line AND its continuation lines down to the composer's bottom rule, since a draft that opens with a newline leaves the glyph line blank: `send-keys -l` would append the nudge to a draft, and an unreadable pane or a missing rule is a stop, never a guess), and no human keystroke within `KEEP_WARM_RECENT_INPUT_MS`. Transient guards defer by `KEEP_WARM_RETRY_MS` and never past the last-chance line; the composer is read only once everything else says "due". ⚠️ **Two submit clocks.** The nudge goes out through a plain `writeViaMux()` after `beginKeepAliveTurn()`. It IS a submit, so it moves `lastSubmitAt` from the write and again from its `prompt_submitted` hook (`markPromptSubmitted()`): the response viewer's conversation correlation (`resolveActiveClaudeSessionIdFromHistory()`) matches that stamp against Claude's history, which records the nudge like any prompt. The cap needs the last REAL prompt instead, so `Session._stampSubmit()` also moves `lastRealPromptAt` for every submit outside the keep-alive window (a person in the browser, an agent over the API, a typist in an attached tmux client seen through the hook), persisted in `SessionState.lastRealPromptAt` and restored by both rebuild paths so a restart never reopens the window. Any `fromUser` write closes the window at once, and so does a `prompt_submitted` hook whose `prompt` is not the exact nudge (the route hands the hook's prompt text to `markPromptSubmitted()`), so a person's prompt right after a nudge, typed in the browser or in an attached tmux client, counts and alerts normally. The home screens and the tile grid read `lastSubmitAt` only to anchor a WORKING row, so a nudge shows there only for the seconds its turn runs; idle rows key on `lastActivityAt`, which the nudge's reply moves like any output. ⚠️ The same window makes the turn's `stop` hook and `idle_prompt` open pre-acknowledged (`preAcknowledged: 'cache keep-alive'` through `notePrompt()`, the same path `watching` uses), so no tab alert, toast, push or webhook fires, and Read My Mind drops the exact nudge text in `isCapturablePrompt()`, the filter `captureIntentPrompt()` → `intentStore.recordPrompt()` runs every transcript prompt through. ⚠️ Re-evaluation is edge-triggered and cheap: every cache report (`status-telemetry-routes.ts`), the toggle route, a settings change (`rescheduleAll()`), `setupSessionListeners()` and the session's `idle` event (`keepWarmSchedule` in session-listener-wiring.ts, which is also what arms a restored report once the recovered pane is attached); the service keeps one timer per session (clamped to the 2^31-1 ms `setTimeout` honours, so a far-off expiry cannot turn into a 1 ms loop), `remove()`s it with the session and stops with the server, and a `remove()` or `stop()` landing during an evaluation's await cancels it there (nothing sent, armed or published after), and the server logs one `[KeepWarm]` line per status change naming the guard that holds it. Status rides `Session.keepWarm` in `toState()` and the change-only `session:keepWarm` event for the readout; `keepWarmEnabled`, `lastRealPromptAt` and the last `promptCache` report are restored by the constructor on both rebuild paths (that is what re-arms the timer after a restart), the status never is. Tests: `test/prompt-cache-keepwarm.test.ts`, `test/keep-warm-launch.test.ts`, `test/keep-warm-i18n.test.ts`, `test/routes/session-routes-keep-warm.test.ts`, the keep-warm readout in `test/prompt-cache-status.test.ts`, the opt-out literals in `test/cli-registry-spawn-golden.test.ts`, and the keep-alive case in `test/routes/hook-event-routes.test.ts`. + ### Cron jobs **Cron (cron-style `CronJob`s)**: saved, named jobs with a recurring schedule (`once`/`interval`/`daily`/`weekly`), enable/disable, Run Now, next-run calc, and per-job run history (`CronJobRun`). ⚠️ **Distinct from the legacy `ScheduledRun`** (`/api/scheduled`, a run-now duration-bounded autonomous loop) — the two never interact; the legacy concept keeps the `Scheduled*` names, the recurring-job feature is `Cron*`. `CronService` (`src/cron/cron-service.ts`) owns CRUD + the 30s background due-tick (`tickDueJobs`, registered via `cleanup.setInterval` in `server.ts`; `init()` recomputes nextRunAt on boot) and **reuses the existing session layer** (create → `addSession` → `setupSessionListeners` → `startInteractive`/`startShell` → prompt via `writeViaMux`/`write`) rather than rebuilding tmux logic. Next-run math is pure/unit-tested in `cron-time.ts` (SERVER-LOCAL timezone for daily/weekly). Dup-launch guard = `lastDueKey` (jobId:fireTime); schedule is advanced BEFORE launch so a slow launch can't re-trigger. `once` jobs self-disable after firing (`completedOnce`). Persisted via `AppState.cronJobs`/`cronJobRuns` (StateStore accessors). Routes `/api/cron/jobs*` + `/api/cron/runs` (`cron-routes.ts`, `CronPort`); schema `CronJobSchema` (cross-field `superRefine`; the `.partial()` update schema does NOT re-run it); SSE `cron:*`. Frontend `cron-ui.js` (#cronModal). Claude/shell/opencode/codex/gemini/antigravity/pi agent types. Tests: `test/cron-time.test.ts`, `test/cron-service.test.ts`. Design: `docs/cron-discovery.md`. diff --git a/docs/wiki/Keeping-Agents-Running.md b/docs/wiki/Keeping-Agents-Running.md index dfa4fc10..7150e7f6 100644 --- a/docs/wiki/Keeping-Agents-Running.md +++ b/docs/wiki/Keeping-Agents-Running.md @@ -101,7 +101,7 @@ limit-paused. Without that, the next cycle would fire `/clear` and wipe the conv are waiting to resume. This is the single most useful setting for overnight runs on a subscription plan. -## The prompt cache readout +## The prompt cache: readout and keep-warm **Claude only.** On the Respawn tab, above the loop controls. @@ -109,6 +109,14 @@ Claude Code bills a turn against the cached conversation while that cache is war Claude Code 2.1.251 and newer reports the cache state on the statusline after every response, and Codeman's statusline exporter keeps it per session. The row shows **Warm until HH:MM** with the lifetime and the tokens that would be re-cached if it went cold, or **Cold** with the size of the re-cache the next turn pays. When Claude reports them it also shows the session's cache hit rate and the cause of the last cache miss (for example `ttl_expired_1h` or `tools_changed`), so a surprise miss has a reason next to it. If the conversation is compacted while it stays warm (Claude Code now does this on its own on large idle sessions, a manual `/compact` does it too, and so does a clearing of old tool results), the row notes **Context compacted at HH:MM** until the next turn: Claude Code reports no re-cache size until then. None of this raises an alert. The row refreshes live and flips to cold on its own when the lifetime passes, and the last state is kept across a Codeman restart so it is not blank until the session's next turn. Sessions without a statusline of their own also get a short `cache:until HH:MM` or `cache:cold` group in the terminal footer. The readout rides the plan usage chip's telemetry collection, which is on by default on desktop. +**Keep the cache warm while idle** is the switch under the readout. With it on, Codeman sends a one-line keep-alive turn (`Codeman cache keep-alive: no action needed, reply with just "ok".`) about five minutes before the cache would expire. Claude answers `ok`, the request refreshes the cache for another hour, and your next real prompt reads the conversation from cache instead of re-processing it. Measured on sessions of about 110k tokens, the keep-alive cost a cache read plus a one-word reply, a small fraction of the re-write it prevented. + +It only fires when there is nothing else going on: the session is idle with an empty prompt, no permission or question dialog is open, nobody has typed in the last few minutes, no respawn loop, Ralph loop or waiting agent is driving the session, and the session is not paused on a usage limit. If any of those hold it waits a minute and looks again, and gives up a minute before the expiry rather than race it. It also stops by itself a few hours after your last prompt (**Keep warm for at most**, under App Settings → System → Automation, default 2 hours), because the saving is concentrated in gaps of one to four hours and an overnight run of hourly keep-alives would cost more than the single cold turn in the morning. It stands down for the rest of a gap once the context has been compacted. The readout's Status line gains a `Keep-alive:` line that says what it is doing: the next keep-alive and when the window closes, waiting and why, sent at a time with a count, or stopped and why. A session started without the cache readout (Plan Usage off at launch, a workspace with its own statusLine, a remote or Docker session) cannot be kept warm: the switch is greyed out and cannot be switched on (one that is already on can still be switched off), and the Status line says why. + +Recent Claude Code builds can compact a large idle session on their own shortly before the cache expires, which would leave only a summary to keep warm. So a session **started** with keep-warm on also launches with Claude Code's idle compaction switched off (Claude Code 2.1.290 or newer); Codeman then owns the idle policy for it. A session you switch on after it started is still kept warm, but Claude Code may compact it first until its next launch, and the Status line says so. Sessions with keep-warm off launch exactly as before. + +**Keep prompt caches warm** in the same Automation group is the default for sessions that have not chosen on their Respawn tab; the per-session switch always wins. A session another agent started (a worker) does not follow the default: most workers are never prompted again once they report done, so turn it on for one by hand if you will come back to it. The keep-alive turn is Codeman's, not yours: it does not restart the **Keep warm for at most** window, does not rename a tab, Read My Mind ignores it, and the response it ends with raises no alert, toast, push or webhook. It is refused on the five-minute cache lifetime, which is what an API key, a cloud provider or extra-usage credits get; a session on an API key can ask Claude Code for the hour with `CLAUDE_CODE_PROMPT_CACHE_TTL=1h` in its environment overrides. + ## The plan usage chip **Claude only.** A header chip showing live subscription usage, on by default on desktop and diff --git a/docs/wiki/Settings-Reference.md b/docs/wiki/Settings-Reference.md index 2e7238ce..10088f8b 100644 --- a/docs/wiki/Settings-Reference.md +++ b/docs/wiki/Settings-Reference.md @@ -184,7 +184,7 @@ Rebinding for the shortcut registry. See [Keyboard Shortcuts](Keyboard-Shortcuts ### System -`CLAUDE.md` template for new cases, default working directory, the image watcher, and +`CLAUDE.md` template for new cases, default working directory, the image watcher, the prompt-cache keep-warm default (**Keep prompt caches warm**, off) and its cap (**Keep warm for at most**, 2 hours after a session's last prompt; see [Keeping Agents Running](Keeping-Agents-Running)), and Cloudflare tunnel controls including the tunnel URL. The **Diagnostics** group runs `codeman doctor` on the server and lists the agent CLIs, tmux, Node and the optional office tools with their versions and install hints (admin only in multi-user mode). In multi-user @@ -196,7 +196,7 @@ Per session, from the tab. | Panel | Contains | | ---------------- | ------------------------------------------------------------------------------------------- | -| **Respawn** | Auto-resume on usage limit, the respawn cycle configuration, presets, duration. See [Keeping Agents Running](Keeping-Agents-Running). | +| **Respawn** | Auto-resume on usage limit, the prompt cache readout and keep-warm switch, the respawn cycle configuration, presets, duration. See [Keeping Agents Running](Keeping-Agents-Running). | | **Session** | Name, working directory, environment overrides, per-tab pop-out override. | | **Ralph / Todo** | Loop configuration, iteration and todo caps, circuit breaker reset. See [Autonomous Loops](Autonomous-Loops). | | **Summary** | What this session has done: tokens, activity, run summary. | diff --git a/src/config/cli-registry/stock.ts b/src/config/cli-registry/stock.ts index ce5c14e3..69af8cc9 100644 --- a/src/config/cli-registry/stock.ts +++ b/src/config/cli-registry/stock.ts @@ -329,7 +329,12 @@ const CLAUDE: CliEntry = { 'CLAUDE_CODE_MAX_CONTEXT_TOKENS', 'CLAUDE_CONFIG_DIR', ], - gates: { nameFlag: { minVersion: '2.1.224', failClosed: true } }, + // idleCompactionSetting: the `idleCompaction` settings key first ships in 2.1.290 (2.1.289 + // already idle-compacts with no opt-out); see TmuxManager._resolveIdleCompactionOptOut. + gates: { + nameFlag: { minVersion: '2.1.224', failClosed: true }, + idleCompactionSetting: { minVersion: '2.1.290', failClosed: true }, + }, // claude reads `$CLAUDE_CONFIG_DIR/.claude.json` when that is set (checked in 2.1.289). mcpConfig: { path: '.claude.json', diff --git a/src/hooks-config.ts b/src/hooks-config.ts index 83f838eb..ca71dc7f 100644 --- a/src/hooks-config.ts +++ b/src/hooks-config.ts @@ -41,6 +41,7 @@ import { HOOK_TIMEOUT_SECONDS } from './config/auth-config.js'; import { dataPath } from './config/instance.js'; import { readJsonConfig, SETTINGS_PATH } from './web/route-helpers.js'; import { isNearStalledPath, probePath } from './utils/index.js'; +import { DEFAULT_KEEP_WARM_MAX_HOURS, type KeepWarmSettings } from './prompt-cache-keepwarm.js'; /** * Existence check for a WRITER. Unlike the bounded read-side probe (`probePath`), @@ -1144,6 +1145,21 @@ export async function readPinCliSessionNameEnabled(): Promise<boolean> { return settings.pinCliSessionName === true; } +/** + * The prompt-cache keep-warm's app-wide default (`promptCacheKeepWarm`, absent = OFF) and its + * cap (`promptCacheKeepWarmMaxHours`, hours after the last real prompt). Read FRESH like + * {@link readPinCliSessionNameEnabled}: by the service on every evaluation, and at every claude + * create and respawn, which decides the launch's `idleCompaction` opt-out. + */ +export async function readKeepWarmSettings(): Promise<KeepWarmSettings> { + const settings = await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'settings.json', {}); + const hours = settings.promptCacheKeepWarmMaxHours; + return { + keepWarmDefault: settings.promptCacheKeepWarm === true, + maxHours: typeof hours === 'number' && Number.isFinite(hours) && hours > 0 ? hours : DEFAULT_KEEP_WARM_MAX_HOURS, + }; +} + /** * Resolve the statusLine command to pass as an EPHEMERAL `claude --settings` * CLI flag for this one process (see buildSpawnCommandFromRegistry in diff --git a/src/intent-store.ts b/src/intent-store.ts index d1592bf9..2ea821ab 100644 --- a/src/intent-store.ts +++ b/src/intent-store.ts @@ -24,6 +24,7 @@ import { createHash } from 'node:crypto'; import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync } from 'node:fs'; import { dirname } from 'node:path'; import { dataPath } from './config/instance.js'; +import { KEEP_WARM_NUDGE } from './prompt-cache-keepwarm.js'; import type { IntentProfile, IntentPromptEntry } from './types/index.js'; // ========== Limits ========== @@ -62,6 +63,9 @@ export function isCapturablePrompt(text: string): boolean { if (text.startsWith('<system-reminder>')) return false; if (text.startsWith('Caveat: The messages below')) return false; if (text.startsWith('[Request interrupted')) return false; + // Codeman's own prompt-cache keep-alive turn is not something the user meant. The exact + // text, so a prompt of the user's that merely mentions it is still captured. + if (text.trim() === KEEP_WARM_NUDGE) return false; return true; } diff --git a/src/mux-interface.ts b/src/mux-interface.ts index c1df2492..d064f165 100644 --- a/src/mux-interface.ts +++ b/src/mux-interface.ts @@ -61,6 +61,11 @@ export interface MuxSession { ralphEnabled?: boolean; /** Whether the current launch carries the statusLine exporter; set on every create and respawn. */ statusLine?: StatusLineExporter; + /** + * Whether the current launch carries `idleCompaction: false` in its `--settings` JSON (keep-warm + * on at launch, the exporter injected, Claude Code new enough); set on every create and respawn. + */ + idleCompactionOff?: boolean; /** * This record was rebuilt from the tmux socket rather than from Codeman's own * bookkeeping, so everything on it but the name and the pid is a guess. Its @@ -123,6 +128,10 @@ export interface CreateSessionOptions { effort?: EffortLevel; /** Claude advisor model, merged into the same `--settings` JSON (overridable via /advisor in-session) */ advisorModel?: string; + /** The session's own prompt-cache keep-warm choice (undefined = app default); decides the `idleCompaction` opt-out. */ + keepWarm?: boolean; + /** Spawned by another session: it does not inherit the keep-warm app default (`keepWarmOn`). */ + spawnedByAgent?: boolean; /** tmux history-limit (scrollback lines) allocated when this session is created. */ historyLimit?: number; /** Remote execution metadata for local tmux sessions wrapping SSH */ @@ -176,6 +185,10 @@ export interface RespawnPaneOptions { effort?: EffortLevel; /** Claude advisor model (preserved across respawns, merged into the same `--settings` JSON) */ advisorModel?: string; + /** The session's own prompt-cache keep-warm choice, re-read at every relaunch (see CreateSessionOptions). */ + keepWarm?: boolean; + /** Spawned by another session (see CreateSessionOptions). */ + spawnedByAgent?: boolean; /** Original tmux history-limit retained for config parity; respawn cannot resize the existing pane. */ historyLimit?: number; /** Remote execution metadata for local tmux sessions wrapping SSH */ diff --git a/src/prompt-cache-keepwarm.ts b/src/prompt-cache-keepwarm.ts new file mode 100644 index 00000000..fb16aaaa --- /dev/null +++ b/src/prompt-cache-keepwarm.ts @@ -0,0 +1,337 @@ +/** + * @fileoverview Prompt-cache keep-warm: a one-line keep-alive turn shortly before an idle + * claude session's prompt cache would expire, so the next real prompt reads the conversation + * from cache instead of re-processing it at the cache-write rate. + * + * Why it exists: Claude Code gives a subscription's main conversation a one-hour cache and + * lets it expire after an hour idle. Measured on real sessions, nearly every gap over an + * hour went cold and the first turn back re-wrote the whole context at 2x; a keep-alive turn + * costs a cache read plus a one-word reply, a small fraction of the miss it prevents, and the + * payoff is concentrated in gaps of one to four hours, which is why there is a cap after the + * last real prompt rather than an indefinite loop. + * + * Codeman owns the idle policy for these sessions (Discussion #554): a launch with keep-warm + * on also carries `idleCompaction: false` in its one `--settings` JSON, so Claude Code's own + * idle compaction (which fires at 90% of the TTL, a minute before the nudge) cannot replace + * the conversation with a summary first. That is decided once per launch in + * `TmuxManager._resolveIdleCompactionOptOut()` and recorded on the mux record; a session + * switched on after launch is kept warm best-effort and says so. + * + * Where the facts come from: `Session.promptCache` (the statusline's `prompt_cache` object, + * see usage-telemetry.ts) carries `expires_at`, the TTL and the tokens at stake, so nothing + * here infers the lifetime from idle detection or the plan tier. + * + * Shape: `decideKeepWarm()` is the pure rulebook (every guard in one place, tested as a + * table); `PromptCacheKeepWarmService` owns the per-session timer and the ports it needs + * from the server. It never touches the session except through `beginKeepAliveTurn()` and + * `writeViaMux()`: the window `beginKeepAliveTurn()` opens is what keeps the nudge off the + * real-prompt clock and opens its stop hook and idle prompt pre-acknowledged + * (hook-event-routes.ts). The nudge is still a submit, so it moves `lastSubmitAt`. + * + * @module prompt-cache-keepwarm + */ + +import type { KeepWarmStatus, PromptCacheStatus, StatusLineExporter } from './types/index.js'; + +/** The nudge. Under 100 chars on purpose: a longer burst reads as a paste to Claude Code. */ +export const KEEP_WARM_NUDGE = 'Codeman cache keep-alive: no action needed, reply with just "ok".'; +/** Nudge this long before `expires_at`. The nudge's own request is what refreshes the timer. */ +export const KEEP_WARM_LEAD_MS = 5 * 60_000; +/** Re-check cadence while a transient guard (working, dialog, draft in the composer) defers. */ +export const KEEP_WARM_RETRY_MS = 60_000; +/** Give up this close to expiry: a turn takes seconds to land and the Enter may be re-pressed. */ +export const KEEP_WARM_LAST_CHANCE_MS = 60_000; +/** Below this many tokens at stake a cold rewrite is cheap and a turn is not worth it. */ +export const KEEP_WARM_MIN_TOKENS = 20_000; +/** A person typed this recently: they may be about to submit, which refreshes the cache for free. */ +export const KEEP_WARM_RECENT_INPUT_MS = 3 * 60_000; +/** How long after sending the nudge its own stop hook and idle prompt stay pre-acknowledged. */ +export const KEEP_WARM_TURN_WINDOW_MS = 3 * 60_000; +/** Default cap: hours after the last real prompt during which nudges are sent. */ +export const DEFAULT_KEEP_WARM_MAX_HOURS = 2; +/** Longest delay `setTimeout` honours; Node fires anything longer after 1 ms. */ +const MAX_TIMER_MS = 2 ** 31 - 1; + +/** + * Whether keep-warm is on for a session: its own choice, else the app-wide default. A session + * an agent spawned (`parentSessionId`) does not inherit the default: a worker that finished + * its task is idle exactly like one that will be prompted again, and most never are, so only + * its own switch keeps it warm. One function because the launch (the `idleCompaction` + * opt-out) and the service must agree. + */ +export function keepWarmOn(choice: boolean | undefined, appDefault: boolean, spawnedByAgent: boolean): boolean { + return choice ?? (appDefault && !spawnedByAgent); +} + +export interface KeepWarmFacts { + now: number; + /** The session's own choice; undefined = the app-wide default applies. */ + choice: boolean | undefined; + appDefault: boolean; + /** Spawned by another session (`parentSessionId`); see `keepWarmOn`. */ + spawnedByAgent: boolean; + /** Whether this launch carries the statusLine exporter; undefined when unknown. */ + statusLine?: StatusLineExporter; + cache: PromptCacheStatus | null | undefined; + /** Idle by Codeman's own probe and not mid-turn. */ + idle: boolean; + /** A pid and no recorded agent exit (`paneExit`: an exited agent's pane keeps its pid). */ + paneAlive: boolean; + /** Auto-resume's pause, or a usage-limit message on screen whose reset is still ahead. */ + limitPaused: boolean; + /** Respawn running, a Ralph loop active, a task assigned, or an agent long-polling the session. */ + automationActive: boolean; + /** A permission, question or elicitation dialog is open (never the idle item). */ + pendingApproval: boolean; + /** The composer line holds no text; null when the pane could not be read. */ + composerEmpty: boolean | null; + /** Last keystroke a person sent, epoch ms; 0 when none seen. */ + lastHumanInputAt: number; + /** Start of the keep-warm window: the last real (non-keep-alive) prompt, else the session start. */ + budgetAnchorAt: number; + maxKeepWarmMs: number; +} + +export type KeepWarmDecision = + | { action: 'nudge' } + | { action: 'wait'; until: number; reason: string } + | { action: 'stop'; reason: string }; + +const stop = (reason: string): KeepWarmDecision => ({ action: 'stop', reason }); +const wait = (until: number, reason: string): KeepWarmDecision => ({ action: 'wait', until, reason }); + +/** + * The rulebook. Hard stops first (nothing to do until the next cache report or a flag + * change), then the schedule, then the transient guards that defer by `KEEP_WARM_RETRY_MS` + * and never past the last-chance line. Pure: `now` is passed in. + */ +export function decideKeepWarm(f: KeepWarmFacts): KeepWarmDecision { + if (!keepWarmOn(f.choice, f.appDefault, f.spawnedByAgent)) { + return stop( + f.choice === undefined && f.appDefault + ? 'an agent-spawned session follows the app-wide default only when switched on for itself' + : 'off' + ); + } + // Only a launch that carries the exporter ever reports `prompt_cache`; the readout names the + // cause from `statusLineExporter`. This is also what stops a CLI without cache telemetry. + if (f.statusLine !== 'injected') return stop(`no cache reports on this launch (${f.statusLine ?? 'unknown'})`); + const c = f.cache; + if (!c) return stop('no cache report yet'); + if (!c.warm || !c.expiresAt) return stop('cache is cold; the next prompt rebuilds it'); + // Compacted this gap (`/compact`, or Claude Code's own idle compaction on a launch without + // the opt-out): the full context is already gone, so keeping the summary warm buys little. + // The stamp is dropped at the next request, which re-opens the next gap. + if (c.compactedAt != null) return stop('the context was compacted; resumes after the next prompt'); + if (c.ttl === '5m') { + return stop('5-minute cache lifetime; a nudge every four minutes costs more than the miss it prevents'); + } + if ((c.recacheTokensIfCold ?? 0) < KEEP_WARM_MIN_TOKENS) return stop('context too small to be worth a turn'); + if (!f.paneAlive) return stop('no live pane'); + if (f.limitPaused) return stop('usage-limit pause'); + if (f.automationActive) return stop('respawn, Ralph or an agent is driving the session'); + if (f.now >= f.budgetAnchorAt + f.maxKeepWarmMs) return stop('keep-warm window after the last prompt has passed'); + const dueAt = c.expiresAt - KEEP_WARM_LEAD_MS; + if (f.now < dueAt) return wait(dueAt, 'not due yet'); + const lastChance = c.expiresAt - KEEP_WARM_LAST_CHANCE_MS; + if (f.now >= lastChance) return stop('too close to expiry to land a turn'); + const retryAt = Math.min(f.now + KEEP_WARM_RETRY_MS, lastChance); + if (!f.idle) return wait(retryAt, 'session is working'); + if (f.pendingApproval) return wait(retryAt, 'a dialog is open'); + if (f.composerEmpty === null) return stop('cannot read the composer'); + if (!f.composerEmpty) return wait(retryAt, 'the composer holds text'); + if (f.lastHumanInputAt && f.now - f.lastHumanInputAt < KEEP_WARM_RECENT_INPUT_MS) { + return wait(retryAt, 'someone typed a moment ago'); + } + return { action: 'nudge' }; +} + +/** What the service needs from a session; `Session` satisfies it structurally. */ +export interface KeepWarmSessionView { + readonly id: string; + readonly createdAt: number; + readonly pid: number | null; + /** Set when the agent exited in a pane that stays open (`remain-on-exit`); the pid stays. */ + readonly paneExit?: unknown; + readonly parentSessionId?: string; + readonly promptCache: PromptCacheStatus | null; + readonly statusLineExporter?: StatusLineExporter; + /** Whether this launch carries `idleCompaction: false` (`MuxSession.idleCompactionOff`). */ + readonly idleCompactionOptOut: boolean; + readonly keepWarmEnabled: boolean | undefined; + readonly isWorking: boolean; + readonly isLimitPaused: boolean; + readonly lastHumanInputAt: number; + /** Last submit that was not the keep-alive's own (persisted): the cap's clock. */ + readonly lastRealPromptAt: number; + isIdle(): boolean; + beginKeepAliveTurn(windowMs: number): void; + writeViaMux(data: string): Promise<boolean>; +} + +export interface KeepWarmSettings { + /** App-wide default for sessions that have not chosen (`promptCacheKeepWarm`). */ + keepWarmDefault: boolean; + /** Cap in hours after the last real prompt (`promptCacheKeepWarmMaxHours`). */ + maxHours: number; +} + +export interface KeepWarmPorts { + getSession(id: string): KeepWarmSessionView | undefined; + sessionIds(): Iterable<string>; + readSettings(): Promise<KeepWarmSettings>; + isAutomationActive(id: string): boolean; + hasPendingApproval(id: string): boolean; + /** + * Read the pane once a nudge is otherwise due: whether the composer is empty (null when it + * cannot be read), and whether a usage-limit message with a reset still ahead is on screen + * (auto-resume's own pause tracking only runs while auto-resume is on). + */ + readPane(session: KeepWarmSessionView): { composerEmpty: boolean | null; limitShown: boolean }; + /** Store and broadcast the status (the server dedupes against the session's last one). */ + publish(id: string, status: KeepWarmStatus): void; + log?(message: string): void; +} + +/** The scheduler the routes see: one method per trigger, nothing else. */ +export interface KeepWarmScheduler { + /** Re-evaluate a session: after a cache report, a flag change, or a settings change. Resolves once the status is published. */ + schedule(sessionId: string): Promise<void>; + /** Re-evaluate every session (the app-wide default or cap changed). */ + rescheduleAll(): void; + /** The session is gone. */ + remove(sessionId: string): void; +} + +export class PromptCacheKeepWarmService implements KeepWarmScheduler { + private readonly timers = new Map<string, NodeJS.Timeout>(); + private readonly tallies = new Map<string, { nudges: number; lastNudgeAt?: number }>(); + /** One evaluation per session; `remove()` and `stop()` cancel it at its next await. */ + private readonly inFlight = new Map<string, symbol>(); + private stopped = false; + + constructor(private readonly ports: KeepWarmPorts) {} + + schedule(sessionId: string): Promise<void> { + if (this.stopped) return Promise.resolve(); + return this.evaluate(sessionId).catch((err) => { + this.ports.log?.(`[KeepWarm] ${sessionId.slice(0, 8)}: ${err instanceof Error ? err.message : String(err)}`); + }); + } + + rescheduleAll(): void { + for (const id of this.ports.sessionIds()) void this.schedule(id); + } + + remove(sessionId: string): void { + this.clearTimer(sessionId); + this.tallies.delete(sessionId); + this.inFlight.delete(sessionId); + } + + stop(): void { + this.stopped = true; + for (const id of [...this.timers.keys()]) this.clearTimer(id); + } + + private clearTimer(id: string): void { + const t = this.timers.get(id); + if (t) clearTimeout(t); + this.timers.delete(id); + } + + private arm(id: string, at: number): void { + this.clearTimer(id); + const t = setTimeout(() => void this.schedule(id), Math.min(Math.max(0, at - Date.now()), MAX_TIMER_MS)); + t.unref?.(); + this.timers.set(id, t); + } + + private async evaluate(id: string): Promise<void> { + if (this.stopped || this.inFlight.has(id)) return; + const token = Symbol(id); + this.inFlight.set(id, token); + // A session removed (or the service stopped) during an await gets nothing after it: no + // nudge, no tally, no timer, no status. + const cancelled = () => this.stopped || this.inFlight.get(id) !== token; + try { + this.clearTimer(id); + const session = this.ports.getSession(id); + if (!session) return; + const settings = await this.ports.readSettings(); + if (cancelled()) return; + const tally = this.tallies.get(id) ?? { nudges: 0 }; + const facts: KeepWarmFacts = { + now: Date.now(), + choice: session.keepWarmEnabled, + appDefault: settings.keepWarmDefault, + spawnedByAgent: !!session.parentSessionId, + statusLine: session.statusLineExporter, + cache: session.promptCache, + idle: session.isIdle() && !session.isWorking, + paneAlive: session.pid !== null && !session.paneExit, + limitPaused: session.isLimitPaused, + automationActive: this.ports.isAutomationActive(id), + pendingApproval: this.ports.hasPendingApproval(id), + // Optimistic until everything else says "due": the pane read is the one costly fact. + composerEmpty: true, + lastHumanInputAt: session.lastHumanInputAt, + budgetAnchorAt: session.lastRealPromptAt || session.createdAt, + maxKeepWarmMs: settings.maxHours * 3_600_000, + }; + let decision = decideKeepWarm(facts); + if (decision.action === 'nudge') { + const pane = this.ports.readPane(session); + decision = decideKeepWarm({ + ...facts, + composerEmpty: pane.composerEmpty, + limitPaused: facts.limitPaused || pane.limitShown, + }); + } + const enabled = keepWarmOn(facts.choice, facts.appDefault, facts.spawnedByAgent); + const base = { + enabled, + idleCompactionOff: session.idleCompactionOptOut, + nudges: tally.nudges, + lastNudgeAt: tally.lastNudgeAt, + budgetEndsAt: facts.budgetAnchorAt + facts.maxKeepWarmMs, + }; + if (decision.action === 'nudge') { + session.beginKeepAliveTurn(KEEP_WARM_TURN_WINDOW_MS); + const ok = await session.writeViaMux(`${KEEP_WARM_NUDGE}\r`); + if (cancelled()) return; + if (ok) { + tally.nudges += 1; + tally.lastNudgeAt = Date.now(); + this.tallies.set(id, tally); + } + this.ports.log?.( + `[KeepWarm] ${ok ? 'nudged' : 'failed to nudge'} ${id.slice(0, 8)} with ${facts.cache?.recacheTokensIfCold ?? '?'} tokens at stake (nudge ${tally.nudges})` + ); + // The nudge's own cache report re-schedules; this timer is the fallback if none arrives. + const nextCheckAt = Date.now() + KEEP_WARM_LEAD_MS; + this.arm(id, nextCheckAt); + this.ports.publish(id, { + ...base, + nudges: tally.nudges, + lastNudgeAt: tally.lastNudgeAt, + state: ok ? 'nudged' : 'waiting', + nextCheckAt, + reason: ok ? undefined : 'the keep-alive could not be written', + }); + } else if (decision.action === 'wait') { + this.arm(id, decision.until); + this.ports.publish(id, { + ...base, + state: decision.reason === 'not due yet' ? 'armed' : 'waiting', + nextCheckAt: decision.until, + reason: decision.reason === 'not due yet' ? undefined : decision.reason, + }); + } else { + this.ports.publish(id, { ...base, state: enabled ? 'stopped' : 'off', reason: decision.reason }); + } + } finally { + if (this.inFlight.get(id) === token) this.inFlight.delete(id); + } + } +} diff --git a/src/session-cli-registry-bridge.ts b/src/session-cli-registry-bridge.ts index bb2e4971..0c82c4bd 100644 --- a/src/session-cli-registry-bridge.ts +++ b/src/session-cli-registry-bridge.ts @@ -68,6 +68,12 @@ export interface SpawnBridgeOptions { * rendered as two independent params. */ statusLineCommand?: string; + /** + * Add `idleCompaction: false` to the same `--settings` JSON: Codeman's prompt-cache keep-warm + * owns the idle policy for this launch. Decided (keep-warm on, exporter injected, version gate + * passed) by TmuxManager._resolveIdleCompactionOptOut, which records it on the mux record. + */ + idleCompactionOff?: boolean; } /** @@ -168,6 +174,20 @@ function resolveGatesPassed(entry: CliEntry, resolveVersion: () => string | null return passed; } +/** + * Whether `mode`'s `capabilities.gates[gate]` passes, through the same fail-closed resolution + * the launch template uses (an unknown version passes no gate). For a launch-time decision that + * is also recorded, like the `idleCompaction` opt-out. `claudeCliVersion` as in + * SpawnBridgeOptions: undefined probes the local CLI, null means unknown. + */ +export function launchGatePasses(mode: string, gate: string, claudeCliVersion?: string | null): boolean { + const entry = getCli(mode); + if (!entry?.capabilities.gates[gate]) return false; + return resolveGatesPassed(entry, () => + claudeCliVersion !== undefined ? claudeCliVersion : getClaudeCliVersion() + ).has(gate); +} + /** * Render the spawn command for `entry` from the legacy option bag. Returns `undefined` for a * `shell`-kind entry (or any entry declaring no launch variants), which callers take as "fall @@ -208,10 +228,15 @@ export function buildSpawnCommandFromRegistry(entry: CliEntry, options: SpawnBri // rendering them as independent params would let the last one silently win. Claude-only in // practice: only claude's launch template renders this engine value, so another CLI's // session carrying an advisorModel launches exactly as before. - // Key order (ultracode, advisorModel, statusLine) keeps a launch without an advisor - // byte-identical to one from before the advisor existed. + // Key order (ultracode, advisorModel, statusLine, idleCompaction) keeps a launch without an + // advisor or keep-warm byte-identical to one from before either existed. const advisorSettings = buildAdvisorSettings(options.advisorModel); - if ((effortFlag === '--settings' && effortValue) || advisorSettings.advisorModel || options.statusLineCommand) { + if ( + (effortFlag === '--settings' && effortValue) || + advisorSettings.advisorModel || + options.statusLineCommand || + options.idleCompactionOff + ) { const settingsObj: Record<string, unknown> = { ...(effortFlag === '--settings' && effortValue ? JSON.parse(effortValue) : {}), ...advisorSettings, @@ -219,6 +244,7 @@ export function buildSpawnCommandFromRegistry(entry: CliEntry, options: SpawnBri if (options.statusLineCommand) { settingsObj.statusLine = { type: 'command', command: options.statusLineCommand }; } + if (options.idleCompactionOff) settingsObj.idleCompaction = false; engineValues.effortSettingsJson = JSON.stringify(settingsObj); } diff --git a/src/session-submit-verifier.ts b/src/session-submit-verifier.ts index 063ad6ce..0b6e2728 100644 --- a/src/session-submit-verifier.ts +++ b/src/session-submit-verifier.ts @@ -65,6 +65,28 @@ export function promptStillInComposer(screen: string, prompt: string, glyph: str return head.length > 0 && composer.startsWith(head); } +/** + * Whether the composer holds nothing: the LAST `glyph` line after the glyph, and every + * continuation line below it down to the composer's bottom rule (a draft that opens with a + * newline leaves the glyph line itself blank). Undefined when there is no composer line or no + * rule under it to bound the read (a shell, a dialog, a direct-PTY session): nothing can be + * said. The prompt-cache keep-warm refuses to type unless this is true: `send-keys -l` would + * append its nudge to whatever draft sits there, and the Enter would submit both. + */ +export function composerIsEmpty(screen: string, glyph: string): boolean | undefined { + if (!glyph) return undefined; + // The structure is read at column 0, before any trimming: Claude Code draws the glyph line and + // both rules there and indents every continuation line, so a draft line that holds the glyph or + // starts with a rule (` ❯`, ` ───`) can never pass for the composer's own edges. + const lines = stripAnsi(screen).split('\n'); + let at = lines.length - 1; + while (at >= 0 && !lines[at].startsWith(glyph)) at--; + if (at < 0) return undefined; + const end = lines.findIndex((l, i) => i > at && l.startsWith('\u2500')); + if (end < 0) return undefined; + return compact(lines[at].slice(glyph.length) + lines.slice(at + 1, end).join('')).length === 0; +} + export interface SubmitVerifierDeps { /** The rendered pane, or null when there is none to read. */ capture: () => string | null | undefined; diff --git a/src/session.ts b/src/session.ts index 63ac4aa8..a9e1fc63 100644 --- a/src/session.ts +++ b/src/session.ts @@ -65,6 +65,8 @@ import { type SessionWriteOptions, type PaneExit, type PromptCacheStatus, + type StatusLineExporter, + type KeepWarmStatus, } from './types.js'; import { resolveAndClaimOmpSessionId } from './utils/omp-session-resolver.js'; import { claudeTranscriptExists } from './utils/claude-transcript.js'; @@ -127,6 +129,7 @@ import { DEFAULT_TMUX_HISTORY_LIMIT } from './config/terminal-history.js'; import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js'; import { getCli } from './config/cli-registry/registry.js'; import { SubmitVerifier } from './session-submit-verifier.js'; +import { KEEP_WARM_NUDGE } from './prompt-cache-keepwarm.js'; import { compileVersionRegex } from './config/cli-registry/patterns.js'; import { resolveSessionCliVersion } from './utils/cli-resolver.js'; import { @@ -597,6 +600,16 @@ export class Session extends EventEmitter { private _watching: string | null = null; /** Last `prompt_cache` report from the statusline exporter; null until Claude sends one. */ private _promptCache: PromptCacheStatus | null = null; + /** Prompt-cache keep-warm opt-in; undefined = follow the app-wide default. Persisted. */ + private _keepWarmEnabled: boolean | undefined; + /** Last status the keep-warm service published for this session; ephemeral. */ + private _keepWarm: KeepWarmStatus | null = null; + /** Until when the current turn counts as Codeman's own keep-alive (hooks open pre-acknowledged). */ + private _keepAliveTurnUntil = 0; + /** Last keystroke a person sent through a `fromUser` write. */ + private _lastHumanInputAt = 0; + /** Last submit that was not the keep-alive's own; the keep-warm cap's clock. Persisted. */ + private _lastRealPromptAt = 0; /** Lazily compiled `capabilities.workDetect.workingLine`. See _workingLinePattern(). */ private _workingLineRe: RegExp | undefined = undefined; /** Lazily compiled `capabilities.workDetect.watchingLine`. See _watchingLinePattern(). */ @@ -804,7 +817,7 @@ export class Session extends EventEmitter { // The session that spawned this one (tab lineage lines). Resolved by the create // route before it reaches here, so this is always either an id that existed at - // create time or undefined. Decoration only — see SessionState.parentSessionId. + // create time or undefined. Decoration only (plus the keep-warm default); see SessionState.parentSessionId. private readonly _parentSessionId?: string; // Session color for visual differentiation @@ -899,6 +912,10 @@ export class Session extends EventEmitter { attachmentHistory?: SessionAttachmentHistoryItem[]; /** Restored wall-clock ms of the pane's last Enter (see `lastSubmitAt`). */ lastSubmitAt?: number; + /** Restored last non-keep-alive submit (see `lastRealPromptAt`). */ + lastRealPromptAt?: number; + /** Restored prompt-cache keep-warm choice (see `keepWarmEnabled`). */ + keepWarmEnabled?: boolean; /** Restored conversation chain, oldest first (see `claudeSessionChain`). */ claudeSessionChain?: string[]; /** Restored agent-exit observation for this session's pane (see `paneExit`). */ @@ -975,6 +992,9 @@ export class Session extends EventEmitter { // response viewer re-derive the live conversation without waiting for the // user to type again. this._lastSubmitAt = config.lastSubmitAt ?? 0; + // Restored so a restart neither reopens the keep-warm window nor drops the choice. + this._lastRealPromptAt = config.lastRealPromptAt ?? 0; + this._keepWarmEnabled = config.keepWarmEnabled; // Restored chain: its tail is the conversation the CLI was actually on when // the server stopped, which outranks the launch id seeded just above. The // FIRST-HAND flag is deliberately NOT restored — a persisted claim is not a @@ -1479,12 +1499,17 @@ export class Session extends EventEmitter { * report. It rides `toState()` for the Session Options readout and is restored on a * Codeman restart (constructor `promptCache`) so the row is not blank until the pane's * next render; the readout is time-aware, so a value whose `expiresAt` has passed renders - * as cold rather than as a stale warm. + * as cold rather than as a stale warm, and a future one lets the keep-warm re-arm without a turn. */ get promptCache(): PromptCacheStatus | null { return this._promptCache; } + /** Whether the current launch carries the statusLine exporter (`MuxSession.statusLine`); undefined when unknown. */ + get statusLineExporter(): StatusLineExporter | undefined { + return this._muxSession?.statusLine; + } + /** * Replace the prompt-cache report; true when it differs from the previous one. Stamps * `compactedAt` when this report ENTERS the window in which Claude Code reads @@ -1513,6 +1538,60 @@ export class Session extends EventEmitter { return changed; } + /** Prompt-cache keep-warm opt-in; undefined means the app-wide default applies. */ + get keepWarmEnabled(): boolean | undefined { + return this._keepWarmEnabled; + } + + setKeepWarmEnabled(enabled: boolean | undefined): void { + this._keepWarmEnabled = enabled; + } + + /** What the keep-warm service last published for this session, or null before its first evaluation. */ + get keepWarm(): KeepWarmStatus | null { + return this._keepWarm; + } + + /** Replace the keep-warm status; true when it differs from the previous one. */ + setKeepWarmStatus(status: KeepWarmStatus): boolean { + const changed = JSON.stringify(status) !== JSON.stringify(this._keepWarm); + this._keepWarm = status; + return changed; + } + + /** + * True while the running turn is Codeman's own keep-alive nudge: its stop hook and idle + * prompt open pre-acknowledged, and its submit moves `lastSubmitAt` (it IS a submit, and the + * response viewer's conversation correlation needs it) but not `lastRealPromptAt`. Any + * `fromUser` write ends it early, so a person's prompt right after a nudge counts and alerts normally. + */ + get keepAliveTurnActive(): boolean { + return Date.now() < this._keepAliveTurnUntil; + } + + beginKeepAliveTurn(windowMs: number): void { + this._keepAliveTurnUntil = Date.now() + windowMs; + } + + /** Last keystroke a person sent (`fromUser` write), epoch ms; 0 when none this process. */ + get lastHumanInputAt(): number { + return this._lastHumanInputAt; + } + + /** + * Last submit that was not the keep-alive's own, from any writer or the UserPromptSubmit hook + * (a person typing in an attached tmux client reaches Codeman only through the hook); epoch + * ms, 0 if none. The keep-warm cap counts from it; persisted so a restart keeps it. + */ + get lastRealPromptAt(): number { + return this._lastRealPromptAt; + } + + /** Whether this launch carries `idleCompaction: false` (`MuxSession.idleCompactionOff`). */ + get idleCompactionOptOut(): boolean { + return this._muxSession?.idleCompactionOff === true; + } + /** * Check if the session's process tree has active child processes beyond Claude itself. * Detects running bash tools, test suites, builds, servers, etc. that Claude spawned. @@ -2008,7 +2087,9 @@ export class Session extends EventEmitter { autoResumeEnabled: this._autoOps.autoResumeEnabled, autoResumeAt: this._autoOps.autoResumeAt ?? undefined, promptCache: this._promptCache ?? undefined, - statusLineExporter: this._muxSession?.statusLine, + statusLineExporter: this.statusLineExporter, + keepWarmEnabled: this._keepWarmEnabled, + keepWarm: this._keepWarm ?? undefined, imageWatcherEnabled: this._imageWatcherEnabled, pinned: this._pinned || undefined, pinnedAt: this._pinned ? (this._pinnedAt ?? undefined) : undefined, @@ -2057,6 +2138,7 @@ export class Session extends EventEmitter { paneExit: this._paneExit ?? undefined, attachmentHistory: this.attachmentHistory.length > 0 ? this.attachmentHistory : undefined, lastSubmitAt: this._lastSubmitAt || undefined, + lastRealPromptAt: this._lastRealPromptAt || undefined, // Only a chain the CLI's own hooks vouched for is persisted, and only when // the pane actually moved conversation. Its LAST entry is the live one, so // it is also what restores `claudeSessionId` across a restart — `start()` @@ -2425,6 +2507,8 @@ export class Session extends EventEmitter { unsetEnvKeys: this._pendingEnvUnsets.size > 0 ? [...this._pendingEnvUnsets] : undefined, effort: this._effort, advisorModel: this._advisorModel, + keepWarm: this._keepWarmEnabled, + spawnedByAgent: !!this._parentSessionId, historyLimit: this._tmuxHistoryLimit, remote: this._remote, docker: this._docker, @@ -2904,6 +2988,8 @@ export class Session extends EventEmitter { envOverrides: this._envOverrides, effort: this._effort, advisorModel: this._advisorModel, + keepWarm: this._keepWarmEnabled, + spawnedByAgent: !!this._parentSessionId, historyLimit: this._tmuxHistoryLimit, remote: this._remote, docker: this._docker, @@ -4650,9 +4736,13 @@ export class Session extends EventEmitter { */ private _trackSubmit(data: string, options: SessionWriteOptions): string[] { const submitted = options.fromUser && this._acceptsPrompts ? this._submittedPromptTracker.feed(data) : []; - if (data.includes('\r') || data.includes('\n')) { - this._lastSubmitAt = Date.now(); + const hasEnter = data.includes('\r') || data.includes('\n'); + if (options.fromUser) { + this._lastHumanInputAt = Date.now(); + // A person is here: whatever turn follows is theirs, not the keep-alive's. + this._keepAliveTurnUntil = 0; } + if (hasEnter) this._stampSubmit(); return submitted; } @@ -4678,9 +4768,20 @@ export class Session extends EventEmitter { * `_trackSubmit` only sees input that flows through Codeman's write path, so * a pane the user drives by attaching to tmux directly never stamped this and * `lastSubmitAt` stayed 0 for its whole life. + * + * `prompt` is the hook's own prompt text: anything but the keep-alive's ends the keep-alive + * window, because a person typing in an attached tmux client reaches Codeman only here, and + * their prompt must count as real and alert normally. */ - markPromptSubmitted(): void { + markPromptSubmitted(prompt?: string): void { + if (prompt !== undefined && prompt.trim() !== KEEP_WARM_NUDGE) this._keepAliveTurnUntil = 0; + this._stampSubmit(); + } + + /** Every submit moves `lastSubmitAt`; only one outside a keep-alive turn moves `lastRealPromptAt`. */ + private _stampSubmit(): void { this._lastSubmitAt = Date.now(); + if (!this.keepAliveTurnActive) this._lastRealPromptAt = this._lastSubmitAt; } /** diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index d3a2d174..cbe5c94f 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -67,12 +67,15 @@ import { missingCliMessage, resolveCliBinDir } from './utils/cli-resolver.js'; import { buildSpawnCommandFromRegistry, configSetenvValues, + launchGatePasses, legacyConfigForMode, } from './session-cli-registry-bridge.js'; +import { keepWarmOn } from './prompt-cache-keepwarm.js'; import type { CliEntry } from './config/cli-registry/types.js'; import { resolveStatusLineCliCommand, readPlanUsageTelemetryEnabled, + readKeepWarmSettings, findEffectiveUserStatusLineCommand, } from './hooks-config.js'; import { @@ -1070,6 +1073,28 @@ export function buildCodexCommand(config?: CodexConfig): string { return buildSpawnCommandFromRegistry(entry, { mode: 'codex', sessionId: '', codexConfig: config }) ?? 'codex'; } +/** + * Whether a launch carries `idleCompaction: false` in its one `--settings` JSON: keep-warm on + * at launch (`keepWarmOn`), the statusLine exporter injected (without its `prompt_cache` reports + * keep-warm cannot run, and the opt-out alone would only cost the user Claude Code's own idle + * compaction), and the CLI's `idleCompactionSetting` gate passed, fail-closed like `--name`: + * an unknown version means no key. Pure; `claudeCliVersion` as in buildSpawnCommand. + */ +export function decideIdleCompactionOptOut(o: { + mode: SessionMode; + statusLine: StatusLineExporter | undefined; + keepWarm: boolean | undefined; + keepWarmDefault: boolean; + spawnedByAgent: boolean | undefined; + claudeCliVersion?: string | null; +}): boolean { + return ( + o.statusLine === 'injected' && + keepWarmOn(o.keepWarm, o.keepWarmDefault, !!o.spawnedByAgent) && + launchGatePasses(o.mode, 'idleCompactionSetting', o.claudeCliVersion) + ); +} + export function buildSpawnCommand(options: { mode: SessionMode; sessionId: string; @@ -1091,6 +1116,8 @@ export function buildSpawnCommand(options: { advisorModel?: string; /** Resolved by resolveStatusLineCliCommand (hooks-config.ts) — undefined skips the exporter. Claude only. */ statusLineCommand?: string; + /** Add `idleCompaction: false` to the launch's one `--settings` JSON (see decideIdleCompactionOptOut). */ + idleCompactionOff?: boolean; /** Name pinned on claude as `--name` (version-gated, sanitized; local spawns only). By default only a user-chosen name; `pinCliSessionName` pins every tab name: see `Session.cliPinnedName`. */ sessionName?: string; /** @@ -2266,6 +2293,28 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { return { command, state: command ? 'injected' : enabled ? 'workspace-statusline' : 'collection-off' }; } + /** + * Whether this launch carries `idleCompaction: false` (decideIdleCompactionOptOut), with the + * keep-warm app default read fresh like the plan-usage switch above. Logged when keep-warm is + * on but the version gate keeps the key off, so a session that may still idle-compact says why. + */ + private async _resolveIdleCompactionOptOut( + mode: SessionMode, + statusLine: StatusLineExporter | undefined, + keepWarm: boolean | undefined, + spawnedByAgent: boolean | undefined + ): Promise<boolean> { + if (statusLine !== 'injected') return false; // no settings read for a launch that can never carry it + const { keepWarmDefault } = await readKeepWarmSettings(); + const off = decideIdleCompactionOptOut({ mode, statusLine, keepWarm, keepWarmDefault, spawnedByAgent }); + if (!off && keepWarmOn(keepWarm, keepWarmDefault, !!spawnedByAgent)) { + console.log( + `[TmuxManager] Keep-warm is on but Claude Code's version is unknown or predates the idleCompaction setting; launching without the opt-out` + ); + } + return off; + } + /** * Export the user's own REAL statusLine command (found by * findEffectiveUserStatusLineCommand) via tmux setenv, so the shared @@ -2326,6 +2375,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { envOverrides, effort, advisorModel, + keepWarm, + spawnedByAgent, historyLimit = DEFAULT_TMUX_HISTORY_LIMIT, remote, docker, @@ -2389,6 +2440,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { workingDir, !!(remote || docker) ); + const idleCompactionOff = await this._resolveIdleCompactionOptOut(mode, statusLine, keepWarm, spawnedByAgent); // The user's own REAL statusLine, if any (walked via Claude Code's own // settings precedence) — exported below so the shared exporter script // can wrap it. Only worth discovering when we're actually injecting. @@ -2413,6 +2465,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { effort, advisorModel, statusLineCommand, + idleCompactionOff, sessionName: cliName, }); @@ -2554,6 +2607,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { attached: false, name, statusLine, + idleCompactionOff, }; this.sessions.set(sessionId, session); @@ -2664,6 +2718,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { unsetEnvKeys, effort, advisorModel, + keepWarm, + spawnedByAgent, remote, docker, cliName, @@ -2684,6 +2740,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { workingDir, !!(remote || docker) ); + const idleCompactionOff = await this._resolveIdleCompactionOptOut(mode, statusLine, keepWarm, spawnedByAgent); const userStatusLineCommand = statusLineCommand ? await findEffectiveUserStatusLineCommand(workingDir) : undefined; const baseCmd = buildSpawnCommand({ @@ -2705,6 +2762,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { effort, advisorModel, statusLineCommand, + idleCompactionOff, sessionName: cliName, }); const config = niceConfig || DEFAULT_NICE_CONFIG; @@ -2747,8 +2805,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { this.clearPaneExit(muxName); const pid = this.getPanePid(muxName); if (pid) session.pid = pid; - if (session.statusLine !== statusLine) { + if (session.statusLine !== statusLine || session.idleCompactionOff !== idleCompactionOff) { session.statusLine = statusLine; + session.idleCompactionOff = idleCompactionOff; this.saveSessions(); } return pid; diff --git a/src/types/session.ts b/src/types/session.ts index 1d701bcd..7186b644 100644 --- a/src/types/session.ts +++ b/src/types/session.ts @@ -797,6 +797,33 @@ export interface PromptCacheStatus { */ export type StatusLineExporter = 'injected' | 'collection-off' | 'workspace-statusline' | 'remote'; +/** + * What the prompt-cache keep-warm (`prompt-cache-keepwarm.ts`) is doing for a session, for + * the Session Options readout. Ephemeral: published through `toState()` and the + * `session:keepWarm` event, never restored. + */ +export interface KeepWarmStatus { + /** Effective opt-in: the session's own flag, else the app-wide default (see `keepWarmOn`). */ + enabled: boolean; + /** + * Whether this launch carries `idleCompaction: false` (`MuxSession.idleCompactionOff`). False + * on a session switched on after launch: Claude Code's own idle compaction may still run + * until its next launch. + */ + idleCompactionOff: boolean; + /** `armed` = waiting for the due time, `waiting` = due but deferred by a guard, `nudged` = sent, waiting for the next cache report. */ + state: 'off' | 'armed' | 'waiting' | 'nudged' | 'stopped'; + /** Next evaluation, epoch ms (armed, waiting, nudged). */ + nextCheckAt?: number; + /** Why it is waiting or stopped, in words. */ + reason?: string; + /** Nudges sent this session so far. */ + nudges: number; + lastNudgeAt?: number; + /** When the keep-warm window after the last real prompt closes, epoch ms. */ + budgetEndsAt?: number; +} + export interface SessionState { /** Unique session identifier */ id: string; @@ -819,7 +846,9 @@ export interface SessionState { * * ⚠️ UI DECORATION ONLY — it draws the lineage lines between tabs. It is never an * ownership, permission, or lifecycle signal: a child outlives its parent, and an - * unresolvable value is dropped rather than failing the spawn. + * unresolvable value is dropped rather than failing the spawn. Its one non-visual reader + * picks a default, nothing more: a session an agent spawned does not inherit the app-wide + * prompt-cache keep-warm default (`keepWarmOn`), though its own switch still works. */ parentSessionId?: string; /** ID of currently assigned task, null if none */ @@ -858,6 +887,10 @@ export interface SessionState { promptCache?: PromptCacheStatus; /** Why a cache report will or will not arrive (see `StatusLineExporter`); absent when unknown. */ statusLineExporter?: StatusLineExporter; + /** Prompt-cache keep-warm opt-in; absent = follow the app-wide `promptCacheKeepWarm` default. */ + keepWarmEnabled?: boolean; + /** What the keep-warm is doing right now; ephemeral, never restored. */ + keepWarm?: KeepWarmStatus; /** Pinned to the top of the session manager list (COD-139) */ pinned?: boolean; /** When the session was pinned (epoch ms) — orders the pinned group, most-recent-first */ @@ -972,6 +1005,13 @@ export interface SessionState { * again until the pane's own Enter is known. */ lastSubmitAt?: number; + /** + * Wall-clock ms of the last submit that was NOT the prompt-cache keep-alive + * (Session.lastRealPromptAt): the keep-warm cap's clock. Persisted so a restart + * does not reopen the window, which `lastSubmitAt` cannot do since every nudge + * moves it. + */ + lastRealPromptAt?: number; /** * Claude conversations this pane has been on, oldest first, current last. * Written ONLY from a first-hand `UserPromptSubmit`/`Stop` hook payload — diff --git a/src/web/approval-inbox.ts b/src/web/approval-inbox.ts index c691fc98..4d6aacbc 100644 --- a/src/web/approval-inbox.ts +++ b/src/web/approval-inbox.ts @@ -123,6 +123,12 @@ interface NotePromptArgs { * `ApprovalItem.answerInTerminal`. */ answerInTerminal?: boolean; + /** + * An idle prompt that should open ALREADY acknowledged for a reason other than + * `watching`, e.g. `cache keep-alive` when the turn that just ended was Codeman's own + * prompt-cache nudge. Same consequence as `watching`: the item exists, only its alert is spent. + */ + preAcknowledged?: string; /** Returns the raw (ANSI-bearing) pane frame, or null when unavailable. */ capture?: () => string | null; /** @@ -285,9 +291,9 @@ export class ApprovalInbox { cwd: args.cwd, ...(args.answerInTerminal ? { answerInTerminal: true } : {}), }; - if (args.kind === 'idle' && args.watching) { + if (args.kind === 'idle' && (args.preAcknowledged || args.watching)) { item.acknowledgedAt = item.createdAt; - item.acknowledgedReason = `watching ${args.watching}`; + item.acknowledgedReason = args.preAcknowledged ?? `watching ${args.watching}`; } if (!args.capture && args.context) item.context = normalizeCapturedFrame(args.context); this.applyCapture(item, args.capture); diff --git a/src/web/ports/index.ts b/src/web/ports/index.ts index c75b8c0d..c9499553 100644 --- a/src/web/ports/index.ts +++ b/src/web/ports/index.ts @@ -15,3 +15,4 @@ export type { AuthPort } from './auth-port.js'; export type { OrchestratorPort } from './orchestrator-port.js'; export type { CronPort } from './cron-port.js'; export type { TabLayoutPort } from './tab-layout-port.js'; +export type { KeepWarmPort } from './keep-warm-port.js'; diff --git a/src/web/ports/keep-warm-port.ts b/src/web/ports/keep-warm-port.ts new file mode 100644 index 00000000..fd5c8fdc --- /dev/null +++ b/src/web/ports/keep-warm-port.ts @@ -0,0 +1,10 @@ +/** + * @fileoverview Port for the prompt-cache keep-warm scheduler (`prompt-cache-keepwarm.ts`). + * Routes only ever trigger a re-evaluation; the rulebook and the timers stay in the service. + */ + +import type { KeepWarmScheduler } from '../../prompt-cache-keepwarm.js'; + +export interface KeepWarmPort { + readonly keepWarm: KeepWarmScheduler; +} diff --git a/src/web/public/app.js b/src/web/public/app.js index 62716497..153f71bf 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -194,6 +194,7 @@ const _SSE_HANDLER_MAP = [ [SSE_EVENTS.SESSION_CLI_INFO, '_onSessionCliInfo'], [SSE_EVENTS.SESSION_STATUS_TELEMETRY, '_onSessionStatusTelemetry'], [SSE_EVENTS.SESSION_PROMPT_CACHE, '_onSessionPromptCache'], + [SSE_EVENTS.SESSION_KEEP_WARM, '_onSessionKeepWarm'], // Scheduled runs [SSE_EVENTS.SCHEDULED_CREATED, '_onScheduledCreated'], diff --git a/src/web/public/constants.js b/src/web/public/constants.js index cd44962f..611f7ec5 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -733,7 +733,7 @@ function computeConnectionLossUi(input) { // it says why instead of "not reported yet" forever. const PROMPT_CACHE_NOT_COLLECTED = { 'collection-off': - 'Not collected: Plan Usage was off when this session started. Turn it on in App Settings; sessions started after that report it.', + 'Not collected: Plan Usage was off when this session started. Turn it on in App Settings → Header & Panels, then start a new session; this one cannot pick it up.', 'workspace-statusline': "Not collected: this workspace's .claude/settings.local.json sets its own statusLine, which Codeman never replaces.", remote: 'Not collected for remote and Docker sessions.', @@ -763,6 +763,45 @@ function formatPromptCacheStatus(cache, now, statusLine) { return parts.join(' '); } +// Session Options → Respawn → Prompt cache → Status: the keep-warm line under the cache +// readout, saying what the keep-warm service is doing (`session.keepWarm`, published by the +// server on `session:keepWarm`), compact and dot-separated. Empty when there is nothing to +// say: switched off, not published yet, or a launch without the statusLine exporter, where +// the readout's own "Not collected" sentence on the same row already says why and +// session-ui.js greys the switch. Pure: no DOM, `now` is passed in. +function formatKeepWarmStatus(status, now, statusLine) { + if (PROMPT_CACHE_NOT_COLLECTED[statusLine]) return ''; + if (!status || typeof status.state !== 'string') return ''; + const clock = (ms) => new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + const window = Number.isFinite(status.budgetEndsAt) + ? status.budgetEndsAt > now + ? `stops ${clock(status.budgetEndsAt)}` + : 'window closed' + : ''; + // Keep-warm switched on after launch: the `idleCompaction` opt-out rides only a launch. + const idleCompaction = + status.enabled && status.idleCompactionOff === false ? 'idle compaction on until relaunch' : ''; + let parts; + switch (status.state) { + case 'off': + if (!status.reason || status.reason === 'off') return ''; + parts = ['off', status.reason]; + break; + case 'armed': + parts = [`next ${clock(status.nextCheckAt)}`, window, idleCompaction]; + break; + case 'waiting': + parts = ['waiting', status.reason || 'retrying', `retry ${clock(status.nextCheckAt)}`, window, idleCompaction]; + break; + case 'nudged': + parts = [`sent ${clock(status.lastNudgeAt)} (${status.nudges}\u00d7)`, window, idleCompaction]; + break; + default: + parts = ['stopped', status.reason || 'nothing to do', idleCompaction]; + } + return `Keep-alive: ${parts.filter(Boolean).join(' \u00b7 ')}`; +} + // SSE staleness policy: is this stream a zombie? // // An EventSource that stops delivering does not always error. A proxy that @@ -1503,6 +1542,8 @@ if (typeof window !== 'undefined') { }; window.CodemanPromptCache = { format: formatPromptCacheStatus, + formatKeepWarm: formatKeepWarmStatus, + keepWarmUnavailable: (statusLine) => !!PROMPT_CACHE_NOT_COLLECTED[statusLine], }; window.CodemanSessionOrder = { RANK: SESSION_ACTIVITY_RANK, @@ -1699,6 +1740,7 @@ const SSE_EVENTS = { SESSION_RUNNING: 'session:running', SESSION_STATUS_TELEMETRY: 'session:statusTelemetry', SESSION_PROMPT_CACHE: 'session:promptCache', + SESSION_KEEP_WARM: 'session:keepWarm', // Scheduled runs SCHEDULED_CREATED: 'scheduled:created', diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index 14db1489..c92e5eef 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -962,6 +962,19 @@ 'Enable Respawn': '启用重生', 'Stop Respawn': '停止重生', 'Auto-resume when usage limit resets': '使用限制重置后自动继续', + // Prompt-cache keep-warm (Session Options → Respawn, App Settings → System → Automation, session-ui.js toasts). + 'Keep the cache warm while idle': '空闲时保持缓存预热', + "Sends a one-line keep-alive just before the cache expires, so your next prompt still reads from cache. Runs only while idle, up to the limit in App Settings → System → Automation; switch on before launch to also stop Claude Code's own idle compaction.": + '在缓存即将过期前发送一行保活消息,让您的下一条提示仍能从缓存读取。仅在空闲时运行,最长不超过“应用设置 → 系统 → 自动化”中的上限;在启动前开启还会关闭 Claude Code 自带的空闲压缩。', + 'Keep prompt caches warm': '保持提示缓存预热', + "Default for Claude sessions that have not chosen on their Respawn tab (a session an agent started needs its own switch): shortly before an idle session's prompt cache would expire, send a one-line keep-alive turn so the next prompt still reads the conversation from cache instead of re-processing it.": + '未在“重生”标签页中单独设置的 Claude 会话的默认值(由智能体启动的会话需单独开启):在空闲会话的提示缓存即将过期前发送一行保活消息,让下一条提示仍从缓存读取对话,而不必重新处理。', + 'Keep warm for at most': '最长保持预热', + 'Hours after your last prompt in a session during which keep-alives are sent; past that the cache is left to expire. A keep-alive costs a small fraction of the re-write it prevents, so a few hours of them pay off, an overnight gap does not.': + '会话中最后一条提示之后发送保活消息的小时数;超过之后任由缓存过期。一次保活的开销只是它所避免的重新写入的一小部分,因此几个小时的保活是值得的,整夜的空档则不值得。', + hours: '小时', + 'Prompt-cache keep-warm enabled': '提示缓存预热已开启', + 'Prompt-cache keep-warm disabled': '提示缓存预热已关闭', 'Auto-restart sessions when context fills up (usually not needed)': '上下文已满时自动重启会话(通常不需要)', 'Auto-Compact': '自动压缩', 'Auto-Clear': '自动清空', diff --git a/src/web/public/index.html b/src/web/public/index.html index abbd2cd9..58b9c8f7 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -1334,8 +1334,19 @@ <div class="set-row-text"> <span class="set-row-label">Status</span> <span class="set-row-desc" id="promptCacheStatus" data-i18n-skip>Not reported yet.</span> + <span class="set-row-desc" id="keepWarmStatus" data-i18n-skip hidden></span> </div> </div> + <!-- Per-session opt-in for the keep-alive nudge (prompt-cache-keepwarm.ts); + the default for sessions that have not chosen lives in App Settings → + Automation. Its status shares the Status row above: session-ui.js updateKeepWarmStatus. --> + <div class="set-row"> + <div class="set-row-text"> + <span class="set-row-label">Keep the cache warm while idle</span> + <span class="set-row-desc">Sends a one-line keep-alive just before the cache expires, so your next prompt still reads from cache. Runs only while idle, up to the limit in App Settings → System → Automation; switch on before launch to also stop Claude Code's own idle compaction.</span> + </div> + <label class="switch switch-sm"><input type="checkbox" id="modalKeepWarmEnabled" onchange="app.autoSaveKeepWarm()"><span class="slider"></span></label> + </div> </div> </div> @@ -3252,6 +3263,23 @@ </div> <label class="switch switch-sm"><input type="checkbox" id="appSettingsImageWatcherEnabled"><span class="slider"></span></label> </div> + <div class="set-row" data-search="prompt cache keep warm keep-alive nudge idle expire"> + <div class="set-row-text"> + <span class="set-row-label">Keep prompt caches warm</span> + <span class="set-row-desc">Default for Claude sessions that have not chosen on their Respawn tab (a session an agent started needs its own switch): shortly before an idle session's prompt cache would expire, send a one-line keep-alive turn so the next prompt still reads the conversation from cache instead of re-processing it.</span> + </div> + <label class="switch switch-sm"><input type="checkbox" id="appSettingsPromptCacheKeepWarm"><span class="slider"></span></label> + </div> + <div class="set-row" data-search="prompt cache keep warm hours limit cap window"> + <div class="set-row-text"> + <span class="set-row-label">Keep warm for at most</span> + <span class="set-row-desc">Hours after your last prompt in a session during which keep-alives are sent; past that the cache is left to expire. A keep-alive costs a small fraction of the re-write it prevents, so a few hours of them pay off, an overnight gap does not.</span> + </div> + <div class="set-row-actions"> + <input type="number" id="appSettingsPromptCacheKeepWarmMaxHours" class="set-num" min="0.5" max="24" step="0.5" value="2"> + <span class="set-unit">hours</span> + </div> + </div> </div> </div> diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index 6067dc0b..5009c2d5 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -2631,6 +2631,10 @@ Object.assign(CodemanApp.prototype, { document.getElementById('modalAutoResumeEnabled').checked = session.autoResumeEnabled ?? false; this.updateAutoResumeStatus(sessionId); this.updatePromptCacheStatus(sessionId); + // Effective opt-in: the server publishes it in the status (session flag, else the app default). + document.getElementById('modalKeepWarmEnabled').checked = + session.keepWarm?.enabled ?? session.keepWarmEnabled ?? false; + this.updateKeepWarmStatus(sessionId); document.getElementById('modalImageWatcherEnabled').checked = session.imageWatcherEnabled ?? true; document.getElementById('modalFlickerFilterEnabled').checked = session.flickerFilterEnabled ?? false; @@ -2915,6 +2919,59 @@ Object.assign(CodemanApp.prototype, { this.updatePromptCacheStatus(sessionId); }, + /** + * Session Options → Respawn → Prompt cache: the keep-warm line on the Status row (what the + * keep-warm service is doing, `session.keepWarm`, worded by `CodemanPromptCache.formatKeepWarm`, + * hidden when it has nothing to say) and the state of the "Keep the cache warm while idle" switch. + */ + updateKeepWarmStatus(sessionId) { + const el = document.getElementById('keepWarmStatus'); + if (!el || this.editingSessionId !== sessionId) return; + const session = this.sessions.get(sessionId); + const status = session?.keepWarm; + const text = window.CodemanPromptCache.formatKeepWarm(status, Date.now(), session?.statusLineExporter); + el.textContent = text; + el.hidden = !text; + // A launch without cache reports can never keep warm: grey the row and refuse to switch it on, + // but a switch that is already on stays reachable so it can be turned off. Same inert-switch + // pattern as the context switch in settings-ui.js _applyModelSelection(). + const box = document.getElementById('modalKeepWarmEnabled'); + if (!box) return; + const inert = window.CodemanPromptCache.keepWarmUnavailable(session?.statusLineExporter); + box.closest('.set-row')?.classList.toggle('set-row-disabled', inert); + box.disabled = inert && !box.checked; + }, + + _onSessionKeepWarm(data) { + const { sessionId, ...status } = data; + const session = this.sessions.get(sessionId); + if (!session) return; + session.keepWarm = status; + if (this.editingSessionId === sessionId) { + const box = document.getElementById('modalKeepWarmEnabled'); + if (box) box.checked = status.enabled; + } + this.updateKeepWarmStatus(sessionId); + }, + + async autoSaveKeepWarm() { + if (!this.editingSessionId) return; + const enabled = document.getElementById('modalKeepWarmEnabled').checked; + try { + // _apiPost unwraps the envelope; the route answers with the status for the NEW choice. + const data = await this._apiPost(`/api/sessions/${this.editingSessionId}/keep-warm`, { enabled }); + const session = this.sessions.get(this.editingSessionId); + if (session) { + session.keepWarmEnabled = enabled; + if (data?.keepWarm?.status) session.keepWarm = data.keepWarm.status; + } + this.updateKeepWarmStatus(this.editingSessionId); + this.showToast(`Prompt-cache keep-warm ${enabled ? 'enabled' : 'disabled'}`, 'success'); + } catch (err) { + this.showToast('Failed to toggle keep-warm: ' + err.message, 'error'); + } + }, + async toggleSessionImageWatcher() { if (!this.editingSessionId) return; const enabled = document.getElementById('modalImageWatcherEnabled').checked; diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index d1343edf..d876a6e4 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -597,6 +597,9 @@ Object.assign(CodemanApp.prototype, { document.getElementById('appSettingsSubagentTracking').checked = settings.subagentTrackingEnabled ?? defaults.subagentTrackingEnabled ?? true; document.getElementById('appSettingsSubagentActiveTabOnly').checked = settings.subagentActiveTabOnly ?? defaults.subagentActiveTabOnly ?? true; document.getElementById('appSettingsImageWatcherEnabled').checked = settings.imageWatcherEnabled ?? defaults.imageWatcherEnabled ?? false; + // Prompt-cache keep-warm: synced, default OFF, cap default 2 h (DEFAULT_KEEP_WARM_MAX_HOURS). + document.getElementById('appSettingsPromptCacheKeepWarm').checked = settings.promptCacheKeepWarm === true; + document.getElementById('appSettingsPromptCacheKeepWarmMaxHours').value = settings.promptCacheKeepWarmMaxHours ?? 2; document.getElementById('appSettingsTunnelEnabled').checked = settings.tunnelEnabled ?? false; this.loadTunnelStatus(); document.getElementById('appSettingsLocalEcho').checked = settings.localEchoEnabled ?? MobileDetection.isTouchDevice(); @@ -2760,6 +2763,9 @@ Object.assign(CodemanApp.prototype, { subagentTrackingEnabled: document.getElementById('appSettingsSubagentTracking').checked, subagentActiveTabOnly: document.getElementById('appSettingsSubagentActiveTabOnly').checked, imageWatcherEnabled: document.getElementById('appSettingsImageWatcherEnabled').checked, + promptCacheKeepWarm: document.getElementById('appSettingsPromptCacheKeepWarm').checked, + promptCacheKeepWarmMaxHours: + parseFloat(document.getElementById('appSettingsPromptCacheKeepWarmMaxHours').value) || 2, tunnelEnabled: document.getElementById('appSettingsTunnelEnabled').checked, localEchoEnabled: document.getElementById('appSettingsLocalEcho').checked, autoCopySelection: document.getElementById('appSettingsAutoCopySelection').checked, diff --git a/src/web/routes/hook-event-routes.ts b/src/web/routes/hook-event-routes.ts index 2cc594d7..cf9ee4f2 100644 --- a/src/web/routes/hook-event-routes.ts +++ b/src/web/routes/hook-event-routes.ts @@ -143,7 +143,8 @@ export function registerHookEventRoutes( // bumped only by input that flowed through Codeman's own write path, so // it read 0 forever for those panes and every consumer of "when did this // pane last submit" silently degraded. - session?.markPromptSubmitted(); + // The prompt text tells a person's prompt from the keep-alive's (prompt-cache-keepwarm.ts). + session?.markPromptSubmitted(typeof data.prompt === 'string' ? data.prompt : undefined); } // Persist when the conversation actually moved: `/clear` emits no // completion event, so without this the successor id is lost on restart @@ -183,6 +184,11 @@ export function registerHookEventRoutes( // seeds from /api/approvals), and it suppresses the push: an alert nobody can answer // is worth even less on a phone than in a tab. let acknowledgedReason: string | undefined; + // A turn Codeman started itself (the prompt-cache keep-alive, prompt-cache-keepwarm.ts) + // ends with the same stop hook and idle prompt as a real one. Both open pre-acknowledged, + // so no tab, toast or push asks a person to look at a session that wants nothing from them. + const keepAliveReason = + session?.keepAliveTurnActive && (event === 'stop' || event === 'idle_prompt') ? 'cache keep-alive' : undefined; const approvalKind = APPROVAL_KIND_BY_EVENT[event]; if (session && hooksAvailableForMode(session.mode, sessionHookOptions(session))) { if (approvalKind) { @@ -209,6 +215,7 @@ export function registerHookEventRoutes( // session that is watching its own work opens acknowledged, so it never arms an // alert nobody can answer; notePrompt() carries the whole reasoning. watching: session.watching, + preAcknowledged: keepAliveReason, // Visible tmux frame first (it IS the dialog); raw byte-buffer tail as // the fallback for direct-PTY sessions and the no-op test mux. capture: () => { @@ -224,6 +231,8 @@ export function registerHookEventRoutes( } } + acknowledgedReason ??= keepAliveReason; + ctx.broadcast(`hook:${event}`, { sessionId, timestamp: Date.now(), diff --git a/src/web/routes/reboot-restore-routes.ts b/src/web/routes/reboot-restore-routes.ts index a989fb4d..7945e96d 100644 --- a/src/web/routes/reboot-restore-routes.ts +++ b/src/web/routes/reboot-restore-routes.ts @@ -201,6 +201,8 @@ export function registerRebootRestoreRoutes(app: FastifyInstance, ctx: RebootRes (saved as { __attachmentHistory?: SessionAttachmentHistoryItem[] }).__attachmentHistory ?? saved.attachmentHistory, lastSubmitAt: saved.lastSubmitAt, + lastRealPromptAt: saved.lastRealPromptAt, + keepWarmEnabled: saved.keepWarmEnabled, claudeSessionChain: saved.claudeSessionChain, lastActivityAt: saved.lastActivityAt, owner: saved.owner, diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 0a794d8a..d8dbb40c 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -55,6 +55,7 @@ import { AutoClearSchema, AutoCompactSchema, AutoResumeSchema, + KeepWarmSchema, PinSessionSchema, ImageWatcherSchema, FlickerFilterSchema, @@ -155,7 +156,15 @@ import { setHistoryIndexRefresher, setHistorySessionIndex, } from '../session-history-index.js'; -import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort, TabLayoutPort } from '../ports/index.js'; +import type { + SessionPort, + EventPort, + ConfigPort, + InfraPort, + AuthPort, + TabLayoutPort, + KeepWarmPort, +} from '../ports/index.js'; import { RunSummaryTracker } from '../../run-summary.js'; import { MAX_INPUT_LENGTH, MAX_SESSION_NAME_LENGTH } from '../../config/terminal-limits.js'; @@ -1024,7 +1033,7 @@ function wakeableHost(host: RemoteHost): WakeableRemote { export function registerSessionRoutes( app: FastifyInstance, - ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort & TabLayoutPort, + ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort & TabLayoutPort & KeepWarmPort, /** Test seam: inject a registry with fake IO instead of the real TCP/WoL probes. */ options: { remoteWake?: RemoteWakeRegistry } = {} ): RemoteWakeRegistry { @@ -3556,6 +3565,39 @@ export function registerSessionRoutes( }; }); + // ========== Prompt-cache keep-warm ========== + + // Per-session opt-in for the keep-alive nudge (prompt-cache-keepwarm.ts). `enabled: null` + // clears the choice so the app-wide default applies again. Refused for a CLI that reports + // no cache telemetry on its statusline: without `expires_at` there is nothing to schedule. + app.post('/api/sessions/:id/keep-warm', async (req) => { + const { id } = req.params as { id: string }; + const body = parseBody(KeepWarmSchema, req.body, 'Invalid request body'); + const session = findSessionOrFail(ctx, id, req); + if (!getCli(session.mode)?.capabilities.statusLineTelemetry) { + return createErrorResponse( + ApiErrorCode.INVALID_INPUT, + 'Prompt-cache keep-warm needs the cache telemetry this CLI does not report' + ); + } + + session.setKeepWarmEnabled(body.enabled ?? undefined); + // Re-evaluate BEFORE the state broadcast and the response: both carry `keepWarm`, and a + // page that received the previous status would overwrite the one the toggle just produced. + await ctx.keepWarm.schedule(id); + persistAndBroadcastSession(ctx, session); + + return { + success: true, + data: { + keepWarm: { + enabled: session.keepWarmEnabled ?? null, + status: session.keepWarm, + }, + }, + }; + }); + // ========== Auto-Resume (usage-limit pause) ========== app.post('/api/sessions/:id/auto-resume', async (req) => { diff --git a/src/web/routes/status-telemetry-routes.ts b/src/web/routes/status-telemetry-routes.ts index a93cb4e1..06c34001 100644 --- a/src/web/routes/status-telemetry-routes.ts +++ b/src/web/routes/status-telemetry-routes.ts @@ -29,10 +29,10 @@ import { } from '../../usage-telemetry.js'; import { SessionPromptCache, SessionStatusTelemetry } from '../sse-events.js'; import { setLatestPlanUsage } from '../plan-usage-latest.js'; -import type { SessionPort, EventPort } from '../ports/index.js'; +import type { SessionPort, EventPort, KeepWarmPort } from '../ports/index.js'; import { getCli } from '../../config/cli-registry/index.js'; -export function registerStatusTelemetryRoutes(app: FastifyInstance, ctx: SessionPort & EventPort): void { +export function registerStatusTelemetryRoutes(app: FastifyInstance, ctx: SessionPort & EventPort & KeepWarmPort): void { // Last broadcast telemetry signature per session — the statusline fires on // every assistant message, so we only rebroadcast when the value changes. const lastSig = new Map<string, string>(); @@ -74,10 +74,14 @@ export function registerStatusTelemetryRoutes(app: FastifyInstance, ctx: Session // arrive out of order; a stale one then briefly overwrites newer state until the next // render corrects it. Accepted for a display-only readout rather than ordered here. const promptCache = reportsStatusLine ? parsePromptCache(payload) : null; - if (promptCache && session?.setPromptCache(promptCache)) { - ctx.broadcast(SessionPromptCache, { sessionId, ...session.promptCache }); - // Persisted on change, like a model change, so a crash restores the latest report. - ctx.persistSessionState(session); + if (promptCache) { + if (session?.setPromptCache(promptCache)) { + ctx.broadcast(SessionPromptCache, { sessionId, ...session.promptCache }); + // Persisted on change, like a model change, so a crash restores the latest report. + ctx.persistSessionState(session); + } + // Every report moves `expires_at`, so the keep-warm re-evaluates on each one. + void ctx.keepWarm.schedule(sessionId); } // Plan-usage limits (account-wide) → broadcast to the header chip, when diff --git a/src/web/routes/system-routes.ts b/src/web/routes/system-routes.ts index b62f717b..fd9c19d2 100644 --- a/src/web/routes/system-routes.ts +++ b/src/web/routes/system-routes.ts @@ -51,7 +51,15 @@ import { SseEvent } from '../sse-events.js'; import { getInstallInfo, checkForUpdate, startUpdate, getUpdateStatusForApi } from '../self-update.js'; import { getRepositoryStatus } from '../repo-status.js'; -import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort, TabLayoutPort } from '../ports/index.js'; +import type { + SessionPort, + EventPort, + ConfigPort, + InfraPort, + AuthPort, + TabLayoutPort, + KeepWarmPort, +} from '../ports/index.js'; import { AUTH_COOKIE_NAME } from '../middleware/auth.js'; import { QR_AUTH_FAILURE_MAX } from '../../config/tunnel-config.js'; import { AUTH_SESSION_TTL_MS } from '../../config/auth-config.js'; @@ -168,7 +176,7 @@ export function settingsForNonAdmin(settings: unknown): unknown { export function registerSystemRoutes( app: FastifyInstance, - ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort & TabLayoutPort + ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort & TabLayoutPort & KeepWarmPort ): void { const windowStatesPath = dataPath('subagent-window-states.json'); const parentMapPath = dataPath('subagent-parents.json'); @@ -1103,6 +1111,11 @@ export function registerSystemRoutes( // drift. Same convention as the tmuxHistoryLimit block above. // Handle subagent tracking toggle dynamically toggleService((merged.subagentTrackingEnabled as boolean) ?? true, subagentWatcher, 'Subagent watcher'); + // The prompt-cache keep-warm reads its default and cap fresh on every decision; a changed + // default or cap re-evaluates every session now rather than at its next cache report. + if ('promptCacheKeepWarm' in settingsToStore || 'promptCacheKeepWarmMaxHours' in settingsToStore) { + ctx.keepWarm.rescheduleAll(); + } // Handle ultracode/workflow run watcher toggle dynamically (default OFF). // Either the docked panel OR the floating windows keep the watcher running. diff --git a/src/web/schemas.ts b/src/web/schemas.ts index ce577394..becbeab9 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -1483,6 +1483,10 @@ export const SettingsUpdateSchema = z * broadcast and /api/search, which is the user's choice to make. */ autoNameSessions: z.boolean().optional(), + /** Prompt-cache keep-warm default for sessions that have not chosen (Session Options overrides it). */ + promptCacheKeepWarm: z.boolean().optional(), + /** Hours after the last real prompt during which keep-alives are sent (default 2, DEFAULT_KEEP_WARM_MAX_HOURS). */ + promptCacheKeepWarmMaxHours: z.number().min(0.5).max(24).optional(), /** * Spawned tabs follow their parent: a session created by another one keeps * following its parent's tab (into its group, moving with it) until moved by @@ -1867,6 +1871,9 @@ export const AutoCompactSchema = z.object({ }); /** POST /api/sessions/:id/auto-resume */ +/** `POST /api/sessions/:id/keep-warm`: `null` clears the session's choice so the app-wide default applies. */ +export const KeepWarmSchema = z.object({ enabled: z.boolean().nullable() }); + export const AutoResumeSchema = z.object({ enabled: z.boolean(), }); diff --git a/src/web/server.ts b/src/web/server.ts index f9bb8b47..910305e7 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -96,7 +96,12 @@ import { RunSummaryTracker } from '../run-summary.js'; import { PlanOrchestrator } from '../plan-orchestrator.js'; import { OrchestratorLoop } from '../orchestrator-loop.js'; import { getLifecycleLog } from '../session-lifecycle-log.js'; -import { applyWorkspaceHooks, pruneAgentSessionPreambles, removeAgentSessionPreamble } from '../hooks-config.js'; +import { + applyWorkspaceHooks, + pruneAgentSessionPreambles, + readKeepWarmSettings, + removeAgentSessionPreamble, +} from '../hooks-config.js'; import { PushSubscriptionStore } from '../push-store.js'; import webpush from 'web-push'; import { SseStreamManager } from './sse-stream-manager.js'; @@ -113,6 +118,9 @@ import { intentStore } from '../intent-store.js'; import { AI_CHECK_MODEL } from '../config/ai-defaults.js'; import { approvalInbox } from './approval-inbox.js'; import { SCREEN_BLOCKED_SOURCE } from './screen-blocked.js'; +import { PromptCacheKeepWarmService } from '../prompt-cache-keepwarm.js'; +import { composerIsEmpty } from '../session-submit-verifier.js'; +import { detectUsageLimitPause } from '../usage-limit-patterns.js'; import { stopDeepSeekWeb } from '../deepseek-web-server.js'; import { wireRespawnListeners, @@ -317,6 +325,49 @@ export class WebServer extends EventEmitter { private _customModelDisplacedNotified: Set<string> = new Set(); /** Cron service (assigned in setupRoutes). */ private cronService!: CronService; + // Prompt-cache keep-warm (prompt-cache-keepwarm.ts): the rulebook and timers live in the + // service; the server only lends it the facts a decision needs. A field initializer, not a + // setupRoutes() step: cleanup and listener wiring reach it on servers that never set up routes + // (the pane-exit and discard tests build a bare WebServer), and every port reads `this.*` lazily. + private readonly keepWarmService = new PromptCacheKeepWarmService({ + getSession: (id) => this.sessions.get(id), + sessionIds: () => this.sessions.keys(), + readSettings: readKeepWarmSettings, + isAutomationActive: (id) => { + const session = this.sessions.get(id); + if (!session) return true; + return ( + (this.respawnControllers.get(id)?.isRunning ?? false) || + session.ralphTracker.loopState.active || + session.currentTaskId !== null || + sessionWaits.signalWaiterCount(id) > 0 + ); + }, + hasPendingApproval: (id) => + approvalInbox.listPending().some((item) => item.sessionId === id && item.kind !== 'idle'), + readPane: (view) => { + const session = view as Session; + const muxName = session.muxName; + // `capture-pane -p`: the visible frame as plain text. + const text = muxName ? (this.mux.capturePaneText?.(muxName) ?? null) : null; + if (!text) return { composerEmpty: null, limitShown: false }; + const limit = detectUsageLimitPause(text); + return { + composerEmpty: composerIsEmpty(text, getCli(session.mode)?.capabilities.workDetect?.promptGlyph ?? '') ?? null, + limitShown: limit !== null && limit.resetAt > Date.now(), + }; + }, + publish: (id, status) => { + const session = this.sessions.get(id); + if (!session?.setKeepWarmStatus(status)) return; + this.broadcast(SseEvent.SessionKeepWarm, { sessionId: id, ...status }); + // Change-only, so one line per transition names whichever guard holds it now. + if (status.state !== 'off' || status.reason !== 'off') { + console.log(`[KeepWarm] ${id.slice(0, 8)}: ${status.state}${status.reason ? ` (${status.reason})` : ''}`); + } + }, + log: (message) => console.log(message), + }); /** * Wake-on-LAN registry, returned by `registerSessionRoutes`. Held for its LIFETIME * only — `drop()` on session cleanup, `stop()` on shutdown. Waking from here would @@ -747,6 +798,7 @@ export class WebServer extends EventEmitter { return { // SessionPort sessions: this.sessions as ReadonlyMap<string, Session>, + keepWarm: this.keepWarmService, addSession: this.registerSessionWithLayout.bind(this), tabLayouts: this.tabLayouts, cleanupSession: this.cleanupSession.bind(this), @@ -1416,6 +1468,7 @@ export class WebServer extends EventEmitter { coordinateLayout = true ): Promise<void> { const session = this.sessions.get(sessionId); + this.keepWarmService.remove(sessionId); const pinned = session?.pinned === true || this.store.getSession(sessionId)?.pinned === true; if (coordinateLayout && session && killMux && !pinned) { return this.tabLayouts.runSessionDeletion([{ id: sessionId, owner: session.owner }], () => @@ -1886,6 +1939,9 @@ export class WebServer extends EventEmitter { } private async setupSessionListeners(session: Session): Promise<void> { + // First keep-warm evaluation: publishes the effective opt-in for the Session Options + // readout even before any cache report arrives. + void this.keepWarmService.schedule(session.id); // Idempotent: the wiring exit handler detaches ALL listeners on every PTY exit // (removeSessionListenerRefs), so the re-attach routes (/interactive, // /interactive-respawn, /shell) call this again to restore observability @@ -1918,6 +1974,7 @@ export class WebServer extends EventEmitter { private buildSessionListenerDeps() { return { broadcast: this.broadcast.bind(this), + keepWarmSchedule: (id: string) => void this.keepWarmService.schedule(id), batchTerminalData: this.batchTerminalData.bind(this), batchTaskUpdate: this.batchTaskUpdate.bind(this), broadcastSessionStateDebounced: this.broadcastSessionStateDebounced.bind(this), @@ -3611,6 +3668,10 @@ export class WebServer extends EventEmitter { // the launch conversation until the user types again, even though // the re-attached CLI is on a post-`/clear` one. lastSubmitAt: savedState?.lastSubmitAt, + // The keep-warm choice and its cap's clock; with the restored `promptCache` + // below, a future `expiresAt` re-arms the timer once the pane is attached. + lastRealPromptAt: savedState?.lastRealPromptAt, + keepWarmEnabled: savedState?.keepWarmEnabled, // Conversations this pane provably owned, oldest first. Its tail is // the conversation the CLI was on when the server stopped, which is // what a re-attach must point the viewer at instead of the launch id. @@ -4063,6 +4124,7 @@ export class WebServer extends EventEmitter { // response), so without this a 10-minute wait holds shutdown open. sessionWaits.cancelEverything(); approvalInbox.stop(); + this.keepWarmService.stop(); // Same reason as `cancelEverything` above: an in-flight wake is awaited by a request, // and `app.close()` (the last line of this method) does not abort in-flight requests — // so without this a restart during a wake waits out the readiness poll. diff --git a/src/web/session-listener-wiring.ts b/src/web/session-listener-wiring.ts index e719847d..a1ce31f4 100644 --- a/src/web/session-listener-wiring.ts +++ b/src/web/session-listener-wiring.ts @@ -93,6 +93,12 @@ interface SessionListenerDeps { updateSessionName(sessionId: string, name: string): boolean; /** The synced `autoNameSessions` setting, read fresh so a flip applies to the next prompt. */ isAutoNameEnabled(): Promise<boolean>; + /** + * Re-evaluate the prompt-cache keep-warm when the session goes idle: a turn just ended + * (its cache report may already be in), and after a restart this is the first moment the + * recovered pane is attached, so the restored report can arm its timer. + */ + keepWarmSchedule?(sessionId: string): void; } /** @@ -262,6 +268,7 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe sessionWaits.notifySignal(session.id, 'idle'); deps.broadcast(SseEvent.SessionIdle, { id: session.id }); deps.broadcastSessionStateDebounced(session.id); + deps.keepWarmSchedule?.(session.id); const tracker = deps.getRunSummaryTracker(session.id); if (tracker) { tracker.recordIdle(); diff --git a/src/web/sse-events.ts b/src/web/sse-events.ts index 3c1df40d..8e79c856 100644 --- a/src/web/sse-events.ts +++ b/src/web/sse-events.ts @@ -5,10 +5,10 @@ * and referenced by the frontend (`SSE_EVENTS` in `constants.js`). * Both files MUST be kept in sync. * - * 162 event constants organized by category: + * 163 event constants organized by category: * - **Core** (1): init * - **Transport** (1): sse:heartbeat - * - **Session lifecycle** (24): created, updated, deleted, terminal, idle, working, ... + * - **Session lifecycle** (25): created, updated, deleted, terminal, idle, working, ... * - **Session: Ralph** (6): ralphLoopUpdate, todoUpdate, completionDetected, ... * - **Session: Bash tools** (3): bashToolStart, bashToolEnd, bashToolsUpdate * - **Session: Plan** (4): planTaskUpdate, planCheckpoint, planRollback, planTaskAdded @@ -122,6 +122,8 @@ export const SessionRunning = 'session:running' as const; export const SessionStatusTelemetry = 'session:statusTelemetry' as const; /** Prompt-cache state of one session's main conversation changed (`{ sessionId, ...PromptCacheStatus }`). */ export const SessionPromptCache = 'session:promptCache' as const; +/** The prompt-cache keep-warm's status for one session changed (`{ sessionId, ...KeepWarmStatus }`). */ +export const SessionKeepWarm = 'session:keepWarm' as const; // ─── Session: Ralph ────────────────────────────────────────────────────────── @@ -534,6 +536,7 @@ export const SseEvent = { SessionRunning, SessionStatusTelemetry, SessionPromptCache, + SessionKeepWarm, // Session: Ralph SessionRalphLoopUpdate, diff --git a/test/cli-registry-spawn-golden.test.ts b/test/cli-registry-spawn-golden.test.ts index 9d1a8f41..c4fe6032 100644 --- a/test/cli-registry-spawn-golden.test.ts +++ b/test/cli-registry-spawn-golden.test.ts @@ -102,6 +102,30 @@ describe('claude', () => { 'claude --dangerously-skip-permissions --session-id "0f9c2b14-1111-2222-3333-444455556666"' ); }); + + it('folds the keep-warm idleCompaction opt-out LAST into the one --settings JSON, and only when asked', () => { + const launch = { + claudeCliVersion: '2.1.300', + statusLineCommand: '/x/exporter.sh', + advisorModel: 'opus', + sessionName: 'w1 alpha', + }; + // Keep-warm off: byte-identical to the launch from before keep-warm existed. + expect(claude(launch)).toBe( + 'claude --dangerously-skip-permissions --session-id "0f9c2b14-1111-2222-3333-444455556666" ' + + '--settings \'{"advisorModel":"opus","statusLine":{"type":"command","command":"/x/exporter.sh"}}\' --name "w1 alpha"' + ); + expect(claude({ ...launch, effort: 'ultracode', idleCompactionOff: true })).toBe( + 'claude --dangerously-skip-permissions --session-id "0f9c2b14-1111-2222-3333-444455556666" ' + + '--settings \'{"ultracode":true,"advisorModel":"opus","statusLine":{"type":"command","command":"/x/exporter.sh"},"idleCompaction":false}\' --name "w1 alpha"' + ); + // The builder folds the key whenever asked, even with nothing else in the JSON (the + // launch only asks alongside the exporter, but the builder does not rely on that). + expect(claude({ idleCompactionOff: true })).toBe( + 'claude --dangerously-skip-permissions --session-id "0f9c2b14-1111-2222-3333-444455556666" ' + + '--settings \'{"idleCompaction":false}\'' + ); + }); }); describe('opencode', () => { diff --git a/test/keep-warm-i18n.test.ts b/test/keep-warm-i18n.test.ts new file mode 100644 index 00000000..444d6f16 --- /dev/null +++ b/test/keep-warm-i18n.test.ts @@ -0,0 +1,68 @@ +/** + * @fileoverview The prompt-cache keep-warm rows (Session Options → Respawn switch, App Settings + * → Automation default and cap) in Chinese (zh-CN): each label and description runs through + * the real translator over the real index.html in JSDOM and reads in Chinese with no English + * left beyond the product names, and every new key, the toggle toasts included, is in the + * dictionary exactly once (a repeated key in the object literal silently replaces the first). + * + * Port: N/A. + */ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { JSDOM } from 'jsdom'; +import { afterAll, describe, expect, it } from 'vitest'; + +const PUBLIC = resolve(import.meta.dirname, '../src/web/public'); +const I18N = readFileSync(resolve(PUBLIC, 'i18n.js'), 'utf8'); +const INDEX = readFileSync(resolve(PUBLIC, 'index.html'), 'utf8'); + +type Api = { configure(o: Record<string, string>): void; start(): void }; + +const IDS = ['modalKeepWarmEnabled', 'appSettingsPromptCacheKeepWarm', 'appSettingsPromptCacheKeepWarmMaxHours']; + +/** What may stay Latin: the product names. */ +const leftover = (text: string) => + text.replace(/\b(Claude Code|Claude|Codeman|Ralph)\b/g, '').match(/[A-Za-z]+/g) ?? []; + +function rowStrings(doc: Document, id: string): { label: string; description: string } { + const row = doc.getElementById(id)!.closest('.set-row')!; + return { + label: row.querySelector('.set-row-label')!.textContent!.trim(), + description: row.querySelector('.set-row-desc')!.textContent!.trim(), + }; +} + +const source = new JSDOM(INDEX); +const dom = new JSDOM(INDEX, { runScripts: 'outside-only', url: 'http://localhost/' }); +vm.runInContext(I18N, dom.getInternalVMContext(), { filename: 'i18n.js' }); +const api = (dom.window as unknown as { CodemanI18n: Api }).CodemanI18n; +api.start(); +api.configure({ language: 'zh-CN' }); + +afterAll(() => { + source.window.close(); + dom.window.close(); +}); + +describe('the keep-warm rows in zh-CN', () => { + it.each(IDS)('translates the %s row, with no English left', (id) => { + const english = rowStrings(source.window.document, id); + const chinese = rowStrings(dom.window.document, id); + expect(english.label.length).toBeGreaterThan(0); + for (const key of ['label', 'description'] as const) { + expect(chinese[key]).not.toBe(english[key]); + expect(leftover(chinese[key])).toEqual([]); + } + }); + + it('adds each key to the dictionary once, the toasts included', () => { + const texts = IDS.flatMap((id) => Object.values(rowStrings(source.window.document, id))); + texts.push('hours', 'Prompt-cache keep-warm enabled', 'Prompt-cache keep-warm disabled'); + for (const text of texts) { + const quoted = I18N.split(`'${text}'`).length + I18N.split(`"${text}"`).length - 2; + const bare = /^\w+$/.test(text) ? I18N.split(`\n ${text}: `).length - 1 : 0; + expect(quoted + bare, text).toBe(1); + } + }); +}); diff --git a/test/keep-warm-launch.test.ts b/test/keep-warm-launch.test.ts new file mode 100644 index 00000000..61ced246 --- /dev/null +++ b/test/keep-warm-launch.test.ts @@ -0,0 +1,158 @@ +/** + * @fileoverview The launch half of the prompt-cache keep-warm (Discussion #554): which launch + * carries `idleCompaction: false` (decideIdleCompactionOptOut, tmux-manager.ts), that every + * create and relaunch path decides it and records it on the mux record, that the session hands + * its choice to both, that a restart keeps the choice and the real-prompt clock, that the app + * settings read defaults to OFF and a 2 h cap, and that Read My Mind's capture runs through the + * filter that drops the nudge. The argv shape itself is pinned in cli-registry-spawn-golden. + * + * Port: N/A (pure functions and source slices; no server). + */ + +import { mkdir, writeFile, rm } from 'node:fs/promises'; +import { readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { describe, it, expect, afterEach } from 'vitest'; +import { decideIdleCompactionOptOut } from '../src/tmux-manager.js'; +import { readKeepWarmSettings } from '../src/hooks-config.js'; +import { SETTINGS_PATH } from '../src/web/route-helpers.js'; +import { DEFAULT_KEEP_WARM_MAX_HOURS } from '../src/prompt-cache-keepwarm.js'; + +const src = (file: string) => readFileSync(resolve(import.meta.dirname, '../src', file), 'utf8'); + +/** The body of `signature` up to the next class member, so a pin cannot pass on a sibling. */ +function methodSlice(source: string, signature: string): string { + const start = source.indexOf(signature); + expect(start, `${signature} not found`).toBeGreaterThan(-1); + const next = source + .slice(start + signature.length) + .search(/\n {2}(?:(?:private|public|protected|static|async|get|set) )*\w+\(/); + return source.slice(start, next === -1 ? undefined : start + signature.length + next); +} + +describe('decideIdleCompactionOptOut', () => { + const on = { + mode: 'claude' as const, + statusLine: 'injected' as const, + keepWarm: true, + keepWarmDefault: false, + spawnedByAgent: false, + claudeCliVersion: '2.1.290', + }; + + it('adds the key when keep-warm is on, the exporter is injected and Claude Code knows the setting', () => { + expect(decideIdleCompactionOptOut(on)).toBe(true); + expect(decideIdleCompactionOptOut({ ...on, claudeCliVersion: '2.1.300' })).toBe(true); + expect(decideIdleCompactionOptOut({ ...on, keepWarm: undefined, keepWarmDefault: true })).toBe(true); + }); + + it.each<[string, Partial<typeof on> & Record<string, unknown>]>([ + ['the version is unknown (fail-closed, like --name)', { claudeCliVersion: null }], + ['2.1.289, which idle-compacts but has no idleCompaction setting', { claudeCliVersion: '2.1.289' }], + ['keep-warm is off for the session', { keepWarm: false, keepWarmDefault: true }], + ['keep-warm is left to an app default that is off', { keepWarm: undefined }], + [ + 'an agent-spawned session on the app default', + { keepWarm: undefined, keepWarmDefault: true, spawnedByAgent: true }, + ], + ['Plan Usage was off at launch (keep-warm cannot run)', { statusLine: 'collection-off' }], + ["the workspace's own statusLine (keep-warm cannot run)", { statusLine: 'workspace-statusline' }], + ['a remote or Docker launch', { statusLine: 'remote' }], + ['a CLI with no exporter at all', { statusLine: undefined }], + ['a CLI that declares no such gate', { mode: 'codex' }], + ])('leaves it off when %s', (_label, over) => { + expect(decideIdleCompactionOptOut({ ...on, ...over } as Parameters<typeof decideIdleCompactionOptOut>[0])).toBe( + false + ); + }); +}); + +describe('every launch decides the opt-out and records it', () => { + const tmux = src('tmux-manager.ts'); + + it.each(['async createSession(', 'async respawnPane('])('%s resolves it, renders it and records it', (sig) => { + const body = methodSlice(tmux, sig); + expect(body).toContain( + 'const idleCompactionOff = await this._resolveIdleCompactionOptOut(mode, statusLine, keepWarm, spawnedByAgent);' + ); + expect(body).toMatch(/statusLineCommand,\n\s+idleCompactionOff,\n\s+sessionName: cliName,/); + }); + + it('createSession records it on the new mux record, next to statusLine', () => { + expect(methodSlice(tmux, 'async createSession(')).toMatch(/name,\n\s+statusLine,\n\s+idleCompactionOff,\n\s+};/); + }); + + it('respawnPane re-records it on the surviving mux record', () => { + expect(methodSlice(tmux, 'async respawnPane(')).toContain('session.idleCompactionOff = idleCompactionOff;'); + }); + + it('the slice stops at the next member, private async ones included', () => { + expect(methodSlice(tmux, 'async createSession(')).not.toContain('async respawnPane('); + expect(methodSlice(tmux, 'private async _resolveStatusLine(')).not.toContain('_resolveIdleCompactionOptOut(\n'); + }); + + it('the session hands its choice to the create AND the relaunch option bags', () => { + const session = src('session.ts'); + const handoff = /keepWarm: this\._keepWarmEnabled,\n\s+spawnedByAgent: !!this\._parentSessionId,/; + expect(session.match(new RegExp(handoff, 'g'))).toHaveLength(2); + expect(methodSlice(session, 'private _buildRespawnPaneOptions(')).toMatch(handoff); + expect(methodSlice(session, 'async startInteractive(')).toMatch(handoff); + }); +}); + +describe('restart keeps the choice and the real-prompt clock', () => { + it('both constructors that rebuild a session from state.json pass them', () => { + for (const file of ['web/server.ts', 'web/routes/reboot-restore-routes.ts']) { + const source = src(file); + expect(source, file).toMatch(/lastRealPromptAt: saved(State\?)?\.lastRealPromptAt,/); + expect(source, file).toMatch(/keepWarmEnabled: saved(State\?)?\.keepWarmEnabled,/); + } + }); +}); + +describe('the pane read behind the last two guards', () => { + it('reads the usage-limit message off the same frame and counts only a reset still ahead', () => { + const port = src('web/server.ts'); + const at = port.indexOf('readPane: (view) => {'); + expect(at).toBeGreaterThan(-1); + const body = port.slice(at, port.indexOf('publish: (id, status) => {', at)); + expect(body).toContain('const limit = detectUsageLimitPause(text);'); + expect(body).toContain('limitShown: limit !== null && limit.resetAt > Date.now(),'); + expect(body).toContain('composerEmpty: composerIsEmpty(text,'); + }); +}); + +describe('Read My Mind capture', () => { + it('records a transcript prompt only through the filter that drops the nudge', () => { + expect(methodSlice(src('web/server.ts'), 'private async captureIntentPrompt(')).toContain( + 'intentStore.recordPrompt(' + ); + const record = methodSlice(src('intent-store.ts'), 'recordPrompt('); + expect(record.indexOf('if (!isCapturablePrompt(rawText)) return false;')).toBeGreaterThan(-1); + expect(src('intent-store.ts')).toContain('if (text.trim() === KEEP_WARM_NUDGE) return false;'); + }); +}); + +describe('readKeepWarmSettings', () => { + afterEach(async () => { + await rm(SETTINGS_PATH, { force: true }); + }); + + it('reads the temp HOME the suite set up, never a real data dir', () => { + expect(SETTINGS_PATH.startsWith(join(tmpdir(), 'codeman-vitest-'))).toBe(true); + }); + + it('defaults to off with a 2 h cap', async () => { + expect(DEFAULT_KEEP_WARM_MAX_HOURS).toBe(2); + expect(await readKeepWarmSettings()).toEqual({ keepWarmDefault: false, maxHours: 2 }); + }); + + it('reads the default and the cap, and ignores a cap that is not a positive number', async () => { + await mkdir(dirname(SETTINGS_PATH), { recursive: true }); + await writeFile(SETTINGS_PATH, JSON.stringify({ promptCacheKeepWarm: true, promptCacheKeepWarmMaxHours: 3.5 })); + expect(await readKeepWarmSettings()).toEqual({ keepWarmDefault: true, maxHours: 3.5 }); + await writeFile(SETTINGS_PATH, JSON.stringify({ promptCacheKeepWarm: 'yes', promptCacheKeepWarmMaxHours: -1 })); + expect(await readKeepWarmSettings()).toEqual({ keepWarmDefault: false, maxHours: 2 }); + }); +}); diff --git a/test/mocks/mock-route-context.ts b/test/mocks/mock-route-context.ts index 9cb26d6a..28b632e8 100644 --- a/test/mocks/mock-route-context.ts +++ b/test/mocks/mock-route-context.ts @@ -83,6 +83,9 @@ export function createMockRouteContext(options?: { restoreRespawnController: vi.fn(), saveRespawnConfig: vi.fn(), + // -- KeepWarmPort -- + keepWarm: { schedule: vi.fn(), rescheduleAll: vi.fn(), remove: vi.fn() }, + // -- ConfigPort -- store: { getConfig: vi.fn(() => ({ ralphEnabled: false, maxConcurrentSessions: 5 })), diff --git a/test/mocks/mock-session.ts b/test/mocks/mock-session.ts index 9b5d01fe..f58b7447 100644 --- a/test/mocks/mock-session.ts +++ b/test/mocks/mock-session.ts @@ -4,7 +4,13 @@ */ import { EventEmitter } from 'node:events'; import { vi } from 'vitest'; -import type { PromptCacheStatus, SessionAttachmentHistoryItem, SessionStatus, SessionRemote } from '../../src/types.js'; +import type { + KeepWarmStatus, + PromptCacheStatus, + SessionAttachmentHistoryItem, + SessionStatus, + SessionRemote, +} from '../../src/types.js'; /** * Enhanced mock session for testing RespawnController. @@ -48,6 +54,18 @@ export class MockSession extends EventEmitter { this.promptCache = status; return changed; } + /** Mirrors the prompt-cache keep-warm surface on Session (prompt-cache-keepwarm.ts). */ + keepAliveTurnActive = false; + keepWarmEnabled: boolean | undefined = undefined; + setKeepWarmEnabled(enabled: boolean | undefined): void { + this.keepWarmEnabled = enabled; + } + keepWarm: KeepWarmStatus | null = null; + setKeepWarmStatus(status: KeepWarmStatus): boolean { + const changed = JSON.stringify(status) !== JSON.stringify(this.keepWarm); + this.keepWarm = status; + return changed; + } private _activeChildProcesses: { pid: number; command: string }[] = []; ralphTracker: null = null; writeBuffer: string[] = []; @@ -76,8 +94,10 @@ export class MockSession extends EventEmitter { this.claudeSessionId = newId; } - /** Mirrors Session.markPromptSubmitted. */ - markPromptSubmitted(): void { + /** Mirrors Session.markPromptSubmitted; records the hook's prompt text it was handed. */ + submittedPrompts: Array<string | undefined> = []; + markPromptSubmitted(prompt?: string): void { + this.submittedPrompts.push(prompt); this.lastSubmitAt = Date.now(); } diff --git a/test/prompt-cache-keepwarm.test.ts b/test/prompt-cache-keepwarm.test.ts new file mode 100644 index 00000000..1364079a --- /dev/null +++ b/test/prompt-cache-keepwarm.test.ts @@ -0,0 +1,571 @@ +/** + * Prompt-cache keep-warm (src/prompt-cache-keepwarm.ts): the rulebook as a table, the + * effective opt-in shared with the launch, the composer-empty read it depends on, the two + * submit clocks on a real Session, and the service's timer and nudge path with a fake + * session and fake ports. Fake timers in the service block; nothing here touches tmux. + */ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + decideKeepWarm, + keepWarmOn, + PromptCacheKeepWarmService, + KEEP_WARM_NUDGE, + KEEP_WARM_LEAD_MS, + KEEP_WARM_RETRY_MS, + KEEP_WARM_LAST_CHANCE_MS, + KEEP_WARM_MIN_TOKENS, + KEEP_WARM_RECENT_INPUT_MS, + KEEP_WARM_TURN_WINDOW_MS, + type KeepWarmFacts, + type KeepWarmPorts, + type KeepWarmSessionView, +} from '../src/prompt-cache-keepwarm.js'; +import { composerIsEmpty } from '../src/session-submit-verifier.js'; +import { isCapturablePrompt } from '../src/intent-store.js'; +import { Session } from '../src/session.js'; +import type { KeepWarmStatus, PromptCacheStatus } from '../src/types.js'; + +const NOW = Date.UTC(2026, 9, 6, 22, 0, 0); +const HOUR = 3_600_000; + +function warmCache(expiresAt: number, extra: Partial<PromptCacheStatus> = {}): PromptCacheStatus { + return { warm: true, ttl: '1h', expiresAt, recacheTokensIfCold: 110_000, misses: 0, ...extra }; +} + +/** Everything green and due: the one shape that nudges. */ +function dueFacts(over: Partial<KeepWarmFacts> = {}): KeepWarmFacts { + return { + now: NOW, + choice: true, + appDefault: false, + spawnedByAgent: false, + statusLine: 'injected', + cache: warmCache(NOW + KEEP_WARM_LEAD_MS - 1000), + idle: true, + paneAlive: true, + limitPaused: false, + automationActive: false, + pendingApproval: false, + composerEmpty: true, + lastHumanInputAt: NOW - HOUR, + budgetAnchorAt: NOW - HOUR, + maxKeepWarmMs: 2 * HOUR, + ...over, + }; +} + +describe('decideKeepWarm', () => { + it('nudges only when every guard holds', () => { + expect(decideKeepWarm(dueFacts())).toEqual({ action: 'nudge' }); + }); + + it('nudges on the app default, and for an agent-spawned session switched on for itself', () => { + expect(decideKeepWarm(dueFacts({ choice: undefined, appDefault: true }))).toEqual({ action: 'nudge' }); + expect(decideKeepWarm(dueFacts({ choice: true, spawnedByAgent: true }))).toEqual({ action: 'nudge' }); + }); + + it.each<[string, Partial<KeepWarmFacts>, string]>([ + ['switched off for the session', { choice: false, appDefault: true }, 'off'], + ['no choice and the app default off', { choice: undefined }, 'off'], + [ + 'an agent-spawned session on the app default', + { choice: undefined, appDefault: true, spawnedByAgent: true }, + 'agent-spawned', + ], + ['Plan Usage off at launch', { statusLine: 'collection-off' }, '(collection-off)'], + ["the workspace's own statusLine", { statusLine: 'workspace-statusline' }, '(workspace-statusline)'], + ['a remote or Docker launch', { statusLine: 'remote' }, '(remote)'], + ['a launch nobody recorded (a CLI without the exporter, a direct PTY)', { statusLine: undefined }, '(unknown)'], + ['no report yet', { cache: null }, 'no cache report yet'], + ['a cold cache', { cache: { warm: false } }, 'cold'], + [ + 'a context compacted this gap (the shape Session.setPromptCache stamps: no re-cache size)', + { cache: warmCache(NOW + 60_000, { recacheTokensIfCold: undefined, compactedAt: NOW - 60_000 }) }, + 'compacted', + ], + ['warm but no expiry to schedule on', { cache: { warm: true, recacheTokensIfCold: 110_000 } }, 'cold'], + ['the five-minute lifetime', { cache: warmCache(NOW + 4 * 60_000, { ttl: '5m' }) }, '5-minute'], + [ + 'a small context', + { cache: warmCache(NOW + 60_000, { recacheTokensIfCold: KEEP_WARM_MIN_TOKENS - 1 }) }, + 'too small', + ], + ['a dead pane', { paneAlive: false }, 'no live pane'], + ['a usage-limit pause', { limitPaused: true }, 'usage-limit'], + ['respawn, Ralph or an agent driving', { automationActive: true }, 'driving the session'], + ['the cap after the last prompt', { budgetAnchorAt: NOW - 2 * HOUR }, 'window after the last prompt'], + ['an unreadable composer', { composerEmpty: null }, 'cannot read the composer'], + ])('stops for %s', (_label, over, reason) => { + const d = decideKeepWarm(dueFacts(over)); + expect(d.action).toBe('stop'); + expect(d.action === 'stop' && d.reason).toContain(reason); + }); + + it('waits for the due time, five minutes before the expiry, when it is not due yet', () => { + const expiresAt = NOW + HOUR; + expect(decideKeepWarm(dueFacts({ cache: warmCache(expiresAt) }))).toEqual({ + action: 'wait', + until: expiresAt - KEEP_WARM_LEAD_MS, + reason: 'not due yet', + }); + }); + + it('gives up inside the last-chance minute rather than race the expiry', () => { + const d = decideKeepWarm(dueFacts({ cache: warmCache(NOW + KEEP_WARM_LAST_CHANCE_MS - 1) })); + expect(d).toMatchObject({ action: 'stop', reason: expect.stringContaining('too close') }); + }); + + it.each<[string, Partial<KeepWarmFacts>, string]>([ + ['the session is working', { idle: false }, 'working'], + ['a dialog is open', { pendingApproval: true }, 'dialog'], + ['the composer holds a draft', { composerEmpty: false }, 'composer holds text'], + ['someone typed a moment ago', { lastHumanInputAt: NOW - KEEP_WARM_RECENT_INPUT_MS + 1 }, 'typed'], + ])('defers by a minute while %s', (_label, over, reason) => { + const d = decideKeepWarm(dueFacts(over)); + expect(d).toMatchObject({ + action: 'wait', + until: NOW + KEEP_WARM_RETRY_MS, + reason: expect.stringContaining(reason), + }); + }); + + it('clamps a deferral to the last-chance line so a retry never lands on an expired cache', () => { + const expiresAt = NOW + KEEP_WARM_LAST_CHANCE_MS + 30_000; // due, 90 s left + const d = decideKeepWarm(dueFacts({ cache: warmCache(expiresAt), idle: false })); + expect(d).toMatchObject({ action: 'wait', until: expiresAt - KEEP_WARM_LAST_CHANCE_MS }); + }); + + it('checks the cap before the schedule, so a session past its window never arms a timer', () => { + const d = decideKeepWarm(dueFacts({ cache: warmCache(NOW + HOUR), budgetAnchorAt: NOW - 4 * HOUR })); + expect(d.action).toBe('stop'); + }); +}); + +describe('keepWarmOn', () => { + it('is the session choice, else the app default, which an agent-spawned session does not inherit', () => { + expect(keepWarmOn(true, false, false)).toBe(true); + expect(keepWarmOn(false, true, false)).toBe(false); + expect(keepWarmOn(undefined, true, false)).toBe(true); + expect(keepWarmOn(undefined, false, false)).toBe(false); + expect(keepWarmOn(undefined, true, true)).toBe(false); + expect(keepWarmOn(true, false, true)).toBe(true); + }); +}); + +describe('composerIsEmpty', () => { + const glyph = '\u276f'; + const rule = '─'.repeat(20); + it('reads the last composer down to its bottom rule: empty, a draft, or nothing to read', () => { + expect(composerIsEmpty(`transcript\n${glyph}\u00a0\n${rule}\n bypass permissions on`, glyph)).toBe(true); + expect(composerIsEmpty(`${glyph} old prompt\n● ok\n${glyph} half a draft\n${rule}`, glyph)).toBe(false); + expect(composerIsEmpty('Accessing workspace:\n Yes, I trust this folder', glyph)).toBeUndefined(); + expect(composerIsEmpty(`${glyph} anything\n${rule}`, '')).toBeUndefined(); + }); + + it('sees a draft that opens with a newline, on the continuation lines under a blank glyph line', () => { + expect(composerIsEmpty(`${glyph} \n unfinished draft\n${rule}\n bypass permissions on`, glyph)).toBe(false); + }); + + it('never takes an indented draft line holding the glyph or a rule for the composer edges', () => { + expect(composerIsEmpty(`${glyph} explain this terminal prompt:\n ${glyph}\n${rule}`, glyph)).toBe(false); + expect(composerIsEmpty(`${glyph} \n ${rule} under the line\n${rule}\n bypass permissions on`, glyph)).toBe(false); + }); + + it('gives no verdict without the bottom rule that bounds the composer', () => { + expect(composerIsEmpty(`${glyph} \n unfinished draft`, glyph)).toBeUndefined(); + }); +}); + +describe('Read My Mind ignores the nudge', () => { + it('drops the exact keep-alive line and nothing else', () => { + expect(isCapturablePrompt(KEEP_WARM_NUDGE)).toBe(false); + expect(isCapturablePrompt(`${KEEP_WARM_NUDGE}\n`)).toBe(false); + expect(isCapturablePrompt(`why did "${KEEP_WARM_NUDGE}" show up in my session?`)).toBe(true); + expect(isCapturablePrompt('fix the login bug')).toBe(true); + }); +}); + +describe('the two submit clocks on a Session', () => { + it('a nudge moves lastSubmitAt but not lastRealPromptAt, from the write and from the hook', () => { + vi.useFakeTimers(); + try { + vi.setSystemTime(NOW); + const session = new Session({ workingDir: '/tmp' }); + session.write('real prompt\r', { fromUser: true }); + expect(session.lastRealPromptAt).toBe(NOW); + + vi.setSystemTime(NOW + HOUR); + session.beginKeepAliveTurn(KEEP_WARM_TURN_WINDOW_MS); + session.write(`${KEEP_WARM_NUDGE}\r`); + session.markPromptSubmitted(); // its UserPromptSubmit hook + expect(session.lastSubmitAt).toBe(NOW + HOUR); + expect(session.lastRealPromptAt).toBe(NOW); + + // A person's prompt ends the keep-alive window at once and counts again. + vi.setSystemTime(NOW + HOUR + 1000); + session.write('next real prompt\r', { fromUser: true }); + expect(session.keepAliveTurnActive).toBe(false); + expect(session.lastRealPromptAt).toBe(NOW + HOUR + 1000); + + // A prompt typed in an attached tmux client reaches Codeman only through the hook. + vi.setSystemTime(NOW + 2 * HOUR); + session.markPromptSubmitted(); + expect(session.lastRealPromptAt).toBe(NOW + 2 * HOUR); + + // ... even inside a keep-alive window: its hook carries the prompt text, and anything but + // the nudge's own ends the window, so it counts and its stop hook alerts normally. + vi.setSystemTime(NOW + 3 * HOUR); + session.beginKeepAliveTurn(KEEP_WARM_TURN_WINDOW_MS); + session.markPromptSubmitted(KEEP_WARM_NUDGE); + expect(session.keepAliveTurnActive).toBe(true); + expect(session.lastRealPromptAt).toBe(NOW + 2 * HOUR); + vi.setSystemTime(NOW + 3 * HOUR + 5000); + session.markPromptSubmitted('fix the login bug'); + expect(session.keepAliveTurnActive).toBe(false); + expect(session.lastRealPromptAt).toBe(NOW + 3 * HOUR + 5000); + } finally { + vi.useRealTimers(); + } + }); + + it('persists the real-prompt clock and the choice, and restores both', () => { + const session = new Session({ workingDir: '/tmp', lastRealPromptAt: NOW, keepWarmEnabled: true }); + expect(session.toState()).toMatchObject({ lastRealPromptAt: NOW, keepWarmEnabled: true }); + const fresh = new Session({ workingDir: '/tmp' }); + expect(fresh.lastRealPromptAt).toBe(0); + expect(fresh.toState().lastRealPromptAt).toBeUndefined(); + }); +}); + +// ─── Service ───────────────────────────────────────────────────────────────── + +class FakeSession implements KeepWarmSessionView { + id = 'sess-1'; + createdAt = NOW - HOUR; + pid: number | null = 4242; + paneExit: unknown = undefined; + parentSessionId: string | undefined = undefined; + promptCache: PromptCacheStatus | null = null; + statusLineExporter = 'injected' as const; + idleCompactionOptOut = true; + keepWarmEnabled: boolean | undefined = undefined; + isWorking = false; + isLimitPaused = false; + lastHumanInputAt = NOW - HOUR; + lastRealPromptAt = NOW - HOUR; + idle = true; + writes: string[] = []; + keepAliveWindows: number[] = []; + writeOk = true; + isIdle(): boolean { + return this.idle; + } + beginKeepAliveTurn(windowMs: number): void { + this.keepAliveWindows.push(windowMs); + } + async writeViaMux(data: string): Promise<boolean> { + this.writes.push(data); + return this.writeOk; + } +} + +function makePorts(session: FakeSession, over: Partial<KeepWarmPorts> = {}) { + const published: KeepWarmStatus[] = []; + const ports: KeepWarmPorts = { + getSession: (id) => (id === session.id ? session : undefined), + sessionIds: () => [session.id], + readSettings: async () => ({ keepWarmDefault: false, maxHours: 2 }), + isAutomationActive: () => false, + hasPendingApproval: () => false, + readPane: () => ({ composerEmpty: true, limitShown: false }), + publish: (_id, status) => { + published.push(status); + }, + ...over, + }; + return { ports, published, last: () => published[published.length - 1] }; +} + +/** Let the service's awaited settings read and write settle. */ +const settle = async () => { + for (let i = 0; i < 5; i++) await Promise.resolve(); +}; + +describe('PromptCacheKeepWarmService', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(NOW); + }); + afterEach(() => { + vi.useRealTimers(); + }); + + it('arms on the cache report, nudges at the due time, and reports what it did', async () => { + const session = new FakeSession(); + session.keepWarmEnabled = true; + session.promptCache = warmCache(NOW + HOUR); + const { ports, last } = makePorts(session); + const service = new PromptCacheKeepWarmService(ports); + + service.schedule(session.id); + await settle(); + expect(last()).toMatchObject({ + enabled: true, + idleCompactionOff: true, + state: 'armed', + nextCheckAt: NOW + HOUR - KEEP_WARM_LEAD_MS, + nudges: 0, + }); + expect(session.writes).toHaveLength(0); + + await vi.advanceTimersByTimeAsync(HOUR - KEEP_WARM_LEAD_MS); + await settle(); + expect(session.writes).toEqual([`${KEEP_WARM_NUDGE}\r`]); + expect(session.keepAliveWindows).toEqual([KEEP_WARM_TURN_WINDOW_MS]); + expect(last()).toMatchObject({ state: 'nudged', nudges: 1, lastNudgeAt: NOW + HOUR - KEEP_WARM_LEAD_MS }); + + // The nudge's own report moves the expiry out; the next evaluation arms on it. + session.promptCache = warmCache(NOW + 2 * HOUR - KEEP_WARM_LEAD_MS); + service.schedule(session.id); + await settle(); + expect(last()).toMatchObject({ state: 'armed', nudges: 1 }); + service.stop(); + }); + + it('reads the composer only when otherwise due, and retries while it holds a draft', async () => { + const session = new FakeSession(); + session.keepWarmEnabled = true; + session.promptCache = warmCache(NOW + KEEP_WARM_LEAD_MS - 1000); + let draft = true; + const reads: number[] = []; + const { ports, last } = makePorts(session, { + readPane: () => { + reads.push(Date.now()); + return { composerEmpty: !draft, limitShown: false }; + }, + }); + const service = new PromptCacheKeepWarmService(ports); + + service.schedule(session.id); + await settle(); + expect(reads).toHaveLength(1); + expect(last()).toMatchObject({ + state: 'waiting', + reason: 'the composer holds text', + nextCheckAt: NOW + KEEP_WARM_RETRY_MS, + }); + expect(session.writes).toHaveLength(0); + + draft = false; + await vi.advanceTimersByTimeAsync(KEEP_WARM_RETRY_MS); + await settle(); + expect(reads).toHaveLength(2); + expect(session.writes).toHaveLength(1); + expect(last()).toMatchObject({ state: 'nudged', nudges: 1 }); + service.stop(); + }); + + it('follows the app-wide default when the session has not chosen, and publishes the effective flag', async () => { + const session = new FakeSession(); + session.promptCache = warmCache(NOW + HOUR); + const { ports, last } = makePorts(session, { readSettings: async () => ({ keepWarmDefault: true, maxHours: 2 }) }); + const service = new PromptCacheKeepWarmService(ports); + service.schedule(session.id); + await settle(); + expect(last()).toMatchObject({ enabled: true, state: 'armed' }); + + session.keepWarmEnabled = false; + service.schedule(session.id); + await settle(); + expect(last()).toMatchObject({ enabled: false, state: 'off' }); + expect(vi.getTimerCount()).toBe(0); + service.stop(); + }); + + it('applies the cap from settings and never arms past it', async () => { + const session = new FakeSession(); + session.keepWarmEnabled = true; + session.lastRealPromptAt = NOW - 2.5 * HOUR; + session.promptCache = warmCache(NOW + HOUR); + const { ports, last } = makePorts(session, { readSettings: async () => ({ keepWarmDefault: false, maxHours: 2 }) }); + const service = new PromptCacheKeepWarmService(ports); + service.schedule(session.id); + await settle(); + expect(last()).toMatchObject({ + state: 'stopped', + reason: expect.stringContaining('window'), + budgetEndsAt: NOW - 0.5 * HOUR, + }); + expect(vi.getTimerCount()).toBe(0); + service.stop(); + }); + + it('keeps a failed write out of the tally and retries after the lead time', async () => { + const session = new FakeSession(); + session.keepWarmEnabled = true; + session.writeOk = false; + session.promptCache = warmCache(NOW + KEEP_WARM_LEAD_MS - 1000); + const { ports, last } = makePorts(session); + const service = new PromptCacheKeepWarmService(ports); + service.schedule(session.id); + await settle(); + expect(session.writes).toHaveLength(1); + expect(last()).toMatchObject({ + state: 'waiting', + nudges: 0, + reason: expect.stringContaining('could not be written'), + }); + service.stop(); + }); + + it('does not nudge a session whose screen shows a usage limit, with auto-resume off', async () => { + const session = new FakeSession(); + session.keepWarmEnabled = true; + session.promptCache = warmCache(NOW + KEEP_WARM_LEAD_MS - 1000); + const { ports, last } = makePorts(session, { readPane: () => ({ composerEmpty: true, limitShown: true }) }); + const service = new PromptCacheKeepWarmService(ports); + await service.schedule(session.id); + expect(session.writes).toHaveLength(0); + expect(last()).toMatchObject({ state: 'stopped', reason: 'usage-limit pause' }); + service.stop(); + }); + + it('does not nudge an agent that exited in a pane that kept its pid', async () => { + const session = new FakeSession(); + session.keepWarmEnabled = true; + session.paneExit = { status: 137 }; + session.promptCache = warmCache(NOW + KEEP_WARM_LEAD_MS - 1000); + const { ports, last } = makePorts(session); + const service = new PromptCacheKeepWarmService(ports); + await service.schedule(session.id); + expect(session.writes).toHaveLength(0); + expect(last()).toMatchObject({ state: 'stopped', reason: 'no live pane' }); + service.stop(); + }); + + it('never arms a timer past what setTimeout honours, however far off the expiry', async () => { + const session = new FakeSession(); + session.keepWarmEnabled = true; + session.promptCache = warmCache(NOW + 30 * 24 * HOUR); + const { ports } = makePorts(session, { + readSettings: async () => ({ keepWarmDefault: false, maxHours: 24 * 60 }), + }); + const spy = vi.spyOn(globalThis, 'setTimeout'); + const service = new PromptCacheKeepWarmService(ports); + await service.schedule(session.id); + expect(spy).toHaveBeenCalledTimes(1); + expect(spy.mock.calls[0][1]).toBe(2 ** 31 - 1); + spy.mockRestore(); + service.stop(); + }); + + it('stop() during the settings read sends nothing, arms nothing and publishes nothing', async () => { + const session = new FakeSession(); + session.keepWarmEnabled = true; + session.promptCache = warmCache(NOW + KEEP_WARM_LEAD_MS - 1000); + let release!: () => void; + const gate = new Promise<void>((r) => (release = r)); + const { ports, published } = makePorts(session, { + readSettings: async () => { + await gate; + return { keepWarmDefault: false, maxHours: 2 }; + }, + }); + const service = new PromptCacheKeepWarmService(ports); + const run = service.schedule(session.id); + service.stop(); + release(); + await run; + expect(session.writes).toHaveLength(0); + expect(published).toHaveLength(0); + expect(vi.getTimerCount()).toBe(0); + }); + + it('remove() while the nudge is being written leaves no timer, tally or status behind', async () => { + const session = new FakeSession(); + session.keepWarmEnabled = true; + session.promptCache = warmCache(NOW + KEEP_WARM_LEAD_MS - 1000); + let release!: (ok: boolean) => void; + session.writeViaMux = async (data: string) => { + session.writes.push(data); + return new Promise<boolean>((r) => (release = r)); + }; + const { ports, published } = makePorts(session); + const service = new PromptCacheKeepWarmService(ports); + const run = service.schedule(session.id); + await settle(); + expect(session.writes).toHaveLength(1); + service.remove(session.id); + release(true); + await run; + expect(published).toHaveLength(0); + expect(vi.getTimerCount()).toBe(0); + service.stop(); + }); + + it('a cancelled evaluation finishing late does not cancel the one that replaced it', async () => { + const session = new FakeSession(); + session.keepWarmEnabled = true; + session.promptCache = warmCache(NOW + HOUR); + let releaseWrite!: (ok: boolean) => void; + let releaseRead: (() => void) | undefined; + const { ports, published } = makePorts(session, { + readSettings: async () => { + if (releaseRead === undefined) return { keepWarmDefault: false, maxHours: 2 }; + await new Promise<void>((r) => (releaseRead = r)); + return { keepWarmDefault: false, maxHours: 2 }; + }, + }); + const service = new PromptCacheKeepWarmService(ports); + // First evaluation: due, held in its write. + session.promptCache = warmCache(NOW + KEEP_WARM_LEAD_MS - 1000); + session.writeViaMux = async (data: string) => { + session.writes.push(data); + return new Promise<boolean>((r) => (releaseWrite = r)); + }; + const first = service.schedule(session.id); + await settle(); + service.remove(session.id); + // Second evaluation: not due, held in its settings read. + session.promptCache = warmCache(NOW + HOUR); + releaseRead = () => {}; + const second = service.schedule(session.id); + await settle(); + releaseWrite(true); + await first; + releaseRead(); + await second; + expect(published).toEqual([expect.objectContaining({ state: 'armed' })]); + service.stop(); + }); + + it('remove() forgets the nudge count, so an id seen again starts from zero', async () => { + const session = new FakeSession(); + session.keepWarmEnabled = true; + session.promptCache = warmCache(NOW + KEEP_WARM_LEAD_MS - 1000); + const { ports, last } = makePorts(session); + const service = new PromptCacheKeepWarmService(ports); + await service.schedule(session.id); + expect(last()).toMatchObject({ state: 'nudged', nudges: 1 }); + service.remove(session.id); + session.promptCache = warmCache(NOW + HOUR); + await service.schedule(session.id); + expect(last()).toMatchObject({ state: 'armed', nudges: 0 }); + service.stop(); + }); + + it('remove() drops the timer so a deleted session never gets a nudge', async () => { + const session = new FakeSession(); + session.keepWarmEnabled = true; + session.promptCache = warmCache(NOW + HOUR); + const { ports } = makePorts(session); + const service = new PromptCacheKeepWarmService(ports); + service.schedule(session.id); + await settle(); + expect(vi.getTimerCount()).toBe(1); + service.remove(session.id); + expect(vi.getTimerCount()).toBe(0); + await vi.advanceTimersByTimeAsync(2 * HOUR); + expect(session.writes).toHaveLength(0); + }); +}); diff --git a/test/prompt-cache-status.test.ts b/test/prompt-cache-status.test.ts index 6f3e9026..b5f0d515 100644 --- a/test/prompt-cache-status.test.ts +++ b/test/prompt-cache-status.test.ts @@ -17,14 +17,29 @@ type Cache = { lastMissCauses?: string[]; compactedAt?: number; }; +type KeepWarm = { + enabled: boolean; + idleCompactionOff?: boolean; + state: string; + nextCheckAt?: number; + reason?: string; + nudges: number; + lastNudgeAt?: number; + budgetEndsAt?: number; +}; +type Api = { + format: (c: Cache | null | undefined, now: number, statusLine?: string) => string; + formatKeepWarm: (s: KeepWarm | null | undefined, now: number, statusLine?: string) => string; + keepWarmUnavailable: (statusLine?: string) => boolean; +}; -function loadFormat() { +function loadApi(): Api { const context = vm.createContext({ window: {}, globalThis: {} }); const source = readFileSync(resolve(import.meta.dirname, '../src/web/public/constants.js'), 'utf8'); vm.runInContext(source, context, { filename: 'constants.js' }); - return (context.window as { CodemanPromptCache: { format: (c: Cache | null | undefined, now: number) => string } }) - .CodemanPromptCache.format as (c: Cache | null | undefined, now: number, statusLine?: string) => string; + return (context.window as { CodemanPromptCache: Api }).CodemanPromptCache; } +const loadFormat = () => loadApi().format; const clock = (ms: number) => new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); const NOW = Date.UTC(2026, 9, 6, 22, 18, 0); @@ -39,6 +54,8 @@ describe('Prompt cache readout', () => { it('says why when the launch carries no statusLine exporter, whatever report is stored', () => { const format = loadFormat(); expect(format(null, NOW, 'collection-off')).toMatch(/^Not collected: Plan Usage was off when this session started/); + // Collection is decided at launch, so the fix is a new session, and the readout says where the switch is. + expect(format(null, NOW, 'collection-off')).toContain('App Settings → Header & Panels, then start a new session'); expect(format({ warm: true, expiresAt: NOW + 60_000 }, NOW, 'collection-off')).toMatch(/^Not collected/); expect(format(null, NOW, 'workspace-statusline')).toMatch(/settings\.local\.json sets its own statusLine/); expect(format(null, NOW, 'remote')).toBe('Not collected for remote and Docker sessions.'); @@ -87,3 +104,149 @@ describe('Prompt cache readout', () => { ); }); }); + +describe('Keep-warm line on the Status row', () => { + const T = NOW + 55 * 60_000; + const END = NOW + 2 * 3_600_000; + + it('says nothing until the service has published, or while switched off', () => { + const { formatKeepWarm } = loadApi(); + expect(formatKeepWarm(null, NOW, 'injected')).toBe(''); + expect(formatKeepWarm({ enabled: false, state: 'off', nudges: 0 }, NOW, 'injected')).toBe(''); + expect(formatKeepWarm({ enabled: false, state: 'off', reason: 'off', nudges: 0 }, NOW, 'injected')).toBe(''); + }); + + it('armed: the due time and when the window closes', () => { + const { formatKeepWarm } = loadApi(); + expect(formatKeepWarm({ enabled: true, state: 'armed', nextCheckAt: T, nudges: 0, budgetEndsAt: END }, NOW)).toBe( + `Keep-alive: next ${clock(T)} · stops ${clock(END)}` + ); + expect( + formatKeepWarm({ enabled: true, state: 'armed', nextCheckAt: T, nudges: 3, budgetEndsAt: NOW - 1 }, NOW) + ).toBe(`Keep-alive: next ${clock(T)} · window closed`); + }); + + it('waiting and sent carry the reason and the count', () => { + const { formatKeepWarm } = loadApi(); + expect( + formatKeepWarm( + { + enabled: true, + state: 'waiting', + reason: 'the composer holds text', + nextCheckAt: T, + nudges: 1, + budgetEndsAt: END, + }, + NOW + ) + ).toBe(`Keep-alive: waiting · the composer holds text · retry ${clock(T)} · stops ${clock(END)}`); + expect(formatKeepWarm({ enabled: true, state: 'nudged', lastNudgeAt: T, nudges: 2, budgetEndsAt: END }, NOW)).toBe( + `Keep-alive: sent ${clock(T)} (2×) · stops ${clock(END)}` + ); + }); + + it('stopped, or off for a reason, names it', () => { + const { formatKeepWarm } = loadApi(); + expect(formatKeepWarm({ enabled: true, state: 'stopped', reason: 'cache is cold', nudges: 0 }, NOW)).toBe( + 'Keep-alive: stopped · cache is cold' + ); + expect( + formatKeepWarm({ enabled: false, state: 'off', reason: 'an agent-spawned session ...', nudges: 0 }, NOW) + ).toBe('Keep-alive: off · an agent-spawned session ...'); + }); + + it("adds nothing on a launch without the exporter: the readout's Not collected sentence already says why", () => { + const { formatKeepWarm } = loadApi(); + const stopped = { enabled: true, state: 'stopped', reason: 'no cache reports on this launch', nudges: 0 }; + for (const statusLine of ['collection-off', 'workspace-statusline', 'remote']) { + expect(formatKeepWarm(stopped, NOW, statusLine)).toBe(''); + } + }); + + it('says idle compaction stays on when the opt-out was not on this launch', () => { + const { formatKeepWarm } = loadApi(); + const armed = { enabled: true, state: 'armed', nextCheckAt: T, nudges: 0 }; + expect(formatKeepWarm({ ...armed, idleCompactionOff: true }, NOW, 'injected')).toBe(`Keep-alive: next ${clock(T)}`); + expect(formatKeepWarm({ ...armed, idleCompactionOff: false }, NOW, 'injected')).toBe( + `Keep-alive: next ${clock(T)} · idle compaction on until relaunch` + ); + }); +}); + +/** + * session-ui.js `updateKeepWarmStatus`, run as shipped against a minimal DOM: the keep-warm line + * on the Status row is hidden when it has nothing to say, and a launch without cache reports + * greys the switch's row and refuses to switch it on, never to switch it off. + */ +describe('Keep-warm switch and its Status line', () => { + /** One Session Options modal: the same elements across every session it is opened on. */ + function makeModal() { + const ui = readFileSync(resolve(import.meta.dirname, '../src/web/public/session-ui.js'), 'utf8'); + const start = ui.indexOf(' updateKeepWarmStatus(sessionId) {'); + expect(start).toBeGreaterThan(-1); + const update = new Function( + 'document', + 'window', + 'sessionId', + ui.slice(ui.indexOf('{', start) + 1, ui.indexOf('\n },\n', start)) + ); + const rowClasses = new Set<string>(); + const line = { textContent: '', hidden: true }; + const row = { + classList: { toggle: (c: string, on: boolean) => void (on ? rowClasses.add(c) : rowClasses.delete(c)) }, + }; + const box = { checked: false, disabled: false, closest: () => row }; + const elements: Record<string, unknown> = { keepWarmStatus: line, modalKeepWarmEnabled: box }; + const document = { getElementById: (id: string) => elements[id] ?? null }; + const window = { CodemanPromptCache: loadApi() }; + return (statusLine: string, keepWarm: KeepWarm | undefined, checked: boolean) => { + box.checked = checked; + const self = { + editingSessionId: 's1', + sessions: new Map([['s1', { statusLineExporter: statusLine, keepWarm }]]), + }; + update.call(self, document, window, 's1'); + return { line: { ...line }, greyed: rowClasses.has('set-row-disabled'), box: { ...box } }; + }; + } + const render = (statusLine: string, keepWarm: KeepWarm | undefined, checked: boolean) => + makeModal()(statusLine, keepWarm, checked); + + it('shows the line whenever it has words, any state, and hides it otherwise', () => { + const waiting = render( + 'injected', + { enabled: true, state: 'waiting', reason: 'the composer holds text', nudges: 0 }, + true + ); + expect(waiting.line.textContent).toContain('Keep-alive: waiting'); + expect(waiting.line.hidden).toBe(false); + expect(waiting.greyed).toBe(false); + expect(waiting.box.disabled).toBe(false); + expect(render('injected', { enabled: false, state: 'off', nudges: 0 }, false).line.hidden).toBe(true); + }); + + it('greys a launch without cache reports, but lets a switch that is on be turned off', () => { + const off = render('collection-off', { enabled: false, state: 'off', nudges: 0 }, false); + expect(off.line.hidden).toBe(true); + expect(off.greyed).toBe(true); + expect(off.box.disabled).toBe(true); + const on = render('collection-off', { enabled: true, state: 'stopped', nudges: 0 }, true); + expect(on.greyed).toBe(true); + expect(on.box.disabled).toBe(false); + }); + + it('undoes what the previous session left on the same modal', () => { + const show = makeModal(); + expect(show('collection-off', { enabled: false, state: 'off', nudges: 0 }, false).box.disabled).toBe(true); + const next = show('injected', { enabled: false, state: 'off', nudges: 0 }, false); + expect(next.box.disabled).toBe(false); + expect(next.greyed).toBe(false); + expect(show('injected', { enabled: true, state: 'armed', nextCheckAt: NOW, nudges: 0 }, true).line.hidden).toBe( + false + ); + const off = show('injected', { enabled: false, state: 'off', nudges: 0 }, false); + expect(off.line.hidden).toBe(true); + expect(off.line.textContent).toBe(''); + }); +}); diff --git a/test/routes/hook-event-routes.test.ts b/test/routes/hook-event-routes.test.ts index a553ea0c..79d18c0d 100644 --- a/test/routes/hook-event-routes.test.ts +++ b/test/routes/hook-event-routes.test.ts @@ -17,6 +17,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import Fastify, { type FastifyInstance } from 'fastify'; import fastifyCookie from '@fastify/cookie'; import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js'; +import { approvalInbox } from '../../src/web/approval-inbox.js'; import { installRouteErrorHandler } from '../../src/web/route-error-handler.js'; import { ApiErrorCode, httpStatusForErrorCode } from '../../src/types.js'; import { registerHookEventRoutes } from '../../src/web/routes/hook-event-routes.js'; @@ -98,6 +99,34 @@ describe('hook-event-routes', () => { ); }); + /** + * The prompt-cache keep-warm's own turn (prompt-cache-keepwarm.ts) ends with the same stop + * hook and idle prompt as a real one. While the session marks the turn as its keep-alive, + * both open pre-acknowledged: the broadcast says why, and nothing is pushed. + */ + it('opens the stop and idle prompt of a keep-alive turn pre-acknowledged and pushes nothing', async () => { + harness.ctx._session.keepAliveTurnActive = true; + for (const event of ['stop', 'idle_prompt']) { + const res = await harness.app.inject({ + method: 'POST', + url: '/api/hook-event', + payload: { event, sessionId: harness.ctx._sessionId, data: null }, + }); + expect(res.statusCode).toBe(200); + expect(harness.ctx.broadcast).toHaveBeenCalledWith( + `hook:${event}`, + expect.objectContaining({ sessionId: harness.ctx._sessionId, acknowledgedReason: 'cache keep-alive' }) + ); + } + expect(harness.ctx.sendPushNotifications).not.toHaveBeenCalled(); + // The inbox item is what a reload seeds tab alerts from, so it must open acknowledged too. + const item = approvalInbox.getForSession(harness.ctx._sessionId); + approvalInbox.resolveForSession(harness.ctx._sessionId, 'dismissed'); + expect(item?.acknowledgedReason).toBe('cache keep-alive'); + // acknowledgedAt is what approvals-ui.js reads to skip the alert on a reload. + expect(item?.acknowledgedAt).toBe(item?.createdAt); + }); + it('sends push notifications for hook events', async () => { const res = await harness.app.inject({ method: 'POST', @@ -144,6 +173,27 @@ describe('hook-event-routes', () => { expect(harness.ctx.persistSessionState).toHaveBeenCalledWith(session); }); + /** + * The hook's prompt text is what tells a person typing in an attached tmux client apart + * from the prompt-cache keep-alive (Session.markPromptSubmitted ends the keep-alive window + * for anything but the nudge), so the route must hand it over, and nothing else. + */ + it("hands the prompt_submitted hook's prompt text to the session", async () => { + const session = harness.ctx._session; + for (const data of [ + { session_id: 'conv-1', prompt: 'fix the login bug' }, + { session_id: 'conv-1', prompt: 42 }, + { session_id: 'conv-1' }, + ]) { + await harness.app.inject({ + method: 'POST', + url: '/api/hook-event', + payload: { event: 'prompt_submitted', sessionId: harness.ctx._sessionId, data }, + }); + } + expect(session.submittedPrompts).toEqual(['fix the login bug', undefined, undefined]); + }); + it('records a /clear successor in the chain and persists it, without duplicating a repeat', async () => { const session = harness.ctx._session; const submit = async (conversationId: string) => diff --git a/test/routes/session-routes-keep-warm.test.ts b/test/routes/session-routes-keep-warm.test.ts new file mode 100644 index 00000000..d5bee0d3 --- /dev/null +++ b/test/routes/session-routes-keep-warm.test.ts @@ -0,0 +1,76 @@ +/** + * `POST /api/sessions/:id/keep-warm` (session-routes.ts): the per-session opt-in for the + * prompt-cache keep-warm. Stores the choice, persists and broadcasts the session, asks the + * scheduler to re-evaluate, clears the choice on `null`, and refuses a CLI with no cache + * telemetry. app.inject(), no port. + */ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import Fastify, { type FastifyInstance } from 'fastify'; +import fastifyCookie from '@fastify/cookie'; +import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js'; +import { installRouteErrorHandler } from '../../src/web/route-error-handler.js'; +import { registerSessionRoutes } from '../../src/web/routes/session-routes.js'; + +interface Harness { + app: FastifyInstance; + ctx: MockRouteContext; +} + +async function createHarness(): Promise<Harness> { + const app = Fastify({ logger: false }); + await app.register(fastifyCookie); + const ctx = createMockRouteContext(); + registerSessionRoutes(app, ctx); + installRouteErrorHandler(app); + await app.ready(); + return { app, ctx }; +} + +describe('POST /api/sessions/:id/keep-warm', () => { + let harness: Harness; + + beforeEach(async () => { + harness = await createHarness(); + }); + + afterEach(async () => { + await harness.app.close(); + }); + + const post = (payload: unknown) => + harness.app.inject({ method: 'POST', url: `/api/sessions/${harness.ctx._sessionId}/keep-warm`, payload }); + + it('stores the choice, persists, broadcasts and re-evaluates', async () => { + const res = await post({ enabled: true }); + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body).data.keepWarm).toMatchObject({ enabled: true }); + expect(harness.ctx._session.keepWarmEnabled).toBe(true); + expect(harness.ctx.persistSessionState).toHaveBeenCalled(); + expect(harness.ctx.keepWarm.schedule).toHaveBeenCalledWith(harness.ctx._sessionId); + // The re-evaluation runs first, so the broadcast state and the response carry the new status. + expect(harness.ctx.keepWarm.schedule.mock.invocationCallOrder[0]).toBeLessThan( + harness.ctx.persistSessionState.mock.invocationCallOrder[0] + ); + }); + + it('clears the choice on null so the app-wide default applies again', async () => { + await post({ enabled: false }); + const res = await post({ enabled: null }); + expect(res.statusCode).toBe(200); + expect(harness.ctx._session.keepWarmEnabled).toBeUndefined(); + expect(JSON.parse(res.body).data.keepWarm.enabled).toBeNull(); + }); + + it('rejects a body without enabled', async () => { + expect((await post({})).statusCode).toBe(400); + }); + + it('refuses a CLI that reports no cache telemetry', async () => { + harness.ctx._session.mode = 'shell'; + const res = await post({ enabled: true }); + // This harness has no envelope hook, so the refusal shows in the body, not the status. + expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: 'INVALID_INPUT' }); + expect(harness.ctx._session.keepWarmEnabled).toBeUndefined(); + expect(harness.ctx.keepWarm.schedule).not.toHaveBeenCalled(); + }); +}); From 5db6c633d34ce0adaca15a778b6e29014fb8fa92 Mon Sep 17 00:00:00 2001 From: JD <jd@jds.haus> Date: Mon, 19 Oct 2026 01:42:00 -0400 Subject: [PATCH 13/96] fix(sessions): leave a pending idle alert to the user through a keep-alive turn Only a human spends an idle alert, but the keep-alive turn resolved an unseen one three ways: its working transition, its stop hook, and its own idle prompt replacing the item with a pre-acknowledged one. While the turn runs, a pending idle item is now left exactly as found, the page's stop handler returns on the keep-alive reason before clearing hooks or raising Response Complete, and session:working/session:idle carry keepAlive so the stuck-detection timer neither restarts nor re-arms. The window that marks the turn closes on its own idle prompt or any other submit instead of a fixed three minutes, which a slow turn's idle prompt (sent a minute after the turn ends) could outlast. The service keeps the default and cap in memory, read through a strict reader so a mid-write read is retried rather than kept, and the cap is clamped before the strict settings save. --- CLAUDE.md | 2 +- docs/api-reference.md | 2 +- docs/architecture-invariants.md | 2 +- docs/wiki/Keeping-Agents-Running.md | 4 +- src/hooks-config.ts | 30 +++++- src/prompt-cache-keepwarm.ts | 32 ++++++- src/session.ts | 22 ++++- src/web/public/app.js | 11 ++- src/web/public/constants.js | 16 +++- src/web/public/settings-ui.js | 7 +- src/web/routes/hook-event-routes.ts | 14 ++- src/web/routes/system-routes.ts | 4 +- src/web/schemas.ts | 6 +- src/web/server.ts | 4 +- src/web/session-listener-wiring.ts | 11 ++- test/keep-alive-page-alerts.test.ts | 126 ++++++++++++++++++++++++++ test/keep-warm-launch.test.ts | 18 +++- test/mocks/mock-session.ts | 3 + test/prompt-cache-keepwarm.test.ts | 58 +++++++++++- test/prompt-cache-status.test.ts | 9 ++ test/routes/hook-event-routes.test.ts | 69 ++++++++++++++ test/session-listener-wiring.test.ts | 32 +++++++ 22 files changed, 441 insertions(+), 41 deletions(-) create mode 100644 test/keep-alive-page-alerts.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index 51ba4dec..829554ae 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -158,7 +158,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Plan-usage chip** (`showPlanUsageLimits`, per-device: desktop default **ON**, handhelds OFF): resolve DISPLAY only through `planUsageChipEnabled()` (settings-ui.js). The same setting is the server-side COLLECTION switch, read fresh by `readPlanUsageTelemetryEnabled()` (hooks-config.ts) at every claude create/respawn. ⚠️ An ABSENT key reads as ON in the reader; `GET /api/settings` must never write. ⚠️ A save sends `showPlanUsageLimits` ONLY when it flips the chip on that device (`planUsageCollectionFlip()`), or a phone switches collection off for every desktop. Claude data comes from the statusLine exporter, injected as an EPHEMERAL `claude --settings` flag (`resolveStatusLineCliCommand`), never written to disk, WRAPPING a user's own statusLine, posting to `POST /api/status-telemetry`. Codex comes from a read-only `account/rateLimits/read` poll (main bucket only). The statusline's `prompt_cache` object (Claude Code v2.1.251+) is kept by `StatusTelemetrySchema` (every documented scalar field, null-tolerant; `parsePromptCache()` in usage-telemetry.ts normalizes only the stored subset) and shown in the footer as `cache:until HH:MM` / `cache:cold` (only when the user has no statusline of their own) and in Session Options → Respawn (`#promptCacheStatus`, fed by `Session.promptCache` in `toState()` plus the change-only `session:promptCache` SSE event, worded by the pure `CodemanPromptCache.format`): warm-until/cold, the re-cache stake, the hit rate, the last miss's causes, and a `Context compacted at HH:MM` note after a compaction or a tool-result clearing (stamped on the first warm report whose `recache_tokens_if_cold` is null after one that carried it, never from `expected_rebuilds`, which Claude Code bumps only at the next request; NEVER an alert). ⚠️ `Session.setPromptCache()` stamps `compactedAt` onto a NEW object (never mutating the parsed report) and the route broadcasts the STORED object; the last report is restored on restart (constructor `promptCache`, like `displayModel`) and the readout is time-aware (a passed `expiresAt` reads cold, not stale-warm). It is the source of truth for the cache TTL, never the plan tier. → [architecture-invariants#plan-usage-chip-statusline-telemetry](docs/architecture-invariants.md#plan-usage-chip-statusline-telemetry), `docs/usage-limits-display-plan.md` -**Prompt-cache keep-warm** (`src/prompt-cache-keepwarm.ts`; per-session opt-in on the Respawn tab, app-wide default `promptCacheKeepWarm` + cap `promptCacheKeepWarmMaxHours` in App Settings → System → Automation, SYNCED, default OFF / 2 h; step 2 of Discussion #554): one keep-alive turn (`KEEP_WARM_NUDGE`, under 100 chars) five minutes before an idle claude session's cache would expire, so the next prompt reads the conversation from cache instead of re-writing it at 2x. Codeman owns the idle policy for these sessions: a launch with keep-warm on also carries `"idleCompaction": false`, folded LAST into the ONE `--settings` JSON (`session-cli-registry-bridge.ts`), decided once per create/respawn by `decideIdleCompactionOptOut()` (tmux-manager.ts: keep-warm on via `keepWarmOn()`, exporter `injected`, claude's `idleCompactionSetting` gate at 2.1.290, fail-closed like `--name`) and recorded as `MuxSession.idleCompactionOff`; a keep-warm-off launch stays byte-identical (`test/cli-registry-spawn-golden.test.ts`). Switched on after launch it is best-effort and the readout says idle compaction may still run until the next launch. ⚠️ The deadline is `Session.promptCache.expiresAt` from the statusline, never idle detection or the plan tier; a `5m` TTL is a hard stop, and so is a launch whose `statusLineExporter` is not `injected`. ⚠️ Every guard lives in the pure `decideKeepWarm()` (effective opt-in, where an agent-spawned session (`parentSessionId`) does not inherit the app default; exporter injected; warm; no `compactedAt` this gap; tokens at stake; live pane (pid AND no `paneExit`); no limit pause (auto-resume's, or a limit message on screen); no respawn/Ralph/task/waiter; inside the cap since `lastRealPromptAt`; idle; no dialog; EMPTY composer via `composerIsEmpty()`, continuation lines included; no recent keystroke); add a guard there, never in the service. ⚠️ The nudge IS a submit and moves `lastSubmitAt` (the response viewer's conversation correlation needs it); the window `beginKeepAliveTurn()` opens keeps it off `lastRealPromptAt` (the cap's own persisted clock) and opens its `stop`/`idle_prompt` hooks pre-acknowledged (`preAcknowledged` on `notePrompt`), so nothing alerts; a `fromUser` write, or a `prompt_submitted` hook whose prompt is not the nudge, ends that window. Read My Mind drops its exact text (`isCapturablePrompt`). → [architecture-invariants#prompt-cache-keep-warm](docs/architecture-invariants.md#prompt-cache-keep-warm) +**Prompt-cache keep-warm** (`src/prompt-cache-keepwarm.ts`; per-session opt-in on the Respawn tab, app-wide default `promptCacheKeepWarm` + cap `promptCacheKeepWarmMaxHours` in App Settings → System → Automation, SYNCED, default OFF / 2 h; step 2 of Discussion #554): one keep-alive turn (`KEEP_WARM_NUDGE`, under 100 chars) five minutes before an idle claude session's cache would expire, so the next prompt reads the conversation from cache instead of re-writing it at 2x. Codeman owns the idle policy for these sessions: a launch with keep-warm on also carries `"idleCompaction": false`, folded LAST into the ONE `--settings` JSON (`session-cli-registry-bridge.ts`), decided once per create/respawn by `decideIdleCompactionOptOut()` (tmux-manager.ts: keep-warm on via `keepWarmOn()`, exporter `injected`, claude's `idleCompactionSetting` gate at 2.1.290, fail-closed like `--name`) and recorded as `MuxSession.idleCompactionOff`; a keep-warm-off launch stays byte-identical (`test/cli-registry-spawn-golden.test.ts`). Switched on after launch it is best-effort and the readout says idle compaction may still run until the next launch. ⚠️ The deadline is `Session.promptCache.expiresAt` from the statusline, never idle detection or the plan tier; a `5m` TTL is a hard stop, and so is a launch whose `statusLineExporter` is not `injected`. ⚠️ Every guard lives in the pure `decideKeepWarm()` (effective opt-in, where an agent-spawned session (`parentSessionId`) does not inherit the app default; exporter injected; warm; no `compactedAt` this gap; tokens at stake; live pane (pid AND no `paneExit`); no limit pause (auto-resume's, or a limit message on screen); no respawn/Ralph/task/waiter; inside the cap since `lastRealPromptAt`; idle; no dialog; EMPTY composer via `composerIsEmpty()`, continuation lines included; no recent keystroke); add a guard there, never in the service. ⚠️ The nudge IS a submit and moves `lastSubmitAt` (the response viewer's conversation correlation needs it); the window `beginKeepAliveTurn()` opens keeps it off `lastRealPromptAt` (the cap's own persisted clock) and opens its `stop`/`idle_prompt` hooks pre-acknowledged (`preAcknowledged` on `notePrompt`), so nothing alerts, and leaves an idle item already pending exactly as found (only a human spends an idle alert; `session:working`/`session:idle` carry `keepAlive: true` so the page's stuck timer ignores the turn); the keep-alive's own `idle_prompt` ends that window (`endKeepAliveTurn()`; `KEEP_WARM_TURN_WINDOW_MS` is only a ceiling, since a slow turn's idle prompt lands a minute after it ends), and so does any other submit: a `fromUser` write, a programmatic write of anything but the nudge, or a `prompt_submitted` hook whose prompt is not the nudge. Read My Mind drops its exact text (`isCapturablePrompt`). → [architecture-invariants#prompt-cache-keep-warm](docs/architecture-invariants.md#prompt-cache-keep-warm) **Orchestrator**: State machine that turns a user goal into a phased plan and drives it to completion: `idle → planning → approval → executing → verifying → (replanning) → completed/failed`. `OrchestratorLoop` (engine) delegates plan generation to `orchestrator-planner` and per-phase verification gates to `orchestrator-verifier`, executing phases via team agents/`task-queue`. State persists under the `orchestrator` key in `state.json`. Distinct from Ralph (single-session autonomous loop) — orchestrator coordinates multi-phase, multi-agent execution. See `docs/orchestrator-loop-architecture.md`. diff --git a/docs/api-reference.md b/docs/api-reference.md index 77b5a8ae..00fc4ef6 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1160,7 +1160,7 @@ Claude Code reports the main conversation's prompt-cache state on its statusline | ------ | ---------------------------------- | ------------------------------------ | ----- | | `POST` | `/api/v1/sessions/:id/keep-warm` | `{ "enabled": true \| false \| null }` | Sets the session's choice; `null` clears it so the app-wide default applies. `200` with `{ keepWarm: { enabled, status } }`. `400 INVALID_INPUT` for a CLI that reports no cache telemetry on its statusline (anything but Claude today). | -`status` is `{ enabled, idleCompactionOff, state, nextCheckAt?, reason?, nudges, lastNudgeAt?, budgetEndsAt? }` (`idleCompactionOff`: whether the session's current launch carries that opt-out) with `state` one of `off`, `armed` (waiting for the due time), `waiting` (due, deferred by a guard named in `reason`), `nudged` (sent, waiting for the next cache report) or `stopped` (`reason` says why). Two SSE events carry changes: `session:promptCache` (`{ sessionId, ...promptCache }`) and `session:keepWarm` (`{ sessionId, ...status }`), both change-only. The keep-alive turn's `hook:stop` and `hook:idle_prompt` broadcasts carry `acknowledgedReason: "cache keep-alive"`, and no push is sent for them. +`status` is `{ enabled, idleCompactionOff, state, nextCheckAt?, reason?, nudges, lastNudgeAt?, budgetEndsAt? }` (`idleCompactionOff`: whether the session's current launch carries that opt-out) with `state` one of `off`, `armed` (waiting for the due time), `waiting` (due, deferred by a guard named in `reason`), `nudged` (sent, waiting for the next cache report) or `stopped` (`reason` says why). Two SSE events carry changes: `session:promptCache` (`{ sessionId, ...promptCache }`) and `session:keepWarm` (`{ sessionId, ...status }`), both change-only. The keep-alive turn's `hook:stop` and `hook:idle_prompt` broadcasts carry `acknowledgedReason: "cache keep-alive"`, and no push is sent for them; an idle approval already pending stays as it was (only a person spends its alert). Its `session:working` and `session:idle` broadcasts carry `keepAlive: true`. ## Webhook notifications diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 98273ed1..6ed205ae 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -161,7 +161,7 @@ Further detail: the handheld OFF default comes from the mobile block in `getDefa ### Prompt-cache keep-warm -**Prompt-cache keep-warm** (`src/prompt-cache-keepwarm.ts`; per-session opt-in `keepWarmEnabled` on the Respawn tab, app-wide default `promptCacheKeepWarm` and cap `promptCacheKeepWarmMaxHours` under App Settings → System → Automation, both SYNCED, default OFF and 2 h; step 2 of Discussion #554): shortly before an idle claude session's prompt cache would expire, Codeman sends ONE keep-alive turn (`KEEP_WARM_NUDGE`, under 100 characters so Claude Code never reads the burst as a paste) so the next real prompt still reads the conversation from cache. Measured on two sessions of ~110k tokens: the nudge read 109,831 tokens from cache and wrote 70, replied `ok` in three seconds with no tool use, and a real turn seven minutes past the original expiry stayed a hit while the un-nudged control re-wrote 89,882 tokens with `last_miss_cause: ttl_expired_1h`; the payoff on real usage is concentrated in gaps of one to four hours, which is why there is a cap after the last real prompt rather than an indefinite loop (2 h by default: the maintainer's simulation over 30 days of gaps came out slightly cheaper than 3 h). ⚠️ **Codeman owns the idle policy for a keep-warm session.** Claude Code 2.1.289+ can compact an idle 1h-cache session by itself at 90% of the TTL (a server-flagged experiment, `fireAtFraction` tunable down to 0.5), which on the default timing always lands a minute before the nudge and leaves only a summary to keep warm. So a launch with keep-warm on carries `"idleCompaction": false`, folded LAST into the ONE `--settings` JSON with ultracode, the advisor and the statusLine exporter (`buildSpawnCommandFromRegistry`; never a second `--settings` flag, Claude Code takes one), so a keep-warm-off launch is byte-identical to one from before keep-warm (`test/cli-registry-spawn-golden.test.ts`). The `--settings` flag outranks user, project and local settings, which is why the key is conditional: everywhere else it would take Claude Code's own behaviour away with no way back. `decideIdleCompactionOptOut()` (tmux-manager.ts, pure) decides it once per create and respawn: keep-warm on (`keepWarmOn()`: the session's choice, else the app default, read fresh by `readKeepWarmSettings()`), the exporter `injected` (without `prompt_cache` reports keep-warm cannot run, and the opt-out alone would only cost the user the idle compaction), and claude's `idleCompactionSetting` gate (`capabilities.gates`, 2.1.290, the first release whose settings schema has the key; 2.1.289 already idle-compacts with no opt-out), fail-closed through `launchGatePasses()` exactly like `--name`: an unknown version means no key, and a log line says so. The result is recorded on the mux record (`MuxSession.idleCompactionOff`, like `statusLine`, persisted in mux-sessions.json) and published in the keep-warm status as `idleCompactionOff`; a session switched on after launch is kept warm best-effort (a relaunch to install the opt-out would cost exactly the re-write being avoided) and its readout says Claude Code's idle compaction may still run until its next launch, which any relaunch (dead-pane recovery, a custom-model `restartCli()`) fixes for free since every relaunch re-decides. ⚠️ The deadline is the statusline's `prompt_cache.expires_at` (`Session.promptCache`), never idle detection or the plan tier, and a `5m` lifetime is a hard stop (a nudge every four minutes costs more than the miss it prevents; API-key users get the hour from `CLAUDE_CODE_PROMPT_CACHE_TTL=1h` in `envOverrides`). ⚠️ `decideKeepWarm()` is the ONE rulebook and every guard lives in it, tested as a table: the effective opt-in (`keepWarmOn()`; a session an agent spawned, `parentSessionId`, does not inherit the app default, since an idle worker that reported done looks exactly like one that will be prompted again and most never are; its own switch still works), the launch's `statusLineExporter` is `injected` (which also stops every CLI without cache telemetry; the toggle line says why in the readout's own `PROMPT_CACHE_NOT_COLLECTED` words), a warm cache with an expiry, no `compactedAt` this gap (a compaction already replaced the context, and the stamp is dropped at the next request, which re-opens the next gap), not a `5m` lifetime, at least `KEEP_WARM_MIN_TOKENS` at stake, a live pane (a pid AND no recorded `paneExit`: an agent that exited in a `remain-on-exit` pane keeps its pid), no usage-limit pause (auto-resume's, or, read off the same pane capture as the composer, a usage-limit message whose reset is still ahead, since auto-resume tracks pauses only while it is on), no automation (respawn running, a Ralph loop active, a task assigned, or an agent long-polling `wait`), inside the cap measured from `lastRealPromptAt` (else `createdAt`), due at `expires_at` minus `KEEP_WARM_LEAD_MS` but not within `KEEP_WARM_LAST_CHANCE_MS`, idle, no open permission or question dialog, an EMPTY composer (`composerIsEmpty()` in session-submit-verifier.ts reads the last glyph line AND its continuation lines down to the composer's bottom rule, since a draft that opens with a newline leaves the glyph line blank: `send-keys -l` would append the nudge to a draft, and an unreadable pane or a missing rule is a stop, never a guess), and no human keystroke within `KEEP_WARM_RECENT_INPUT_MS`. Transient guards defer by `KEEP_WARM_RETRY_MS` and never past the last-chance line; the composer is read only once everything else says "due". ⚠️ **Two submit clocks.** The nudge goes out through a plain `writeViaMux()` after `beginKeepAliveTurn()`. It IS a submit, so it moves `lastSubmitAt` from the write and again from its `prompt_submitted` hook (`markPromptSubmitted()`): the response viewer's conversation correlation (`resolveActiveClaudeSessionIdFromHistory()`) matches that stamp against Claude's history, which records the nudge like any prompt. The cap needs the last REAL prompt instead, so `Session._stampSubmit()` also moves `lastRealPromptAt` for every submit outside the keep-alive window (a person in the browser, an agent over the API, a typist in an attached tmux client seen through the hook), persisted in `SessionState.lastRealPromptAt` and restored by both rebuild paths so a restart never reopens the window. Any `fromUser` write closes the window at once, and so does a `prompt_submitted` hook whose `prompt` is not the exact nudge (the route hands the hook's prompt text to `markPromptSubmitted()`), so a person's prompt right after a nudge, typed in the browser or in an attached tmux client, counts and alerts normally. The home screens and the tile grid read `lastSubmitAt` only to anchor a WORKING row, so a nudge shows there only for the seconds its turn runs; idle rows key on `lastActivityAt`, which the nudge's reply moves like any output. ⚠️ The same window makes the turn's `stop` hook and `idle_prompt` open pre-acknowledged (`preAcknowledged: 'cache keep-alive'` through `notePrompt()`, the same path `watching` uses), so no tab alert, toast, push or webhook fires, and Read My Mind drops the exact nudge text in `isCapturablePrompt()`, the filter `captureIntentPrompt()` → `intentStore.recordPrompt()` runs every transcript prompt through. ⚠️ Re-evaluation is edge-triggered and cheap: every cache report (`status-telemetry-routes.ts`), the toggle route, a settings change (`rescheduleAll()`), `setupSessionListeners()` and the session's `idle` event (`keepWarmSchedule` in session-listener-wiring.ts, which is also what arms a restored report once the recovered pane is attached); the service keeps one timer per session (clamped to the 2^31-1 ms `setTimeout` honours, so a far-off expiry cannot turn into a 1 ms loop), `remove()`s it with the session and stops with the server, and a `remove()` or `stop()` landing during an evaluation's await cancels it there (nothing sent, armed or published after), and the server logs one `[KeepWarm]` line per status change naming the guard that holds it. Status rides `Session.keepWarm` in `toState()` and the change-only `session:keepWarm` event for the readout; `keepWarmEnabled`, `lastRealPromptAt` and the last `promptCache` report are restored by the constructor on both rebuild paths (that is what re-arms the timer after a restart), the status never is. Tests: `test/prompt-cache-keepwarm.test.ts`, `test/keep-warm-launch.test.ts`, `test/keep-warm-i18n.test.ts`, `test/routes/session-routes-keep-warm.test.ts`, the keep-warm readout in `test/prompt-cache-status.test.ts`, the opt-out literals in `test/cli-registry-spawn-golden.test.ts`, and the keep-alive case in `test/routes/hook-event-routes.test.ts`. +**Prompt-cache keep-warm** (`src/prompt-cache-keepwarm.ts`; per-session opt-in `keepWarmEnabled` on the Respawn tab, app-wide default `promptCacheKeepWarm` and cap `promptCacheKeepWarmMaxHours` under App Settings → System → Automation, both SYNCED, default OFF and 2 h; step 2 of Discussion #554): shortly before an idle claude session's prompt cache would expire, Codeman sends ONE keep-alive turn (`KEEP_WARM_NUDGE`, under 100 characters so Claude Code never reads the burst as a paste) so the next real prompt still reads the conversation from cache. Measured on two sessions of ~110k tokens: the nudge read 109,831 tokens from cache and wrote 70, replied `ok` in three seconds with no tool use, and a real turn seven minutes past the original expiry stayed a hit while the un-nudged control re-wrote 89,882 tokens with `last_miss_cause: ttl_expired_1h`; the payoff on real usage is concentrated in gaps of one to four hours, which is why there is a cap after the last real prompt rather than an indefinite loop (2 h by default: the maintainer's simulation over 30 days of gaps came out slightly cheaper than 3 h). ⚠️ **Codeman owns the idle policy for a keep-warm session.** Claude Code 2.1.289+ can compact an idle 1h-cache session by itself at 90% of the TTL (a server-flagged experiment, `fireAtFraction` tunable down to 0.5), which on the default timing always lands a minute before the nudge and leaves only a summary to keep warm. So a launch with keep-warm on carries `"idleCompaction": false`, folded LAST into the ONE `--settings` JSON with ultracode, the advisor and the statusLine exporter (`buildSpawnCommandFromRegistry`; never a second `--settings` flag, Claude Code takes one), so a keep-warm-off launch is byte-identical to one from before keep-warm (`test/cli-registry-spawn-golden.test.ts`). The `--settings` flag outranks user, project and local settings, which is why the key is conditional: everywhere else it would take Claude Code's own behaviour away with no way back. `decideIdleCompactionOptOut()` (tmux-manager.ts, pure) decides it once per create and respawn: keep-warm on (`keepWarmOn()`: the session's choice, else the app default, read fresh by `readKeepWarmSettings()`), the exporter `injected` (without `prompt_cache` reports keep-warm cannot run, and the opt-out alone would only cost the user the idle compaction), and claude's `idleCompactionSetting` gate (`capabilities.gates`, 2.1.290, the first release whose settings schema has the key; 2.1.289 already idle-compacts with no opt-out), fail-closed through `launchGatePasses()` exactly like `--name`: an unknown version means no key, and a log line says so. The result is recorded on the mux record (`MuxSession.idleCompactionOff`, like `statusLine`, persisted in mux-sessions.json) and published in the keep-warm status as `idleCompactionOff`; a session switched on after launch is kept warm best-effort (a relaunch to install the opt-out would cost exactly the re-write being avoided) and its readout says Claude Code's idle compaction may still run until its next launch, which any relaunch (dead-pane recovery, a custom-model `restartCli()`) fixes for free since every relaunch re-decides. ⚠️ The deadline is the statusline's `prompt_cache.expires_at` (`Session.promptCache`), never idle detection or the plan tier, and a `5m` lifetime is a hard stop (a nudge every four minutes costs more than the miss it prevents; API-key users get the hour from `CLAUDE_CODE_PROMPT_CACHE_TTL=1h` in `envOverrides`). ⚠️ `decideKeepWarm()` is the ONE rulebook and every guard lives in it, tested as a table: the effective opt-in (`keepWarmOn()`; a session an agent spawned, `parentSessionId`, does not inherit the app default, since an idle worker that reported done looks exactly like one that will be prompted again and most never are; its own switch still works), the launch's `statusLineExporter` is `injected` (which also stops every CLI without cache telemetry; the toggle line says why in the readout's own `PROMPT_CACHE_NOT_COLLECTED` words), a warm cache with an expiry, no `compactedAt` this gap (a compaction already replaced the context, and the stamp is dropped at the next request, which re-opens the next gap), not a `5m` lifetime, at least `KEEP_WARM_MIN_TOKENS` at stake, a live pane (a pid AND no recorded `paneExit`: an agent that exited in a `remain-on-exit` pane keeps its pid), no usage-limit pause (auto-resume's, or, read off the same pane capture as the composer, a usage-limit message whose reset is still ahead, since auto-resume tracks pauses only while it is on), no automation (respawn running, a Ralph loop active, a task assigned, or an agent long-polling `wait`), inside the cap measured from `lastRealPromptAt` (else `createdAt`), due at `expires_at` minus `KEEP_WARM_LEAD_MS` but not within `KEEP_WARM_LAST_CHANCE_MS`, idle, no open permission or question dialog, an EMPTY composer (`composerIsEmpty()` in session-submit-verifier.ts reads the last glyph line AND its continuation lines down to the composer's bottom rule, since a draft that opens with a newline leaves the glyph line blank: `send-keys -l` would append the nudge to a draft, and an unreadable pane or a missing rule is a stop, never a guess), and no human keystroke within `KEEP_WARM_RECENT_INPUT_MS`. Transient guards defer by `KEEP_WARM_RETRY_MS` and never past the last-chance line; the composer is read only once everything else says "due". ⚠️ **Two submit clocks.** The nudge goes out through a plain `writeViaMux()` after `beginKeepAliveTurn()`. It IS a submit, so it moves `lastSubmitAt` from the write and again from its `prompt_submitted` hook (`markPromptSubmitted()`): the response viewer's conversation correlation (`resolveActiveClaudeSessionIdFromHistory()`) matches that stamp against Claude's history, which records the nudge like any prompt. The cap needs the last REAL prompt instead, so `Session._stampSubmit()` also moves `lastRealPromptAt` for every submit outside the keep-alive window (a person in the browser, an agent over the API, a typist in an attached tmux client seen through the hook), persisted in `SessionState.lastRealPromptAt` and restored by both rebuild paths so a restart never reopens the window. The window closes on the keep-alive's own `idle_prompt` (the hook route calls `endKeepAliveTurn()`; Claude Code sends it about a minute after the turn ends, so a fixed short window would let a slow turn's idle prompt alert and push, and `KEEP_WARM_TURN_WINDOW_MS` is only a ceiling for a workspace whose hooks never report). Any other submit closes it at once: a `fromUser` write, a programmatic write carrying anything but the nudge or a bare Enter (an agent, respawn, auto-resume; `isKeepAliveWrite()` in session.ts), or a `prompt_submitted` hook whose `prompt` is not the exact nudge (the route hands the hook's prompt text to `markPromptSubmitted()`), so a person's prompt right after a nudge, typed in the browser or in an attached tmux client, counts and alerts normally. The home screens and the tile grid read `lastSubmitAt` only to anchor a WORKING row, so a nudge shows there only for the seconds its turn runs; idle rows key on `lastActivityAt`, which the nudge's reply moves like any output. ⚠️ The same window makes the turn's `stop` hook and `idle_prompt` open pre-acknowledged (`preAcknowledged: 'cache keep-alive'` through `notePrompt()`, the same path `watching` uses), so no tab alert, toast, push or webhook fires. ⚠️ Only a human spends an idle alert, so the keep-alive turn leaves an idle item already pending exactly as it found it: the `working` transition's idle resolve (session-listener-wiring.ts) and the `stop` resolve skip it, and the keep-alive's `idle_prompt` does not replace it (a replacement would forget that it was unseen, or that a person had seen it), while the page's `_onHookStop` returns on `acknowledgedReason` before clearing anything; `session:working` and `session:idle` carry `keepAlive: true` for the turn, so the page's stuck-detection timer is neither cleared nor re-armed by it. Read My Mind drops the exact nudge text in `isCapturablePrompt()`, the filter `captureIntentPrompt()` → `intentStore.recordPrompt()` runs every transcript prompt through. ⚠️ Re-evaluation is edge-triggered and cheap: every cache report (`status-telemetry-routes.ts`), the toggle route, a settings change (`rescheduleAll()`, which also drops the service's in-memory copy of the default and cap, so a cache report never reads settings.json), `setupSessionListeners()` and the session's `idle` event (`keepWarmSchedule` in session-listener-wiring.ts, which is also what arms a restored report once the recovered pane is attached); the service keeps one timer per session (clamped to the 2^31-1 ms `setTimeout` honours, so a far-off expiry cannot turn into a 1 ms loop), `remove()`s it with the session and stops with the server, and a `remove()` or `stop()` landing during an evaluation's await cancels it there (nothing sent, armed or published after), and the server logs one `[KeepWarm]` line per status change naming the guard that holds it. Status rides `Session.keepWarm` in `toState()` and the change-only `session:keepWarm` event for the readout; `keepWarmEnabled`, `lastRealPromptAt` and the last `promptCache` report are restored by the constructor on both rebuild paths (that is what re-arms the timer after a restart), the status never is. Tests: `test/prompt-cache-keepwarm.test.ts`, `test/keep-warm-launch.test.ts`, `test/keep-warm-i18n.test.ts`, `test/routes/session-routes-keep-warm.test.ts`, the keep-warm readout in `test/prompt-cache-status.test.ts`, the opt-out literals in `test/cli-registry-spawn-golden.test.ts`, the keep-alive cases in `test/routes/hook-event-routes.test.ts` and `test/session-listener-wiring.test.ts`, and the page handlers in `test/keep-alive-page-alerts.test.ts`. ### Cron jobs diff --git a/docs/wiki/Keeping-Agents-Running.md b/docs/wiki/Keeping-Agents-Running.md index 7150e7f6..18855654 100644 --- a/docs/wiki/Keeping-Agents-Running.md +++ b/docs/wiki/Keeping-Agents-Running.md @@ -113,9 +113,9 @@ Claude Code 2.1.251 and newer reports the cache state on the statusline after ev It only fires when there is nothing else going on: the session is idle with an empty prompt, no permission or question dialog is open, nobody has typed in the last few minutes, no respawn loop, Ralph loop or waiting agent is driving the session, and the session is not paused on a usage limit. If any of those hold it waits a minute and looks again, and gives up a minute before the expiry rather than race it. It also stops by itself a few hours after your last prompt (**Keep warm for at most**, under App Settings → System → Automation, default 2 hours), because the saving is concentrated in gaps of one to four hours and an overnight run of hourly keep-alives would cost more than the single cold turn in the morning. It stands down for the rest of a gap once the context has been compacted. The readout's Status line gains a `Keep-alive:` line that says what it is doing: the next keep-alive and when the window closes, waiting and why, sent at a time with a count, or stopped and why. A session started without the cache readout (Plan Usage off at launch, a workspace with its own statusLine, a remote or Docker session) cannot be kept warm: the switch is greyed out and cannot be switched on (one that is already on can still be switched off), and the Status line says why. -Recent Claude Code builds can compact a large idle session on their own shortly before the cache expires, which would leave only a summary to keep warm. So a session **started** with keep-warm on also launches with Claude Code's idle compaction switched off (Claude Code 2.1.290 or newer); Codeman then owns the idle policy for it. A session you switch on after it started is still kept warm, but Claude Code may compact it first until its next launch, and the Status line says so. Sessions with keep-warm off launch exactly as before. +Recent Claude Code builds can compact a large idle session on their own shortly before the cache expires, which would leave only a summary to keep warm. So a session **started** with keep-warm on also launches with Claude Code's idle compaction switched off (Claude Code 2.1.290 or newer); Codeman then owns the idle policy for it. A session you switch on after it started is still kept warm, but Claude Code may compact it first until its next launch, and the Status line says so; one you switch off keeps Claude Code's idle compaction off until its next launch, and the Status line says that too. Sessions with keep-warm off launch exactly as before. -**Keep prompt caches warm** in the same Automation group is the default for sessions that have not chosen on their Respawn tab; the per-session switch always wins. A session another agent started (a worker) does not follow the default: most workers are never prompted again once they report done, so turn it on for one by hand if you will come back to it. The keep-alive turn is Codeman's, not yours: it does not restart the **Keep warm for at most** window, does not rename a tab, Read My Mind ignores it, and the response it ends with raises no alert, toast, push or webhook. It is refused on the five-minute cache lifetime, which is what an API key, a cloud provider or extra-usage credits get; a session on an API key can ask Claude Code for the hour with `CLAUDE_CODE_PROMPT_CACHE_TTL=1h` in its environment overrides. +**Keep prompt caches warm** in the same Automation group is the default for sessions that have not chosen on their Respawn tab; the per-session switch always wins. A session another agent started (a worker) does not follow the default: most workers are never prompted again once they report done, so turn it on for one by hand if you will come back to it. The keep-alive turn is Codeman's, not yours: it does not restart the **Keep warm for at most** window, does not rename a tab, Read My Mind ignores it, and the response it ends with raises no alert, toast, push or webhook. It never clears an alert that was already waiting for you. It is refused on the five-minute cache lifetime, which is what an API key, a cloud provider or extra-usage credits get; a session on an API key can ask Claude Code for the hour with `CLAUDE_CODE_PROMPT_CACHE_TTL=1h` in its environment overrides. ## The plan usage chip diff --git a/src/hooks-config.ts b/src/hooks-config.ts index ca71dc7f..f1bd7ebc 100644 --- a/src/hooks-config.ts +++ b/src/hooks-config.ts @@ -1148,14 +1148,34 @@ export async function readPinCliSessionNameEnabled(): Promise<boolean> { /** * The prompt-cache keep-warm's app-wide default (`promptCacheKeepWarm`, absent = OFF) and its * cap (`promptCacheKeepWarmMaxHours`, hours after the last real prompt). Read FRESH like - * {@link readPinCliSessionNameEnabled}: by the service on every evaluation, and at every claude - * create and respawn, which decides the launch's `idleCompaction` opt-out. + * {@link readPinCliSessionNameEnabled} at every claude create and respawn, which decides the + * launch's `idleCompaction` opt-out. */ export async function readKeepWarmSettings(): Promise<KeepWarmSettings> { - const settings = await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'settings.json', {}); - const hours = settings.promptCacheKeepWarmMaxHours; + return keepWarmSettingsFrom(await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'settings.json', {})); +} + +/** + * The same two keys for the keep-warm service, which keeps what it reads until the next + * settings change. A missing file is the defaults, but any other read or parse failure rejects + * rather than falling back: settings.json is rewritten in place, so a read can land mid-write, + * and a fallback kept from it would hold keep-warm off until the next keep-warm settings change. + */ +export async function readKeepWarmSettingsOrThrow(): Promise<KeepWarmSettings> { + let text: string; + try { + text = await readFile(SETTINGS_PATH, 'utf-8'); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return keepWarmSettingsFrom({}); + throw err; + } + return keepWarmSettingsFrom(JSON.parse(text) as Record<string, unknown>); +} + +function keepWarmSettingsFrom(settings: Record<string, unknown>): KeepWarmSettings { + const hours = settings?.promptCacheKeepWarmMaxHours; return { - keepWarmDefault: settings.promptCacheKeepWarm === true, + keepWarmDefault: settings?.promptCacheKeepWarm === true, maxHours: typeof hours === 'number' && Number.isFinite(hours) && hours > 0 ? hours : DEFAULT_KEEP_WARM_MAX_HOURS, }; } diff --git a/src/prompt-cache-keepwarm.ts b/src/prompt-cache-keepwarm.ts index fb16aaaa..7fc67450 100644 --- a/src/prompt-cache-keepwarm.ts +++ b/src/prompt-cache-keepwarm.ts @@ -45,8 +45,13 @@ export const KEEP_WARM_LAST_CHANCE_MS = 60_000; export const KEEP_WARM_MIN_TOKENS = 20_000; /** A person typed this recently: they may be about to submit, which refreshes the cache for free. */ export const KEEP_WARM_RECENT_INPUT_MS = 3 * 60_000; -/** How long after sending the nudge its own stop hook and idle prompt stay pre-acknowledged. */ -export const KEEP_WARM_TURN_WINDOW_MS = 3 * 60_000; +/** + * Ceiling on the keep-alive window. The window normally closes on the turn's own idle prompt + * (Claude Code sends it about a minute after the turn ends, so a slow turn must not outlast a + * short fixed window) or earlier, at any other submit; the ceiling covers a turn whose idle + * prompt never arrives, such as a workspace without Codeman's hooks. + */ +export const KEEP_WARM_TURN_WINDOW_MS = 10 * 60_000; /** Default cap: hours after the last real prompt during which nudges are sent. */ export const DEFAULT_KEEP_WARM_MAX_HOURS = 2; /** Longest delay `setTimeout` honours; Node fires anything longer after 1 ms. */ @@ -197,7 +202,7 @@ export interface KeepWarmPorts { export interface KeepWarmScheduler { /** Re-evaluate a session: after a cache report, a flag change, or a settings change. Resolves once the status is published. */ schedule(sessionId: string): Promise<void>; - /** Re-evaluate every session (the app-wide default or cap changed). */ + /** Re-read the app-wide default and cap, then re-evaluate every session (either one changed). */ rescheduleAll(): void; /** The session is gone. */ remove(sessionId: string): void; @@ -208,6 +213,12 @@ export class PromptCacheKeepWarmService implements KeepWarmScheduler { private readonly tallies = new Map<string, { nudges: number; lastNudgeAt?: number }>(); /** One evaluation per session; `remove()` and `stop()` cancel it at its next await. */ private readonly inFlight = new Map<string, symbol>(); + /** + * The default and cap, read once and kept: every statusline report schedules an evaluation, + * and reading settings.json on each would cost every install, the feature off included. + * `rescheduleAll()` drops it, and the settings PUT calls that whenever either key changes. + */ + private settings: Promise<KeepWarmSettings> | undefined; private stopped = false; constructor(private readonly ports: KeepWarmPorts) {} @@ -220,6 +231,7 @@ export class PromptCacheKeepWarmService implements KeepWarmScheduler { } rescheduleAll(): void { + this.settings = undefined; for (const id of this.ports.sessionIds()) void this.schedule(id); } @@ -234,6 +246,18 @@ export class PromptCacheKeepWarmService implements KeepWarmScheduler { for (const id of [...this.timers.keys()]) this.clearTimer(id); } + private readSettings(): Promise<KeepWarmSettings> { + if (!this.settings) { + const read = this.ports.readSettings(); + this.settings = read; + // A failed read is not kept: the next evaluation tries again. + read.catch(() => { + if (this.settings === read) this.settings = undefined; + }); + } + return this.settings; + } + private clearTimer(id: string): void { const t = this.timers.get(id); if (t) clearTimeout(t); @@ -258,7 +282,7 @@ export class PromptCacheKeepWarmService implements KeepWarmScheduler { this.clearTimer(id); const session = this.ports.getSession(id); if (!session) return; - const settings = await this.ports.readSettings(); + const settings = await this.readSettings(); if (cancelled()) return; const tally = this.tallies.get(id) ?? { nudges: 0 }; const facts: KeepWarmFacts = { diff --git a/src/session.ts b/src/session.ts index a9e1fc63..e8ac08b1 100644 --- a/src/session.ts +++ b/src/session.ts @@ -456,6 +456,15 @@ export interface ClaudeMessage { duration_ms?: number; } +/** + * Whether a programmatic write leaves the keep-alive turn running: the nudge itself, or a bare + * Enter (which submits nothing new). Anything else carrying an Enter starts a turn of its own. + */ +function isKeepAliveWrite(data: string): boolean { + const text = data.replace(/[\r\n]/g, '').trim(); + return text === '' || text === KEEP_WARM_NUDGE; +} + /** * Event signatures emitted by the Session class. * Subscribe using `session.on('eventName', handler)`. @@ -1562,8 +1571,10 @@ export class Session extends EventEmitter { /** * True while the running turn is Codeman's own keep-alive nudge: its stop hook and idle * prompt open pre-acknowledged, and its submit moves `lastSubmitAt` (it IS a submit, and the - * response viewer's conversation correlation needs it) but not `lastRealPromptAt`. Any - * `fromUser` write ends it early, so a person's prompt right after a nudge counts and alerts normally. + * response viewer's conversation correlation needs it) but not `lastRealPromptAt`. The turn's + * own idle prompt ends it (`endKeepAliveTurn()`), and so does any other submit: a `fromUser` + * write, a programmatic write of anything but the nudge, or a `prompt_submitted` hook for + * another prompt, so a prompt right after a nudge counts and alerts normally. */ get keepAliveTurnActive(): boolean { return Date.now() < this._keepAliveTurnUntil; @@ -1573,6 +1584,10 @@ export class Session extends EventEmitter { this._keepAliveTurnUntil = Date.now() + windowMs; } + endKeepAliveTurn(): void { + this._keepAliveTurnUntil = 0; + } + /** Last keystroke a person sent (`fromUser` write), epoch ms; 0 when none this process. */ get lastHumanInputAt(): number { return this._lastHumanInputAt; @@ -4741,6 +4756,9 @@ export class Session extends EventEmitter { this._lastHumanInputAt = Date.now(); // A person is here: whatever turn follows is theirs, not the keep-alive's. this._keepAliveTurnUntil = 0; + } else if (hasEnter && !isKeepAliveWrite(data)) { + // Anything else submitted (an agent, respawn, auto-resume) starts a turn of its own. + this._keepAliveTurnUntil = 0; } if (hasEnter) this._stampSubmit(); return submitted; diff --git a/src/web/public/app.js b/src/web/public/app.js index 153f71bf..16844f13 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -3470,8 +3470,10 @@ class CodemanApp { this.sendPendingCtrlL(data.id); if (data.id === this.activeSessionId) this._updateLocalEchoState(); } - // Start stuck detection timer (only if no respawn running) - if (!this.respawnStatus[data.id]?.enabled) { + // Start stuck detection timer (only if no respawn running). Not at the end of Codeman's + // own keep-alive turn (`keepAlive`): the session has been waiting since the last real + // turn, whose timer is still running or has already fired. + if (!data.keepAlive && !this.respawnStatus[data.id]?.enabled) { const threshold = this.notificationManager?.preferences?.stuckThresholdMs || 600000; clearTimeout(this.idleTimers.get(data.id)); this.idleTimers.set(data.id, setTimeout(() => { @@ -3493,8 +3495,9 @@ class CodemanApp { this.sendPendingCtrlL(data.id); if (data.id === this.activeSessionId) this._updateLocalEchoState(); } - // Clear stuck detection timer - const timer = this.idleTimers.get(data.id); + // Clear stuck detection timer, unless this is Codeman's own keep-alive turn, which is + // no sign of life from the agent's side. + const timer = data.keepAlive ? null : this.idleTimers.get(data.id); if (timer) { clearTimeout(timer); this.idleTimers.delete(data.id); diff --git a/src/web/public/constants.js b/src/web/public/constants.js index 611f7ec5..4f9df935 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -778,15 +778,21 @@ function formatKeepWarmStatus(status, now, statusLine) { ? `stops ${clock(status.budgetEndsAt)}` : 'window closed' : ''; - // Keep-warm switched on after launch: the `idleCompaction` opt-out rides only a launch. + // Keep-warm switched on or off after launch: the `idleCompaction` opt-out rides only a launch. const idleCompaction = - status.enabled && status.idleCompactionOff === false ? 'idle compaction on until relaunch' : ''; + status.enabled && status.idleCompactionOff === false + ? 'idle compaction on until relaunch' + : !status.enabled && status.idleCompactionOff === true + ? 'idle compaction off until relaunch' + : ''; let parts; switch (status.state) { - case 'off': - if (!status.reason || status.reason === 'off') return ''; - parts = ['off', status.reason]; + case 'off': { + const reason = status.reason === 'off' ? '' : status.reason; + if (!reason && !idleCompaction) return ''; + parts = ['off', reason, idleCompaction]; break; + } case 'armed': parts = [`next ${clock(status.nextCheckAt)}`, window, idleCompaction]; break; diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index d876a6e4..926ea984 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -72,6 +72,10 @@ Object.assign(CodemanApp.prototype, { }, _onHookStop(data) { + // The end of Codeman's own keep-alive turn (prompt-cache keep-warm): nothing finished + // that a person asked for, and an idle alert still pending is theirs to spend, so this + // neither clears it nor raises "Response Complete". + if (data.acknowledgedReason) return; // Clear all pending hooks when Claude finishes responding if (data.sessionId) { this.clearPendingHooks(data.sessionId); @@ -2765,7 +2769,8 @@ Object.assign(CodemanApp.prototype, { imageWatcherEnabled: document.getElementById('appSettingsImageWatcherEnabled').checked, promptCacheKeepWarm: document.getElementById('appSettingsPromptCacheKeepWarm').checked, promptCacheKeepWarmMaxHours: - parseFloat(document.getElementById('appSettingsPromptCacheKeepWarmMaxHours').value) || 2, + // Clamped to the schema's range: one value outside it fails the whole save. + Math.min(24, Math.max(0.5, parseFloat(document.getElementById('appSettingsPromptCacheKeepWarmMaxHours').value) || 2)), tunnelEnabled: document.getElementById('appSettingsTunnelEnabled').checked, localEchoEnabled: document.getElementById('appSettingsLocalEcho').checked, autoCopySelection: document.getElementById('appSettingsAutoCopySelection').checked, diff --git a/src/web/routes/hook-event-routes.ts b/src/web/routes/hook-event-routes.ts index cf9ee4f2..4e17a76b 100644 --- a/src/web/routes/hook-event-routes.ts +++ b/src/web/routes/hook-event-routes.ts @@ -190,8 +190,15 @@ export function registerHookEventRoutes( const keepAliveReason = session?.keepAliveTurnActive && (event === 'stop' || event === 'idle_prompt') ? 'cache keep-alive' : undefined; const approvalKind = APPROVAL_KIND_BY_EVENT[event]; + // Only a human spends an idle alert, so a keep-alive turn leaves a pending idle item exactly + // as it found it: its stop does not resolve it, and its idle prompt does not replace it (a + // replacement would forget the item was unseen, or that a person had seen it). With no idle + // item pending, the keep-alive's idle prompt opens a pre-acknowledged one. + const keepsPendingIdle = keepAliveReason !== undefined && approvalInbox.getForSession(sessionId)?.kind === 'idle'; if (session && hooksAvailableForMode(session.mode, sessionHookOptions(session))) { - if (approvalKind) { + if (approvalKind === 'idle' && keepsPendingIdle) { + // Left in place: no new item, and the broadcast below carries the keep-alive reason. + } else if (approvalKind) { const toolInput = safeData.tool_input && typeof safeData.tool_input === 'object' ? (safeData.tool_input as Record<string, unknown>) @@ -226,12 +233,15 @@ export function registerHookEventRoutes( }); approvalId = item.id; acknowledgedReason = item.acknowledgedReason; - } else if (APPROVAL_RESOLVING_EVENTS.has(event)) { + } else if (APPROVAL_RESOLVING_EVENTS.has(event) && !keepsPendingIdle) { approvalInbox.resolveForSession(sessionId, 'resolved_in_terminal'); } } acknowledgedReason ??= keepAliveReason; + // The keep-alive turn's own idle prompt is the last thing it sends, so the window ends here + // rather than on a fixed clock that a slow turn could outlast. + if (keepAliveReason && event === 'idle_prompt') session?.endKeepAliveTurn(); ctx.broadcast(`hook:${event}`, { sessionId, diff --git a/src/web/routes/system-routes.ts b/src/web/routes/system-routes.ts index fd9c19d2..0359a8c9 100644 --- a/src/web/routes/system-routes.ts +++ b/src/web/routes/system-routes.ts @@ -1111,8 +1111,8 @@ export function registerSystemRoutes( // drift. Same convention as the tmuxHistoryLimit block above. // Handle subagent tracking toggle dynamically toggleService((merged.subagentTrackingEnabled as boolean) ?? true, subagentWatcher, 'Subagent watcher'); - // The prompt-cache keep-warm reads its default and cap fresh on every decision; a changed - // default or cap re-evaluates every session now rather than at its next cache report. + // The prompt-cache keep-warm keeps its default and cap in memory; a changed default or cap + // makes it re-read them and re-evaluate every session now rather than at its next cache report. if ('promptCacheKeepWarm' in settingsToStore || 'promptCacheKeepWarmMaxHours' in settingsToStore) { ctx.keepWarm.rescheduleAll(); } diff --git a/src/web/schemas.ts b/src/web/schemas.ts index becbeab9..5cd3503e 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -1871,13 +1871,13 @@ export const AutoCompactSchema = z.object({ }); /** POST /api/sessions/:id/auto-resume */ -/** `POST /api/sessions/:id/keep-warm`: `null` clears the session's choice so the app-wide default applies. */ -export const KeepWarmSchema = z.object({ enabled: z.boolean().nullable() }); - export const AutoResumeSchema = z.object({ enabled: z.boolean(), }); +/** `POST /api/sessions/:id/keep-warm`: `null` clears the session's choice so the app-wide default applies. */ +export const KeepWarmSchema = z.object({ enabled: z.boolean().nullable() }); + /** POST /api/sessions/:id/pin (COD-139) — explicit pin state for idempotency. */ export const PinSessionSchema = z.object({ pinned: z.boolean(), diff --git a/src/web/server.ts b/src/web/server.ts index 910305e7..ab55acba 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -99,7 +99,7 @@ import { getLifecycleLog } from '../session-lifecycle-log.js'; import { applyWorkspaceHooks, pruneAgentSessionPreambles, - readKeepWarmSettings, + readKeepWarmSettingsOrThrow, removeAgentSessionPreamble, } from '../hooks-config.js'; import { PushSubscriptionStore } from '../push-store.js'; @@ -332,7 +332,7 @@ export class WebServer extends EventEmitter { private readonly keepWarmService = new PromptCacheKeepWarmService({ getSession: (id) => this.sessions.get(id), sessionIds: () => this.sessions.keys(), - readSettings: readKeepWarmSettings, + readSettings: readKeepWarmSettingsOrThrow, isAutomationActive: (id) => { const session = this.sessions.get(id); if (!session) return true; diff --git a/src/web/session-listener-wiring.ts b/src/web/session-listener-wiring.ts index a1ce31f4..93d29898 100644 --- a/src/web/session-listener-wiring.ts +++ b/src/web/session-listener-wiring.ts @@ -240,7 +240,8 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe // kind: `working` is heuristic and can flap mid-turn, so clearing a // pending permission/question dialog on the signal ALONE would // false-clear real approvals. - approvalInbox.resolveForSession(session.id, 'resolved_in_terminal', ['idle']); + // Not for Codeman's own keep-alive turn: only a human spends an idle alert. + if (!session.keepAliveTurnActive) approvalInbox.resolveForSession(session.id, 'resolved_in_terminal', ['idle']); // A permission/question dialog gets the pane-VERIFIED variant instead: // the signal only decides when to look, `verifyStillAnswerable` re-reads // the screen and resolves only when the dialog is really gone. Without @@ -250,7 +251,11 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe // open. `stop` was the first thing to clear it, which on a long turn is // minutes away. approvalInbox.resolveIfDialogGone(session.id); - deps.broadcast(SseEvent.SessionWorking, { id: session.id }); + // `keepAlive` tells the page this turn is Codeman's own keep-alive (stuck detection ignores it). + deps.broadcast(SseEvent.SessionWorking, { + id: session.id, + ...(session.keepAliveTurnActive && { keepAlive: true }), + }); // Full state ride-along: the home screens sort the running group on // lastSubmitAt, and without this the browser keeps the stamp it loaded // with (a turn started after page load ranks by the PREVIOUS turn's @@ -266,7 +271,7 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe /** Broadcasts `session:idle` — Claude finished processing, waiting for input */ idle: () => { sessionWaits.notifySignal(session.id, 'idle'); - deps.broadcast(SseEvent.SessionIdle, { id: session.id }); + deps.broadcast(SseEvent.SessionIdle, { id: session.id, ...(session.keepAliveTurnActive && { keepAlive: true }) }); deps.broadcastSessionStateDebounced(session.id); deps.keepWarmSchedule?.(session.id); const tracker = deps.getRunSummaryTracker(session.id); diff --git a/test/keep-alive-page-alerts.test.ts b/test/keep-alive-page-alerts.test.ts new file mode 100644 index 00000000..3531bf86 --- /dev/null +++ b/test/keep-alive-page-alerts.test.ts @@ -0,0 +1,126 @@ +// Port: none (handlers sliced out of the shipped frontend files and run against stubs). +// +// The prompt-cache keep-alive turn (prompt-cache-keepwarm.ts) is Codeman's, not the user's, so +// on the page it must spend nothing: an idle alert still pending stays (only a human spends +// one), no "Response Complete" is raised, and the stuck-detection timer neither restarts nor +// re-arms ("Session Idle 10+ minutes" would otherwise follow every nudge). The server marks the +// turn's `hook:stop` with `acknowledgedReason` and its `session:working`/`session:idle` with +// `keepAlive`; these tests run the real handlers on those payloads. +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const PUBLIC = resolve(import.meta.dirname, '../src/web/public'); +const SESSION = 'keep-alive-session'; +const THRESHOLD = 600_000; + +/** The body of a method as shipped, compiled with `data` as its one parameter. */ +function method(file: string, signature: string): (this: unknown, data: Record<string, unknown>) => void { + const source = readFileSync(resolve(PUBLIC, file), 'utf8'); + const start = source.indexOf(` ${signature} {`); + expect(start).toBeGreaterThan(-1); + const bodyStart = source.indexOf('{', start) + 1; + const end = source.indexOf('\n }', bodyStart); + return new Function('data', source.slice(bodyStart, end)) as (data: Record<string, unknown>) => void; +} + +function makeApp() { + const notifications: string[] = []; + const app = { + sessions: new Map([[SESSION, { status: 'idle' }]]), + activeSessionId: 'another-session', + respawnStatus: {}, + notificationManager: { preferences: { stuckThresholdMs: THRESHOLD } }, + idleTimers: new Map<string, unknown>(), + pendingHooks: new Map<string, Set<string>>(), + tabAlerts: new Map<string, string>(), + notifications, + renderSessionTabs() {}, + sendPendingCtrlL() {}, + _updateLocalEchoState() {}, + _notifySession: (_id: string, _level: string, kind: string) => notifications.push(kind), + clearPendingHooks(sessionId: string) { + app.pendingHooks.delete(sessionId); + app.tabAlerts.delete(sessionId); + }, + onIdle: method('app.js', '_onSessionIdle(data)'), + onWorking: method('app.js', '_onSessionWorking(data)'), + onStop: method('settings-ui.js', '_onHookStop(data)'), + }; + return app; +} + +describe('the keep-alive turn spends nothing on the page', () => { + beforeEach(() => vi.useFakeTimers()); + afterEach(() => vi.useRealTimers()); + + it('keeps the stuck timer of the last real turn: not cleared, not re-armed', () => { + const app = makeApp(); + app.onIdle({ id: SESSION }); + const realTimer = app.idleTimers.get(SESSION); + expect(realTimer).toBeDefined(); + vi.advanceTimersByTime(THRESHOLD / 2); + + app.onWorking({ id: SESSION, keepAlive: true }); + expect(app.idleTimers.get(SESSION)).toBe(realTimer); + app.onIdle({ id: SESSION, keepAlive: true }); + expect(app.idleTimers.get(SESSION)).toBe(realTimer); + + // Fires once, on the real turn's schedule, and nothing follows the keep-alive. + vi.advanceTimersByTime(THRESHOLD / 2); + expect(app.notifications).toEqual(['session-stuck']); + vi.advanceTimersByTime(THRESHOLD * 2); + expect(app.notifications).toEqual(['session-stuck']); + }); + + it('a real turn still restarts the stuck timer', () => { + const app = makeApp(); + app.onIdle({ id: SESSION }); + app.onWorking({ id: SESSION }); + expect(app.idleTimers.has(SESSION)).toBe(false); + app.onIdle({ id: SESSION }); + expect(app.idleTimers.has(SESSION)).toBe(true); + }); + + it("its stop leaves a pending idle alert and raises no 'Response Complete'", () => { + const app = makeApp(); + app.pendingHooks.set(SESSION, new Set(['idle_prompt'])); + app.tabAlerts.set(SESSION, 'idle'); + app.onStop({ sessionId: SESSION, acknowledgedReason: 'cache keep-alive' }); + expect([...(app.pendingHooks.get(SESSION) ?? [])]).toEqual(['idle_prompt']); + expect(app.tabAlerts.get(SESSION)).toBe('idle'); + // _onSessionWorking deletes the tab alert only with no pending hook, so it stays too. + app.onWorking({ id: SESSION, keepAlive: true }); + expect(app.tabAlerts.get(SESSION)).toBe('idle'); + expect(app.notifications).toEqual([]); + }); + + it('a real stop still clears and notifies', () => { + const app = makeApp(); + app.pendingHooks.set(SESSION, new Set(['idle_prompt'])); + app.onStop({ sessionId: SESSION }); + expect(app.pendingHooks.has(SESSION)).toBe(false); + expect(app.notifications).toEqual(['hook-stop']); + }); +}); + +/** + * `promptCacheKeepWarmMaxHours` is clamped to the settings schema's range (0.5 to 24) before the + * save: the schema is strict, so one value outside it would reject every other change in the body. + */ +describe('the keep-warm cap is clamped before the save', () => { + it.each([ + ['0.25', 0.5], + ['30', 24], + ['', 2], + ['abc', 2], + ['3.5', 3.5], + ])('%j saves as %d', (input, expected) => { + const source = readFileSync(resolve(PUBLIC, 'settings-ui.js'), 'utf8'); + const match = source.match(/promptCacheKeepWarmMaxHours:\s*\n?((?:\s*\/\/.*\n)*)\s*([^\n]+),\n/); + expect(match).not.toBeNull(); + const document = { getElementById: () => ({ value: input }) }; + const value = new Function('document', `return ${match![2]};`)(document); + expect(value).toBe(expected); + }); +}); diff --git a/test/keep-warm-launch.test.ts b/test/keep-warm-launch.test.ts index 61ced246..50b4c6f1 100644 --- a/test/keep-warm-launch.test.ts +++ b/test/keep-warm-launch.test.ts @@ -15,7 +15,7 @@ import { tmpdir } from 'node:os'; import { dirname, join, resolve } from 'node:path'; import { describe, it, expect, afterEach } from 'vitest'; import { decideIdleCompactionOptOut } from '../src/tmux-manager.js'; -import { readKeepWarmSettings } from '../src/hooks-config.js'; +import { readKeepWarmSettings, readKeepWarmSettingsOrThrow } from '../src/hooks-config.js'; import { SETTINGS_PATH } from '../src/web/route-helpers.js'; import { DEFAULT_KEEP_WARM_MAX_HOURS } from '../src/prompt-cache-keepwarm.js'; @@ -155,4 +155,20 @@ describe('readKeepWarmSettings', () => { await writeFile(SETTINGS_PATH, JSON.stringify({ promptCacheKeepWarm: 'yes', promptCacheKeepWarmMaxHours: -1 })); expect(await readKeepWarmSettings()).toEqual({ keepWarmDefault: false, maxHours: 2 }); }); + + /** + * The service keeps what it reads until the next keep-warm settings change, so its reader must + * never hand back a fallback for a file it could not read: settings.json is rewritten in place + * by several routes, and defaults kept from a mid-write read would hold keep-warm off. + */ + it('the service reader rejects an unreadable file instead of falling back, and the server wires it', async () => { + expect(await readKeepWarmSettingsOrThrow()).toEqual({ keepWarmDefault: false, maxHours: 2 }); + await mkdir(dirname(SETTINGS_PATH), { recursive: true }); + await writeFile(SETTINGS_PATH, JSON.stringify({ promptCacheKeepWarm: true, promptCacheKeepWarmMaxHours: 3.5 })); + expect(await readKeepWarmSettingsOrThrow()).toEqual({ keepWarmDefault: true, maxHours: 3.5 }); + await writeFile(SETTINGS_PATH, '{"promptCacheKeepWarm": tr'); + await expect(readKeepWarmSettingsOrThrow()).rejects.toThrow(); + expect(await readKeepWarmSettings()).toEqual({ keepWarmDefault: false, maxHours: 2 }); + expect(src('web/server.ts')).toContain('readSettings: readKeepWarmSettingsOrThrow,'); + }); }); diff --git a/test/mocks/mock-session.ts b/test/mocks/mock-session.ts index f58b7447..665cffbe 100644 --- a/test/mocks/mock-session.ts +++ b/test/mocks/mock-session.ts @@ -56,6 +56,9 @@ export class MockSession extends EventEmitter { } /** Mirrors the prompt-cache keep-warm surface on Session (prompt-cache-keepwarm.ts). */ keepAliveTurnActive = false; + endKeepAliveTurn(): void { + this.keepAliveTurnActive = false; + } keepWarmEnabled: boolean | undefined = undefined; setKeepWarmEnabled(enabled: boolean | undefined): void { this.keepWarmEnabled = enabled; diff --git a/test/prompt-cache-keepwarm.test.ts b/test/prompt-cache-keepwarm.test.ts index 1364079a..a4265114 100644 --- a/test/prompt-cache-keepwarm.test.ts +++ b/test/prompt-cache-keepwarm.test.ts @@ -228,6 +228,28 @@ describe('the two submit clocks on a Session', () => { } }); + /** + * The window is a ceiling, not the turn's length: a slow keep-alive turn's idle prompt lands + * about a minute after it ends, so the window closes on that prompt (the route calls + * endKeepAliveTurn) or on any other submit, never by a short clock alone. + */ + it('the keep-alive window survives the nudge and a bare Enter, and ends on any other submit', () => { + const session = new Session({ workingDir: '/tmp' }); + session.beginKeepAliveTurn(KEEP_WARM_TURN_WINDOW_MS); + session.write(`${KEEP_WARM_NUDGE}\r`); + session.write('\r'); + expect(session.keepAliveTurnActive).toBe(true); + session.write('/compact\r'); + expect(session.keepAliveTurnActive).toBe(false); + + session.beginKeepAliveTurn(KEEP_WARM_TURN_WINDOW_MS); + session.write('typed but not sent'); + expect(session.keepAliveTurnActive).toBe(true); + session.endKeepAliveTurn(); + expect(session.keepAliveTurnActive).toBe(false); + expect(KEEP_WARM_TURN_WINDOW_MS).toBeGreaterThanOrEqual(10 * 60_000); + }); + it('persists the real-prompt clock and the choice, and restores both', () => { const session = new Session({ workingDir: '/tmp', lastRealPromptAt: NOW, keepWarmEnabled: true }); expect(session.toState()).toMatchObject({ lastRealPromptAt: NOW, keepWarmEnabled: true }); @@ -529,16 +551,48 @@ describe('PromptCacheKeepWarmService', () => { // Second evaluation: not due, held in its settings read. session.promptCache = warmCache(NOW + HOUR); releaseRead = () => {}; - const second = service.schedule(session.id); + // rescheduleAll() drops the kept settings, so this evaluation really waits in its read. + service.rescheduleAll(); await settle(); releaseWrite(true); await first; releaseRead(); - await second; + await settle(); expect(published).toEqual([expect.objectContaining({ state: 'armed' })]); service.stop(); }); + /** + * Every statusline report schedules an evaluation, so the settings are read once and kept + * rather than read from disk on each report, the feature off included; the settings PUT calls + * rescheduleAll() when either key changes, which is what makes the next read fresh. + */ + it('reads the settings once, again after rescheduleAll(), and again after a failed read', async () => { + const session = new FakeSession(); + session.promptCache = warmCache(NOW + HOUR); + let reads = 0; + let fail = false; + const { ports } = makePorts(session, { + readSettings: async () => { + reads += 1; + if (fail) throw new Error('unreadable'); + return { keepWarmDefault: false, maxHours: 2 }; + }, + }); + const service = new PromptCacheKeepWarmService(ports); + await service.schedule(session.id); + await service.schedule(session.id); + expect(reads).toBe(1); + fail = true; + service.rescheduleAll(); + await settle(); + expect(reads).toBe(2); + fail = false; + await service.schedule(session.id); + expect(reads).toBe(3); + service.stop(); + }); + it('remove() forgets the nudge count, so an id seen again starts from zero', async () => { const session = new FakeSession(); session.keepWarmEnabled = true; diff --git a/test/prompt-cache-status.test.ts b/test/prompt-cache-status.test.ts index b5f0d515..fb7a0105 100644 --- a/test/prompt-cache-status.test.ts +++ b/test/prompt-cache-status.test.ts @@ -172,6 +172,15 @@ describe('Keep-warm line on the Status row', () => { `Keep-alive: next ${clock(T)} · idle compaction on until relaunch` ); }); + + it('says idle compaction stays off when keep-warm was switched off on a launch that carries the opt-out', () => { + const { formatKeepWarm } = loadApi(); + const off = { enabled: false, state: 'off', reason: 'off', nudges: 0 }; + expect(formatKeepWarm({ ...off, idleCompactionOff: false }, NOW, 'injected')).toBe(''); + expect(formatKeepWarm({ ...off, idleCompactionOff: true }, NOW, 'injected')).toBe( + 'Keep-alive: off · idle compaction off until relaunch' + ); + }); }); /** diff --git a/test/routes/hook-event-routes.test.ts b/test/routes/hook-event-routes.test.ts index 79d18c0d..a6066475 100644 --- a/test/routes/hook-event-routes.test.ts +++ b/test/routes/hook-event-routes.test.ts @@ -127,6 +127,75 @@ describe('hook-event-routes', () => { expect(item?.acknowledgedAt).toBe(item?.createdAt); }); + /** + * Only a human spends an idle alert (CLAUDE.md, Approvals Inbox). A keep-alive turn that + * starts while an idle item is pending must leave that item exactly as it found it: its stop + * does not resolve it, and its idle prompt does not replace it, so an unseen alert stays + * unseen (and pushes nothing a second time) and one a person looked at keeps saying so. + */ + it.each([ + { seen: false, label: 'an unseen' }, + { seen: true, label: 'a seen' }, + ])('leaves $label idle item pending through a keep-alive turn', async ({ seen }) => { + const sessionId = harness.ctx._sessionId; + const hook = (event: string) => + harness.app.inject({ method: 'POST', url: '/api/hook-event', payload: { event, sessionId, data: null } }); + await hook('idle_prompt'); + const before = approvalInbox.getForSession(sessionId); + expect(before?.acknowledgedAt).toBeUndefined(); + if (seen) approvalInbox.acknowledge(sessionId); + const acknowledgedAt = before?.acknowledgedAt; + harness.ctx.sendPushNotifications.mockClear(); + harness.ctx.broadcast.mockClear(); + + harness.ctx._session.keepAliveTurnActive = true; + await hook('stop'); + await hook('idle_prompt'); + + const after = approvalInbox.getForSession(sessionId); + approvalInbox.resolveForSession(sessionId, 'dismissed'); + expect(after?.id).toBe(before?.id); + expect(after?.acknowledgedAt).toBe(acknowledgedAt); + expect(after?.acknowledgedReason).toBeUndefined(); + expect(harness.ctx.sendPushNotifications).not.toHaveBeenCalled(); + // The page's _onHookStop and _onHookIdlePrompt return on the reason, leaving the tab alert. + for (const event of ['stop', 'idle_prompt']) { + expect(harness.ctx.broadcast).toHaveBeenCalledWith( + `hook:${event}`, + expect.objectContaining({ acknowledgedReason: 'cache keep-alive' }) + ); + } + }); + + it("a keep-alive turn's idle prompt ends the keep-alive window, its stop does not", async () => { + const sessionId = harness.ctx._sessionId; + const hook = (event: string) => + harness.app.inject({ method: 'POST', url: '/api/hook-event', payload: { event, sessionId, data: null } }); + harness.ctx._session.keepAliveTurnActive = true; + await hook('stop'); + expect(harness.ctx._session.keepAliveTurnActive).toBe(true); + await hook('idle_prompt'); + approvalInbox.resolveForSession(sessionId, 'dismissed'); + expect(harness.ctx._session.keepAliveTurnActive).toBe(false); + }); + + it("a keep-alive turn's stop still resolves a pending permission dialog", async () => { + const sessionId = harness.ctx._sessionId; + await harness.app.inject({ + method: 'POST', + url: '/api/hook-event', + payload: { event: 'permission_prompt', sessionId, data: null }, + }); + expect(approvalInbox.getForSession(sessionId)?.kind).toBe('permission'); + harness.ctx._session.keepAliveTurnActive = true; + await harness.app.inject({ + method: 'POST', + url: '/api/hook-event', + payload: { event: 'stop', sessionId, data: null }, + }); + expect(approvalInbox.getForSession(sessionId)).toBeUndefined(); + }); + it('sends push notifications for hook events', async () => { const res = await harness.app.inject({ method: 'POST', diff --git a/test/session-listener-wiring.test.ts b/test/session-listener-wiring.test.ts index cc54ff00..e6b1a70f 100644 --- a/test/session-listener-wiring.test.ts +++ b/test/session-listener-wiring.test.ts @@ -6,6 +6,7 @@ import { detachSessionListeners, } from '../src/web/session-listener-wiring.js'; import { SseEvent } from '../src/web/sse-events.js'; +import { approvalInbox } from '../src/web/approval-inbox.js'; describe('session listener wiring', () => { it('forwards the attachment request source through registerAttachment', async () => { @@ -62,6 +63,37 @@ describe('session listener wiring', () => { expect(session.listenerCount('displayModelChanged')).toBe(0); }); + /** + * A working transition resolves a pending idle item (input arrived), except during Codeman's + * own keep-alive turn: only a human spends an idle alert. Both transitions of that turn are + * flagged `keepAlive`, which is what keeps the page's stuck-detection timer out of it. + */ + it.each([ + { keepAlive: false, resolved: true }, + { keepAlive: true, resolved: false }, + ])('working and idle with keepAlive=$keepAlive', ({ keepAlive, resolved }) => { + const session = new Session({ id: `wiring-keepalive-${keepAlive}`, workingDir: '/tmp', mode: 'claude' }); + const broadcast = vi.fn(); + const deps = { + broadcast, + broadcastSessionStateDebounced: vi.fn(), + getRunSummaryTracker: vi.fn(() => undefined), + } as unknown as Parameters<typeof createSessionListeners>[1]; + const refs = createSessionListeners(session, deps); + approvalInbox.notePrompt({ sessionId: session.id, sessionName: 'w1', kind: 'idle' }); + if (keepAlive) session.beginKeepAliveTurn(60_000); + + refs.working(); + refs.idle(); + + const pending = approvalInbox.getForSession(session.id); + approvalInbox.resolveForSession(session.id, 'dismissed'); + expect(pending === undefined).toBe(resolved); + const flag = keepAlive ? { keepAlive: true } : {}; + expect(broadcast).toHaveBeenCalledWith(SseEvent.SessionWorking, { id: session.id, ...flag }); + expect(broadcast).toHaveBeenCalledWith(SseEvent.SessionIdle, { id: session.id, ...flag }); + }); + /** The listener reads the setting asynchronously; let its promise chain settle. */ const flush = () => new Promise((resolve) => setTimeout(resolve, 5)); From 83d80578e4c4ede0f20ea42773f4bede4eb32f37 Mon Sep 17 00:00:00 2001 From: JD <jd@jds.haus> Date: Mon, 19 Oct 2026 19:58:14 -0400 Subject: [PATCH 14/96] fix(sessions): read the composer from a styled capture so a prompt suggestion is not a draft Claude Code draws its prompt suggestion in an empty composer, the first letter in the reverse-video cursor cell and the rest faint, so a plain capture read it as typed text and keep-warm never nudged those sessions. The keep-warm pane read now uses capture-pane -e and drops the suggestion before checking the composer. Also from review: an off status carries no window, automation and an unreadable composer retry instead of stopping, a failed evaluation re-arms a retry, a trigger during an evaluation queues one follow-up that remove() cancels, and the cap's clock is seeded from lastSubmitAt for pre-upgrade state. --- CLAUDE.md | 2 +- docs/api-reference.md | 2 +- docs/architecture-invariants.md | 2 +- docs/wiki/Keeping-Agents-Running.md | 2 +- src/mux-interface.ts | 7 +- src/prompt-cache-keepwarm.ts | 48 +++++++-- src/session-submit-verifier.ts | 65 ++++++++++- src/session.ts | 5 +- src/tmux-manager.ts | 14 +-- src/web/public/constants.js | 8 +- src/web/server.ts | 9 +- test/keep-warm-launch.test.ts | 3 +- test/prompt-cache-keepwarm.test.ts | 162 +++++++++++++++++++++++++++- 13 files changed, 292 insertions(+), 37 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 829554ae..9b8f64f2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -158,7 +158,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Plan-usage chip** (`showPlanUsageLimits`, per-device: desktop default **ON**, handhelds OFF): resolve DISPLAY only through `planUsageChipEnabled()` (settings-ui.js). The same setting is the server-side COLLECTION switch, read fresh by `readPlanUsageTelemetryEnabled()` (hooks-config.ts) at every claude create/respawn. ⚠️ An ABSENT key reads as ON in the reader; `GET /api/settings` must never write. ⚠️ A save sends `showPlanUsageLimits` ONLY when it flips the chip on that device (`planUsageCollectionFlip()`), or a phone switches collection off for every desktop. Claude data comes from the statusLine exporter, injected as an EPHEMERAL `claude --settings` flag (`resolveStatusLineCliCommand`), never written to disk, WRAPPING a user's own statusLine, posting to `POST /api/status-telemetry`. Codex comes from a read-only `account/rateLimits/read` poll (main bucket only). The statusline's `prompt_cache` object (Claude Code v2.1.251+) is kept by `StatusTelemetrySchema` (every documented scalar field, null-tolerant; `parsePromptCache()` in usage-telemetry.ts normalizes only the stored subset) and shown in the footer as `cache:until HH:MM` / `cache:cold` (only when the user has no statusline of their own) and in Session Options → Respawn (`#promptCacheStatus`, fed by `Session.promptCache` in `toState()` plus the change-only `session:promptCache` SSE event, worded by the pure `CodemanPromptCache.format`): warm-until/cold, the re-cache stake, the hit rate, the last miss's causes, and a `Context compacted at HH:MM` note after a compaction or a tool-result clearing (stamped on the first warm report whose `recache_tokens_if_cold` is null after one that carried it, never from `expected_rebuilds`, which Claude Code bumps only at the next request; NEVER an alert). ⚠️ `Session.setPromptCache()` stamps `compactedAt` onto a NEW object (never mutating the parsed report) and the route broadcasts the STORED object; the last report is restored on restart (constructor `promptCache`, like `displayModel`) and the readout is time-aware (a passed `expiresAt` reads cold, not stale-warm). It is the source of truth for the cache TTL, never the plan tier. → [architecture-invariants#plan-usage-chip-statusline-telemetry](docs/architecture-invariants.md#plan-usage-chip-statusline-telemetry), `docs/usage-limits-display-plan.md` -**Prompt-cache keep-warm** (`src/prompt-cache-keepwarm.ts`; per-session opt-in on the Respawn tab, app-wide default `promptCacheKeepWarm` + cap `promptCacheKeepWarmMaxHours` in App Settings → System → Automation, SYNCED, default OFF / 2 h; step 2 of Discussion #554): one keep-alive turn (`KEEP_WARM_NUDGE`, under 100 chars) five minutes before an idle claude session's cache would expire, so the next prompt reads the conversation from cache instead of re-writing it at 2x. Codeman owns the idle policy for these sessions: a launch with keep-warm on also carries `"idleCompaction": false`, folded LAST into the ONE `--settings` JSON (`session-cli-registry-bridge.ts`), decided once per create/respawn by `decideIdleCompactionOptOut()` (tmux-manager.ts: keep-warm on via `keepWarmOn()`, exporter `injected`, claude's `idleCompactionSetting` gate at 2.1.290, fail-closed like `--name`) and recorded as `MuxSession.idleCompactionOff`; a keep-warm-off launch stays byte-identical (`test/cli-registry-spawn-golden.test.ts`). Switched on after launch it is best-effort and the readout says idle compaction may still run until the next launch. ⚠️ The deadline is `Session.promptCache.expiresAt` from the statusline, never idle detection or the plan tier; a `5m` TTL is a hard stop, and so is a launch whose `statusLineExporter` is not `injected`. ⚠️ Every guard lives in the pure `decideKeepWarm()` (effective opt-in, where an agent-spawned session (`parentSessionId`) does not inherit the app default; exporter injected; warm; no `compactedAt` this gap; tokens at stake; live pane (pid AND no `paneExit`); no limit pause (auto-resume's, or a limit message on screen); no respawn/Ralph/task/waiter; inside the cap since `lastRealPromptAt`; idle; no dialog; EMPTY composer via `composerIsEmpty()`, continuation lines included; no recent keystroke); add a guard there, never in the service. ⚠️ The nudge IS a submit and moves `lastSubmitAt` (the response viewer's conversation correlation needs it); the window `beginKeepAliveTurn()` opens keeps it off `lastRealPromptAt` (the cap's own persisted clock) and opens its `stop`/`idle_prompt` hooks pre-acknowledged (`preAcknowledged` on `notePrompt`), so nothing alerts, and leaves an idle item already pending exactly as found (only a human spends an idle alert; `session:working`/`session:idle` carry `keepAlive: true` so the page's stuck timer ignores the turn); the keep-alive's own `idle_prompt` ends that window (`endKeepAliveTurn()`; `KEEP_WARM_TURN_WINDOW_MS` is only a ceiling, since a slow turn's idle prompt lands a minute after it ends), and so does any other submit: a `fromUser` write, a programmatic write of anything but the nudge, or a `prompt_submitted` hook whose prompt is not the nudge. Read My Mind drops its exact text (`isCapturablePrompt`). → [architecture-invariants#prompt-cache-keep-warm](docs/architecture-invariants.md#prompt-cache-keep-warm) +**Prompt-cache keep-warm** (`src/prompt-cache-keepwarm.ts`; per-session opt-in on the Respawn tab, app-wide default `promptCacheKeepWarm` + cap `promptCacheKeepWarmMaxHours` in App Settings → System → Automation, SYNCED, default OFF / 2 h; step 2 of Discussion #554): one keep-alive turn (`KEEP_WARM_NUDGE`, under 100 chars) five minutes before an idle claude session's cache would expire, so the next prompt reads the conversation from cache instead of re-writing it at 2x. Codeman owns the idle policy for these sessions: a launch with keep-warm on also carries `"idleCompaction": false`, folded LAST into the ONE `--settings` JSON (`session-cli-registry-bridge.ts`), decided once per create/respawn by `decideIdleCompactionOptOut()` (tmux-manager.ts: keep-warm on via `keepWarmOn()`, exporter `injected`, claude's `idleCompactionSetting` gate at 2.1.290, fail-closed like `--name`) and recorded as `MuxSession.idleCompactionOff`; a keep-warm-off launch stays byte-identical (`test/cli-registry-spawn-golden.test.ts`). Switched on after launch it is best-effort and the readout says idle compaction may still run until the next launch. ⚠️ The deadline is `Session.promptCache.expiresAt` from the statusline, never idle detection or the plan tier; a `5m` TTL is a hard stop, and so is a launch whose `statusLineExporter` is not `injected`. ⚠️ Every guard lives in the pure `decideKeepWarm()` (effective opt-in, where an agent-spawned session (`parentSessionId`) does not inherit the app default; exporter injected; warm; no `compactedAt` this gap; tokens at stake; live pane (pid AND no `paneExit`); no limit pause (auto-resume's, or a limit message on screen); no respawn/Ralph/task/waiter; inside the cap since `lastRealPromptAt`; idle; no dialog; EMPTY composer via `composerIsEmpty()`, continuation lines included, read from a STYLED capture so Claude Code's faint prompt suggestion is not taken for a draft; no recent keystroke); add a guard there, never in the service. ⚠️ The nudge IS a submit and moves `lastSubmitAt` (the response viewer's conversation correlation needs it); the window `beginKeepAliveTurn()` opens keeps it off `lastRealPromptAt` (the cap's own persisted clock) and opens its `stop`/`idle_prompt` hooks pre-acknowledged (`preAcknowledged` on `notePrompt`), so nothing alerts, and leaves an idle item already pending exactly as found (only a human spends an idle alert; `session:working`/`session:idle` carry `keepAlive: true` so the page's stuck timer ignores the turn); the keep-alive's own `idle_prompt` ends that window (`endKeepAliveTurn()`; `KEEP_WARM_TURN_WINDOW_MS` is only a ceiling, since a slow turn's idle prompt lands a minute after it ends), and so does any other submit: a `fromUser` write, a programmatic write of anything but the nudge, or a `prompt_submitted` hook whose prompt is not the nudge. Read My Mind drops its exact text (`isCapturablePrompt`). → [architecture-invariants#prompt-cache-keep-warm](docs/architecture-invariants.md#prompt-cache-keep-warm) **Orchestrator**: State machine that turns a user goal into a phased plan and drives it to completion: `idle → planning → approval → executing → verifying → (replanning) → completed/failed`. `OrchestratorLoop` (engine) delegates plan generation to `orchestrator-planner` and per-phase verification gates to `orchestrator-verifier`, executing phases via team agents/`task-queue`. State persists under the `orchestrator` key in `state.json`. Distinct from Ralph (single-session autonomous loop) — orchestrator coordinates multi-phase, multi-agent execution. See `docs/orchestrator-loop-architecture.md`. diff --git a/docs/api-reference.md b/docs/api-reference.md index 00fc4ef6..81fecd30 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1160,7 +1160,7 @@ Claude Code reports the main conversation's prompt-cache state on its statusline | ------ | ---------------------------------- | ------------------------------------ | ----- | | `POST` | `/api/v1/sessions/:id/keep-warm` | `{ "enabled": true \| false \| null }` | Sets the session's choice; `null` clears it so the app-wide default applies. `200` with `{ keepWarm: { enabled, status } }`. `400 INVALID_INPUT` for a CLI that reports no cache telemetry on its statusline (anything but Claude today). | -`status` is `{ enabled, idleCompactionOff, state, nextCheckAt?, reason?, nudges, lastNudgeAt?, budgetEndsAt? }` (`idleCompactionOff`: whether the session's current launch carries that opt-out) with `state` one of `off`, `armed` (waiting for the due time), `waiting` (due, deferred by a guard named in `reason`), `nudged` (sent, waiting for the next cache report) or `stopped` (`reason` says why). Two SSE events carry changes: `session:promptCache` (`{ sessionId, ...promptCache }`) and `session:keepWarm` (`{ sessionId, ...status }`), both change-only. The keep-alive turn's `hook:stop` and `hook:idle_prompt` broadcasts carry `acknowledgedReason: "cache keep-alive"`, and no push is sent for them; an idle approval already pending stays as it was (only a person spends its alert). Its `session:working` and `session:idle` broadcasts carry `keepAlive: true`. +`status` is `{ enabled, idleCompactionOff, state, nextCheckAt?, reason?, nudges, lastNudgeAt?, budgetEndsAt? }` (`idleCompactionOff`: whether the session's current launch carries that opt-out; `budgetEndsAt` only while `enabled`) with `state` one of `off`, `armed` (waiting for the due time), `waiting` (due, deferred by a guard named in `reason`), `nudged` (sent, waiting for the next cache report) or `stopped` (`reason` says why). Two SSE events carry changes: `session:promptCache` (`{ sessionId, ...promptCache }`) and `session:keepWarm` (`{ sessionId, ...status }`), both change-only. The keep-alive turn's `hook:stop` and `hook:idle_prompt` broadcasts carry `acknowledgedReason: "cache keep-alive"`, and no push is sent for them; an idle approval already pending stays as it was (only a person spends its alert). Its `session:working` and `session:idle` broadcasts carry `keepAlive: true`. ## Webhook notifications diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 6ed205ae..835d5279 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -161,7 +161,7 @@ Further detail: the handheld OFF default comes from the mobile block in `getDefa ### Prompt-cache keep-warm -**Prompt-cache keep-warm** (`src/prompt-cache-keepwarm.ts`; per-session opt-in `keepWarmEnabled` on the Respawn tab, app-wide default `promptCacheKeepWarm` and cap `promptCacheKeepWarmMaxHours` under App Settings → System → Automation, both SYNCED, default OFF and 2 h; step 2 of Discussion #554): shortly before an idle claude session's prompt cache would expire, Codeman sends ONE keep-alive turn (`KEEP_WARM_NUDGE`, under 100 characters so Claude Code never reads the burst as a paste) so the next real prompt still reads the conversation from cache. Measured on two sessions of ~110k tokens: the nudge read 109,831 tokens from cache and wrote 70, replied `ok` in three seconds with no tool use, and a real turn seven minutes past the original expiry stayed a hit while the un-nudged control re-wrote 89,882 tokens with `last_miss_cause: ttl_expired_1h`; the payoff on real usage is concentrated in gaps of one to four hours, which is why there is a cap after the last real prompt rather than an indefinite loop (2 h by default: the maintainer's simulation over 30 days of gaps came out slightly cheaper than 3 h). ⚠️ **Codeman owns the idle policy for a keep-warm session.** Claude Code 2.1.289+ can compact an idle 1h-cache session by itself at 90% of the TTL (a server-flagged experiment, `fireAtFraction` tunable down to 0.5), which on the default timing always lands a minute before the nudge and leaves only a summary to keep warm. So a launch with keep-warm on carries `"idleCompaction": false`, folded LAST into the ONE `--settings` JSON with ultracode, the advisor and the statusLine exporter (`buildSpawnCommandFromRegistry`; never a second `--settings` flag, Claude Code takes one), so a keep-warm-off launch is byte-identical to one from before keep-warm (`test/cli-registry-spawn-golden.test.ts`). The `--settings` flag outranks user, project and local settings, which is why the key is conditional: everywhere else it would take Claude Code's own behaviour away with no way back. `decideIdleCompactionOptOut()` (tmux-manager.ts, pure) decides it once per create and respawn: keep-warm on (`keepWarmOn()`: the session's choice, else the app default, read fresh by `readKeepWarmSettings()`), the exporter `injected` (without `prompt_cache` reports keep-warm cannot run, and the opt-out alone would only cost the user the idle compaction), and claude's `idleCompactionSetting` gate (`capabilities.gates`, 2.1.290, the first release whose settings schema has the key; 2.1.289 already idle-compacts with no opt-out), fail-closed through `launchGatePasses()` exactly like `--name`: an unknown version means no key, and a log line says so. The result is recorded on the mux record (`MuxSession.idleCompactionOff`, like `statusLine`, persisted in mux-sessions.json) and published in the keep-warm status as `idleCompactionOff`; a session switched on after launch is kept warm best-effort (a relaunch to install the opt-out would cost exactly the re-write being avoided) and its readout says Claude Code's idle compaction may still run until its next launch, which any relaunch (dead-pane recovery, a custom-model `restartCli()`) fixes for free since every relaunch re-decides. ⚠️ The deadline is the statusline's `prompt_cache.expires_at` (`Session.promptCache`), never idle detection or the plan tier, and a `5m` lifetime is a hard stop (a nudge every four minutes costs more than the miss it prevents; API-key users get the hour from `CLAUDE_CODE_PROMPT_CACHE_TTL=1h` in `envOverrides`). ⚠️ `decideKeepWarm()` is the ONE rulebook and every guard lives in it, tested as a table: the effective opt-in (`keepWarmOn()`; a session an agent spawned, `parentSessionId`, does not inherit the app default, since an idle worker that reported done looks exactly like one that will be prompted again and most never are; its own switch still works), the launch's `statusLineExporter` is `injected` (which also stops every CLI without cache telemetry; the toggle line says why in the readout's own `PROMPT_CACHE_NOT_COLLECTED` words), a warm cache with an expiry, no `compactedAt` this gap (a compaction already replaced the context, and the stamp is dropped at the next request, which re-opens the next gap), not a `5m` lifetime, at least `KEEP_WARM_MIN_TOKENS` at stake, a live pane (a pid AND no recorded `paneExit`: an agent that exited in a `remain-on-exit` pane keeps its pid), no usage-limit pause (auto-resume's, or, read off the same pane capture as the composer, a usage-limit message whose reset is still ahead, since auto-resume tracks pauses only while it is on), no automation (respawn running, a Ralph loop active, a task assigned, or an agent long-polling `wait`), inside the cap measured from `lastRealPromptAt` (else `createdAt`), due at `expires_at` minus `KEEP_WARM_LEAD_MS` but not within `KEEP_WARM_LAST_CHANCE_MS`, idle, no open permission or question dialog, an EMPTY composer (`composerIsEmpty()` in session-submit-verifier.ts reads the last glyph line AND its continuation lines down to the composer's bottom rule, since a draft that opens with a newline leaves the glyph line blank: `send-keys -l` would append the nudge to a draft, and an unreadable pane or a missing rule is a stop, never a guess), and no human keystroke within `KEEP_WARM_RECENT_INPUT_MS`. Transient guards defer by `KEEP_WARM_RETRY_MS` and never past the last-chance line; the composer is read only once everything else says "due". ⚠️ **Two submit clocks.** The nudge goes out through a plain `writeViaMux()` after `beginKeepAliveTurn()`. It IS a submit, so it moves `lastSubmitAt` from the write and again from its `prompt_submitted` hook (`markPromptSubmitted()`): the response viewer's conversation correlation (`resolveActiveClaudeSessionIdFromHistory()`) matches that stamp against Claude's history, which records the nudge like any prompt. The cap needs the last REAL prompt instead, so `Session._stampSubmit()` also moves `lastRealPromptAt` for every submit outside the keep-alive window (a person in the browser, an agent over the API, a typist in an attached tmux client seen through the hook), persisted in `SessionState.lastRealPromptAt` and restored by both rebuild paths so a restart never reopens the window. The window closes on the keep-alive's own `idle_prompt` (the hook route calls `endKeepAliveTurn()`; Claude Code sends it about a minute after the turn ends, so a fixed short window would let a slow turn's idle prompt alert and push, and `KEEP_WARM_TURN_WINDOW_MS` is only a ceiling for a workspace whose hooks never report). Any other submit closes it at once: a `fromUser` write, a programmatic write carrying anything but the nudge or a bare Enter (an agent, respawn, auto-resume; `isKeepAliveWrite()` in session.ts), or a `prompt_submitted` hook whose `prompt` is not the exact nudge (the route hands the hook's prompt text to `markPromptSubmitted()`), so a person's prompt right after a nudge, typed in the browser or in an attached tmux client, counts and alerts normally. The home screens and the tile grid read `lastSubmitAt` only to anchor a WORKING row, so a nudge shows there only for the seconds its turn runs; idle rows key on `lastActivityAt`, which the nudge's reply moves like any output. ⚠️ The same window makes the turn's `stop` hook and `idle_prompt` open pre-acknowledged (`preAcknowledged: 'cache keep-alive'` through `notePrompt()`, the same path `watching` uses), so no tab alert, toast, push or webhook fires. ⚠️ Only a human spends an idle alert, so the keep-alive turn leaves an idle item already pending exactly as it found it: the `working` transition's idle resolve (session-listener-wiring.ts) and the `stop` resolve skip it, and the keep-alive's `idle_prompt` does not replace it (a replacement would forget that it was unseen, or that a person had seen it), while the page's `_onHookStop` returns on `acknowledgedReason` before clearing anything; `session:working` and `session:idle` carry `keepAlive: true` for the turn, so the page's stuck-detection timer is neither cleared nor re-armed by it. Read My Mind drops the exact nudge text in `isCapturablePrompt()`, the filter `captureIntentPrompt()` → `intentStore.recordPrompt()` runs every transcript prompt through. ⚠️ Re-evaluation is edge-triggered and cheap: every cache report (`status-telemetry-routes.ts`), the toggle route, a settings change (`rescheduleAll()`, which also drops the service's in-memory copy of the default and cap, so a cache report never reads settings.json), `setupSessionListeners()` and the session's `idle` event (`keepWarmSchedule` in session-listener-wiring.ts, which is also what arms a restored report once the recovered pane is attached); the service keeps one timer per session (clamped to the 2^31-1 ms `setTimeout` honours, so a far-off expiry cannot turn into a 1 ms loop), `remove()`s it with the session and stops with the server, and a `remove()` or `stop()` landing during an evaluation's await cancels it there (nothing sent, armed or published after), and the server logs one `[KeepWarm]` line per status change naming the guard that holds it. Status rides `Session.keepWarm` in `toState()` and the change-only `session:keepWarm` event for the readout; `keepWarmEnabled`, `lastRealPromptAt` and the last `promptCache` report are restored by the constructor on both rebuild paths (that is what re-arms the timer after a restart), the status never is. Tests: `test/prompt-cache-keepwarm.test.ts`, `test/keep-warm-launch.test.ts`, `test/keep-warm-i18n.test.ts`, `test/routes/session-routes-keep-warm.test.ts`, the keep-warm readout in `test/prompt-cache-status.test.ts`, the opt-out literals in `test/cli-registry-spawn-golden.test.ts`, the keep-alive cases in `test/routes/hook-event-routes.test.ts` and `test/session-listener-wiring.test.ts`, and the page handlers in `test/keep-alive-page-alerts.test.ts`. +**Prompt-cache keep-warm** (`src/prompt-cache-keepwarm.ts`; per-session opt-in `keepWarmEnabled` on the Respawn tab, app-wide default `promptCacheKeepWarm` and cap `promptCacheKeepWarmMaxHours` under App Settings → System → Automation, both SYNCED, default OFF and 2 h; step 2 of Discussion #554): shortly before an idle claude session's prompt cache would expire, Codeman sends ONE keep-alive turn (`KEEP_WARM_NUDGE`, under 100 characters so Claude Code never reads the burst as a paste) so the next real prompt still reads the conversation from cache. Measured on two sessions of ~110k tokens: the nudge read 109,831 tokens from cache and wrote 70, replied `ok` in three seconds with no tool use, and a real turn seven minutes past the original expiry stayed a hit while the un-nudged control re-wrote 89,882 tokens with `last_miss_cause: ttl_expired_1h`; the payoff on real usage is concentrated in gaps of one to four hours, which is why there is a cap after the last real prompt rather than an indefinite loop (2 h by default: the maintainer's simulation over 30 days of gaps came out slightly cheaper than 3 h). ⚠️ **Codeman owns the idle policy for a keep-warm session.** Claude Code 2.1.289+ can compact an idle 1h-cache session by itself at 90% of the TTL (a server-flagged experiment, `fireAtFraction` tunable down to 0.5), which on the default timing always lands a minute before the nudge and leaves only a summary to keep warm. So a launch with keep-warm on carries `"idleCompaction": false`, folded LAST into the ONE `--settings` JSON with ultracode, the advisor and the statusLine exporter (`buildSpawnCommandFromRegistry`; never a second `--settings` flag, Claude Code takes one), so a keep-warm-off launch is byte-identical to one from before keep-warm (`test/cli-registry-spawn-golden.test.ts`). The `--settings` flag outranks user, project and local settings, which is why the key is conditional: everywhere else it would take Claude Code's own behaviour away with no way back. `decideIdleCompactionOptOut()` (tmux-manager.ts, pure) decides it once per create and respawn: keep-warm on (`keepWarmOn()`: the session's choice, else the app default, read fresh by `readKeepWarmSettings()`), the exporter `injected` (without `prompt_cache` reports keep-warm cannot run, and the opt-out alone would only cost the user the idle compaction), and claude's `idleCompactionSetting` gate (`capabilities.gates`, 2.1.290, the first release whose settings schema has the key; 2.1.289 already idle-compacts with no opt-out), fail-closed through `launchGatePasses()` exactly like `--name`: an unknown version means no key, and a log line says so. The result is recorded on the mux record (`MuxSession.idleCompactionOff`, like `statusLine`, persisted in mux-sessions.json) and published in the keep-warm status as `idleCompactionOff`; a session switched on after launch is kept warm best-effort (a relaunch to install the opt-out would cost exactly the re-write being avoided) and its readout says Claude Code's idle compaction may still run until its next launch, which any relaunch (dead-pane recovery, a custom-model `restartCli()`) fixes for free since every relaunch re-decides. ⚠️ The deadline is the statusline's `prompt_cache.expires_at` (`Session.promptCache`), never idle detection or the plan tier, and a `5m` lifetime is a hard stop (a nudge every four minutes costs more than the miss it prevents; API-key users get the hour from `CLAUDE_CODE_PROMPT_CACHE_TTL=1h` in `envOverrides`). ⚠️ `decideKeepWarm()` is the ONE rulebook and every guard lives in it, tested as a table: the effective opt-in (`keepWarmOn()`; a session an agent spawned, `parentSessionId`, does not inherit the app default, since an idle worker that reported done looks exactly like one that will be prompted again and most never are; its own switch still works), the launch's `statusLineExporter` is `injected` (which also stops every CLI without cache telemetry; the toggle line says why in the readout's own `PROMPT_CACHE_NOT_COLLECTED` words), a warm cache with an expiry, no `compactedAt` this gap (a compaction already replaced the context, and the stamp is dropped at the next request, which re-opens the next gap), not a `5m` lifetime, at least `KEEP_WARM_MIN_TOKENS` at stake, a live pane (a pid AND no recorded `paneExit`: an agent that exited in a `remain-on-exit` pane keeps its pid), no usage-limit pause (auto-resume's, or, read off the same pane capture as the composer, a usage-limit message whose reset is still ahead, since auto-resume tracks pauses only while it is on), no automation (respawn running, a Ralph loop active, a task assigned, or an agent long-polling `wait`), inside the cap measured from `lastRealPromptAt` (else `createdAt`), due at `expires_at` minus `KEEP_WARM_LEAD_MS` but not within `KEEP_WARM_LAST_CHANCE_MS`, idle, no open permission or question dialog, an EMPTY composer (`composerIsEmpty()` in session-submit-verifier.ts reads the last glyph line AND its continuation lines down to the composer's bottom rule, since a draft that opens with a newline leaves the glyph line blank: the nudge's paste would join a draft, and a missing rule is never read as empty). ⚠️ The composer is read from a STYLED capture (`capturePaneText(…, { styles: true })`, `capture-pane -p -e`, still one line per row; never `capturePaneBuffer()`, whose visible frame is a cursor-addressed repaint with no line breaks): Claude Code's prompt suggestion (on by default) fills an empty composer with faint (SGR 2) text whose first letter sits in the reverse-video cursor cell, and only that styling tells it from a typed draft, so `composerIsEmpty()` drops faint runs and a reverse cell followed by faint text. The paste replaces a suggestion rather than joining it (measured on Claude Code 2.1.300), and no human keystroke within `KEEP_WARM_RECENT_INPUT_MS`. Transient guards (working, a dialog, automation, an unreadable or non-empty composer, a recent keystroke) defer by `KEEP_WARM_RETRY_MS` and never past the last-chance line, an evaluation that throws re-arms the same retry, and a trigger landing during an evaluation queues one follow-up after it instead of being dropped, and `remove()` cancels both the follow-up and the retry; the composer is read only once everything else says "due". ⚠️ **Two submit clocks.** The nudge goes out through a plain `writeViaMux()` after `beginKeepAliveTurn()`. It IS a submit, so it moves `lastSubmitAt` from the write and again from its `prompt_submitted` hook (`markPromptSubmitted()`): the response viewer's conversation correlation (`resolveActiveClaudeSessionIdFromHistory()`) matches that stamp against Claude's history, which records the nudge like any prompt. The cap needs the last REAL prompt instead, so `Session._stampSubmit()` also moves `lastRealPromptAt` for every submit outside the keep-alive window (a person in the browser, an agent over the API, a typist in an attached tmux client seen through the hook), persisted in `SessionState.lastRealPromptAt` and restored by both rebuild paths so a restart never reopens the window. The window closes on the keep-alive's own `idle_prompt` (the hook route calls `endKeepAliveTurn()`; Claude Code sends it about a minute after the turn ends, so a fixed short window would let a slow turn's idle prompt alert and push, and `KEEP_WARM_TURN_WINDOW_MS` is only a ceiling for a workspace whose hooks never report). Any other submit closes it at once: a `fromUser` write, a programmatic write carrying anything but the nudge or a bare Enter (an agent, respawn, auto-resume; `isKeepAliveWrite()` in session.ts), or a `prompt_submitted` hook whose `prompt` is not the exact nudge (the route hands the hook's prompt text to `markPromptSubmitted()`), so a person's prompt right after a nudge, typed in the browser or in an attached tmux client, counts and alerts normally. The home screens and the tile grid read `lastSubmitAt` only to anchor a WORKING row, so a nudge shows there only for the seconds its turn runs; idle rows key on `lastActivityAt`, which the nudge's reply moves like any output. ⚠️ The same window makes the turn's `stop` hook and `idle_prompt` open pre-acknowledged (`preAcknowledged: 'cache keep-alive'` through `notePrompt()`, the same path `watching` uses), so no tab alert, toast, push or webhook fires. ⚠️ Only a human spends an idle alert, so the keep-alive turn leaves an idle item already pending exactly as it found it: the `working` transition's idle resolve (session-listener-wiring.ts) and the `stop` resolve skip it, and the keep-alive's `idle_prompt` does not replace it (a replacement would forget that it was unseen, or that a person had seen it), while the page's `_onHookStop` returns on `acknowledgedReason` before clearing anything; `session:working` and `session:idle` carry `keepAlive: true` for the turn, so the page's stuck-detection timer is neither cleared nor re-armed by it. Read My Mind drops the exact nudge text in `isCapturablePrompt()`, the filter `captureIntentPrompt()` → `intentStore.recordPrompt()` runs every transcript prompt through. ⚠️ Re-evaluation is edge-triggered and cheap: every cache report (`status-telemetry-routes.ts`), the toggle route, a settings change (`rescheduleAll()`, which also drops the service's in-memory copy of the default and cap, so a cache report never reads settings.json), `setupSessionListeners()` and the session's `idle` event (`keepWarmSchedule` in session-listener-wiring.ts, which is also what arms a restored report once the recovered pane is attached); the service keeps one timer per session (clamped to the 2^31-1 ms `setTimeout` honours, so a far-off expiry cannot turn into a 1 ms loop), `remove()`s it with the session and stops with the server, and a `remove()` or `stop()` landing during an evaluation's await cancels it there (nothing sent, armed or published after), and the server logs one `[KeepWarm]` line per status change naming the guard that holds it. Status rides `Session.keepWarm` in `toState()` and the change-only `session:keepWarm` event for the readout; `keepWarmEnabled`, `lastRealPromptAt` and the last `promptCache` report are restored by the constructor on both rebuild paths (that is what re-arms the timer after a restart), the status never is. Tests: `test/prompt-cache-keepwarm.test.ts`, `test/keep-warm-launch.test.ts`, `test/keep-warm-i18n.test.ts`, `test/routes/session-routes-keep-warm.test.ts`, the keep-warm readout in `test/prompt-cache-status.test.ts`, the opt-out literals in `test/cli-registry-spawn-golden.test.ts`, the keep-alive cases in `test/routes/hook-event-routes.test.ts` and `test/session-listener-wiring.test.ts`, and the page handlers in `test/keep-alive-page-alerts.test.ts`. ### Cron jobs diff --git a/docs/wiki/Keeping-Agents-Running.md b/docs/wiki/Keeping-Agents-Running.md index 18855654..927e1011 100644 --- a/docs/wiki/Keeping-Agents-Running.md +++ b/docs/wiki/Keeping-Agents-Running.md @@ -111,7 +111,7 @@ Claude Code 2.1.251 and newer reports the cache state on the statusline after ev **Keep the cache warm while idle** is the switch under the readout. With it on, Codeman sends a one-line keep-alive turn (`Codeman cache keep-alive: no action needed, reply with just "ok".`) about five minutes before the cache would expire. Claude answers `ok`, the request refreshes the cache for another hour, and your next real prompt reads the conversation from cache instead of re-processing it. Measured on sessions of about 110k tokens, the keep-alive cost a cache read plus a one-word reply, a small fraction of the re-write it prevented. -It only fires when there is nothing else going on: the session is idle with an empty prompt, no permission or question dialog is open, nobody has typed in the last few minutes, no respawn loop, Ralph loop or waiting agent is driving the session, and the session is not paused on a usage limit. If any of those hold it waits a minute and looks again, and gives up a minute before the expiry rather than race it. It also stops by itself a few hours after your last prompt (**Keep warm for at most**, under App Settings → System → Automation, default 2 hours), because the saving is concentrated in gaps of one to four hours and an overnight run of hourly keep-alives would cost more than the single cold turn in the morning. It stands down for the rest of a gap once the context has been compacted. The readout's Status line gains a `Keep-alive:` line that says what it is doing: the next keep-alive and when the window closes, waiting and why, sent at a time with a count, or stopped and why. A session started without the cache readout (Plan Usage off at launch, a workspace with its own statusLine, a remote or Docker session) cannot be kept warm: the switch is greyed out and cannot be switched on (one that is already on can still be switched off), and the Status line says why. +It only fires when there is nothing else going on: the session is idle with an empty prompt (Claude Code's greyed-out prompt suggestion counts as empty), no permission or question dialog is open, nobody has typed in the last few minutes, no respawn loop, Ralph loop or waiting agent is driving the session, and the session is not paused on a usage limit. If any of those hold it waits a minute and looks again, and gives up a minute before the expiry rather than race it. It also stops by itself a few hours after your last prompt (**Keep warm for at most**, under App Settings → System → Automation, default 2 hours), because the saving is concentrated in gaps of one to four hours and an overnight run of hourly keep-alives would cost more than the single cold turn in the morning. It stands down for the rest of a gap once the context has been compacted. The readout's Status line gains a `Keep-alive:` line that says what it is doing: the next keep-alive and when the window closes, waiting and why, sent at a time with a count, or stopped and why. A session started without the cache readout (Plan Usage off at launch, a workspace with its own statusLine, a remote or Docker session) cannot be kept warm: the switch is greyed out and cannot be switched on (one that is already on can still be switched off), and the Status line says why. Recent Claude Code builds can compact a large idle session on their own shortly before the cache expires, which would leave only a summary to keep warm. So a session **started** with keep-warm on also launches with Claude Code's idle compaction switched off (Claude Code 2.1.290 or newer); Codeman then owns the idle policy for it. A session you switch on after it started is still kept warm, but Claude Code may compact it first until its next launch, and the Status line says so; one you switch off keeps Claude Code's idle compaction off until its next launch, and the Status line says that too. Sessions with keep-warm off launch exactly as before. diff --git a/src/mux-interface.ts b/src/mux-interface.ts index d064f165..446cba54 100644 --- a/src/mux-interface.ts +++ b/src/mux-interface.ts @@ -428,11 +428,12 @@ export interface TerminalMultiplexer extends EventEmitter { captureActivePaneBuffer?(muxName: string, opts?: PaneCaptureOptions): string | null; /** - * Plain text of the visible frame: no styles, no cursor query, no repaint - * reconstruction. Deliberately cheaper than `capturePaneBuffer` because idle + * Plain text of the visible frame: no cursor query, no repaint + * reconstruction, and no styles unless `styles` asks for them (SGR only, lines + * still `\n`-separated). Deliberately cheaper than `capturePaneBuffer` because idle * detection calls it on a timer: it only needs to read what the CLI is * currently rendering, never to replay it into an xterm. Returns null when the * pane cannot be read. */ - capturePaneText?(muxName: string, paneTarget?: string): string | null; + capturePaneText?(muxName: string, paneTarget?: string, opts?: { styles?: boolean }): string | null; } diff --git a/src/prompt-cache-keepwarm.ts b/src/prompt-cache-keepwarm.ts index 7fc67450..f721e16a 100644 --- a/src/prompt-cache-keepwarm.ts +++ b/src/prompt-cache-keepwarm.ts @@ -134,7 +134,6 @@ export function decideKeepWarm(f: KeepWarmFacts): KeepWarmDecision { if ((c.recacheTokensIfCold ?? 0) < KEEP_WARM_MIN_TOKENS) return stop('context too small to be worth a turn'); if (!f.paneAlive) return stop('no live pane'); if (f.limitPaused) return stop('usage-limit pause'); - if (f.automationActive) return stop('respawn, Ralph or an agent is driving the session'); if (f.now >= f.budgetAnchorAt + f.maxKeepWarmMs) return stop('keep-warm window after the last prompt has passed'); const dueAt = c.expiresAt - KEEP_WARM_LEAD_MS; if (f.now < dueAt) return wait(dueAt, 'not due yet'); @@ -143,7 +142,10 @@ export function decideKeepWarm(f: KeepWarmFacts): KeepWarmDecision { const retryAt = Math.min(f.now + KEEP_WARM_RETRY_MS, lastChance); if (!f.idle) return wait(retryAt, 'session is working'); if (f.pendingApproval) return wait(retryAt, 'a dialog is open'); - if (f.composerEmpty === null) return stop('cannot read the composer'); + // Transient, and nothing else re-triggers an idle session: an agent's long-poll waiter ends + // without a turn, and so does a capture that failed once. + if (f.automationActive) return wait(retryAt, 'respawn, Ralph or an agent is driving the session'); + if (f.composerEmpty === null) return wait(retryAt, 'cannot read the composer'); if (!f.composerEmpty) return wait(retryAt, 'the composer holds text'); if (f.lastHumanInputAt && f.now - f.lastHumanInputAt < KEEP_WARM_RECENT_INPUT_MS) { return wait(retryAt, 'someone typed a moment ago'); @@ -213,6 +215,9 @@ export class PromptCacheKeepWarmService implements KeepWarmScheduler { private readonly tallies = new Map<string, { nudges: number; lastNudgeAt?: number }>(); /** One evaluation per session; `remove()` and `stop()` cancel it at its next await. */ private readonly inFlight = new Map<string, symbol>(); + /** The evaluation running per session, and the one follow-up queued behind it. */ + private readonly running = new Map<string, Promise<void>>(); + private readonly followUps = new Map<string, Promise<void>>(); /** * The default and cap, read once and kept: every statusline report schedules an evaluation, * and reading settings.json on each would cost every install, the feature off included. @@ -225,9 +230,36 @@ export class PromptCacheKeepWarmService implements KeepWarmScheduler { schedule(sessionId: string): Promise<void> { if (this.stopped) return Promise.resolve(); - return this.evaluate(sessionId).catch((err) => { - this.ports.log?.(`[KeepWarm] ${sessionId.slice(0, 8)}: ${err instanceof Error ? err.message : String(err)}`); - }); + const running = this.running.get(sessionId); + if (running) { + // A trigger landing mid-evaluation (a toggle while the nudge is written, a settings change) + // carries what that evaluation read too early, so one follow-up runs after it, shared by + // every trigger that arrives meanwhile. + let next = this.followUps.get(sessionId); + if (!next) { + next = running.then(() => { + // `remove()` drops the queued follow-up: a deleted session gets no evaluation. + if (this.followUps.get(sessionId) !== next) return; + this.followUps.delete(sessionId); + return this.schedule(sessionId); + }); + this.followUps.set(sessionId, next); + } + return next; + } + const run = this.evaluate(sessionId) + .catch((err) => { + this.ports.log?.(`[KeepWarm] ${sessionId.slice(0, 8)}: ${err instanceof Error ? err.message : String(err)}`); + // A failed settings read or write is transient, and an idle session gets no other + // trigger until its next turn. + // Not after `remove()`, which drops this run: the session is being deleted. + if (!this.stopped && this.running.get(sessionId) === run) this.arm(sessionId, Date.now() + KEEP_WARM_RETRY_MS); + }) + .finally(() => { + if (this.running.get(sessionId) === run) this.running.delete(sessionId); + }); + this.running.set(sessionId, run); + return run; } rescheduleAll(): void { @@ -239,6 +271,8 @@ export class PromptCacheKeepWarmService implements KeepWarmScheduler { this.clearTimer(sessionId); this.tallies.delete(sessionId); this.inFlight.delete(sessionId); + this.running.delete(sessionId); + this.followUps.delete(sessionId); } stop(): void { @@ -318,7 +352,9 @@ export class PromptCacheKeepWarmService implements KeepWarmScheduler { idleCompactionOff: session.idleCompactionOptOut, nudges: tally.nudges, lastNudgeAt: tally.lastNudgeAt, - budgetEndsAt: facts.budgetAnchorAt + facts.maxKeepWarmMs, + // Off, the window means nothing, and it moves with every real prompt: carrying it would + // broadcast a change on every turn of every session. + ...(enabled && { budgetEndsAt: facts.budgetAnchorAt + facts.maxKeepWarmMs }), }; if (decision.action === 'nudge') { session.beginKeepAliveTurn(KEEP_WARM_TURN_WINDOW_MS); diff --git a/src/session-submit-verifier.ts b/src/session-submit-verifier.ts index 0b6e2728..edadf998 100644 --- a/src/session-submit-verifier.ts +++ b/src/session-submit-verifier.ts @@ -65,26 +65,83 @@ export function promptStillInComposer(screen: string, prompt: string, glyph: str return head.length > 0 && composer.startsWith(head); } +/** + * A styled frame as plain text. SGR goes first with its own pattern because tmux writes + * colon sub-parameters (`ESC[4:3m`, a curly underline), which `stripAnsi` leaves behind. + */ +export function plainFrame(styled: string): string { + // eslint-disable-next-line no-control-regex + return stripAnsi(styled.replace(/\x1b\[[0-9;:]*m/g, '')); +} + +/** + * `screen` (a `capture-pane -p -e` frame) without the text Claude Code draws in an empty + * composer that nobody typed: its prompt suggestion, drawn faint (SGR 2), and the suggestion's + * first letter, which sits in the reverse-video cursor cell (SGR 7) right before the faint rest. + * A typed draft is never faint, so a cursor cell followed by anything else is kept. Every line + * break is kept, so line `n` here is line `n` of the plain frame, and plain text passes through + * unchanged. Other escapes are left for `stripAnsi`. + */ +function withoutSuggestion(screen: string): string { + let faint = false; + let inverse = false; + let cursorCell = ''; + let out = ''; + // eslint-disable-next-line no-control-regex + for (const m of screen.matchAll(/\x1b\[([0-9;:]*)m|[\s\S]/gu)) { + if (m[1] !== undefined) { + const params = m[1].split(';'); + for (let i = 0; i < params.length; i++) { + const p = params[i]; + if (p === '' || p === '0') faint = inverse = false; + else if (p === '2') faint = true; + else if (p === '22') faint = false; + else if (p === '7') inverse = true; + else if (p === '27') inverse = false; + // An extended colour's own arguments (`38;5;n`, `38;2;r;g;b`) are not attributes. + else if (p === '38' || p === '48' || p === '58') i += params[i + 1] === '5' ? 2 : params[i + 1] === '2' ? 4 : 0; + } + continue; + } + const ch = m[0]; + if (ch !== '\n' && faint) { + cursorCell = ''; + continue; + } + out += cursorCell; + cursorCell = ''; + if (ch !== '\n' && inverse) cursorCell = ch; + else out += ch; + } + return out + cursorCell; +} + /** * Whether the composer holds nothing: the LAST `glyph` line after the glyph, and every * continuation line below it down to the composer's bottom rule (a draft that opens with a * newline leaves the glyph line itself blank). Undefined when there is no composer line or no * rule under it to bound the read (a shell, a dialog, a direct-PTY session): nothing can be - * said. The prompt-cache keep-warm refuses to type unless this is true: `send-keys -l` would - * append its nudge to whatever draft sits there, and the Enter would submit both. + * said. The prompt-cache keep-warm refuses to type unless this is true: its paste would land + * in whatever draft sits there, and the Enter would submit both. + * + * Pass a STYLED frame (`capture-pane -p -e`): Claude Code's prompt suggestion (on by default) + * fills an empty composer with faint text, and only the styling tells it from a typed draft. + * The paste replaces a suggestion rather than joining it (measured on 2.1.300). A plain frame + * still parses, but reads a suggestion as a draft. */ export function composerIsEmpty(screen: string, glyph: string): boolean | undefined { if (!glyph) return undefined; // The structure is read at column 0, before any trimming: Claude Code draws the glyph line and // both rules there and indents every continuation line, so a draft line that holds the glyph or // starts with a rule (` ❯`, ` ───`) can never pass for the composer's own edges. - const lines = stripAnsi(screen).split('\n'); + const lines = plainFrame(screen).split('\n'); let at = lines.length - 1; while (at >= 0 && !lines[at].startsWith(glyph)) at--; if (at < 0) return undefined; const end = lines.findIndex((l, i) => i > at && l.startsWith('\u2500')); if (end < 0) return undefined; - return compact(lines[at].slice(glyph.length) + lines.slice(at + 1, end).join('')).length === 0; + const typed = plainFrame(withoutSuggestion(screen)).split('\n'); + return compact(typed[at].slice(glyph.length) + typed.slice(at + 1, end).join('')).length === 0; } export interface SubmitVerifierDeps { diff --git a/src/session.ts b/src/session.ts index e8ac08b1..8ded0775 100644 --- a/src/session.ts +++ b/src/session.ts @@ -1001,8 +1001,9 @@ export class Session extends EventEmitter { // response viewer re-derive the live conversation without waiting for the // user to type again. this._lastSubmitAt = config.lastSubmitAt ?? 0; - // Restored so a restart neither reopens the keep-warm window nor drops the choice. - this._lastRealPromptAt = config.lastRealPromptAt ?? 0; + // Restored so a restart neither reopens the keep-warm window nor drops the choice. A state.json + // from before the field has only `lastSubmitAt`, and every submit then was a real one. + this._lastRealPromptAt = config.lastRealPromptAt ?? config.lastSubmitAt ?? 0; this._keepWarmEnabled = config.keepWarmEnabled; // Restored chain: its tail is the conversation the CLI was actually on when // the server stopped, which outranks the launch id seeded just above. The diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index cbe5c94f..129f9d52 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -4143,17 +4143,19 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { /** * Plain visible-frame text for the working/idle probe (see `session.ts`). * - * One `capture-pane` and nothing else: no `-e` styles, no `display-message` - * cursor query, no repaint reconstruction: this feeds a regex, not a - * terminal. Returns null in tests (no tmux) so callers fall back to their - * stream heuristics rather than reading an empty screen as "not working". + * One `capture-pane` and nothing else: no `display-message` cursor query, no + * repaint reconstruction: this feeds a regex, not a terminal. `styles` adds + * `-e` for a reader that must tell faint text from typed text (the keep-warm + * composer check); lines stay `\n`-separated either way. Returns null in tests + * (no tmux) so callers fall back to their stream heuristics rather than + * reading an empty screen as "not working". */ - capturePaneText(muxName: string, paneTarget?: string): string | null { + capturePaneText(muxName: string, paneTarget?: string, opts?: { styles?: boolean }): string | null { if (IS_TEST_MODE) return null; const target = resolveTmuxPaneTarget(muxName, paneTarget); if (!target) return null; try { - return execSync(`${this.tmux()} capture-pane -p -t ${shellescape(target)}`, { + return execSync(`${this.tmux()} capture-pane -p${opts?.styles ? ' -e' : ''} -t ${shellescape(target)}`, { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS, }); diff --git a/src/web/public/constants.js b/src/web/public/constants.js index 4f9df935..4cb2f099 100644 --- a/src/web/public/constants.js +++ b/src/web/public/constants.js @@ -773,7 +773,7 @@ function formatKeepWarmStatus(status, now, statusLine) { if (PROMPT_CACHE_NOT_COLLECTED[statusLine]) return ''; if (!status || typeof status.state !== 'string') return ''; const clock = (ms) => new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); - const window = Number.isFinite(status.budgetEndsAt) + const keepWarmWindow = Number.isFinite(status.budgetEndsAt) ? status.budgetEndsAt > now ? `stops ${clock(status.budgetEndsAt)}` : 'window closed' @@ -794,13 +794,13 @@ function formatKeepWarmStatus(status, now, statusLine) { break; } case 'armed': - parts = [`next ${clock(status.nextCheckAt)}`, window, idleCompaction]; + parts = [`next ${clock(status.nextCheckAt)}`, keepWarmWindow, idleCompaction]; break; case 'waiting': - parts = ['waiting', status.reason || 'retrying', `retry ${clock(status.nextCheckAt)}`, window, idleCompaction]; + parts = ['waiting', status.reason || 'retrying', `retry ${clock(status.nextCheckAt)}`, keepWarmWindow, idleCompaction]; break; case 'nudged': - parts = [`sent ${clock(status.lastNudgeAt)} (${status.nudges}\u00d7)`, window, idleCompaction]; + parts = [`sent ${clock(status.lastNudgeAt)} (${status.nudges}\u00d7)`, keepWarmWindow, idleCompaction]; break; default: parts = ['stopped', status.reason || 'nothing to do', idleCompaction]; diff --git a/src/web/server.ts b/src/web/server.ts index ab55acba..4148bdda 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -119,7 +119,7 @@ import { AI_CHECK_MODEL } from '../config/ai-defaults.js'; import { approvalInbox } from './approval-inbox.js'; import { SCREEN_BLOCKED_SOURCE } from './screen-blocked.js'; import { PromptCacheKeepWarmService } from '../prompt-cache-keepwarm.js'; -import { composerIsEmpty } from '../session-submit-verifier.js'; +import { composerIsEmpty, plainFrame } from '../session-submit-verifier.js'; import { detectUsageLimitPause } from '../usage-limit-patterns.js'; import { stopDeepSeekWeb } from '../deepseek-web-server.js'; import { @@ -348,10 +348,11 @@ export class WebServer extends EventEmitter { readPane: (view) => { const session = view as Session; const muxName = session.muxName; - // `capture-pane -p`: the visible frame as plain text. - const text = muxName ? (this.mux.capturePaneText?.(muxName) ?? null) : null; + // `capture-pane -p -e`: the visible frame with its styles, which is how `composerIsEmpty()` + // tells Claude Code's faint prompt suggestion from a typed draft. + const text = muxName ? (this.mux.capturePaneText?.(muxName, undefined, { styles: true }) ?? null) : null; if (!text) return { composerEmpty: null, limitShown: false }; - const limit = detectUsageLimitPause(text); + const limit = detectUsageLimitPause(plainFrame(text)); return { composerEmpty: composerIsEmpty(text, getCli(session.mode)?.capabilities.workDetect?.promptGlyph ?? '') ?? null, limitShown: limit !== null && limit.resetAt > Date.now(), diff --git a/test/keep-warm-launch.test.ts b/test/keep-warm-launch.test.ts index 50b4c6f1..03eb819b 100644 --- a/test/keep-warm-launch.test.ts +++ b/test/keep-warm-launch.test.ts @@ -117,7 +117,8 @@ describe('the pane read behind the last two guards', () => { const at = port.indexOf('readPane: (view) => {'); expect(at).toBeGreaterThan(-1); const body = port.slice(at, port.indexOf('publish: (id, status) => {', at)); - expect(body).toContain('const limit = detectUsageLimitPause(text);'); + expect(body).toContain('this.mux.capturePaneText?.(muxName, undefined, { styles: true })'); + expect(body).toContain('const limit = detectUsageLimitPause(plainFrame(text));'); expect(body).toContain('limitShown: limit !== null && limit.resetAt > Date.now(),'); expect(body).toContain('composerEmpty: composerIsEmpty(text,'); }); diff --git a/test/prompt-cache-keepwarm.test.ts b/test/prompt-cache-keepwarm.test.ts index a4265114..4ad0f9e7 100644 --- a/test/prompt-cache-keepwarm.test.ts +++ b/test/prompt-cache-keepwarm.test.ts @@ -20,7 +20,8 @@ import { type KeepWarmPorts, type KeepWarmSessionView, } from '../src/prompt-cache-keepwarm.js'; -import { composerIsEmpty } from '../src/session-submit-verifier.js'; +import { composerIsEmpty, plainFrame } from '../src/session-submit-verifier.js'; +import { detectUsageLimitPause } from '../src/usage-limit-patterns.js'; import { isCapturablePrompt } from '../src/intent-store.js'; import { Session } from '../src/session.js'; import type { KeepWarmStatus, PromptCacheStatus } from '../src/types.js'; @@ -92,9 +93,7 @@ describe('decideKeepWarm', () => { ], ['a dead pane', { paneAlive: false }, 'no live pane'], ['a usage-limit pause', { limitPaused: true }, 'usage-limit'], - ['respawn, Ralph or an agent driving', { automationActive: true }, 'driving the session'], ['the cap after the last prompt', { budgetAnchorAt: NOW - 2 * HOUR }, 'window after the last prompt'], - ['an unreadable composer', { composerEmpty: null }, 'cannot read the composer'], ])('stops for %s', (_label, over, reason) => { const d = decideKeepWarm(dueFacts(over)); expect(d.action).toBe('stop'); @@ -119,6 +118,9 @@ describe('decideKeepWarm', () => { ['the session is working', { idle: false }, 'working'], ['a dialog is open', { pendingApproval: true }, 'dialog'], ['the composer holds a draft', { composerEmpty: false }, 'composer holds text'], + // Neither ends with a turn, so a stop here would leave an idle session with no trigger. + ['respawn, Ralph or an agent is driving (a long-poll waiter included)', { automationActive: true }, 'driving'], + ['one capture of the composer failed', { composerEmpty: null }, 'cannot read the composer'], ['someone typed a moment ago', { lastHumanInputAt: NOW - KEEP_WARM_RECENT_INPUT_MS + 1 }, 'typed'], ])('defers by a minute while %s', (_label, over, reason) => { const d = decideKeepWarm(dueFacts(over)); @@ -174,6 +176,50 @@ describe('composerIsEmpty', () => { it('gives no verdict without the bottom rule that bounds the composer', () => { expect(composerIsEmpty(`${glyph} \n unfinished draft`, glyph)).toBeUndefined(); }); + + /** + * `capture-pane -p -e` of one Claude Code 2.1.300 pane, byte for byte apart from the + * statusline row (dropped): the composer with its prompt suggestion, empty, holding a draft, + * holding the same draft with the cursor moved home, and holding a pasted-text chip. The suggestion's first letter sits + * in the reverse-video cursor cell and the rest is faint; a typed draft is neither. + */ + const RULE = `\x1b[38;5;244m${'─'.repeat(120)}`; + const FOOTER = ' \x1b[38;5;220m⏵⏵ auto mode on\x1b[38;5;246m (shift+tab to cycle)\x1b[39m'; + const frame = (composer: string) => + ['\x1b[38;5;246m✻ Cooked for 2s · done 6:56 PM\x1b[39m', '', RULE, composer, RULE, FOOTER].join('\n'); + const SUGGESTION = '\x1b[39m❯\u00a0\x1b[7mw\x1b[0;2m\x1b[39m\x1b[49mrite tests for add\x1b[0m\x1b[39m\x1b[49m'; + const EMPTY = '\x1b[39m❯\u00a0\x1b[7m \x1b[0m\x1b[39m\x1b[49m'; + const DRAFT = '\x1b[39m❯\u00a0half a draft\x1b[7m \x1b[0m\x1b[39m\x1b[49m'; + const DRAFT_CURSOR_HOME = '\x1b[39m❯\u00a0\x1b[7mh\x1b[0m\x1b[39m\x1b[49malf a draft'; + // A 40-line paste collapses to a chip, drawn plain like typed text. + const PASTED_CHIP = '\x1b[39m❯\u00a0[Pasted text #1 +40 lines]\x1b[7m \x1b[0m\x1b[39m\x1b[49m'; + + it("reads Claude Code's prompt suggestion as empty and a typed draft as text, from a real styled capture", () => { + expect(composerIsEmpty(frame(SUGGESTION), glyph)).toBe(true); + expect(composerIsEmpty(frame(EMPTY), glyph)).toBe(true); + expect(composerIsEmpty(frame(DRAFT), glyph)).toBe(false); + // The cursor cell holds the draft's first letter, but no faint text follows it. + expect(composerIsEmpty(frame(DRAFT_CURSOR_HOME), glyph)).toBe(false); + expect(composerIsEmpty(frame(PASTED_CHIP), glyph)).toBe(false); + }); + + it('reads a suggestion drawn without a cursor cell as empty, and a plain capture of it as text', () => { + expect(composerIsEmpty(frame('❯\u00a0\x1b[2mwrite tests for add\x1b[0m'), glyph)).toBe(true); + expect(composerIsEmpty(frame('❯\u00a0\x1b[2mwrite \x1b[22mtyped\x1b[0m'), glyph)).toBe(false); + // Without the styles nothing tells the two apart, which is why the pane is read with -e. + expect(composerIsEmpty(frame('❯\u00a0write tests for add'), glyph)).toBe(false); + }); + + it("reads text styled with tmux's colon sub-parameters, which stripAnsi leaves behind", () => { + const limit = "You've hit your \x1b[4:3mlimit\x1b[0m · resets 1:40pm (UTC)"; + expect(detectUsageLimitPause(plainFrame(limit), NOW)).not.toBeNull(); + expect(composerIsEmpty(frame('\x1b[4:3m❯\x1b[0m\u00a0'), glyph)).toBe(true); + }); + + it("never takes an extended colour's arguments for the faint or reverse attributes", () => { + expect(composerIsEmpty(frame('❯\u00a0\x1b[38;2;2;7;2mtyped in truecolour\x1b[0m'), glyph)).toBe(false); + expect(composerIsEmpty(frame('❯\u00a0\x1b[38;5;2mtyped in a 256 colour\x1b[0m'), glyph)).toBe(false); + }); }); describe('Read My Mind ignores the nudge', () => { @@ -257,6 +303,12 @@ describe('the two submit clocks on a Session', () => { expect(fresh.lastRealPromptAt).toBe(0); expect(fresh.toState().lastRealPromptAt).toBeUndefined(); }); + + it('seeds the real-prompt clock from lastSubmitAt for a state.json written before it existed', () => { + expect(new Session({ workingDir: '/tmp', lastSubmitAt: NOW - HOUR }).lastRealPromptAt).toBe(NOW - HOUR); + const both = new Session({ workingDir: '/tmp', lastSubmitAt: NOW, lastRealPromptAt: NOW - HOUR }); + expect(both.lastRealPromptAt).toBe(NOW - HOUR); + }); }); // ─── Service ───────────────────────────────────────────────────────────────── @@ -593,6 +645,110 @@ describe('PromptCacheKeepWarmService', () => { service.stop(); }); + it('publishes no window while off, so a real prompt on a session with keep-warm off broadcasts nothing', async () => { + const session = new FakeSession(); + session.keepWarmEnabled = false; + session.promptCache = warmCache(NOW + HOUR); + const { ports, published } = makePorts(session); + const service = new PromptCacheKeepWarmService(ports); + await service.schedule(session.id); + session.lastRealPromptAt = NOW; + await service.schedule(session.id); + expect(published[0]).toMatchObject({ enabled: false, state: 'off' }); + expect(published[0]).not.toHaveProperty('budgetEndsAt'); + expect(published[0]).not.toHaveProperty('nextCheckAt'); + expect(JSON.stringify(published[1])).toBe(JSON.stringify(published[0])); + service.stop(); + }); + + it('retries a minute later when an evaluation throws, since an idle session has no other trigger', async () => { + const session = new FakeSession(); + session.keepWarmEnabled = true; + session.promptCache = warmCache(NOW + KEEP_WARM_LEAD_MS - 1000); + let fail = true; + const { ports, last } = makePorts(session, { + readSettings: async () => { + if (fail) throw new Error('settings.json mid-rewrite'); + return { keepWarmDefault: false, maxHours: 2 }; + }, + }); + const service = new PromptCacheKeepWarmService(ports); + await service.schedule(session.id); + expect(session.writes).toHaveLength(0); + expect(vi.getTimerCount()).toBe(1); + fail = false; + await vi.advanceTimersByTimeAsync(KEEP_WARM_RETRY_MS); + await settle(); + expect(session.writes).toEqual([`${KEEP_WARM_NUDGE}\r`]); + expect(last()).toMatchObject({ state: 'nudged' }); + service.stop(); + }); + + it('runs one follow-up for triggers that land during an evaluation, and resolves after it', async () => { + const session = new FakeSession(); + session.keepWarmEnabled = true; + session.promptCache = warmCache(NOW + KEEP_WARM_LEAD_MS - 1000); + let releaseWrite!: (ok: boolean) => void; + session.writeViaMux = async (data: string) => { + session.writes.push(data); + return new Promise<boolean>((r) => (releaseWrite = r)); + }; + const { ports, published } = makePorts(session); + const service = new PromptCacheKeepWarmService(ports); + const first = service.schedule(session.id); + await settle(); + expect(session.writes).toHaveLength(1); + // Switched off while the nudge is being written: the toggle's own evaluation must not be lost. + session.keepWarmEnabled = false; + const toggle = service.schedule(session.id); + const settingsChange = service.schedule(session.id); + expect(settingsChange).toBe(toggle); + releaseWrite(true); + await first; + await toggle; + expect(published.map((s) => s.state)).toEqual(['nudged', 'off']); + service.stop(); + }); + + it('remove() drops a follow-up queued behind an evaluation, and a failed read after it arms nothing', async () => { + const session = new FakeSession(); + session.keepWarmEnabled = true; + session.promptCache = warmCache(NOW + KEEP_WARM_LEAD_MS - 1000); + let release!: () => void; + let fail = false; + const { ports, published } = makePorts(session, { + readSettings: async () => { + await new Promise<void>((r) => (release = r)); + if (fail) throw new Error('settings.json mid-rewrite'); + return { keepWarmDefault: false, maxHours: 2 }; + }, + }); + const service = new PromptCacheKeepWarmService(ports); + // The session stays discoverable while it is being deleted, so only remove() can stop these. + const first = service.schedule(session.id); + const followUp = service.schedule(session.id); + service.remove(session.id); + release(); + await first; + await followUp; + await settle(); + expect(session.writes).toHaveLength(0); + expect(published).toHaveLength(0); + expect(vi.getTimerCount()).toBe(0); + + // rescheduleAll() drops the kept settings, so this read really runs, and fails. + fail = true; + const logged: string[] = []; + ports.log = (m) => logged.push(m); + service.rescheduleAll(); + service.remove(session.id); + release(); + await settle(); + expect(logged).toEqual([expect.stringContaining('mid-rewrite')]); + expect(vi.getTimerCount()).toBe(0); + service.stop(); + }); + it('remove() forgets the nudge count, so an id seen again starts from zero', async () => { const session = new FakeSession(); session.keepWarmEnabled = true; From e3c6f9f2c737ffa56481edb2d9d4cf31459cd1e1 Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:30:38 +0800 Subject: [PATCH 15/96] fix(file-viewer): the resize grip only starts on the primary button --- src/web/public/panels-ui.js | 1 + test/file-preview-window.test.ts | 9 +++++++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/web/public/panels-ui.js b/src/web/public/panels-ui.js index a7d14b54..ed026845 100644 --- a/src/web/public/panels-ui.js +++ b/src/web/public/panels-ui.js @@ -4666,6 +4666,7 @@ Object.assign(CodemanApp.prototype, { const win = grip?.parentElement; if (!grip || !win) return; grip.addEventListener('pointerdown', (ev) => { + if (ev.button !== 0) return; // a right-click on the grip must not start a resize ev.preventDefault(); const startX = ev.clientX; const startY = ev.clientY; diff --git a/test/file-preview-window.test.ts b/test/file-preview-window.test.ts index 26875efe..c29d25c4 100644 --- a/test/file-preview-window.test.ts +++ b/test/file-preview-window.test.ts @@ -339,7 +339,7 @@ describe('file viewer Alt+arrow chord', () => { }); describe('file viewer resize grip', () => { - function drag(to: [number, number]) { + function drag(to: [number, number], button = 0) { const { app, elements } = loadApp(); const listeners: Record<string, (e: unknown) => void> = {}; const win = { offsetWidth: 400, offsetHeight: 300, style: {} as Record<string, string> }; @@ -350,11 +350,16 @@ describe('file viewer resize grip', () => { removeEventListener: () => {}, }; app._bindFilePreviewResize(); - listeners.pointerdown({ preventDefault: () => {}, clientX: 100, clientY: 100, pointerId: 1 }); + listeners.pointerdown({ preventDefault: () => {}, clientX: 100, clientY: 100, pointerId: 1, button }); + if (!listeners.pointermove) return win.style; // a refused pointerdown binds no move handler listeners.pointermove({ clientX: 100 + to[0], clientY: 100 + to[1] }); return win.style; } + it('ignores a pointerdown that is not the primary button', () => { + expect(drag([100, 50], 2)).toEqual({}); + }); + it('doubles the pointer delta, because the window is centred', () => { expect(drag([100, 50])).toMatchObject({ width: '600px', height: '400px' }); }); From 131ee1a31e7c7017f986b494f0e75db08a42c2b9 Mon Sep 17 00:00:00 2001 From: Saqeb Akhter <saqeb.akhter@gmail.com> Date: Tue, 20 Oct 2026 12:30:52 +0400 Subject: [PATCH 16/96] test(terminal): restore reversal assertion and clarify scrolling guidance (#7) --- CLAUDE.md | 2 +- docs/architecture-invariants.md | 2 +- test/terminal-scroll-routing.test.ts | 4 ++-- test/terminal-tile-scroll.test.ts | 5 +++-- test/terminal-touch-tap.test.ts | 2 +- 5 files changed, 8 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 193ba569..54090d5c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -206,7 +206,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Full-scrollback replay**: `GET /api/sessions/:id/terminal?full=1` returns the whole tmux scrollback ALONE (`source='mux-full-history'`), superseding the byte buffer. First load of each non-shell TUI session requests it (`_fullHistoryLoaded`); Shell selection, drop recovery, clear and a tile's load use `SHELL_LOAD_QUERY` (constants.js), a bounded window of tmux's RENDERED history (`?full=1&tail=1MiB&alt=recording&lines=10000`), never the raw byte recording, whose full-screen repaints replayed at another pane height push copies of their top row into scrollback; the route reports a client `lines=` bound below the history ceiling that left lines in tmux as `truncationReason: 'tail'`, and `alt=recording` falls back to the visible frame over the tailed byte recording when the pane is in the alternate screen (a nested tmux in a remote or Docker case keeps no tmux history; vim/less take it too while open). A Shell scroll-to-top pulls a bounded `?full=1&tail=` window (a window no longer than the browser's buffer is skipped before the downgrade guard, so it never marks the session exhausted); the unbounded pull stays behind **Load full history**. A Shell split-pane Pane B has its own copy of the bounded pull against its own xterm (`TerminalTile._pullHistory`, terminal-tile.js); keep the two in step. → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions) ⚠️ The capture ends with a RELATIVE cursor move back to the pane's caret (never `CUP`), so no line-deleting transform may run over it; those skips key on `isFullCapture`, never on `?full=1` alone. ⚠️ A re-pull must never shrink the buffer (`_replayWouldShrinkBuffer()`). ⚠️ `captureCols`/`captureRows` are absent when no frame was positioned: test `Number.isFinite`, never truthiness. ⚠️ A frame dropped at the 128 KiB render cap MUST be recovered, and the recovery verifies itself: `_scheduleDroppedOutputRecovery` re-arms (bounded by `DROP_RECOVERY_MAX_ATTEMPTS`) while `_onSessionNeedsRefresh` reports no repaint, but never after a capture-fetch `'deadline'`. → [architecture-invariants#full-scrollback-replay](docs/architecture-invariants.md#full-scrollback-replay) -**Terminal scrollback strip + wheel/touch forwarding**: codex/claude/gemini get the FULL strip (alt-screen, `3J`, mouse DECSETs); tmux-backed opencode and copilot get 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 only with observed `cliMouseTracking`: **Claude ≥ 2.1.187, tracked Codex, or tracked Copilot**. Codex 0.163.0 fullscreen was measured with `alternate_on=1`, `mouse_any_flag=1`: one SGR report moves one transcript row; see the fresh measurement in architecture-invariants. Inline/untracked Codex retains local history or the capability-gated paging fallback. ⚠️ `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. ⚠️ A `TerminalTile` (grid tile, split Pane B) forwards the wheel as SGR reports, pages it and reports clicks through these SAME gates with itself as the target (its terminal, its session, never `activeSessionId`), and scrolls Shift+wheel itself (xterm makes it a horizontal no-op); never copy the mode lists into terminal-tile.js. 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 and copilot get 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 only with observed `cliMouseTracking`: **Claude ≥ 2.1.187, tracked Codex, or tracked Copilot**. Codex 0.163.0 fullscreen was measured with `alternate_on=1`, `mouse_any_flag=1`: one SGR report moves one transcript row; see the fresh measurement in architecture-invariants. Inline/untracked Codex retains local history or the capability-gated paging fallback. Copilot has no version gate; a stale/false tracking flag falls back to PageUp/PageDown. ⚠️ `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. ⚠️ A `TerminalTile` (grid tile, split Pane B) forwards the wheel as SGR reports, pages it and reports clicks through these SAME gates with itself as the target (its terminal, its session, never `activeSessionId`), and scrolls Shift+wheel itself (xterm makes it a horizontal no-op); never copy the mode lists into terminal-tile.js. 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 5b58d86e..90174cca 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -225,7 +225,7 @@ Further detail: the `<prefix>: <title>` form (`w3-myapp: fix the login redirect` ⚠️ **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. -**Wheel forwarding requires observed mouse tracking: Claude ≥ 2.1.187, tracked Codex or tracked Copilot** (`terminal-ui.js:_shouldForwardWheelToApp`). The original #227 measurement remains valid for **Codex 0.147.0 inline**: `alternate_on=0`, `mouse_any_flag=0`, growing tmux history, and SGR wheel reports do nothing. Claude 2.1.280 inline similarly reports `alternate_on=0`, `mouse_any_flag=0`; forwarding there swallows gestures, so it keeps local scrollback. Fullscreen Claude (`CLAUDE_CODE_NO_FLICKER=1` or `"tui": "fullscreen"`) enables mouse tracking and accepts reports. **Fresh Codex 0.163.0 fullscreen measurement (2026-10-18, isolated local tmux pane)**: `alternate_on=1`, `mouse_any_flag=1`; one `\x1b[<64;10;10M` report moves the transcript one row upward, and `\x1b[<65;10;10M` restores the original screen. This is capability evidence, not a minimum-version promise: untracked Codex (including the maintainer's 0.162.0 measurement with `mouse_any_flag=0`) keeps local history or the hollow-pane paging fallback. Codex does not publish the Claude-only CLI version field, so its gate requires the observed flag rather than a Claude version. The registry records this as `wheelForward.mode: 'always'` (observed mouse tracking is still required); Claude remains `version-gated`. A stale/false flag after startup cannot authorize forwarding. ⚠️ A strip mode alone is not evidence that a CLI consumes SGR reports. Hand-encoded SGR taps remain independently gated by `_shouldReportMouseToCli()`; the older #325 no-op measurement describes untracked inline Codex and must not be generalized to newer tracked fullscreen renderers. Shift/local-history opt-outs and xterm's native mouse/alternate-screen handling remain intact. +**Wheel forwarding requires observed mouse tracking: Claude ≥ 2.1.187, tracked Codex or tracked Copilot** (`terminal-ui.js:_shouldForwardWheelToApp`). The original #227 measurement remains valid for **Codex 0.147.0 inline**: `alternate_on=0`, `mouse_any_flag=0`, growing tmux history, and SGR wheel reports do nothing. Claude 2.1.280 inline similarly reports `alternate_on=0`, `mouse_any_flag=0`; forwarding there swallows gestures, so it keeps local scrollback. Fullscreen Claude (`CLAUDE_CODE_NO_FLICKER=1` or `"tui": "fullscreen"`) enables mouse tracking and accepts reports. **Fresh Codex 0.163.0 fullscreen measurement (2026-10-18, isolated local tmux pane)**: `alternate_on=1`, `mouse_any_flag=1`; one `\x1b[<64;10;10M` report moves the transcript one row upward, and `\x1b[<65;10;10M` restores the original screen. This is capability evidence, not a minimum-version promise: untracked Codex (including the maintainer's 0.162.0 measurement with `mouse_any_flag=0`) keeps local history or the hollow-pane paging fallback. Codex does not publish the Claude-only CLI version field, so its gate requires the observed flag rather than a Claude version. The registry records this as `wheelForward.mode: 'always'` (observed mouse tracking is still required); Claude remains `version-gated`. A stale/false flag after startup cannot authorize forwarding. ⚠️ A strip mode alone is not evidence that a CLI consumes SGR reports. Verify with a real `\\x1b[<64;c;rM` write into a live pane before adding a mode here. Hand-encoded SGR taps remain independently gated by `_shouldReportMouseToCli()`; the older #325 no-op measurement describes untracked inline Codex and must not be generalized to newer tracked fullscreen renderers. Shift/local-history opt-outs and xterm's native mouse/alternate-screen handling remain intact. **Copilot also receives forwarded reports** (1.0.95 measured in tmux: alt screen, `mouse_any_flag=1`): its mouse DECSETs are stripped so a drag selects, which also stops xterm encoding the wheel for it, so `_shouldForwardWheelToApp` sends the same SGR reports by hand while `cliMouseTracking` is true; six `\x1b[<64;…M` reports moved its transcript 18 lines, a phone swipe takes the same gate, and there is NO version gate. diff --git a/test/terminal-scroll-routing.test.ts b/test/terminal-scroll-routing.test.ts index a3bee9d3..bc0dc4cd 100644 --- a/test/terminal-scroll-routing.test.ts +++ b/test/terminal-scroll-routing.test.ts @@ -694,8 +694,8 @@ describe('pageKeysForGesture, the pure gesture rules both panes share', () => { expect(steps.map((s) => s.keys)).toEqual([UP, '', '', UP]); }); - it('responds immediately to a notch without paging for every later notch', () => { - // Five 4-row notches 400ms apart share the pre-paid page debt. + it('accumulates a notch-sized opening event, and carries plain travel across notches', () => { + // Five 4-row notches 400 ms apart: each is a new gesture, none pages alone. const steps = run([ [-4, 0], [-4, 400], diff --git a/test/terminal-tile-scroll.test.ts b/test/terminal-tile-scroll.test.ts index 1c9d3f51..8f17e0dd 100644 --- a/test/terminal-tile-scroll.test.ts +++ b/test/terminal-tile-scroll.test.ts @@ -660,7 +660,7 @@ describe("gesture handling is the primary pane's, through the shared pure helper }; } - it('pages a Codex tile, which draws on the alternate screen and ignores SGR wheel reports', async () => { + it('pages an untracked Codex tile through the hollow-pane fallback', async () => { const { ws, mount } = await connectTile(makeApp({ 's-tile': { mode: 'codex' } }), { mode: 'codex' }); mount.fire('wheel', wheelLines(-18)); @@ -765,6 +765,7 @@ describe("gesture handling is the primary pane's, through the shared pure helper primary._flushWheelSgrQueue(); expect(tileBytes).toContain(PAGE_UP); + expect(tileBytes).toContain(PAGE_DOWN); expect(tileBytes).toBe(sent.join('')); }); @@ -792,7 +793,7 @@ describe("gesture handling is the primary pane's, through the shared pure helper const tileBytes = flushed(ws) .map((f) => f.d) .join(''); - // One page for the slow ticks, one for the wobble's opening event, nothing more. + // One page for the slow ticks; the wobble cancels its unsent opening page on reversal. expect(tileBytes).toBe(PAGE_UP); // the first slow tick flushed before the later wobble const primary = makeApp({ other: { mode: 'codex' } }, 'other') as App & { diff --git a/test/terminal-touch-tap.test.ts b/test/terminal-touch-tap.test.ts index d55b2e96..0a467404 100644 --- a/test/terminal-touch-tap.test.ts +++ b/test/terminal-touch-tap.test.ts @@ -853,7 +853,7 @@ describe('terminal touch tap mouse guard', () => { expect(app._wheelScrollLinesFloat({ shiftKey: true, deltaX: -5, deltaY: 0 })).toBe(-0.2); }); - it('wheel: encodes SGR 64/65 ticks, caps per event, and coalesces into one flush', () => { + it('wheel: encodes SGR 64/65 ticks, caps per event, and sends the first report before batching the rest', () => { const { app } = loadTerminalUiHarness(); const sent: Array<{ id: string; data: string }> = []; app.activeSessionId = 'sess-1'; From f2230d5d3d5f6bc536a9f065cb66b4c4f28dcf30 Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Sun, 11 Oct 2026 12:15:21 +0800 Subject: [PATCH 17/96] feat(config): timestamped config backups with a configurable folder and retention The files that hold a user's customization (settings.json, clis.json, custom-model-hosts.json, webhook.json, intents.json, linked-cases.json, push-keys.json, users.json, .env) are plain JSON in the data dir, rewritten by the server, the Settings UI and the CLI, with no copy to go back to. A snapshot is taken at startup and whenever the tracked files' content changes (checked every five minutes, and only written when a digest of the file set differs from the latest snapshot's), into `<data dir>/backups/config` by default or any absolute folder. Retention keeps the newest configBackupKeepCount (1-500) and, when configBackupKeepDays > 0, drops older ones; it only ever deletes folders it made (name pattern and a parseable manifest) and never the newest. GET/POST /api/config-backups list and back up now; POST .../:id/restore verifies every file against the manifest's SHA-256, takes a pre-restore snapshot, then replaces files atomically with mode 0600, writing only allowlisted names. Snapshots are 0700 folders of 0600 files and all routes are admin only in multi-user mode. Settings -> System gets the controls and a Restore list. --- .changeset/18dce807.md | 5 + docs/api-reference.md | 14 + docs/wiki/HTTP-API.md | 3 + src/config-backup.ts | 356 +++++++++++++++++++++++ src/web/public/index.html | 42 +++ src/web/public/settings-ui.js | 77 +++++ src/web/routes/config-backup-routes.ts | 144 +++++++++ src/web/routes/index.ts | 1 + src/web/schemas.ts | 12 + src/web/server.ts | 18 ++ test/config-backup.test.ts | 268 +++++++++++++++++ test/routes/config-backup-routes.test.ts | 121 ++++++++ 12 files changed, 1061 insertions(+) create mode 100644 .changeset/18dce807.md create mode 100644 src/config-backup.ts create mode 100644 src/web/routes/config-backup-routes.ts create mode 100644 test/config-backup.test.ts create mode 100644 test/routes/config-backup-routes.test.ts diff --git a/.changeset/18dce807.md b/.changeset/18dce807.md new file mode 100644 index 00000000..a0974900 --- /dev/null +++ b/.changeset/18dce807.md @@ -0,0 +1,5 @@ +--- +"aicodeman": minor +--- + +Config backups: timestamped snapshots of the files that hold your customization (settings, CLI overrides, custom endpoints, webhook, linked cases and, in multi-user mode, accounts), taken at startup and whenever one changes. On by default into `~/.codeman/backups/config`; the folder and the retention (keep N backups, drop older than N days) are configurable in Settings → System, with Back up now and Restore. Retention only deletes folders Codeman made, never the newest backup. diff --git a/docs/api-reference.md b/docs/api-reference.md index c0350a66..d638fd74 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1152,6 +1152,20 @@ Result (`data`): The result carries server **names** only, never `env` values, `headers` or file content. Each changed file keeps its previous content as `<file>.codeman-bak` (overwritten by each sync); a file that receives servers carrying `env` or `headers` is left mode `0600`. +## Config backups + +Timestamped snapshots of the files that hold what a user has customized (`src/config-backup.ts`). On by default; a snapshot is taken at startup and whenever the tracked files' content changes (checked every 5 minutes), so there is one entry per real change. All three routes are admin only in multi-user mode (`403`), because the files include credentials. Responses carry file names and sizes, never content. + +Tracked files (a fixed list, relative to the data dir): `settings.json`, `clis.json`, `custom-model-hosts.json`, `webhook.json`, `intents.json`, `linked-cases.json`, `push-keys.json`, `users.json`, `.env`. Session state, logs, the hook secret and caches are not. + +Settings (synced, in `settings.json`): `configBackupEnabled` (absent = on), `configBackupDir` (absolute path, `~` expanded; empty = `<data dir>/backups/config`), `configBackupKeepCount` (1–500, default 20), `configBackupKeepDays` (0–3650, default 30; `0` = no age limit). Retention only ever deletes snapshot folders Codeman made there, and never the newest one. Each snapshot is a `0700` folder of `0600` files plus a `manifest.json` with SHA-256 digests. + +| Method | Path | Body | Notes | +| ------ | ----------------------------------- | ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/api/config-backups` | none | `settings` in effect, `dir`, `defaultDir`, `tracked[]` and `backups[]` (`id`, `createdAt`, `reason`, `appVersion`, `files[]`, `bytes`), newest first. | +| `POST` | `/api/config-backups` | none | Snapshot now, even if nothing changed (`reason: manual`), then apply retention. `status` is `created` or `empty` (nothing tracked exists yet). | +| `POST` | `/api/config-backups/:id/restore` | none | Put a snapshot's files back, atomically, after a `pre-restore` snapshot of the current ones. `404` unknown id; `409 CONFLICT` if a file fails its checksum (nothing changed). `restartRequired[]` names files the running server only reloads on restart. | + ## Webhook notifications Posts the Web Push events to ntfy, Slack, Discord or a generic JSON URL (Settings → Notifications). Off by default. The webhook URL is a bearer secret (anyone holding a Slack/Discord URL can post as it), so it lives in `~/.codeman/webhook.json` (0600), is **never returned**, and is kept out of `settings.json`. All three routes answer `403` for a non-admin in multi-user mode. diff --git a/docs/wiki/HTTP-API.md b/docs/wiki/HTTP-API.md index 0f940586..2b5b2f1c 100644 --- a/docs/wiki/HTTP-API.md +++ b/docs/wiki/HTTP-API.md @@ -173,6 +173,9 @@ curl -s "$API/api/subagents" | jq # background agents curl -s "$API/api/search?q=deploy" | jq # cross-session search curl -s "$API/api/mcp-sync" | jq # preview MCP server sync (opt-in: 403 until mcpSyncEnabled is on) curl -s -X POST "$API/api/mcp-sync" | jq # apply it: add missing servers to each CLI config, never edit/remove +curl -s "$API/api/config-backups" | jq # config snapshots: folder, retention, list (admin only in multi-user mode) +curl -s -X POST "$API/api/config-backups" | jq # back up the customized config files now +curl -s -X POST "$API/api/config-backups/cfg-20261011-120000/restore" | jq # put a snapshot back (after a pre-restore copy) # with ID set to a session id: curl -s "$API/api/sessions/$ID/last-response" | jq -r '.data.text' # last answer, from the transcript (claude, codex, deepseek) diff --git a/src/config-backup.ts b/src/config-backup.ts new file mode 100644 index 00000000..84f60be1 --- /dev/null +++ b/src/config-backup.ts @@ -0,0 +1,356 @@ +/** + * @fileoverview Config backups: timestamped snapshots of the files that hold what a user has + * customized, kept in a default-but-configurable folder with configurable retention. + * + * Why it exists: those files are plain JSON in the data dir, rewritten by the server, the Settings UI, + * the CLI and (once, by a mis-configured test run) by things that should never touch them, and there + * was no copy to go back to. A snapshot is taken at startup and whenever the tracked files' content + * changes, so the history is one entry per actual change rather than one per timer tick. + * + * Safety rules the code enforces rather than documents: + * - The tracked file list is a fixed allowlist, never "everything in the data dir". + * - Retention only ever deletes folders this module made (name pattern AND a parseable manifest), so + * pointing the backup folder at a directory that holds other things cannot delete them. + * - The newest snapshot is never pruned, whatever the retention numbers say. + * - A snapshot is built in a temp folder and renamed into place, so a crash leaves no half snapshot. + * - Restore verifies each file against the manifest's SHA-256 and takes a `pre-restore` snapshot first. + * - Snapshot folders are 0700 and their files 0600: some tracked files carry credentials. + * + * Pure of the server: every function takes the directories it works in. + * + * @module config-backup + */ + +import { createHash } from 'node:crypto'; +import { + chmodSync, + existsSync, + mkdirSync, + readdirSync, + readFileSync, + renameSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs'; +import { homedir } from 'node:os'; +import { isAbsolute, join, resolve, sep } from 'node:path'; + +/** + * The files (relative to the data dir) a snapshot holds: what a user configured, not what the server + * regenerates (session state, logs, the hook secret, docker seeds, caches). + * + * ⚠️ `.env`, `users.json`, `custom-model-hosts.json` and `push-keys.json` carry credentials or keys. + * They are already in the data dir with the same permissions; a snapshot folder is 0700 with 0600 + * files, so the default location widens nothing. A custom location is the user's own choice. + */ +export const CONFIG_BACKUP_FILES = [ + 'settings.json', + 'clis.json', + 'custom-model-hosts.json', + 'webhook.json', + 'intents.json', + 'linked-cases.json', + 'push-keys.json', + 'users.json', + '.env', +] as const; + +export const CONFIG_BACKUP_DEFAULTS = { enabled: true, keepCount: 20, keepDays: 30 } as const; +export const CONFIG_BACKUP_MAX_KEEP_COUNT = 500; +export const CONFIG_BACKUP_MAX_KEEP_DAYS = 3650; + +/** `cfg-YYYYMMDD-HHMMSS`, plus `-2`, `-3`… if two land in one second. */ +const SNAPSHOT_ID_RE = /^cfg-\d{8}-\d{6}(?:-\d{1,3})?$/; +const MANIFEST = 'manifest.json'; + +export interface ConfigBackupSettings { + enabled: boolean; + /** Absolute folder, or null for the default (`<data dir>/backups/config`). */ + dir: string | null; + /** Keep at most this many snapshots (newest first). 1–500. */ + keepCount: number; + /** Also drop snapshots older than this many days; 0 = no age limit. */ + keepDays: number; +} + +export interface ConfigBackupFile { + name: string; + bytes: number; + sha256: string; +} + +export interface ConfigBackupManifest { + id: string; + createdAt: number; + reason: string; + appVersion: string; + /** Digest of the set of files, so an unchanged state is not snapshotted twice. */ + hash: string; + files: ConfigBackupFile[]; +} + +export interface ConfigBackupSummary { + id: string; + createdAt: number; + reason: string; + appVersion: string; + files: Array<{ name: string; bytes: number }>; + bytes: number; +} + +const intIn = (value: unknown, min: number, max: number, fallback: number): number => { + const n = typeof value === 'number' ? value : typeof value === 'string' && value.trim() ? Number(value) : NaN; + if (!Number.isFinite(n)) return fallback; + return Math.min(max, Math.max(min, Math.floor(n))); +}; + +/** `~`-expand and require an absolute path; anything else means "use the default". */ +export function normalizeBackupDir(raw: unknown, home: string = homedir()): string | null { + if (typeof raw !== 'string') return null; + let value = raw.trim(); + if (!value || value.includes('\0')) return null; + if (value === '~') value = home; + else if (value.startsWith('~/')) value = join(home, value.slice(2)); + if (!isAbsolute(value)) return null; + return resolve(value); +} + +/** Read the four `configBackup*` settings out of a settings.json object, clamped and defaulted. */ +export function readConfigBackupSettings(raw: Record<string, unknown> | null | undefined): ConfigBackupSettings { + const s = raw ?? {}; + return { + // Absent means ON: the feature protects against an event nobody schedules. + enabled: s.configBackupEnabled !== false, + dir: normalizeBackupDir(s.configBackupDir), + keepCount: intIn(s.configBackupKeepCount, 1, CONFIG_BACKUP_MAX_KEEP_COUNT, CONFIG_BACKUP_DEFAULTS.keepCount), + keepDays: intIn(s.configBackupKeepDays, 0, CONFIG_BACKUP_MAX_KEEP_DAYS, CONFIG_BACKUP_DEFAULTS.keepDays), + }; +} + +export function defaultConfigBackupDir(dataDir: string): string { + return join(dataDir, 'backups', 'config'); +} + +/** + * The folder snapshots go to: the configured one, or the default. A custom folder that IS the data dir + * falls back to the default, because snapshot folders and the live files must not share a directory. + */ +export function resolveConfigBackupDir(settings: ConfigBackupSettings, dataDir: string): string { + const fallback = defaultConfigBackupDir(dataDir); + const dir = settings.dir; + if (!dir) return fallback; + const data = resolve(dataDir); + if (dir === data) return fallback; + return dir; +} + +const sha256 = (data: Buffer | string): string => createHash('sha256').update(data).digest('hex'); + +function stamp(now: number): string { + const d = new Date(now); + const p = (n: number, w = 2) => String(n).padStart(w, '0'); + return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`; +} + +function readManifest(dir: string, id: string): ConfigBackupManifest | null { + if (!SNAPSHOT_ID_RE.test(id)) return null; + try { + const m = JSON.parse(readFileSync(join(dir, id, MANIFEST), 'utf8')) as ConfigBackupManifest; + if (!m || m.id !== id || !Array.isArray(m.files) || typeof m.createdAt !== 'number' || typeof m.hash !== 'string') { + return null; + } + return m; + } catch { + return null; + } +} + +/** Snapshots this module made in `dir`, newest first. Foreign folders and files are ignored. */ +export function listConfigBackups(dir: string): ConfigBackupSummary[] { + let names: string[]; + try { + names = readdirSync(dir); + } catch { + return []; + } + const out: ConfigBackupSummary[] = []; + for (const id of names) { + const m = readManifest(dir, id); + if (!m) continue; + const files = m.files.map((f) => ({ name: f.name, bytes: f.bytes })); + out.push({ + id, + createdAt: m.createdAt, + reason: m.reason, + appVersion: m.appVersion, + files, + bytes: files.reduce((n, f) => n + f.bytes, 0), + }); + } + return out.sort((a, b) => b.createdAt - a.createdAt || (a.id < b.id ? 1 : -1)); +} + +export type ConfigBackupResult = + | { status: 'created'; id: string; files: number; bytes: number } + | { status: 'unchanged'; id: string } + | { status: 'empty' }; + +export interface CreateConfigBackupOptions { + dataDir: string; + backupDir: string; + reason: string; + appVersion?: string; + /** Snapshot even if nothing changed (a manual request, a pre-restore safety copy). */ + force?: boolean; + now?: number; +} + +/** Snapshot the tracked files that exist, unless the latest snapshot already holds exactly this state. */ +export function createConfigBackup(opts: CreateConfigBackupOptions): ConfigBackupResult { + const now = opts.now ?? Date.now(); + const present: Array<{ name: string; data: Buffer; sha: string }> = []; + for (const name of CONFIG_BACKUP_FILES) { + try { + const path = join(opts.dataDir, name); + if (!statSync(path).isFile()) continue; + const data = readFileSync(path); + present.push({ name, data, sha: sha256(data) }); + } catch { + /* absent or unreadable: simply not part of this snapshot */ + } + } + if (present.length === 0) return { status: 'empty' }; + + const hash = sha256(present.map((f) => `${f.name}\0${f.sha}`).join('\n')); + const existing = listConfigBackups(opts.backupDir); + if (!opts.force && existing[0]) { + const latest = readManifest(opts.backupDir, existing[0].id); + if (latest && latest.hash === hash) return { status: 'unchanged', id: latest.id }; + } + + mkdirSync(opts.backupDir, { recursive: true, mode: 0o700 }); + const base = `cfg-${stamp(now)}`; + let id = base; + for (let n = 2; existsSync(join(opts.backupDir, id)); n++) id = `${base}-${n}`; + + const tmp = join(opts.backupDir, `.tmp-${id}-${process.pid}`); + rmSync(tmp, { recursive: true, force: true }); + mkdirSync(tmp, { mode: 0o700 }); + try { + const manifest: ConfigBackupManifest = { + id, + createdAt: now, + reason: opts.reason, + appVersion: opts.appVersion ?? '', + hash, + files: present.map((f) => ({ name: f.name, bytes: f.data.length, sha256: f.sha })), + }; + for (const f of present) writeFileSync(join(tmp, f.name), f.data, { mode: 0o600 }); + writeFileSync(join(tmp, MANIFEST), JSON.stringify(manifest, null, 2) + '\n', { mode: 0o600 }); + renameSync(tmp, join(opts.backupDir, id)); + try { + chmodSync(join(opts.backupDir, id), 0o700); + } catch { + /* best effort on filesystems without modes */ + } + } catch (err) { + rmSync(tmp, { recursive: true, force: true }); + throw err; + } + return { status: 'created', id, files: present.length, bytes: present.reduce((n, f) => n + f.data.length, 0) }; +} + +export interface PruneOptions { + keepCount: number; + keepDays: number; + now?: number; +} + +/** + * Apply retention: keep the newest `keepCount`, and (when `keepDays` > 0) drop anything older than + * that. The newest snapshot always survives. Returns the ids removed. + */ +export function pruneConfigBackups(dir: string, opts: PruneOptions): string[] { + const now = opts.now ?? Date.now(); + const all = listConfigBackups(dir); + const cutoff = opts.keepDays > 0 ? now - opts.keepDays * 86_400_000 : null; + const removed: string[] = []; + all.forEach((b, index) => { + if (index === 0) return; + const tooMany = index >= Math.max(1, opts.keepCount); + const tooOld = cutoff !== null && b.createdAt < cutoff; + if (!tooMany && !tooOld) return; + // listConfigBackups already proved this is one of ours (name pattern + manifest). + const target = join(dir, b.id); + if (!target.startsWith(resolve(dir) + sep)) return; + try { + rmSync(target, { recursive: true, force: true }); + removed.push(b.id); + } catch { + /* leave it; the next pass retries */ + } + }); + return removed; +} + +export class ConfigBackupNotFoundError extends Error {} +export class ConfigBackupCorruptError extends Error {} + +export interface RestoreResult { + restored: string[]; + /** The snapshot taken of the live files before they were overwritten. */ + safetySnapshot: string | null; +} + +/** + * Put a snapshot's files back into the data dir. Every file is checked against the manifest's digest + * BEFORE anything is written, a `pre-restore` snapshot of the live files is taken first, and each + * file is replaced atomically (temp + rename) with mode 0600. + */ +export function restoreConfigBackup(opts: { + dataDir: string; + backupDir: string; + id: string; + appVersion?: string; + now?: number; +}): RestoreResult { + const manifest = readManifest(opts.backupDir, opts.id); + if (!manifest) throw new ConfigBackupNotFoundError(`No config backup named ${opts.id}`); + + const allowed = new Set<string>(CONFIG_BACKUP_FILES); + const loaded: Array<{ name: string; data: Buffer }> = []; + for (const f of manifest.files) { + if (!allowed.has(f.name)) continue; + let data: Buffer; + try { + data = readFileSync(join(opts.backupDir, opts.id, f.name)); + } catch { + throw new ConfigBackupCorruptError(`${f.name} is missing from ${opts.id}`); + } + if (sha256(data) !== f.sha256) + throw new ConfigBackupCorruptError(`${f.name} in ${opts.id} does not match its checksum`); + loaded.push({ name: f.name, data }); + } + if (loaded.length === 0) throw new ConfigBackupCorruptError(`${opts.id} holds no restorable files`); + + const safety = createConfigBackup({ + dataDir: opts.dataDir, + backupDir: opts.backupDir, + reason: 'pre-restore', + appVersion: opts.appVersion, + force: true, + now: opts.now, + }); + + mkdirSync(opts.dataDir, { recursive: true }); + const restored: string[] = []; + for (const f of loaded) { + const target = join(opts.dataDir, f.name); + const tmp = `${target}.restore-${process.pid}`; + writeFileSync(tmp, f.data, { mode: 0o600 }); + renameSync(tmp, target); + restored.push(f.name); + } + return { restored, safetySnapshot: safety.status === 'created' ? safety.id : null }; +} diff --git a/src/web/public/index.html b/src/web/public/index.html index abbd2cd9..cd2509ca 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -3242,6 +3242,48 @@ </div> </div> + <div class="set-group" id="configBackupGroup"> + <div class="set-group-head"><h4>Config backups</h4><span class="set-scope">server</span></div> + <div class="set-group-body"> + <div class="set-row" data-search="config backup snapshot settings clis restore retention keep"> + <div class="set-row-text"> + <span class="set-row-label">Back up my configuration</span> + <span class="set-row-desc">Keeps timestamped copies of your settings, CLI overrides, custom endpoints, webhook, linked cases and (in multi-user mode) accounts. A copy is made at startup and whenever one of them changes. Some of these files hold credentials, so each copy is private to this user (mode 0700/0600).</span> + </div> + <label class="switch switch-sm"><input type="checkbox" id="appSettingsConfigBackup"><span class="slider"></span></label> + </div> + <div class="set-row has-field" data-search="config backup folder location path directory"> + <div class="set-row-text"> + <span class="set-row-label">Backup folder</span> + <span class="set-row-desc">An absolute path (a leading <code>~</code> is expanded). Leave empty for the default shown. Only folders Codeman created there are ever pruned.</span> + </div> + <input type="text" id="appSettingsConfigBackupDir" class="set-input" placeholder="default: ~/.codeman/backups/config" data-i18n-skip> + </div> + <div class="set-row has-field" data-search="config backup retention keep count snapshots"> + <div class="set-row-text"> + <span class="set-row-label">Keep this many backups</span> + <span class="set-row-desc">1 to 500. The newest backup is always kept.</span> + </div> + <input type="number" id="appSettingsConfigBackupKeepCount" class="set-input" min="1" max="500" step="1" placeholder="20"> + </div> + <div class="set-row has-field" data-search="config backup retention keep days age"> + <div class="set-row-text"> + <span class="set-row-label">Delete backups older than (days)</span> + <span class="set-row-desc">0 keeps backups until the count above pushes them out.</span> + </div> + <input type="number" id="appSettingsConfigBackupKeepDays" class="set-input" min="0" max="3650" step="1" placeholder="30"> + </div> + <div class="set-row" data-search="config backup now restore list"> + <div class="set-row-text"> + <span class="set-row-label">Backups</span> + <span class="set-row-desc">Folder and retention changes apply after you save. Restoring puts a backup's files back and makes a copy of the current ones first.</span> + </div> + <button class="btn-toolbar btn-sm btn-primary" id="configBackupNowBtn" onclick="app.backupConfigNow()">Back up now</button> + </div> + <div id="configBackupList" class="set-note" data-i18n-skip>Loading…</div> + </div> + </div> + <div class="set-group"> <div class="set-group-head"><h4>Automation</h4><span class="set-scope">synced</span></div> <div class="set-group-body"> diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index d1343edf..464134b4 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -551,6 +551,12 @@ Object.assign(CodemanApp.prototype, { this._mcpSyncSavedOn = settings.mcpSyncEnabled === true; document.getElementById('appSettingsMcpSync').checked = this._mcpSyncSavedOn; this.applyMcpSyncVisibility(); + // Config backups: absent means ON, 20 copies, 30 days. + document.getElementById('appSettingsConfigBackup').checked = settings.configBackupEnabled !== false; + document.getElementById('appSettingsConfigBackupDir').value = settings.configBackupDir || ''; + document.getElementById('appSettingsConfigBackupKeepCount').value = settings.configBackupKeepCount ?? 20; + document.getElementById('appSettingsConfigBackupKeepDays').value = settings.configBackupKeepDays ?? 30; + this.loadConfigBackups(); this._applyDoctorAdminGate(); this.loadWebhook(); // Read My Mind: synced, default OFF (opt-in; capture + prediction cost real tokens). @@ -1365,6 +1371,73 @@ Object.assign(CodemanApp.prototype, { }, /** Preview (apply=false) or run (apply=true) the MCP server sync across enabled CLIs. */ + // ── Config backups ────────────────────────────────────────────────────────── + // Server-side files, so these read the SAVED settings: a folder or retention edit applies after Save. + + /** Fill the backup list, and show the default folder as the input's placeholder. */ + async loadConfigBackups() { + const out = this.$('configBackupList'); + if (!out) return; + const res = await this._api('/api/config-backups'); + let body = null; + try { body = res ? await res.json() : null; } catch { /* fall through */ } + if (!res || !res.ok || !body || body.success === false) { + out.textContent = body?.error || 'Backups are not available here (admin only in multi-user mode).'; + return; + } + const data = body.data; + const dirInput = this.$('appSettingsConfigBackupDir') || document.getElementById('appSettingsConfigBackupDir'); + if (dirInput && data.defaultDir) dirInput.placeholder = `default: ${data.defaultDir}`; + if (!data.backups.length) { + out.innerHTML = `No backups yet in <code>${escapeHtml(data.dir)}</code>.`; + return; + } + const kb = (n) => (n < 1024 ? `${n} B` : `${(n / 1024).toFixed(1)} KB`); + const rows = data.backups.map((b) => { + const when = new Date(b.createdAt).toLocaleString(); + const names = b.files.map((f) => escapeHtml(f.name)).join(', '); + return `<li><b>${escapeHtml(when)}</b> <span class="set-row-desc">(${escapeHtml(b.reason)}, ${kb(b.bytes)})</span> + <button class="btn-toolbar btn-sm" onclick="app.restoreConfigBackup('${escapeHtml(b.id)}')">Restore</button> + <div class="set-row-desc">${names}</div></li>`; + }); + out.innerHTML = `<div>${data.backups.length} backup${data.backups.length === 1 ? '' : 's'} in <code>${escapeHtml(data.dir)}</code></div><ul>${rows.join('')}</ul>`; + }, + + async backupConfigNow() { + const btn = this.$('configBackupNowBtn'); + if (btn) btn.disabled = true; + try { + const res = await this._apiPost('/api/config-backups', {}); + let body = null; + try { body = res ? await res.json() : null; } catch { /* fall through */ } + if (!res || !res.ok || !body || body.success === false) { + this.showToast(body?.error || 'Backup failed', 'error'); + } else if (body.data.status === 'empty') { + this.showToast('Nothing to back up yet', 'warning'); + } else { + this.showToast(`Backed up ${body.data.files} file${body.data.files === 1 ? '' : 's'}`, 'success'); + } + } finally { + if (btn) btn.disabled = false; + this.loadConfigBackups(); + } + }, + + async restoreConfigBackup(id) { + if (!confirm('Restore this backup? Its files replace the current ones (a copy of the current files is made first). Some files only take effect after a restart.')) return; + const res = await this._apiPost(`/api/config-backups/${encodeURIComponent(id)}/restore`, {}); + let body = null; + try { body = res ? await res.json() : null; } catch { /* fall through */ } + if (!res || !res.ok || !body || body.success === false) { + this.showToast(body?.error || 'Restore failed', 'error'); + return; + } + const d = body.data; + const restart = d.restartRequired?.length ? ` Restart Codeman to apply: ${d.restartRequired.join(', ')}.` : ''; + this.showToast(`Restored ${d.restored.length} file${d.restored.length === 1 ? '' : 's'}. Reload this page to see settings.${restart}`, 'success'); + this.loadConfigBackups(); + }, + async mcpSync(apply) { const out = this.$('mcpSyncResult'); const show = (html, hint = '') => { @@ -2737,6 +2810,10 @@ Object.assign(CodemanApp.prototype, { customModelEndpointsEnabled: document.getElementById('appSettingsCustomModelEndpoints').checked, cliManagementEnabled: document.getElementById('appSettingsCliManagement').checked, mcpSyncEnabled: document.getElementById('appSettingsMcpSync').checked, + configBackupEnabled: document.getElementById('appSettingsConfigBackup').checked, + configBackupDir: document.getElementById('appSettingsConfigBackupDir').value.trim(), + configBackupKeepCount: Math.min(500, Math.max(1, parseInt(document.getElementById('appSettingsConfigBackupKeepCount').value, 10) || 20)), + configBackupKeepDays: Math.min(3650, Math.max(0, parseInt(document.getElementById('appSettingsConfigBackupKeepDays').value, 10) || 0)), readMyMindEnabled: document.getElementById('appSettingsReadMyMind').checked, ultracodeFloatingWindows: document.getElementById('appSettingsUltracodeFloatingWindows').checked, showMultiMonitorButton: document.getElementById('appSettingsShowMultiMonitorButton').checked, diff --git a/src/web/routes/config-backup-routes.ts b/src/web/routes/config-backup-routes.ts new file mode 100644 index 00000000..8ee681e1 --- /dev/null +++ b/src/web/routes/config-backup-routes.ts @@ -0,0 +1,144 @@ +/** + * @fileoverview Config backups (src/config-backup.ts). + * + * GET /api/config-backups — settings in effect, the folder, and the snapshots in it. + * POST /api/config-backups — snapshot now (a manual request always makes one). + * POST /api/config-backups/:id/restore — put a snapshot's files back (after a `pre-restore` snapshot). + * + * The files include credentials (`.env`, `users.json`, endpoint keys), so in multi-user mode all three + * are admin only. Responses carry file NAMES and sizes, never file content. Restore replaces files the + * running server may have cached: `restartRequired` says which ones only apply after a restart. + */ + +import { createRequire } from 'node:module'; +import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; +import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js'; +import { isAdmin, readJsonConfig, SETTINGS_PATH } from '../route-helpers.js'; +import { isMultiUserMode } from '../../config/multiuser.js'; +import { getDataDir } from '../../config/instance.js'; +import { + CONFIG_BACKUP_FILES, + ConfigBackupCorruptError, + ConfigBackupNotFoundError, + createConfigBackup, + defaultConfigBackupDir, + listConfigBackups, + pruneConfigBackups, + readConfigBackupSettings, + resolveConfigBackupDir, + restoreConfigBackup, + type ConfigBackupResult, + type ConfigBackupSettings, + type ConfigBackupSummary, +} from '../../config-backup.js'; + +// Only recorded in each manifest; a missing package.json just leaves it blank. +const APP_VERSION: string = (() => { + try { + return (createRequire(import.meta.url)('../../../package.json') as { version?: string }).version ?? ''; + } catch { + return ''; + } +})(); + +/** Files whose running-process copy only reloads on restart (the rest are read per request). */ +const RESTART_REQUIRED = new Set<string>(['clis.json', 'users.json', '.env', 'push-keys.json']); + +export interface ConfigBackupStatus { + settings: ConfigBackupSettings; + dir: string; + defaultDir: string; + tracked: readonly string[]; + backups: ConfigBackupSummary[]; +} + +export async function readEffectiveBackupSettings(): Promise<{ settings: ConfigBackupSettings; dir: string }> { + const raw = await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'settings.json', {}); + const settings = readConfigBackupSettings(raw); + return { settings, dir: resolveConfigBackupDir(settings, getDataDir()) }; +} + +/** Snapshot (when changed) and apply retention; shared by the timer and the manual route. */ +export async function runConfigBackup( + reason: string, + opts: { force?: boolean } = {} +): Promise<{ result: ConfigBackupResult; removed: string[] } | null> { + const { settings, dir } = await readEffectiveBackupSettings(); + if (!settings.enabled && !opts.force) return null; + const result = createConfigBackup({ + dataDir: getDataDir(), + backupDir: dir, + reason, + appVersion: APP_VERSION, + force: opts.force, + }); + const removed = pruneConfigBackups(dir, { keepCount: settings.keepCount, keepDays: settings.keepDays }); + return { result, removed }; +} + +export function registerConfigBackupRoutes(app: FastifyInstance): void { + const deny = (req: FastifyRequest, reply: FastifyReply): ApiResponse<never> | null => { + if (isMultiUserMode() && !isAdmin(req)) { + reply.code(403); + return createErrorResponse(ApiErrorCode.FORBIDDEN, 'Admin only in multi-user mode'); + } + return null; + }; + + app.get('/api/config-backups', async (req, reply): Promise<ApiResponse<ConfigBackupStatus>> => { + const denied = deny(req, reply); + if (denied) return denied; + const { settings, dir } = await readEffectiveBackupSettings(); + return { + success: true, + data: { + settings, + dir, + defaultDir: defaultConfigBackupDir(getDataDir()), + tracked: CONFIG_BACKUP_FILES, + backups: listConfigBackups(dir), + }, + }; + }); + + app.post('/api/config-backups', async (req, reply) => { + const denied = deny(req, reply); + if (denied) return denied; + try { + const out = await runConfigBackup('manual', { force: true }); + return { success: true, data: { ...out!.result, removed: out!.removed } }; + } catch (err) { + reply.code(500); + return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Backup failed: ${getErrorMessage(err)}`); + } + }); + + app.post('/api/config-backups/:id/restore', async (req, reply) => { + const denied = deny(req, reply); + if (denied) return denied; + const { id } = req.params as { id: string }; + try { + const { dir } = await readEffectiveBackupSettings(); + const out = restoreConfigBackup({ dataDir: getDataDir(), backupDir: dir, id, appVersion: APP_VERSION }); + return { + success: true, + data: { + restored: out.restored, + safetySnapshot: out.safetySnapshot, + restartRequired: out.restored.filter((n) => RESTART_REQUIRED.has(n)), + }, + }; + } catch (err) { + if (err instanceof ConfigBackupNotFoundError) { + reply.code(404); + return createErrorResponse(ApiErrorCode.NOT_FOUND, err.message); + } + if (err instanceof ConfigBackupCorruptError) { + reply.code(409); + return createErrorResponse(ApiErrorCode.CONFLICT, `${err.message}. Nothing was changed.`); + } + reply.code(500); + return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Restore failed: ${getErrorMessage(err)}`); + } + }); +} diff --git a/src/web/routes/index.ts b/src/web/routes/index.ts index aa1d721f..ad34f34d 100644 --- a/src/web/routes/index.ts +++ b/src/web/routes/index.ts @@ -32,6 +32,7 @@ export { registerVoiceRoutes } from './voice-routes.js'; export { registerWebviewRoutes, tryWebviewRefererFallback } from './webview-routes.js'; export { registerTabLayoutRoutes } from './tab-layout-routes.js'; export { registerMcpSyncRoutes } from './mcp-sync-routes.js'; +export { registerConfigBackupRoutes, runConfigBackup } from './config-backup-routes.js'; export { registerWebhookRoutes } from './webhook-routes.js'; export { registerDoctorRoutes } from './doctor-routes.js'; export { diff --git a/src/web/schemas.ts b/src/web/schemas.ts index ce577394..bf235521 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -1521,6 +1521,18 @@ export const SettingsUpdateSchema = z * opt-in. While OFF, GET/POST /api/mcp-sync answer 403 and the Settings controls are hidden. */ mcpSyncEnabled: z.boolean().optional(), + /** + * Config backups (src/config-backup.ts): timestamped snapshots of the customized config files, + * taken at startup and whenever their content changes. SYNCED. Absent means ON (the feature + * protects against an event nobody schedules); the folder defaults to `<data dir>/backups/config`. + */ + configBackupEnabled: z.boolean().optional(), + /** Absolute folder (a leading `~` is expanded); empty or absent = the default folder. */ + configBackupDir: z.string().max(1024).optional(), + /** Keep at most this many snapshots. */ + configBackupKeepCount: z.number().int().min(1).max(500).optional(), + /** Also drop snapshots older than this many days; 0 = no age limit. */ + configBackupKeepDays: z.number().int().min(0).max(3650).optional(), /** * Read My Mind predictor model override. Empty/absent = the AI-checker * default (opus: prediction quality is the product and it runs only on an diff --git a/src/web/server.ts b/src/web/server.ts index f9bb8b47..095f1fbd 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -208,6 +208,8 @@ import { registerWebviewRoutes, registerTabLayoutRoutes, registerMcpSyncRoutes, + registerConfigBackupRoutes, + runConfigBackup, registerWebhookRoutes, registerDoctorRoutes, registerCustomModelRoutes, @@ -229,6 +231,9 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); // while capping growth of `sseClientsById` and blocking pathological inputs. const SSE_CLIENT_ID_RE = /^[A-Za-z0-9_-]{8,64}$/; const CODEX_USAGE_POLL_INTERVAL_MS = 5 * 60_000; +// Config backups: one cheap read-and-hash of a handful of small files; a snapshot is written only when +// their content changed since the latest one, so this is one entry per real change, not per tick. +const CONFIG_BACKUP_INTERVAL_MS = 5 * 60_000; const CUSTOM_MODEL_REDISCOVER_INTERVAL_MS = 5 * 60_000; // Much shorter than the model-LIST refresh above on purpose: this catches an actual // eviction (a session's model no longer loaded, silently swapped out by another @@ -1179,6 +1184,7 @@ export class WebServer extends EventEmitter { registerWebviewRoutes(this.app, ctx, this.basePath); registerTabLayoutRoutes(this.app, ctx); registerMcpSyncRoutes(this.app); + registerConfigBackupRoutes(this.app); registerWebhookRoutes(this.app, { notifier: this.webhookNotifier, configDir: getDataDir(), @@ -3067,6 +3073,18 @@ export class WebServer extends EventEmitter { // $CODEMAN_HOOK_SECRET_FILE — hook curls cat that path at execution time. getHookSecret(); + // Config backups: a snapshot at start (the state the process found) and then on change. + if (!this.testMode) { + const backup = (reason: string) => + void runConfigBackup(reason).catch((err) => + console.warn('[config-backup] snapshot failed:', err instanceof Error ? err.message : err) + ); + backup('startup'); + this.cleanup.setInterval(() => backup('auto'), CONFIG_BACKUP_INTERVAL_MS, { + description: 'Config backup', + }); + } + // Main Codex subscription limits come from the signed-in local CLI. Keep // this read-only and host-scoped; multi-user SSE routing makes it admin-only. if (!this.testMode) { diff --git a/test/config-backup.test.ts b/test/config-backup.test.ts new file mode 100644 index 00000000..1410188e --- /dev/null +++ b/test/config-backup.test.ts @@ -0,0 +1,268 @@ +/** + * @fileoverview Config backups (src/config-backup.ts): what is tracked, when a snapshot is made, + * how retention prunes, and that restore verifies before it writes. Pure fs over temp dirs; + * port: N/A. + */ +import { + chmodSync, + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + rmSync, + statSync, + writeFileSync, +} from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { + CONFIG_BACKUP_FILES, + ConfigBackupCorruptError, + ConfigBackupNotFoundError, + createConfigBackup, + defaultConfigBackupDir, + listConfigBackups, + normalizeBackupDir, + pruneConfigBackups, + readConfigBackupSettings, + resolveConfigBackupDir, + restoreConfigBackup, +} from '../src/config-backup.js'; + +let root: string; +let data: string; +let backups: string; +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'cfg-backup-')); + data = join(root, 'data'); + backups = join(root, 'backups'); + mkdirSync(data); +}); +afterEach(() => rmSync(root, { recursive: true, force: true })); + +const put = (name: string, text: string) => writeFileSync(join(data, name), text); +const DAY = 86_400_000; +const T0 = Date.UTC(2026, 9, 1, 12, 0, 0); + +describe('settings', () => { + it('defaults to ON, 20 copies, 30 days, default folder', () => { + expect(readConfigBackupSettings({})).toEqual({ enabled: true, dir: null, keepCount: 20, keepDays: 30 }); + expect(readConfigBackupSettings(null).enabled).toBe(true); + }); + + it('only an explicit false turns it off, and numbers are clamped to their ranges', () => { + expect(readConfigBackupSettings({ configBackupEnabled: false }).enabled).toBe(false); + const s = readConfigBackupSettings({ configBackupKeepCount: 9999, configBackupKeepDays: -3 }); + expect(s.keepCount).toBe(500); + expect(s.keepDays).toBe(0); + expect(readConfigBackupSettings({ configBackupKeepCount: 0 }).keepCount).toBe(1); + expect(readConfigBackupSettings({ configBackupKeepCount: 'abc' }).keepCount).toBe(20); + expect(readConfigBackupSettings({ configBackupKeepCount: 7.9 }).keepCount).toBe(7); + }); + + it('expands ~ and refuses anything that is not an absolute path', () => { + expect(normalizeBackupDir('~/cfg', '/home/u')).toBe('/home/u/cfg'); + expect(normalizeBackupDir('/mnt/backups/codeman', '/home/u')).toBe('/mnt/backups/codeman'); + for (const bad of ['', ' ', 'relative/dir', './here', undefined, 42, '/x\0y']) { + expect(normalizeBackupDir(bad as never, '/home/u'), String(bad)).toBeNull(); + } + }); + + it('uses <data dir>/backups/config by default and never the data dir itself', () => { + expect(resolveConfigBackupDir(readConfigBackupSettings({}), '/d')).toBe(defaultConfigBackupDir('/d')); + expect(resolveConfigBackupDir(readConfigBackupSettings({ configBackupDir: '/elsewhere' }), '/d')).toBe( + '/elsewhere' + ); + expect(resolveConfigBackupDir(readConfigBackupSettings({ configBackupDir: '/d' }), '/d')).toBe( + defaultConfigBackupDir('/d') + ); + }); +}); + +describe('createConfigBackup', () => { + it('snapshots only the tracked files that exist, with a manifest', () => { + put('settings.json', '{"a":1}'); + put('clis.json', '{"clis":{}}'); + put('state.json', '{"runtime":true}'); // not config: must not be copied + put('hook-secret', 'x'); // regenerated: must not be copied + const r = createConfigBackup({ + dataDir: data, + backupDir: backups, + reason: 'startup', + appVersion: '1.2.3', + now: T0, + }); + expect(r.status).toBe('created'); + const [b] = listConfigBackups(backups); + expect(b.files.map((f) => f.name).sort()).toEqual(['clis.json', 'settings.json']); + expect(b.reason).toBe('startup'); + expect(b.appVersion).toBe('1.2.3'); + expect(b.createdAt).toBe(T0); + expect(existsSync(join(backups, b.id, 'state.json'))).toBe(false); + expect(readFileSync(join(backups, b.id, 'settings.json'), 'utf8')).toBe('{"a":1}'); + }); + + it('tracks a fixed allowlist', () => { + expect([...CONFIG_BACKUP_FILES]).toContain('settings.json'); + expect([...CONFIG_BACKUP_FILES]).not.toContain('state.json'); + expect([...CONFIG_BACKUP_FILES]).not.toContain('hook-secret'); + }); + + it('makes no second snapshot while nothing changed, and one when something does', () => { + put('settings.json', '{"a":1}'); + const first = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 }); + const again = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + 1000 }); + expect(again).toEqual({ status: 'unchanged', id: (first as { id: string }).id }); + expect(listConfigBackups(backups)).toHaveLength(1); + + put('settings.json', '{"a":2}'); + expect(createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + 2000 }).status).toBe( + 'created' + ); + expect(listConfigBackups(backups)).toHaveLength(2); + }); + + it('force makes a snapshot even when unchanged, and two in one second get distinct ids', () => { + put('settings.json', '{}'); + createConfigBackup({ dataDir: data, backupDir: backups, reason: 'a', now: T0 }); + const forced = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'manual', force: true, now: T0 }); + expect(forced.status).toBe('created'); + const ids = listConfigBackups(backups).map((b) => b.id); + expect(new Set(ids).size).toBe(2); + }); + + it('reports empty, creating nothing, when no tracked file exists', () => { + expect(createConfigBackup({ dataDir: data, backupDir: backups, reason: 'x' })).toEqual({ status: 'empty' }); + expect(existsSync(backups)).toBe(false); + }); + + it('keeps snapshots private: 0700 folder and 0600 files', () => { + put('settings.json', '{}'); + put('.env', 'CODEMAN_PASSWORD=secret'); + const r = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'x', now: T0 }); + const id = (r as { id: string }).id; + expect(statSync(join(backups, id)).mode & 0o777).toBe(0o700); + expect(statSync(join(backups, id, '.env')).mode & 0o777).toBe(0o600); + expect(statSync(join(backups, id, 'manifest.json')).mode & 0o777).toBe(0o600); + }); + + it('leaves no temp folder behind', () => { + put('settings.json', '{}'); + createConfigBackup({ dataDir: data, backupDir: backups, reason: 'x', now: T0 }); + const stray = readdirSync(backups).filter((n) => n.startsWith('.tmp-')); + expect(stray).toEqual([]); + }); +}); + +describe('retention', () => { + const makeN = (n: number) => { + for (let i = 0; i < n; i++) { + put('settings.json', JSON.stringify({ i })); + createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + i * DAY }); + } + }; + + it('keeps the newest keepCount', () => { + makeN(6); + const removed = pruneConfigBackups(backups, { keepCount: 3, keepDays: 0, now: T0 + 6 * DAY }); + expect(removed).toHaveLength(3); + const left = listConfigBackups(backups); + expect(left).toHaveLength(3); + expect(left.map((b) => b.createdAt)).toEqual([T0 + 5 * DAY, T0 + 4 * DAY, T0 + 3 * DAY]); + }); + + it('drops anything older than keepDays, but never the newest', () => { + makeN(4); // T0 .. T0+3d + // At T0+100d everything is older than 30 days: the newest still survives. + const removed = pruneConfigBackups(backups, { keepCount: 50, keepDays: 30, now: T0 + 100 * DAY }); + expect(removed).toHaveLength(3); + expect(listConfigBackups(backups).map((b) => b.createdAt)).toEqual([T0 + 3 * DAY]); + }); + + it('applies both limits together and keepDays 0 means no age limit', () => { + makeN(5); + expect(pruneConfigBackups(backups, { keepCount: 50, keepDays: 0, now: T0 + 999 * DAY })).toEqual([]); + const removed = pruneConfigBackups(backups, { keepCount: 4, keepDays: 3, now: T0 + 5 * DAY }); + // Newest first: T0+4d, +3d, +2d, +1d, T0. The oldest is over the count AND past the cutoff + // (T0+2d); T0+1d is past the cutoff; T0+2d sits exactly on it and is kept. + expect(removed.length).toBe(2); + expect(listConfigBackups(backups).map((b) => b.createdAt)).toEqual([T0 + 4 * DAY, T0 + 3 * DAY, T0 + 2 * DAY]); + }); + + it('never deletes a folder it did not make', () => { + makeN(3); + mkdirSync(join(backups, 'photos')); + writeFileSync(join(backups, 'photos', 'a.jpg'), 'x'); + mkdirSync(join(backups, 'cfg-20200101-000000')); // right name, no manifest: not ours + writeFileSync(join(backups, 'notes.txt'), 'keep'); + pruneConfigBackups(backups, { keepCount: 1, keepDays: 1, now: T0 + 500 * DAY }); + expect(existsSync(join(backups, 'photos', 'a.jpg'))).toBe(true); + expect(existsSync(join(backups, 'cfg-20200101-000000'))).toBe(true); + expect(existsSync(join(backups, 'notes.txt'))).toBe(true); + expect(listConfigBackups(backups)).toHaveLength(1); + }); + + it('does nothing for a folder that does not exist', () => { + expect(pruneConfigBackups(join(root, 'nope'), { keepCount: 1, keepDays: 1 })).toEqual([]); + }); +}); + +describe('restoreConfigBackup', () => { + it('puts the files back, atomically, after taking a pre-restore snapshot', () => { + put('settings.json', '{"good":true}'); + put('clis.json', '{"clis":{"grok":{"enabled":true}}}'); + const made = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 }) as { id: string }; + + put('settings.json', '{"polluted":true}'); + rmSync(join(data, 'clis.json')); + + const r = restoreConfigBackup({ dataDir: data, backupDir: backups, id: made.id, now: T0 + 1000 }); + expect(r.restored.sort()).toEqual(['clis.json', 'settings.json']); + expect(readFileSync(join(data, 'settings.json'), 'utf8')).toBe('{"good":true}'); + expect(readFileSync(join(data, 'clis.json'), 'utf8')).toBe('{"clis":{"grok":{"enabled":true}}}'); + expect(statSync(join(data, 'settings.json')).mode & 0o777).toBe(0o600); + + // The state it replaced is recoverable. + expect(r.safetySnapshot).toBeTruthy(); + const safety = listConfigBackups(backups).find((b) => b.id === r.safetySnapshot)!; + expect(safety.reason).toBe('pre-restore'); + expect(readFileSync(join(backups, safety.id, 'settings.json'), 'utf8')).toBe('{"polluted":true}'); + }); + + it('refuses a snapshot whose file no longer matches its checksum, changing nothing', () => { + put('settings.json', '{"good":true}'); + const made = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 }) as { id: string }; + chmodSync(join(backups, made.id, 'settings.json'), 0o600); + writeFileSync(join(backups, made.id, 'settings.json'), '{"tampered":true}'); + put('settings.json', '{"live":true}'); + expect(() => restoreConfigBackup({ dataDir: data, backupDir: backups, id: made.id })).toThrow( + ConfigBackupCorruptError + ); + expect(readFileSync(join(data, 'settings.json'), 'utf8')).toBe('{"live":true}'); + expect(listConfigBackups(backups)).toHaveLength(1); // no pre-restore snapshot for a refused restore + }); + + it('rejects unknown ids and anything that is not a snapshot name (no path traversal)', () => { + put('settings.json', '{}'); + createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 }); + for (const id of ['cfg-19990101-000000', '../data', '..', 'photos', 'cfg-20261001-120000/../../x']) { + expect(() => restoreConfigBackup({ dataDir: data, backupDir: backups, id }), id).toThrow( + ConfigBackupNotFoundError + ); + } + }); + + it('only ever writes allowlisted names, even if a manifest lists others', () => { + put('settings.json', '{"a":1}'); + const made = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 }) as { id: string }; + const mpath = join(backups, made.id, 'manifest.json'); + const m = JSON.parse(readFileSync(mpath, 'utf8')); + m.files.push({ name: '../escape.json', bytes: 1, sha256: 'x' }, { name: 'state.json', bytes: 1, sha256: 'x' }); + writeFileSync(mpath, JSON.stringify(m)); + restoreConfigBackup({ dataDir: data, backupDir: backups, id: made.id }); + expect(existsSync(join(root, 'escape.json'))).toBe(false); + expect(existsSync(join(data, 'state.json'))).toBe(false); + }); +}); diff --git a/test/routes/config-backup-routes.test.ts b/test/routes/config-backup-routes.test.ts new file mode 100644 index 00000000..b8b4501b --- /dev/null +++ b/test/routes/config-backup-routes.test.ts @@ -0,0 +1,121 @@ +/** + * @fileoverview /api/config-backups: list, back up now, restore. Settings are read fresh from + * settings.json, so a folder or retention change applies on the next request. + * + * ⚠️ test/setup.ts gives the whole FILE one temp HOME (and so one data dir). Port: N/A (inject()). + */ +import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { createRouteTestHarness } from './_route-test-utils.js'; +import { registerConfigBackupRoutes } from '../../src/web/routes/config-backup-routes.js'; +import { SETTINGS_PATH } from '../../src/web/route-helpers.js'; +import { getDataDir } from '../../src/config/instance.js'; +import { defaultConfigBackupDir } from '../../src/config-backup.js'; + +let custom: string; +const settings = (extra: Record<string, unknown> = {}) => { + mkdirSync(dirname(SETTINGS_PATH), { recursive: true }); + writeFileSync(SETTINGS_PATH, JSON.stringify({ displayName: 'before', ...extra })); +}; + +beforeEach(() => { + custom = mkdtempSync(join(tmpdir(), 'cfg-backup-route-')); + rmSync(defaultConfigBackupDir(getDataDir()), { recursive: true, force: true }); + settings(); +}); +afterEach(() => { + delete process.env.CODEMAN_MULTIUSER; + rmSync(custom, { recursive: true, force: true }); + rmSync(SETTINGS_PATH, { force: true }); + rmSync(defaultConfigBackupDir(getDataDir()), { recursive: true, force: true }); +}); + +describe('/api/config-backups', () => { + it('lists the effective settings, the default folder and the tracked files', async () => { + const { app } = await createRouteTestHarness(registerConfigBackupRoutes); + const res = await app.inject({ method: 'GET', url: '/api/config-backups' }); + expect(res.statusCode).toBe(200); + const d = res.json().data; + expect(d.settings).toEqual({ enabled: true, dir: null, keepCount: 20, keepDays: 30 }); + expect(d.dir).toBe(defaultConfigBackupDir(getDataDir())); + expect(d.tracked).toContain('settings.json'); + expect(d.backups).toEqual([]); + }); + + it('backs up now into the default folder and lists it', async () => { + const { app } = await createRouteTestHarness(registerConfigBackupRoutes); + const made = await app.inject({ method: 'POST', url: '/api/config-backups' }); + expect(made.statusCode).toBe(200); + expect(made.json().data.status).toBe('created'); + const list = (await app.inject({ method: 'GET', url: '/api/config-backups' })).json().data; + expect(list.backups).toHaveLength(1); + expect(list.backups[0].reason).toBe('manual'); + expect(list.backups[0].files.map((f: { name: string }) => f.name)).toContain('settings.json'); + // Names and sizes only, never content. + expect(JSON.stringify(list)).not.toContain('before'); + }); + + it('follows a custom folder and retention from settings on the next request', async () => { + const dir = join(custom, 'snap'); + settings({ configBackupDir: dir, configBackupKeepCount: 2, configBackupKeepDays: 0 }); + const { app } = await createRouteTestHarness(registerConfigBackupRoutes); + for (let i = 0; i < 4; i++) { + writeFileSync(SETTINGS_PATH, JSON.stringify({ configBackupDir: dir, configBackupKeepCount: 2, i })); + await app.inject({ method: 'POST', url: '/api/config-backups' }); + await new Promise((r) => setTimeout(r, 5)); + } + const d = (await app.inject({ method: 'GET', url: '/api/config-backups' })).json().data; + expect(d.dir).toBe(dir); + expect(d.backups).toHaveLength(2); + expect(existsSync(defaultConfigBackupDir(getDataDir()))).toBe(false); + }); + + it('restores a backup, reports what needs a restart and keeps a pre-restore copy', async () => { + const { app } = await createRouteTestHarness(registerConfigBackupRoutes); + writeFileSync(join(dirname(SETTINGS_PATH), 'clis.json'), '{"schemaVersion":1,"clis":{}}', { mode: 0o600 }); + const id = (await app.inject({ method: 'POST', url: '/api/config-backups' })).json().data.id; + writeFileSync(SETTINGS_PATH, JSON.stringify({ displayName: 'polluted' })); + writeFileSync(join(dirname(SETTINGS_PATH), 'clis.json'), '{"clis":{"evil":{}}}'); + + const res = await app.inject({ method: 'POST', url: `/api/config-backups/${id}/restore` }); + expect(res.statusCode).toBe(200); + const d = res.json().data; + expect(d.restored).toEqual(expect.arrayContaining(['settings.json', 'clis.json'])); + expect(d.restartRequired).toContain('clis.json'); + expect(d.restartRequired).not.toContain('settings.json'); + expect(d.safetySnapshot).toBeTruthy(); + expect(JSON.parse(readFileSync(SETTINGS_PATH, 'utf8')).displayName).toBe('before'); + }); + + it('answers 404 for an unknown backup and 404 for a traversal attempt', async () => { + const { app } = await createRouteTestHarness(registerConfigBackupRoutes); + for (const id of ['cfg-19990101-000000', '..%2F..%2Fetc', 'photos']) { + const res = await app.inject({ method: 'POST', url: `/api/config-backups/${id}/restore` }); + expect(res.statusCode, id).toBe(404); + } + }); + + it('is admin only in multi-user mode: a non-admin is refused on every verb', async () => { + process.env.CODEMAN_MULTIUSER = '1'; + const { app } = await createRouteTestHarness(registerConfigBackupRoutes, { + authUser: { username: 'bob', role: 'user' }, + }); + for (const [method, url] of [ + ['GET', '/api/config-backups'], + ['POST', '/api/config-backups'], + ['POST', '/api/config-backups/cfg-20261001-120000/restore'], + ] as const) { + expect((await app.inject({ method, url })).statusCode, `${method} ${url}`).toBe(403); + } + }); + + it('multi-user: an admin is allowed', async () => { + process.env.CODEMAN_MULTIUSER = '1'; + const { app } = await createRouteTestHarness(registerConfigBackupRoutes, { + authUser: { username: 'root', role: 'admin' }, + }); + expect((await app.inject({ method: 'GET', url: '/api/config-backups' })).json().success).toBe(true); + }); +}); From 3756f8b5c24d88c0be40df7ab6ceeba23c40963c Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Sun, 11 Oct 2026 12:51:55 +0800 Subject: [PATCH 18/96] docs(config-backups): document config backups in full, and track the remote/docker/webview files - New wiki page Config-Backups: what is backed up (and what is not, and why), when a backup is made, where and under what name (cfg-YYYYMMDD-HHMMSS, manifest.json, reasons), retention and its guarantees, the Settings controls, restore (verify, pre-restore copy, atomic write, which files need a restart), security, the HTTP API and troubleshooting. - Linked from the Home index and sidebar; Settings Reference, Troubleshooting ("my settings were reset"), Security and Core Concepts updated; README and CLAUDE.md entries; the api-reference section now states the naming convention and the full tracked list. - Found while documenting: remote-hosts.json, docker-hosts.json, docker-cases.json and webviews.json are user configuration too and were not tracked; they are now. state.json stays out on purpose (rewritten per session event, also holds cron jobs and respawn presets) and the docs say so. --- CLAUDE.md | 2 + README.md | 1 + docs/api-reference.md | 4 +- docs/wiki/Config-Backups.md | 169 ++++++++++++++++++++++++++++++++ docs/wiki/Core-Concepts.md | 1 + docs/wiki/Home.md | 1 + docs/wiki/Security.md | 5 + docs/wiki/Settings-Reference.md | 8 ++ docs/wiki/Troubleshooting.md | 17 ++++ docs/wiki/_Sidebar.md | 1 + src/config-backup.ts | 7 ++ 11 files changed, 214 insertions(+), 2 deletions(-) create mode 100644 docs/wiki/Config-Backups.md diff --git a/CLAUDE.md b/CLAUDE.md index bb78756b..d0f72db6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -156,6 +156,8 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Auto-resume on usage limit** (opt-in per session, top of the Respawn tab): when Claude halts on a subscription limit, `usage-limit-patterns.ts` (pure, unit-tested) parses the reset time and `SessionAutoOps` arms a timer for reset+2min, then sends Esc + `continue`. ⚠️ Respawn cycles are blocked while paused (`isLimitPaused` guard in `onIdleDetected`), which is what prevents `/clear` from wiping the paused conversation. Claude-mode only. → [architecture-invariants#auto-resume-on-usage-limit](docs/architecture-invariants.md#auto-resume-on-usage-limit) +**Config backups** (`src/config-backup.ts`, `routes/config-backup-routes.ts`; `configBackupEnabled` absent = ON, `configBackupDir`, `configBackupKeepCount` 1-500 default 20, `configBackupKeepDays` 0-3650 default 30; `docs/wiki/Config-Backups.md`): timestamped snapshots `cfg-YYYYMMDD-HHMMSS/` (+ `manifest.json` with SHA-256s, `reason`, `appVersion`) of a FIXED allowlist (`CONFIG_BACKUP_FILES`: settings, clis, custom-model-hosts, webhook, intents, linked-cases, remote/docker hosts+cases, webviews, push-keys, users, `.env`) into `<data dir>/backups/config` or a custom absolute folder. ⚠️ `state.json` is deliberately NOT tracked (rewritten per session event; it also holds cron jobs and respawn presets). One snapshot per real content change (digest of the file set vs the latest manifest), at startup + a 5-minute timer, skipped in test mode. ⚠️ Retention (`pruneConfigBackups`) only deletes folders matching the name pattern AND holding a parseable manifest, and never the newest; a custom folder equal to the data dir falls back to the default. ⚠️ Restore verifies every file's SHA-256 BEFORE writing, takes a `pre-restore` snapshot, writes atomically at 0600 and only allowlisted names; the id is validated against the name pattern (no traversal). Snapshots are 0700/0600 because `.env`/`users.json`/`custom-model-hosts.json` carry credentials; all routes are admin-only in multi-user mode. → [docs/wiki/Config-Backups.md](docs/wiki/Config-Backups.md) + **Plan-usage chip** (`showPlanUsageLimits`, per-device: desktop default **ON**, handhelds OFF): resolve DISPLAY only through `planUsageChipEnabled()` (settings-ui.js). The same setting is the server-side COLLECTION switch, read fresh by `readPlanUsageTelemetryEnabled()` (hooks-config.ts) at every claude create/respawn. ⚠️ An ABSENT key reads as ON in the reader; `GET /api/settings` must never write. ⚠️ A save sends `showPlanUsageLimits` ONLY when it flips the chip on that device (`planUsageCollectionFlip()`), or a phone switches collection off for every desktop. Claude data comes from the statusLine exporter, injected as an EPHEMERAL `claude --settings` flag (`resolveStatusLineCliCommand`), never written to disk, WRAPPING a user's own statusLine, posting to `POST /api/status-telemetry`. Codex comes from a read-only `account/rateLimits/read` poll (main bucket only). The statusline's `prompt_cache` object (Claude Code v2.1.251+) is kept by `StatusTelemetrySchema` (every documented scalar field, null-tolerant; `parsePromptCache()` in usage-telemetry.ts normalizes only the stored subset) and shown in the footer as `cache:until HH:MM` / `cache:cold` (only when the user has no statusline of their own) and in Session Options → Respawn (`#promptCacheStatus`, fed by `Session.promptCache` in `toState()` plus the change-only `session:promptCache` SSE event, worded by the pure `CodemanPromptCache.format`): warm-until/cold, the re-cache stake, the hit rate, the last miss's causes, and a `Context compacted at HH:MM` note after a compaction or a tool-result clearing (stamped on the first warm report whose `recache_tokens_if_cold` is null after one that carried it, never from `expected_rebuilds`, which Claude Code bumps only at the next request; NEVER an alert). ⚠️ `Session.setPromptCache()` stamps `compactedAt` onto a NEW object (never mutating the parsed report) and the route broadcasts the STORED object; the last report is restored on restart (constructor `promptCache`, like `displayModel`) and the readout is time-aware (a passed `expiresAt` reads cold, not stale-warm). It is the source of truth for the cache TTL, never the plan tier. → [architecture-invariants#plan-usage-chip-statusline-telemetry](docs/architecture-invariants.md#plan-usage-chip-statusline-telemetry), `docs/usage-limits-display-plan.md` **Orchestrator**: State machine that turns a user goal into a phased plan and drives it to completion: `idle → planning → approval → executing → verifying → (replanning) → completed/failed`. `OrchestratorLoop` (engine) delegates plan generation to `orchestrator-planner` and per-phase verification gates to `orchestrator-verifier`, executing phases via team agents/`task-queue`. State persists under the `orchestrator` key in `state.json`. Distinct from Ralph (single-session autonomous loop) — orchestrator coordinates multi-phase, multi-agent execution. See `docs/orchestrator-loop-architecture.md`. diff --git a/README.md b/README.md index d3ad103a..0cdb3b6a 100644 --- a/README.md +++ b/README.md @@ -479,6 +479,7 @@ PTY Output → 16ms Server Batch → DEC 2026 Wrap → SSE → Client rAF → xt - **OSC 52 clipboard**: a program in a session that sets the clipboard with OSC 52 (Claude Code's `/copy`, a tmux yank, vim) puts the text on your browser's clipboard, in the main terminal and in every tile. Clipboard read requests are refused, and replaying a buffer never copies - **MCP server sync** _(opt-in)_: copies the user-level MCP servers each installed CLI has (Claude, Codex, Gemini, OpenCode, Antigravity, and GitHub Copilot CLI) into the others' own config files. Additive only, a server you switched off is never copied, **Preview** shows the changes first, and the previous file is kept as `.codeman-bak`. App Settings → Agents & CLIs; admin only in multi-user mode - **CJK / IME input** — full composition support for Chinese / Japanese / Korean, with Ctrl- and Alt-modified navigation keys passed through to the CLI +- **Config backups** — timestamped copies of the files that hold your customization (settings, CLI overrides, custom endpoints, webhook, linked cases, remote and Docker hosts, saved dashboards and, in multi-user mode, accounts), made at startup and whenever one changes. On by default into `~/.codeman/backups/config`; the folder, how many to keep (1–500) and how long (days, 0 = no limit) are settings, with **Back up now** and a verified **Restore** in Settings → System. Retention only ever deletes folders Codeman made and never the newest. See the [wiki page](docs/wiki/Config-Backups.md) - **Plan usage in the header** — live Claude subscription usage (the 5-hour and weekly windows) from a statusline exporter Codeman hands to `claude` at spawn and never writes into your settings files, plus Codex limits from its own app-server; per device, on for desktops and off for phones - **Prompt-cache readout**: each Claude session shows whether its prompt cache is warm (`cache:until HH:MM`) or cold, in the footer (when you have no statusline of your own) and in Session Options → Respawn, with the cost of a re-cache and a note after a compaction. Display only; it survives a restart - **Session list, your way** — the header strip, a left sidebar with a filter box, or a vertical rail whose detailed rows carry created and state stamps and sort by activity; the phone home screen and the desktop home rail use the same order. On the rail, **Search sessions** narrows the list to the tabs that match, across every group, and a tab with an alert stays visible even when its name does not. Named **tab groups** collapse, reorder and take drags; a group's menu has **New session**, and a worker an agent spawns lands right after its parent (**Spawned tabs follow their parent**, off by default, keeps it moving with the parent). **Add to Focus** pins a shortcut to any tab in a section at the top of the rail, and **CLI Logos on Tabs** can be switched off per device diff --git a/docs/api-reference.md b/docs/api-reference.md index d638fd74..fa78ea91 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1156,9 +1156,9 @@ The result carries server **names** only, never `env` values, `headers` or file Timestamped snapshots of the files that hold what a user has customized (`src/config-backup.ts`). On by default; a snapshot is taken at startup and whenever the tracked files' content changes (checked every 5 minutes), so there is one entry per real change. All three routes are admin only in multi-user mode (`403`), because the files include credentials. Responses carry file names and sizes, never content. -Tracked files (a fixed list, relative to the data dir): `settings.json`, `clis.json`, `custom-model-hosts.json`, `webhook.json`, `intents.json`, `linked-cases.json`, `push-keys.json`, `users.json`, `.env`. Session state, logs, the hook secret and caches are not. +Tracked files (a fixed list, relative to the data dir): `settings.json`, `clis.json`, `custom-model-hosts.json`, `webhook.json`, `intents.json`, `linked-cases.json`, `remote-hosts.json`, `docker-hosts.json`, `docker-cases.json`, `webviews.json`, `push-keys.json`, `users.json`, `.env`. Session state (`state.json`, which also holds cron jobs and respawn presets), logs, the hook secret and caches are not. -Settings (synced, in `settings.json`): `configBackupEnabled` (absent = on), `configBackupDir` (absolute path, `~` expanded; empty = `<data dir>/backups/config`), `configBackupKeepCount` (1–500, default 20), `configBackupKeepDays` (0–3650, default 30; `0` = no age limit). Retention only ever deletes snapshot folders Codeman made there, and never the newest one. Each snapshot is a `0700` folder of `0600` files plus a `manifest.json` with SHA-256 digests. +Settings (synced, in `settings.json`): `configBackupEnabled` (absent = on), `configBackupDir` (absolute path, `~` expanded; empty = `<data dir>/backups/config`), `configBackupKeepCount` (1–500, default 20), `configBackupKeepDays` (0–3650, default 30; `0` = no age limit). Retention only ever deletes snapshot folders Codeman made there, and never the newest one. Each snapshot is a `0700` folder named `cfg-YYYYMMDD-HHMMSS` (server local time; `-2`, `-3`… on a same-second collision) holding the tracked files under their own names plus a `manifest.json` (`id`, `createdAt`, `reason` of `startup`/`auto`/`manual`/`pre-restore`, `appVersion`, and a SHA-256 and size per file), all `0600`. Only folders with that name and a valid manifest are listed, restored or pruned. Full guide: `docs/wiki/Config-Backups.md`. | Method | Path | Body | Notes | | ------ | ----------------------------------- | ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | diff --git a/docs/wiki/Config-Backups.md b/docs/wiki/Config-Backups.md new file mode 100644 index 00000000..5b9f0ddc --- /dev/null +++ b/docs/wiki/Config-Backups.md @@ -0,0 +1,169 @@ +# Config Backups + +Codeman keeps timestamped copies of the files that hold what you have customized, so a bad +write, a hand edit or a botched restart can be undone. It is on by default, needs no setup, +and everything about it (where the copies go, how many are kept, how long) is a setting. + +## What is backed up + +A fixed list of files from the data directory (`~/.codeman/`, or the directory of the +[instance](Core-Concepts#instances) you run): + +| File | What it holds | +| ---- | ------------- | +| `settings.json` | Your preferences that sync across devices, including model and CLI settings. | +| `clis.json` | CLI enable/disable and custom CLI entries. | +| `custom-model-hosts.json` | Saved custom model endpoints. **Holds API keys.** | +| `webhook.json` | Webhook notification target. | +| `intents.json` | Read My Mind per-case profiles. | +| `linked-cases.json` | Registered cases. | +| `remote-hosts.json`, `docker-hosts.json`, `docker-cases.json` | Remote SSH and Docker overlay configuration. | +| `webviews.json` | Saved dashboard URLs. | +| `push-keys.json` | Web-push keys (regenerating them drops every device's subscription). | +| `users.json` | Multi-user accounts. **Holds password hashes.** | +| `.env` | Credentials the TUI client reads. **Holds a password.** | + +Only files that exist are included. This is an allowlist: nothing else in the data +directory is ever copied. + +**Not backed up:** `state.json` (sessions, and also cron jobs and respawn presets: it is rewritten +with every session event, so it would bury the real changes), logs, `mux-sessions.json`, the hook +secret, Docker seeds and caches. Back `state.json` up with your normal file backups if you rely on +cron jobs. + +## When a backup is made + +- **At startup**, so the state the server found is always on record. +- **Whenever the content changes.** The files are read and hashed every five minutes; a new + backup is written only if the set differs from the latest backup. An idle server writes + nothing, and the history is one entry per real change rather than one per timer tick. +- **On demand**, with **Back up now** (always writes one, even if nothing changed). +- **Before a restore**, as a `pre-restore` safety copy of the files about to be replaced. + +## Where they go and what they are called + +The default folder is `<data dir>/backups/config` (`~/.codeman/backups/config`). You can point +it anywhere with an absolute path (a leading `~` is expanded). A path that is not absolute, or +that is the data directory itself, falls back to the default. + +Each backup is one folder named for the moment it was made, in the server's local time: + +``` +~/.codeman/backups/config/ + cfg-20261011-120305/ + manifest.json id, createdAt (epoch ms), reason, appVersion, SHA-256 + size per file + settings.json the original file names, unchanged + clis.json + custom-model-hosts.json + ... + cfg-20261011-123012/ + cfg-20261011-123012-2/ a second backup in the same second gets -2, -3, ... +``` + +| Part | Meaning | +| ---- | ------- | +| `cfg-YYYYMMDD-HHMMSS` | Date and time (server local time) the backup was made. A collision in the same second gets `-2`, `-3`, … | +| `manifest.json` | The record of the backup: `reason`, `appVersion`, and a SHA-256 and size for every file. | +| `reason` | `startup`, `auto` (a change was detected), `manual` (Back up now) or `pre-restore`. It is in the manifest and the Settings list, not in the folder name. | +| `.tmp-…` | A backup being written. It is renamed into place when complete and is never listed. | + +A folder counts as a backup only if its name matches that pattern **and** it has a valid +`manifest.json`. That is what makes it safe to point the folder at a directory that also holds +other things. + +## Retention + +Two limits, applied after every backup: + +| Setting | Range | Default | Meaning | +| ------- | ----- | ------- | ------- | +| **Keep this many backups** (`configBackupKeepCount`) | 1 – 500 | 20 | Keep the newest N; older ones are removed. | +| **Delete backups older than (days)** (`configBackupKeepDays`) | 0 – 3650 | 30 | Also remove anything older than this. `0` means no age limit. | + +Both apply together. Three guarantees: + +- **The newest backup is never removed**, whatever the numbers say, so there is always one to + go back to. +- **Only folders Codeman made are ever removed**, never anything else that happens to live in a + custom folder. +- Because backups are made on change, 20 backups reach back across 20 *changes*, however long + that took, not 20 timer ticks. + +## Settings → System → Config backups + +| Control | Setting | Default | +| ------- | ------- | ------- | +| Back up my configuration | `configBackupEnabled` | On (absent means on) | +| Backup folder | `configBackupDir` | empty = `<data dir>/backups/config` | +| Keep this many backups | `configBackupKeepCount` | 20 | +| Delete backups older than (days) | `configBackupKeepDays` | 30 | +| **Back up now** | | | +| The list, with **Restore** per entry | | | + +The settings are saved server-side and read fresh, so a change applies on the next pass. The +list and **Back up now** use the **saved** folder and retention: apply your edits first. + +## Restoring + +From the list, press **Restore** on a backup and confirm. Codeman then: + +1. **Verifies** every file in the backup against the SHA-256 in its manifest. If any file is + missing or does not match, nothing is changed and you get an error. +2. **Takes a `pre-restore` backup** of the files about to be replaced, so a restore can itself + be undone. +3. **Replaces each file atomically** (written beside it, then renamed) with mode `0600`. + +Only the tracked names are ever written, whatever a manifest lists. + +Settings are re-read on the next request, so reload the page. `clis.json`, `users.json`, +`.env` and `push-keys.json` are read once per process: **restart Codeman** for those to take +effect (the response lists which). A restore does not touch running sessions. + +You can also restore by hand: the files in a backup folder are the originals, so copying one back +over the file in the data directory is equivalent (stop Codeman first if you are replacing +`clis.json` or `users.json`). + +## Security + +- Some tracked files carry credentials (`.env`, `users.json`, `custom-model-hosts.json`, + `push-keys.json`). Each backup folder is mode `0700` and its files `0600`, so the default + location exposes nothing the data directory does not already. +- **A custom folder is your decision.** Putting it on a shared drive, a synced folder or a + network share copies those credentials there, with whatever permissions that location has. + Choose a private location, or turn the feature off if you cannot. +- In [multi-user mode](Multi-User-Mode) all of it is admin only: the list, Back up now and + Restore. +- Listing returns file names and sizes, never contents. + +## HTTP API + +```bash +API=http://localhost:3000 +curl -s "$API/api/config-backups" | jq # settings, folder, list +curl -s -X POST "$API/api/config-backups" | jq # back up now +curl -s -X POST "$API/api/config-backups/cfg-20261011-120305/restore" | jq +``` + +See [HTTP API](HTTP-API) and the endpoint reference in `docs/api-reference.md`. + +## Troubleshooting + +- **The list is empty.** None of the tracked files existed yet, or backups are turned off. Press + **Back up now**. +- **"Backups are not available here".** You are not an admin in multi-user mode. +- **No new backup after I changed a setting.** Changes are picked up within five minutes; **Back up + now** takes one immediately. +- **A restore said a file does not match its checksum.** The backup was altered or damaged on + disk. Nothing was changed; restore an earlier one. +- **The backups folder is growing.** Lower **Keep this many backups**. Each backup is a few + kilobytes, but `custom-model-hosts.json` and `intents.json` can be larger. +- **I want my own off-machine copy.** Point the folder at a location your backup tool already + covers, mind the Security note above, and keep **Keep this many backups** high enough that + your tool sees each one. + +## Read next + +- [Settings Reference](Settings-Reference) - where the controls live. +- [Core Concepts](Core-Concepts) - what is on disk. +- [Security](Security) - what is protected and how. +- [Troubleshooting](Troubleshooting) - "my settings were reset". diff --git a/docs/wiki/Core-Concepts.md b/docs/wiki/Core-Concepts.md index 60da25e6..bc41e354 100644 --- a/docs/wiki/Core-Concepts.md +++ b/docs/wiki/Core-Concepts.md @@ -135,6 +135,7 @@ Everything Codeman knows lives under `~/.codeman/`: | `users.json` | Multi-user accounts, mode 0600. | | `push-*.json` | Web push keys and subscriptions. | | `certs/` | Self-signed TLS for `--https`. | +| `backups/config/` | Timestamped copies of the files above that you customized ([Config Backups](Config-Backups)). | None of it needs root, none of it leaves the machine, and deleting `~/.codeman/` resets Codeman to a fresh install without touching your code. diff --git a/docs/wiki/Home.md b/docs/wiki/Home.md index d14c60ea..50c108bb 100644 --- a/docs/wiki/Home.md +++ b/docs/wiki/Home.md @@ -113,6 +113,7 @@ codeman web # then open http://localhost:3000 | Page | What it answers | | --------------------------------------------- | -------------------------------------------------- | | [Running As A Service](Running-As-A-Service) | How do I keep it up across reboots, and update it? | +| [Config Backups](Config-Backups) | How do I get my settings back after they were changed or lost? | | [Troubleshooting](Troubleshooting) | Why is it doing that? | | [FAQ](FAQ) | The questions that keep coming up. | | [Contributing](Contributing) | How do I send a fix? | diff --git a/docs/wiki/Security.md b/docs/wiki/Security.md index 8dbaaa38..d7e169f1 100644 --- a/docs/wiki/Security.md +++ b/docs/wiki/Security.md @@ -107,6 +107,11 @@ browser and your server. Your agent CLI's traffic is its own, on your account. Two features send data outward, both off by default and both stated where they appear: voice dictation through your Claude login, and the Read My Mind prediction call. +[Config Backups](Config-Backups) copies some credential-bearing files (`.env`, `users.json`, +`custom-model-hosts.json`, `push-keys.json`) into a backup folder. The default folder is inside +the data directory, and every backup is mode `0700`/`0600`, so nothing new is exposed. Only a +folder you choose yourself can move them somewhere less private; admin only in multi-user mode. + ## Reporting a vulnerability **Never in a public issue.** diff --git a/docs/wiki/Settings-Reference.md b/docs/wiki/Settings-Reference.md index 2e7238ce..bf0a7800 100644 --- a/docs/wiki/Settings-Reference.md +++ b/docs/wiki/Settings-Reference.md @@ -190,6 +190,13 @@ Cloudflare tunnel controls including the tunnel URL. The **Diagnostics** group r tools with their versions and install hints (admin only in multi-user mode). In multi-user mode, the **Users** administration entry is injected here. +The **Config backups** group controls timestamped copies of your configuration files: an +on/off switch (on by default), the backup folder (default `~/.codeman/backups/config`), how +many backups to keep (1 to 500, default 20) and how many days to keep them (0 to 3650, default +30; 0 means no age limit), plus **Back up now** and a list with **Restore**. These are +server-side settings, read fresh. Details, naming and restore behaviour are on +[Config Backups](Config-Backups). + ## Session Options Per session, from the tab. @@ -243,3 +250,4 @@ Some things are configured before the server starts, not in the UI: - [The Dashboard](The-Dashboard) - what each control does once visible. - [Keeping Agents Running](Keeping-Agents-Running) - the Respawn panel in depth. - [Agent CLIs](Agent-CLIs) - model, effort, and permission modes. +- [Config Backups](Config-Backups) - copies of your settings, and how to restore them. diff --git a/docs/wiki/Troubleshooting.md b/docs/wiki/Troubleshooting.md index 114ca37d..aa3a83f4 100644 --- a/docs/wiki/Troubleshooting.md +++ b/docs/wiki/Troubleshooting.md @@ -131,6 +131,23 @@ That prompt comes from Claude Code, not Codeman. Codeman's default is to start w permission prompts skipped, which is what the security model is built around. If you would rather it prompted, change **App Settings → Agents & CLIs → Claude → Startup Mode**. +### My settings were reset, or a CLI I enabled is gone + +Something rewrote a file in `~/.codeman/` (a hand edit, a restart that loaded a bad file, a +test run pointed at the real data directory). If [Config Backups](Config-Backups) was on, +which is the default, you have copies: + +1. Open **Settings → System → Config backups** and pick the newest backup from before the + change. The list shows when each was made and what it holds. +2. **Restore** it, reload the page, and restart Codeman if the response says `clis.json`, + `users.json`, `.env` or `push-keys.json` were restored. + +Without the UI: the backups are plain folders in `~/.codeman/backups/config/` +(`cfg-YYYYMMDD-HHMMSS`); copy the file you need back over the one in `~/.codeman/`. + +Not covered: `state.json` (cron jobs and respawn presets live there). A backup only exists +from the moment the feature first ran, so a reset that happened before that cannot be undone. + ### Sessions vanished after a reboot Expected. tmux does not survive a reboot, so the sessions are gone. Conversations are not: diff --git a/docs/wiki/_Sidebar.md b/docs/wiki/_Sidebar.md index 62e0e7a7..0b85b925 100644 --- a/docs/wiki/_Sidebar.md +++ b/docs/wiki/_Sidebar.md @@ -51,6 +51,7 @@ **Operating it** - [Running As A Service](Running-As-A-Service) +- [Config Backups](Config-Backups) - [Troubleshooting](Troubleshooting) - [FAQ](FAQ) - [Contributing](Contributing) diff --git a/src/config-backup.ts b/src/config-backup.ts index 84f60be1..9749c5d0 100644 --- a/src/config-backup.ts +++ b/src/config-backup.ts @@ -40,6 +40,9 @@ import { isAbsolute, join, resolve, sep } from 'node:path'; * The files (relative to the data dir) a snapshot holds: what a user configured, not what the server * regenerates (session state, logs, the hook secret, docker seeds, caches). * + * ⚠️ Not tracked: `state.json`. It also holds cron jobs and respawn presets, but it is rewritten with + * every session event, so tracking it would make a snapshot per event and bury the real changes. + * * ⚠️ `.env`, `users.json`, `custom-model-hosts.json` and `push-keys.json` carry credentials or keys. * They are already in the data dir with the same permissions; a snapshot folder is 0700 with 0600 * files, so the default location widens nothing. A custom location is the user's own choice. @@ -51,6 +54,10 @@ export const CONFIG_BACKUP_FILES = [ 'webhook.json', 'intents.json', 'linked-cases.json', + 'remote-hosts.json', + 'docker-hosts.json', + 'docker-cases.json', + 'webviews.json', 'push-keys.json', 'users.json', '.env', From 06182c6fced3a92d90d2d0ede6ad33e935d2c2a6 Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Mon, 12 Oct 2026 16:59:12 +0800 Subject: [PATCH 19/96] fix(config-backup): a self-refreshed discovery timestamp is not a change custom-model-hosts.json carries a lastDiscoveredAt per host that the 5-minute model discovery poll rewrites, so the content hash never matched the latest snapshot and a snapshot was written every tick. At the default keep count of 20 that left about 100 minutes of history and evicted every useful older copy. The change check now looks through volatile keys per tracked file. The snapshot still stores the file as written and restore still verifies against its own digest. --- docs/wiki/Config-Backups.md | 4 ++++ src/config-backup.ts | 43 ++++++++++++++++++++++++++++++++++--- test/config-backup.test.ts | 29 +++++++++++++++++++++++++ 3 files changed, 73 insertions(+), 3 deletions(-) diff --git a/docs/wiki/Config-Backups.md b/docs/wiki/Config-Backups.md index 5b9f0ddc..053c262f 100644 --- a/docs/wiki/Config-Backups.md +++ b/docs/wiki/Config-Backups.md @@ -37,6 +37,10 @@ cron jobs. - **Whenever the content changes.** The files are read and hashed every five minutes; a new backup is written only if the set differs from the latest backup. An idle server writes nothing, and the history is one entry per real change rather than one per timer tick. + A field a file refreshes by itself is not a change: `custom-model-hosts.json` carries a + `lastDiscoveredAt` per host that the model-discovery poll rewrites every five minutes, and + the comparison looks through it (the snapshot still stores the file exactly as written). + Without that, every tick looked like an edit and 20 copies covered only about 100 minutes. - **On demand**, with **Back up now** (always writes one, even if nothing changed). - **Before a restore**, as a `pre-restore` safety copy of the files about to be replaced. diff --git a/src/config-backup.ts b/src/config-backup.ts index 9749c5d0..09223500 100644 --- a/src/config-backup.ts +++ b/src/config-backup.ts @@ -154,6 +154,42 @@ export function resolveConfigBackupDir(settings: ConfigBackupSettings, dataDir: const sha256 = (data: Buffer | string): string => createHash('sha256').update(data).digest('hex'); +/** + * Keys a tracked file rewrites on its own schedule, with no change to anything the user set, so + * they must not make a snapshot look different. `custom-model-hosts.json` carries a + * `lastDiscoveredAt` per host that the model-discovery poll refreshes every 5 minutes: before this, + * the change check never matched and the snapshot folder filled with copies that differed by one + * timestamp, evicting every older snapshot within about 100 minutes at the default keep count. + * The snapshot itself still stores the file exactly as it was, and restore still verifies against + * the file's own digest; only the "has anything changed?" comparison looks through these keys. + */ +const VOLATILE_KEYS: Readonly<Record<string, readonly string[]>> = { + 'custom-model-hosts.json': ['lastDiscoveredAt'], +}; + +function withoutKeys(value: unknown, drop: ReadonlySet<string>): unknown { + if (Array.isArray(value)) return value.map((v) => withoutKeys(v, drop)); + if (value && typeof value === 'object') { + const out: Record<string, unknown> = {}; + for (const [k, v] of Object.entries(value as Record<string, unknown>)) { + if (!drop.has(k)) out[k] = withoutKeys(v, drop); + } + return out; + } + return value; +} + +/** Digest used only to decide whether two states are the same: the file's own, minus its volatile keys. */ +function changeDigest(name: string, data: Buffer, rawSha: string): string { + const volatile = VOLATILE_KEYS[name]; + if (!volatile) return rawSha; + try { + return sha256(JSON.stringify(withoutKeys(JSON.parse(data.toString('utf8')), new Set(volatile)))); + } catch { + return rawSha; // not JSON (or unreadable as such): compare the bytes, as for any other file + } +} + function stamp(now: number): string { const d = new Date(now); const p = (n: number, w = 2) => String(n).padStart(w, '0'); @@ -216,20 +252,21 @@ export interface CreateConfigBackupOptions { /** Snapshot the tracked files that exist, unless the latest snapshot already holds exactly this state. */ export function createConfigBackup(opts: CreateConfigBackupOptions): ConfigBackupResult { const now = opts.now ?? Date.now(); - const present: Array<{ name: string; data: Buffer; sha: string }> = []; + const present: Array<{ name: string; data: Buffer; sha: string; change: string }> = []; for (const name of CONFIG_BACKUP_FILES) { try { const path = join(opts.dataDir, name); if (!statSync(path).isFile()) continue; const data = readFileSync(path); - present.push({ name, data, sha: sha256(data) }); + const sha = sha256(data); + present.push({ name, data, sha, change: changeDigest(name, data, sha) }); } catch { /* absent or unreadable: simply not part of this snapshot */ } } if (present.length === 0) return { status: 'empty' }; - const hash = sha256(present.map((f) => `${f.name}\0${f.sha}`).join('\n')); + const hash = sha256(present.map((f) => `${f.name}\0${f.change}`).join('\n')); const existing = listConfigBackups(opts.backupDir); if (!opts.force && existing[0]) { const latest = readManifest(opts.backupDir, existing[0].id); diff --git a/test/config-backup.test.ts b/test/config-backup.test.ts index 1410188e..d409ae9d 100644 --- a/test/config-backup.test.ts +++ b/test/config-backup.test.ts @@ -124,6 +124,35 @@ describe('createConfigBackup', () => { expect(listConfigBackups(backups)).toHaveLength(2); }); + it('does not count a self-refreshed discovery timestamp as a change', () => { + const hosts = (stamp: string, model = 'm1') => + JSON.stringify({ hosts: [{ id: 'h', models: [{ id: model }], lastDiscoveredAt: stamp }] }); + put('custom-model-hosts.json', hosts('2026-10-12T08:44:22.026Z')); + const first = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 }); + + // The 5-minute discovery poll rewrites only the timestamp: still the same state. + put('custom-model-hosts.json', hosts('2026-10-12T08:49:22.025Z')); + const again = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + 1000 }); + expect(again).toEqual({ status: 'unchanged', id: (first as { id: string }).id }); + expect(listConfigBackups(backups)).toHaveLength(1); + + // A real edit next to a refreshed timestamp is still a change, stored exactly as written. + const edited = hosts('2026-10-12T08:54:22.025Z', 'm2'); + put('custom-model-hosts.json', edited); + const third = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + 2000 }); + expect(third.status).toBe('created'); + expect(readFileSync(join(backups, (third as { id: string }).id, 'custom-model-hosts.json'), 'utf8')).toBe(edited); + }); + + it('compares a tracked file that is not valid JSON by its bytes', () => { + put('custom-model-hosts.json', 'not json lastDiscoveredAt 1'); + createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 }); + put('custom-model-hosts.json', 'not json lastDiscoveredAt 2'); + expect(createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + 1000 }).status).toBe( + 'created' + ); + }); + it('force makes a snapshot even when unchanged, and two in one second get distinct ids', () => { put('settings.json', '{}'); createConfigBackup({ dataDir: data, backupDir: backups, reason: 'a', now: T0 }); From a2e49eb26d687634f630e3f9eef8073d3c6f4e01 Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Sun, 18 Oct 2026 04:38:37 +0800 Subject: [PATCH 20/96] feat(config-backup): compare backups with each other and with the running config Settings -> System -> Config backups gains a Compare view: a backup against the running config, a backup against the one before it, or any two backups. Each differing file lists its changed keys as before/after, plus files that appeared or vanished. GET /api/config-backups/diff?from=&to= serves it (`to` defaults to `current`; admin only in multi-user mode). Credential values are never returned: .env, users.json and push-keys.json report names only, and any other key that looks like a token, secret, password, credential or key is reported as changed without its value. A non-JSON file reports only that it differs, a file is cut at 200 changes, and a self-refreshed discovery timestamp does not count as a difference. --- .changeset/18dce807.md | 2 +- docs/api-reference.md | 11 +- docs/wiki/Config-Backups.md | 32 ++++- docs/wiki/HTTP-API.md | 1 + src/config-backup.ts | 172 +++++++++++++++++++++++ src/web/public/index.html | 2 +- src/web/public/settings-ui.js | 64 ++++++++- src/web/routes/config-backup-routes.ts | 42 +++++- test/config-backup.test.ts | 93 ++++++++++++ test/routes/config-backup-routes.test.ts | 23 +++ 10 files changed, 426 insertions(+), 16 deletions(-) diff --git a/.changeset/18dce807.md b/.changeset/18dce807.md index a0974900..9eb3a1b8 100644 --- a/.changeset/18dce807.md +++ b/.changeset/18dce807.md @@ -2,4 +2,4 @@ "aicodeman": minor --- -Config backups: timestamped snapshots of the files that hold your customization (settings, CLI overrides, custom endpoints, webhook, linked cases and, in multi-user mode, accounts), taken at startup and whenever one changes. On by default into `~/.codeman/backups/config`; the folder and the retention (keep N backups, drop older than N days) are configurable in Settings → System, with Back up now and Restore. Retention only deletes folders Codeman made, never the newest backup. +Config backups: timestamped snapshots of the files that hold your customization (settings, CLI overrides, custom endpoints, webhook, linked cases and, in multi-user mode, accounts), taken at startup and whenever one changes. On by default into `~/.codeman/backups/config`; the folder and the retention (keep N backups, drop older than N days) are configurable in Settings → System, with Back up now and Restore. Retention only deletes folders Codeman made, never the newest backup. A comparison view shows what changed between two backups, or between a backup and the running config, without ever showing a credential's value. diff --git a/docs/api-reference.md b/docs/api-reference.md index fa78ea91..4e5b5e4b 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1160,11 +1160,12 @@ Tracked files (a fixed list, relative to the data dir): `settings.json`, `clis.j Settings (synced, in `settings.json`): `configBackupEnabled` (absent = on), `configBackupDir` (absolute path, `~` expanded; empty = `<data dir>/backups/config`), `configBackupKeepCount` (1–500, default 20), `configBackupKeepDays` (0–3650, default 30; `0` = no age limit). Retention only ever deletes snapshot folders Codeman made there, and never the newest one. Each snapshot is a `0700` folder named `cfg-YYYYMMDD-HHMMSS` (server local time; `-2`, `-3`… on a same-second collision) holding the tracked files under their own names plus a `manifest.json` (`id`, `createdAt`, `reason` of `startup`/`auto`/`manual`/`pre-restore`, `appVersion`, and a SHA-256 and size per file), all `0600`. Only folders with that name and a valid manifest are listed, restored or pruned. Full guide: `docs/wiki/Config-Backups.md`. -| Method | Path | Body | Notes | -| ------ | ----------------------------------- | ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `GET` | `/api/config-backups` | none | `settings` in effect, `dir`, `defaultDir`, `tracked[]` and `backups[]` (`id`, `createdAt`, `reason`, `appVersion`, `files[]`, `bytes`), newest first. | -| `POST` | `/api/config-backups` | none | Snapshot now, even if nothing changed (`reason: manual`), then apply retention. `status` is `created` or `empty` (nothing tracked exists yet). | -| `POST` | `/api/config-backups/:id/restore` | none | Put a snapshot's files back, atomically, after a `pre-restore` snapshot of the current ones. `404` unknown id; `409 CONFLICT` if a file fails its checksum (nothing changed). `restartRequired[]` names files the running server only reloads on restart. | +| Method | Path | Body | Notes | +| ------ | ------------------------------------ | ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/api/config-backups` | none | `settings` in effect, `dir`, `defaultDir`, `tracked[]` and `backups[]` (`id`, `createdAt`, `reason`, `appVersion`, `files[]`, `bytes`), newest first. | +| `POST` | `/api/config-backups` | none | Snapshot now, even if nothing changed (`reason: manual`), then apply retention. `status` is `created` or `empty` (nothing tracked exists yet). | +| `GET` | `/api/config-backups/diff?from=&to=` | none | What differs between two states: `from`/`to` are a backup id or `current` (the running config; the default for `to`). `files[]` (`name`, `status` `added`/`removed`/`changed`/`unchanged`, `changes[]` of `path`, `kind`, `before`/`after`, `truncated`, `detail`) and `changed`. Credential values are never returned (`redacted: true`). `400` without `from`; `404` unknown id; `409` if a file fails its checksum. | +| `POST` | `/api/config-backups/:id/restore` | none | Put a snapshot's files back, atomically, after a `pre-restore` snapshot of the current ones. `404` unknown id; `409 CONFLICT` if a file fails its checksum (nothing changed). `restartRequired[]` names files the running server only reloads on restart. | ## Webhook notifications diff --git a/docs/wiki/Config-Backups.md b/docs/wiki/Config-Backups.md index 053c262f..73eab2ed 100644 --- a/docs/wiki/Config-Backups.md +++ b/docs/wiki/Config-Backups.md @@ -102,7 +102,8 @@ Both apply together. Three guarantees: | Keep this many backups | `configBackupKeepCount` | 20 | | Delete backups older than (days) | `configBackupKeepDays` | 30 | | **Back up now** | | | -| The list, with **Restore** per entry | | | +| The list, with **Restore**, **Compare with current** and **Changes in this backup** per entry | | | +| **Compare** … with … **Show changes** | | | The settings are saved server-side and read fresh, so a change applies on the next pass. The list and **Back up now** use the **saved** folder and retention: apply your edits first. @@ -127,6 +128,26 @@ You can also restore by hand: the files in a backup folder are the originals, so over the file in the data directory is equivalent (stop Codeman first if you are replacing `clis.json` or `users.json`). +## Comparing backups + +Before you restore, or to find out what changed and when, use **Compare** in the list: + +- **Compare with current** on a backup shows what differs between that backup and the config + running now, which is also what a restore of it would undo. +- **Changes in this backup** shows what differs between the backup before it and this one. +- The **Compare … with … Show changes** pickers compare any two backups, or a backup with the + current config. + +Each file that differs is listed with its changed keys (`theme.mode`, `tags[1]`, a variable name +in `.env`) as before → after, plus files that appeared or vanished between the two states. Files +that are the same are listed on one line. A file that is not JSON shows only that it differs, and +a file with more than 200 changes is cut short, so use the files themselves for a full diff. A +self-refreshed discovery timestamp in `custom-model-hosts.json` is not counted as a change. + +**Credential values are never shown.** `.env`, `users.json` and `push-keys.json` list only the +names that changed, and in every other file a key that looks like a token, secret, password, +credential or key is shown as "changed" without its value. + ## Security - Some tracked files carry credentials (`.env`, `users.json`, `custom-model-hosts.json`, @@ -135,9 +156,10 @@ over the file in the data directory is equivalent (stop Codeman first if you are - **A custom folder is your decision.** Putting it on a shared drive, a synced folder or a network share copies those credentials there, with whatever permissions that location has. Choose a private location, or turn the feature off if you cannot. -- In [multi-user mode](Multi-User-Mode) all of it is admin only: the list, Back up now and - Restore. -- Listing returns file names and sizes, never contents. +- In [multi-user mode](Multi-User-Mode) all of it is admin only: the list, Back up now, + Restore and Compare. +- Listing returns file names and sizes, never contents. A comparison returns changed values but + never a credential's (see [Comparing backups](#comparing-backups)). ## HTTP API @@ -146,6 +168,8 @@ API=http://localhost:3000 curl -s "$API/api/config-backups" | jq # settings, folder, list curl -s -X POST "$API/api/config-backups" | jq # back up now curl -s -X POST "$API/api/config-backups/cfg-20261011-120305/restore" | jq +curl -s "$API/api/config-backups/diff?from=cfg-20261011-120305" | jq # that backup vs the running config +curl -s "$API/api/config-backups/diff?from=cfg-20261011-120305&to=cfg-20261012-090000" | jq ``` See [HTTP API](HTTP-API) and the endpoint reference in `docs/api-reference.md`. diff --git a/docs/wiki/HTTP-API.md b/docs/wiki/HTTP-API.md index 2b5b2f1c..6feb1517 100644 --- a/docs/wiki/HTTP-API.md +++ b/docs/wiki/HTTP-API.md @@ -176,6 +176,7 @@ curl -s -X POST "$API/api/mcp-sync" | jq # apply it: add missing ser curl -s "$API/api/config-backups" | jq # config snapshots: folder, retention, list (admin only in multi-user mode) curl -s -X POST "$API/api/config-backups" | jq # back up the customized config files now curl -s -X POST "$API/api/config-backups/cfg-20261011-120000/restore" | jq # put a snapshot back (after a pre-restore copy) +curl -s "$API/api/config-backups/diff?from=cfg-20261011-120000" | jq # what differs from the running config (`to=` another snapshot id to compare two) # with ID set to a session id: curl -s "$API/api/sessions/$ID/last-response" | jq -r '.data.text' # last answer, from the transcript (claude, codex, deepseek) diff --git a/src/config-backup.ts b/src/config-backup.ts index 09223500..f4c67ffd 100644 --- a/src/config-backup.ts +++ b/src/config-backup.ts @@ -398,3 +398,175 @@ export function restoreConfigBackup(opts: { } return { restored, safetySnapshot: safety.status === 'created' ? safety.id : null }; } + +/** The live files in the data dir, as a side of a diff. */ +export const CONFIG_DIFF_CURRENT = 'current'; + +export interface ConfigDiffChange { + /** Dotted path inside the file (`a.b[0].c`), or the variable name for `.env`. */ + path: string; + kind: 'added' | 'removed' | 'changed'; + /** JSON text of the value; absent when it did not exist on that side or `redacted` is set. */ + before?: string; + after?: string; + /** The value is a credential, so only the fact that it differs is reported. */ + redacted?: boolean; +} + +export interface ConfigDiffFile { + name: string; + /** `added` = only in `to`, `removed` = only in `from`. */ + status: 'added' | 'removed' | 'changed' | 'unchanged'; + changes: ConfigDiffChange[]; + /** More changes than `MAX_DIFF_CHANGES`; the rest are not listed. */ + truncated: boolean; + /** `keys` = per-key changes; `bytes` = the file is not JSON/env, so only "differs" is known. */ + detail: 'keys' | 'bytes'; +} + +export interface ConfigDiff { + from: string; + to: string; + files: ConfigDiffFile[]; + /** Files whose status is not `unchanged`. */ + changed: number; +} + +const MAX_DIFF_CHANGES = 200; +const MAX_DIFF_VALUE_CHARS = 200; + +/** Files whose every value is a credential or key: only names are ever shown. */ +const REDACT_ALL_FILES: ReadonlySet<string> = new Set(['.env', 'users.json', 'push-keys.json']); +/** Elsewhere a value is hidden when its own key looks like a credential. */ +const SECRET_KEY_RE = /(token|secret|passw|credential|auth|key$)/i; + +type Leaves = Map<string, string>; + +function flattenJson(value: unknown, path: string, out: Leaves): void { + if (Array.isArray(value) && value.length > 0) { + value.forEach((v, i) => flattenJson(v, `${path}[${i}]`, out)); + } else if (value && typeof value === 'object' && Object.keys(value).length > 0) { + for (const [k, v] of Object.entries(value as Record<string, unknown>)) { + flattenJson(v, path ? `${path}.${k}` : k, out); + } + } else { + out.set(path || '(root)', JSON.stringify(value) ?? 'null'); + } +} + +function parseEnv(text: string): Leaves { + const out: Leaves = new Map(); + for (const line of text.split(/\r?\n/)) { + const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_.-]*)\s*=(.*)$/.exec(line); + if (m) out.set(m[1], m[2].trim()); + } + return out; +} + +/** Per-key view of a tracked file, or null when it is not JSON/env text. */ +function leavesOf(name: string, data: Buffer): Leaves | null { + const text = data.toString('utf8'); + if (name === '.env') return parseEnv(text); + try { + const parsed = JSON.parse(text) as unknown; + const volatile = VOLATILE_KEYS[name]; + const out: Leaves = new Map(); + flattenJson(volatile ? withoutKeys(parsed, new Set(volatile)) : parsed, '', out); + return out; + } catch { + return null; + } +} + +const clip = (s: string): string => (s.length > MAX_DIFF_VALUE_CHARS ? `${s.slice(0, MAX_DIFF_VALUE_CHARS)}…` : s); + +function diffLeaves(name: string, a: Leaves, b: Leaves): { changes: ConfigDiffChange[]; truncated: boolean } { + const all = REDACT_ALL_FILES.has(name); + const changes: ConfigDiffChange[] = []; + for (const path of [...new Set([...a.keys(), ...b.keys()])].sort()) { + const before = a.get(path); + const after = b.get(path); + if (before === after) continue; + const kind = before === undefined ? 'added' : after === undefined ? 'removed' : 'changed'; + if (all || SECRET_KEY_RE.test(path.split('.').pop() ?? '')) { + changes.push({ path, kind, redacted: true }); + } else { + changes.push({ + path, + kind, + ...(before !== undefined && { before: clip(before) }), + ...(after !== undefined && { after: clip(after) }), + }); + } + } + return { changes: changes.slice(0, MAX_DIFF_CHANGES), truncated: changes.length > MAX_DIFF_CHANGES }; +} + +/** The tracked files of one side, verified: a snapshot's against its manifest, the live ones as they are. */ +function loadDiffSide(side: string, dataDir: string, backupDir: string): Map<string, Buffer> { + const out = new Map<string, Buffer>(); + if (side === CONFIG_DIFF_CURRENT) { + for (const name of CONFIG_BACKUP_FILES) { + try { + const path = join(dataDir, name); + if (statSync(path).isFile()) out.set(name, readFileSync(path)); + } catch { + /* absent or unreadable: not part of this side */ + } + } + return out; + } + const manifest = readManifest(backupDir, side); + if (!manifest) throw new ConfigBackupNotFoundError(`No config backup named ${side}`); + const allowed = new Set<string>(CONFIG_BACKUP_FILES); + for (const f of manifest.files) { + if (!allowed.has(f.name)) continue; + let data: Buffer; + try { + data = readFileSync(join(backupDir, side, f.name)); + } catch { + throw new ConfigBackupCorruptError(`${f.name} is missing from ${side}`); + } + if (sha256(data) !== f.sha256) + throw new ConfigBackupCorruptError(`${f.name} in ${side} does not match its checksum`); + out.set(f.name, data); + } + return out; +} + +/** + * What differs between two states of the tracked files; a state is a snapshot id or `current` + * (the live files). Never returns the value of a credential: see REDACT_ALL_FILES and SECRET_KEY_RE. + */ +export function diffConfigBackups(opts: { dataDir: string; backupDir: string; from: string; to: string }): ConfigDiff { + const a = loadDiffSide(opts.from, opts.dataDir, opts.backupDir); + const b = loadDiffSide(opts.to, opts.dataDir, opts.backupDir); + const files: ConfigDiffFile[] = []; + for (const name of CONFIG_BACKUP_FILES) { + const before = a.get(name); + const after = b.get(name); + if (!before && !after) continue; + const base = { name, changes: [] as ConfigDiffChange[], truncated: false }; + if (before && after && sha256(before) === sha256(after)) { + files.push({ ...base, status: 'unchanged', detail: 'keys' }); + continue; + } + const la = before ? leavesOf(name, before) : new Map<string, string>(); + const lb = after ? leavesOf(name, after) : new Map<string, string>(); + const status = !before ? 'added' : !after ? 'removed' : 'changed'; + if (!la || !lb) { + files.push({ ...base, status, detail: 'bytes' }); + continue; + } + const { changes, truncated } = diffLeaves(name, la, lb); + // Only volatile keys differed (a discovery timestamp): the same state as far as the user is concerned. + files.push({ + name, + status: changes.length || status !== 'changed' ? status : 'unchanged', + changes, + truncated, + detail: 'keys', + }); + } + return { from: opts.from, to: opts.to, files, changed: files.filter((f) => f.status !== 'unchanged').length }; +} diff --git a/src/web/public/index.html b/src/web/public/index.html index cd2509ca..aac43e48 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -3276,7 +3276,7 @@ <div class="set-row" data-search="config backup now restore list"> <div class="set-row-text"> <span class="set-row-label">Backups</span> - <span class="set-row-desc">Folder and retention changes apply after you save. Restoring puts a backup's files back and makes a copy of the current ones first.</span> + <span class="set-row-desc">Folder and retention changes apply after you save. Restoring puts a backup's files back and makes a copy of the current ones first. Compare shows what changed between two backups, or between a backup and the running config; credential values are never shown.</span> </div> <button class="btn-toolbar btn-sm btn-primary" id="configBackupNowBtn" onclick="app.backupConfigNow()">Back up now</button> </div> diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index 464134b4..6fcab2ac 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -1393,14 +1393,72 @@ Object.assign(CodemanApp.prototype, { return; } const kb = (n) => (n < 1024 ? `${n} B` : `${(n / 1024).toFixed(1)} KB`); - const rows = data.backups.map((b) => { + this._configBackups = data.backups; + const rows = data.backups.map((b, i) => { const when = new Date(b.createdAt).toLocaleString(); const names = b.files.map((f) => escapeHtml(f.name)).join(', '); + const id = escapeHtml(b.id); + // The list is newest first, so the next row is the backup this one followed. + const prev = data.backups[i + 1]; + const vsPrev = prev ? `<button class="btn-toolbar btn-sm" onclick="app.showConfigDiff('${escapeHtml(prev.id)}','${id}')">Changes in this backup</button>` : ''; return `<li><b>${escapeHtml(when)}</b> <span class="set-row-desc">(${escapeHtml(b.reason)}, ${kb(b.bytes)})</span> - <button class="btn-toolbar btn-sm" onclick="app.restoreConfigBackup('${escapeHtml(b.id)}')">Restore</button> + <button class="btn-toolbar btn-sm" onclick="app.restoreConfigBackup('${id}')">Restore</button> + <button class="btn-toolbar btn-sm" onclick="app.showConfigDiff('${id}','current')">Compare with current</button> + ${vsPrev} <div class="set-row-desc">${names}</div></li>`; }); - out.innerHTML = `<div>${data.backups.length} backup${data.backups.length === 1 ? '' : 's'} in <code>${escapeHtml(data.dir)}</code></div><ul>${rows.join('')}</ul>`; + const options = (sel) => [`<option value="current"${sel === 'current' ? ' selected' : ''}>Current config</option>`] + .concat(data.backups.map((b) => `<option value="${escapeHtml(b.id)}"${sel === b.id ? ' selected' : ''}>${escapeHtml(new Date(b.createdAt).toLocaleString())} (${escapeHtml(b.reason)})</option>`)) + .join(''); + const picker = data.backups.length + ? `<div class="set-row-desc">Compare <select id="configDiffFrom">${options(data.backups[0].id)}</select> with <select id="configDiffTo">${options('current')}</select> + <button class="btn-toolbar btn-sm" onclick="app.showConfigDiff(app.$('configDiffFrom').value, app.$('configDiffTo').value)">Show changes</button></div>` + : ''; + out.innerHTML = `<div>${data.backups.length} backup${data.backups.length === 1 ? '' : 's'} in <code>${escapeHtml(data.dir)}</code></div>${picker}<ul>${rows.join('')}</ul><div id="configBackupDiff"></div>`; + }, + + /** What differs between two states of the config files: a backup id or `current` (the running config). */ + async showConfigDiff(from, to) { + const out = this.$('configBackupDiff'); + if (!out) return; + out.textContent = 'Comparing…'; + const res = await this._api(`/api/config-backups/diff?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`); + let body = null; + try { body = res ? await res.json() : null; } catch { /* fall through */ } + if (!res || !res.ok || !body || body.success === false) { + out.textContent = body?.error || 'Could not compare these'; + return; + } + const label = (id) => { + if (id === 'current') return 'the current config'; + const b = (this._configBackups || []).find((x) => x.id === id); + return b ? new Date(b.createdAt).toLocaleString() : id; + }; + const d = body.data; + const verb = { added: 'added', removed: 'removed', changed: 'changed' }; + const files = d.files.filter((f) => f.status !== 'unchanged').map((f) => { + let detail; + if (f.detail === 'bytes') { + detail = '<div class="set-row-desc">Differs (not a key/value file, so no detail).</div>'; + } else { + const rows = f.changes.map((c) => { + const val = (v) => `<code>${escapeHtml(v ?? '')}</code>`; + const text = c.redacted ? `${verb[c.kind]} <span class="set-row-desc">(value hidden: credential)</span>` + : c.kind === 'added' ? `added ${val(c.after)}` + : c.kind === 'removed' ? `removed (was ${val(c.before)})` + : `${val(c.before)} → ${val(c.after)}`; + return `<li><code>${escapeHtml(c.path)}</code>: ${text}</li>`; + }); + const more = f.truncated ? '<li class="set-row-desc">More changes not shown.</li>' : ''; + detail = f.changes.length ? `<ul>${rows.join('')}${more}</ul>` : ''; + } + const whole = f.status === 'added' ? ' (new file)' : f.status === 'removed' ? ' (file gone)' : ''; + return `<div><b>${escapeHtml(f.name)}</b>${whole}${detail}</div>`; + }); + const same = d.files.filter((f) => f.status === 'unchanged').map((f) => escapeHtml(f.name)); + out.innerHTML = `<div><b>${escapeHtml(label(d.from))}</b> → <b>${escapeHtml(label(d.to))}</b>: ${ + d.changed ? `${d.changed} file${d.changed === 1 ? '' : 's'} differ` : 'no differences'}</div>${files.join('')}${ + same.length ? `<div class="set-row-desc">Unchanged: ${same.join(', ')}</div>` : ''}`; }, async backupConfigNow() { diff --git a/src/web/routes/config-backup-routes.ts b/src/web/routes/config-backup-routes.ts index 8ee681e1..fd0e2c7d 100644 --- a/src/web/routes/config-backup-routes.ts +++ b/src/web/routes/config-backup-routes.ts @@ -4,10 +4,14 @@ * GET /api/config-backups — settings in effect, the folder, and the snapshots in it. * POST /api/config-backups — snapshot now (a manual request always makes one). * POST /api/config-backups/:id/restore — put a snapshot's files back (after a `pre-restore` snapshot). + * GET /api/config-backups/diff?from=&to= — what differs between two snapshots, or a snapshot and the + * running config (`current`, the default for `to`). * * The files include credentials (`.env`, `users.json`, endpoint keys), so in multi-user mode all three - * are admin only. Responses carry file NAMES and sizes, never file content. Restore replaces files the - * running server may have cached: `restartRequired` says which ones only apply after a restart. + * are admin only. Responses carry file NAMES and sizes, never file content, except the diff, which shows + * changed values but never a credential's (`.env`, `users.json`, `push-keys.json` and any key that looks + * like a token/secret/password are reported as "changed" only). Restore replaces files the running server + * may have cached: `restartRequired` says which ones only apply after a restart. */ import { createRequire } from 'node:module'; @@ -18,10 +22,12 @@ import { isMultiUserMode } from '../../config/multiuser.js'; import { getDataDir } from '../../config/instance.js'; import { CONFIG_BACKUP_FILES, + CONFIG_DIFF_CURRENT, ConfigBackupCorruptError, ConfigBackupNotFoundError, createConfigBackup, defaultConfigBackupDir, + diffConfigBackups, listConfigBackups, pruneConfigBackups, readConfigBackupSettings, @@ -30,6 +36,7 @@ import { type ConfigBackupResult, type ConfigBackupSettings, type ConfigBackupSummary, + type ConfigDiff, } from '../../config-backup.js'; // Only recorded in each manifest; a missing package.json just leaves it blank. @@ -113,6 +120,37 @@ export function registerConfigBackupRoutes(app: FastifyInstance): void { } }); + app.get('/api/config-backups/diff', async (req, reply): Promise<ApiResponse<ConfigDiff>> => { + const denied = deny(req, reply); + if (denied) return denied; + const q = req.query as { from?: string; to?: string }; + if (typeof q.from !== 'string' || !q.from) { + reply.code(400); + return createErrorResponse(ApiErrorCode.INVALID_INPUT, '`from` is required (a backup id or "current")'); + } + try { + const { dir } = await readEffectiveBackupSettings(); + const data = diffConfigBackups({ + dataDir: getDataDir(), + backupDir: dir, + from: q.from, + to: typeof q.to === 'string' && q.to ? q.to : CONFIG_DIFF_CURRENT, + }); + return { success: true, data }; + } catch (err) { + if (err instanceof ConfigBackupNotFoundError) { + reply.code(404); + return createErrorResponse(ApiErrorCode.NOT_FOUND, err.message); + } + if (err instanceof ConfigBackupCorruptError) { + reply.code(409); + return createErrorResponse(ApiErrorCode.CONFLICT, err.message); + } + reply.code(500); + return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Diff failed: ${getErrorMessage(err)}`); + } + }); + app.post('/api/config-backups/:id/restore', async (req, reply) => { const denied = deny(req, reply); if (denied) return denied; diff --git a/test/config-backup.test.ts b/test/config-backup.test.ts index d409ae9d..fefcb16b 100644 --- a/test/config-backup.test.ts +++ b/test/config-backup.test.ts @@ -23,6 +23,7 @@ import { ConfigBackupNotFoundError, createConfigBackup, defaultConfigBackupDir, + diffConfigBackups, listConfigBackups, normalizeBackupDir, pruneConfigBackups, @@ -295,3 +296,95 @@ describe('restoreConfigBackup', () => { expect(existsSync(join(data, 'state.json'))).toBe(false); }); }); + +describe('diffConfigBackups', () => { + const snap = (now: number) => { + const r = createConfigBackup({ dataDir: data, backupDir: backups, reason: 't', force: true, now }); + if (r.status !== 'created') throw new Error('expected a snapshot'); + return r.id; + }; + const diff = (from: string, to: string) => diffConfigBackups({ dataDir: data, backupDir: backups, from, to }); + const file = (d: ReturnType<typeof diff>, name: string) => d.files.find((f) => f.name === name)!; + + it('lists key-level changes between two backups', () => { + put('settings.json', JSON.stringify({ displayName: 'a', theme: { mode: 'dark' }, tags: ['x'] })); + const a = snap(T0); + put('settings.json', JSON.stringify({ displayName: 'b', theme: { mode: 'dark' }, tags: ['x', 'y'], extra: 1 })); + const b = snap(T0 + 1000); + const f = file(diff(a, b), 'settings.json'); + expect(f.status).toBe('changed'); + expect(f.changes).toEqual([ + { path: 'displayName', kind: 'changed', before: '"a"', after: '"b"' }, + { path: 'extra', kind: 'added', after: '1' }, + { path: 'tags[1]', kind: 'added', after: '"y"' }, + ]); + }); + + it('compares a backup with the running config, and reports files that appeared or vanished', () => { + put('settings.json', '{"a":1}'); + put('webhook.json', '{"url":"u"}'); + const a = snap(T0); + put('settings.json', '{"a":2}'); + rmSync(join(data, 'webhook.json')); + put('intents.json', '{"i":true}'); + const d = diff(a, 'current'); + expect(d.changed).toBe(3); + expect(file(d, 'settings.json').changes).toEqual([{ path: 'a', kind: 'changed', before: '1', after: '2' }]); + expect(file(d, 'webhook.json').status).toBe('removed'); + expect(file(d, 'intents.json').status).toBe('added'); + expect(diff(a, a).changed).toBe(0); + }); + + it('never returns a credential value, only that it differs', () => { + put('.env', 'SECRET_ONE=hunter2\nOTHER=abc\n'); + put('users.json', JSON.stringify({ admin: { name: 'root' } })); + put('custom-model-hosts.json', JSON.stringify([{ name: 'h', apiKey: 'sk-old', url: 'http://x' }])); + const a = snap(T0); + put('.env', 'SECRET_ONE=hunter3\nOTHER=abd\n'); + put('users.json', JSON.stringify({ admin: { name: 'rooted' } })); + put('custom-model-hosts.json', JSON.stringify([{ name: 'h', apiKey: 'sk-new', url: 'http://y' }])); + const d = diff(a, 'current'); + const json = JSON.stringify(d); + for (const secret of ['hunter', 'abc', 'abd', 'root', 'sk-old', 'sk-new']) expect(json).not.toContain(secret); + expect(file(d, '.env').changes.every((c) => c.redacted)).toBe(true); + expect(file(d, 'custom-model-hosts.json').changes).toEqual([ + { path: '[0].apiKey', kind: 'changed', redacted: true }, + { path: '[0].url', kind: 'changed', before: '"http://x"', after: '"http://y"' }, + ]); + }); + + it('does not count a self-refreshed discovery timestamp as a difference', () => { + put('custom-model-hosts.json', JSON.stringify([{ name: 'h', lastDiscoveredAt: 1 }])); + const a = snap(T0); + put('custom-model-hosts.json', JSON.stringify([{ name: 'h', lastDiscoveredAt: 2 }])); + const d = diff(a, 'current'); + expect(file(d, 'custom-model-hosts.json').status).toBe('unchanged'); + expect(d.changed).toBe(0); + }); + + it('reports a file that is not JSON as differing, without its content', () => { + put('settings.json', 'not json at all'); + const a = snap(T0); + put('settings.json', 'still not json'); + const f = file(diff(a, 'current'), 'settings.json'); + expect(f).toMatchObject({ status: 'changed', detail: 'bytes', changes: [] }); + }); + + it('caps the changes listed per file', () => { + put('settings.json', '{}'); + const a = snap(T0); + put('settings.json', JSON.stringify(Object.fromEntries(Array.from({ length: 250 }, (_, i) => [`k${i}`, i])))); + const f = file(diff(a, 'current'), 'settings.json'); + expect(f.changes).toHaveLength(200); + expect(f.truncated).toBe(true); + }); + + it('refuses unknown ids, path traversal and a tampered snapshot', () => { + put('settings.json', '{"a":1}'); + const a = snap(T0); + expect(() => diff('cfg-20200101-000000', 'current')).toThrow(ConfigBackupNotFoundError); + expect(() => diff('../etc', 'current')).toThrow(ConfigBackupNotFoundError); + writeFileSync(join(backups, a, 'settings.json'), '{"a":99}'); + expect(() => diff(a, 'current')).toThrow(ConfigBackupCorruptError); + }); +}); diff --git a/test/routes/config-backup-routes.test.ts b/test/routes/config-backup-routes.test.ts index b8b4501b..cc76cb35 100644 --- a/test/routes/config-backup-routes.test.ts +++ b/test/routes/config-backup-routes.test.ts @@ -106,6 +106,7 @@ describe('/api/config-backups', () => { ['GET', '/api/config-backups'], ['POST', '/api/config-backups'], ['POST', '/api/config-backups/cfg-20261001-120000/restore'], + ['GET', '/api/config-backups/diff?from=cfg-20261001-120000'], ] as const) { expect((await app.inject({ method, url })).statusCode, `${method} ${url}`).toBe(403); } @@ -119,3 +120,25 @@ describe('/api/config-backups', () => { expect((await app.inject({ method: 'GET', url: '/api/config-backups' })).json().success).toBe(true); }); }); + +describe('GET /api/config-backups/diff', () => { + it('compares a backup with the running config, defaulting `to` to current', async () => { + const { app } = await createRouteTestHarness(registerConfigBackupRoutes); + const made = (await app.inject({ method: 'POST', url: '/api/config-backups' })).json().data; + settings({ displayName: 'after' }); + const res = await app.inject({ method: 'GET', url: `/api/config-backups/diff?from=${made.id}` }); + expect(res.statusCode).toBe(200); + const d = res.json().data; + expect(d.to).toBe('current'); + expect(d.files.find((f: { name: string }) => f.name === 'settings.json').changes).toEqual([ + { path: 'displayName', kind: 'changed', before: '"before"', after: '"after"' }, + ]); + }); + + it('400 without `from`, 404 for an unknown backup', async () => { + const { app } = await createRouteTestHarness(registerConfigBackupRoutes); + expect((await app.inject({ method: 'GET', url: '/api/config-backups/diff' })).statusCode).toBe(400); + const missing = await app.inject({ method: 'GET', url: '/api/config-backups/diff?from=cfg-20200101-000000' }); + expect(missing.statusCode).toBe(404); + }); +}); From 247740fc7750b79792c176d0a34a4b0085b912f3 Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Sun, 18 Oct 2026 20:20:38 +0800 Subject: [PATCH 21/96] fix(config-backup): redact webhook and intents, bounded folder probe, per-instance origin, reload intents on restore Review round on #661: webhook.json and intents.json are redacted in compare and URL values lose userinfo/query; a custom folder is probed (bounded, pastCap) before each pass and in each route and only created when its parent exists; manifests record an origin and change check, prune, list, restore and diff are scoped to it; a restore reloads IntentStore, lists untouched files and treats settings.json as restart-required; remote-cases.json is tracked; stale .tmp-cfg-* folders are swept and a failed restore unlinks its temp; the group is hidden from non-admins; an empty days field saves 30. --- CLAUDE.md | 2 +- docs/wiki/Config-Backups.md | 21 ++++-- src/config-backup.ts | 92 +++++++++++++++++++----- src/intent-store.ts | 5 ++ src/web/public/settings-ui.js | 11 +-- src/web/routes/config-backup-routes.ts | 62 +++++++++++++--- test/config-backup.test.ts | 57 +++++++++++++++ test/routes/config-backup-routes.test.ts | 27 ++++++- 8 files changed, 240 insertions(+), 37 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d0f72db6..f43a20cf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -156,7 +156,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Auto-resume on usage limit** (opt-in per session, top of the Respawn tab): when Claude halts on a subscription limit, `usage-limit-patterns.ts` (pure, unit-tested) parses the reset time and `SessionAutoOps` arms a timer for reset+2min, then sends Esc + `continue`. ⚠️ Respawn cycles are blocked while paused (`isLimitPaused` guard in `onIdleDetected`), which is what prevents `/clear` from wiping the paused conversation. Claude-mode only. → [architecture-invariants#auto-resume-on-usage-limit](docs/architecture-invariants.md#auto-resume-on-usage-limit) -**Config backups** (`src/config-backup.ts`, `routes/config-backup-routes.ts`; `configBackupEnabled` absent = ON, `configBackupDir`, `configBackupKeepCount` 1-500 default 20, `configBackupKeepDays` 0-3650 default 30; `docs/wiki/Config-Backups.md`): timestamped snapshots `cfg-YYYYMMDD-HHMMSS/` (+ `manifest.json` with SHA-256s, `reason`, `appVersion`) of a FIXED allowlist (`CONFIG_BACKUP_FILES`: settings, clis, custom-model-hosts, webhook, intents, linked-cases, remote/docker hosts+cases, webviews, push-keys, users, `.env`) into `<data dir>/backups/config` or a custom absolute folder. ⚠️ `state.json` is deliberately NOT tracked (rewritten per session event; it also holds cron jobs and respawn presets). One snapshot per real content change (digest of the file set vs the latest manifest), at startup + a 5-minute timer, skipped in test mode. ⚠️ Retention (`pruneConfigBackups`) only deletes folders matching the name pattern AND holding a parseable manifest, and never the newest; a custom folder equal to the data dir falls back to the default. ⚠️ Restore verifies every file's SHA-256 BEFORE writing, takes a `pre-restore` snapshot, writes atomically at 0600 and only allowlisted names; the id is validated against the name pattern (no traversal). Snapshots are 0700/0600 because `.env`/`users.json`/`custom-model-hosts.json` carry credentials; all routes are admin-only in multi-user mode. → [docs/wiki/Config-Backups.md](docs/wiki/Config-Backups.md) +**Config backups** (`src/config-backup.ts`, `routes/config-backup-routes.ts`; `configBackupEnabled` absent = ON, `configBackupDir`, `configBackupKeepCount` 1-500 default 20, `configBackupKeepDays` 0-3650 default 30; `docs/wiki/Config-Backups.md`): timestamped snapshots `cfg-YYYYMMDD-HHMMSS/` (+ `manifest.json` with SHA-256s, `reason`, `appVersion`) of a FIXED allowlist (`CONFIG_BACKUP_FILES`: settings, clis, custom-model-hosts, webhook, intents, linked-cases, remote/docker hosts+cases (incl. `remote-cases.json`), webviews, push-keys, users, `.env`) into `<data dir>/backups/config` or a custom absolute folder. ⚠️ `state.json` is deliberately NOT tracked (rewritten per session event; it also holds cron jobs and respawn presets). One snapshot per real content change (digest of the file set vs the latest manifest), at startup + a 5-minute timer, skipped in test mode. ⚠️ Retention (`pruneConfigBackups`) only deletes folders matching the name pattern AND holding a parseable manifest, and never the newest; a custom folder equal to the data dir falls back to the default. ⚠️ Restore verifies every file's SHA-256 BEFORE writing, takes a `pre-restore` snapshot, writes atomically at 0600 and only allowlisted names; the id is validated against the name pattern (no traversal). Snapshots are 0700/0600 because `.env`/`users.json`/`custom-model-hosts.json` carry credentials; all routes are admin-only in multi-user mode. ⚠️ A custom folder is probed with `probePathKind(dir, { pastCap: true })` before every pass and in each route (#516: it may be a network mount; `unknown` skips the pass / answers 503 `OPERATION_FAILED`), and a missing custom folder is only created when its parent is present. ⚠️ Each manifest records an `origin` (hostname + data dir); change check, prune, list, restore and diff are scoped to it so instances sharing one folder do not snapshot or prune each other. Restoring `intents.json` calls `intentStore.reload()`; `settings.json` applies its watcher toggles after a restart. → [docs/wiki/Config-Backups.md](docs/wiki/Config-Backups.md) **Plan-usage chip** (`showPlanUsageLimits`, per-device: desktop default **ON**, handhelds OFF): resolve DISPLAY only through `planUsageChipEnabled()` (settings-ui.js). The same setting is the server-side COLLECTION switch, read fresh by `readPlanUsageTelemetryEnabled()` (hooks-config.ts) at every claude create/respawn. ⚠️ An ABSENT key reads as ON in the reader; `GET /api/settings` must never write. ⚠️ A save sends `showPlanUsageLimits` ONLY when it flips the chip on that device (`planUsageCollectionFlip()`), or a phone switches collection off for every desktop. Claude data comes from the statusLine exporter, injected as an EPHEMERAL `claude --settings` flag (`resolveStatusLineCliCommand`), never written to disk, WRAPPING a user's own statusLine, posting to `POST /api/status-telemetry`. Codex comes from a read-only `account/rateLimits/read` poll (main bucket only). The statusline's `prompt_cache` object (Claude Code v2.1.251+) is kept by `StatusTelemetrySchema` (every documented scalar field, null-tolerant; `parsePromptCache()` in usage-telemetry.ts normalizes only the stored subset) and shown in the footer as `cache:until HH:MM` / `cache:cold` (only when the user has no statusline of their own) and in Session Options → Respawn (`#promptCacheStatus`, fed by `Session.promptCache` in `toState()` plus the change-only `session:promptCache` SSE event, worded by the pure `CodemanPromptCache.format`): warm-until/cold, the re-cache stake, the hit rate, the last miss's causes, and a `Context compacted at HH:MM` note after a compaction or a tool-result clearing (stamped on the first warm report whose `recache_tokens_if_cold` is null after one that carried it, never from `expected_rebuilds`, which Claude Code bumps only at the next request; NEVER an alert). ⚠️ `Session.setPromptCache()` stamps `compactedAt` onto a NEW object (never mutating the parsed report) and the route broadcasts the STORED object; the last report is restored on restart (constructor `promptCache`, like `displayModel`) and the readout is time-aware (a passed `expiresAt` reads cold, not stale-warm). It is the source of truth for the cache TTL, never the plan tier. → [architecture-invariants#plan-usage-chip-statusline-telemetry](docs/architecture-invariants.md#plan-usage-chip-statusline-telemetry), `docs/usage-limits-display-plan.md` diff --git a/docs/wiki/Config-Backups.md b/docs/wiki/Config-Backups.md index 73eab2ed..295a4b9c 100644 --- a/docs/wiki/Config-Backups.md +++ b/docs/wiki/Config-Backups.md @@ -17,7 +17,7 @@ A fixed list of files from the data directory (`~/.codeman/`, or the directory o | `webhook.json` | Webhook notification target. | | `intents.json` | Read My Mind per-case profiles. | | `linked-cases.json` | Registered cases. | -| `remote-hosts.json`, `docker-hosts.json`, `docker-cases.json` | Remote SSH and Docker overlay configuration. | +| `remote-hosts.json`, `remote-cases.json`, `docker-hosts.json`, `docker-cases.json` | Remote SSH and Docker overlay configuration. | | `webviews.json` | Saved dashboard URLs. | | `push-keys.json` | Web-push keys (regenerating them drops every device's subscription). | | `users.json` | Multi-user accounts. **Holds password hashes.** | @@ -122,7 +122,14 @@ Only the tracked names are ever written, whatever a manifest lists. Settings are re-read on the next request, so reload the page. `clis.json`, `users.json`, `.env` and `push-keys.json` are read once per process: **restart Codeman** for those to take -effect (the response lists which). A restore does not touch running sessions. +effect (the response lists which). Restoring `settings.json` also does not re-run the switches +that saving in Settings applies at once (file watchers and the like), so those apply after a +restart, and `settings.json` is listed with the restart-required files. A restore does not touch +running sessions. + +When the backup folder is shared (a synced folder used by two machines, or prod and a beta), +each snapshot records the host and data directory that made it, and every instance only +snapshots, lists, prunes, compares and restores its own. You can also restore by hand: the files in a backup folder are the originals, so copying one back over the file in the data directory is equivalent (stop Codeman first if you are replacing @@ -133,7 +140,8 @@ over the file in the data directory is equivalent (stop Codeman first if you are Before you restore, or to find out what changed and when, use **Compare** in the list: - **Compare with current** on a backup shows what differs between that backup and the config - running now, which is also what a restore of it would undo. + running now. A restore puts back the files the backup holds; files created since are left as + they are, and the restore response names them (`untouched`). - **Changes in this backup** shows what differs between the backup before it and this one. - The **Compare … with … Show changes** pickers compare any two backups, or a backup with the current config. @@ -144,9 +152,10 @@ that are the same are listed on one line. A file that is not JSON shows only tha a file with more than 200 changes is cut short, so use the files themselves for a full diff. A self-refreshed discovery timestamp in `custom-model-hosts.json` is not counted as a change. -**Credential values are never shown.** `.env`, `users.json` and `push-keys.json` list only the -names that changed, and in every other file a key that looks like a token, secret, password, -credential or key is shown as "changed" without its value. +**Credential values are never shown.** `.env`, `users.json`, `push-keys.json`, `webhook.json` (the +URL is a bearer secret) and `intents.json` (captured prompts) list only the names that changed, and in every other file a key that looks like a token, secret, password, +credential or key is shown as "changed" without its value. URL values lose any `user:password@`, +query and fragment. ## Security diff --git a/src/config-backup.ts b/src/config-backup.ts index f4c67ffd..befbfc7c 100644 --- a/src/config-backup.ts +++ b/src/config-backup.ts @@ -25,6 +25,7 @@ import { createHash } from 'node:crypto'; import { chmodSync, existsSync, + lstatSync, mkdirSync, readdirSync, readFileSync, @@ -33,7 +34,7 @@ import { statSync, writeFileSync, } from 'node:fs'; -import { homedir } from 'node:os'; +import { homedir, hostname } from 'node:os'; import { isAbsolute, join, resolve, sep } from 'node:path'; /** @@ -55,6 +56,7 @@ export const CONFIG_BACKUP_FILES = [ 'intents.json', 'linked-cases.json', 'remote-hosts.json', + 'remote-cases.json', 'docker-hosts.json', 'docker-cases.json', 'webviews.json', @@ -94,6 +96,8 @@ export interface ConfigBackupManifest { appVersion: string; /** Digest of the set of files, so an unchanged state is not snapshotted twice. */ hash: string; + /** Host and data dir that made it, so instances sharing one folder keep to their own snapshots. */ + origin?: string; files: ConfigBackupFile[]; } @@ -152,6 +156,9 @@ export function resolveConfigBackupDir(settings: ConfigBackupSettings, dataDir: return dir; } +/** Who owns a snapshot: this machine's name plus the data dir (prod and a beta differ by the latter). */ +export const configBackupOrigin = (dataDir: string): string => `${hostname()}:${resolve(dataDir)}`; + const sha256 = (data: Buffer | string): string => createHash('sha256').update(data).digest('hex'); /** @@ -196,21 +203,25 @@ function stamp(now: number): string { return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}`; } -function readManifest(dir: string, id: string): ConfigBackupManifest | null { +function readManifest(dir: string, id: string, origin?: string): ConfigBackupManifest | null { if (!SNAPSHOT_ID_RE.test(id)) return null; try { const m = JSON.parse(readFileSync(join(dir, id, MANIFEST), 'utf8')) as ConfigBackupManifest; if (!m || m.id !== id || !Array.isArray(m.files) || typeof m.createdAt !== 'number' || typeof m.hash !== 'string') { return null; } + if (origin !== undefined && m.origin !== origin) return null; return m; } catch { return null; } } -/** Snapshots this module made in `dir`, newest first. Foreign folders and files are ignored. */ -export function listConfigBackups(dir: string): ConfigBackupSummary[] { +/** + * Snapshots this module made in `dir`, newest first. Foreign folders and files are ignored, and so + * are other instances' snapshots when `origin` is given (see {@link configBackupOrigin}). + */ +export function listConfigBackups(dir: string, origin?: string): ConfigBackupSummary[] { let names: string[]; try { names = readdirSync(dir); @@ -219,7 +230,7 @@ export function listConfigBackups(dir: string): ConfigBackupSummary[] { } const out: ConfigBackupSummary[] = []; for (const id of names) { - const m = readManifest(dir, id); + const m = readManifest(dir, id, origin); if (!m) continue; const files = m.files.map((f) => ({ name: f.name, bytes: f.bytes })); out.push({ @@ -267,9 +278,10 @@ export function createConfigBackup(opts: CreateConfigBackupOptions): ConfigBacku if (present.length === 0) return { status: 'empty' }; const hash = sha256(present.map((f) => `${f.name}\0${f.change}`).join('\n')); - const existing = listConfigBackups(opts.backupDir); + const origin = configBackupOrigin(opts.dataDir); + const existing = listConfigBackups(opts.backupDir, origin); if (!opts.force && existing[0]) { - const latest = readManifest(opts.backupDir, existing[0].id); + const latest = readManifest(opts.backupDir, existing[0].id, origin); if (latest && latest.hash === hash) return { status: 'unchanged', id: latest.id }; } @@ -288,6 +300,7 @@ export function createConfigBackup(opts: CreateConfigBackupOptions): ConfigBacku reason: opts.reason, appVersion: opts.appVersion ?? '', hash, + origin, files: present.map((f) => ({ name: f.name, bytes: f.data.length, sha256: f.sha })), }; for (const f of present) writeFileSync(join(tmp, f.name), f.data, { mode: 0o600 }); @@ -305,9 +318,26 @@ export function createConfigBackup(opts: CreateConfigBackupOptions): ConfigBacku return { status: 'created', id, files: present.length, bytes: present.reduce((n, f) => n + f.data.length, 0) }; } +/** Remove `.tmp-cfg-*` build folders a crashed snapshot left behind (older than an hour, so a live one on a shared folder is safe). */ +export function sweepConfigBackupTemps(dir: string, now: number = Date.now()): void { + let names: string[]; + try { + names = readdirSync(dir); + } catch { + return; + } + for (const n of names) { + if (!/^\.tmp-cfg-\d{8}-\d{6}(?:-\d{1,3})?-\d+$/.test(n)) continue; + const st = lstatSync(join(dir, n), { throwIfNoEntry: false }); + if (st && now - st.mtimeMs > 3_600_000) rmSync(join(dir, n), { recursive: true, force: true }); + } +} + export interface PruneOptions { keepCount: number; keepDays: number; + /** Only this instance's snapshots are pruned; omitted = every snapshot in the folder. */ + origin?: string; now?: number; } @@ -317,7 +347,7 @@ export interface PruneOptions { */ export function pruneConfigBackups(dir: string, opts: PruneOptions): string[] { const now = opts.now ?? Date.now(); - const all = listConfigBackups(dir); + const all = listConfigBackups(dir, opts.origin); const cutoff = opts.keepDays > 0 ? now - opts.keepDays * 86_400_000 : null; const removed: string[] = []; all.forEach((b, index) => { @@ -343,6 +373,8 @@ export class ConfigBackupCorruptError extends Error {} export interface RestoreResult { restored: string[]; + /** Tracked files present now that the snapshot did not hold; a restore leaves them as they are. */ + untouched: string[]; /** The snapshot taken of the live files before they were overwritten. */ safetySnapshot: string | null; } @@ -359,7 +391,7 @@ export function restoreConfigBackup(opts: { appVersion?: string; now?: number; }): RestoreResult { - const manifest = readManifest(opts.backupDir, opts.id); + const manifest = readManifest(opts.backupDir, opts.id, configBackupOrigin(opts.dataDir)); if (!manifest) throw new ConfigBackupNotFoundError(`No config backup named ${opts.id}`); const allowed = new Set<string>(CONFIG_BACKUP_FILES); @@ -392,11 +424,20 @@ export function restoreConfigBackup(opts: { for (const f of loaded) { const target = join(opts.dataDir, f.name); const tmp = `${target}.restore-${process.pid}`; - writeFileSync(tmp, f.data, { mode: 0o600 }); - renameSync(tmp, target); + try { + writeFileSync(tmp, f.data, { mode: 0o600 }); + renameSync(tmp, target); + } catch (err) { + rmSync(tmp, { force: true }); + throw err; + } restored.push(f.name); } - return { restored, safetySnapshot: safety.status === 'created' ? safety.id : null }; + // A restore never deletes: tracked files the snapshot did not hold stay as they are. + const untouched = CONFIG_BACKUP_FILES.filter( + (n) => !restored.includes(n) && lstatSync(join(opts.dataDir, n), { throwIfNoEntry: false })?.isFile() + ); + return { restored, untouched, safetySnapshot: safety.status === 'created' ? safety.id : null }; } /** The live files in the data dir, as a side of a diff. */ @@ -436,7 +477,13 @@ const MAX_DIFF_CHANGES = 200; const MAX_DIFF_VALUE_CHARS = 200; /** Files whose every value is a credential or key: only names are ever shown. */ -const REDACT_ALL_FILES: ReadonlySet<string> = new Set(['.env', 'users.json', 'push-keys.json']); +const REDACT_ALL_FILES: ReadonlySet<string> = new Set([ + '.env', + 'users.json', + 'push-keys.json', + 'webhook.json', + 'intents.json', +]); /** Elsewhere a value is hidden when its own key looks like a credential. */ const SECRET_KEY_RE = /(token|secret|passw|credential|auth|key$)/i; @@ -478,6 +525,19 @@ function leavesOf(name: string, data: Buffer): Leaves | null { } } +/** A URL value loses its userinfo, query and fragment: they carry passwords and tokens. */ +function scrubUrl(jsonText: string): string { + try { + const v = JSON.parse(jsonText) as unknown; + if (typeof v !== 'string' || !/^[a-z][a-z0-9+.-]*:\/\//i.test(v)) return jsonText; + const u = new URL(v); + if (!u.username && !u.password && !u.search && !u.hash) return jsonText; + return JSON.stringify(`${u.protocol}//${u.host}${u.pathname}`); + } catch { + return jsonText; + } +} + const clip = (s: string): string => (s.length > MAX_DIFF_VALUE_CHARS ? `${s.slice(0, MAX_DIFF_VALUE_CHARS)}…` : s); function diffLeaves(name: string, a: Leaves, b: Leaves): { changes: ConfigDiffChange[]; truncated: boolean } { @@ -494,8 +554,8 @@ function diffLeaves(name: string, a: Leaves, b: Leaves): { changes: ConfigDiffCh changes.push({ path, kind, - ...(before !== undefined && { before: clip(before) }), - ...(after !== undefined && { after: clip(after) }), + ...(before !== undefined && { before: clip(scrubUrl(before)) }), + ...(after !== undefined && { after: clip(scrubUrl(after)) }), }); } } @@ -516,7 +576,7 @@ function loadDiffSide(side: string, dataDir: string, backupDir: string): Map<str } return out; } - const manifest = readManifest(backupDir, side); + const manifest = readManifest(backupDir, side, configBackupOrigin(dataDir)); if (!manifest) throw new ConfigBackupNotFoundError(`No config backup named ${side}`); const allowed = new Set<string>(CONFIG_BACKUP_FILES); for (const f of manifest.files) { diff --git a/src/intent-store.ts b/src/intent-store.ts index d1592bf9..432a318f 100644 --- a/src/intent-store.ts +++ b/src/intent-store.ts @@ -108,6 +108,11 @@ export class IntentStore { // ----- Public API ----- + /** Drop the in-memory copy so the next call re-reads intents.json (after a config restore replaced it). */ + reload(): void { + this.profiles = null; + } + /** * The profile for a session's case. Never persists on read: an absent * profile returns an empty transient one (`updatedAt: 0`). diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index 6fcab2ac..78f41506 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -1364,13 +1364,13 @@ Object.assign(CodemanApp.prototype, { * wired to `codeman:me` for the same late-resolving role as the groups above. */ _applyDoctorAdminGate() { - const group = document.getElementById('doctorGroup'); - if (!group) return; const me = window.__codemanUser || {}; - group.style.display = me.multiUser && me.role !== 'admin' ? 'none' : ''; + for (const id of ['doctorGroup', 'configBackupGroup']) { + const group = document.getElementById(id); + if (group) group.style.display = me.multiUser && me.role !== 'admin' ? 'none' : ''; + } }, - /** Preview (apply=false) or run (apply=true) the MCP server sync across enabled CLIs. */ // ── Config backups ────────────────────────────────────────────────────────── // Server-side files, so these read the SAVED settings: a folder or retention edit applies after Save. @@ -1496,6 +1496,7 @@ Object.assign(CodemanApp.prototype, { this.loadConfigBackups(); }, + /** Preview (apply=false) or run (apply=true) the MCP server sync across enabled CLIs. */ async mcpSync(apply) { const out = this.$('mcpSyncResult'); const show = (html, hint = '') => { @@ -2871,7 +2872,7 @@ Object.assign(CodemanApp.prototype, { configBackupEnabled: document.getElementById('appSettingsConfigBackup').checked, configBackupDir: document.getElementById('appSettingsConfigBackupDir').value.trim(), configBackupKeepCount: Math.min(500, Math.max(1, parseInt(document.getElementById('appSettingsConfigBackupKeepCount').value, 10) || 20)), - configBackupKeepDays: Math.min(3650, Math.max(0, parseInt(document.getElementById('appSettingsConfigBackupKeepDays').value, 10) || 0)), + configBackupKeepDays: Math.min(3650, Math.max(0, Number.isNaN(parseInt(document.getElementById('appSettingsConfigBackupKeepDays').value, 10)) ? 30 : parseInt(document.getElementById('appSettingsConfigBackupKeepDays').value, 10))), readMyMindEnabled: document.getElementById('appSettingsReadMyMind').checked, ultracodeFloatingWindows: document.getElementById('appSettingsUltracodeFloatingWindows').checked, showMultiMonitorButton: document.getElementById('appSettingsShowMultiMonitorButton').checked, diff --git a/src/web/routes/config-backup-routes.ts b/src/web/routes/config-backup-routes.ts index fd0e2c7d..3494d602 100644 --- a/src/web/routes/config-backup-routes.ts +++ b/src/web/routes/config-backup-routes.ts @@ -20,6 +20,9 @@ import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } import { isAdmin, readJsonConfig, SETTINGS_PATH } from '../route-helpers.js'; import { isMultiUserMode } from '../../config/multiuser.js'; import { getDataDir } from '../../config/instance.js'; +import { intentStore } from '../../intent-store.js'; +import { describeUnknownPath, probePathKind } from '../../utils/bounded-path-probe.js'; +import { dirname } from 'node:path'; import { CONFIG_BACKUP_FILES, CONFIG_DIFF_CURRENT, @@ -33,6 +36,8 @@ import { readConfigBackupSettings, resolveConfigBackupDir, restoreConfigBackup, + configBackupOrigin, + sweepConfigBackupTemps, type ConfigBackupResult, type ConfigBackupSettings, type ConfigBackupSummary, @@ -49,7 +54,7 @@ const APP_VERSION: string = (() => { })(); /** Files whose running-process copy only reloads on restart (the rest are read per request). */ -const RESTART_REQUIRED = new Set<string>(['clis.json', 'users.json', '.env', 'push-keys.json']); +const RESTART_REQUIRED = new Set<string>(['clis.json', 'users.json', '.env', 'push-keys.json', 'settings.json']); export interface ConfigBackupStatus { settings: ConfigBackupSettings; @@ -65,6 +70,29 @@ export async function readEffectiveBackupSettings(): Promise<{ settings: ConfigB return { settings, dir: resolveConfigBackupDir(settings, getDataDir()) }; } +/** A user-chosen backup folder that is not safe to touch (bounded probe, as for case paths, #516). */ +export class BackupDirUnavailableError extends Error {} + +/** + * Probe a custom backup folder without blocking the event loop on a dead mount. The default folder + * needs no probe. Returns whether the folder exists; throws when it did not answer, or is not a + * folder. A missing custom folder is only creatable when its parent is present. + */ +export async function probeBackupDir(settings: ConfigBackupSettings, dir: string): Promise<boolean> { + if (!settings.dir || dir === defaultConfigBackupDir(getDataDir())) return true; + const kind = await probePathKind(dir, { pastCap: true }); + if (kind === 'unknown') + throw new BackupDirUnavailableError(describeUnknownPath('Backup folder', dir, { pastCap: true })); + if (kind === 'file') throw new BackupDirUnavailableError(`Backup folder is a file, not a folder: ${dir}`); + if (kind === 'directory') return true; + const parent = await probePathKind(dirname(dir), { pastCap: true }); + if (parent === 'unknown') { + throw new BackupDirUnavailableError(describeUnknownPath('Backup folder', dirname(dir), { pastCap: true })); + } + if (parent !== 'directory') throw new BackupDirUnavailableError(`Backup folder's parent is missing: ${dirname(dir)}`); + return false; +} + /** Snapshot (when changed) and apply retention; shared by the timer and the manual route. */ export async function runConfigBackup( reason: string, @@ -72,6 +100,8 @@ export async function runConfigBackup( ): Promise<{ result: ConfigBackupResult; removed: string[] } | null> { const { settings, dir } = await readEffectiveBackupSettings(); if (!settings.enabled && !opts.force) return null; + await probeBackupDir(settings, dir); + sweepConfigBackupTemps(dir); const result = createConfigBackup({ dataDir: getDataDir(), backupDir: dir, @@ -79,7 +109,11 @@ export async function runConfigBackup( appVersion: APP_VERSION, force: opts.force, }); - const removed = pruneConfigBackups(dir, { keepCount: settings.keepCount, keepDays: settings.keepDays }); + const removed = pruneConfigBackups(dir, { + keepCount: settings.keepCount, + keepDays: settings.keepDays, + origin: configBackupOrigin(getDataDir()), + }); return { result, removed }; } @@ -96,6 +130,13 @@ export function registerConfigBackupRoutes(app: FastifyInstance): void { const denied = deny(req, reply); if (denied) return denied; const { settings, dir } = await readEffectiveBackupSettings(); + let present: boolean; + try { + present = await probeBackupDir(settings, dir); + } catch (err) { + reply.code(503); + return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); + } return { success: true, data: { @@ -103,7 +144,7 @@ export function registerConfigBackupRoutes(app: FastifyInstance): void { dir, defaultDir: defaultConfigBackupDir(getDataDir()), tracked: CONFIG_BACKUP_FILES, - backups: listConfigBackups(dir), + backups: present ? listConfigBackups(dir, configBackupOrigin(getDataDir())) : [], }, }; }); @@ -115,7 +156,7 @@ export function registerConfigBackupRoutes(app: FastifyInstance): void { const out = await runConfigBackup('manual', { force: true }); return { success: true, data: { ...out!.result, removed: out!.removed } }; } catch (err) { - reply.code(500); + reply.code(err instanceof BackupDirUnavailableError ? 503 : 500); return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Backup failed: ${getErrorMessage(err)}`); } }); @@ -129,7 +170,8 @@ export function registerConfigBackupRoutes(app: FastifyInstance): void { return createErrorResponse(ApiErrorCode.INVALID_INPUT, '`from` is required (a backup id or "current")'); } try { - const { dir } = await readEffectiveBackupSettings(); + const { settings, dir } = await readEffectiveBackupSettings(); + await probeBackupDir(settings, dir); const data = diffConfigBackups({ dataDir: getDataDir(), backupDir: dir, @@ -146,7 +188,7 @@ export function registerConfigBackupRoutes(app: FastifyInstance): void { reply.code(409); return createErrorResponse(ApiErrorCode.CONFLICT, err.message); } - reply.code(500); + reply.code(err instanceof BackupDirUnavailableError ? 503 : 500); return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Diff failed: ${getErrorMessage(err)}`); } }); @@ -156,12 +198,16 @@ export function registerConfigBackupRoutes(app: FastifyInstance): void { if (denied) return denied; const { id } = req.params as { id: string }; try { - const { dir } = await readEffectiveBackupSettings(); + const { settings, dir } = await readEffectiveBackupSettings(); + await probeBackupDir(settings, dir); const out = restoreConfigBackup({ dataDir: getDataDir(), backupDir: dir, id, appVersion: APP_VERSION }); + // IntentStore caches the file and rewrites it whole on the next prompt, which would undo the restore. + if (out.restored.includes('intents.json')) intentStore.reload(); return { success: true, data: { restored: out.restored, + untouched: out.untouched, safetySnapshot: out.safetySnapshot, restartRequired: out.restored.filter((n) => RESTART_REQUIRED.has(n)), }, @@ -175,7 +221,7 @@ export function registerConfigBackupRoutes(app: FastifyInstance): void { reply.code(409); return createErrorResponse(ApiErrorCode.CONFLICT, `${err.message}. Nothing was changed.`); } - reply.code(500); + reply.code(err instanceof BackupDirUnavailableError ? 503 : 500); return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Restore failed: ${getErrorMessage(err)}`); } }); diff --git a/test/config-backup.test.ts b/test/config-backup.test.ts index fefcb16b..4e3ffcc7 100644 --- a/test/config-backup.test.ts +++ b/test/config-backup.test.ts @@ -30,6 +30,8 @@ import { readConfigBackupSettings, resolveConfigBackupDir, restoreConfigBackup, + configBackupOrigin, + sweepConfigBackupTemps, } from '../src/config-backup.js'; let root: string; @@ -335,6 +337,21 @@ describe('diffConfigBackups', () => { expect(diff(a, a).changed).toBe(0); }); + it('never returns a webhook URL, captured prompts, or URL userinfo and query', () => { + put('webhook.json', JSON.stringify({ url: 'https://hooks.slack.com/services/T0/B0/oldsecret' })); + put('intents.json', JSON.stringify({ profiles: [{ key: 'k', prompts: ['my password is pw1'] }] })); + put('settings.json', JSON.stringify({ baseUrl: 'https://user:pw@host/p?token=t1#f' })); + const a = snap(T0); + put('webhook.json', JSON.stringify({ url: 'https://hooks.slack.com/services/T0/B0/newsecret' })); + put('intents.json', JSON.stringify({ profiles: [{ key: 'k', prompts: ['my password is pw2'] }] })); + put('settings.json', JSON.stringify({ baseUrl: 'https://user:pw@host/p?token=t2#f' })); + const d = diff(a, 'current'); + const json = JSON.stringify(d); + for (const s of ['secret', 'pw1', 'pw2', 'user:pw', 'token=', 'hooks.slack']) expect(json).not.toContain(s); + expect(file(d, 'webhook.json').changes.every((c) => c.redacted)).toBe(true); + expect(file(d, 'intents.json').changes.every((c) => c.redacted)).toBe(true); + }); + it('never returns a credential value, only that it differs', () => { put('.env', 'SECRET_ONE=hunter2\nOTHER=abc\n'); put('users.json', JSON.stringify({ admin: { name: 'root' } })); @@ -388,3 +405,43 @@ describe('diffConfigBackups', () => { expect(() => diff(a, 'current')).toThrow(ConfigBackupCorruptError); }); }); + +describe('instances sharing one backup folder', () => { + it('keeps to its own snapshots: no churn, no prune, no restore of the other', () => { + const other = join(root, 'other-data'); + mkdirSync(other, { recursive: true }); + put('settings.json', '{"a":1}'); + writeFileSync(join(other, 'settings.json'), '{"b":2}'); + const mine = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 }); + const theirs = createConfigBackup({ dataDir: other, backupDir: backups, reason: 'auto', now: T0 + 1000 }); + expect(theirs.status).toBe('created'); + // Each side's change check compares with its own newest snapshot, so a second tick is a no-op. + expect(createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + 2000 }).status).toBe( + 'unchanged' + ); + expect(createConfigBackup({ dataDir: other, backupDir: backups, reason: 'auto', now: T0 + 3000 }).status).toBe( + 'unchanged' + ); + expect(listConfigBackups(backups, configBackupOrigin(data))).toHaveLength(1); + expect( + pruneConfigBackups(backups, { keepCount: 1, keepDays: 1, now: T0 + 999 * DAY, origin: configBackupOrigin(data) }) + ).toEqual([]); + expect(listConfigBackups(backups)).toHaveLength(2); + const theirId = (theirs as { id: string }).id; + expect(() => restoreConfigBackup({ dataDir: data, backupDir: backups, id: theirId })).toThrow( + ConfigBackupNotFoundError + ); + expect((mine as { id: string }).id).not.toBe(theirId); + }); + + it('a restore reports tracked files it left alone, and sweeps only old temp folders', () => { + put('settings.json', '{"a":1}'); + const made = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 }) as { id: string }; + put('webhook.json', '{"url":"u"}'); + expect(restoreConfigBackup({ dataDir: data, backupDir: backups, id: made.id }).untouched).toEqual(['webhook.json']); + const stale = join(backups, '.tmp-cfg-20200101-000000-99999'); + mkdirSync(stale); + sweepConfigBackupTemps(backups, Date.now() + 2 * 3_600_000); + expect(existsSync(stale)).toBe(false); + }); +}); diff --git a/test/routes/config-backup-routes.test.ts b/test/routes/config-backup-routes.test.ts index cc76cb35..3d2cceba 100644 --- a/test/routes/config-backup-routes.test.ts +++ b/test/routes/config-backup-routes.test.ts @@ -13,6 +13,7 @@ import { registerConfigBackupRoutes } from '../../src/web/routes/config-backup-r import { SETTINGS_PATH } from '../../src/web/route-helpers.js'; import { getDataDir } from '../../src/config/instance.js'; import { defaultConfigBackupDir } from '../../src/config-backup.js'; +import { intentStore } from '../../src/intent-store.js'; let custom: string; const settings = (extra: Record<string, unknown> = {}) => { @@ -84,11 +85,35 @@ describe('/api/config-backups', () => { const d = res.json().data; expect(d.restored).toEqual(expect.arrayContaining(['settings.json', 'clis.json'])); expect(d.restartRequired).toContain('clis.json'); - expect(d.restartRequired).not.toContain('settings.json'); + expect(d.restartRequired).toContain('settings.json'); // its watcher toggles are not re-run by a restore expect(d.safetySnapshot).toBeTruthy(); expect(JSON.parse(readFileSync(SETTINGS_PATH, 'utf8')).displayName).toBe('before'); }); + it('a restored intents.json is not reverted by the next captured prompt', async () => { + const { app } = await createRouteTestHarness(registerConfigBackupRoutes); + const dir = mkdtempSync(join(tmpdir(), 'cfg-backup-intent-')); + try { + intentStore.recordPrompt(undefined, dir, 's1', 'first prompt about the build pipeline'); + const id = (await app.inject({ method: 'POST', url: '/api/config-backups' })).json().data.id; + intentStore.recordPrompt(undefined, dir, 's1', 'second prompt about the build pipeline'); + await app.inject({ method: 'POST', url: `/api/config-backups/${id}/restore` }); + intentStore.recordPrompt(undefined, dir, 's1', 'third prompt about the build pipeline'); + expect(intentStore.getProfile(undefined, dir).recentPrompts).toHaveLength(2); + } finally { + intentStore.deleteProfile(undefined, dir); + rmSync(dir, { recursive: true, force: true }); + } + }); + + it('refuses a custom folder whose parent is missing instead of creating it', async () => { + settings({ configBackupDir: join(custom, 'no', 'such', 'dir') }); + const { app } = await createRouteTestHarness(registerConfigBackupRoutes); + const res = await app.inject({ method: 'POST', url: '/api/config-backups' }); + expect(res.statusCode).toBe(503); + expect(existsSync(join(custom, 'no'))).toBe(false); + }); + it('answers 404 for an unknown backup and 404 for a traversal attempt', async () => { const { app } = await createRouteTestHarness(registerConfigBackupRoutes); for (const id of ['cfg-19990101-000000', '..%2F..%2Fetc', 'photos']) { From 8b27e4beb646bb02caf7bb3570d72f696653d3d2 Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Sun, 18 Oct 2026 21:31:56 +0800 Subject: [PATCH 22/96] fix(config-backup): origin is a stored random id, Settings refills after a settings restore, clis.json reloads Review round 2 on #661: the hostname in the origin orphaned every snapshot when a Docker container was recreated, so the origin is a random id kept in <data dir>/config-backup-id; after restoring settings.json the Settings form is re-read from /api/settings so Save cannot write the old values back; the restore confirm names the files and the credential ones; clis.json is reloaded (reloadCliRegistry) and users.json needs no restart; API reference and route docs corrected. --- CLAUDE.md | 4 +- docs/api-reference.md | 10 ++-- docs/architecture-invariants.md | 2 +- docs/wiki/Config-Backups.md | 9 ++-- src/config-backup.ts | 35 +++++++++++-- src/web/public/settings-ui.js | 13 ++++- src/web/routes/config-backup-routes.ts | 12 +++-- test/config-backup-origin.test.ts | 58 +++++++++++++++++++++ test/config-backup-restore-ui.test.ts | 64 ++++++++++++++++++++++++ test/routes/config-backup-routes.test.ts | 2 +- 10 files changed, 186 insertions(+), 23 deletions(-) create mode 100644 test/config-backup-origin.test.ts create mode 100644 test/config-backup-restore-ui.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index f43a20cf..93e73e78 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -156,7 +156,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Auto-resume on usage limit** (opt-in per session, top of the Respawn tab): when Claude halts on a subscription limit, `usage-limit-patterns.ts` (pure, unit-tested) parses the reset time and `SessionAutoOps` arms a timer for reset+2min, then sends Esc + `continue`. ⚠️ Respawn cycles are blocked while paused (`isLimitPaused` guard in `onIdleDetected`), which is what prevents `/clear` from wiping the paused conversation. Claude-mode only. → [architecture-invariants#auto-resume-on-usage-limit](docs/architecture-invariants.md#auto-resume-on-usage-limit) -**Config backups** (`src/config-backup.ts`, `routes/config-backup-routes.ts`; `configBackupEnabled` absent = ON, `configBackupDir`, `configBackupKeepCount` 1-500 default 20, `configBackupKeepDays` 0-3650 default 30; `docs/wiki/Config-Backups.md`): timestamped snapshots `cfg-YYYYMMDD-HHMMSS/` (+ `manifest.json` with SHA-256s, `reason`, `appVersion`) of a FIXED allowlist (`CONFIG_BACKUP_FILES`: settings, clis, custom-model-hosts, webhook, intents, linked-cases, remote/docker hosts+cases (incl. `remote-cases.json`), webviews, push-keys, users, `.env`) into `<data dir>/backups/config` or a custom absolute folder. ⚠️ `state.json` is deliberately NOT tracked (rewritten per session event; it also holds cron jobs and respawn presets). One snapshot per real content change (digest of the file set vs the latest manifest), at startup + a 5-minute timer, skipped in test mode. ⚠️ Retention (`pruneConfigBackups`) only deletes folders matching the name pattern AND holding a parseable manifest, and never the newest; a custom folder equal to the data dir falls back to the default. ⚠️ Restore verifies every file's SHA-256 BEFORE writing, takes a `pre-restore` snapshot, writes atomically at 0600 and only allowlisted names; the id is validated against the name pattern (no traversal). Snapshots are 0700/0600 because `.env`/`users.json`/`custom-model-hosts.json` carry credentials; all routes are admin-only in multi-user mode. ⚠️ A custom folder is probed with `probePathKind(dir, { pastCap: true })` before every pass and in each route (#516: it may be a network mount; `unknown` skips the pass / answers 503 `OPERATION_FAILED`), and a missing custom folder is only created when its parent is present. ⚠️ Each manifest records an `origin` (hostname + data dir); change check, prune, list, restore and diff are scoped to it so instances sharing one folder do not snapshot or prune each other. Restoring `intents.json` calls `intentStore.reload()`; `settings.json` applies its watcher toggles after a restart. → [docs/wiki/Config-Backups.md](docs/wiki/Config-Backups.md) +**Config backups** (`src/config-backup.ts`, `routes/config-backup-routes.ts`; `configBackupEnabled` absent = ON, `configBackupDir`, `configBackupKeepCount` 1-500 default 20, `configBackupKeepDays` 0-3650 default 30; `docs/wiki/Config-Backups.md`): timestamped snapshots `cfg-YYYYMMDD-HHMMSS/` (+ `manifest.json` with SHA-256s, `reason`, `appVersion`) of a FIXED allowlist (`CONFIG_BACKUP_FILES`: settings, clis, custom-model-hosts, webhook, intents, linked-cases, remote/docker hosts+cases (incl. `remote-cases.json`), webviews, push-keys, users, `.env`) into `<data dir>/backups/config` or a custom absolute folder. ⚠️ `state.json` is deliberately NOT tracked (rewritten per session event; it also holds cron jobs and respawn presets). One snapshot per real content change (digest of the file set vs the latest manifest), at startup + a 5-minute timer, skipped in test mode. ⚠️ Retention (`pruneConfigBackups`) only deletes folders matching the name pattern AND holding a parseable manifest, and never the newest; a custom folder equal to the data dir falls back to the default. ⚠️ Restore verifies every file's SHA-256 BEFORE writing, takes a `pre-restore` snapshot, writes atomically at 0600 and only allowlisted names; the id is validated against the name pattern (no traversal). Snapshots are 0700/0600 because `.env`/`users.json`/`custom-model-hosts.json` carry credentials; all routes are admin-only in multi-user mode. ⚠️ A custom folder is probed with `probePathKind(dir, { pastCap: true })` before every pass and in each route (#516: it may be a network mount; `unknown` skips the pass / answers 503 `OPERATION_FAILED`), and a missing custom folder is only created when its parent is present. ⚠️ Each manifest records an `origin`, a random id kept in `<data dir>/config-backup-id` (NOT the hostname: a Docker container gets a new one per recreate, which orphaned every snapshot); change check, prune, list, restore and diff are scoped to it so instances sharing one folder do not snapshot or prune each other. Restoring `intents.json` calls `intentStore.reload()`; `settings.json` applies its watcher toggles after a restart; Settings re-reads `/api/settings` into the form after a restore of it, since Save would otherwise PUT the old values back. ⚠️ Restoring `clis.json` writes it directly (the one writer besides `mutateRegistryFile()`) and then calls `reloadCliRegistry()`. → [docs/wiki/Config-Backups.md](docs/wiki/Config-Backups.md) **Plan-usage chip** (`showPlanUsageLimits`, per-device: desktop default **ON**, handhelds OFF): resolve DISPLAY only through `planUsageChipEnabled()` (settings-ui.js). The same setting is the server-side COLLECTION switch, read fresh by `readPlanUsageTelemetryEnabled()` (hooks-config.ts) at every claude create/respawn. ⚠️ An ABSENT key reads as ON in the reader; `GET /api/settings` must never write. ⚠️ A save sends `showPlanUsageLimits` ONLY when it flips the chip on that device (`planUsageCollectionFlip()`), or a phone switches collection off for every desktop. Claude data comes from the statusLine exporter, injected as an EPHEMERAL `claude --settings` flag (`resolveStatusLineCliCommand`), never written to disk, WRAPPING a user's own statusLine, posting to `POST /api/status-telemetry`. Codex comes from a read-only `account/rateLimits/read` poll (main bucket only). The statusline's `prompt_cache` object (Claude Code v2.1.251+) is kept by `StatusTelemetrySchema` (every documented scalar field, null-tolerant; `parsePromptCache()` in usage-telemetry.ts normalizes only the stored subset) and shown in the footer as `cache:until HH:MM` / `cache:cold` (only when the user has no statusline of their own) and in Session Options → Respawn (`#promptCacheStatus`, fed by `Session.promptCache` in `toState()` plus the change-only `session:promptCache` SSE event, worded by the pure `CodemanPromptCache.format`): warm-until/cold, the re-cache stake, the hit rate, the last miss's causes, and a `Context compacted at HH:MM` note after a compaction or a tool-result clearing (stamped on the first warm report whose `recache_tokens_if_cold` is null after one that carried it, never from `expected_rebuilds`, which Claude Code bumps only at the next request; NEVER an alert). ⚠️ `Session.setPromptCache()` stamps `compactedAt` onto a NEW object (never mutating the parsed report) and the route broadcasts the STORED object; the last report is restored on restart (constructor `promptCache`, like `displayModel`) and the readout is time-aware (a passed `expiresAt` reads cold, not stale-warm). It is the source of truth for the cache TTL, never the plan tier. → [architecture-invariants#plan-usage-chip-statusline-telemetry](docs/architecture-invariants.md#plan-usage-chip-statusline-telemetry), `docs/usage-limits-display-plan.md` @@ -172,7 +172,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Docker Compose deployment** (`docker/`): Codeman runs in a container and spawns Docker cases as **SIBLING** containers via the host socket, never nested. `resolveDockerDaemonMountSource()` maps HOME bind sources into the daemon's namespace (`CODEMAN_DOCKER_HOST_HOME`); `CODEMAN_CASES_PATH` makes workspaces resolve to the same absolute path on both sides. ⚠️ `CODEMAN_CASES_PATH` must move every consumer: resolve it only via `config/cases-dir.ts`. ⚠️ `.dockerignore` matches whole paths: keep `**/.env` or `docker/.env` secrets ship in the image. ⚠️ Long-form binds create missing sources ROOT-OWNED: `Start-Codeman.sh` pre-creates them, and `docker/entrypoint.sh` (root, `cap_add: [CHOWN, DAC_OVERRIDE, KILL, SETGID, SETUID]` against `cap_drop: ALL`, `KILL` for tini; pinned by the test) fixes ownership then drops to `PUID:PGID` via `setpriv`, never re-owning foreign dirs. ⚠️ Append `/opt/codeman-cli` and `~/.local/bin` to `PATH`, never prepend. ⚠️ `server.Dockerfile`, the compose file and `.env.example` feed the self-updater's environment gate (`docs/docker-self-update.md`). → [architecture-invariants#docker-compose-deployment](docs/architecture-invariants.md#docker-compose-deployment), `docs/docker-compose.md` -**CLI registry** (`src/config/cli-registry/`): every run mode is a `CliEntry` (discovery, launch argv template, env handling, `capabilities`, and the `overlays` behind remote/docker pane commands). **No code outside `stock.ts` may branch on a CLI id**: use a capability field or a NAMED PROFILE (`profiles.ts`); `test/cli-registry-no-id-branching.test.ts` and `test/frontend-cli-no-id-branching.test.ts` enforce it. ⚠️ Config holds typed argv tokens, never shell text; literals are validated at LOAD time and a bad one rejects the whole entry. ⚠️ Keep `external`, `hooks` and `altScreen` independent; never derive one from another. ⚠️ Config regexes (`discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`, `capabilities.workDetect.awaitingLine`, `capabilities.workDetect.blockedLine`, `capabilities.modelDetect.screenLine`) must compile through `compileVersionRegex()`. ⚠️ `privilegedParams[].param` names a LAUNCH PARAM, not the legacy `<Mode>Config` field (bridged only by `launch.legacyConfigAliases`); a wrong name silently clamps nothing. ⚠️ Resolve the registry AT CALL TIME, never in a module-level const. ⚠️ Remote claude/omp arms of `buildRemoteLaunchCommand` are not covered by the pane-command golden. `~/.codeman/clis.json` overrides entries. It is WRITTEN only by the opt-in CLI management routes (`cliManagementEnabled`, default OFF; `/api/clis`, `cli-registry-routes.ts`), and only through `mutateRegistryFile()` in `registry-writer.ts`, which serializes mutations and refuses (409) a file that does not parse or has group/world permission bits rather than overwriting it. Importing the registry still writes nothing. → [architecture-invariants#cli-registry](docs/architecture-invariants.md#cli-registry), `docs/cli-registry.md` +**CLI registry** (`src/config/cli-registry/`): every run mode is a `CliEntry` (discovery, launch argv template, env handling, `capabilities`, and the `overlays` behind remote/docker pane commands). **No code outside `stock.ts` may branch on a CLI id**: use a capability field or a NAMED PROFILE (`profiles.ts`); `test/cli-registry-no-id-branching.test.ts` and `test/frontend-cli-no-id-branching.test.ts` enforce it. ⚠️ Config holds typed argv tokens, never shell text; literals are validated at LOAD time and a bad one rejects the whole entry. ⚠️ Keep `external`, `hooks` and `altScreen` independent; never derive one from another. ⚠️ Config regexes (`discovery.version.regex`, `capabilities.workDetect.workingLine`, `capabilities.workDetect.watchingLine`, `capabilities.workDetect.awaitingLine`, `capabilities.workDetect.blockedLine`, `capabilities.modelDetect.screenLine`) must compile through `compileVersionRegex()`. ⚠️ `privilegedParams[].param` names a LAUNCH PARAM, not the legacy `<Mode>Config` field (bridged only by `launch.legacyConfigAliases`); a wrong name silently clamps nothing. ⚠️ Resolve the registry AT CALL TIME, never in a module-level const. ⚠️ Remote claude/omp arms of `buildRemoteLaunchCommand` are not covered by the pane-command golden. `~/.codeman/clis.json` overrides entries. It is WRITTEN only by the opt-in CLI management routes (`cliManagementEnabled`, default OFF; `/api/clis`, `cli-registry-routes.ts`), and only through `mutateRegistryFile()` in `registry-writer.ts` (the one exception: a config-backup restore of `clis.json`, which then calls `reloadCliRegistry()`), which serializes mutations and refuses (409) a file that does not parse or has group/world permission bits rather than overwriting it. Importing the registry still writes nothing. → [architecture-invariants#cli-registry](docs/architecture-invariants.md#cli-registry), `docs/cli-registry.md` **External CLI modes (OpenCode, Codex, Gemini, Antigravity, Pi, Grok, DeepSeek, OMP, Copilot)**: `isExternalCliMode()` in `session.ts` gates Claude-specific behavior off (Ralph tracker, BashToolParser, token parsing, ❯ readiness; readiness is output stabilization); work detection is per-CLI `capabilities.workDetect` data, not this gate. All nine **require tmux, no direct PTY fallback** (secrets go via socket-scoped `tmux setenv`, never the command line). ⚠️ `run*()` in `session-ui.js` MUST unwrap the `{success,data}` envelope. ⚠️ **Codex uses predictive write-through echo, never the buffer overlay**: `_predictHookOnData` must never `return` (wire path stays byte-identical), and flushed text and a bracketed paste must go out as separate delayed writes. ⚠️ **Pi**: no bypass flag, never invent one; `approveProjectTrust` executes repo code, so it is in the clamp's **materialize** branch; never wire `--api-key`. ⚠️ **Grok**: `alwaysApprove` is stripped for non-granted owners (only-if-sent). ⚠️ **DeepSeek**: the agent is a PROFILE (Run gates on `isDeepSeekRunnable()`); the permission switch is the `DSH_PERMISSION_MODE` env var, so `clampEnvOverridesForOwner()` must DROP `DSH_PERMISSION_MODE`, `DSH_HOME` and `DEEPSEEK_BASE_URL` for non-granted owners; `hooksAvailableForMode()` is per-SESSION for it (pass `sessionHookOptions(session)`) and is never a stand-in for `mode === 'claude'`; answers come from `deepseek-transcript.ts`, paired by header `cwd` + boot window, never newest-mtime. ⚠️ **OMP**: `OMP_AUTH_BROKER_URL`/`_TOKEN` are clamped the same way. ⚠️ **Copilot**: `allowAll` (`--yolo`) is a `privilegedParam` and `COPILOT_HOME`/`COPILOT_ALLOW_ALL`/`COPILOT_PROVIDER_*`/`COPILOT_MODEL` are privileged env keys, and so is every other `COPILOT_*` variable the CLI reads that switches approvals, runs code with no approval row or sends its sign-in token elsewhere (the 1.0.98 list is in `stock.ts`; the prefix admits whatever a later release adds, so re-read the bundle whenever the measured version moves); the resume id must start with a letter or digit (`path-segment`), since `--resume`'s value is optional and `--resume --yolo` would otherwise make `--yolo` its own flag; `--name` is dropped next to `--resume`/`--continue` (Copilot refuses the pair); its mouse DECSETs are stripped (`strip-mux-and-mouse`) so a drag selects and the wheel is forwarded by `_shouldForwardWheelToApp` while `cliMouseTracking` is on; `composerReadyMark` is the footer hint `/ commands · ? help`, NOT `❯` (the folder-trust dialog draws `❯ 1. Yes`); `last-response` reads the pane's visible screen (`transcriptNeedsScreenCapture`) through a per-CLI `PANE_DIALECTS` row, because its stream is cursor moves, not lines (a prompt row scrolled off by a tall answer reads as the answer's tail unless a banner/trust dialog is on screen, and typed composer text is not a prompt); Docker seeds no Copilot credential (`config.json` holds a plain-text token without a keyring). → [architecture-invariants#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek-omp](docs/architecture-invariants.md#external-cli-modes-opencode-codex-gemini-antigravity-pi-grok-deepseek-omp) diff --git a/docs/api-reference.md b/docs/api-reference.md index 4e5b5e4b..dc2fa42a 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1154,18 +1154,18 @@ The result carries server **names** only, never `env` values, `headers` or file ## Config backups -Timestamped snapshots of the files that hold what a user has customized (`src/config-backup.ts`). On by default; a snapshot is taken at startup and whenever the tracked files' content changes (checked every 5 minutes), so there is one entry per real change. All three routes are admin only in multi-user mode (`403`), because the files include credentials. Responses carry file names and sizes, never content. +Timestamped snapshots of the files that hold what a user has customized (`src/config-backup.ts`). On by default; a snapshot is taken at startup and whenever the tracked files' content changes (checked every 5 minutes), so there is one entry per real change. All four routes are admin only in multi-user mode (`403`), because the files include credentials. Responses carry file names and sizes; only the diff returns values, and never a credential's. -Tracked files (a fixed list, relative to the data dir): `settings.json`, `clis.json`, `custom-model-hosts.json`, `webhook.json`, `intents.json`, `linked-cases.json`, `remote-hosts.json`, `docker-hosts.json`, `docker-cases.json`, `webviews.json`, `push-keys.json`, `users.json`, `.env`. Session state (`state.json`, which also holds cron jobs and respawn presets), logs, the hook secret and caches are not. +Tracked files (a fixed list, relative to the data dir): `settings.json`, `clis.json`, `custom-model-hosts.json`, `webhook.json`, `intents.json`, `linked-cases.json`, `remote-hosts.json`, `remote-cases.json`, `docker-hosts.json`, `docker-cases.json`, `webviews.json`, `push-keys.json`, `users.json`, `.env`. Session state (`state.json`, which also holds cron jobs and respawn presets), logs, the hook secret and caches are not. -Settings (synced, in `settings.json`): `configBackupEnabled` (absent = on), `configBackupDir` (absolute path, `~` expanded; empty = `<data dir>/backups/config`), `configBackupKeepCount` (1–500, default 20), `configBackupKeepDays` (0–3650, default 30; `0` = no age limit). Retention only ever deletes snapshot folders Codeman made there, and never the newest one. Each snapshot is a `0700` folder named `cfg-YYYYMMDD-HHMMSS` (server local time; `-2`, `-3`… on a same-second collision) holding the tracked files under their own names plus a `manifest.json` (`id`, `createdAt`, `reason` of `startup`/`auto`/`manual`/`pre-restore`, `appVersion`, and a SHA-256 and size per file), all `0600`. Only folders with that name and a valid manifest are listed, restored or pruned. Full guide: `docs/wiki/Config-Backups.md`. +Settings (synced, in `settings.json`): `configBackupEnabled` (absent = on), `configBackupDir` (absolute path, `~` expanded; empty = `<data dir>/backups/config`), `configBackupKeepCount` (1–500, default 20), `configBackupKeepDays` (0–3650, default 30; `0` = no age limit). Retention only ever deletes snapshot folders Codeman made there, and never the newest one. Each snapshot is a `0700` folder named `cfg-YYYYMMDD-HHMMSS` (server local time; `-2`, `-3`… on a same-second collision) holding the tracked files under their own names plus a `manifest.json` (`id`, `createdAt`, `reason` of `startup`/`auto`/`manual`/`pre-restore`, `appVersion`, an `origin` id, and a SHA-256 and size per file), all `0600`. Only folders with that name and a valid manifest are listed, restored or pruned. Full guide: `docs/wiki/Config-Backups.md`. | Method | Path | Body | Notes | | ------ | ------------------------------------ | ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `GET` | `/api/config-backups` | none | `settings` in effect, `dir`, `defaultDir`, `tracked[]` and `backups[]` (`id`, `createdAt`, `reason`, `appVersion`, `files[]`, `bytes`), newest first. | -| `POST` | `/api/config-backups` | none | Snapshot now, even if nothing changed (`reason: manual`), then apply retention. `status` is `created` or `empty` (nothing tracked exists yet). | +| `POST` | `/api/config-backups` | none | Snapshot now, even if nothing changed (`reason: manual`), then apply retention. `status` is `created` or `empty` (nothing tracked exists yet); `removed[]` lists the ids retention deleted. `503` when a custom folder does not answer. | | `GET` | `/api/config-backups/diff?from=&to=` | none | What differs between two states: `from`/`to` are a backup id or `current` (the running config; the default for `to`). `files[]` (`name`, `status` `added`/`removed`/`changed`/`unchanged`, `changes[]` of `path`, `kind`, `before`/`after`, `truncated`, `detail`) and `changed`. Credential values are never returned (`redacted: true`). `400` without `from`; `404` unknown id; `409` if a file fails its checksum. | -| `POST` | `/api/config-backups/:id/restore` | none | Put a snapshot's files back, atomically, after a `pre-restore` snapshot of the current ones. `404` unknown id; `409 CONFLICT` if a file fails its checksum (nothing changed). `restartRequired[]` names files the running server only reloads on restart. | +| `POST` | `/api/config-backups/:id/restore` | none | Put a snapshot's files back, atomically, after a `pre-restore` snapshot of the current ones. `404` unknown id; `409 CONFLICT` if a file fails its checksum (nothing changed). Returns `restored[]`, `untouched[]` (tracked files present now that the backup did not hold; left as they are), `safetySnapshot` (the `pre-restore` id) and `restartRequired[]` (files the running server only reads at start: `.env`, `push-keys.json`, `settings.json`). | ## Webhook notifications diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index a50539c8..4c9bdbde 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -113,7 +113,7 @@ Further detail: ⚠️ An ADOPTED container may back SEVERAL cases at different Spawn commands are pinned as literal strings in `test/cli-registry-spawn-golden.test.ts`, remote/docker pane commands in `test/location-overlay-commands.test.ts`. ⚠️ That second golden no longer covers **remote claude or remote omp**: both now have their own arm in `buildRemoteLaunchCommand` (a `--session-id || --resume` pair, and `--continue`, so a respawn continues the same conversation) and never reach `defaultRemoteCommandForMode`, which is what that test asserts. Their real pins are `toContain` substrings in `test/tmux-manager.test.ts` and `test/remote-shared-sessions.test.ts`; changing either arm will NOT fail the golden. -⚠️ Anything reading the registry resolves it AT CALL TIME (`sessionModeSchema()`, `allowedEnvPrefixes()`, `dependencyRegistry()`, the resolvers' `searchDirs` thunks) — a module-level const freezes at first import, so a CLI enabled while the server ran moved the run menu but not that surface. `~/.codeman/clis.json` overrides any entry. Importing the registry still has no filesystem side effects: the only writer is `registry-writer.ts`, imported only by `cli-registry-routes.ts` (the opt-in CLI management routes, `cliManagementEnabled`, default OFF). ⚠️ Every write goes through `mutateRegistryFile()`: mutations run one at a time on a single promise chain, each with a unique temp file (three parallel toggles once lost two and 500'd on a shared temp name), and a file the READER would ignore (group/world permission bits) or quarantine (unparseable) is REFUSED with a 409 rather than rewritten, so one Settings click cannot replace a hand-edit or turn a refused file into trusted 0600 config. Only a missing file starts fresh. After a write the registry is reloaded, so changes apply without a restart. User guide: `docs/cli-registry.md`. +⚠️ Anything reading the registry resolves it AT CALL TIME (`sessionModeSchema()`, `allowedEnvPrefixes()`, `dependencyRegistry()`, the resolvers' `searchDirs` thunks) — a module-level const freezes at first import, so a CLI enabled while the server ran moved the run menu but not that surface. `~/.codeman/clis.json` overrides any entry. Importing the registry still has no filesystem side effects: the only writer is `registry-writer.ts`, imported only by `cli-registry-routes.ts` (the opt-in CLI management routes, `cliManagementEnabled`, default OFF). ⚠️ Every write goes through `mutateRegistryFile()` (the one exception is a config-backup restore of `clis.json`, which writes it atomically and calls `reloadCliRegistry()`): mutations run one at a time on a single promise chain, each with a unique temp file (three parallel toggles once lost two and 500'd on a shared temp name), and a file the READER would ignore (group/world permission bits) or quarantine (unparseable) is REFUSED with a 409 rather than rewritten, so one Settings click cannot replace a hand-edit or turn a refused file into trusted 0600 config. Only a missing file starts fresh. After a write the registry is reloaded, so changes apply without a restart. User guide: `docs/cli-registry.md`. ## Session data and lifecycle diff --git a/docs/wiki/Config-Backups.md b/docs/wiki/Config-Backups.md index 295a4b9c..0f819f4a 100644 --- a/docs/wiki/Config-Backups.md +++ b/docs/wiki/Config-Backups.md @@ -122,14 +122,17 @@ Only the tracked names are ever written, whatever a manifest lists. Settings are re-read on the next request, so reload the page. `clis.json`, `users.json`, `.env` and `push-keys.json` are read once per process: **restart Codeman** for those to take -effect (the response lists which). Restoring `settings.json` also does not re-run the switches +effect (the response lists which). `clis.json` is reloaded by the restore itself and `users.json` +is re-read within a second. Restoring `settings.json` also does not re-run the switches that saving in Settings applies at once (file watchers and the like), so those apply after a restart, and `settings.json` is listed with the restart-required files. A restore does not touch running sessions. When the backup folder is shared (a synced folder used by two machines, or prod and a beta), -each snapshot records the host and data directory that made it, and every instance only -snapshots, lists, prunes, compares and restores its own. +each snapshot records a random id that the instance made once and keeps in its data directory +(`config-backup-id`; not the hostname, which a Docker container changes on every recreate). Every +instance only snapshots, lists, prunes, compares and restores its own. Deleting that file makes +the instance a new owner, and its earlier snapshots stop being listed (they stay on disk). You can also restore by hand: the files in a backup folder are the originals, so copying one back over the file in the data directory is equivalent (stop Codeman first if you are replacing diff --git a/src/config-backup.ts b/src/config-backup.ts index befbfc7c..9ac01bad 100644 --- a/src/config-backup.ts +++ b/src/config-backup.ts @@ -21,7 +21,7 @@ * @module config-backup */ -import { createHash } from 'node:crypto'; +import { createHash, randomUUID } from 'node:crypto'; import { chmodSync, existsSync, @@ -34,7 +34,7 @@ import { statSync, writeFileSync, } from 'node:fs'; -import { homedir, hostname } from 'node:os'; +import { homedir } from 'node:os'; import { isAbsolute, join, resolve, sep } from 'node:path'; /** @@ -96,7 +96,7 @@ export interface ConfigBackupManifest { appVersion: string; /** Digest of the set of files, so an unchanged state is not snapshotted twice. */ hash: string; - /** Host and data dir that made it, so instances sharing one folder keep to their own snapshots. */ + /** Id of the data dir that made it (see {@link configBackupOrigin}), so instances sharing one folder keep to their own snapshots. */ origin?: string; files: ConfigBackupFile[]; } @@ -156,8 +156,33 @@ export function resolveConfigBackupDir(settings: ConfigBackupSettings, dataDir: return dir; } -/** Who owns a snapshot: this machine's name plus the data dir (prod and a beta differ by the latter). */ -export const configBackupOrigin = (dataDir: string): string => `${hostname()}:${resolve(dataDir)}`; +const ORIGIN_FILE = 'config-backup-id'; +const ORIGIN_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; + +/** + * Who owns a snapshot: a random id made once and kept in the data dir. A hostname would not do (a + * Docker container gets a new one on every recreate, which orphaned every earlier snapshot), nor + * would the path alone (two machines can share a `$HOME` path). It is a file of its own, not the + * hook secret, and is not tracked or restored. If it cannot be written the id is not persisted, so + * snapshots made meanwhile are not listed later; the data dir being unwritable breaks far more. + */ +export function configBackupOrigin(dataDir: string): string { + const path = join(dataDir, ORIGIN_FILE); + try { + const existing = readFileSync(path, 'utf8').trim(); + if (ORIGIN_RE.test(existing)) return existing; + } catch { + /* missing: made below */ + } + const id = randomUUID(); + try { + mkdirSync(dataDir, { recursive: true }); + writeFileSync(path, id + '\n', { mode: 0o600 }); + } catch { + /* see above */ + } + return id; +} const sha256 = (data: Buffer | string): string => createHash('sha256').update(data).digest('hex'); diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index 78f41506..a09119d3 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -1482,7 +1482,11 @@ Object.assign(CodemanApp.prototype, { }, async restoreConfigBackup(id) { - if (!confirm('Restore this backup? Its files replace the current ones (a copy of the current files is made first). Some files only take effect after a restart.')) return; + const names = ((this._configBackups || []).find((b) => b.id === id)?.files || []).map((f) => f.name); + const secret = names.filter((n) => ['.env', 'users.json', 'push-keys.json', 'custom-model-hosts.json', 'webhook.json'].includes(n)); + const list = names.length ? `\n\nFiles replaced: ${names.join(', ')}` : ''; + const warn = secret.length ? `\n\nThis rolls back credentials and accounts (${secret.join(', ')}): passwords, disabled accounts and keys return to how they were then.` : ''; + if (!confirm(`Restore this backup? Its files replace the current ones (a copy of the current files is made first). Some files only take effect after a restart.${list}${warn}`)) return; const res = await this._apiPost(`/api/config-backups/${encodeURIComponent(id)}/restore`, {}); let body = null; try { body = res ? await res.json() : null; } catch { /* fall through */ } @@ -1492,7 +1496,12 @@ Object.assign(CodemanApp.prototype, { } const d = body.data; const restart = d.restartRequired?.length ? ` Restart Codeman to apply: ${d.restartRequired.join(', ')}.` : ''; - this.showToast(`Restored ${d.restored.length} file${d.restored.length === 1 ? '' : 's'}. Reload this page to see settings.${restart}`, 'success'); + // The form still holds the pre-restore values, and Save would PUT them over the restored file. + if (d.restored.includes('settings.json')) { + await this.loadAppSettingsFromServer(); + this.openAppSettings(); + } + this.showToast(`Restored ${d.restored.length} file${d.restored.length === 1 ? '' : 's'}.${restart}`, 'success'); this.loadConfigBackups(); }, diff --git a/src/web/routes/config-backup-routes.ts b/src/web/routes/config-backup-routes.ts index 3494d602..1ecf9e03 100644 --- a/src/web/routes/config-backup-routes.ts +++ b/src/web/routes/config-backup-routes.ts @@ -7,9 +7,9 @@ * GET /api/config-backups/diff?from=&to= — what differs between two snapshots, or a snapshot and the * running config (`current`, the default for `to`). * - * The files include credentials (`.env`, `users.json`, endpoint keys), so in multi-user mode all three + * The files include credentials (`.env`, `users.json`, endpoint keys), so in multi-user mode all four * are admin only. Responses carry file NAMES and sizes, never file content, except the diff, which shows - * changed values but never a credential's (`.env`, `users.json`, `push-keys.json` and any key that looks + * changed values but never a credential's (`.env`, `users.json`, `push-keys.json`, `webhook.json`, `intents.json` and any key that looks * like a token/secret/password are reported as "changed" only). Restore replaces files the running server * may have cached: `restartRequired` says which ones only apply after a restart. */ @@ -18,6 +18,7 @@ import { createRequire } from 'node:module'; import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js'; import { isAdmin, readJsonConfig, SETTINGS_PATH } from '../route-helpers.js'; +import { reloadCliRegistry } from '../../config/cli-registry/registry.js'; import { isMultiUserMode } from '../../config/multiuser.js'; import { getDataDir } from '../../config/instance.js'; import { intentStore } from '../../intent-store.js'; @@ -53,8 +54,9 @@ const APP_VERSION: string = (() => { } })(); -/** Files whose running-process copy only reloads on restart (the rest are read per request). */ -const RESTART_REQUIRED = new Set<string>(['clis.json', 'users.json', '.env', 'push-keys.json', 'settings.json']); +/** Files whose running-process copy only reloads on restart (the rest are read per request; `users.json` + * is re-read within a second, `clis.json` is reloaded below, `intents.json` by `reload()`). */ +const RESTART_REQUIRED = new Set<string>(['.env', 'push-keys.json', 'settings.json']); export interface ConfigBackupStatus { settings: ConfigBackupSettings; @@ -203,6 +205,8 @@ export function registerConfigBackupRoutes(app: FastifyInstance): void { const out = restoreConfigBackup({ dataDir: getDataDir(), backupDir: dir, id, appVersion: APP_VERSION }); // IntentStore caches the file and rewrites it whole on the next prompt, which would undo the restore. if (out.restored.includes('intents.json')) intentStore.reload(); + // restoreConfigBackup writes clis.json directly (the one writer besides mutateRegistryFile). + if (out.restored.includes('clis.json')) reloadCliRegistry(); return { success: true, data: { diff --git a/test/config-backup-origin.test.ts b/test/config-backup-origin.test.ts new file mode 100644 index 00000000..4db940e5 --- /dev/null +++ b/test/config-backup-origin.test.ts @@ -0,0 +1,58 @@ +/** + * @fileoverview The snapshot origin must survive a hostname change (a Docker container gets a new + * hostname on every recreate) and still differ between data dirs. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const host = vi.hoisted(() => ({ name: 'container-a' })); +vi.mock('node:os', async (importOriginal) => ({ + ...(await importOriginal<typeof import('node:os')>()), + hostname: () => host.name, +})); + +import { + configBackupOrigin, + createConfigBackup, + listConfigBackups, + pruneConfigBackups, + restoreConfigBackup, +} from '../src/config-backup.js'; + +let root: string; +let data: string; +let backups: string; +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'cfg-origin-')); + data = join(root, 'data'); + backups = join(root, 'backups'); + mkdirSync(data, { recursive: true }); + writeFileSync(join(data, 'settings.json'), '{"a":1}'); + host.name = 'container-a'; +}); +afterEach(() => rmSync(root, { recursive: true, force: true })); + +describe('configBackupOrigin', () => { + it('is made once, kept in the data dir, and differs between data dirs', () => { + const a = configBackupOrigin(data); + expect(configBackupOrigin(data)).toBe(a); + expect(readFileSync(join(data, 'config-backup-id'), 'utf8').trim()).toBe(a); + const other = join(root, 'other'); + expect(configBackupOrigin(other)).not.toBe(a); + }); + + it('keeps snapshots listed, restorable and prunable after the hostname changes', () => { + const made = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: 1_000_000 }) as { + id: string; + }; + createConfigBackup({ dataDir: data, backupDir: backups, reason: 'manual', force: true, now: 2_000_000 }); + host.name = 'container-b'; // the container was recreated + const origin = configBackupOrigin(data); + expect(listConfigBackups(backups, origin)).toHaveLength(2); + expect(() => restoreConfigBackup({ dataDir: data, backupDir: backups, id: made.id })).not.toThrow(); + const removed = pruneConfigBackups(backups, { keepCount: 1, keepDays: 0, origin }); + expect(removed).toHaveLength(2); // 3 now: the restore took a pre-restore copy + }); +}); diff --git a/test/config-backup-restore-ui.test.ts b/test/config-backup-restore-ui.test.ts new file mode 100644 index 00000000..3e79e972 --- /dev/null +++ b/test/config-backup-restore-ui.test.ts @@ -0,0 +1,64 @@ +/** + * @fileoverview Restoring settings.json from inside App Settings must refill the form, or Save + * would PUT the pre-restore values back over the restored file. Evaluates the real settings-ui.js + * against a stub app. + */ +import { describe, expect, it } from 'vitest'; +import { readFileSync } from 'node:fs'; +import vm from 'node:vm'; + +const SRC = readFileSync(new URL('../src/web/public/settings-ui.js', import.meta.url), 'utf-8'); + +function makeApp(restored: string[], confirmed = true) { + const calls: string[] = []; + const confirms: string[] = []; + function CodemanApp(this: unknown) {} + const ctx = vm.createContext({ + CodemanApp, + window: {}, + document: {}, + confirm: (m: string) => (confirms.push(m), confirmed), + escapeHtml: (s: string) => s, + }); + vm.runInContext(SRC, ctx); + const app = Object.create((CodemanApp as unknown as { prototype: object }).prototype) as Record<string, unknown>; + Object.assign(app, { + _configBackups: [ + { + id: 'cfg-1', + files: [{ name: 'settings.json' }, { name: 'users.json' }, { name: '.env' }, { name: 'clis.json' }], + }, + ], + _apiPost: async () => ({ + ok: true, + json: async () => ({ success: true, data: { restored, restartRequired: [] } }), + }), + loadAppSettingsFromServer: async () => void calls.push('reload-settings'), + openAppSettings: () => void calls.push('refill-form'), + loadConfigBackups: () => void calls.push('reload-list'), + showToast: () => {}, + }); + return { app, calls, confirms }; +} + +describe('restoreConfigBackup (Settings UI)', () => { + it('re-reads settings into the form when settings.json was restored', async () => { + const { app, calls } = makeApp(['settings.json', 'clis.json']); + await (app.restoreConfigBackup as (id: string) => Promise<void>)('cfg-1'); + expect(calls).toEqual(['reload-settings', 'refill-form', 'reload-list']); + }); + + it('leaves the form alone when settings.json was not part of the restore', async () => { + const { app, calls } = makeApp(['clis.json']); + await (app.restoreConfigBackup as (id: string) => Promise<void>)('cfg-1'); + expect(calls).toEqual(['reload-list']); + }); + + it('names the files being replaced and calls out the credential ones', async () => { + const { app, confirms } = makeApp(['settings.json'], false); + await (app.restoreConfigBackup as (id: string) => Promise<void>)('cfg-1'); + expect(confirms[0]).toContain('settings.json, users.json, .env, clis.json'); + expect(confirms[0]).toContain('credentials'); + expect(confirms[0]).toMatch(/users\.json, \.env\)/); + }); +}); diff --git a/test/routes/config-backup-routes.test.ts b/test/routes/config-backup-routes.test.ts index 3d2cceba..a0a2bc17 100644 --- a/test/routes/config-backup-routes.test.ts +++ b/test/routes/config-backup-routes.test.ts @@ -84,7 +84,7 @@ describe('/api/config-backups', () => { expect(res.statusCode).toBe(200); const d = res.json().data; expect(d.restored).toEqual(expect.arrayContaining(['settings.json', 'clis.json'])); - expect(d.restartRequired).toContain('clis.json'); + expect(d.restartRequired).not.toContain('clis.json'); // reloaded by the restore itself expect(d.restartRequired).toContain('settings.json'); // its watcher toggles are not re-run by a restore expect(d.safetySnapshot).toBeTruthy(); expect(JSON.parse(readFileSync(SETTINGS_PATH, 'utf8')).displayName).toBe('before'); From a0b957045738a6f6c3ecc6cf2a088b12c7c5106b Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Sun, 18 Oct 2026 22:31:08 +0800 Subject: [PATCH 23/96] fix(config-backup): age a snapshot from its successor, ignore activity-only rewrites, keep at least 2 Review round 3 on #661: retention aged each snapshot by its own createdAt, so the first change after a quiet month deleted the last good copy; it now ages from when the next snapshot was made. users.json lastLoginAt, settings.json lastUsedCase and intents.json recentPrompts/updatedAt are volatile keys for the change check. The keep count has a minimum of 2. Wiki restore paragraph aligned with RESTART_REQUIRED. --- CLAUDE.md | 2 +- docs/api-reference.md | 2 +- docs/wiki/Config-Backups.md | 15 +++++++----- src/config-backup.ts | 20 ++++++++++++--- src/web/public/index.html | 4 +-- src/web/public/settings-ui.js | 2 +- src/web/schemas.ts | 2 +- test/config-backup.test.ts | 46 +++++++++++++++++++++++++++++++---- 8 files changed, 73 insertions(+), 20 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 93e73e78..bcdefb3d 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -156,7 +156,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Auto-resume on usage limit** (opt-in per session, top of the Respawn tab): when Claude halts on a subscription limit, `usage-limit-patterns.ts` (pure, unit-tested) parses the reset time and `SessionAutoOps` arms a timer for reset+2min, then sends Esc + `continue`. ⚠️ Respawn cycles are blocked while paused (`isLimitPaused` guard in `onIdleDetected`), which is what prevents `/clear` from wiping the paused conversation. Claude-mode only. → [architecture-invariants#auto-resume-on-usage-limit](docs/architecture-invariants.md#auto-resume-on-usage-limit) -**Config backups** (`src/config-backup.ts`, `routes/config-backup-routes.ts`; `configBackupEnabled` absent = ON, `configBackupDir`, `configBackupKeepCount` 1-500 default 20, `configBackupKeepDays` 0-3650 default 30; `docs/wiki/Config-Backups.md`): timestamped snapshots `cfg-YYYYMMDD-HHMMSS/` (+ `manifest.json` with SHA-256s, `reason`, `appVersion`) of a FIXED allowlist (`CONFIG_BACKUP_FILES`: settings, clis, custom-model-hosts, webhook, intents, linked-cases, remote/docker hosts+cases (incl. `remote-cases.json`), webviews, push-keys, users, `.env`) into `<data dir>/backups/config` or a custom absolute folder. ⚠️ `state.json` is deliberately NOT tracked (rewritten per session event; it also holds cron jobs and respawn presets). One snapshot per real content change (digest of the file set vs the latest manifest), at startup + a 5-minute timer, skipped in test mode. ⚠️ Retention (`pruneConfigBackups`) only deletes folders matching the name pattern AND holding a parseable manifest, and never the newest; a custom folder equal to the data dir falls back to the default. ⚠️ Restore verifies every file's SHA-256 BEFORE writing, takes a `pre-restore` snapshot, writes atomically at 0600 and only allowlisted names; the id is validated against the name pattern (no traversal). Snapshots are 0700/0600 because `.env`/`users.json`/`custom-model-hosts.json` carry credentials; all routes are admin-only in multi-user mode. ⚠️ A custom folder is probed with `probePathKind(dir, { pastCap: true })` before every pass and in each route (#516: it may be a network mount; `unknown` skips the pass / answers 503 `OPERATION_FAILED`), and a missing custom folder is only created when its parent is present. ⚠️ Each manifest records an `origin`, a random id kept in `<data dir>/config-backup-id` (NOT the hostname: a Docker container gets a new one per recreate, which orphaned every snapshot); change check, prune, list, restore and diff are scoped to it so instances sharing one folder do not snapshot or prune each other. Restoring `intents.json` calls `intentStore.reload()`; `settings.json` applies its watcher toggles after a restart; Settings re-reads `/api/settings` into the form after a restore of it, since Save would otherwise PUT the old values back. ⚠️ Restoring `clis.json` writes it directly (the one writer besides `mutateRegistryFile()`) and then calls `reloadCliRegistry()`. → [docs/wiki/Config-Backups.md](docs/wiki/Config-Backups.md) +**Config backups** (`src/config-backup.ts`, `routes/config-backup-routes.ts`; `configBackupEnabled` absent = ON, `configBackupDir`, `configBackupKeepCount` 2-500 default 20, `configBackupKeepDays` 0-3650 default 30; `docs/wiki/Config-Backups.md`): timestamped snapshots `cfg-YYYYMMDD-HHMMSS/` (+ `manifest.json` with SHA-256s, `reason`, `appVersion`) of a FIXED allowlist (`CONFIG_BACKUP_FILES`: settings, clis, custom-model-hosts, webhook, intents, linked-cases, remote/docker hosts+cases (incl. `remote-cases.json`), webviews, push-keys, users, `.env`) into `<data dir>/backups/config` or a custom absolute folder. ⚠️ `state.json` is deliberately NOT tracked (rewritten per session event; it also holds cron jobs and respawn presets). One snapshot per real content change (digest of the file set vs the latest manifest), at startup + a 5-minute timer, skipped in test mode. ⚠️ Retention (`pruneConfigBackups`) only deletes folders matching the name pattern AND holding a parseable manifest, and never the newest; a custom folder equal to the data dir falls back to the default. ⚠️ Restore verifies every file's SHA-256 BEFORE writing, takes a `pre-restore` snapshot, writes atomically at 0600 and only allowlisted names; the id is validated against the name pattern (no traversal). Snapshots are 0700/0600 because `.env`/`users.json`/`custom-model-hosts.json` carry credentials; all routes are admin-only in multi-user mode. ⚠️ A custom folder is probed with `probePathKind(dir, { pastCap: true })` before every pass and in each route (#516: it may be a network mount; `unknown` skips the pass / answers 503 `OPERATION_FAILED`), and a missing custom folder is only created when its parent is present. ⚠️ Each manifest records an `origin`, a random id kept in `<data dir>/config-backup-id` (NOT the hostname: a Docker container gets a new one per recreate, which orphaned every snapshot); change check, prune, list, restore and diff are scoped to it so instances sharing one folder do not snapshot or prune each other. Restoring `intents.json` calls `intentStore.reload()`; `settings.json` applies its watcher toggles after a restart; Settings re-reads `/api/settings` into the form after a restore of it, since Save would otherwise PUT the old values back. ⚠️ Restoring `clis.json` writes it directly (the one writer besides `mutateRegistryFile()`) and then calls `reloadCliRegistry()`. → [docs/wiki/Config-Backups.md](docs/wiki/Config-Backups.md) **Plan-usage chip** (`showPlanUsageLimits`, per-device: desktop default **ON**, handhelds OFF): resolve DISPLAY only through `planUsageChipEnabled()` (settings-ui.js). The same setting is the server-side COLLECTION switch, read fresh by `readPlanUsageTelemetryEnabled()` (hooks-config.ts) at every claude create/respawn. ⚠️ An ABSENT key reads as ON in the reader; `GET /api/settings` must never write. ⚠️ A save sends `showPlanUsageLimits` ONLY when it flips the chip on that device (`planUsageCollectionFlip()`), or a phone switches collection off for every desktop. Claude data comes from the statusLine exporter, injected as an EPHEMERAL `claude --settings` flag (`resolveStatusLineCliCommand`), never written to disk, WRAPPING a user's own statusLine, posting to `POST /api/status-telemetry`. Codex comes from a read-only `account/rateLimits/read` poll (main bucket only). The statusline's `prompt_cache` object (Claude Code v2.1.251+) is kept by `StatusTelemetrySchema` (every documented scalar field, null-tolerant; `parsePromptCache()` in usage-telemetry.ts normalizes only the stored subset) and shown in the footer as `cache:until HH:MM` / `cache:cold` (only when the user has no statusline of their own) and in Session Options → Respawn (`#promptCacheStatus`, fed by `Session.promptCache` in `toState()` plus the change-only `session:promptCache` SSE event, worded by the pure `CodemanPromptCache.format`): warm-until/cold, the re-cache stake, the hit rate, the last miss's causes, and a `Context compacted at HH:MM` note after a compaction or a tool-result clearing (stamped on the first warm report whose `recache_tokens_if_cold` is null after one that carried it, never from `expected_rebuilds`, which Claude Code bumps only at the next request; NEVER an alert). ⚠️ `Session.setPromptCache()` stamps `compactedAt` onto a NEW object (never mutating the parsed report) and the route broadcasts the STORED object; the last report is restored on restart (constructor `promptCache`, like `displayModel`) and the readout is time-aware (a passed `expiresAt` reads cold, not stale-warm). It is the source of truth for the cache TTL, never the plan tier. → [architecture-invariants#plan-usage-chip-statusline-telemetry](docs/architecture-invariants.md#plan-usage-chip-statusline-telemetry), `docs/usage-limits-display-plan.md` diff --git a/docs/api-reference.md b/docs/api-reference.md index dc2fa42a..45553419 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1158,7 +1158,7 @@ Timestamped snapshots of the files that hold what a user has customized (`src/co Tracked files (a fixed list, relative to the data dir): `settings.json`, `clis.json`, `custom-model-hosts.json`, `webhook.json`, `intents.json`, `linked-cases.json`, `remote-hosts.json`, `remote-cases.json`, `docker-hosts.json`, `docker-cases.json`, `webviews.json`, `push-keys.json`, `users.json`, `.env`. Session state (`state.json`, which also holds cron jobs and respawn presets), logs, the hook secret and caches are not. -Settings (synced, in `settings.json`): `configBackupEnabled` (absent = on), `configBackupDir` (absolute path, `~` expanded; empty = `<data dir>/backups/config`), `configBackupKeepCount` (1–500, default 20), `configBackupKeepDays` (0–3650, default 30; `0` = no age limit). Retention only ever deletes snapshot folders Codeman made there, and never the newest one. Each snapshot is a `0700` folder named `cfg-YYYYMMDD-HHMMSS` (server local time; `-2`, `-3`… on a same-second collision) holding the tracked files under their own names plus a `manifest.json` (`id`, `createdAt`, `reason` of `startup`/`auto`/`manual`/`pre-restore`, `appVersion`, an `origin` id, and a SHA-256 and size per file), all `0600`. Only folders with that name and a valid manifest are listed, restored or pruned. Full guide: `docs/wiki/Config-Backups.md`. +Settings (synced, in `settings.json`): `configBackupEnabled` (absent = on), `configBackupDir` (absolute path, `~` expanded; empty = `<data dir>/backups/config`), `configBackupKeepCount` (2–500, default 20), `configBackupKeepDays` (0–3650, default 30; `0` = no age limit). Retention only ever deletes snapshot folders Codeman made there, and never the newest one. Each snapshot is a `0700` folder named `cfg-YYYYMMDD-HHMMSS` (server local time; `-2`, `-3`… on a same-second collision) holding the tracked files under their own names plus a `manifest.json` (`id`, `createdAt`, `reason` of `startup`/`auto`/`manual`/`pre-restore`, `appVersion`, an `origin` id, and a SHA-256 and size per file), all `0600`. Only folders with that name and a valid manifest are listed, restored or pruned. Full guide: `docs/wiki/Config-Backups.md`. | Method | Path | Body | Notes | | ------ | ------------------------------------ | ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | diff --git a/docs/wiki/Config-Backups.md b/docs/wiki/Config-Backups.md index 0f819f4a..041f7110 100644 --- a/docs/wiki/Config-Backups.md +++ b/docs/wiki/Config-Backups.md @@ -81,8 +81,8 @@ Two limits, applied after every backup: | Setting | Range | Default | Meaning | | ------- | ----- | ------- | ------- | -| **Keep this many backups** (`configBackupKeepCount`) | 1 – 500 | 20 | Keep the newest N; older ones are removed. | -| **Delete backups older than (days)** (`configBackupKeepDays`) | 0 – 3650 | 30 | Also remove anything older than this. `0` means no age limit. | +| **Keep this many backups** (`configBackupKeepCount`) | 2 – 500 | 20 | Keep the newest N; older ones are removed. (1 is not offered: every change would delete the state before it.) | +| **Delete backups older than (days)** (`configBackupKeepDays`) | 0 – 3650 | 30 | Also remove a backup once it has been replaced for longer than this. `0` means no age limit. | Both apply together. Three guarantees: @@ -90,6 +90,10 @@ Both apply together. Three guarantees: go back to. - **Only folders Codeman made are ever removed**, never anything else that happens to live in a custom folder. +- **Age counts from when a backup was replaced**, not from when it was made. Backups are made on + change, so a config that stays the same for months has one backup whose own date keeps + ageing; it is the copy you need the day something goes wrong, so it stays until a newer one + has existed for longer than the limit. - Because backups are made on change, 20 backups reach back across 20 *changes*, however long that took, not 20 timer ticks. @@ -120,10 +124,9 @@ From the list, press **Restore** on a backup and confirm. Codeman then: Only the tracked names are ever written, whatever a manifest lists. -Settings are re-read on the next request, so reload the page. `clis.json`, `users.json`, -`.env` and `push-keys.json` are read once per process: **restart Codeman** for those to take -effect (the response lists which). `clis.json` is reloaded by the restore itself and `users.json` -is re-read within a second. Restoring `settings.json` also does not re-run the switches +Settings are re-read on the next request, so reload the page. `.env` and `push-keys.json` are +read once per process: **restart Codeman** for those to take effect (the response lists which). +`clis.json` is reloaded by the restore itself and `users.json` is re-read within a second. Restoring `settings.json` also does not re-run the switches that saving in Settings applies at once (file watchers and the like), so those apply after a restart, and `settings.json` is listed with the restart-required files. A restore does not touch running sessions. diff --git a/src/config-backup.ts b/src/config-backup.ts index 9ac01bad..a583dcf8 100644 --- a/src/config-backup.ts +++ b/src/config-backup.ts @@ -66,6 +66,8 @@ export const CONFIG_BACKUP_FILES = [ ] as const; export const CONFIG_BACKUP_DEFAULTS = { enabled: true, keepCount: 20, keepDays: 30 } as const; +/** 1 would delete the state before every change, so a bad write could never be undone. */ +export const CONFIG_BACKUP_MIN_KEEP_COUNT = 2; export const CONFIG_BACKUP_MAX_KEEP_COUNT = 500; export const CONFIG_BACKUP_MAX_KEEP_DAYS = 3650; @@ -77,7 +79,7 @@ export interface ConfigBackupSettings { enabled: boolean; /** Absolute folder, or null for the default (`<data dir>/backups/config`). */ dir: string | null; - /** Keep at most this many snapshots (newest first). 1–500. */ + /** Keep at most this many snapshots (newest first). 2–500. */ keepCount: number; /** Also drop snapshots older than this many days; 0 = no age limit. */ keepDays: number; @@ -134,7 +136,12 @@ export function readConfigBackupSettings(raw: Record<string, unknown> | null | u // Absent means ON: the feature protects against an event nobody schedules. enabled: s.configBackupEnabled !== false, dir: normalizeBackupDir(s.configBackupDir), - keepCount: intIn(s.configBackupKeepCount, 1, CONFIG_BACKUP_MAX_KEEP_COUNT, CONFIG_BACKUP_DEFAULTS.keepCount), + keepCount: intIn( + s.configBackupKeepCount, + CONFIG_BACKUP_MIN_KEEP_COUNT, + CONFIG_BACKUP_MAX_KEEP_COUNT, + CONFIG_BACKUP_DEFAULTS.keepCount + ), keepDays: intIn(s.configBackupKeepDays, 0, CONFIG_BACKUP_MAX_KEEP_DAYS, CONFIG_BACKUP_DEFAULTS.keepDays), }; } @@ -197,6 +204,11 @@ const sha256 = (data: Buffer | string): string => createHash('sha256').update(da */ const VOLATILE_KEYS: Readonly<Record<string, readonly string[]>> = { 'custom-model-hosts.json': ['lastDiscoveredAt'], + // Rewritten by activity, not by anything the user set: a Basic-auth login (at most once a minute + // per user), a session started in another case, and every captured prompt. + 'users.json': ['lastLoginAt'], + 'settings.json': ['lastUsedCase'], + 'intents.json': ['recentPrompts', 'updatedAt'], }; function withoutKeys(value: unknown, drop: ReadonlySet<string>): unknown { @@ -378,7 +390,9 @@ export function pruneConfigBackups(dir: string, opts: PruneOptions): string[] { all.forEach((b, index) => { if (index === 0) return; const tooMany = index >= Math.max(1, opts.keepCount); - const tooOld = cutoff !== null && b.createdAt < cutoff; + // A snapshot's state was live until the next one was made: age it from then. Aging by its own + // createdAt deleted the last good copy on the first change after a quiet month. + const tooOld = cutoff !== null && all[index - 1].createdAt < cutoff; if (!tooMany && !tooOld) return; // listConfigBackups already proved this is one of ours (name pattern + manifest). const target = join(dir, b.id); diff --git a/src/web/public/index.html b/src/web/public/index.html index aac43e48..a671bb9c 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -3262,9 +3262,9 @@ <div class="set-row has-field" data-search="config backup retention keep count snapshots"> <div class="set-row-text"> <span class="set-row-label">Keep this many backups</span> - <span class="set-row-desc">1 to 500. The newest backup is always kept.</span> + <span class="set-row-desc">2 to 500 (with 1, every change would delete the state before it). The newest backup is always kept.</span> </div> - <input type="number" id="appSettingsConfigBackupKeepCount" class="set-input" min="1" max="500" step="1" placeholder="20"> + <input type="number" id="appSettingsConfigBackupKeepCount" class="set-input" min="2" max="500" step="1" placeholder="20"> </div> <div class="set-row has-field" data-search="config backup retention keep days age"> <div class="set-row-text"> diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index a09119d3..30a475b8 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -2880,7 +2880,7 @@ Object.assign(CodemanApp.prototype, { mcpSyncEnabled: document.getElementById('appSettingsMcpSync').checked, configBackupEnabled: document.getElementById('appSettingsConfigBackup').checked, configBackupDir: document.getElementById('appSettingsConfigBackupDir').value.trim(), - configBackupKeepCount: Math.min(500, Math.max(1, parseInt(document.getElementById('appSettingsConfigBackupKeepCount').value, 10) || 20)), + configBackupKeepCount: Math.min(500, Math.max(2, parseInt(document.getElementById('appSettingsConfigBackupKeepCount').value, 10) || 20)), configBackupKeepDays: Math.min(3650, Math.max(0, Number.isNaN(parseInt(document.getElementById('appSettingsConfigBackupKeepDays').value, 10)) ? 30 : parseInt(document.getElementById('appSettingsConfigBackupKeepDays').value, 10))), readMyMindEnabled: document.getElementById('appSettingsReadMyMind').checked, ultracodeFloatingWindows: document.getElementById('appSettingsUltracodeFloatingWindows').checked, diff --git a/src/web/schemas.ts b/src/web/schemas.ts index bf235521..4223310b 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -1530,7 +1530,7 @@ export const SettingsUpdateSchema = z /** Absolute folder (a leading `~` is expanded); empty or absent = the default folder. */ configBackupDir: z.string().max(1024).optional(), /** Keep at most this many snapshots. */ - configBackupKeepCount: z.number().int().min(1).max(500).optional(), + configBackupKeepCount: z.number().int().min(2).max(500).optional(), /** Also drop snapshots older than this many days; 0 = no age limit. */ configBackupKeepDays: z.number().int().min(0).max(3650).optional(), /** diff --git a/test/config-backup.test.ts b/test/config-backup.test.ts index 4e3ffcc7..bcd17295 100644 --- a/test/config-backup.test.ts +++ b/test/config-backup.test.ts @@ -60,7 +60,7 @@ describe('settings', () => { const s = readConfigBackupSettings({ configBackupKeepCount: 9999, configBackupKeepDays: -3 }); expect(s.keepCount).toBe(500); expect(s.keepDays).toBe(0); - expect(readConfigBackupSettings({ configBackupKeepCount: 0 }).keepCount).toBe(1); + expect(readConfigBackupSettings({ configBackupKeepCount: 0 }).keepCount).toBe(2); expect(readConfigBackupSettings({ configBackupKeepCount: 'abc' }).keepCount).toBe(20); expect(readConfigBackupSettings({ configBackupKeepCount: 7.9 }).keepCount).toBe(7); }); @@ -147,6 +147,28 @@ describe('createConfigBackup', () => { expect(readFileSync(join(backups, (third as { id: string }).id, 'custom-model-hosts.json'), 'utf8')).toBe(edited); }); + it.each([ + ['users.json', (n: number) => JSON.stringify({ users: [{ name: 'a', role: 'admin', lastLoginAt: n }] }), 'role'], + ['settings.json', (n: number) => JSON.stringify({ displayName: 'x', lastUsedCase: `case-${n}` }), 'displayName'], + [ + 'intents.json', + (n: number) => JSON.stringify({ profiles: [{ key: 'k', goals: 'g', updatedAt: n, recentPrompts: [`p${n}`] }] }), + 'goals', + ], + ])('does not count activity in %s as a change, but does count an edit next to it', (name, make, editKey) => { + put(name, make(1)); + const first = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 }); + put(name, make(2)); + expect(createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + 1000 })).toEqual({ + status: 'unchanged', + id: (first as { id: string }).id, + }); + put(name, make(3).replace(new RegExp(`"${editKey}":"[^"]*"`), `"${editKey}":"edited"`)); + expect(createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + 2000 }).status).toBe( + 'created' + ); + }); + it('compares a tracked file that is not valid JSON by its bytes', () => { put('custom-model-hosts.json', 'not json lastDiscoveredAt 1'); createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 }); @@ -217,10 +239,24 @@ describe('retention', () => { makeN(5); expect(pruneConfigBackups(backups, { keepCount: 50, keepDays: 0, now: T0 + 999 * DAY })).toEqual([]); const removed = pruneConfigBackups(backups, { keepCount: 4, keepDays: 3, now: T0 + 5 * DAY }); - // Newest first: T0+4d, +3d, +2d, +1d, T0. The oldest is over the count AND past the cutoff - // (T0+2d); T0+1d is past the cutoff; T0+2d sits exactly on it and is kept. - expect(removed.length).toBe(2); - expect(listConfigBackups(backups).map((b) => b.createdAt)).toEqual([T0 + 4 * DAY, T0 + 3 * DAY, T0 + 2 * DAY]); + // Newest first: T0+4d, +3d, +2d, +1d, T0. A snapshot ages from its successor: T0+1d was live + // until T0+2d, which sits exactly on the cutoff (kept); only T0 is over the count. + expect(removed.length).toBe(1); + expect(listConfigBackups(backups).map((b) => b.createdAt)).toEqual([ + T0 + 4 * DAY, + T0 + 3 * DAY, + T0 + 2 * DAY, + T0 + DAY, + ]); + }); + + it('keeps the last good copy when the first change comes after a quiet month', () => { + put('settings.json', '{"theme":"good"}'); + createConfigBackup({ dataDir: data, backupDir: backups, reason: 'startup', now: T0 }); + put('settings.json', '{}'); // the bad write, 40 days later + createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + 40 * DAY }); + pruneConfigBackups(backups, { keepCount: 20, keepDays: 30, now: T0 + 40 * DAY }); + expect(listConfigBackups(backups).map((b) => b.createdAt)).toEqual([T0 + 40 * DAY, T0]); }); it('never deletes a folder it did not make', () => { From 71fcefbaf2f96b968ed20fce888d9ee824871660 Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Tue, 20 Oct 2026 11:31:12 +0800 Subject: [PATCH 24/96] fix(config-backup): ignore a Docker case's conversation id, list every ignored key, correct the retention and restart docs --- README.md | 2 +- docs/wiki/Config-Backups.md | 17 +++++++++++------ docs/wiki/Security.md | 2 +- docs/wiki/Settings-Reference.md | 2 +- docs/wiki/Troubleshooting.md | 4 ++-- src/config-backup.ts | 4 +++- src/web/public/index.html | 2 +- test/config-backup.test.ts | 5 +++++ 8 files changed, 25 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 0cdb3b6a..a9a4e3c2 100644 --- a/README.md +++ b/README.md @@ -479,7 +479,7 @@ PTY Output → 16ms Server Batch → DEC 2026 Wrap → SSE → Client rAF → xt - **OSC 52 clipboard**: a program in a session that sets the clipboard with OSC 52 (Claude Code's `/copy`, a tmux yank, vim) puts the text on your browser's clipboard, in the main terminal and in every tile. Clipboard read requests are refused, and replaying a buffer never copies - **MCP server sync** _(opt-in)_: copies the user-level MCP servers each installed CLI has (Claude, Codex, Gemini, OpenCode, Antigravity, and GitHub Copilot CLI) into the others' own config files. Additive only, a server you switched off is never copied, **Preview** shows the changes first, and the previous file is kept as `.codeman-bak`. App Settings → Agents & CLIs; admin only in multi-user mode - **CJK / IME input** — full composition support for Chinese / Japanese / Korean, with Ctrl- and Alt-modified navigation keys passed through to the CLI -- **Config backups** — timestamped copies of the files that hold your customization (settings, CLI overrides, custom endpoints, webhook, linked cases, remote and Docker hosts, saved dashboards and, in multi-user mode, accounts), made at startup and whenever one changes. On by default into `~/.codeman/backups/config`; the folder, how many to keep (1–500) and how long (days, 0 = no limit) are settings, with **Back up now** and a verified **Restore** in Settings → System. Retention only ever deletes folders Codeman made and never the newest. See the [wiki page](docs/wiki/Config-Backups.md) +- **Config backups** — timestamped copies of the files that hold your customization (settings, CLI overrides, custom endpoints, webhook, linked cases, remote and Docker hosts, saved dashboards and, in multi-user mode, accounts), made at startup and whenever one changes. On by default into `~/.codeman/backups/config`; the folder, how many to keep (2–500) and how long (days, 0 = no limit) are settings, with **Back up now** and a verified **Restore** in Settings → System. Retention only ever deletes folders Codeman made and never the newest. See the [wiki page](docs/wiki/Config-Backups.md) - **Plan usage in the header** — live Claude subscription usage (the 5-hour and weekly windows) from a statusline exporter Codeman hands to `claude` at spawn and never writes into your settings files, plus Codex limits from its own app-server; per device, on for desktops and off for phones - **Prompt-cache readout**: each Claude session shows whether its prompt cache is warm (`cache:until HH:MM`) or cold, in the footer (when you have no statusline of your own) and in Session Options → Respawn, with the cost of a re-cache and a note after a compaction. Display only; it survives a restart - **Session list, your way** — the header strip, a left sidebar with a filter box, or a vertical rail whose detailed rows carry created and state stamps and sort by activity; the phone home screen and the desktop home rail use the same order. On the rail, **Search sessions** narrows the list to the tabs that match, across every group, and a tab with an alert stays visible even when its name does not. Named **tab groups** collapse, reorder and take drags; a group's menu has **New session**, and a worker an agent spawns lands right after its parent (**Spawned tabs follow their parent**, off by default, keeps it moving with the parent). **Add to Focus** pins a shortcut to any tab in a section at the top of the rail, and **CLI Logos on Tabs** can be switched off per device diff --git a/docs/wiki/Config-Backups.md b/docs/wiki/Config-Backups.md index 041f7110..a11be141 100644 --- a/docs/wiki/Config-Backups.md +++ b/docs/wiki/Config-Backups.md @@ -37,10 +37,15 @@ cron jobs. - **Whenever the content changes.** The files are read and hashed every five minutes; a new backup is written only if the set differs from the latest backup. An idle server writes nothing, and the history is one entry per real change rather than one per timer tick. - A field a file refreshes by itself is not a change: `custom-model-hosts.json` carries a - `lastDiscoveredAt` per host that the model-discovery poll rewrites every five minutes, and - the comparison looks through it (the snapshot still stores the file exactly as written). - Without that, every tick looked like an edit and 20 copies covered only about 100 minutes. + A field a file rewrites on its own is not a change, and the comparison looks through it (the + snapshot still stores the file exactly as written, and a restore puts those fields back as they + were): `custom-model-hosts.json` `lastDiscoveredAt` (the model-discovery poll, every five + minutes), `users.json` `lastLoginAt`, `settings.json` `lastUsedCase`, `intents.json` + `recentPrompts` and `updatedAt` (every captured prompt) and `docker-cases.json` + `lastClaudeSessionId` (a Docker case's conversation id after a `/clear`). Without that, every + tick looked like an edit and 20 copies covered only about 100 minutes. So Compare shows + `intents.json` as unchanged when only prompts differ, yet a restore drops the prompts captured + since. - **On demand**, with **Back up now** (always writes one, even if nothing changed). - **Before a restore**, as a `pre-restore` safety copy of the files about to be replaced. @@ -155,8 +160,8 @@ Before you restore, or to find out what changed and when, use **Compare** in the Each file that differs is listed with its changed keys (`theme.mode`, `tags[1]`, a variable name in `.env`) as before → after, plus files that appeared or vanished between the two states. Files that are the same are listed on one line. A file that is not JSON shows only that it differs, and -a file with more than 200 changes is cut short, so use the files themselves for a full diff. A -self-refreshed discovery timestamp in `custom-model-hosts.json` is not counted as a change. +a file with more than 200 changes is cut short, so use the files themselves for a full diff. The +self-rewritten fields listed under "When a backup is made" are not counted as changes. **Credential values are never shown.** `.env`, `users.json`, `push-keys.json`, `webhook.json` (the URL is a bearer secret) and `intents.json` (captured prompts) list only the names that changed, and in every other file a key that looks like a token, secret, password, diff --git a/docs/wiki/Security.md b/docs/wiki/Security.md index d7e169f1..5d72ac5e 100644 --- a/docs/wiki/Security.md +++ b/docs/wiki/Security.md @@ -108,7 +108,7 @@ Two features send data outward, both off by default and both stated where they a dictation through your Claude login, and the Read My Mind prediction call. [Config Backups](Config-Backups) copies some credential-bearing files (`.env`, `users.json`, -`custom-model-hosts.json`, `push-keys.json`) into a backup folder. The default folder is inside +`custom-model-hosts.json`, `push-keys.json`, `webhook.json`) into a backup folder. The default folder is inside the data directory, and every backup is mode `0700`/`0600`, so nothing new is exposed. Only a folder you choose yourself can move them somewhere less private; admin only in multi-user mode. diff --git a/docs/wiki/Settings-Reference.md b/docs/wiki/Settings-Reference.md index bf0a7800..b61ab68e 100644 --- a/docs/wiki/Settings-Reference.md +++ b/docs/wiki/Settings-Reference.md @@ -192,7 +192,7 @@ mode, the **Users** administration entry is injected here. The **Config backups** group controls timestamped copies of your configuration files: an on/off switch (on by default), the backup folder (default `~/.codeman/backups/config`), how -many backups to keep (1 to 500, default 20) and how many days to keep them (0 to 3650, default +many backups to keep (2 to 500, default 20) and how many days to keep them (0 to 3650, default 30; 0 means no age limit), plus **Back up now** and a list with **Restore**. These are server-side settings, read fresh. Details, naming and restore behaviour are on [Config Backups](Config-Backups). diff --git a/docs/wiki/Troubleshooting.md b/docs/wiki/Troubleshooting.md index aa3a83f4..b101e45c 100644 --- a/docs/wiki/Troubleshooting.md +++ b/docs/wiki/Troubleshooting.md @@ -139,8 +139,8 @@ which is the default, you have copies: 1. Open **Settings → System → Config backups** and pick the newest backup from before the change. The list shows when each was made and what it holds. -2. **Restore** it, reload the page, and restart Codeman if the response says `clis.json`, - `users.json`, `.env` or `push-keys.json` were restored. +2. **Restore** it, reload the page, and restart Codeman if the response's `restartRequired` + list is not empty. Without the UI: the backups are plain folders in `~/.codeman/backups/config/` (`cfg-YYYYMMDD-HHMMSS`); copy the file you need back over the one in `~/.codeman/`. diff --git a/src/config-backup.ts b/src/config-backup.ts index a583dcf8..4c6511e4 100644 --- a/src/config-backup.ts +++ b/src/config-backup.ts @@ -205,10 +205,12 @@ const sha256 = (data: Buffer | string): string => createHash('sha256').update(da const VOLATILE_KEYS: Readonly<Record<string, readonly string[]>> = { 'custom-model-hosts.json': ['lastDiscoveredAt'], // Rewritten by activity, not by anything the user set: a Basic-auth login (at most once a minute - // per user), a session started in another case, and every captured prompt. + // per user), a session started in another case, every captured prompt, and a Docker case's + // conversation id after a /clear. 'users.json': ['lastLoginAt'], 'settings.json': ['lastUsedCase'], 'intents.json': ['recentPrompts', 'updatedAt'], + 'docker-cases.json': ['lastClaudeSessionId'], }; function withoutKeys(value: unknown, drop: ReadonlySet<string>): unknown { diff --git a/src/web/public/index.html b/src/web/public/index.html index a671bb9c..55150e12 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -3269,7 +3269,7 @@ <div class="set-row has-field" data-search="config backup retention keep days age"> <div class="set-row-text"> <span class="set-row-label">Delete backups older than (days)</span> - <span class="set-row-desc">0 keeps backups until the count above pushes them out.</span> + <span class="set-row-desc">A backup is removed once a newer one has existed this many days. 0 keeps backups until the count above pushes them out.</span> </div> <input type="number" id="appSettingsConfigBackupKeepDays" class="set-input" min="0" max="3650" step="1" placeholder="30"> </div> diff --git a/test/config-backup.test.ts b/test/config-backup.test.ts index bcd17295..5089e8c6 100644 --- a/test/config-backup.test.ts +++ b/test/config-backup.test.ts @@ -148,6 +148,11 @@ describe('createConfigBackup', () => { }); it.each([ + [ + 'docker-cases.json', + (n: number) => JSON.stringify([{ name: 'app', hostId: 'h', lastClaudeSessionId: `c${n}` }]), + 'hostId', + ], ['users.json', (n: number) => JSON.stringify({ users: [{ name: 'a', role: 'admin', lastLoginAt: n }] }), 'role'], ['settings.json', (n: number) => JSON.stringify({ displayName: 'x', lastUsedCase: `case-${n}` }), 'displayName'], [ From c5f1e990367d4da536258ee40f341d62d1768dbe Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Tue, 20 Oct 2026 16:47:49 +0800 Subject: [PATCH 25/96] fix(config-backup): ignore a web tab's lastOpenedAt, audit multi-user restores, refuse symlinked or loosened snapshots, keep the folder settings on restore --- docs/wiki/Config-Backups.md | 17 +++++-- src/config-backup.ts | 48 +++++++++++++++++++- src/web/routes/config-backup-routes.ts | 13 +++++- test/config-backup.test.ts | 58 ++++++++++++++++++++++++ test/routes/config-backup-routes.test.ts | 16 +++++++ 5 files changed, 146 insertions(+), 6 deletions(-) diff --git a/docs/wiki/Config-Backups.md b/docs/wiki/Config-Backups.md index a11be141..4ba6a999 100644 --- a/docs/wiki/Config-Backups.md +++ b/docs/wiki/Config-Backups.md @@ -42,7 +42,8 @@ cron jobs. were): `custom-model-hosts.json` `lastDiscoveredAt` (the model-discovery poll, every five minutes), `users.json` `lastLoginAt`, `settings.json` `lastUsedCase`, `intents.json` `recentPrompts` and `updatedAt` (every captured prompt) and `docker-cases.json` - `lastClaudeSessionId` (a Docker case's conversation id after a `/clear`). Without that, every + `lastClaudeSessionId` (a Docker case's conversation id after a `/clear`) and `webviews.json` + `lastOpenedAt` (opening a web tab). Without that, every tick looked like an edit and 20 copies covered only about 100 minutes. So Compare shows `intents.json` as unchanged when only prompts differ, yet a restore drops the prompts captured since. @@ -122,13 +123,19 @@ list and **Back up now** use the **saved** folder and retention: apply your edit From the list, press **Restore** on a backup and confirm. Codeman then: 1. **Verifies** every file in the backup against the SHA-256 in its manifest. If any file is - missing or does not match, nothing is changed and you get an error. + missing or does not match, nothing is changed and you get an error. It also refuses a backup + folder or file that is a symlink or that group or others can read or write (Codeman never + makes one like that, and writing it back as `0600` would turn it into trusted config). 2. **Takes a `pre-restore` backup** of the files about to be replaced, so a restore can itself be undone. 3. **Replaces each file atomically** (written beside it, then renamed) with mode `0600`. Only the tracked names are ever written, whatever a manifest lists. +Restoring `settings.json` keeps the four backup settings (on or off, folder, how many, how many +days) at their current values, so a restore from before a folder change does not point the list +at the old folder and hide the `pre-restore` copy it just made. + Settings are re-read on the next request, so reload the page. `.env` and `push-keys.json` are read once per process: **restart Codeman** for those to take effect (the response lists which). `clis.json` is reloaded by the restore itself and `users.json` is re-read within a second. Restoring `settings.json` also does not re-run the switches @@ -176,8 +183,12 @@ query and fragment. - **A custom folder is your decision.** Putting it on a shared drive, a synced folder or a network share copies those credentials there, with whatever permissions that location has. Choose a private location, or turn the feature off if you cannot. +- **Anyone who can write to a custom backup folder can plant a backup that Restore applies.** + Restore refuses symlinks and files others can write, but a file of your own user's making + passes, so keep the folder private. - In [multi-user mode](Multi-User-Mode) all of it is admin only: the list, Back up now, - Restore and Compare. + Restore and Compare, and each restore is written to the admin audit log + (`config_backup_restore`). - Listing returns file names and sizes, never contents. A comparison returns changed values but never a credential's (see [Comparing backups](#comparing-backups)). diff --git a/src/config-backup.ts b/src/config-backup.ts index 4c6511e4..1189c2d7 100644 --- a/src/config-backup.ts +++ b/src/config-backup.ts @@ -206,11 +206,12 @@ const VOLATILE_KEYS: Readonly<Record<string, readonly string[]>> = { 'custom-model-hosts.json': ['lastDiscoveredAt'], // Rewritten by activity, not by anything the user set: a Basic-auth login (at most once a minute // per user), a session started in another case, every captured prompt, and a Docker case's - // conversation id after a /clear. + // conversation id after a /clear, and a web tab being opened. 'users.json': ['lastLoginAt'], 'settings.json': ['lastUsedCase'], 'intents.json': ['recentPrompts', 'updatedAt'], 'docker-cases.json': ['lastClaudeSessionId'], + 'webviews.json': ['lastOpenedAt'], }; function withoutKeys(value: unknown, drop: ReadonlySet<string>): unknown { @@ -412,6 +413,45 @@ export function pruneConfigBackups(dir: string, opts: PruneOptions): string[] { export class ConfigBackupNotFoundError extends Error {} export class ConfigBackupCorruptError extends Error {} +/** + * Restore refuses what this module never writes. Snapshots are 0700/0600 real files, and a restore + * writes the file as 0600: a symlink, or a file with a group/world bit, was planted or loosened + * after the snapshot, and rewriting it as 0600 would turn it into trusted config (`clis.json` + * selects the binaries Codeman spawns; `isUnsafePermissions` refuses the same bits there). + * POSIX only, like `isUnsafePermissions`. + */ +function assertTrustedSnapshotEntry(path: string, label: string): void { + if (process.platform === 'win32') return; + const st = lstatSync(path, { throwIfNoEntry: false }); + if (!st) return; // missing is reported by the read that follows + if (st.isSymbolicLink()) throw new ConfigBackupCorruptError(`${label} is a symlink, so it is not restored`); + if ((st.mode & 0o077) !== 0) { + throw new ConfigBackupCorruptError(`${label} is readable or writable by group or others, so it is not restored`); + } +} + +/** The folder's own backup settings stay as they are now: restoring an old `configBackupDir` hides the `pre-restore` copy. */ +const BACKUP_SETTING_KEYS = ['configBackupEnabled', 'configBackupDir', 'configBackupKeepCount', 'configBackupKeepDays']; + +function keepCurrentBackupSettings(snapshot: Buffer, livePath: string): Buffer { + try { + const restored = JSON.parse(snapshot.toString('utf8')) as Record<string, unknown>; + const live = JSON.parse(readFileSync(livePath, 'utf8')) as Record<string, unknown>; + if (!restored || typeof restored !== 'object' || !live || typeof live !== 'object') return snapshot; + let changed = false; + for (const key of BACKUP_SETTING_KEYS) { + if (JSON.stringify(restored[key]) === JSON.stringify(live[key])) continue; + changed = true; + if (key in live) restored[key] = live[key]; + else delete restored[key]; + } + if (!changed) return snapshot; // byte-identical to the snapshot when the folder settings did not move + return Buffer.from(JSON.stringify(restored, null, 2) + '\n'); + } catch { + return snapshot; // not JSON, or no live file to take the values from + } +} + export interface RestoreResult { restored: string[]; /** Tracked files present now that the snapshot did not hold; a restore leaves them as they are. */ @@ -437,9 +477,11 @@ export function restoreConfigBackup(opts: { const allowed = new Set<string>(CONFIG_BACKUP_FILES); const loaded: Array<{ name: string; data: Buffer }> = []; + assertTrustedSnapshotEntry(join(opts.backupDir, opts.id), `Backup ${opts.id}`); for (const f of manifest.files) { if (!allowed.has(f.name)) continue; let data: Buffer; + assertTrustedSnapshotEntry(join(opts.backupDir, opts.id, f.name), `${f.name} in ${opts.id}`); try { data = readFileSync(join(opts.backupDir, opts.id, f.name)); } catch { @@ -466,7 +508,9 @@ export function restoreConfigBackup(opts: { const target = join(opts.dataDir, f.name); const tmp = `${target}.restore-${process.pid}`; try { - writeFileSync(tmp, f.data, { mode: 0o600 }); + writeFileSync(tmp, f.name === 'settings.json' ? keepCurrentBackupSettings(f.data, target) : f.data, { + mode: 0o600, + }); renameSync(tmp, target); } catch (err) { rmSync(tmp, { force: true }); diff --git a/src/web/routes/config-backup-routes.ts b/src/web/routes/config-backup-routes.ts index 1ecf9e03..b250437d 100644 --- a/src/web/routes/config-backup-routes.ts +++ b/src/web/routes/config-backup-routes.ts @@ -17,7 +17,8 @@ import { createRequire } from 'node:module'; import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js'; -import { isAdmin, readJsonConfig, SETTINGS_PATH } from '../route-helpers.js'; +import { getAuthUser, isAdmin, readJsonConfig, SETTINGS_PATH } from '../route-helpers.js'; +import { appendAdminAudit } from '../admin-audit.js'; import { reloadCliRegistry } from '../../config/cli-registry/registry.js'; import { isMultiUserMode } from '../../config/multiuser.js'; import { getDataDir } from '../../config/instance.js'; @@ -207,6 +208,16 @@ export function registerConfigBackupRoutes(app: FastifyInstance): void { if (out.restored.includes('intents.json')) intentStore.reload(); // restoreConfigBackup writes clis.json directly (the one writer besides mutateRegistryFile). if (out.restored.includes('clis.json')) reloadCliRegistry(); + // A restore of users.json replaces accounts, password hashes, roles and bypass grants. + if (isMultiUserMode()) { + void appendAdminAudit({ + admin: getAuthUser(req).username, + action: 'config_backup_restore', + target: id, + ip: req.ip, + detail: { restored: out.restored }, + }); + } return { success: true, data: { diff --git a/test/config-backup.test.ts b/test/config-backup.test.ts index 5089e8c6..0a24ceea 100644 --- a/test/config-backup.test.ts +++ b/test/config-backup.test.ts @@ -11,6 +11,7 @@ import { readdirSync, readFileSync, rmSync, + symlinkSync, statSync, writeFileSync, } from 'node:fs'; @@ -148,6 +149,22 @@ describe('createConfigBackup', () => { }); it.each([ + [ + 'webviews.json', + (n: number) => + JSON.stringify([ + { + id: 'w1', + name: 'Grafana', + url: 'http://localhost:3001', + embedMode: 'proxy', + trusted: false, + createdAt: 1, + lastOpenedAt: n, + }, + ]), + 'name', + ], [ 'docker-cases.json', (n: number) => JSON.stringify([{ name: 'app', hostId: 'h', lastClaudeSessionId: `c${n}` }]), @@ -486,3 +503,44 @@ describe('instances sharing one backup folder', () => { expect(existsSync(stale)).toBe(false); }); }); + +describe('restore trusts only what this module wrote', () => { + const made = () => + createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 }) as { id: string }; + + it('refuses a symlinked snapshot file and a snapshot file with group or world bits', () => { + put('settings.json', '{"a":1}'); + put('clis.json', '{"clis":{}}'); + const { id } = made(); + const file = join(backups, id, 'clis.json'); + chmodSync(file, 0o644); + expect(() => restoreConfigBackup({ dataDir: data, backupDir: backups, id })).toThrow(ConfigBackupCorruptError); + + chmodSync(file, 0o600); + const real = join(root, 'elsewhere.json'); + writeFileSync(real, '{"clis":{}}'); + rmSync(file); + symlinkSync(real, file); + expect(() => restoreConfigBackup({ dataDir: data, backupDir: backups, id })).toThrow(/symlink/); + // nothing was written and no pre-restore copy was taken for a refused restore + expect(listConfigBackups(backups)).toHaveLength(1); + }); + + it('refuses a snapshot folder that others can write to', () => { + put('settings.json', '{"a":1}'); + const { id } = made(); + chmodSync(join(backups, id), 0o777); + expect(() => restoreConfigBackup({ dataDir: data, backupDir: backups, id })).toThrow(ConfigBackupCorruptError); + chmodSync(join(backups, id), 0o700); + expect(() => restoreConfigBackup({ dataDir: data, backupDir: backups, id })).not.toThrow(); + }); + + it('keeps the current folder and retention when settings.json is restored', () => { + put('settings.json', JSON.stringify({ displayName: 'old', configBackupDir: '/old', configBackupKeepCount: 5 })); + const { id } = made(); + put('settings.json', JSON.stringify({ displayName: 'new', configBackupDir: '/new', configBackupKeepDays: 7 })); + restoreConfigBackup({ dataDir: data, backupDir: backups, id }); + const after = JSON.parse(readFileSync(join(data, 'settings.json'), 'utf8')); + expect(after).toEqual({ displayName: 'old', configBackupDir: '/new', configBackupKeepDays: 7 }); + }); +}); diff --git a/test/routes/config-backup-routes.test.ts b/test/routes/config-backup-routes.test.ts index a0a2bc17..e5e7b79c 100644 --- a/test/routes/config-backup-routes.test.ts +++ b/test/routes/config-backup-routes.test.ts @@ -137,6 +137,22 @@ describe('/api/config-backups', () => { } }); + it('multi-user: a restore is written to the admin audit log', async () => { + process.env.CODEMAN_MULTIUSER = '1'; + const { app } = await createRouteTestHarness(registerConfigBackupRoutes, { + authUser: { username: 'root', role: 'admin' }, + }); + const id = (await app.inject({ method: 'POST', url: '/api/config-backups' })).json().data.id; + await app.inject({ method: 'POST', url: `/api/config-backups/${id}/restore` }); + const auditPath = join(getDataDir(), 'admin-audit.jsonl'); + const written = () => existsSync(auditPath) && readFileSync(auditPath, 'utf8').includes('config_backup_restore'); + for (let i = 0; i < 40 && !written(); i++) await new Promise((r) => setTimeout(r, 25)); + const last = JSON.parse(readFileSync(auditPath, 'utf8').trim().split('\n').pop()!); + expect(last).toMatchObject({ admin: 'root', action: 'config_backup_restore', target: id }); + expect(last.detail.restored).toContain('settings.json'); + rmSync(auditPath, { force: true }); + }); + it('multi-user: an admin is allowed', async () => { process.env.CODEMAN_MULTIUSER = '1'; const { app } = await createRouteTestHarness(registerConfigBackupRoutes, { From c982fa1ce525ce64e47eeb94346e603ba86f0052 Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Tue, 20 Oct 2026 18:03:55 +0800 Subject: [PATCH 26/96] fix(config-backup): retention survives an unreadable or reset settings.json, restore works on mode-less filesystems, compare checks snapshots too --- docs/wiki/Config-Backups.md | 17 +++-- docs/wiki/Troubleshooting.md | 5 ++ src/config-backup.ts | 85 +++++++++++++++++++++--- src/web/routes/config-backup-routes.ts | 44 ++++++++++-- test/config-backup.test.ts | 24 +++++++ test/routes/config-backup-routes.test.ts | 62 ++++++++++++++++- 6 files changed, 215 insertions(+), 22 deletions(-) diff --git a/docs/wiki/Config-Backups.md b/docs/wiki/Config-Backups.md index 4ba6a999..e743327c 100644 --- a/docs/wiki/Config-Backups.md +++ b/docs/wiki/Config-Backups.md @@ -90,7 +90,7 @@ Two limits, applied after every backup: | **Keep this many backups** (`configBackupKeepCount`) | 2 – 500 | 20 | Keep the newest N; older ones are removed. (1 is not offered: every change would delete the state before it.) | | **Delete backups older than (days)** (`configBackupKeepDays`) | 0 – 3650 | 30 | Also remove a backup once it has been replaced for longer than this. `0` means no age limit. | -Both apply together. Three guarantees: +Both apply together. Four guarantees: - **The newest backup is never removed**, whatever the numbers say, so there is always one to go back to. @@ -125,7 +125,11 @@ From the list, press **Restore** on a backup and confirm. Codeman then: 1. **Verifies** every file in the backup against the SHA-256 in its manifest. If any file is missing or does not match, nothing is changed and you get an error. It also refuses a backup folder or file that is a symlink or that group or others can read or write (Codeman never - makes one like that, and writing it back as `0600` would turn it into trusted config). + makes one like that, and writing it back as `0600` would turn it into trusted config). The + permission test is skipped when the backup folder's filesystem does not keep file modes + (FAT/exFAT drives, SMB shares, WSL's `/mnt/d` without the `metadata` option report the same + mode for every file, so it would refuse every backup there). The symlink test is not. Compare + applies the same checks to the backups it reads. 2. **Takes a `pre-restore` backup** of the files about to be replaced, so a restore can itself be undone. 3. **Replaces each file atomically** (written beside it, then renamed) with mode `0600`. @@ -147,7 +151,9 @@ When the backup folder is shared (a synced folder used by two machines, or prod each snapshot records a random id that the instance made once and keeps in its data directory (`config-backup-id`; not the hostname, which a Docker container changes on every recreate). Every instance only snapshots, lists, prunes, compares and restores its own. Deleting that file makes -the instance a new owner, and its earlier snapshots stop being listed (they stay on disk). +the instance a new owner, and its earlier snapshots stop being listed (they stay on disk). The +same happens after a reinstall of the data directory. To see them again, copy the `origin` value +from any of their `manifest.json` files into `<data dir>/config-backup-id`. You can also restore by hand: the files in a backup folder are the originals, so copying one back over the file in the data directory is equivalent (stop Codeman first if you are replacing @@ -178,11 +184,14 @@ query and fragment. ## Security - Some tracked files carry credentials (`.env`, `users.json`, `custom-model-hosts.json`, - `push-keys.json`). Each backup folder is mode `0700` and its files `0600`, so the default + `push-keys.json`, `webhook.json`). Each backup folder is mode `0700` and its files `0600`, so the default location exposes nothing the data directory does not already. - **A custom folder is your decision.** Putting it on a shared drive, a synced folder or a network share copies those credentials there, with whatever permissions that location has. Choose a private location, or turn the feature off if you cannot. +- **Retention survives a bad `settings.json`.** If it does not parse, a pass still takes its + snapshot but deletes nothing. If the four backup settings are missing (a reset file), the pass + prunes with the retention the newest backup recorded, not the 20 copies and 30 days defaults. - **Anyone who can write to a custom backup folder can plant a backup that Restore applies.** Restore refuses symlinks and files others can write, but a file of your own user's making passes, so keep the folder private. diff --git a/docs/wiki/Troubleshooting.md b/docs/wiki/Troubleshooting.md index b101e45c..5c34528b 100644 --- a/docs/wiki/Troubleshooting.md +++ b/docs/wiki/Troubleshooting.md @@ -142,6 +142,11 @@ which is the default, you have copies: 2. **Restore** it, reload the page, and restart Codeman if the response's `restartRequired` list is not empty. +A reset `settings.json` also drops the backup folder setting. If you use a custom folder, enter +it again and save before looking for the list; until then the list shows the default folder. +After a reinstall of the data directory the old backups are unlisted because the instance has a +new id; see [Config Backups](Config-Backups) for how to make them visible again. + Without the UI: the backups are plain folders in `~/.codeman/backups/config/` (`cfg-YYYYMMDD-HHMMSS`); copy the file you need back over the one in `~/.codeman/`. diff --git a/src/config-backup.ts b/src/config-backup.ts index 1189c2d7..9912950e 100644 --- a/src/config-backup.ts +++ b/src/config-backup.ts @@ -100,6 +100,13 @@ export interface ConfigBackupManifest { hash: string; /** Id of the data dir that made it (see {@link configBackupOrigin}), so instances sharing one folder keep to their own snapshots. */ origin?: string; + /** + * The retention in force when it was made. If `settings.json` later fails to parse or is reset, + * the next pass prunes with these instead of the defaults, so one bad write cannot shorten a + * history the user asked to keep. + */ + keepCount?: number; + keepDays?: number; files: ConfigBackupFile[]; } @@ -191,6 +198,23 @@ export function configBackupOrigin(dataDir: string): string { return id; } +/** The id already made for this data dir, or null. Read-only: listing must not write a file. */ +export function readConfigBackupOrigin(dataDir: string): string | null { + try { + const existing = readFileSync(join(dataDir, ORIGIN_FILE), 'utf8').trim(); + return ORIGIN_RE.test(existing) ? existing : null; + } catch { + return null; + } +} + +/** True when any of the four `configBackup*` keys is present (the Settings form always sends all four). */ +export function hasConfigBackupSettings(raw: Record<string, unknown>): boolean { + return ['configBackupEnabled', 'configBackupDir', 'configBackupKeepCount', 'configBackupKeepDays'].some( + (k) => k in raw + ); +} + const sha256 = (data: Buffer | string): string => createHash('sha256').update(data).digest('hex'); /** @@ -297,6 +321,9 @@ export interface CreateConfigBackupOptions { appVersion?: string; /** Snapshot even if nothing changed (a manual request, a pre-restore safety copy). */ force?: boolean; + /** Recorded in the manifest, see {@link ConfigBackupManifest.keepCount}. */ + keepCount?: number; + keepDays?: number; now?: number; } @@ -341,6 +368,8 @@ export function createConfigBackup(opts: CreateConfigBackupOptions): ConfigBacku appVersion: opts.appVersion ?? '', hash, origin, + ...(opts.keepCount !== undefined && { keepCount: opts.keepCount }), + ...(opts.keepDays !== undefined && { keepDays: opts.keepDays }), files: present.map((f) => ({ name: f.name, bytes: f.data.length, sha256: f.sha })), }; for (const f of present) writeFileSync(join(tmp, f.name), f.data, { mode: 0o600 }); @@ -373,6 +402,15 @@ export function sweepConfigBackupTemps(dir: string, now: number = Date.now()): v } } +/** The retention recorded in this instance's newest snapshot, or null when it recorded none. */ +export function latestBackupRetention(dir: string, origin: string): { keepCount: number; keepDays: number } | null { + const [newest] = listConfigBackups(dir, origin); + const m = newest ? readManifest(dir, newest.id, origin) : null; + return m && typeof m.keepCount === 'number' && typeof m.keepDays === 'number' + ? { keepCount: m.keepCount, keepDays: m.keepDays } + : null; +} + export interface PruneOptions { keepCount: number; keepDays: number; @@ -418,15 +456,36 @@ export class ConfigBackupCorruptError extends Error {} * writes the file as 0600: a symlink, or a file with a group/world bit, was planted or loosened * after the snapshot, and rewriting it as 0600 would turn it into trusted config (`clis.json` * selects the binaries Codeman spawns; `isUnsafePermissions` refuses the same bits there). - * POSIX only, like `isUnsafePermissions`. + * POSIX only, like `isUnsafePermissions`. The mode test is skipped (the symlink test is not) on a + * filesystem that does not keep modes, see {@link folderKeepsModes}. */ -function assertTrustedSnapshotEntry(path: string, label: string): void { +function assertTrustedSnapshotEntry(path: string, label: string, checkMode: boolean): void { if (process.platform === 'win32') return; const st = lstatSync(path, { throwIfNoEntry: false }); if (!st) return; // missing is reported by the read that follows - if (st.isSymbolicLink()) throw new ConfigBackupCorruptError(`${label} is a symlink, so it is not restored`); - if ((st.mode & 0o077) !== 0) { - throw new ConfigBackupCorruptError(`${label} is readable or writable by group or others, so it is not restored`); + if (st.isSymbolicLink()) throw new ConfigBackupCorruptError(`${label} is a symlink, so it is not used`); + if (checkMode && (st.mode & 0o077) !== 0) { + throw new ConfigBackupCorruptError(`${label} is readable or writable by group or others, so it is not used`); + } +} + +/** + * Whether the backup folder's filesystem keeps a file's mode: vfat/exFAT, CIFS shares and WSL's + * `/mnt/d` report 0755 or 0777 for everything, so the group/world test would refuse every backup + * there even though snapshots succeed (`createConfigBackup` already expects mode-less filesystems). + * Probed with a 0600 temp file; if the probe cannot run, the strict test stays on. + */ +function folderKeepsModes(dir: string): boolean { + if (process.platform === 'win32') return false; + const probe = join(dir, `.mode-probe-${process.pid}`); + try { + writeFileSync(probe, '', { mode: 0o600 }); + chmodSync(probe, 0o600); + return (lstatSync(probe).mode & 0o777 & 0o077) === 0; + } catch { + return true; + } finally { + rmSync(probe, { force: true }); } } @@ -471,17 +530,21 @@ export function restoreConfigBackup(opts: { id: string; appVersion?: string; now?: number; + /** Override the probe of whether the backup folder keeps file modes (tests; a mode-less filesystem). */ + keepsModes?: boolean; }): RestoreResult { - const manifest = readManifest(opts.backupDir, opts.id, configBackupOrigin(opts.dataDir)); + const origin = readConfigBackupOrigin(opts.dataDir); + const manifest = origin ? readManifest(opts.backupDir, opts.id, origin) : null; if (!manifest) throw new ConfigBackupNotFoundError(`No config backup named ${opts.id}`); const allowed = new Set<string>(CONFIG_BACKUP_FILES); const loaded: Array<{ name: string; data: Buffer }> = []; - assertTrustedSnapshotEntry(join(opts.backupDir, opts.id), `Backup ${opts.id}`); + const checkMode = opts.keepsModes ?? folderKeepsModes(opts.backupDir); + assertTrustedSnapshotEntry(join(opts.backupDir, opts.id), `Backup ${opts.id}`, checkMode); for (const f of manifest.files) { if (!allowed.has(f.name)) continue; let data: Buffer; - assertTrustedSnapshotEntry(join(opts.backupDir, opts.id, f.name), `${f.name} in ${opts.id}`); + assertTrustedSnapshotEntry(join(opts.backupDir, opts.id, f.name), `${f.name} in ${opts.id}`, checkMode); try { data = readFileSync(join(opts.backupDir, opts.id, f.name)); } catch { @@ -661,12 +724,16 @@ function loadDiffSide(side: string, dataDir: string, backupDir: string): Map<str } return out; } - const manifest = readManifest(backupDir, side, configBackupOrigin(dataDir)); + const origin = readConfigBackupOrigin(dataDir); + const manifest = origin ? readManifest(backupDir, side, origin) : null; if (!manifest) throw new ConfigBackupNotFoundError(`No config backup named ${side}`); const allowed = new Set<string>(CONFIG_BACKUP_FILES); + const checkMode = folderKeepsModes(backupDir); + assertTrustedSnapshotEntry(join(backupDir, side), `Backup ${side}`, checkMode); for (const f of manifest.files) { if (!allowed.has(f.name)) continue; let data: Buffer; + assertTrustedSnapshotEntry(join(backupDir, side, f.name), `${f.name} in ${side}`, checkMode); try { data = readFileSync(join(backupDir, side, f.name)); } catch { diff --git a/src/web/routes/config-backup-routes.ts b/src/web/routes/config-backup-routes.ts index b250437d..1a1cc3e4 100644 --- a/src/web/routes/config-backup-routes.ts +++ b/src/web/routes/config-backup-routes.ts @@ -14,6 +14,7 @@ * may have cached: `restartRequired` says which ones only apply after a restart. */ +import { readFile } from 'node:fs/promises'; import { createRequire } from 'node:module'; import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'; import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.js'; @@ -39,6 +40,9 @@ import { resolveConfigBackupDir, restoreConfigBackup, configBackupOrigin, + hasConfigBackupSettings, + latestBackupRetention, + readConfigBackupOrigin, sweepConfigBackupTemps, type ConfigBackupResult, type ConfigBackupSettings, @@ -96,27 +100,51 @@ export async function probeBackupDir(settings: ConfigBackupSettings, dir: string return false; } +async function readSettingsForBackup(): Promise<Record<string, unknown> | 'unreadable'> { + try { + const parsed: unknown = JSON.parse(await readFile(SETTINGS_PATH, 'utf8')); + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as Record<string, unknown>) + : 'unreadable'; + } catch (err) { + return (err as NodeJS.ErrnoException).code === 'ENOENT' ? {} : 'unreadable'; + } +} + /** Snapshot (when changed) and apply retention; shared by the timer and the manual route. */ export async function runConfigBackup( reason: string, opts: { force?: boolean } = {} ): Promise<{ result: ConfigBackupResult; removed: string[] } | null> { - const { settings, dir } = await readEffectiveBackupSettings(); - if (!settings.enabled && !opts.force) return null; - await probeBackupDir(settings, dir); + // Read settings.json directly: readJsonConfig answers {} for ANY failure, so a syntax error or a read + // that lands inside PUT /api/settings's non-atomic write used to prune with the defaults (20 / 30 + // days) and drop the longer history the user asked to keep. + const raw = await readSettingsForBackup(); + const unreadable = raw === 'unreadable'; + const fromFile = readConfigBackupSettings(unreadable ? {} : raw); + if (!fromFile.enabled && !opts.force) return null; + const dir = resolveConfigBackupDir(fromFile, getDataDir()); + await probeBackupDir(fromFile, dir); sweepConfigBackupTemps(dir); + const origin = configBackupOrigin(getDataDir()); + // No backup settings in the file (reset, or unreadable): keep the retention the newest snapshot recorded. + const settings = + unreadable || !hasConfigBackupSettings(raw) + ? { ...fromFile, ...(latestBackupRetention(dir, origin) ?? {}) } + : fromFile; const result = createConfigBackup({ dataDir: getDataDir(), backupDir: dir, reason, appVersion: APP_VERSION, force: opts.force, - }); - const removed = pruneConfigBackups(dir, { keepCount: settings.keepCount, keepDays: settings.keepDays, - origin: configBackupOrigin(getDataDir()), }); + // An unreadable settings.json still gets its snapshot, but no pass deletes anything on its say-so. + const removed = unreadable + ? [] + : pruneConfigBackups(dir, { keepCount: settings.keepCount, keepDays: settings.keepDays, origin }); return { result, removed }; } @@ -133,6 +161,8 @@ export function registerConfigBackupRoutes(app: FastifyInstance): void { const denied = deny(req, reply); if (denied) return denied; const { settings, dir } = await readEffectiveBackupSettings(); + // Read-only: a listing must not create config-backup-id, and without one nothing can be ours. + const origin = readConfigBackupOrigin(getDataDir()); let present: boolean; try { present = await probeBackupDir(settings, dir); @@ -147,7 +177,7 @@ export function registerConfigBackupRoutes(app: FastifyInstance): void { dir, defaultDir: defaultConfigBackupDir(getDataDir()), tracked: CONFIG_BACKUP_FILES, - backups: present ? listConfigBackups(dir, configBackupOrigin(getDataDir())) : [], + backups: present && origin ? listConfigBackups(dir, origin) : [], }, }; }); diff --git a/test/config-backup.test.ts b/test/config-backup.test.ts index 0a24ceea..fe56474f 100644 --- a/test/config-backup.test.ts +++ b/test/config-backup.test.ts @@ -526,6 +526,30 @@ describe('restore trusts only what this module wrote', () => { expect(listConfigBackups(backups)).toHaveLength(1); }); + it('skips the group/world bit test on a filesystem that does not keep modes, but never the symlink test', () => { + put('settings.json', '{"a":1}'); + put('clis.json', '{"clis":{}}'); + const { id } = made(); + const file = join(backups, id, 'clis.json'); + chmodSync(file, 0o644); // what vfat, CIFS and WSL /mnt/d report for every file + expect(() => restoreConfigBackup({ dataDir: data, backupDir: backups, id, keepsModes: false })).not.toThrow(); + + const real = join(root, 'elsewhere.json'); + writeFileSync(real, '{"clis":{}}'); + rmSync(file); + symlinkSync(real, file); + expect(() => restoreConfigBackup({ dataDir: data, backupDir: backups, id, keepsModes: false })).toThrow(/symlink/); + }); + + it('compare applies the same check to a snapshot file', () => { + put('settings.json', '{"a":1}'); + const { id } = made(); + chmodSync(join(backups, id, 'settings.json'), 0o644); + expect(() => diffConfigBackups({ dataDir: data, backupDir: backups, from: id, to: 'current' })).toThrow( + ConfigBackupCorruptError + ); + }); + it('refuses a snapshot folder that others can write to', () => { put('settings.json', '{"a":1}'); const { id } = made(); diff --git a/test/routes/config-backup-routes.test.ts b/test/routes/config-backup-routes.test.ts index e5e7b79c..1dc1704b 100644 --- a/test/routes/config-backup-routes.test.ts +++ b/test/routes/config-backup-routes.test.ts @@ -9,10 +9,10 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { createRouteTestHarness } from './_route-test-utils.js'; -import { registerConfigBackupRoutes } from '../../src/web/routes/config-backup-routes.js'; +import { registerConfigBackupRoutes, runConfigBackup } from '../../src/web/routes/config-backup-routes.js'; import { SETTINGS_PATH } from '../../src/web/route-helpers.js'; import { getDataDir } from '../../src/config/instance.js'; -import { defaultConfigBackupDir } from '../../src/config-backup.js'; +import { createConfigBackup, defaultConfigBackupDir, listConfigBackups } from '../../src/config-backup.js'; import { intentStore } from '../../src/intent-store.js'; let custom: string; @@ -183,3 +183,61 @@ describe('GET /api/config-backups/diff', () => { expect(missing.statusCode).toBe(404); }); }); + +describe('retention survives an unreadable or reset settings.json', () => { + const DAY = 86_400_000; + const BASE = Date.UTC(2025, 0, 1); + const dir = () => defaultConfigBackupDir(getDataDir()); + /** 30 snapshots a day apart, made under a long-history retention (100 copies, no age limit). */ + function seed() { + settings({ configBackupKeepCount: 100, configBackupKeepDays: 0 }); + for (let i = 0; i < 30; i++) { + writeFileSync( + SETTINGS_PATH, + JSON.stringify({ displayName: `v${i}`, configBackupKeepCount: 100, configBackupKeepDays: 0 }) + ); + createConfigBackup({ + dataDir: getDataDir(), + backupDir: dir(), + reason: 'auto', + now: BASE + i * DAY, + keepCount: 100, + keepDays: 0, + }); + } + expect(listConfigBackups(dir())).toHaveLength(30); + } + + it('a settings.json that does not parse still gets a snapshot but prunes nothing', async () => { + seed(); + writeFileSync(SETTINGS_PATH, '{ "displayName": "half-written", }'); + const out = await runConfigBackup('auto'); + expect(out?.result.status).toBe('created'); + expect(out?.removed).toEqual([]); + expect(listConfigBackups(dir())).toHaveLength(31); + }); + + it('a reset settings.json prunes with the retention the newest snapshot recorded, not the defaults', async () => { + seed(); + writeFileSync(SETTINGS_PATH, '{}'); + const out = await runConfigBackup('auto'); + expect(out?.result.status).toBe('created'); + // aged manifests (a year old) and keepDays 0: the defaults (20 copies, 30 days) would leave 20 + expect(listConfigBackups(dir())).toHaveLength(31); + }); + + it('a settings.json that sets the retention still wins', async () => { + seed(); + writeFileSync(SETTINGS_PATH, JSON.stringify({ configBackupKeepCount: 5, configBackupKeepDays: 0 })); + await runConfigBackup('auto'); + expect(listConfigBackups(dir())).toHaveLength(5); + }); + + it('listing never creates config-backup-id', async () => { + const { app } = await createRouteTestHarness(registerConfigBackupRoutes); + const idFile = join(getDataDir(), 'config-backup-id'); + rmSync(idFile, { force: true }); + expect((await app.inject({ method: 'GET', url: '/api/config-backups' })).json().data.backups).toEqual([]); + expect(existsSync(idFile)).toBe(false); + }); +}); From 241886d44dacf707a29a0589bc25fe48a74a3330 Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Tue, 20 Oct 2026 19:31:55 +0800 Subject: [PATCH 27/96] fix(config-backup): a restore after a reset keeps the retention, the mode probe cannot write through a planted symlink, the form shows the retention in use --- docs/wiki/Config-Backups.md | 11 ++++- src/config-backup.ts | 51 +++++++++++++++++++----- src/web/public/settings-ui.js | 7 ++++ src/web/routes/config-backup-routes.ts | 14 +++---- test/config-backup-restore-ui.test.ts | 44 ++++++++++++++++++++ test/config-backup.test.ts | 43 ++++++++++++++++++++ test/routes/config-backup-routes.test.ts | 28 ++++++++++++- 7 files changed, 176 insertions(+), 22 deletions(-) diff --git a/docs/wiki/Config-Backups.md b/docs/wiki/Config-Backups.md index e743327c..f3dd9962 100644 --- a/docs/wiki/Config-Backups.md +++ b/docs/wiki/Config-Backups.md @@ -138,7 +138,10 @@ Only the tracked names are ever written, whatever a manifest lists. Restoring `settings.json` keeps the four backup settings (on or off, folder, how many, how many days) at their current values, so a restore from before a folder change does not point the list -at the old folder and hide the `pre-restore` copy it just made. +at the old folder and hide the `pre-restore` copy it just made. The exception is a live +`settings.json` that has none of the four (a reset file): the restore then keeps the backup's own +how-many and how-many-days, so the next pass does not fall back to 20 copies and 30 days. The +`pre-restore` copy records the retention in force too. Settings are re-read on the next request, so reload the page. `.env` and `push-keys.json` are read once per process: **restart Codeman** for those to take effect (the response lists which). @@ -191,7 +194,11 @@ query and fragment. Choose a private location, or turn the feature off if you cannot. - **Retention survives a bad `settings.json`.** If it does not parse, a pass still takes its snapshot but deletes nothing. If the four backup settings are missing (a reset file), the pass - prunes with the retention the newest backup recorded, not the 20 copies and 30 days defaults. + prunes with the retention the newest backup that recorded one holds, not the 20 copies and 30 + days defaults, and Settings shows those values until you save. While `settings.json` does not + parse, a pass reads it as empty, so its snapshot goes to the default folder even if a custom + folder is set or backups are switched off; nothing is widened (same modes, inside the data + directory). - **Anyone who can write to a custom backup folder can plant a backup that Restore applies.** Restore refuses symlinks and files others can write, but a file of your own user's making passes, so keep the folder private. diff --git a/src/config-backup.ts b/src/config-backup.ts index 9912950e..c641af37 100644 --- a/src/config-backup.ts +++ b/src/config-backup.ts @@ -404,11 +404,30 @@ export function sweepConfigBackupTemps(dir: string, now: number = Date.now()): v /** The retention recorded in this instance's newest snapshot, or null when it recorded none. */ export function latestBackupRetention(dir: string, origin: string): { keepCount: number; keepDays: number } | null { - const [newest] = listConfigBackups(dir, origin); - const m = newest ? readManifest(dir, newest.id, origin) : null; - return m && typeof m.keepCount === 'number' && typeof m.keepDays === 'number' - ? { keepCount: m.keepCount, keepDays: m.keepDays } - : null; + // The newest manifest that recorded one: a snapshot from before this field existed must not hide it. + for (const b of listConfigBackups(dir, origin)) { + const m = readManifest(dir, b.id, origin); + if (m && typeof m.keepCount === 'number' && typeof m.keepDays === 'number') { + return { keepCount: m.keepCount, keepDays: m.keepDays }; + } + } + return null; +} + +/** + * The settings in effect when `raw` (the parsed settings.json) has none of the four `configBackup*` + * keys, as after a reset: the retention the newest backup recorded instead of the defaults. One + * place for the timer pass and for what the GET route reports, so they cannot disagree. + */ +export function withRecordedRetention( + settings: ConfigBackupSettings, + raw: Record<string, unknown>, + dir: string, + origin: string | null +): ConfigBackupSettings { + if (hasConfigBackupSettings(raw) || !origin) return settings; + const kept = latestBackupRetention(dir, origin); + return kept ? { ...settings, ...kept } : settings; } export interface PruneOptions { @@ -473,19 +492,25 @@ function assertTrustedSnapshotEntry(path: string, label: string, checkMode: bool * Whether the backup folder's filesystem keeps a file's mode: vfat/exFAT, CIFS shares and WSL's * `/mnt/d` report 0755 or 0777 for everything, so the group/world test would refuse every backup * there even though snapshots succeed (`createConfigBackup` already expects mode-less filesystems). - * Probed with a 0600 temp file; if the probe cannot run, the strict test stays on. + * Probed with a 0600 temp file made with `wx` under a random name; if the probe cannot run, the + * strict test stays on. */ function folderKeepsModes(dir: string): boolean { if (process.platform === 'win32') return false; - const probe = join(dir, `.mode-probe-${process.pid}`); + // A random name and 'wx' (fail if anything is there): a planted symlink or directory with a + // guessable name must neither be written through nor break the probe. Only a file this call + // created is removed. + const probe = join(dir, `.mode-probe-${randomUUID()}`); + let created = false; try { - writeFileSync(probe, '', { mode: 0o600 }); + writeFileSync(probe, '', { mode: 0o600, flag: 'wx' }); + created = true; chmodSync(probe, 0o600); return (lstatSync(probe).mode & 0o777 & 0o077) === 0; } catch { return true; } finally { - rmSync(probe, { force: true }); + if (created) rmSync(probe, { force: true }); } } @@ -498,7 +523,11 @@ function keepCurrentBackupSettings(snapshot: Buffer, livePath: string): Buffer { const live = JSON.parse(readFileSync(livePath, 'utf8')) as Record<string, unknown>; if (!restored || typeof restored !== 'object' || !live || typeof live !== 'object') return snapshot; let changed = false; + // A reset live file has none of the four keys. Taking "absent" for the retention too would bring + // the defaults back (20 copies, 30 days) on the next pass, so the backup's own retention stays. + const liveHasSettings = hasConfigBackupSettings(live); for (const key of BACKUP_SETTING_KEYS) { + if (!liveHasSettings && (key === 'configBackupKeepCount' || key === 'configBackupKeepDays')) continue; if (JSON.stringify(restored[key]) === JSON.stringify(live[key])) continue; changed = true; if (key in live) restored[key] = live[key]; @@ -535,7 +564,7 @@ export function restoreConfigBackup(opts: { }): RestoreResult { const origin = readConfigBackupOrigin(opts.dataDir); const manifest = origin ? readManifest(opts.backupDir, opts.id, origin) : null; - if (!manifest) throw new ConfigBackupNotFoundError(`No config backup named ${opts.id}`); + if (!manifest || !origin) throw new ConfigBackupNotFoundError(`No config backup named ${opts.id}`); const allowed = new Set<string>(CONFIG_BACKUP_FILES); const loaded: Array<{ name: string; data: Buffer }> = []; @@ -563,6 +592,8 @@ export function restoreConfigBackup(opts: { appVersion: opts.appVersion, force: true, now: opts.now, + // The retention in force, so this newest snapshot does not hide it from the next pass. + ...(latestBackupRetention(opts.backupDir, origin) ?? {}), }); mkdirSync(opts.dataDir, { recursive: true }); diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index 30a475b8..03d3e239 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -1388,6 +1388,13 @@ Object.assign(CodemanApp.prototype, { const data = body.data; const dirInput = this.$('appSettingsConfigBackupDir') || document.getElementById('appSettingsConfigBackupDir'); if (dirInput && data.defaultDir) dirInput.placeholder = `default: ${data.defaultDir}`; + // A reset settings.json has none of the four keys, but the passes keep the retention the newest backup + // recorded: show that, or a Save would write 20 / 30 over it. + const saved = this.loadAppSettingsFromStorage(); + const countInput = document.getElementById('appSettingsConfigBackupKeepCount'); + const daysInput = document.getElementById('appSettingsConfigBackupKeepDays'); + if (countInput && saved.configBackupKeepCount === undefined && data.settings) countInput.value = data.settings.keepCount; + if (daysInput && saved.configBackupKeepDays === undefined && data.settings) daysInput.value = data.settings.keepDays; if (!data.backups.length) { out.innerHTML = `No backups yet in <code>${escapeHtml(data.dir)}</code>.`; return; diff --git a/src/web/routes/config-backup-routes.ts b/src/web/routes/config-backup-routes.ts index 1a1cc3e4..602e0673 100644 --- a/src/web/routes/config-backup-routes.ts +++ b/src/web/routes/config-backup-routes.ts @@ -40,8 +40,7 @@ import { resolveConfigBackupDir, restoreConfigBackup, configBackupOrigin, - hasConfigBackupSettings, - latestBackupRetention, + withRecordedRetention, readConfigBackupOrigin, sweepConfigBackupTemps, type ConfigBackupResult, @@ -73,8 +72,10 @@ export interface ConfigBackupStatus { export async function readEffectiveBackupSettings(): Promise<{ settings: ConfigBackupSettings; dir: string }> { const raw = await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'settings.json', {}); - const settings = readConfigBackupSettings(raw); - return { settings, dir: resolveConfigBackupDir(settings, getDataDir()) }; + const base = readConfigBackupSettings(raw); + const dir = resolveConfigBackupDir(base, getDataDir()); + // The same fallback the timer pass uses, so the form never shows 20 / 30 while a pass keeps 100 / 0. + return { settings: withRecordedRetention(base, raw, dir, readConfigBackupOrigin(getDataDir())), dir }; } /** A user-chosen backup folder that is not safe to touch (bounded probe, as for case paths, #516). */ @@ -128,10 +129,7 @@ export async function runConfigBackup( sweepConfigBackupTemps(dir); const origin = configBackupOrigin(getDataDir()); // No backup settings in the file (reset, or unreadable): keep the retention the newest snapshot recorded. - const settings = - unreadable || !hasConfigBackupSettings(raw) - ? { ...fromFile, ...(latestBackupRetention(dir, origin) ?? {}) } - : fromFile; + const settings = withRecordedRetention(fromFile, unreadable ? {} : raw, dir, origin); const result = createConfigBackup({ dataDir: getDataDir(), backupDir: dir, diff --git a/test/config-backup-restore-ui.test.ts b/test/config-backup-restore-ui.test.ts index 3e79e972..256e4479 100644 --- a/test/config-backup-restore-ui.test.ts +++ b/test/config-backup-restore-ui.test.ts @@ -62,3 +62,47 @@ describe('restoreConfigBackup (Settings UI)', () => { expect(confirms[0]).toMatch(/users\.json, \.env\)/); }); }); + +describe('loadConfigBackups (retention fields)', () => { + function load(saved: Record<string, unknown>) { + const inputs: Record<string, { value: string; placeholder: string }> = { + appSettingsConfigBackupKeepCount: { value: '20', placeholder: '' }, + appSettingsConfigBackupKeepDays: { value: '30', placeholder: '' }, + appSettingsConfigBackupDir: { value: '', placeholder: '' }, + }; + function CodemanApp(this: unknown) {} + const ctx = vm.createContext({ + CodemanApp, + window: {}, + document: { getElementById: (id: string) => inputs[id] ?? null }, + escapeHtml: (s: string) => s, + }); + vm.runInContext(SRC, ctx); + const app = Object.create((CodemanApp as unknown as { prototype: object }).prototype) as Record<string, unknown>; + Object.assign(app, { + $: () => ({ textContent: '', innerHTML: '' }), + loadAppSettingsFromStorage: () => saved, + _api: async () => ({ + ok: true, + json: async () => ({ + success: true, + data: { settings: { keepCount: 100, keepDays: 0 }, dir: '/d', defaultDir: '/d', backups: [] }, + }), + }), + }); + return { app, inputs }; + } + + it('shows the retention the passes use when settings.json has none of the keys (a reset file)', async () => { + const { app, inputs } = load({}); + await (app.loadConfigBackups as () => Promise<void>)(); + expect(inputs.appSettingsConfigBackupKeepCount.value).toBe(100); + expect(inputs.appSettingsConfigBackupKeepDays.value).toBe(0); + }); + + it('leaves the form alone when the saved settings state the retention', async () => { + const { app, inputs } = load({ configBackupKeepCount: 7, configBackupKeepDays: 9 }); + await (app.loadConfigBackups as () => Promise<void>)(); + expect(inputs.appSettingsConfigBackupKeepCount.value).toBe('20'); // the open-settings code owns it + }); +}); diff --git a/test/config-backup.test.ts b/test/config-backup.test.ts index fe56474f..a045939e 100644 --- a/test/config-backup.test.ts +++ b/test/config-backup.test.ts @@ -32,6 +32,7 @@ import { resolveConfigBackupDir, restoreConfigBackup, configBackupOrigin, + latestBackupRetention, sweepConfigBackupTemps, } from '../src/config-backup.js'; @@ -541,6 +542,19 @@ describe('restore trusts only what this module wrote', () => { expect(() => restoreConfigBackup({ dataDir: data, backupDir: backups, id, keepsModes: false })).toThrow(/symlink/); }); + it('the mode probe never writes through a planted symlink or trips over a planted directory', () => { + put('settings.json', '{"a":1}'); + const { id } = made(); + const target = join(root, 'victim.txt'); + writeFileSync(target, 'precious', { mode: 0o644 }); + symlinkSync(target, join(backups, `.mode-probe-${process.pid}`)); + mkdirSync(join(backups, '.mode-probe-0')); + expect(() => restoreConfigBackup({ dataDir: data, backupDir: backups, id })).not.toThrow(); + expect(() => diffConfigBackups({ dataDir: data, backupDir: backups, from: id, to: 'current' })).not.toThrow(); + expect(readFileSync(target, 'utf8')).toBe('precious'); + expect(statSync(target).mode & 0o777).toBe(0o644); + }); + it('compare applies the same check to a snapshot file', () => { put('settings.json', '{"a":1}'); const { id } = made(); @@ -559,6 +573,35 @@ describe('restore trusts only what this module wrote', () => { expect(() => restoreConfigBackup({ dataDir: data, backupDir: backups, id })).not.toThrow(); }); + it('keeps the backup retention when the live settings.json was reset and has none of the four keys', () => { + put('settings.json', JSON.stringify({ displayName: 'old', configBackupKeepCount: 100, configBackupKeepDays: 0 })); + const { id } = made(); + put('settings.json', '{}'); + restoreConfigBackup({ dataDir: data, backupDir: backups, id }); + expect(JSON.parse(readFileSync(join(data, 'settings.json'), 'utf8'))).toEqual({ + displayName: 'old', + configBackupKeepCount: 100, + configBackupKeepDays: 0, + }); + }); + + it('latestBackupRetention skips a newer snapshot that recorded none, and a pre-restore copy records it', () => { + put('settings.json', '{"a":1}'); + createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0, keepCount: 100, keepDays: 0 }); + put('settings.json', '{"a":2}'); + createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + 1000 }); // an older build + const origin = configBackupOrigin(data); + expect(latestBackupRetention(backups, origin)).toEqual({ keepCount: 100, keepDays: 0 }); + const id = listConfigBackups(backups, origin).at(-1)!.id; + const r = restoreConfigBackup({ dataDir: data, backupDir: backups, id, now: T0 + 2000 }); + expect(r.safetySnapshot).toBeTruthy(); + const pre = listConfigBackups(backups, origin).find((b) => b.id === r.safetySnapshot)!; + expect(JSON.parse(readFileSync(join(backups, pre.id, 'manifest.json'), 'utf8'))).toMatchObject({ + keepCount: 100, + keepDays: 0, + }); + }); + it('keeps the current folder and retention when settings.json is restored', () => { put('settings.json', JSON.stringify({ displayName: 'old', configBackupDir: '/old', configBackupKeepCount: 5 })); const { id } = made(); diff --git a/test/routes/config-backup-routes.test.ts b/test/routes/config-backup-routes.test.ts index 1dc1704b..a87d71be 100644 --- a/test/routes/config-backup-routes.test.ts +++ b/test/routes/config-backup-routes.test.ts @@ -189,12 +189,16 @@ describe('retention survives an unreadable or reset settings.json', () => { const BASE = Date.UTC(2025, 0, 1); const dir = () => defaultConfigBackupDir(getDataDir()); /** 30 snapshots a day apart, made under a long-history retention (100 copies, no age limit). */ - function seed() { + function seed(withKeys = true) { settings({ configBackupKeepCount: 100, configBackupKeepDays: 0 }); for (let i = 0; i < 30; i++) { writeFileSync( SETTINGS_PATH, - JSON.stringify({ displayName: `v${i}`, configBackupKeepCount: 100, configBackupKeepDays: 0 }) + JSON.stringify( + withKeys + ? { displayName: `v${i}`, configBackupKeepCount: 100, configBackupKeepDays: 0 } + : { displayName: `v${i}` } + ) ); createConfigBackup({ dataDir: getDataDir(), @@ -226,6 +230,26 @@ describe('retention survives an unreadable or reset settings.json', () => { expect(listConfigBackups(dir())).toHaveLength(31); }); + it.each([true, false])( + 'a restore after a reset keeps the retention (the backup %s the four keys)', + async (withKeys) => { + seed(withKeys); + const oldest = listConfigBackups(dir()).at(-1)!.id; + writeFileSync(SETTINGS_PATH, '{}'); // the reset + expect((await runConfigBackup('auto'))?.removed).toEqual([]); + + const { app } = await createRouteTestHarness(registerConfigBackupRoutes); + const res = await app.inject({ method: 'POST', url: `/api/config-backups/${oldest}/restore` }); + expect(res.statusCode).toBe(200); + // the restored file carries the retention, or the recorded one still applies via the pre-restore copy + const out = await runConfigBackup('auto'); + expect(out?.removed).toEqual([]); + expect(listConfigBackups(dir()).length).toBeGreaterThanOrEqual(32); + const status = (await app.inject({ method: 'GET', url: '/api/config-backups' })).json().data.settings; + expect(status).toMatchObject({ keepCount: 100, keepDays: 0 }); // what the form shows is what the pass uses + } + ); + it('a settings.json that sets the retention still wins', async () => { seed(); writeFileSync(SETTINGS_PATH, JSON.stringify({ configBackupKeepCount: 5, configBackupKeepDays: 0 })); From 9c0fc30927bea3f6b2a049e15145e99fc0862758 Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Tue, 20 Oct 2026 20:32:14 +0800 Subject: [PATCH 28/96] fix(config-backup): the retention fallback follows the folder, a clock-ahead snapshot cannot make every pass write a copy --- docs/api-reference.md | 2 +- docs/wiki/Config-Backups.md | 12 ++++--- src/config-backup.ts | 41 +++++++++++++----------- src/web/public/index.html | 4 +-- src/web/public/settings-ui.js | 10 ++++-- test/config-backup.test.ts | 34 +++++++++++++++++++- test/routes/config-backup-routes.test.ts | 33 +++++++++++++++++++ 7 files changed, 108 insertions(+), 28 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index 45553419..e5e27151 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1158,7 +1158,7 @@ Timestamped snapshots of the files that hold what a user has customized (`src/co Tracked files (a fixed list, relative to the data dir): `settings.json`, `clis.json`, `custom-model-hosts.json`, `webhook.json`, `intents.json`, `linked-cases.json`, `remote-hosts.json`, `remote-cases.json`, `docker-hosts.json`, `docker-cases.json`, `webviews.json`, `push-keys.json`, `users.json`, `.env`. Session state (`state.json`, which also holds cron jobs and respawn presets), logs, the hook secret and caches are not. -Settings (synced, in `settings.json`): `configBackupEnabled` (absent = on), `configBackupDir` (absolute path, `~` expanded; empty = `<data dir>/backups/config`), `configBackupKeepCount` (2–500, default 20), `configBackupKeepDays` (0–3650, default 30; `0` = no age limit). Retention only ever deletes snapshot folders Codeman made there, and never the newest one. Each snapshot is a `0700` folder named `cfg-YYYYMMDD-HHMMSS` (server local time; `-2`, `-3`… on a same-second collision) holding the tracked files under their own names plus a `manifest.json` (`id`, `createdAt`, `reason` of `startup`/`auto`/`manual`/`pre-restore`, `appVersion`, an `origin` id, and a SHA-256 and size per file), all `0600`. Only folders with that name and a valid manifest are listed, restored or pruned. Full guide: `docs/wiki/Config-Backups.md`. +Settings (synced, in `settings.json`): `configBackupEnabled` (absent = on), `configBackupDir` (absolute path, `~` expanded; empty = `<data dir>/backups/config`), `configBackupKeepCount` (2–500, default 20), `configBackupKeepDays` (0–3650, default 30; `0` = no age limit). Retention only ever deletes snapshot folders Codeman made there, and never the newest one. Each snapshot is a `0700` folder named `cfg-YYYYMMDD-HHMMSS` (server local time; `-2`, `-3`… on a same-second collision) holding the tracked files under their own names plus a `manifest.json` (`id`, `createdAt`, `reason` of `startup`/`auto`/`manual`/`pre-restore`, `appVersion`, the change-check `hash`, an `origin` id, the `keepCount` and `keepDays` in force, and a SHA-256 and size per file), all `0600`. A snapshot is never dated earlier than the instance's newest one, so a clock that read ahead once does not make every later pass write a copy. All four routes answer `503 OPERATION_FAILED` when a custom folder does not respond, and diff and restore answer `409` for a symlinked or group/world-accessible backup as well as a checksum mismatch. Only folders with that name and a valid manifest are listed, restored or pruned. Full guide: `docs/wiki/Config-Backups.md`. | Method | Path | Body | Notes | | ------ | ------------------------------------ | ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | diff --git a/docs/wiki/Config-Backups.md b/docs/wiki/Config-Backups.md index f3dd9962..62736e73 100644 --- a/docs/wiki/Config-Backups.md +++ b/docs/wiki/Config-Backups.md @@ -61,7 +61,8 @@ Each backup is one folder named for the moment it was made, in the server's loca ``` ~/.codeman/backups/config/ cfg-20261011-120305/ - manifest.json id, createdAt (epoch ms), reason, appVersion, SHA-256 + size per file + manifest.json id, createdAt (epoch ms), reason, appVersion, hash, origin, + keepCount and keepDays in force, SHA-256 + size per file settings.json the original file names, unchanged clis.json custom-model-hosts.json @@ -73,7 +74,7 @@ Each backup is one folder named for the moment it was made, in the server's loca | Part | Meaning | | ---- | ------- | | `cfg-YYYYMMDD-HHMMSS` | Date and time (server local time) the backup was made. A collision in the same second gets `-2`, `-3`, … | -| `manifest.json` | The record of the backup: `reason`, `appVersion`, and a SHA-256 and size for every file. | +| `manifest.json` | The record of the backup: `reason`, `appVersion`, the change-check `hash`, the `origin` id, the retention in force (`keepCount`, `keepDays`), and a SHA-256 and size for every file. | | `reason` | `startup`, `auto` (a change was detected), `manual` (Back up now) or `pre-restore`. It is in the manifest and the Settings list, not in the folder name. | | `.tmp-…` | A backup being written. It is renamed into place when complete and is never listed. | @@ -193,9 +194,12 @@ query and fragment. network share copies those credentials there, with whatever permissions that location has. Choose a private location, or turn the feature off if you cannot. - **Retention survives a bad `settings.json`.** If it does not parse, a pass still takes its - snapshot but deletes nothing. If the four backup settings are missing (a reset file), the pass + snapshot but deletes nothing. If a retention setting is missing (a reset file), the pass prunes with the retention the newest backup that recorded one holds, not the 20 copies and 30 - days defaults, and Settings shows those values until you save. While `settings.json` does not + days defaults, and Settings shows those values. Saving sends a retention value only if it was + saved before or you edited it there, so saving other settings (or re-entering a custom folder + after a reset) does not write the displayed 20 and 30 over it; the first pass in the re-entered + folder finds the retention its own backups recorded. While `settings.json` does not parse, a pass reads it as empty, so its snapshot goes to the default folder even if a custom folder is set or backups are switched off; nothing is widened (same modes, inside the data directory). diff --git a/src/config-backup.ts b/src/config-backup.ts index c641af37..34b4ec86 100644 --- a/src/config-backup.ts +++ b/src/config-backup.ts @@ -208,13 +208,6 @@ export function readConfigBackupOrigin(dataDir: string): string | null { } } -/** True when any of the four `configBackup*` keys is present (the Settings form always sends all four). */ -export function hasConfigBackupSettings(raw: Record<string, unknown>): boolean { - return ['configBackupEnabled', 'configBackupDir', 'configBackupKeepCount', 'configBackupKeepDays'].some( - (k) => k in raw - ); -} - const sha256 = (data: Buffer | string): string => createHash('sha256').update(data).digest('hex'); /** @@ -352,8 +345,12 @@ export function createConfigBackup(opts: CreateConfigBackupOptions): ConfigBacku if (latest && latest.hash === hash) return { status: 'unchanged', id: latest.id }; } + // Never dated earlier than this instance's newest snapshot. One written while the clock read ahead + // (a dual-boot machine, a VM resumed with a stale clock) would otherwise stay newest after the + // correction, differ from every later pass, and have each one write a copy until real time caught up. + const createdAt = existing[0] ? Math.max(now, existing[0].createdAt + 1) : now; mkdirSync(opts.backupDir, { recursive: true, mode: 0o700 }); - const base = `cfg-${stamp(now)}`; + const base = `cfg-${stamp(createdAt)}`; let id = base; for (let n = 2; existsSync(join(opts.backupDir, id)); n++) id = `${base}-${n}`; @@ -363,7 +360,7 @@ export function createConfigBackup(opts: CreateConfigBackupOptions): ConfigBacku try { const manifest: ConfigBackupManifest = { id, - createdAt: now, + createdAt, reason: opts.reason, appVersion: opts.appVersion ?? '', hash, @@ -415,9 +412,11 @@ export function latestBackupRetention(dir: string, origin: string): { keepCount: } /** - * The settings in effect when `raw` (the parsed settings.json) has none of the four `configBackup*` - * keys, as after a reset: the retention the newest backup recorded instead of the defaults. One - * place for the timer pass and for what the GET route reports, so they cannot disagree. + * The settings in effect, with each retention key that `raw` (the parsed settings.json) lacks, as + * after a reset, taken from what the newest backup recorded in `dir` instead of the defaults. It + * looks at the retention keys only (not the folder), so re-entering a custom folder after a reset + * finds that folder's own manifests. One place for the timer pass and for what the GET route + * reports, so they cannot disagree. */ export function withRecordedRetention( settings: ConfigBackupSettings, @@ -425,9 +424,16 @@ export function withRecordedRetention( dir: string, origin: string | null ): ConfigBackupSettings { - if (hasConfigBackupSettings(raw) || !origin) return settings; + const lacksCount = !('configBackupKeepCount' in raw); + const lacksDays = !('configBackupKeepDays' in raw); + if ((!lacksCount && !lacksDays) || !origin) return settings; const kept = latestBackupRetention(dir, origin); - return kept ? { ...settings, ...kept } : settings; + if (!kept) return settings; + return { + ...settings, + ...(lacksCount && { keepCount: kept.keepCount }), + ...(lacksDays && { keepDays: kept.keepDays }), + }; } export interface PruneOptions { @@ -523,11 +529,10 @@ function keepCurrentBackupSettings(snapshot: Buffer, livePath: string): Buffer { const live = JSON.parse(readFileSync(livePath, 'utf8')) as Record<string, unknown>; if (!restored || typeof restored !== 'object' || !live || typeof live !== 'object') return snapshot; let changed = false; - // A reset live file has none of the four keys. Taking "absent" for the retention too would bring - // the defaults back (20 copies, 30 days) on the next pass, so the backup's own retention stays. - const liveHasSettings = hasConfigBackupSettings(live); + // A reset live file lacks the retention keys. Taking "absent" for them would bring the defaults + // back (20 copies, 30 days) on the next pass, so the backup's own retention stays. for (const key of BACKUP_SETTING_KEYS) { - if (!liveHasSettings && (key === 'configBackupKeepCount' || key === 'configBackupKeepDays')) continue; + if (!(key in live) && (key === 'configBackupKeepCount' || key === 'configBackupKeepDays')) continue; if (JSON.stringify(restored[key]) === JSON.stringify(live[key])) continue; changed = true; if (key in live) restored[key] = live[key]; diff --git a/src/web/public/index.html b/src/web/public/index.html index 55150e12..d2e9b155 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -3264,14 +3264,14 @@ <span class="set-row-label">Keep this many backups</span> <span class="set-row-desc">2 to 500 (with 1, every change would delete the state before it). The newest backup is always kept.</span> </div> - <input type="number" id="appSettingsConfigBackupKeepCount" class="set-input" min="2" max="500" step="1" placeholder="20"> + <input type="number" id="appSettingsConfigBackupKeepCount" oninput="this.dataset.touched=1" class="set-input" min="2" max="500" step="1" placeholder="20"> </div> <div class="set-row has-field" data-search="config backup retention keep days age"> <div class="set-row-text"> <span class="set-row-label">Delete backups older than (days)</span> <span class="set-row-desc">A backup is removed once a newer one has existed this many days. 0 keeps backups until the count above pushes them out.</span> </div> - <input type="number" id="appSettingsConfigBackupKeepDays" class="set-input" min="0" max="3650" step="1" placeholder="30"> + <input type="number" id="appSettingsConfigBackupKeepDays" oninput="this.dataset.touched=1" class="set-input" min="0" max="3650" step="1" placeholder="30"> </div> <div class="set-row" data-search="config backup now restore list"> <div class="set-row-text"> diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index 03d3e239..99662c23 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -2887,8 +2887,14 @@ Object.assign(CodemanApp.prototype, { mcpSyncEnabled: document.getElementById('appSettingsMcpSync').checked, configBackupEnabled: document.getElementById('appSettingsConfigBackup').checked, configBackupDir: document.getElementById('appSettingsConfigBackupDir').value.trim(), - configBackupKeepCount: Math.min(500, Math.max(2, parseInt(document.getElementById('appSettingsConfigBackupKeepCount').value, 10) || 20)), - configBackupKeepDays: Math.min(3650, Math.max(0, Number.isNaN(parseInt(document.getElementById('appSettingsConfigBackupKeepDays').value, 10)) ? 30 : parseInt(document.getElementById('appSettingsConfigBackupKeepDays').value, 10))), + // Sent only once saved before or edited here: a value merely displayed from the server (after a reset) + // would otherwise be written back and replace the retention the backups recorded. + ...(_prev.configBackupKeepCount !== undefined || document.getElementById('appSettingsConfigBackupKeepCount').dataset.touched + ? { configBackupKeepCount: Math.min(500, Math.max(2, parseInt(document.getElementById('appSettingsConfigBackupKeepCount').value, 10) || 20)) } + : {}), + ...(_prev.configBackupKeepDays !== undefined || document.getElementById('appSettingsConfigBackupKeepDays').dataset.touched + ? { configBackupKeepDays: Math.min(3650, Math.max(0, Number.isNaN(parseInt(document.getElementById('appSettingsConfigBackupKeepDays').value, 10)) ? 30 : parseInt(document.getElementById('appSettingsConfigBackupKeepDays').value, 10))) } + : {}), readMyMindEnabled: document.getElementById('appSettingsReadMyMind').checked, ultracodeFloatingWindows: document.getElementById('appSettingsUltracodeFloatingWindows').checked, showMultiMonitorButton: document.getElementById('appSettingsShowMultiMonitorButton').checked, diff --git a/test/config-backup.test.ts b/test/config-backup.test.ts index a045939e..2cbaa58e 100644 --- a/test/config-backup.test.ts +++ b/test/config-backup.test.ts @@ -465,6 +465,32 @@ describe('diffConfigBackups', () => { }); }); +describe('createConfigBackup: a snapshot dated ahead of the clock', () => { + it('is not written again on every pass once the clock is corrected', () => { + put('settings.json', '{"v":0}'); + createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 }); + put('settings.json', '{"v":1}'); + const ahead = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + 2 * 3_600_000 }); + expect(ahead.status).toBe('created'); + put('settings.json', '{"v":2}'); // the clock is back at T0 + 1 minute + const corrected = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + 60_000 }); + expect(corrected.status).toBe('created'); + const [newest, second] = listConfigBackups(backups); + expect(newest.id).toBe((corrected as { id: string }).id); // dated no earlier than the one before it + expect(newest.createdAt).toBeGreaterThan(second.createdAt); + for (let i = 0; i < 24; i++) { + const r = createConfigBackup({ + dataDir: data, + backupDir: backups, + reason: 'auto', + now: T0 + 120_000 + i * 300_000, + }); + expect(r.status, `pass ${i}`).toBe('unchanged'); + } + expect(listConfigBackups(backups)).toHaveLength(3); + }); +}); + describe('instances sharing one backup folder', () => { it('keeps to its own snapshots: no churn, no prune, no restore of the other', () => { const other = join(root, 'other-data'); @@ -608,6 +634,12 @@ describe('restore trusts only what this module wrote', () => { put('settings.json', JSON.stringify({ displayName: 'new', configBackupDir: '/new', configBackupKeepDays: 7 })); restoreConfigBackup({ dataDir: data, backupDir: backups, id }); const after = JSON.parse(readFileSync(join(data, 'settings.json'), 'utf8')); - expect(after).toEqual({ displayName: 'old', configBackupDir: '/new', configBackupKeepDays: 7 }); + // the folder and days follow the live file; the live file has no keep count (reset), so the backup's 5 stays + expect(after).toEqual({ + displayName: 'old', + configBackupDir: '/new', + configBackupKeepCount: 5, + configBackupKeepDays: 7, + }); }); }); diff --git a/test/routes/config-backup-routes.test.ts b/test/routes/config-backup-routes.test.ts index a87d71be..40dce415 100644 --- a/test/routes/config-backup-routes.test.ts +++ b/test/routes/config-backup-routes.test.ts @@ -250,6 +250,39 @@ describe('retention survives an unreadable or reset settings.json', () => { } ); + it("re-entering a custom folder after a reset finds that folder's recorded retention", async () => { + const folder = join(custom, 'kept'); + settings({ configBackupDir: folder, configBackupKeepCount: 100, configBackupKeepDays: 0 }); + for (let i = 0; i < 30; i++) { + createConfigBackup({ + dataDir: getDataDir(), + backupDir: folder, + reason: 'auto', + force: true, + now: BASE + i * DAY, + keepCount: 100, + keepDays: 0, + }); + } + writeFileSync(SETTINGS_PATH, '{}'); // the reset drops the folder too + await runConfigBackup('auto'); // goes to the default folder, which holds none of those manifests + // the user enters the folder again and saves; the form no longer sends retention it only displayed + writeFileSync(SETTINGS_PATH, JSON.stringify({ configBackupDir: folder, configBackupEnabled: true })); + const { app } = await createRouteTestHarness(registerConfigBackupRoutes); + const reported = (await app.inject({ method: 'GET', url: '/api/config-backups' })).json().data.settings; + expect(reported).toMatchObject({ keepCount: 100, keepDays: 0 }); + const out = await runConfigBackup('auto'); + expect(out?.removed).toEqual([]); + expect(listConfigBackups(folder).length).toBeGreaterThanOrEqual(30); + }); + + it('only the retention key the file lacks falls back to the recorded one', async () => { + seed(); + writeFileSync(SETTINGS_PATH, JSON.stringify({ configBackupKeepDays: 3650 })); + await runConfigBackup('auto'); + expect(listConfigBackups(dir())).toHaveLength(31); // keepCount 100 from the manifests, not the default 20 + }); + it('a settings.json that sets the retention still wins', async () => { seed(); writeFileSync(SETTINGS_PATH, JSON.stringify({ configBackupKeepCount: 5, configBackupKeepDays: 0 })); From 8a4ff775fbb9f4fc56cbda4b477616a851d5a10c Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Tue, 20 Oct 2026 21:47:20 +0800 Subject: [PATCH 29/96] fix(config-backup): probe a custom folder before the retention fallback reads it, test that Save omits a displayed-only retention --- CLAUDE.md | 2 +- docs/api-reference.md | 2 +- docs/wiki/Config-Backups.md | 6 +- docs/wiki/Settings-Reference.md | 3 +- docs/wiki/Troubleshooting.md | 4 + src/web/public/settings-ui.js | 28 +++++-- src/web/routes/config-backup-routes.ts | 31 +++++--- test/config-backup-restore-ui.test.ts | 46 +++++++++++ test/routes/config-backup-unreachable.test.ts | 76 +++++++++++++++++++ 9 files changed, 173 insertions(+), 25 deletions(-) create mode 100644 test/routes/config-backup-unreachable.test.ts diff --git a/CLAUDE.md b/CLAUDE.md index bcdefb3d..357c1db4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -156,7 +156,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Auto-resume on usage limit** (opt-in per session, top of the Respawn tab): when Claude halts on a subscription limit, `usage-limit-patterns.ts` (pure, unit-tested) parses the reset time and `SessionAutoOps` arms a timer for reset+2min, then sends Esc + `continue`. ⚠️ Respawn cycles are blocked while paused (`isLimitPaused` guard in `onIdleDetected`), which is what prevents `/clear` from wiping the paused conversation. Claude-mode only. → [architecture-invariants#auto-resume-on-usage-limit](docs/architecture-invariants.md#auto-resume-on-usage-limit) -**Config backups** (`src/config-backup.ts`, `routes/config-backup-routes.ts`; `configBackupEnabled` absent = ON, `configBackupDir`, `configBackupKeepCount` 2-500 default 20, `configBackupKeepDays` 0-3650 default 30; `docs/wiki/Config-Backups.md`): timestamped snapshots `cfg-YYYYMMDD-HHMMSS/` (+ `manifest.json` with SHA-256s, `reason`, `appVersion`) of a FIXED allowlist (`CONFIG_BACKUP_FILES`: settings, clis, custom-model-hosts, webhook, intents, linked-cases, remote/docker hosts+cases (incl. `remote-cases.json`), webviews, push-keys, users, `.env`) into `<data dir>/backups/config` or a custom absolute folder. ⚠️ `state.json` is deliberately NOT tracked (rewritten per session event; it also holds cron jobs and respawn presets). One snapshot per real content change (digest of the file set vs the latest manifest), at startup + a 5-minute timer, skipped in test mode. ⚠️ Retention (`pruneConfigBackups`) only deletes folders matching the name pattern AND holding a parseable manifest, and never the newest; a custom folder equal to the data dir falls back to the default. ⚠️ Restore verifies every file's SHA-256 BEFORE writing, takes a `pre-restore` snapshot, writes atomically at 0600 and only allowlisted names; the id is validated against the name pattern (no traversal). Snapshots are 0700/0600 because `.env`/`users.json`/`custom-model-hosts.json` carry credentials; all routes are admin-only in multi-user mode. ⚠️ A custom folder is probed with `probePathKind(dir, { pastCap: true })` before every pass and in each route (#516: it may be a network mount; `unknown` skips the pass / answers 503 `OPERATION_FAILED`), and a missing custom folder is only created when its parent is present. ⚠️ Each manifest records an `origin`, a random id kept in `<data dir>/config-backup-id` (NOT the hostname: a Docker container gets a new one per recreate, which orphaned every snapshot); change check, prune, list, restore and diff are scoped to it so instances sharing one folder do not snapshot or prune each other. Restoring `intents.json` calls `intentStore.reload()`; `settings.json` applies its watcher toggles after a restart; Settings re-reads `/api/settings` into the form after a restore of it, since Save would otherwise PUT the old values back. ⚠️ Restoring `clis.json` writes it directly (the one writer besides `mutateRegistryFile()`) and then calls `reloadCliRegistry()`. → [docs/wiki/Config-Backups.md](docs/wiki/Config-Backups.md) +**Config backups** (`src/config-backup.ts`, `routes/config-backup-routes.ts`; `configBackupEnabled` absent = ON, `configBackupDir`, `configBackupKeepCount` 2-500 default 20, `configBackupKeepDays` 0-3650 default 30 (an ABSENT key means what the newest backup in the folder recorded, `withRecordedRetention`); `docs/wiki/Config-Backups.md`): timestamped snapshots `cfg-YYYYMMDD-HHMMSS/` (+ `manifest.json` with SHA-256s, `reason`, `appVersion`) of a FIXED allowlist (`CONFIG_BACKUP_FILES`: settings, clis, custom-model-hosts, webhook, intents, linked-cases, remote/docker hosts+cases (incl. `remote-cases.json`), webviews, push-keys, users, `.env`) into `<data dir>/backups/config` or a custom absolute folder. ⚠️ `state.json` is deliberately NOT tracked (rewritten per session event; it also holds cron jobs and respawn presets). One snapshot per real content change (digest of the file set vs the latest manifest), at startup + a 5-minute timer, skipped in test mode. ⚠️ Retention (`pruneConfigBackups`) only deletes folders matching the name pattern AND holding a parseable manifest, and never the newest; a custom folder equal to the data dir falls back to the default. ⚠️ Restore verifies every file's SHA-256 BEFORE writing, takes a `pre-restore` snapshot, writes atomically at 0600 and only allowlisted names; the id is validated against the name pattern (no traversal). Snapshots are 0700/0600 because `.env`/`users.json`/`custom-model-hosts.json` carry credentials; all routes are admin-only in multi-user mode. ⚠️ A custom folder is probed with `probePathKind(dir, { pastCap: true })` before every pass and in each route (#516: it may be a network mount; `unknown` skips the pass / answers 503 `OPERATION_FAILED`), and a missing custom folder is only created when its parent is present. ⚠️ Each manifest records an `origin`, a random id kept in `<data dir>/config-backup-id` (NOT the hostname: a Docker container gets a new one per recreate, which orphaned every snapshot); change check, prune, list, restore and diff are scoped to it so instances sharing one folder do not snapshot or prune each other. Restoring `intents.json` calls `intentStore.reload()`; `settings.json` applies its watcher toggles after a restart; Settings re-reads `/api/settings` into the form after a restore of it, since Save would otherwise PUT the old values back. ⚠️ Restoring `clis.json` writes it directly (the one writer besides `mutateRegistryFile()`) and then calls `reloadCliRegistry()`. → [docs/wiki/Config-Backups.md](docs/wiki/Config-Backups.md) **Plan-usage chip** (`showPlanUsageLimits`, per-device: desktop default **ON**, handhelds OFF): resolve DISPLAY only through `planUsageChipEnabled()` (settings-ui.js). The same setting is the server-side COLLECTION switch, read fresh by `readPlanUsageTelemetryEnabled()` (hooks-config.ts) at every claude create/respawn. ⚠️ An ABSENT key reads as ON in the reader; `GET /api/settings` must never write. ⚠️ A save sends `showPlanUsageLimits` ONLY when it flips the chip on that device (`planUsageCollectionFlip()`), or a phone switches collection off for every desktop. Claude data comes from the statusLine exporter, injected as an EPHEMERAL `claude --settings` flag (`resolveStatusLineCliCommand`), never written to disk, WRAPPING a user's own statusLine, posting to `POST /api/status-telemetry`. Codex comes from a read-only `account/rateLimits/read` poll (main bucket only). The statusline's `prompt_cache` object (Claude Code v2.1.251+) is kept by `StatusTelemetrySchema` (every documented scalar field, null-tolerant; `parsePromptCache()` in usage-telemetry.ts normalizes only the stored subset) and shown in the footer as `cache:until HH:MM` / `cache:cold` (only when the user has no statusline of their own) and in Session Options → Respawn (`#promptCacheStatus`, fed by `Session.promptCache` in `toState()` plus the change-only `session:promptCache` SSE event, worded by the pure `CodemanPromptCache.format`): warm-until/cold, the re-cache stake, the hit rate, the last miss's causes, and a `Context compacted at HH:MM` note after a compaction or a tool-result clearing (stamped on the first warm report whose `recache_tokens_if_cold` is null after one that carried it, never from `expected_rebuilds`, which Claude Code bumps only at the next request; NEVER an alert). ⚠️ `Session.setPromptCache()` stamps `compactedAt` onto a NEW object (never mutating the parsed report) and the route broadcasts the STORED object; the last report is restored on restart (constructor `promptCache`, like `displayModel`) and the readout is time-aware (a passed `expiresAt` reads cold, not stale-warm). It is the source of truth for the cache TTL, never the plan tier. → [architecture-invariants#plan-usage-chip-statusline-telemetry](docs/architecture-invariants.md#plan-usage-chip-statusline-telemetry), `docs/usage-limits-display-plan.md` diff --git a/docs/api-reference.md b/docs/api-reference.md index e5e27151..ae5e881c 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1158,7 +1158,7 @@ Timestamped snapshots of the files that hold what a user has customized (`src/co Tracked files (a fixed list, relative to the data dir): `settings.json`, `clis.json`, `custom-model-hosts.json`, `webhook.json`, `intents.json`, `linked-cases.json`, `remote-hosts.json`, `remote-cases.json`, `docker-hosts.json`, `docker-cases.json`, `webviews.json`, `push-keys.json`, `users.json`, `.env`. Session state (`state.json`, which also holds cron jobs and respawn presets), logs, the hook secret and caches are not. -Settings (synced, in `settings.json`): `configBackupEnabled` (absent = on), `configBackupDir` (absolute path, `~` expanded; empty = `<data dir>/backups/config`), `configBackupKeepCount` (2–500, default 20), `configBackupKeepDays` (0–3650, default 30; `0` = no age limit). Retention only ever deletes snapshot folders Codeman made there, and never the newest one. Each snapshot is a `0700` folder named `cfg-YYYYMMDD-HHMMSS` (server local time; `-2`, `-3`… on a same-second collision) holding the tracked files under their own names plus a `manifest.json` (`id`, `createdAt`, `reason` of `startup`/`auto`/`manual`/`pre-restore`, `appVersion`, the change-check `hash`, an `origin` id, the `keepCount` and `keepDays` in force, and a SHA-256 and size per file), all `0600`. A snapshot is never dated earlier than the instance's newest one, so a clock that read ahead once does not make every later pass write a copy. All four routes answer `503 OPERATION_FAILED` when a custom folder does not respond, and diff and restore answer `409` for a symlinked or group/world-accessible backup as well as a checksum mismatch. Only folders with that name and a valid manifest are listed, restored or pruned. Full guide: `docs/wiki/Config-Backups.md`. +Settings (synced, in `settings.json`): `configBackupEnabled` (absent = on), `configBackupDir` (absolute path, `~` expanded; empty = `<data dir>/backups/config`), `configBackupKeepCount` (2–500, default 20), `configBackupKeepDays` (0–3650, default 30; `0` = no age limit). A key absent from `settings.json` means the retention the newest backup in the folder recorded, and the default only when none did. Retention only ever deletes snapshot folders Codeman made there, and never the newest one. Each snapshot is a `0700` folder named `cfg-YYYYMMDD-HHMMSS` (server local time; `-2`, `-3`… on a same-second collision) holding the tracked files under their own names plus a `manifest.json` (`id`, `createdAt`, `reason` of `startup`/`auto`/`manual`/`pre-restore`, `appVersion`, the change-check `hash`, an `origin` id, the `keepCount` and `keepDays` in force, and a SHA-256 and size per file), all `0600`. A snapshot is never dated earlier than the instance's newest one, so a clock that read ahead once does not make every later pass write a copy. All four routes answer `503 OPERATION_FAILED` when a custom folder does not respond, and diff and restore answer `409` for a symlinked or group/world-accessible backup as well as a checksum mismatch. Only folders with that name and a valid manifest are listed, restored or pruned. Full guide: `docs/wiki/Config-Backups.md`. | Method | Path | Body | Notes | | ------ | ------------------------------------ | ---- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | diff --git a/docs/wiki/Config-Backups.md b/docs/wiki/Config-Backups.md index 62736e73..5dabfd24 100644 --- a/docs/wiki/Config-Backups.md +++ b/docs/wiki/Config-Backups.md @@ -139,9 +139,9 @@ Only the tracked names are ever written, whatever a manifest lists. Restoring `settings.json` keeps the four backup settings (on or off, folder, how many, how many days) at their current values, so a restore from before a folder change does not point the list -at the old folder and hide the `pre-restore` copy it just made. The exception is a live -`settings.json` that has none of the four (a reset file): the restore then keeps the backup's own -how-many and how-many-days, so the next pass does not fall back to 20 copies and 30 days. The +at the old folder and hide the `pre-restore` copy it just made. The exception is the two +retention values: where the live `settings.json` lacks one (a reset file), the restore keeps the +backup's own, key by key, so the next pass does not fall back to 20 copies and 30 days. The `pre-restore` copy records the retention in force too. Settings are re-read on the next request, so reload the page. `.env` and `push-keys.json` are diff --git a/docs/wiki/Settings-Reference.md b/docs/wiki/Settings-Reference.md index b61ab68e..a5287f9b 100644 --- a/docs/wiki/Settings-Reference.md +++ b/docs/wiki/Settings-Reference.md @@ -193,7 +193,8 @@ mode, the **Users** administration entry is injected here. The **Config backups** group controls timestamped copies of your configuration files: an on/off switch (on by default), the backup folder (default `~/.codeman/backups/config`), how many backups to keep (2 to 500, default 20) and how many days to keep them (0 to 3650, default -30; 0 means no age limit), plus **Back up now** and a list with **Restore**. These are +30; 0 means no age limit). A value that is not saved in `settings.json` (a reset file) is whatever +the newest backup in that folder recorded, and the default only when none did, plus **Back up now** and a list with **Restore**. These are server-side settings, read fresh. Details, naming and restore behaviour are on [Config Backups](Config-Backups). diff --git a/docs/wiki/Troubleshooting.md b/docs/wiki/Troubleshooting.md index 5c34528b..c6332686 100644 --- a/docs/wiki/Troubleshooting.md +++ b/docs/wiki/Troubleshooting.md @@ -147,6 +147,10 @@ it again and save before looking for the list; until then the list shows the def After a reinstall of the data directory the old backups are unlisted because the instance has a new id; see [Config Backups](Config-Backups) for how to make them visible again. +A backup is never dated earlier than the newest one, so if the clock was once set to a far +future date, every later backup keeps that date. Delete the future-dated backup folder to +return to normal dating. + Without the UI: the backups are plain folders in `~/.codeman/backups/config/` (`cfg-YYYYMMDD-HHMMSS`); copy the file you need back over the one in `~/.codeman/`. diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index 99662c23..c40dcd75 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -1374,6 +1374,25 @@ Object.assign(CodemanApp.prototype, { // ── Config backups ────────────────────────────────────────────────────────── // Server-side files, so these read the SAVED settings: a folder or retention edit applies after Save. + /** + * The retention keys a Save sends: a value only once it was saved before or edited here. After a reset + * the form merely DISPLAYS what the passes use (the retention the backups recorded), and writing that back + * would replace it with whatever a different folder or an older default says. + */ + _configBackupRetentionPayload(prev) { + const count = document.getElementById('appSettingsConfigBackupKeepCount'); + const days = document.getElementById('appSettingsConfigBackupKeepDays'); + const out = {}; + if (prev.configBackupKeepCount !== undefined || count.dataset.touched) { + out.configBackupKeepCount = Math.min(500, Math.max(2, parseInt(count.value, 10) || 20)); + } + if (prev.configBackupKeepDays !== undefined || days.dataset.touched) { + const n = parseInt(days.value, 10); + out.configBackupKeepDays = Math.min(3650, Math.max(0, Number.isNaN(n) ? 30 : n)); + } + return out; + }, + /** Fill the backup list, and show the default folder as the input's placeholder. */ async loadConfigBackups() { const out = this.$('configBackupList'); @@ -2887,14 +2906,7 @@ Object.assign(CodemanApp.prototype, { mcpSyncEnabled: document.getElementById('appSettingsMcpSync').checked, configBackupEnabled: document.getElementById('appSettingsConfigBackup').checked, configBackupDir: document.getElementById('appSettingsConfigBackupDir').value.trim(), - // Sent only once saved before or edited here: a value merely displayed from the server (after a reset) - // would otherwise be written back and replace the retention the backups recorded. - ...(_prev.configBackupKeepCount !== undefined || document.getElementById('appSettingsConfigBackupKeepCount').dataset.touched - ? { configBackupKeepCount: Math.min(500, Math.max(2, parseInt(document.getElementById('appSettingsConfigBackupKeepCount').value, 10) || 20)) } - : {}), - ...(_prev.configBackupKeepDays !== undefined || document.getElementById('appSettingsConfigBackupKeepDays').dataset.touched - ? { configBackupKeepDays: Math.min(3650, Math.max(0, Number.isNaN(parseInt(document.getElementById('appSettingsConfigBackupKeepDays').value, 10)) ? 30 : parseInt(document.getElementById('appSettingsConfigBackupKeepDays').value, 10))) } - : {}), + ...this._configBackupRetentionPayload(_prev), readMyMindEnabled: document.getElementById('appSettingsReadMyMind').checked, ultracodeFloatingWindows: document.getElementById('appSettingsUltracodeFloatingWindows').checked, showMultiMonitorButton: document.getElementById('appSettingsShowMultiMonitorButton').checked, diff --git a/src/web/routes/config-backup-routes.ts b/src/web/routes/config-backup-routes.ts index 602e0673..e4ecea1f 100644 --- a/src/web/routes/config-backup-routes.ts +++ b/src/web/routes/config-backup-routes.ts @@ -70,12 +70,23 @@ export interface ConfigBackupStatus { backups: ConfigBackupSummary[]; } -export async function readEffectiveBackupSettings(): Promise<{ settings: ConfigBackupSettings; dir: string }> { +/** + * The settings in effect and the folder they name. A custom folder is probed (bounded, #516) BEFORE + * the recorded-retention fallback reads it, so this throws `BackupDirUnavailableError` for a folder + * that does not answer and never lists it; `present` is false for a custom folder that does not exist yet. + */ +export async function readEffectiveBackupSettings(): Promise<{ + settings: ConfigBackupSettings; + dir: string; + present: boolean; +}> { const raw = await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'settings.json', {}); const base = readConfigBackupSettings(raw); const dir = resolveConfigBackupDir(base, getDataDir()); + const present = await probeBackupDir(base, dir); // The same fallback the timer pass uses, so the form never shows 20 / 30 while a pass keeps 100 / 0. - return { settings: withRecordedRetention(base, raw, dir, readConfigBackupOrigin(getDataDir())), dir }; + const settings = present ? withRecordedRetention(base, raw, dir, readConfigBackupOrigin(getDataDir())) : base; + return { settings, dir, present }; } /** A user-chosen backup folder that is not safe to touch (bounded probe, as for case paths, #516). */ @@ -158,16 +169,16 @@ export function registerConfigBackupRoutes(app: FastifyInstance): void { app.get('/api/config-backups', async (req, reply): Promise<ApiResponse<ConfigBackupStatus>> => { const denied = deny(req, reply); if (denied) return denied; - const { settings, dir } = await readEffectiveBackupSettings(); - // Read-only: a listing must not create config-backup-id, and without one nothing can be ours. - const origin = readConfigBackupOrigin(getDataDir()); - let present: boolean; + let eff: Awaited<ReturnType<typeof readEffectiveBackupSettings>>; try { - present = await probeBackupDir(settings, dir); + eff = await readEffectiveBackupSettings(); } catch (err) { reply.code(503); return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); } + const { settings, dir, present } = eff; + // Read-only: a listing must not create config-backup-id, and without one nothing can be ours. + const origin = readConfigBackupOrigin(getDataDir()); return { success: true, data: { @@ -201,8 +212,7 @@ export function registerConfigBackupRoutes(app: FastifyInstance): void { return createErrorResponse(ApiErrorCode.INVALID_INPUT, '`from` is required (a backup id or "current")'); } try { - const { settings, dir } = await readEffectiveBackupSettings(); - await probeBackupDir(settings, dir); + const { dir } = await readEffectiveBackupSettings(); const data = diffConfigBackups({ dataDir: getDataDir(), backupDir: dir, @@ -229,8 +239,7 @@ export function registerConfigBackupRoutes(app: FastifyInstance): void { if (denied) return denied; const { id } = req.params as { id: string }; try { - const { settings, dir } = await readEffectiveBackupSettings(); - await probeBackupDir(settings, dir); + const { dir } = await readEffectiveBackupSettings(); const out = restoreConfigBackup({ dataDir: getDataDir(), backupDir: dir, id, appVersion: APP_VERSION }); // IntentStore caches the file and rewrites it whole on the next prompt, which would undo the restore. if (out.restored.includes('intents.json')) intentStore.reload(); diff --git a/test/config-backup-restore-ui.test.ts b/test/config-backup-restore-ui.test.ts index 256e4479..840ee567 100644 --- a/test/config-backup-restore-ui.test.ts +++ b/test/config-backup-restore-ui.test.ts @@ -106,3 +106,49 @@ describe('loadConfigBackups (retention fields)', () => { expect(inputs.appSettingsConfigBackupKeepCount.value).toBe('20'); // the open-settings code owns it }); }); + +describe('Save and the displayed retention', () => { + function payload( + prev: Record<string, unknown>, + touched: { count?: boolean; days?: boolean }, + count = '100', + days = '0' + ) { + const inputs: Record<string, { value: string; dataset: Record<string, string> }> = { + appSettingsConfigBackupKeepCount: { value: count, dataset: touched.count ? { touched: '1' } : {} }, + appSettingsConfigBackupKeepDays: { value: days, dataset: touched.days ? { touched: '1' } : {} }, + }; + function CodemanApp(this: unknown) {} + const ctx = vm.createContext({ + CodemanApp, + window: {}, + document: { getElementById: (id: string) => inputs[id] }, + }); + vm.runInContext(SRC, ctx); + const app = Object.create((CodemanApp as unknown as { prototype: object }).prototype) as Record<string, unknown>; + return (app._configBackupRetentionPayload as (p: Record<string, unknown>) => Record<string, unknown>).call( + app, + prev + ); + } + + it('leaves out a retention value that was only displayed (a reset settings.json)', () => { + expect(payload({}, {})).toEqual({}); + }); + + it('sends a value that was saved before, or edited here, and only that one', () => { + expect(payload({ configBackupKeepCount: 100 }, {})).toEqual({ configBackupKeepCount: 100 }); + expect(payload({}, { days: true })).toEqual({ configBackupKeepDays: 0 }); + expect(payload({}, { count: true, days: true }, '5', '7')).toEqual({ + configBackupKeepCount: 5, + configBackupKeepDays: 7, + }); + }); + + it('clamps like the form always did', () => { + expect(payload({}, { count: true, days: true }, '1', '99999')).toEqual({ + configBackupKeepCount: 2, + configBackupKeepDays: 3650, + }); + }); +}); diff --git a/test/routes/config-backup-unreachable.test.ts b/test/routes/config-backup-unreachable.test.ts new file mode 100644 index 00000000..d1d99700 --- /dev/null +++ b/test/routes/config-backup-unreachable.test.ts @@ -0,0 +1,76 @@ +/** + * @fileoverview A custom backup folder that stops answering (a network mount) must never be listed or + * read before the bounded probe says it is there: GET, diff and restore answer 503 without touching it. + * With only `configBackupDir` in settings.json the recorded-retention fallback would otherwise + * `readdirSync` the folder first (found in review of #661). + * + * ⚠️ test/setup.ts gives the whole FILE one temp HOME. Port: N/A (inject()). + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; + +const dead = vi.hoisted(() => ({ dir: '', touched: [] as string[] })); + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal<typeof import('node:fs')>(); + const record = <F extends (...a: never[]) => unknown>(name: string, real: F): F => + ((path: unknown, ...rest: unknown[]) => { + if (dead.dir && String(path).startsWith(dead.dir)) dead.touched.push(`${name} ${String(path)}`); + return (real as unknown as (...a: unknown[]) => unknown)(path, ...rest); + }) as unknown as F; + const wrapped = { + ...actual, + readdirSync: record('readdirSync', actual.readdirSync), + readFileSync: record('readFileSync', actual.readFileSync), + statSync: record('statSync', actual.statSync), + lstatSync: record('lstatSync', actual.lstatSync), + existsSync: record('existsSync', actual.existsSync), + }; + return { ...wrapped, default: wrapped }; +}); + +vi.mock('../../src/utils/bounded-path-probe.js', async (importOriginal) => { + const actual = await importOriginal<typeof import('../../src/utils/bounded-path-probe.js')>(); + return { + ...actual, + probePathKind: async (path: string, options?: { pastCap?: boolean }) => + dead.dir && path.startsWith(dead.dir) ? 'unknown' : actual.probePathKind(path, options), + }; +}); + +import { createRouteTestHarness } from './_route-test-utils.js'; +import { registerConfigBackupRoutes } from '../../src/web/routes/config-backup-routes.js'; +import { SETTINGS_PATH } from '../../src/web/route-helpers.js'; +import { getDataDir } from '../../src/config/instance.js'; +import { configBackupOrigin } from '../../src/config-backup.js'; + +let root: string; +beforeEach(() => { + root = mkdtempSync(join(tmpdir(), 'cfg-backup-dead-')); + dead.dir = join(root, 'mount'); + dead.touched.length = 0; + configBackupOrigin(getDataDir()); // an instance that already has an id: the fallback has something to look for + mkdirSync(dirname(SETTINGS_PATH), { recursive: true }); + // Only the folder: no retention keys, the shape that makes the recorded-retention fallback want to read it. + writeFileSync(SETTINGS_PATH, JSON.stringify({ configBackupDir: dead.dir })); +}); +afterEach(() => { + dead.dir = ''; + rmSync(root, { recursive: true, force: true }); + rmSync(SETTINGS_PATH, { force: true }); +}); + +describe('a custom backup folder that does not answer', () => { + it.each([ + ['GET', '/api/config-backups'], + ['GET', '/api/config-backups/diff?from=cfg-20261001-120000'], + ['POST', '/api/config-backups/cfg-20261001-120000/restore'], + ] as const)('%s %s answers 503 without reading the folder', async (method, url) => { + const { app } = await createRouteTestHarness(registerConfigBackupRoutes); + const res = await app.inject({ method, url }); + expect(res.statusCode).toBe(503); + expect(dead.touched).toEqual([]); + }); +}); From 04eb1ed1583e8245ca1797b1503556486dec933d Mon Sep 17 00:00:00 2001 From: "Claudia[bot]" <4437565+Claudia[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 03:46:18 +0200 Subject: [PATCH 30/96] feat(limits): make the global session cap configurable with CODEMAN_MAX_SESSIONS The cap of 50 concurrent sessions was a hard-coded constant, so a machine with the memory for more (or a small box that wants fewer) had to patch the source. CODEMAN_MAX_SESSIONS now sets it at startup. The default stays 50, and anything that is not a positive integer (empty, 0, negative, fractional, 1e3, out of safe-integer range) falls back to it, so a typo never locks session creation. Every site keeps reading MAX_CONCURRENT_SESSIONS, so the session routes, cron launches, the scheduled-run loop and the multi-user per-user cap (half the global cap unless CODEMAN_MAX_SESSIONS_PER_USER is set) all follow the variable. The test goes through sessionCapacityState and sessionCapacityMessage, the check every session-creating route uses. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- .changeset/max-sessions-env.md | 5 ++ docs/cron-guide.md | 4 +- .../skills/codeman/reference/endpoints.md | 4 +- skills/codeman/reference/endpoints.md | 4 +- src/config/map-limits.ts | 18 +++- src/web/schemas.ts | 2 +- test/config/max-sessions.test.ts | 83 +++++++++++++++++++ 7 files changed, 112 insertions(+), 8 deletions(-) create mode 100644 .changeset/max-sessions-env.md create mode 100644 test/config/max-sessions.test.ts diff --git a/.changeset/max-sessions-env.md b/.changeset/max-sessions-env.md new file mode 100644 index 00000000..ff2516d6 --- /dev/null +++ b/.changeset/max-sessions-env.md @@ -0,0 +1,5 @@ +--- +'aicodeman': minor +--- + +The cap of 50 concurrent sessions can now be set with `CODEMAN_MAX_SESSIONS` (a positive integer, read at startup), for a machine with the memory for more sessions or a small one that should run fewer. Without it nothing changes. A value that is not a positive integer falls back to 50, so a typo never blocks session creation. In multi-user mode the per-user cap still defaults to half the global cap, so it follows the new value unless `CODEMAN_MAX_SESSIONS_PER_USER` is set. diff --git a/docs/cron-guide.md b/docs/cron-guide.md index 0be6ec77..bc08c52d 100644 --- a/docs/cron-guide.md +++ b/docs/cron-guide.md @@ -261,8 +261,8 @@ Sequence in `CronService.launch()`: 4. **Auto-close previous session** (recurring schedules, unless `autoClosePreviousSession: false`): any still-open session created by this job's previous runs is closed via the normal session-cleanup path. -5. The global session cap is checked (`MAX_CONCURRENT_SESSIONS = 50`). At cap → - **`failed`**. +5. The global session cap is checked (`MAX_CONCURRENT_SESSIONS`, 50 unless + `CODEMAN_MAX_SESSIONS` sets it). At cap → **`failed`**. 6. A `Session` is created **with `useMux: true`** (so it runs inside tmux), registered, listeners attached, and started via `startInteractive()` (`startShell()` for `shell` mode). Model/claudeMode come from global config. diff --git a/plugins/codeman/skills/codeman/reference/endpoints.md b/plugins/codeman/skills/codeman/reference/endpoints.md index f2ef2fb2..2799f542 100644 --- a/plugins/codeman/skills/codeman/reference/endpoints.md +++ b/plugins/codeman/skills/codeman/reference/endpoints.md @@ -113,7 +113,7 @@ Every JSON response: `{"success":true,"data":…}` or | `UNAUTHORIZED` | 401 | auth required or failed (send `-u user:password`). ⚠️ The 401 body is plain text, NOT this envelope, see [Auth and credentials](#auth-and-credentials) | | `FORBIDDEN` | 403 | authenticated but not permitted: an admin-only route in multi-user mode, a `workingDir`/case path outside your own workspace, or a shell session without the can-bypass-permissions grant. ⚠️ **Not** what an ownership miss on a session returns: a session you do not own answers 404 `NOT_FOUND`, identically to one that does not exist (deliberate, it leaks no existence) | | `NOT_FOUND` | 404 | no such session, or one this caller does not own. Also quick-start's answer for an unknown remote or docker host | -| `SESSION_BUSY` | 409 | on a **wait**: this session's waiter cap (16, combined signal+output) is full. On **quick-start**: a session cap is full, so clean up before starting more. Two different caps can raise it: the global 50 (`MAX_CONCURRENT_SESSIONS`), and in multi-user mode the per-user cap, which defaults to half of that, **25** (`maxSessionsPerUser()`, `config/multiuser.ts:59-63`). The message tells you which | +| `SESSION_BUSY` | 409 | on a **wait**: this session's waiter cap (16, combined signal+output) is full. On **quick-start**: a session cap is full, so clean up before starting more. Two different caps can raise it: the global 50 (`MAX_CONCURRENT_SESSIONS`, env `CODEMAN_MAX_SESSIONS`), and in multi-user mode the per-user cap, which defaults to half of that, **25** (`maxSessionsPerUser()`, `config/multiuser.ts:59-63`). The message tells you which | | `CONFLICT` / `ALREADY_EXISTS` | 409 | conflicts with current state | | `OPERATION_FAILED` | 422 | well-formed but could not be completed | | `RATE_LIMITED` | 429 | per-owner or process-wide waiter pool is full; back off, switching sessions will not help | @@ -796,7 +796,7 @@ response echoes. | `match` string | 1–200 characters, literal only | 400; `regex=` is rejected outright | | `from=buffer` scan window | 256 KB tail of the terminal buffer | a marker older than that tail is invisible even with `from=buffer` | | wait-output snippet context | 80 characters either side | `wait.snippet` is bounded, not the whole line | -| sessions, process-wide | 50 (`MAX_CONCURRENT_SESSIONS`) | 409 `SESSION_BUSY` on quick-start | +| sessions, process-wide | 50 (`MAX_CONCURRENT_SESSIONS`, env `CODEMAN_MAX_SESSIONS`) | 409 `SESSION_BUSY` on quick-start | | sessions, per user | 25 in multi-user mode (half the global cap) | the same 409, with a different message | | SSE clients, process-wide | 100 (`MAX_SSE_CLIENTS`) | plain-text `503 Too many SSE connections`; shared with every browser tab | | active bash tools tracked | 20 per session | oldest entries drop off `active-tools` | diff --git a/skills/codeman/reference/endpoints.md b/skills/codeman/reference/endpoints.md index f2ef2fb2..2799f542 100644 --- a/skills/codeman/reference/endpoints.md +++ b/skills/codeman/reference/endpoints.md @@ -113,7 +113,7 @@ Every JSON response: `{"success":true,"data":…}` or | `UNAUTHORIZED` | 401 | auth required or failed (send `-u user:password`). ⚠️ The 401 body is plain text, NOT this envelope, see [Auth and credentials](#auth-and-credentials) | | `FORBIDDEN` | 403 | authenticated but not permitted: an admin-only route in multi-user mode, a `workingDir`/case path outside your own workspace, or a shell session without the can-bypass-permissions grant. ⚠️ **Not** what an ownership miss on a session returns: a session you do not own answers 404 `NOT_FOUND`, identically to one that does not exist (deliberate, it leaks no existence) | | `NOT_FOUND` | 404 | no such session, or one this caller does not own. Also quick-start's answer for an unknown remote or docker host | -| `SESSION_BUSY` | 409 | on a **wait**: this session's waiter cap (16, combined signal+output) is full. On **quick-start**: a session cap is full, so clean up before starting more. Two different caps can raise it: the global 50 (`MAX_CONCURRENT_SESSIONS`), and in multi-user mode the per-user cap, which defaults to half of that, **25** (`maxSessionsPerUser()`, `config/multiuser.ts:59-63`). The message tells you which | +| `SESSION_BUSY` | 409 | on a **wait**: this session's waiter cap (16, combined signal+output) is full. On **quick-start**: a session cap is full, so clean up before starting more. Two different caps can raise it: the global 50 (`MAX_CONCURRENT_SESSIONS`, env `CODEMAN_MAX_SESSIONS`), and in multi-user mode the per-user cap, which defaults to half of that, **25** (`maxSessionsPerUser()`, `config/multiuser.ts:59-63`). The message tells you which | | `CONFLICT` / `ALREADY_EXISTS` | 409 | conflicts with current state | | `OPERATION_FAILED` | 422 | well-formed but could not be completed | | `RATE_LIMITED` | 429 | per-owner or process-wide waiter pool is full; back off, switching sessions will not help | @@ -796,7 +796,7 @@ response echoes. | `match` string | 1–200 characters, literal only | 400; `regex=` is rejected outright | | `from=buffer` scan window | 256 KB tail of the terminal buffer | a marker older than that tail is invisible even with `from=buffer` | | wait-output snippet context | 80 characters either side | `wait.snippet` is bounded, not the whole line | -| sessions, process-wide | 50 (`MAX_CONCURRENT_SESSIONS`) | 409 `SESSION_BUSY` on quick-start | +| sessions, process-wide | 50 (`MAX_CONCURRENT_SESSIONS`, env `CODEMAN_MAX_SESSIONS`) | 409 `SESSION_BUSY` on quick-start | | sessions, per user | 25 in multi-user mode (half the global cap) | the same 409, with a different message | | SSE clients, process-wide | 100 (`MAX_SSE_CLIENTS`) | plain-text `503 Too many SSE connections`; shared with every browser tab | | active bash tools tracked | 20 per session | oldest entries drop off `active-tools` | diff --git a/src/config/map-limits.ts b/src/config/map-limits.ts index c3e246c2..0d27a2f6 100644 --- a/src/config/map-limits.ts +++ b/src/config/map-limits.ts @@ -18,11 +18,27 @@ // Session Tracking Limits // ============================================================================ +/** Default cap on concurrent sessions when `CODEMAN_MAX_SESSIONS` is not set. */ +const DEFAULT_MAX_CONCURRENT_SESSIONS = 50; + +/** + * Resolve the session cap from `CODEMAN_MAX_SESSIONS` (a positive integer). + * Anything else (unset, empty, 0, negative, fractional, not a number) falls back + * to {@link DEFAULT_MAX_CONCURRENT_SESSIONS}, so a typo never locks session creation. + */ +function resolveMaxConcurrentSessions(env: NodeJS.ProcessEnv = process.env): number { + const raw = env.CODEMAN_MAX_SESSIONS?.trim(); + if (!raw || !/^\d+$/.test(raw)) return DEFAULT_MAX_CONCURRENT_SESSIONS; + const n = Number(raw); + return Number.isSafeInteger(n) && n >= 1 ? n : DEFAULT_MAX_CONCURRENT_SESSIONS; +} + /** * Maximum concurrent sessions allowed. * Each session consumes significant resources (PTY, buffers, watchers). + * 50 unless `CODEMAN_MAX_SESSIONS` says otherwise; read once at startup. */ -export const MAX_CONCURRENT_SESSIONS = 50; +export const MAX_CONCURRENT_SESSIONS = resolveMaxConcurrentSessions(); // ============================================================================ // SSE Client Limits diff --git a/src/web/schemas.ts b/src/web/schemas.ts index ce577394..196e14c3 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -1981,7 +1981,7 @@ export const CaseOrderSchema = z.object({ /** PUT /api/session-order — global tab order (ordered sessionIds), COD-131 */ export const SessionOrderUpdateSchema = z.object({ // Bounded defensively: ids are uuid-ish (<=100 chars) and the client pushes only - // its open-tab order (max sessions is 50) — 500 leaves ample headroom while + // its open-tab order (max sessions is 50 by default) — 500 leaves ample headroom while // keeping a hostile/buggy client from persisting megabytes into state.json. order: z.array(z.string().max(100)).max(500), }); diff --git a/test/config/max-sessions.test.ts b/test/config/max-sessions.test.ts new file mode 100644 index 00000000..58f46e26 --- /dev/null +++ b/test/config/max-sessions.test.ts @@ -0,0 +1,83 @@ +/** + * @fileoverview The global session cap can be set with `CODEMAN_MAX_SESSIONS`. + * + * Tested through `sessionCapacityState` / `sessionCapacityMessage`, the check every + * session-creating route goes through, so a regression in the env parsing OR in + * the wiring from `map-limits` to the routes (and to the derived per-user cap in + * multi-user mode) fails here. + */ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +type Helpers = typeof import('../../src/web/route-helpers.js'); + +/** A sessions map of the given size, every session owned by `owner`. */ +function sessionsOf(n: number, owner?: string): ReadonlyMap<string, never> { + return new Map(Array.from({ length: n }, (_, i) => [`s${i}`, { id: `s${i}`, owner } as never])); +} + +/** Fresh module graph, so map-limits re-reads the environment at import. */ +async function loadWithEnv(env: Record<string, string | undefined>): Promise<Helpers> { + vi.resetModules(); + vi.stubEnv('CODEMAN_MAX_SESSIONS', undefined as unknown as string); + vi.stubEnv('CODEMAN_MAX_SESSIONS_PER_USER', undefined as unknown as string); + vi.stubEnv('CODEMAN_MULTIUSER', undefined as unknown as string); + for (const [key, value] of Object.entries(env)) vi.stubEnv(key, value as string); + return import('../../src/web/route-helpers.js'); +} + +describe('global session cap (CODEMAN_MAX_SESSIONS)', () => { + afterEach(() => { + vi.unstubAllEnvs(); + vi.resetModules(); + }); + + it('stays at 50 when the variable is not set', async () => { + const { sessionCapacityState, sessionCapacityMessage } = await loadWithEnv({}); + expect(sessionCapacityState(sessionsOf(49)).atGlobalCap).toBe(false); + expect(sessionCapacityState(sessionsOf(50)).atGlobalCap).toBe(true); + expect(sessionCapacityMessage(sessionsOf(50))).toContain('(50)'); + }); + + it('raises the cap', async () => { + const { sessionCapacityState, sessionCapacityMessage } = await loadWithEnv({ CODEMAN_MAX_SESSIONS: '120' }); + expect(sessionCapacityState(sessionsOf(50)).atGlobalCap).toBe(false); + expect(sessionCapacityState(sessionsOf(119)).atGlobalCap).toBe(false); + expect(sessionCapacityState(sessionsOf(120)).atGlobalCap).toBe(true); + expect(sessionCapacityMessage(sessionsOf(120))).toContain('(120)'); + }); + + it('lowers the cap, ignoring surrounding whitespace', async () => { + const { sessionCapacityState } = await loadWithEnv({ CODEMAN_MAX_SESSIONS: ' 3 ' }); + expect(sessionCapacityState(sessionsOf(2)).atGlobalCap).toBe(false); + expect(sessionCapacityState(sessionsOf(3)).atGlobalCap).toBe(true); + }); + + it.each(['', '0', '-5', '2.5', 'abc', '1e3', '99999999999999999999'])( + 'falls back to 50 for an unusable value %j', + async (value) => { + const { sessionCapacityState } = await loadWithEnv({ CODEMAN_MAX_SESSIONS: value }); + expect(sessionCapacityState(sessionsOf(49)).atGlobalCap).toBe(false); + expect(sessionCapacityState(sessionsOf(50)).atGlobalCap).toBe(true); + } + ); + + it('derives the multi-user per-user cap (half the global cap) from it', async () => { + const { sessionCapacityState, sessionCapacityMessage } = await loadWithEnv({ + CODEMAN_MAX_SESSIONS: '100', + CODEMAN_MULTIUSER: '1', + }); + expect(sessionCapacityState(sessionsOf(49, 'alice'), 'alice').atUserCap).toBe(false); + expect(sessionCapacityState(sessionsOf(50, 'alice'), 'alice')).toEqual({ atGlobalCap: false, atUserCap: true }); + expect(sessionCapacityMessage(sessionsOf(50, 'alice'), 'alice')).toContain('(50)'); + }); + + it('leaves an explicit CODEMAN_MAX_SESSIONS_PER_USER in charge of the per-user cap', async () => { + const { sessionCapacityState } = await loadWithEnv({ + CODEMAN_MAX_SESSIONS: '100', + CODEMAN_MAX_SESSIONS_PER_USER: '10', + CODEMAN_MULTIUSER: '1', + }); + expect(sessionCapacityState(sessionsOf(9, 'alice'), 'alice').atUserCap).toBe(false); + expect(sessionCapacityState(sessionsOf(10, 'alice'), 'alice').atUserCap).toBe(true); + }); +}); From 3bd7e81233c2bb2e6d8fad17e49d581dac50adce Mon Sep 17 00:00:00 2001 From: "Claudia[bot]" <4437565+Claudia[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 03:48:00 +0200 Subject: [PATCH 31/96] fix(mux): give a discovered tmux session its pane's directory, not the server cwd reconcileSessions() adopts codeman-* sessions it finds on the socket but has no record of (a lost or stale mux-sessions.json) as "Restored: ..." entries, and set their workingDir to process.cwd(). Every such session then looked like it lived wherever the server was started, and a later respawn of it cd'd there instead of into its project. tmux already knows the answer as #{pane_current_path}. It is appended as the last field of PANE_LIST_FORMAT, so a directory containing the separator stays whole, and parsePaneRows() reads it as PaneRow.currentPath. No extra tmux call: the same list-panes read that yields the pid now yields the directory. The path is set by whoever owns the pane, so it is admitted only if it passes isValidPath(), the check createSession() and respawnPane() already apply to a workingDir; otherwise, and when tmux reported none, process.cwd() stays the fallback. A hand-split session takes the first pane tmux lists. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- src/tmux-manager.ts | 22 ++++++++++++-- test/tmux-manager.test.ts | 63 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 83 insertions(+), 2 deletions(-) diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index d3a2d174..a52cf7de 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -252,10 +252,14 @@ const PANE_LIST_SEP = '|'; * know a variable substitutes the empty string rather than failing, which is how * tmux 3.2a answers `#{pane_dead_signal}` (added in 3.4), and the parser reads a * short row as "pid known, deadness unknown" rather than discarding it. + * + * `#{pane_current_path}` comes last on purpose: a directory name may contain the + * separator, and only the final field can absorb it without ambiguity. */ const PANE_LIST_FORMAT = `#{session_name}${PANE_LIST_SEP}#{pane_pid}` + - `${PANE_LIST_SEP}#{pane_dead}${PANE_LIST_SEP}#{pane_dead_status}${PANE_LIST_SEP}#{pane_dead_signal}`; + `${PANE_LIST_SEP}#{pane_dead}${PANE_LIST_SEP}#{pane_dead_status}${PANE_LIST_SEP}#{pane_dead_signal}` + + `${PANE_LIST_SEP}#{pane_current_path}`; /** * 构建 pane 启动前的 nofile 修复命令。 @@ -282,6 +286,8 @@ export interface PaneRow { exitStatus?: number; /** `#{pane_dead_signal}` — the killing signal, absent before tmux 3.4 and when unsignalled. */ exitSignal?: number; + /** `#{pane_current_path}` — the pane's current directory, absent when tmux reported none. */ + currentPath?: string; } /** @@ -347,6 +353,8 @@ export function parsePaneRows(output: string): PaneRow[] { dead: deadFlag === '1' ? true : deadFlag === '0' ? false : undefined, exitStatus: paneField(fields, 3), exitSignal: paneField(fields, 4), + // Everything after the fifth separator, so a path containing `|` stays whole. + currentPath: fields.slice(5).join(PANE_LIST_SEP) || undefined, }); } return rows; @@ -3115,6 +3123,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { // socket tag to reconcile and no cross-socket ambiguity that could mark a // live session dead (the root cause of vanished/duplicate tabs). let active: Map<string, number>; + const paneDirs = new Map<string, string>(); try { const output = execSync(`${this.tmux()} list-panes -a -F '${PANE_LIST_FORMAT}' 2>/dev/null || true`, { encoding: 'utf-8', @@ -3122,6 +3131,14 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { }).trim(); const rows = parsePaneRows(output); active = new Map(rows.map((row) => [row.sessionName, row.pid])); + // A discovered session's directory, from its first pane with a usable path. + // The path is admitted only if it passes the same check createSession and + // respawnPane apply, since it was set by whoever owns the pane, not by us. + for (const row of rows) { + if (row.currentPath && !paneDirs.has(row.sessionName) && isValidPath(row.currentPath)) { + paneDirs.set(row.sessionName, row.currentPath); + } + } // The same read answers both questions, so recovery starts with a pane-exit // reading rather than waiting for the first stats tick — which may never // come, since the collector only starts when boot found a live session. @@ -3175,7 +3192,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { muxName: sessionName, pid, createdAt: Date.now(), - workingDir: process.cwd(), + // Where the pane actually works, when tmux said; the server's cwd is only a fallback. + workingDir: paneDirs.get(sessionName) ?? process.cwd(), mode: 'claude', attached: false, name: `Restored: ${sessionName}`, diff --git a/test/tmux-manager.test.ts b/test/tmux-manager.test.ts index 24b0659f..08d47a73 100644 --- a/test/tmux-manager.test.ts +++ b/test/tmux-manager.test.ts @@ -903,6 +903,52 @@ describe('TmuxManager (unit)', () => { nonTestManager.destroy(); } }); + + it('gives a discovered session the directory tmux reports for its pane, not the server cwd', async () => { + mockedExecSync.mockImplementation((cmd: string) => { + if (typeof cmd === 'string' && cmd.includes(' list-panes ')) { + return [ + 'codeman-aaaa1111|101|0|||/home/user/project-a', + // A hand-split session answers with the pane tmux lists first. + 'codeman-bbbb2222|102|0|||/home/user/project-b', + 'codeman-bbbb2222|103|0|||/home/user/project-b/sub', + // Set by whoever owns the pane, so it must pass the same check as any workingDir. + 'codeman-cccc3333|104|0|||/home/user/$(touch pwned)', + // A tmux that reported no path. + 'codeman-dddd4444|105|0|||', + ].join('\n'); + } + return ''; + }); + const NonTestTmuxManager = await importWithTmuxCommandsEnabled(); + const nonTestManager = new NonTestTmuxManager(); + + try { + const { discovered } = await nonTestManager.reconcileSessions(); + expect(discovered).toEqual([ + 'restored-aaaa1111', + 'restored-bbbb2222', + 'restored-cccc3333', + 'restored-dddd4444', + ]); + const dirs = Object.fromEntries(nonTestManager.getSessions().map((s) => [s.muxName, s.workingDir])); + expect(dirs).toEqual({ + 'codeman-aaaa1111': '/home/user/project-a', + 'codeman-bbbb2222': '/home/user/project-b', + 'codeman-cccc3333': process.cwd(), + 'codeman-dddd4444': process.cwd(), + }); + + // The directory comes from the same list-panes call, as its last field. + const listCalls = mockedExecSync.mock.calls + .map(([cmd]) => cmd) + .filter((cmd): cmd is string => typeof cmd === 'string' && cmd.includes(' list-panes ')); + expect(listCalls).toHaveLength(1); + expect(listCalls[0]).toContain("|#{pane_current_path}'"); + } finally { + nonTestManager.destroy(); + } + }); }); }); @@ -1013,6 +1059,23 @@ describe('parsePaneRows', () => { expect(row.dead).toBeUndefined(); }); + it('reads the pane directory from the last field', () => { + const [row] = parsePaneRows('codeman-aaaa|1234|0|||/home/user/project'); + expect(row.pid).toBe(1234); + expect(row.dead).toBe(false); + expect(row.currentPath).toBe('/home/user/project'); + }); + + it('keeps a pane directory that contains the separator whole', () => { + const [row] = parsePaneRows('codeman-aaaa|1234|0|||/home/user/a|b'); + expect(row.currentPath).toBe('/home/user/a|b'); + }); + + it('leaves the pane directory undefined when tmux reported none', () => { + expect(parsePaneRows('codeman-aaaa|1234')[0].currentPath).toBeUndefined(); + expect(parsePaneRows('codeman-aaaa|1234|0|||')[0].currentPath).toBeUndefined(); + }); + it('returns one row per pane of a split session, in tmux order', () => { const rows = parsePaneRows('codeman-aaaa|100|0|||\ncodeman-aaaa|200|1|0|'); expect(rows.map((row) => row.pid)).toEqual([100, 200]); From e3de35446135c3c07dcfe8d27de771247e59d671 Mon Sep 17 00:00:00 2001 From: "Claudia[bot]" <4437565+Claudia[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 03:51:31 +0200 Subject: [PATCH 32/96] fix(input): keep the line breaks of a multi-line mux prompt, pasted as one block TmuxManager.sendInput() removed every `\n` before the text reached tmux, so a multi-line prompt sent with `useMux` (or a multi-line respawn prompt) arrived as one line with its lines run together: `"echo A\necho B\r"` ran `echo Aecho B`. The text half of a submit has gone in as one bracketed paste (buildPasteTextCommands) since the Codex paste-burst fix, and inside the paste markers Claude Code and Codex insert a pasted line break into the composer instead of submitting. So the `\n` is now kept (`\r\n` loses only its `\r`) and the existing paste carries the lines; the Enter stays a separate tmux call. Multi-line text without an Enter is pasted too rather than typed with `send-keys -l`, where every line break would reach the pane as a key. Single-line input is unchanged. The test-mode gate moves from the top of sendInput() into inputRunner(), as readPaneRows() does, so a test subclass can record the commands sendInput() would run instead of pinning the branch shapes on the source. SubmitVerifier still compares the composer's last glyph line with the prompt minus its newlines, so it does not reliably recognise a multi-line paste and may not re-press an Enter that Claude Code ignored. `agent send` and the TUI therefore keep refusing or joining multi-line input; the docs that described the stripping now describe the paste and that limit. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- .changeset/multiline-input-paste.md | 5 + CLAUDE.md | 4 +- docs/architecture-invariants.md | 2 +- docs/extending-codeman.md | 2 +- docs/reliable-input-delivery.md | 2 +- docs/wiki/Driving-Codeman-From-An-Agent.md | 12 ++- docs/wiki/Input-And-Voice.md | 7 +- plugins/codeman/skills/codeman/SKILL.md | 2 +- .../skills/codeman/reference/endpoints.md | 4 +- .../codeman/skills/codeman/reference/verbs.md | 4 +- skills/codeman/SKILL.md | 2 +- skills/codeman/reference/endpoints.md | 4 +- skills/codeman/reference/verbs.md | 4 +- src/cli-agent.ts | 14 +-- src/tmux-manager.ts | 49 ++++++---- src/tui/tui-client.ts | 3 +- src/web/CLAUDE.md | 2 +- src/web/public/app.js | 4 +- test/tmux-submit-paste.test.ts | 94 ++++++++++++++++++- 19 files changed, 164 insertions(+), 56 deletions(-) create mode 100644 .changeset/multiline-input-paste.md diff --git a/.changeset/multiline-input-paste.md b/.changeset/multiline-input-paste.md new file mode 100644 index 00000000..abe94a80 --- /dev/null +++ b/.changeset/multiline-input-paste.md @@ -0,0 +1,5 @@ +--- +'aicodeman': patch +--- + +A multi-line prompt sent through the mux (`POST /api/sessions/:id/input` with `useMux`, respawn prompts) now keeps its line breaks. `sendInput` used to strip every `\n` before the text reached tmux, so `"line one\nline two\r"` arrived as `line oneline two`. The lines now go in as one bracketed paste, the same paste a single-line prompt already uses, and the Enter after it submits them together; multi-line text without an Enter is pasted too instead of typed. A shell runs each line. The submit check that re-presses a stranded Enter does not yet recognise a multi-line paste in the composer, so `codeman agent send` and the TUI keep refusing or joining multi-line input. diff --git a/CLAUDE.md b/CLAUDE.md index bb78756b..afb393c7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -88,7 +88,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph ## Common Gotchas -- **Single-line prompts only** — `writeViaMux()` sends text+Enter separately; multi-line breaks Ink. ⚠️ **Input must END with `\r` or Enter is never sent**: `sendInput()` only issues `send-keys Enter` when the payload contains a carriage return, a `\r`-less `POST /api/sessions/:id/input` still succeeds (send-and-wait even reports `delivered:true`) while the text sits unsubmitted on the composer, and any `wait` burns its whole timeout on a turn that never started. Embedded newlines are stripped, not rejected, so `"echo A\necho B\r"` runs the joined `echo Aecho B`. ⚠️ **Claude Code 2.1.277+ ignores Enter for the first 30-50 s after the composer paints** while still taking the typed text (measured 2026-09-19: an Enter at 28 s stranded the prompt, one at 51 s submitted it), so text+`\r` sent at readiness sits unsent with `0 tokens` and a `wait` burns its timeout. So the SERVER verifies every programmatic write that carried a `\r`: `SubmitVerifier` (`session-submit-verifier.ts`, armed from `writeViaMux`) reads the pane on a 2 s to 60 s schedule and re-sends Enter only while the LAST composer line (the CLI's own `promptGlyph`) verifiably still holds the head of what was sent; an empty composer, other text, or no composer line at all (a shell, a direct-PTY session) ends it, and a newer write replaces the schedule. The skill's `sendwait` keeps its own copy of the loop (`_composer_text` in `skills/codeman/preamble.sh`) for servers that predate this. The `shift+tab` footer only means the composer painted, never that Enter is accepted. ⚠️ **A prompt must never be written into the pane as ONE burst**: Claude Code 2.1.283 takes a `<text>\r` burst of ~100+ chars as a paste, its `\r` lands as a NEWLINE and the prompt strands (a later raw `\r` does not recover it, a tmux `send-keys Enter` does). So `POST .../input` routes a plain prompt (`isPlainPromptInput()`, route-helpers.ts: printable text + exactly one trailing `\r`) through `writeViaMux` even without `useMux`, AWAITED so the browser's serialized POST fallback keeps frame order; raw frames and an explicit `useMux:false` keep the direct write +- **A prompt is one paste plus Enter** — `writeViaMux()` sends the text as one bracketed paste and Enter separately. ⚠️ **Input must END with `\r` or Enter is never sent**: `sendInput()` only issues `send-keys Enter` when the payload contains a carriage return, a `\r`-less `POST /api/sessions/:id/input` still succeeds (send-and-wait even reports `delivered:true`) while the text sits unsubmitted on the composer, and any `wait` burns its whole timeout on a turn that never started. Embedded newlines are kept (`\r\n` becomes `\n`): all lines go in as that ONE paste and the Enter after it submits them together, so `"echo A\necho B\r"` runs both commands in a shell (bash holds the pasted lines until the Enter; a shell without bracketed paste runs each line as it arrives). ⚠️ `SubmitVerifier` does not recognise a multi-line paste in the composer (it shows as a placeholder such as `[Pasted text #N +k lines]`, or as its first line only), so it may not re-press a stranded Enter for one. ⚠️ **Claude Code 2.1.277+ ignores Enter for the first 30-50 s after the composer paints** while still taking the typed text (measured 2026-09-19: an Enter at 28 s stranded the prompt, one at 51 s submitted it), so text+`\r` sent at readiness sits unsent with `0 tokens` and a `wait` burns its timeout. So the SERVER verifies every programmatic write that carried a `\r`: `SubmitVerifier` (`session-submit-verifier.ts`, armed from `writeViaMux`) reads the pane on a 2 s to 60 s schedule and re-sends Enter only while the LAST composer line (the CLI's own `promptGlyph`) verifiably still holds the head of what was sent; an empty composer, other text, or no composer line at all (a shell, a direct-PTY session) ends it, and a newer write replaces the schedule. The skill's `sendwait` keeps its own copy of the loop (`_composer_text` in `skills/codeman/preamble.sh`) for servers that predate this. The `shift+tab` footer only means the composer painted, never that Enter is accepted. ⚠️ **A prompt must never be written into the pane as ONE burst**: Claude Code 2.1.283 takes a `<text>\r` burst of ~100+ chars as a paste, its `\r` lands as a NEWLINE and the prompt strands (a later raw `\r` does not recover it, a tmux `send-keys Enter` does). So `POST .../input` routes a plain prompt (`isPlainPromptInput()`, route-helpers.ts: printable text + exactly one trailing `\r`) through `writeViaMux` even without `useMux`, AWAITED so the browser's serialized POST fallback keeps frame order; raw frames and an explicit `useMux:false` keep the direct write - **ESM only** — Never `require()`, use `await import()`. `tsx` masks CJS/ESM issues in dev but production breaks - **Package ≠ product name** — npm: `aicodeman`, product: **Codeman**. Release renames tags accordingly. Both `aicodeman` and `codeman` bin aliases are installed (`package.json` `bin`) - **Global regex `lastIndex`** — Shared `g`-flag patterns in loops must reset `lastIndex = 0` first, or use the `execPattern()` helper in `utils/regex-patterns.ts` (resets automatically) @@ -124,7 +124,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph ### Key Patterns -**Input**: `session.writeViaMux()` for programmatic/curl input via one tmux bracketed paste (`paste-buffer -p`) + `send-keys Enter`, single-line only. A single character is typed with `send-keys -l` instead, because selection dialogs ignore a pasted digit. Interactive **browser** input goes through a durable **exactly-once** layer: a stable `clientId` + monotonic per-session `seq` persisted to localStorage until the server ACKs, so a dropped link cannot lose or double-deliver a prompt. `ws-connection-registry.ts` supersedes only same-TAB reconnects, so two tabs on one session coexist. → [architecture-invariants#input-delivery-and-ws-resilience](docs/architecture-invariants.md#input-delivery-and-ws-resilience) +**Input**: `session.writeViaMux()` for programmatic/curl input via one tmux bracketed paste (`paste-buffer -p`) + `send-keys Enter`; a multi-line prompt keeps its line breaks inside that paste. A single character is typed with `send-keys -l` instead, because selection dialogs ignore a pasted digit. Interactive **browser** input goes through a durable **exactly-once** layer: a stable `clientId` + monotonic per-session `seq` persisted to localStorage until the server ACKs, so a dropped link cannot lose or double-deliver a prompt. `ws-connection-registry.ts` supersedes only same-TAB reconnects, so two tabs on one session coexist. → [architecture-invariants#input-delivery-and-ws-resilience](docs/architecture-invariants.md#input-delivery-and-ws-resilience) **Agent wait primitives**: bounded long-polls: `GET /api/sessions/:id/wait`, `GET .../wait-output` (literal substring, **never** regex) and `wait`/`waitTimeout` on `POST .../input`. Registry `session-wait-registry.ts` (pure), bounds `config/agent-wait.ts`. ⚠️ A timeout is a 200 (`wait.timedOut`). ⚠️ `stop` exists for `claude` and `deepseek` ONLY (rule lives in `hooksAvailableForMode()`), `blocked` also for a CLI declaring `capabilities.workDetect.blockedLine` (codex, read off the screen; `blockedSignalAvailable()`): explicit request elsewhere is a 400. ⚠️ Send-and-wait registers the waiter BEFORE the write; teardown must `notifySignal('exit')` BEFORE `cancelAll()`; hangup abort listens on `reply.raw` (guarded by `writableFinished`), never `req.raw`; liveness comes from `isPaneDead`, never `session.pid`. ⚠️ Signals are edge-triggered with no history: gather fan-outs via send-and-wait or `wait-output` markers. Packaged as the `skills/codeman` skill (`codeman skill install`, plugin marketplace, or injection behind `agentSkillEnabled`, SYNCED, default OFF, into the directory each CLI scans: `capabilities.agentSkillDir`, `.claude/skills` for claude, `.agents/skills` for the others, none for pi): injection is add-only, marker-owned (`applyAgentSkill`), refuses symlinks, and refreshes a marker-owned user-level copy (`refreshUserAgentSkill`). → [architecture-invariants#agent-wait-primitives](docs/architecture-invariants.md#agent-wait-primitives), `docs/api-reference.md` diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index a50539c8..1d8ca4a5 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -119,7 +119,7 @@ Spawn commands are pinned as literal strings in `test/cli-registry-spawn-golden. ### Input delivery and WS resilience -**Input**: `session.writeViaMux()` for programmatic/curl input — the text as one tmux bracketed paste (`paste-buffer -p`, so Codex cannot fold the Enter into a paste burst) + `send-keys Enter`. A single character is typed with `send-keys -l` instead (`submitsAsKeys()`): Claude Code's and Codex's selection dialogs ignore a pasted `3`, and the Enter then confirms the highlighted option. Single-line only (fire-and-once). Interactive **browser** input goes through a durable **exactly-once** layer: each frame carries a stable `clientId` + monotonic per-session `seq`, persisted to localStorage until the server ACKs (`{t:'ia',seq}` over WS, or HTTP 2xx), so a dropped link/reconnect can't lose or double-deliver a prompt. A duplicate is ACKed as `{t:'ia',seq,dup:true,last}` so a client whose persisted counter rolled back below the server's watermark can lift itself out instead of typing into a silently dead terminal (`docs/reliable-input-delivery.md`). **WS resilience** (#149): the upgrade URL carries `cid = clientId + ':' + perTabNonce`, and `ws-connection-registry.ts` supersedes only same-TAB reconnects (two tabs on one session coexist; input frames keep the bare `clientId` for seq dedup); reconnects back off exponentially (attempts preserved across `_connectWs`), and the header connection chip renders from a real `_wsState` lifecycle (`connecting`/`connected`/`fallback`/`reconnecting`/`disconnected`). +**Input**: `session.writeViaMux()` for programmatic/curl input — the text as one tmux bracketed paste (`paste-buffer -p`, so Codex cannot fold the Enter into a paste burst) + `send-keys Enter`. A single character is typed with `send-keys -l` instead (`submitsAsKeys()`): Claude Code's and Codex's selection dialogs ignore a pasted `3`, and the Enter then confirms the highlighted option. A multi-line prompt keeps its line breaks inside that one paste, with or without the Enter (fire-and-once). Interactive **browser** input goes through a durable **exactly-once** layer: each frame carries a stable `clientId` + monotonic per-session `seq`, persisted to localStorage until the server ACKs (`{t:'ia',seq}` over WS, or HTTP 2xx), so a dropped link/reconnect can't lose or double-deliver a prompt. A duplicate is ACKed as `{t:'ia',seq,dup:true,last}` so a client whose persisted counter rolled back below the server's watermark can lift itself out instead of typing into a silently dead terminal (`docs/reliable-input-delivery.md`). **WS resilience** (#149): the upgrade URL carries `cid = clientId + ':' + perTabNonce`, and `ws-connection-registry.ts` supersedes only same-TAB reconnects (two tabs on one session coexist; input frames keep the bare `clientId` for seq dedup); reconnects back off exponentially (attempts preserved across `_connectWs`), and the header connection chip renders from a real `_wsState` lifecycle (`connecting`/`connected`/`fallback`/`reconnecting`/`disconnected`). ### Per-session env overrides: exact-key allowlist and CLAUDE_CONFIG_DIR diff --git a/docs/extending-codeman.md b/docs/extending-codeman.md index eaf18448..cad0d04e 100644 --- a/docs/extending-codeman.md +++ b/docs/extending-codeman.md @@ -183,7 +183,7 @@ curl -u admin:$PASS -X POST http://127.0.0.1:3000/api/v1/sessions \ -H 'Content-Type: application/json' \ -d '{"workingDir":"/home/me/project","mode":"claude"}' -# Send a prompt (single-line only, and it must end with \r: Enter is sent only +# Send a prompt (it must end with \r: Enter is sent only # when the input contains a carriage return; without it the text sits on the # session's prompt unsubmitted) curl -u admin:$PASS -X POST http://127.0.0.1:3000/api/v1/sessions/$ID/input \ diff --git a/docs/reliable-input-delivery.md b/docs/reliable-input-delivery.md index 99f65beb..37e154dd 100644 --- a/docs/reliable-input-delivery.md +++ b/docs/reliable-input-delivery.md @@ -78,7 +78,7 @@ input for the session, and came back from localStorage on each reload. - `_sendInputAsync()` splits a paste over the frame limit into in-limit frames (`CodemanInputLimit.split`, constants.js, never cutting a surrogate pair). They go out in seq order, so the PTY sees one contiguous stream. A paste over - `PASTE_MAX_CHARS` (1 MiB), or an oversized `useMux` write (line-oriented, never + `PASTE_MAX_CHARS` (1 MiB), or an oversized `useMux` write (one paste plus its own Enter, never split), is refused with a toast and never queued. - The WebSocket answers an oversized sequenced frame with `{t:'ia', seq, err:'too_large', max}`; the client drops it with a toast. A diff --git a/docs/wiki/Driving-Codeman-From-An-Agent.md b/docs/wiki/Driving-Codeman-From-An-Agent.md index 01bcaded..bcf1bc06 100644 --- a/docs/wiki/Driving-Codeman-From-An-Agent.md +++ b/docs/wiki/Driving-Codeman-From-An-Agent.md @@ -125,11 +125,13 @@ These are set in every managed session. Read them rather than hardcoding anythin Read these before writing any code. Each one has cost somebody an afternoon. -1. **Input is single line and must end with `\r`.** Enter fires only when the payload - contains a carriage return. Without it the text sits unsubmitted on the prompt, the - request still succeeds, and a combined wait burns its full timeout on a turn that never - started. Embedded newlines are stripped rather than rejected, so `"echo A\necho B\r"` runs - the joined `echo Aecho B`. One line per call. +1. **Input must end with `\r`.** Enter fires only when the payload contains a carriage + return. Without it the text sits unsubmitted on the prompt, the request still succeeds, + and a combined wait burns its full timeout on a turn that never started. Embedded + newlines are kept: the text goes in as one paste and the Enter after it submits all of + it, so `"echo A\necho B\r"` runs both commands in a shell. The check that re-presses a + stranded Enter cannot reliably recognise a multi-line paste, so a long multi-line + prompt to a just-started agent is still safer in a file it is told to read. 2. **Make input idempotent.** Send a stable `clientId` and a monotonic per-session `seq`. The server deduplicates, so a retry after a dropped connection cannot double-deliver. 3. **Auth.** With `CODEMAN_PASSWORD` set, use HTTP Basic or the session cookie. A missing diff --git a/docs/wiki/Input-And-Voice.md b/docs/wiki/Input-And-Voice.md index a873a504..223835c6 100644 --- a/docs/wiki/Input-And-Voice.md +++ b/docs/wiki/Input-And-Voice.md @@ -125,9 +125,10 @@ Sending prompts over the API has one rule that catches everyone: **the payload m `\r`** or Enter is never sent. The request still succeeds, the text sits unsubmitted in the composer, and any wait burns its whole timeout on a turn that never started. -Input is also **single line**. Embedded newlines are stripped rather than rejected, so -`"echo A\necho B\r"` runs the joined `echo Aecho B`. Put multi-line content in a file and -tell the agent to read it. +Embedded newlines are kept: multi-line input goes in as one paste and the Enter after it +submits all of it, so `"echo A\necho B\r"` runs both commands in a shell. The check that +re-presses a stranded Enter cannot reliably recognise a multi-line paste, so long +multi-line content is still safer in a file the agent is told to read. See [Driving Codeman From An Agent](Driving-Codeman-From-An-Agent). diff --git a/plugins/codeman/skills/codeman/SKILL.md b/plugins/codeman/skills/codeman/SKILL.md index a9772f77..e67e96c9 100644 --- a/plugins/codeman/skills/codeman/SKILL.md +++ b/plugins/codeman/skills/codeman/SKILL.md @@ -587,7 +587,7 @@ One row per job. Acting on this table alone is correct; the §5 links are the de | know if it is stuck | `GET .../active-tools` and `GET .../run-summary` are structured and free; two `terminal?tail=` samples are the crude fallback | [§5.6](reference/verbs.md#56-alive-and-stuck) | | make a runaway worker stop | `POST .../input {"input":"\u001b"}` (ESC, **no** `\r`). Deleting the session would destroy the conversation instead | [§5.7](reference/verbs.md#57-interrupt-without-destroying) | | resume a worker halted on a usage limit | `POST .../auto-resume {"enabled":true}`. Respawn and Ralph are **not** the remedy: respawn runs `/clear` | [§5.8](reference/verbs.md#58-usage-limits) | -| give a worker big input | write a file into its workspace with your own tools and send one short line pointing at it. The composer takes 65536 characters, single-line, newlines stripped | [§5.9](reference/verbs.md#59-big-input-via-the-workspace) | +| give a worker big input | write a file into its workspace with your own tools and send one short line pointing at it. The composer takes 65536 characters, and `send` takes one line | [§5.9](reference/verbs.md#59-big-input-via-the-workspace) | | watch N workers at once | one in-flight wait per worker (per-session waiter cap 16); fan-out shapes differ for claude and shell | [§5.10](reference/verbs.md#510-fan-out) | | find yourself, list what exists | `GET /api/v1/sessions`, match your `$SELF` by **prefix** | [§5.11](reference/verbs.md#511-list-and-find-yourself) | | read or record what the user wants | `GET/PUT .../intent`, and `POST .../readmymind` to predict | [§5.12](reference/verbs.md#512-read-my-mind) | diff --git a/plugins/codeman/skills/codeman/reference/endpoints.md b/plugins/codeman/skills/codeman/reference/endpoints.md index f2ef2fb2..5e8b5d38 100644 --- a/plugins/codeman/skills/codeman/reference/endpoints.md +++ b/plugins/codeman/skills/codeman/reference/endpoints.md @@ -420,8 +420,8 @@ callers send no body at all. **or Enter is never sent**: the text is typed onto the worker's prompt and sits there unsubmitted. This is [symptom 1](#1-deliveredtrue-then-every-wait-times-out), the number-one silent failure. -- `input` must be single-line (newlines are stripped). To send a bare Enter (confirm - a dialog), send `{"input":"\r"}`. +- Newlines in `input` are kept: the text goes in as one bracketed paste and the `\r` + submits all lines. To send a bare Enter (confirm a dialog), send `{"input":"\r"}`. - `input` is capped at **65536** characters. ⚠️ **Two caps disagree and the smaller one is the real one**: the Zod schema allows 100000 (`schemas.ts:1035`), so a 65537-to-100000 character body passes validation and *then* 400s at the route against diff --git a/plugins/codeman/skills/codeman/reference/verbs.md b/plugins/codeman/skills/codeman/reference/verbs.md index 2dfe8bb0..f85fb631 100644 --- a/plugins/codeman/skills/codeman/reference/verbs.md +++ b/plugins/codeman/skills/codeman/reference/verbs.md @@ -303,8 +303,8 @@ duplicate resend costs nothing. Codeman types the text and sends Enter **only when the input contains a carriage return**; without it your command sits unsubmitted on the worker's prompt and everything downstream times out. No response field catches this: `delivered:true` -means "written to the pane", **not** "submitted". Newlines are stripped, so input is -single-line by construction. Build the body with `jq -n` for any prompt you did not +means "written to the pane", **not** "submitted". Newlines are kept: the text goes in +as one paste, and the `\r` submits all of it. Build the body with `jq -n` for any prompt you did not author as a literal, because the inline `-d '{"input":"'"$P"'\r"}'` pattern breaks on the first double quote, backslash or `$` in a real prompt: diff --git a/skills/codeman/SKILL.md b/skills/codeman/SKILL.md index a9772f77..e67e96c9 100644 --- a/skills/codeman/SKILL.md +++ b/skills/codeman/SKILL.md @@ -587,7 +587,7 @@ One row per job. Acting on this table alone is correct; the §5 links are the de | know if it is stuck | `GET .../active-tools` and `GET .../run-summary` are structured and free; two `terminal?tail=` samples are the crude fallback | [§5.6](reference/verbs.md#56-alive-and-stuck) | | make a runaway worker stop | `POST .../input {"input":"\u001b"}` (ESC, **no** `\r`). Deleting the session would destroy the conversation instead | [§5.7](reference/verbs.md#57-interrupt-without-destroying) | | resume a worker halted on a usage limit | `POST .../auto-resume {"enabled":true}`. Respawn and Ralph are **not** the remedy: respawn runs `/clear` | [§5.8](reference/verbs.md#58-usage-limits) | -| give a worker big input | write a file into its workspace with your own tools and send one short line pointing at it. The composer takes 65536 characters, single-line, newlines stripped | [§5.9](reference/verbs.md#59-big-input-via-the-workspace) | +| give a worker big input | write a file into its workspace with your own tools and send one short line pointing at it. The composer takes 65536 characters, and `send` takes one line | [§5.9](reference/verbs.md#59-big-input-via-the-workspace) | | watch N workers at once | one in-flight wait per worker (per-session waiter cap 16); fan-out shapes differ for claude and shell | [§5.10](reference/verbs.md#510-fan-out) | | find yourself, list what exists | `GET /api/v1/sessions`, match your `$SELF` by **prefix** | [§5.11](reference/verbs.md#511-list-and-find-yourself) | | read or record what the user wants | `GET/PUT .../intent`, and `POST .../readmymind` to predict | [§5.12](reference/verbs.md#512-read-my-mind) | diff --git a/skills/codeman/reference/endpoints.md b/skills/codeman/reference/endpoints.md index f2ef2fb2..5e8b5d38 100644 --- a/skills/codeman/reference/endpoints.md +++ b/skills/codeman/reference/endpoints.md @@ -420,8 +420,8 @@ callers send no body at all. **or Enter is never sent**: the text is typed onto the worker's prompt and sits there unsubmitted. This is [symptom 1](#1-deliveredtrue-then-every-wait-times-out), the number-one silent failure. -- `input` must be single-line (newlines are stripped). To send a bare Enter (confirm - a dialog), send `{"input":"\r"}`. +- Newlines in `input` are kept: the text goes in as one bracketed paste and the `\r` + submits all lines. To send a bare Enter (confirm a dialog), send `{"input":"\r"}`. - `input` is capped at **65536** characters. ⚠️ **Two caps disagree and the smaller one is the real one**: the Zod schema allows 100000 (`schemas.ts:1035`), so a 65537-to-100000 character body passes validation and *then* 400s at the route against diff --git a/skills/codeman/reference/verbs.md b/skills/codeman/reference/verbs.md index 2dfe8bb0..f85fb631 100644 --- a/skills/codeman/reference/verbs.md +++ b/skills/codeman/reference/verbs.md @@ -303,8 +303,8 @@ duplicate resend costs nothing. Codeman types the text and sends Enter **only when the input contains a carriage return**; without it your command sits unsubmitted on the worker's prompt and everything downstream times out. No response field catches this: `delivered:true` -means "written to the pane", **not** "submitted". Newlines are stripped, so input is -single-line by construction. Build the body with `jq -n` for any prompt you did not +means "written to the pane", **not** "submitted". Newlines are kept: the text goes in +as one paste, and the `\r` submits all of it. Build the body with `jq -n` for any prompt you did not author as a literal, because the inline `-d '{"input":"'"$P"'\r"}'` pattern breaks on the first double quote, backslash or `$` in a real prompt: diff --git a/src/cli-agent.ts b/src/cli-agent.ts index 505a5af3..f89de132 100644 --- a/src/cli-agent.ts +++ b/src/cli-agent.ts @@ -129,16 +129,18 @@ export function sendPromptFromArgs(words: readonly string[]): { text: string } | } /** - * Why `send` refuses this text, or `undefined` when it is printable. The composer - * takes one line; the server strips `\r`/`\n` but everything else below 0x20 (and - * DEL) reaches the pane as a keypress. None of that is a prompt. + * Why `send` refuses this text, or `undefined` when it is printable. A line break is + * refused for the reason below; everything else below 0x20 (and DEL) reaches the pane + * as a keypress. None of that is a prompt. */ export function inputRefusal(text: string): string | undefined { if (text.length === 0) return 'refusing: empty input (use `interrupt` for ESC, `send <id> ""` is never a prompt)'; - // The composer is one line: the server strips newlines, which silently joins the - // lines into one prompt, and a tab reaches the pane as a keypress (claude: mode toggle). + // The server pastes a multi-line prompt as one block, but the check that re-presses + // a stranded Enter cannot reliably recognise that block in the composer, so a wait + // could burn its timeout on a prompt that never went out. A tab reaches the pane as + // a keypress (claude: mode toggle). if (/[\n\r\t]/.test(text)) { - return 'refusing: input must be a single line (the composer strips newlines and would join your lines) — join them yourself, or write a file into the workspace and send its path'; + return 'refusing: input must be a single line (a multi-line paste cannot be confirmed as submitted) — join them yourself, or write a file into the workspace and send its path'; } // C0, DEL and C1 (U+0080–U+009F: an 8-bit CSI is still a CSI to a terminal). // eslint-disable-next-line no-control-regex diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index d3a2d174..f24af42f 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -3828,6 +3828,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { * so Codex cannot fold the Enter into a paste burst; a single character is typed * with `send-keys -l` instead, so a dialog answer selects (submitsAsKeys) * - Text without `\r` is typed literally with `send-keys -l` (no key interpretation) + * - A `\n` inside the text is kept (`\r\n` loses only its `\r`), and multi-line text + * always goes in as ONE bracketed paste, with or without a following Enter: inside + * the paste markers an agent CLI inserts the line breaks into its composer, where + * typed as keys each one could submit a line on its own * - `Enter` key is sent as a SEPARATE tmux invocation after a small delay * - Ink (Claude CLI) needs text and Enter split to avoid treating Enter as a newline */ @@ -3840,8 +3844,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { return false; } - // TEST MODE: No-op — don't send input to real tmux sessions - if (IS_TEST_MODE) { + // TEST MODE: No-op — don't send input to real tmux sessions. The gate lives in + // inputRunner() so a test subclass can record what WOULD reach tmux. + const run = this.inputRunner(); + if (!run) { return true; } @@ -3856,7 +3862,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { try { const hasCarriageReturn = input.includes('\r'); - const textPart = input.replace(/\r/g, '').replace(/\n/g, '').trimEnd(); + const textPart = input.replace(/\r/g, '').trimEnd(); if (textPart && hasCarriageReturn) { // Send text first, then Enter as a SEPARATE tmux command after a short delay. @@ -3868,31 +3874,26 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { // selection dialog ignores a pasted `3` and Enter then confirms the // highlighted option (see submitsAsKeys). if (submitsAsKeys(textPart)) { - await execAsync( - `${this.tmux()} send-keys -t "${session.muxName}" -l ${shellescape(escapeTmuxTrailingSemicolon(textPart))}`, - { timeout: EXEC_TIMEOUT_MS } + await run( + `${this.tmux()} send-keys -t "${session.muxName}" -l ${shellescape(escapeTmuxTrailingSemicolon(textPart))}` ); } else { - await pasteText( - (cmd) => execAsync(cmd, { timeout: EXEC_TIMEOUT_MS }), - buildPasteTextCommands(this.tmux(), session.muxName, textPart) - ); + await pasteText(run, buildPasteTextCommands(this.tmux(), session.muxName, textPart)); } await new Promise((resolve) => setTimeout(resolve, 50)); - await execAsync(`${this.tmux()} send-keys -t "${session.muxName}" Enter`, { - timeout: EXEC_TIMEOUT_MS, - }); + await run(`${this.tmux()} send-keys -t "${session.muxName}" Enter`); + } else if (textPart.includes('\n')) { + // Multi-line text, no Enter: pasted, because typed as keys every `\n` would + // reach the pane as a key the CLI may read as submit. + await pasteText(run, buildPasteTextCommands(this.tmux(), session.muxName, textPart)); } else if (textPart) { // Text only, no Enter - await execAsync( - `${this.tmux()} send-keys -t "${session.muxName}" -l ${shellescape(escapeTmuxTrailingSemicolon(textPart))}`, - { timeout: EXEC_TIMEOUT_MS } + await run( + `${this.tmux()} send-keys -t "${session.muxName}" -l ${shellescape(escapeTmuxTrailingSemicolon(textPart))}` ); } else if (hasCarriageReturn) { // Enter only - await execAsync(`${this.tmux()} send-keys -t "${session.muxName}" Enter`, { - timeout: EXEC_TIMEOUT_MS, - }); + await run(`${this.tmux()} send-keys -t "${session.muxName}" Enter`); } return true; @@ -3902,6 +3903,16 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { } } + /** + * How {@link sendInput} reaches tmux, or null where it must not (vitest). Split + * out like {@link readPaneRows}: with the test-mode gate at the top of sendInput, + * no test could see which commands a submit actually sends. + */ + protected inputRunner(): ((cmd: string) => Promise<unknown>) | null { + if (IS_TEST_MODE) return null; + return (cmd) => execAsync(cmd, { timeout: EXEC_TIMEOUT_MS }); + } + // ========== Pane Methods (for Agent Team teammate panes) ========== /** diff --git a/src/tui/tui-client.ts b/src/tui/tui-client.ts index b90863cc..a78bf453 100644 --- a/src/tui/tui-client.ts +++ b/src/tui/tui-client.ts @@ -705,7 +705,8 @@ export class TuiClient { * Two hard rules from CLAUDE.md, both enforced here so no caller can get them * wrong: the payload must END with `\r` or the server never issues Enter and * the text sits unsubmitted, and embedded newlines are stripped rather than - * sent (multi-line input breaks Ink, and the server would join the lines). + * sent (the server would paste them as one block, which its submit check + * cannot reliably recognise in the composer). * The `clientId`/`seq` pair makes delivery exactly-once, so a retry after a * dropped connection cannot type the prompt twice. */ diff --git a/src/web/CLAUDE.md b/src/web/CLAUDE.md index 30ef4952..b7e20d33 100644 --- a/src/web/CLAUDE.md +++ b/src/web/CLAUDE.md @@ -70,7 +70,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L **Shell keyboard accessory bar + one-shot Ctrl** (`keyboard-accessory.js`): a shell-mode session swaps the mobile accessory bar for terminal controls; `setMode()` records `extendedKeyboardBar` as the base layout and `refreshForActiveSession()` resolves base-vs-shell. ⚠️ Ctrl is a one-shot modifier applied in `terminal.onData` (after `shouldSuppressTerminalQueryResponse`, before every send path), and must skip `isTerminalFocusOrMouseReport()` chunks. ⚠️ It must disarm on use, second tap, any other accessory key, session switch, keyboard dismissal and layout swap. ⚠️ `_handleCjkInput()` must apply it too (the CJK textarea bypasses onData). Mapping: `ctrlByteFor()`. ⚠️ mobile.css's light-skin repaint must keep excluding `.accessory-btn:not(.armed)` or the armed state is invisible. → [architecture-invariants#shell-keyboard-accessory-bar-and-one-shot-ctrl](../../docs/architecture-invariants.md#shell-keyboard-accessory-bar-and-one-shot-ctrl) -**Mobile prompt composer** (`keyboard-accessory.js`): the agent bars' Paste key is **Compose**, a native multiline dialog where only **Send** submits (the shell bar keeps plain Paste). Opening it adopts the whole terminal prompt (`_takePendingLocalEcho`), erasing the flushed prefix with backspaces counted in code points. ⚠️ Drafts are per-session and in memory only (`_composerDrafts`), never persisted (prompts carry secrets). ⚠️ Delivery is a hand-built bracketed-paste frame via `_sendInputAsync` WITHOUT `useMux`, then a separate delayed Enter WITH it; never `terminal.paste()`, and the frame must never take the mux fallback (it strips newlines). ⚠️ `_composerMaxLength` must stay derived from `MAX_INPUT_LENGTH` minus the markers, or an oversized frame wedges the durable queue. ⚠️ The composer overlay needs its own gutter restatement after the fold rules. Test: `test/mobile-prompt-composer.test.ts`. → [architecture-invariants#mobile-prompt-composer](../../docs/architecture-invariants.md#mobile-prompt-composer) +**Mobile prompt composer** (`keyboard-accessory.js`): the agent bars' Paste key is **Compose**, a native multiline dialog where only **Send** submits (the shell bar keeps plain Paste). Opening it adopts the whole terminal prompt (`_takePendingLocalEcho`), erasing the flushed prefix with backspaces counted in code points. ⚠️ Drafts are per-session and in memory only (`_composerDrafts`), never persisted (prompts carry secrets). ⚠️ Delivery is a hand-built bracketed-paste frame via `_sendInputAsync` WITHOUT `useMux`, then a separate delayed Enter WITH it; never `terminal.paste()`, and the frame must never take the mux fallback (it would paste the frame's own markers inside a second paste). ⚠️ `_composerMaxLength` must stay derived from `MAX_INPUT_LENGTH` minus the markers, or an oversized frame wedges the durable queue. ⚠️ The composer overlay needs its own gutter restatement after the fold rules. Test: `test/mobile-prompt-composer.test.ts`. → [architecture-invariants#mobile-prompt-composer](../../docs/architecture-invariants.md#mobile-prompt-composer) **Terminal resilience**: a replay clear is the queued in-stream `\x1bc` in `_resetTerminalForReplay()`, never `reset()`/`clear()` (queued bytes fuse into the snapshot); the renderer watchdog `_kickRenderer()` reads xterm privates, pinned by `test/xterm-private-api.test.ts` against the resolved lockfile version; every terminal capture fetch has a deadline that covers the BODY (`_fetchTerminalCapture`). → [architecture-invariants#terminal-resilience-replay-clears-renderer-liveness-fetch-deadlines](../../docs/architecture-invariants.md#terminal-resilience-replay-clears-renderer-liveness-fetch-deadlines) diff --git a/src/web/public/app.js b/src/web/public/app.js index 62716497..f6e48baa 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -3938,8 +3938,8 @@ class CodemanApp { // Both transports refuse a frame over the server's limit (issue #484), and a // refused frame used to sit at the head of the durable queue for good. So an // oversized paste goes out as several in-limit frames, delivered in seq order - // as one contiguous stream. A mux write is line-oriented (it strips newlines - // and sends Enter on its own), so it is never split: refuse it instead. + // as one contiguous stream. A mux write is one paste that sends Enter on its + // own, so it is never split: refuse it instead. const limit = window.CodemanInputLimit; if (limit && input.length > limit.FRAME_MAX_CHARS) { if (useMux || input.length > limit.PASTE_MAX_CHARS) { diff --git a/test/tmux-submit-paste.test.ts b/test/tmux-submit-paste.test.ts index ba529f18..7104fdc7 100644 --- a/test/tmux-submit-paste.test.ts +++ b/test/tmux-submit-paste.test.ts @@ -11,16 +11,27 @@ * a one-line prompt). With `paste-buffer -p` the paste is marked and the separate * Enter submits. * + * Also the line breaks of a multi-line `sendInput`: kept, and pasted as ONE block + * with or without the Enter, where they used to be stripped and the lines joined. + * * Strategy: pure command shape, plus a round trip through a real shell with a fake * `tmux` that prints its argv, so quoting and tmux's trailing-`;` rule are checked * on the bytes a shell actually hands over. `pasteText` runs against a recording - * runner. No real tmux. + * runner, and `sendInput` against a subclass whose `inputRunner()` records the + * commands instead of running them. No real tmux. * * Port: N/A (no server / no real tmux) */ import { execSync } from 'node:child_process'; import { describe, expect, it } from 'vitest'; -import { buildPasteTextCommands, escapeTmuxTrailingSemicolon, pasteText, submitsAsKeys } from '../src/tmux-manager.js'; +import type { MuxSession } from '../src/mux-interface.js'; +import { + TmuxManager, + buildPasteTextCommands, + escapeTmuxTrailingSemicolon, + pasteText, + submitsAsKeys, +} from '../src/tmux-manager.js'; const FAKE_TMUX = `${JSON.stringify(process.execPath)} -e 'console.log(JSON.stringify(process.argv.slice(1)))' --`; @@ -136,8 +147,8 @@ describe('pasteText', () => { }); describe('sendInput wiring', () => { - // sendInput() returns before touching tmux under vitest (IS_TEST_MODE), so the - // branch that submits text is pinned on the source instead. + // These pin the branch shapes on the source; the commands sendInput actually runs + // are recorded in 'sendInput line breaks' below. async function source(): Promise<string> { const { readFileSync } = await import('node:fs'); return readFileSync(new URL('../src/tmux-manager.ts', import.meta.url), 'utf-8'); @@ -178,3 +189,78 @@ describe('sendInput wiring', () => { for (const cmd of typedHere) expect(cmd).toContain('escapeTmuxTrailingSemicolon(textPart)'); }); }); + +describe('sendInput line breaks', () => { + class RecordingManager extends TmuxManager { + ran: string[] = []; + protected override inputRunner(): (cmd: string) => Promise<unknown> { + return async (cmd: string) => { + this.ran.push(cmd); + }; + } + } + + function managerWithSession(): RecordingManager { + const manager = new RecordingManager(); + manager.registerSession({ + sessionId: 's1', + muxName: 'codeman-abc12345', + pid: 100, + createdAt: 0, + workingDir: '/tmp', + mode: 'claude', + attached: true, + } as MuxSession); + return manager; + } + + /** The text a recorded `set-buffer` command hands to tmux, as a shell delivers it. */ + function bufferText(setBufferCmd: string): string { + return argvOf(setBufferCmd.replace(/^.*? set-buffer /, `${FAKE_TMUX} set-buffer `)).at(-1) as string; + } + + it('submits a multi-line prompt as one paste with its line breaks, then Enter', async () => { + const manager = managerWithSession(); + expect(await manager.sendInput('s1', 'line one\nline two\n\nparagraph three\r')).toBe(true); + + expect(manager.ran).toHaveLength(3); + const [set, paste, enter] = manager.ran; + expect(bufferText(set)).toBe('line one\nline two\n\nparagraph three'); + expect(paste).toMatch(/ paste-buffer -p -d -b \S+ -t 'codeman-abc12345'$/); + expect(enter).toMatch(/ send-keys -t "codeman-abc12345" Enter$/); + }); + + it('turns CRLF line endings into plain line breaks', async () => { + const manager = managerWithSession(); + await manager.sendInput('s1', 'one\r\ntwo\r'); + expect(bufferText(manager.ran[0])).toBe('one\ntwo'); + }); + + it('pastes multi-line text without Enter too, and never types it as keys', async () => { + const manager = managerWithSession(); + await manager.sendInput('s1', 'one\ntwo'); + + expect(manager.ran).toHaveLength(2); + expect(bufferText(manager.ran[0])).toBe('one\ntwo'); + expect(manager.ran[1]).toContain(' paste-buffer -p '); + for (const cmd of manager.ran) expect(cmd).not.toContain('send-keys'); + }); + + it('drops trailing line breaks instead of pasting an empty last line', async () => { + const manager = managerWithSession(); + await manager.sendInput('s1', 'one\ntwo\n\n\r'); + expect(bufferText(manager.ran[0])).toBe('one\ntwo'); + }); + + it('leaves single-line input as it was: pasted with Enter, typed without', async () => { + const submitted = managerWithSession(); + await submitted.sendInput('s1', 'read your inbox\r'); + expect(bufferText(submitted.ran[0])).toBe('read your inbox'); + expect(submitted.ran.at(-1)).toMatch(/ send-keys -t "codeman-abc12345" Enter$/); + + const typed = managerWithSession(); + await typed.sendInput('s1', 'just type'); + expect(typed.ran).toHaveLength(1); + expect(typed.ran[0]).toMatch(/ send-keys -t "codeman-abc12345" -l 'just type'$/); + }); +}); From fe10cad83f007cf15d948855c4e88c21cff50def Mon Sep 17 00:00:00 2001 From: "Claudia[bot]" <4437565+Claudia[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 04:11:28 +0200 Subject: [PATCH 33/96] docs: finish dropping the stripped-newline rule from the input docs The first commit left the old rule standing in places: the gotchas list in extending-codeman.md still said newlines were stripped and that "echo A\necho B\r" ran `echo Aecho B`, and so did both READMEs and the skill's big-input section. Those now describe the paste. Newlines are only kept on the mux path, so the docs that describe the paste say "with useMux": without it a multi-line body is not a plain prompt and is written straight into the pane. Cron keeps rejecting multi-line prompts, but its docs and the code comments in cron-service.ts, schemas.ts and respawn-controller.ts gave a reason this change removes (writeViaMux joining lines, a newline submitting in Ink). The reason that remains is the submit check, which cannot recognise a multi-line paste in the composer, and for cron's paste mode the newlines reaching the CLI as keys. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- README.md | 2 +- README.zh-CN.md | 2 +- docs/cron-guide.md | 7 +++--- docs/extending-codeman.md | 22 ++++++++++--------- docs/wiki/Cron-Jobs.md | 9 ++++---- docs/wiki/Driving-Codeman-From-An-Agent.md | 11 +++++----- docs/wiki/Input-And-Voice.md | 6 ++--- .../skills/codeman/reference/endpoints.md | 5 +++-- .../codeman/skills/codeman/reference/verbs.md | 15 +++++++------ skills/codeman/reference/endpoints.md | 5 +++-- skills/codeman/reference/verbs.md | 15 +++++++------ src/cron/cron-service.ts | 2 +- src/respawn-controller.ts | 3 ++- src/web/schemas.ts | 2 +- 14 files changed, 58 insertions(+), 48 deletions(-) diff --git a/README.md b/README.md index d3ad103a..bad01443 100644 --- a/README.md +++ b/README.md @@ -851,7 +851,7 @@ When a CLI runs in a Codeman-managed session, these environment variables are se ### Rules of the road (read before you POST) -1. **Single-line input, ending in `\r`.** Programmatic input is sent as literal text, and Enter fires **only when the input contains a carriage return**: `{"input":"run tests\r"}`. Without the `\r` the text sits on the session's prompt unsubmitted (and a combined `wait` runs its full timeout on a turn that never started). Embedded newlines are stripped rather than rejected, so `"echo A\necho B\r"` runs the joined command `echo Aecho B`: send one line per call. +1. **Input must end in `\r`.** Programmatic input is sent as literal text, and Enter fires **only when the input contains a carriage return**: `{"input":"run tests\r"}`. Without the `\r` the text sits on the session's prompt unsubmitted (and a combined `wait` runs its full timeout on a turn that never started). With `useMux`, embedded newlines are kept and the lines go in as one paste, so `"echo A\necho B\r"` runs both commands in a shell; the check that re-presses a stranded Enter cannot reliably recognise a multi-line paste, so put long multi-line content in a file the agent is told to read. 2. **Make input idempotent.** Include a stable `clientId` and a monotonic per-session `seq` on `POST …/input`. The server de-duplicates, so a retry after a dropped connection can't double-deliver a prompt. 3. **Auth.** If `CODEMAN_PASSWORD` is set, send HTTP Basic auth (user `admin` or `CODEMAN_USERNAME`) or a `codeman_session` cookie. The default loopback install is passwordless. A missing `Origin` header is allowed, so plain `curl` works; cross-site browser origins are rejected (CSRF guard). ⚠️ A `401` replies with the bare string `Unauthorized`, **not** the JSON envelope, so piping it into `jq` throws a parse error instead of showing the failure: check the status before parsing. 4. **Response envelope.** Most endpoints return `{ "success": true, "data": … }` (errors: `{ "success": false, "error", "errorCode" }`). A few legacy GETs return bare bodies — **handle both** (`body.data ?? body`). diff --git a/README.zh-CN.md b/README.zh-CN.md index eb5dd9ba..e70bd7a2 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -853,7 +853,7 @@ deleted 9f2d8e5f, aff9c691, be9f1f15 标签消失 ### 行路规则(POST 之前先读) -1. **只发单行输入,而且必须以 `\r` 结尾。** 编程输入按字面文本发送,**只有当输入里含回车符时才会触发 Enter**:`{"input":"run tests\r"}`。少了 `\r`,文本就停在会话的输入框里不被提交(同一次调用里的 `wait` 还会在一个压根没开始的回合上耗满整个超时)。内嵌的换行会被剥掉而不是报错,因此 `"echo A\necho B\r"` 执行的是拼起来的 `echo Aecho B`:一次调用只发一行。 +1. **输入必须以 `\r` 结尾。** 编程输入按字面文本发送,**只有当输入里含回车符时才会触发 Enter**:`{"input":"run tests\r"}`。少了 `\r`,文本就停在会话的输入框里不被提交(同一次调用里的 `wait` 还会在一个压根没开始的回合上耗满整个超时)。使用 `useMux` 时内嵌的换行会保留,各行作为一次粘贴送入,因此 `"echo A\necho B\r"` 在 shell 里会执行两条命令;但重新按下滞留 Enter 的检查无法可靠识别多行粘贴,所以较长的多行内容最好写进文件,让 agent 自己去读。 2. **让输入幂等。** 在 `POST …/input` 上带上稳定的 `clientId` 和按会话单调递增的 `seq`。服务端会去重,因此连接中断后的重试不会重复投递提示。 3. **认证。** 若设置了 `CODEMAN_PASSWORD`,发送 HTTP Basic 认证(用户 `admin` 或 `CODEMAN_USERNAME`)或 `codeman_session` cookie。默认的环回安装无密码。缺失的 `Origin` 头被允许,因此普通 `curl` 可用;跨站的浏览器 origin 会被拒绝(CSRF 防护)。⚠️ `401` 回的是裸字符串 `Unauthorized`,**不是** JSON 信封,直接喂给 `jq` 只会抛解析错误而看不到真正的失败原因:先看状态码,再解析。 4. **响应信封。** 多数端点返回 `{ "success": true, "data": … }`(错误:`{ "success": false, "error", "errorCode" }`)。少数遗留 GET 返回裸响应体 —— **两种都要处理**(`body.data ?? body`)。 diff --git a/docs/cron-guide.md b/docs/cron-guide.md index 0be6ec77..3ea21f62 100644 --- a/docs/cron-guide.md +++ b/docs/cron-guide.md @@ -207,9 +207,10 @@ trailing carriage return: | `typed` | `session.writeViaMux()` — the text as a tmux bracketed paste + Enter (a single character is typed, so a dialog answer selects) | Default; like a human pasting the prompt and pressing Enter. | | `paste` | `session.write()` — writes directly to the PTY/mux | Bulk paste-style delivery. | -> ⚠️ **Single-line only — enforced.** Like all programmatic input in Codeman, -> multi-line delivery would be silently corrupted (Ink-based TUIs treat a -> newline as submit; typed mode fuses lines). So newlines are **rejected**: the +> ⚠️ **Single-line only — enforced.** A job fires unattended, and the submit +> check that re-presses an ignored Enter cannot recognise a multi-line paste in +> the composer, so a stranded prompt would go unnoticed (in paste mode the +> newlines reach the CLI as keys). So newlines are **rejected**: the > schema and the form refuse a multi-line `promptText`, and at fire time a > prompt file whose content is multi-line (after stripping trailing newlines) > fails the run with a clear `errorMessage`. Put multi-line instructions in a diff --git a/docs/extending-codeman.md b/docs/extending-codeman.md index cad0d04e..3b76697a 100644 --- a/docs/extending-codeman.md +++ b/docs/extending-codeman.md @@ -294,8 +294,8 @@ if ! jq -e '.data.wait.matched' <<<"$R" >/dev/null; then fi # 3. Send the prompt AND register the wait in one call, so the answer cannot be -# the previous turn's idle state. Single line only, ending in \r (otherwise -# Enter is never sent and this wait times out on a turn that never started). +# the previous turn's idle state. It must end in \r (otherwise Enter is never +# sent and this wait times out on a turn that never started). W=$("${CURL[@]}" -X POST "$API/api/v1/sessions/$SID/input" \ -H 'Content-Type: application/json' \ -d '{"input":"Run the test suite and summarize the failures\r","useMux":true, @@ -414,14 +414,16 @@ Every one of these has cost somebody real time. shipped bugs more than once. - **`text/plain` bodies stay raw.** Auto-parsing them as JSON enabled simple-request CSRF, so it is deliberate. Send `application/json`. -- **Prompts are single-line and must end with `\r`.** The server splits your text - and Enter into two separate tmux writes (Ink needs them apart), but it sends the - Enter **only when the input contains a carriage return**. Without it your text - sits on the prompt unsubmitted, which is the single most common "the wait - endpoints don't work" report: the wait runs its full timeout on a turn that never - started. Newlines inside the string are stripped rather than rejected, so - `"echo A\necho B\r"` runs the single joined command `echo Aecho B`: send one line - per call. +- **Prompts must end with `\r`.** The server splits your text and Enter into two + separate tmux writes (Ink needs them apart), but it sends the Enter **only when + the input contains a carriage return**. Without it your text sits on the prompt + unsubmitted, which is the single most common "the wait endpoints don't work" + report: the wait runs its full timeout on a turn that never started. With + `useMux`, newlines inside the string are kept: the text goes in as one paste and + the Enter after it submits all of it, so `"echo A\necho B\r"` runs both commands + in a shell. The check that re-presses a stranded Enter cannot reliably recognise + a multi-line paste, so a long multi-line prompt to a just-started agent is still + safer in a file it is told to read. - **`wait-output`'s `from=now` is not "printed after you asked".** tmux repaints the visible screen on attach, on resize, and on any TUI redraw, and a repaint arrives as ordinary output, so text already on screen can satisfy a fresh wait. diff --git a/docs/wiki/Cron-Jobs.md b/docs/wiki/Cron-Jobs.md index e45f694f..36d04993 100644 --- a/docs/wiki/Cron-Jobs.md +++ b/docs/wiki/Cron-Jobs.md @@ -51,10 +51,11 @@ or a server restart shifts later runs slightly. That drift is accepted rather th ## Prompts are single line -This is the rule people trip over. Programmatic input into an agent session is single line -everywhere in Codeman, because the terminal UIs these CLIs use treat a newline as submit. A -multi-line prompt would be silently mangled, so it is **rejected** instead: the form refuses -it, and a prompt file whose contents are multi-line fails the run with a clear message. +This is the rule people trip over. A cron job fires with nobody watching, and the check that +re-presses an Enter the agent ignored cannot recognise a multi-line paste in the composer, so +a multi-line prompt could sit there unsent. In `paste` mode the newlines would reach the agent +as keys. So a multi-line prompt is **rejected**: the form refuses it, and a prompt file whose +contents are multi-line fails the run with a clear message. For anything longer than a sentence, put the instructions in a file and make the prompt tell the agent to read it: diff --git a/docs/wiki/Driving-Codeman-From-An-Agent.md b/docs/wiki/Driving-Codeman-From-An-Agent.md index bcf1bc06..3d976294 100644 --- a/docs/wiki/Driving-Codeman-From-An-Agent.md +++ b/docs/wiki/Driving-Codeman-From-An-Agent.md @@ -127,11 +127,12 @@ Read these before writing any code. Each one has cost somebody an afternoon. 1. **Input must end with `\r`.** Enter fires only when the payload contains a carriage return. Without it the text sits unsubmitted on the prompt, the request still succeeds, - and a combined wait burns its full timeout on a turn that never started. Embedded - newlines are kept: the text goes in as one paste and the Enter after it submits all of - it, so `"echo A\necho B\r"` runs both commands in a shell. The check that re-presses a - stranded Enter cannot reliably recognise a multi-line paste, so a long multi-line - prompt to a just-started agent is still safer in a file it is told to read. + and a combined wait burns its full timeout on a turn that never started. With + `useMux`, embedded newlines are kept: the text goes in as one paste and the Enter after + it submits all of it, so `"echo A\necho B\r"` runs both commands in a shell. The check + that re-presses a stranded Enter cannot reliably recognise a multi-line paste, so a + long multi-line prompt to a just-started agent is still safer in a file it is told to + read. 2. **Make input idempotent.** Send a stable `clientId` and a monotonic per-session `seq`. The server deduplicates, so a retry after a dropped connection cannot double-deliver. 3. **Auth.** With `CODEMAN_PASSWORD` set, use HTTP Basic or the session cookie. A missing diff --git a/docs/wiki/Input-And-Voice.md b/docs/wiki/Input-And-Voice.md index 223835c6..928e511f 100644 --- a/docs/wiki/Input-And-Voice.md +++ b/docs/wiki/Input-And-Voice.md @@ -125,9 +125,9 @@ Sending prompts over the API has one rule that catches everyone: **the payload m `\r`** or Enter is never sent. The request still succeeds, the text sits unsubmitted in the composer, and any wait burns its whole timeout on a turn that never started. -Embedded newlines are kept: multi-line input goes in as one paste and the Enter after it -submits all of it, so `"echo A\necho B\r"` runs both commands in a shell. The check that -re-presses a stranded Enter cannot reliably recognise a multi-line paste, so long +With `useMux`, embedded newlines are kept: multi-line input goes in as one paste and the +Enter after it submits all of it, so `"echo A\necho B\r"` runs both commands in a shell. The +check that re-presses a stranded Enter cannot reliably recognise a multi-line paste, so long multi-line content is still safer in a file the agent is told to read. See [Driving Codeman From An Agent](Driving-Codeman-From-An-Agent). diff --git a/plugins/codeman/skills/codeman/reference/endpoints.md b/plugins/codeman/skills/codeman/reference/endpoints.md index 5e8b5d38..89ea8ba5 100644 --- a/plugins/codeman/skills/codeman/reference/endpoints.md +++ b/plugins/codeman/skills/codeman/reference/endpoints.md @@ -420,8 +420,9 @@ callers send no body at all. **or Enter is never sent**: the text is typed onto the worker's prompt and sits there unsubmitted. This is [symptom 1](#1-deliveredtrue-then-every-wait-times-out), the number-one silent failure. -- Newlines in `input` are kept: the text goes in as one bracketed paste and the `\r` - submits all lines. To send a bare Enter (confirm a dialog), send `{"input":"\r"}`. +- With `useMux`, newlines in `input` are kept: the text goes in as one bracketed paste + and the `\r` submits all lines (without `useMux`, multi-line input is written straight + into the pane). To send a bare Enter (confirm a dialog), send `{"input":"\r"}`. - `input` is capped at **65536** characters. ⚠️ **Two caps disagree and the smaller one is the real one**: the Zod schema allows 100000 (`schemas.ts:1035`), so a 65537-to-100000 character body passes validation and *then* 400s at the route against diff --git a/plugins/codeman/skills/codeman/reference/verbs.md b/plugins/codeman/skills/codeman/reference/verbs.md index f85fb631..47ca5cb7 100644 --- a/plugins/codeman/skills/codeman/reference/verbs.md +++ b/plugins/codeman/skills/codeman/reference/verbs.md @@ -303,10 +303,11 @@ duplicate resend costs nothing. Codeman types the text and sends Enter **only when the input contains a carriage return**; without it your command sits unsubmitted on the worker's prompt and everything downstream times out. No response field catches this: `delivered:true` -means "written to the pane", **not** "submitted". Newlines are kept: the text goes in -as one paste, and the `\r` submits all of it. Build the body with `jq -n` for any prompt you did not -author as a literal, because the inline `-d '{"input":"'"$P"'\r"}'` pattern breaks on -the first double quote, backslash or `$` in a real prompt: +means "written to the pane", **not** "submitted". With `useMux`, newlines are kept: the +text goes in as one paste, and the `\r` submits all of it. Build the body with `jq -n` +for any prompt you did not author as a literal, because the inline +`-d '{"input":"'"$P"'\r"}'` pattern breaks on the first double quote, backslash or `$` +in a real prompt: ```bash BODY=$(jq -n --arg p "$PROMPT" '{input:($p+"\r"),useMux:true,clientId:"agent-1",seq:1,wait:true,waitTimeout:60000}') @@ -587,9 +588,9 @@ allowlist in §4. ### 5.9 Big input via the workspace -The composer is a single line capped at 65536 characters with newlines stripped, which -makes it a bad channel for a spec, a diff or a file list. The workspace is the good -one, and for a local or docker case you are on the same filesystem as the worker. +The composer is capped at 65536 characters, and a multi-line paste cannot be confirmed +as submitted, which makes it a bad channel for a spec, a diff or a file list. The +workspace is the good one, and for a local or docker case you are on the same filesystem as the worker. 1. Write `TASK.md` into the worker's workspace with your own file tools. The path is `.data.casePath` from `quick-start`, or the `workingDir` you passed to diff --git a/skills/codeman/reference/endpoints.md b/skills/codeman/reference/endpoints.md index 5e8b5d38..89ea8ba5 100644 --- a/skills/codeman/reference/endpoints.md +++ b/skills/codeman/reference/endpoints.md @@ -420,8 +420,9 @@ callers send no body at all. **or Enter is never sent**: the text is typed onto the worker's prompt and sits there unsubmitted. This is [symptom 1](#1-deliveredtrue-then-every-wait-times-out), the number-one silent failure. -- Newlines in `input` are kept: the text goes in as one bracketed paste and the `\r` - submits all lines. To send a bare Enter (confirm a dialog), send `{"input":"\r"}`. +- With `useMux`, newlines in `input` are kept: the text goes in as one bracketed paste + and the `\r` submits all lines (without `useMux`, multi-line input is written straight + into the pane). To send a bare Enter (confirm a dialog), send `{"input":"\r"}`. - `input` is capped at **65536** characters. ⚠️ **Two caps disagree and the smaller one is the real one**: the Zod schema allows 100000 (`schemas.ts:1035`), so a 65537-to-100000 character body passes validation and *then* 400s at the route against diff --git a/skills/codeman/reference/verbs.md b/skills/codeman/reference/verbs.md index f85fb631..47ca5cb7 100644 --- a/skills/codeman/reference/verbs.md +++ b/skills/codeman/reference/verbs.md @@ -303,10 +303,11 @@ duplicate resend costs nothing. Codeman types the text and sends Enter **only when the input contains a carriage return**; without it your command sits unsubmitted on the worker's prompt and everything downstream times out. No response field catches this: `delivered:true` -means "written to the pane", **not** "submitted". Newlines are kept: the text goes in -as one paste, and the `\r` submits all of it. Build the body with `jq -n` for any prompt you did not -author as a literal, because the inline `-d '{"input":"'"$P"'\r"}'` pattern breaks on -the first double quote, backslash or `$` in a real prompt: +means "written to the pane", **not** "submitted". With `useMux`, newlines are kept: the +text goes in as one paste, and the `\r` submits all of it. Build the body with `jq -n` +for any prompt you did not author as a literal, because the inline +`-d '{"input":"'"$P"'\r"}'` pattern breaks on the first double quote, backslash or `$` +in a real prompt: ```bash BODY=$(jq -n --arg p "$PROMPT" '{input:($p+"\r"),useMux:true,clientId:"agent-1",seq:1,wait:true,waitTimeout:60000}') @@ -587,9 +588,9 @@ allowlist in §4. ### 5.9 Big input via the workspace -The composer is a single line capped at 65536 characters with newlines stripped, which -makes it a bad channel for a spec, a diff or a file list. The workspace is the good -one, and for a local or docker case you are on the same filesystem as the worker. +The composer is capped at 65536 characters, and a multi-line paste cannot be confirmed +as submitted, which makes it a bad channel for a spec, a diff or a file list. The +workspace is the good one, and for a local or docker case you are on the same filesystem as the worker. 1. Write `TASK.md` into the worker's workspace with your own file tools. The path is `.data.casePath` from `quick-start`, or the `workingDir` you passed to diff --git a/src/cron/cron-service.ts b/src/cron/cron-service.ts index 7a57424c..7b0a6599 100644 --- a/src/cron/cron-service.ts +++ b/src/cron/cron-service.ts @@ -97,7 +97,7 @@ const CRON_PSEUDO_FS_TREES: readonly string[] = ['/proc', '/sys', '/dev']; /** Sync blocklist for the create/update workingDir gate (no settings extras). */ const CRON_WORKING_DIR_BLOCKED_TREES: readonly string[] = [...DEFAULT_BLOCKED_TREES, ...CRON_PSEUDO_FS_TREES]; -/** Prompt delivery is single-line only (writeViaMux/Ink constraint). */ +/** Prompt delivery is single-line only: the submit check cannot recognise a multi-line paste. */ const HAS_NEWLINE = /[\r\n]/; /** The three session calls prompt delivery needs, so it can be tested without a PTY. */ diff --git a/src/respawn-controller.ts b/src/respawn-controller.ts index ef5d84f8..41f7f490 100644 --- a/src/respawn-controller.ts +++ b/src/respawn-controller.ts @@ -2829,7 +2829,8 @@ export class RespawnController extends EventEmitter { let updatePrompt = this.config.updatePrompt; if (statusBlock?.recommendation) { - // Append RECOMMENDATION to the update prompt (single-line — writeViaMux breaks on newlines) + // Append RECOMMENDATION to the update prompt (single-line: the submit check cannot + // recognise a multi-line paste in the composer) const rec = statusBlock.recommendation.replace(/\n/g, ' ').substring(0, 200); updatePrompt = `${this.config.updatePrompt} (Claude's last recommendation: ${rec})`; this.logAction('ralph', `Using RECOMMENDATION: ${rec.substring(0, 50)}...`); diff --git a/src/web/schemas.ts b/src/web/schemas.ts index ce577394..0a276d3c 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -1905,7 +1905,7 @@ export const ScheduledRunSchema = z.object({ /** 'HH:MM' 24-hour time. */ const hhmmSchema = z.string().regex(/^([01]?\d|2[0-3]):[0-5]\d$/, 'Time must be HH:MM (24-hour)'); -/** Prompt delivery is single-line only (writeViaMux/Ink constraint) — reject newlines outright. */ +/** Prompt delivery is single-line only (the submit check cannot see a multi-line paste) — reject newlines outright. */ const noNewlines = (v: string) => !/[\r\n]/.test(v); /** Shared field shape for creating/updating a scheduled job. */ From f06635512abfc8238c8c9f5488c0995285232eab Mon Sep 17 00:00:00 2001 From: "Claudia[bot]" <4437565+Claudia[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 04:53:56 +0200 Subject: [PATCH 34/96] fix(respawn): send a multi-line update or kickstart prompt as one line The update and kickstart prompts are textareas in the respawn settings and the schema accepts line breaks, so a saved config can hold several lines. Both steps went out through writeViaMux, which used to strip every `\n` and run the lines together. Since the mux now pastes multi-line text as one block, a multi-line respawn prompt would have gone out as a multi-line paste, and the check that re-presses a stranded Enter cannot recognise that paste in the composer. Respawn runs unattended, the same situation for which cron keeps rejecting multi-line prompts. singleLineRespawnPrompt() joins the lines with a space before the write; a single-line prompt is passed through unchanged. Respawn joins rather than rejects, so a multi-line config that was accepted before is still sent. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- .changeset/multiline-input-paste.md | 2 +- src/respawn-controller.ts | 23 ++++-- test/respawn-single-line-prompt.test.ts | 93 +++++++++++++++++++++++++ 3 files changed, 112 insertions(+), 6 deletions(-) create mode 100644 test/respawn-single-line-prompt.test.ts diff --git a/.changeset/multiline-input-paste.md b/.changeset/multiline-input-paste.md index abe94a80..3d3afaa6 100644 --- a/.changeset/multiline-input-paste.md +++ b/.changeset/multiline-input-paste.md @@ -2,4 +2,4 @@ 'aicodeman': patch --- -A multi-line prompt sent through the mux (`POST /api/sessions/:id/input` with `useMux`, respawn prompts) now keeps its line breaks. `sendInput` used to strip every `\n` before the text reached tmux, so `"line one\nline two\r"` arrived as `line oneline two`. The lines now go in as one bracketed paste, the same paste a single-line prompt already uses, and the Enter after it submits them together; multi-line text without an Enter is pasted too instead of typed. A shell runs each line. The submit check that re-presses a stranded Enter does not yet recognise a multi-line paste in the composer, so `codeman agent send` and the TUI keep refusing or joining multi-line input. +A multi-line prompt sent through the mux (`POST /api/sessions/:id/input` with `useMux`) now keeps its line breaks. `sendInput` used to strip every `\n` before the text reached tmux, so `"line one\nline two\r"` arrived as `line oneline two`. The lines now go in as one bracketed paste, the same paste a single-line prompt already uses, and the Enter after it submits them together; multi-line text without an Enter is pasted too instead of typed. A shell runs each line. The submit check that re-presses a stranded Enter does not yet recognise a multi-line paste in the composer, so the unattended senders stay single-line: cron still rejects multi-line prompts, respawn now joins the lines of a multi-line update or kickstart prompt with a space (they used to run together), and `codeman agent send` and the TUI keep refusing or joining multi-line input. diff --git a/src/respawn-controller.ts b/src/respawn-controller.ts index 41f7f490..33e7c277 100644 --- a/src/respawn-controller.ts +++ b/src/respawn-controller.ts @@ -503,6 +503,19 @@ function formatRemainingSeconds(ms: number): number { return Math.max(0, Math.ceil(ms / 1000)); } +/** + * A respawn prompt as the one line it is sent as: every run of line breaks, with the + * whitespace around it, becomes a single space; a single-line prompt is returned as + * is. The config may hold several lines (the settings fields are textareas), but the + * steps run unattended through `writeViaMux`, and the check that re-presses a stranded + * Enter cannot recognise a multi-line paste in the composer. Cron rejects multi-line + * prompts for the same reason; respawn joins them instead, so a saved multi-line + * config is still sent rather than refused. + */ +export function singleLineRespawnPrompt(prompt: string): string { + return prompt.replace(/\s*[\r\n]+\s*/g, ' '); +} + /** Default configuration values */ const DEFAULT_CONFIG: RespawnConfig = { idleTimeoutMs: 10000, // 10 seconds of no activity after prompt (legacy, still used as fallback) @@ -1621,7 +1634,7 @@ export class RespawnController extends EventEmitter { this.config.interStepDelayMs, async () => { if (this._state === 'stopped') return; - const prompt = this.config.kickstartPrompt!; + const prompt = singleLineRespawnPrompt(this.config.kickstartPrompt!); this.logAction('command', `Sending kickstart: "${prompt.substring(0, 40)}..."`); await this.session.writeViaMux(prompt + '\r'); // \r triggers key.return in Ink/Claude CLI // COD-51: stop() may have run during the await; re-check before reviving the @@ -2826,13 +2839,13 @@ export class RespawnController extends EventEmitter { // Use RALPH_STATUS RECOMMENDATION if available, otherwise fall back to config const statusBlock = this.session.ralphTracker?.lastStatusBlock; - let updatePrompt = this.config.updatePrompt; + let updatePrompt = singleLineRespawnPrompt(this.config.updatePrompt); if (statusBlock?.recommendation) { - // Append RECOMMENDATION to the update prompt (single-line: the submit check cannot - // recognise a multi-line paste in the composer) + // Append RECOMMENDATION to the update prompt, on the same single line + // (see singleLineRespawnPrompt) const rec = statusBlock.recommendation.replace(/\n/g, ' ').substring(0, 200); - updatePrompt = `${this.config.updatePrompt} (Claude's last recommendation: ${rec})`; + updatePrompt = `${updatePrompt} (Claude's last recommendation: ${rec})`; this.logAction('ralph', `Using RECOMMENDATION: ${rec.substring(0, 50)}...`); } diff --git a/test/respawn-single-line-prompt.test.ts b/test/respawn-single-line-prompt.test.ts new file mode 100644 index 00000000..f976e2d5 --- /dev/null +++ b/test/respawn-single-line-prompt.test.ts @@ -0,0 +1,93 @@ +/** + * Respawn prompts go out as ONE line, whatever the config holds. + * + * The update and kickstart prompts are textareas in the settings, so a saved config + * can hold several lines. `TmuxManager.sendInput()` used to strip every `\n`, which + * ran the lines together; it now pastes multi-line text as one block. Respawn runs + * unattended, and the check that re-presses a stranded Enter cannot recognise a + * multi-line paste in the composer, so the controller joins the lines with a space + * before the write instead of leaning on the mux to drop them. + * + * Strategy: drive the step methods directly against a MockSession and read what + * reached `writeViaMux`. No real tmux. + * + * Port: N/A (no server / no real tmux) + */ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('node:child_process', async (orig) => { + const actual = await orig<typeof import('node:child_process')>(); + return { ...actual, exec: vi.fn((_cmd: string, cb?: (e: Error | null, o: string) => void) => cb?.(null, '')) }; +}); + +import { RespawnController, singleLineRespawnPrompt } from '../src/respawn-controller.js'; +import { Session } from '../src/session.js'; +import { MockSession } from './mocks/index.js'; + +describe('singleLineRespawnPrompt', () => { + it('joins lines with one space, folding the whitespace around each break', () => { + expect(singleLineRespawnPrompt('update the docs\n\n then CLAUDE.md\r\nand continue')).toBe( + 'update the docs then CLAUDE.md and continue' + ); + }); + + it('returns a single-line prompt unchanged', () => { + expect(singleLineRespawnPrompt(' keep "quotes" and $vars ')).toBe(' keep "quotes" and $vars '); + }); +}); + +describe('respawn steps send a multi-line prompt as one line', () => { + let controller: RespawnController | null = null; + + afterEach(() => controller?.stop()); + + /** Run one step method and return what it wrote through the mux. */ + async function written( + config: { updatePrompt?: string; kickstartPrompt?: string }, + step: 'sendUpdateDocs' | 'sendKickstart', + recommendation?: string + ): Promise<string[]> { + const session = new MockSession(); + if (recommendation !== undefined) { + (session as unknown as { ralphTracker: unknown }).ralphTracker = { lastStatusBlock: { recommendation } }; + } + controller = new RespawnController(session as unknown as Session, { + interStepDelayMs: 10, + completionConfirmMs: 10, + noOutputTimeoutMs: 500, + aiIdleCheckEnabled: false, + ...config, + }); + (controller as never as { _state: string })._state = 'watching'; + (controller as never as Record<typeof step, () => void>)[step](); + // The step schedules its write on the step-delay timer. + await new Promise((r) => setTimeout(r, 40)); + return session.writeBuffer; + } + + it('update prompt', async () => { + const writes = await written( + { updatePrompt: 'summarise progress\nin CLAUDE.md\n\nthen continue' }, + 'sendUpdateDocs' + ); + expect(writes).toEqual(['summarise progress in CLAUDE.md then continue\r']); + }); + + it('update prompt with a recommendation appended', async () => { + const writes = await written({ updatePrompt: 'summarise\nprogress' }, 'sendUpdateDocs', 'fix the\nlogin test'); + expect(writes).toEqual(["summarise progress (Claude's last recommendation: fix the login test)\r"]); + }); + + it('kickstart prompt', async () => { + const writes = await written( + { kickstartPrompt: 'read @fix_plan.md\r\ncontinue with the next task' }, + 'sendKickstart' + ); + expect(writes).toEqual(['read @fix_plan.md continue with the next task\r']); + }); + + it('a single-line prompt is sent as before', async () => { + const writes = await written({ updatePrompt: 'update all the docs and CLAUDE.md' }, 'sendUpdateDocs'); + expect(writes).toEqual(['update all the docs and CLAUDE.md\r']); + }); +}); From 3392a6929054fa72ac6a34ce2420c2fc4f50ba38 Mon Sep 17 00:00:00 2001 From: "Claudia[bot]" <4437565+Claudia[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 04:53:57 +0200 Subject: [PATCH 35/96] docs: tighten the comments and skill text around multi-line input - sendInput's docstring said all text without `\r` is typed; that now holds for single-line text only. - The skill's big-input section still listed "the newline stripping" among the hazards a workspace file avoids; it is the unconfirmed multi-line paste now. Rewrapped, mirrored in plugins/codeman. - The mobile composer's hand-back comment said the prompt transport strips newlines. The hand-back writes to the terminal prompt, whose text reaches the PTY as typed input, where a newline is a key; the behaviour (a multi-line draft stays parked) is unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- plugins/codeman/skills/codeman/reference/verbs.md | 11 ++++++----- skills/codeman/reference/verbs.md | 11 ++++++----- src/tmux-manager.ts | 7 ++++--- src/web/public/keyboard-accessory.js | 7 ++++--- 4 files changed, 20 insertions(+), 16 deletions(-) diff --git a/plugins/codeman/skills/codeman/reference/verbs.md b/plugins/codeman/skills/codeman/reference/verbs.md index 47ca5cb7..cda70197 100644 --- a/plugins/codeman/skills/codeman/reference/verbs.md +++ b/plugins/codeman/skills/codeman/reference/verbs.md @@ -590,7 +590,8 @@ allowlist in §4. The composer is capped at 65536 characters, and a multi-line paste cannot be confirmed as submitted, which makes it a bad channel for a spec, a diff or a file list. The -workspace is the good one, and for a local or docker case you are on the same filesystem as the worker. +workspace is the good one, and for a local or docker case you are on the same +filesystem as the worker. 1. Write `TASK.md` into the worker's workspace with your own file tools. The path is `.data.casePath` from `quick-start`, or the `workingDir` you passed to @@ -600,10 +601,10 @@ workspace is the good one, and for a local or docker case you are on the same fi 3. Wait on `DONE_<token>` with `wait-output` ([§5.5](#55-markers-for-hook-less-workers)), then read `RESULT.json` back with your own tools. -This sidesteps the byte cap, the newline stripping and the quoting hazards in one -move, and it makes the marker **split by construction**: the token lives in the file, -never in the line you type, so the echo of your own keystrokes cannot match it. The -worker also gets to re-read the task instead of holding it in one echoed line. +This sidesteps the byte cap, the unconfirmed multi-line paste and the quoting hazards +in one move, and it makes the marker **split by construction**: the token lives in the +file, never in the line you type, so the echo of your own keystrokes cannot match it. +The worker also gets to re-read the task instead of holding it in one echoed line. ⚠️ Two places it does not work: a **remote-SSH case** runs on another host whose filesystem you cannot see, and any worker **currently editing** the directory you are diff --git a/skills/codeman/reference/verbs.md b/skills/codeman/reference/verbs.md index 47ca5cb7..cda70197 100644 --- a/skills/codeman/reference/verbs.md +++ b/skills/codeman/reference/verbs.md @@ -590,7 +590,8 @@ allowlist in §4. The composer is capped at 65536 characters, and a multi-line paste cannot be confirmed as submitted, which makes it a bad channel for a spec, a diff or a file list. The -workspace is the good one, and for a local or docker case you are on the same filesystem as the worker. +workspace is the good one, and for a local or docker case you are on the same +filesystem as the worker. 1. Write `TASK.md` into the worker's workspace with your own file tools. The path is `.data.casePath` from `quick-start`, or the `workingDir` you passed to @@ -600,10 +601,10 @@ workspace is the good one, and for a local or docker case you are on the same fi 3. Wait on `DONE_<token>` with `wait-output` ([§5.5](#55-markers-for-hook-less-workers)), then read `RESULT.json` back with your own tools. -This sidesteps the byte cap, the newline stripping and the quoting hazards in one -move, and it makes the marker **split by construction**: the token lives in the file, -never in the line you type, so the echo of your own keystrokes cannot match it. The -worker also gets to re-read the task instead of holding it in one echoed line. +This sidesteps the byte cap, the unconfirmed multi-line paste and the quoting hazards +in one move, and it makes the marker **split by construction**: the token lives in the +file, never in the line you type, so the echo of your own keystrokes cannot match it. +The worker also gets to re-read the task instead of holding it in one echoed line. ⚠️ Two places it does not work: a **remote-SSH case** runs on another host whose filesystem you cannot see, and any worker **currently editing** the directory you are diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index f24af42f..8364cbeb 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -3827,11 +3827,12 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { * - Text followed by `\r` goes in as ONE bracketed paste (buildPasteTextCommands), * so Codex cannot fold the Enter into a paste burst; a single character is typed * with `send-keys -l` instead, so a dialog answer selects (submitsAsKeys) - * - Text without `\r` is typed literally with `send-keys -l` (no key interpretation) + * - Single-line text without `\r` is typed literally with `send-keys -l` (no key + * interpretation) * - A `\n` inside the text is kept (`\r\n` loses only its `\r`), and multi-line text * always goes in as ONE bracketed paste, with or without a following Enter: inside - * the paste markers an agent CLI inserts the line breaks into its composer, where - * typed as keys each one could submit a line on its own + * the paste markers a CLI that enabled bracketed paste receives the line breaks as + * pasted text, where typed as keys each one could submit a line on its own * - `Enter` key is sent as a SEPARATE tmux invocation after a small delay * - Ink (Claude CLI) needs text and Enter split to avoid treating Enter as a newline */ diff --git a/src/web/public/keyboard-accessory.js b/src/web/public/keyboard-accessory.js index d76aa9c1..4db19523 100644 --- a/src/web/public/keyboard-accessory.js +++ b/src/web/public/keyboard-accessory.js @@ -1415,9 +1415,10 @@ const KeyboardAccessoryBar = { overlay.querySelector('.prompt-composer-terminal').addEventListener('click', () => { // Hand the text BACK to the terminal prompt so both surfaces hold the same // prompt, the mirror of the adoption composePrompt() does on open. Only a - // single-line draft can make the trip: the prompt transport is single-line - // and strips embedded newlines, so a multi-line draft would arrive mangled - // and instead stays parked as a draft (the dot on the Compose key shows it). + // single-line draft can make the trip: the terminal prompt is one line and + // its text reaches the PTY as typed input, where an embedded newline is a + // key, so a multi-line draft would arrive mangled and instead stays parked + // as a draft (the dot on the Compose key shows it). const text = textarea.value; if (!text || text.includes('\n')) { close({ focusTerminal: true }); From cf999c1296e723aa978ec67cc3cf357da82ef333 Mon Sep 17 00:00:00 2001 From: "Claudia[bot]" <4437565+Claudia[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 05:06:42 +0200 Subject: [PATCH 36/96] fix(auto-compact): send a multi-line focus prompt as one line Auto-compact writes `/compact <prompt>\r` through writeViaMux when the token count crosses the threshold, unattended. AutoCompactSchema.prompt accepts line breaks, so a prompt set through the API can hold several lines. The mux used to strip them and run the lines together; it now pastes multi-line text as one block, and the check that re-presses a stranded Enter cannot recognise that paste in the composer. That is the case respawn's update and kickstart prompts were already joined for. The join moves out of respawn-controller into a shared toSingleLine() (src/utils/single-line.ts), used by both respawn steps and by auto-compact. Behaviour for respawn is unchanged. The respawn test file becomes test/unattended-single-line-prompt.test.ts and gains an auto-compact block that feeds a real Session a token count over its threshold and reads what reaches writeViaMux. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- .changeset/multiline-input-paste.md | 2 +- src/respawn-controller.ts | 27 +++----- src/session-auto-ops.ts | 7 ++- src/utils/index.ts | 1 + src/utils/single-line.ts | 20 ++++++ ... => unattended-single-line-prompt.test.ts} | 61 +++++++++++++++---- 6 files changed, 87 insertions(+), 31 deletions(-) create mode 100644 src/utils/single-line.ts rename test/{respawn-single-line-prompt.test.ts => unattended-single-line-prompt.test.ts} (53%) diff --git a/.changeset/multiline-input-paste.md b/.changeset/multiline-input-paste.md index 3d3afaa6..8d63d181 100644 --- a/.changeset/multiline-input-paste.md +++ b/.changeset/multiline-input-paste.md @@ -2,4 +2,4 @@ 'aicodeman': patch --- -A multi-line prompt sent through the mux (`POST /api/sessions/:id/input` with `useMux`) now keeps its line breaks. `sendInput` used to strip every `\n` before the text reached tmux, so `"line one\nline two\r"` arrived as `line oneline two`. The lines now go in as one bracketed paste, the same paste a single-line prompt already uses, and the Enter after it submits them together; multi-line text without an Enter is pasted too instead of typed. A shell runs each line. The submit check that re-presses a stranded Enter does not yet recognise a multi-line paste in the composer, so the unattended senders stay single-line: cron still rejects multi-line prompts, respawn now joins the lines of a multi-line update or kickstart prompt with a space (they used to run together), and `codeman agent send` and the TUI keep refusing or joining multi-line input. +A multi-line prompt sent through the mux (`POST /api/sessions/:id/input` with `useMux`) now keeps its line breaks. `sendInput` used to strip every `\n` before the text reached tmux, so `"line one\nline two\r"` arrived as `line oneline two`. The lines now go in as one bracketed paste, the same paste a single-line prompt already uses, and the Enter after it submits them together; multi-line text without an Enter is pasted too instead of typed. A shell runs each line. The submit check that re-presses a stranded Enter does not yet recognise a multi-line paste in the composer, so the unattended senders stay single-line: cron still rejects multi-line prompts, respawn's update and kickstart prompts and the auto-compact focus prompt now have their lines joined with a space (they used to run together), and `codeman agent send` and the TUI keep refusing or joining multi-line input. diff --git a/src/respawn-controller.ts b/src/respawn-controller.ts index 33e7c277..c1b775ee 100644 --- a/src/respawn-controller.ts +++ b/src/respawn-controller.ts @@ -49,7 +49,13 @@ import { Session } from './session.js'; import { AiIdleChecker, type AiCheckState } from './ai-idle-checker.js'; import { AiPlanChecker } from './ai-plan-checker.js'; import type { TeamWatcher } from './team-watcher.js'; -import { BufferAccumulator, ANSI_ESCAPE_PATTERN_SIMPLE, assertNever, CleanupManager } from './utils/index.js'; +import { + BufferAccumulator, + ANSI_ESCAPE_PATTERN_SIMPLE, + assertNever, + CleanupManager, + toSingleLine, +} from './utils/index.js'; import { MAX_RESPAWN_BUFFER_SIZE, TRIM_RESPAWN_BUFFER_TO as RESPAWN_BUFFER_TRIM_SIZE } from './config/buffer-limits.js'; import { isCompletionMessage, @@ -503,19 +509,6 @@ function formatRemainingSeconds(ms: number): number { return Math.max(0, Math.ceil(ms / 1000)); } -/** - * A respawn prompt as the one line it is sent as: every run of line breaks, with the - * whitespace around it, becomes a single space; a single-line prompt is returned as - * is. The config may hold several lines (the settings fields are textareas), but the - * steps run unattended through `writeViaMux`, and the check that re-presses a stranded - * Enter cannot recognise a multi-line paste in the composer. Cron rejects multi-line - * prompts for the same reason; respawn joins them instead, so a saved multi-line - * config is still sent rather than refused. - */ -export function singleLineRespawnPrompt(prompt: string): string { - return prompt.replace(/\s*[\r\n]+\s*/g, ' '); -} - /** Default configuration values */ const DEFAULT_CONFIG: RespawnConfig = { idleTimeoutMs: 10000, // 10 seconds of no activity after prompt (legacy, still used as fallback) @@ -1634,7 +1627,7 @@ export class RespawnController extends EventEmitter { this.config.interStepDelayMs, async () => { if (this._state === 'stopped') return; - const prompt = singleLineRespawnPrompt(this.config.kickstartPrompt!); + const prompt = toSingleLine(this.config.kickstartPrompt!); this.logAction('command', `Sending kickstart: "${prompt.substring(0, 40)}..."`); await this.session.writeViaMux(prompt + '\r'); // \r triggers key.return in Ink/Claude CLI // COD-51: stop() may have run during the await; re-check before reviving the @@ -2839,11 +2832,11 @@ export class RespawnController extends EventEmitter { // Use RALPH_STATUS RECOMMENDATION if available, otherwise fall back to config const statusBlock = this.session.ralphTracker?.lastStatusBlock; - let updatePrompt = singleLineRespawnPrompt(this.config.updatePrompt); + let updatePrompt = toSingleLine(this.config.updatePrompt); if (statusBlock?.recommendation) { // Append RECOMMENDATION to the update prompt, on the same single line - // (see singleLineRespawnPrompt) + // (see toSingleLine) const rec = statusBlock.recommendation.replace(/\n/g, ' ').substring(0, 200); updatePrompt = `${updatePrompt} (Claude's last recommendation: ${rec})`; this.logAction('ralph', `Using RECOMMENDATION: ${rec.substring(0, 50)}...`); diff --git a/src/session-auto-ops.ts b/src/session-auto-ops.ts index 28f061c7..74c3ee47 100644 --- a/src/session-auto-ops.ts +++ b/src/session-auto-ops.ts @@ -20,6 +20,7 @@ import { EventEmitter } from 'node:events'; import { detectUsageLimitPause } from './usage-limit-patterns.js'; +import { toSingleLine } from './utils/single-line.js'; // ============================================================================ // Timing Constants @@ -408,7 +409,11 @@ export class SessionAutoOps extends EventEmitter { ); const action = async () => { - const compactCmd = this._autoCompactPrompt ? `/compact ${this._autoCompactPrompt}\r` : '/compact\r'; + // One line: this fires unattended, and a multi-line focus prompt would go out + // as a multi-line paste (see toSingleLine). + const compactCmd = this._autoCompactPrompt + ? `/compact ${toSingleLine(this._autoCompactPrompt)}\r` + : '/compact\r'; await this.callbacks.writeCommand(compactCmd); this.emit('autoCompact', { tokens: totalTokens, diff --git a/src/utils/index.ts b/src/utils/index.ts index 95fab94e..717cb1dc 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -27,6 +27,7 @@ export { MAX_SESSION_TOKENS } from './token-validation.js'; export { isSafePushEndpoint } from './push-endpoint-validation.js'; export { stringSimilarity, fuzzyPhraseMatch, todoContentHash } from './string-similarity.js'; export { assertNever } from './type-safety.js'; +export { toSingleLine } from './single-line.js'; export { wrapWithNice } from './nice-wrapper.js'; export { resolveLocalShell, loginShellArgs } from './shell-resolver.js'; export { diff --git a/src/utils/single-line.ts b/src/utils/single-line.ts new file mode 100644 index 00000000..21cbc3db --- /dev/null +++ b/src/utils/single-line.ts @@ -0,0 +1,20 @@ +/** + * @fileoverview Collapse a prompt onto the one line an unattended sender writes. + * + * @module utils/single-line + */ + +/** + * A prompt as one line: every run of line breaks, with the whitespace around it, + * becomes a single space; a single-line prompt is returned as is. + * + * For the senders that run unattended through `writeViaMux` (respawn's update and + * kickstart steps, auto-compact). The mux pastes a multi-line prompt as one block, + * and the check that re-presses a stranded Enter cannot recognise a multi-line paste + * in the composer, so nobody would notice a prompt left unsubmitted. Cron rejects + * multi-line prompts for the same reason; these senders join them instead, so a + * saved multi-line setting is still sent rather than refused. + */ +export function toSingleLine(prompt: string): string { + return prompt.replace(/\s*[\r\n]+\s*/g, ' '); +} diff --git a/test/respawn-single-line-prompt.test.ts b/test/unattended-single-line-prompt.test.ts similarity index 53% rename from test/respawn-single-line-prompt.test.ts rename to test/unattended-single-line-prompt.test.ts index f976e2d5..2ea1fb80 100644 --- a/test/respawn-single-line-prompt.test.ts +++ b/test/unattended-single-line-prompt.test.ts @@ -1,15 +1,17 @@ /** - * Respawn prompts go out as ONE line, whatever the config holds. + * The senders that run unattended write a prompt as ONE line, whatever the config holds. * - * The update and kickstart prompts are textareas in the settings, so a saved config - * can hold several lines. `TmuxManager.sendInput()` used to strip every `\n`, which - * ran the lines together; it now pastes multi-line text as one block. Respawn runs + * Respawn's update and kickstart prompts are textareas in the settings, and the + * auto-compact focus prompt's schema accepts line breaks, so a saved config can hold + * several lines. `TmuxManager.sendInput()` used to strip every `\n`, which ran the + * lines together; it now pastes multi-line text as one block. These senders run * unattended, and the check that re-presses a stranded Enter cannot recognise a - * multi-line paste in the composer, so the controller joins the lines with a space - * before the write instead of leaning on the mux to drop them. + * multi-line paste in the composer, so they join the lines with a space before the + * write (`toSingleLine()`) instead of leaning on the mux to drop them. * - * Strategy: drive the step methods directly against a MockSession and read what - * reached `writeViaMux`. No real tmux. + * Strategy: drive the respawn step methods against a MockSession, and feed a real + * Session a token count over its auto-compact threshold, then read what reached + * `writeViaMux`. No real tmux. * * Port: N/A (no server / no real tmux) */ @@ -20,19 +22,20 @@ vi.mock('node:child_process', async (orig) => { return { ...actual, exec: vi.fn((_cmd: string, cb?: (e: Error | null, o: string) => void) => cb?.(null, '')) }; }); -import { RespawnController, singleLineRespawnPrompt } from '../src/respawn-controller.js'; +import { RespawnController } from '../src/respawn-controller.js'; +import { toSingleLine } from '../src/utils/single-line.js'; import { Session } from '../src/session.js'; import { MockSession } from './mocks/index.js'; -describe('singleLineRespawnPrompt', () => { +describe('toSingleLine', () => { it('joins lines with one space, folding the whitespace around each break', () => { - expect(singleLineRespawnPrompt('update the docs\n\n then CLAUDE.md\r\nand continue')).toBe( + expect(toSingleLine('update the docs\n\n then CLAUDE.md\r\nand continue')).toBe( 'update the docs then CLAUDE.md and continue' ); }); it('returns a single-line prompt unchanged', () => { - expect(singleLineRespawnPrompt(' keep "quotes" and $vars ')).toBe(' keep "quotes" and $vars '); + expect(toSingleLine(' keep "quotes" and $vars ')).toBe(' keep "quotes" and $vars '); }); }); @@ -91,3 +94,37 @@ describe('respawn steps send a multi-line prompt as one line', () => { expect(writes).toEqual(['update all the docs and CLAUDE.md\r']); }); }); + +describe('auto-compact sends a multi-line focus prompt as one line', () => { + afterEach(() => vi.useRealTimers()); + + /** + * Feed a Session a status line over the threshold and return what its auto-compact + * wrote through `writeViaMux`: the session's own token parser, the trigger and its + * timers, and the `writeCommand` wiring all run as in production. + */ + async function compactWrites(prompt?: string): Promise<string[]> { + vi.useFakeTimers(); + const session = new Session({ workingDir: '/tmp' }); + const writeViaMux = vi.spyOn(session, 'writeViaMux').mockResolvedValue(true); + session.setAutoCompact(true, 1_000, prompt); + (session as never as { parseTokensFromStatusLine(s: string): void }).parseTokensFromStatusLine('5.0k tokens'); + // The trigger waits AUTO_INITIAL_DELAY_MS (1 s) before it writes. + await vi.advanceTimersByTimeAsync(1_500); + return writeViaMux.mock.calls.map((call) => call[0]); + } + + it('focus prompt with line breaks', async () => { + expect(await compactWrites('keep the open tasks\n\n and the test results\r\nfrom today')).toEqual([ + '/compact keep the open tasks and the test results from today\r', + ]); + }); + + it('a single-line focus prompt is sent as before', async () => { + expect(await compactWrites('keep the open tasks')).toEqual(['/compact keep the open tasks\r']); + }); + + it('no focus prompt sends a bare /compact', async () => { + expect(await compactWrites()).toEqual(['/compact\r']); + }); +}); From 225fdbf50501783f5089658749723cd527c3fc0e Mon Sep 17 00:00:00 2001 From: "Claudia[bot]" <4437565+Claudia[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 05:06:43 +0200 Subject: [PATCH 37/96] docs: align the cron, teammate-pane and TUI comments with the paste - cron resolvePrompt(): give the reason the rest of the cron docs give (the submit check cannot recognise a multi-line paste; in paste mode the newlines reach the CLI as keys) instead of the old one about writeViaMux fusing lines. - sendInputToPane(): it types with send-keys -l and strips line breaks, so it no longer uses "the same approach as sendInput()". - TUI client: newlines become spaces (the docstring said stripped), and the test name no longer cites "multi-line breaks Ink". Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- src/cron/cron-service.ts | 10 +++++----- src/tmux-manager.ts | 4 +++- src/tui/tui-client.ts | 6 +++--- test/tui/tui-client.test.ts | 2 +- 4 files changed, 12 insertions(+), 10 deletions(-) diff --git a/src/cron/cron-service.ts b/src/cron/cron-service.ts index 7b0a6599..b1d33cd6 100644 --- a/src/cron/cron-service.ts +++ b/src/cron/cron-service.ts @@ -536,11 +536,11 @@ export class CronService { } /** - * Resolves the prompt text and enforces the single-line constraint: prompt - * delivery rides writeViaMux/PTY writes where a newline is Enter, so a - * multi-line prompt would be silently corrupted (typed mode fuses lines, - * paste mode submits the first line and dribbles the rest in as separate - * messages). Rather than mangle an unattended agent's instructions, fail the + * Resolves the prompt text and enforces the single-line constraint: a job + * fires unattended, and the submit check that re-presses an ignored Enter + * cannot recognise a multi-line paste in the composer (typed mode), so a + * stranded prompt would go unnoticed; in paste mode the newlines reach the + * CLI as keys. Rather than risk an unattended agent's instructions, fail the * run with a clear error. A prompt FILE may end with trailing newline(s) * (every editor writes one) — those are stripped before the check. */ diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index 8364cbeb..53cd3c89 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -4019,7 +4019,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { /** * Send input to a specific pane within a tmux session. - * Uses the same literal text approach as sendInput() but targets a specific pane. + * Types the text literally with `send-keys -l`, line breaks stripped, then Enter + * when the input carried a `\r`. Unlike sendInput(), it neither pastes nor keeps + * the line breaks of multi-line text. */ sendInputToPane(muxName: string, paneTarget: string, input: string): boolean { if (IS_TEST_MODE) return true; diff --git a/src/tui/tui-client.ts b/src/tui/tui-client.ts index a78bf453..c4f035af 100644 --- a/src/tui/tui-client.ts +++ b/src/tui/tui-client.ts @@ -704,9 +704,9 @@ export class TuiClient { * * Two hard rules from CLAUDE.md, both enforced here so no caller can get them * wrong: the payload must END with `\r` or the server never issues Enter and - * the text sits unsubmitted, and embedded newlines are stripped rather than - * sent (the server would paste them as one block, which its submit check - * cannot reliably recognise in the composer). + * the text sits unsubmitted, and embedded newlines become spaces rather than + * being sent (the server would paste them as one block, which its submit + * check cannot reliably recognise in the composer). * The `clientId`/`seq` pair makes delivery exactly-once, so a retry after a * dropped connection cannot type the prompt twice. */ diff --git a/test/tui/tui-client.test.ts b/test/tui/tui-client.test.ts index 59ab9844..8b78fdb8 100644 --- a/test/tui/tui-client.test.ts +++ b/test/tui/tui-client.test.ts @@ -327,7 +327,7 @@ describe('TuiClient.sendInput', () => { expect(JSON.parse(recorded[0].body)).toMatchObject({ input: 'hello world\r', useMux: true }); }); - it('collapses embedded newlines into spaces (multi-line breaks Ink)', async () => { + it('collapses embedded newlines into spaces (a multi-line paste cannot be confirmed as submitted)', async () => { await client().sendInput('abc', 'echo A\necho B\r\nline three '); expect(JSON.parse(recorded[0].body).input).toBe('echo A echo B line three\r'); }); From b073035bdcac3403c0b9f8a68082f8be9fe8b774 Mon Sep 17 00:00:00 2001 From: "Claudia[bot]" <4437565+Claudia[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 08:01:24 +0200 Subject: [PATCH 38/96] fix(limits): clamp CODEMAN_MAX_SESSIONS to 500 and document it Review follow-up. Other bounds assume a small session count: an owner's tab layout holds at most MAX_TAB_REFS (512) refs and PUT /api/session-order accepts at most 500 ids, so with CODEMAN_MAX_SESSIONS=600 the 513th session creation answered HTTP 500 instead of the capacity message. The value is now clamped to a named MAX_CONCURRENT_SESSIONS_CEILING of 500; any digit string above it (including one past the safe-integer range) resolves to 500 instead of falling back to 50. The resolver reads process.env directly, the unused env parameter is gone. Docs: a CODEMAN_MAX_SESSIONS row in the Settings Reference environment table, the env name next to the cap in the cron guide and the architecture invariants, and both copies of the skill's endpoints reference now give the per-user cap as half the global cap (25 by default). The changeset says "1 to 500". The test drops the `as unknown as string` casts and adds '1000', '501' and an out-of-range value, each refusing at 500. Co-Authored-By: Christian Haberl <116439789+christianhaberl@users.noreply.github.com> Co-Authored-By: Claude <noreply@anthropic.com> --- .changeset/max-sessions-env.md | 2 +- docs/architecture-invariants.md | 2 +- docs/cron-guide.md | 2 +- docs/wiki/Settings-Reference.md | 1 + .../skills/codeman/reference/endpoints.md | 10 +++---- skills/codeman/reference/endpoints.md | 10 +++---- src/config/map-limits.ts | 24 +++++++++++----- src/web/schemas.ts | 3 +- test/config/max-sessions.test.ts | 28 +++++++++++-------- 9 files changed, 49 insertions(+), 33 deletions(-) diff --git a/.changeset/max-sessions-env.md b/.changeset/max-sessions-env.md index ff2516d6..8c28fb8b 100644 --- a/.changeset/max-sessions-env.md +++ b/.changeset/max-sessions-env.md @@ -2,4 +2,4 @@ 'aicodeman': minor --- -The cap of 50 concurrent sessions can now be set with `CODEMAN_MAX_SESSIONS` (a positive integer, read at startup), for a machine with the memory for more sessions or a small one that should run fewer. Without it nothing changes. A value that is not a positive integer falls back to 50, so a typo never blocks session creation. In multi-user mode the per-user cap still defaults to half the global cap, so it follows the new value unless `CODEMAN_MAX_SESSIONS_PER_USER` is set. +The cap of 50 concurrent sessions can now be set with `CODEMAN_MAX_SESSIONS` (1 to 500, read at startup; a larger value is clamped to 500), for a machine with the memory for more sessions or a small one that should run fewer. Without it nothing changes. A value that is not a positive integer falls back to 50, so a typo never blocks session creation. In multi-user mode the per-user cap still defaults to half the global cap, so it follows the new value unless `CODEMAN_MAX_SESSIONS_PER_USER` is set. diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index a50539c8..7db9b5d0 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -1124,7 +1124,7 @@ Tests: `test/mobile-prompt-composer.test.ts` (in the CI gate, deliberately not u ### Buffers, uploads, and terminal history -Target: 20 sessions, 50 agent windows at 60fps. Limits in `src/config/`: terminal 32MB (see below), text 1MB, messages 1000, max agents 500, max sessions 50, max SSE clients 100. **Terminal history** (`src/config/terminal-history.ts`, COD-80): tmux history-limit 100k lines, PTY buffer 32MB max / 24MB trim (env `CODEMAN_MAX_TERMINAL_BUFFER`/`CODEMAN_TRIM_TERMINAL_TO`; the env-derived trim is clamped ≤75% of max — trim ≥ max would disable `BufferAccumulator` trimming entirely = unbounded memory); browser xterm scrollback stays a separate hardcoded 50k (`DEFAULT_SCROLLBACK` in constants.js — 100k/tab is a mobile-memory hazard). tmux <3.7 allocates history at pane creation, so `createSession()` sets the global default in the same command queue immediately before `new-session`; tmux 3.7+ instead creates the session and targets only that pane, because changing the global option can resize and trim unrelated live panes. A settings change resizes tracked panes only on 3.7+ and otherwise affects future panes; no version can recover lines already evicted. Settings keys `terminalScrollbackLines`/`terminalBufferMaxBytes`/`terminalBufferTrimBytes` are schema-validated but inert (only `tmuxHistoryLimit` is wired); `buffer-limits.ts` re-exports the defaults. Text/message limits are env-overridable too (`CODEMAN_MAX_TEXT_OUTPUT`/`CODEMAN_TRIM_TEXT_TO`/`CODEMAN_MAX_MESSAGES`). **Image upload** (`image-input.js` / `config/buffer-limits.ts`): up to `_maxBatchImages` 20 images/batch (bounded concurrency 3), per-file `MAX_PASTE_IMAGE_BYTES` 50MB (env `CODEMAN_MAX_PASTE_IMAGE_BYTES`); the mobile camera-roll picker auto-downscales to fit before upload. A camera-roll VIDEO (`?kind=video` on the same route, set by the client from the file's MIME type) is never buffered: it streams to disk under `MAX_PASTE_VIDEO_BYTES` 1GB (env `CODEMAN_MAX_PASTE_VIDEO_BYTES`), sniffed from the stream's first bytes (ISO BMFF / QuickTime atom, EBML for webm, Ogg for ogv) and unlinked on any failure. The client keys the flag on the MIME type, falling back to the name's extension because some Android file managers hand over a File with none; that classifier (`_promptAttachKind()`, image-input.js) is the ONE filter every pick and paste surface reads: Attach, drop, the Compose and paste-dialog pickers and the clipboard handlers. They reach it through `_admitPromptAttachments()` (same file), which also toasts what a pick skipped: the count of a mixed pick's refused files, `Unsupported file type` when none pass, and nothing for a text paste. A new surface admits through that helper, never by filtering on `_promptAttachKind()` directly, or it silently drops files with no word to the user. ⚠️ The flag is the ONLY way onto that path and admits video types alone (the registry's `VIDEO_ATTACHMENT_EXTENSIONS`, single-sourced so an uploaded video also previews: mp4/webm/mov/m4v/ogv; 3gp/mkv are out), so an image can never ride the larger cap through memory; without it the route is byte-identical to the image-only version. A DOCUMENT (the office formats pdf/docx/pptx/xlsx/zip plus the text family txt/md/csv/tsv/json/log/yaml/yml/xml, the curated `ALLOWED_DOCUMENT_EXTS`, never `SUPPORTED_ATTACHMENT_EXTENSIONS` or `EDITABLE_EXTENSIONS`) is admitted by its name's extension and buffered like an image, judged on its bytes by the same fail-closed `imageMagicMatchesExt` (a magic-number signature, or UTF-8 without a NUL byte for the text family) and saved under a sanitized `[A-Za-z0-9._-]` stem of its name; sh/py/js/html are a deliberate SCOPE exclusion by extension (the server declines to store what `file-raw` would serve or an agent could run), not a content filter, since the agent can already write any of them. `_promptAttachKind()` returns `image`, `video` or `document`, and the server's dispatch mirrors it: an image MIME wins whatever the name says, a document extension takes the document branch, and a foreign extension with no image MIME is refused `Unsupported file type` only after the image sniff fails, so nothing the image path accepted by its bytes is lost. **HEIC paste uploads** (#151): converted server-side to JPEG in a `worker_threads` worker (`web/heic-jpeg-worker.ts`, resourceLimits + 30s timeout) gated by `runWithConversionLimit()`; detection is magic-byte based (covers Android/MIUI HEIFs mislabeled as JPEG); headers declaring > 64MP are rejected 415 BEFORE decode (decompression-bomb guard). Deps: `heic-decode` + `jpeg-js`. Use `LRUMap` for bounded caches, `StaleExpirationMap` for TTL cleanup. Anti-flicker pipeline: `docs/terminal-anti-flicker.md`. +Target: 20 sessions, 50 agent windows at 60fps. Limits in `src/config/`: terminal 32MB (see below), text 1MB, messages 1000, max agents 500, max sessions 50 (env `CODEMAN_MAX_SESSIONS`, 1 to 500), max SSE clients 100. **Terminal history** (`src/config/terminal-history.ts`, COD-80): tmux history-limit 100k lines, PTY buffer 32MB max / 24MB trim (env `CODEMAN_MAX_TERMINAL_BUFFER`/`CODEMAN_TRIM_TERMINAL_TO`; the env-derived trim is clamped ≤75% of max — trim ≥ max would disable `BufferAccumulator` trimming entirely = unbounded memory); browser xterm scrollback stays a separate hardcoded 50k (`DEFAULT_SCROLLBACK` in constants.js — 100k/tab is a mobile-memory hazard). tmux <3.7 allocates history at pane creation, so `createSession()` sets the global default in the same command queue immediately before `new-session`; tmux 3.7+ instead creates the session and targets only that pane, because changing the global option can resize and trim unrelated live panes. A settings change resizes tracked panes only on 3.7+ and otherwise affects future panes; no version can recover lines already evicted. Settings keys `terminalScrollbackLines`/`terminalBufferMaxBytes`/`terminalBufferTrimBytes` are schema-validated but inert (only `tmuxHistoryLimit` is wired); `buffer-limits.ts` re-exports the defaults. Text/message limits are env-overridable too (`CODEMAN_MAX_TEXT_OUTPUT`/`CODEMAN_TRIM_TEXT_TO`/`CODEMAN_MAX_MESSAGES`). **Image upload** (`image-input.js` / `config/buffer-limits.ts`): up to `_maxBatchImages` 20 images/batch (bounded concurrency 3), per-file `MAX_PASTE_IMAGE_BYTES` 50MB (env `CODEMAN_MAX_PASTE_IMAGE_BYTES`); the mobile camera-roll picker auto-downscales to fit before upload. A camera-roll VIDEO (`?kind=video` on the same route, set by the client from the file's MIME type) is never buffered: it streams to disk under `MAX_PASTE_VIDEO_BYTES` 1GB (env `CODEMAN_MAX_PASTE_VIDEO_BYTES`), sniffed from the stream's first bytes (ISO BMFF / QuickTime atom, EBML for webm, Ogg for ogv) and unlinked on any failure. The client keys the flag on the MIME type, falling back to the name's extension because some Android file managers hand over a File with none; that classifier (`_promptAttachKind()`, image-input.js) is the ONE filter every pick and paste surface reads: Attach, drop, the Compose and paste-dialog pickers and the clipboard handlers. They reach it through `_admitPromptAttachments()` (same file), which also toasts what a pick skipped: the count of a mixed pick's refused files, `Unsupported file type` when none pass, and nothing for a text paste. A new surface admits through that helper, never by filtering on `_promptAttachKind()` directly, or it silently drops files with no word to the user. ⚠️ The flag is the ONLY way onto that path and admits video types alone (the registry's `VIDEO_ATTACHMENT_EXTENSIONS`, single-sourced so an uploaded video also previews: mp4/webm/mov/m4v/ogv; 3gp/mkv are out), so an image can never ride the larger cap through memory; without it the route is byte-identical to the image-only version. A DOCUMENT (the office formats pdf/docx/pptx/xlsx/zip plus the text family txt/md/csv/tsv/json/log/yaml/yml/xml, the curated `ALLOWED_DOCUMENT_EXTS`, never `SUPPORTED_ATTACHMENT_EXTENSIONS` or `EDITABLE_EXTENSIONS`) is admitted by its name's extension and buffered like an image, judged on its bytes by the same fail-closed `imageMagicMatchesExt` (a magic-number signature, or UTF-8 without a NUL byte for the text family) and saved under a sanitized `[A-Za-z0-9._-]` stem of its name; sh/py/js/html are a deliberate SCOPE exclusion by extension (the server declines to store what `file-raw` would serve or an agent could run), not a content filter, since the agent can already write any of them. `_promptAttachKind()` returns `image`, `video` or `document`, and the server's dispatch mirrors it: an image MIME wins whatever the name says, a document extension takes the document branch, and a foreign extension with no image MIME is refused `Unsupported file type` only after the image sniff fails, so nothing the image path accepted by its bytes is lost. **HEIC paste uploads** (#151): converted server-side to JPEG in a `worker_threads` worker (`web/heic-jpeg-worker.ts`, resourceLimits + 30s timeout) gated by `runWithConversionLimit()`; detection is magic-byte based (covers Android/MIUI HEIFs mislabeled as JPEG); headers declaring > 64MP are rejected 415 BEFORE decode (decompression-bomb guard). Deps: `heic-decode` + `jpeg-js`. Use `LRUMap` for bounded caches, `StaleExpirationMap` for TTL cleanup. Anti-flicker pipeline: `docs/terminal-anti-flicker.md`. **Prompt uploads live in `<workspace>/.codeman-uploads/`** (`POST /api/sessions/:id/paste-image`; the names live ONLY in `src/web/paste-image-gc.ts`): IN the workspace because that is the only path that resolves identically for a local agent and a container (only the workspace is bind-mounted, at the same absolute path; `~/.codeman` is not); hidden so it stays out of `git status` and the agent's view of the repository; FLAT because a nested `<workspace>/.codeman/` IS the data dir (`join(homedir(), '.codeman')`, `src/config/instance.ts`) when the workspace is the home directory, which nothing refuses; and self-ignoring through a `.gitignore` of `*` written once with `wx`, so a file already there is the user's and stays. The pre-move `.claude-images/` receives nothing but stays readable for one release: `UPLOAD_DIR_NAMES` lists both, the hourly sweep and the delete cleanup in `cleanupSession()` go through `uploadDirs()`, and the image watcher's ignore filter reads the names. ⚠️ `uploadDirs()` is async: the working directory is a user-chosen path, so it is probed BOUNDED first (`probePathKind()`, the #516 rule; `unknown` is skipped and never touched, the hourly sweep keeps the stall cap and `cleanupSession()` passes `pastCap`, since it acts on one path at the user's request), and the lstat and realpath after it are `fs.promises` calls, never sync, because the sweep runs 30 s after boot and hourly over every live session and a linked case on a dead mount would otherwise freeze the whole server. It returns only REAL directories (lstat at the leaf, so a planted `.codeman-uploads -> /other-case/.codeman-uploads` is never listed: `readdir` follows a link to a directory, and the sweep would otherwise age out the other case's uploads through it), none that is or contains `getDataDir()` (contrived, `CODEMAN_INSTANCE=uploads` makes `~/.codeman-uploads` the data dir of a home workspace and `CODEMAN_DATA_DIR` can point inside one, but it is one check next to a recursive delete; one strictly INSIDE the data dir is listed, since it only ever holds uploads and a workspace like the `~/.codeman/app` that `install.sh` clones would otherwise never have its uploads collected), and nothing for a remote (SSH) session, whose `workingDir` is the remote path and would otherwise name a same-named LOCAL directory for the sweep and the delete. The check is made when the directories are listed: a same-user process that swaps a listed directory for a link during the sweep's awaits is accepted (Node has no `openat()`, and that actor already writes anywhere this process can). The route refuses a remote (SSH) session with 400 before any disk touch: its `workingDir` is the remote path, so the file would land on THIS host where the agent cannot read it. ⚠️ And because a live session's workspace can go onto a network mount that later stops answering, the route probes `session.workingDir` BOUNDED (`probePathKind(..., { pastCap: true })`) before the first disk touch, refusing an `unknown` mount through `describeUnknownPath()` (OPERATION_FAILED) exactly as `POST /api/sessions` does, so a mount already unreachable cannot freeze a libuv worker on every upload (`ensureUploadDir()`'s own lstat/mkdir then start on a reachable path; a mount that dies between the probe and that write is the same residual race `POST /api/sessions` accepts, since a pre-check cannot bound the write that follows it). Tests: `test/paste-image-gc.test.ts`, `test/paste-image-dir-shared.test.ts`, the paste-image block of `test/routes/session-routes.test.ts`, `test/routes/session-create-unreachable-path.test.ts`. diff --git a/docs/cron-guide.md b/docs/cron-guide.md index bc08c52d..b5c37fc3 100644 --- a/docs/cron-guide.md +++ b/docs/cron-guide.md @@ -376,7 +376,7 @@ boot. Sessions the jobs create persist through the normal session-recovery path. | Readiness settle | 2000ms | `CRON_READY_SETTLE_MS` | | Run-history cap (global) | 500 | `MAX_CRON_RUN_HISTORY` (`config/map-limits.ts`) | | Saved-jobs cap | 100 | `MAX_CRON_JOBS` (`config/map-limits.ts`) | -| Concurrent-session cap | 50 | `MAX_CONCURRENT_SESSIONS` | +| Concurrent-session cap | 50 | `MAX_CONCURRENT_SESSIONS` (env `CODEMAN_MAX_SESSIONS`) | | Prompt-file size cap | 1 MiB | `MAX_PROMPT_FILE_BYTES` (`cron-service.ts`) | | `name` length | 1–200 | `CronJobSchema` | | `promptText` length | ≤ 100000 | `CronJobSchema` | diff --git a/docs/wiki/Settings-Reference.md b/docs/wiki/Settings-Reference.md index 2e7238ce..52bc7416 100644 --- a/docs/wiki/Settings-Reference.md +++ b/docs/wiki/Settings-Reference.md @@ -221,6 +221,7 @@ Some things are configured before the server starts, not in the UI: | `CODEMAN_FILE_PICKER_ROOTS` | Extra roots for the path picker. | | `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` | Acknowledges exposing the server with no password. | | `CODEMAN_BASE_URL` | Mounts Codeman under a sub-path behind a reverse proxy that forwards the prefix unchanged. See [Remote Access](Remote-Access). | +| `CODEMAN_MAX_SESSIONS` | Cap on concurrent sessions. 50 by default, 1 to 500 (a larger value is clamped to 500, anything that is not a positive integer falls back to 50). Read at startup. In multi-user mode the per-user cap defaults to half of it unless `CODEMAN_MAX_SESSIONS_PER_USER` is set. | | `CODEMAN_MAX_DOWNLOAD_BYTES` | Cap on raw file bodies and downloads. 2 GB by default, `0` for none. | | `CODEMAN_MAX_PASTE_IMAGE_BYTES` | Cap on one pasted, dropped or attached image or document. 50 MB by default. | | `CODEMAN_MAX_PASTE_VIDEO_BYTES` | Cap on one attached video (phone Attach, drop). 1 GB by default; videos stream to disk, so this bounds disk use, not memory. | diff --git a/plugins/codeman/skills/codeman/reference/endpoints.md b/plugins/codeman/skills/codeman/reference/endpoints.md index 2799f542..5324e571 100644 --- a/plugins/codeman/skills/codeman/reference/endpoints.md +++ b/plugins/codeman/skills/codeman/reference/endpoints.md @@ -113,7 +113,7 @@ Every JSON response: `{"success":true,"data":…}` or | `UNAUTHORIZED` | 401 | auth required or failed (send `-u user:password`). ⚠️ The 401 body is plain text, NOT this envelope, see [Auth and credentials](#auth-and-credentials) | | `FORBIDDEN` | 403 | authenticated but not permitted: an admin-only route in multi-user mode, a `workingDir`/case path outside your own workspace, or a shell session without the can-bypass-permissions grant. ⚠️ **Not** what an ownership miss on a session returns: a session you do not own answers 404 `NOT_FOUND`, identically to one that does not exist (deliberate, it leaks no existence) | | `NOT_FOUND` | 404 | no such session, or one this caller does not own. Also quick-start's answer for an unknown remote or docker host | -| `SESSION_BUSY` | 409 | on a **wait**: this session's waiter cap (16, combined signal+output) is full. On **quick-start**: a session cap is full, so clean up before starting more. Two different caps can raise it: the global 50 (`MAX_CONCURRENT_SESSIONS`, env `CODEMAN_MAX_SESSIONS`), and in multi-user mode the per-user cap, which defaults to half of that, **25** (`maxSessionsPerUser()`, `config/multiuser.ts:59-63`). The message tells you which | +| `SESSION_BUSY` | 409 | on a **wait**: this session's waiter cap (16, combined signal+output) is full. On **quick-start**: a session cap is full, so clean up before starting more. Two different caps can raise it: the global cap, 50 by default (`MAX_CONCURRENT_SESSIONS`, env `CODEMAN_MAX_SESSIONS`, 1 to 500), and in multi-user mode the per-user cap, which defaults to half the global cap, **25** by default (`maxSessionsPerUser()`, `config/multiuser.ts:59-63`). The message tells you which | | `CONFLICT` / `ALREADY_EXISTS` | 409 | conflicts with current state | | `OPERATION_FAILED` | 422 | well-formed but could not be completed | | `RATE_LIMITED` | 429 | per-owner or process-wide waiter pool is full; back off, switching sessions will not help | @@ -367,7 +367,7 @@ name is in front" rather than "nothing is installed". `shell` has no CLI to prob is absent, `jq -r` prints the literal string `null`, and every later call then targets `/api/v1/sessions/null`, burning the full readiness budget and reporting jq noise instead of the real cause. The failure codes here are `SESSION_BUSY` (a **session** cap: -the global 50, or the per-user 25 in multi-user mode, never the waiter cap), +the global cap (50 by default), or the per-user cap (25 by default) in multi-user mode, never the waiter cap), `NOT_FOUND` (an unknown remote host or docker host named by the case), `FORBIDDEN`, `CONFLICT`, `OPERATION_FAILED` and `INVALID_INPUT`. None of them are retryable in a loop. @@ -401,7 +401,7 @@ differences that break copied code: `POST /api/v1/sessions/:id/interactive` (claude and the other agent CLIs) or `POST /api/v1/sessions/:id/shell` (shell mode) to actually start the worker. - Its capacity failure is **`OPERATION_FAILED` (422)**, not quick-start's - `SESSION_BUSY` (409), from the same global-50 / per-user-25 caps + `SESSION_BUSY` (409), from the same global / per-user caps (50 / 25 by default) (`sessionCapacityMessage()` in `route-helpers.ts`). ⚠️ `POST .../interactive` accepts `{"clearBreaker":true}`, which resets the **PTY-exit @@ -796,8 +796,8 @@ response echoes. | `match` string | 1–200 characters, literal only | 400; `regex=` is rejected outright | | `from=buffer` scan window | 256 KB tail of the terminal buffer | a marker older than that tail is invisible even with `from=buffer` | | wait-output snippet context | 80 characters either side | `wait.snippet` is bounded, not the whole line | -| sessions, process-wide | 50 (`MAX_CONCURRENT_SESSIONS`, env `CODEMAN_MAX_SESSIONS`) | 409 `SESSION_BUSY` on quick-start | -| sessions, per user | 25 in multi-user mode (half the global cap) | the same 409, with a different message | +| sessions, process-wide | 50 by default (`MAX_CONCURRENT_SESSIONS`, env `CODEMAN_MAX_SESSIONS`, 1 to 500) | 409 `SESSION_BUSY` on quick-start | +| sessions, per user | half the global cap (25 by default) in multi-user mode | the same 409, with a different message | | SSE clients, process-wide | 100 (`MAX_SSE_CLIENTS`) | plain-text `503 Too many SSE connections`; shared with every browser tab | | active bash tools tracked | 20 per session | oldest entries drop off `active-tools` | | auth failures per IP | 10, decaying over 15 min | plain-text 429 with `Retry-After`; locks out the login path, so never loop a bad credential | diff --git a/skills/codeman/reference/endpoints.md b/skills/codeman/reference/endpoints.md index 2799f542..5324e571 100644 --- a/skills/codeman/reference/endpoints.md +++ b/skills/codeman/reference/endpoints.md @@ -113,7 +113,7 @@ Every JSON response: `{"success":true,"data":…}` or | `UNAUTHORIZED` | 401 | auth required or failed (send `-u user:password`). ⚠️ The 401 body is plain text, NOT this envelope, see [Auth and credentials](#auth-and-credentials) | | `FORBIDDEN` | 403 | authenticated but not permitted: an admin-only route in multi-user mode, a `workingDir`/case path outside your own workspace, or a shell session without the can-bypass-permissions grant. ⚠️ **Not** what an ownership miss on a session returns: a session you do not own answers 404 `NOT_FOUND`, identically to one that does not exist (deliberate, it leaks no existence) | | `NOT_FOUND` | 404 | no such session, or one this caller does not own. Also quick-start's answer for an unknown remote or docker host | -| `SESSION_BUSY` | 409 | on a **wait**: this session's waiter cap (16, combined signal+output) is full. On **quick-start**: a session cap is full, so clean up before starting more. Two different caps can raise it: the global 50 (`MAX_CONCURRENT_SESSIONS`, env `CODEMAN_MAX_SESSIONS`), and in multi-user mode the per-user cap, which defaults to half of that, **25** (`maxSessionsPerUser()`, `config/multiuser.ts:59-63`). The message tells you which | +| `SESSION_BUSY` | 409 | on a **wait**: this session's waiter cap (16, combined signal+output) is full. On **quick-start**: a session cap is full, so clean up before starting more. Two different caps can raise it: the global cap, 50 by default (`MAX_CONCURRENT_SESSIONS`, env `CODEMAN_MAX_SESSIONS`, 1 to 500), and in multi-user mode the per-user cap, which defaults to half the global cap, **25** by default (`maxSessionsPerUser()`, `config/multiuser.ts:59-63`). The message tells you which | | `CONFLICT` / `ALREADY_EXISTS` | 409 | conflicts with current state | | `OPERATION_FAILED` | 422 | well-formed but could not be completed | | `RATE_LIMITED` | 429 | per-owner or process-wide waiter pool is full; back off, switching sessions will not help | @@ -367,7 +367,7 @@ name is in front" rather than "nothing is installed". `shell` has no CLI to prob is absent, `jq -r` prints the literal string `null`, and every later call then targets `/api/v1/sessions/null`, burning the full readiness budget and reporting jq noise instead of the real cause. The failure codes here are `SESSION_BUSY` (a **session** cap: -the global 50, or the per-user 25 in multi-user mode, never the waiter cap), +the global cap (50 by default), or the per-user cap (25 by default) in multi-user mode, never the waiter cap), `NOT_FOUND` (an unknown remote host or docker host named by the case), `FORBIDDEN`, `CONFLICT`, `OPERATION_FAILED` and `INVALID_INPUT`. None of them are retryable in a loop. @@ -401,7 +401,7 @@ differences that break copied code: `POST /api/v1/sessions/:id/interactive` (claude and the other agent CLIs) or `POST /api/v1/sessions/:id/shell` (shell mode) to actually start the worker. - Its capacity failure is **`OPERATION_FAILED` (422)**, not quick-start's - `SESSION_BUSY` (409), from the same global-50 / per-user-25 caps + `SESSION_BUSY` (409), from the same global / per-user caps (50 / 25 by default) (`sessionCapacityMessage()` in `route-helpers.ts`). ⚠️ `POST .../interactive` accepts `{"clearBreaker":true}`, which resets the **PTY-exit @@ -796,8 +796,8 @@ response echoes. | `match` string | 1–200 characters, literal only | 400; `regex=` is rejected outright | | `from=buffer` scan window | 256 KB tail of the terminal buffer | a marker older than that tail is invisible even with `from=buffer` | | wait-output snippet context | 80 characters either side | `wait.snippet` is bounded, not the whole line | -| sessions, process-wide | 50 (`MAX_CONCURRENT_SESSIONS`, env `CODEMAN_MAX_SESSIONS`) | 409 `SESSION_BUSY` on quick-start | -| sessions, per user | 25 in multi-user mode (half the global cap) | the same 409, with a different message | +| sessions, process-wide | 50 by default (`MAX_CONCURRENT_SESSIONS`, env `CODEMAN_MAX_SESSIONS`, 1 to 500) | 409 `SESSION_BUSY` on quick-start | +| sessions, per user | half the global cap (25 by default) in multi-user mode | the same 409, with a different message | | SSE clients, process-wide | 100 (`MAX_SSE_CLIENTS`) | plain-text `503 Too many SSE connections`; shared with every browser tab | | active bash tools tracked | 20 per session | oldest entries drop off `active-tools` | | auth failures per IP | 10, decaying over 15 min | plain-text 429 with `Retry-After`; locks out the login path, so never loop a bad credential | diff --git a/src/config/map-limits.ts b/src/config/map-limits.ts index 0d27a2f6..3007060f 100644 --- a/src/config/map-limits.ts +++ b/src/config/map-limits.ts @@ -22,21 +22,31 @@ const DEFAULT_MAX_CONCURRENT_SESSIONS = 50; /** - * Resolve the session cap from `CODEMAN_MAX_SESSIONS` (a positive integer). - * Anything else (unset, empty, 0, negative, fractional, not a number) falls back - * to {@link DEFAULT_MAX_CONCURRENT_SESSIONS}, so a typo never locks session creation. + * Highest value `CODEMAN_MAX_SESSIONS` can raise the cap to. Other bounds assume + * the session count stays small: an owner's tab layout holds at most + * `MAX_TAB_REFS` (512) refs and `PUT /api/session-order` accepts at most 500 ids, + * so a larger cap would let session creation fail with a 500 instead of the + * capacity message. */ -function resolveMaxConcurrentSessions(env: NodeJS.ProcessEnv = process.env): number { - const raw = env.CODEMAN_MAX_SESSIONS?.trim(); +export const MAX_CONCURRENT_SESSIONS_CEILING = 500; + +/** + * Resolve the session cap from `CODEMAN_MAX_SESSIONS` (a positive integer, + * clamped to {@link MAX_CONCURRENT_SESSIONS_CEILING}). Anything else (unset, + * empty, 0, negative, fractional, not a number) falls back to + * {@link DEFAULT_MAX_CONCURRENT_SESSIONS}, so a typo never locks session creation. + */ +function resolveMaxConcurrentSessions(): number { + const raw = process.env.CODEMAN_MAX_SESSIONS?.trim(); if (!raw || !/^\d+$/.test(raw)) return DEFAULT_MAX_CONCURRENT_SESSIONS; const n = Number(raw); - return Number.isSafeInteger(n) && n >= 1 ? n : DEFAULT_MAX_CONCURRENT_SESSIONS; + return n >= 1 ? Math.min(n, MAX_CONCURRENT_SESSIONS_CEILING) : DEFAULT_MAX_CONCURRENT_SESSIONS; } /** * Maximum concurrent sessions allowed. * Each session consumes significant resources (PTY, buffers, watchers). - * 50 unless `CODEMAN_MAX_SESSIONS` says otherwise; read once at startup. + * 50 unless `CODEMAN_MAX_SESSIONS` says otherwise (1 to 500); read once at startup. */ export const MAX_CONCURRENT_SESSIONS = resolveMaxConcurrentSessions(); diff --git a/src/web/schemas.ts b/src/web/schemas.ts index 196e14c3..7d7b5630 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -1981,7 +1981,8 @@ export const CaseOrderSchema = z.object({ /** PUT /api/session-order — global tab order (ordered sessionIds), COD-131 */ export const SessionOrderUpdateSchema = z.object({ // Bounded defensively: ids are uuid-ish (<=100 chars) and the client pushes only - // its open-tab order (max sessions is 50 by default) — 500 leaves ample headroom while + // its open-tab order (max sessions is 50 by default, at most 500 via CODEMAN_MAX_SESSIONS, + // see MAX_CONCURRENT_SESSIONS_CEILING) — 500 leaves enough room while // keeping a hostile/buggy client from persisting megabytes into state.json. order: z.array(z.string().max(100)).max(500), }); diff --git a/test/config/max-sessions.test.ts b/test/config/max-sessions.test.ts index 58f46e26..5d689ddd 100644 --- a/test/config/max-sessions.test.ts +++ b/test/config/max-sessions.test.ts @@ -18,10 +18,10 @@ function sessionsOf(n: number, owner?: string): ReadonlyMap<string, never> { /** Fresh module graph, so map-limits re-reads the environment at import. */ async function loadWithEnv(env: Record<string, string | undefined>): Promise<Helpers> { vi.resetModules(); - vi.stubEnv('CODEMAN_MAX_SESSIONS', undefined as unknown as string); - vi.stubEnv('CODEMAN_MAX_SESSIONS_PER_USER', undefined as unknown as string); - vi.stubEnv('CODEMAN_MULTIUSER', undefined as unknown as string); - for (const [key, value] of Object.entries(env)) vi.stubEnv(key, value as string); + vi.stubEnv('CODEMAN_MAX_SESSIONS', undefined); + vi.stubEnv('CODEMAN_MAX_SESSIONS_PER_USER', undefined); + vi.stubEnv('CODEMAN_MULTIUSER', undefined); + for (const [key, value] of Object.entries(env)) vi.stubEnv(key, value); return import('../../src/web/route-helpers.js'); } @@ -52,14 +52,18 @@ describe('global session cap (CODEMAN_MAX_SESSIONS)', () => { expect(sessionCapacityState(sessionsOf(3)).atGlobalCap).toBe(true); }); - it.each(['', '0', '-5', '2.5', 'abc', '1e3', '99999999999999999999'])( - 'falls back to 50 for an unusable value %j', - async (value) => { - const { sessionCapacityState } = await loadWithEnv({ CODEMAN_MAX_SESSIONS: value }); - expect(sessionCapacityState(sessionsOf(49)).atGlobalCap).toBe(false); - expect(sessionCapacityState(sessionsOf(50)).atGlobalCap).toBe(true); - } - ); + it.each(['1000', '501', '99999999999999999999'])('clamps %j to the ceiling of 500', async (value) => { + const { sessionCapacityState, sessionCapacityMessage } = await loadWithEnv({ CODEMAN_MAX_SESSIONS: value }); + expect(sessionCapacityState(sessionsOf(499)).atGlobalCap).toBe(false); + expect(sessionCapacityState(sessionsOf(500)).atGlobalCap).toBe(true); + expect(sessionCapacityMessage(sessionsOf(500))).toContain('(500)'); + }); + + it.each(['', '0', '-5', '2.5', 'abc', '1e3'])('falls back to 50 for an unusable value %j', async (value) => { + const { sessionCapacityState } = await loadWithEnv({ CODEMAN_MAX_SESSIONS: value }); + expect(sessionCapacityState(sessionsOf(49)).atGlobalCap).toBe(false); + expect(sessionCapacityState(sessionsOf(50)).atGlobalCap).toBe(true); + }); it('derives the multi-user per-user cap (half the global cap) from it', async () => { const { sessionCapacityState, sessionCapacityMessage } = await loadWithEnv({ From ced88b331008d6514cc4fa7a2de3afabc4f4c057 Mon Sep 17 00:00:00 2001 From: "Claudia[bot]" <4437565+Claudia[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 08:02:05 +0200 Subject: [PATCH 39/96] fix(mux): treat the launch directory as no answer for a discovered pane Every pane starts in TMUX_LAUNCH_CWD (/tmp) and reaches its workspace with a cd inside the pane command, but a remote or docker pane never does that cd, so tmux reports /tmp for it. A rediscovered record carries no remote/docker metadata and looks local, so adopting that directory would root the session at /tmp: hooks written into /tmp/.claude, the Files panel at /tmp, and a respawn running claude in a directory any local account can write to. reconcileSessions() now skips a pane whose directory is the launch directory, in both spellings a pane can report it: the literal one and its realpath (/private/tmp on macOS, where a process cwd comes back resolved). The set is computed once at module load. Such a session falls back to process.cwd(), as before this series. Also stop trimming the list-panes output: the directory is the last field, so a trim cut a trailing space off the last row's path; parsePaneRows() already skips empty lines. And the comments that list what a discovered record knows for certain now include the directory, when tmux reported a usable one. Co-Authored-By: Christian Haberl <116439789+christianhaberl@users.noreply.github.com> Co-Authored-By: Claude <noreply@anthropic.com> --- src/mux-interface.ts | 3 ++- src/tmux-manager.ts | 35 +++++++++++++++++++++++---- test/tmux-manager.test.ts | 50 ++++++++++++++++++++++++++++++++++++++- 3 files changed, 82 insertions(+), 6 deletions(-) diff --git a/src/mux-interface.ts b/src/mux-interface.ts index c1df2492..702cd900 100644 --- a/src/mux-interface.ts +++ b/src/mux-interface.ts @@ -63,7 +63,8 @@ export interface MuxSession { statusLine?: StatusLineExporter; /** * This record was rebuilt from the tmux socket rather than from Codeman's own - * bookkeeping, so everything on it but the name and the pid is a guess. Its + * bookkeeping, so everything on it but the name, the pid and, when tmux + * reported a usable one, the directory is a guess. Its * synthetic `restored-<fragment>` id cannot find the session's `state.json` * entry either, which means a remote or docker session rediscovered this way * arrives with no `remote`/`docker` metadata and looks local. Anything that diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index a52cf7de..92af82b8 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -27,7 +27,7 @@ import { execSync, exec, execFile } from 'node:child_process'; import { promisify } from 'node:util'; const execAsync = promisify(exec); -import { existsSync, readFileSync, mkdirSync } from 'node:fs'; +import { existsSync, readFileSync, mkdirSync, realpathSync } from 'node:fs'; import { writeFile, rename } from 'node:fs/promises'; import { dirname } from 'node:path'; import { homedir } from 'node:os'; @@ -183,6 +183,22 @@ const DEFAULT_REMOTE_RECONNECT_INTERVAL_MS = 5000; /** Stable cwd for tmux server/pane launch; actual session cwd is reached inside the pane. */ const TMUX_LAUNCH_CWD = '/tmp'; +/** + * TMUX_LAUNCH_CWD in every spelling a pane can report it. tmux reads a pane's + * directory from its process, which comes back resolved (`/private/tmp` on + * macOS, where `/tmp` is a symlink), so the literal and the resolved spelling + * both count. Computed once. + */ +const TMUX_LAUNCH_CWD_SPELLINGS: ReadonlySet<string> = (() => { + const spellings = new Set([TMUX_LAUNCH_CWD]); + try { + spellings.add(realpathSync(TMUX_LAUNCH_CWD)); + } catch { + // Unresolvable launch directory: the literal spelling is all there is. + } + return spellings; +})(); + /** Claude Code native macOS recommendation for avoiding low nofile startup failures. */ export const CLAUDE_CODE_NOFILE_LIMIT = 2147483646; @@ -3128,14 +3144,24 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { const output = execSync(`${this.tmux()} list-panes -a -F '${PANE_LIST_FORMAT}' 2>/dev/null || true`, { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS, - }).trim(); + }); + // Not trimmed: the directory is the last field, so trimming would cut a + // trailing space off the last row's path. parsePaneRows skips empty lines. const rows = parsePaneRows(output); active = new Map(rows.map((row) => [row.sessionName, row.pid])); // A discovered session's directory, from its first pane with a usable path. // The path is admitted only if it passes the same check createSession and // respawnPane apply, since it was set by whoever owns the pane, not by us. + // The launch directory counts as no answer: every pane starts there, and a + // remote or docker pane never cds out of it, so for such a session it is + // not the workspace — and a rediscovered record cannot tell it is one. for (const row of rows) { - if (row.currentPath && !paneDirs.has(row.sessionName) && isValidPath(row.currentPath)) { + if ( + row.currentPath && + !TMUX_LAUNCH_CWD_SPELLINGS.has(row.currentPath) && + !paneDirs.has(row.sessionName) && + isValidPath(row.currentPath) + ) { paneDirs.set(row.sessionName, row.currentPath); } } @@ -3197,7 +3223,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { mode: 'claude', attached: false, name: `Restored: ${sessionName}`, - // Every field above except the name and the pid is a guess: this record + // Every field above except the name, the pid and, when tmux reported a + // usable one, the directory is a guess: this record // was rebuilt from the socket because Codeman's own bookkeeping did not // have it. The synthetic id also cannot find the session's state.json // entry, so a remote or docker session rediscovered this way arrives diff --git a/test/tmux-manager.test.ts b/test/tmux-manager.test.ts index 08d47a73..39fe2344 100644 --- a/test/tmux-manager.test.ts +++ b/test/tmux-manager.test.ts @@ -52,11 +52,24 @@ vi.mock('node:child_process', async () => { }; }); +// Paths realpathSync should resolve differently than this host does, e.g. a +// macOS `/tmp` -> `/private/tmp`. Hoisted so it survives vi.resetModules(). +const realpathOverrides = vi.hoisted(() => new Map<string, string>()); + // Mock fs to avoid file I/O vi.mock('node:fs', async () => { - const actual = await vi.importActual('node:fs'); + const actual = await vi.importActual<typeof import('node:fs')>('node:fs'); + const realpathSync = Object.assign( + vi.fn((path: string, ...rest: unknown[]) => + realpathOverrides.has(String(path)) + ? realpathOverrides.get(String(path)) + : (actual.realpathSync as (...args: unknown[]) => string)(path, ...rest) + ), + { native: actual.realpathSync.native } + ); return { ...actual, + realpathSync, existsSync: vi.fn().mockReturnValue(false), readFileSync: vi.fn(), mkdirSync: vi.fn(), @@ -916,6 +929,10 @@ describe('TmuxManager (unit)', () => { 'codeman-cccc3333|104|0|||/home/user/$(touch pwned)', // A tmux that reported no path. 'codeman-dddd4444|105|0|||', + // The launch directory every pane starts in; a remote or docker pane never leaves it. + 'codeman-eeee5555|106|0|||/tmp', + // Last row, path ending in a space: the output must not be trimmed. + 'codeman-ffff6666|107|0|||/home/user/trailing ', ].join('\n'); } return ''; @@ -930,6 +947,8 @@ describe('TmuxManager (unit)', () => { 'restored-bbbb2222', 'restored-cccc3333', 'restored-dddd4444', + 'restored-eeee5555', + 'restored-ffff6666', ]); const dirs = Object.fromEntries(nonTestManager.getSessions().map((s) => [s.muxName, s.workingDir])); expect(dirs).toEqual({ @@ -937,6 +956,8 @@ describe('TmuxManager (unit)', () => { 'codeman-bbbb2222': '/home/user/project-b', 'codeman-cccc3333': process.cwd(), 'codeman-dddd4444': process.cwd(), + 'codeman-eeee5555': process.cwd(), + 'codeman-ffff6666': '/home/user/trailing ', }); // The directory comes from the same list-panes call, as its last field. @@ -949,6 +970,33 @@ describe('TmuxManager (unit)', () => { nonTestManager.destroy(); } }); + + it('treats the resolved spelling of the launch directory as no answer too (macOS /private/tmp)', async () => { + // On macOS /tmp is a symlink and a pane reports its process cwd resolved. + realpathOverrides.set('/tmp', '/private/tmp'); + mockedExecSync.mockImplementation((cmd: string) => { + if (typeof cmd === 'string' && cmd.includes(' list-panes ')) { + return ['codeman-aaaa1111|101|0|||/private/tmp', 'codeman-bbbb2222|102|0|||/tmp'].join('\n'); + } + return ''; + }); + try { + const NonTestTmuxManager = await importWithTmuxCommandsEnabled(); + const nonTestManager = new NonTestTmuxManager(); + try { + await nonTestManager.reconcileSessions(); + const dirs = Object.fromEntries(nonTestManager.getSessions().map((s) => [s.muxName, s.workingDir])); + expect(dirs).toEqual({ + 'codeman-aaaa1111': process.cwd(), + 'codeman-bbbb2222': process.cwd(), + }); + } finally { + nonTestManager.destroy(); + } + } finally { + realpathOverrides.clear(); + } + }); }); }); From 3b747774f1039128a68dafb820760816260dde3e Mon Sep 17 00:00:00 2001 From: "Claudia[bot]" <4437565+Claudia[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 08:03:53 +0200 Subject: [PATCH 40/96] fix(single-line): join lines by split and trim instead of a backtracking regex /\s*[\r\n]+\s*/g backtracks over every start position of a long whitespace run, so 50,000 spaces took over a second. Split on line breaks, trim each line, drop blank ones and join with a space: linear, and a leading or trailing blank line no longer leaves a space behind. A prompt without a line break is still returned unchanged. session-auto-ops now imports toSingleLine from the utils barrel like respawn-controller. Co-Authored-By: Christian Haberl <116439789+christianhaberl@users.noreply.github.com> Co-Authored-By: Claude <noreply@anthropic.com> --- src/session-auto-ops.ts | 2 +- src/utils/single-line.ts | 23 +++++++++++++++------- test/unattended-single-line-prompt.test.ts | 22 ++++++++++++++++++--- 3 files changed, 36 insertions(+), 11 deletions(-) diff --git a/src/session-auto-ops.ts b/src/session-auto-ops.ts index 74c3ee47..d2454c65 100644 --- a/src/session-auto-ops.ts +++ b/src/session-auto-ops.ts @@ -20,7 +20,7 @@ import { EventEmitter } from 'node:events'; import { detectUsageLimitPause } from './usage-limit-patterns.js'; -import { toSingleLine } from './utils/single-line.js'; +import { toSingleLine } from './utils/index.js'; // ============================================================================ // Timing Constants diff --git a/src/utils/single-line.ts b/src/utils/single-line.ts index 21cbc3db..e29b0bd5 100644 --- a/src/utils/single-line.ts +++ b/src/utils/single-line.ts @@ -5,16 +5,25 @@ */ /** - * A prompt as one line: every run of line breaks, with the whitespace around it, - * becomes a single space; a single-line prompt is returned as is. + * A prompt as one line: each line is trimmed, blank lines are dropped, and the rest + * are joined with a single space; a prompt without a line break is returned as is. * * For the senders that run unattended through `writeViaMux` (respawn's update and * kickstart steps, auto-compact). The mux pastes a multi-line prompt as one block, - * and the check that re-presses a stranded Enter cannot recognise a multi-line paste - * in the composer, so nobody would notice a prompt left unsubmitted. Cron rejects - * multi-line prompts for the same reason; these senders join them instead, so a - * saved multi-line setting is still sent rather than refused. + * and the check that re-presses a stranded Enter cannot reliably recognise a + * multi-line paste in the composer, so nobody would notice a prompt left unsubmitted. + * Cron rejects multi-line prompts for the same reason; these senders join them + * instead, so a saved multi-line setting is still sent rather than refused. + * + * Split and trim, which is linear, rather than one regex replace that folds the + * whitespace around each break: that regex backtracks quadratically over a long + * whitespace run (50,000 spaces took over a second). */ export function toSingleLine(prompt: string): string { - return prompt.replace(/\s*[\r\n]+\s*/g, ' '); + if (!/[\r\n]/.test(prompt)) return prompt; + return prompt + .split(/[\r\n]+/) + .map((line) => line.trim()) + .filter(Boolean) + .join(' '); } diff --git a/test/unattended-single-line-prompt.test.ts b/test/unattended-single-line-prompt.test.ts index 2ea1fb80..aa834174 100644 --- a/test/unattended-single-line-prompt.test.ts +++ b/test/unattended-single-line-prompt.test.ts @@ -5,9 +5,9 @@ * auto-compact focus prompt's schema accepts line breaks, so a saved config can hold * several lines. `TmuxManager.sendInput()` used to strip every `\n`, which ran the * lines together; it now pastes multi-line text as one block. These senders run - * unattended, and the check that re-presses a stranded Enter cannot recognise a - * multi-line paste in the composer, so they join the lines with a space before the - * write (`toSingleLine()`) instead of leaning on the mux to drop them. + * unattended, and the check that re-presses a stranded Enter cannot reliably + * recognise a multi-line paste in the composer, so they join the lines with a space + * before the write (`toSingleLine()`) instead of leaning on the mux to drop them. * * Strategy: drive the respawn step methods against a MockSession, and feed a real * Session a token count over its auto-compact threshold, then read what reached @@ -37,6 +37,22 @@ describe('toSingleLine', () => { it('returns a single-line prompt unchanged', () => { expect(toSingleLine(' keep "quotes" and $vars ')).toBe(' keep "quotes" and $vars '); }); + + it('drops leading and trailing blank lines instead of leaving a space there', () => { + expect(toSingleLine('\n compact the log \n\n')).toBe('compact the log'); + }); + + it('stays linear on a long whitespace run', () => { + // A regex that folds the whitespace around each break backtracks over every + // start position in the run: 50,000 spaces took well over a second that way. + // The split takes well under a millisecond, so the bound leaves room either side. + const run = ' '.repeat(50_000); + const started = performance.now(); + const joined = toSingleLine(`a${run}b\nc${run}`); + const elapsed = performance.now() - started; + expect(joined).toBe(`a${run}b c`); + expect(elapsed).toBeLessThan(250); + }); }); describe('respawn steps send a multi-line prompt as one line', () => { From cf9511cb57d1e1838a180487f35ce43d2a057662 Mon Sep 17 00:00:00 2001 From: "Claudia[bot]" <4437565+Claudia[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 08:03:54 +0200 Subject: [PATCH 41/96] fix(tmux): drop leading line breaks from sendInput text "\nfix it\r" pasted an empty first line. Leading line breaks are now dropped like trailing ones. Document what useMux does with a line feed, and that a pane without bracketed paste receives each pasted line break as CR, so a multi-line write without \r runs every line but the last there, where the old line-stripping typed the joined text and ran nothing. Co-Authored-By: Christian Haberl <116439789+christianhaberl@users.noreply.github.com> Co-Authored-By: Claude <noreply@anthropic.com> --- CLAUDE.md | 2 +- docs/api-reference.md | 4 ++++ src/tmux-manager.ts | 9 +++++++-- test/tmux-submit-paste.test.ts | 15 +++++++++++++++ 4 files changed, 27 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index afb393c7..735f03e5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -88,7 +88,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph ## Common Gotchas -- **A prompt is one paste plus Enter** — `writeViaMux()` sends the text as one bracketed paste and Enter separately. ⚠️ **Input must END with `\r` or Enter is never sent**: `sendInput()` only issues `send-keys Enter` when the payload contains a carriage return, a `\r`-less `POST /api/sessions/:id/input` still succeeds (send-and-wait even reports `delivered:true`) while the text sits unsubmitted on the composer, and any `wait` burns its whole timeout on a turn that never started. Embedded newlines are kept (`\r\n` becomes `\n`): all lines go in as that ONE paste and the Enter after it submits them together, so `"echo A\necho B\r"` runs both commands in a shell (bash holds the pasted lines until the Enter; a shell without bracketed paste runs each line as it arrives). ⚠️ `SubmitVerifier` does not recognise a multi-line paste in the composer (it shows as a placeholder such as `[Pasted text #N +k lines]`, or as its first line only), so it may not re-press a stranded Enter for one. ⚠️ **Claude Code 2.1.277+ ignores Enter for the first 30-50 s after the composer paints** while still taking the typed text (measured 2026-09-19: an Enter at 28 s stranded the prompt, one at 51 s submitted it), so text+`\r` sent at readiness sits unsent with `0 tokens` and a `wait` burns its timeout. So the SERVER verifies every programmatic write that carried a `\r`: `SubmitVerifier` (`session-submit-verifier.ts`, armed from `writeViaMux`) reads the pane on a 2 s to 60 s schedule and re-sends Enter only while the LAST composer line (the CLI's own `promptGlyph`) verifiably still holds the head of what was sent; an empty composer, other text, or no composer line at all (a shell, a direct-PTY session) ends it, and a newer write replaces the schedule. The skill's `sendwait` keeps its own copy of the loop (`_composer_text` in `skills/codeman/preamble.sh`) for servers that predate this. The `shift+tab` footer only means the composer painted, never that Enter is accepted. ⚠️ **A prompt must never be written into the pane as ONE burst**: Claude Code 2.1.283 takes a `<text>\r` burst of ~100+ chars as a paste, its `\r` lands as a NEWLINE and the prompt strands (a later raw `\r` does not recover it, a tmux `send-keys Enter` does). So `POST .../input` routes a plain prompt (`isPlainPromptInput()`, route-helpers.ts: printable text + exactly one trailing `\r`) through `writeViaMux` even without `useMux`, AWAITED so the browser's serialized POST fallback keeps frame order; raw frames and an explicit `useMux:false` keep the direct write +- **A prompt is one paste plus Enter** — `writeViaMux()` sends the text as one bracketed paste and Enter separately. ⚠️ **Input must END with `\r` or Enter is never sent**: `sendInput()` only issues `send-keys Enter` when the payload contains a carriage return, a `\r`-less `POST /api/sessions/:id/input` still succeeds (send-and-wait even reports `delivered:true`) while the text sits unsubmitted on the composer, and any `wait` burns its whole timeout on a turn that never started. Embedded newlines are kept (`\r\n` becomes `\n`): all lines go in as that ONE paste and the Enter after it submits them together, so `"echo A\necho B\r"` runs both commands in a shell (bash holds the pasted lines until the Enter; a shell without bracketed paste runs each line as it arrives). Multi-line text WITHOUT `\r` is pasted the same way; on a pane that has not enabled bracketed paste (POSIX sh, macOS `/bin/bash` 3.2) tmux delivers every line break as a CR, so every line but the last runs, where the old line-stripping typed the joined text and ran nothing. Leading and trailing line breaks are dropped. ⚠️ `SubmitVerifier` cannot reliably recognise a multi-line paste in the composer: it matches one Claude shows inline whose first line has 24+ non-space characters, but misses a short first line and the `[Pasted text #N +k lines]` placeholder, so it may not re-press a stranded Enter for one. ⚠️ **Claude Code 2.1.277+ ignores Enter for the first 30-50 s after the composer paints** while still taking the typed text (measured 2026-09-19: an Enter at 28 s stranded the prompt, one at 51 s submitted it), so text+`\r` sent at readiness sits unsent with `0 tokens` and a `wait` burns its timeout. So the SERVER verifies every programmatic write that carried a `\r`: `SubmitVerifier` (`session-submit-verifier.ts`, armed from `writeViaMux`) reads the pane on a 2 s to 60 s schedule and re-sends Enter only while the LAST composer line (the CLI's own `promptGlyph`) verifiably still holds the head of what was sent; an empty composer, other text, or no composer line at all (a shell, a direct-PTY session) ends it, and a newer write replaces the schedule. The skill's `sendwait` keeps its own copy of the loop (`_composer_text` in `skills/codeman/preamble.sh`) for servers that predate this. The `shift+tab` footer only means the composer painted, never that Enter is accepted. ⚠️ **A prompt must never be written into the pane as ONE burst**: Claude Code 2.1.283 takes a `<text>\r` burst of ~100+ chars as a paste, its `\r` lands as a NEWLINE and the prompt strands (a later raw `\r` does not recover it, a tmux `send-keys Enter` does). So `POST .../input` routes a plain prompt (`isPlainPromptInput()`, route-helpers.ts: printable text + exactly one trailing `\r`) through `writeViaMux` even without `useMux`, AWAITED so the browser's serialized POST fallback keeps frame order; raw frames and an explicit `useMux:false` keep the direct write - **ESM only** — Never `require()`, use `await import()`. `tsx` masks CJS/ESM issues in dev but production breaks - **Package ≠ product name** — npm: `aicodeman`, product: **Codeman**. Release renames tags accordingly. Both `aicodeman` and `codeman` bin aliases are installed (`package.json` `bin`) - **Global regex `lastIndex`** — Shared `g`-flag patterns in loops must reset `lastIndex = 0` first, or use the `execPattern()` helper in `utils/regex-patterns.ts` (resets automatically) diff --git a/docs/api-reference.md b/docs/api-reference.md index c0350a66..1bd86888 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -434,6 +434,10 @@ about a hundred characters or more is taken as a paste by Claude Code, its `\r` becomes a newline, and the prompt stays unsent (measured on 2.1.283). Any other input (escape sequences, a bracketed-paste frame, a line feed, a bare `\r`) keeps the raw write, and an explicit `"useMux": false` forces it. +With `"useMux": true`, a line feed is kept: the text goes in as one bracketed paste +(`\r\n` read as `\n`, leading and trailing line breaks dropped), then Enter if the +input carried a `\r`; a pane that has not enabled bracketed paste receives each line +break as a carriage return and runs the lines as they arrive. ```bash curl -s -X POST "$API/api/v1/sessions/$SID/input" \ diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index 53cd3c89..9e2fd296 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -3863,7 +3863,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { try { const hasCarriageReturn = input.includes('\r'); - const textPart = input.replace(/\r/g, '').trimEnd(); + // Leading and trailing line breaks are dropped, so `"\nfix it\r"` does not paste + // an empty first line (nor `"fix it\n\r"` an empty last one). + const textPart = input.replace(/\r/g, '').replace(/^\n+/, '').trimEnd(); if (textPart && hasCarriageReturn) { // Send text first, then Enter as a SEPARATE tmux command after a short delay. @@ -3885,7 +3887,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { await run(`${this.tmux()} send-keys -t "${session.muxName}" Enter`); } else if (textPart.includes('\n')) { // Multi-line text, no Enter: pasted, because typed as keys every `\n` would - // reach the pane as a key the CLI may read as submit. + // reach the pane as a key the CLI may read as submit. A pane that has not + // enabled bracketed paste (POSIX sh, macOS /bin/bash 3.2) still gets every line + // break as a CR, so there every line but the last runs; before line breaks were + // kept, the joined text was typed and nothing ran. await pasteText(run, buildPasteTextCommands(this.tmux(), session.muxName, textPart)); } else if (textPart) { // Text only, no Enter diff --git a/test/tmux-submit-paste.test.ts b/test/tmux-submit-paste.test.ts index 7104fdc7..e76cf820 100644 --- a/test/tmux-submit-paste.test.ts +++ b/test/tmux-submit-paste.test.ts @@ -252,6 +252,21 @@ describe('sendInput line breaks', () => { expect(bufferText(manager.ran[0])).toBe('one\ntwo'); }); + it('drops leading line breaks instead of pasting an empty first line', async () => { + const single = managerWithSession(); + await single.sendInput('s1', '\nfix it\r'); + expect(bufferText(single.ran[0])).toBe('fix it'); + + const multi = managerWithSession(); + await multi.sendInput('s1', '\r\n\r\none\ntwo\r'); + expect(bufferText(multi.ran[0])).toBe('one\ntwo'); + + // Without an Enter, what is left is one line, so it is typed like any other. + const typed = managerWithSession(); + await typed.sendInput('s1', '\n\njust type'); + expect(typed.ran).toEqual([expect.stringMatching(/ send-keys -t "codeman-abc12345" -l 'just type'$/)]); + }); + it('leaves single-line input as it was: pasted with Enter, typed without', async () => { const submitted = managerWithSession(); await submitted.sendInput('s1', 'read your inbox\r'); From 74dcf9a59dee8fdbb2b41af642deddf7dbb71086 Mon Sep 17 00:00:00 2001 From: "Claudia[bot]" <4437565+Claudia[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 08:03:55 +0200 Subject: [PATCH 42/96] docs: the submit check cannot reliably recognise a multi-line paste It does match one Claude shows inline when the first line has 24+ non-space characters; it misses a short first line and the [Pasted text #N +k lines] placeholder. Say "cannot reliably" instead of "cannot". Co-Authored-By: Christian Haberl <116439789+christianhaberl@users.noreply.github.com> Co-Authored-By: Claude <noreply@anthropic.com> --- docs/cron-guide.md | 6 +++--- docs/wiki/Cron-Jobs.md | 6 +++--- src/cron/cron-service.ts | 4 ++-- src/web/schemas.ts | 2 +- 4 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/cron-guide.md b/docs/cron-guide.md index 3ea21f62..e8961fdd 100644 --- a/docs/cron-guide.md +++ b/docs/cron-guide.md @@ -208,9 +208,9 @@ trailing carriage return: | `paste` | `session.write()` — writes directly to the PTY/mux | Bulk paste-style delivery. | > ⚠️ **Single-line only — enforced.** A job fires unattended, and the submit -> check that re-presses an ignored Enter cannot recognise a multi-line paste in -> the composer, so a stranded prompt would go unnoticed (in paste mode the -> newlines reach the CLI as keys). So newlines are **rejected**: the +> check that re-presses an ignored Enter cannot reliably recognise a multi-line +> paste in the composer, so a stranded prompt would go unnoticed (in paste mode +> the newlines reach the CLI as keys). So newlines are **rejected**: the > schema and the form refuse a multi-line `promptText`, and at fire time a > prompt file whose content is multi-line (after stripping trailing newlines) > fails the run with a clear `errorMessage`. Put multi-line instructions in a diff --git a/docs/wiki/Cron-Jobs.md b/docs/wiki/Cron-Jobs.md index 36d04993..a3d49c1d 100644 --- a/docs/wiki/Cron-Jobs.md +++ b/docs/wiki/Cron-Jobs.md @@ -52,9 +52,9 @@ or a server restart shifts later runs slightly. That drift is accepted rather th ## Prompts are single line This is the rule people trip over. A cron job fires with nobody watching, and the check that -re-presses an Enter the agent ignored cannot recognise a multi-line paste in the composer, so -a multi-line prompt could sit there unsent. In `paste` mode the newlines would reach the agent -as keys. So a multi-line prompt is **rejected**: the form refuses it, and a prompt file whose +re-presses an Enter the agent ignored cannot reliably recognise a multi-line paste in the +composer, so a multi-line prompt could sit there unsent. In `paste` mode the newlines would +reach the agent as keys. So a multi-line prompt is **rejected**: the form refuses it, and a prompt file whose contents are multi-line fails the run with a clear message. For anything longer than a sentence, put the instructions in a file and make the prompt tell diff --git a/src/cron/cron-service.ts b/src/cron/cron-service.ts index b1d33cd6..d2b6f753 100644 --- a/src/cron/cron-service.ts +++ b/src/cron/cron-service.ts @@ -97,7 +97,7 @@ const CRON_PSEUDO_FS_TREES: readonly string[] = ['/proc', '/sys', '/dev']; /** Sync blocklist for the create/update workingDir gate (no settings extras). */ const CRON_WORKING_DIR_BLOCKED_TREES: readonly string[] = [...DEFAULT_BLOCKED_TREES, ...CRON_PSEUDO_FS_TREES]; -/** Prompt delivery is single-line only: the submit check cannot recognise a multi-line paste. */ +/** Prompt delivery is single-line only: the submit check cannot reliably recognise a multi-line paste. */ const HAS_NEWLINE = /[\r\n]/; /** The three session calls prompt delivery needs, so it can be tested without a PTY. */ @@ -538,7 +538,7 @@ export class CronService { /** * Resolves the prompt text and enforces the single-line constraint: a job * fires unattended, and the submit check that re-presses an ignored Enter - * cannot recognise a multi-line paste in the composer (typed mode), so a + * cannot reliably recognise a multi-line paste in the composer (typed mode), so a * stranded prompt would go unnoticed; in paste mode the newlines reach the * CLI as keys. Rather than risk an unattended agent's instructions, fail the * run with a clear error. A prompt FILE may end with trailing newline(s) diff --git a/src/web/schemas.ts b/src/web/schemas.ts index 0a276d3c..2f3fcc86 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -1905,7 +1905,7 @@ export const ScheduledRunSchema = z.object({ /** 'HH:MM' 24-hour time. */ const hhmmSchema = z.string().regex(/^([01]?\d|2[0-3]):[0-5]\d$/, 'Time must be HH:MM (24-hour)'); -/** Prompt delivery is single-line only (the submit check cannot see a multi-line paste) — reject newlines outright. */ +/** Prompt delivery is single-line only (the submit check cannot reliably recognise a multi-line paste) — reject newlines outright. */ const noNewlines = (v: string) => !/[\r\n]/.test(v); /** Shared field shape for creating/updating a scheduled job. */ From 00d89ff82864180d618ae568e3599109845babd9 Mon Sep 17 00:00:00 2001 From: "Claudia[bot]" <4437565+Claudia[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 08:09:59 +0200 Subject: [PATCH 43/96] test(single-line): make the linearity check fail on time, not on the result The input ended on a whitespace run, so the old regex returned a different string and the test failed on the result before it reached the time bound. Keep the run inside a line ('a<run>b\nc'): the old regex and the split now return the same 'a<run>b c', and only the elapsed time tells them apart (about 1.8 s against well under a millisecond). Co-Authored-By: Christian Haberl <116439789+christianhaberl@users.noreply.github.com> Co-Authored-By: Claude <noreply@anthropic.com> --- test/unattended-single-line-prompt.test.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/test/unattended-single-line-prompt.test.ts b/test/unattended-single-line-prompt.test.ts index aa834174..a23fe0a1 100644 --- a/test/unattended-single-line-prompt.test.ts +++ b/test/unattended-single-line-prompt.test.ts @@ -43,12 +43,13 @@ describe('toSingleLine', () => { }); it('stays linear on a long whitespace run', () => { - // A regex that folds the whitespace around each break backtracks over every - // start position in the run: 50,000 spaces took well over a second that way. - // The split takes well under a millisecond, so the bound leaves room either side. + // The run sits inside a line, so the result is the same either way; only the time + // differs. A regex that folds the whitespace around each break retries the run from + // every start position: 50,000 spaces took over a second that way. The split takes + // well under a millisecond, so the bound leaves room either side. const run = ' '.repeat(50_000); const started = performance.now(); - const joined = toSingleLine(`a${run}b\nc${run}`); + const joined = toSingleLine(`a${run}b\nc`); const elapsed = performance.now() - started; expect(joined).toBe(`a${run}b c`); expect(elapsed).toBeLessThan(250); From 157752b4f03d43e9d23d8dc9b17f1b29c61b584c Mon Sep 17 00:00:00 2001 From: "Claudia[bot]" <4437565+Claudia[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 08:10:00 +0200 Subject: [PATCH 44/96] docs(cron): rewrap the single-line prompt paragraph Co-Authored-By: Christian Haberl <116439789+christianhaberl@users.noreply.github.com> Co-Authored-By: Claude <noreply@anthropic.com> --- docs/wiki/Cron-Jobs.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/wiki/Cron-Jobs.md b/docs/wiki/Cron-Jobs.md index a3d49c1d..e26cbd01 100644 --- a/docs/wiki/Cron-Jobs.md +++ b/docs/wiki/Cron-Jobs.md @@ -54,8 +54,8 @@ or a server restart shifts later runs slightly. That drift is accepted rather th This is the rule people trip over. A cron job fires with nobody watching, and the check that re-presses an Enter the agent ignored cannot reliably recognise a multi-line paste in the composer, so a multi-line prompt could sit there unsent. In `paste` mode the newlines would -reach the agent as keys. So a multi-line prompt is **rejected**: the form refuses it, and a prompt file whose -contents are multi-line fails the run with a clear message. +reach the agent as keys. So a multi-line prompt is **rejected**: the form refuses it, and a +prompt file whose contents are multi-line fails the run with a clear message. For anything longer than a sentence, put the instructions in a file and make the prompt tell the agent to read it: From 2b9aa055cd39a432e3e0306c13a7f7c02ca51def Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Wed, 21 Oct 2026 08:34:14 +0200 Subject: [PATCH 45/96] fix(webview): relay the request Content-Length so length-only dashboards get the body The proxy hands undici the request body as a stream and dropped the browser's content-length, assuming undici recomputes it. For a stream it cannot: undici frames it as Transfer-Encoding: chunked. A dashboard that reads only Content-Length (Python's http.server, many small Go/C servers) then saw an empty body on every POST through a web tab. Found on a WoL dashboard whose Shut down posted {"confirm":true} and was refused for a missing flag on every click through Codeman, while curl straight at it worked. buildUpstreamRequestHeaders now puts the browser's content-length back when a body goes upstream (relayBodyLength), it is a plain number, and no transfer-encoding contradicts it. GET/HEAD never carry it. undici 6.28 frames the stream with that length and fails cleanly on a mismatch. Side effect: a DELETE/OPTIONS with content-length 0 now goes upstream with neither header nor body instead of an empty chunked body. test/webview-proxy-body-framing.test.ts pins it end to end against a Content-Length-only upstream; it fails on the old code with "expected 'chunked' to be null". Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- docs/architecture-invariants.md | 4 +- src/web/routes/webview-routes.ts | 1 + src/web/webview-proxy.ts | 33 +++++- test/webview-proxy-body-framing.test.ts | 141 ++++++++++++++++++++++++ test/webview-proxy.test.ts | 32 +++++- 5 files changed, 201 insertions(+), 10 deletions(-) create mode 100644 test/webview-proxy-body-framing.test.ts diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index a50539c8..2cca18e9 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -541,9 +541,9 @@ Further detail on the Docker Compose supervisor (`docker-compose`), see also `do 1. **Root-absolute URLs built at runtime.** `<base href>` only governs URLs the HTML parser resolves; `fetch('/api/data')` bypasses it and lands on Codeman's root. That is how most dashboards talk to their own backend. The `Referer`-keyed 404 fallback is only a rescue (it fires after every real route missed, and only when the browser sends a usable `Referer`), so the fix is `runtimeUrlShim()`: a small script injected right after `<base>` that rebases root-absolute and same-origin-absolute URLs into the prefix. It removes the whole class inside the iframe instead of trading security for it. ⚠️ It must be injected even when the page ships its OWN `<base>` (an early return there silently breaks exactly the pages that need it most). ⚠️ **The DOM sinks are as load-bearing as `fetch`.** Patching only `fetch`/`XHR`/`WebSocket`/`EventSource` leaves `container.innerHTML = '<img src="/api/hero?slug=x">'` and `img.src = '/api/slide'` untouched, and neither of the other layers can reach those either (`<base>` never applies to root-absolute URLs, and `rewriteHtml()` only ever sees the INITIAL document, never markup built later by page script). The symptom is precise and easy to misdiagnose as an upstream fault: the dashboard's **data** loads while every **image** stays broken. So the shim also wraps `innerHTML`/`outerHTML`/`insertAdjacentHTML`, `setAttribute`/`setAttributeNS`, and the `src`/`srcset`/`href`/`poster`/`data`/`action` property setters, with a `MutationObserver` as a last net for sinks not patched above. Every rewrite routes through the same idempotent `rw()`, which matters because unlike the server-side rewrite this one sees markup that may ALREADY be proxied (a page re-injecting its own `outerHTML` would otherwise double-prefix). The DOM half is pinned in jsdom by `test/webview-proxy.test.ts`; `curl` cannot see any of it. 2. **CORS on same-host requests.** An opaque-origin document treats EVERY request as cross-origin, including to the very host it was served from, so its `fetch`/XHR are CORS-checked and its preflights carry `Origin: null`. Static subresources (script/css/img) are NOT CORS-checked, which is why the page renders while its API calls die with an opaque `net::ERR_FAILED`. `buildProxyCorsHeaders()` echoes the origin (omitting `allow-credentials` for `null`, which browsers reject in combination), upstream `access-control-*` headers are dropped (they describe the dashboard's origin, not the frame's), and the proxy answers preflights itself rather than relaying them. ⚠️ `registerSecurityHeaders` answers EVERY `OPTIONS` with a bare 204 before routing, and its CORS block only emits headers for localhost origins, so that short-circuit **must** exempt a valid webview capability or every preflight fails. `curl` cannot reproduce any of this because curl does not enforce CORS. -**Rewrites, each load-bearing** (pure + unit-tested in `src/web/webview-proxy.ts`): drop `x-frame-options` and the CSP `frame-ancestors` directive (the point of the proxy); drop `content-encoding`/`content-length` because undici's `fetch` already decoded the body (forwarding them makes the browser gunzip plaintext); rewrite `Location` for same-origin redirects only, handing CROSS-origin redirects back unchanged so this never becomes an open relay; rebase `Set-Cookie` `Path` onto the prefix and drop `Domain`; inject `<base href>` and rebase root-absolute `src`/`href`/`action`. `resolveUpstreamUrl()` returns null on anything escaping the upstream origin. +**Rewrites, each load-bearing** (pure + unit-tested in `src/web/webview-proxy.ts`): drop `x-frame-options` and the CSP `frame-ancestors` directive (the point of the proxy); drop the response's `content-encoding`/`content-length` because undici's `fetch` already decoded the body (forwarding them makes the browser gunzip plaintext); relay the REQUEST's `content-length` whenever a body goes upstream (`relayBodyLength`, no `transfer-encoding` present), see Fastify specifics; rewrite `Location` for same-origin redirects only, handing CROSS-origin redirects back unchanged so this never becomes an open relay; rebase `Set-Cookie` `Path` onto the prefix and drop `Domain`; inject `<base href>` and rebase root-absolute `src`/`href`/`action`. `resolveUpstreamUrl()` returns null on anything escaping the upstream origin. -**Fastify specifics.** The proxy lives in an **encapsulated plugin scope** with `removeAllContentTypeParsers()` + a `'*'` pass-through parser, so raw bodies relay byte-for-byte while the root instance keeps its JSON parsing (and keeps `text/plain` RAW, which was a real CSRF hole once). One `app.route({ method:'GET', handler, wsHandler })` serves both HTTP and the WebSocket upgrade; **HEAD must NOT be declared** on the sibling route because `exposeHeadRoutes` already derives it and the duplicate is a startup error. ⚠️ **Every exit path in the proxy handler RETURNS `reply.send(...)`**: the handler is `async`, and a bare `return` after `reply.send(stream)` resolves the handler promise to `undefined` before the stream is consumed, so Fastify answers with an EMPTY body. HTML survives that (synchronous string payload) while every streamed asset comes back zero-length, which is a genuinely confusing failure. The root-absolute fallback hangs off `setNotFoundHandler`, so real Codeman routes always win. +**Fastify specifics.** The proxy lives in an **encapsulated plugin scope** with `removeAllContentTypeParsers()` + a `'*'` pass-through parser, so raw bodies relay byte-for-byte while the root instance keeps its JSON parsing (and keeps `text/plain` RAW, which was a real CSRF hole once). ⚠️ **Bytes, and the FRAMING too**: undici sends a stream body with no known length as `Transfer-Encoding: chunked`, and a server that reads only `Content-Length` (Python's `http.server`, many small Go/C ones) then sees an EMPTY body. That broke every POST to such a dashboard until 2026-10-21 (a WoL page's Shut down lost its `{"confirm":true}` and was refused on every click, while curl straight at it worked), so `buildUpstreamRequestHeaders` puts the browser's `content-length` back, pinned end to end by `test/webview-proxy-body-framing.test.ts` against a length-only upstream. One `app.route({ method:'GET', handler, wsHandler })` serves both HTTP and the WebSocket upgrade; **HEAD must NOT be declared** on the sibling route because `exposeHeadRoutes` already derives it and the duplicate is a startup error. ⚠️ **Every exit path in the proxy handler RETURNS `reply.send(...)`**: the handler is `async`, and a bare `return` after `reply.send(stream)` resolves the handler promise to `undefined` before the stream is consumed, so Fastify answers with an EMPTY body. HTML survives that (synchronous string payload) while every streamed asset comes back zero-length, which is a genuinely confusing failure. The root-absolute fallback hangs off `setNotFoundHandler`, so real Codeman routes always win. **Frontend** (`src/web/public/webview-tabs.js`, load order 12.5): web tabs render into `#sessionTabs` carrying `data-webview-id` instead of `data-id`, so every session-tab path (drag-and-drop, alerts, badges) skips them. ⚠️ `_renderSessionTabsImmediate()`'s `canIncremental` check must include the web-tab comparison: the session-only comparison is vacuously "unchanged" whenever session count is stable, most visibly at ZERO sessions (`0 === 0`), where opening a dashboard would never draw its tab. ⚠️ `isActive` for a session tab is `id === activeSessionId && !activeWebviewId`: `activeSessionId` stays set while a web tab is showing (the terminal keeps streaming underneath), so without that clause the debounced re-render lights two tabs at once. Frames stay MOUNTED while hidden (LRU-evicted past `maxLiveFrames`) so tab switching never reloads a dashboard. diff --git a/src/web/routes/webview-routes.ts b/src/web/routes/webview-routes.ts index 99dc8420..579aada5 100644 --- a/src/web/routes/webview-routes.ts +++ b/src/web/routes/webview-routes.ts @@ -473,6 +473,7 @@ async function proxyRequest( sessionCookieName: AUTH_COOKIE_NAME, refererPath: typeof req.headers.referer === 'string' ? stripProxyPrefix(req.headers.referer, cap, basePath) : undefined, + relayBodyLength: hasBody && req.body !== undefined, }); // #237: the timeout bounds TIME-TO-HEADERS only. A plain AbortSignal.timeout on diff --git a/src/web/webview-proxy.ts b/src/web/webview-proxy.ts index 1b707306..bc05feed 100644 --- a/src/web/webview-proxy.ts +++ b/src/web/webview-proxy.ts @@ -21,9 +21,13 @@ * * - `x-frame-options` / CSP `frame-ancestors`: dropped, else the browser refuses * to render the frame. This is the whole point of the proxy. - * - `content-encoding` / `content-length`: dropped, because undici's `fetch` - * already decoded the body. Forwarding them makes the browser try to gunzip - * plaintext. + * - `content-encoding` / `content-length`: dropped from responses, because + * undici's `fetch` already decoded the body. Forwarding them makes the browser + * try to gunzip plaintext. + * - Request `content-length`: RELAYED with a body. The body reaches undici as a + * stream, which it sends chunked unless told the length, and a server that + * reads only Content-Length (Python's `http.server`, many small Go/C ones) + * then sees an EMPTY body: every POST to such a dashboard arrived blank. * - `authorization` + the `codeman_session` cookie: stripped on the way OUT. In * trusted mode the iframe is same-origin, so the browser attaches Codeman's own * Basic-auth header and session cookie to every proxied request. Forwarding @@ -57,8 +61,10 @@ const HOP_BY_HOP = new Set([ /** * Request headers dropped on the way to the upstream. `authorization` and `cookie` - * carry Codeman's own credentials on a same-origin (trusted) frame; `host`, - * `content-length` and `accept-encoding` are recomputed by undici. + * carry Codeman's own credentials on a same-origin (trusted) frame; `host` and + * `accept-encoding` are recomputed by undici. `content-length` is NOT recomputed + * for a streamed body; it is listed here only so the copy loop never relays it + * blindly, and `buildUpstreamRequestHeaders` puts it back when a body goes with it. */ const DROP_REQUEST_HEADERS = new Set([ ...HOP_BY_HOP, @@ -305,7 +311,7 @@ export function filterCookieHeader(cookie: string | undefined, drop: string[]): export function buildUpstreamRequestHeaders( incoming: Record<string, string | string[] | undefined>, upstream: URL, - opts: { forwardCookies: boolean; sessionCookieName: string; refererPath?: string } + opts: { forwardCookies: boolean; sessionCookieName: string; refererPath?: string; relayBodyLength?: boolean } ): Record<string, string> { const headers: Record<string, string> = {}; @@ -316,6 +322,21 @@ export function buildUpstreamRequestHeaders( headers[lower] = Array.isArray(value) ? value.join(', ') : value; } + // The body is relayed as a stream, and undici frames a stream it has no length + // for as `Transfer-Encoding: chunked`. Hand it the browser's own length so the + // upstream sees the framing the browser sent. Only alongside a body (a length + // with none would leave the upstream waiting for bytes that never come), and + // never against a transfer-encoding, which would make the length a lie. + const length = incoming['content-length']; + if ( + opts.relayBodyLength && + typeof length === 'string' && + /^\d+$/.test(length) && + incoming['transfer-encoding'] === undefined + ) { + headers['content-length'] = length; + } + if (opts.forwardCookies) { const raw = incoming['cookie']; const cookie = filterCookieHeader(Array.isArray(raw) ? raw.join('; ') : raw, [opts.sessionCookieName]); diff --git a/test/webview-proxy-body-framing.test.ts b/test/webview-proxy-body-framing.test.ts new file mode 100644 index 00000000..d821cb80 --- /dev/null +++ b/test/webview-proxy-body-framing.test.ts @@ -0,0 +1,141 @@ +/** + * @fileoverview The webview proxy relays a request body with the browser's own + * Content-Length, not as `Transfer-Encoding: chunked`. + * + * The proxy hands undici the body as a stream, and undici frames a stream with + * no known length as chunked. Servers that read only Content-Length (Python's + * `http.server`, many small Go/C ones) then see an EMPTY body: a WoL dashboard's + * "Shut down" posted `{"confirm":true}` and was refused for missing the flag on + * every click through the web tab, while curl straight at it worked. + * + * Uses app.inject() against the real registerWebviewRoutes with a real upstream + * that parses requests the way such a server does: headers, then exactly + * Content-Length bytes, and nothing else. Port: ephemeral (server.listen(0)). + */ + +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import Fastify, { type FastifyInstance } from 'fastify'; +import { createServer, type Server } from 'node:net'; + +import { registerWebviewRoutes } from '../src/web/routes/webview-routes.js'; +import { webviewCapabilities } from '../src/webview-capabilities.js'; +import { writeWebviews } from '../src/webview-store.js'; +import { getDataDir } from '../src/config/instance.js'; +import type { EventPort } from '../src/web/ports/index.js'; + +const WEBVIEW_ID = 'wv-body-framing-test'; + +let upstream: Server; +let upstreamPort: number; +let app: FastifyInstance; +let capability: string; + +const eventPortStub: EventPort = { + broadcast: vi.fn(), + sendPushNotifications: vi.fn(), + batchTerminalData: vi.fn(), + broadcastSessionStateDebounced: vi.fn(), + batchTaskUpdate: vi.fn(), + getSseClientCount: vi.fn(() => 0), +}; + +interface Seen { + method: string; + body: string; + contentLength: string | null; + transferEncoding: string | null; +} + +beforeAll(async () => { + // A Content-Length-only HTTP/1.0 server. It answers with what it saw, so a + // chunked body shows up as `body: ''` exactly as it did for the dashboard. + upstream = createServer((socket) => { + let buf = Buffer.alloc(0); + const onData = (chunk: Buffer) => { + buf = Buffer.concat([buf, chunk]); + const end = buf.indexOf('\r\n\r\n'); + if (end < 0) return; + const lines = buf.subarray(0, end).toString('latin1').split('\r\n'); + const headers = new Map( + lines.slice(1).map((l) => [l.slice(0, l.indexOf(':')).trim().toLowerCase(), l.slice(l.indexOf(':') + 1).trim()]) + ); + const length = Number(headers.get('content-length') ?? 0); + if (buf.length - (end + 4) < length) return; + socket.off('data', onData); + const seen: Seen = { + method: lines[0].split(' ')[0], + body: buf.subarray(end + 4, end + 4 + length).toString(), + contentLength: headers.get('content-length') ?? null, + transferEncoding: headers.get('transfer-encoding') ?? null, + }; + const payload = JSON.stringify(seen); + socket.end( + `HTTP/1.0 200 OK\r\ncontent-type: application/json\r\ncontent-length: ${Buffer.byteLength(payload)}\r\n\r\n${payload}` + ); + }; + socket.on('data', onData); + socket.on('error', () => {}); + }); + await new Promise<void>((resolve) => upstream.listen(0, '127.0.0.1', resolve)); + const address = upstream.address(); + if (typeof address === 'object' && address) upstreamPort = address.port; + + await writeWebviews(getDataDir(), [ + { + id: WEBVIEW_ID, + name: 'body-framing-under-test', + url: `http://127.0.0.1:${upstreamPort}/`, + embedMode: 'proxy', + trusted: false, + createdAt: Date.now(), + }, + ]); + capability = webviewCapabilities.mint(WEBVIEW_ID, undefined); + + app = Fastify({ logger: false }); + registerWebviewRoutes(app, eventPortStub); + await app.ready(); +}); + +afterAll(async () => { + await app.close(); + webviewCapabilities.revokeWebview(WEBVIEW_ID); + await new Promise<void>((resolve) => upstream.close(() => resolve())); +}); + +describe('webview proxy request body framing', () => { + it('relays a JSON POST body with its Content-Length, so a length-only server reads it', async () => { + const res = await app.inject({ + method: 'POST', + url: `/webview/${capability}/api/shutdown`, + headers: { 'content-type': 'application/json' }, + payload: '{"confirm":true}', + }); + expect(res.statusCode).toBe(200); + const seen = res.json() as Seen; + expect(seen.transferEncoding).toBeNull(); + expect(seen.contentLength).toBe('16'); + expect(JSON.parse(seen.body)).toEqual({ confirm: true }); + }); + + it('keeps a large body intact under the same framing', async () => { + const big = 'x'.repeat(256 * 1024); + const res = await app.inject({ + method: 'PUT', + url: `/webview/${capability}/upload`, + headers: { 'content-type': 'application/octet-stream' }, + payload: big, + }); + const seen = res.json() as Seen; + expect(seen.contentLength).toBe(String(big.length)); + expect(seen.body).toBe(big); + }); + + it('sends no length on a GET, which carries no body', async () => { + const res = await app.inject({ method: 'GET', url: `/webview/${capability}/status` }); + const seen = res.json() as Seen; + expect(seen.method).toBe('GET'); + expect(seen.contentLength).toBeNull(); + expect(seen.transferEncoding).toBeNull(); + }); +}); diff --git a/test/webview-proxy.test.ts b/test/webview-proxy.test.ts index 224ab86d..54b3f68d 100644 --- a/test/webview-proxy.test.ts +++ b/test/webview-proxy.test.ts @@ -247,17 +247,45 @@ describe('buildUpstreamRequestHeaders', () => { expect(headers.referer).toBe('http://127.0.0.1:4000/panel'); }); - it('drops hop-by-hop and recomputed headers', () => { + it('drops hop-by-hop headers, and a length that a transfer-encoding contradicts', () => { const headers = buildUpstreamRequestHeaders( { host: 'codeman.local', connection: 'keep-alive', 'transfer-encoding': 'chunked', 'content-length': '5' }, upstream, - { forwardCookies: false, sessionCookieName: 'codeman_session' } + { forwardCookies: false, sessionCookieName: 'codeman_session', relayBodyLength: true } ); expect(headers.host).toBeUndefined(); expect(headers.connection).toBeUndefined(); expect(headers['transfer-encoding']).toBeUndefined(); expect(headers['content-length']).toBeUndefined(); }); + + // A streamed body without a length goes upstream chunked, and a dashboard that + // reads only Content-Length (Python's http.server) then sees an empty body. + it('relays the browser content-length alongside a body', () => { + const headers = buildUpstreamRequestHeaders( + { 'content-length': '16', 'content-type': 'application/json' }, + upstream, + { forwardCookies: false, sessionCookieName: 'codeman_session', relayBodyLength: true } + ); + expect(headers['content-length']).toBe('16'); + }); + + it('never relays a content-length without a body, or one that is not a plain number', () => { + const opts = { forwardCookies: false, sessionCookieName: 'codeman_session' }; + expect(buildUpstreamRequestHeaders({ 'content-length': '16' }, upstream, opts)['content-length']).toBeUndefined(); + expect( + buildUpstreamRequestHeaders({ 'content-length': '16' }, upstream, { ...opts, relayBodyLength: false })[ + 'content-length' + ] + ).toBeUndefined(); + for (const bad of ['-1', '16, 16', ' 16', '0x10', '']) { + expect( + buildUpstreamRequestHeaders({ 'content-length': bad }, upstream, { ...opts, relayBodyLength: true })[ + 'content-length' + ] + ).toBeUndefined(); + } + }); }); describe('buildDownstreamResponseHeaders', () => { From e58c1a2f1906bf66eff72c8113f42da38b767192 Mon Sep 17 00:00:00 2001 From: "Claudia[bot]" <4437565+Claudia[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 08:59:29 +0200 Subject: [PATCH 46/96] fix(limits): lower the session ceiling to 462 so the layout budget holds An owner's tab layout holds at most MAX_TAB_REFS (512) refs, counting saved web tabs (up to MAX_WEBVIEWS, 50 by default) as well as sessions. With a ceiling of 500, session creation could still fail with HTTP 500 on the layout limit before the capacity message. 512 - 50 = 462; a test pins the ceiling plus MAX_WEBVIEWS inside MAX_TAB_REFS so the two limits cannot drift apart again. Co-Authored-By: Christian Haberl <116439789+christianhaberl@users.noreply.github.com> Co-Authored-By: Claude <noreply@anthropic.com> --- .changeset/max-sessions-env.md | 2 +- docs/architecture-invariants.md | 2 +- docs/wiki/HTTP-API.md | 2 +- docs/wiki/Settings-Reference.md | 2 +- .../skills/codeman/reference/endpoints.md | 4 ++-- skills/codeman/reference/endpoints.md | 4 ++-- src/config/map-limits.ts | 13 +++++++------ src/web/schemas.ts | 2 +- test/config/max-sessions.test.ts | 16 ++++++++++++---- 9 files changed, 28 insertions(+), 19 deletions(-) diff --git a/.changeset/max-sessions-env.md b/.changeset/max-sessions-env.md index 8c28fb8b..5691f2c2 100644 --- a/.changeset/max-sessions-env.md +++ b/.changeset/max-sessions-env.md @@ -2,4 +2,4 @@ 'aicodeman': minor --- -The cap of 50 concurrent sessions can now be set with `CODEMAN_MAX_SESSIONS` (1 to 500, read at startup; a larger value is clamped to 500), for a machine with the memory for more sessions or a small one that should run fewer. Without it nothing changes. A value that is not a positive integer falls back to 50, so a typo never blocks session creation. In multi-user mode the per-user cap still defaults to half the global cap, so it follows the new value unless `CODEMAN_MAX_SESSIONS_PER_USER` is set. +The cap of 50 concurrent sessions can now be set with `CODEMAN_MAX_SESSIONS` (1 to 462, read at startup; a larger value is clamped to 462), for a machine with the memory for more sessions or a small one that should run fewer. Without it nothing changes. A value that is not a positive integer falls back to 50, so a typo never blocks session creation. In multi-user mode the per-user cap still defaults to half the global cap, so it follows the new value unless `CODEMAN_MAX_SESSIONS_PER_USER` is set. diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 7db9b5d0..3fad7e42 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -1124,7 +1124,7 @@ Tests: `test/mobile-prompt-composer.test.ts` (in the CI gate, deliberately not u ### Buffers, uploads, and terminal history -Target: 20 sessions, 50 agent windows at 60fps. Limits in `src/config/`: terminal 32MB (see below), text 1MB, messages 1000, max agents 500, max sessions 50 (env `CODEMAN_MAX_SESSIONS`, 1 to 500), max SSE clients 100. **Terminal history** (`src/config/terminal-history.ts`, COD-80): tmux history-limit 100k lines, PTY buffer 32MB max / 24MB trim (env `CODEMAN_MAX_TERMINAL_BUFFER`/`CODEMAN_TRIM_TERMINAL_TO`; the env-derived trim is clamped ≤75% of max — trim ≥ max would disable `BufferAccumulator` trimming entirely = unbounded memory); browser xterm scrollback stays a separate hardcoded 50k (`DEFAULT_SCROLLBACK` in constants.js — 100k/tab is a mobile-memory hazard). tmux <3.7 allocates history at pane creation, so `createSession()` sets the global default in the same command queue immediately before `new-session`; tmux 3.7+ instead creates the session and targets only that pane, because changing the global option can resize and trim unrelated live panes. A settings change resizes tracked panes only on 3.7+ and otherwise affects future panes; no version can recover lines already evicted. Settings keys `terminalScrollbackLines`/`terminalBufferMaxBytes`/`terminalBufferTrimBytes` are schema-validated but inert (only `tmuxHistoryLimit` is wired); `buffer-limits.ts` re-exports the defaults. Text/message limits are env-overridable too (`CODEMAN_MAX_TEXT_OUTPUT`/`CODEMAN_TRIM_TEXT_TO`/`CODEMAN_MAX_MESSAGES`). **Image upload** (`image-input.js` / `config/buffer-limits.ts`): up to `_maxBatchImages` 20 images/batch (bounded concurrency 3), per-file `MAX_PASTE_IMAGE_BYTES` 50MB (env `CODEMAN_MAX_PASTE_IMAGE_BYTES`); the mobile camera-roll picker auto-downscales to fit before upload. A camera-roll VIDEO (`?kind=video` on the same route, set by the client from the file's MIME type) is never buffered: it streams to disk under `MAX_PASTE_VIDEO_BYTES` 1GB (env `CODEMAN_MAX_PASTE_VIDEO_BYTES`), sniffed from the stream's first bytes (ISO BMFF / QuickTime atom, EBML for webm, Ogg for ogv) and unlinked on any failure. The client keys the flag on the MIME type, falling back to the name's extension because some Android file managers hand over a File with none; that classifier (`_promptAttachKind()`, image-input.js) is the ONE filter every pick and paste surface reads: Attach, drop, the Compose and paste-dialog pickers and the clipboard handlers. They reach it through `_admitPromptAttachments()` (same file), which also toasts what a pick skipped: the count of a mixed pick's refused files, `Unsupported file type` when none pass, and nothing for a text paste. A new surface admits through that helper, never by filtering on `_promptAttachKind()` directly, or it silently drops files with no word to the user. ⚠️ The flag is the ONLY way onto that path and admits video types alone (the registry's `VIDEO_ATTACHMENT_EXTENSIONS`, single-sourced so an uploaded video also previews: mp4/webm/mov/m4v/ogv; 3gp/mkv are out), so an image can never ride the larger cap through memory; without it the route is byte-identical to the image-only version. A DOCUMENT (the office formats pdf/docx/pptx/xlsx/zip plus the text family txt/md/csv/tsv/json/log/yaml/yml/xml, the curated `ALLOWED_DOCUMENT_EXTS`, never `SUPPORTED_ATTACHMENT_EXTENSIONS` or `EDITABLE_EXTENSIONS`) is admitted by its name's extension and buffered like an image, judged on its bytes by the same fail-closed `imageMagicMatchesExt` (a magic-number signature, or UTF-8 without a NUL byte for the text family) and saved under a sanitized `[A-Za-z0-9._-]` stem of its name; sh/py/js/html are a deliberate SCOPE exclusion by extension (the server declines to store what `file-raw` would serve or an agent could run), not a content filter, since the agent can already write any of them. `_promptAttachKind()` returns `image`, `video` or `document`, and the server's dispatch mirrors it: an image MIME wins whatever the name says, a document extension takes the document branch, and a foreign extension with no image MIME is refused `Unsupported file type` only after the image sniff fails, so nothing the image path accepted by its bytes is lost. **HEIC paste uploads** (#151): converted server-side to JPEG in a `worker_threads` worker (`web/heic-jpeg-worker.ts`, resourceLimits + 30s timeout) gated by `runWithConversionLimit()`; detection is magic-byte based (covers Android/MIUI HEIFs mislabeled as JPEG); headers declaring > 64MP are rejected 415 BEFORE decode (decompression-bomb guard). Deps: `heic-decode` + `jpeg-js`. Use `LRUMap` for bounded caches, `StaleExpirationMap` for TTL cleanup. Anti-flicker pipeline: `docs/terminal-anti-flicker.md`. +Target: 20 sessions, 50 agent windows at 60fps. Limits in `src/config/`: terminal 32MB (see below), text 1MB, messages 1000, max agents 500, max sessions 50 (env `CODEMAN_MAX_SESSIONS`, 1 to 462), max SSE clients 100. **Terminal history** (`src/config/terminal-history.ts`, COD-80): tmux history-limit 100k lines, PTY buffer 32MB max / 24MB trim (env `CODEMAN_MAX_TERMINAL_BUFFER`/`CODEMAN_TRIM_TERMINAL_TO`; the env-derived trim is clamped ≤75% of max — trim ≥ max would disable `BufferAccumulator` trimming entirely = unbounded memory); browser xterm scrollback stays a separate hardcoded 50k (`DEFAULT_SCROLLBACK` in constants.js — 100k/tab is a mobile-memory hazard). tmux <3.7 allocates history at pane creation, so `createSession()` sets the global default in the same command queue immediately before `new-session`; tmux 3.7+ instead creates the session and targets only that pane, because changing the global option can resize and trim unrelated live panes. A settings change resizes tracked panes only on 3.7+ and otherwise affects future panes; no version can recover lines already evicted. Settings keys `terminalScrollbackLines`/`terminalBufferMaxBytes`/`terminalBufferTrimBytes` are schema-validated but inert (only `tmuxHistoryLimit` is wired); `buffer-limits.ts` re-exports the defaults. Text/message limits are env-overridable too (`CODEMAN_MAX_TEXT_OUTPUT`/`CODEMAN_TRIM_TEXT_TO`/`CODEMAN_MAX_MESSAGES`). **Image upload** (`image-input.js` / `config/buffer-limits.ts`): up to `_maxBatchImages` 20 images/batch (bounded concurrency 3), per-file `MAX_PASTE_IMAGE_BYTES` 50MB (env `CODEMAN_MAX_PASTE_IMAGE_BYTES`); the mobile camera-roll picker auto-downscales to fit before upload. A camera-roll VIDEO (`?kind=video` on the same route, set by the client from the file's MIME type) is never buffered: it streams to disk under `MAX_PASTE_VIDEO_BYTES` 1GB (env `CODEMAN_MAX_PASTE_VIDEO_BYTES`), sniffed from the stream's first bytes (ISO BMFF / QuickTime atom, EBML for webm, Ogg for ogv) and unlinked on any failure. The client keys the flag on the MIME type, falling back to the name's extension because some Android file managers hand over a File with none; that classifier (`_promptAttachKind()`, image-input.js) is the ONE filter every pick and paste surface reads: Attach, drop, the Compose and paste-dialog pickers and the clipboard handlers. They reach it through `_admitPromptAttachments()` (same file), which also toasts what a pick skipped: the count of a mixed pick's refused files, `Unsupported file type` when none pass, and nothing for a text paste. A new surface admits through that helper, never by filtering on `_promptAttachKind()` directly, or it silently drops files with no word to the user. ⚠️ The flag is the ONLY way onto that path and admits video types alone (the registry's `VIDEO_ATTACHMENT_EXTENSIONS`, single-sourced so an uploaded video also previews: mp4/webm/mov/m4v/ogv; 3gp/mkv are out), so an image can never ride the larger cap through memory; without it the route is byte-identical to the image-only version. A DOCUMENT (the office formats pdf/docx/pptx/xlsx/zip plus the text family txt/md/csv/tsv/json/log/yaml/yml/xml, the curated `ALLOWED_DOCUMENT_EXTS`, never `SUPPORTED_ATTACHMENT_EXTENSIONS` or `EDITABLE_EXTENSIONS`) is admitted by its name's extension and buffered like an image, judged on its bytes by the same fail-closed `imageMagicMatchesExt` (a magic-number signature, or UTF-8 without a NUL byte for the text family) and saved under a sanitized `[A-Za-z0-9._-]` stem of its name; sh/py/js/html are a deliberate SCOPE exclusion by extension (the server declines to store what `file-raw` would serve or an agent could run), not a content filter, since the agent can already write any of them. `_promptAttachKind()` returns `image`, `video` or `document`, and the server's dispatch mirrors it: an image MIME wins whatever the name says, a document extension takes the document branch, and a foreign extension with no image MIME is refused `Unsupported file type` only after the image sniff fails, so nothing the image path accepted by its bytes is lost. **HEIC paste uploads** (#151): converted server-side to JPEG in a `worker_threads` worker (`web/heic-jpeg-worker.ts`, resourceLimits + 30s timeout) gated by `runWithConversionLimit()`; detection is magic-byte based (covers Android/MIUI HEIFs mislabeled as JPEG); headers declaring > 64MP are rejected 415 BEFORE decode (decompression-bomb guard). Deps: `heic-decode` + `jpeg-js`. Use `LRUMap` for bounded caches, `StaleExpirationMap` for TTL cleanup. Anti-flicker pipeline: `docs/terminal-anti-flicker.md`. **Prompt uploads live in `<workspace>/.codeman-uploads/`** (`POST /api/sessions/:id/paste-image`; the names live ONLY in `src/web/paste-image-gc.ts`): IN the workspace because that is the only path that resolves identically for a local agent and a container (only the workspace is bind-mounted, at the same absolute path; `~/.codeman` is not); hidden so it stays out of `git status` and the agent's view of the repository; FLAT because a nested `<workspace>/.codeman/` IS the data dir (`join(homedir(), '.codeman')`, `src/config/instance.ts`) when the workspace is the home directory, which nothing refuses; and self-ignoring through a `.gitignore` of `*` written once with `wx`, so a file already there is the user's and stays. The pre-move `.claude-images/` receives nothing but stays readable for one release: `UPLOAD_DIR_NAMES` lists both, the hourly sweep and the delete cleanup in `cleanupSession()` go through `uploadDirs()`, and the image watcher's ignore filter reads the names. ⚠️ `uploadDirs()` is async: the working directory is a user-chosen path, so it is probed BOUNDED first (`probePathKind()`, the #516 rule; `unknown` is skipped and never touched, the hourly sweep keeps the stall cap and `cleanupSession()` passes `pastCap`, since it acts on one path at the user's request), and the lstat and realpath after it are `fs.promises` calls, never sync, because the sweep runs 30 s after boot and hourly over every live session and a linked case on a dead mount would otherwise freeze the whole server. It returns only REAL directories (lstat at the leaf, so a planted `.codeman-uploads -> /other-case/.codeman-uploads` is never listed: `readdir` follows a link to a directory, and the sweep would otherwise age out the other case's uploads through it), none that is or contains `getDataDir()` (contrived, `CODEMAN_INSTANCE=uploads` makes `~/.codeman-uploads` the data dir of a home workspace and `CODEMAN_DATA_DIR` can point inside one, but it is one check next to a recursive delete; one strictly INSIDE the data dir is listed, since it only ever holds uploads and a workspace like the `~/.codeman/app` that `install.sh` clones would otherwise never have its uploads collected), and nothing for a remote (SSH) session, whose `workingDir` is the remote path and would otherwise name a same-named LOCAL directory for the sweep and the delete. The check is made when the directories are listed: a same-user process that swaps a listed directory for a link during the sweep's awaits is accepted (Node has no `openat()`, and that actor already writes anywhere this process can). The route refuses a remote (SSH) session with 400 before any disk touch: its `workingDir` is the remote path, so the file would land on THIS host where the agent cannot read it. ⚠️ And because a live session's workspace can go onto a network mount that later stops answering, the route probes `session.workingDir` BOUNDED (`probePathKind(..., { pastCap: true })`) before the first disk touch, refusing an `unknown` mount through `describeUnknownPath()` (OPERATION_FAILED) exactly as `POST /api/sessions` does, so a mount already unreachable cannot freeze a libuv worker on every upload (`ensureUploadDir()`'s own lstat/mkdir then start on a reachable path; a mount that dies between the probe and that write is the same residual race `POST /api/sessions` accepts, since a pre-check cannot bound the write that follows it). Tests: `test/paste-image-gc.test.ts`, `test/paste-image-dir-shared.test.ts`, the paste-image block of `test/routes/session-routes.test.ts`, `test/routes/session-create-unreachable-path.test.ts`. diff --git a/docs/wiki/HTTP-API.md b/docs/wiki/HTTP-API.md index 0f940586..7f72223e 100644 --- a/docs/wiki/HTTP-API.md +++ b/docs/wiki/HTTP-API.md @@ -185,7 +185,7 @@ curl -s -X POST "$API/api/sessions/$ID/custom-model" -H 'Content-Type: applicati | Limit | Default | | --------------------- | ------------------------------------------ | -| Max sessions | 50 | +| Max sessions | 50 (`CODEMAN_MAX_SESSIONS`) | | Max agent windows | 500 | | Max SSE clients | 100 | | Terminal buffer | 32 MB per session | diff --git a/docs/wiki/Settings-Reference.md b/docs/wiki/Settings-Reference.md index 52bc7416..3db462e6 100644 --- a/docs/wiki/Settings-Reference.md +++ b/docs/wiki/Settings-Reference.md @@ -221,7 +221,7 @@ Some things are configured before the server starts, not in the UI: | `CODEMAN_FILE_PICKER_ROOTS` | Extra roots for the path picker. | | `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` | Acknowledges exposing the server with no password. | | `CODEMAN_BASE_URL` | Mounts Codeman under a sub-path behind a reverse proxy that forwards the prefix unchanged. See [Remote Access](Remote-Access). | -| `CODEMAN_MAX_SESSIONS` | Cap on concurrent sessions. 50 by default, 1 to 500 (a larger value is clamped to 500, anything that is not a positive integer falls back to 50). Read at startup. In multi-user mode the per-user cap defaults to half of it unless `CODEMAN_MAX_SESSIONS_PER_USER` is set. | +| `CODEMAN_MAX_SESSIONS` | Cap on concurrent sessions. 50 by default, 1 to 462 (a larger value is clamped to 462, anything that is not a positive integer falls back to 50). Read at startup. In multi-user mode the per-user cap defaults to half of it unless `CODEMAN_MAX_SESSIONS_PER_USER` is set. | | `CODEMAN_MAX_DOWNLOAD_BYTES` | Cap on raw file bodies and downloads. 2 GB by default, `0` for none. | | `CODEMAN_MAX_PASTE_IMAGE_BYTES` | Cap on one pasted, dropped or attached image or document. 50 MB by default. | | `CODEMAN_MAX_PASTE_VIDEO_BYTES` | Cap on one attached video (phone Attach, drop). 1 GB by default; videos stream to disk, so this bounds disk use, not memory. | diff --git a/plugins/codeman/skills/codeman/reference/endpoints.md b/plugins/codeman/skills/codeman/reference/endpoints.md index 5324e571..2dafe613 100644 --- a/plugins/codeman/skills/codeman/reference/endpoints.md +++ b/plugins/codeman/skills/codeman/reference/endpoints.md @@ -113,7 +113,7 @@ Every JSON response: `{"success":true,"data":…}` or | `UNAUTHORIZED` | 401 | auth required or failed (send `-u user:password`). ⚠️ The 401 body is plain text, NOT this envelope, see [Auth and credentials](#auth-and-credentials) | | `FORBIDDEN` | 403 | authenticated but not permitted: an admin-only route in multi-user mode, a `workingDir`/case path outside your own workspace, or a shell session without the can-bypass-permissions grant. ⚠️ **Not** what an ownership miss on a session returns: a session you do not own answers 404 `NOT_FOUND`, identically to one that does not exist (deliberate, it leaks no existence) | | `NOT_FOUND` | 404 | no such session, or one this caller does not own. Also quick-start's answer for an unknown remote or docker host | -| `SESSION_BUSY` | 409 | on a **wait**: this session's waiter cap (16, combined signal+output) is full. On **quick-start**: a session cap is full, so clean up before starting more. Two different caps can raise it: the global cap, 50 by default (`MAX_CONCURRENT_SESSIONS`, env `CODEMAN_MAX_SESSIONS`, 1 to 500), and in multi-user mode the per-user cap, which defaults to half the global cap, **25** by default (`maxSessionsPerUser()`, `config/multiuser.ts:59-63`). The message tells you which | +| `SESSION_BUSY` | 409 | on a **wait**: this session's waiter cap (16, combined signal+output) is full. On **quick-start**: a session cap is full, so clean up before starting more. Two different caps can raise it: the global cap, 50 by default (`MAX_CONCURRENT_SESSIONS`, env `CODEMAN_MAX_SESSIONS`, 1 to 462), and in multi-user mode the per-user cap, which defaults to half the global cap, **25** by default (`maxSessionsPerUser()`, `config/multiuser.ts:59-63`). The message tells you which | | `CONFLICT` / `ALREADY_EXISTS` | 409 | conflicts with current state | | `OPERATION_FAILED` | 422 | well-formed but could not be completed | | `RATE_LIMITED` | 429 | per-owner or process-wide waiter pool is full; back off, switching sessions will not help | @@ -796,7 +796,7 @@ response echoes. | `match` string | 1–200 characters, literal only | 400; `regex=` is rejected outright | | `from=buffer` scan window | 256 KB tail of the terminal buffer | a marker older than that tail is invisible even with `from=buffer` | | wait-output snippet context | 80 characters either side | `wait.snippet` is bounded, not the whole line | -| sessions, process-wide | 50 by default (`MAX_CONCURRENT_SESSIONS`, env `CODEMAN_MAX_SESSIONS`, 1 to 500) | 409 `SESSION_BUSY` on quick-start | +| sessions, process-wide | 50 by default (`MAX_CONCURRENT_SESSIONS`, env `CODEMAN_MAX_SESSIONS`, 1 to 462) | 409 `SESSION_BUSY` on quick-start | | sessions, per user | half the global cap (25 by default) in multi-user mode | the same 409, with a different message | | SSE clients, process-wide | 100 (`MAX_SSE_CLIENTS`) | plain-text `503 Too many SSE connections`; shared with every browser tab | | active bash tools tracked | 20 per session | oldest entries drop off `active-tools` | diff --git a/skills/codeman/reference/endpoints.md b/skills/codeman/reference/endpoints.md index 5324e571..2dafe613 100644 --- a/skills/codeman/reference/endpoints.md +++ b/skills/codeman/reference/endpoints.md @@ -113,7 +113,7 @@ Every JSON response: `{"success":true,"data":…}` or | `UNAUTHORIZED` | 401 | auth required or failed (send `-u user:password`). ⚠️ The 401 body is plain text, NOT this envelope, see [Auth and credentials](#auth-and-credentials) | | `FORBIDDEN` | 403 | authenticated but not permitted: an admin-only route in multi-user mode, a `workingDir`/case path outside your own workspace, or a shell session without the can-bypass-permissions grant. ⚠️ **Not** what an ownership miss on a session returns: a session you do not own answers 404 `NOT_FOUND`, identically to one that does not exist (deliberate, it leaks no existence) | | `NOT_FOUND` | 404 | no such session, or one this caller does not own. Also quick-start's answer for an unknown remote or docker host | -| `SESSION_BUSY` | 409 | on a **wait**: this session's waiter cap (16, combined signal+output) is full. On **quick-start**: a session cap is full, so clean up before starting more. Two different caps can raise it: the global cap, 50 by default (`MAX_CONCURRENT_SESSIONS`, env `CODEMAN_MAX_SESSIONS`, 1 to 500), and in multi-user mode the per-user cap, which defaults to half the global cap, **25** by default (`maxSessionsPerUser()`, `config/multiuser.ts:59-63`). The message tells you which | +| `SESSION_BUSY` | 409 | on a **wait**: this session's waiter cap (16, combined signal+output) is full. On **quick-start**: a session cap is full, so clean up before starting more. Two different caps can raise it: the global cap, 50 by default (`MAX_CONCURRENT_SESSIONS`, env `CODEMAN_MAX_SESSIONS`, 1 to 462), and in multi-user mode the per-user cap, which defaults to half the global cap, **25** by default (`maxSessionsPerUser()`, `config/multiuser.ts:59-63`). The message tells you which | | `CONFLICT` / `ALREADY_EXISTS` | 409 | conflicts with current state | | `OPERATION_FAILED` | 422 | well-formed but could not be completed | | `RATE_LIMITED` | 429 | per-owner or process-wide waiter pool is full; back off, switching sessions will not help | @@ -796,7 +796,7 @@ response echoes. | `match` string | 1–200 characters, literal only | 400; `regex=` is rejected outright | | `from=buffer` scan window | 256 KB tail of the terminal buffer | a marker older than that tail is invisible even with `from=buffer` | | wait-output snippet context | 80 characters either side | `wait.snippet` is bounded, not the whole line | -| sessions, process-wide | 50 by default (`MAX_CONCURRENT_SESSIONS`, env `CODEMAN_MAX_SESSIONS`, 1 to 500) | 409 `SESSION_BUSY` on quick-start | +| sessions, process-wide | 50 by default (`MAX_CONCURRENT_SESSIONS`, env `CODEMAN_MAX_SESSIONS`, 1 to 462) | 409 `SESSION_BUSY` on quick-start | | sessions, per user | half the global cap (25 by default) in multi-user mode | the same 409, with a different message | | SSE clients, process-wide | 100 (`MAX_SSE_CLIENTS`) | plain-text `503 Too many SSE connections`; shared with every browser tab | | active bash tools tracked | 20 per session | oldest entries drop off `active-tools` | diff --git a/src/config/map-limits.ts b/src/config/map-limits.ts index 3007060f..49ce8795 100644 --- a/src/config/map-limits.ts +++ b/src/config/map-limits.ts @@ -22,13 +22,14 @@ const DEFAULT_MAX_CONCURRENT_SESSIONS = 50; /** - * Highest value `CODEMAN_MAX_SESSIONS` can raise the cap to. Other bounds assume - * the session count stays small: an owner's tab layout holds at most - * `MAX_TAB_REFS` (512) refs and `PUT /api/session-order` accepts at most 500 ids, - * so a larger cap would let session creation fail with a 500 instead of the - * capacity message. + * Highest value `CODEMAN_MAX_SESSIONS` can raise the cap to. An owner's tab layout + * holds at most `MAX_TAB_REFS` (512) refs, and those count saved web tabs as well + * as sessions; an owner can save up to `MAX_WEBVIEWS` (50 by default) web tabs. + * 512 - 50 = 462 keeps session creation answering with the capacity message + * instead of failing with a 500 on the layout limit. (`PUT /api/session-order` + * accepts up to 500 ids, which stays above this.) */ -export const MAX_CONCURRENT_SESSIONS_CEILING = 500; +export const MAX_CONCURRENT_SESSIONS_CEILING = 462; /** * Resolve the session cap from `CODEMAN_MAX_SESSIONS` (a positive integer, diff --git a/src/web/schemas.ts b/src/web/schemas.ts index 7d7b5630..0c12f797 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -1981,7 +1981,7 @@ export const CaseOrderSchema = z.object({ /** PUT /api/session-order — global tab order (ordered sessionIds), COD-131 */ export const SessionOrderUpdateSchema = z.object({ // Bounded defensively: ids are uuid-ish (<=100 chars) and the client pushes only - // its open-tab order (max sessions is 50 by default, at most 500 via CODEMAN_MAX_SESSIONS, + // its open-tab order (max sessions is 50 by default, at most 462 via CODEMAN_MAX_SESSIONS, // see MAX_CONCURRENT_SESSIONS_CEILING) — 500 leaves enough room while // keeping a hostile/buggy client from persisting megabytes into state.json. order: z.array(z.string().max(100)).max(500), diff --git a/test/config/max-sessions.test.ts b/test/config/max-sessions.test.ts index 5d689ddd..03440505 100644 --- a/test/config/max-sessions.test.ts +++ b/test/config/max-sessions.test.ts @@ -52,11 +52,19 @@ describe('global session cap (CODEMAN_MAX_SESSIONS)', () => { expect(sessionCapacityState(sessionsOf(3)).atGlobalCap).toBe(true); }); - it.each(['1000', '501', '99999999999999999999'])('clamps %j to the ceiling of 500', async (value) => { + it.each(['1000', '500', '463', '99999999999999999999'])('clamps %j to the ceiling of 462', async (value) => { const { sessionCapacityState, sessionCapacityMessage } = await loadWithEnv({ CODEMAN_MAX_SESSIONS: value }); - expect(sessionCapacityState(sessionsOf(499)).atGlobalCap).toBe(false); - expect(sessionCapacityState(sessionsOf(500)).atGlobalCap).toBe(true); - expect(sessionCapacityMessage(sessionsOf(500))).toContain('(500)'); + expect(sessionCapacityState(sessionsOf(461)).atGlobalCap).toBe(false); + expect(sessionCapacityState(sessionsOf(462)).atGlobalCap).toBe(true); + expect(sessionCapacityMessage(sessionsOf(462))).toContain('(462)'); + }); + + it('keeps the ceiling inside the tab-layout budget next to the saved web tabs', async () => { + vi.resetModules(); + const { MAX_CONCURRENT_SESSIONS_CEILING } = await import('../../src/config/map-limits.js'); + const { MAX_WEBVIEWS } = await import('../../src/config/webview-limits.js'); + const { MAX_TAB_REFS } = await import('../../src/tab-layout.js'); + expect(MAX_CONCURRENT_SESSIONS_CEILING + MAX_WEBVIEWS).toBeLessThanOrEqual(MAX_TAB_REFS); }); it.each(['', '0', '-5', '2.5', 'abc', '1e3'])('falls back to 50 for an unusable value %j', async (value) => { From f17aa047d0d4f00f38286938b46f494f4d09a79b Mon Sep 17 00:00:00 2001 From: "Claudia[bot]" <4437565+Claudia[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 09:02:15 +0200 Subject: [PATCH 47/96] docs(limits): say 1 to 462 in the MAX_CONCURRENT_SESSIONS doc comment Co-Authored-By: Christian Haberl <116439789+christianhaberl@users.noreply.github.com> Co-Authored-By: Claude <noreply@anthropic.com> --- src/config/map-limits.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/config/map-limits.ts b/src/config/map-limits.ts index 49ce8795..ee8c7965 100644 --- a/src/config/map-limits.ts +++ b/src/config/map-limits.ts @@ -47,7 +47,7 @@ function resolveMaxConcurrentSessions(): number { /** * Maximum concurrent sessions allowed. * Each session consumes significant resources (PTY, buffers, watchers). - * 50 unless `CODEMAN_MAX_SESSIONS` says otherwise (1 to 500); read once at startup. + * 50 unless `CODEMAN_MAX_SESSIONS` says otherwise (1 to 462); read once at startup. */ export const MAX_CONCURRENT_SESSIONS = resolveMaxConcurrentSessions(); From c3fa6d8923c9c44d32256a48427579eaf4335b70 Mon Sep 17 00:00:00 2001 From: "Claudia[bot]" <4437565+Claudia[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 10:18:23 +0200 Subject: [PATCH 48/96] docs(limits): state when the 462 ceiling holds; skill docs follow CODEMAN_MAX_SESSIONS The tab layout's 512 refs also count pinned sessions kept as stopped records, so the ceiling holds at the default MAX_WEBVIEWS and with no pinned closed sessions. SKILL.md, verbs.md (both copies) and the cron guide no longer give a fixed 50/25. Co-Authored-By: Christian Haberl <116439789+christianhaberl@users.noreply.github.com> Co-Authored-By: Claude <noreply@anthropic.com> --- docs/cron-guide.md | 2 +- docs/wiki/Settings-Reference.md | 2 +- plugins/codeman/skills/codeman/SKILL.md | 5 +++-- plugins/codeman/skills/codeman/reference/verbs.md | 5 +++-- skills/codeman/SKILL.md | 5 +++-- skills/codeman/reference/verbs.md | 5 +++-- src/config/map-limits.ts | 6 ++++-- 7 files changed, 18 insertions(+), 12 deletions(-) diff --git a/docs/cron-guide.md b/docs/cron-guide.md index b5c37fc3..461b5cb7 100644 --- a/docs/cron-guide.md +++ b/docs/cron-guide.md @@ -284,7 +284,7 @@ automatically after **Run Now**. > ⚠️ **Session-cap math if you disable auto-close.** With > `autoClosePreviousSession: false`, nothing ever closes the sessions a > recurring job creates — an interval job every 30 min creates 48 tabs/day and -> hits the global 50-session cap in ~25 hours (sooner with existing tabs), after +> hits the global session cap (50 by default, `CODEMAN_MAX_SESSIONS`) in ~25 hours (sooner with existing tabs), after > which **every** fire of **every** job fails with "Maximum concurrent sessions > reached" until you delete tabs by hand. Leave auto-close on for unattended > recurring jobs, or clean up sessions yourself. diff --git a/docs/wiki/Settings-Reference.md b/docs/wiki/Settings-Reference.md index 3db462e6..13b71aeb 100644 --- a/docs/wiki/Settings-Reference.md +++ b/docs/wiki/Settings-Reference.md @@ -221,7 +221,7 @@ Some things are configured before the server starts, not in the UI: | `CODEMAN_FILE_PICKER_ROOTS` | Extra roots for the path picker. | | `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK` | Acknowledges exposing the server with no password. | | `CODEMAN_BASE_URL` | Mounts Codeman under a sub-path behind a reverse proxy that forwards the prefix unchanged. See [Remote Access](Remote-Access). | -| `CODEMAN_MAX_SESSIONS` | Cap on concurrent sessions. 50 by default, 1 to 462 (a larger value is clamped to 462, anything that is not a positive integer falls back to 50). Read at startup. In multi-user mode the per-user cap defaults to half of it unless `CODEMAN_MAX_SESSIONS_PER_USER` is set. | +| `CODEMAN_MAX_SESSIONS` | Cap on concurrent sessions. 50 by default, 1 to 462 (a larger value is clamped to 462, anything that is not a positive integer falls back to 50). Read at startup. The 462 assumes the default `CODEMAN_MAX_WEBVIEWS` and no pinned closed sessions, which share the same 512-entry tab layout. In multi-user mode the per-user cap defaults to half of it unless `CODEMAN_MAX_SESSIONS_PER_USER` is set. | | `CODEMAN_MAX_DOWNLOAD_BYTES` | Cap on raw file bodies and downloads. 2 GB by default, `0` for none. | | `CODEMAN_MAX_PASTE_IMAGE_BYTES` | Cap on one pasted, dropped or attached image or document. 50 MB by default. | | `CODEMAN_MAX_PASTE_VIDEO_BYTES` | Cap on one attached video (phone Attach, drop). 1 GB by default; videos stream to disk, so this bounds disk use, not memory. | diff --git a/plugins/codeman/skills/codeman/SKILL.md b/plugins/codeman/skills/codeman/SKILL.md index a9772f77..d1376225 100644 --- a/plugins/codeman/skills/codeman/SKILL.md +++ b/plugins/codeman/skills/codeman/SKILL.md @@ -666,8 +666,9 @@ You are yourself a session on this server, and the API has **no undo**. user's repository state, so say that you did; **removing** one discards any uncommitted work inside it, so ask first ([§5.1](reference/verbs.md#51-where-to-spawn)). - Never `tmux kill-session`, `pkill tmux`, `pkill claude`. The API is the only interface. -- Sessions count against a **global cap of 50** (and, in multi-user mode, a per-user - cap of 25 that fires the same 409). Case creation is uncapped and writes real +- Sessions count against a **global cap, 50 by default** (`CODEMAN_MAX_SESSIONS`), and, + in multi-user mode, a per-user cap (half the global cap, 25 by default) that fires + the same 409. Case creation is uncapped and writes real directories. Clean up every session you start, and never retry `quick-start` in a loop. diff --git a/plugins/codeman/skills/codeman/reference/verbs.md b/plugins/codeman/skills/codeman/reference/verbs.md index 2dfe8bb0..c0020676 100644 --- a/plugins/codeman/skills/codeman/reference/verbs.md +++ b/plugins/codeman/skills/codeman/reference/verbs.md @@ -101,8 +101,9 @@ Differences from `quick-start` worth knowing before you debug one: - hitting the session cap here is `OPERATION_FAILED`, where `quick-start` returns `SESSION_BUSY` for the identical condition. -`quick-start` failure codes are `SESSION_BUSY` (the global 50-session cap, or the -per-user cap of 25 in multi-user mode), `FORBIDDEN`, `CONFLICT`, `NOT_FOUND` (a +`quick-start` failure codes are `SESSION_BUSY` (the global session cap, 50 by default via +`CODEMAN_MAX_SESSIONS`, or the per-user cap in multi-user mode, half the global cap, +25 by default), `FORBIDDEN`, `CONFLICT`, `NOT_FOUND` (a remote or docker host named by the case no longer exists), `OPERATION_FAILED` and `INVALID_INPUT`. **None of them are retryable in a loop.** Always branch on `.success` before reading `.data.sessionId`: on failure the field is absent, `jq -r` diff --git a/skills/codeman/SKILL.md b/skills/codeman/SKILL.md index a9772f77..d1376225 100644 --- a/skills/codeman/SKILL.md +++ b/skills/codeman/SKILL.md @@ -666,8 +666,9 @@ You are yourself a session on this server, and the API has **no undo**. user's repository state, so say that you did; **removing** one discards any uncommitted work inside it, so ask first ([§5.1](reference/verbs.md#51-where-to-spawn)). - Never `tmux kill-session`, `pkill tmux`, `pkill claude`. The API is the only interface. -- Sessions count against a **global cap of 50** (and, in multi-user mode, a per-user - cap of 25 that fires the same 409). Case creation is uncapped and writes real +- Sessions count against a **global cap, 50 by default** (`CODEMAN_MAX_SESSIONS`), and, + in multi-user mode, a per-user cap (half the global cap, 25 by default) that fires + the same 409. Case creation is uncapped and writes real directories. Clean up every session you start, and never retry `quick-start` in a loop. diff --git a/skills/codeman/reference/verbs.md b/skills/codeman/reference/verbs.md index 2dfe8bb0..c0020676 100644 --- a/skills/codeman/reference/verbs.md +++ b/skills/codeman/reference/verbs.md @@ -101,8 +101,9 @@ Differences from `quick-start` worth knowing before you debug one: - hitting the session cap here is `OPERATION_FAILED`, where `quick-start` returns `SESSION_BUSY` for the identical condition. -`quick-start` failure codes are `SESSION_BUSY` (the global 50-session cap, or the -per-user cap of 25 in multi-user mode), `FORBIDDEN`, `CONFLICT`, `NOT_FOUND` (a +`quick-start` failure codes are `SESSION_BUSY` (the global session cap, 50 by default via +`CODEMAN_MAX_SESSIONS`, or the per-user cap in multi-user mode, half the global cap, +25 by default), `FORBIDDEN`, `CONFLICT`, `NOT_FOUND` (a remote or docker host named by the case no longer exists), `OPERATION_FAILED` and `INVALID_INPUT`. **None of them are retryable in a loop.** Always branch on `.success` before reading `.data.sessionId`: on failure the field is absent, `jq -r` diff --git a/src/config/map-limits.ts b/src/config/map-limits.ts index ee8c7965..f7b0f010 100644 --- a/src/config/map-limits.ts +++ b/src/config/map-limits.ts @@ -26,8 +26,10 @@ const DEFAULT_MAX_CONCURRENT_SESSIONS = 50; * holds at most `MAX_TAB_REFS` (512) refs, and those count saved web tabs as well * as sessions; an owner can save up to `MAX_WEBVIEWS` (50 by default) web tabs. * 512 - 50 = 462 keeps session creation answering with the capacity message - * instead of failing with a 500 on the layout limit. (`PUT /api/session-order` - * accepts up to 500 ids, which stays above this.) + * instead of failing with a 500 on the layout limit, at the default `MAX_WEBVIEWS` + * and with no pinned closed sessions: a pinned session stays in the layout as a + * stopped record after it is closed, and it shares the same 512-ref budget. + * (`PUT /api/session-order` accepts up to 500 ids, which stays above this.) */ export const MAX_CONCURRENT_SESSIONS_CEILING = 462; From 302e2660bea3243db66483104a040f61de1d49e5 Mon Sep 17 00:00:00 2001 From: "Claudia[bot]" <4437565+Claudia[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 10:19:48 +0200 Subject: [PATCH 49/96] docs: sendwait takes one line; give the submit-check reason in tui.md verbs.md section 5.3 (both copies): sendwait's Enter loop and the server's submit check cannot reliably recognise a multi-line paste, so join the lines or use a file (section 5.9). docs/tui.md gives the same reason instead of the composer one. Co-Authored-By: Christian Haberl <116439789+christianhaberl@users.noreply.github.com> Co-Authored-By: Claude <noreply@anthropic.com> --- docs/tui.md | 6 +++--- plugins/codeman/skills/codeman/reference/verbs.md | 6 +++++- skills/codeman/reference/verbs.md | 6 +++++- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/docs/tui.md b/docs/tui.md index 9907d5f1..ec07bb7d 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -136,9 +136,9 @@ instead of failing silently; `Enter` and `1-9` keep working. ### `p` sends exactly one line -The composer is a single line by design, ending in a carriage return: that is the -input contract every Codeman path follows, because multi-line text breaks the -agent's own composer. Pasted newlines become spaces rather than being rejected, so +The composer is a single line by design, ending in a carriage return, because the +server's submit check cannot reliably recognise a multi-line paste: a prompt it cannot +see on the composer line is not re-submitted when the agent ignored the first Enter. Pasted newlines become spaces rather than being rejected, so a paste cannot silently run a different command than the one you read. ## Answering approvals diff --git a/plugins/codeman/skills/codeman/reference/verbs.md b/plugins/codeman/skills/codeman/reference/verbs.md index cda70197..a4f43d5a 100644 --- a/plugins/codeman/skills/codeman/reference/verbs.md +++ b/plugins/codeman/skills/codeman/reference/verbs.md @@ -304,7 +304,11 @@ Codeman types the text and sends Enter **only when the input contains a carriage return**; without it your command sits unsubmitted on the worker's prompt and everything downstream times out. No response field catches this: `delivered:true` means "written to the pane", **not** "submitted". With `useMux`, newlines are kept: the -text goes in as one paste, and the `\r` submits all of it. Build the body with `jq -n` +text goes in as one paste, and the `\r` submits all of it. **`sendwait` takes one line, +though:** its Enter loop and the server's submit check cannot reliably recognise a +multi-line paste, so a multi-line prompt to a freshly spawned worker can sit unsent in +Claude Code's Enter-ignore window. Join the lines, or put long multi-line content in a +file ([§5.9](#59-big-input-via-the-workspace)). Build the body with `jq -n` for any prompt you did not author as a literal, because the inline `-d '{"input":"'"$P"'\r"}'` pattern breaks on the first double quote, backslash or `$` in a real prompt: diff --git a/skills/codeman/reference/verbs.md b/skills/codeman/reference/verbs.md index cda70197..a4f43d5a 100644 --- a/skills/codeman/reference/verbs.md +++ b/skills/codeman/reference/verbs.md @@ -304,7 +304,11 @@ Codeman types the text and sends Enter **only when the input contains a carriage return**; without it your command sits unsubmitted on the worker's prompt and everything downstream times out. No response field catches this: `delivered:true` means "written to the pane", **not** "submitted". With `useMux`, newlines are kept: the -text goes in as one paste, and the `\r` submits all of it. Build the body with `jq -n` +text goes in as one paste, and the `\r` submits all of it. **`sendwait` takes one line, +though:** its Enter loop and the server's submit check cannot reliably recognise a +multi-line paste, so a multi-line prompt to a freshly spawned worker can sit unsent in +Claude Code's Enter-ignore window. Join the lines, or put long multi-line content in a +file ([§5.9](#59-big-input-via-the-workspace)). Build the body with `jq -n` for any prompt you did not author as a literal, because the inline `-d '{"input":"'"$P"'\r"}'` pattern breaks on the first double quote, backslash or `$` in a real prompt: From 2fe9105bab5c4cf180a6a1af1281ba1f0d4ee000 Mon Sep 17 00:00:00 2001 From: "Claudia[bot]" <4437565+Claudia[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:31:05 +0200 Subject: [PATCH 50/96] fix(skill): sendwait joins a multi-line prompt onto one line sendwait posted its prompt as is. With the mux now keeping line breaks, a multi-line prompt goes in as a paste, and right after spawn_worker returns it can land inside Claude Code's Enter-ignore window. Neither the Enter loop here nor the server's submit check recognises a pasted block that is still on the composer, so `Review this:\n- item 1` sat unsent while the re-wait ran out. sendwait now trims each line, drops blank ones and joins the rest with a space before building the body, the same result as toSingleLine, like every other waiting sender. The preamble stamp moves to 1.43.1 so cached preambles pick the change up; the plugin mirror is regenerated with scripts/sync-plugin.mjs. The new test runs the real preamble.sh with CURL stubbed and fails without the join. docs/api-reference.md gets the submit-check sentence the README and wiki already carry. Co-Authored-By: Christian Haberl <116439789+christianhaberl@users.noreply.github.com> Co-Authored-By: Claude <noreply@anthropic.com> --- docs/api-reference.md | 4 +- plugins/codeman/skills/codeman/SKILL.md | 21 +++-- plugins/codeman/skills/codeman/preamble.sh | 13 ++- .../skills/codeman/reference/recipes.md | 2 +- skills/codeman/SKILL.md | 21 +++-- skills/codeman/preamble.sh | 13 ++- skills/codeman/reference/recipes.md | 2 +- test/agent-skill-sendwait.test.ts | 80 +++++++++++++++++++ 8 files changed, 137 insertions(+), 19 deletions(-) create mode 100644 test/agent-skill-sendwait.test.ts diff --git a/docs/api-reference.md b/docs/api-reference.md index 1bd86888..b1323a58 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -437,7 +437,9 @@ the raw write, and an explicit `"useMux": false` forces it. With `"useMux": true`, a line feed is kept: the text goes in as one bracketed paste (`\r\n` read as `\n`, leading and trailing line breaks dropped), then Enter if the input carried a `\r`; a pane that has not enabled bracketed paste receives each line -break as a carriage return and runs the lines as they arrive. +break as a carriage return and runs the lines as they arrive. The check that +re-presses a stranded Enter cannot reliably recognise a multi-line paste, so a long +multi-line prompt to a just-started agent is still safer in a file it is told to read. ```bash curl -s -X POST "$API/api/v1/sessions/$SID/input" \ diff --git a/plugins/codeman/skills/codeman/SKILL.md b/plugins/codeman/skills/codeman/SKILL.md index e67e96c9..621748ae 100644 --- a/plugins/codeman/skills/codeman/SKILL.md +++ b/plugins/codeman/skills/codeman/SKILL.md @@ -70,7 +70,7 @@ later call opens with, and your first REAL call performs them anyway: ```bash . "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null -[ "${CODEMAN_PREAMBLE:-}" = 1.42.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; } +[ "${CODEMAN_PREAMBLE:-}" = 1.43.1 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; } ``` ⚠️ **Never spend a Bash call on this check alone.** §1's block opens with this same @@ -98,8 +98,8 @@ PRE="${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" mkdir -p "$(dirname "$PRE")" # Rewrite unless the file already ends with THIS version's stamp, so a stale or a # half-written file self-heals here instead of costing you a round trip to rm it. -grep -qs '^CODEMAN_PREAMBLE=1.42.0$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE' -# ---- Codeman agent preamble 1.42.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ---- +grep -qs '^CODEMAN_PREAMBLE=1.43.1$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE' +# ---- Codeman agent preamble 1.43.1 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ---- # Positional parameters are always written with braces, ${1} and never a bare dollar-digit: Claude Code # replaces a bare one in a skill body with the matching word of the skill's arguments, and §0 is pasted # from that body (test/agent-skill.test.ts pins it). @@ -339,6 +339,15 @@ spawn_workers() { # pane clearly finished means that profile, so switch that worker to markers. sendwait() { local sid="${1:?}" p="${2:?}" seq="${3:-$(date +%s)}" body r c head n=0 tmp bg i + # One line, like every other waiting sender (`toSingleLine`, `codeman agent send`, + # the TUI): the server pastes a multi-line prompt as one block, and the loop below, + # like the server's own submit check, cannot recognise a pasted block that is still + # on the composer, so a prompt like `Review this:\n- item 1` would sit unsent for + # the whole re-wait. Each line is trimmed, blank lines are dropped, the rest are + # joined with a space. For a long multi-line task, write a file and send its path. + case "$p" in *$'\n'*|*$'\r'*) + p=$(printf '%s\n' "$p" | tr '\r' '\n' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e '/^$/d' | paste -sd ' ' -) ;; + esac # `wait:"stop,exit"`, never the `wait:true` default set: that set also carries # `idle`, which is INFERRED from output stabilization and flaps mid-turn. On a # dsh worker whose TUI repaints rarely the session reads `idle` while the model @@ -410,10 +419,10 @@ last_text() { # The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept # bare on purpose: the write condition above anchors on it with $, so an inline comment # here would fail that match and rewrite this file on every single bootstrap. -CODEMAN_PREAMBLE=1.42.0 +CODEMAN_PREAMBLE=1.43.1 PREAMBLE ) -. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.42.0 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; } +. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.43.1 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; } ``` Every later Bash call that touches the API starts with the same two loader lines from @@ -464,7 +473,7 @@ and no per-call body to hand-build. ```bash . "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null # §0 loader -[ "${CODEMAN_PREAMBLE:-}" = 1.42.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; } +[ "${CODEMAN_PREAMBLE:-}" = 1.43.1 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; } N=(alpha beta) # INVENT one fresh case name per worker; never list cases first # (a name may carry a mode: `beta:deepseek`, see below) T=('reply with one line: the absolute path of your working directory' diff --git a/plugins/codeman/skills/codeman/preamble.sh b/plugins/codeman/skills/codeman/preamble.sh index 43c30afa..b6dd5f42 100644 --- a/plugins/codeman/skills/codeman/preamble.sh +++ b/plugins/codeman/skills/codeman/preamble.sh @@ -1,4 +1,4 @@ -# ---- Codeman agent preamble 1.42.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ---- +# ---- Codeman agent preamble 1.43.1 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ---- # Positional parameters are always written with braces, ${1} and never a bare dollar-digit: Claude Code # replaces a bare one in a skill body with the matching word of the skill's arguments, and §0 is pasted # from that body (test/agent-skill.test.ts pins it). @@ -238,6 +238,15 @@ spawn_workers() { # pane clearly finished means that profile, so switch that worker to markers. sendwait() { local sid="${1:?}" p="${2:?}" seq="${3:-$(date +%s)}" body r c head n=0 tmp bg i + # One line, like every other waiting sender (`toSingleLine`, `codeman agent send`, + # the TUI): the server pastes a multi-line prompt as one block, and the loop below, + # like the server's own submit check, cannot recognise a pasted block that is still + # on the composer, so a prompt like `Review this:\n- item 1` would sit unsent for + # the whole re-wait. Each line is trimmed, blank lines are dropped, the rest are + # joined with a space. For a long multi-line task, write a file and send its path. + case "$p" in *$'\n'*|*$'\r'*) + p=$(printf '%s\n' "$p" | tr '\r' '\n' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e '/^$/d' | paste -sd ' ' -) ;; + esac # `wait:"stop,exit"`, never the `wait:true` default set: that set also carries # `idle`, which is INFERRED from output stabilization and flaps mid-turn. On a # dsh worker whose TUI repaints rarely the session reads `idle` while the model @@ -309,4 +318,4 @@ last_text() { # The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept # bare on purpose: the write condition above anchors on it with $, so an inline comment # here would fail that match and rewrite this file on every single bootstrap. -CODEMAN_PREAMBLE=1.42.0 +CODEMAN_PREAMBLE=1.43.1 diff --git a/plugins/codeman/skills/codeman/reference/recipes.md b/plugins/codeman/skills/codeman/reference/recipes.md index be1db504..99ef3108 100644 --- a/plugins/codeman/skills/codeman/reference/recipes.md +++ b/plugins/codeman/skills/codeman/reference/recipes.md @@ -21,7 +21,7 @@ by sourcing the preamble file the §0 bootstrap wrote, and checking its version ```bash . "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null -[ "${CODEMAN_PREAMBLE:-}" = 1.42.0 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; } +[ "${CODEMAN_PREAMBLE:-}" = 1.43.1 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; } ``` Do **not** re-paste the preamble body into each call. Sourcing it is what retires the diff --git a/skills/codeman/SKILL.md b/skills/codeman/SKILL.md index e67e96c9..621748ae 100644 --- a/skills/codeman/SKILL.md +++ b/skills/codeman/SKILL.md @@ -70,7 +70,7 @@ later call opens with, and your first REAL call performs them anyway: ```bash . "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null -[ "${CODEMAN_PREAMBLE:-}" = 1.42.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; } +[ "${CODEMAN_PREAMBLE:-}" = 1.43.1 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; } ``` ⚠️ **Never spend a Bash call on this check alone.** §1's block opens with this same @@ -98,8 +98,8 @@ PRE="${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" mkdir -p "$(dirname "$PRE")" # Rewrite unless the file already ends with THIS version's stamp, so a stale or a # half-written file self-heals here instead of costing you a round trip to rm it. -grep -qs '^CODEMAN_PREAMBLE=1.42.0$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE' -# ---- Codeman agent preamble 1.42.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ---- +grep -qs '^CODEMAN_PREAMBLE=1.43.1$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE' +# ---- Codeman agent preamble 1.43.1 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ---- # Positional parameters are always written with braces, ${1} and never a bare dollar-digit: Claude Code # replaces a bare one in a skill body with the matching word of the skill's arguments, and §0 is pasted # from that body (test/agent-skill.test.ts pins it). @@ -339,6 +339,15 @@ spawn_workers() { # pane clearly finished means that profile, so switch that worker to markers. sendwait() { local sid="${1:?}" p="${2:?}" seq="${3:-$(date +%s)}" body r c head n=0 tmp bg i + # One line, like every other waiting sender (`toSingleLine`, `codeman agent send`, + # the TUI): the server pastes a multi-line prompt as one block, and the loop below, + # like the server's own submit check, cannot recognise a pasted block that is still + # on the composer, so a prompt like `Review this:\n- item 1` would sit unsent for + # the whole re-wait. Each line is trimmed, blank lines are dropped, the rest are + # joined with a space. For a long multi-line task, write a file and send its path. + case "$p" in *$'\n'*|*$'\r'*) + p=$(printf '%s\n' "$p" | tr '\r' '\n' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e '/^$/d' | paste -sd ' ' -) ;; + esac # `wait:"stop,exit"`, never the `wait:true` default set: that set also carries # `idle`, which is INFERRED from output stabilization and flaps mid-turn. On a # dsh worker whose TUI repaints rarely the session reads `idle` while the model @@ -410,10 +419,10 @@ last_text() { # The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept # bare on purpose: the write condition above anchors on it with $, so an inline comment # here would fail that match and rewrite this file on every single bootstrap. -CODEMAN_PREAMBLE=1.42.0 +CODEMAN_PREAMBLE=1.43.1 PREAMBLE ) -. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.42.0 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; } +. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.43.1 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; } ``` Every later Bash call that touches the API starts with the same two loader lines from @@ -464,7 +473,7 @@ and no per-call body to hand-build. ```bash . "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null # §0 loader -[ "${CODEMAN_PREAMBLE:-}" = 1.42.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; } +[ "${CODEMAN_PREAMBLE:-}" = 1.43.1 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; } N=(alpha beta) # INVENT one fresh case name per worker; never list cases first # (a name may carry a mode: `beta:deepseek`, see below) T=('reply with one line: the absolute path of your working directory' diff --git a/skills/codeman/preamble.sh b/skills/codeman/preamble.sh index 43c30afa..b6dd5f42 100644 --- a/skills/codeman/preamble.sh +++ b/skills/codeman/preamble.sh @@ -1,4 +1,4 @@ -# ---- Codeman agent preamble 1.42.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ---- +# ---- Codeman agent preamble 1.43.1 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ---- # Positional parameters are always written with braces, ${1} and never a bare dollar-digit: Claude Code # replaces a bare one in a skill body with the matching word of the skill's arguments, and §0 is pasted # from that body (test/agent-skill.test.ts pins it). @@ -238,6 +238,15 @@ spawn_workers() { # pane clearly finished means that profile, so switch that worker to markers. sendwait() { local sid="${1:?}" p="${2:?}" seq="${3:-$(date +%s)}" body r c head n=0 tmp bg i + # One line, like every other waiting sender (`toSingleLine`, `codeman agent send`, + # the TUI): the server pastes a multi-line prompt as one block, and the loop below, + # like the server's own submit check, cannot recognise a pasted block that is still + # on the composer, so a prompt like `Review this:\n- item 1` would sit unsent for + # the whole re-wait. Each line is trimmed, blank lines are dropped, the rest are + # joined with a space. For a long multi-line task, write a file and send its path. + case "$p" in *$'\n'*|*$'\r'*) + p=$(printf '%s\n' "$p" | tr '\r' '\n' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e '/^$/d' | paste -sd ' ' -) ;; + esac # `wait:"stop,exit"`, never the `wait:true` default set: that set also carries # `idle`, which is INFERRED from output stabilization and flaps mid-turn. On a # dsh worker whose TUI repaints rarely the session reads `idle` while the model @@ -309,4 +318,4 @@ last_text() { # The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept # bare on purpose: the write condition above anchors on it with $, so an inline comment # here would fail that match and rewrite this file on every single bootstrap. -CODEMAN_PREAMBLE=1.42.0 +CODEMAN_PREAMBLE=1.43.1 diff --git a/skills/codeman/reference/recipes.md b/skills/codeman/reference/recipes.md index be1db504..99ef3108 100644 --- a/skills/codeman/reference/recipes.md +++ b/skills/codeman/reference/recipes.md @@ -21,7 +21,7 @@ by sourcing the preamble file the §0 bootstrap wrote, and checking its version ```bash . "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null -[ "${CODEMAN_PREAMBLE:-}" = 1.42.0 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; } +[ "${CODEMAN_PREAMBLE:-}" = 1.43.1 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; } ``` Do **not** re-paste the preamble body into each call. Sourcing it is what retires the diff --git a/test/agent-skill-sendwait.test.ts b/test/agent-skill-sendwait.test.ts new file mode 100644 index 00000000..d0d45e53 --- /dev/null +++ b/test/agent-skill-sendwait.test.ts @@ -0,0 +1,80 @@ +/** + * @fileoverview `sendwait` in the packaged agent preamble sends one line. + * + * Runs the REAL `skills/codeman/preamble.sh` in bash with `CURL` replaced by a stub + * that records the request body, so it checks what the function puts on the wire, + * not a copy of its logic. The server pastes a multi-line prompt as one block, and + * neither sendwait's Enter loop nor the server's submit check can recognise a pasted + * block still sitting on the composer, so the prompt must arrive as one line, the + * way `toSingleLine` joins it for the other unattended senders. + * + * Port: N/A (no server; the stub answers every request). + */ + +import { describe, it, expect } from 'vitest'; +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { toSingleLine } from '../src/utils/single-line.js'; + +const PREAMBLE = join(__dirname, '..', 'skills', 'codeman', 'preamble.sh'); + +// The preamble builds every request body with jq. +let hasJq = true; +try { + execFileSync('jq', ['--version'], { stdio: 'ignore' }); +} catch { + hasJq = false; +} + +/** The `input` field of the first body sendwait posts for `prompt`. */ +function sentInput(prompt: string): string { + // sendwait calls CURL inside $(...), a subshell, so the stub records to a file. + const dir = mkdtempSync(join(tmpdir(), 'codeman-sendwait-')); + const bodies = join(dir, 'bodies'); + const script = ` + . "$PREAMBLE" >/dev/null + stub() { + while [ "$#" -gt 0 ]; do + if [ "$1" = --data-binary ]; then printf '%s\\n' "$2" >> "$BODIES"; shift; fi + shift + done + printf '%s' '{"data":{"delivered":true,"wait":{"timedOut":false}}}' + } + CURL=(stub) + sendwait worker1234 "$PROMPT" 7 >/dev/null + `; + try { + execFileSync('bash', ['-c', script], { + env: { + PATH: process.env.PATH, + HOME: process.env.HOME, + PREAMBLE, + PROMPT: prompt, + BODIES: bodies, + CODEMAN_API_URL: 'http://127.0.0.1:9', + CODEMAN_SESSION_ID: 'caller-session-0000', + }, + }); + const first = readFileSync(bodies, 'utf-8').split('\n')[0]; + return (JSON.parse(first) as { input: string }).input; + } finally { + rmSync(dir, { recursive: true, force: true }); + } +} + +describe.skipIf(!hasJq)('preamble sendwait', () => { + it('joins a multi-line prompt onto one line before sending it', () => { + expect(sentInput('Review this:\n- item 1\n- item 2')).toBe('Review this: - item 1 - item 2\r'); + }); + + it('joins the same way as toSingleLine (trimmed lines, blank lines dropped, \\r\\n as a break)', () => { + const prompt = ' first line \r\n\n\t second\n\n \nthird\n'; + expect(sentInput(prompt)).toBe(toSingleLine(prompt) + '\r'); + }); + + it('leaves a single-line prompt, glob characters included, as it was', () => { + expect(sentInput('fix * in ?.ts ')).toBe('fix * in ?.ts \r'); + }); +}); From 9a00c2feaac7cbbddc653d59dfcc104e2ea48861 Mon Sep 17 00:00:00 2001 From: "Claudia[bot]" <4437565+Claudia[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:31:06 +0200 Subject: [PATCH 51/96] fix(tmux): drop a leading blank line made of spaces, not only line breaks `" \nfix it\r"` still pasted a blank first line, because the leading strip only matched `\n`. /^\s*\n/ drops everything up to the last leading line break, mirroring the trimEnd() at the other end, and the first real line keeps its indentation. The test row fails with the old pattern. Co-Authored-By: Christian Haberl <116439789+christianhaberl@users.noreply.github.com> Co-Authored-By: Claude <noreply@anthropic.com> --- src/tmux-manager.ts | 11 ++++++++--- test/tmux-submit-paste.test.ts | 5 +++++ 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index 9e2fd296..6c2cde4d 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -3863,9 +3863,14 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { try { const hasCarriageReturn = input.includes('\r'); - // Leading and trailing line breaks are dropped, so `"\nfix it\r"` does not paste - // an empty first line (nor `"fix it\n\r"` an empty last one). - const textPart = input.replace(/\r/g, '').replace(/^\n+/, '').trimEnd(); + // Leading and trailing blank lines are dropped, so neither `"\nfix it\r"` nor + // `" \nfix it\r"` pastes an empty first line (nor `"fix it\n\r"` an empty last + // one). The leading match ends at a line break, so the first real line keeps its + // indentation. + const textPart = input + .replace(/\r/g, '') + .replace(/^\s*\n/, '') + .trimEnd(); if (textPart && hasCarriageReturn) { // Send text first, then Enter as a SEPARATE tmux command after a short delay. diff --git a/test/tmux-submit-paste.test.ts b/test/tmux-submit-paste.test.ts index e76cf820..39eb1d55 100644 --- a/test/tmux-submit-paste.test.ts +++ b/test/tmux-submit-paste.test.ts @@ -261,6 +261,11 @@ describe('sendInput line breaks', () => { await multi.sendInput('s1', '\r\n\r\none\ntwo\r'); expect(bufferText(multi.ran[0])).toBe('one\ntwo'); + // A blank first line made of spaces is dropped too; the first real line keeps its indentation. + const spaced = managerWithSession(); + await spaced.sendInput('s1', ' \n\t\n fix it\nnow\r'); + expect(bufferText(spaced.ran[0])).toBe(' fix it\nnow'); + // Without an Enter, what is left is one line, so it is typed like any other. const typed = managerWithSession(); await typed.sendInput('s1', '\n\njust type'); From b9b6ce57b742879b3db0b42f34170e64641eee2f Mon Sep 17 00:00:00 2001 From: "Claudia[bot]" <4437565+Claudia[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:38:17 +0200 Subject: [PATCH 52/96] docs(changeset): mention that sendwait now joins multi-line prompts Co-Authored-By: Christian Haberl <116439789+christianhaberl@users.noreply.github.com> Co-Authored-By: Claude <noreply@anthropic.com> --- .changeset/multiline-input-paste.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/multiline-input-paste.md b/.changeset/multiline-input-paste.md index 8d63d181..f72280ac 100644 --- a/.changeset/multiline-input-paste.md +++ b/.changeset/multiline-input-paste.md @@ -2,4 +2,4 @@ 'aicodeman': patch --- -A multi-line prompt sent through the mux (`POST /api/sessions/:id/input` with `useMux`) now keeps its line breaks. `sendInput` used to strip every `\n` before the text reached tmux, so `"line one\nline two\r"` arrived as `line oneline two`. The lines now go in as one bracketed paste, the same paste a single-line prompt already uses, and the Enter after it submits them together; multi-line text without an Enter is pasted too instead of typed. A shell runs each line. The submit check that re-presses a stranded Enter does not yet recognise a multi-line paste in the composer, so the unattended senders stay single-line: cron still rejects multi-line prompts, respawn's update and kickstart prompts and the auto-compact focus prompt now have their lines joined with a space (they used to run together), and `codeman agent send` and the TUI keep refusing or joining multi-line input. +A multi-line prompt sent through the mux (`POST /api/sessions/:id/input` with `useMux`) now keeps its line breaks. `sendInput` used to strip every `\n` before the text reached tmux, so `"line one\nline two\r"` arrived as `line oneline two`. The lines now go in as one bracketed paste, the same paste a single-line prompt already uses, and the Enter after it submits them together; multi-line text without an Enter is pasted too instead of typed. A shell runs each line. The submit check that re-presses a stranded Enter does not yet recognise a multi-line paste in the composer, so the unattended senders stay single-line: cron still rejects multi-line prompts, respawn's update and kickstart prompts and the auto-compact focus prompt now have their lines joined with a space (they used to run together), `codeman agent send` and the TUI keep refusing or joining multi-line input, and the agent skill's `sendwait` now joins the lines of a multi-line prompt with a space too. From 8c8ff383c28afbb5955cb47c66738eb649867082 Mon Sep 17 00:00:00 2001 From: "Claudia[bot]" <4437565+Claudia[bot]@users.noreply.github.com> Date: Wed, 21 Oct 2026 23:42:12 +0200 Subject: [PATCH 53/96] fix(skill): sendwait refuses an empty prompt; verbs.md says it joins lines A prompt made only of blank lines joined to nothing, and sendwait then sent a bare Enter onto whatever sat on the worker's composer. It now refuses with "sendwait: empty prompt" (the test fails without the check). reference/verbs.md still said sendwait takes one line and a multi-line prompt can sit unsent; it now says sendwait joins the lines with a space, which loses their layout, so long multi-line content belongs in a file. Plugin mirror regenerated. Co-Authored-By: Christian Haberl <116439789+christianhaberl@users.noreply.github.com> Co-Authored-By: Claude <noreply@anthropic.com> --- plugins/codeman/skills/codeman/SKILL.md | 1 + plugins/codeman/skills/codeman/preamble.sh | 1 + plugins/codeman/skills/codeman/reference/verbs.md | 9 +++++---- skills/codeman/SKILL.md | 1 + skills/codeman/preamble.sh | 1 + skills/codeman/reference/verbs.md | 9 +++++---- test/agent-skill-sendwait.test.ts | 5 +++++ 7 files changed, 19 insertions(+), 8 deletions(-) diff --git a/plugins/codeman/skills/codeman/SKILL.md b/plugins/codeman/skills/codeman/SKILL.md index 621748ae..c6c85f4a 100644 --- a/plugins/codeman/skills/codeman/SKILL.md +++ b/plugins/codeman/skills/codeman/SKILL.md @@ -348,6 +348,7 @@ sendwait() { case "$p" in *$'\n'*|*$'\r'*) p=$(printf '%s\n' "$p" | tr '\r' '\n' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e '/^$/d' | paste -sd ' ' -) ;; esac + [ -n "$p" ] || { echo "sendwait: empty prompt" >&2; return 1; } # `wait:"stop,exit"`, never the `wait:true` default set: that set also carries # `idle`, which is INFERRED from output stabilization and flaps mid-turn. On a # dsh worker whose TUI repaints rarely the session reads `idle` while the model diff --git a/plugins/codeman/skills/codeman/preamble.sh b/plugins/codeman/skills/codeman/preamble.sh index b6dd5f42..a0e95a32 100644 --- a/plugins/codeman/skills/codeman/preamble.sh +++ b/plugins/codeman/skills/codeman/preamble.sh @@ -247,6 +247,7 @@ sendwait() { case "$p" in *$'\n'*|*$'\r'*) p=$(printf '%s\n' "$p" | tr '\r' '\n' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e '/^$/d' | paste -sd ' ' -) ;; esac + [ -n "$p" ] || { echo "sendwait: empty prompt" >&2; return 1; } # `wait:"stop,exit"`, never the `wait:true` default set: that set also carries # `idle`, which is INFERRED from output stabilization and flaps mid-turn. On a # dsh worker whose TUI repaints rarely the session reads `idle` while the model diff --git a/plugins/codeman/skills/codeman/reference/verbs.md b/plugins/codeman/skills/codeman/reference/verbs.md index a4f43d5a..fa91a749 100644 --- a/plugins/codeman/skills/codeman/reference/verbs.md +++ b/plugins/codeman/skills/codeman/reference/verbs.md @@ -304,11 +304,12 @@ Codeman types the text and sends Enter **only when the input contains a carriage return**; without it your command sits unsubmitted on the worker's prompt and everything downstream times out. No response field catches this: `delivered:true` means "written to the pane", **not** "submitted". With `useMux`, newlines are kept: the -text goes in as one paste, and the `\r` submits all of it. **`sendwait` takes one line, +text goes in as one paste, and the `\r` submits all of it. **`sendwait` sends one line, though:** its Enter loop and the server's submit check cannot reliably recognise a -multi-line paste, so a multi-line prompt to a freshly spawned worker can sit unsent in -Claude Code's Enter-ignore window. Join the lines, or put long multi-line content in a -file ([§5.9](#59-big-input-via-the-workspace)). Build the body with `jq -n` +multi-line paste, so a multi-line prompt to a freshly spawned worker could sit unsent in +Claude Code's Enter-ignore window. `sendwait` therefore joins the lines with a space, +which loses their layout; put long multi-line content in a file +([§5.9](#59-big-input-via-the-workspace)). Build the body with `jq -n` for any prompt you did not author as a literal, because the inline `-d '{"input":"'"$P"'\r"}'` pattern breaks on the first double quote, backslash or `$` in a real prompt: diff --git a/skills/codeman/SKILL.md b/skills/codeman/SKILL.md index 621748ae..c6c85f4a 100644 --- a/skills/codeman/SKILL.md +++ b/skills/codeman/SKILL.md @@ -348,6 +348,7 @@ sendwait() { case "$p" in *$'\n'*|*$'\r'*) p=$(printf '%s\n' "$p" | tr '\r' '\n' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e '/^$/d' | paste -sd ' ' -) ;; esac + [ -n "$p" ] || { echo "sendwait: empty prompt" >&2; return 1; } # `wait:"stop,exit"`, never the `wait:true` default set: that set also carries # `idle`, which is INFERRED from output stabilization and flaps mid-turn. On a # dsh worker whose TUI repaints rarely the session reads `idle` while the model diff --git a/skills/codeman/preamble.sh b/skills/codeman/preamble.sh index b6dd5f42..a0e95a32 100644 --- a/skills/codeman/preamble.sh +++ b/skills/codeman/preamble.sh @@ -247,6 +247,7 @@ sendwait() { case "$p" in *$'\n'*|*$'\r'*) p=$(printf '%s\n' "$p" | tr '\r' '\n' | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//' -e '/^$/d' | paste -sd ' ' -) ;; esac + [ -n "$p" ] || { echo "sendwait: empty prompt" >&2; return 1; } # `wait:"stop,exit"`, never the `wait:true` default set: that set also carries # `idle`, which is INFERRED from output stabilization and flaps mid-turn. On a # dsh worker whose TUI repaints rarely the session reads `idle` while the model diff --git a/skills/codeman/reference/verbs.md b/skills/codeman/reference/verbs.md index a4f43d5a..fa91a749 100644 --- a/skills/codeman/reference/verbs.md +++ b/skills/codeman/reference/verbs.md @@ -304,11 +304,12 @@ Codeman types the text and sends Enter **only when the input contains a carriage return**; without it your command sits unsubmitted on the worker's prompt and everything downstream times out. No response field catches this: `delivered:true` means "written to the pane", **not** "submitted". With `useMux`, newlines are kept: the -text goes in as one paste, and the `\r` submits all of it. **`sendwait` takes one line, +text goes in as one paste, and the `\r` submits all of it. **`sendwait` sends one line, though:** its Enter loop and the server's submit check cannot reliably recognise a -multi-line paste, so a multi-line prompt to a freshly spawned worker can sit unsent in -Claude Code's Enter-ignore window. Join the lines, or put long multi-line content in a -file ([§5.9](#59-big-input-via-the-workspace)). Build the body with `jq -n` +multi-line paste, so a multi-line prompt to a freshly spawned worker could sit unsent in +Claude Code's Enter-ignore window. `sendwait` therefore joins the lines with a space, +which loses their layout; put long multi-line content in a file +([§5.9](#59-big-input-via-the-workspace)). Build the body with `jq -n` for any prompt you did not author as a literal, because the inline `-d '{"input":"'"$P"'\r"}'` pattern breaks on the first double quote, backslash or `$` in a real prompt: diff --git a/test/agent-skill-sendwait.test.ts b/test/agent-skill-sendwait.test.ts index d0d45e53..b38d412f 100644 --- a/test/agent-skill-sendwait.test.ts +++ b/test/agent-skill-sendwait.test.ts @@ -47,6 +47,7 @@ function sentInput(prompt: string): string { `; try { execFileSync('bash', ['-c', script], { + stdio: ['ignore', 'pipe', 'pipe'], env: { PATH: process.env.PATH, HOME: process.env.HOME, @@ -74,6 +75,10 @@ describe.skipIf(!hasJq)('preamble sendwait', () => { expect(sentInput(prompt)).toBe(toSingleLine(prompt) + '\r'); }); + it('refuses a prompt made only of blank lines instead of sending a bare Enter', () => { + expect(() => sentInput(' \n \t\n')).toThrow(/sendwait: empty prompt/); + }); + it('leaves a single-line prompt, glob characters included, as it was', () => { expect(sentInput('fix * in ?.ts ')).toBe('fix * in ?.ts \r'); }); From 242bbfcd316a8b7ccb830a122aa07c80393e9a34 Mon Sep 17 00:00:00 2001 From: Saqeb Akhter <saqeb.akhter@gmail.com> Date: Thu, 22 Oct 2026 02:01:17 +0400 Subject: [PATCH 54/96] fix(terminal): capture remote history and preserve isolated wheel travel (#7) --- config/test-suites.ts | 1 + docs/architecture-invariants.md | 4 +- src/remote-hosts.ts | 8 +++ src/remote-pane-history.ts | 13 +++- src/web/public/terminal-tile.js | 6 +- src/web/public/terminal-ui.js | 20 +++--- test/codex-wheel-routing.browser.test.ts | 82 ++++++++++++++++++++++++ test/remote-pane-history.test.ts | 36 ++++++++++- test/terminal-scroll-routing.test.ts | 19 ++++++ 9 files changed, 173 insertions(+), 16 deletions(-) create mode 100644 test/codex-wheel-routing.browser.test.ts diff --git a/config/test-suites.ts b/config/test-suites.ts index 2148a467..eb10e9a9 100644 --- a/config/test-suites.ts +++ b/config/test-suites.ts @@ -37,6 +37,7 @@ export const BROWSER_TEST_GLOBS = [ 'test/codex-predictive-echo.test.ts', // also needs a real codex binary 'test/split-pane-terminal.browser.test.ts', 'test/terminal-tile-scroll.browser.test.ts', + 'test/codex-wheel-routing.browser.test.ts', 'test/terminal-touch-paging.browser.test.ts', 'test/shift-enter-keypress.browser.test.ts', 'test/key-tester.browser.test.ts', diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 90174cca..debd7fba 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -225,13 +225,13 @@ Further detail: the `<prefix>: <title>` form (`w3-myapp: fix the login redirect` ⚠️ **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. -**Wheel forwarding requires observed mouse tracking: Claude ≥ 2.1.187, tracked Codex or tracked Copilot** (`terminal-ui.js:_shouldForwardWheelToApp`). The original #227 measurement remains valid for **Codex 0.147.0 inline**: `alternate_on=0`, `mouse_any_flag=0`, growing tmux history, and SGR wheel reports do nothing. Claude 2.1.280 inline similarly reports `alternate_on=0`, `mouse_any_flag=0`; forwarding there swallows gestures, so it keeps local scrollback. Fullscreen Claude (`CLAUDE_CODE_NO_FLICKER=1` or `"tui": "fullscreen"`) enables mouse tracking and accepts reports. **Fresh Codex 0.163.0 fullscreen measurement (2026-10-18, isolated local tmux pane)**: `alternate_on=1`, `mouse_any_flag=1`; one `\x1b[<64;10;10M` report moves the transcript one row upward, and `\x1b[<65;10;10M` restores the original screen. This is capability evidence, not a minimum-version promise: untracked Codex (including the maintainer's 0.162.0 measurement with `mouse_any_flag=0`) keeps local history or the hollow-pane paging fallback. Codex does not publish the Claude-only CLI version field, so its gate requires the observed flag rather than a Claude version. The registry records this as `wheelForward.mode: 'always'` (observed mouse tracking is still required); Claude remains `version-gated`. A stale/false flag after startup cannot authorize forwarding. ⚠️ A strip mode alone is not evidence that a CLI consumes SGR reports. Verify with a real `\\x1b[<64;c;rM` write into a live pane before adding a mode here. Hand-encoded SGR taps remain independently gated by `_shouldReportMouseToCli()`; the older #325 no-op measurement describes untracked inline Codex and must not be generalized to newer tracked fullscreen renderers. Shift/local-history opt-outs and xterm's native mouse/alternate-screen handling remain intact. +**Wheel forwarding requires observed mouse tracking: Claude ≥ 2.1.187, tracked Codex or tracked Copilot** (`terminal-ui.js:_shouldForwardWheelToApp`). The original #227 measurement remains valid for **Codex 0.147.0 inline**: `alternate_on=0`, `mouse_any_flag=0`, growing tmux history, and SGR wheel reports do nothing. Claude 2.1.280 inline similarly reports `alternate_on=0`, `mouse_any_flag=0`; forwarding there swallows gestures, so it keeps local scrollback. Fullscreen Claude (`CLAUDE_CODE_NO_FLICKER=1` or `"tui": "fullscreen"`) enables mouse tracking and accepts reports. **Fresh Codex 0.163.0 fullscreen measurement (2026-10-18, isolated local tmux pane)**: `alternate_on=1`, `mouse_any_flag=1`; one `\x1b[<64;10;10M` report moves the transcript one row upward, and `\x1b[<65;10;10M` restores the original screen. This is capability evidence, not a minimum-version promise: untracked Codex (including a 0.162.0 measurement (2026-10) with `mouse_any_flag=0`) keeps local history or the hollow-pane paging fallback. Codex does not publish the Claude-only CLI version field, so its gate requires the observed flag rather than a Claude version. The registry records this as `wheelForward.mode: 'always'` (observed mouse tracking is still required); Claude remains `version-gated`. A stale/false flag after startup cannot authorize forwarding. ⚠️ A strip mode alone is not evidence that a CLI consumes SGR reports. Verify with a real `\x1b[<64;c;rM` write into a live pane before adding a mode here. Hand-encoded SGR taps remain independently gated by `_shouldReportMouseToCli()`; the older #325 no-op measurement describes untracked inline Codex and must not be generalized to newer tracked fullscreen renderers. Shift/local-history opt-outs and xterm's native mouse/alternate-screen handling remain intact. **Copilot also receives forwarded reports** (1.0.95 measured in tmux: alt screen, `mouse_any_flag=1`): its mouse DECSETs are stripped so a drag selects, which also stops xterm encoding the wheel for it, so `_shouldForwardWheelToApp` sends the same SGR reports by hand while `cliMouseTracking` is true; six `\x1b[<64;…M` reports moved its transcript 18 lines, a phone swipe takes the same gate, and there is NO version gate. Codex 0.163.0 approval check in that isolated read-only/on-request session: the "Would you like to run the following command?" prompt remained on `1. Yes, proceed` after SGR wheel-up and wheel-down over both the transcript and approval option rows 36/37; the requested marker file was not created. The background transcript can scroll while the approval prompt remains fixed. Codex has no Claude hook alert, so a `tabAlerts === 'action'` test cannot detect this prompt and is not a native-wheel safety gate. Native Codex owns its approval selection; do not mistake a hook-only unit fixture for approval-dialog evidence. -Native wheel travel retains upstream pixel/line/page units (100 px = 4 lines; Firefox 3-line events = 3 lines); no one-tick-per-event policy is introduced here. A fresh native gesture at or above 0.1 row (2.5px) pre-pays one tick when its opening travel is under a row, so an isolated 4px mouse click responds. The signed remainder is debt repaid by subsequent same-direction events: ten 2.5px events still total one tick. Debt/fractions expire after a 150ms gap; a reversal inside that gap discards the old debt without another pre-payment. Sub-threshold jitter does not pre-pay. The first native report of each tab visit bypasses the 40 ms batching timer; later reports coalesce and only unsent bytes can be cancelled on reversal. Every queued batch belongs to its originating session, so switching tabs cannot send its bytes into the newly selected pane. Zero-delta events leave pending movement untouched. Tiles use the same helpers and their own queue/session. +Native wheel travel retains upstream pixel/line/page units (100 px = 4 lines; Firefox 3-line events = 3 lines); no one-tick-per-event policy is introduced here. A fresh native gesture at or above 0.1 row (2.5px) rounds its opening travel to at least one tick (subject to the per-event cap), so an isolated 4px mouse click responds, 40px sends two ticks and 120px sends five. The signed remainder is carried forward; travel discarded by the per-event cap does not become a backlog. The signed remainder is debt repaid by subsequent same-direction events: ten 2.5px events still total one tick. Debt/fractions expire after a 150ms gap; a reversal inside that gap discards the old debt without another pre-payment. Sub-threshold jitter does not pre-pay. The first native report of each tab visit bypasses the 40 ms batching timer; later reports coalesce and only unsent bytes can be cancelled on reversal. Every queued batch belongs to its originating session, so switching tabs cannot send its bytes into the newly selected pane. Zero-delta events leave pending movement untouched. Tiles use the same helpers and their own queue/session. **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 or tracked Codex, with observed `cliMouseTracking`; see the native measurement above), the plain wheel AND touch drags forward as coalesced SGR reports (`_forwardScrollToApp` → `_sendSyntheticSgrWheel`, 40ms batches after the first native report, 5-tick per-event cap; no aggregate byte/tick limit). 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). `_wheelScrollLinesFloat()` 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`. diff --git a/src/remote-hosts.ts b/src/remote-hosts.ts index 3dea3bde..3a68aa29 100644 --- a/src/remote-hosts.ts +++ b/src/remote-hosts.ts @@ -89,6 +89,14 @@ export function remoteLoginShellCommand(command: string): string { return `exec ${REMOTE_LOGIN_SHELL} -i -l -c ${shellescape(command)}`; } +/** Standard CLI locations missing from non-interactive SSH shells (notably Homebrew). */ +export const REMOTE_PATH_BOOTSTRAP = + 'export PATH=$HOME/.opencode/bin:$HOME/.local/bin:$HOME/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH'; + +export function withRemotePathBootstrap(command: string): string { + return `${REMOTE_PATH_BOOTSTRAP}; ${command}`; +} + /** * The CLI text a location overlay should launch for `mode`, or null when this build has no * entry for it. `overlays.<location>.command` when the entry names one, otherwise the bare diff --git a/src/remote-pane-history.ts b/src/remote-pane-history.ts index f4a12e48..b3eab6b0 100644 --- a/src/remote-pane-history.ts +++ b/src/remote-pane-history.ts @@ -48,7 +48,13 @@ import { exec } from 'node:child_process'; import { randomBytes } from 'node:crypto'; import { promisify } from 'node:util'; import type { SessionRemote } from './types/session.js'; -import { buildSshConnectionArgs, classifyRemoteAliveExit, remoteSshTarget, shellescape } from './remote-hosts.js'; +import { + buildSshConnectionArgs, + classifyRemoteAliveExit, + remoteSshTarget, + shellescape, + withRemotePathBootstrap, +} from './remote-hosts.js'; import { RemoteSshSlotTimeoutError, runWithRemoteSshLimit } from './remote-ssh-limiter.js'; import { remoteTmuxLocation } from './tmux-manager.js'; @@ -153,7 +159,10 @@ export function buildRemoteScrollbackCommand( marker: string ): string { const { socket, sessionName } = remoteTmuxLocation(remote, sessionId); - const script = buildRemoteScrollbackScript(socket, sessionName, historyLines, tailBytes, marker); + // Non-interactive macOS shells omit Homebrew and other per-user CLI paths. + const script = withRemotePathBootstrap( + buildRemoteScrollbackScript(socket, sessionName, historyLines, tailBytes, marker) + ); // `exec` so a timeout kill reaches ssh itself, not only the local `sh -c`. return ['exec', ...buildSshConnectionArgs(remote), remoteSshTarget(remote), shellescape(script)].join(' '); } diff --git a/src/web/public/terminal-tile.js b/src/web/public/terminal-tile.js index e1ecf319..0c188d36 100644 --- a/src/web/public/terminal-tile.js +++ b/src/web/public/terminal-tile.js @@ -1074,11 +1074,11 @@ // SGR wheel forwarding, the twin of the primary pane's capture-phase wheel // handler and _forwardScrollToApp (terminal-ui.js; keep them in step). - // Claude's fullscreen renderer (claude 2.1.187+ while its mouse tracking is - // on, cliMouseTracking) scrolls its own transcript on SGR wheel reports, + // Fullscreen Claude (2.1.187+) and measured tracked Codex scroll their own + // transcripts on SGR wheel reports while cliMouseTracking is on, // while this xterm holds only Codeman's replayed repaint frames (tmux keeps // no history for such a pane). Left to xterm, the wheel dragged those stale - // frames, Claude's pinned input box with them, up the tile, or scrolled + // frames, including the CLI's pinned input box, up the tile, or scrolled // nothing at all. The gate is the primary pane's own, asked for THIS pane // (its terminal, its session, never the active one), so the CLI rules stay // in terminal-ui.js and this file names no CLI; the reports go to this diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 6ab39f55..45afb289 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -137,9 +137,9 @@ return `\x1b[<${btn};${pos.col};${pos.row}M`.repeat(ticks); } - // A deliberate opening event pre-pays a tick, so a lone small mouse click - // responds immediately. The signed remainder is debt repaid by subsequent - // same-direction travel: ten 0.1-line events still total one tick. Debt and + // A deliberate opening event preserves upstream rounding and pre-pays at + // least one tick, so isolated clicks retain their travel. The signed remainder + // is repaid by subsequent same-direction travel: ten 0.1-line events still total one tick. Debt and // fractions expire with a 150ms gesture gap or reversal; a reversal within // the gesture accumulates without another pre-payment (like page gestures). function wheelTicksForGesture(state, lines, now) { @@ -147,8 +147,12 @@ const dir = Math.sign(lines); const fresh = !state || typeof state.lastAt !== 'number' || now - state.lastAt > PAGE_KEY_GESTURE_GAP_MS; const reversed = state?.dir && state.dir !== dir; - if (fresh && Math.abs(lines) >= PAGE_KEY_MIN_START_ROWS && Math.abs(lines) < 1) { - return { state: { dir, lastAt: now, pending: lines - dir }, ticks: dir }; + if (fresh && Math.abs(lines) >= PAGE_KEY_MIN_START_ROWS) { + const rounded = dir * Math.max(1, Math.round(Math.abs(lines))); + return { + state: { dir, lastAt: now, pending: lines - rounded }, + ticks: dir * Math.min(Math.abs(rounded), SGR_WHEEL_MAX_TICKS), + }; } const total = (fresh || reversed ? 0 : state.pending || 0) + lines; const whole = Math.trunc(total + Math.sign(total) * 1e-9); @@ -5852,9 +5856,9 @@ Object.assign(CodemanApp.prototype, { if (sessionMode === 'copilot') return session?.cliMouseTracking === true; if (sessionMode !== 'claude' && sessionMode !== 'codex') return false; if (sessionMode === 'claude' && !this._cliVersionAtLeast(session?.cliVersion, '2.1.187')) return false; - // Only while Claude is actually listening for the mouse. In its default - // inline renderer (2.1.280 measured: alternate_on=0, mouse_any_flag=0) the - // transcript lives in real scrollback, like codex, and SGR wheel reports are + // Only while Claude or Codex is actually listening for the mouse. Claude's + // inline renderer (2.1.280 measured: alternate_on=0, mouse_any_flag=0), like + // older inline Codex, keeps its transcript in real scrollback; SGR reports are // ignored, so forwarding made every swipe and wheel tick dead. Fullscreen // (CLAUDE_CODE_NO_FLICKER=1, or "tui": "fullscreen" in ~/.claude/settings.json) // turns on alt-screen + mode 1003/1006, which the server records as diff --git a/test/codex-wheel-routing.browser.test.ts b/test/codex-wheel-routing.browser.test.ts new file mode 100644 index 00000000..0cf76b8c --- /dev/null +++ b/test/codex-wheel-routing.browser.test.ts @@ -0,0 +1,82 @@ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { chromium, type Browser } from 'playwright'; +import { WebServer } from '../src/web/server.js'; + +// Adversarial browser layout fixture, not evidence of native Codex history: +// authentic CLI behavior is measured separately in an isolated live pane. +describe('tracked remote Codex wheel routing (#13)', () => { + let server: WebServer; + let browser: Browser; + beforeAll(async () => { + server = new WebServer(0, false, true); + await server.start(); + browser = await chromium.launch({ headless: true }); + }, 60000); + afterAll(async () => { + await browser?.close(); + await server?.stop(); + }); + + it('sends plain wheel to the CLI rather than scrolling stale repaint frames', async () => { + const page = await browser.newPage({ viewport: { width: 1200, height: 800 } }); + try { + await page.goto(`http://127.0.0.1:${server.boundPort}`, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => (window as any).app?.terminal); + await page.waitForTimeout(3500); + await page.evaluate(async () => { + const app = (window as any).app; + app.activeSessionId = 'codex-wheel-probe'; + app.sessions.set(app.activeSessionId, { id: app.activeSessionId, mode: 'codex', cliMouseTracking: true }); + (window as any).scrollInputs = []; + // Cell hit testing is irrelevant to routing; the empty dashboard covers the test terminal. + app._clientPointToCell = () => ({ col: 1, row: 1 }); + app._sendInputEphemeral = (id: string, data: string) => (window as any).scrollInputs.push({ id, data }); + app.loadAppSettingsFromStorage = () => ({ terminalWheelLocalScrollback: false }); + await new Promise<void>((resolve) => app.terminal.write('\r\nold repaint row'.repeat(100), resolve)); + app.terminal.scrollToTop(); + }); + const fire = (shiftKey = false) => + page.evaluate((shiftKey) => { + const t = (window as any).app.terminal; + t.element.dispatchEvent(new WheelEvent('wheel', { deltaY: -100, shiftKey, bubbles: true, cancelable: true })); + }, shiftKey); + await fire(); + await page.waitForTimeout(150); + const forwarded = await page.evaluate(() => { + const app = (window as any).app; + return { + inputs: (window as any).scrollInputs, + baseY: app.terminal.buffer.active.baseY, + viewportY: app.terminal.buffer.active.viewportY, + }; + }); + expect(forwarded.inputs.length).toBeGreaterThan(0); + expect(forwarded.inputs.every((i: { id: string }) => i.id === 'codex-wheel-probe')).toBe(true); + expect( + forwarded.inputs + .map((i: { data: string }) => i.data) + .join('') + .match(/\x1b\[<64;/g) + ).toHaveLength(4); + expect(forwarded.viewportY).toBe(forwarded.baseY); + for (const shift of [false, true]) { + await page.evaluate((tracked) => { + const app = (window as any).app; + app.sessions.get(app.activeSessionId).cliMouseTracking = tracked; + app.terminal.scrollToBottom(); + (window as any).scrollInputs = []; + }, shift); + await fire(shift); + await page.waitForTimeout(150); + const local = await page.evaluate(() => { + const b = (window as any).app.terminal.buffer.active; + return { inputs: (window as any).scrollInputs, baseY: b.baseY, viewportY: b.viewportY }; + }); + expect(local.inputs).toEqual([]); + expect(local.viewportY).toBeLessThan(local.baseY); + } + } finally { + await page.close(); + } + }); +}); diff --git a/test/remote-pane-history.test.ts b/test/remote-pane-history.test.ts index 014e7f48..03f9aa77 100644 --- a/test/remote-pane-history.test.ts +++ b/test/remote-pane-history.test.ts @@ -11,7 +11,7 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest'; import { execFileSync } from 'node:child_process'; -import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { @@ -79,6 +79,40 @@ describe('buildRemoteScrollbackCommand', () => { }); }); +describe('remote history PATH bootstrap', () => { + it('finds tmux installed under the remote home when the login shell omits it', () => { + const root = mkdtempSync(join(tmpdir(), 'remote-history-path-')); + const home = join(root, 'remote home'); + const sshBin = join(root, 'local-bin'); + const remoteBin = join(home, '.local', 'bin'); + mkdirSync(sshBin, { recursive: true }); + mkdirSync(remoteBin, { recursive: true }); + writeFileSync(join(sshBin, 'ssh'), '#!/bin/sh\nfor arg do command="$arg"; done\nexec /bin/sh -c "$command"\n', { + mode: 0o755, + }); + writeFileSync( + join(remoteBin, 'tmux'), + `#!/bin/sh +printf 'older row\\n${MARKER}\\n1\\ncurrent screen\\n' +`, + { mode: 0o755 } + ); + try { + const command = buildRemoteScrollbackCommand(remote, SESSION_ID, 100, 0, MARKER); + const stdout = execFileSync('/bin/sh', ['-c', command], { + encoding: 'utf-8', + env: { ...process.env, HOME: home, PATH: `${sshBin}:/usr/bin:/bin` }, + }); + expect(parseRemoteScrollbackOutput(stdout, MARKER, 0)).toEqual({ + scrollback: 'older row\n', + screen: 'current screen\n', + }); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); + describe('remoteTailBytes', () => { it('adds room for the marker, size and screen to the cap, so a cut lands in the history', () => { expect(remoteTailBytes(2048)).toBe(2048 + 1024 * 1024); diff --git a/test/terminal-scroll-routing.test.ts b/test/terminal-scroll-routing.test.ts index bc0dc4cd..2694c012 100644 --- a/test/terminal-scroll-routing.test.ts +++ b/test/terminal-scroll-routing.test.ts @@ -909,6 +909,25 @@ describe('consistent scroll gesture adapters', () => { expect(loadTerminalUiHarness().windowRef.CodemanTerminalInput.PAGE_KEY_IMMEDIATE_MAX_ROWS).toBe(2); }); + it('preserves rounded travel for isolated coarse clicks without accumulating a capped backlog', () => { + const { wheelTicksForGesture } = loadTerminalUiHarness().windowRef.CodemanTerminalInput; + for (const direction of [-1, 1]) { + expect(wheelTicksForGesture(null, direction * 4.8, 0).ticks).toBe(direction * 5); // 120px + expect(wheelTicksForGesture(null, direction * 1.6, 0).ticks).toBe(direction * 2); // 40px + let state = null; + let ticks = 0; + for (let i = 0; i < 5; i++) { + const step = wheelTicksForGesture(state, direction * 4.8, i * 300); + state = step.state; + ticks += step.ticks; + } + expect(ticks).toBe(direction * 25); + const capped = wheelTicksForGesture(null, direction * 40, 0); + expect(capped.ticks).toBe(direction * 5); + expect(capped.state.pending).toBe(0); // don't retain travel discarded by the event cap + } + }); + it('pre-pays an isolated deliberate opening tick and repays its signed debt', () => { const { wheelTicksForGesture } = loadTerminalUiHarness().windowRef.CodemanTerminalInput; const opening = wheelTicksForGesture(null, -0.16, 0); // a 4px mouse click From d56b90b7c6c4673ddcb37270503f437d9ab4a061 Mon Sep 17 00:00:00 2001 From: JD <jd@jds.haus> Date: Wed, 21 Oct 2026 21:09:44 -0400 Subject: [PATCH 55/96] fix(mobile): keep the keyboard up when an accessory key is pressed An accessory key that acts on the terminal (arrows, Tab, Esc, Ctrl, scroll, clear input) took focus on press, so every tap blurred the terminal and the click handler had to focus it again: a blur/focus pair per tap that started a keyboard dismissal and shifted the toolbar each time. Such a key now cancels the mousedown default instead, the same pattern as the Attach input, so the terminal never loses focus on the press; the click-time refocus stays as the fallback. A first tap on clear/compact only arms the confirm and is treated the same way. --- docs/architecture-invariants.md | 2 + src/web/CLAUDE.md | 2 +- src/web/public/keyboard-accessory.js | 21 +++- test/accessory-keeps-terminal-focus.test.ts | 105 ++++++++++++++++++++ 4 files changed, 127 insertions(+), 3 deletions(-) create mode 100644 test/accessory-keeps-terminal-focus.test.ts diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index a50539c8..28048d92 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -1068,6 +1068,8 @@ Tests: `test/mobile-prompt-composer.test.ts` (in the CI gate, deliberately not u ⚠️ **A scroll ends in `touchend` too** — dismissing there closes the keyboard and drops the composer mid-read, so travel is tracked from `touchstart` and multi-touch is never a tap. Both classifiers MUST share one threshold: `initTerminal`'s `TAP_THRESHOLD` reads `MOBILE_KEYBOARD_DISMISS_TAP_SLOP`, since a gesture the terminal calls a scroll and the dismiss handler calls a tap is exactly that bug. +⚠️ **An accessory key that keeps the keyboard up must not take focus on the press.** The keys that act on the terminal and leave the keyboard open (`ACCESSORY_REFOCUS_ACTIONS` in keyboard-accessory.js: scroll, arrows, Tab, Shift+Tab, Ctrl, Esc, clear input and the rest) cancel the `mousedown` default, the same pattern as the Attach input, so the hidden textarea keeps focus through the press; the `terminal.focus()` in the click handler stays as the fallback. Letting the tap blur the terminal and refocusing it afterwards made every such key start a keyboard dismissal, a blur/focus pair per tap that shifted the toolbar each time. A first tap on clear/compact only arms the confirm and is treated the same; the confirming tap, and keys that open a dialog or dismiss the keyboard on purpose, keep the default. Pinned against the real module by `test/accessory-keeps-terminal-focus.test.ts`. + ⚠️ **The gate excludes `test/mobile/**`, so CI cannot see the only test covering (1)** — run `npm run test:mobile -- test/mobile/keyboard.test.ts` by hand and diff the FAIL list against master. (Not `npm test --`: the gate's config excludes that path, so a file filter pointing into it matches nothing and exits green having run zero tests.) That blind spot is why merging the two PRs, which conflicted semantically but not textually, produced a red suite with two green CI checks. ### SSE staleness watchdog diff --git a/src/web/CLAUDE.md b/src/web/CLAUDE.md index 30ef4952..328d0b73 100644 --- a/src/web/CLAUDE.md +++ b/src/web/CLAUDE.md @@ -76,7 +76,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L **WebSocket output-gap reconcile** (`_wsOutputGapSession`, app.js): output frames carry no sequence number, so an unintentional WS close while SSE stays up marks the session and the next open reconciles. ⚠️ The marker is cleared only once a repaint actually happened (`_markTerminalBufferReconciled()`, never in a `finally`). → [architecture-invariants#websocket-output-gap-reconcile](../../docs/architecture-invariants.md#websocket-output-gap-reconcile) -**Dismissing the on-screen keyboard** (`terminal-ui.js`): two gestures blur the terminal's hidden textarea. (1) `_installMobileKeyboardDismiss()`, a document `touchend` that must never fire inside `#terminalContainer` or on a control (`MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR`, via `closest()`; roving-tabindex items sit at `tabindex=-1`, so the grouped rail's are listed as `[role="treeitem"]`). (2) In `_handleMobileTerminalTap`, a second tap on inert `content` blurs; the prompt row keeps focus-then-position. ⚠️ A scroll also ends in `touchend`: both classifiers must share one threshold (`TAP_THRESHOLD` reads `MOBILE_KEYBOARD_DISMISS_TAP_SLOP`), and multi-touch is never a tap. ⚠️ CI cannot see the only test for (1): run `npm run test:mobile -- test/mobile/keyboard.test.ts` by hand and diff the FAIL list against master. → [architecture-invariants#dismissing-the-on-screen-keyboard](../../docs/architecture-invariants.md#dismissing-the-on-screen-keyboard) +**Dismissing the on-screen keyboard** (`terminal-ui.js`): two gestures blur the terminal's hidden textarea. (1) `_installMobileKeyboardDismiss()`, a document `touchend` that must never fire inside `#terminalContainer` or on a control (`MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR`, via `closest()`; roving-tabindex items sit at `tabindex=-1`, so the grouped rail's are listed as `[role="treeitem"]`). (2) In `_handleMobileTerminalTap`, a second tap on inert `content` blurs; the prompt row keeps focus-then-position. ⚠️ A scroll also ends in `touchend`: both classifiers must share one threshold (`TAP_THRESHOLD` reads `MOBILE_KEYBOARD_DISMISS_TAP_SLOP`), and multi-touch is never a tap. ⚠️ An accessory key that keeps the keyboard up (`ACCESSORY_REFOCUS_ACTIONS`, keyboard-accessory.js) cancels its `mousedown` default, so the press does not blur the terminal; the click-time `terminal.focus()` stays as the fallback. Letting the tap blur and refocusing afterwards made every such key start a dismissal, a blur/focus pair per tap. A first tap on clear/compact only arms the confirm and is treated the same (`test/accessory-keeps-terminal-focus.test.ts`). ⚠️ CI cannot see the only test for (1): run `npm run test:mobile -- test/mobile/keyboard.test.ts` by hand and diff the FAIL list against master. → [architecture-invariants#dismissing-the-on-screen-keyboard](../../docs/architecture-invariants.md#dismissing-the-on-screen-keyboard) **Phone toolbar: Enter replaces Shell** (post-1.8.0): inside `@media (max-width: 599px)` `btn-shell` is `display:none` and `btn-enter` takes its slot (`order: 4`); starting a shell moved into the Run dropdown (`Terminal / Shell` → `setRunMode('shell')` → `run()` → `runShell()`, button label "Run SH"). `runMode` is `z.string().max(20)` server-side, so new modes need no schema change. Desktop and tablet keep the green Run Shell button unchanged. diff --git a/src/web/public/keyboard-accessory.js b/src/web/public/keyboard-accessory.js index d76aa9c1..33391135 100644 --- a/src/web/public/keyboard-accessory.js +++ b/src/web/public/keyboard-accessory.js @@ -647,6 +647,9 @@ const COMPOSER_INPUT_FRAME_LIMIT = 64 * 1024; const COMPOSER_PASTE_START = '\x1b[200~'; const COMPOSER_PASTE_END = '\x1b[201~'; +// Accessory keys that act on the terminal and leave the keyboard up. +const ACCESSORY_REFOCUS_ACTIONS = new Set(['scroll-up', 'scroll-down', 'arrow-left', 'arrow-right', 'tab', 'shift-tab', 'shift-left', 'shift-right', 'ctrl', 'ctrl-o', 'opt-enter', 'esc', 'effort-max', 'clear-input']); + /** * KeyboardAccessoryBar - Quick action buttons shown above keyboard when typing. */ @@ -802,6 +805,21 @@ const KeyboardAccessoryBar = { this.syncCodexKeys(); this._syncComposerDraftIndicator(); + // A key that keeps the keyboard up must not take focus on press at all. + // Letting the tap blur the terminal and refocusing it in the click handler + // (kept below as the fallback) made every such key start a keyboard + // dismissal: a blur/focus pair per tap. Same pattern as the Attach input + // (image-input.js). A first tap on clear/compact only arms the confirm. + this.element.addEventListener('mousedown', (e) => { + const btn = e.target.closest('.accessory-btn'); + if (!btn) return; + const action = btn.dataset.action; + if (ACCESSORY_REFOCUS_ACTIONS.has(action) || + ((action === 'clear' || action === 'compact') && this._confirmAction !== action)) { + e.preventDefault(); + } + }); + // Add click handlers — preventDefault stops event from reaching terminal this.element.addEventListener('click', (e) => { const btn = e.target.closest('.accessory-btn'); @@ -813,8 +831,7 @@ const KeyboardAccessoryBar = { this.handleAction(action, btn); // Refocus terminal so keyboard stays open (tap blurs terminal → keyboard dismisses → toolbar shifts) - const refocusActions = new Set(['scroll-up', 'scroll-down', 'arrow-left', 'arrow-right', 'tab', 'shift-tab', 'shift-left', 'shift-right', 'ctrl', 'ctrl-o', 'opt-enter', 'esc', 'effort-max', 'clear-input']); - if (refocusActions.has(action) || + if (ACCESSORY_REFOCUS_ACTIONS.has(action) || ((action === 'clear' || action === 'compact') && this._confirmAction)) { if (typeof app !== 'undefined' && app.terminal) { app.terminal.focus(); diff --git a/test/accessory-keeps-terminal-focus.test.ts b/test/accessory-keeps-terminal-focus.test.ts new file mode 100644 index 00000000..a9e979ee --- /dev/null +++ b/test/accessory-keeps-terminal-focus.test.ts @@ -0,0 +1,105 @@ +/** + * @fileoverview Accessory keys that keep the keyboard up must not take focus on + * press. A tap used to blur the terminal's textarea and the click handler then + * refocused it, so every arrow/Tab/Esc tap started a keyboard dismissal. The bar + * now cancels the mousedown default for exactly those keys (and the first, + * arming tap of clear/compact); every other key keeps the old behavior. + * + * Loaded with `vm` against a minimal fake DOM, like the other accessory-bar tests. + */ +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { describe, expect, it, vi } from 'vitest'; + +type Listener = (e: unknown) => void; +type Bar = { + init(): void; + _confirmAction: string | null; + syncReadMyMind(): void; + syncCodexKeys(): void; + _syncComposerDraftIndicator(): void; +}; + +function loadBar() { + const listeners: Record<string, Listener> = {}; + const element = { + className: '', + innerHTML: '', + addEventListener: (type: string, fn: Listener) => { + listeners[type] = fn; + }, + }; + const context = vm.createContext({ + console, + document: { createElement: () => element, querySelector: () => null }, + MobileDetection: { isTouchDevice: () => true }, + localStorage: { getItem: () => null, setItem: () => {} }, + }); + vm.runInContext( + `${readFileSync(resolve('src/web/public/keyboard-accessory.js'), 'utf8')}\nglobalThis.__bar = KeyboardAccessoryBar;`, + context + ); + const bar = (context as unknown as { __bar: Bar }).__bar; + bar.syncReadMyMind = () => {}; + bar.syncCodexKeys = () => {}; + bar._syncComposerDraftIndicator = () => {}; + bar.init(); + // `null` is a press on the bar itself (padding, a gap): closest() finds no key. + const press = (action: string | null) => { + const preventDefault = vi.fn(); + listeners.mousedown({ + target: { closest: () => (action === null ? null : { dataset: { action } }) }, + preventDefault, + }); + return preventDefault.mock.calls.length > 0; + }; + return { bar, press }; +} + +describe('accessory keys keep the terminal focused on press', () => { + it('cancels the press default for keys that leave the keyboard up', () => { + const { press } = loadBar(); + for (const action of [ + 'scroll-up', + 'scroll-down', + 'arrow-left', + 'arrow-right', + 'tab', + 'shift-tab', + 'shift-left', + 'shift-right', + 'ctrl', + 'ctrl-o', + 'opt-enter', + 'esc', + 'effort-max', + 'clear-input', + ]) { + expect(press(action), action).toBe(true); + } + }); + + it('leaves keys that move focus or dismiss the keyboard alone', () => { + const { press } = loadBar(); + for (const action of ['compose', 'readmymind', 'pick-path', 'paste', 'dismiss', 'init']) { + expect(press(action), action).toBe(false); + } + }); + + it('cancels only the arming tap of clear/compact, not the confirming one', () => { + const { bar, press } = loadBar(); + bar._confirmAction = null; + expect(press('clear')).toBe(true); + bar._confirmAction = 'clear'; + expect(press('clear')).toBe(false); + expect(press('compact')).toBe(true); + bar._confirmAction = 'compact'; + expect(press('compact')).toBe(false); + }); + + it('ignores a press outside a key', () => { + const { press } = loadBar(); + expect(press(null)).toBe(false); + }); +}); From 63cada938d57376aedb578aa0a311211b02a9852 Mon Sep 17 00:00:00 2001 From: JD <jd@jds.haus> Date: Wed, 21 Oct 2026 21:12:04 -0400 Subject: [PATCH 56/96] fix(mobile): give a sideways drag on the terminal to the session swipe alone The terminal's touch handler and the session swipe judged every gesture separately, so a sideways flick also ended as a tap on the session being left (a link, a CLI click, a keyboard toggle) or as a vertical scroll. The terminal now locks the dominant axis past the tap slop, hands a sideways drag it cannot pan to the swipe and publishes the axis, and the swipe stands aside for scrolls, pans, a lift the terminal consumed (its selection gesture), touch selections, a second finger and surfaces with controls of their own. A touch that starts outside the terminal resets the published axis, so swiping from the phone home screen into a session keeps working after a terminal scroll. A deliberate swipe gets 500ms instead of 300ms, and vertical drift cancels it mid-gesture instead of only at the lift. --- docs/architecture-invariants.md | 4 +- src/web/CLAUDE.md | 2 +- src/web/public/mobile-handlers.js | 62 +++++++++--- src/web/public/terminal-ui.js | 17 +++- test/mobile/README.md | 2 +- test/mobile/helpers/constants.ts | 6 +- test/mobile/tabs.test.ts | 4 +- test/session-swipe-gesture.test.ts | 152 +++++++++++++++++++++++++++++ 8 files changed, 221 insertions(+), 28 deletions(-) create mode 100644 test/session-swipe-gesture.test.ts diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index a50539c8..957cc691 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -928,7 +928,9 @@ Further detail: the design follows Apple's [Designing for iPhone Duo](https://de ⚠️ **Long-press selects** by driving xterm's public `select()` (renderer-independent — under WebGL the glyphs are pixels and native selection cannot exist), drag or a further tap extends, and Copy goes through `copyTerminalSelection()` for its execCommand fallback on plain-HTTP installs. Three guards are load-bearing and each came from a real phone: the compat mouse pair after `touchend` (xterm focuses on mousedown and SelectionService resets the model there, so the keyboard sprang up and the selection vanished on lift), the platform's own ~500ms long-press (Android Chrome focuses the nearest editable element — the helper textarea — through no event a handler can preventDefault, so a bounded focus guard blurs it and `contextmenu` is suppressed for the gesture window), and `copyTerminalSelection()`'s closing `terminal.focus()` (right on desktop, wrong on a phone). -Tests: `test/terminal-touch-tap.test.ts`. +⚠️ **A sideways drag belongs to the session swipe**, never to a tap or a scroll. The touch handler locks the dominant axis on the first travel past the tap slop (`panAxis`: `y` scrolls the buffer, `x` pans a terminal wider than its box, `swipe` when it cannot pan) and publishes it per gesture as `app._terminalGestureAxis`; a `swipe` drag is consumed without scrolling and never ends as a tap, which used to activate a link, a CLI click or a keyboard toggle on the session being left. `SwipeHandler` (mobile-handlers.js) reads that axis and stands aside for `x` and `y`, for a touch selection, after a second finger, and on surfaces with controls of their own (`IGNORE_SELECTOR`: the tile grid, the selection bar, the CJK input, the history banner); the phone overview and the welcome screen are deliberately not listed, since swiping from home into a session is a feature. A swipe is 80 px of travel within 500 ms (300 ms rejected an unhurried but deliberate swipe outright) with at most 50 px of vertical drift at any point: `touchmove` cancels it mid-gesture rather than only at the lift. Two more rules came from walking the sequences: a touch that starts outside the terminal (a swipe over the home screen) resets the published axis, so a terminal scroll a moment earlier cannot veto it, and a lift the terminal consumed (`defaultPrevented`: its long-press selection gesture, which on blank space leaves no selection behind to check) is never a swipe; the second-finger cancel stays set until the next touchstart, so the first finger lifting cannot clear it. + +Tests: `test/terminal-touch-tap.test.ts`, `test/session-swipe-gesture.test.ts` (the real `SwipeHandler` and the terminal's axis lock), `test/mobile/tabs.test.ts` (CDP swipes, mobile suite). ### Entrance animations diff --git a/src/web/CLAUDE.md b/src/web/CLAUDE.md index 30ef4952..c72e9cbf 100644 --- a/src/web/CLAUDE.md +++ b/src/web/CLAUDE.md @@ -14,7 +14,7 @@ **Tile grid** (`showTileGridButton`, header Tiles button + `Ctrl+Shift+G`, default ON on desktop and OFF on handhelds and touch-primary tablets (primary pointer coarse, `getDefaultSettings()`; a touchscreen laptop keeps ON), desktop-only at 1180px, per-device; tile-grid.js, design `docs/tile-grid-plan.md`): 1 to 6 live sessions side by side (the cap is ONE constant, `TILE_GRID_MAX` in constants.js, owner decision; the layout table still covers 7 to 9, unreachable), each a `TerminalTile` with a header (state dot, harness logo, name, model, menu, zoom, ×; no +, owner decision), laid out by count (`CodemanTileGrid`, constants.js) with draggable column/row dividers, zoom (tmux-style), an Attach overlay, and per-browser persistence (`codeman:tile-grid`, localStorage only: cells with `null` holes, `count`, focus, user zoom, divider fractions, never content; written on every change, kept by EVERY close, restored INSIDE `handleInit` in place of the single-view select, so the main terminal never loads on that page load). While the grid is open the main terminal is PARKED: `activeSessionId` is the focused tile's session, and every main-terminal path that would write, fetch, resize or reconnect stands aside through `_tilesOwnTerminal()` (the WebGL long-task observer included). ⚠️ Every capture a tile fetches (initial, reconnect refresh, `{t:'r'}`, history pull) goes through ONE `TileLoadQueue` (concurrency 1, a deadline covering the body), because each is a synchronous tmux call on the server. ⚠️ Only a USER-initiated pick of a non-tiled session (or `leaveTiles`, a followed link) leaves the grid; an `auto` selection never collapses it, and every app-driven fallback (close, delete, restore) picks a tile. A caller of `closeTileGrid({ reselect: false })` must null `activeSessionId` before any `_cleanupPreviousSession`, or the parked terminal's stale content is saved as a snapshot. ⚠️ The grid and the split are never open together. ⚠️ Tile chords go through `tileShortcutFor` and are swallowed in every xterm key handler BEFORE the Shift+Enter gate; the toggle follows `showTileGridButton` (owner decision: OFF makes the chord inert, it passes through like any unbound key). ⚠️ The Tiles button's click and Ctrl+Shift+G are ONE function, `toggleTileGrid` (owner decision): they open the grid AT ONCE on `tileGridOpenSet` (constants.js): the stored grid EXACTLY as the user left it (owner decision 11: `restoreTileGridCells`, never filled to the count nor trimmed to the window; a cell whose session is gone since is filled from the ranking, a hole the user made stays), else an open split's two, else the open sessions as `rankTileSessions` orders them (working first, keyed off `lastSubmitAt` ONLY, then needing input, then most recent; `_mobileOverviewState`'s states), the active one focused, filled to the remembered count (`codeman:tile-count`, default 6, at most `_tileGridLimit()`) by `tileGridSetForCount`, never a menu. ⚠️ `removeTile(..., { gone: true })` (a session deleted, popped out or refused) keeps the stored `count` so the next open refills that cell; a removal by hand lowers it. Right-click is the 2 / 4 / 6 count menu (`openTileCountMenu`, owner decision 10), which owns its Escape. The button has no native title: its hover card (`_installTileGridHint`, `#tileGridHint`) says it, is its `aria-describedby` (always present, hidden, kept current) and hides in the capture phase on any press, click or right-click, so the menu never opens beside it. ⚠️ The open, the count menu and the toggle's close animate opacity and transform only (the close leaves an inert cloned still copy until the single view's selection settles, at most 700 ms); nothing moves under `prefers-reduced-motion`. ⚠️ Opening builds one tile terminal per frame (`_connectTilesPaced`), so `openTileGrid` returns before the terminals exist: focus is handed over in `_connectTile` (`focusOnConnect`). ⚠️ A divider drag reflows locally per frame and sends ONE resize per affected tile at pointer-up. ⚠️ The grid is CELLS (owner: an empty cell can be any cell): `grid.cells` (id or `null`) is the one source of truth, `grid.ids` a derived getter, never written; the shape still comes from the tile count, a shape change goes through `fitTileCells`, and the stored `ids` carry the cells with `null` holes. ⚠️ A tile moves (its header dragged or its tab dropped onto another tile or an empty cell, `Ctrl+Shift+Arrows`) ONLY through `_reorderTiles`: never a remount, reconnect or reload, and since sizes belong to the cells only a tile whose cell size changed fits; off while a tile is zoomed. The header drag carries its own type, never text, and is not `draggedTabId`; the header focuses on click, never on press, so a cancelled drag changes nothing. The arrow chords skip text fields. ⚠️ Sessions Run from this tab join the open grid (`_joinTileGridFromRun`, called from `_ensureCreatedSessionVisible`); sessions created elsewhere never do. Such a tile connects before its pane exists, the server drops that resize and spawns at 120x40, and Run's own resize measures the parked terminal (nothing), so the chrome refresh calls `tile.paneStarted()` when the session's pid appears or changes. ⚠️ An agent that exited in a live pane (`paneExit`) cannot be re-attached in place (both attach routes refuse while the pane's tmux client runs, and say so in a 200 envelope): its tile shows the exit and points at Close session. ⚠️ Each header (a tile's, both split panes': Pane A gets one only while the split is open, fitted through `sendResize`/`syncTerminalGeometry`) names the harness with PR #532's `run-mode-dot <cliId>` logo (the id is data, never a branch) and `SessionState.displayModel` (src/session-display-model.ts: custom endpoint, else the newest report from the CLI itself, i.e. claude's statusline or a footer read with `capabilities.modelDetect`, else what its config pins via the named `modelDetect.configResolver` (dsh-TUI's route, `src/deepseek-route-config.ts`: read-only, bounded, nothing on doubt), else the launch model, else nothing), painted by ONE diffing `_paintSessionHarness`; the model is untrusted text (`textContent`, `data-i18n-skip`). ⚠️ zh-CN: every string the grid shows has its own `ZH_CN` entry or `translateDynamic` pattern in i18n.js (`test/tile-grid-i18n.test.ts` harvests them from the real code; add the entry with any new string), and a refresh compares with the last ENGLISH value it set, never the DOM, which holds the translation. ⚠️ **Detach Tiles** (`tileDetachEnabled`, per-device, default OFF): a header let go outside the window pops the tile out (`detachSession` with a placement), dropped on ANOTHER Codeman window's tiles it moves there, and a pop-out's title drags back onto any grid. A foreign tile docks ONLY through `_dockForeignTile` (the target's own drop, then `tile-adopted` on the window channel), and only the window it was dragged FROM lets go of its copy (never a last-tile fallback onto that session: two windows sizing one PTY garble it). The gesture overlay moves tiles only through tile-grid.js's helpers (`tileDropTargetAt`/`dropOnTileTarget`/`detachTileAtPoint`). → [architecture-invariants#tile-grid](../../docs/architecture-invariants.md#tile-grid) -**Terminal touch gestures: link taps and text selection**: on touch devices xterm's linkifier and SelectionService never see the gesture, so both are driven explicitly (terminal-ui.js). ⚠️ A tap activates the link under it through the SAME provider as the hover linkifier (`_terminalLinkAtPoint`), synchronously inside `touchend` (keeps the user gesture `window.open` needs) and BEFORE any mouse report; the caret's logical line (`_tapIsOnCaretLine`) and TUI-owned rows (`_isActionableMobileTerminalTap`) keep their meaning. ⚠️ Gate on the caret line, never on tap intent (a shell calls every tap `'input'`). ⚠️ Long-press selects via xterm's public `select()`; keep the three guards: suppress the compat mouse pair after `touchend`, the bounded focus guard + `contextmenu` suppression for the platform long-press, and no closing `terminal.focus()` on phones. Tests: `test/terminal-touch-tap.test.ts`. → [architecture-invariants#terminal-touch-gestures-link-taps-and-text-selection](../../docs/architecture-invariants.md#terminal-touch-gestures-link-taps-and-text-selection) +**Terminal touch gestures: link taps and text selection**: on touch devices xterm's linkifier and SelectionService never see the gesture, so both are driven explicitly (terminal-ui.js). ⚠️ A tap activates the link under it through the SAME provider as the hover linkifier (`_terminalLinkAtPoint`), synchronously inside `touchend` (keeps the user gesture `window.open` needs) and BEFORE any mouse report; the caret's logical line (`_tapIsOnCaretLine`) and TUI-owned rows (`_isActionableMobileTerminalTap`) keep their meaning. ⚠️ Gate on the caret line, never on tap intent (a shell calls every tap `'input'`). ⚠️ Long-press selects via xterm's public `select()`; keep the three guards: suppress the compat mouse pair after `touchend`, the bounded focus guard + `contextmenu` suppression for the platform long-press, and no closing `terminal.focus()` on phones. ⚠️ A sideways drag belongs to the session swipe (`SwipeHandler`, mobile-handlers.js): the touch handler locks the dominant axis on the first travel past the tap slop (`panAxis`: `y` scrolls, `x` pans a terminal wider than its box, `swipe` otherwise) and publishes it as `app._terminalGestureAxis`; a `swipe` drag neither scrolls nor ends as a tap on the session being left, and the swipe stands aside for `x`/`y`, a touch selection, a second finger and surfaces with controls of their own (`IGNORE_SELECTOR`; the phone overview and welcome screen are deliberately not listed). A swipe is 80 px within 500 ms with at most 50 px of vertical drift at any point; a touch starting outside the terminal (the home screen) resets the published axis, and a lift the terminal consumed (`defaultPrevented`, its selection gesture) is never a swipe (`test/session-swipe-gesture.test.ts`). Tests: `test/terminal-touch-tap.test.ts`. → [architecture-invariants#terminal-touch-gestures-link-taps-and-text-selection](../../docs/architecture-invariants.md#terminal-touch-gestures-link-taps-and-text-selection) **Auto Copy (copy-on-select)** (`autoCopySelection`, per-device, default OFF): a finished terminal selection lands on the clipboard with no keystroke. ⚠️ Copy at the END of a gesture, never in `onSelectionChange` (per-cell); it only arms `_autoCopyPending` and a document-level `mouseup` flushes. ⚠️ The flush must be SYNCHRONOUS in the handler (both clipboard paths need user activation); never defer it to a timer. ⚠️ Touch needs its own calls from `_endTouchSelectionGesture()`/`_selectTouchSelectionLine()` (no mouseup arrives). ⚠️ Unlike `copyTerminalSelection()`, never clear the selection or focus the terminal; restore prior focus. Guards are pure in `decideAutoCopy()` (constants.js, 1M-char cap, refused not truncated). Tests: `test/terminal-auto-copy.test.ts`. → [architecture-invariants#auto-copy-copy-on-select](../../docs/architecture-invariants.md#auto-copy-copy-on-select) diff --git a/src/web/public/mobile-handlers.js b/src/web/public/mobile-handlers.js index fb22923a..9bfd9acb 100644 --- a/src/web/public/mobile-handlers.js +++ b/src/web/public/mobile-handlers.js @@ -758,14 +758,24 @@ const SwipeHandler = { startY: 0, startTime: 0, minSwipeDistance: 80, // Minimum pixels for a valid swipe - maxSwipeTime: 300, // Maximum ms for a swipe gesture - maxVerticalDrift: 100, // Max vertical movement allowed + // 300ms rejected an unhurried but deliberate swipe outright, which read as an + // unresponsive gesture rather than as "too slow". + maxSwipeTime: 500, // Maximum ms for a swipe gesture + maxVerticalDrift: 50, // Max vertical movement allowed, at any point of the gesture _touchStartHandler: null, + _touchMoveHandler: null, _touchEndHandler: null, _element: null, _ignoreGesture: false, + /** + * Surfaces inside `.main` that own their own gestures or controls: a swipe + * there must not switch sessions. The phone overview and welcome screen are + * deliberately NOT listed: swiping from home into a session is a feature. + */ + IGNORE_SELECTOR: '.tile-grid, .term-select-bar, #cjkInput, .history-trunc-bar', + /** Initialize swipe handling */ init() { // Only on touch devices @@ -776,8 +786,10 @@ const SwipeHandler = { this._element = terminal; this._touchStartHandler = (e) => this.onTouchStart(e); + this._touchMoveHandler = (e) => this.onTouchMove(e); this._touchEndHandler = (e) => this.onTouchEnd(e); terminal.addEventListener('touchstart', this._touchStartHandler, { passive: true }); + terminal.addEventListener('touchmove', this._touchMoveHandler, { passive: true }); terminal.addEventListener('touchend', this._touchEndHandler, { passive: true }); }, @@ -785,9 +797,11 @@ const SwipeHandler = { cleanup() { if (this._element && this._touchStartHandler) { this._element.removeEventListener('touchstart', this._touchStartHandler); + this._element.removeEventListener('touchmove', this._touchMoveHandler); this._element.removeEventListener('touchend', this._touchEndHandler); } this._touchStartHandler = null; + this._touchMoveHandler = null; this._touchEndHandler = null; this._element = null; }, @@ -797,20 +811,43 @@ const SwipeHandler = { // here. Swiping across the open session drawer — the natural "dismiss it" // gesture — would otherwise fire nextSession() and drop the user into a // session they never tapped. + // The terminal's touch handler publishes the axis it locks for a gesture + // that starts on it; a touch that starts elsewhere in `.main` (the home + // screen) must not inherit the last one, or a terminal scroll a moment + // earlier vetoes a swipe from home. Reset before any early return. + if (typeof app !== 'undefined') app._terminalGestureAxis = null; this._ignoreGesture = !!e.target?.closest?.('.session-sidebar'); if (this._ignoreGesture) return; - if (!e.touches || e.touches.length !== 1) return; + // A second finger makes this a different gesture (pinch, two-finger scroll): + // never measure a later lift against the first finger's start. Stays set + // until the next touchstart, so the first finger's lift cannot clear it. + if (!e.touches || e.touches.length !== 1) { + this._ignoreGesture = true; + return; + } + this._ignoreGesture = !!e.target?.closest?.(this.IGNORE_SELECTOR); this.startX = e.touches[0].clientX; this.startY = e.touches[0].clientY; this.startTime = Date.now(); }, + onTouchMove(e) { + if (this._ignoreGesture || !e.touches || e.touches.length !== 1) return; + if (Math.abs(e.touches[0].clientY - this.startY) > this.maxVerticalDrift) this._ignoreGesture = true; + }, + onTouchEnd(e) { - if (this._ignoreGesture) { - this._ignoreGesture = false; - return; - } + if (this._ignoreGesture) return; if (!e.changedTouches || e.changedTouches.length !== 1) return; + if (typeof app === 'undefined') return; + // The terminal's own touch handler (terminal-ui.js) runs first, on the same + // gesture: a drag it locked as a scroll or a pan is not a session swipe, + // and neither is a lift it consumed (its long-press selection gesture + // preventDefaults the touchend, even on blank space where no selection + // survives the lift). A swipe must not extend or drop a touch selection. + if (e.defaultPrevented) return; + if (app._terminalGestureAxis === 'y' || app._terminalGestureAxis === 'x') return; + if (app._touchSelecting || app._touchSelectionActive) return; const endX = e.changedTouches[0].clientX; const endY = e.changedTouches[0].clientY; @@ -824,13 +861,8 @@ const SwipeHandler = { if (deltaY > this.maxVerticalDrift) return; // Too much vertical movement if (Math.abs(deltaX) < this.minSwipeDistance) return; // Too short - // Valid swipe detected - if (deltaX > 0) { - // Swipe right -> previous session - if (typeof app !== 'undefined') app.prevSession(); - } else { - // Swipe left -> next session - if (typeof app !== 'undefined') app.nextSession(); - } + // Valid swipe detected. Swipe right -> previous session, left -> next. + if (deltaX > 0) app.prevSession(); + else app.nextSession(); }, }; diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index ef062070..95d80e0a 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -1196,8 +1196,13 @@ Object.assign(CodemanApp.prototype, { let longPressStartY = 0; let touchStartY = 0; let touchStartX = 0; - // 'x' | 'y' | null — locked on the first travel past the tap slop, so a - // diagonal drag cannot pan and scroll at the same time. + // 'x' | 'y' | 'swipe' | null — locked on the first travel past the tap + // slop, so a diagonal drag cannot pan and scroll at the same time. 'swipe' + // is a sideways drag on a terminal that cannot pan: it is the session + // swipe's (SwipeHandler, mobile-handlers.js), so it neither scrolls nor + // ends as a tap on the session being left. Published per gesture as + // `this._terminalGestureAxis`, which SwipeHandler reads to stand aside + // for a scroll or a pan. let panAxis = null; /** * Can this gesture pan sideways? Only while the terminal is wider than @@ -1242,6 +1247,7 @@ Object.assign(CodemanApp.prototype, { touchStartY = touchLastY; touchStartX = touchLastX; panAxis = null; + this._terminalGestureAxis = null; velocity = 0; pixelAccum = 0; isTouching = true; @@ -1315,10 +1321,10 @@ Object.assign(CodemanApp.prototype, { if (!didScroll) { const travelY = Math.abs(touchY - touchStartY); const travelX = Math.abs(touchX - touchStartX); - const sideways = canPanHorizontally() && travelX >= TAP_THRESHOLD; - if (travelY >= TAP_THRESHOLD || sideways) { + if (travelY >= TAP_THRESHOLD || travelX >= TAP_THRESHOLD) { didScroll = true; - panAxis = sideways && travelX > travelY ? 'x' : 'y'; + panAxis = travelX > travelY ? (canPanHorizontally() ? 'x' : 'swipe') : 'y'; + this._terminalGestureAxis = panAxis; } } // Below the tap threshold, treat the gesture as a potential tap: @@ -1329,6 +1335,7 @@ Object.assign(CodemanApp.prototype, { // fling, so a jittery tap would both position the cursor AND scroll. if (!didScroll) return; ev.preventDefault(); + if (panAxis === 'swipe') return; if (panAxis === 'x') { // Pan the container, and touch nothing the vertical path owns — // no pixelAccum, no velocity, so touchend cannot turn a sideways diff --git a/test/mobile/README.md b/test/mobile/README.md index 1ff30a73..e910fdaf 100644 --- a/test/mobile/README.md +++ b/test/mobile/README.md @@ -130,7 +130,7 @@ The unified `showKeyboard(page, height, options?)` tries Layer 1 → 2 → 3 aut | **CDP `Input.dispatchTouchEvent`** | Chromium | `true` | Default for swipe/tap tests | | **Synthetic `TouchEvent`** | Cross-engine | `false` | WebKit fallback, still triggers handlers | -Swipe simulation includes intermediate move points and timing to satisfy `SwipeHandler` thresholds (≥80px distance, ≤300ms duration, ≤100px vertical drift). +Swipe simulation includes intermediate move points and timing to satisfy `SwipeHandler` thresholds (≥80px distance, ≤500ms duration, ≤50px vertical drift). ### Visual Regression diff --git a/test/mobile/helpers/constants.ts b/test/mobile/helpers/constants.ts index 95dc6961..e43a33e1 100644 --- a/test/mobile/helpers/constants.ts +++ b/test/mobile/helpers/constants.ts @@ -68,11 +68,11 @@ export const KEYBOARD = { CONFIRM_TIMEOUT: 2000, } as const; -// Swipe constants (match app.js SwipeHandler) +// Swipe constants (match SwipeHandler in mobile-handlers.js) export const SWIPE = { MIN_DISTANCE: 80, - MAX_TIME: 300, - MAX_VERTICAL_DRIFT: 100, + MAX_TIME: 500, + MAX_VERTICAL_DRIFT: 50, } as const; // Subagent window constants diff --git a/test/mobile/tabs.test.ts b/test/mobile/tabs.test.ts index 977421d1..d9335f2b 100644 --- a/test/mobile/tabs.test.ts +++ b/test/mobile/tabs.test.ts @@ -420,11 +420,11 @@ describe('Tab Navigation', () => { expect(log).toEqual([]); }); - it('slow swipe (>300ms) does not trigger navigation', async () => { + it('slow swipe (past the time limit) does not trigger navigation', async () => { await clearSwipeLog(); await installSwipeSpy(); // Duration exceeds MAX_TIME threshold - await swipeViaCDP(page, 'left', { distance: SWIPE.MIN_DISTANCE + 20, duration: 400 }); + await swipeViaCDP(page, 'left', { distance: SWIPE.MIN_DISTANCE + 20, duration: SWIPE.MAX_TIME + 150 }); await page.waitForTimeout(200); const log = await getSwipeLog(); expect(log).toEqual([]); diff --git a/test/session-swipe-gesture.test.ts b/test/session-swipe-gesture.test.ts new file mode 100644 index 00000000..9acece0b --- /dev/null +++ b/test/session-swipe-gesture.test.ts @@ -0,0 +1,152 @@ +// Port: none (pure logic in a vm context: no browser, no server). +// +// The session swipe (SwipeHandler, mobile-handlers.js) shares every gesture with +// the terminal's own touch handler (terminal-ui.js), which runs first. A swipe +// used to switch sessions even when the terminal had taken the same drag as a +// scroll or a pan, and the terminal took a sideways flick as a TAP on the +// session being left. The terminal now publishes the axis it locked +// (`app._terminalGestureAxis`) and the swipe stands aside for 'y' and 'x'. +// +// Lives outside test/mobile/ because that suite is Playwright-driven and not in +// the CI gate (same reasoning as test/viewport-shape-change.test.ts). +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import vm from 'node:vm'; +import { describe, expect, it, vi } from 'vitest'; + +const SOURCE = readFileSync(resolve(import.meta.dirname, '../src/web/public/mobile-handlers.js'), 'utf8'); +const TERMINAL_SOURCE = readFileSync(resolve(import.meta.dirname, '../src/web/public/terminal-ui.js'), 'utf8'); + +type Touch = { clientX: number; clientY: number }; +type Swipe = { + onTouchStart(e: unknown): void; + onTouchMove(e: unknown): void; + onTouchEnd(e: unknown): void; + maxSwipeTime: number; + startTime: number; +}; + +function load(appOverrides: Record<string, unknown> = {}) { + const app = { + nextSession: vi.fn(), + prevSession: vi.fn(), + _terminalGestureAxis: null as string | null, + ...appOverrides, + }; + const context = vm.createContext({ + console, + app, + navigator: { userAgent: 'iPhone', maxTouchPoints: 5 }, + window: { addEventListener: () => {}, matchMedia: () => ({ matches: true }) }, + document: { querySelector: () => null }, + setTimeout: () => 0, + clearTimeout: () => {}, + }); + vm.runInContext(`${SOURCE}\nglobalThis.__swipe = SwipeHandler;`, context); + const swipe = (context as unknown as { __swipe: Swipe }).__swipe; + const target = (ignored = false) => ({ + closest: (sel: string) => (ignored && sel !== '.session-sidebar' ? {} : null), + }); + // `axis` is what the terminal's touchmove publishes mid-gesture (after the + // touchstart, which resets it); `consumed` a touchend the terminal + // preventDefault'ed. + const gesture = ( + from: Touch, + to: Touch, + opts: { ms?: number; ignored?: boolean; via?: Touch; axis?: string | null; consumed?: boolean } = {} + ) => { + swipe.onTouchStart({ target: target(opts.ignored), touches: [from] }); + if (opts.axis !== undefined) app._terminalGestureAxis = opts.axis; + if (opts.ms) swipe.startTime -= opts.ms; + if (opts.via) swipe.onTouchMove({ touches: [opts.via] }); + swipe.onTouchMove({ touches: [to] }); + swipe.onTouchEnd({ changedTouches: [to], defaultPrevented: !!opts.consumed }); + }; + return { app, swipe, gesture }; +} + +describe('session swipe', () => { + it('switches on a clean horizontal swipe', () => { + const { app, gesture } = load(); + gesture({ clientX: 300, clientY: 400 }, { clientX: 150, clientY: 405 }); + expect(app.nextSession).toHaveBeenCalledTimes(1); + + gesture({ clientX: 100, clientY: 400 }, { clientX: 260, clientY: 400 }); + expect(app.prevSession).toHaveBeenCalledTimes(1); + }); + + it('accepts an unhurried swipe up to 500ms, not past it', () => { + const { app, gesture } = load(); + gesture({ clientX: 300, clientY: 400 }, { clientX: 150, clientY: 400 }, { ms: 450 }); + expect(app.nextSession).toHaveBeenCalledTimes(1); + gesture({ clientX: 300, clientY: 400 }, { clientX: 150, clientY: 400 }, { ms: 600 }); + expect(app.nextSession).toHaveBeenCalledTimes(1); + }); + + it('cancels when the finger drifted vertically mid-gesture, even if it came back', () => { + const { app, gesture } = load(); + gesture({ clientX: 300, clientY: 400 }, { clientX: 150, clientY: 400 }, { via: { clientX: 220, clientY: 470 } }); + expect(app.nextSession).not.toHaveBeenCalled(); + }); + + it('stands aside when the terminal took the drag as a scroll or a pan', () => { + for (const axis of ['y', 'x']) { + const { app, gesture } = load(); + gesture({ clientX: 300, clientY: 400 }, { clientX: 150, clientY: 400 }, { axis }); + expect(app.nextSession, axis).not.toHaveBeenCalled(); + } + const { app, gesture } = load(); + gesture({ clientX: 300, clientY: 400 }, { clientX: 150, clientY: 400 }, { axis: 'swipe' }); + expect(app.nextSession).toHaveBeenCalledTimes(1); + }); + + it('forgets the axis a previous gesture left behind, so a swipe from home works after a terminal scroll', () => { + // The terminal resets the axis only for a touch that starts on it; a swipe + // over the home screen starts elsewhere in `.main`. + const { app, gesture } = load({ _terminalGestureAxis: 'y' }); + gesture({ clientX: 300, clientY: 400 }, { clientX: 150, clientY: 400 }); + expect(app.nextSession).toHaveBeenCalledTimes(1); + }); + + it('stands aside when the terminal consumed the lift (a long-press selection gesture)', () => { + // A long-press on blank space sets _touchSelecting, finds no word, and the + // terminal clears the flag synchronously in its own touchend before this + // handler runs; the preventDefault it leaves behind is the signal. + const { app, gesture } = load(); + gesture({ clientX: 300, clientY: 400 }, { clientX: 150, clientY: 400 }, { consumed: true }); + expect(app.nextSession).not.toHaveBeenCalled(); + }); + + it('never switches during a touch selection or on a surface with its own controls', () => { + for (const flag of ['_touchSelecting', '_touchSelectionActive']) { + const { app, gesture } = load({ [flag]: true }); + gesture({ clientX: 300, clientY: 400 }, { clientX: 150, clientY: 400 }); + expect(app.nextSession, flag).not.toHaveBeenCalled(); + } + const { app, gesture } = load(); + gesture({ clientX: 300, clientY: 400 }, { clientX: 150, clientY: 400 }, { ignored: true }); + expect(app.nextSession).not.toHaveBeenCalled(); + }); + + it('ignores every lift after a second finger joined, the first finger lifting first included', () => { + // Finger A starts the gesture, B joins (a pinch), A lifts, then B lifts + // 150 px from A's start within the time limit: neither lift is a swipe. + const { app, swipe } = load(); + const a = { clientX: 300, clientY: 400 }; + const b = { clientX: 100, clientY: 400 }; + swipe.onTouchStart({ target: { closest: () => null }, touches: [a] }); + swipe.onTouchStart({ target: { closest: () => null }, touches: [a, b] }); + swipe.onTouchEnd({ changedTouches: [{ clientX: 200, clientY: 400 }] }); + swipe.onTouchEnd({ changedTouches: [{ clientX: 150, clientY: 400 }] }); + expect(app.nextSession).not.toHaveBeenCalled(); + }); +}); + +describe('terminal touch handler hands sideways drags to the swipe', () => { + it('locks a dominant sideways drag as a swipe unless the terminal can pan', () => { + expect(TERMINAL_SOURCE).toContain("panAxis = travelX > travelY ? (canPanHorizontally() ? 'x' : 'swipe') : 'y';"); + expect(TERMINAL_SOURCE).toContain('this._terminalGestureAxis = panAxis;'); + // A 'swipe' is neither a scroll nor (didScroll is set) a tap. + expect(TERMINAL_SOURCE).toContain("if (panAxis === 'swipe') return;"); + }); +}); From 74c980413a49ac997ac3c97f4ce6e965e2cbd31c Mon Sep 17 00:00:00 2001 From: Aamer Akhter <aakhter@gmail.com> Date: Thu, 22 Oct 2026 03:42:42 -0400 Subject: [PATCH 57/96] feat: per-case default session group Case settings gains a "Default session group" selector (Ungrouped plus every named tab group of the owner). A new session of the case lands in that group in the same versioned layout write that registers it, so it is drawn in place with no Ungrouped-to-group jump. - Stored per owner and case in state.json (caseDefaultGroups), as the group's stable id or null for Ungrouped; read and written only through TabLayoutService under the owner lock. - Precedence in sessionCreated: explicit tabGroupId, then the parent placement, then the case default, else Ungrouped. A deleted or foreign id never fails a spawn and is forgotten only after the creating write lands. - POST /api/quick-start (local, linked, remote, Docker) and /api/ralph-loop/start pass their case; POST /api/sessions infers it lexically from workingDir. The create response carries tabLayout when a case default placed the session. - GET/PUT /api/cases/:name/default-group; deleting a case (or an admin deleting a user's case folder) forgets the value. - The selector mirrors desktop and phone, reverts on a refused save, and is redrawn from every adopted tab layout; zh-CN strings added. --- docs/api-reference.md | 20 + docs/architecture-invariants.md | 1 + docs/wiki/Core-Concepts.md | 4 + src/state-store.ts | 35 ++ src/tab-layout-service.ts | 94 +++- src/types/app-state.ts | 6 + src/web/ports/session-port.ts | 4 +- src/web/public/app.js | 2 + src/web/public/i18n.js | 3 + src/web/public/index.html | 10 + src/web/public/session-ui.js | 93 ++++ src/web/public/styles.css | 21 + src/web/routes/admin-routes.ts | 9 +- src/web/routes/case-routes.ts | 90 +++- src/web/routes/ralph-routes.ts | 4 +- src/web/routes/session-routes.ts | 60 ++- src/web/schemas.ts | 9 + src/web/server.ts | 5 +- test/admin-routes.test.ts | 5 +- test/case-default-group-ui.test.ts | 160 +++++++ test/case-default-group.test.ts | 332 ++++++++++++++ test/mocks/mock-route-context.ts | 6 + test/routes/case-default-group-routes.test.ts | 411 ++++++++++++++++++ test/routes/session-routes-tab-group.test.ts | 4 +- 24 files changed, 1362 insertions(+), 26 deletions(-) create mode 100644 test/case-default-group-ui.test.ts create mode 100644 test/case-default-group.test.ts create mode 100644 test/routes/case-default-group-routes.test.ts diff --git a/docs/api-reference.md b/docs/api-reference.md index c0350a66..068b5d0a 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -778,6 +778,26 @@ deleted group, another user's group) is ignored and the session lands where it otherwise would, never a `400`. A malformed value (`null`, an empty string, a non-string) is a `400 INVALID_INPUT`. Requests without the field are unchanged. +### A case's default session group + +- `GET /api/v1/cases/:name/default-group` → `{ caseName, groupId, groups }`: `groupId` is a + group id from the caller's own tab layout or `null` (Ungrouped), `groups` the caller's + current named groups (`{ id, name }`, in layout order). A stored id whose group was + deleted reads back as `null` and is forgotten. +- `PUT /api/v1/cases/:name/default-group` with `{ "groupId": string | null }` saves it. An + id not in the caller's layout is a `400 INVALID_INPUT` that leaves the old value in place. + +Both answer `404 NOT_FOUND` for a case the caller cannot see (the same rules as +`GET /api/v1/cases`). The value is stored per owner and case, so in multi-user mode it is +never shared and needs no admin role. A new session of the case is placed in that group in +its creating write when nothing stronger applies; the order is: an explicit `tabGroupId`, +then the session it was spawned from (`parentSessionId`), then the case default, else +Ungrouped. `POST /api/v1/quick-start` uses its `caseName` (local, linked, remote and Docker +cases); `POST /api/v1/sessions` uses the case whose folder `workingDir` is (a direct child of +the caller's case space, or a linked case's exact path). When a case default places the +session, the create response carries `tabLayout` as it does for `tabGroupId`. Deleting the +case forgets the value; existing sessions never move. + ## Approvals Inbox Cross-session queue of prompts waiting on a human (permission dialogs, diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index a50539c8..096344e0 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -331,6 +331,7 @@ So: `_confirmIdle()` (session.ts) requires the pane to go quiet, and then asks t - **Edits are named operations, saved serially.** `createEditCoordinator` applies `createGroup` / `renameGroup` / `deleteGroup` / `reorderGroup` / `moveRef` to the rail at once, then sends ONE `PUT /api/tab-layout {baseVersion, layout}` at a time; edits made meanwhile wait and go out on the version that write returns. A 409 carries the server's layout: the in-flight operations are replayed onto it (an operation that no longer applies is dropped and reported) and re-sent, at most `maxAttempts` times; a 400 re-reads once (a second 400 is reported as a failed save, not as a race); anything else reports and re-reads. A `moveRef` moves the session together with the sessions that still follow it and marks a hand-moved child `placement: 'manual'`, mirroring the server's `moveRef`, and `normalizeLayout` keeps `placement` because whole layouts are written back. `_onTabLayoutChanged` / `_applyTabLayout` defer a read while a write is in flight and rebase unsaved edits onto a read otherwise. ⚠️ A FAILED read (`_applyTabLayout(null)`) while edits are pending keeps the held layout and the editor and re-reads after the write settles: disposing there would orphan the in-flight write, whose 409 then never gets its rebase. Any path that does drop unsaved work says so in a toast. On `pagehide`, unconfirmed operations go out in a `keepalive` PUT AND into sessionStorage as `{ owner, baseVersion, savedAt, operations }`; after reload they replay onto the fresh layout (a no-op when the keepalive landed), but only for the same owner, within `TAB_LAYOUT_PENDING_MAX_AGE_MS` (60 s), and never onto a layout older than the copy's base. A "Move to <group>" with no anchor carries no `index`, so a replay still puts the row last. - **Every way in has a keyboard path.** The session row menu (Shift+F10 in the tree, the rail's overflow button) gains Move up/down, Move to <group>, Move to Ungrouped and Move to new group in the vertical rail (only "new group" before the first group exists; nothing on the strip). A group header opens its menu with Shift+F10 / ContextMenu, right-click or its hover glyph (a non-focusable, `aria-hidden` span: a treeitem holds no interactive children), and F2 renames it inline (Enter commits and refocuses the header; a commit by BLUR leaves focus where it went, since refocusing from inside the blur handler overrides the user's click). The glyph stays visible under `@media (hover: none)`: a touch tablet has no hover and no long-press `contextmenu`. Group names in "Move to" labels are quoted, so a group named "New group" or "ungrouped" cannot read (or translate, case-insensitively) like the fixed entries. The menu closes on Escape (which it consumes before the global Escape handler), a pointer outside, Tab, focus leaving it, a resize, a second open and any full re-render. It borrows the `.tab-rail-action-menu` class for its look only: `closeTabRailActionMenu()` excludes `.tab-layout-group-action-menu`, so closing the row menu (every `session:deleted` does) cannot strand the group menu's listeners. The inline group editor shares `_activeRename` with the session rename, so only the CURRENT editor may release `_inlineRenameActive`. - **New, child and web tabs land in groups.** A group menu's "New session" (`createSessionInTabGroup`) runs ONE launch with `run({ count: 1, tabGroupId })`; every launch path puts `tabGroupId` on its create request (`POST /api/sessions` or `/api/quick-start`), and `TabLayoutService.sessionCreated(owner, id, { tabGroupId }, register)` places the session at the end of that group in the SAME versioned write that first records it. The session enters the live map inside the owner lock (`register`, rolled back if the write fails), so no queued layout edit can commit it first. The group is looked up in the creating owner's own layout, so an unknown, deleted or foreign id is ignored rather than failing the spawn, and an explicit group is a hand placement (a child placed this way is `manual`). The create response carries the committed `tabLayout`, adopted before the tab is drawn. A session spawned by another one (`parentSessionId`) and placed by nobody lands after its parent: the server's normalization puts it after the parent's rows in the parent's container, and `project({ parents })` draws a not-yet-stored child there at once. Whether it then FOLLOWS its parent is the synced `spawnedTabsFollowParent` setting (default OFF), which `sessionCreated()` reads fresh through the `childrenFollowParent` dep: off, the child is marked `manual` in that same creating write; on, it follows until moved by hand. Deciding at creation is enough, so flipping the setting leaves existing tabs alone. A hand move makes a following child `manual`; with the setting on, the row menu notes where a child sits (following, placed by hand, parent closed, parent loop; plain text, never a focusable menu item) and offers "Follow parent again" only to a `manual` child, which clears `placement` and is re-derived by the server on save. With the setting off the row menu says nothing about placement. A legacy `PUT /api/session-order` (strip or phone drag, a same-section Ctrl+Shift+{/}, a device without a layout, an admin's cross-owner order) marks a child `manual` ONLY when the requested order splits it from its parent's block (`applyLegacySessionRank`, `childrenSplitFromParent`); a PUT of the current order, or one that moves a family together, leaves every stored placement alone. The browser never echoes a create or delete through that PUT, with or without groups: the service already re-projects the order and broadcasts it (`session:orderChanged`), and `syncSessionOrder` adopts it. A child whose parent is gone stands on its own and is never re-adopted. Saved web tabs are layout refs like sessions, but OPENNESS stays per device: a grouped web tab renders (and takes an Alt+N slot) only while it is open on this device, and reopens where the layout keeps it. +- **A case's default session group** (`caseDefaultGroups` in state.json, keyed by the layout owner key then case name; a group id or `null` for Ungrouped) is the fallback placement for a new session of that case. It is read and written ONLY through `TabLayoutService` (`caseDefaultGroup`, `setCaseDefaultGroup`, `forgetCaseDefaultGroup`, and `sessionCreated`'s `caseName` hint), under the owner lock, never by a route or the store directly. Precedence inside `sessionCreated`'s single write: an explicit `tabGroupId` found in the owner's layout, then a parent that places the child (`hasPlacingParent`, the same conditions as `pinCreatedChild`), then the case default, else normal placement. The id is looked up in the creating owner's own layout, so a deleted or foreign id never fails a spawn; a stale id is forgotten only AFTER the creating write lands (a failed write rolls back and changes nothing). `POST /api/quick-start` and `POST /api/ralph-loop/start` pass their `caseName`; `POST /api/sessions` infers it LEXICALLY from `workingDir` (`inferLocalCaseName`: a direct child of the caller's case space, or an exact linked-case path for an admin or single-user), never for a remote attach and never touching the filesystem. Cron jobs, the one-shot run route and reboot restore keep normal placement. The Case settings selector (`onCaseDefaultGroupChanged`, session-ui.js) reads it on popover open and is redrawn from every adopted layout (`_refreshCaseDefaultGroupOptions` in `_applyTabLayout`). - **Only the grouped rail is a tree.** `#sessionTabs` ships as `role=tablist` with `role=tab` rows, and the header strip, sidebar and flat rail keep exactly that. While grouped, `_applyTabListRole` makes it `role=tree` (and restores `tablist` + its label when grouping ends), named-group headers are level-1 `treeitem`s that `aria-owns` their rows' `role=group` (rows sit beside the header, not inside it), and ungrouped rows plus a collapsed group's kept selection are level-1 items. A collapsed header owns nothing, a group with no open rows is a leaf (no `aria-expanded`, no owned group), and the "Ungrouped" heading is `aria-hidden`. Rows are re-roled in the DOM by `_applyTabTreeSemantics` after render, never by rewriting their markup, so a grouped row's content stays the flat row's. - **One tab stop in the tree.** Exactly one treeitem carries `tabindex=0` (the focused or selected item); every control inside a row drops to `-1`, which is why Shift+F10 / ContextMenu open a row's actions from the keyboard. Focus survives a full re-render by identity (`group:`/`session:`/`webview:`; a row a collapse just hid hands focus to its header), but only when focus was already inside the rail. The tree walk (`_tabTreeItems`) follows painted order WITHIN each group when the rail is sorted; the flat list keeps its own whole-list computed-order walk. `aria-posinset`/`aria-setsize` follow painted order too, so the incremental render path re-runs `_applyTabTreePositions` after it re-sorts rows in place. ⚠️ `_handleTabTreeKeydown` acts only when the key lands on the treeitem ITSELF: a key on a focused in-row control (close, overflow, the rename input) is that control's, or Enter on the overflow button re-selects the row instead of reopening its menu. ⚠️ The roving `tabindex=-1` also hides every item but the stop from the keyboard-dismiss selector's `[tabindex]` arm, which is why `MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR` lists `[role="treeitem"]` (see Dismissing the on-screen keyboard). diff --git a/docs/wiki/Core-Concepts.md b/docs/wiki/Core-Concepts.md index 60da25e6..6efe48a5 100644 --- a/docs/wiki/Core-Concepts.md +++ b/docs/wiki/Core-Concepts.md @@ -13,6 +13,10 @@ Codeman-side configuration: - Which CLI the Run button should default to. - Per-case toggles (Agent Teams, 1M Opus context). +- A **Default session group** (Case settings…): new sessions of the case land in that tab group of the + vertical rail unless you start them from a group's own *New session* or they follow the session that + spawned them. *Ungrouped* turns it off. It follows you across devices, never moves existing tabs, and a + deleted group simply falls back to Ungrouped. - Where it runs, if it is not the local filesystem: see [Location overlays](#location-overlays). Three ways to get one, all under **New or link a case…** at the bottom of the case picker diff --git a/src/state-store.ts b/src/state-store.ts index 6afaddbf..88510c93 100644 --- a/src/state-store.ts +++ b/src/state-store.ts @@ -286,6 +286,9 @@ export class StateStore { if (this.state.tabLayouts !== undefined) { parts.push(`"tabLayouts":${JSON.stringify(this.state.tabLayouts)}`); } + if (this.state.caseDefaultGroups !== undefined) { + parts.push(`"caseDefaultGroups":${JSON.stringify(this.state.caseDefaultGroups)}`); + } return `{${parts.join(',')}}`; } @@ -710,6 +713,38 @@ export class StateStore { return { layouts: structuredClone(validated), sessionOrder: [...sessionOrder] }; } + /** + * An owner's default session group for a case: a group id, `null` for an + * intentional Ungrouped choice, or `undefined` when unset. state.json is read + * unvalidated, so anything but a string or null reads as unset. + */ + getCaseDefaultGroup(owner: string, caseName: string): string | null | undefined { + const all = this.state.caseDefaultGroups; + if (!all || typeof all !== 'object' || !Object.hasOwn(all, owner)) return undefined; + const forOwner = all[owner]; + if (!forOwner || typeof forOwner !== 'object' || !Object.hasOwn(forOwner, caseName)) return undefined; + const value = forOwner[caseName]; + return typeof value === 'string' || value === null ? value : undefined; + } + + /** Persist an owner's default session group for a case (`null` = Ungrouped). */ + setCaseDefaultGroup(owner: string, caseName: string, groupId: string | null): void { + const all = this.state.caseDefaultGroups ?? {}; + const forOwner = Object.hasOwn(all, owner) ? all[owner] : {}; + this.state.caseDefaultGroups = { ...all, [owner]: { ...forOwner, [caseName]: groupId } }; + this.save(); + } + + /** Forget an owner's default session group for a case (unset, not Ungrouped). */ + deleteCaseDefaultGroup(owner: string, caseName: string): void { + if (this.getCaseDefaultGroup(owner, caseName) === undefined) return; + const all = this.state.caseDefaultGroups ?? {}; + const forOwner = { ...all[owner] }; + delete forOwner[caseName]; + this.state.caseDefaultGroups = { ...all, [owner]: forOwner }; + this.save(); + } + /** Resets all state to initial values and saves immediately. */ reset(): void { this.state = createInitialState(); diff --git a/src/tab-layout-service.ts b/src/tab-layout-service.ts index 0b0e5802..a6c781d6 100644 --- a/src/tab-layout-service.ts +++ b/src/tab-layout-service.ts @@ -50,6 +50,11 @@ interface TabLayoutServiceDeps { * from then on; on, it keeps following its parent until moved by hand. */ childrenFollowParent?(): boolean | Promise<boolean>; + /** + * Where per-case default session groups live (StateStore). Optional so a + * harness without it simply never applies a case default. + */ + caseDefaults?: Pick<StateStore, 'getCaseDefaultGroup' | 'setCaseDefaultGroup' | 'deleteCaseDefaultGroup'>; now?: () => string; } @@ -78,6 +83,17 @@ export interface RemovedTabLayoutSession { /** Where a newly created session asked to be placed. Advisory: an unknown group is ignored. */ export interface SessionPlacementHint { tabGroupId?: string; + /** + * The case the session was started for. Its default session group (if any) + * is the fallback when neither `tabGroupId` nor a parent places the session. + */ + caseName?: string; +} + +/** A case's default session group plus the owner's current named groups to choose from. */ +export interface CaseDefaultGroupView { + groupId: string | null; + groups: Array<{ id: string; name: string }>; } /** @@ -158,6 +174,20 @@ function pinCreatedChild(layout: TabLayout, sessionId: string, metadata: readonl return moveRef(pinned, { kind: 'session', id: sessionId }, { groupId: group?.id ?? null, index: to }, metadata); } +/** + * Does the just-created session have a parent that places it (right after that + * parent)? Mirrors the conditions under which normalization and + * {@link pinCreatedChild} treat it as a child. + */ +function hasPlacingParent(layout: TabLayout, sessionId: string, metadata: readonly TabRefMetadata[]): boolean { + const item = metadata.find((fact) => fact.kind === 'session' && fact.id === sessionId); + const parentId = item?.parentSessionId; + if (!item?.ownerValid || !parentId) return false; + const parentValid = metadata.some((fact) => fact.kind === 'session' && fact.id === parentId && fact.ownerValid); + const refs = [...layout.groups.flatMap((group) => group.refs), ...layout.ungrouped]; + return parentValid && refs.some((ref) => ref.kind === 'session' && ref.id === parentId); +} + /** Move a just-created session to the end of `groupId`, or leave the layout alone. */ function placeCreatedSession( layout: TabLayout, @@ -550,17 +580,31 @@ export class TabLayoutService { ): Promise<TabLayout> { return this.withOwner(owner, async () => { const follow = sessionId ? (await this.deps.childrenFollowParent?.()) === true : true; + const caseName = sessionId ? placement.caseName : undefined; + const caseDefault = caseName ? this.deps.caseDefaults?.getCaseDefaultGroup(owner, caseName) : undefined; + let staleCaseDefault = false; const rollback = register?.(); try { const groupId = placement.tabGroupId; const adjust = - sessionId && (groupId || !follow) + sessionId && (groupId || !follow || typeof caseDefault === 'string') ? (layout: TabLayout, metadata: readonly TabRefMetadata[]) => { - const placed = groupId ? placeCreatedSession(layout, sessionId, groupId, metadata) : layout; + // Precedence: explicit group > parent placement > case default > none. + const explicit = groupId && layout.groups.some((group) => group.id === groupId) ? groupId : undefined; + let placed = explicit ? placeCreatedSession(layout, sessionId, explicit, metadata) : layout; + if (!explicit && typeof caseDefault === 'string') { + if (!layout.groups.some((group) => group.id === caseDefault)) staleCaseDefault = true; + else if (!hasPlacingParent(layout, sessionId, metadata)) + placed = placeCreatedSession(layout, sessionId, caseDefault, metadata); + } return follow ? placed : pinCreatedChild(placed, sessionId, metadata); } : undefined; - return await this.getUnlocked(owner, adjust); + const layout = await this.getUnlocked(owner, adjust); + // A default whose group was deleted is ignored above and forgotten here, + // only once the creation's own write has landed. + if (staleCaseDefault && caseName) this.deps.caseDefaults?.deleteCaseDefaultGroup(owner, caseName); + return layout; } catch (error) { if (typeof rollback === 'function') rollback(); throw error; @@ -568,6 +612,50 @@ export class TabLayoutService { }); } + /** + * A case's default session group and the owner's named groups to choose from. + * A stored id that is no longer one of this owner's groups is forgotten and + * reported as `null` (Ungrouped), which is also where creation would put it. + */ + async caseDefaultGroup(owner: string, caseName: string): Promise<CaseDefaultGroupView> { + return this.withOwner(owner, async () => { + const { authoritative } = await this.prepareUnlocked(owner); + const groups = authoritative.groups.map((group) => ({ id: group.id, name: group.name })); + const stored = this.deps.caseDefaults?.getCaseDefaultGroup(owner, caseName); + if (typeof stored === 'string' && !groups.some((group) => group.id === stored)) { + this.deps.caseDefaults?.deleteCaseDefaultGroup(owner, caseName); + return { groupId: null, groups }; + } + return { groupId: typeof stored === 'string' ? stored : null, groups }; + }); + } + + /** + * Set a case's default session group: one of this owner's current groups, or + * `null` for Ungrouped. Only future creations read it; nothing is moved. + * Throws {@link TabLayoutValidationError} for an id not in the owner's layout. + */ + async setCaseDefaultGroup(owner: string, caseName: string, groupId: string | null): Promise<CaseDefaultGroupView> { + return this.withOwner(owner, async () => { + const caseDefaults = this.deps.caseDefaults; + if (!caseDefaults) throw new Error('Case default groups are not available'); + const { authoritative } = await this.prepareUnlocked(owner); + const groups = authoritative.groups.map((group) => ({ id: group.id, name: group.name })); + if (groupId !== null && !groups.some((group) => group.id === groupId)) { + throw new TabLayoutValidationError('Unknown tab group'); + } + caseDefaults.setCaseDefaultGroup(owner, caseName, groupId); + return { groupId, groups }; + }); + } + + /** Forget a case's default session group (the case was deleted or unlinked). */ + async forgetCaseDefaultGroup(owner: string, caseName: string): Promise<void> { + await this.withOwner(owner, async () => { + this.deps.caseDefaults?.deleteCaseDefaultGroup(owner, caseName); + }); + } + /** Reconcile one completed saved-webview creation into one versioned mutation. */ async webviewCreated(owner: string): Promise<TabLayout> { return this.get(owner); diff --git a/src/types/app-state.ts b/src/types/app-state.ts index 4382573a..ccd96542 100644 --- a/src/types/app-state.ts +++ b/src/types/app-state.ts @@ -121,6 +121,12 @@ export interface AppState { sessionOrder?: string[]; /** Owner-scoped authoritative grouped tab layouts. */ tabLayouts?: Record<string, TabLayout>; + /** + * Per-case default session group, keyed by the tab-layout owner key, then case + * name. A group id from that owner's layout, or null for an intentional + * Ungrouped choice. Read and written only through TabLayoutService. + */ + caseDefaultGroups?: Record<string, Record<string, string | null>>; } // ========== Default Configuration ========== diff --git a/src/web/ports/session-port.ts b/src/web/ports/session-port.ts index adb4e85d..6a735f5b 100644 --- a/src/web/ports/session-port.ts +++ b/src/web/ports/session-port.ts @@ -13,7 +13,9 @@ export interface SessionPort { /** * Register a created session with the owner's tab layout. `placement.tabGroupId` * puts it at the end of that group in the same layout write (ignored when the - * group is not in the owner's layout). Resolves to the committed layout. + * group is not in the owner's layout); without one (and without a parent that + * places it), `placement.caseName`'s default session group does the same. + * Resolves to the committed layout. */ addSession(session: Session, placement?: SessionPlacementHint): Promise<TabLayout>; cleanupSession(sessionId: string, killMux?: boolean, reason?: string): Promise<void>; diff --git a/src/web/public/app.js b/src/web/public/app.js index 62716497..bfe0ae30 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -7692,6 +7692,8 @@ class CodemanApp { this._tabLayoutEditor = null; } this.tabLayout = next; + // An open Case settings popover lists the groups as its Default session group options. + this._refreshCaseDefaultGroupOptions?.(next); const storage = this._getTabCollapseStorage(); const collapsed = storage && next ? window.CodemanTabLayout.loadCollapsedGroupIds(storage, next.groups.map((group) => group.id)) diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index 14db1489..893ce13d 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -678,6 +678,9 @@ 'Agent Teams': '智能体团队', 'Claude Model': 'Claude 模型', '1M Opus Context': 'Opus 100 万上下文', + 'Default session group': '默认会话分组', + 'New sessions for this case land in this group': '此案例的新会话将放入此分组', + 'Could not save the default session group.': '无法保存默认会话分组。', 'Remote auto-reconnect': '远程自动重连', 'Pin tab names on the CLI': '在 CLI 上固定标签名', "Launch every Claude session with its tab name, so the CLI's prompt label and /resume list match your tabs. Claude then stops generating its own titles for those sessions, and a rename in Codeman holds only until the next turn. Applies from the next session start or respawn, no server restart needed.": diff --git a/src/web/public/index.html b/src/web/public/index.html index abbd2cd9..aac45b8d 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -774,6 +774,11 @@ <span>1M Opus Context</span> </label> <span class="form-hint">Use 1M token context window for new sessions</span> + <label class="case-default-group-label" for="caseDefaultGroup">Default session group</label> + <select id="caseDefaultGroup" class="case-default-group-select" onchange="app.onCaseDefaultGroupChanged(this)"> + <option value="">Ungrouped</option> + </select> + <span class="form-hint">New sessions for this case land in this group</span> </div> </div> <!-- Mobile-only case button + gear --> @@ -818,6 +823,11 @@ <span>1M Opus Context</span> </label> <span class="form-hint">Use 1M token context window</span> + <label class="case-default-group-label" for="caseDefaultGroupMobile">Default session group</label> + <select id="caseDefaultGroupMobile" class="case-default-group-select" onchange="app.onCaseDefaultGroupChanged(this)"> + <option value="">Ungrouped</option> + </select> + <span class="form-hint">New sessions for this case land in this group</span> </div> </div> diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index 6067dc0b..73bf6303 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -3272,6 +3272,7 @@ Object.assign(CodemanApp.prototype, { document.getElementById('caseAgentTeams').checked = settings.agentTeams; document.getElementById('caseOpusContext1m').checked = settings.opusContext1m; popover.classList.remove('hidden'); + this._loadCaseDefaultGroup(); // Close on outside click (one-shot listener) const closeHandler = (e) => { @@ -3320,6 +3321,7 @@ Object.assign(CodemanApp.prototype, { document.getElementById('caseAgentTeamsMobile').checked = settings.agentTeams; document.getElementById('caseOpusContext1mMobile').checked = settings.opusContext1m; popover.classList.remove('hidden'); + this._loadCaseDefaultGroup(); const closeHandler = (e) => { if (!popover.contains(e.target) && !e.target.classList.contains('btn-case-settings-mobile')) { @@ -3346,6 +3348,97 @@ Object.assign(CodemanApp.prototype, { if (desktopOpusCheckbox) desktopOpusCheckbox.checked = settings.opusContext1m; }, + // ── Default session group (server-side, per owner + case) ── + // + // Unlike the two checkboxes above (this browser's localStorage), the default + // group lives on the server, because the server places a new session in it in + // the same layout write that registers it. The value is a group ID; '' in the + // <select> is Ungrouped (null on the wire). Desktop and phone popovers mirror. + + _caseDefaultGroupSelects() { + return ['caseDefaultGroup', 'caseDefaultGroupMobile'] + .map((id) => document.getElementById(id)) + .filter(Boolean); + }, + + /** Rebuild both selectors from `groups`, keeping `groupId` if it still exists. */ + _renderCaseDefaultGroupSelects(groups, groupId) { + const known = groups.some((group) => group.id === groupId); + const value = known ? groupId : ''; + for (const select of this._caseDefaultGroupSelects()) { + const ungrouped = document.createElement('option'); + ungrouped.value = ''; + ungrouped.textContent = 'Ungrouped'; + const options = groups.map((group) => { + const option = document.createElement('option'); + option.value = group.id; + option.textContent = group.name; + option.setAttribute('data-i18n-skip', ''); + return option; + }); + select.replaceChildren(ungrouped, ...options); + select.value = value; + } + return value; + }, + + /** Read the selected case's default group from the server (popover open). */ + async _loadCaseDefaultGroup() { + const caseName = document.getElementById('quickStartCase')?.value; + const selects = this._caseDefaultGroupSelects(); + if (!caseName || selects.length === 0) return; + for (const select of selects) select.disabled = true; + const data = await this._apiJson(`/api/cases/${encodeURIComponent(caseName)}/default-group`); + if (document.getElementById('quickStartCase')?.value !== caseName) return; // case switched meanwhile + const groups = Array.isArray(data?.groups) ? data.groups : []; + const groupId = typeof data?.groupId === 'string' ? data.groupId : null; + this._caseDefaultGroupState = { caseName, groupId, groups }; + this._renderCaseDefaultGroupSelects(groups, groupId); + for (const select of selects) select.disabled = !data; + }, + + /** Save a selector change; on refusal restore the last confirmed value. */ + async onCaseDefaultGroupChanged(select) { + const state = this._caseDefaultGroupState; + if (!state) return; + const groupId = select.value || null; + const selects = this._caseDefaultGroupSelects(); + for (const other of selects) { + other.value = select.value; + other.disabled = true; + } + const res = await this._apiPut(`/api/cases/${encodeURIComponent(state.caseName)}/default-group`, { groupId }); + let saved = null; + if (res?.ok) { + try { + saved = (await res.json())?.data ?? null; + } catch { + saved = null; + } + } + if (saved) { + state.groupId = typeof saved.groupId === 'string' ? saved.groupId : null; + if (Array.isArray(saved.groups)) state.groups = saved.groups; + } else { + this.showToast?.('Could not save the default session group.', 'error'); + } + this._renderCaseDefaultGroupSelects(state.groups, state.groupId); + for (const other of selects) other.disabled = false; + }, + + /** + * The tab layout changed (a group was created, renamed or deleted): redraw the + * options from it. A deleted selection shows as Ungrouped, which is where the + * server now places new sessions (it forgets the stale ID on its next read). + */ + _refreshCaseDefaultGroupOptions(layout) { + const state = this._caseDefaultGroupState; + if (!state || !layout || !Array.isArray(layout.groups)) return; + state.groups = layout.groups.map((group) => ({ id: group.id, name: group.name })); + const value = this._renderCaseDefaultGroupSelects(state.groups, state.groupId); + if (!value) state.groupId = null; + }, + // ═══════════════════════════════════════════════════════════════ // Create Case Modal // ═══════════════════════════════════════════════════════════════ diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 624364d9..64dd459a 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -6669,6 +6669,27 @@ html[data-skin="og"] .welcome-primary:hover { display: none; } +/* Default session group selector (Case settings, desktop and phone popovers) */ +.case-default-group-label { + display: block; + margin-top: 8px; + font-size: 0.75rem; + color: var(--text); +} + +.case-default-group-select { + display: block; + width: 100%; + margin-top: 4px; + padding: 3px 6px; + font: inherit; + font-size: 0.75rem; + color: var(--text); + background: var(--bg-input, var(--bg-card)); + border: 1px solid var(--control-border); + border-radius: 6px; +} + .case-settings-popover .form-hint { margin-top: 0.2rem; font-size: 0.6rem; diff --git a/src/web/routes/admin-routes.ts b/src/web/routes/admin-routes.ts index 582006e1..bc18ec33 100644 --- a/src/web/routes/admin-routes.ts +++ b/src/web/routes/admin-routes.ts @@ -42,6 +42,8 @@ import { SseEvent } from '../sse-events.js'; import type { AuthPort } from '../ports/auth-port.js'; import type { SessionPort } from '../ports/session-port.js'; import type { EventPort } from '../ports/event-port.js'; +import type { TabLayoutPort } from '../ports/tab-layout-port.js'; +import { ownerLayoutKey } from '../../tab-layout-persistence.js'; const CreateUserSchema = z.object({ username: z.string().min(1).max(64), @@ -73,7 +75,10 @@ function storeError(reply: FastifyReply, err: unknown): ReturnType<typeof create return createErrorResponse(ApiErrorCode.INTERNAL_ERROR, err instanceof Error ? err.message : 'error'); } -export function registerAdminRoutes(app: FastifyInstance, ctx: SessionPort & AuthPort & EventPort): void { +export function registerAdminRoutes( + app: FastifyInstance, + ctx: SessionPort & AuthPort & EventPort & TabLayoutPort +): void { // Gate: admin routes exist only in multi-user mode, and only for admins. const gate = (req: FastifyRequest, reply: FastifyReply): boolean => { if (!isMultiUserMode()) { @@ -299,6 +304,8 @@ export function registerAdminRoutes(app: FastifyInstance, ctx: SessionPort & Aut ); } await fsp.rm(realTarget, { recursive: true, force: true }); + // The user's default session group for that case goes with it. + await ctx.tabLayouts.forgetCaseDefaultGroup(ownerLayoutKey(username), params.caseName).catch(() => undefined); audit(req, 'user.case-delete', username, { caseName: params.caseName }); ctx.broadcast(SseEvent.AdminUsersChanged, {}); return { success: true, data: { username, caseName: params.caseName } }; diff --git a/src/web/routes/case-routes.ts b/src/web/routes/case-routes.ts index 8eb2ff87..6d5c7c42 100644 --- a/src/web/routes/case-routes.ts +++ b/src/web/routes/case-routes.ts @@ -29,6 +29,7 @@ import { ClonePreflightSchema, LinkCaseSchema, CaseOrderSchema, + CaseDefaultGroupSchema, RemoteCaseLinkSchema, RemoteHostSchema, DockerCaseLinkSchema, @@ -51,7 +52,7 @@ import { import type { GitRemoteProbe, GitUrlParse } from '../../git-clone.js'; import { generateClaudeMd } from '../../templates/claude-md.js'; import { prepareNewCasePath } from '../case-path.js'; -import { boundedPathExists, describeUnknownPath, probePath } from '../../utils/index.js'; +import { boundedPathExists, describeUnknownPath, probePath, probePathKind } from '../../utils/index.js'; import { readAgentCaseMarker, type AgentCaseMarker } from '../../agent-case-marker.js'; import { settingsWriteBlocker, writeHooksConfig } from '../../hooks-config.js'; import { @@ -70,7 +71,9 @@ import { import { isMultiUserMode } from '../../config/multiuser.js'; import type { AuthUser } from '../../types.js'; import { SseEvent } from '../sse-events.js'; -import type { EventPort, ConfigPort, SessionPort } from '../ports/index.js'; +import type { EventPort, ConfigPort, SessionPort, TabLayoutPort } from '../ports/index.js'; +import { ownerLayoutKey } from '../../tab-layout-persistence.js'; +import { TabLayoutValidationError } from '../../tab-layout.js'; import type { FastifyRequest } from 'fastify'; import { dataPath, getDataDir } from '../../config/instance.js'; import { @@ -246,7 +249,33 @@ async function ensureCaseImage( return { ok: true, imageBuilding: true }; } -export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & ConfigPort & SessionPort): void { +/** + * Is `name` a case the caller can see (the same rules as GET /api/cases and + * resolveCasePath): an owned remote or Docker case, a linked case (admin or + * single-user only), or a directory in the caller's own case space. Bounded: + * a case folder on a mount that stopped answering still counts as there, like + * the case list keeps listing it. + */ +async function caseVisibleToCaller(name: string, req: FastifyRequest): Promise<boolean> { + if (!SAFE_CASE_NAME.test(name)) return false; + const user = getAuthUser(req); + if ((await readRemoteCases(CODEMAN_CONFIG_DIR)).some((c) => c.name === name && canAccessOwned(user, c.owner))) { + return true; + } + if ((await readDockerCases(CODEMAN_CONFIG_DIR)).some((c) => c.name === name && canAccessOwned(user, c.owner))) { + return true; + } + const linkedCases = await readLinkedCases(); + if (linkedCases[name] && (!isMultiUserMode() || isAdmin(req))) + return (await probePath(linkedCases[name])) !== 'absent'; + const kind = await probePathKind(join(resolveCasesDir(user), name)); + return kind === 'directory' || kind === 'unknown'; +} + +export function registerCaseRoutes( + app: FastifyInstance, + ctx: EventPort & ConfigPort & SessionPort & TabLayoutPort +): void { // ═══════════════════════════════════════════════════════════════ // Case CRUD (list, create, link, detail, fix-plan) // ═══════════════════════════════════════════════════════════════ @@ -1584,6 +1613,19 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config app.delete('/api/cases/:name', async (req): Promise<ApiResponse<{ name: string }>> => { const { name } = req.params as { name: string }; const user = getAuthUser(req); + // The caller's default session group for this case goes with it (best-effort: + // a stale value is ignored at creation anyway). + // An admin deleting another user's remote/Docker case clears that owner's too. + const forgetCaseDefault = async (caseOwner?: string): Promise<void> => { + const owners = new Set([ownerLayoutKey(ownerFor(req)), ...(caseOwner ? [ownerLayoutKey(caseOwner)] : [])]); + for (const owner of owners) { + try { + await ctx.tabLayouts.forgetCaseDefaultGroup(owner, name); + } catch { + /* never blocks the delete */ + } + } + }; if (!validatePathWithinBase(name, resolveCasesDir(user))) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name'); @@ -1593,12 +1635,14 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config // docker case is skipped so control falls through to the caller's own local delete. // canAccessOwned is all-true for admins/single-user, so flag-OFF stays byte-identical. const remoteCases = await readRemoteCases(CODEMAN_CONFIG_DIR); - if (remoteCases.some((item) => item.name === name && canAccessOwned(user, item.owner))) { + const remoteCase = remoteCases.find((item) => item.name === name && canAccessOwned(user, item.owner)); + if (remoteCase) { await writeRemoteCases( CODEMAN_CONFIG_DIR, remoteCases.filter((item) => !(item.name === name && canAccessOwned(user, item.owner))) ); ctx.broadcast(SseEvent.CaseDeleted, { name, type: 'remote-unlinked' }); + await forgetCaseDefault(remoteCase.owner); return { success: true, data: { name } }; } @@ -1631,6 +1675,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config .catch(() => {}); } ctx.broadcast(SseEvent.CaseDeleted, { name, type: 'docker-unlinked' }); + await forgetCaseDefault(dockerCase.owner); return { success: true, data: { name } }; } @@ -1643,6 +1688,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config try { await fs.writeFile(LINKED_CASES_FILE, JSON.stringify(linkedCases, null, 2)); ctx.broadcast(SseEvent.CaseDeleted, { name, type: 'unlinked' }); + await forgetCaseDefault(); return { success: true, data: { name } }; } catch (err) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); @@ -1658,12 +1704,48 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config try { await fs.rm(casePath, { recursive: true, force: true }); ctx.broadcast(SseEvent.CaseDeleted, { name, type: 'deleted' }); + await forgetCaseDefault(); return { success: true, data: { name } }; } catch (err) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); } }); + // ========== Default session group ========== + + /** + * The tab group new sessions of this case land in when the create request names + * no group and no parent places them. Scoped to the caller's own tab layout: the + * options are their groups, and a stored id that is no longer one of them reads + * as Ungrouped (null) and is forgotten. Not admin-gated in multi-user mode: the + * value is per owner (like the layout it points into), never server-wide. + */ + app.get('/api/cases/:name/default-group', async (req, reply) => { + const { name } = req.params as { name: string }; + if (!(await caseVisibleToCaller(name, req))) { + return reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, `Case "${name}" not found`)); + } + const view = await ctx.tabLayouts.caseDefaultGroup(ownerLayoutKey(ownerFor(req)), name); + return { success: true, data: { caseName: name, ...view } }; + }); + + app.put('/api/cases/:name/default-group', async (req, reply) => { + const { name } = req.params as { name: string }; + const { groupId } = parseBody(CaseDefaultGroupSchema, req.body, 'Invalid default group'); + if (!(await caseVisibleToCaller(name, req))) { + return reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, `Case "${name}" not found`)); + } + try { + const view = await ctx.tabLayouts.setCaseDefaultGroup(ownerLayoutKey(ownerFor(req)), name, groupId); + return { success: true, data: { caseName: name, ...view } }; + } catch (err) { + if (err instanceof TabLayoutValidationError) { + return reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'That tab group does not exist')); + } + throw err; + } + }); + // ========== Reorder Cases ========== app.put('/api/cases/order', async (req, reply): Promise<ApiResponse<{ order: string[] }> | undefined> => { diff --git a/src/web/routes/ralph-routes.ts b/src/web/routes/ralph-routes.ts index e8df0f74..0f624a19 100644 --- a/src/web/routes/ralph-routes.ts +++ b/src/web/routes/ralph-routes.ts @@ -413,8 +413,8 @@ export function registerRalphRoutes( const promptPath = join(casePath, '@ralph_prompt.md'); writeFileSync(promptPath, fullPrompt, 'utf-8'); - // Register session - await ctx.addSession(session); + // Register session (in its case's default session group, if one is set) + await ctx.addSession(session, { caseName }); ctx.store.incrementSessionsCreated(); ctx.persistSessionState(session); await ctx.setupSessionListeners(session); diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 0a794d8a..96b9d455 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -4,9 +4,9 @@ * auto-clear, auto-compact, image watcher, flicker filter, and logout. */ -import { FastifyInstance, type FastifyReply } from 'fastify'; +import { FastifyInstance, type FastifyReply, type FastifyRequest } from 'fastify'; import { z } from 'zod'; -import { join, dirname, extname, basename } from 'node:path'; +import { join, dirname, extname, basename, resolve as resolvePath } from 'node:path'; import { homedir } from 'node:os'; import { existsSync, statSync, mkdirSync, writeFileSync } from 'node:fs'; import { execFile } from 'node:child_process'; @@ -76,7 +76,7 @@ import { } from './custom-model-routes.js'; import { matchesPattern } from '../../config/cli-registry/patterns.js'; import { ownerLayoutKey } from '../../tab-layout-persistence.js'; -import { TabLayoutValidationError } from '../../tab-layout.js'; +import { TabLayoutValidationError, type TabLayout } from '../../tab-layout.js'; import { sessionWaits, resolveWaitSignals, @@ -107,6 +107,7 @@ import { ownerFor, parseBody, persistAndBroadcastSession, + readJsonConfig, resolveAgentCaseOrigin, resolveCasesDir, resolveParentSessionId, @@ -204,6 +205,35 @@ import { UPLOADS_DIR } from '../paste-image-gc.js'; const LINKED_CASES_FILE = dataPath('linked-cases.json'); const CODEMAN_CONFIG_DIR = getDataDir(); +const SAFE_CASE_NAME = /^[a-zA-Z0-9_-]+$/; + +/** Did the committed layout put this session in a named group (not Ungrouped)? */ +function landedInGroup(layout: TabLayout | undefined, sessionId: string): boolean { + return !!layout?.groups.some((group) => group.refs.some((ref) => ref.kind === 'session' && ref.id === sessionId)); +} + +/** + * The case a POST /api/sessions `workingDir` belongs to, for its default session + * group: a direct child of the caller's case space, or (admin / single-user, as + * GET /api/cases lists them) a linked case's exact path. Lexical only: the + * browser sends the path the case list gave it, and this must never touch a + * possibly-dead mount. Anything else is no case, which keeps today's placement. + */ +async function inferLocalCaseName(req: FastifyRequest, workingDir: string): Promise<string | undefined> { + const target = resolvePath(workingDir); + const user = getAuthUser(req); + if (dirname(target) === resolvePath(resolveCasesDir(user))) { + const name = basename(target); + if (SAFE_CASE_NAME.test(name)) return name; + } + if (isMultiUserMode() && !isAdmin(req)) return undefined; + const linked = await readJsonConfig<Record<string, string>>(LINKED_CASES_FILE, 'linked cases', {}); + for (const [name, path] of Object.entries(linked)) { + if (SAFE_CASE_NAME.test(name) && typeof path === 'string' && resolvePath(path) === target) return name; + } + return undefined; +} + // Pre-compiled regex for terminal buffer cleaning (avoids per-request compilation) // eslint-disable-next-line no-control-regex const CLAUDE_BANNER_PATTERN = /\x1b\[1mClaud/; @@ -1412,9 +1442,13 @@ export function registerSessionRoutes( parentSessionId: resolveParentSessionId(ctx, req, body.parentSessionId, owner), }); - const tabLayout = body.tabGroupId - ? await ctx.addSession(session, { tabGroupId: body.tabGroupId }) - : await ctx.addSession(session); + const placementCase = remote ? undefined : await inferLocalCaseName(req, workingDir); + const placement = { + ...(body.tabGroupId ? { tabGroupId: body.tabGroupId } : {}), + ...(placementCase ? { caseName: placementCase } : {}), + }; + const tabLayout = + Object.keys(placement).length > 0 ? await ctx.addSession(session, placement) : await ctx.addSession(session); ctx.store.incrementSessionsCreated(); ctx.persistSessionState(session); await ctx.setupSessionListeners(session); @@ -1432,9 +1466,13 @@ export function registerSessionRoutes( // Avoids serializing 2-3MB of terminal+text buffers per session creation. const lightState = ctx.getSessionStateWithRespawn(session); ctx.broadcast(SseEvent.SessionCreated, lightState); - // A grouped creation also returns the layout that placed it, so the browser - // draws the tab inside its group at once instead of after a re-read. - return { session: lightState, ...(body.tabGroupId ? { tabLayout } : {}) }; + // A grouped creation (asked for, or its case's default group) also returns the + // layout that placed it, so the browser draws the tab inside its group at once + // instead of after a re-read. + return { + session: lightState, + ...(body.tabGroupId || landedInGroup(tabLayout, session.id) ? { tabLayout } : {}), + }; }); // ========== Rename Session ========== @@ -4461,7 +4499,7 @@ export function registerSessionRoutes( } } - const tabLayout = tabGroupId ? await ctx.addSession(session, { tabGroupId }) : await ctx.addSession(session); + const tabLayout = await ctx.addSession(session, { caseName, ...(tabGroupId ? { tabGroupId } : {}) }); ctx.store.incrementSessionsCreated(); ctx.persistSessionState(session); await ctx.setupSessionListeners(session); @@ -4553,7 +4591,7 @@ export function registerSessionRoutes( caseName, ...(worktree ? { worktree, workingDir: workspacePath } : {}), ...(customModel ? { modelSwapInProgress: qsCustomModelSwapInProgress } : {}), - ...(tabGroupId ? { tabLayout } : {}), + ...(tabGroupId || landedInGroup(tabLayout, session.id) ? { tabLayout } : {}), }; } catch (err) { // Clean up session on error to prevent orphaned resources diff --git a/src/web/schemas.ts b/src/web/schemas.ts index ce577394..b97b9938 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -1978,6 +1978,15 @@ export const CaseOrderSchema = z.object({ order: z.array(z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format')), }); +/** + * PUT /api/cases/:name/default-group — the tab group new sessions of this case + * land in: one of the caller's own group ids, or null for Ungrouped. Same bounds + * as tabGroupIdSchema; the route checks the id against the caller's layout. + */ +export const CaseDefaultGroupSchema = z.object({ + groupId: z.string().min(1).max(100).nullable(), +}); + /** PUT /api/session-order — global tab order (ordered sessionIds), COD-131 */ export const SessionOrderUpdateSchema = z.object({ // Bounded defensively: ids are uuid-ish (<=100 chars) and the client pushes only diff --git a/src/web/server.ts b/src/web/server.ts index f9bb8b47..f6e10dc7 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -470,6 +470,7 @@ export class WebServer extends EventEmitter { // Fresh, not the 2s cache: the settings PUT does not invalidate it, and a // flip must apply to the very next spawn. childrenFollowParent: async () => (await this.readSettings(true)).spawnedTabsFollowParent === true, + caseDefaults: this.store, }); if (this.testMode) this.tabLayouts.markRestorationSkipped(); @@ -725,8 +726,8 @@ export class WebServer extends EventEmitter { /** * Add a tentative session only after its owner layout accepts the creation. * The session enters the live map inside the layout service's owner lock, so - * the placement it asked for (`tabGroupId`) lands in the same versioned write - * that first records it. + * the placement it asked for (`tabGroupId`, else its case's default group) + * lands in the same versioned write that first records it. */ private async registerSessionWithLayout(session: Session, placement?: SessionPlacementHint): Promise<TabLayout> { return this.tabLayouts.sessionCreated(ownerLayoutKey(session.owner), session.id, placement, () => { diff --git a/test/admin-routes.test.ts b/test/admin-routes.test.ts index e2ea176a..07308403 100644 --- a/test/admin-routes.test.ts +++ b/test/admin-routes.test.ts @@ -189,10 +189,13 @@ describe('admin case-folder API', () => { expect(sl.status).toBe(400); await expect(fs.stat(path.join(spacesDir, 'outside'))).resolves.toBeTruthy(); - // A real folder is deleted. + // A real folder is deleted, and kim's default session group for it goes with it. + const store = (server as unknown as { store: import('../src/state-store.js').StateStore }).store; + store.setCaseDefaultGroup('kim', 'proj1', null); const del = await fetch(url('/api/admin/users/kim/cases/proj1'), { method: 'DELETE', headers: adminNoBody }); expect(del.status).toBe(200); await expect(fs.stat(path.join(spacesDir, 'kim', 'cases', 'proj1'))).rejects.toBeTruthy(); + expect(store.getCaseDefaultGroup('kim', 'proj1')).toBeUndefined(); // Deleting it again 404s. const gone = await fetch(url('/api/admin/users/kim/cases/proj1'), { method: 'DELETE', headers: adminNoBody }); diff --git a/test/case-default-group-ui.test.ts b/test/case-default-group-ui.test.ts new file mode 100644 index 00000000..d85f3175 --- /dev/null +++ b/test/case-default-group-ui.test.ts @@ -0,0 +1,160 @@ +/** + * @fileoverview The Case settings "Default session group" selector (session-ui.js): + * it lists Ungrouped plus every named group the server reports, saves through + * PUT /api/cases/:name/default-group, reverts on a refused save, mirrors the + * desktop and phone popovers, and refreshes its options when the tab layout + * changes (groups created, renamed or deleted). Also pins the zh-CN strings. + * + * Port: none (vm sandbox, stubbed fetch and DOM). + */ +import { readFileSync } from 'node:fs'; +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 fakeSelect() { + const select: any = { + value: '', + disabled: false, + options: [] as any[], + replaceChildren(...nodes: any[]) { + select.options = nodes; + }, + }; + return select; +} + +function harness(server: { groups: Array<{ id: string; name: string }>; groupId: string | null; putStatus?: number }) { + const elements: Record<string, any> = { + quickStartCase: { value: 'proj' }, + caseDefaultGroup: fakeSelect(), + caseDefaultGroupMobile: fakeSelect(), + }; + const requests: Array<{ url: string; method: string; body?: any }> = []; + const CodemanApp = function CodemanApp(this: any) {}; + const context = vm.createContext({ + CodemanApp, + localStorage: { getItem: () => null, setItem: () => {} }, + document: { + getElementById: (id: string) => elements[id] ?? null, + createElement: () => { + const attrs: Record<string, string> = {}; + return { value: '', textContent: '', attrs, setAttribute: (k: string, v: string) => (attrs[k] = v) }; + }, + }, + fetch: async (url: string, init?: { method?: string; body?: string }) => { + const method = init?.method ?? 'GET'; + const body = init?.body ? JSON.parse(init.body) : undefined; + requests.push({ url, method, body }); + const ok = method !== 'PUT' || (server.putStatus ?? 200) === 200; + if (method === 'PUT' && ok) server.groupId = body.groupId; + const payload = ok + ? { success: true, data: { caseName: 'proj', groupId: server.groupId, groups: server.groups } } + : { success: false, error: 'nope', errorCode: 'INVALID_INPUT' }; + return { ok, status: ok ? 200 : (server.putStatus ?? 400), json: async () => payload }; + }, + console, + }); + for (const file of ['api-client.js', 'session-ui.js']) { + vm.runInContext(readFileSync(resolve(PUBLIC, file), 'utf8'), context, { filename: file }); + } + const app = new (CodemanApp as any)(); + app.showToast = vi.fn(); + return { app, elements, requests, server }; +} + +const optionView = (select: any) => select.options.map((o: any) => [o.value, o.textContent]); + +describe('Case settings: Default session group selector', () => { + it('lists Ungrouped plus every named group, selecting the stored one in both popovers', async () => { + const h = harness({ + groups: [ + { id: 'g1', name: 'Core' }, + { id: 'g2', name: 'Ops' }, + ], + groupId: 'g2', + }); + await h.app._loadCaseDefaultGroup(); + expect(h.requests[0]).toMatchObject({ url: '/api/cases/proj/default-group', method: 'GET' }); + for (const id of ['caseDefaultGroup', 'caseDefaultGroupMobile']) { + const select = h.elements[id]; + expect(optionView(select)).toEqual([ + ['', 'Ungrouped'], + ['g1', 'Core'], + ['g2', 'Ops'], + ]); + expect(select.value).toBe('g2'); + expect(select.disabled).toBe(false); + // Group names are user text: never handed to the translator. + expect(select.options[1].attrs['data-i18n-skip']).toBe(''); + expect(select.options[0].attrs['data-i18n-skip']).toBeUndefined(); + } + }); + + it('saves a choice (and Ungrouped as null) and mirrors it to the other popover', async () => { + const h = harness({ groups: [{ id: 'g1', name: 'Core' }], groupId: null }); + await h.app._loadCaseDefaultGroup(); + h.elements.caseDefaultGroup.value = 'g1'; + await h.app.onCaseDefaultGroupChanged(h.elements.caseDefaultGroup); + expect(h.requests.at(-1)).toEqual({ url: '/api/cases/proj/default-group', method: 'PUT', body: { groupId: 'g1' } }); + expect(h.elements.caseDefaultGroupMobile.value).toBe('g1'); + + h.elements.caseDefaultGroupMobile.value = ''; + await h.app.onCaseDefaultGroupChanged(h.elements.caseDefaultGroupMobile); + expect(h.requests.at(-1)?.body).toEqual({ groupId: null }); + expect(h.elements.caseDefaultGroup.value).toBe(''); + }); + + it('reverts to the confirmed value and says so when the save is refused', async () => { + const h = harness({ groups: [{ id: 'g1', name: 'Core' }], groupId: 'g1', putStatus: 400 }); + await h.app._loadCaseDefaultGroup(); + h.elements.caseDefaultGroup.value = ''; + await h.app.onCaseDefaultGroupChanged(h.elements.caseDefaultGroup); + expect(h.elements.caseDefaultGroup.value).toBe('g1'); + expect(h.app.showToast).toHaveBeenCalledWith('Could not save the default session group.', 'error'); + }); + + it('refreshes its options when the layout changes: created, renamed and deleted groups', async () => { + const h = harness({ groups: [{ id: 'g1', name: 'Core' }], groupId: 'g1' }); + await h.app._loadCaseDefaultGroup(); + h.app._refreshCaseDefaultGroupOptions({ + groups: [ + { id: 'g1', name: 'Core team' }, + { id: 'g3', name: 'New' }, + ], + }); + expect(optionView(h.elements.caseDefaultGroup)).toEqual([ + ['', 'Ungrouped'], + ['g1', 'Core team'], + ['g3', 'New'], + ]); + expect(h.elements.caseDefaultGroup.value).toBe('g1'); + // The selected group is deleted: the selector visibly falls back to Ungrouped. + h.app._refreshCaseDefaultGroupOptions({ groups: [{ id: 'g3', name: 'New' }] }); + expect(h.elements.caseDefaultGroup.value).toBe(''); + expect(h.elements.caseDefaultGroupMobile.value).toBe(''); + }); + + it('the layout refresh is wired into _applyTabLayout', () => { + const app = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8'); + const body = app.slice(app.indexOf(' _applyTabLayout(layout) {')); + expect(body.slice(0, body.indexOf('\n }\n'))).toMatch(/^\s*this\._refreshCaseDefaultGroupOptions\?\.\(next\);/m); + }); + + it('both popovers carry the selector, and every new string has a zh-CN entry', () => { + const html = readFileSync(resolve(PUBLIC, 'index.html'), 'utf8'); + expect(html).toContain('id="caseDefaultGroup"'); + expect(html).toContain('id="caseDefaultGroupMobile"'); + const i18n = readFileSync(resolve(PUBLIC, 'i18n.js'), 'utf8'); + for (const text of [ + 'Default session group', + 'New sessions for this case land in this group', + 'Could not save the default session group.', + 'Ungrouped', + ]) { + expect(i18n, text).toMatch(new RegExp(`['"]?${text.replace(/[.]/g, '\\.')}['"]?: '`)); + } + }); +}); diff --git a/test/case-default-group.test.ts b/test/case-default-group.test.ts new file mode 100644 index 00000000..5b2c79b3 --- /dev/null +++ b/test/case-default-group.test.ts @@ -0,0 +1,332 @@ +/** + * @fileoverview Per-case default session group: the state.json store and the + * TabLayoutService placement precedence (explicit group > parent > case default > none). + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { StateStore } from '../src/state-store.js'; +import { TabLayoutService } from '../src/tab-layout-service.js'; +import { TabLayoutValidationError, type TabLayout } from '../src/tab-layout.js'; + +type SessionFact = { id: string; owner?: string; createdAt: number; parentSessionId?: string }; + +describe('StateStore case default groups', () => { + let dir: string; + let file: string; + beforeEach(() => { + dir = mkdtempSync(join(tmpdir(), 'codeman-case-default-')); + file = join(dir, 'state.json'); + }); + afterEach(() => rmSync(dir, { recursive: true, force: true })); + + it('distinguishes unset from an intentional Ungrouped choice, per owner', () => { + const store = new StateStore(file); + expect(store.getCaseDefaultGroup('@single', 'app')).toBeUndefined(); + store.setCaseDefaultGroup('@single', 'app', 'g1'); + store.setCaseDefaultGroup('@single', 'other', null); + store.setCaseDefaultGroup('bob', 'app', 'g9'); + expect(store.getCaseDefaultGroup('@single', 'app')).toBe('g1'); + expect(store.getCaseDefaultGroup('@single', 'other')).toBeNull(); + expect(store.getCaseDefaultGroup('bob', 'app')).toBe('g9'); + expect(store.getCaseDefaultGroup('alice', 'app')).toBeUndefined(); + }); + + it('survives a save and reload (persistence round-trip)', () => { + const store = new StateStore(file); + store.setCaseDefaultGroup('@single', 'app', 'g1'); + store.setCaseDefaultGroup('@single', 'none', null); + store.saveNow(); + const disk = JSON.parse(readFileSync(file, 'utf-8')); + expect(disk.caseDefaultGroups).toEqual({ '@single': { app: 'g1', none: null } }); + const reloaded = new StateStore(file); + expect(reloaded.getCaseDefaultGroup('@single', 'app')).toBe('g1'); + expect(reloaded.getCaseDefaultGroup('@single', 'none')).toBeNull(); + }); + + it('forgets one owner mapping without touching another owner', () => { + const store = new StateStore(file); + store.setCaseDefaultGroup('alice', 'app', 'g1'); + store.setCaseDefaultGroup('bob', 'app', 'g2'); + store.deleteCaseDefaultGroup('alice', 'app'); + expect(store.getCaseDefaultGroup('alice', 'app')).toBeUndefined(); + expect(store.getCaseDefaultGroup('bob', 'app')).toBe('g2'); + }); + + it('reads a malformed on-disk value as unset instead of throwing', () => { + writeFileSync( + file, + JSON.stringify({ sessions: {}, caseDefaultGroups: { '@single': { app: 42, ok: 'g1' }, bad: 'x' } }) + ); + const store = new StateStore(file); + expect(store.getCaseDefaultGroup('@single', 'app')).toBeUndefined(); + expect(store.getCaseDefaultGroup('@single', 'ok')).toBe('g1'); + expect(store.getCaseDefaultGroup('bad', 'app')).toBeUndefined(); + expect(store.getCaseDefaultGroup('@single', '__proto__')).toBeUndefined(); + }); +}); + +function createHarness(options: { layouts?: Record<string, TabLayout>; live?: SessionFact[]; follow?: boolean } = {}) { + const layouts = { ...(options.layouts ?? {}) }; + const live = new Map((options.live ?? []).map((session) => [session.id, session])); + const order: string[] = []; + const defaults: Record<string, Record<string, string | null>> = {}; + const caseDefaults = { + getCaseDefaultGroup: vi.fn((owner: string, caseName: string) => + defaults[owner] && caseName in defaults[owner] ? defaults[owner][caseName] : undefined + ), + setCaseDefaultGroup: vi.fn((owner: string, caseName: string, groupId: string | null) => { + defaults[owner] = { ...(defaults[owner] ?? {}), [caseName]: groupId }; + }), + deleteCaseDefaultGroup: vi.fn((owner: string, caseName: string) => { + if (defaults[owner]) delete defaults[owner][caseName]; + }), + }; + const store = { + getTabLayout: vi.fn((owner: string) => layouts[owner] ?? null), + getTabLayouts: vi.fn(() => ({ ...layouts })), + getSessions: vi.fn(() => ({})), + getSessionOrder: vi.fn(() => [...order]), + commitTabLayoutProjection: vi.fn( + (updates: Readonly<Record<string, TabLayout>>, project: (latest: readonly string[]) => readonly string[]) => { + const projected = [...project([...order])]; + Object.assign(layouts, structuredClone(updates)); + order.splice(0, order.length, ...projected); + return { layouts: structuredClone(updates), sessionOrder: [...order] }; + } + ), + }; + const service = new TabLayoutService({ + store: store as never, + sessions: live as never, + readWebviews: vi.fn(async () => []), + broadcast: vi.fn(), + broadcastSessionOrder: vi.fn(), + childrenFollowParent: vi.fn(() => options.follow === true), + caseDefaults, + now: () => '2026-10-22T12:00:00.000Z', + }); + const register = (fact: SessionFact) => + vi.fn(() => { + live.set(fact.id, fact); + return () => live.delete(fact.id); + }); + return { service, store, layouts, live, defaults, caseDefaults, register }; +} + +const grouped = (version = 7): TabLayout => ({ + version, + groups: [ + { id: 'g1', name: 'Core', refs: [{ kind: 'session', id: 'a' }] }, + { id: 'g2', name: 'Ops', refs: [] }, + ], + ungrouped: [{ kind: 'session', id: 'b' }], + updatedAt: '2026-10-21T00:00:00.000Z', +}); +const facts = (owner = 'alice'): SessionFact[] => [ + { id: 'a', owner, createdAt: 1 }, + { id: 'b', owner, createdAt: 2 }, +]; +const idsIn = (layout: TabLayout, groupId: string | null): string[] => + (groupId === null ? layout.ungrouped : (layout.groups.find((g) => g.id === groupId)?.refs ?? [])).map((r) => r.id); + +describe('TabLayoutService case default placement', () => { + it('places a session for the case into its default group in the creating write', async () => { + const h = createHarness({ layouts: { alice: grouped() }, live: facts() }); + h.defaults.alice = { app: 'g2' }; + const layout = await h.service.sessionCreated( + 'alice', + 'new', + { caseName: 'app' }, + h.register({ id: 'new', owner: 'alice', createdAt: 3 }) + ); + expect(idsIn(layout, 'g2')).toEqual(['new']); + expect(idsIn(layout, null)).toEqual(['b']); + expect(layout.version).toBe(8); + // One versioned write: no transient Ungrouped layout was ever committed. + expect(h.store.commitTabLayoutProjection).toHaveBeenCalledTimes(1); + }); + + it('an explicit tabGroupId wins over the case default', async () => { + const h = createHarness({ layouts: { alice: grouped() }, live: facts() }); + h.defaults.alice = { app: 'g2' }; + const layout = await h.service.sessionCreated( + 'alice', + 'new', + { caseName: 'app', tabGroupId: 'g1' }, + h.register({ id: 'new', owner: 'alice', createdAt: 3 }) + ); + expect(idsIn(layout, 'g1')).toEqual(['a', 'new']); + expect(idsIn(layout, 'g2')).toEqual([]); + }); + + it('an unknown explicit group falls through to the case default', async () => { + const h = createHarness({ layouts: { alice: grouped() }, live: facts() }); + h.defaults.alice = { app: 'g2' }; + const layout = await h.service.sessionCreated( + 'alice', + 'new', + { caseName: 'app', tabGroupId: 'gone' }, + h.register({ id: 'new', owner: 'alice', createdAt: 3 }) + ); + expect(idsIn(layout, 'g2')).toEqual(['new']); + }); + + it.each([true, false])('the parent placement wins over the case default (follow=%s)', async (follow) => { + const h = createHarness({ layouts: { alice: grouped() }, live: facts(), follow }); + h.defaults.alice = { app: 'g2' }; + const layout = await h.service.sessionCreated( + 'alice', + 'child', + { caseName: 'app' }, + h.register({ id: 'child', owner: 'alice', createdAt: 3, parentSessionId: 'a' }) + ); + expect(idsIn(layout, 'g1')).toEqual(['a', 'child']); + expect(idsIn(layout, 'g2')).toEqual([]); + }); + + it('a parent that is not in the layout does not block the case default', async () => { + const h = createHarness({ layouts: { alice: grouped() }, live: facts() }); + h.defaults.alice = { app: 'g2' }; + const layout = await h.service.sessionCreated( + 'alice', + 'orphan', + { caseName: 'app' }, + h.register({ id: 'orphan', owner: 'alice', createdAt: 3, parentSessionId: 'nobody' }) + ); + expect(idsIn(layout, 'g2')).toEqual(['orphan']); + }); + + it('an intentional Ungrouped default (null) or no default leaves the session ungrouped', async () => { + const h = createHarness({ layouts: { alice: grouped() }, live: facts() }); + h.defaults.alice = { app: null }; + const first = await h.service.sessionCreated( + 'alice', + 'n1', + { caseName: 'app' }, + h.register({ id: 'n1', owner: 'alice', createdAt: 3 }) + ); + expect(idsIn(first, null)).toEqual(['b', 'n1']); + const second = await h.service.sessionCreated( + 'alice', + 'n2', + { caseName: 'unset' }, + h.register({ id: 'n2', owner: 'alice', createdAt: 4 }) + ); + expect(idsIn(second, null)).toEqual(['b', 'n1', 'n2']); + }); + + it('a deleted default group never fails creation: Ungrouped, and the stale value is cleared', async () => { + const h = createHarness({ layouts: { alice: grouped() }, live: facts() }); + h.defaults.alice = { app: 'deleted-group' }; + const layout = await h.service.sessionCreated( + 'alice', + 'new', + { caseName: 'app' }, + h.register({ id: 'new', owner: 'alice', createdAt: 3 }) + ); + expect(idsIn(layout, null)).toEqual(['b', 'new']); + expect(h.caseDefaults.getCaseDefaultGroup('alice', 'app')).toBeUndefined(); + }); + + it("never applies another owner's group id", async () => { + const h = createHarness({ + layouts: { alice: grouped(), bob: { ...grouped(3), groups: [], ungrouped: [] } }, + live: [...facts()], + }); + // A value naming alice's group, stored under bob, is not found in bob's layout. + h.defaults.bob = { app: 'g1' }; + const layout = await h.service.sessionCreated( + 'bob', + 'bob-new', + { caseName: 'app' }, + h.register({ id: 'bob-new', owner: 'bob', createdAt: 5 }) + ); + expect(layout.groups).toEqual([]); + expect(idsIn(layout, null)).toEqual(['bob-new']); + expect(idsIn(h.layouts.alice, 'g1')).toEqual(['a']); + }); + + it('a renamed group keeps receiving sessions (the default is the stable id)', async () => { + const renamed = grouped(); + renamed.groups[1] = { ...renamed.groups[1], name: 'Operations' }; + const h = createHarness({ layouts: { alice: renamed }, live: facts() }); + h.defaults.alice = { app: 'g2' }; + const layout = await h.service.sessionCreated( + 'alice', + 'new', + { caseName: 'app' }, + h.register({ id: 'new', owner: 'alice', createdAt: 3 }) + ); + expect(layout.groups[1]).toMatchObject({ id: 'g2', name: 'Operations' }); + expect(idsIn(layout, 'g2')).toEqual(['new']); + }); + + it('changing the default never moves an existing session', async () => { + const h = createHarness({ layouts: { alice: grouped() }, live: facts() }); + h.defaults.alice = { app: 'g2' }; + await h.service.sessionCreated( + 'alice', + 's1', + { caseName: 'app' }, + h.register({ id: 's1', owner: 'alice', createdAt: 3 }) + ); + await h.service.setCaseDefaultGroup('alice', 'app', 'g1'); + const layout = await h.service.sessionCreated( + 'alice', + 's2', + { caseName: 'app' }, + h.register({ id: 's2', owner: 'alice', createdAt: 4 }) + ); + expect(idsIn(layout, 'g2')).toEqual(['s1']); + expect(idsIn(layout, 'g1')).toEqual(['a', 's2']); + }); + + it('a failed layout write rolls the registration back and writes no case default change', async () => { + const h = createHarness({ layouts: { alice: grouped() }, live: facts() }); + h.defaults.alice = { app: 'deleted-group' }; + h.store.commitTabLayoutProjection.mockImplementationOnce(() => { + throw new Error('disk full'); + }); + const added = h.register({ id: 'new', owner: 'alice', createdAt: 3 }); + await expect(h.service.sessionCreated('alice', 'new', { caseName: 'app' }, added)).rejects.toThrow('disk full'); + expect(h.live.has('new')).toBe(false); + expect(h.layouts.alice).toEqual(grouped()); + expect(h.caseDefaults.setCaseDefaultGroup).not.toHaveBeenCalled(); + expect(h.caseDefaults.deleteCaseDefaultGroup).not.toHaveBeenCalled(); + }); +}); + +describe('TabLayoutService case default reads and writes', () => { + it('reports the selection with the owner group options, clearing a stale id', async () => { + const h = createHarness({ layouts: { alice: grouped() }, live: facts() }); + expect(await h.service.caseDefaultGroup('alice', 'app')).toEqual({ + groupId: null, + groups: [ + { id: 'g1', name: 'Core' }, + { id: 'g2', name: 'Ops' }, + ], + }); + h.defaults.alice = { app: 'gone' }; + expect((await h.service.caseDefaultGroup('alice', 'app')).groupId).toBeNull(); + expect(h.caseDefaults.getCaseDefaultGroup('alice', 'app')).toBeUndefined(); + }); + + it('saves a known group or null, and refuses an id that is not in the owner layout', async () => { + const h = createHarness({ layouts: { alice: grouped(), bob: { ...grouped(1), groups: [] } }, live: facts() }); + await h.service.setCaseDefaultGroup('alice', 'app', 'g2'); + expect(h.defaults.alice.app).toBe('g2'); + await h.service.setCaseDefaultGroup('alice', 'app', null); + expect(h.defaults.alice.app).toBeNull(); + await expect(h.service.setCaseDefaultGroup('bob', 'app', 'g1')).rejects.toBeInstanceOf(TabLayoutValidationError); + expect(h.defaults.bob).toBeUndefined(); + }); + + it('forgets a mapping when its case goes away', async () => { + const h = createHarness({ layouts: { alice: grouped() }, live: facts() }); + h.defaults.alice = { app: 'g1' }; + await h.service.forgetCaseDefaultGroup('alice', 'app'); + expect(h.caseDefaults.getCaseDefaultGroup('alice', 'app')).toBeUndefined(); + }); +}); diff --git a/test/mocks/mock-route-context.ts b/test/mocks/mock-route-context.ts index 9cb26d6a..b7b2e222 100644 --- a/test/mocks/mock-route-context.ts +++ b/test/mocks/mock-route-context.ts @@ -56,6 +56,12 @@ export function createMockRouteContext(options?: { runSessionDeletion: vi.fn(async (_removed, action) => action()), runStaleSessionCleanup: vi.fn(async (_activeIds, action) => action(new Set())), reconcileAfterRestoration: vi.fn(async () => {}), + caseDefaultGroup: vi.fn(async () => ({ groupId: null, groups: [] })), + setCaseDefaultGroup: vi.fn(async (_owner: string, _caseName: string, groupId: string | null) => ({ + groupId, + groups: [], + })), + forgetCaseDefaultGroup: vi.fn(async () => {}), }, cleanupSession: vi.fn(async () => {}), setupSessionListeners: vi.fn(async () => {}), diff --git a/test/routes/case-default-group-routes.test.ts b/test/routes/case-default-group-routes.test.ts new file mode 100644 index 00000000..43041f19 --- /dev/null +++ b/test/routes/case-default-group-routes.test.ts @@ -0,0 +1,411 @@ +/** + * @fileoverview Per-case default session group over HTTP: the case routes that + * read and write it, and the create routes that carry the case into the real + * TabLayoutService so a new session lands in its case's group in the creating + * write (POST /api/sessions, and POST /api/quick-start for local, remote and + * Docker cases). + * + * Uses app.inject(), so no real HTTP port is needed. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdir, rm, writeFile } from 'node:fs/promises'; +import { join } 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'; +import { registerCaseRoutes } from '../../src/web/routes/case-routes.js'; +import { Session } from '../../src/session.js'; +import { TabLayoutService } from '../../src/tab-layout-service.js'; +import { ownerLayoutKey } from '../../src/tab-layout-persistence.js'; +import type { TabLayout } from '../../src/tab-layout.js'; +import { CASES_DIR } from '../../src/web/route-helpers.js'; +import { getDataDir } from '../../src/config/instance.js'; +import { safeRmHomeTree } from '../mocks/index.js'; + +vi.mock('../../src/utils/cli-launcher.js', async (importOriginal) => { + const actual = await importOriginal<typeof import('../../src/utils/cli-launcher.js')>(); + return { ...actual, resolveCliLaunchError: vi.fn().mockResolvedValue(null) }; +}); + +const CASE = 'cdgcase'; +const DATA_FILES = ['docker-hosts.json', 'docker-cases.json', 'remote-hosts.json', 'remote-cases.json']; + +interface World { + layouts: Record<string, TabLayout>; + order: string[]; + defaults: Record<string, Record<string, string | null>>; + service: TabLayoutService; +} + +/** The real layout service over an in-memory store, wired the way server.ts wires it. */ +function attachRealLayout(harness: RouteTestHarness): World { + const world = { layouts: {}, order: [], defaults: {} } as unknown as World; + const caseDefaults = { + getCaseDefaultGroup: (owner: string, name: string) => + world.defaults[owner] && name in world.defaults[owner] ? world.defaults[owner][name] : undefined, + setCaseDefaultGroup: (owner: string, name: string, id: string | null) => { + world.defaults[owner] = { ...(world.defaults[owner] ?? {}), [name]: id }; + }, + deleteCaseDefaultGroup: (owner: string, name: string) => { + if (world.defaults[owner]) delete world.defaults[owner][name]; + }, + }; + world.service = new TabLayoutService({ + store: { + getTabLayout: (owner: string) => world.layouts[owner] ?? null, + getTabLayouts: () => ({ ...world.layouts }), + getSessions: () => ({}), + getSessionOrder: () => [...world.order], + commitTabLayoutProjection: ( + updates: Readonly<Record<string, TabLayout>>, + project: (latest: readonly string[]) => readonly string[] + ) => { + world.order = [...project(world.order)]; + Object.assign(world.layouts, structuredClone(updates)); + return { layouts: structuredClone(updates), sessionOrder: [...world.order] }; + }, + } as never, + sessions: harness.ctx.sessions as never, + readWebviews: async () => [], + broadcast: () => {}, + broadcastSessionOrder: () => {}, + caseDefaults, + }); + world.service.markRestorationSkipped(); + (harness.ctx as unknown as { tabLayouts: TabLayoutService }).tabLayouts = world.service; + harness.ctx.addSession.mockImplementation((session: Session, placement?: object) => + world.service.sessionCreated(ownerLayoutKey(session.owner), session.id, placement, () => { + harness.ctx.sessions.set(session.id, session as never); + return () => harness.ctx.sessions.delete(session.id); + }) + ); + return world; +} + +const OWNER = ownerLayoutKey(undefined); +const withGroups = (world: World) => { + world.layouts[OWNER] = { + version: 3, + updatedAt: '2026-10-21T00:00:00.000Z', + groups: [ + { id: 'g-core', name: 'Core', refs: [] }, + { id: 'g-ops', name: 'Ops', refs: [] }, + ], + ungrouped: [], + }; +}; +const groupOf = (layout: TabLayout, sessionId: string): string | null => + layout.groups.find((g) => g.refs.some((r) => r.id === sessionId))?.id ?? null; +const unwrap = (raw: string) => { + const parsed = JSON.parse(raw); + return parsed.data ?? parsed; +}; + +describe('case default group: create routes', () => { + let harness: RouteTestHarness; + let world: World; + const caseDir = join(CASES_DIR, CASE); + const outside = join(homedir(), 'cdg-not-a-case'); + + beforeEach(async () => { + for (const name of DATA_FILES) await rm(join(getDataDir(), name), { force: true }); + await mkdir(caseDir, { recursive: true }); + await mkdir(outside, { recursive: true }); + vi.spyOn(Session.prototype, 'startInteractive').mockResolvedValue(undefined); + vi.spyOn(Session.prototype, 'startShell').mockResolvedValue(undefined); + harness = await createRouteTestHarness(registerSessionRoutes); + harness.ctx.sessions.clear(); + world = attachRealLayout(harness); + withGroups(world); + world.defaults[OWNER] = { [CASE]: 'g-ops' }; + }); + + afterEach(async () => { + await harness.app.close(); + for (const name of DATA_FILES) await rm(join(getDataDir(), name), { force: true }); + safeRmHomeTree(CASES_DIR); + safeRmHomeTree(outside); + vi.restoreAllMocks(); + }); + + it('POST /api/sessions in a case folder lands in the case default group and returns that layout', async () => { + const res = await harness.app.inject({ + method: 'POST', + url: '/api/sessions', + payload: { name: 'w1-cdg', mode: 'shell', workingDir: caseDir }, + }); + expect(res.statusCode).toBe(200); + const data = unwrap(res.body); + expect(groupOf(world.layouts[OWNER], data.session.id)).toBe('g-ops'); + // The response carries the committed layout so the browser draws the tab in place. + expect(data.tabLayout).toEqual(world.layouts[OWNER]); + }); + + it('POST /api/sessions outside any case gets no case default and keeps its response shape', async () => { + const res = await harness.app.inject({ + method: 'POST', + url: '/api/sessions', + payload: { name: 'w1-out', mode: 'shell', workingDir: outside }, + }); + const data = unwrap(res.body); + expect(Object.keys(data)).toEqual(['session']); + expect(groupOf(world.layouts[OWNER], data.session.id)).toBeNull(); + }); + + it('POST /api/sessions: an explicit tabGroupId still wins', async () => { + const res = await harness.app.inject({ + method: 'POST', + url: '/api/sessions', + payload: { name: 'w1-cdg', mode: 'shell', workingDir: caseDir, tabGroupId: 'g-core' }, + }); + expect(groupOf(world.layouts[OWNER], unwrap(res.body).session.id)).toBe('g-core'); + }); + + it('POST /api/quick-start for a local case lands in the case default group', async () => { + const res = await harness.app.inject({ + method: 'POST', + url: '/api/quick-start', + payload: { caseName: CASE, mode: 'shell' }, + }); + expect(res.statusCode).toBe(200); + const data = unwrap(res.body); + expect(groupOf(world.layouts[OWNER], data.sessionId)).toBe('g-ops'); + expect(data.tabLayout).toEqual(world.layouts[OWNER]); + }); + + it('POST /api/quick-start: explicit tabGroupId wins over the case default', async () => { + const res = await harness.app.inject({ + method: 'POST', + url: '/api/quick-start', + payload: { caseName: CASE, mode: 'shell', tabGroupId: 'g-core' }, + }); + expect(groupOf(world.layouts[OWNER], unwrap(res.body).sessionId)).toBe('g-core'); + }); + + it('POST /api/quick-start for a remote case lands in the case default group', async () => { + await writeFile( + join(getDataDir(), 'remote-hosts.json'), + JSON.stringify([{ id: 'remote1', label: 'Remote', host: '10.0.0.5', username: 'dev' }]) + ); + await writeFile( + join(getDataDir(), 'remote-cases.json'), + JSON.stringify([{ name: 'cdgremote', type: 'remote', hostId: 'remote1', remotePath: '/home/dev/work' }]) + ); + world.defaults[OWNER].cdgremote = 'g-core'; + const res = await harness.app.inject({ + method: 'POST', + url: '/api/quick-start', + payload: { caseName: 'cdgremote', mode: 'claude' }, + }); + expect(res.statusCode).toBe(200); + const data = unwrap(res.body); + expect(data.sessionId, res.body).toBeTruthy(); + expect(groupOf(world.layouts[OWNER], data.sessionId)).toBe('g-core'); + }); + + it('POST /api/quick-start for a Docker case lands in the case default group', async () => { + await writeFile( + join(getDataDir(), 'docker-hosts.json'), + JSON.stringify([{ id: 'docker1', label: 'Docker', image: 'codeman/agent:base' }]) + ); + await writeFile( + join(getDataDir(), 'docker-cases.json'), + JSON.stringify([ + { name: 'cdgdocker', type: 'docker', hostId: 'docker1', hostWorkspacePath: caseDir, container: 'codeman-cdg' }, + ]) + ); + world.defaults[OWNER].cdgdocker = 'g-core'; + const res = await harness.app.inject({ + method: 'POST', + url: '/api/quick-start', + payload: { caseName: 'cdgdocker', mode: 'claude' }, + }); + expect(res.statusCode).toBe(200); + const data = unwrap(res.body); + expect(data.sessionId, res.body).toBeTruthy(); + expect(groupOf(world.layouts[OWNER], data.sessionId)).toBe('g-core'); + }); + + it('a deleted default group never fails creation: the session lands ungrouped and the value is cleared', async () => { + world.defaults[OWNER][CASE] = 'g-deleted'; + const res = await harness.app.inject({ + method: 'POST', + url: '/api/quick-start', + payload: { caseName: CASE, mode: 'shell' }, + }); + expect(res.statusCode).toBe(200); + const data = unwrap(res.body); + expect(groupOf(world.layouts[OWNER], data.sessionId)).toBeNull(); + expect(data.tabLayout).toBeUndefined(); + expect(CASE in world.defaults[OWNER]).toBe(false); + }); + + it('a creation that fails after registration leaves no layout reference', async () => { + vi.mocked(Session.prototype.startShell).mockRejectedValueOnce(new Error('pane failed')); + const res = await harness.app.inject({ + method: 'POST', + url: '/api/quick-start', + payload: { caseName: CASE, mode: 'shell' }, + }); + expect(unwrap(res.body).sessionId).toBeUndefined(); + // The route's own error path ran cleanupSession for the registered id. + expect(harness.ctx.cleanupSession).toHaveBeenCalledTimes(1); + }); +}); + +describe('case default group: GET/PUT /api/cases/:name/default-group', () => { + let harness: RouteTestHarness; + let world: World; + const caseDir = join(CASES_DIR, CASE); + + beforeEach(async () => { + for (const name of DATA_FILES) await rm(join(getDataDir(), name), { force: true }); + await mkdir(caseDir, { recursive: true }); + harness = await createRouteTestHarness(registerCaseRoutes); + world = attachRealLayout(harness); + withGroups(world); + }); + + afterEach(async () => { + await harness.app.close(); + delete process.env.CODEMAN_MULTIUSER; + for (const name of DATA_FILES) await rm(join(getDataDir(), name), { force: true }); + safeRmHomeTree(CASES_DIR); + vi.restoreAllMocks(); + }); + + const get = (name = CASE) => harness.app.inject({ method: 'GET', url: `/api/cases/${name}/default-group` }); + const put = (groupId: unknown, name = CASE) => + harness.app.inject({ method: 'PUT', url: `/api/cases/${name}/default-group`, payload: { groupId } }); + + it('lists Ungrouped (null) and every named group of the owner', async () => { + const res = await get(); + expect(res.statusCode).toBe(200); + expect(res.json()).toEqual({ + success: true, + data: { + caseName: CASE, + groupId: null, + groups: [ + { id: 'g-core', name: 'Core' }, + { id: 'g-ops', name: 'Ops' }, + ], + }, + }); + }); + + it('saves a group and reads it back; null is an intentional Ungrouped', async () => { + expect((await put('g-ops')).json().data.groupId).toBe('g-ops'); + expect(world.defaults[OWNER][CASE]).toBe('g-ops'); + expect((await get()).json().data.groupId).toBe('g-ops'); + expect((await put(null)).json().data.groupId).toBeNull(); + expect(world.defaults[OWNER][CASE]).toBeNull(); + }); + + it('reads the current group names, so a rename shows through (the id is stored)', async () => { + await put('g-ops'); + world.layouts[OWNER].groups[1].name = 'Operations'; + const data = (await get()).json().data; + expect(data.groupId).toBe('g-ops'); + expect(data.groups[1]).toEqual({ id: 'g-ops', name: 'Operations' }); + }); + + it('a deleted group reads back as Ungrouped and is cleared', async () => { + world.defaults[OWNER] = { [CASE]: 'g-gone' }; + expect((await get()).json().data.groupId).toBeNull(); + expect(CASE in world.defaults[OWNER]).toBe(false); + }); + + it.each([ + ['an unknown group', 'g-nope', 400], + ['an empty string', '', 400], + ['a number', 7, 400], + ])('refuses %s without changing the stored value', async (_label, groupId, status) => { + await put('g-core'); + const res = await put(groupId); + expect(res.statusCode).toBe(status); + expect(res.json().errorCode).toBe('INVALID_INPUT'); + expect(world.defaults[OWNER][CASE]).toBe('g-core'); + }); + + it('404s a case that does not exist (or is not the caller’s), for both verbs', async () => { + expect((await get('nosuchcase')).statusCode).toBe(404); + expect((await put('g-core', 'nosuchcase')).statusCode).toBe(404); + expect(world.defaults[OWNER]).toBeUndefined(); + }); + + it('accepts a remote case and a Docker case by name', async () => { + await writeFile( + join(getDataDir(), 'remote-hosts.json'), + JSON.stringify([{ id: 'remote1', label: 'Remote', host: '10.0.0.5', username: 'dev' }]) + ); + await writeFile( + join(getDataDir(), 'remote-cases.json'), + JSON.stringify([{ name: 'cdgremote', type: 'remote', hostId: 'remote1', remotePath: '/home/dev/work' }]) + ); + await writeFile( + join(getDataDir(), 'docker-cases.json'), + JSON.stringify([{ name: 'cdgdocker', type: 'docker', hostId: 'docker1', hostWorkspacePath: caseDir }]) + ); + expect((await put('g-core', 'cdgremote')).statusCode).toBe(200); + expect((await put('g-ops', 'cdgdocker')).statusCode).toBe(200); + expect(world.defaults[OWNER]).toMatchObject({ cdgremote: 'g-core', cdgdocker: 'g-ops' }); + }); + + it('DELETE /api/cases/:name forgets the mapping', async () => { + await put('g-ops'); + const res = await harness.app.inject({ method: 'DELETE', url: `/api/cases/${CASE}` }); + expect(res.statusCode).toBe(200); + expect(CASE in world.defaults[OWNER]).toBe(false); + }); +}); + +describe('case default group: multi-user scoping', () => { + afterEach(() => { + delete process.env.CODEMAN_MULTIUSER; + vi.restoreAllMocks(); + }); + + it("stores a non-admin's choice under their own owner key and validates against their own groups", async () => { + process.env.CODEMAN_MULTIUSER = '1'; + const harness = await createRouteTestHarness(registerCaseRoutes, { authUser: { username: 'bob', role: 'user' } }); + try { + harness.ctx.sessions.clear(); + const world = attachRealLayout(harness); + world.layouts.alice = { + version: 1, + updatedAt: '2026-10-21T00:00:00.000Z', + groups: [{ id: 'g-alice', name: 'Alice', refs: [] }], + ungrouped: [], + }; + world.layouts.bob = { + version: 1, + updatedAt: '2026-10-21T00:00:00.000Z', + groups: [{ id: 'g-bob', name: 'Bob', refs: [] }], + ungrouped: [], + }; + const { resolveCasesDir } = await import('../../src/web/route-helpers.js'); + const bobCases = resolveCasesDir({ username: 'bob', role: 'user' } as never); + await mkdir(join(bobCases, 'bobcase'), { recursive: true }); + + const read = await harness.app.inject({ method: 'GET', url: '/api/cases/bobcase/default-group' }); + expect(read.json().data.groups).toEqual([{ id: 'g-bob', name: 'Bob' }]); + const foreign = await harness.app.inject({ + method: 'PUT', + url: '/api/cases/bobcase/default-group', + payload: { groupId: 'g-alice' }, + }); + expect(foreign.statusCode).toBe(400); + const own = await harness.app.inject({ + method: 'PUT', + url: '/api/cases/bobcase/default-group', + payload: { groupId: 'g-bob' }, + }); + expect(own.statusCode).toBe(200); + expect(world.defaults).toEqual({ bob: { bobcase: 'g-bob' } }); + safeRmHomeTree(join(bobCases, '..')); + } finally { + await harness.app.close(); + } + }); +}); diff --git a/test/routes/session-routes-tab-group.test.ts b/test/routes/session-routes-tab-group.test.ts index b550151c..77b7a745 100644 --- a/test/routes/session-routes-tab-group.test.ts +++ b/test/routes/session-routes-tab-group.test.ts @@ -104,7 +104,9 @@ describe('create routes: tabGroupId', () => { }); expect(res.statusCode).toBe(200); const data = body(res.body); - expect(ctx.addSession.mock.calls[0][1]).toEqual({ tabGroupId: 'g1' }); + // quick-start always names its case too, for the case's default group (the + // explicit group still wins inside TabLayoutService). + expect(ctx.addSession.mock.calls[0][1]).toEqual({ caseName: 'tabgroupcase', tabGroupId: 'g1' }); expect(data.tabLayout.groups[0].refs).toEqual([{ kind: 'session', id: data.sessionId }]); const plain = await app.inject({ From 71bf7288e0d32c7f15f41bb9d5ba103f00747a8d Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Thu, 22 Oct 2026 19:03:04 +0200 Subject: [PATCH 58/96] fix(remote): leave the remote-history PATH bootstrap out of this PR Merge-time fix for #645: the PATH bootstrap for remote history reads (src/remote-pane-history.ts, src/remote-hosts.ts and its test) is a separate fix from the scroll work. On its own it does not reach a host whose tmux lives only under /opt/homebrew/bin, because the link precheck, the launch, the alive probe and discovery still run a bare tmux through the same non-interactive ssh shell. It belongs in a follow-up PR that routes every remote tmux call through one wrapper. These files are restored to the PR's merge base; every scroll change stays. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- src/remote-hosts.ts | 8 ------- src/remote-pane-history.ts | 13 ++---------- test/remote-pane-history.test.ts | 36 +------------------------------- 3 files changed, 3 insertions(+), 54 deletions(-) diff --git a/src/remote-hosts.ts b/src/remote-hosts.ts index 3a68aa29..3dea3bde 100644 --- a/src/remote-hosts.ts +++ b/src/remote-hosts.ts @@ -89,14 +89,6 @@ export function remoteLoginShellCommand(command: string): string { return `exec ${REMOTE_LOGIN_SHELL} -i -l -c ${shellescape(command)}`; } -/** Standard CLI locations missing from non-interactive SSH shells (notably Homebrew). */ -export const REMOTE_PATH_BOOTSTRAP = - 'export PATH=$HOME/.opencode/bin:$HOME/.local/bin:$HOME/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin:$PATH'; - -export function withRemotePathBootstrap(command: string): string { - return `${REMOTE_PATH_BOOTSTRAP}; ${command}`; -} - /** * The CLI text a location overlay should launch for `mode`, or null when this build has no * entry for it. `overlays.<location>.command` when the entry names one, otherwise the bare diff --git a/src/remote-pane-history.ts b/src/remote-pane-history.ts index b3eab6b0..f4a12e48 100644 --- a/src/remote-pane-history.ts +++ b/src/remote-pane-history.ts @@ -48,13 +48,7 @@ import { exec } from 'node:child_process'; import { randomBytes } from 'node:crypto'; import { promisify } from 'node:util'; import type { SessionRemote } from './types/session.js'; -import { - buildSshConnectionArgs, - classifyRemoteAliveExit, - remoteSshTarget, - shellescape, - withRemotePathBootstrap, -} from './remote-hosts.js'; +import { buildSshConnectionArgs, classifyRemoteAliveExit, remoteSshTarget, shellescape } from './remote-hosts.js'; import { RemoteSshSlotTimeoutError, runWithRemoteSshLimit } from './remote-ssh-limiter.js'; import { remoteTmuxLocation } from './tmux-manager.js'; @@ -159,10 +153,7 @@ export function buildRemoteScrollbackCommand( marker: string ): string { const { socket, sessionName } = remoteTmuxLocation(remote, sessionId); - // Non-interactive macOS shells omit Homebrew and other per-user CLI paths. - const script = withRemotePathBootstrap( - buildRemoteScrollbackScript(socket, sessionName, historyLines, tailBytes, marker) - ); + const script = buildRemoteScrollbackScript(socket, sessionName, historyLines, tailBytes, marker); // `exec` so a timeout kill reaches ssh itself, not only the local `sh -c`. return ['exec', ...buildSshConnectionArgs(remote), remoteSshTarget(remote), shellescape(script)].join(' '); } diff --git a/test/remote-pane-history.test.ts b/test/remote-pane-history.test.ts index 03f9aa77..014e7f48 100644 --- a/test/remote-pane-history.test.ts +++ b/test/remote-pane-history.test.ts @@ -11,7 +11,7 @@ import { describe, it, expect, beforeAll, afterAll, beforeEach, afterEach, vi } from 'vitest'; import { execFileSync } from 'node:child_process'; -import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { @@ -79,40 +79,6 @@ describe('buildRemoteScrollbackCommand', () => { }); }); -describe('remote history PATH bootstrap', () => { - it('finds tmux installed under the remote home when the login shell omits it', () => { - const root = mkdtempSync(join(tmpdir(), 'remote-history-path-')); - const home = join(root, 'remote home'); - const sshBin = join(root, 'local-bin'); - const remoteBin = join(home, '.local', 'bin'); - mkdirSync(sshBin, { recursive: true }); - mkdirSync(remoteBin, { recursive: true }); - writeFileSync(join(sshBin, 'ssh'), '#!/bin/sh\nfor arg do command="$arg"; done\nexec /bin/sh -c "$command"\n', { - mode: 0o755, - }); - writeFileSync( - join(remoteBin, 'tmux'), - `#!/bin/sh -printf 'older row\\n${MARKER}\\n1\\ncurrent screen\\n' -`, - { mode: 0o755 } - ); - try { - const command = buildRemoteScrollbackCommand(remote, SESSION_ID, 100, 0, MARKER); - const stdout = execFileSync('/bin/sh', ['-c', command], { - encoding: 'utf-8', - env: { ...process.env, HOME: home, PATH: `${sshBin}:/usr/bin:/bin` }, - }); - expect(parseRemoteScrollbackOutput(stdout, MARKER, 0)).toEqual({ - scrollback: 'older row\n', - screen: 'current screen\n', - }); - } finally { - rmSync(root, { recursive: true, force: true }); - } - }); -}); - describe('remoteTailBytes', () => { it('adds room for the marker, size and screen to the cap, so a cut lands in the history', () => { expect(remoteTailBytes(2048)).toBe(2048 + 1024 * 1024); From ac8409920b74d30ca525efbee19500c90eb45a6d Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Thu, 22 Oct 2026 19:04:32 +0200 Subject: [PATCH 59/96] docs: say Codex approval prompts do raise the action alert Merge-time fix for #645: architecture-invariants said Codex has no hook alert, so tabAlerts === 'action' could not see its approval prompt. It can: src/web/screen-blocked.ts reads Codex dialogs off the pane (capabilities.workDetect.blockedLine) and broadcasts hook:permission_prompt, which becomes the 'action' tab alert, and both page-key fallbacks check it. The sentence now says the alert exists and that the SGR wheel path deliberately does not consult it, because the 0.163.0 measurement showed wheel reports leave the approval selection unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- docs/architecture-invariants.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index debd7fba..2addf9c6 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -229,7 +229,7 @@ Further detail: the `<prefix>: <title>` form (`w3-myapp: fix the login redirect` **Copilot also receives forwarded reports** (1.0.95 measured in tmux: alt screen, `mouse_any_flag=1`): its mouse DECSETs are stripped so a drag selects, which also stops xterm encoding the wheel for it, so `_shouldForwardWheelToApp` sends the same SGR reports by hand while `cliMouseTracking` is true; six `\x1b[<64;…M` reports moved its transcript 18 lines, a phone swipe takes the same gate, and there is NO version gate. -Codex 0.163.0 approval check in that isolated read-only/on-request session: the "Would you like to run the following command?" prompt remained on `1. Yes, proceed` after SGR wheel-up and wheel-down over both the transcript and approval option rows 36/37; the requested marker file was not created. The background transcript can scroll while the approval prompt remains fixed. Codex has no Claude hook alert, so a `tabAlerts === 'action'` test cannot detect this prompt and is not a native-wheel safety gate. Native Codex owns its approval selection; do not mistake a hook-only unit fixture for approval-dialog evidence. +Codex 0.163.0 approval check in that isolated read-only/on-request session: the "Would you like to run the following command?" prompt remained on `1. Yes, proceed` after SGR wheel-up and wheel-down over both the transcript and approval option rows 36/37; the requested marker file was not created. The background transcript can scroll while the approval prompt remains fixed. Codeman does alert on this prompt: `src/web/screen-blocked.ts` reads Codex dialogs off the pane (`capabilities.workDetect.blockedLine`) and broadcasts `hook:permission_prompt`, which `_onHookPermissionPrompt` (settings-ui.js) records and `updateTabAlertFromHooks` (app.js) turns into the `'action'` tab alert, and both page-key fallbacks check it. The SGR wheel path deliberately does not consult that alert, because this measurement showed wheel reports leave the approval selection unchanged. Native Codex owns its approval selection; do not mistake a hook-only unit fixture for approval-dialog evidence. Native wheel travel retains upstream pixel/line/page units (100 px = 4 lines; Firefox 3-line events = 3 lines); no one-tick-per-event policy is introduced here. A fresh native gesture at or above 0.1 row (2.5px) rounds its opening travel to at least one tick (subject to the per-event cap), so an isolated 4px mouse click responds, 40px sends two ticks and 120px sends five. The signed remainder is carried forward; travel discarded by the per-event cap does not become a backlog. The signed remainder is debt repaid by subsequent same-direction events: ten 2.5px events still total one tick. Debt/fractions expire after a 150ms gap; a reversal inside that gap discards the old debt without another pre-payment. Sub-threshold jitter does not pre-pay. The first native report of each tab visit bypasses the 40 ms batching timer; later reports coalesce and only unsent bytes can be cancelled on reversal. Every queued batch belongs to its originating session, so switching tabs cannot send its bytes into the newly selected pane. Zero-delta events leave pending movement untouched. Tiles use the same helpers and their own queue/session. From 0fa79b37ed7b7253e111d41c21f3cc481037bfea Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Thu, 22 Oct 2026 19:04:32 +0200 Subject: [PATCH 60/96] fix(terminal): name any tracked CLI in the tile's wheel-forwarding comments Merge-time fix for #645: the tile's paging gate comment (and the two other tile comments describing the same route, the @fileoverview bullet and the wheel listener's) still said the wheel is forwarded to Claude's fullscreen renderer. Forwarding now covers every tracked CLI the shared gate accepts, tracked Codex included. Comments only. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- src/web/public/terminal-tile.js | 23 ++++++++++++----------- 1 file changed, 12 insertions(+), 11 deletions(-) diff --git a/src/web/public/terminal-tile.js b/src/web/public/terminal-tile.js index 0c188d36..1a0c7058 100644 --- a/src/web/public/terminal-tile.js +++ b/src/web/public/terminal-tile.js @@ -18,13 +18,14 @@ * What it does carry over from the primary pane, through the primary pane's * own code aimed at THIS pane (its terminal, its session, never the active * one): - * - SGR wheel forwarding (_maybeForwardWheelToCli): Claude's fullscreen - * renderer scrolls its own transcript on SGR wheel reports, while this - * xterm holds only replayed repaint frames, so the wheel goes to the CLI - * as reports at the pointer's cell in this pane, through the primary - * pane's forwarding gate and its encoding (sgrWheelReports). Shift+wheel - * scrolls the local scrollback itself (_maybeScrollLocalOnShift), as the - * primary pane does, since xterm turns it into a horizontal no-op. + * - SGR wheel forwarding (_maybeForwardWheelToCli): a tracked CLI + * (fullscreen Claude, tracked Codex) scrolls its own transcript on SGR + * wheel reports, while this xterm holds only replayed repaint frames, so + * the wheel goes to the CLI as reports at the pointer's cell in this pane, + * through the primary pane's forwarding gate and its encoding + * (sgrWheelReports). Shift+wheel scrolls the local scrollback itself + * (_maybeScrollLocalOnShift), as the primary pane does, since xterm turns + * it into a horizontal no-op. * - Hollow-buffer paging (#555): a CLI that draws in place (opencode and codex * on the alternate screen, Claude's repaint mode) leaves the xterm no * scrollback, so the wheel pages the CLI's own transcript with @@ -1052,7 +1053,7 @@ // Capture phase, because xterm's own wheel handler stopPropagation()s every // event it consumes, so a bubbling listener here would never see the wheel // while the pane still has scrollback to scroll. Not passive: the three - // routes this pane takes over, forwarding the wheel to Claude's fullscreen + // routes this pane takes over, forwarding the wheel to a tracked CLI's // renderer (_maybeForwardWheelToCli), paging a hollow buffer's CLI // transcript (_maybePageCliTranscript) and Shift+wheel's local scrollback // (_maybeScrollLocalOnShift), are consumed right here (preventDefault plus @@ -1186,9 +1187,9 @@ const tracking = this.terminal.modes?.mouseTrackingMode; if (tracking && tracking !== 'none') return false; const target = { terminal: this.terminal, sessionId: this.sessionId }; - // A wheel for Claude's fullscreen renderer was forwarded as SGR reports - // before this ran (_maybeForwardWheelToCli); the gate is repeated so a - // forwarding session is never paged. + // A wheel for any tracked CLI (a session the forwarding gate accepts) was + // forwarded as SGR reports before this ran (_maybeForwardWheelToCli); the + // gate is repeated so a forwarding session is never paged. if (app._shouldForwardWheelToApp?.(ev, target)) return false; if (!app._localScrollbackIsHollow?.({ ...target, localRows: this._localRows() })) return false; // Only from the live screen. The one gate the primary pane never needs: a From 7db4d90bb63a1429fbc96db85474efa49669752e Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Thu, 22 Oct 2026 19:04:32 +0200 Subject: [PATCH 61/96] test(terminal): rename the Codex wheel browser test after what it drives Merge-time fix for #645: the describe title mentioned "remote" and the fork's #13, but the test drives the local primary pane. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- test/codex-wheel-routing.browser.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/codex-wheel-routing.browser.test.ts b/test/codex-wheel-routing.browser.test.ts index 0cf76b8c..cf573fcc 100644 --- a/test/codex-wheel-routing.browser.test.ts +++ b/test/codex-wheel-routing.browser.test.ts @@ -4,7 +4,7 @@ import { WebServer } from '../src/web/server.js'; // Adversarial browser layout fixture, not evidence of native Codex history: // authentic CLI behavior is measured separately in an isolated live pane. -describe('tracked remote Codex wheel routing (#13)', () => { +describe('tracked Codex wheel routing in the primary pane', () => { let server: WebServer; let browser: Browser; beforeAll(async () => { From 6d1a542018c9522cd62e99289379c8f4d8fb5004 Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Thu, 22 Oct 2026 19:10:08 +0200 Subject: [PATCH 62/96] fix(test): pin that a deleted pane directory is not adopted on rediscovery Merge-time fix for #667. tmux reports the directory of a pane whose directory was deleted while it ran as `/path (deleted)` (tmux 3.4 on Linux). Today the rediscovered session falls back to the server cwd only because isValidPath() rejects the parentheses; this row in the wiring test keeps a later relaxation of the path pattern from quietly adopting a dead directory. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- test/tmux-manager.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/test/tmux-manager.test.ts b/test/tmux-manager.test.ts index 39fe2344..cf0889e1 100644 --- a/test/tmux-manager.test.ts +++ b/test/tmux-manager.test.ts @@ -931,6 +931,9 @@ describe('TmuxManager (unit)', () => { 'codeman-dddd4444|105|0|||', // The launch directory every pane starts in; a remote or docker pane never leaves it. 'codeman-eeee5555|106|0|||/tmp', + // A directory deleted while its pane ran, as tmux 3.4 on Linux reports it. isValidPath() + // rejects it for the parentheses; a relaxed path pattern must not adopt a dead directory. + 'codeman-abcd7777|108|0|||/home/user/gone (deleted)', // Last row, path ending in a space: the output must not be trimmed. 'codeman-ffff6666|107|0|||/home/user/trailing ', ].join('\n'); @@ -948,6 +951,7 @@ describe('TmuxManager (unit)', () => { 'restored-cccc3333', 'restored-dddd4444', 'restored-eeee5555', + 'restored-abcd7777', 'restored-ffff6666', ]); const dirs = Object.fromEntries(nonTestManager.getSessions().map((s) => [s.muxName, s.workingDir])); @@ -957,6 +961,7 @@ describe('TmuxManager (unit)', () => { 'codeman-cccc3333': process.cwd(), 'codeman-dddd4444': process.cwd(), 'codeman-eeee5555': process.cwd(), + 'codeman-abcd7777': process.cwd(), 'codeman-ffff6666': '/home/user/trailing ', }); From e408fa21bc962b0b9738818ed3ee332597b18dba Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Thu, 22 Oct 2026 19:10:11 +0200 Subject: [PATCH 63/96] fix(sessions): retry a failed keep-alive write before the expiry and close its window Merge-time fix for #666. A nudge only goes out inside the last five minutes before `expiresAt`, so the check armed a whole `KEEP_WARM_LEAD_MS` after a failed write landed at or past the expiry and stopped with "too close to expiry": the retry the status promised never happened. The window `beginKeepAliveTurn()` opened also stayed open for its ten-minute ceiling with no turn running. A failed write now ends the window (`endKeepAliveTurn()` joins `KeepWarmSessionView`; `Session` already has it) and re-arms at `min(now + KEEP_WARM_RETRY_MS, expiresAt - KEEP_WARM_LAST_CHANCE_MS)`. The test advances the clock and sees the second write, and a second one pins that the retry never lands past the last-chance line. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- CLAUDE.md | 2 +- docs/architecture-invariants.md | 2 +- docs/wiki/Keeping-Agents-Running.md | 2 +- src/prompt-cache-keepwarm.ts | 14 +++++++++-- test/prompt-cache-keepwarm.test.ts | 38 ++++++++++++++++++++++++++++- 5 files changed, 52 insertions(+), 6 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 9b8f64f2..fc95bc37 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -158,7 +158,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Plan-usage chip** (`showPlanUsageLimits`, per-device: desktop default **ON**, handhelds OFF): resolve DISPLAY only through `planUsageChipEnabled()` (settings-ui.js). The same setting is the server-side COLLECTION switch, read fresh by `readPlanUsageTelemetryEnabled()` (hooks-config.ts) at every claude create/respawn. ⚠️ An ABSENT key reads as ON in the reader; `GET /api/settings` must never write. ⚠️ A save sends `showPlanUsageLimits` ONLY when it flips the chip on that device (`planUsageCollectionFlip()`), or a phone switches collection off for every desktop. Claude data comes from the statusLine exporter, injected as an EPHEMERAL `claude --settings` flag (`resolveStatusLineCliCommand`), never written to disk, WRAPPING a user's own statusLine, posting to `POST /api/status-telemetry`. Codex comes from a read-only `account/rateLimits/read` poll (main bucket only). The statusline's `prompt_cache` object (Claude Code v2.1.251+) is kept by `StatusTelemetrySchema` (every documented scalar field, null-tolerant; `parsePromptCache()` in usage-telemetry.ts normalizes only the stored subset) and shown in the footer as `cache:until HH:MM` / `cache:cold` (only when the user has no statusline of their own) and in Session Options → Respawn (`#promptCacheStatus`, fed by `Session.promptCache` in `toState()` plus the change-only `session:promptCache` SSE event, worded by the pure `CodemanPromptCache.format`): warm-until/cold, the re-cache stake, the hit rate, the last miss's causes, and a `Context compacted at HH:MM` note after a compaction or a tool-result clearing (stamped on the first warm report whose `recache_tokens_if_cold` is null after one that carried it, never from `expected_rebuilds`, which Claude Code bumps only at the next request; NEVER an alert). ⚠️ `Session.setPromptCache()` stamps `compactedAt` onto a NEW object (never mutating the parsed report) and the route broadcasts the STORED object; the last report is restored on restart (constructor `promptCache`, like `displayModel`) and the readout is time-aware (a passed `expiresAt` reads cold, not stale-warm). It is the source of truth for the cache TTL, never the plan tier. → [architecture-invariants#plan-usage-chip-statusline-telemetry](docs/architecture-invariants.md#plan-usage-chip-statusline-telemetry), `docs/usage-limits-display-plan.md` -**Prompt-cache keep-warm** (`src/prompt-cache-keepwarm.ts`; per-session opt-in on the Respawn tab, app-wide default `promptCacheKeepWarm` + cap `promptCacheKeepWarmMaxHours` in App Settings → System → Automation, SYNCED, default OFF / 2 h; step 2 of Discussion #554): one keep-alive turn (`KEEP_WARM_NUDGE`, under 100 chars) five minutes before an idle claude session's cache would expire, so the next prompt reads the conversation from cache instead of re-writing it at 2x. Codeman owns the idle policy for these sessions: a launch with keep-warm on also carries `"idleCompaction": false`, folded LAST into the ONE `--settings` JSON (`session-cli-registry-bridge.ts`), decided once per create/respawn by `decideIdleCompactionOptOut()` (tmux-manager.ts: keep-warm on via `keepWarmOn()`, exporter `injected`, claude's `idleCompactionSetting` gate at 2.1.290, fail-closed like `--name`) and recorded as `MuxSession.idleCompactionOff`; a keep-warm-off launch stays byte-identical (`test/cli-registry-spawn-golden.test.ts`). Switched on after launch it is best-effort and the readout says idle compaction may still run until the next launch. ⚠️ The deadline is `Session.promptCache.expiresAt` from the statusline, never idle detection or the plan tier; a `5m` TTL is a hard stop, and so is a launch whose `statusLineExporter` is not `injected`. ⚠️ Every guard lives in the pure `decideKeepWarm()` (effective opt-in, where an agent-spawned session (`parentSessionId`) does not inherit the app default; exporter injected; warm; no `compactedAt` this gap; tokens at stake; live pane (pid AND no `paneExit`); no limit pause (auto-resume's, or a limit message on screen); no respawn/Ralph/task/waiter; inside the cap since `lastRealPromptAt`; idle; no dialog; EMPTY composer via `composerIsEmpty()`, continuation lines included, read from a STYLED capture so Claude Code's faint prompt suggestion is not taken for a draft; no recent keystroke); add a guard there, never in the service. ⚠️ The nudge IS a submit and moves `lastSubmitAt` (the response viewer's conversation correlation needs it); the window `beginKeepAliveTurn()` opens keeps it off `lastRealPromptAt` (the cap's own persisted clock) and opens its `stop`/`idle_prompt` hooks pre-acknowledged (`preAcknowledged` on `notePrompt`), so nothing alerts, and leaves an idle item already pending exactly as found (only a human spends an idle alert; `session:working`/`session:idle` carry `keepAlive: true` so the page's stuck timer ignores the turn); the keep-alive's own `idle_prompt` ends that window (`endKeepAliveTurn()`; `KEEP_WARM_TURN_WINDOW_MS` is only a ceiling, since a slow turn's idle prompt lands a minute after it ends), and so does any other submit: a `fromUser` write, a programmatic write of anything but the nudge, or a `prompt_submitted` hook whose prompt is not the nudge. Read My Mind drops its exact text (`isCapturablePrompt`). → [architecture-invariants#prompt-cache-keep-warm](docs/architecture-invariants.md#prompt-cache-keep-warm) +**Prompt-cache keep-warm** (`src/prompt-cache-keepwarm.ts`; per-session opt-in on the Respawn tab, app-wide default `promptCacheKeepWarm` + cap `promptCacheKeepWarmMaxHours` in App Settings → System → Automation, SYNCED, default OFF / 2 h; step 2 of Discussion #554): one keep-alive turn (`KEEP_WARM_NUDGE`, under 100 chars) five minutes before an idle claude session's cache would expire, so the next prompt reads the conversation from cache instead of re-writing it at 2x. Codeman owns the idle policy for these sessions: a launch with keep-warm on also carries `"idleCompaction": false`, folded LAST into the ONE `--settings` JSON (`session-cli-registry-bridge.ts`), decided once per create/respawn by `decideIdleCompactionOptOut()` (tmux-manager.ts: keep-warm on via `keepWarmOn()`, exporter `injected`, claude's `idleCompactionSetting` gate at 2.1.290, fail-closed like `--name`) and recorded as `MuxSession.idleCompactionOff`; a keep-warm-off launch stays byte-identical (`test/cli-registry-spawn-golden.test.ts`). Switched on after launch it is best-effort and the readout says idle compaction may still run until the next launch. ⚠️ The deadline is `Session.promptCache.expiresAt` from the statusline, never idle detection or the plan tier; a `5m` TTL is a hard stop, and so is a launch whose `statusLineExporter` is not `injected`. ⚠️ Every guard lives in the pure `decideKeepWarm()` (effective opt-in, where an agent-spawned session (`parentSessionId`) does not inherit the app default; exporter injected; warm; no `compactedAt` this gap; tokens at stake; live pane (pid AND no `paneExit`); no limit pause (auto-resume's, or a limit message on screen); no respawn/Ralph/task/waiter; inside the cap since `lastRealPromptAt`; idle; no dialog; EMPTY composer via `composerIsEmpty()`, continuation lines included, read from a STYLED capture so Claude Code's faint prompt suggestion is not taken for a draft; no recent keystroke); add a guard there, never in the service. ⚠️ The nudge IS a submit and moves `lastSubmitAt` (the response viewer's conversation correlation needs it); the window `beginKeepAliveTurn()` opens keeps it off `lastRealPromptAt` (the cap's own persisted clock) and opens its `stop`/`idle_prompt` hooks pre-acknowledged (`preAcknowledged` on `notePrompt`), so nothing alerts, and leaves an idle item already pending exactly as found (only a human spends an idle alert; `session:working`/`session:idle` carry `keepAlive: true` so the page's stuck timer ignores the turn); the keep-alive's own `idle_prompt` ends that window (`endKeepAliveTurn()`; `KEEP_WARM_TURN_WINDOW_MS` is only a ceiling, since a slow turn's idle prompt lands a minute after it ends), so does a nudge whose write failed (retried a minute later, never past the last-chance line), and so does any other submit: a `fromUser` write, a programmatic write of anything but the nudge, or a `prompt_submitted` hook whose prompt is not the nudge. Read My Mind drops its exact text (`isCapturablePrompt`). → [architecture-invariants#prompt-cache-keep-warm](docs/architecture-invariants.md#prompt-cache-keep-warm) **Orchestrator**: State machine that turns a user goal into a phased plan and drives it to completion: `idle → planning → approval → executing → verifying → (replanning) → completed/failed`. `OrchestratorLoop` (engine) delegates plan generation to `orchestrator-planner` and per-phase verification gates to `orchestrator-verifier`, executing phases via team agents/`task-queue`. State persists under the `orchestrator` key in `state.json`. Distinct from Ralph (single-session autonomous loop) — orchestrator coordinates multi-phase, multi-agent execution. See `docs/orchestrator-loop-architecture.md`. diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 835d5279..374348ea 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -161,7 +161,7 @@ Further detail: the handheld OFF default comes from the mobile block in `getDefa ### Prompt-cache keep-warm -**Prompt-cache keep-warm** (`src/prompt-cache-keepwarm.ts`; per-session opt-in `keepWarmEnabled` on the Respawn tab, app-wide default `promptCacheKeepWarm` and cap `promptCacheKeepWarmMaxHours` under App Settings → System → Automation, both SYNCED, default OFF and 2 h; step 2 of Discussion #554): shortly before an idle claude session's prompt cache would expire, Codeman sends ONE keep-alive turn (`KEEP_WARM_NUDGE`, under 100 characters so Claude Code never reads the burst as a paste) so the next real prompt still reads the conversation from cache. Measured on two sessions of ~110k tokens: the nudge read 109,831 tokens from cache and wrote 70, replied `ok` in three seconds with no tool use, and a real turn seven minutes past the original expiry stayed a hit while the un-nudged control re-wrote 89,882 tokens with `last_miss_cause: ttl_expired_1h`; the payoff on real usage is concentrated in gaps of one to four hours, which is why there is a cap after the last real prompt rather than an indefinite loop (2 h by default: the maintainer's simulation over 30 days of gaps came out slightly cheaper than 3 h). ⚠️ **Codeman owns the idle policy for a keep-warm session.** Claude Code 2.1.289+ can compact an idle 1h-cache session by itself at 90% of the TTL (a server-flagged experiment, `fireAtFraction` tunable down to 0.5), which on the default timing always lands a minute before the nudge and leaves only a summary to keep warm. So a launch with keep-warm on carries `"idleCompaction": false`, folded LAST into the ONE `--settings` JSON with ultracode, the advisor and the statusLine exporter (`buildSpawnCommandFromRegistry`; never a second `--settings` flag, Claude Code takes one), so a keep-warm-off launch is byte-identical to one from before keep-warm (`test/cli-registry-spawn-golden.test.ts`). The `--settings` flag outranks user, project and local settings, which is why the key is conditional: everywhere else it would take Claude Code's own behaviour away with no way back. `decideIdleCompactionOptOut()` (tmux-manager.ts, pure) decides it once per create and respawn: keep-warm on (`keepWarmOn()`: the session's choice, else the app default, read fresh by `readKeepWarmSettings()`), the exporter `injected` (without `prompt_cache` reports keep-warm cannot run, and the opt-out alone would only cost the user the idle compaction), and claude's `idleCompactionSetting` gate (`capabilities.gates`, 2.1.290, the first release whose settings schema has the key; 2.1.289 already idle-compacts with no opt-out), fail-closed through `launchGatePasses()` exactly like `--name`: an unknown version means no key, and a log line says so. The result is recorded on the mux record (`MuxSession.idleCompactionOff`, like `statusLine`, persisted in mux-sessions.json) and published in the keep-warm status as `idleCompactionOff`; a session switched on after launch is kept warm best-effort (a relaunch to install the opt-out would cost exactly the re-write being avoided) and its readout says Claude Code's idle compaction may still run until its next launch, which any relaunch (dead-pane recovery, a custom-model `restartCli()`) fixes for free since every relaunch re-decides. ⚠️ The deadline is the statusline's `prompt_cache.expires_at` (`Session.promptCache`), never idle detection or the plan tier, and a `5m` lifetime is a hard stop (a nudge every four minutes costs more than the miss it prevents; API-key users get the hour from `CLAUDE_CODE_PROMPT_CACHE_TTL=1h` in `envOverrides`). ⚠️ `decideKeepWarm()` is the ONE rulebook and every guard lives in it, tested as a table: the effective opt-in (`keepWarmOn()`; a session an agent spawned, `parentSessionId`, does not inherit the app default, since an idle worker that reported done looks exactly like one that will be prompted again and most never are; its own switch still works), the launch's `statusLineExporter` is `injected` (which also stops every CLI without cache telemetry; the toggle line says why in the readout's own `PROMPT_CACHE_NOT_COLLECTED` words), a warm cache with an expiry, no `compactedAt` this gap (a compaction already replaced the context, and the stamp is dropped at the next request, which re-opens the next gap), not a `5m` lifetime, at least `KEEP_WARM_MIN_TOKENS` at stake, a live pane (a pid AND no recorded `paneExit`: an agent that exited in a `remain-on-exit` pane keeps its pid), no usage-limit pause (auto-resume's, or, read off the same pane capture as the composer, a usage-limit message whose reset is still ahead, since auto-resume tracks pauses only while it is on), no automation (respawn running, a Ralph loop active, a task assigned, or an agent long-polling `wait`), inside the cap measured from `lastRealPromptAt` (else `createdAt`), due at `expires_at` minus `KEEP_WARM_LEAD_MS` but not within `KEEP_WARM_LAST_CHANCE_MS`, idle, no open permission or question dialog, an EMPTY composer (`composerIsEmpty()` in session-submit-verifier.ts reads the last glyph line AND its continuation lines down to the composer's bottom rule, since a draft that opens with a newline leaves the glyph line blank: the nudge's paste would join a draft, and a missing rule is never read as empty). ⚠️ The composer is read from a STYLED capture (`capturePaneText(…, { styles: true })`, `capture-pane -p -e`, still one line per row; never `capturePaneBuffer()`, whose visible frame is a cursor-addressed repaint with no line breaks): Claude Code's prompt suggestion (on by default) fills an empty composer with faint (SGR 2) text whose first letter sits in the reverse-video cursor cell, and only that styling tells it from a typed draft, so `composerIsEmpty()` drops faint runs and a reverse cell followed by faint text. The paste replaces a suggestion rather than joining it (measured on Claude Code 2.1.300), and no human keystroke within `KEEP_WARM_RECENT_INPUT_MS`. Transient guards (working, a dialog, automation, an unreadable or non-empty composer, a recent keystroke) defer by `KEEP_WARM_RETRY_MS` and never past the last-chance line, an evaluation that throws re-arms the same retry, and a trigger landing during an evaluation queues one follow-up after it instead of being dropped, and `remove()` cancels both the follow-up and the retry; the composer is read only once everything else says "due". ⚠️ **Two submit clocks.** The nudge goes out through a plain `writeViaMux()` after `beginKeepAliveTurn()`. It IS a submit, so it moves `lastSubmitAt` from the write and again from its `prompt_submitted` hook (`markPromptSubmitted()`): the response viewer's conversation correlation (`resolveActiveClaudeSessionIdFromHistory()`) matches that stamp against Claude's history, which records the nudge like any prompt. The cap needs the last REAL prompt instead, so `Session._stampSubmit()` also moves `lastRealPromptAt` for every submit outside the keep-alive window (a person in the browser, an agent over the API, a typist in an attached tmux client seen through the hook), persisted in `SessionState.lastRealPromptAt` and restored by both rebuild paths so a restart never reopens the window. The window closes on the keep-alive's own `idle_prompt` (the hook route calls `endKeepAliveTurn()`; Claude Code sends it about a minute after the turn ends, so a fixed short window would let a slow turn's idle prompt alert and push, and `KEEP_WARM_TURN_WINDOW_MS` is only a ceiling for a workspace whose hooks never report). Any other submit closes it at once: a `fromUser` write, a programmatic write carrying anything but the nudge or a bare Enter (an agent, respawn, auto-resume; `isKeepAliveWrite()` in session.ts), or a `prompt_submitted` hook whose `prompt` is not the exact nudge (the route hands the hook's prompt text to `markPromptSubmitted()`), so a person's prompt right after a nudge, typed in the browser or in an attached tmux client, counts and alerts normally. The home screens and the tile grid read `lastSubmitAt` only to anchor a WORKING row, so a nudge shows there only for the seconds its turn runs; idle rows key on `lastActivityAt`, which the nudge's reply moves like any output. ⚠️ The same window makes the turn's `stop` hook and `idle_prompt` open pre-acknowledged (`preAcknowledged: 'cache keep-alive'` through `notePrompt()`, the same path `watching` uses), so no tab alert, toast, push or webhook fires. ⚠️ Only a human spends an idle alert, so the keep-alive turn leaves an idle item already pending exactly as it found it: the `working` transition's idle resolve (session-listener-wiring.ts) and the `stop` resolve skip it, and the keep-alive's `idle_prompt` does not replace it (a replacement would forget that it was unseen, or that a person had seen it), while the page's `_onHookStop` returns on `acknowledgedReason` before clearing anything; `session:working` and `session:idle` carry `keepAlive: true` for the turn, so the page's stuck-detection timer is neither cleared nor re-armed by it. Read My Mind drops the exact nudge text in `isCapturablePrompt()`, the filter `captureIntentPrompt()` → `intentStore.recordPrompt()` runs every transcript prompt through. ⚠️ Re-evaluation is edge-triggered and cheap: every cache report (`status-telemetry-routes.ts`), the toggle route, a settings change (`rescheduleAll()`, which also drops the service's in-memory copy of the default and cap, so a cache report never reads settings.json), `setupSessionListeners()` and the session's `idle` event (`keepWarmSchedule` in session-listener-wiring.ts, which is also what arms a restored report once the recovered pane is attached); the service keeps one timer per session (clamped to the 2^31-1 ms `setTimeout` honours, so a far-off expiry cannot turn into a 1 ms loop), `remove()`s it with the session and stops with the server, and a `remove()` or `stop()` landing during an evaluation's await cancels it there (nothing sent, armed or published after), and the server logs one `[KeepWarm]` line per status change naming the guard that holds it. Status rides `Session.keepWarm` in `toState()` and the change-only `session:keepWarm` event for the readout; `keepWarmEnabled`, `lastRealPromptAt` and the last `promptCache` report are restored by the constructor on both rebuild paths (that is what re-arms the timer after a restart), the status never is. Tests: `test/prompt-cache-keepwarm.test.ts`, `test/keep-warm-launch.test.ts`, `test/keep-warm-i18n.test.ts`, `test/routes/session-routes-keep-warm.test.ts`, the keep-warm readout in `test/prompt-cache-status.test.ts`, the opt-out literals in `test/cli-registry-spawn-golden.test.ts`, the keep-alive cases in `test/routes/hook-event-routes.test.ts` and `test/session-listener-wiring.test.ts`, and the page handlers in `test/keep-alive-page-alerts.test.ts`. +**Prompt-cache keep-warm** (`src/prompt-cache-keepwarm.ts`; per-session opt-in `keepWarmEnabled` on the Respawn tab, app-wide default `promptCacheKeepWarm` and cap `promptCacheKeepWarmMaxHours` under App Settings → System → Automation, both SYNCED, default OFF and 2 h; step 2 of Discussion #554): shortly before an idle claude session's prompt cache would expire, Codeman sends ONE keep-alive turn (`KEEP_WARM_NUDGE`, under 100 characters so Claude Code never reads the burst as a paste) so the next real prompt still reads the conversation from cache. Measured on two sessions of ~110k tokens: the nudge read 109,831 tokens from cache and wrote 70, replied `ok` in three seconds with no tool use, and a real turn seven minutes past the original expiry stayed a hit while the un-nudged control re-wrote 89,882 tokens with `last_miss_cause: ttl_expired_1h`; the payoff on real usage is concentrated in gaps of one to four hours, which is why there is a cap after the last real prompt rather than an indefinite loop (2 h by default: the maintainer's simulation over 30 days of gaps came out slightly cheaper than 3 h). ⚠️ **Codeman owns the idle policy for a keep-warm session.** Claude Code 2.1.289+ can compact an idle 1h-cache session by itself at 90% of the TTL (a server-flagged experiment, `fireAtFraction` tunable down to 0.5), which on the default timing always lands a minute before the nudge and leaves only a summary to keep warm. So a launch with keep-warm on carries `"idleCompaction": false`, folded LAST into the ONE `--settings` JSON with ultracode, the advisor and the statusLine exporter (`buildSpawnCommandFromRegistry`; never a second `--settings` flag, Claude Code takes one), so a keep-warm-off launch is byte-identical to one from before keep-warm (`test/cli-registry-spawn-golden.test.ts`). The `--settings` flag outranks user, project and local settings, which is why the key is conditional: everywhere else it would take Claude Code's own behaviour away with no way back. `decideIdleCompactionOptOut()` (tmux-manager.ts, pure) decides it once per create and respawn: keep-warm on (`keepWarmOn()`: the session's choice, else the app default, read fresh by `readKeepWarmSettings()`), the exporter `injected` (without `prompt_cache` reports keep-warm cannot run, and the opt-out alone would only cost the user the idle compaction), and claude's `idleCompactionSetting` gate (`capabilities.gates`, 2.1.290, the first release whose settings schema has the key; 2.1.289 already idle-compacts with no opt-out), fail-closed through `launchGatePasses()` exactly like `--name`: an unknown version means no key, and a log line says so. The result is recorded on the mux record (`MuxSession.idleCompactionOff`, like `statusLine`, persisted in mux-sessions.json) and published in the keep-warm status as `idleCompactionOff`; a session switched on after launch is kept warm best-effort (a relaunch to install the opt-out would cost exactly the re-write being avoided) and its readout says Claude Code's idle compaction may still run until its next launch, which any relaunch (dead-pane recovery, a custom-model `restartCli()`) fixes for free since every relaunch re-decides. ⚠️ The deadline is the statusline's `prompt_cache.expires_at` (`Session.promptCache`), never idle detection or the plan tier, and a `5m` lifetime is a hard stop (a nudge every four minutes costs more than the miss it prevents; API-key users get the hour from `CLAUDE_CODE_PROMPT_CACHE_TTL=1h` in `envOverrides`). ⚠️ `decideKeepWarm()` is the ONE rulebook and every guard lives in it, tested as a table: the effective opt-in (`keepWarmOn()`; a session an agent spawned, `parentSessionId`, does not inherit the app default, since an idle worker that reported done looks exactly like one that will be prompted again and most never are; its own switch still works), the launch's `statusLineExporter` is `injected` (which also stops every CLI without cache telemetry; the toggle line says why in the readout's own `PROMPT_CACHE_NOT_COLLECTED` words), a warm cache with an expiry, no `compactedAt` this gap (a compaction already replaced the context, and the stamp is dropped at the next request, which re-opens the next gap), not a `5m` lifetime, at least `KEEP_WARM_MIN_TOKENS` at stake, a live pane (a pid AND no recorded `paneExit`: an agent that exited in a `remain-on-exit` pane keeps its pid), no usage-limit pause (auto-resume's, or, read off the same pane capture as the composer, a usage-limit message whose reset is still ahead, since auto-resume tracks pauses only while it is on), no automation (respawn running, a Ralph loop active, a task assigned, or an agent long-polling `wait`), inside the cap measured from `lastRealPromptAt` (else `createdAt`), due at `expires_at` minus `KEEP_WARM_LEAD_MS` but not within `KEEP_WARM_LAST_CHANCE_MS`, idle, no open permission or question dialog, an EMPTY composer (`composerIsEmpty()` in session-submit-verifier.ts reads the last glyph line AND its continuation lines down to the composer's bottom rule, since a draft that opens with a newline leaves the glyph line blank: the nudge's paste would join a draft, and a missing rule is never read as empty). ⚠️ The composer is read from a STYLED capture (`capturePaneText(…, { styles: true })`, `capture-pane -p -e`, still one line per row; never `capturePaneBuffer()`, whose visible frame is a cursor-addressed repaint with no line breaks): Claude Code's prompt suggestion (on by default) fills an empty composer with faint (SGR 2) text whose first letter sits in the reverse-video cursor cell, and only that styling tells it from a typed draft, so `composerIsEmpty()` drops faint runs and a reverse cell followed by faint text. The paste replaces a suggestion rather than joining it (measured on Claude Code 2.1.300), and no human keystroke within `KEEP_WARM_RECENT_INPUT_MS`. Transient guards (working, a dialog, automation, an unreadable or non-empty composer, a recent keystroke) defer by `KEEP_WARM_RETRY_MS` and never past the last-chance line, and so does a nudge whose write fails, which also closes the keep-alive window at once (`endKeepAliveTurn()`, since no turn is running; a check a whole `KEEP_WARM_LEAD_MS` later would land past the expiry and stop), an evaluation that throws re-arms the same retry, and a trigger landing during an evaluation queues one follow-up after it instead of being dropped, and `remove()` cancels both the follow-up and the retry; the composer is read only once everything else says "due". ⚠️ **Two submit clocks.** The nudge goes out through a plain `writeViaMux()` after `beginKeepAliveTurn()`. It IS a submit, so it moves `lastSubmitAt` from the write and again from its `prompt_submitted` hook (`markPromptSubmitted()`): the response viewer's conversation correlation (`resolveActiveClaudeSessionIdFromHistory()`) matches that stamp against Claude's history, which records the nudge like any prompt. The cap needs the last REAL prompt instead, so `Session._stampSubmit()` also moves `lastRealPromptAt` for every submit outside the keep-alive window (a person in the browser, an agent over the API, a typist in an attached tmux client seen through the hook), persisted in `SessionState.lastRealPromptAt` and restored by both rebuild paths so a restart never reopens the window. The window closes on the keep-alive's own `idle_prompt` (the hook route calls `endKeepAliveTurn()`; Claude Code sends it about a minute after the turn ends, so a fixed short window would let a slow turn's idle prompt alert and push, and `KEEP_WARM_TURN_WINDOW_MS` is only a ceiling for a workspace whose hooks never report). Any other submit closes it at once: a `fromUser` write, a programmatic write carrying anything but the nudge or a bare Enter (an agent, respawn, auto-resume; `isKeepAliveWrite()` in session.ts), or a `prompt_submitted` hook whose `prompt` is not the exact nudge (the route hands the hook's prompt text to `markPromptSubmitted()`), so a person's prompt right after a nudge, typed in the browser or in an attached tmux client, counts and alerts normally. The home screens and the tile grid read `lastSubmitAt` only to anchor a WORKING row, so a nudge shows there only for the seconds its turn runs; idle rows key on `lastActivityAt`, which the nudge's reply moves like any output. ⚠️ The same window makes the turn's `stop` hook and `idle_prompt` open pre-acknowledged (`preAcknowledged: 'cache keep-alive'` through `notePrompt()`, the same path `watching` uses), so no tab alert, toast, push or webhook fires. ⚠️ Only a human spends an idle alert, so the keep-alive turn leaves an idle item already pending exactly as it found it: the `working` transition's idle resolve (session-listener-wiring.ts) and the `stop` resolve skip it, and the keep-alive's `idle_prompt` does not replace it (a replacement would forget that it was unseen, or that a person had seen it), while the page's `_onHookStop` returns on `acknowledgedReason` before clearing anything; `session:working` and `session:idle` carry `keepAlive: true` for the turn, so the page's stuck-detection timer is neither cleared nor re-armed by it. Read My Mind drops the exact nudge text in `isCapturablePrompt()`, the filter `captureIntentPrompt()` → `intentStore.recordPrompt()` runs every transcript prompt through. ⚠️ Re-evaluation is edge-triggered and cheap: every cache report (`status-telemetry-routes.ts`), the toggle route, a settings change (`rescheduleAll()`, which also drops the service's in-memory copy of the default and cap, so a cache report never reads settings.json), `setupSessionListeners()` and the session's `idle` event (`keepWarmSchedule` in session-listener-wiring.ts, which is also what arms a restored report once the recovered pane is attached); the service keeps one timer per session (clamped to the 2^31-1 ms `setTimeout` honours, so a far-off expiry cannot turn into a 1 ms loop), `remove()`s it with the session and stops with the server, and a `remove()` or `stop()` landing during an evaluation's await cancels it there (nothing sent, armed or published after), and the server logs one `[KeepWarm]` line per status change naming the guard that holds it. Status rides `Session.keepWarm` in `toState()` and the change-only `session:keepWarm` event for the readout; `keepWarmEnabled`, `lastRealPromptAt` and the last `promptCache` report are restored by the constructor on both rebuild paths (that is what re-arms the timer after a restart), the status never is. Tests: `test/prompt-cache-keepwarm.test.ts`, `test/keep-warm-launch.test.ts`, `test/keep-warm-i18n.test.ts`, `test/routes/session-routes-keep-warm.test.ts`, the keep-warm readout in `test/prompt-cache-status.test.ts`, the opt-out literals in `test/cli-registry-spawn-golden.test.ts`, the keep-alive cases in `test/routes/hook-event-routes.test.ts` and `test/session-listener-wiring.test.ts`, and the page handlers in `test/keep-alive-page-alerts.test.ts`. ### Cron jobs diff --git a/docs/wiki/Keeping-Agents-Running.md b/docs/wiki/Keeping-Agents-Running.md index 927e1011..905f215f 100644 --- a/docs/wiki/Keeping-Agents-Running.md +++ b/docs/wiki/Keeping-Agents-Running.md @@ -111,7 +111,7 @@ Claude Code 2.1.251 and newer reports the cache state on the statusline after ev **Keep the cache warm while idle** is the switch under the readout. With it on, Codeman sends a one-line keep-alive turn (`Codeman cache keep-alive: no action needed, reply with just "ok".`) about five minutes before the cache would expire. Claude answers `ok`, the request refreshes the cache for another hour, and your next real prompt reads the conversation from cache instead of re-processing it. Measured on sessions of about 110k tokens, the keep-alive cost a cache read plus a one-word reply, a small fraction of the re-write it prevented. -It only fires when there is nothing else going on: the session is idle with an empty prompt (Claude Code's greyed-out prompt suggestion counts as empty), no permission or question dialog is open, nobody has typed in the last few minutes, no respawn loop, Ralph loop or waiting agent is driving the session, and the session is not paused on a usage limit. If any of those hold it waits a minute and looks again, and gives up a minute before the expiry rather than race it. It also stops by itself a few hours after your last prompt (**Keep warm for at most**, under App Settings → System → Automation, default 2 hours), because the saving is concentrated in gaps of one to four hours and an overnight run of hourly keep-alives would cost more than the single cold turn in the morning. It stands down for the rest of a gap once the context has been compacted. The readout's Status line gains a `Keep-alive:` line that says what it is doing: the next keep-alive and when the window closes, waiting and why, sent at a time with a count, or stopped and why. A session started without the cache readout (Plan Usage off at launch, a workspace with its own statusLine, a remote or Docker session) cannot be kept warm: the switch is greyed out and cannot be switched on (one that is already on can still be switched off), and the Status line says why. +It only fires when there is nothing else going on: the session is idle with an empty prompt (Claude Code's greyed-out prompt suggestion counts as empty), no permission or question dialog is open, nobody has typed in the last few minutes, no respawn loop, Ralph loop or waiting agent is driving the session, and the session is not paused on a usage limit. If any of those hold it waits a minute and looks again, and gives up a minute before the expiry rather than race it; a keep-alive that could not be sent is retried the same way. It also stops by itself a few hours after your last prompt (**Keep warm for at most**, under App Settings → System → Automation, default 2 hours), because the saving is concentrated in gaps of one to four hours and an overnight run of hourly keep-alives would cost more than the single cold turn in the morning. It stands down for the rest of a gap once the context has been compacted. The readout's Status line gains a `Keep-alive:` line that says what it is doing: the next keep-alive and when the window closes, waiting and why, sent at a time with a count, or stopped and why. A session started without the cache readout (Plan Usage off at launch, a workspace with its own statusLine, a remote or Docker session) cannot be kept warm: the switch is greyed out and cannot be switched on (one that is already on can still be switched off), and the Status line says why. Recent Claude Code builds can compact a large idle session on their own shortly before the cache expires, which would leave only a summary to keep warm. So a session **started** with keep-warm on also launches with Claude Code's idle compaction switched off (Claude Code 2.1.290 or newer); Codeman then owns the idle policy for it. A session you switch on after it started is still kept warm, but Claude Code may compact it first until its next launch, and the Status line says so; one you switch off keeps Claude Code's idle compaction off until its next launch, and the Status line says that too. Sessions with keep-warm off launch exactly as before. diff --git a/src/prompt-cache-keepwarm.ts b/src/prompt-cache-keepwarm.ts index f721e16a..7d83461b 100644 --- a/src/prompt-cache-keepwarm.ts +++ b/src/prompt-cache-keepwarm.ts @@ -173,6 +173,8 @@ export interface KeepWarmSessionView { readonly lastRealPromptAt: number; isIdle(): boolean; beginKeepAliveTurn(windowMs: number): void; + /** Close the window `beginKeepAliveTurn()` opened, when the nudge never went out. */ + endKeepAliveTurn(): void; writeViaMux(data: string): Promise<boolean>; } @@ -368,8 +370,16 @@ export class PromptCacheKeepWarmService implements KeepWarmScheduler { this.ports.log?.( `[KeepWarm] ${ok ? 'nudged' : 'failed to nudge'} ${id.slice(0, 8)} with ${facts.cache?.recacheTokensIfCold ?? '?'} tokens at stake (nudge ${tally.nudges})` ); - // The nudge's own cache report re-schedules; this timer is the fallback if none arrives. - const nextCheckAt = Date.now() + KEEP_WARM_LEAD_MS; + // Sent: the nudge's own cache report re-schedules, and this timer is the fallback if none + // arrives. Not sent: no turn is running, so the window closes now rather than at its + // ceiling, and the retry lands inside the last minutes before the expiry, where a nudge + // can still go out (a lead time later would be past it). + let nextCheckAt = Date.now() + KEEP_WARM_LEAD_MS; + if (!ok) { + session.endKeepAliveTurn(); + const expiresAt = facts.cache?.expiresAt ?? 0; + nextCheckAt = Math.min(Date.now() + KEEP_WARM_RETRY_MS, expiresAt - KEEP_WARM_LAST_CHANCE_MS); + } this.arm(id, nextCheckAt); this.ports.publish(id, { ...base, diff --git a/test/prompt-cache-keepwarm.test.ts b/test/prompt-cache-keepwarm.test.ts index 4ad0f9e7..3dafa356 100644 --- a/test/prompt-cache-keepwarm.test.ts +++ b/test/prompt-cache-keepwarm.test.ts @@ -337,6 +337,10 @@ class FakeSession implements KeepWarmSessionView { beginKeepAliveTurn(windowMs: number): void { this.keepAliveWindows.push(windowMs); } + keepAliveWindowsEnded = 0; + endKeepAliveTurn(): void { + this.keepAliveWindowsEnded += 1; + } async writeViaMux(data: string): Promise<boolean> { this.writes.push(data); return this.writeOk; @@ -474,7 +478,12 @@ describe('PromptCacheKeepWarmService', () => { service.stop(); }); - it('keeps a failed write out of the tally and retries after the lead time', async () => { + /** + * A nudge only goes out inside the last five minutes before the expiry, so a retry armed a + * whole lead time later would land past it and stop: the failed write ends the keep-alive + * window (no turn is running) and retries a minute later, never past the last-chance line. + */ + it('keeps a failed write out of the tally, ends the keep-alive window and retries a minute later', async () => { const session = new FakeSession(); session.keepWarmEnabled = true; session.writeOk = false; @@ -484,11 +493,38 @@ describe('PromptCacheKeepWarmService', () => { service.schedule(session.id); await settle(); expect(session.writes).toHaveLength(1); + expect(session.keepAliveWindowsEnded).toBe(1); expect(last()).toMatchObject({ state: 'waiting', nudges: 0, + nextCheckAt: NOW + KEEP_WARM_RETRY_MS, reason: expect.stringContaining('could not be written'), }); + + session.writeOk = true; + await vi.advanceTimersByTimeAsync(KEEP_WARM_RETRY_MS); + await settle(); + expect(session.writes).toEqual([`${KEEP_WARM_NUDGE}\r`, `${KEEP_WARM_NUDGE}\r`]); + expect(session.keepAliveWindowsEnded).toBe(1); + expect(last()).toMatchObject({ state: 'nudged', nudges: 1 }); + service.stop(); + }); + + it('never arms the retry after a failed write past the last-chance line', async () => { + const session = new FakeSession(); + session.keepWarmEnabled = true; + session.writeOk = false; + // Due, with only 30 s left before the last-chance line. + session.promptCache = warmCache(NOW + KEEP_WARM_LAST_CHANCE_MS + 30_000); + const { ports, last } = makePorts(session); + const service = new PromptCacheKeepWarmService(ports); + await service.schedule(session.id); + expect(last()).toMatchObject({ state: 'waiting', nextCheckAt: NOW + 30_000 }); + await vi.advanceTimersByTimeAsync(30_000); + await settle(); + expect(session.writes).toHaveLength(1); + expect(last()).toMatchObject({ state: 'stopped', reason: 'too close to expiry to land a turn' }); + expect(vi.getTimerCount()).toBe(0); service.stop(); }); From f334b59dd32a571bb7ad45a1a993d0050410b33c Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Thu, 22 Oct 2026 19:10:26 +0200 Subject: [PATCH 64/96] fix(sessions): log an unreadable settings.json once and retry only sessions that can nudge Merge-time fix for #666. The strict settings reader plus the retry after a throw is right for a read that lands mid-write, but a settings.json that stays broken (a hand edit with a trailing comma) made every session, keep-warm off included, re-read the file and log a line every 60 seconds until it was fixed (11 reads and 11 log lines in ten simulated minutes for one session). The settings read now fails inside `evaluate()`: a given failure is logged once until a read succeeds, and the retry is armed only for a session that could nudge (its choice not `false`, the exporter `injected`, a warm report). Other throws keep the generic retry in `schedule()`. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- docs/architecture-invariants.md | 2 +- src/prompt-cache-keepwarm.ts | 37 +++++++++++++-- test/prompt-cache-keepwarm.test.ts | 73 ++++++++++++++++++++++++++++++ 3 files changed, 108 insertions(+), 4 deletions(-) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 374348ea..bc585b7b 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -161,7 +161,7 @@ Further detail: the handheld OFF default comes from the mobile block in `getDefa ### Prompt-cache keep-warm -**Prompt-cache keep-warm** (`src/prompt-cache-keepwarm.ts`; per-session opt-in `keepWarmEnabled` on the Respawn tab, app-wide default `promptCacheKeepWarm` and cap `promptCacheKeepWarmMaxHours` under App Settings → System → Automation, both SYNCED, default OFF and 2 h; step 2 of Discussion #554): shortly before an idle claude session's prompt cache would expire, Codeman sends ONE keep-alive turn (`KEEP_WARM_NUDGE`, under 100 characters so Claude Code never reads the burst as a paste) so the next real prompt still reads the conversation from cache. Measured on two sessions of ~110k tokens: the nudge read 109,831 tokens from cache and wrote 70, replied `ok` in three seconds with no tool use, and a real turn seven minutes past the original expiry stayed a hit while the un-nudged control re-wrote 89,882 tokens with `last_miss_cause: ttl_expired_1h`; the payoff on real usage is concentrated in gaps of one to four hours, which is why there is a cap after the last real prompt rather than an indefinite loop (2 h by default: the maintainer's simulation over 30 days of gaps came out slightly cheaper than 3 h). ⚠️ **Codeman owns the idle policy for a keep-warm session.** Claude Code 2.1.289+ can compact an idle 1h-cache session by itself at 90% of the TTL (a server-flagged experiment, `fireAtFraction` tunable down to 0.5), which on the default timing always lands a minute before the nudge and leaves only a summary to keep warm. So a launch with keep-warm on carries `"idleCompaction": false`, folded LAST into the ONE `--settings` JSON with ultracode, the advisor and the statusLine exporter (`buildSpawnCommandFromRegistry`; never a second `--settings` flag, Claude Code takes one), so a keep-warm-off launch is byte-identical to one from before keep-warm (`test/cli-registry-spawn-golden.test.ts`). The `--settings` flag outranks user, project and local settings, which is why the key is conditional: everywhere else it would take Claude Code's own behaviour away with no way back. `decideIdleCompactionOptOut()` (tmux-manager.ts, pure) decides it once per create and respawn: keep-warm on (`keepWarmOn()`: the session's choice, else the app default, read fresh by `readKeepWarmSettings()`), the exporter `injected` (without `prompt_cache` reports keep-warm cannot run, and the opt-out alone would only cost the user the idle compaction), and claude's `idleCompactionSetting` gate (`capabilities.gates`, 2.1.290, the first release whose settings schema has the key; 2.1.289 already idle-compacts with no opt-out), fail-closed through `launchGatePasses()` exactly like `--name`: an unknown version means no key, and a log line says so. The result is recorded on the mux record (`MuxSession.idleCompactionOff`, like `statusLine`, persisted in mux-sessions.json) and published in the keep-warm status as `idleCompactionOff`; a session switched on after launch is kept warm best-effort (a relaunch to install the opt-out would cost exactly the re-write being avoided) and its readout says Claude Code's idle compaction may still run until its next launch, which any relaunch (dead-pane recovery, a custom-model `restartCli()`) fixes for free since every relaunch re-decides. ⚠️ The deadline is the statusline's `prompt_cache.expires_at` (`Session.promptCache`), never idle detection or the plan tier, and a `5m` lifetime is a hard stop (a nudge every four minutes costs more than the miss it prevents; API-key users get the hour from `CLAUDE_CODE_PROMPT_CACHE_TTL=1h` in `envOverrides`). ⚠️ `decideKeepWarm()` is the ONE rulebook and every guard lives in it, tested as a table: the effective opt-in (`keepWarmOn()`; a session an agent spawned, `parentSessionId`, does not inherit the app default, since an idle worker that reported done looks exactly like one that will be prompted again and most never are; its own switch still works), the launch's `statusLineExporter` is `injected` (which also stops every CLI without cache telemetry; the toggle line says why in the readout's own `PROMPT_CACHE_NOT_COLLECTED` words), a warm cache with an expiry, no `compactedAt` this gap (a compaction already replaced the context, and the stamp is dropped at the next request, which re-opens the next gap), not a `5m` lifetime, at least `KEEP_WARM_MIN_TOKENS` at stake, a live pane (a pid AND no recorded `paneExit`: an agent that exited in a `remain-on-exit` pane keeps its pid), no usage-limit pause (auto-resume's, or, read off the same pane capture as the composer, a usage-limit message whose reset is still ahead, since auto-resume tracks pauses only while it is on), no automation (respawn running, a Ralph loop active, a task assigned, or an agent long-polling `wait`), inside the cap measured from `lastRealPromptAt` (else `createdAt`), due at `expires_at` minus `KEEP_WARM_LEAD_MS` but not within `KEEP_WARM_LAST_CHANCE_MS`, idle, no open permission or question dialog, an EMPTY composer (`composerIsEmpty()` in session-submit-verifier.ts reads the last glyph line AND its continuation lines down to the composer's bottom rule, since a draft that opens with a newline leaves the glyph line blank: the nudge's paste would join a draft, and a missing rule is never read as empty). ⚠️ The composer is read from a STYLED capture (`capturePaneText(…, { styles: true })`, `capture-pane -p -e`, still one line per row; never `capturePaneBuffer()`, whose visible frame is a cursor-addressed repaint with no line breaks): Claude Code's prompt suggestion (on by default) fills an empty composer with faint (SGR 2) text whose first letter sits in the reverse-video cursor cell, and only that styling tells it from a typed draft, so `composerIsEmpty()` drops faint runs and a reverse cell followed by faint text. The paste replaces a suggestion rather than joining it (measured on Claude Code 2.1.300), and no human keystroke within `KEEP_WARM_RECENT_INPUT_MS`. Transient guards (working, a dialog, automation, an unreadable or non-empty composer, a recent keystroke) defer by `KEEP_WARM_RETRY_MS` and never past the last-chance line, and so does a nudge whose write fails, which also closes the keep-alive window at once (`endKeepAliveTurn()`, since no turn is running; a check a whole `KEEP_WARM_LEAD_MS` later would land past the expiry and stop), an evaluation that throws re-arms the same retry, and a trigger landing during an evaluation queues one follow-up after it instead of being dropped, and `remove()` cancels both the follow-up and the retry; the composer is read only once everything else says "due". ⚠️ **Two submit clocks.** The nudge goes out through a plain `writeViaMux()` after `beginKeepAliveTurn()`. It IS a submit, so it moves `lastSubmitAt` from the write and again from its `prompt_submitted` hook (`markPromptSubmitted()`): the response viewer's conversation correlation (`resolveActiveClaudeSessionIdFromHistory()`) matches that stamp against Claude's history, which records the nudge like any prompt. The cap needs the last REAL prompt instead, so `Session._stampSubmit()` also moves `lastRealPromptAt` for every submit outside the keep-alive window (a person in the browser, an agent over the API, a typist in an attached tmux client seen through the hook), persisted in `SessionState.lastRealPromptAt` and restored by both rebuild paths so a restart never reopens the window. The window closes on the keep-alive's own `idle_prompt` (the hook route calls `endKeepAliveTurn()`; Claude Code sends it about a minute after the turn ends, so a fixed short window would let a slow turn's idle prompt alert and push, and `KEEP_WARM_TURN_WINDOW_MS` is only a ceiling for a workspace whose hooks never report). Any other submit closes it at once: a `fromUser` write, a programmatic write carrying anything but the nudge or a bare Enter (an agent, respawn, auto-resume; `isKeepAliveWrite()` in session.ts), or a `prompt_submitted` hook whose `prompt` is not the exact nudge (the route hands the hook's prompt text to `markPromptSubmitted()`), so a person's prompt right after a nudge, typed in the browser or in an attached tmux client, counts and alerts normally. The home screens and the tile grid read `lastSubmitAt` only to anchor a WORKING row, so a nudge shows there only for the seconds its turn runs; idle rows key on `lastActivityAt`, which the nudge's reply moves like any output. ⚠️ The same window makes the turn's `stop` hook and `idle_prompt` open pre-acknowledged (`preAcknowledged: 'cache keep-alive'` through `notePrompt()`, the same path `watching` uses), so no tab alert, toast, push or webhook fires. ⚠️ Only a human spends an idle alert, so the keep-alive turn leaves an idle item already pending exactly as it found it: the `working` transition's idle resolve (session-listener-wiring.ts) and the `stop` resolve skip it, and the keep-alive's `idle_prompt` does not replace it (a replacement would forget that it was unseen, or that a person had seen it), while the page's `_onHookStop` returns on `acknowledgedReason` before clearing anything; `session:working` and `session:idle` carry `keepAlive: true` for the turn, so the page's stuck-detection timer is neither cleared nor re-armed by it. Read My Mind drops the exact nudge text in `isCapturablePrompt()`, the filter `captureIntentPrompt()` → `intentStore.recordPrompt()` runs every transcript prompt through. ⚠️ Re-evaluation is edge-triggered and cheap: every cache report (`status-telemetry-routes.ts`), the toggle route, a settings change (`rescheduleAll()`, which also drops the service's in-memory copy of the default and cap, so a cache report never reads settings.json), `setupSessionListeners()` and the session's `idle` event (`keepWarmSchedule` in session-listener-wiring.ts, which is also what arms a restored report once the recovered pane is attached); the service keeps one timer per session (clamped to the 2^31-1 ms `setTimeout` honours, so a far-off expiry cannot turn into a 1 ms loop), `remove()`s it with the session and stops with the server, and a `remove()` or `stop()` landing during an evaluation's await cancels it there (nothing sent, armed or published after), and the server logs one `[KeepWarm]` line per status change naming the guard that holds it. Status rides `Session.keepWarm` in `toState()` and the change-only `session:keepWarm` event for the readout; `keepWarmEnabled`, `lastRealPromptAt` and the last `promptCache` report are restored by the constructor on both rebuild paths (that is what re-arms the timer after a restart), the status never is. Tests: `test/prompt-cache-keepwarm.test.ts`, `test/keep-warm-launch.test.ts`, `test/keep-warm-i18n.test.ts`, `test/routes/session-routes-keep-warm.test.ts`, the keep-warm readout in `test/prompt-cache-status.test.ts`, the opt-out literals in `test/cli-registry-spawn-golden.test.ts`, the keep-alive cases in `test/routes/hook-event-routes.test.ts` and `test/session-listener-wiring.test.ts`, and the page handlers in `test/keep-alive-page-alerts.test.ts`. +**Prompt-cache keep-warm** (`src/prompt-cache-keepwarm.ts`; per-session opt-in `keepWarmEnabled` on the Respawn tab, app-wide default `promptCacheKeepWarm` and cap `promptCacheKeepWarmMaxHours` under App Settings → System → Automation, both SYNCED, default OFF and 2 h; step 2 of Discussion #554): shortly before an idle claude session's prompt cache would expire, Codeman sends ONE keep-alive turn (`KEEP_WARM_NUDGE`, under 100 characters so Claude Code never reads the burst as a paste) so the next real prompt still reads the conversation from cache. Measured on two sessions of ~110k tokens: the nudge read 109,831 tokens from cache and wrote 70, replied `ok` in three seconds with no tool use, and a real turn seven minutes past the original expiry stayed a hit while the un-nudged control re-wrote 89,882 tokens with `last_miss_cause: ttl_expired_1h`; the payoff on real usage is concentrated in gaps of one to four hours, which is why there is a cap after the last real prompt rather than an indefinite loop (2 h by default: the maintainer's simulation over 30 days of gaps came out slightly cheaper than 3 h). ⚠️ **Codeman owns the idle policy for a keep-warm session.** Claude Code 2.1.289+ can compact an idle 1h-cache session by itself at 90% of the TTL (a server-flagged experiment, `fireAtFraction` tunable down to 0.5), which on the default timing always lands a minute before the nudge and leaves only a summary to keep warm. So a launch with keep-warm on carries `"idleCompaction": false`, folded LAST into the ONE `--settings` JSON with ultracode, the advisor and the statusLine exporter (`buildSpawnCommandFromRegistry`; never a second `--settings` flag, Claude Code takes one), so a keep-warm-off launch is byte-identical to one from before keep-warm (`test/cli-registry-spawn-golden.test.ts`). The `--settings` flag outranks user, project and local settings, which is why the key is conditional: everywhere else it would take Claude Code's own behaviour away with no way back. `decideIdleCompactionOptOut()` (tmux-manager.ts, pure) decides it once per create and respawn: keep-warm on (`keepWarmOn()`: the session's choice, else the app default, read fresh by `readKeepWarmSettings()`), the exporter `injected` (without `prompt_cache` reports keep-warm cannot run, and the opt-out alone would only cost the user the idle compaction), and claude's `idleCompactionSetting` gate (`capabilities.gates`, 2.1.290, the first release whose settings schema has the key; 2.1.289 already idle-compacts with no opt-out), fail-closed through `launchGatePasses()` exactly like `--name`: an unknown version means no key, and a log line says so. The result is recorded on the mux record (`MuxSession.idleCompactionOff`, like `statusLine`, persisted in mux-sessions.json) and published in the keep-warm status as `idleCompactionOff`; a session switched on after launch is kept warm best-effort (a relaunch to install the opt-out would cost exactly the re-write being avoided) and its readout says Claude Code's idle compaction may still run until its next launch, which any relaunch (dead-pane recovery, a custom-model `restartCli()`) fixes for free since every relaunch re-decides. ⚠️ The deadline is the statusline's `prompt_cache.expires_at` (`Session.promptCache`), never idle detection or the plan tier, and a `5m` lifetime is a hard stop (a nudge every four minutes costs more than the miss it prevents; API-key users get the hour from `CLAUDE_CODE_PROMPT_CACHE_TTL=1h` in `envOverrides`). ⚠️ `decideKeepWarm()` is the ONE rulebook and every guard lives in it, tested as a table: the effective opt-in (`keepWarmOn()`; a session an agent spawned, `parentSessionId`, does not inherit the app default, since an idle worker that reported done looks exactly like one that will be prompted again and most never are; its own switch still works), the launch's `statusLineExporter` is `injected` (which also stops every CLI without cache telemetry; the toggle line says why in the readout's own `PROMPT_CACHE_NOT_COLLECTED` words), a warm cache with an expiry, no `compactedAt` this gap (a compaction already replaced the context, and the stamp is dropped at the next request, which re-opens the next gap), not a `5m` lifetime, at least `KEEP_WARM_MIN_TOKENS` at stake, a live pane (a pid AND no recorded `paneExit`: an agent that exited in a `remain-on-exit` pane keeps its pid), no usage-limit pause (auto-resume's, or, read off the same pane capture as the composer, a usage-limit message whose reset is still ahead, since auto-resume tracks pauses only while it is on), no automation (respawn running, a Ralph loop active, a task assigned, or an agent long-polling `wait`), inside the cap measured from `lastRealPromptAt` (else `createdAt`), due at `expires_at` minus `KEEP_WARM_LEAD_MS` but not within `KEEP_WARM_LAST_CHANCE_MS`, idle, no open permission or question dialog, an EMPTY composer (`composerIsEmpty()` in session-submit-verifier.ts reads the last glyph line AND its continuation lines down to the composer's bottom rule, since a draft that opens with a newline leaves the glyph line blank: the nudge's paste would join a draft, and a missing rule is never read as empty). ⚠️ The composer is read from a STYLED capture (`capturePaneText(…, { styles: true })`, `capture-pane -p -e`, still one line per row; never `capturePaneBuffer()`, whose visible frame is a cursor-addressed repaint with no line breaks): Claude Code's prompt suggestion (on by default) fills an empty composer with faint (SGR 2) text whose first letter sits in the reverse-video cursor cell, and only that styling tells it from a typed draft, so `composerIsEmpty()` drops faint runs and a reverse cell followed by faint text. The paste replaces a suggestion rather than joining it (measured on Claude Code 2.1.300), and no human keystroke within `KEEP_WARM_RECENT_INPUT_MS`. Transient guards (working, a dialog, automation, an unreadable or non-empty composer, a recent keystroke) defer by `KEEP_WARM_RETRY_MS` and never past the last-chance line, and so does a nudge whose write fails, which also closes the keep-alive window at once (`endKeepAliveTurn()`, since no turn is running; a check a whole `KEEP_WARM_LEAD_MS` later would land past the expiry and stop), an evaluation that throws re-arms the same retry (a failed settings read only for a session that could nudge: its own switch not off, the exporter `injected`, a warm report; a settings.json that stays broken is logged once, not once a minute per session, until a read succeeds), and a trigger landing during an evaluation queues one follow-up after it instead of being dropped, and `remove()` cancels both the follow-up and the retry; the composer is read only once everything else says "due". ⚠️ **Two submit clocks.** The nudge goes out through a plain `writeViaMux()` after `beginKeepAliveTurn()`. It IS a submit, so it moves `lastSubmitAt` from the write and again from its `prompt_submitted` hook (`markPromptSubmitted()`): the response viewer's conversation correlation (`resolveActiveClaudeSessionIdFromHistory()`) matches that stamp against Claude's history, which records the nudge like any prompt. The cap needs the last REAL prompt instead, so `Session._stampSubmit()` also moves `lastRealPromptAt` for every submit outside the keep-alive window (a person in the browser, an agent over the API, a typist in an attached tmux client seen through the hook), persisted in `SessionState.lastRealPromptAt` and restored by both rebuild paths so a restart never reopens the window. The window closes on the keep-alive's own `idle_prompt` (the hook route calls `endKeepAliveTurn()`; Claude Code sends it about a minute after the turn ends, so a fixed short window would let a slow turn's idle prompt alert and push, and `KEEP_WARM_TURN_WINDOW_MS` is only a ceiling for a workspace whose hooks never report). Any other submit closes it at once: a `fromUser` write, a programmatic write carrying anything but the nudge or a bare Enter (an agent, respawn, auto-resume; `isKeepAliveWrite()` in session.ts), or a `prompt_submitted` hook whose `prompt` is not the exact nudge (the route hands the hook's prompt text to `markPromptSubmitted()`), so a person's prompt right after a nudge, typed in the browser or in an attached tmux client, counts and alerts normally. The home screens and the tile grid read `lastSubmitAt` only to anchor a WORKING row, so a nudge shows there only for the seconds its turn runs; idle rows key on `lastActivityAt`, which the nudge's reply moves like any output. ⚠️ The same window makes the turn's `stop` hook and `idle_prompt` open pre-acknowledged (`preAcknowledged: 'cache keep-alive'` through `notePrompt()`, the same path `watching` uses), so no tab alert, toast, push or webhook fires. ⚠️ Only a human spends an idle alert, so the keep-alive turn leaves an idle item already pending exactly as it found it: the `working` transition's idle resolve (session-listener-wiring.ts) and the `stop` resolve skip it, and the keep-alive's `idle_prompt` does not replace it (a replacement would forget that it was unseen, or that a person had seen it), while the page's `_onHookStop` returns on `acknowledgedReason` before clearing anything; `session:working` and `session:idle` carry `keepAlive: true` for the turn, so the page's stuck-detection timer is neither cleared nor re-armed by it. Read My Mind drops the exact nudge text in `isCapturablePrompt()`, the filter `captureIntentPrompt()` → `intentStore.recordPrompt()` runs every transcript prompt through. ⚠️ Re-evaluation is edge-triggered and cheap: every cache report (`status-telemetry-routes.ts`), the toggle route, a settings change (`rescheduleAll()`, which also drops the service's in-memory copy of the default and cap, so a cache report never reads settings.json), `setupSessionListeners()` and the session's `idle` event (`keepWarmSchedule` in session-listener-wiring.ts, which is also what arms a restored report once the recovered pane is attached); the service keeps one timer per session (clamped to the 2^31-1 ms `setTimeout` honours, so a far-off expiry cannot turn into a 1 ms loop), `remove()`s it with the session and stops with the server, and a `remove()` or `stop()` landing during an evaluation's await cancels it there (nothing sent, armed or published after), and the server logs one `[KeepWarm]` line per status change naming the guard that holds it. Status rides `Session.keepWarm` in `toState()` and the change-only `session:keepWarm` event for the readout; `keepWarmEnabled`, `lastRealPromptAt` and the last `promptCache` report are restored by the constructor on both rebuild paths (that is what re-arms the timer after a restart), the status never is. Tests: `test/prompt-cache-keepwarm.test.ts`, `test/keep-warm-launch.test.ts`, `test/keep-warm-i18n.test.ts`, `test/routes/session-routes-keep-warm.test.ts`, the keep-warm readout in `test/prompt-cache-status.test.ts`, the opt-out literals in `test/cli-registry-spawn-golden.test.ts`, the keep-alive cases in `test/routes/hook-event-routes.test.ts` and `test/session-listener-wiring.test.ts`, and the page handlers in `test/keep-alive-page-alerts.test.ts`. ### Cron jobs diff --git a/src/prompt-cache-keepwarm.ts b/src/prompt-cache-keepwarm.ts index 7d83461b..2dd5e754 100644 --- a/src/prompt-cache-keepwarm.ts +++ b/src/prompt-cache-keepwarm.ts @@ -153,6 +153,20 @@ export function decideKeepWarm(f: KeepWarmFacts): KeepWarmDecision { return { action: 'nudge' }; } +/** + * Whether a session could be nudged once the settings are known: its own switch is not off, its + * launch reports the cache, and the last report is warm. Only such a session has anything to + * lose while the settings cannot be read, so only it retries on its own. + */ +function couldNudge(s: KeepWarmSessionView): boolean { + return ( + s.keepWarmEnabled !== false && + s.statusLineExporter === 'injected' && + !!s.promptCache?.warm && + !!s.promptCache.expiresAt + ); +} + /** What the service needs from a session; `Session` satisfies it structurally. */ export interface KeepWarmSessionView { readonly id: string; @@ -226,6 +240,8 @@ export class PromptCacheKeepWarmService implements KeepWarmScheduler { * `rescheduleAll()` drops it, and the settings PUT calls that whenever either key changes. */ private settings: Promise<KeepWarmSettings> | undefined; + /** The last settings read failure logged, so one that persists is logged once; cleared by a good read. */ + private settingsFailure: string | undefined; private stopped = false; constructor(private readonly ports: KeepWarmPorts) {} @@ -252,8 +268,8 @@ export class PromptCacheKeepWarmService implements KeepWarmScheduler { const run = this.evaluate(sessionId) .catch((err) => { this.ports.log?.(`[KeepWarm] ${sessionId.slice(0, 8)}: ${err instanceof Error ? err.message : String(err)}`); - // A failed settings read or write is transient, and an idle session gets no other - // trigger until its next turn. + // A failed write or pane read is transient, and an idle session gets no other trigger + // until its next turn. (A failed settings read is handled in evaluate().) // Not after `remove()`, which drops this run: the session is being deleted. if (!this.stopped && this.running.get(sessionId) === run) this.arm(sessionId, Date.now() + KEEP_WARM_RETRY_MS); }) @@ -318,7 +334,22 @@ export class PromptCacheKeepWarmService implements KeepWarmScheduler { this.clearTimer(id); const session = this.ports.getSession(id); if (!session) return; - const settings = await this.readSettings(); + let settings: KeepWarmSettings; + try { + settings = await this.readSettings(); + } catch (err) { + // A read that lands mid-write is retried. A settings.json that stays broken (a hand edit + // with a trailing comma) fails every read until it is fixed, so it is logged once rather + // than once a minute per session, and only a session that could nudge retries. + const message = err instanceof Error ? err.message : String(err); + if (message !== this.settingsFailure) { + this.settingsFailure = message; + this.ports.log?.(`[KeepWarm] ${id.slice(0, 8)}: cannot read the settings: ${message}`); + } + if (!cancelled() && couldNudge(session)) this.arm(id, Date.now() + KEEP_WARM_RETRY_MS); + return; + } + this.settingsFailure = undefined; if (cancelled()) return; const tally = this.tallies.get(id) ?? { nudges: 0 }; const facts: KeepWarmFacts = { diff --git a/test/prompt-cache-keepwarm.test.ts b/test/prompt-cache-keepwarm.test.ts index 3dafa356..f46c264e 100644 --- a/test/prompt-cache-keepwarm.test.ts +++ b/test/prompt-cache-keepwarm.test.ts @@ -720,6 +720,79 @@ describe('PromptCacheKeepWarmService', () => { service.stop(); }); + /** + * A settings.json left broken (a hand edit with a trailing comma) fails every read until it is + * fixed. Only a session that could nudge retries on its own, and the failure is logged once + * rather than once a minute per session. + */ + it.each([ + { label: 'keep-warm off', set: (s: FakeSession) => (s.keepWarmEnabled = false) }, + { label: 'no exporter on this launch', set: (s: FakeSession) => (s.statusLineExporter = 'remote' as never) }, + { label: 'no cache report', set: (s: FakeSession) => (s.promptCache = null) }, + { label: 'a cold cache', set: (s: FakeSession) => (s.promptCache = { ...warmCache(NOW + HOUR), warm: false }) }, + ])('does not retry an unreadable settings file for a session that cannot nudge ($label)', async ({ set }) => { + const session = new FakeSession(); + session.promptCache = warmCache(NOW + KEEP_WARM_LEAD_MS - 1000); + set(session); + let reads = 0; + const logged: string[] = []; + const { ports, published } = makePorts(session, { + readSettings: async () => { + reads += 1; + throw new Error('Unexpected token } in JSON at position 812'); + }, + log: (m) => logged.push(m), + }); + const service = new PromptCacheKeepWarmService(ports); + await service.schedule(session.id); + expect(vi.getTimerCount()).toBe(0); + await vi.advanceTimersByTimeAsync(10 * KEEP_WARM_RETRY_MS); + await settle(); + expect(reads).toBe(1); + expect(logged).toEqual([expect.stringContaining('Unexpected token')]); + expect(published).toHaveLength(0); + service.stop(); + }); + + it('retries an unreadable settings file every minute for a session that can nudge, logging it once', async () => { + const session = new FakeSession(); + session.promptCache = warmCache(NOW + 30 * 60_000); + let reads = 0; + let fail = true; + const logged: string[] = []; + const { ports, last } = makePorts(session, { + readSettings: async () => { + reads += 1; + if (fail) throw new Error('Unexpected token } in JSON at position 812'); + return { keepWarmDefault: true, maxHours: 2 }; + }, + log: (m) => logged.push(m), + }); + const service = new PromptCacheKeepWarmService(ports); + await service.schedule(session.id); + expect(vi.getTimerCount()).toBe(1); + await vi.advanceTimersByTimeAsync(10 * KEEP_WARM_RETRY_MS); + await settle(); + expect(reads).toBe(11); + expect(logged).toEqual([expect.stringContaining('Unexpected token')]); + + // Fixed: the next retry reads it and the session is kept warm from there. + fail = false; + await vi.advanceTimersByTimeAsync(KEEP_WARM_RETRY_MS); + await settle(); + expect(reads).toBe(12); + expect(last()).toMatchObject({ enabled: true, state: 'armed' }); + + // Broken again later: logged again, once. + fail = true; + service.rescheduleAll(); + await settle(); + await vi.advanceTimersByTimeAsync(3 * KEEP_WARM_RETRY_MS); + await settle(); + expect(logged).toHaveLength(2); + service.stop(); + }); + it('runs one follow-up for triggers that land during an evaluation, and resolves after it', async () => { const session = new FakeSession(); session.keepWarmEnabled = true; From a56038ebef3467a688a23dc3e4f5a932296ba072 Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Thu, 22 Oct 2026 19:10:27 +0200 Subject: [PATCH 65/96] test(terminal): guard the xterm mouse-service privates touch forwarding reads Merge-time fix for #664: _shouldForwardTouchToApp reads terminal._core.coreMouseService.activeEncoding, an xterm private. The bundle-string loop in test/xterm-private-api.test.ts now checks coreMouseService and activeEncoding too, and both failure messages name _shouldForwardTouchToApp, so an xterm upgrade that renames either field cannot silently stop touch forwarding to native SGR tracking apps. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- test/xterm-private-api.test.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/test/xterm-private-api.test.ts b/test/xterm-private-api.test.ts index a4eee111..ede5f239 100644 --- a/test/xterm-private-api.test.ts +++ b/test/xterm-private-api.test.ts @@ -49,8 +49,10 @@ 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, _isSendingComposition, _dataAlreadySent), ' + - 'then update VERIFIED_XTERM_VERSION here. The accessor is optional-chained, so a renamed field ' + + '_handleAnyTextareaChanges, _coreService, _isComposing, _isSendingComposition, _dataAlreadySent) ' + + 'and the mouse-encoding read _shouldForwardTouchToApp makes (terminal-ui.js: ' + + '_core.coreMouseService.activeEncoding), 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); }); @@ -60,9 +62,12 @@ describe('xterm private-API dependency guard', () => { // 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 + // composition xterm finalizes at an Enter keydown is sent twice). terminal-ui.js's + // _shouldForwardTouchToApp reads `_core.coreMouseService.activeEncoding` to forward a touch drag + // only to an app that asked for SGR mouse reports; if either name changes the read is undefined + // and touch quietly stops forwarding to native SGR tracking apps. 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', () => { + it('still ships the composition-helper and mouse-service fields terminal input depends on', () => { const bundle = readFileSync(resolve(root, 'node_modules/@xterm/xterm/lib/xterm.js'), 'utf8'); for (const name of [ '_handleAnyTextareaChanges', @@ -71,11 +76,14 @@ describe('xterm private-API dependency guard', () => { '_isComposing', '_isSendingComposition', '_dataAlreadySent', + 'coreMouseService', + 'activeEncoding', ]) { expect( bundle, `xterm no longer mentions ${name}: re-verify terminal-keycode229-recovery.js installEditSync() ` + - '(src/web/public) against the new CompositionHelper before bumping VERIFIED_XTERM_VERSION' + 'against the new CompositionHelper, and terminal-ui.js _shouldForwardTouchToApp against the ' + + 'new CoreMouseService (both in src/web/public), before bumping VERIFIED_XTERM_VERSION' ).toContain(name); } }); From 2a56b2a9977b90cc8f729dc4801fcdfce485aeba Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Thu, 22 Oct 2026 19:10:48 +0200 Subject: [PATCH 66/96] fix(hooks): keep a keep-alive turn's hooks out of the run summary Merge-time fix for #666. `recordHookEvent()` ran for every `stop`, the run summary files a `stop` as a warning, and the Away digest lists warnings under Needs Attention, so each keep-alive nudge added a row there and to the session's Summary timeline. The hook route now skips the run summary while `keepAliveReason` is set (the keep-alive turn's `stop` and `idle_prompt`). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- docs/api-reference.md | 2 +- docs/architecture-invariants.md | 2 +- docs/wiki/Keeping-Agents-Running.md | 2 +- src/web/routes/hook-event-routes.ts | 6 ++++-- test/routes/hook-event-routes.test.ts | 30 +++++++++++++++++++++++++++ 5 files changed, 37 insertions(+), 5 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index 81fecd30..e6d05ff6 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1160,7 +1160,7 @@ Claude Code reports the main conversation's prompt-cache state on its statusline | ------ | ---------------------------------- | ------------------------------------ | ----- | | `POST` | `/api/v1/sessions/:id/keep-warm` | `{ "enabled": true \| false \| null }` | Sets the session's choice; `null` clears it so the app-wide default applies. `200` with `{ keepWarm: { enabled, status } }`. `400 INVALID_INPUT` for a CLI that reports no cache telemetry on its statusline (anything but Claude today). | -`status` is `{ enabled, idleCompactionOff, state, nextCheckAt?, reason?, nudges, lastNudgeAt?, budgetEndsAt? }` (`idleCompactionOff`: whether the session's current launch carries that opt-out; `budgetEndsAt` only while `enabled`) with `state` one of `off`, `armed` (waiting for the due time), `waiting` (due, deferred by a guard named in `reason`), `nudged` (sent, waiting for the next cache report) or `stopped` (`reason` says why). Two SSE events carry changes: `session:promptCache` (`{ sessionId, ...promptCache }`) and `session:keepWarm` (`{ sessionId, ...status }`), both change-only. The keep-alive turn's `hook:stop` and `hook:idle_prompt` broadcasts carry `acknowledgedReason: "cache keep-alive"`, and no push is sent for them; an idle approval already pending stays as it was (only a person spends its alert). Its `session:working` and `session:idle` broadcasts carry `keepAlive: true`. +`status` is `{ enabled, idleCompactionOff, state, nextCheckAt?, reason?, nudges, lastNudgeAt?, budgetEndsAt? }` (`idleCompactionOff`: whether the session's current launch carries that opt-out; `budgetEndsAt` only while `enabled`) with `state` one of `off`, `armed` (waiting for the due time), `waiting` (due, deferred by a guard named in `reason`), `nudged` (sent, waiting for the next cache report) or `stopped` (`reason` says why). Two SSE events carry changes: `session:promptCache` (`{ sessionId, ...promptCache }`) and `session:keepWarm` (`{ sessionId, ...status }`), both change-only. The keep-alive turn's `hook:stop` and `hook:idle_prompt` broadcasts carry `acknowledgedReason: "cache keep-alive"`, no push is sent for them and the run summary does not record them; an idle approval already pending stays as it was (only a person spends its alert). Its `session:working` and `session:idle` broadcasts carry `keepAlive: true`. ## Webhook notifications diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index bc585b7b..1ddfa8fa 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -161,7 +161,7 @@ Further detail: the handheld OFF default comes from the mobile block in `getDefa ### Prompt-cache keep-warm -**Prompt-cache keep-warm** (`src/prompt-cache-keepwarm.ts`; per-session opt-in `keepWarmEnabled` on the Respawn tab, app-wide default `promptCacheKeepWarm` and cap `promptCacheKeepWarmMaxHours` under App Settings → System → Automation, both SYNCED, default OFF and 2 h; step 2 of Discussion #554): shortly before an idle claude session's prompt cache would expire, Codeman sends ONE keep-alive turn (`KEEP_WARM_NUDGE`, under 100 characters so Claude Code never reads the burst as a paste) so the next real prompt still reads the conversation from cache. Measured on two sessions of ~110k tokens: the nudge read 109,831 tokens from cache and wrote 70, replied `ok` in three seconds with no tool use, and a real turn seven minutes past the original expiry stayed a hit while the un-nudged control re-wrote 89,882 tokens with `last_miss_cause: ttl_expired_1h`; the payoff on real usage is concentrated in gaps of one to four hours, which is why there is a cap after the last real prompt rather than an indefinite loop (2 h by default: the maintainer's simulation over 30 days of gaps came out slightly cheaper than 3 h). ⚠️ **Codeman owns the idle policy for a keep-warm session.** Claude Code 2.1.289+ can compact an idle 1h-cache session by itself at 90% of the TTL (a server-flagged experiment, `fireAtFraction` tunable down to 0.5), which on the default timing always lands a minute before the nudge and leaves only a summary to keep warm. So a launch with keep-warm on carries `"idleCompaction": false`, folded LAST into the ONE `--settings` JSON with ultracode, the advisor and the statusLine exporter (`buildSpawnCommandFromRegistry`; never a second `--settings` flag, Claude Code takes one), so a keep-warm-off launch is byte-identical to one from before keep-warm (`test/cli-registry-spawn-golden.test.ts`). The `--settings` flag outranks user, project and local settings, which is why the key is conditional: everywhere else it would take Claude Code's own behaviour away with no way back. `decideIdleCompactionOptOut()` (tmux-manager.ts, pure) decides it once per create and respawn: keep-warm on (`keepWarmOn()`: the session's choice, else the app default, read fresh by `readKeepWarmSettings()`), the exporter `injected` (without `prompt_cache` reports keep-warm cannot run, and the opt-out alone would only cost the user the idle compaction), and claude's `idleCompactionSetting` gate (`capabilities.gates`, 2.1.290, the first release whose settings schema has the key; 2.1.289 already idle-compacts with no opt-out), fail-closed through `launchGatePasses()` exactly like `--name`: an unknown version means no key, and a log line says so. The result is recorded on the mux record (`MuxSession.idleCompactionOff`, like `statusLine`, persisted in mux-sessions.json) and published in the keep-warm status as `idleCompactionOff`; a session switched on after launch is kept warm best-effort (a relaunch to install the opt-out would cost exactly the re-write being avoided) and its readout says Claude Code's idle compaction may still run until its next launch, which any relaunch (dead-pane recovery, a custom-model `restartCli()`) fixes for free since every relaunch re-decides. ⚠️ The deadline is the statusline's `prompt_cache.expires_at` (`Session.promptCache`), never idle detection or the plan tier, and a `5m` lifetime is a hard stop (a nudge every four minutes costs more than the miss it prevents; API-key users get the hour from `CLAUDE_CODE_PROMPT_CACHE_TTL=1h` in `envOverrides`). ⚠️ `decideKeepWarm()` is the ONE rulebook and every guard lives in it, tested as a table: the effective opt-in (`keepWarmOn()`; a session an agent spawned, `parentSessionId`, does not inherit the app default, since an idle worker that reported done looks exactly like one that will be prompted again and most never are; its own switch still works), the launch's `statusLineExporter` is `injected` (which also stops every CLI without cache telemetry; the toggle line says why in the readout's own `PROMPT_CACHE_NOT_COLLECTED` words), a warm cache with an expiry, no `compactedAt` this gap (a compaction already replaced the context, and the stamp is dropped at the next request, which re-opens the next gap), not a `5m` lifetime, at least `KEEP_WARM_MIN_TOKENS` at stake, a live pane (a pid AND no recorded `paneExit`: an agent that exited in a `remain-on-exit` pane keeps its pid), no usage-limit pause (auto-resume's, or, read off the same pane capture as the composer, a usage-limit message whose reset is still ahead, since auto-resume tracks pauses only while it is on), no automation (respawn running, a Ralph loop active, a task assigned, or an agent long-polling `wait`), inside the cap measured from `lastRealPromptAt` (else `createdAt`), due at `expires_at` minus `KEEP_WARM_LEAD_MS` but not within `KEEP_WARM_LAST_CHANCE_MS`, idle, no open permission or question dialog, an EMPTY composer (`composerIsEmpty()` in session-submit-verifier.ts reads the last glyph line AND its continuation lines down to the composer's bottom rule, since a draft that opens with a newline leaves the glyph line blank: the nudge's paste would join a draft, and a missing rule is never read as empty). ⚠️ The composer is read from a STYLED capture (`capturePaneText(…, { styles: true })`, `capture-pane -p -e`, still one line per row; never `capturePaneBuffer()`, whose visible frame is a cursor-addressed repaint with no line breaks): Claude Code's prompt suggestion (on by default) fills an empty composer with faint (SGR 2) text whose first letter sits in the reverse-video cursor cell, and only that styling tells it from a typed draft, so `composerIsEmpty()` drops faint runs and a reverse cell followed by faint text. The paste replaces a suggestion rather than joining it (measured on Claude Code 2.1.300), and no human keystroke within `KEEP_WARM_RECENT_INPUT_MS`. Transient guards (working, a dialog, automation, an unreadable or non-empty composer, a recent keystroke) defer by `KEEP_WARM_RETRY_MS` and never past the last-chance line, and so does a nudge whose write fails, which also closes the keep-alive window at once (`endKeepAliveTurn()`, since no turn is running; a check a whole `KEEP_WARM_LEAD_MS` later would land past the expiry and stop), an evaluation that throws re-arms the same retry (a failed settings read only for a session that could nudge: its own switch not off, the exporter `injected`, a warm report; a settings.json that stays broken is logged once, not once a minute per session, until a read succeeds), and a trigger landing during an evaluation queues one follow-up after it instead of being dropped, and `remove()` cancels both the follow-up and the retry; the composer is read only once everything else says "due". ⚠️ **Two submit clocks.** The nudge goes out through a plain `writeViaMux()` after `beginKeepAliveTurn()`. It IS a submit, so it moves `lastSubmitAt` from the write and again from its `prompt_submitted` hook (`markPromptSubmitted()`): the response viewer's conversation correlation (`resolveActiveClaudeSessionIdFromHistory()`) matches that stamp against Claude's history, which records the nudge like any prompt. The cap needs the last REAL prompt instead, so `Session._stampSubmit()` also moves `lastRealPromptAt` for every submit outside the keep-alive window (a person in the browser, an agent over the API, a typist in an attached tmux client seen through the hook), persisted in `SessionState.lastRealPromptAt` and restored by both rebuild paths so a restart never reopens the window. The window closes on the keep-alive's own `idle_prompt` (the hook route calls `endKeepAliveTurn()`; Claude Code sends it about a minute after the turn ends, so a fixed short window would let a slow turn's idle prompt alert and push, and `KEEP_WARM_TURN_WINDOW_MS` is only a ceiling for a workspace whose hooks never report). Any other submit closes it at once: a `fromUser` write, a programmatic write carrying anything but the nudge or a bare Enter (an agent, respawn, auto-resume; `isKeepAliveWrite()` in session.ts), or a `prompt_submitted` hook whose `prompt` is not the exact nudge (the route hands the hook's prompt text to `markPromptSubmitted()`), so a person's prompt right after a nudge, typed in the browser or in an attached tmux client, counts and alerts normally. The home screens and the tile grid read `lastSubmitAt` only to anchor a WORKING row, so a nudge shows there only for the seconds its turn runs; idle rows key on `lastActivityAt`, which the nudge's reply moves like any output. ⚠️ The same window makes the turn's `stop` hook and `idle_prompt` open pre-acknowledged (`preAcknowledged: 'cache keep-alive'` through `notePrompt()`, the same path `watching` uses), so no tab alert, toast, push or webhook fires. ⚠️ Only a human spends an idle alert, so the keep-alive turn leaves an idle item already pending exactly as it found it: the `working` transition's idle resolve (session-listener-wiring.ts) and the `stop` resolve skip it, and the keep-alive's `idle_prompt` does not replace it (a replacement would forget that it was unseen, or that a person had seen it), while the page's `_onHookStop` returns on `acknowledgedReason` before clearing anything; `session:working` and `session:idle` carry `keepAlive: true` for the turn, so the page's stuck-detection timer is neither cleared nor re-armed by it. Read My Mind drops the exact nudge text in `isCapturablePrompt()`, the filter `captureIntentPrompt()` → `intentStore.recordPrompt()` runs every transcript prompt through. ⚠️ Re-evaluation is edge-triggered and cheap: every cache report (`status-telemetry-routes.ts`), the toggle route, a settings change (`rescheduleAll()`, which also drops the service's in-memory copy of the default and cap, so a cache report never reads settings.json), `setupSessionListeners()` and the session's `idle` event (`keepWarmSchedule` in session-listener-wiring.ts, which is also what arms a restored report once the recovered pane is attached); the service keeps one timer per session (clamped to the 2^31-1 ms `setTimeout` honours, so a far-off expiry cannot turn into a 1 ms loop), `remove()`s it with the session and stops with the server, and a `remove()` or `stop()` landing during an evaluation's await cancels it there (nothing sent, armed or published after), and the server logs one `[KeepWarm]` line per status change naming the guard that holds it. Status rides `Session.keepWarm` in `toState()` and the change-only `session:keepWarm` event for the readout; `keepWarmEnabled`, `lastRealPromptAt` and the last `promptCache` report are restored by the constructor on both rebuild paths (that is what re-arms the timer after a restart), the status never is. Tests: `test/prompt-cache-keepwarm.test.ts`, `test/keep-warm-launch.test.ts`, `test/keep-warm-i18n.test.ts`, `test/routes/session-routes-keep-warm.test.ts`, the keep-warm readout in `test/prompt-cache-status.test.ts`, the opt-out literals in `test/cli-registry-spawn-golden.test.ts`, the keep-alive cases in `test/routes/hook-event-routes.test.ts` and `test/session-listener-wiring.test.ts`, and the page handlers in `test/keep-alive-page-alerts.test.ts`. +**Prompt-cache keep-warm** (`src/prompt-cache-keepwarm.ts`; per-session opt-in `keepWarmEnabled` on the Respawn tab, app-wide default `promptCacheKeepWarm` and cap `promptCacheKeepWarmMaxHours` under App Settings → System → Automation, both SYNCED, default OFF and 2 h; step 2 of Discussion #554): shortly before an idle claude session's prompt cache would expire, Codeman sends ONE keep-alive turn (`KEEP_WARM_NUDGE`, under 100 characters so Claude Code never reads the burst as a paste) so the next real prompt still reads the conversation from cache. Measured on two sessions of ~110k tokens: the nudge read 109,831 tokens from cache and wrote 70, replied `ok` in three seconds with no tool use, and a real turn seven minutes past the original expiry stayed a hit while the un-nudged control re-wrote 89,882 tokens with `last_miss_cause: ttl_expired_1h`; the payoff on real usage is concentrated in gaps of one to four hours, which is why there is a cap after the last real prompt rather than an indefinite loop (2 h by default: the maintainer's simulation over 30 days of gaps came out slightly cheaper than 3 h). ⚠️ **Codeman owns the idle policy for a keep-warm session.** Claude Code 2.1.289+ can compact an idle 1h-cache session by itself at 90% of the TTL (a server-flagged experiment, `fireAtFraction` tunable down to 0.5), which on the default timing always lands a minute before the nudge and leaves only a summary to keep warm. So a launch with keep-warm on carries `"idleCompaction": false`, folded LAST into the ONE `--settings` JSON with ultracode, the advisor and the statusLine exporter (`buildSpawnCommandFromRegistry`; never a second `--settings` flag, Claude Code takes one), so a keep-warm-off launch is byte-identical to one from before keep-warm (`test/cli-registry-spawn-golden.test.ts`). The `--settings` flag outranks user, project and local settings, which is why the key is conditional: everywhere else it would take Claude Code's own behaviour away with no way back. `decideIdleCompactionOptOut()` (tmux-manager.ts, pure) decides it once per create and respawn: keep-warm on (`keepWarmOn()`: the session's choice, else the app default, read fresh by `readKeepWarmSettings()`), the exporter `injected` (without `prompt_cache` reports keep-warm cannot run, and the opt-out alone would only cost the user the idle compaction), and claude's `idleCompactionSetting` gate (`capabilities.gates`, 2.1.290, the first release whose settings schema has the key; 2.1.289 already idle-compacts with no opt-out), fail-closed through `launchGatePasses()` exactly like `--name`: an unknown version means no key, and a log line says so. The result is recorded on the mux record (`MuxSession.idleCompactionOff`, like `statusLine`, persisted in mux-sessions.json) and published in the keep-warm status as `idleCompactionOff`; a session switched on after launch is kept warm best-effort (a relaunch to install the opt-out would cost exactly the re-write being avoided) and its readout says Claude Code's idle compaction may still run until its next launch, which any relaunch (dead-pane recovery, a custom-model `restartCli()`) fixes for free since every relaunch re-decides. ⚠️ The deadline is the statusline's `prompt_cache.expires_at` (`Session.promptCache`), never idle detection or the plan tier, and a `5m` lifetime is a hard stop (a nudge every four minutes costs more than the miss it prevents; API-key users get the hour from `CLAUDE_CODE_PROMPT_CACHE_TTL=1h` in `envOverrides`). ⚠️ `decideKeepWarm()` is the ONE rulebook and every guard lives in it, tested as a table: the effective opt-in (`keepWarmOn()`; a session an agent spawned, `parentSessionId`, does not inherit the app default, since an idle worker that reported done looks exactly like one that will be prompted again and most never are; its own switch still works), the launch's `statusLineExporter` is `injected` (which also stops every CLI without cache telemetry; the toggle line says why in the readout's own `PROMPT_CACHE_NOT_COLLECTED` words), a warm cache with an expiry, no `compactedAt` this gap (a compaction already replaced the context, and the stamp is dropped at the next request, which re-opens the next gap), not a `5m` lifetime, at least `KEEP_WARM_MIN_TOKENS` at stake, a live pane (a pid AND no recorded `paneExit`: an agent that exited in a `remain-on-exit` pane keeps its pid), no usage-limit pause (auto-resume's, or, read off the same pane capture as the composer, a usage-limit message whose reset is still ahead, since auto-resume tracks pauses only while it is on), no automation (respawn running, a Ralph loop active, a task assigned, or an agent long-polling `wait`), inside the cap measured from `lastRealPromptAt` (else `createdAt`), due at `expires_at` minus `KEEP_WARM_LEAD_MS` but not within `KEEP_WARM_LAST_CHANCE_MS`, idle, no open permission or question dialog, an EMPTY composer (`composerIsEmpty()` in session-submit-verifier.ts reads the last glyph line AND its continuation lines down to the composer's bottom rule, since a draft that opens with a newline leaves the glyph line blank: the nudge's paste would join a draft, and a missing rule is never read as empty). ⚠️ The composer is read from a STYLED capture (`capturePaneText(…, { styles: true })`, `capture-pane -p -e`, still one line per row; never `capturePaneBuffer()`, whose visible frame is a cursor-addressed repaint with no line breaks): Claude Code's prompt suggestion (on by default) fills an empty composer with faint (SGR 2) text whose first letter sits in the reverse-video cursor cell, and only that styling tells it from a typed draft, so `composerIsEmpty()` drops faint runs and a reverse cell followed by faint text. The paste replaces a suggestion rather than joining it (measured on Claude Code 2.1.300), and no human keystroke within `KEEP_WARM_RECENT_INPUT_MS`. Transient guards (working, a dialog, automation, an unreadable or non-empty composer, a recent keystroke) defer by `KEEP_WARM_RETRY_MS` and never past the last-chance line, and so does a nudge whose write fails, which also closes the keep-alive window at once (`endKeepAliveTurn()`, since no turn is running; a check a whole `KEEP_WARM_LEAD_MS` later would land past the expiry and stop), an evaluation that throws re-arms the same retry (a failed settings read only for a session that could nudge: its own switch not off, the exporter `injected`, a warm report; a settings.json that stays broken is logged once, not once a minute per session, until a read succeeds), and a trigger landing during an evaluation queues one follow-up after it instead of being dropped, and `remove()` cancels both the follow-up and the retry; the composer is read only once everything else says "due". ⚠️ **Two submit clocks.** The nudge goes out through a plain `writeViaMux()` after `beginKeepAliveTurn()`. It IS a submit, so it moves `lastSubmitAt` from the write and again from its `prompt_submitted` hook (`markPromptSubmitted()`): the response viewer's conversation correlation (`resolveActiveClaudeSessionIdFromHistory()`) matches that stamp against Claude's history, which records the nudge like any prompt. The cap needs the last REAL prompt instead, so `Session._stampSubmit()` also moves `lastRealPromptAt` for every submit outside the keep-alive window (a person in the browser, an agent over the API, a typist in an attached tmux client seen through the hook), persisted in `SessionState.lastRealPromptAt` and restored by both rebuild paths so a restart never reopens the window. The window closes on the keep-alive's own `idle_prompt` (the hook route calls `endKeepAliveTurn()`; Claude Code sends it about a minute after the turn ends, so a fixed short window would let a slow turn's idle prompt alert and push, and `KEEP_WARM_TURN_WINDOW_MS` is only a ceiling for a workspace whose hooks never report). Any other submit closes it at once: a `fromUser` write, a programmatic write carrying anything but the nudge or a bare Enter (an agent, respawn, auto-resume; `isKeepAliveWrite()` in session.ts), or a `prompt_submitted` hook whose `prompt` is not the exact nudge (the route hands the hook's prompt text to `markPromptSubmitted()`), so a person's prompt right after a nudge, typed in the browser or in an attached tmux client, counts and alerts normally. The home screens and the tile grid read `lastSubmitAt` only to anchor a WORKING row, so a nudge shows there only for the seconds its turn runs; idle rows key on `lastActivityAt`, which the nudge's reply moves like any output. ⚠️ The same window makes the turn's `stop` hook and `idle_prompt` open pre-acknowledged (`preAcknowledged: 'cache keep-alive'` through `notePrompt()`, the same path `watching` uses), so no tab alert, toast, push or webhook fires, and neither is recorded in the run summary (`recordHookEvent()` files a `stop` as a warning, and the Away digest lists warnings under Needs Attention). ⚠️ Only a human spends an idle alert, so the keep-alive turn leaves an idle item already pending exactly as it found it: the `working` transition's idle resolve (session-listener-wiring.ts) and the `stop` resolve skip it, and the keep-alive's `idle_prompt` does not replace it (a replacement would forget that it was unseen, or that a person had seen it), while the page's `_onHookStop` returns on `acknowledgedReason` before clearing anything; `session:working` and `session:idle` carry `keepAlive: true` for the turn, so the page's stuck-detection timer is neither cleared nor re-armed by it. Read My Mind drops the exact nudge text in `isCapturablePrompt()`, the filter `captureIntentPrompt()` → `intentStore.recordPrompt()` runs every transcript prompt through. ⚠️ Re-evaluation is edge-triggered and cheap: every cache report (`status-telemetry-routes.ts`), the toggle route, a settings change (`rescheduleAll()`, which also drops the service's in-memory copy of the default and cap, so a cache report never reads settings.json), `setupSessionListeners()` and the session's `idle` event (`keepWarmSchedule` in session-listener-wiring.ts, which is also what arms a restored report once the recovered pane is attached); the service keeps one timer per session (clamped to the 2^31-1 ms `setTimeout` honours, so a far-off expiry cannot turn into a 1 ms loop), `remove()`s it with the session and stops with the server, and a `remove()` or `stop()` landing during an evaluation's await cancels it there (nothing sent, armed or published after), and the server logs one `[KeepWarm]` line per status change naming the guard that holds it. Status rides `Session.keepWarm` in `toState()` and the change-only `session:keepWarm` event for the readout; `keepWarmEnabled`, `lastRealPromptAt` and the last `promptCache` report are restored by the constructor on both rebuild paths (that is what re-arms the timer after a restart), the status never is. Tests: `test/prompt-cache-keepwarm.test.ts`, `test/keep-warm-launch.test.ts`, `test/keep-warm-i18n.test.ts`, `test/routes/session-routes-keep-warm.test.ts`, the keep-warm readout in `test/prompt-cache-status.test.ts`, the opt-out literals in `test/cli-registry-spawn-golden.test.ts`, the keep-alive cases in `test/routes/hook-event-routes.test.ts` and `test/session-listener-wiring.test.ts`, and the page handlers in `test/keep-alive-page-alerts.test.ts`. ### Cron jobs diff --git a/docs/wiki/Keeping-Agents-Running.md b/docs/wiki/Keeping-Agents-Running.md index 905f215f..35f81870 100644 --- a/docs/wiki/Keeping-Agents-Running.md +++ b/docs/wiki/Keeping-Agents-Running.md @@ -115,7 +115,7 @@ It only fires when there is nothing else going on: the session is idle with an e Recent Claude Code builds can compact a large idle session on their own shortly before the cache expires, which would leave only a summary to keep warm. So a session **started** with keep-warm on also launches with Claude Code's idle compaction switched off (Claude Code 2.1.290 or newer); Codeman then owns the idle policy for it. A session you switch on after it started is still kept warm, but Claude Code may compact it first until its next launch, and the Status line says so; one you switch off keeps Claude Code's idle compaction off until its next launch, and the Status line says that too. Sessions with keep-warm off launch exactly as before. -**Keep prompt caches warm** in the same Automation group is the default for sessions that have not chosen on their Respawn tab; the per-session switch always wins. A session another agent started (a worker) does not follow the default: most workers are never prompted again once they report done, so turn it on for one by hand if you will come back to it. The keep-alive turn is Codeman's, not yours: it does not restart the **Keep warm for at most** window, does not rename a tab, Read My Mind ignores it, and the response it ends with raises no alert, toast, push or webhook. It never clears an alert that was already waiting for you. It is refused on the five-minute cache lifetime, which is what an API key, a cloud provider or extra-usage credits get; a session on an API key can ask Claude Code for the hour with `CLAUDE_CODE_PROMPT_CACHE_TTL=1h` in its environment overrides. +**Keep prompt caches warm** in the same Automation group is the default for sessions that have not chosen on their Respawn tab; the per-session switch always wins. A session another agent started (a worker) does not follow the default: most workers are never prompted again once they report done, so turn it on for one by hand if you will come back to it. The keep-alive turn is Codeman's, not yours: it does not restart the **Keep warm for at most** window, does not rename a tab, Read My Mind ignores it, and the response it ends with raises no alert, toast, push or webhook, nor a row in the Away digest or the session's Summary. It never clears an alert that was already waiting for you. It is refused on the five-minute cache lifetime, which is what an API key, a cloud provider or extra-usage credits get; a session on an API key can ask Claude Code for the hour with `CLAUDE_CODE_PROMPT_CACHE_TTL=1h` in its environment overrides. ## The plan usage chip diff --git a/src/web/routes/hook-event-routes.ts b/src/web/routes/hook-event-routes.ts index 4e17a76b..e05d0448 100644 --- a/src/web/routes/hook-event-routes.ts +++ b/src/web/routes/hook-event-routes.ts @@ -278,9 +278,11 @@ export function registerHookEventRoutes( // Claude pane; only the ones where the conversation actually moved (a /clear // successor) carry information, and recording the rest would push a row into // the Summary timeline and /api/search per turn and evict useful rows from - // the 1000-event FIFO (#367 merge-time fix). + // the 1000-event FIFO (#367 merge-time fix). A keep-alive turn's stop and idle prompt are + // left out too: the summary files a `stop` as a warning, which the Away digest lists under + // Needs Attention, and that turn is Codeman's own, not the agent's. const summaryTracker = ctx.runSummaryTrackers.get(sessionId); - if (summaryTracker && (event !== 'prompt_submitted' || conversationChanged)) { + if (summaryTracker && !keepAliveReason && (event !== 'prompt_submitted' || conversationChanged)) { summaryTracker.recordHookEvent(event, safeData); } diff --git a/test/routes/hook-event-routes.test.ts b/test/routes/hook-event-routes.test.ts index a6066475..ba4184d9 100644 --- a/test/routes/hook-event-routes.test.ts +++ b/test/routes/hook-event-routes.test.ts @@ -427,6 +427,36 @@ describe('hook-event-routes', () => { expect(mockTracker.recordHookEvent).toHaveBeenCalledWith('stop', expect.any(Object)); }); + /** + * The run summary files every `stop` as a warning, and the Away digest lists warnings under + * Needs Attention, so a keep-alive turn's hooks would add a row there (and to the session's + * Summary timeline) for every nudge. They are Codeman's own turn, not the agent's. + */ + it("keeps a keep-alive turn's stop and idle prompt out of the run summary", async () => { + const sessionId = harness.ctx._sessionId; + const mockTracker = { recordHookEvent: vi.fn() }; + harness.ctx.runSummaryTrackers.set(sessionId, mockTracker as never); + harness.ctx._session.keepAliveTurnActive = true; + for (const event of ['stop', 'idle_prompt']) { + const res = await harness.app.inject({ + method: 'POST', + url: '/api/hook-event', + payload: { event, sessionId, data: null }, + }); + expect(res.statusCode).toBe(200); + } + approvalInbox.resolveForSession(sessionId, 'dismissed'); + expect(mockTracker.recordHookEvent).not.toHaveBeenCalled(); + + // The next real turn is recorded as before. + await harness.app.inject({ + method: 'POST', + url: '/api/hook-event', + payload: { event: 'stop', sessionId, data: null }, + }); + expect(mockTracker.recordHookEvent).toHaveBeenCalledWith('stop', expect.any(Object)); + }); + it('starts transcript watcher when transcript_path is provided', async () => { const res = await harness.app.inject({ method: 'POST', From 0ca64f8256e8b86c18c8168176e1c934bd023e6e Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Thu, 22 Oct 2026 19:11:06 +0200 Subject: [PATCH 67/96] fix(settings): re-evaluate the keep-warm only when its default or cap changes Merge-time fix for #666 (the review's nit). The settings PUT called `rescheduleAll()` whenever the body carried either keep-warm key, and the browser sends both on every App Settings save, so every save of anything re-read the settings and re-evaluated every session. It now compares the merged settings with the stored ones, following the rule that a settings action resolves from `merged`, never from the raw body. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- docs/architecture-invariants.md | 2 +- src/web/routes/system-routes.ts | 6 ++++- ...system-routes-settings-partial-put.test.ts | 26 ++++++++++++++++++- 3 files changed, 31 insertions(+), 3 deletions(-) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 1ddfa8fa..355b7cb2 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -161,7 +161,7 @@ Further detail: the handheld OFF default comes from the mobile block in `getDefa ### Prompt-cache keep-warm -**Prompt-cache keep-warm** (`src/prompt-cache-keepwarm.ts`; per-session opt-in `keepWarmEnabled` on the Respawn tab, app-wide default `promptCacheKeepWarm` and cap `promptCacheKeepWarmMaxHours` under App Settings → System → Automation, both SYNCED, default OFF and 2 h; step 2 of Discussion #554): shortly before an idle claude session's prompt cache would expire, Codeman sends ONE keep-alive turn (`KEEP_WARM_NUDGE`, under 100 characters so Claude Code never reads the burst as a paste) so the next real prompt still reads the conversation from cache. Measured on two sessions of ~110k tokens: the nudge read 109,831 tokens from cache and wrote 70, replied `ok` in three seconds with no tool use, and a real turn seven minutes past the original expiry stayed a hit while the un-nudged control re-wrote 89,882 tokens with `last_miss_cause: ttl_expired_1h`; the payoff on real usage is concentrated in gaps of one to four hours, which is why there is a cap after the last real prompt rather than an indefinite loop (2 h by default: the maintainer's simulation over 30 days of gaps came out slightly cheaper than 3 h). ⚠️ **Codeman owns the idle policy for a keep-warm session.** Claude Code 2.1.289+ can compact an idle 1h-cache session by itself at 90% of the TTL (a server-flagged experiment, `fireAtFraction` tunable down to 0.5), which on the default timing always lands a minute before the nudge and leaves only a summary to keep warm. So a launch with keep-warm on carries `"idleCompaction": false`, folded LAST into the ONE `--settings` JSON with ultracode, the advisor and the statusLine exporter (`buildSpawnCommandFromRegistry`; never a second `--settings` flag, Claude Code takes one), so a keep-warm-off launch is byte-identical to one from before keep-warm (`test/cli-registry-spawn-golden.test.ts`). The `--settings` flag outranks user, project and local settings, which is why the key is conditional: everywhere else it would take Claude Code's own behaviour away with no way back. `decideIdleCompactionOptOut()` (tmux-manager.ts, pure) decides it once per create and respawn: keep-warm on (`keepWarmOn()`: the session's choice, else the app default, read fresh by `readKeepWarmSettings()`), the exporter `injected` (without `prompt_cache` reports keep-warm cannot run, and the opt-out alone would only cost the user the idle compaction), and claude's `idleCompactionSetting` gate (`capabilities.gates`, 2.1.290, the first release whose settings schema has the key; 2.1.289 already idle-compacts with no opt-out), fail-closed through `launchGatePasses()` exactly like `--name`: an unknown version means no key, and a log line says so. The result is recorded on the mux record (`MuxSession.idleCompactionOff`, like `statusLine`, persisted in mux-sessions.json) and published in the keep-warm status as `idleCompactionOff`; a session switched on after launch is kept warm best-effort (a relaunch to install the opt-out would cost exactly the re-write being avoided) and its readout says Claude Code's idle compaction may still run until its next launch, which any relaunch (dead-pane recovery, a custom-model `restartCli()`) fixes for free since every relaunch re-decides. ⚠️ The deadline is the statusline's `prompt_cache.expires_at` (`Session.promptCache`), never idle detection or the plan tier, and a `5m` lifetime is a hard stop (a nudge every four minutes costs more than the miss it prevents; API-key users get the hour from `CLAUDE_CODE_PROMPT_CACHE_TTL=1h` in `envOverrides`). ⚠️ `decideKeepWarm()` is the ONE rulebook and every guard lives in it, tested as a table: the effective opt-in (`keepWarmOn()`; a session an agent spawned, `parentSessionId`, does not inherit the app default, since an idle worker that reported done looks exactly like one that will be prompted again and most never are; its own switch still works), the launch's `statusLineExporter` is `injected` (which also stops every CLI without cache telemetry; the toggle line says why in the readout's own `PROMPT_CACHE_NOT_COLLECTED` words), a warm cache with an expiry, no `compactedAt` this gap (a compaction already replaced the context, and the stamp is dropped at the next request, which re-opens the next gap), not a `5m` lifetime, at least `KEEP_WARM_MIN_TOKENS` at stake, a live pane (a pid AND no recorded `paneExit`: an agent that exited in a `remain-on-exit` pane keeps its pid), no usage-limit pause (auto-resume's, or, read off the same pane capture as the composer, a usage-limit message whose reset is still ahead, since auto-resume tracks pauses only while it is on), no automation (respawn running, a Ralph loop active, a task assigned, or an agent long-polling `wait`), inside the cap measured from `lastRealPromptAt` (else `createdAt`), due at `expires_at` minus `KEEP_WARM_LEAD_MS` but not within `KEEP_WARM_LAST_CHANCE_MS`, idle, no open permission or question dialog, an EMPTY composer (`composerIsEmpty()` in session-submit-verifier.ts reads the last glyph line AND its continuation lines down to the composer's bottom rule, since a draft that opens with a newline leaves the glyph line blank: the nudge's paste would join a draft, and a missing rule is never read as empty). ⚠️ The composer is read from a STYLED capture (`capturePaneText(…, { styles: true })`, `capture-pane -p -e`, still one line per row; never `capturePaneBuffer()`, whose visible frame is a cursor-addressed repaint with no line breaks): Claude Code's prompt suggestion (on by default) fills an empty composer with faint (SGR 2) text whose first letter sits in the reverse-video cursor cell, and only that styling tells it from a typed draft, so `composerIsEmpty()` drops faint runs and a reverse cell followed by faint text. The paste replaces a suggestion rather than joining it (measured on Claude Code 2.1.300), and no human keystroke within `KEEP_WARM_RECENT_INPUT_MS`. Transient guards (working, a dialog, automation, an unreadable or non-empty composer, a recent keystroke) defer by `KEEP_WARM_RETRY_MS` and never past the last-chance line, and so does a nudge whose write fails, which also closes the keep-alive window at once (`endKeepAliveTurn()`, since no turn is running; a check a whole `KEEP_WARM_LEAD_MS` later would land past the expiry and stop), an evaluation that throws re-arms the same retry (a failed settings read only for a session that could nudge: its own switch not off, the exporter `injected`, a warm report; a settings.json that stays broken is logged once, not once a minute per session, until a read succeeds), and a trigger landing during an evaluation queues one follow-up after it instead of being dropped, and `remove()` cancels both the follow-up and the retry; the composer is read only once everything else says "due". ⚠️ **Two submit clocks.** The nudge goes out through a plain `writeViaMux()` after `beginKeepAliveTurn()`. It IS a submit, so it moves `lastSubmitAt` from the write and again from its `prompt_submitted` hook (`markPromptSubmitted()`): the response viewer's conversation correlation (`resolveActiveClaudeSessionIdFromHistory()`) matches that stamp against Claude's history, which records the nudge like any prompt. The cap needs the last REAL prompt instead, so `Session._stampSubmit()` also moves `lastRealPromptAt` for every submit outside the keep-alive window (a person in the browser, an agent over the API, a typist in an attached tmux client seen through the hook), persisted in `SessionState.lastRealPromptAt` and restored by both rebuild paths so a restart never reopens the window. The window closes on the keep-alive's own `idle_prompt` (the hook route calls `endKeepAliveTurn()`; Claude Code sends it about a minute after the turn ends, so a fixed short window would let a slow turn's idle prompt alert and push, and `KEEP_WARM_TURN_WINDOW_MS` is only a ceiling for a workspace whose hooks never report). Any other submit closes it at once: a `fromUser` write, a programmatic write carrying anything but the nudge or a bare Enter (an agent, respawn, auto-resume; `isKeepAliveWrite()` in session.ts), or a `prompt_submitted` hook whose `prompt` is not the exact nudge (the route hands the hook's prompt text to `markPromptSubmitted()`), so a person's prompt right after a nudge, typed in the browser or in an attached tmux client, counts and alerts normally. The home screens and the tile grid read `lastSubmitAt` only to anchor a WORKING row, so a nudge shows there only for the seconds its turn runs; idle rows key on `lastActivityAt`, which the nudge's reply moves like any output. ⚠️ The same window makes the turn's `stop` hook and `idle_prompt` open pre-acknowledged (`preAcknowledged: 'cache keep-alive'` through `notePrompt()`, the same path `watching` uses), so no tab alert, toast, push or webhook fires, and neither is recorded in the run summary (`recordHookEvent()` files a `stop` as a warning, and the Away digest lists warnings under Needs Attention). ⚠️ Only a human spends an idle alert, so the keep-alive turn leaves an idle item already pending exactly as it found it: the `working` transition's idle resolve (session-listener-wiring.ts) and the `stop` resolve skip it, and the keep-alive's `idle_prompt` does not replace it (a replacement would forget that it was unseen, or that a person had seen it), while the page's `_onHookStop` returns on `acknowledgedReason` before clearing anything; `session:working` and `session:idle` carry `keepAlive: true` for the turn, so the page's stuck-detection timer is neither cleared nor re-armed by it. Read My Mind drops the exact nudge text in `isCapturablePrompt()`, the filter `captureIntentPrompt()` → `intentStore.recordPrompt()` runs every transcript prompt through. ⚠️ Re-evaluation is edge-triggered and cheap: every cache report (`status-telemetry-routes.ts`), the toggle route, a settings change (`rescheduleAll()`, which also drops the service's in-memory copy of the default and cap, so a cache report never reads settings.json), `setupSessionListeners()` and the session's `idle` event (`keepWarmSchedule` in session-listener-wiring.ts, which is also what arms a restored report once the recovered pane is attached); the service keeps one timer per session (clamped to the 2^31-1 ms `setTimeout` honours, so a far-off expiry cannot turn into a 1 ms loop), `remove()`s it with the session and stops with the server, and a `remove()` or `stop()` landing during an evaluation's await cancels it there (nothing sent, armed or published after), and the server logs one `[KeepWarm]` line per status change naming the guard that holds it. Status rides `Session.keepWarm` in `toState()` and the change-only `session:keepWarm` event for the readout; `keepWarmEnabled`, `lastRealPromptAt` and the last `promptCache` report are restored by the constructor on both rebuild paths (that is what re-arms the timer after a restart), the status never is. Tests: `test/prompt-cache-keepwarm.test.ts`, `test/keep-warm-launch.test.ts`, `test/keep-warm-i18n.test.ts`, `test/routes/session-routes-keep-warm.test.ts`, the keep-warm readout in `test/prompt-cache-status.test.ts`, the opt-out literals in `test/cli-registry-spawn-golden.test.ts`, the keep-alive cases in `test/routes/hook-event-routes.test.ts` and `test/session-listener-wiring.test.ts`, and the page handlers in `test/keep-alive-page-alerts.test.ts`. +**Prompt-cache keep-warm** (`src/prompt-cache-keepwarm.ts`; per-session opt-in `keepWarmEnabled` on the Respawn tab, app-wide default `promptCacheKeepWarm` and cap `promptCacheKeepWarmMaxHours` under App Settings → System → Automation, both SYNCED, default OFF and 2 h; step 2 of Discussion #554): shortly before an idle claude session's prompt cache would expire, Codeman sends ONE keep-alive turn (`KEEP_WARM_NUDGE`, under 100 characters so Claude Code never reads the burst as a paste) so the next real prompt still reads the conversation from cache. Measured on two sessions of ~110k tokens: the nudge read 109,831 tokens from cache and wrote 70, replied `ok` in three seconds with no tool use, and a real turn seven minutes past the original expiry stayed a hit while the un-nudged control re-wrote 89,882 tokens with `last_miss_cause: ttl_expired_1h`; the payoff on real usage is concentrated in gaps of one to four hours, which is why there is a cap after the last real prompt rather than an indefinite loop (2 h by default: the maintainer's simulation over 30 days of gaps came out slightly cheaper than 3 h). ⚠️ **Codeman owns the idle policy for a keep-warm session.** Claude Code 2.1.289+ can compact an idle 1h-cache session by itself at 90% of the TTL (a server-flagged experiment, `fireAtFraction` tunable down to 0.5), which on the default timing always lands a minute before the nudge and leaves only a summary to keep warm. So a launch with keep-warm on carries `"idleCompaction": false`, folded LAST into the ONE `--settings` JSON with ultracode, the advisor and the statusLine exporter (`buildSpawnCommandFromRegistry`; never a second `--settings` flag, Claude Code takes one), so a keep-warm-off launch is byte-identical to one from before keep-warm (`test/cli-registry-spawn-golden.test.ts`). The `--settings` flag outranks user, project and local settings, which is why the key is conditional: everywhere else it would take Claude Code's own behaviour away with no way back. `decideIdleCompactionOptOut()` (tmux-manager.ts, pure) decides it once per create and respawn: keep-warm on (`keepWarmOn()`: the session's choice, else the app default, read fresh by `readKeepWarmSettings()`), the exporter `injected` (without `prompt_cache` reports keep-warm cannot run, and the opt-out alone would only cost the user the idle compaction), and claude's `idleCompactionSetting` gate (`capabilities.gates`, 2.1.290, the first release whose settings schema has the key; 2.1.289 already idle-compacts with no opt-out), fail-closed through `launchGatePasses()` exactly like `--name`: an unknown version means no key, and a log line says so. The result is recorded on the mux record (`MuxSession.idleCompactionOff`, like `statusLine`, persisted in mux-sessions.json) and published in the keep-warm status as `idleCompactionOff`; a session switched on after launch is kept warm best-effort (a relaunch to install the opt-out would cost exactly the re-write being avoided) and its readout says Claude Code's idle compaction may still run until its next launch, which any relaunch (dead-pane recovery, a custom-model `restartCli()`) fixes for free since every relaunch re-decides. ⚠️ The deadline is the statusline's `prompt_cache.expires_at` (`Session.promptCache`), never idle detection or the plan tier, and a `5m` lifetime is a hard stop (a nudge every four minutes costs more than the miss it prevents; API-key users get the hour from `CLAUDE_CODE_PROMPT_CACHE_TTL=1h` in `envOverrides`). ⚠️ `decideKeepWarm()` is the ONE rulebook and every guard lives in it, tested as a table: the effective opt-in (`keepWarmOn()`; a session an agent spawned, `parentSessionId`, does not inherit the app default, since an idle worker that reported done looks exactly like one that will be prompted again and most never are; its own switch still works), the launch's `statusLineExporter` is `injected` (which also stops every CLI without cache telemetry; the toggle line says why in the readout's own `PROMPT_CACHE_NOT_COLLECTED` words), a warm cache with an expiry, no `compactedAt` this gap (a compaction already replaced the context, and the stamp is dropped at the next request, which re-opens the next gap), not a `5m` lifetime, at least `KEEP_WARM_MIN_TOKENS` at stake, a live pane (a pid AND no recorded `paneExit`: an agent that exited in a `remain-on-exit` pane keeps its pid), no usage-limit pause (auto-resume's, or, read off the same pane capture as the composer, a usage-limit message whose reset is still ahead, since auto-resume tracks pauses only while it is on), no automation (respawn running, a Ralph loop active, a task assigned, or an agent long-polling `wait`), inside the cap measured from `lastRealPromptAt` (else `createdAt`), due at `expires_at` minus `KEEP_WARM_LEAD_MS` but not within `KEEP_WARM_LAST_CHANCE_MS`, idle, no open permission or question dialog, an EMPTY composer (`composerIsEmpty()` in session-submit-verifier.ts reads the last glyph line AND its continuation lines down to the composer's bottom rule, since a draft that opens with a newline leaves the glyph line blank: the nudge's paste would join a draft, and a missing rule is never read as empty). ⚠️ The composer is read from a STYLED capture (`capturePaneText(…, { styles: true })`, `capture-pane -p -e`, still one line per row; never `capturePaneBuffer()`, whose visible frame is a cursor-addressed repaint with no line breaks): Claude Code's prompt suggestion (on by default) fills an empty composer with faint (SGR 2) text whose first letter sits in the reverse-video cursor cell, and only that styling tells it from a typed draft, so `composerIsEmpty()` drops faint runs and a reverse cell followed by faint text. The paste replaces a suggestion rather than joining it (measured on Claude Code 2.1.300), and no human keystroke within `KEEP_WARM_RECENT_INPUT_MS`. Transient guards (working, a dialog, automation, an unreadable or non-empty composer, a recent keystroke) defer by `KEEP_WARM_RETRY_MS` and never past the last-chance line, and so does a nudge whose write fails, which also closes the keep-alive window at once (`endKeepAliveTurn()`, since no turn is running; a check a whole `KEEP_WARM_LEAD_MS` later would land past the expiry and stop), an evaluation that throws re-arms the same retry (a failed settings read only for a session that could nudge: its own switch not off, the exporter `injected`, a warm report; a settings.json that stays broken is logged once, not once a minute per session, until a read succeeds), and a trigger landing during an evaluation queues one follow-up after it instead of being dropped, and `remove()` cancels both the follow-up and the retry; the composer is read only once everything else says "due". ⚠️ **Two submit clocks.** The nudge goes out through a plain `writeViaMux()` after `beginKeepAliveTurn()`. It IS a submit, so it moves `lastSubmitAt` from the write and again from its `prompt_submitted` hook (`markPromptSubmitted()`): the response viewer's conversation correlation (`resolveActiveClaudeSessionIdFromHistory()`) matches that stamp against Claude's history, which records the nudge like any prompt. The cap needs the last REAL prompt instead, so `Session._stampSubmit()` also moves `lastRealPromptAt` for every submit outside the keep-alive window (a person in the browser, an agent over the API, a typist in an attached tmux client seen through the hook), persisted in `SessionState.lastRealPromptAt` and restored by both rebuild paths so a restart never reopens the window. The window closes on the keep-alive's own `idle_prompt` (the hook route calls `endKeepAliveTurn()`; Claude Code sends it about a minute after the turn ends, so a fixed short window would let a slow turn's idle prompt alert and push, and `KEEP_WARM_TURN_WINDOW_MS` is only a ceiling for a workspace whose hooks never report). Any other submit closes it at once: a `fromUser` write, a programmatic write carrying anything but the nudge or a bare Enter (an agent, respawn, auto-resume; `isKeepAliveWrite()` in session.ts), or a `prompt_submitted` hook whose `prompt` is not the exact nudge (the route hands the hook's prompt text to `markPromptSubmitted()`), so a person's prompt right after a nudge, typed in the browser or in an attached tmux client, counts and alerts normally. The home screens and the tile grid read `lastSubmitAt` only to anchor a WORKING row, so a nudge shows there only for the seconds its turn runs; idle rows key on `lastActivityAt`, which the nudge's reply moves like any output. ⚠️ The same window makes the turn's `stop` hook and `idle_prompt` open pre-acknowledged (`preAcknowledged: 'cache keep-alive'` through `notePrompt()`, the same path `watching` uses), so no tab alert, toast, push or webhook fires, and neither is recorded in the run summary (`recordHookEvent()` files a `stop` as a warning, and the Away digest lists warnings under Needs Attention). ⚠️ Only a human spends an idle alert, so the keep-alive turn leaves an idle item already pending exactly as it found it: the `working` transition's idle resolve (session-listener-wiring.ts) and the `stop` resolve skip it, and the keep-alive's `idle_prompt` does not replace it (a replacement would forget that it was unseen, or that a person had seen it), while the page's `_onHookStop` returns on `acknowledgedReason` before clearing anything; `session:working` and `session:idle` carry `keepAlive: true` for the turn, so the page's stuck-detection timer is neither cleared nor re-armed by it. Read My Mind drops the exact nudge text in `isCapturablePrompt()`, the filter `captureIntentPrompt()` → `intentStore.recordPrompt()` runs every transcript prompt through. ⚠️ Re-evaluation is edge-triggered and cheap: every cache report (`status-telemetry-routes.ts`), the toggle route, a settings save that changes the default or the cap (`rescheduleAll()`, compared on the merged settings since the browser sends both keys on every save, which also drops the service's in-memory copy of the default and cap, so a cache report never reads settings.json), `setupSessionListeners()` and the session's `idle` event (`keepWarmSchedule` in session-listener-wiring.ts, which is also what arms a restored report once the recovered pane is attached); the service keeps one timer per session (clamped to the 2^31-1 ms `setTimeout` honours, so a far-off expiry cannot turn into a 1 ms loop), `remove()`s it with the session and stops with the server, and a `remove()` or `stop()` landing during an evaluation's await cancels it there (nothing sent, armed or published after), and the server logs one `[KeepWarm]` line per status change naming the guard that holds it. Status rides `Session.keepWarm` in `toState()` and the change-only `session:keepWarm` event for the readout; `keepWarmEnabled`, `lastRealPromptAt` and the last `promptCache` report are restored by the constructor on both rebuild paths (that is what re-arms the timer after a restart), the status never is. Tests: `test/prompt-cache-keepwarm.test.ts`, `test/keep-warm-launch.test.ts`, `test/keep-warm-i18n.test.ts`, `test/routes/session-routes-keep-warm.test.ts`, the keep-warm readout in `test/prompt-cache-status.test.ts`, the opt-out literals in `test/cli-registry-spawn-golden.test.ts`, the keep-alive cases in `test/routes/hook-event-routes.test.ts` and `test/session-listener-wiring.test.ts`, and the page handlers in `test/keep-alive-page-alerts.test.ts`. ### Cron jobs diff --git a/src/web/routes/system-routes.ts b/src/web/routes/system-routes.ts index 0359a8c9..c7a37c10 100644 --- a/src/web/routes/system-routes.ts +++ b/src/web/routes/system-routes.ts @@ -1113,7 +1113,11 @@ export function registerSystemRoutes( toggleService((merged.subagentTrackingEnabled as boolean) ?? true, subagentWatcher, 'Subagent watcher'); // The prompt-cache keep-warm keeps its default and cap in memory; a changed default or cap // makes it re-read them and re-evaluate every session now rather than at its next cache report. - if ('promptCacheKeepWarm' in settingsToStore || 'promptCacheKeepWarmMaxHours' in settingsToStore) { + // Compared on `merged`, not tested for in the body: the browser sends both keys on every save. + if ( + merged.promptCacheKeepWarm !== existing.promptCacheKeepWarm || + merged.promptCacheKeepWarmMaxHours !== existing.promptCacheKeepWarmMaxHours + ) { ctx.keepWarm.rescheduleAll(); } diff --git a/test/routes/system-routes-settings-partial-put.test.ts b/test/routes/system-routes-settings-partial-put.test.ts index bc38b150..b6876843 100644 --- a/test/routes/system-routes-settings-partial-put.test.ts +++ b/test/routes/system-routes-settings-partial-put.test.ts @@ -48,7 +48,13 @@ const { EXISTING_SETTINGS, subagentWatcher, imageWatcher, workflowRunWatcher } = }; return { // Persisted settings.json for these tests: two watchers ON, subagent tracking OFF. - EXISTING_SETTINGS: { subagentTrackingEnabled: false, imageWatcherEnabled: true, showUltracodeAgents: true }, + EXISTING_SETTINGS: { + subagentTrackingEnabled: false, + imageWatcherEnabled: true, + showUltracodeAgents: true, + promptCacheKeepWarm: false, + promptCacheKeepWarmMaxHours: 2, + }, subagentWatcher: makeWatcher(false), imageWatcher: makeWatcher(true), workflowRunWatcher: makeWatcher(true), @@ -143,4 +149,22 @@ describe('PUT /api/settings — partial body must not reset service toggles', () // showUltracodeAgents is still true in settings.json, so it stays up. expect(workflowRunWatcher.stop).not.toHaveBeenCalled(); }); + + /** + * The browser sends both keep-warm keys on every App Settings save, so testing for the keys in + * the body re-read the settings and re-evaluated every session on each save of anything. + */ + it('re-evaluates the prompt-cache keep-warm only when its default or cap changes', async () => { + const put = (payload: Record<string, unknown>) => + harness.app.inject({ method: 'PUT', url: '/api/settings', payload }); + + expect((await put({ promptCacheKeepWarm: false, promptCacheKeepWarmMaxHours: 2 })).statusCode).toBe(200); + expect(harness.ctx.keepWarm.rescheduleAll).not.toHaveBeenCalled(); + + expect((await put({ promptCacheKeepWarm: false, promptCacheKeepWarmMaxHours: 3 })).statusCode).toBe(200); + expect(harness.ctx.keepWarm.rescheduleAll).toHaveBeenCalledTimes(1); + + expect((await put({ promptCacheKeepWarm: true })).statusCode).toBe(200); + expect(harness.ctx.keepWarm.rescheduleAll).toHaveBeenCalledTimes(2); + }); }); From 6c97d634998f2a5654a46fed21bf78a1a057480b Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Thu, 22 Oct 2026 19:12:03 +0200 Subject: [PATCH 68/96] docs: give the composer frame's mux-fallback rule its current reason Merge-time fix for #670. TmuxManager.sendInput() now keeps line feeds and strips only `\r`. The rule that the composer's bracketed-paste frame must never take the mux fallback still holds, because the frame maps its line breaks to `\r`: on tmux the lines weld into one, pasted inside a second pair of markers, and get submitted. docs/architecture-invariants.md and src/web/CLAUDE.md now say that instead of "strips every \r and \n" and "pastes the frame's own markers inside a second paste". Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- docs/architecture-invariants.md | 2 +- src/web/CLAUDE.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 1d8ca4a5..659350c8 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -1050,7 +1050,7 @@ Mapping is `ctrlByteFor()` (`code & 0x1f` over @A-Z[\]^_ and a-z, plus Ctrl+Spac ⚠️ **Delivery is a hand-built bracketed-paste frame** (`\x1b[200~` + the text with newlines mapped to `\r` + `\x1b[201~`, byte-identical to what `terminal.paste()` would emit) through `_sendInputAsync` WITHOUT `{ useMux: true }`, plus a SEPARATE Enter 120 ms later WITH it (codex drops keys that share a PTY read with a bracketed paste). Not `terminal.paste()`, for two reasons: xterm's `bracketedPasteMode` mirror is false for every session after a tab switch or reload (`terminal.reset()` in the replay re-clones the DEC modes, the tmux capture carries no `?2004h`, and tmux never forwards the pane's DECSET to a client after attach), so a paste through xterm would go out unbracketed and the CLI would submit at the first `\r`; and xterm's onData is where the local-echo paste branch flushes pending overlay text AHEAD of the block, text the composer has already taken and erased from the PTY, so the frame goes straight to the wire with the composer as the prompt's only owner. The unconditional frame is safe for a CLI that never enabled DECSET 2004 because tmux does the gating (measured against a live pane: markers stripped for a `cat -v` pane, forwarded intact to a process that had emitted `?2004h`). -⚠️ The frame must never take the mux fallback: `TmuxManager.sendInput()` strips every `\r` and `\n`, which welds the lines together and submits them. +⚠️ The frame must never take the mux fallback: `TmuxManager.sendInput()` strips every `\r`, the frame's line separator, so the lines weld into one, pasted inside a second pair of markers, and get submitted. ⚠️ The size guard sits on the `MAX_INPUT_LENGTH` boundary (64 KiB of UTF-16 code units): `ws-routes.ts` drops a longer frame WITHOUT an ACK, which would wedge the durable queue, so `_composerMaxLength` is derived from that limit minus both markers and an oversized prompt stays a draft with a toast. diff --git a/src/web/CLAUDE.md b/src/web/CLAUDE.md index b7e20d33..bf9addfe 100644 --- a/src/web/CLAUDE.md +++ b/src/web/CLAUDE.md @@ -70,7 +70,7 @@ Frontend JS modules have `@fileoverview` with `@dependency`/`@loadorder` tags. L **Shell keyboard accessory bar + one-shot Ctrl** (`keyboard-accessory.js`): a shell-mode session swaps the mobile accessory bar for terminal controls; `setMode()` records `extendedKeyboardBar` as the base layout and `refreshForActiveSession()` resolves base-vs-shell. ⚠️ Ctrl is a one-shot modifier applied in `terminal.onData` (after `shouldSuppressTerminalQueryResponse`, before every send path), and must skip `isTerminalFocusOrMouseReport()` chunks. ⚠️ It must disarm on use, second tap, any other accessory key, session switch, keyboard dismissal and layout swap. ⚠️ `_handleCjkInput()` must apply it too (the CJK textarea bypasses onData). Mapping: `ctrlByteFor()`. ⚠️ mobile.css's light-skin repaint must keep excluding `.accessory-btn:not(.armed)` or the armed state is invisible. → [architecture-invariants#shell-keyboard-accessory-bar-and-one-shot-ctrl](../../docs/architecture-invariants.md#shell-keyboard-accessory-bar-and-one-shot-ctrl) -**Mobile prompt composer** (`keyboard-accessory.js`): the agent bars' Paste key is **Compose**, a native multiline dialog where only **Send** submits (the shell bar keeps plain Paste). Opening it adopts the whole terminal prompt (`_takePendingLocalEcho`), erasing the flushed prefix with backspaces counted in code points. ⚠️ Drafts are per-session and in memory only (`_composerDrafts`), never persisted (prompts carry secrets). ⚠️ Delivery is a hand-built bracketed-paste frame via `_sendInputAsync` WITHOUT `useMux`, then a separate delayed Enter WITH it; never `terminal.paste()`, and the frame must never take the mux fallback (it would paste the frame's own markers inside a second paste). ⚠️ `_composerMaxLength` must stay derived from `MAX_INPUT_LENGTH` minus the markers, or an oversized frame wedges the durable queue. ⚠️ The composer overlay needs its own gutter restatement after the fold rules. Test: `test/mobile-prompt-composer.test.ts`. → [architecture-invariants#mobile-prompt-composer](../../docs/architecture-invariants.md#mobile-prompt-composer) +**Mobile prompt composer** (`keyboard-accessory.js`): the agent bars' Paste key is **Compose**, a native multiline dialog where only **Send** submits (the shell bar keeps plain Paste). Opening it adopts the whole terminal prompt (`_takePendingLocalEcho`), erasing the flushed prefix with backspaces counted in code points. ⚠️ Drafts are per-session and in memory only (`_composerDrafts`), never persisted (prompts carry secrets). ⚠️ Delivery is a hand-built bracketed-paste frame via `_sendInputAsync` WITHOUT `useMux`, then a separate delayed Enter WITH it; never `terminal.paste()`, and the frame must never take the mux fallback (`TmuxManager.sendInput()` strips every `\r`, the frame's line separator, so the lines weld into one inside a second pair of markers and get submitted). ⚠️ `_composerMaxLength` must stay derived from `MAX_INPUT_LENGTH` minus the markers, or an oversized frame wedges the durable queue. ⚠️ The composer overlay needs its own gutter restatement after the fold rules. Test: `test/mobile-prompt-composer.test.ts`. → [architecture-invariants#mobile-prompt-composer](../../docs/architecture-invariants.md#mobile-prompt-composer) **Terminal resilience**: a replay clear is the queued in-stream `\x1bc` in `_resetTerminalForReplay()`, never `reset()`/`clear()` (queued bytes fuse into the snapshot); the renderer watchdog `_kickRenderer()` reads xterm privates, pinned by `test/xterm-private-api.test.ts` against the resolved lockfile version; every terminal capture fetch has a deadline that covers the BODY (`_fetchTerminalCapture`). → [architecture-invariants#terminal-resilience-replay-clears-renderer-liveness-fetch-deadlines](../../docs/architecture-invariants.md#terminal-resilience-replay-clears-renderer-liveness-fetch-deadlines) From 0fcf52d38a6f5bf03e7263b1dc38785b35e0a8cd Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Thu, 22 Oct 2026 19:12:16 +0200 Subject: [PATCH 69/96] fix(terminal): keep the touch forwarding gate's comments with their code Merge-time fix for #664: _shouldForwardTouchToApp had landed between _shouldForwardWheelToApp and the `target` comment that documents it, so it now sits above that comment. Its own comment names both sessions a local scroll fails in (a direct PTY, where the app is on xterm's alternate screen, and a tmux-backed session, where xterm stays in the normal buffer holding stale tmux repaints), and the touch handler's comment no longer says touch shares the wheel's gate unchanged. Comments only. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- src/web/public/terminal-ui.js | 28 +++++++++++++++++++--------- 1 file changed, 19 insertions(+), 9 deletions(-) diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index e44967b7..cd6ef453 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -1141,9 +1141,13 @@ Object.assign(CodemanApp.prototype, { // (_shouldForwardWheelToApp), a touch drag must go there too, or every // phone/tablet swipe scrolls the local buffer of stale repaint frames and // drags the CLI's pinned input box off the screen (issue #205's mobile - // half). Same gate, so Shift has no touch analog but the local-scrollback - // opt-out setting and the version/tracking gate apply to touch exactly as they - // do to the wheel — including the PageUp/PageDown fallback the wheel uses + // half). Touch asks _shouldForwardTouchToApp, which is the wheel's gate + // plus one branch of its own: a drag also forwards as SGR wheel reports + // while xterm itself tracks the mouse with SGR encoding (an app in the + // pane asked for it), since the wheel needs no help there (xterm encodes + // it) but a drag does. Shift has no touch analog, but the local-scrollback + // opt-out setting and the version/tracking gate apply to touch as they + // do to the wheel, including the PageUp/PageDown fallback the wheel uses // when that gate is false and there is no local scrollback to scroll // (_maybePageCliTranscript), which is what keeps a swipe from being a // complete no-op on a phone. @@ -5830,12 +5834,15 @@ Object.assign(CodemanApp.prototype, { return window.CodemanTerminalInput.wheelDeltaLines(ev, this.terminal?.rows); }, - // `target` ({ terminal, sessionId }) asks the question for a TerminalTile: - // its own terminal's tracking mode and its own session, never the active one. - // Either field left out means the primary pane's. - // xterm encodes native wheel events when mouse tracking is enabled, but - // our touch listeners consume finger drags themselves. Forward those - // explicitly: local scrollLines() cannot move an alternate-screen buffer. + // The touch listeners' forwarding gate: the wheel's gate below, plus one + // branch of its own. While xterm itself tracks the mouse (an app in the pane + // asked for it, a shell's htop or vim), xterm encodes a native wheel event on + // its own, but the touch listeners consume finger drags themselves, so those + // are forwarded explicitly. Scrolling locally instead shows nothing useful in + // either kind of session: on a direct PTY the app draws on xterm's alternate + // screen, which scrollLines() cannot move, and in a tmux-backed session xterm + // stays in the normal buffer, so a local scroll only drags stale tmux + // repaints up the screen. _shouldForwardTouchToApp() { if (this.loadAppSettingsFromStorage?.()?.terminalWheelLocalScrollback) return false; const mode = this.terminal?.modes?.mouseTrackingMode; @@ -5845,6 +5852,9 @@ Object.assign(CodemanApp.prototype, { return !!(mode && mode !== 'none' && mode !== 'x10' && sgr) || this._shouldForwardWheelToApp({ shiftKey: false }); }, + // `target` ({ terminal, sessionId }) asks the question for a TerminalTile: + // its own terminal's tracking mode and its own session, never the active one. + // Either field left out means the primary pane's. _shouldForwardWheelToApp(ev, target = {}) { if (ev.shiftKey) return false; // Opt-out (App Settings → Input → "Wheel scrolls local history"): pin the From d38291b1433e90bc023e285389a26feb05034960 Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Thu, 22 Oct 2026 19:12:25 +0200 Subject: [PATCH 70/96] docs: describe touch forwarding to native SGR mouse tracking Merge-time fix for #664: CLAUDE.md and architecture-invariants still said touch forwards only for claude/copilot and shares the wheel's gate. Both now name the extra branch in _shouldForwardTouchToApp: a drag also forwards as SGR wheel reports while xterm itself tracks the mouse with SGR encoding, and DEFAULT, X10 and SGR-pixel apps fail closed. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- CLAUDE.md | 2 +- docs/architecture-invariants.md | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index bb78756b..4f4c95c0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -206,7 +206,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Full-scrollback replay**: `GET /api/sessions/:id/terminal?full=1` returns the whole tmux scrollback ALONE (`source='mux-full-history'`), superseding the byte buffer. First load of each non-shell TUI session requests it (`_fullHistoryLoaded`); Shell selection, drop recovery, clear and a tile's load use `SHELL_LOAD_QUERY` (constants.js), a bounded window of tmux's RENDERED history (`?full=1&tail=1MiB&alt=recording&lines=10000`), never the raw byte recording, whose full-screen repaints replayed at another pane height push copies of their top row into scrollback; the route reports a client `lines=` bound below the history ceiling that left lines in tmux as `truncationReason: 'tail'`, and `alt=recording` falls back to the visible frame over the tailed byte recording when the pane is in the alternate screen (a nested tmux in a remote or Docker case keeps no tmux history; vim/less take it too while open). A Shell scroll-to-top pulls a bounded `?full=1&tail=` window (a window no longer than the browser's buffer is skipped before the downgrade guard, so it never marks the session exhausted); the unbounded pull stays behind **Load full history**. A Shell split-pane Pane B has its own copy of the bounded pull against its own xterm (`TerminalTile._pullHistory`, terminal-tile.js); keep the two in step. → [architecture-invariants#split-pane-sessions](docs/architecture-invariants.md#split-pane-sessions) ⚠️ The capture ends with a RELATIVE cursor move back to the pane's caret (never `CUP`), so no line-deleting transform may run over it; those skips key on `isFullCapture`, never on `?full=1` alone. ⚠️ A re-pull must never shrink the buffer (`_replayWouldShrinkBuffer()`). ⚠️ `captureCols`/`captureRows` are absent when no frame was positioned: test `Number.isFinite`, never truthiness. ⚠️ A frame dropped at the 128 KiB render cap MUST be recovered, and the recovery verifies itself: `_scheduleDroppedOutputRecovery` re-arms (bounded by `DROP_RECOVERY_MAX_ATTEMPTS`) while `_onSessionNeedsRefresh` reports no repaint, but never after a capture-fetch `'deadline'`. → [architecture-invariants#full-scrollback-replay](docs/architecture-invariants.md#full-scrollback-replay) -**Terminal scrollback strip + wheel/touch forwarding**: codex/claude/gemini get the FULL strip (alt-screen, `3J`, mouse DECSETs); tmux-backed opencode and copilot get 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 and copilot ONLY, and only while they have mouse tracking on** (copilot has no version gate; with a stale flag it falls back to PageUp/PageDown through its `transcriptPageKeys` capability) (`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. ⚠️ A `TerminalTile` (grid tile, split Pane B) forwards the wheel as SGR reports, pages it and reports clicks through these SAME gates with itself as the target (its terminal, its session, never `activeSessionId`), and scrolls Shift+wheel itself (xterm makes it a horizontal no-op); never copy the mode lists into terminal-tile.js. 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 and copilot get 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 and copilot ONLY, and only while they have mouse tracking on** (copilot has no version gate; with a stale flag it falls back to PageUp/PageDown through its `transcriptPageKeys` capability) (`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). Touch alone has one more branch (`_shouldForwardTouchToApp`): in any mode, a drag also forwards as SGR wheel reports while xterm ITSELF tracks the mouse with SGR encoding (an app in the pane asked for it; a tracking mode other than `x10` and `coreMouseService.activeEncoding === 'SGR'`, so DEFAULT, X10 and SGR-pixel apps fail closed), because xterm encodes a wheel there but the touch listeners consume the drag. ⚠️ `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. ⚠️ A `TerminalTile` (grid tile, split Pane B) forwards the wheel as SGR reports, pages it and reports clicks through these SAME gates with itself as the target (its terminal, its session, never `activeSessionId`), and scrolls Shift+wheel itself (xterm makes it a horizontal no-op); never copy the mode lists into terminal-tile.js. 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 a50539c8..2da2eb9c 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -227,7 +227,7 @@ Further detail: the `<prefix>: <title>` form (`w3-myapp: fix the login redirect` **Only claude ≥ 2.1.187 and copilot, each with mouse tracking on, forward 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. **Copilot is the second forwarder** (1.0.95 measured in tmux: alt screen, `mouse_any_flag=1`): its mouse DECSETs are stripped so a drag selects, which also stops xterm encoding the wheel for it, so `_shouldForwardWheelToApp` sends the same SGR reports by hand while `cliMouseTracking` is true; six `\x1b[<64;…M` reports moved its transcript 18 lines, a phone swipe takes the same gate, and there is NO version gate. ⚠️ "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`. +**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). Touch asks `_shouldForwardTouchToApp()`, which is that gate plus one branch: in any session mode, a drag and its fling momentum also forward as SGR wheel reports while xterm itself tracks the mouse (`modes.mouseTrackingMode` other than `none`/`x10`) with SGR encoding (`_core.coreMouseService.activeEncoding === 'SGR'`, an xterm private pinned by `test/xterm-private-api.test.ts`; DEFAULT, X10 and SGR-pixel apps fail closed). xterm encodes a native wheel for such an app itself, but the touch listeners consume the drag, and a local scroll shows nothing useful: a direct-PTY app is on xterm's alternate screen, and in a tmux-backed session xterm stays in the normal buffer holding stale tmux repaints. `_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 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()`: the `transcriptPageKeys` capability, 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). ⚠️ The first event of a trackpad-sized gesture (opening under 2 rows) pages AT ONCE and the travel it skipped is owed back by the rest of the gesture (one page per half screen still). Waiting for half a screen first meant an ordinary trackpad flick (well under 19 rows on a 38-row pane) sent nothing at all. A larger opening event is a mouse-wheel notch (100 px is 4 rows) and accumulates as before, or slow notches, each its own gesture past the gap, would page a full screen apiece. ⚠️ What starts a NEW gesture (and so may pre-pay) is narrow, because every boundary is another free page: (1) a TOUCH CONTACT is one gesture, never split by time: the touch paths tag every call, touchmove and momentum frames alike, with `touchId` (one per touchstart), and a different id (or a wheel event after a touch) is a boundary. touchmove only calls once per whole cell of finger travel, so a slow drag arrives as single lines further apart than any gap; on the gap alone 12 lines at 170 ms each sent 12 PageUps (measured through the real listeners, `test/terminal-touch-paging.browser.test.ts`). (2) Outside a contact, 150 ms of quiet (`PAGE_KEY_GESTURE_GAP_MS`), stretched to one second (`PAGE_KEY_DEBT_MS`) while a pre-paid page is still owed in the SAME direction, so slow small wheel ticks (40 px every 250 ms, a Firefox 1-line notch) pay the debt back instead of each pre-paying a page; after a second of quiet the debt lapses and the next small tick pages at once again. The debt also ends the moment travel repays it (reaches or crosses zero): the rest is plain travel and the 150 ms gap applies again. (3) A direction change inside a gesture (within the gap, or within one contact) drops a pre-paid debt (it belongs to the other direction) and accumulates WITHOUT pre-paying, so a +/-3 px trackpad wobble pages once for its opening event and then nothing; a reversal after the gap is a new gesture and pages at once. A tab switch also starts a new gesture. ⚠️ These gesture rules (first-event page, contact and debt lifetimes, reversal handling, notch accumulation, pinch and sideways-swipe consume) are ONE pure helper, `CodemanTerminalInput.pageKeysForGesture(state, ev, lines, rows, now)`, which both panes call with their own state; never re-derive them in a pane. ⚠️ Three gestures are consumed WITHOUT paging: a pinch (`ctrlKey`, Chrome's trackpad-pinch wheel events), a mostly horizontal swipe (both deltas present and `|deltaX| > |deltaY|`; the touch path locks its own axis first), and anything while the pane's OWN session has the alert `'action'` (a pending `permission_prompt` or `elicitation_dialog`, `updateTabAlertFromHooks` in app.js), so a page key never lands in an open dialog's selector; the primary pane logs that case as `page-keys-dialog-open` so a wheel dead under a stale alert leaves a `[scroll]` line. The dialog check is the one rule outside the helper, because it is per pane: the primary pane reads `tabAlerts` for `activeSessionId`, a tile for its own `sessionId` (usually not the active one). ⚠️ Which modes page is the registry's `capabilities.transcriptPageKeys`, published as `window.__codemanTranscriptPageKeys`, never an id check in terminal-ui.js. Today that is claude, codex and opencode. Codex is on it although it is never forwarded the wheel: newer codex draws on tmux's alternate screen, which the full strip hides from xterm, so its pane is hollow too, and PageUp pages its transcript while SGR wheel reports still do nothing. Which version made the switch is not pinned down: alt-screen drawing was observed on 0.157.1 (local) and 0.160 (remote), while 0.147.0 still draws inline; nothing between 0.147.0 and 0.157.1 has been checked. This is the page-key fallback, NOT wheel forwarding, and an older inline codex keeps real local history (`baseY > 0`) so the guard never fires for it. `shell`/`pi` own real terminal scrollback and are never paged, and 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()`. **Tiles** (a grid tile, the split's Pane B: `TerminalTile`) page the wheel through these same gates, called with the tile's own terminal and session (`_localScrollbackIsHollow(target)`, `_shouldForwardWheelToApp(ev, target)`) and the pure `CodemanTerminalInput.pageKeysForGesture` on the tile's own state, so the mode rule and the gesture rules stay in terminal-ui.js alone. ⚠️ A tile's `baseY` is rarely 0 even when hollow: its first capture is taken at the PTY's previous, taller size and its row-shrinking fits push rows up, so it passes `localRows` with those rows discounted (`TerminalTile._localRows`). ⚠️ So a tile can be hollow with its viewport still up in those rows (Shift+PageUp, a scrollbar drag, a wheel during the first replay), and it pages only while `_terminalViewportAtBottom(tile.terminal)` holds: otherwise every wheel, wheel-down included, was paged and swallowed, the stale rows stayed on screen and the click report (which refuses an off-bottom viewport) went dead too. Off the bottom the wheel stays xterm's, so a wheel-down brings the viewport home. The primary pane needs no such gate: hollow there means `baseY === 0`, which is always at the bottom. Tiles also FORWARD the wheel to Claude's fullscreen renderer as SGR reports (`TerminalTile._maybeForwardWheelToCli`: the same `_shouldForwardWheelToApp(ev, target)` gate, the cell from `_clientPointToCell(x, y, tile.terminal)`, the bytes from the pure `CodemanTerminalInput.sgrWheelReports` the primary pane's `_sendSyntheticSgrWheel` also uses, flushed through the tile's own coalescer to the tile's session, never the primary queue, which flushes to `activeSessionId`); before that a fullscreen Claude tile left the wheel to xterm, which scrolled only stale replayed frames while Claude's transcript never moved. ⚠️ Shift+wheel in a tile is scrolled by the tile itself (`_maybeScrollLocalOnShift`, `scrollLines()` on the dominant axis), as in the primary pane: xterm's own scroller turns a Shift+vertical wheel into a horizontal one off macOS and Chrome on Windows sends Shift+wheel as `deltaX`, so left to xterm the gesture was dead. Tiles have no touch path. Tests: `test/terminal-scroll-routing.test.ts`, `test/terminal-tile-scroll.test.ts`, `test/claude-cli-version-cache.test.ts`. From 76c17e5c4ece3253e984f1d25c2a4a8968ee7ed8 Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Thu, 22 Oct 2026 19:12:25 +0200 Subject: [PATCH 71/96] fix(terminal): log xterm's mouse encoding with each scroll routing decision Merge-time fix for #664 (optional item): _logScrollRouting now prints mouseEncoding (xterm's coreMouseService.activeEncoding, 'unknown' when the private cannot be read) and counts it in the per-session signature, since an app switching encodings changes where a touch drag goes. Adds a unit test for that line and one asserting SGR_PIXELS fails closed. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- src/web/public/terminal-ui.js | 11 ++++++++--- test/terminal-scroll-routing.test.ts | 21 +++++++++++++++++++++ 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index cd6ef453..afc03555 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -6082,19 +6082,24 @@ Object.assign(CodemanApp.prototype, { const session = this.sessions?.get(sessionId); const optOut = !!this.loadAppSettingsFromStorage?.()?.terminalWheelLocalScrollback; const tracking = this.terminal?.modes?.mouseTrackingMode || 'none'; + // The encoding xterm would report in (DEFAULT, SGR, SGR_PIXELS): touch + // forwards to xterm's own tracking only when it is SGR + // (_shouldForwardTouchToApp). + const encoding = this.terminal?._core?.coreMouseService?.activeEncoding || 'unknown'; // xterm's own mode above stays 'none' for a strip mode (the server removes // the DECSETs), so the CLI's real tracking state is reported separately. const cliTracking = session?.cliMouseTracking === true; const baseY = this.terminal?.buffer?.active?.baseY ?? -1; const signature = - `${decision}|${session?.mode}|${session?.cliVersion}|${optOut}|${tracking}|${cliTracking}|${baseY > 0}`; + `${decision}|${session?.mode}|${session?.cliVersion}|${optOut}|` + + `${tracking}|${encoding}|${cliTracking}|${baseY > 0}`; if (!this._scrollRoutingLogged) this._scrollRoutingLogged = new Map(); if (this._scrollRoutingLogged.get(sessionId) === signature) return; this._scrollRoutingLogged.set(sessionId, signature); console.log( `[scroll] ${sessionId} → ${decision} (mode=${session?.mode || '?'}, cliVersion=${session?.cliVersion || 'unknown'}, ` + - `localScrollbackOptOut=${optOut}, mouseTracking=${tracking}, cliMouseTracking=${cliTracking}, ` + - `localScrollbackRows=${baseY})` + `localScrollbackOptOut=${optOut}, mouseTracking=${tracking}, mouseEncoding=${encoding}, ` + + `cliMouseTracking=${cliTracking}, localScrollbackRows=${baseY})` ); }, diff --git a/test/terminal-scroll-routing.test.ts b/test/terminal-scroll-routing.test.ts index 6293a21c..7fd26e64 100644 --- a/test/terminal-scroll-routing.test.ts +++ b/test/terminal-scroll-routing.test.ts @@ -876,6 +876,20 @@ describe('scroll routing diagnostic (issue #205 round 2)', () => { app._logScrollRouting('page-keys'); expect(logs[0]).toContain('cliVersion=unknown'); }); + + it("reports xterm's mouse encoding, the input touch forwarding to native tracking reads", () => { + const { app, logs } = hollowApp({ mode: 'shell' }); + app._logScrollRouting('local-scrollback'); // no readable xterm private + expect(logs[0]).toContain('mouseEncoding=unknown'); + + // An app switching to SGR changes what touch does, so it prints again. + app.terminal.modes.mouseTrackingMode = 'any'; + app.terminal._core = { coreMouseService: { activeEncoding: 'SGR' } }; + app._logScrollRouting('local-scrollback'); + expect(logs).toHaveLength(2); + expect(logs[1]).toContain('mouseTracking=any'); + expect(logs[1]).toContain('mouseEncoding=SGR'); + }); }); describe('touch scrolling when xterm owns native mouse tracking', () => { @@ -898,6 +912,13 @@ describe('touch scrolling when xterm owns native mouse tracking', () => { expect(app._shouldForwardTouchToApp()).toBe(false); }); + it('does not send cell-based SGR reports to an app that asked for SGR pixel coordinates', () => { + const { app } = hollowApp({ mode: 'shell' }); + app.terminal.modes.mouseTrackingMode = 'any'; + app.terminal._core = { coreMouseService: { activeEncoding: 'SGR_PIXELS' } }; + expect(app._shouldForwardTouchToApp()).toBe(false); + }); + it('keeps the explicit local-history preference for touch', () => { const { app } = hollowApp({ mode: 'shell' }); app.terminal.modes.mouseTrackingMode = 'any'; From a02a5879cada34f2117775f95c9e65954644fa32 Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Thu, 22 Oct 2026 19:12:25 +0200 Subject: [PATCH 72/96] test(terminal): give the native SGR touch browser test its own describe Merge-time fix for #664 (optional item): the drag-and-momentum test for xterm's own SGR mouse tracking sits in its own describe, nested so it keeps the file's page and touch dispatcher, and the file overview mentions it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- test/terminal-touch-paging.browser.test.ts | 115 +++++++++++---------- 1 file changed, 63 insertions(+), 52 deletions(-) diff --git a/test/terminal-touch-paging.browser.test.ts b/test/terminal-touch-paging.browser.test.ts index 5fb41a7c..c7ba4ae8 100644 --- a/test/terminal-touch-paging.browser.test.ts +++ b/test/terminal-touch-paging.browser.test.ts @@ -11,6 +11,10 @@ * the contact's id (one per touchstart, carried by its momentum frames), and a * contact is one gesture. * + * Its own describe covers the other touch route in the same listeners (#663): + * an app that turned on xterm's own SGR mouse tracking gets a drag and its + * fling momentum as SGR wheel reports, not a local scroll. + * * What runs for real: the shipped app (page served by a WebServer on port 0), * its xterm, the touch listeners, the momentum loop on requestAnimationFrame, * the gates in terminal-ui.js and the 40 ms key coalescer. What is stubbed: @@ -144,58 +148,65 @@ describe('touch paging of a hollow Codex pane in a real browser', () => { expect(second.keys).toBe(UP + UP); await touch('touchend', x, y0 + Math.ceil(cell)); }, 60000); - it.each([390, 820])('forwards drag and momentum with native mouse tracking at %ipx', async (width) => { - await page.setViewportSize({ width, height: 900 }); - await page.evaluate(async (id) => { - const app = (window as any).app; - app.sessions.set(id, { id, name: id, mode: 'shell', status: 'idle' }); - app.activeSessionId = id; - app.loadAppSettingsFromStorage = () => ({ terminalWheelLocalScrollback: false }); - app._wheelSgrQueue = ''; - const sent: string[] = []; - app._sendInputEphemeral = (_id: string, data: string) => sent.push(data); - (window as any).__mouseTouchProbe = { sent }; - await new Promise<void>((resolve) => app.terminal.write('\x1b[?1049h\x1b[?1003h\x1b[?1006h', resolve)); - }, PROBE_ID); - const point = await page.evaluate(() => { - const app = (window as any).app; - const r = document.getElementById('terminalContainer')!.getBoundingClientRect(); - return { - x: r.left + 30, - y: r.top + 200, - mode: app.terminal.modes.mouseTrackingMode, - buffer: app.terminal.buffer.active.type, - }; + + // Not the page-key fallback: a shell app (htop, vim) that turned on xterm's + // own SGR mouse tracking gets touch drags and fling momentum as SGR wheel + // reports (_shouldForwardTouchToApp), at phone and tablet widths. Shares this + // file's page and touch dispatcher. + describe('touch forwarding when xterm owns native SGR mouse tracking', () => { + it.each([390, 820])('forwards drag and momentum with native mouse tracking at %ipx', async (width) => { + await page.setViewportSize({ width, height: 900 }); + await page.evaluate(async (id) => { + const app = (window as any).app; + app.sessions.set(id, { id, name: id, mode: 'shell', status: 'idle' }); + app.activeSessionId = id; + app.loadAppSettingsFromStorage = () => ({ terminalWheelLocalScrollback: false }); + app._wheelSgrQueue = ''; + const sent: string[] = []; + app._sendInputEphemeral = (_id: string, data: string) => sent.push(data); + (window as any).__mouseTouchProbe = { sent }; + await new Promise<void>((resolve) => app.terminal.write('\x1b[?1049h\x1b[?1003h\x1b[?1006h', resolve)); + }, PROBE_ID); + const point = await page.evaluate(() => { + const app = (window as any).app; + const r = document.getElementById('terminalContainer')!.getBoundingClientRect(); + return { + x: r.left + 30, + y: r.top + 200, + mode: app.terminal.modes.mouseTrackingMode, + buffer: app.terminal.buffer.active.type, + }; + }); + expect(point.mode).toBe('any'); + expect(point.buffer).toBe('alternate'); + await touch('touchstart', point.x, point.y); + await touch('touchmove', point.x, point.y - 60); + const drag = await page.evaluate(() => { + (window as any).app._flushWheelSgrQueue(); + return (window as any).__mouseTouchProbe.sent.join(''); + }); + expect(drag).toMatch(/\x1b\[<65;\d+;\d+M/); + await touch('touchend', point.x, point.y - 60); + await page.waitForTimeout(150); + const momentum = await page.evaluate(() => { + (window as any).app._flushWheelSgrQueue(); + return (window as any).__mouseTouchProbe.sent.join(''); + }); + expect(momentum.length).toBeGreaterThan(drag.length); + // Stop momentum before changing the requested mouse encoding. + await touch('touchstart', point.x, point.y); + await touch('touchcancel', point.x, point.y); + await page.evaluate(async () => { + const app = (window as any).app; + app._wheelSgrQueue = ''; + (window as any).__mouseTouchProbe.sent.length = 0; + await new Promise<void>((resolve) => app.terminal.write('\x1b[?1006l', resolve)); + }); + await touch('touchstart', point.x, point.y); + await touch('touchmove', point.x, point.y - 60); + await page.waitForTimeout(60); + expect(await page.evaluate(() => (window as any).__mouseTouchProbe.sent.join(''))).toBe(''); + await touch('touchcancel', point.x, point.y); }); - expect(point.mode).toBe('any'); - expect(point.buffer).toBe('alternate'); - await touch('touchstart', point.x, point.y); - await touch('touchmove', point.x, point.y - 60); - const drag = await page.evaluate(() => { - (window as any).app._flushWheelSgrQueue(); - return (window as any).__mouseTouchProbe.sent.join(''); - }); - expect(drag).toMatch(/\x1b\[<65;\d+;\d+M/); - await touch('touchend', point.x, point.y - 60); - await page.waitForTimeout(150); - const momentum = await page.evaluate(() => { - (window as any).app._flushWheelSgrQueue(); - return (window as any).__mouseTouchProbe.sent.join(''); - }); - expect(momentum.length).toBeGreaterThan(drag.length); - // Stop momentum before changing the requested mouse encoding. - await touch('touchstart', point.x, point.y); - await touch('touchcancel', point.x, point.y); - await page.evaluate(async () => { - const app = (window as any).app; - app._wheelSgrQueue = ''; - (window as any).__mouseTouchProbe.sent.length = 0; - await new Promise<void>((resolve) => app.terminal.write('\x1b[?1006l', resolve)); - }); - await touch('touchstart', point.x, point.y); - await touch('touchmove', point.x, point.y - 60); - await page.waitForTimeout(60); - expect(await page.evaluate(() => (window as any).__mouseTouchProbe.sent.join(''))).toBe(''); - await touch('touchcancel', point.x, point.y); }); }); From 0952a2ee4737384b3a0cfa5916cacbefd16c7ffb Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Thu, 22 Oct 2026 19:14:08 +0200 Subject: [PATCH 73/96] fix(config-backup): a restore that fails partway names what it restored and what failed Merge-time fix for #661. A restore that could not write one file (a full disk, a directory where the file should be) used to stop there and answer a bare 500, naming neither the files it had already put back nor the pre-restore copy, and the Settings form was not re-read, so the next Save wrote the old settings back over a restored settings.json. restoreConfigBackup now writes every verified file it can, then throws ConfigBackupPartialRestoreError with restored, failed (name and error), untouched and safetySnapshot. The route runs the same reload hooks and audit for the files written as a full restore does, and answers 500 with the error envelope plus those fields (and restartRequired) in data, as the tab-layout 409 carries its layout. Settings refills the form when settings.json is among the restored files, also on that path. Tests: a unit case where a later file is still restored, the clis.json-as-a-directory route case, and two vm cases for the Settings handler. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- docs/api-reference.md | 2 +- docs/wiki/Config-Backups.md | 6 +++ src/config-backup.ts | 45 +++++++++++++++--- src/web/public/settings-ui.js | 13 +++-- src/web/routes/config-backup-routes.ts | 60 +++++++++++++++++------- test/config-backup-restore-ui.test.ts | 48 ++++++++++++++++--- test/config-backup.test.ts | 25 ++++++++++ test/routes/config-backup-routes.test.ts | 30 ++++++++++++ 8 files changed, 196 insertions(+), 33 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index ae5e881c..82af1322 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1165,7 +1165,7 @@ Settings (synced, in `settings.json`): `configBackupEnabled` (absent = on), `con | `GET` | `/api/config-backups` | none | `settings` in effect, `dir`, `defaultDir`, `tracked[]` and `backups[]` (`id`, `createdAt`, `reason`, `appVersion`, `files[]`, `bytes`), newest first. | | `POST` | `/api/config-backups` | none | Snapshot now, even if nothing changed (`reason: manual`), then apply retention. `status` is `created` or `empty` (nothing tracked exists yet); `removed[]` lists the ids retention deleted. `503` when a custom folder does not answer. | | `GET` | `/api/config-backups/diff?from=&to=` | none | What differs between two states: `from`/`to` are a backup id or `current` (the running config; the default for `to`). `files[]` (`name`, `status` `added`/`removed`/`changed`/`unchanged`, `changes[]` of `path`, `kind`, `before`/`after`, `truncated`, `detail`) and `changed`. Credential values are never returned (`redacted: true`). `400` without `from`; `404` unknown id; `409` if a file fails its checksum. | -| `POST` | `/api/config-backups/:id/restore` | none | Put a snapshot's files back, atomically, after a `pre-restore` snapshot of the current ones. `404` unknown id; `409 CONFLICT` if a file fails its checksum (nothing changed). Returns `restored[]`, `untouched[]` (tracked files present now that the backup did not hold; left as they are), `safetySnapshot` (the `pre-restore` id) and `restartRequired[]` (files the running server only reads at start: `.env`, `push-keys.json`, `settings.json`). | +| `POST` | `/api/config-backups/:id/restore` | none | Put a snapshot's files back, atomically, after a `pre-restore` snapshot of the current ones. `404` unknown id; `409 CONFLICT` if a file fails its checksum (nothing changed). Returns `restored[]`, `untouched[]` (tracked files present now that the backup did not hold; left as they are), `safetySnapshot` (the `pre-restore` id) and `restartRequired[]` (files the running server only reads at start: `.env`, `push-keys.json`, `settings.json`). A file that cannot be written (a full disk, a directory in its place) does not stop the others: the answer is then `500 OPERATION_FAILED` with the same fields in `data` plus `failed[]` (`name`, `error`); the files listed in `restored[]` are the backup's and are reloaded like a full restore. | ## Webhook notifications diff --git a/docs/wiki/Config-Backups.md b/docs/wiki/Config-Backups.md index 5dabfd24..3918af94 100644 --- a/docs/wiki/Config-Backups.md +++ b/docs/wiki/Config-Backups.md @@ -137,6 +137,12 @@ From the list, press **Restore** on a backup and confirm. Codeman then: Only the tracked names are ever written, whatever a manifest lists. +If a file cannot be written (a full disk, a folder where the file should be), the restore still +writes the others and then reports an error that names the files it restored, the ones it could +not write, and the `pre-restore` backup that holds the files as they were before. The restored +files are in effect as after a full restore (Settings re-reads `settings.json` into the form), so +fix the cause and restore again, or restore the `pre-restore` backup to go back. + Restoring `settings.json` keeps the four backup settings (on or off, folder, how many, how many days) at their current values, so a restore from before a folder change does not point the list at the old folder and hide the `pre-restore` copy it just made. The exception is the two diff --git a/src/config-backup.ts b/src/config-backup.ts index 34b4ec86..adf58a5b 100644 --- a/src/config-backup.ts +++ b/src/config-backup.ts @@ -476,6 +476,26 @@ export function pruneConfigBackups(dir: string, opts: PruneOptions): string[] { export class ConfigBackupNotFoundError extends Error {} export class ConfigBackupCorruptError extends Error {} +/** + * A restore that wrote some files and could not write others (a full disk, a directory where a file + * should be). Every file passed verification first, so the ones written are the backup's: the caller + * still has to reload what they feed and tell the user which files are which. + */ +export class ConfigBackupPartialRestoreError extends Error { + constructor( + readonly restored: string[], + readonly failed: Array<{ name: string; error: string }>, + readonly untouched: string[], + readonly safetySnapshot: string | null + ) { + const done = restored.length ? `${restored.join(', ')} restored` : 'nothing was restored'; + const undo = safetySnapshot ? `; the files from before the restore are in backup ${safetySnapshot}` : ''; + super( + `Restore incomplete: could not write ${failed.map((f) => f.name).join(', ')} (${failed[0]?.error}); ${done}${undo}` + ); + } +} + /** * Restore refuses what this module never writes. Snapshots are 0700/0600 real files, and a restore * writes the file as 0600: a symlink, or a file with a group/world bit, was planted or loosened @@ -556,7 +576,8 @@ export interface RestoreResult { /** * Put a snapshot's files back into the data dir. Every file is checked against the manifest's digest * BEFORE anything is written, a `pre-restore` snapshot of the live files is taken first, and each - * file is replaced atomically (temp + rename) with mode 0600. + * file is replaced atomically (temp + rename) with mode 0600. A file that cannot be written does not + * stop the others; it throws {@link ConfigBackupPartialRestoreError} at the end, naming both lists. */ export function restoreConfigBackup(opts: { dataDir: string; @@ -603,6 +624,7 @@ export function restoreConfigBackup(opts: { mkdirSync(opts.dataDir, { recursive: true }); const restored: string[] = []; + const failed: Array<{ name: string; error: string }> = []; for (const f of loaded) { const target = join(opts.dataDir, f.name); const tmp = `${target}.restore-${process.pid}`; @@ -611,17 +633,28 @@ export function restoreConfigBackup(opts: { mode: 0o600, }); renameSync(tmp, target); + restored.push(f.name); } catch (err) { - rmSync(tmp, { force: true }); - throw err; + try { + rmSync(tmp, { force: true }); + } catch { + /* the temp is a leftover at worst; the failure below is what matters */ + } + // Keep going: each file is replaced on its own, and the files written before this one are the + // backup's already. Stopping here used to answer a bare 500 that named neither list. + failed.push({ name: f.name, error: err instanceof Error ? err.message : String(err) }); } - restored.push(f.name); } // A restore never deletes: tracked files the snapshot did not hold stay as they are. const untouched = CONFIG_BACKUP_FILES.filter( - (n) => !restored.includes(n) && lstatSync(join(opts.dataDir, n), { throwIfNoEntry: false })?.isFile() + (n) => + !restored.includes(n) && + !failed.some((x) => x.name === n) && + lstatSync(join(opts.dataDir, n), { throwIfNoEntry: false })?.isFile() ); - return { restored, untouched, safetySnapshot: safety.status === 'created' ? safety.id : null }; + const safetySnapshot = safety.status === 'created' ? safety.id : null; + if (failed.length) throw new ConfigBackupPartialRestoreError(restored, failed, untouched, safetySnapshot); + return { restored, untouched, safetySnapshot }; } /** The live files in the data dir, as a side of a diff. */ diff --git a/src/web/public/settings-ui.js b/src/web/public/settings-ui.js index c40dcd75..2962adfa 100644 --- a/src/web/public/settings-ui.js +++ b/src/web/public/settings-ui.js @@ -1516,18 +1516,25 @@ Object.assign(CodemanApp.prototype, { const res = await this._apiPost(`/api/config-backups/${encodeURIComponent(id)}/restore`, {}); let body = null; try { body = res ? await res.json() : null; } catch { /* fall through */ } - if (!res || !res.ok || !body || body.success === false) { + const ok = res && res.ok && body && body.success !== false; + const d = body?.data; + // A restore that failed partway still answers with what it wrote (`restored`, `failed`, `safetySnapshot`): + // those files are the backup's now and need the same refill as a full restore. + if (!ok && !Array.isArray(d?.restored)) { this.showToast(body?.error || 'Restore failed', 'error'); return; } - const d = body.data; const restart = d.restartRequired?.length ? ` Restart Codeman to apply: ${d.restartRequired.join(', ')}.` : ''; // The form still holds the pre-restore values, and Save would PUT them over the restored file. if (d.restored.includes('settings.json')) { await this.loadAppSettingsFromServer(); this.openAppSettings(); } - this.showToast(`Restored ${d.restored.length} file${d.restored.length === 1 ? '' : 's'}.${restart}`, 'success'); + if (ok) { + this.showToast(`Restored ${d.restored.length} file${d.restored.length === 1 ? '' : 's'}.${restart}`, 'success'); + } else { + this.showToast(`${body.error || 'Restore failed partway'}.${restart}`, 'error'); + } this.loadConfigBackups(); }, diff --git a/src/web/routes/config-backup-routes.ts b/src/web/routes/config-backup-routes.ts index e4ecea1f..8ecff444 100644 --- a/src/web/routes/config-backup-routes.ts +++ b/src/web/routes/config-backup-routes.ts @@ -11,7 +11,9 @@ * are admin only. Responses carry file NAMES and sizes, never file content, except the diff, which shows * changed values but never a credential's (`.env`, `users.json`, `push-keys.json`, `webhook.json`, `intents.json` and any key that looks * like a token/secret/password are reported as "changed" only). Restore replaces files the running server - * may have cached: `restartRequired` says which ones only apply after a restart. + * may have cached: `restartRequired` says which ones only apply after a restart. A restore that could not + * write every file answers 500 with the error envelope plus `data` (`restored`, `failed`, `untouched`, + * `safetySnapshot`, `restartRequired`): the files it wrote stay restored and are reloaded like a full one. */ import { readFile } from 'node:fs/promises'; @@ -31,6 +33,7 @@ import { CONFIG_DIFF_CURRENT, ConfigBackupCorruptError, ConfigBackupNotFoundError, + ConfigBackupPartialRestoreError, createConfigBackup, defaultConfigBackupDir, diffConfigBackups, @@ -234,6 +237,28 @@ export function registerConfigBackupRoutes(app: FastifyInstance): void { } }); + /** + * What a restore that wrote files must do, whether or not every file made it: reload what the running + * server caches, audit it, and name the files that only apply after a restart. + */ + const afterRestore = (req: FastifyRequest, id: string, restored: string[], failed: string[] = []): string[] => { + // IntentStore caches the file and rewrites it whole on the next prompt, which would undo the restore. + if (restored.includes('intents.json')) intentStore.reload(); + // restoreConfigBackup writes clis.json directly (the one writer besides mutateRegistryFile). + if (restored.includes('clis.json')) reloadCliRegistry(); + // A restore of users.json replaces accounts, password hashes, roles and bypass grants. + if (isMultiUserMode()) { + void appendAdminAudit({ + admin: getAuthUser(req).username, + action: 'config_backup_restore', + target: id, + ip: req.ip, + detail: { restored, ...(failed.length > 0 && { failed }) }, + }); + } + return restored.filter((n) => RESTART_REQUIRED.has(n)); + }; + app.post('/api/config-backups/:id/restore', async (req, reply) => { const denied = deny(req, reply); if (denied) return denied; @@ -241,27 +266,13 @@ export function registerConfigBackupRoutes(app: FastifyInstance): void { try { const { dir } = await readEffectiveBackupSettings(); const out = restoreConfigBackup({ dataDir: getDataDir(), backupDir: dir, id, appVersion: APP_VERSION }); - // IntentStore caches the file and rewrites it whole on the next prompt, which would undo the restore. - if (out.restored.includes('intents.json')) intentStore.reload(); - // restoreConfigBackup writes clis.json directly (the one writer besides mutateRegistryFile). - if (out.restored.includes('clis.json')) reloadCliRegistry(); - // A restore of users.json replaces accounts, password hashes, roles and bypass grants. - if (isMultiUserMode()) { - void appendAdminAudit({ - admin: getAuthUser(req).username, - action: 'config_backup_restore', - target: id, - ip: req.ip, - detail: { restored: out.restored }, - }); - } return { success: true, data: { restored: out.restored, untouched: out.untouched, safetySnapshot: out.safetySnapshot, - restartRequired: out.restored.filter((n) => RESTART_REQUIRED.has(n)), + restartRequired: afterRestore(req, id, out.restored), }, }; } catch (err) { @@ -273,6 +284,23 @@ export function registerConfigBackupRoutes(app: FastifyInstance): void { reply.code(409); return createErrorResponse(ApiErrorCode.CONFLICT, `${err.message}. Nothing was changed.`); } + if (err instanceof ConfigBackupPartialRestoreError) { + // Some files are the backup's now: the client must learn which (Settings re-reads a restored + // settings.json, or its next Save writes the old values back) and where the undo copy is. + const failedNames = err.failed.map((f) => f.name); + const restartRequired = afterRestore(req, id, err.restored, failedNames); + reply.code(500); + return { + ...createErrorResponse(ApiErrorCode.OPERATION_FAILED, err.message), + data: { + restored: err.restored, + failed: err.failed, + untouched: err.untouched, + safetySnapshot: err.safetySnapshot, + restartRequired, + }, + }; + } reply.code(err instanceof BackupDirUnavailableError ? 503 : 500); return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Restore failed: ${getErrorMessage(err)}`); } diff --git a/test/config-backup-restore-ui.test.ts b/test/config-backup-restore-ui.test.ts index 840ee567..bb086859 100644 --- a/test/config-backup-restore-ui.test.ts +++ b/test/config-backup-restore-ui.test.ts @@ -9,9 +9,14 @@ import vm from 'node:vm'; const SRC = readFileSync(new URL('../src/web/public/settings-ui.js', import.meta.url), 'utf-8'); -function makeApp(restored: string[], confirmed = true) { +function makeApp( + restored: string[], + confirmed = true, + reply: { ok: boolean; body: unknown } = { ok: true, body: { success: true, data: { restored, restartRequired: [] } } } +) { const calls: string[] = []; const confirms: string[] = []; + const toasts: Array<[string, string]> = []; function CodemanApp(this: unknown) {} const ctx = vm.createContext({ CodemanApp, @@ -29,16 +34,13 @@ function makeApp(restored: string[], confirmed = true) { files: [{ name: 'settings.json' }, { name: 'users.json' }, { name: '.env' }, { name: 'clis.json' }], }, ], - _apiPost: async () => ({ - ok: true, - json: async () => ({ success: true, data: { restored, restartRequired: [] } }), - }), + _apiPost: async () => ({ ok: reply.ok, json: async () => reply.body }), loadAppSettingsFromServer: async () => void calls.push('reload-settings'), openAppSettings: () => void calls.push('refill-form'), loadConfigBackups: () => void calls.push('reload-list'), - showToast: () => {}, + showToast: (m: string, type: string) => void toasts.push([m, type]), }); - return { app, calls, confirms }; + return { app, calls, confirms, toasts }; } describe('restoreConfigBackup (Settings UI)', () => { @@ -54,6 +56,38 @@ describe('restoreConfigBackup (Settings UI)', () => { expect(calls).toEqual(['reload-list']); }); + it('a restore that failed partway still refills the form when settings.json was among the files written', async () => { + const { app, calls, toasts } = makeApp([], true, { + ok: false, + body: { + success: false, + errorCode: 'OPERATION_FAILED', + error: 'Restore incomplete: could not write clis.json (EISDIR); settings.json restored', + data: { + restored: ['settings.json'], + failed: [{ name: 'clis.json', error: 'EISDIR' }], + safetySnapshot: 'cfg-2', + restartRequired: ['settings.json'], + }, + }, + }); + await (app.restoreConfigBackup as (id: string) => Promise<void>)('cfg-1'); + expect(calls).toEqual(['reload-settings', 'refill-form', 'reload-list']); + expect(toasts).toHaveLength(1); + expect(toasts[0][1]).toBe('error'); + expect(toasts[0][0]).toContain('clis.json'); + }); + + it('a refused restore (nothing written) only reports the error', async () => { + const { app, calls, toasts } = makeApp([], true, { + ok: false, + body: { success: false, errorCode: 'CONFLICT', error: 'settings.json does not match its checksum' }, + }); + await (app.restoreConfigBackup as (id: string) => Promise<void>)('cfg-1'); + expect(calls).toEqual([]); + expect(toasts).toEqual([['settings.json does not match its checksum', 'error']]); + }); + it('names the files being replaced and calls out the credential ones', async () => { const { app, confirms } = makeApp(['settings.json'], false); await (app.restoreConfigBackup as (id: string) => Promise<void>)('cfg-1'); diff --git a/test/config-backup.test.ts b/test/config-backup.test.ts index 2cbaa58e..fca4243f 100644 --- a/test/config-backup.test.ts +++ b/test/config-backup.test.ts @@ -22,6 +22,7 @@ import { CONFIG_BACKUP_FILES, ConfigBackupCorruptError, ConfigBackupNotFoundError, + ConfigBackupPartialRestoreError, createConfigBackup, defaultConfigBackupDir, diffConfigBackups, @@ -345,6 +346,30 @@ describe('restoreConfigBackup', () => { } }); + it('a file it cannot write does not stop the others, and the error names both lists', () => { + put('settings.json', '{"good":true}'); + put('clis.json', '{"clis":{}}'); + put('webhook.json', '{"enabled":false}'); + const made = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 }) as { id: string }; + put('settings.json', '{"polluted":true}'); + put('webhook.json', '{"enabled":true}'); + rmSync(join(data, 'clis.json')); + mkdirSync(join(data, 'clis.json')); // between the other two in the tracked list + let caught: unknown; + try { + restoreConfigBackup({ dataDir: data, backupDir: backups, id: made.id, now: T0 + 1000 }); + } catch (err) { + caught = err; + } + expect(caught).toBeInstanceOf(ConfigBackupPartialRestoreError); + const err = caught as ConfigBackupPartialRestoreError; + expect(err.restored).toEqual(['settings.json', 'webhook.json']); + expect(err.failed.map((f) => f.name)).toEqual(['clis.json']); + expect(err.safetySnapshot).toBeTruthy(); + expect(readFileSync(join(data, 'webhook.json'), 'utf8')).toBe('{"enabled":false}'); + expect(readdirSync(data).filter((n) => n.includes('.restore-'))).toEqual([]); // no temp left behind + }); + it('only ever writes allowlisted names, even if a manifest lists others', () => { put('settings.json', '{"a":1}'); const made = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 }) as { id: string }; diff --git a/test/routes/config-backup-routes.test.ts b/test/routes/config-backup-routes.test.ts index 40dce415..3eb25547 100644 --- a/test/routes/config-backup-routes.test.ts +++ b/test/routes/config-backup-routes.test.ts @@ -90,6 +90,36 @@ describe('/api/config-backups', () => { expect(JSON.parse(readFileSync(SETTINGS_PATH, 'utf8')).displayName).toBe('before'); }); + it('a restore that fails partway names what it restored, what failed and the pre-restore copy', async () => { + const { app } = await createRouteTestHarness(registerConfigBackupRoutes); + const clis = join(dirname(SETTINGS_PATH), 'clis.json'); + writeFileSync(clis, '{"schemaVersion":1,"clis":{}}', { mode: 0o600 }); + const id = (await app.inject({ method: 'POST', url: '/api/config-backups' })).json().data.id; + writeFileSync(SETTINGS_PATH, JSON.stringify({ displayName: 'polluted' })); + // A file the server cannot replace: settings.json (first in the list) goes back, clis.json cannot. + rmSync(clis); + mkdirSync(clis); + try { + const res = await app.inject({ method: 'POST', url: `/api/config-backups/${id}/restore` }); + expect(res.statusCode).toBe(500); + const body = res.json(); + expect(body).toMatchObject({ success: false, errorCode: 'OPERATION_FAILED' }); + expect(body.error).toContain('clis.json'); + expect(body.data.restored).toContain('settings.json'); + expect(body.data.failed.map((f: { name: string }) => f.name)).toEqual(['clis.json']); + expect(body.data.restartRequired).toContain('settings.json'); + expect(JSON.parse(readFileSync(SETTINGS_PATH, 'utf8')).displayName).toBe('before'); + // The undo copy exists, holds the state the restore replaced, and is the one named. + const safety = body.data.safetySnapshot; + expect(safety).toBeTruthy(); + expect(body.error).toContain(safety); + const listed = (await app.inject({ method: 'GET', url: '/api/config-backups' })).json().data.backups; + expect(listed.find((b: { id: string }) => b.id === safety)?.reason).toBe('pre-restore'); + } finally { + rmSync(clis, { recursive: true, force: true }); + } + }); + it('a restored intents.json is not reverted by the next captured prompt', async () => { const { app } = await createRouteTestHarness(registerConfigBackupRoutes); const dir = mkdtempSync(join(tmpdir(), 'cfg-backup-intent-')); From 922b077cc93fb9e3f40698c5356941794425bc97 Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Thu, 22 Oct 2026 19:14:27 +0200 Subject: [PATCH 74/96] test(config-backup): pin Back up now against a custom folder that does not answer Merge-time fix for #661. The unreachable-folder table covered GET, diff and restore; POST /api/config-backups (Back up now) now has its row too. runConfigBackup already probes before it touches the folder, and the row fails when the temp sweep is moved ahead of the probe. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- test/routes/config-backup-unreachable.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/test/routes/config-backup-unreachable.test.ts b/test/routes/config-backup-unreachable.test.ts index d1d99700..97afb391 100644 --- a/test/routes/config-backup-unreachable.test.ts +++ b/test/routes/config-backup-unreachable.test.ts @@ -1,6 +1,7 @@ /** * @fileoverview A custom backup folder that stops answering (a network mount) must never be listed or - * read before the bounded probe says it is there: GET, diff and restore answer 503 without touching it. + * read before the bounded probe says it is there: GET, Back up now, diff and restore answer 503 without + * touching it. * With only `configBackupDir` in settings.json the recorded-retention fallback would otherwise * `readdirSync` the folder first (found in review of #661). * @@ -65,6 +66,7 @@ afterEach(() => { describe('a custom backup folder that does not answer', () => { it.each([ ['GET', '/api/config-backups'], + ['POST', '/api/config-backups'], ['GET', '/api/config-backups/diff?from=cfg-20261001-120000'], ['POST', '/api/config-backups/cfg-20261001-120000/restore'], ] as const)('%s %s answers 503 without reading the folder', async (method, url) => { From df521267ae1c288c8d7ebcf94c7a8e07e4be9789 Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Thu, 22 Oct 2026 19:17:05 +0200 Subject: [PATCH 75/96] fix(file-viewer): keep the Expand label fixed while it is pressed Merge-time fix for #659. The Expand button swapped its title and aria-label to "Restore size" while on, so a screen reader announced "Restore size, pressed". The label now stays "Expand" and aria-pressed alone carries the state, the way the MD, Lines and Wrap toggles work. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- src/web/public/panels-ui.js | 10 +++------- test/file-preview-window.test.ts | 8 +++++++- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/src/web/public/panels-ui.js b/src/web/public/panels-ui.js index ed026845..dedaea57 100644 --- a/src/web/public/panels-ui.js +++ b/src/web/public/panels-ui.js @@ -4622,13 +4622,9 @@ Object.assign(CodemanApp.prototype, { const on = this._filePreviewPref('expanded'); const win = this.$('filePreviewOverlay')?.querySelector?.('.file-preview-window'); win?.classList?.toggle('expanded', on); - const btn = this.$('filePreviewExpandBtn'); - if (btn) { - btn.setAttribute('aria-pressed', on ? 'true' : 'false'); - const label = on ? 'Restore size' : 'Expand'; - btn.title = label; - btn.setAttribute('aria-label', label); - } + // The label stays "Expand" and aria-pressed carries the state, like MD, Lines and + // Wrap, so a screen reader never announces "Restore size, pressed". + this.$('filePreviewExpandBtn')?.setAttribute('aria-pressed', on ? 'true' : 'false'); }, /** diff --git a/test/file-preview-window.test.ts b/test/file-preview-window.test.ts index c29d25c4..bd9347dd 100644 --- a/test/file-preview-window.test.ts +++ b/test/file-preview-window.test.ts @@ -196,16 +196,22 @@ describe('file viewer back/forward', () => { describe('file viewer expand', () => { it('toggles the window class, the pref and the button state', () => { const { app, winClasses, store, expand } = loadApp(); + // As index.html ships it. + expand.title = 'Expand'; + expand.attrs['aria-label'] = 'Expand'; app.toggleFilePreviewExpanded(); expect(winClasses.has('expanded')).toBe(true); expect(store['codeman:filePreviewExpanded']).toBe('1'); expect(expand.attrs['aria-pressed']).toBe('true'); - expect(expand.title).toBe('Restore size'); + // aria-pressed carries the state; the label stays fixed, like the MD/Lines/Wrap toggles. + expect(expand.title).toBe('Expand'); + expect(expand.attrs['aria-label']).toBe('Expand'); app.toggleFilePreviewExpanded(); expect(winClasses.has('expanded')).toBe(false); expect(expand.attrs['aria-pressed']).toBe('false'); + expect(expand.title).toBe('Expand'); }); it('re-applies a remembered expanded state when a preview opens', async () => { From 73ff2339d80247b1644932fb8abeb52f8e40e5ad Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Thu, 22 Oct 2026 19:17:05 +0200 Subject: [PATCH 76/96] docs(file-viewer): list Alt+Left/Right in the shortcut overlay and help Merge-time fix for #659. - Alt+Left/Right (File Viewer back/forward) is a display-only entry in DEFAULT_SHORTCUTS, so the Ctrl+? overlay lists it, and a row in the help modal's Panels group, pinned in help-modal-shortcuts.test.ts. zh-CN entry for the new label; the "Restore size" entry is dropped, since nothing shows that string any more. - The wiki row moves from "Sessions and tabs" to "Everything else". - The grip comment in styles.css says only phones hide the grip; tablets show and use it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- docs/wiki/Keyboard-Shortcuts.md | 12 ++++++------ src/web/public/app.js | 8 ++++++++ src/web/public/i18n.js | 2 +- src/web/public/index.html | 1 + src/web/public/styles.css | 3 ++- test/help-modal-shortcuts.test.ts | 1 + 6 files changed, 19 insertions(+), 8 deletions(-) diff --git a/docs/wiki/Keyboard-Shortcuts.md b/docs/wiki/Keyboard-Shortcuts.md index 66212940..d4d54c46 100644 --- a/docs/wiki/Keyboard-Shortcuts.md +++ b/docs/wiki/Keyboard-Shortcuts.md @@ -10,7 +10,6 @@ Press `Ctrl+?` in the app for the same list in a floating overlay. | ------------------------------- | --------------------------------------------------------------- | | `Ctrl+K` (also `Cmd+K`, `Alt+K`)| Find an open session or start a new one. | | `Ctrl+Tab` | Next session. | -| `Alt+←` / `Alt+→` | File Viewer window: back / forward between previewed files. | | `Alt+[` / `Alt+]` | Previous / next tab. | | `Alt+1` to `Alt+9` | Switch to tab N. Physical keys, so macOS Option layouts work. | | `Ctrl+Shift+{` / `Ctrl+Shift+}` | Move the active tab left / right. | @@ -58,11 +57,12 @@ default. See [Tile Grid](Tile-Grid). ## Everything else -| Shortcut | Action | -| -------------- | ------------------------------- | -| `Ctrl+Shift+V` | Toggle voice input. | -| `Ctrl+?` | Shortcut reference overlay. | -| `Escape` | Close panels and modals. | +| Shortcut | Action | +| ----------------- | ------------------------------------------------------------ | +| `Ctrl+Shift+V` | Toggle voice input. | +| `Ctrl+?` | Shortcut reference overlay. | +| `Escape` | Close panels and modals. | +| `Alt+←` / `Alt+→` | File Viewer window: back / forward between previewed files. | ## Rebinding diff --git a/src/web/public/app.js b/src/web/public/app.js index 62716497..29077b53 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -685,6 +685,14 @@ const DEFAULT_SHORTCUTS = [ label: 'Close Panels', displayBindings: ['Escape'], }, + // Handled by the File Viewer's own capture-phase listener while it is open + // (_bindFilePreviewChrome, panels-ui.js), never through the registry. + { + id: 'file-preview-history', + group: 'Panels', + label: 'File Viewer Back / Forward', + displayBindings: ['Alt/Option+ArrowLeft', 'Alt/Option+ArrowRight'], + }, ]; diff --git a/src/web/public/i18n.js b/src/web/public/i18n.js index 1dc592fa..d7951087 100644 --- a/src/web/public/i18n.js +++ b/src/web/public/i18n.js @@ -1044,7 +1044,6 @@ 'Back to the previous file': '返回上一个文件', 'Forward to the next file': '前进到下一个文件', Expand: '展开', - 'Restore size': '恢复大小', 'Drag to resize': '拖动以调整大小', 'Rendered markdown': '渲染 Markdown', 'Line numbers': '行号', @@ -1242,6 +1241,7 @@ 'Voice input (Ctrl+Shift+V)': '语音输入(Ctrl+Shift+V)', 'Insert Newline': '插入换行', 'Close Panels': '关闭面板', + 'File Viewer Back / Forward': '文件查看器后退 / 前进', 'Previous / Next Session': '上一个 / 下一个会话', 'Next Session': '下一个会话', 'Previous Session': '上一个会话', diff --git a/src/web/public/index.html b/src/web/public/index.html index 46ac8247..ceda80f3 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -926,6 +926,7 @@ <div><kbd>Ctrl</kbd>+<kbd>?</kbd></div><div>Show Shortcuts</div> <div><kbd>Alt/Option</kbd>+<kbd>?</kbd></div><div>Show Shortcuts</div> <div><kbd>Escape</kbd></div><div>Close Panels</div> + <div><kbd>Alt/Option</kbd>+<kbd>ArrowLeft</kbd> / <kbd>Alt/Option</kbd>+<kbd>ArrowRight</kbd></div><div>File Viewer Back / Forward</div> </div> </section> </div> diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 4f3b926e..aca188de 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -12251,7 +12251,8 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat /* Corner grip (panels-ui.js _bindFilePreviewResize). The window is centred, so a drag moves both edges: the handler doubles the pointer delta to keep the corner - under the pointer. Touch devices use the Expand button instead. */ + under the pointer. Only phones hide it (with Expand, mobile.css under 600px), since + the window is full screen there; tablets show and use it. */ .file-preview-resize { position: absolute; right: 0; diff --git a/test/help-modal-shortcuts.test.ts b/test/help-modal-shortcuts.test.ts index 20e54c94..25e9c0fd 100644 --- a/test/help-modal-shortcuts.test.ts +++ b/test/help-modal-shortcuts.test.ts @@ -51,6 +51,7 @@ describe('help modal shortcuts', () => { expect(helpModal).not.toMatch(/Ctrl<\/kbd>\s*\+\s*<kbd>\/<\/kbd>.*?Show Shortcuts/i); expectShortcut(helpModal, ['Ctrl', 'Shift', 'V'], 'Voice Input'); expectShortcut(helpModal, ['Escape'], 'Close Panels'); + expectShortcut(helpModal, ['Alt/Option', 'ArrowLeft'], 'File Viewer Back / Forward'); }); it('documents the tile grid chords', () => { From 8a216df12f72c4fa239ecb70bf48592e26d71972 Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Thu, 22 Oct 2026 19:17:28 +0200 Subject: [PATCH 77/96] fix(config-backup): read the backup folder once per pass Merge-time fix for #661. A pass listed the folder and parsed every manifest three times: for the recorded-retention fallback, for the change check and for the prune (and restore listed it twice more). On a network folder that is every manifest read three times every five minutes. runConfigBackupPass (config-backup.ts) now reads the folder once, shares the names with the temp sweep and the manifests with withRecordedRetention, the snapshot and the prune, and prepends the snapshot it just made (always the newest, by the date floor) to the listing retention counts. runConfigBackup in the route keeps the settings read and the bounded probe and calls it. restoreConfigBackup lists once for the recorded retention and the pre-restore snapshot. The exported createConfigBackup, latestBackupRetention, withRecordedRetention and pruneConfigBackups keep their signatures (latestBackupRetention and withRecordedRetention take an optional pre-read listing). A recording node:fs test pins one readdir and one read per manifest per pass, and that the new snapshot counts toward retention. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- src/config-backup.ts | 206 +++++++++++++++------ src/web/routes/config-backup-routes.ts | 22 +-- test/routes/config-backup-one-walk.test.ts | 94 ++++++++++ 3 files changed, 248 insertions(+), 74 deletions(-) create mode 100644 test/routes/config-backup-one-walk.test.ts diff --git a/src/config-backup.ts b/src/config-backup.ts index adf58a5b..563f1c45 100644 --- a/src/config-backup.ts +++ b/src/config-backup.ts @@ -274,32 +274,46 @@ function readManifest(dir: string, id: string, origin?: string): ConfigBackupMan } } +/** A folder's entry names; none when it cannot be read (absent, or not a folder). */ +function folderEntries(dir: string): string[] { + try { + return readdirSync(dir); + } catch { + return []; + } +} + +/** + * The manifests of the snapshots this module made among `names` in `dir`, newest first. Foreign + * folders and files are ignored, and so are other instances' snapshots when `origin` is given (see + * {@link configBackupOrigin}). Each one is a file read, so a pass does this once and passes the + * result on (see {@link runConfigBackupPass}). + */ +function manifestsIn(dir: string, names: readonly string[], origin?: string): ConfigBackupManifest[] { + const out: ConfigBackupManifest[] = []; + for (const id of names) { + const m = readManifest(dir, id, origin); + if (m) out.push(m); + } + return out.sort((a, b) => b.createdAt - a.createdAt || (a.id < b.id ? 1 : -1)); +} + /** * Snapshots this module made in `dir`, newest first. Foreign folders and files are ignored, and so * are other instances' snapshots when `origin` is given (see {@link configBackupOrigin}). */ export function listConfigBackups(dir: string, origin?: string): ConfigBackupSummary[] { - let names: string[]; - try { - names = readdirSync(dir); - } catch { - return []; - } - const out: ConfigBackupSummary[] = []; - for (const id of names) { - const m = readManifest(dir, id, origin); - if (!m) continue; + return manifestsIn(dir, folderEntries(dir), origin).map((m) => { const files = m.files.map((f) => ({ name: f.name, bytes: f.bytes })); - out.push({ - id, + return { + id: m.id, createdAt: m.createdAt, reason: m.reason, appVersion: m.appVersion, files, bytes: files.reduce((n, f) => n + f.bytes, 0), - }); - } - return out.sort((a, b) => b.createdAt - a.createdAt || (a.id < b.id ? 1 : -1)); + }; + }); } export type ConfigBackupResult = @@ -322,6 +336,17 @@ export interface CreateConfigBackupOptions { /** Snapshot the tracked files that exist, unless the latest snapshot already holds exactly this state. */ export function createConfigBackup(opts: CreateConfigBackupOptions): ConfigBackupResult { + return snapshotConfig(opts).result; +} + +/** + * {@link createConfigBackup}, also returning the manifest it wrote, over this instance's snapshots + * when the caller has already listed them (`listed`, newest first): one walk of the folder per pass. + */ +function snapshotConfig( + opts: CreateConfigBackupOptions, + listed?: { origin: string; existing: ConfigBackupManifest[] } +): { result: ConfigBackupResult; manifest: ConfigBackupManifest | null } { const now = opts.now ?? Date.now(); const present: Array<{ name: string; data: Buffer; sha: string; change: string }> = []; for (const name of CONFIG_BACKUP_FILES) { @@ -335,14 +360,13 @@ export function createConfigBackup(opts: CreateConfigBackupOptions): ConfigBacku /* absent or unreadable: simply not part of this snapshot */ } } - if (present.length === 0) return { status: 'empty' }; + if (present.length === 0) return { result: { status: 'empty' }, manifest: null }; const hash = sha256(present.map((f) => `${f.name}\0${f.change}`).join('\n')); - const origin = configBackupOrigin(opts.dataDir); - const existing = listConfigBackups(opts.backupDir, origin); - if (!opts.force && existing[0]) { - const latest = readManifest(opts.backupDir, existing[0].id, origin); - if (latest && latest.hash === hash) return { status: 'unchanged', id: latest.id }; + const origin = listed?.origin ?? configBackupOrigin(opts.dataDir); + const existing = listed?.existing ?? manifestsIn(opts.backupDir, folderEntries(opts.backupDir), origin); + if (!opts.force && existing[0] && existing[0].hash === hash) { + return { result: { status: 'unchanged', id: existing[0].id }, manifest: null }; } // Never dated earlier than this instance's newest snapshot. One written while the clock read ahead @@ -357,18 +381,18 @@ export function createConfigBackup(opts: CreateConfigBackupOptions): ConfigBacku const tmp = join(opts.backupDir, `.tmp-${id}-${process.pid}`); rmSync(tmp, { recursive: true, force: true }); mkdirSync(tmp, { mode: 0o700 }); + const manifest: ConfigBackupManifest = { + id, + createdAt, + reason: opts.reason, + appVersion: opts.appVersion ?? '', + hash, + origin, + ...(opts.keepCount !== undefined && { keepCount: opts.keepCount }), + ...(opts.keepDays !== undefined && { keepDays: opts.keepDays }), + files: present.map((f) => ({ name: f.name, bytes: f.data.length, sha256: f.sha })), + }; try { - const manifest: ConfigBackupManifest = { - id, - createdAt, - reason: opts.reason, - appVersion: opts.appVersion ?? '', - hash, - origin, - ...(opts.keepCount !== undefined && { keepCount: opts.keepCount }), - ...(opts.keepDays !== undefined && { keepDays: opts.keepDays }), - files: present.map((f) => ({ name: f.name, bytes: f.data.length, sha256: f.sha })), - }; for (const f of present) writeFileSync(join(tmp, f.name), f.data, { mode: 0o600 }); writeFileSync(join(tmp, MANIFEST), JSON.stringify(manifest, null, 2) + '\n', { mode: 0o600 }); renameSync(tmp, join(opts.backupDir, id)); @@ -381,17 +405,16 @@ export function createConfigBackup(opts: CreateConfigBackupOptions): ConfigBacku rmSync(tmp, { recursive: true, force: true }); throw err; } - return { status: 'created', id, files: present.length, bytes: present.reduce((n, f) => n + f.data.length, 0) }; + const bytes = present.reduce((n, f) => n + f.data.length, 0); + return { result: { status: 'created', id, files: present.length, bytes }, manifest }; } /** Remove `.tmp-cfg-*` build folders a crashed snapshot left behind (older than an hour, so a live one on a shared folder is safe). */ export function sweepConfigBackupTemps(dir: string, now: number = Date.now()): void { - let names: string[]; - try { - names = readdirSync(dir); - } catch { - return; - } + sweepTemps(dir, folderEntries(dir), now); +} + +function sweepTemps(dir: string, names: readonly string[], now: number): void { for (const n of names) { if (!/^\.tmp-cfg-\d{8}-\d{6}(?:-\d{1,3})?-\d+$/.test(n)) continue; const st = lstatSync(join(dir, n), { throwIfNoEntry: false }); @@ -399,12 +422,18 @@ export function sweepConfigBackupTemps(dir: string, now: number = Date.now()): v } } -/** The retention recorded in this instance's newest snapshot, or null when it recorded none. */ -export function latestBackupRetention(dir: string, origin: string): { keepCount: number; keepDays: number } | null { +/** + * The retention recorded in this instance's newest snapshot, or null when it recorded none. `listed` + * is this instance's manifests in `dir`, newest first, when the caller already read them. + */ +export function latestBackupRetention( + dir: string, + origin: string, + listed?: readonly ConfigBackupManifest[] +): { keepCount: number; keepDays: number } | null { // The newest manifest that recorded one: a snapshot from before this field existed must not hide it. - for (const b of listConfigBackups(dir, origin)) { - const m = readManifest(dir, b.id, origin); - if (m && typeof m.keepCount === 'number' && typeof m.keepDays === 'number') { + for (const m of listed ?? manifestsIn(dir, folderEntries(dir), origin)) { + if (typeof m.keepCount === 'number' && typeof m.keepDays === 'number') { return { keepCount: m.keepCount, keepDays: m.keepDays }; } } @@ -422,12 +451,13 @@ export function withRecordedRetention( settings: ConfigBackupSettings, raw: Record<string, unknown>, dir: string, - origin: string | null + origin: string | null, + listed?: readonly ConfigBackupManifest[] ): ConfigBackupSettings { const lacksCount = !('configBackupKeepCount' in raw); const lacksDays = !('configBackupKeepDays' in raw); if ((!lacksCount && !lacksDays) || !origin) return settings; - const kept = latestBackupRetention(dir, origin); + const kept = latestBackupRetention(dir, origin, listed); if (!kept) return settings; return { ...settings, @@ -449,8 +479,12 @@ export interface PruneOptions { * that. The newest snapshot always survives. Returns the ids removed. */ export function pruneConfigBackups(dir: string, opts: PruneOptions): string[] { + return pruneListed(dir, manifestsIn(dir, folderEntries(dir), opts.origin), opts); +} + +/** {@link pruneConfigBackups} over a listing already made (`all`, newest first). */ +function pruneListed(dir: string, all: ReadonlyArray<{ id: string; createdAt: number }>, opts: PruneOptions): string[] { const now = opts.now ?? Date.now(); - const all = listConfigBackups(dir, opts.origin); const cutoff = opts.keepDays > 0 ? now - opts.keepDays * 86_400_000 : null; const removed: string[] = []; all.forEach((b, index) => { @@ -460,7 +494,7 @@ export function pruneConfigBackups(dir: string, opts: PruneOptions): string[] { // createdAt deleted the last good copy on the first change after a quiet month. const tooOld = cutoff !== null && all[index - 1].createdAt < cutoff; if (!tooMany && !tooOld) return; - // listConfigBackups already proved this is one of ours (name pattern + manifest). + // The listing already proved this is one of ours (name pattern + manifest). const target = join(dir, b.id); if (!target.startsWith(resolve(dir) + sep)) return; try { @@ -473,6 +507,57 @@ export function pruneConfigBackups(dir: string, opts: PruneOptions): string[] { return removed; } +export interface ConfigBackupPassOptions { + dataDir: string; + backupDir: string; + reason: string; + appVersion?: string; + /** Snapshot even if nothing changed (Back up now). */ + force?: boolean; + /** As read from settings.json ({@link readConfigBackupSettings}), before the recorded-retention fallback. */ + settings: ConfigBackupSettings; + /** The parsed settings.json (`{}` when it did not parse): the fallback tells an absent key from a set one. */ + raw: Record<string, unknown>; + /** False when settings.json did not parse: the pass still snapshots, but deletes nothing on its say-so. */ + prune: boolean; + now?: number; +} + +/** + * One pass of the timer or of Back up now: sweep stale temp folders, snapshot when the state changed, + * apply retention. The folder is read and each manifest parsed ONCE, and that listing feeds the + * recorded-retention fallback, the change check and the prune. It used to be listed for each of the + * three, which on a network folder is every manifest read three times every five minutes. + */ +export function runConfigBackupPass(opts: ConfigBackupPassOptions): { + result: ConfigBackupResult; + removed: string[]; +} { + const names = folderEntries(opts.backupDir); + sweepTemps(opts.backupDir, names, Date.now()); + const origin = configBackupOrigin(opts.dataDir); + const existing = manifestsIn(opts.backupDir, names, origin); + // No retention in the file (reset, or unreadable): keep what the newest snapshot recorded. + const { keepCount, keepDays } = withRecordedRetention(opts.settings, opts.raw, opts.backupDir, origin, existing); + const { result, manifest } = snapshotConfig( + { + dataDir: opts.dataDir, + backupDir: opts.backupDir, + reason: opts.reason, + appVersion: opts.appVersion, + force: opts.force, + keepCount, + keepDays, + now: opts.now, + }, + { origin, existing } + ); + if (!opts.prune) return { result, removed: [] }; + // A snapshot made now is dated after existing[0], so it heads the listing retention counts. + const all = manifest ? [manifest, ...existing] : existing; + return { result, removed: pruneListed(opts.backupDir, all, { keepCount, keepDays, now: opts.now }) }; +} + export class ConfigBackupNotFoundError extends Error {} export class ConfigBackupCorruptError extends Error {} @@ -611,16 +696,21 @@ export function restoreConfigBackup(opts: { } if (loaded.length === 0) throw new ConfigBackupCorruptError(`${opts.id} holds no restorable files`); - const safety = createConfigBackup({ - dataDir: opts.dataDir, - backupDir: opts.backupDir, - reason: 'pre-restore', - appVersion: opts.appVersion, - force: true, - now: opts.now, - // The retention in force, so this newest snapshot does not hide it from the next pass. - ...(latestBackupRetention(opts.backupDir, origin) ?? {}), - }); + // One walk of the folder serves both the recorded retention and the snapshot below. + const existing = manifestsIn(opts.backupDir, folderEntries(opts.backupDir), origin); + const { result: safety } = snapshotConfig( + { + dataDir: opts.dataDir, + backupDir: opts.backupDir, + reason: 'pre-restore', + appVersion: opts.appVersion, + force: true, + now: opts.now, + // The retention in force, so this newest snapshot does not hide it from the next pass. + ...(latestBackupRetention(opts.backupDir, origin, existing) ?? {}), + }, + { origin, existing } + ); mkdirSync(opts.dataDir, { recursive: true }); const restored: string[] = []; diff --git a/src/web/routes/config-backup-routes.ts b/src/web/routes/config-backup-routes.ts index 8ecff444..5cf78eba 100644 --- a/src/web/routes/config-backup-routes.ts +++ b/src/web/routes/config-backup-routes.ts @@ -34,18 +34,15 @@ import { ConfigBackupCorruptError, ConfigBackupNotFoundError, ConfigBackupPartialRestoreError, - createConfigBackup, defaultConfigBackupDir, diffConfigBackups, listConfigBackups, - pruneConfigBackups, readConfigBackupSettings, resolveConfigBackupDir, restoreConfigBackup, - configBackupOrigin, + runConfigBackupPass, withRecordedRetention, readConfigBackupOrigin, - sweepConfigBackupTemps, type ConfigBackupResult, type ConfigBackupSettings, type ConfigBackupSummary, @@ -140,24 +137,17 @@ export async function runConfigBackup( if (!fromFile.enabled && !opts.force) return null; const dir = resolveConfigBackupDir(fromFile, getDataDir()); await probeBackupDir(fromFile, dir); - sweepConfigBackupTemps(dir); - const origin = configBackupOrigin(getDataDir()); - // No backup settings in the file (reset, or unreadable): keep the retention the newest snapshot recorded. - const settings = withRecordedRetention(fromFile, unreadable ? {} : raw, dir, origin); - const result = createConfigBackup({ + return runConfigBackupPass({ dataDir: getDataDir(), backupDir: dir, reason, appVersion: APP_VERSION, force: opts.force, - keepCount: settings.keepCount, - keepDays: settings.keepDays, + settings: fromFile, + raw: unreadable ? {} : raw, + // An unreadable settings.json still gets its snapshot, but no pass deletes anything on its say-so. + prune: !unreadable, }); - // An unreadable settings.json still gets its snapshot, but no pass deletes anything on its say-so. - const removed = unreadable - ? [] - : pruneConfigBackups(dir, { keepCount: settings.keepCount, keepDays: settings.keepDays, origin }); - return { result, removed }; } export function registerConfigBackupRoutes(app: FastifyInstance): void { diff --git a/test/routes/config-backup-one-walk.test.ts b/test/routes/config-backup-one-walk.test.ts new file mode 100644 index 00000000..2a690a41 --- /dev/null +++ b/test/routes/config-backup-one-walk.test.ts @@ -0,0 +1,94 @@ +/** + * @fileoverview A backup pass reads the backup folder once: one directory listing and one read per + * manifest, shared by the recorded-retention fallback, the change check and the prune. Each used to + * list the folder itself, which on a network folder meant every manifest read three times every five + * minutes (found in review of #661). + * + * ⚠️ test/setup.ts gives the whole FILE one temp HOME. Port: N/A (no server). + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; + +const rec = vi.hoisted(() => ({ dir: '', calls: [] as string[] })); + +vi.mock('node:fs', async (importOriginal) => { + const actual = await importOriginal<typeof import('node:fs')>(); + const record = <F extends (...a: never[]) => unknown>(name: string, real: F): F => + ((path: unknown, ...rest: unknown[]) => { + if (rec.dir && String(path).startsWith(rec.dir)) rec.calls.push(`${name} ${String(path)}`); + return (real as unknown as (...a: unknown[]) => unknown)(path, ...rest); + }) as unknown as F; + const wrapped = { + ...actual, + readdirSync: record('readdirSync', actual.readdirSync), + readFileSync: record('readFileSync', actual.readFileSync), + }; + return { ...wrapped, default: wrapped }; +}); + +import { runConfigBackup } from '../../src/web/routes/config-backup-routes.js'; +import { SETTINGS_PATH } from '../../src/web/route-helpers.js'; +import { getDataDir } from '../../src/config/instance.js'; +import { createConfigBackup, defaultConfigBackupDir, listConfigBackups } from '../../src/config-backup.js'; + +const dir = () => defaultConfigBackupDir(getDataDir()); +const DAY = 86_400_000; +const BASE = Date.UTC(2025, 0, 1); + +beforeEach(() => { + rec.dir = ''; + rec.calls.length = 0; + rmSync(dir(), { recursive: true, force: true }); + mkdirSync(dirname(SETTINGS_PATH), { recursive: true }); +}); +afterEach(() => { + rec.dir = ''; + rmSync(dir(), { recursive: true, force: true }); + rmSync(SETTINGS_PATH, { force: true }); +}); + +describe('one backup pass', () => { + it('lists the folder once and reads each manifest once', async () => { + // Only the OLDEST snapshot recorded a retention, so the fallback has to look through every one. + for (let i = 0; i < 6; i++) { + writeFileSync(SETTINGS_PATH, JSON.stringify({ displayName: `v${i}` })); + createConfigBackup({ + dataDir: getDataDir(), + backupDir: dir(), + reason: 'auto', + now: BASE + i * DAY, + ...(i === 0 && { keepCount: 100, keepDays: 0 }), + }); + } + // A change (so the pass writes a snapshot and then prunes) and no retention keys (the fallback reads). + writeFileSync(SETTINGS_PATH, JSON.stringify({ displayName: 'changed' })); + rec.dir = dir(); + const out = await runConfigBackup('auto'); + rec.dir = ''; + + expect(out?.result.status).toBe('created'); + expect(out?.removed).toEqual([]); // the recorded 100 / no age limit, not the defaults + expect(rec.calls.filter((c) => c.startsWith('readdirSync'))).toHaveLength(1); + const manifestReads = rec.calls.filter((c) => c.startsWith('readFileSync') && c.endsWith('manifest.json')); + expect(manifestReads).toHaveLength(6); + expect(new Set(manifestReads).size).toBe(6); + expect(listConfigBackups(dir())).toHaveLength(7); + }); + + it('counts the snapshot it just made when it prunes', async () => { + writeFileSync(SETTINGS_PATH, JSON.stringify({ configBackupKeepCount: 2, configBackupKeepDays: 0, v: 0 })); + for (let i = 0; i < 3; i++) { + writeFileSync(SETTINGS_PATH, JSON.stringify({ configBackupKeepCount: 2, configBackupKeepDays: 0, v: i })); + createConfigBackup({ dataDir: getDataDir(), backupDir: dir(), reason: 'auto', now: BASE + i * DAY }); + } + const before = listConfigBackups(dir()).map((b) => b.id); + writeFileSync(SETTINGS_PATH, JSON.stringify({ configBackupKeepCount: 2, configBackupKeepDays: 0, v: 9 })); + const out = await runConfigBackup('auto'); + expect(out?.result.status).toBe('created'); + const kept = listConfigBackups(dir()).map((b) => b.id); + // keep 2: the new one and the newest before it; the two older ones go + expect(kept).toEqual([(out?.result as { id: string }).id, before[0]]); + expect(out?.removed.sort()).toEqual([before[1], before[2]].sort()); + }); +}); From 6fb9c4ed8aa334d897761434cf8cd65d66baa069 Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Thu, 22 Oct 2026 19:18:51 +0200 Subject: [PATCH 78/96] fix(mobile): hand a sideways-opening drag back to the scroll past the swipe's drift limit Merge-time fix for #674: the first travel past the tap slop decided the axis for the whole contact, so a scroll that opened on a thumb arc (+9 px across, +5 px down, then straight down) locked as 'swipe'. Every later touchmove returned before the scroll code and SwipeHandler gave the swipe up at its 50 px drift limit, so the gesture did nothing. Past SwipeHandler.maxVerticalDrift the drag is now handed to 'y' (and the published axis with it), so it scrolls; a clean sideways swipe, a vertical scroll and a long-press drag are unchanged. The source pin in test/session-swipe-gesture.test.ts moves with the code and also pins the drift value, and a new browser test drives the real touch listeners: the thumb-arc drag ends as a scroll (it fails without the fallback, axis stays 'swipe') and a clean sideways swipe stays a swipe with nothing scrolled. The architecture note and src/web/CLAUDE.md now say a swipe drag is consumed only up to that limit. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- config/test-suites.ts | 1 + docs/architecture-invariants.md | 4 +- src/web/CLAUDE.md | 2 +- src/web/public/terminal-ui.js | 12 +- test/session-swipe-gesture.test.ts | 14 +- .../terminal-touch-swipe-axis.browser.test.ts | 149 ++++++++++++++++++ 6 files changed, 176 insertions(+), 6 deletions(-) create mode 100644 test/terminal-touch-swipe-axis.browser.test.ts diff --git a/config/test-suites.ts b/config/test-suites.ts index 2148a467..f2925ae7 100644 --- a/config/test-suites.ts +++ b/config/test-suites.ts @@ -51,6 +51,7 @@ export const BROWSER_TEST_GLOBS = [ 'test/run-mode-menu-scroll.browser.test.ts', 'test/markdown-anchor-links.browser.test.ts', 'test/fullscreen-tab-switch-capture.browser.test.ts', + 'test/terminal-touch-swipe-axis.browser.test.ts', ]; /** diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 957cc691..5663d493 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -928,9 +928,9 @@ Further detail: the design follows Apple's [Designing for iPhone Duo](https://de ⚠️ **Long-press selects** by driving xterm's public `select()` (renderer-independent — under WebGL the glyphs are pixels and native selection cannot exist), drag or a further tap extends, and Copy goes through `copyTerminalSelection()` for its execCommand fallback on plain-HTTP installs. Three guards are load-bearing and each came from a real phone: the compat mouse pair after `touchend` (xterm focuses on mousedown and SelectionService resets the model there, so the keyboard sprang up and the selection vanished on lift), the platform's own ~500ms long-press (Android Chrome focuses the nearest editable element — the helper textarea — through no event a handler can preventDefault, so a bounded focus guard blurs it and `contextmenu` is suppressed for the gesture window), and `copyTerminalSelection()`'s closing `terminal.focus()` (right on desktop, wrong on a phone). -⚠️ **A sideways drag belongs to the session swipe**, never to a tap or a scroll. The touch handler locks the dominant axis on the first travel past the tap slop (`panAxis`: `y` scrolls the buffer, `x` pans a terminal wider than its box, `swipe` when it cannot pan) and publishes it per gesture as `app._terminalGestureAxis`; a `swipe` drag is consumed without scrolling and never ends as a tap, which used to activate a link, a CLI click or a keyboard toggle on the session being left. `SwipeHandler` (mobile-handlers.js) reads that axis and stands aside for `x` and `y`, for a touch selection, after a second finger, and on surfaces with controls of their own (`IGNORE_SELECTOR`: the tile grid, the selection bar, the CJK input, the history banner); the phone overview and the welcome screen are deliberately not listed, since swiping from home into a session is a feature. A swipe is 80 px of travel within 500 ms (300 ms rejected an unhurried but deliberate swipe outright) with at most 50 px of vertical drift at any point: `touchmove` cancels it mid-gesture rather than only at the lift. Two more rules came from walking the sequences: a touch that starts outside the terminal (a swipe over the home screen) resets the published axis, so a terminal scroll a moment earlier cannot veto it, and a lift the terminal consumed (`defaultPrevented`: its long-press selection gesture, which on blank space leaves no selection behind to check) is never a swipe; the second-finger cancel stays set until the next touchstart, so the first finger lifting cannot clear it. +⚠️ **A sideways drag belongs to the session swipe**, never to a tap or a scroll. The touch handler locks the dominant axis on the first travel past the tap slop (`panAxis`: `y` scrolls the buffer, `x` pans a terminal wider than its box, `swipe` when it cannot pan) and publishes it per gesture as `app._terminalGestureAxis`; a `swipe` drag is consumed without scrolling and never ends as a tap, which used to activate a link, a CLI click or a keyboard toggle on the session being left. ⚠️ Only up to the swipe's own drift limit (`SwipeHandler.maxVerticalDrift`): past it the swipe has given up, so the drag is handed back to the scroll (`y`). Otherwise a scroll whose first few pixels lean sideways (a thumb arc) locked as `swipe` and did nothing at all. `SwipeHandler` (mobile-handlers.js) reads that axis and stands aside for `x` and `y`, for a touch selection, after a second finger, and on surfaces with controls of their own (`IGNORE_SELECTOR`: the tile grid, the selection bar, the CJK input, the history banner); the phone overview and the welcome screen are deliberately not listed, since swiping from home into a session is a feature. A swipe is 80 px of travel within 500 ms (300 ms rejected an unhurried but deliberate swipe outright) with at most 50 px of vertical drift at any point: `touchmove` cancels it mid-gesture rather than only at the lift. Two more rules came from walking the sequences: a touch that starts outside the terminal (a swipe over the home screen) resets the published axis, so a terminal scroll a moment earlier cannot veto it, and a lift the terminal consumed (`defaultPrevented`: its long-press selection gesture, which on blank space leaves no selection behind to check) is never a swipe; the second-finger cancel stays set until the next touchstart, so the first finger lifting cannot clear it. -Tests: `test/terminal-touch-tap.test.ts`, `test/session-swipe-gesture.test.ts` (the real `SwipeHandler` and the terminal's axis lock), `test/mobile/tabs.test.ts` (CDP swipes, mobile suite). +Tests: `test/terminal-touch-tap.test.ts`, `test/session-swipe-gesture.test.ts` (the real `SwipeHandler` and the terminal's axis lock), `test/terminal-touch-swipe-axis.browser.test.ts` (the real touch listeners: the drift handback and a clean swipe, browser suite), `test/mobile/tabs.test.ts` (CDP swipes, mobile suite). ### Entrance animations diff --git a/src/web/CLAUDE.md b/src/web/CLAUDE.md index c72e9cbf..8efc835e 100644 --- a/src/web/CLAUDE.md +++ b/src/web/CLAUDE.md @@ -14,7 +14,7 @@ **Tile grid** (`showTileGridButton`, header Tiles button + `Ctrl+Shift+G`, default ON on desktop and OFF on handhelds and touch-primary tablets (primary pointer coarse, `getDefaultSettings()`; a touchscreen laptop keeps ON), desktop-only at 1180px, per-device; tile-grid.js, design `docs/tile-grid-plan.md`): 1 to 6 live sessions side by side (the cap is ONE constant, `TILE_GRID_MAX` in constants.js, owner decision; the layout table still covers 7 to 9, unreachable), each a `TerminalTile` with a header (state dot, harness logo, name, model, menu, zoom, ×; no +, owner decision), laid out by count (`CodemanTileGrid`, constants.js) with draggable column/row dividers, zoom (tmux-style), an Attach overlay, and per-browser persistence (`codeman:tile-grid`, localStorage only: cells with `null` holes, `count`, focus, user zoom, divider fractions, never content; written on every change, kept by EVERY close, restored INSIDE `handleInit` in place of the single-view select, so the main terminal never loads on that page load). While the grid is open the main terminal is PARKED: `activeSessionId` is the focused tile's session, and every main-terminal path that would write, fetch, resize or reconnect stands aside through `_tilesOwnTerminal()` (the WebGL long-task observer included). ⚠️ Every capture a tile fetches (initial, reconnect refresh, `{t:'r'}`, history pull) goes through ONE `TileLoadQueue` (concurrency 1, a deadline covering the body), because each is a synchronous tmux call on the server. ⚠️ Only a USER-initiated pick of a non-tiled session (or `leaveTiles`, a followed link) leaves the grid; an `auto` selection never collapses it, and every app-driven fallback (close, delete, restore) picks a tile. A caller of `closeTileGrid({ reselect: false })` must null `activeSessionId` before any `_cleanupPreviousSession`, or the parked terminal's stale content is saved as a snapshot. ⚠️ The grid and the split are never open together. ⚠️ Tile chords go through `tileShortcutFor` and are swallowed in every xterm key handler BEFORE the Shift+Enter gate; the toggle follows `showTileGridButton` (owner decision: OFF makes the chord inert, it passes through like any unbound key). ⚠️ The Tiles button's click and Ctrl+Shift+G are ONE function, `toggleTileGrid` (owner decision): they open the grid AT ONCE on `tileGridOpenSet` (constants.js): the stored grid EXACTLY as the user left it (owner decision 11: `restoreTileGridCells`, never filled to the count nor trimmed to the window; a cell whose session is gone since is filled from the ranking, a hole the user made stays), else an open split's two, else the open sessions as `rankTileSessions` orders them (working first, keyed off `lastSubmitAt` ONLY, then needing input, then most recent; `_mobileOverviewState`'s states), the active one focused, filled to the remembered count (`codeman:tile-count`, default 6, at most `_tileGridLimit()`) by `tileGridSetForCount`, never a menu. ⚠️ `removeTile(..., { gone: true })` (a session deleted, popped out or refused) keeps the stored `count` so the next open refills that cell; a removal by hand lowers it. Right-click is the 2 / 4 / 6 count menu (`openTileCountMenu`, owner decision 10), which owns its Escape. The button has no native title: its hover card (`_installTileGridHint`, `#tileGridHint`) says it, is its `aria-describedby` (always present, hidden, kept current) and hides in the capture phase on any press, click or right-click, so the menu never opens beside it. ⚠️ The open, the count menu and the toggle's close animate opacity and transform only (the close leaves an inert cloned still copy until the single view's selection settles, at most 700 ms); nothing moves under `prefers-reduced-motion`. ⚠️ Opening builds one tile terminal per frame (`_connectTilesPaced`), so `openTileGrid` returns before the terminals exist: focus is handed over in `_connectTile` (`focusOnConnect`). ⚠️ A divider drag reflows locally per frame and sends ONE resize per affected tile at pointer-up. ⚠️ The grid is CELLS (owner: an empty cell can be any cell): `grid.cells` (id or `null`) is the one source of truth, `grid.ids` a derived getter, never written; the shape still comes from the tile count, a shape change goes through `fitTileCells`, and the stored `ids` carry the cells with `null` holes. ⚠️ A tile moves (its header dragged or its tab dropped onto another tile or an empty cell, `Ctrl+Shift+Arrows`) ONLY through `_reorderTiles`: never a remount, reconnect or reload, and since sizes belong to the cells only a tile whose cell size changed fits; off while a tile is zoomed. The header drag carries its own type, never text, and is not `draggedTabId`; the header focuses on click, never on press, so a cancelled drag changes nothing. The arrow chords skip text fields. ⚠️ Sessions Run from this tab join the open grid (`_joinTileGridFromRun`, called from `_ensureCreatedSessionVisible`); sessions created elsewhere never do. Such a tile connects before its pane exists, the server drops that resize and spawns at 120x40, and Run's own resize measures the parked terminal (nothing), so the chrome refresh calls `tile.paneStarted()` when the session's pid appears or changes. ⚠️ An agent that exited in a live pane (`paneExit`) cannot be re-attached in place (both attach routes refuse while the pane's tmux client runs, and say so in a 200 envelope): its tile shows the exit and points at Close session. ⚠️ Each header (a tile's, both split panes': Pane A gets one only while the split is open, fitted through `sendResize`/`syncTerminalGeometry`) names the harness with PR #532's `run-mode-dot <cliId>` logo (the id is data, never a branch) and `SessionState.displayModel` (src/session-display-model.ts: custom endpoint, else the newest report from the CLI itself, i.e. claude's statusline or a footer read with `capabilities.modelDetect`, else what its config pins via the named `modelDetect.configResolver` (dsh-TUI's route, `src/deepseek-route-config.ts`: read-only, bounded, nothing on doubt), else the launch model, else nothing), painted by ONE diffing `_paintSessionHarness`; the model is untrusted text (`textContent`, `data-i18n-skip`). ⚠️ zh-CN: every string the grid shows has its own `ZH_CN` entry or `translateDynamic` pattern in i18n.js (`test/tile-grid-i18n.test.ts` harvests them from the real code; add the entry with any new string), and a refresh compares with the last ENGLISH value it set, never the DOM, which holds the translation. ⚠️ **Detach Tiles** (`tileDetachEnabled`, per-device, default OFF): a header let go outside the window pops the tile out (`detachSession` with a placement), dropped on ANOTHER Codeman window's tiles it moves there, and a pop-out's title drags back onto any grid. A foreign tile docks ONLY through `_dockForeignTile` (the target's own drop, then `tile-adopted` on the window channel), and only the window it was dragged FROM lets go of its copy (never a last-tile fallback onto that session: two windows sizing one PTY garble it). The gesture overlay moves tiles only through tile-grid.js's helpers (`tileDropTargetAt`/`dropOnTileTarget`/`detachTileAtPoint`). → [architecture-invariants#tile-grid](../../docs/architecture-invariants.md#tile-grid) -**Terminal touch gestures: link taps and text selection**: on touch devices xterm's linkifier and SelectionService never see the gesture, so both are driven explicitly (terminal-ui.js). ⚠️ A tap activates the link under it through the SAME provider as the hover linkifier (`_terminalLinkAtPoint`), synchronously inside `touchend` (keeps the user gesture `window.open` needs) and BEFORE any mouse report; the caret's logical line (`_tapIsOnCaretLine`) and TUI-owned rows (`_isActionableMobileTerminalTap`) keep their meaning. ⚠️ Gate on the caret line, never on tap intent (a shell calls every tap `'input'`). ⚠️ Long-press selects via xterm's public `select()`; keep the three guards: suppress the compat mouse pair after `touchend`, the bounded focus guard + `contextmenu` suppression for the platform long-press, and no closing `terminal.focus()` on phones. ⚠️ A sideways drag belongs to the session swipe (`SwipeHandler`, mobile-handlers.js): the touch handler locks the dominant axis on the first travel past the tap slop (`panAxis`: `y` scrolls, `x` pans a terminal wider than its box, `swipe` otherwise) and publishes it as `app._terminalGestureAxis`; a `swipe` drag neither scrolls nor ends as a tap on the session being left, and the swipe stands aside for `x`/`y`, a touch selection, a second finger and surfaces with controls of their own (`IGNORE_SELECTOR`; the phone overview and welcome screen are deliberately not listed). A swipe is 80 px within 500 ms with at most 50 px of vertical drift at any point; a touch starting outside the terminal (the home screen) resets the published axis, and a lift the terminal consumed (`defaultPrevented`, its selection gesture) is never a swipe (`test/session-swipe-gesture.test.ts`). Tests: `test/terminal-touch-tap.test.ts`. → [architecture-invariants#terminal-touch-gestures-link-taps-and-text-selection](../../docs/architecture-invariants.md#terminal-touch-gestures-link-taps-and-text-selection) +**Terminal touch gestures: link taps and text selection**: on touch devices xterm's linkifier and SelectionService never see the gesture, so both are driven explicitly (terminal-ui.js). ⚠️ A tap activates the link under it through the SAME provider as the hover linkifier (`_terminalLinkAtPoint`), synchronously inside `touchend` (keeps the user gesture `window.open` needs) and BEFORE any mouse report; the caret's logical line (`_tapIsOnCaretLine`) and TUI-owned rows (`_isActionableMobileTerminalTap`) keep their meaning. ⚠️ Gate on the caret line, never on tap intent (a shell calls every tap `'input'`). ⚠️ Long-press selects via xterm's public `select()`; keep the three guards: suppress the compat mouse pair after `touchend`, the bounded focus guard + `contextmenu` suppression for the platform long-press, and no closing `terminal.focus()` on phones. ⚠️ A sideways drag belongs to the session swipe (`SwipeHandler`, mobile-handlers.js): the touch handler locks the dominant axis on the first travel past the tap slop (`panAxis`: `y` scrolls, `x` pans a terminal wider than its box, `swipe` otherwise) and publishes it as `app._terminalGestureAxis`; a `swipe` drag neither scrolls nor ends as a tap on the session being left until it passes the swipe's own vertical drift limit (`SwipeHandler.maxVerticalDrift`), where it is handed back to the scroll as `y` (a scroll that opens on a thumb arc), and the swipe stands aside for `x`/`y`, a touch selection, a second finger and surfaces with controls of their own (`IGNORE_SELECTOR`; the phone overview and welcome screen are deliberately not listed). A swipe is 80 px within 500 ms with at most 50 px of vertical drift at any point; a touch starting outside the terminal (the home screen) resets the published axis, and a lift the terminal consumed (`defaultPrevented`, its selection gesture) is never a swipe (`test/session-swipe-gesture.test.ts`). Tests: `test/terminal-touch-tap.test.ts`. → [architecture-invariants#terminal-touch-gestures-link-taps-and-text-selection](../../docs/architecture-invariants.md#terminal-touch-gestures-link-taps-and-text-selection) **Auto Copy (copy-on-select)** (`autoCopySelection`, per-device, default OFF): a finished terminal selection lands on the clipboard with no keystroke. ⚠️ Copy at the END of a gesture, never in `onSelectionChange` (per-cell); it only arms `_autoCopyPending` and a document-level `mouseup` flushes. ⚠️ The flush must be SYNCHRONOUS in the handler (both clipboard paths need user activation); never defer it to a timer. ⚠️ Touch needs its own calls from `_endTouchSelectionGesture()`/`_selectTouchSelectionLine()` (no mouseup arrives). ⚠️ Unlike `copyTerminalSelection()`, never clear the selection or focus the terminal; restore prior focus. Guards are pure in `decideAutoCopy()` (constants.js, 1M-char cap, refused not truncated). Tests: `test/terminal-auto-copy.test.ts`. → [architecture-invariants#auto-copy-copy-on-select](../../docs/architecture-invariants.md#auto-copy-copy-on-select) diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 95d80e0a..d47475da 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -1335,7 +1335,17 @@ Object.assign(CodemanApp.prototype, { // fling, so a jittery tap would both position the cursor AND scroll. if (!didScroll) return; ev.preventDefault(); - if (panAxis === 'swipe') return; + if (panAxis === 'swipe') { + // Past the swipe's own vertical drift limit the session swipe has + // given up, so the drag is a scroll after all: hand it to 'y'. + const drift = typeof SwipeHandler !== 'undefined' ? SwipeHandler.maxVerticalDrift : 50; + if (Math.abs(touchY - touchStartY) <= drift) return; + panAxis = 'y'; + this._terminalGestureAxis = 'y'; + touchLastX = touchX; + touchLastY = touchY; + return; + } if (panAxis === 'x') { // Pan the container, and touch nothing the vertical path owns — // no pixelAccum, no velocity, so touchend cannot turn a sideways diff --git a/test/session-swipe-gesture.test.ts b/test/session-swipe-gesture.test.ts index 9acece0b..af8143a2 100644 --- a/test/session-swipe-gesture.test.ts +++ b/test/session-swipe-gesture.test.ts @@ -23,6 +23,7 @@ type Swipe = { onTouchMove(e: unknown): void; onTouchEnd(e: unknown): void; maxSwipeTime: number; + maxVerticalDrift: number; startTime: number; }; @@ -146,7 +147,16 @@ describe('terminal touch handler hands sideways drags to the swipe', () => { it('locks a dominant sideways drag as a swipe unless the terminal can pan', () => { expect(TERMINAL_SOURCE).toContain("panAxis = travelX > travelY ? (canPanHorizontally() ? 'x' : 'swipe') : 'y';"); expect(TERMINAL_SOURCE).toContain('this._terminalGestureAxis = panAxis;'); - // A 'swipe' is neither a scroll nor (didScroll is set) a tap. - expect(TERMINAL_SOURCE).toContain("if (panAxis === 'swipe') return;"); + // A 'swipe' is neither a scroll nor (didScroll is set) a tap, until the + // finger passes the swipe's own vertical drift limit: the swipe has given + // up by then, so the drag is handed back to the scroll. + expect(TERMINAL_SOURCE).toContain("if (panAxis === 'swipe') {"); + expect(TERMINAL_SOURCE).toContain( + "const drift = typeof SwipeHandler !== 'undefined' ? SwipeHandler.maxVerticalDrift : 50;" + ); + expect(TERMINAL_SOURCE).toContain('if (Math.abs(touchY - touchStartY) <= drift) return;'); + expect(TERMINAL_SOURCE).toContain("this._terminalGestureAxis = 'y';"); + // The terminal reads the swipe's limit by name; its fallback is the same value. + expect(load().swipe.maxVerticalDrift).toBe(50); }); }); diff --git a/test/terminal-touch-swipe-axis.browser.test.ts b/test/terminal-touch-swipe-axis.browser.test.ts new file mode 100644 index 00000000..0a9fa5b4 --- /dev/null +++ b/test/terminal-touch-swipe-axis.browser.test.ts @@ -0,0 +1,149 @@ +/** + * @fileoverview Real Chromium coverage for the axis the primary pane's touch + * listeners lock on the first travel past the tap slop (#674): `'swipe'` for a + * sideways drag on a terminal that cannot pan, which belongs to the session + * swipe (SwipeHandler, mobile-handlers.js) and so neither scrolls nor ends as a + * tap. + * + * The edge this pins: a scroll whose first few pixels lean sideways (a thumb + * arc: +9 px across, +5 px down, then straight down) used to lock as `'swipe'` + * for the whole contact, so every later touchmove returned before the scroll + * code, the swipe itself gave up at its vertical drift limit, and the gesture + * did nothing. Past `SwipeHandler.maxVerticalDrift` the drag is handed back to + * the scroll (`'y'`). A clean sideways swipe must still lock as `'swipe'` and + * leave the buffer where it was. + * + * What runs for real: the shipped app (page served by a WebServer on port 0), + * its xterm, the touch listeners `initTerminal()` installs on + * `#terminalContainer` and the gates they call. What is stubbed: the active + * session (a shell session the page knows about but no PTY, so a drag scrolls + * xterm's own buffer), and `nextSession`/`prevSession`, so a swipe can never + * switch away mid-test. The touch events are DOM events carrying `touches`, + * dispatched on the container. + * + * Port: ephemeral (`new WebServer(0, …)`, read back from `boundPort`). + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { chromium, type Browser, type Page } from 'playwright'; +import { WebServer } from '../src/web/server.js'; + +const PROBE_ID = 'touch-swipe-axis-probe'; + +describe('touch axis lock of the primary pane in a real browser', () => { + let server: WebServer; + let browser: Browser; + let page: Page; + + beforeAll(async () => { + server = new WebServer(0, false, true); + await server.start(); + browser = await chromium.launch({ headless: true }); + page = await browser.newPage({ viewport: { width: 390, height: 844 }, deviceScaleFactor: 1, hasTouch: true }); + await page.goto(`http://localhost:${server.boundPort}`, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction(() => (window as any).app?.terminal, null, { timeout: 30000 }); + // Let the page settle first: its layout refits the terminal (62 rows to 50 + // at this size, measured) a moment after load, which moves baseY. + await page.waitForTimeout(3500); + await page.evaluate(async (id) => { + const app = (window as any).app; + app.sessions.set(id, { id, name: id, mode: 'shell', status: 'idle' }); + app.activeSessionId = id; + app.loadAppSettingsFromStorage = () => ({ terminalWheelLocalScrollback: false }); + app.nextSession = () => {}; + app.prevSession = () => {}; + let text = ''; + for (let i = 0; i < 300; i++) text += `\r\nscrollback row ${i}`; + await new Promise<void>((resolve) => app.terminal.write(text, resolve)); + }, PROBE_ID); + }, 90000); + + afterAll(async () => { + if (browser) await browser.close(); + if (server) await server.stop(); + }, 60000); + + /** Dispatch one touch event of `type` at (x, y) on the terminal container. */ + const touch = (type: string, x: number, y: number) => + page.evaluate( + ([t, cx, cy]) => { + const container = document.getElementById('terminalContainer')!; + const ev = new Event(t as string, { bubbles: true, cancelable: true }); + const point = { clientX: cx, clientY: cy, target: container }; + Object.defineProperty(ev, 'touches', { value: t === 'touchend' ? [] : [point] }); + Object.defineProperty(ev, 'changedTouches', { value: [point] }); + container.dispatchEvent(ev); + }, + [type, x, y] as const + ); + + /** Viewport back at the bottom, and a start point inside the container. */ + const reset = () => + page.evaluate(() => { + const app = (window as any).app; + app.terminal.scrollToBottom(); + const container = document.getElementById('terminalContainer')!; + const r = container.getBoundingClientRect(); + const b = app.terminal.buffer.active; + return { + x: Math.round(r.left + r.width / 2), + y: Math.round(r.top + 40), + height: Math.round(r.height), + baseY: b.baseY, + viewportY: b.viewportY, + rows: app.terminal.rows, + canPan: container.classList.contains('term-overflows-x'), + }; + }); + + /** SwipeHandler is a top-level `const` (no window property): read it by name. */ + const swipeDrift = () => + page.evaluate<number | null>('typeof SwipeHandler === "undefined" ? null : SwipeHandler.maxVerticalDrift'); + + const state = () => + page.evaluate(() => { + const app = (window as any).app; + const b = app.terminal.buffer.active; + return { axis: app._terminalGestureAxis, viewportY: b.viewportY, baseY: b.baseY, rows: app.terminal.rows }; + }); + + it('hands a drag that opens sideways back to the scroll once it passes the swipe drift limit', async () => { + const start = await reset(); + // The preconditions, so a pass is not vacuous: real scrollback to scroll + // through, a terminal that cannot pan (a sideways opening locks 'swipe', + // not 'x'), and room in the container for 300 px of travel. + expect(start.baseY).toBeGreaterThan(50); + expect(start.viewportY).toBe(start.baseY); + expect(start.canPan).toBe(false); + expect(await swipeDrift()).toBe(50); + expect(start.height).toBeGreaterThan(340); + + const { x, y } = start; + await touch('touchstart', x, y); + await touch('touchmove', x + 9, y + 5); // the thumb arc: past the slop, mostly sideways + const opened = await state(); + expect(opened.axis).toBe('swipe'); + expect(opened.viewportY).toBe(opened.baseY); + + for (let dy = 25; dy <= 305; dy += 20) await touch('touchmove', x + 9, y + dy); + const dragged = await state(); + expect(dragged.axis).toBe('y'); + // Finger down scrolls back through the scrollback, measured from the bottom + // (baseY) as it is now. 300 px of travel is well over ten rows; the travel + // up to the drift limit is not counted. + expect(dragged.baseY - dragged.viewportY).toBeGreaterThanOrEqual(5); + // Cancel rather than lift: a lift starts a momentum fling, which would keep + // scrolling into the next test. + await touch('touchcancel', x + 9, y + 305); + }, 60000); + + it('keeps a clean sideways swipe locked as the session swipe, with nothing scrolled', async () => { + const start = await reset(); + const { x, y } = start; + await touch('touchstart', x + 120, y); + for (let dx = 20; dx <= 160; dx += 20) await touch('touchmove', x + 120 - dx, y + 3); + const swiped = await state(); + expect(swiped.axis).toBe('swipe'); + expect(swiped.viewportY).toBe(swiped.baseY); + await touch('touchcancel', x - 40, y + 3); + }, 60000); +}); From a198b815d2e991475dcc1db55d507eccdf730834 Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Thu, 22 Oct 2026 19:18:51 +0200 Subject: [PATCH 79/96] docs: update the swipe limits and note what pauses or replaces a swipe Merge-time fix for #674: the mobile-handlers.js @fileoverview still gave the old 300 ms and 100 px limits (now 500 ms and 50 px), and the Mobile Guide now says a visible text selection pauses the swipe and a terminal wider than the screen pans instead of switching. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- docs/wiki/Mobile-Guide.md | 3 ++- src/web/public/mobile-handlers.js | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/wiki/Mobile-Guide.md b/docs/wiki/Mobile-Guide.md index 11a78292..98a4dd89 100644 --- a/docs/wiki/Mobile-Guide.md +++ b/docs/wiki/Mobile-Guide.md @@ -37,7 +37,8 @@ a bottom sheet rather than a dropdown. On a folding phone (iPhone Duo) dialogs s the hinge, and opening or closing the device is treated as the device changing shape, never as the keyboard appearing. -**Swipe left and right** on the terminal to switch sessions. +**Swipe left and right** on the terminal to switch sessions. A visible text selection pauses +the swipe, and a terminal wider than the screen pans sideways instead of switching. ## The home screen diff --git a/src/web/public/mobile-handlers.js b/src/web/public/mobile-handlers.js index 9bfd9acb..3c3ba549 100644 --- a/src/web/public/mobile-handlers.js +++ b/src/web/public/mobile-handlers.js @@ -9,7 +9,7 @@ * toolbar/accessory bar repositioning, terminal resize on keyboard open/close, * and input scroll-into-view. Uses 100px threshold for iOS address bar drift. * - SwipeHandler — Horizontal swipe detection on the terminal area for session switching. - * 80px minimum distance, 300ms maximum time, 100px max vertical drift. + * 80px minimum distance, 500ms maximum time, 50px max vertical drift. * * All three have init()/cleanup() lifecycle methods. They are re-initialized after SSE * reconnect (in handleInit) to prevent stale closures. From ebb50ef53a701eb7fcff130621cd1efc20ad1494 Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Thu, 22 Oct 2026 19:18:59 +0200 Subject: [PATCH 80/96] fix(config-backup): reuse the newest backup as the undo copy when nothing changed Merge-time fix for #661. The pre-restore copy was forced, so a restore made while the live files equalled the newest backup spent a retention slot on a duplicate. It now goes through the normal change check: when nothing changed since the newest backup, no copy is made and safetySnapshot names that backup. Two tests that relied on the forced copy now change a file before the restore; a new one pins the reuse. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- docs/api-reference.md | 2 +- docs/wiki/Config-Backups.md | 12 +++++++----- src/config-backup.ts | 17 +++++++++++------ test/config-backup-origin.test.ts | 1 + test/config-backup.test.ts | 16 ++++++++++++++++ 5 files changed, 36 insertions(+), 12 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index 82af1322..613f99a6 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -1165,7 +1165,7 @@ Settings (synced, in `settings.json`): `configBackupEnabled` (absent = on), `con | `GET` | `/api/config-backups` | none | `settings` in effect, `dir`, `defaultDir`, `tracked[]` and `backups[]` (`id`, `createdAt`, `reason`, `appVersion`, `files[]`, `bytes`), newest first. | | `POST` | `/api/config-backups` | none | Snapshot now, even if nothing changed (`reason: manual`), then apply retention. `status` is `created` or `empty` (nothing tracked exists yet); `removed[]` lists the ids retention deleted. `503` when a custom folder does not answer. | | `GET` | `/api/config-backups/diff?from=&to=` | none | What differs between two states: `from`/`to` are a backup id or `current` (the running config; the default for `to`). `files[]` (`name`, `status` `added`/`removed`/`changed`/`unchanged`, `changes[]` of `path`, `kind`, `before`/`after`, `truncated`, `detail`) and `changed`. Credential values are never returned (`redacted: true`). `400` without `from`; `404` unknown id; `409` if a file fails its checksum. | -| `POST` | `/api/config-backups/:id/restore` | none | Put a snapshot's files back, atomically, after a `pre-restore` snapshot of the current ones. `404` unknown id; `409 CONFLICT` if a file fails its checksum (nothing changed). Returns `restored[]`, `untouched[]` (tracked files present now that the backup did not hold; left as they are), `safetySnapshot` (the `pre-restore` id) and `restartRequired[]` (files the running server only reads at start: `.env`, `push-keys.json`, `settings.json`). A file that cannot be written (a full disk, a directory in its place) does not stop the others: the answer is then `500 OPERATION_FAILED` with the same fields in `data` plus `failed[]` (`name`, `error`); the files listed in `restored[]` are the backup's and are reloaded like a full restore. | +| `POST` | `/api/config-backups/:id/restore` | none | Put a snapshot's files back, atomically, after a `pre-restore` snapshot of the current ones (none when nothing changed since the newest backup). `404` unknown id; `409 CONFLICT` if a file fails its checksum (nothing changed). Returns `restored[]`, `untouched[]` (tracked files present now that the backup did not hold; left as they are), `safetySnapshot` (the `pre-restore` id, or the newest backup's when nothing had changed since it) and `restartRequired[]` (files the running server only reads at start: `.env`, `push-keys.json`, `settings.json`). A file that cannot be written (a full disk, a directory in its place) does not stop the others: the answer is then `500 OPERATION_FAILED` with the same fields in `data` plus `failed[]` (`name`, `error`); the files listed in `restored[]` are the backup's and are reloaded like a full restore. | ## Webhook notifications diff --git a/docs/wiki/Config-Backups.md b/docs/wiki/Config-Backups.md index 3918af94..8ca07216 100644 --- a/docs/wiki/Config-Backups.md +++ b/docs/wiki/Config-Backups.md @@ -48,7 +48,8 @@ cron jobs. `intents.json` as unchanged when only prompts differ, yet a restore drops the prompts captured since. - **On demand**, with **Back up now** (always writes one, even if nothing changed). -- **Before a restore**, as a `pre-restore` safety copy of the files about to be replaced. +- **Before a restore**, as a `pre-restore` safety copy of the files about to be replaced (unless + nothing changed since the newest backup). ## Where they go and what they are called @@ -132,16 +133,17 @@ From the list, press **Restore** on a backup and confirm. Codeman then: mode for every file, so it would refuse every backup there). The symlink test is not. Compare applies the same checks to the backups it reads. 2. **Takes a `pre-restore` backup** of the files about to be replaced, so a restore can itself - be undone. + be undone. When nothing changed since the newest backup (the same comparison an automatic + backup makes), no copy is made and the response names that backup instead (`safetySnapshot`). 3. **Replaces each file atomically** (written beside it, then renamed) with mode `0600`. Only the tracked names are ever written, whatever a manifest lists. If a file cannot be written (a full disk, a folder where the file should be), the restore still writes the others and then reports an error that names the files it restored, the ones it could -not write, and the `pre-restore` backup that holds the files as they were before. The restored -files are in effect as after a full restore (Settings re-reads `settings.json` into the form), so -fix the cause and restore again, or restore the `pre-restore` backup to go back. +not write, and the backup that holds the files as they were before. The restored files are in +effect as after a full restore (Settings re-reads `settings.json` into the form), so fix the cause +and restore again, or restore that backup to go back. Restoring `settings.json` keeps the four backup settings (on or off, folder, how many, how many days) at their current values, so a restore from before a folder change does not point the list diff --git a/src/config-backup.ts b/src/config-backup.ts index 563f1c45..f9e315aa 100644 --- a/src/config-backup.ts +++ b/src/config-backup.ts @@ -654,15 +654,19 @@ export interface RestoreResult { restored: string[]; /** Tracked files present now that the snapshot did not hold; a restore leaves them as they are. */ untouched: string[]; - /** The snapshot taken of the live files before they were overwritten. */ + /** + * The snapshot holding the live files as they were before the restore: the `pre-restore` copy, or the + * newest backup when it already held exactly that state. Null only when no tracked file existed. + */ safetySnapshot: string | null; } /** * Put a snapshot's files back into the data dir. Every file is checked against the manifest's digest - * BEFORE anything is written, a `pre-restore` snapshot of the live files is taken first, and each - * file is replaced atomically (temp + rename) with mode 0600. A file that cannot be written does not - * stop the others; it throws {@link ConfigBackupPartialRestoreError} at the end, naming both lists. + * BEFORE anything is written, a `pre-restore` snapshot of the live files is taken first (unless the + * newest backup already holds that state, which is then reported instead), and each file is replaced + * atomically (temp + rename) with mode 0600. A file that cannot be written does not stop the others; + * it throws {@link ConfigBackupPartialRestoreError} at the end, naming both lists. */ export function restoreConfigBackup(opts: { dataDir: string; @@ -704,7 +708,8 @@ export function restoreConfigBackup(opts: { backupDir: opts.backupDir, reason: 'pre-restore', appVersion: opts.appVersion, - force: true, + // Not forced: when the live files equal the newest backup, that backup already is the undo copy, + // and a forced duplicate would spend a retention slot on nothing. now: opts.now, // The retention in force, so this newest snapshot does not hide it from the next pass. ...(latestBackupRetention(opts.backupDir, origin, existing) ?? {}), @@ -742,7 +747,7 @@ export function restoreConfigBackup(opts: { !failed.some((x) => x.name === n) && lstatSync(join(opts.dataDir, n), { throwIfNoEntry: false })?.isFile() ); - const safetySnapshot = safety.status === 'created' ? safety.id : null; + const safetySnapshot = safety.status === 'empty' ? null : safety.id; if (failed.length) throw new ConfigBackupPartialRestoreError(restored, failed, untouched, safetySnapshot); return { restored, untouched, safetySnapshot }; } diff --git a/test/config-backup-origin.test.ts b/test/config-backup-origin.test.ts index 4db940e5..bf6f26c4 100644 --- a/test/config-backup-origin.test.ts +++ b/test/config-backup-origin.test.ts @@ -51,6 +51,7 @@ describe('configBackupOrigin', () => { host.name = 'container-b'; // the container was recreated const origin = configBackupOrigin(data); expect(listConfigBackups(backups, origin)).toHaveLength(2); + writeFileSync(join(data, 'settings.json'), '{"a":2}'); // a change, so the restore takes a pre-restore copy expect(() => restoreConfigBackup({ dataDir: data, backupDir: backups, id: made.id })).not.toThrow(); const removed = pruneConfigBackups(backups, { keepCount: 1, keepDays: 0, origin }); expect(removed).toHaveLength(2); // 3 now: the restore took a pre-restore copy diff --git a/test/config-backup.test.ts b/test/config-backup.test.ts index fca4243f..2a7e1231 100644 --- a/test/config-backup.test.ts +++ b/test/config-backup.test.ts @@ -346,6 +346,19 @@ describe('restoreConfigBackup', () => { } }); + it('reuses the newest backup as the undo copy when the live files already equal it', () => { + put('settings.json', '{"a":1}'); + const first = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 }) as { id: string }; + put('settings.json', '{"a":2}'); + const newest = createConfigBackup({ dataDir: data, backupDir: backups, reason: 'auto', now: T0 + DAY }) as { + id: string; + }; + const r = restoreConfigBackup({ dataDir: data, backupDir: backups, id: first.id, now: T0 + 2 * DAY }); + expect(r.safetySnapshot).toBe(newest.id); + expect(listConfigBackups(backups).map((b) => b.id)).toEqual([newest.id, first.id]); // no duplicate + expect(readFileSync(join(data, 'settings.json'), 'utf8')).toBe('{"a":1}'); + }); + it('a file it cannot write does not stop the others, and the error names both lists', () => { put('settings.json', '{"good":true}'); put('clis.json', '{"clis":{}}'); @@ -644,9 +657,12 @@ describe('restore trusts only what this module wrote', () => { const origin = configBackupOrigin(data); expect(latestBackupRetention(backups, origin)).toEqual({ keepCount: 100, keepDays: 0 }); const id = listConfigBackups(backups, origin).at(-1)!.id; + // A change since the newest backup, so the restore makes a pre-restore copy rather than reusing it. + put('settings.json', '{"a":3}'); const r = restoreConfigBackup({ dataDir: data, backupDir: backups, id, now: T0 + 2000 }); expect(r.safetySnapshot).toBeTruthy(); const pre = listConfigBackups(backups, origin).find((b) => b.id === r.safetySnapshot)!; + expect(pre.reason).toBe('pre-restore'); expect(JSON.parse(readFileSync(join(backups, pre.id, 'manifest.json'), 'utf8'))).toMatchObject({ keepCount: 100, keepDays: 0, From fde23e3a076957da3599473f6c68ecd3ec94922d Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Thu, 22 Oct 2026 19:20:01 +0200 Subject: [PATCH 81/96] fix(response-viewer): keep a path link's label in the prose font, pin its edges Merge-time fix for #665. - A markdown link converted to a preview link became `a.rv-path`, so its label ("Our incident record") took the path style: monospace, 0.92em and `word-break: break-all`, which split prose mid-word on a phone. The anchor now also carries `rv-path-link`, and `.rv-text a.rv-path.rv-path-link` resets font and word-break, as the File Viewer does for its labelled document links. - The first new test adds `/tmp/a.json?raw=true` (pins the `?query` strip) and `/tmp/` (pins the trailing-slash folder check, since findFilePathLinks finds no folder in a bare `/tmp/`), and checks the new class. - The `_linkifyFilePaths` JSDoc and docs/architecture-invariants.md say the function also rewrites anchors whose href is a scheme-less, non-`//` path, on purpose without FILE_PATH_LINK_PATTERN's root and extension filter. - Mobile Guide: "a web link" in the response viewer opens a tab. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- docs/architecture-invariants.md | 2 +- docs/wiki/Mobile-Guide.md | 2 +- src/web/public/app.js | 10 +++++++++- src/web/public/styles.css | 8 ++++++++ test/response-viewer-file-links.test.ts | 11 +++++++++-- 5 files changed, 28 insertions(+), 5 deletions(-) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index a50539c8..8c42278a 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -386,7 +386,7 @@ A file path an agent prints is a link on both surfaces it can appear on, and cli **One pattern, two consumers.** `FILE_PATH_LINK_PATTERN` / `absoluteFilePathPattern()` live in `constants.js`; the xterm link provider (`registerFilePathLinkProvider`, terminal-ui.js) and the response viewer's `_linkifyFilePaths()` (app.js) both reach it through `findFilePathLinks()` (below), which builds a fresh instance per call. ⚠️ Fresh per call, never one shared object: `lastIndex` is per-object state on a `/g` regex. The pattern is anchored on a known absolute root and terminated by a known extension, so a fraction (`3/4`) or a date can't match and trailing punctuation stays out. Roots include `Users` and `mnt`, without which nothing was clickable on macOS or WSL. The linear-time guard and the "terminal-ui builds from the factory" structural check are in `test/link-provider-regex.test.ts`. -**The chat linkifier walks text nodes.** `_linkifyFilePaths()` builds anchors with `createElement`/`textContent` on the rendered subtree, never by rebuilding sanitized markup as a string — the source is model output. Subtrees already inside an `<a>` are skipped (marked autolinks URLs; a nested anchor would swallow the click), and the anchor's text is the path verbatim so "copy code" still yields what the agent printed. `test/response-viewer-file-links.test.ts` pins both properties. +**The chat linkifier walks text nodes.** `_linkifyFilePaths()` builds anchors with `createElement`/`textContent` on the rendered subtree, never by rebuilding sanitized markup as a string — the source is model output. Subtrees already inside an `<a>` are skipped (marked autolinks URLs; a nested anchor would swallow the click), and the anchor's text is the path verbatim so "copy code" still yields what the agent printed. Before the walk it also rewrites existing anchors whose href is a scheme-less, non-`//` path (Codex cites files as `[label](/abs/path.json)`, which otherwise opens as a same-origin URL) into the same `a.rv-path` shape, plus `rv-path-link` so the label keeps the prose font, on purpose without `FILE_PATH_LINK_PATTERN`'s root and extension filter: the href is a link target the agent chose. `test/response-viewer-file-links.test.ts` pins both properties and the anchor rewrite. **Out-of-workspace paths go through the attachment routes, not the file routes.** `file-content`/`file-raw` resolve against `workingDir` and 404 anything that escapes it, which is correct and unchanged — but the paths agents most often print (a `/tmp` capture, Claude's own scratchpad, another checkout) are exactly that, so clicking one used to report "File not found" for a file sitting on disk. `openFilePreview()` now detects the case (`_isExternalPreviewPath`, a string compare for ROUTING only; the real decision stays server-side) and registers the path via `POST /api/sessions/:id/attachments` first, rendering by id. ⚠️ That registration passes `notify: false`, which suppresses ONLY the `attachment:detected` broadcast — the guard, the registry entry and the by-id routes are identical either way. Without it every click also popped an attachment card announcing the file already filling the screen. ⚠️ The click is an explicit user action on the **explicit, Origin-guarded** registration route, which is why it may cross the workspace boundary at all; the passive magic-link scanner stays force-confined. A type outside `SUPPORTED_ATTACHMENT_EXTENSIONS` (`.svg`, `.bmp`) is refused with a message naming what IS previewable, rather than the registry's own policy term. diff --git a/docs/wiki/Mobile-Guide.md b/docs/wiki/Mobile-Guide.md index 820710ad..b21b896e 100644 --- a/docs/wiki/Mobile-Guide.md +++ b/docs/wiki/Mobile-Guide.md @@ -131,7 +131,7 @@ already writing in the editor. ## Tapping, links and copying -- **Tap a link** in terminal output and it opens in a new tab. Same for a link in an agent's +- **Tap a link** in terminal output and it opens in a new tab. Same for a web link in an agent's answer in the response viewer — it opens a tab rather than navigating the dashboard away, which on a phone would unload the whole session view. - **Tap a file path** an agent printed, or a link to one in its answer (Codex writes diff --git a/src/web/public/app.js b/src/web/public/app.js index 6d53cb4d..5ac677de 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -2959,6 +2959,12 @@ class CodemanApp { * output. Subtrees already inside an `<a>` are skipped so an autolinked URL * is never re-cut, and the anchor's textContent is the path verbatim, so * "copy code" still yields exactly what the agent printed. + * + * Before that walk it also rewrites an existing anchor whose href is a path + * (no scheme, not `//host`, not a bare `#fragment`) into the same `a.rv-path` + * shape, on purpose WITHOUT `FILE_PATH_LINK_PATTERN`'s root and extension + * filter: the agent chose that href as a link target, and left alone it opens + * as a same-origin URL (a 404 page in a new tab). */ _linkifyFilePaths(root) { if (!root || typeof document === 'undefined') return; @@ -2984,7 +2990,9 @@ class CodemanApp { } // A bare `#fragment` (and every `.rv-path`, whose href is `#`) ends up empty. if (!path) continue; - a.className = 'rv-path'; + // rv-path-link keeps the label in the prose font (styles.css): it is a label, + // not a path, and break-all would split it mid-word on a phone. + a.className = 'rv-path rv-path-link'; a.dataset.path = path; a.dataset.linkified = '1'; if (path.endsWith('/') || findFilePathLinks(path).some((f) => f.folder && f.path === path)) a.dataset.folder = '1'; diff --git a/src/web/public/styles.css b/src/web/public/styles.css index 624364d9..8fcc9cb5 100644 --- a/src/web/public/styles.css +++ b/src/web/public/styles.css @@ -14926,6 +14926,14 @@ html:is([data-skin="paper-gray"], [data-skin="solarized-light"], [data-skin="cat cursor: pointer; } +/* A markdown link whose target is a path (`[Our incident record](/tmp/x.json)`) + takes the rv-path shape for the click delegate, but its text is a label, not a + path: keep it in the prose font and never break it mid-word. */ +.rv-text a.rv-path.rv-path-link { + font: inherit; + word-break: normal; +} + /* Tables — scroll wrapper keeps table proper while allowing horizontal overflow */ .rv-table-wrap { margin: 1em 0; diff --git a/test/response-viewer-file-links.test.ts b/test/response-viewer-file-links.test.ts index c30d2fd3..85947186 100644 --- a/test/response-viewer-file-links.test.ts +++ b/test/response-viewer-file-links.test.ts @@ -117,7 +117,7 @@ describe('response viewer file-path linkifier', () => { const root = linkify( `<p><a href="${path}" target="_blank" rel="noopener noreferrer">Our incident record</a> and ` + `<a href="src/a%20b.ts:42:7">a b</a>, <a href="docs/#L3">docs</a>, <a href="/tmp/renders">renders</a>, ` + - `<a href="/tmp/release%3A12">release</a></p>` + `<a href="/tmp/release%3A12">release</a>, <a href="/tmp/a.json?raw=true">raw</a>, <a href="/tmp/">tmp</a></p>` ); const links = paths(root); @@ -128,6 +128,9 @@ describe('response viewer file-path linkifier', () => { '/tmp/renders', // An encoded colon is part of the name, never a line suffix. '/tmp/release:12', + // A query names a view of the file, not the file. + '/tmp/a.json', + '/tmp/', ]); for (const a of links) { expect(a.getAttribute('href')).toBe('#'); @@ -136,9 +139,13 @@ describe('response viewer file-path linkifier', () => { expect(a.getAttribute('data-linkified')).toBe('1'); } expect(links[0].textContent).toBe('Our incident record'); + // A label, not a path: the second class keeps it in the prose font (styles.css). + expect(links.every((a) => a.classList.contains('rv-path-link'))).toBe(true); // A trailing slash and a rooted folder path without one both open the folder grid // (`:` ends a folder match in findFilePathLinks, so `release:12` previews instead). - expect(links.map((a) => a.hasAttribute('data-folder'))).toEqual([false, false, true, true, false]); + // findFilePathLinks finds no folder in a bare `/tmp/`, so only the trailing-slash + // check marks that one. + expect(links.map((a) => a.hasAttribute('data-folder'))).toEqual([false, false, true, true, false, false, true]); }); it('opens a clicked markdown path link in the file preview through the click delegate', () => { From 6cf5ab05d6f47899a4ddec45e29caee6b26250c9 Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Thu, 22 Oct 2026 19:21:02 +0200 Subject: [PATCH 82/96] docs: config backups section in architecture-invariants, CLAUDE.md pointer and State Files Merge-time fix for #661, as promised in the reviews. The long Config backups paragraph in CLAUDE.md moves into a new architecture-invariants section that gives each rule with the reason behind it (allowlist, volatile keys, retention by successor and the date floor, the origin id, retention after a bad or reset settings.json, the bounded probe, one walk per pass, verify before write, the undo copy and the partial-restore answer, the reload hooks, redaction, admin only). CLAUDE.md keeps the short rules and a pointer, and the State Files list names config-backup-id and backups/config/ with the retention each manifest records. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- CLAUDE.md | 4 ++-- docs/architecture-invariants.md | 30 ++++++++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 357c1db4..3e2905cf 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -156,7 +156,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Auto-resume on usage limit** (opt-in per session, top of the Respawn tab): when Claude halts on a subscription limit, `usage-limit-patterns.ts` (pure, unit-tested) parses the reset time and `SessionAutoOps` arms a timer for reset+2min, then sends Esc + `continue`. ⚠️ Respawn cycles are blocked while paused (`isLimitPaused` guard in `onIdleDetected`), which is what prevents `/clear` from wiping the paused conversation. Claude-mode only. → [architecture-invariants#auto-resume-on-usage-limit](docs/architecture-invariants.md#auto-resume-on-usage-limit) -**Config backups** (`src/config-backup.ts`, `routes/config-backup-routes.ts`; `configBackupEnabled` absent = ON, `configBackupDir`, `configBackupKeepCount` 2-500 default 20, `configBackupKeepDays` 0-3650 default 30 (an ABSENT key means what the newest backup in the folder recorded, `withRecordedRetention`); `docs/wiki/Config-Backups.md`): timestamped snapshots `cfg-YYYYMMDD-HHMMSS/` (+ `manifest.json` with SHA-256s, `reason`, `appVersion`) of a FIXED allowlist (`CONFIG_BACKUP_FILES`: settings, clis, custom-model-hosts, webhook, intents, linked-cases, remote/docker hosts+cases (incl. `remote-cases.json`), webviews, push-keys, users, `.env`) into `<data dir>/backups/config` or a custom absolute folder. ⚠️ `state.json` is deliberately NOT tracked (rewritten per session event; it also holds cron jobs and respawn presets). One snapshot per real content change (digest of the file set vs the latest manifest), at startup + a 5-minute timer, skipped in test mode. ⚠️ Retention (`pruneConfigBackups`) only deletes folders matching the name pattern AND holding a parseable manifest, and never the newest; a custom folder equal to the data dir falls back to the default. ⚠️ Restore verifies every file's SHA-256 BEFORE writing, takes a `pre-restore` snapshot, writes atomically at 0600 and only allowlisted names; the id is validated against the name pattern (no traversal). Snapshots are 0700/0600 because `.env`/`users.json`/`custom-model-hosts.json` carry credentials; all routes are admin-only in multi-user mode. ⚠️ A custom folder is probed with `probePathKind(dir, { pastCap: true })` before every pass and in each route (#516: it may be a network mount; `unknown` skips the pass / answers 503 `OPERATION_FAILED`), and a missing custom folder is only created when its parent is present. ⚠️ Each manifest records an `origin`, a random id kept in `<data dir>/config-backup-id` (NOT the hostname: a Docker container gets a new one per recreate, which orphaned every snapshot); change check, prune, list, restore and diff are scoped to it so instances sharing one folder do not snapshot or prune each other. Restoring `intents.json` calls `intentStore.reload()`; `settings.json` applies its watcher toggles after a restart; Settings re-reads `/api/settings` into the form after a restore of it, since Save would otherwise PUT the old values back. ⚠️ Restoring `clis.json` writes it directly (the one writer besides `mutateRegistryFile()`) and then calls `reloadCliRegistry()`. → [docs/wiki/Config-Backups.md](docs/wiki/Config-Backups.md) +**Config backups** (`src/config-backup.ts`, `routes/config-backup-routes.ts`; `configBackupEnabled` absent = ON, SYNCED; `docs/wiki/Config-Backups.md`): timestamped `cfg-*` snapshots of a FIXED allowlist of config files (`CONFIG_BACKUP_FILES`; `state.json` deliberately not) at startup and whenever their content changes, into `<data dir>/backups/config` or `configBackupDir`. ⚠️ A field a tracked file rewrites on its own goes in `VOLATILE_KEYS`, or every tick makes a snapshot and the history shrinks to about 95 minutes. ⚠️ Retention deletes only this instance's folders that carry a valid manifest, never the newest, and ages a snapshot from its SUCCESSOR; a retention key ABSENT from `settings.json` means what the newest manifest recorded (`withRecordedRetention`), never the defaults. ⚠️ The instance is identified by the random id in `config-backup-id`, never the hostname (a Docker recreate changes it). ⚠️ A custom folder is probed BOUNDED (#516) before ANY read, the recorded-retention fallback included, and a pass reads the folder once (`runConfigBackupPass`). ⚠️ Restore verifies every SHA-256 and refuses symlinks and group/world bits before it writes anything, takes the undo copy first, and reports a partial failure as 500 with `restored`/`failed`/`safetySnapshot` in `data`; every path that wrote files runs `afterRestore()` (intents reload, CLI registry reload, admin audit), and Settings re-reads the form after a `settings.json` restore. ⚠️ Compare never returns a credential (`REDACT_ALL_FILES`, `SECRET_KEY_RE`). Admin only in multi-user mode. → [architecture-invariants#config-backups](docs/architecture-invariants.md#config-backups) **Plan-usage chip** (`showPlanUsageLimits`, per-device: desktop default **ON**, handhelds OFF): resolve DISPLAY only through `planUsageChipEnabled()` (settings-ui.js). The same setting is the server-side COLLECTION switch, read fresh by `readPlanUsageTelemetryEnabled()` (hooks-config.ts) at every claude create/respawn. ⚠️ An ABSENT key reads as ON in the reader; `GET /api/settings` must never write. ⚠️ A save sends `showPlanUsageLimits` ONLY when it flips the chip on that device (`planUsageCollectionFlip()`), or a phone switches collection off for every desktop. Claude data comes from the statusLine exporter, injected as an EPHEMERAL `claude --settings` flag (`resolveStatusLineCliCommand`), never written to disk, WRAPPING a user's own statusLine, posting to `POST /api/status-telemetry`. Codex comes from a read-only `account/rateLimits/read` poll (main bucket only). The statusline's `prompt_cache` object (Claude Code v2.1.251+) is kept by `StatusTelemetrySchema` (every documented scalar field, null-tolerant; `parsePromptCache()` in usage-telemetry.ts normalizes only the stored subset) and shown in the footer as `cache:until HH:MM` / `cache:cold` (only when the user has no statusline of their own) and in Session Options → Respawn (`#promptCacheStatus`, fed by `Session.promptCache` in `toState()` plus the change-only `session:promptCache` SSE event, worded by the pure `CodemanPromptCache.format`): warm-until/cold, the re-cache stake, the hit rate, the last miss's causes, and a `Context compacted at HH:MM` note after a compaction or a tool-result clearing (stamped on the first warm report whose `recache_tokens_if_cold` is null after one that carried it, never from `expected_rebuilds`, which Claude Code bumps only at the next request; NEVER an alert). ⚠️ `Session.setPromptCache()` stamps `compactedAt` onto a NEW object (never mutating the parsed report) and the route broadcasts the STORED object; the last report is restored on restart (constructor `promptCache`, like `displayModel`) and the readout is time-aware (a passed `expiresAt` reads cold, not stale-warm). It is the source of truth for the cache TTL, never the plan tier. → [architecture-invariants#plan-usage-chip-statusline-telemetry](docs/architecture-invariants.md#plan-usage-chip-statusline-telemetry), `docs/usage-limits-display-plan.md` @@ -304,7 +304,7 @@ One module per domain in `src/web/routes/` (plus a barrel; `ls src/web/routes/` ## State Files -All in `~/.codeman/`: `state.json` (sessions, settings, respawn, orchestrator, cron jobs/runs, owner tab layouts), `mux-sessions.json` (tmux recovery), `settings.json` (user prefs), `push-keys.json` + `push-subscriptions.json`, `session-lifecycle.jsonl` (audit log), `update-status.json` (self-updater progress, polled across the service restart), `docker-env-applied.json` (Compose deployment only: sha256 of the Dockerfile + compose file the running container was built from, written by `Start-Codeman.sh`, read by the self-updater's environment gate), `docker-build-source.json` (Compose deployment only: the checkout's HEAD commit and `package-lock.json` hash the `codeman-node-modules`/`codeman-dist` volumes currently reflect, written by both `Start-Codeman.sh` and a successful in-place self-update, compared to detect and refresh a volume left stale by an externally-triggered rebuild), `linked-cases.json`, `webviews.json` (saved web-tab dashboard URLs), `remote-hosts.json` + `remote-cases.json`, `docker-hosts.json` + `docker-cases.json` + `docker-exports/`, `subagent-window-states.json` + `subagent-parents.json` (subagent window layout, GET/PUT `/api/subagent-window-states`/`-parents`), `hook-secret` (per-instance), `users.json` (multi-user, mode 0600) + `admin-audit.jsonl`, `intents.json` (Read My Mind intent profiles, mode 0600), `webhook.json` (webhook notifications: enabled/service/scope plus the ntfy/Slack/Discord URL, a bearer secret, so mode 0600, kept out of `settings.json` and never returned by `/api/webhook`, which is admin-only in multi-user mode), `certs/` (self-signed TLS for `--https`), `.env` (CODEMAN_USERNAME/PASSWORD fallback for the `codeman attach` CLI), `install.log` (installer step output, written by `install.sh`'s `run_step`) and `tailscale-rename` (the node name before `install.sh` renamed it, so uninstall can offer it back; both installer-route only). Transient: `self-update-runner.sh`. Multi-user case spaces live OUTSIDE the data dir at `~/codeman-users/<username>/cases` (shared across instances like `~/codeman-cases`, override `CODEMAN_USER_SPACES_DIR`). +All in `~/.codeman/`: `state.json` (sessions, settings, respawn, orchestrator, cron jobs/runs, owner tab layouts), `mux-sessions.json` (tmux recovery), `settings.json` (user prefs), `push-keys.json` + `push-subscriptions.json`, `session-lifecycle.jsonl` (audit log), `update-status.json` (self-updater progress, polled across the service restart), `docker-env-applied.json` (Compose deployment only: sha256 of the Dockerfile + compose file the running container was built from, written by `Start-Codeman.sh`, read by the self-updater's environment gate), `docker-build-source.json` (Compose deployment only: the checkout's HEAD commit and `package-lock.json` hash the `codeman-node-modules`/`codeman-dist` volumes currently reflect, written by both `Start-Codeman.sh` and a successful in-place self-update, compared to detect and refresh a volume left stale by an externally-triggered rebuild), `linked-cases.json`, `webviews.json` (saved web-tab dashboard URLs), `remote-hosts.json` + `remote-cases.json`, `docker-hosts.json` + `docker-cases.json` + `docker-exports/`, `subagent-window-states.json` + `subagent-parents.json` (subagent window layout, GET/PUT `/api/subagent-window-states`/`-parents`), `hook-secret` (per-instance), `users.json` (multi-user, mode 0600) + `admin-audit.jsonl`, `intents.json` (Read My Mind intent profiles, mode 0600), `webhook.json` (webhook notifications: enabled/service/scope plus the ntfy/Slack/Discord URL, a bearer secret, so mode 0600, kept out of `settings.json` and never returned by `/api/webhook`, which is admin-only in multi-user mode), `config-backup-id` (the random id config backups record as each manifest's `origin`, so instances sharing a backup folder keep to their own snapshots; mode 0600, never tracked or restored, and deleting it unlists this instance's earlier snapshots), `backups/config/` (the default config-backup folder, moved by `configBackupDir`: `cfg-YYYYMMDD-HHMMSS/` snapshots, 0700 with 0600 files, each with a `manifest.json` of SHA-256s, the origin and the retention in force (`keepCount`/`keepDays`), which a pass falls back to when `settings.json` lacks a retention key), `certs/` (self-signed TLS for `--https`), `.env` (CODEMAN_USERNAME/PASSWORD fallback for the `codeman attach` CLI), `install.log` (installer step output, written by `install.sh`'s `run_step`) and `tailscale-rename` (the node name before `install.sh` renamed it, so uninstall can offer it back; both installer-route only). Transient: `self-update-runner.sh`. Multi-user case spaces live OUTSIDE the data dir at `~/codeman-users/<username>/cases` (shared across instances like `~/codeman-cases`, override `CODEMAN_USER_SPACES_DIR`). **Generated top-level dirs** (all gitignored — don't edit or commit): `dist/` (esbuild output), `out/`, `coverage/`, `test-results/`, `tmp/`, `screenshots-echo-diag/`. The committed gesture bundle (`src/web/public/gesture/gesture-codeman.js`) IS tracked, but its runtime wasm/model assets (`src/web/public/gesture/wasm/`, `*.task`) are fetched and gitignored. diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 4c9bdbde..6a7d4b72 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -707,6 +707,36 @@ Matching semantics: a query containing `/` matches the relative path, otherwise ⚠️ Closing the preview must **pause and unload** the media (`_stopFilePreviewMedia` in panels-ui.js): dropping the overlay's `visible` class is `display:none` and nothing else, and a DETACHED `HTMLMediaElement` keeps playing, which is how the X button used to leave a video audible with no player to pause. +### Config backups + +**Config backups** (`src/config-backup.ts`, pure over the directories it is handed; `routes/config-backup-routes.ts`; the Config backups group in App Settings → System, `settings-ui.js`; contributed in #661, user guide `docs/wiki/Config-Backups.md`, endpoints in `docs/api-reference.md`): timestamped snapshots of the files that hold what a user customized, so a bad write can be undone. Settings (SYNCED): `configBackupEnabled` (absent = ON, since it protects against an event nobody schedules), `configBackupDir` (absolute, `~` expanded; empty, relative or the data dir itself = `<data dir>/backups/config`), `configBackupKeepCount` (2 to 500, default 20), `configBackupKeepDays` (0 to 3650, default 30, `0` = no age limit). A snapshot is a `cfg-YYYYMMDD-HHMMSS` folder (server local time, `-2`, `-3`… on a same-second collision; mode 0700) holding the tracked files under their own names plus a `manifest.json` (all 0600): `id`, `createdAt`, `reason` (`startup`/`auto`/`manual`/`pre-restore`), `appVersion`, the change-check `hash`, the `origin`, the `keepCount`/`keepDays` in force, and a SHA-256 and size per file. It is built in a `.tmp-cfg-*` folder and renamed into place; a pass sweeps temp folders older than an hour. Passes run at startup and every 5 minutes (`CONFIG_BACKUP_INTERVAL_MS` in `server.ts`, not in test mode), and on **Back up now** (forced). + +⚠️ **The tracked list is a fixed allowlist** (`CONFIG_BACKUP_FILES`), never "everything in the data dir", and restore writes only those names whatever a manifest lists. `state.json` is deliberately NOT tracked: it is rewritten on every session event, so it would make a snapshot per event and bury the real changes. It also holds cron jobs and respawn presets, which is the documented gap. + +⚠️ **One snapshot per real change.** The change check compares a digest of the file set with the newest manifest's `hash`, looking through `VOLATILE_KEYS`, fields a tracked file rewrites on its own: `custom-model-hosts.json` `lastDiscoveredAt` (the discovery poll), `users.json` `lastLoginAt` (every Basic login), `settings.json` `lastUsedCase` (a session started in another case), `intents.json` `recentPrompts`/`updatedAt` (every captured prompt), `docker-cases.json` `lastClaudeSessionId` (a `/clear` in a Docker case) and `webviews.json` `lastOpenedAt` (opening a web tab). Each was found the same way: one tick-driven rewrite made a snapshot per tick, and the 20 kept copies covered about 95 minutes. A new self-rewritten field in a tracked file needs an entry there and a row in the `it.each` table of `test/config-backup.test.ts`. The snapshot still stores the bytes and restore verifies the raw digest; only the comparison looks through these keys (so Compare calls `intents.json` unchanged when only prompts differ, yet a restore drops the prompts captured since). + +⚠️ **Retention deletes only what this module made, and never the newest snapshot.** A folder counts only with the name pattern AND a parseable manifest of this origin, so a custom folder that also holds other things is safe to point at. A snapshot is aged from its SUCCESSOR's `createdAt` (its state was live until the next one was made): aging it by its own date deleted the last good copy on the first change after a quiet month, which is exactly the bad write the feature exists for. The keep count floor is 2 (`CONFIG_BACKUP_MIN_KEEP_COUNT`), since 1 deletes the state before every change. A new snapshot is never dated earlier than the newest one (`createdAt + 1`): one written while the clock read ahead (dual boot, a VM resumed with a stale clock) stayed newest after the correction, differed from every later pass, and made each one write a copy until real time caught up. The flip side, a clock once set to a future year, is a Troubleshooting entry (delete the future-dated folder). + +⚠️ **The origin is a random id in `<data dir>/config-backup-id`, never the hostname.** Instances that share a folder (two machines on one synced folder, prod and a beta) must keep to their own snapshots, so the change check, list, prune, Compare and restore are all scoped to it; without that, each instance snapshotted on every tick and Restore offered the other machine's `users.json` and `.env`. The hostname was tried first and was wrong: a Docker Compose container gets a new one on every recreate, which unlisted every earlier snapshot, and since prune is origin-scoped they were never deleted either. The id is not the hook secret and is never tracked or restored. Listing never creates it (`readConfigBackupOrigin()`; only a pass or a restore does). A fresh or reinstalled data dir is a new owner; copying an `origin` from an old manifest into the file makes those snapshots visible again. + +⚠️ **Retention survives a bad `settings.json`.** Each manifest records the retention in force. `runConfigBackup()` reads `settings.json` itself, because `readJsonConfig` answers `{}` for any failure, including a read that lands inside the non-atomic write of `PUT /api/settings`: a file that does not parse still gets its snapshot but prunes nothing, and a retention key ABSENT from the file (a reset) means what the newest manifest that recorded one holds (`withRecordedRetention()`, per key, in the folder the file names, so re-entering a custom folder after a reset finds that folder's own history). `GET /api/config-backups` reports the same values the pass uses, the form fills from them, and Save sends a retention key only when it was saved before or edited there (`_configBackupRetentionPayload()`), so merely displaying the recorded retention never writes it back. Restoring `settings.json` keeps the four `configBackup*` keys at their live values (an old `configBackupDir` would point the list at another folder and hide the undo copy just made), except a retention key the live file lacks, which keeps the backup's. While `settings.json` does not parse a pass reads it as `{}`, so its snapshot goes to the default folder; nothing is widened. + +⚠️ **A custom folder is probed BOUNDED before anything reads it** (#516: it may be a network mount that stopped answering, and the GET runs every time App Settings opens). `probeBackupDir()` runs `probePathKind(dir, { pastCap: true })` before every pass and in every route, and `readEffectiveBackupSettings()` probes BEFORE the recorded-retention fallback lists the folder (that fallback once did a `readdirSync` first whenever only `configBackupDir` was set). `unknown` answers 503 `OPERATION_FAILED` through `describeUnknownPath()` and skips the pass; a missing custom folder is created only when its parent is present. The default folder needs no probe. `test/routes/config-backup-unreachable.test.ts` pins all four routes with a recording `node:fs` mock. + +⚠️ **One walk of the folder per pass.** `runConfigBackupPass()` reads the folder once and hands the names to the temp sweep and the manifests to the retention fallback, the change check and the prune (with the snapshot it just made prepended: it is always the newest, by the date floor above). Each of the three used to list the folder itself, so every manifest was read three times per pass, every five minutes, on what may be a network folder; restore lists once too. `test/routes/config-backup-one-walk.test.ts` counts the reads. + +⚠️ **Restore verifies everything before it writes anything.** Every file's SHA-256 against the manifest, and the id must match the name pattern (no traversal); a mismatch is a 409 with nothing changed. It also refuses a snapshot folder or file that is a symlink or has a group/world bit: this module only ever writes 0700/0600 real files, so such a thing was planted or loosened later, and rewriting it as 0600 would turn it into trusted config (`clis.json` selects the binaries Codeman spawns; `isUnsafePermissions` refuses the same bits there). The mode test is skipped where the filesystem keeps no modes (vfat/exFAT, CIFS, WSL `/mnt/d` without `metadata` report 0755/0777 for everything, and Restore would refuse every backup there): `folderKeepsModes()` decides by creating a 0600 probe file with a random name and `wx`, because the earlier fixed `.mode-probe-<pid>` name let a planted symlink be truncated and chmodded by one Compare click. Compare applies the same checks to the snapshots it reads. + +⚠️ **The undo copy comes first, and a partial restore is reported, never hidden.** A `pre-restore` snapshot of the live files precedes the writes; it is not forced, so when nothing changed since the newest backup that backup is the undo copy and is the one reported (a forced copy spent a retention slot on a duplicate). It records the retention in force, so it cannot hide that retention from the next pass. Each file is replaced atomically (temp + rename, 0600). A file that cannot be written (a full disk, a directory in its place) does not stop the others: `ConfigBackupPartialRestoreError` carries `restored`, `failed`, `untouched` and `safetySnapshot`, and the route answers 500 with the error envelope plus those fields (and `restartRequired`) in `data`, the shape the tab-layout 409 uses. The bare 500 it replaced named neither the files already put back nor the undo copy. + +⚠️ **What a restore wrote must reach the running server**, on the full and the partial path alike (`afterRestore()` in the route): `intentStore.reload()` (the store caches `intents.json` and rewrote it whole on the next captured prompt, reverting the restore), `reloadCliRegistry()` (a restore of `clis.json` is the one write that bypasses `mutateRegistryFile()`, see CLI registry), and in multi-user mode a `config_backup_restore` admin-audit entry (`users.json` holds accounts, password hashes, roles and bypass grants). `users.json` itself is re-read within a second by the user store. `.env`, `push-keys.json` and `settings.json` (whose watcher toggles only `PUT /api/settings` runs) apply after a restart: `RESTART_REQUIRED`, returned as `restartRequired`. Settings re-reads `/api/settings` into the form after any restore that wrote `settings.json`, partial ones included, because the form still holds the old values and the next Save would PUT them over the restored file. + +⚠️ **Compare never returns a credential.** `REDACT_ALL_FILES` (`.env`, `users.json`, `push-keys.json`, `webhook.json` whose URL is a bearer secret, `intents.json` whose prompts carry secrets) report changed key names only; anywhere else a key matching `SECRET_KEY_RE` (token, secret, password, credential, auth, `*key`) is reported as changed without its value, and URL values lose userinfo, query and fragment. Listing returns file names and sizes only. + +⚠️ **Admin only in multi-user mode**: all four routes answer 403 for a non-admin and the Settings group is hidden for them (`_applyDoctorAdminGate()`), because the files include credentials. + +Tests: `test/config-backup.test.ts`, `test/config-backup-origin.test.ts`, `test/config-backup-restore-ui.test.ts` (vm harness over the real `settings-ui.js`), `test/routes/config-backup-routes.test.ts`, `test/routes/config-backup-unreachable.test.ts`, `test/routes/config-backup-one-walk.test.ts`. + ## Frontend ### Command palette and shortcut registry From 6eaaa960ff012d6a3404a26b363c19ba11d4b591 Mon Sep 17 00:00:00 2001 From: "Claudia[bot]" <4437565+Claudia[bot]@users.noreply.github.com> Date: Thu, 22 Oct 2026 19:23:09 +0200 Subject: [PATCH 83/96] fix(tmux): keep browser keys from switching a tab onto another session (#669) Squash of PR #669. Browser terminals could be moved onto another session by tmux's prefix keys: Ctrl+B then ), (, s or w switched the tab's tmux client to a different session while the tab kept the original's name. Codeman's tmux socket now keeps only Ctrl+B Ctrl+B (send-prefix) in its prefix table, and codeman tui and scripts/tmux-manager.sh get F1 as their way out instead of Ctrl+B d. Merge-time fixes by the maintainer: on an empty prefix table the unbind-key that fails no longer skips binding send-prefix; a key tmux lists escaped (prefix C-\ shows as C-\\) compares equal, so the reset does not rerun on every create; and reconcileSessions() reduces the table at boot when the socket has live sessions, so sessions that survive an upgrade are covered too. Co-authored-by: christianhaberl <116439789+christianhaberl@users.noreply.github.com> Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- .changeset/tmux-prefix-table.md | 5 + CLAUDE.md | 1 + docs/architecture-invariants.md | 4 + docs/tui.md | 7 +- docs/wiki/Core-Concepts.md | 5 +- scripts/tmux-manager.sh | 51 ++++- src/tmux-manager.ts | 10 + src/tmux-prefix-table.ts | 138 ++++++++++++ src/tui/tui-app.ts | 31 ++- test/tmux-manager-script-attach.test.ts | 123 +++++++++++ test/tmux-manager.test.ts | 99 ++++++++- test/tmux-prefix-table.test.ts | 280 ++++++++++++++++++++++++ test/tui/tui-app.test.ts | 43 +++- test/tui/tui-e2e.test.ts | 6 + 14 files changed, 775 insertions(+), 28 deletions(-) create mode 100644 .changeset/tmux-prefix-table.md create mode 100644 src/tmux-prefix-table.ts create mode 100644 test/tmux-manager-script-attach.test.ts create mode 100644 test/tmux-prefix-table.test.ts diff --git a/.changeset/tmux-prefix-table.md b/.changeset/tmux-prefix-table.md new file mode 100644 index 00000000..d711b9c2 --- /dev/null +++ b/.changeset/tmux-prefix-table.md @@ -0,0 +1,5 @@ +--- +"aicodeman": patch +--- + +Browser terminals can no longer be moved onto another session by tmux's prefix keys: typing Ctrl+B then `)`, `(`, `s` or `w` in a tab switched its tmux client to a different session while the tab kept the original's name. Codeman's tmux socket now keeps only Ctrl+B Ctrl+B (send-prefix) in its prefix table, so `Ctrl+B d` no longer detaches a terminal attached there: `codeman tui` and `scripts/tmux-manager.sh` detach with F1. diff --git a/CLAUDE.md b/CLAUDE.md index bb78756b..ebadaabe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -102,6 +102,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph - **`xterm-zerolag-input` is single-source** — BOTH echo addons live ONLY in `packages/xterm-zerolag-input/src/`, bundled into TWO **gitignored** vendor files: `vendor/xterm-zerolag-input.js` (buffer overlay, entry `zerolag-input-addon.ts`) and `vendor/xterm-predictive-echo.js` (codex write-through, entry `predictive-echo-addon.ts`) — dev by `scripts/postinstall.js`, prod by `scripts/build.mjs`. `app.js`/terminal-ui.js only **consume** them via `new LocalEchoOverlay(terminal)` / `new PredictiveEchoOverlay(terminal)`; there is no inline copy. So: change the package source, then rerun the bundle step (`npm install` for dev, `npm run build` for prod). **Never hand-edit `app.js` for overlay behavior, and never commit the gitignored vendor bundles.** Always test on mobile after touching it. → [architecture-invariants#xterm-zerolag-input-is-single-source](docs/architecture-invariants.md#xterm-zerolag-input-is-single-source), `docs/local-echo-overlay-plan.md` - **Default bind is loopback-only; non-loopback without a password starts but warns** — the server defaults to `--host 127.0.0.1`. Binding non-loopback (`--host`/`-H`/`CODEMAN_HOST`) without `CODEMAN_PASSWORD` starts anyway but prints a loud warning; `--allow-unauthenticated-network` / `CODEMAN_ALLOW_UNAUTHENTICATED_NETWORK=1` acknowledges it. ⚠️ The production systemd unit passes no `--host`, so prod binds **localhost only**: reach it via `tailscale serve`/tunnel to `127.0.0.1`. A loopback bind is reachable through a same-host tunnel but NOT by a browser hitting the box's LAN IP. `install.sh` is separate and prompts for the binding (defaulting to LAN + a password), and preserves the existing binding on re-runs. → [architecture-invariants#default-bind-and-the-non-loopback-warning-path](docs/architecture-invariants.md#default-bind-and-the-non-loopback-warning-path), `docs/security-architecture.md` - **Instance isolation / multi-instance attach danger** — the data dir (`~/.codeman`) and tmux socket (`tmux -L codeman`) are PROCESS-WIDE and shared by every Codeman on the machine, derived from `CODEMAN_INSTANCE` via `src/config/instance.ts`. ⚠️ A 2nd instance on the SAME socket **discovers and attaches PTYs to the first instance's live sessions**, resizing and mutating them. `$HOME` isolation is NOT enough because tmux is system-global. To run two instances, give each a distinct `CODEMAN_INSTANCE` (scopes dir + socket together), or set `CODEMAN_TMUX_SOCKET` + `CODEMAN_DATA_DIR` individually; `scripts/run-beta.sh` does this for a beta alongside prod. **Any new `~/.codeman/...` path MUST go through `dataPath()`**, never `join(homedir(), '.codeman', …)`, and **any new `tmux -L` caller through `resolveTmuxSocketName()`** (both in `config/instance.ts`): the TUI shells out to tmux from a second process, and a hardcoded `codeman` there would point a beta instance at prod's panes. → [architecture-invariants#instance-isolation-and-the-multi-instance-attach-danger](docs/architecture-invariants.md#instance-isolation-and-the-multi-instance-attach-danger) +- **The prefix table on Codeman's socket holds only `<prefix> send-prefix`** — browser tabs are tmux attach clients, and tmux's stock bindings let Ctrl+B `)`/`s` move a tab onto another session. `createSession` resets the table, and `reconcileSessions()` at boot for sessions that survived an upgrade (`src/tmux-prefix-table.ts`); `root` and `C-<letter> detach-client` (the TUI attach's way out) are left alone, so while a `codeman tui` attach is open its `root` keys reach browser tabs too, Alt+1..9 `switch-client` included (open follow-up). ⚠️ So `Ctrl+B d` does not exist there: anything attaching a user's terminal claims its own detach key (F1 in `root`, as `codeman tui` and `scripts/tmux-manager.sh` do). → [architecture-invariants#the-prefix-table-on-codemans-socket-holds-only-send-prefix](docs/architecture-invariants.md#the-prefix-table-on-codemans-socket-holds-only-send-prefix) - **node-pty's macOS `spawn-helper` ships without `+x`** (issues #6, #204): `node-pty@1.1.0` publishes `prebuilds/darwin-<arch>/spawn-helper` as mode 0644, and macOS launches every PTY through it, so a stock macOS install fails every session start with `Error: posix_spawnp failed.` **Linux can never reproduce it**: `spawn-helper` is an `OS=="mac"` gyp target and node-pty ships no Linux prebuild, so node-gyp always emits an executable helper there. ⚠️ The flip side of that: since Linux has no prebuild, `npm install` **needs a C/C++ toolchain there** (`make`, `g++`, `python3`), so `install.sh` checks for and installs one alongside Node/tmux/git — a stock Ubuntu 24 server has none and died inside node-gyp with `not found: make`. Do not drop that step. ⚠️ Look in **`prebuilds/<platform>-<arch>/`**, not just `build/Release/`, which does not exist on macOS. Repair is a chmod, never a mandatory rebuild (that would require Xcode CLI tools and deletes `prebuilds/` before compiling): `npm run fix:node-pty` chmods every helper then proves it by really opening a PTY. `spawnPtyWithHelperRepair()` (`utils/node-pty-repair.ts`) wraps every `pty.spawn()` in `session.ts` and self-heals a broken install on the first failure. → [architecture-invariants#node-ptys-macos-spawn-helper-must-be-executable](docs/architecture-invariants.md#node-ptys-macos-spawn-helper-must-be-executable) - **User-chosen paths are probed BOUNDED, never with `existsSync`/`statSync`** (#516): a linked case or a `workingDir` can sit on a network mount that stopped answering. A synchronous check there freezes the whole server, and an unbounded async `stat`/`lstat`/`readFile` holds one of libuv's threadpool workers (4 by default, shared with every `fs`, `dns.lookup` and `crypto` call) until the mount returns. On a request or spawn path use `probePath()`/`probePathKind()` (`utils/bounded-path-probe.ts`, read its `@fileoverview`). ⚠️ `unknown` is NEVER `absent`: nothing is created, scaffolded or 404'd on it. Bulk scans keep the stall cap; a request for ONE path the user named may pass `{ pastCap: true }`, which still stops at the ceiling that keeps one worker free; and a helper that would otherwise touch the path skips whatever is still `unknown`. User-facing errors for it go through `describeUnknownPath()`, so a refused probe is not reported as a broken folder. - **Headless screenshots: `deviceScaleFactor` MUST be 1, and write unique filenames** — under DSF=2 xterm's WebGL renderer draws glyphs at ~2× nominal size while still *reporting* nominal cell dims, so only the pixels reveal it and only the terminal font looks wrong. And overwriting a fixed output path leaves OS image viewers showing the old render, which reads as "the fix didn't work"; `scripts/capture-real-overview.mjs` mints a timestamped filename per run. Seed the per-device `localStorage` keys (`codeman:skin`, `codeman-font-size`, `codeman-app-settings`) so the capture matches a real device. → [architecture-invariants#headless-screenshot-capture](docs/architecture-invariants.md#headless-screenshot-capture) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 45d02ca5..473a52b8 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -16,6 +16,10 @@ Implementation detail extracted from `CLAUDE.md` so that file stays small enough **Instance isolation / multi-instance attach danger** — data dir (`~/.codeman`) and tmux socket (`tmux -L codeman`) are PROCESS-WIDE and shared by every Codeman on the machine, derived from `CODEMAN_INSTANCE` via `src/config/instance.ts` (`getDataDir()`/`dataPath()`/`DEFAULT_TMUX_SOCKET`). ⚠️ A 2nd instance on the SAME socket **discovers and attaches PTYs to the first instance's live sessions** (`tmux -L codeman attach-session …`), resizing/mutating them — `$HOME` isolation is NOT enough (tmux is system-global). To run two instances, give each a distinct `CODEMAN_INSTANCE` (scopes BOTH dir+socket: `~/.codeman-<name>` + `-L codeman-<name>`), or set `CODEMAN_TMUX_SOCKET` + `CODEMAN_DATA_DIR` individually. **`CODEMAN_INSTANCE` defaults to empty = the production layout (`~/.codeman`, `-L codeman`, port 3000)**, so this branch is safe to ship to master without disturbing existing installs. To run THIS beta alongside prod, launch with `scripts/run-beta.sh` (`CODEMAN_INSTANCE=beta` + `CODEMAN_PORT=5000`) — it never collides with prod's data dir/socket/port. Any new `~/.codeman/...` path MUST go through `dataPath()`, never `join(homedir(), '.codeman', …)`, and any new `tmux -L` caller through `resolveTmuxSocketName()` (same module): it applies the `CODEMAN_TMUX_SOCKET` override only when the name is safe and falls back to `DEFAULT_TMUX_SOCKET` otherwise. `TmuxManager` was the only such caller until `codeman tui` shelled out to tmux from a SECOND process for its degraded-mode listing and its attach handoff; a hardcoded `codeman` there would have pointed a beta instance straight at prod's panes. +### The prefix table on Codeman's socket holds only send-prefix + +**Browser tabs are tmux clients, so tmux's prefix table must not reach `switch-client`** (`src/tmux-prefix-table.ts`, called from `createSession` before `respawn-pane`, and from `reconcileSessions()` at boot whenever the socket lists a pane, so sessions that survived an upgrade are covered). Every keystroke typed into a browser terminal reaches the `tmux attach-session` client behind it, and app.js deliberately lets Ctrl+B through to the terminal (readline, Claude Code's "ctrl+b ctrl+b"). With tmux's stock prefix table, Ctrl+B then `(` `)` `L` `s` `w` moved that client onto ANOTHER session, which the tab then showed and typed into under the original's label; `d` detached it, `$` renamed the session, `:` opened tmux's prompt. So the table is reduced to `<prefix> send-prefix` (the socket's configured prefix, read with `show-options -gv prefix`), once per tmux server in practice: the reset only runs while `list-keys -T prefix` shows anything else. ⚠️ Keep `send-prefix`: Claude Code inside tmux backgrounds a command with "ctrl+b ctrl+b". ⚠️ Never touch the `root` table, and tolerate (and re-bind after a reset) `C-<letter> detach-client`: `codeman tui`'s attach claims F1 and Alt+1..9 in `root` and the held-Ctrl alias `C-d` in `prefix` (`beginAttachHandoff`), and a session created mid-attach must not take that way out away. ⚠️ Key tables are server-wide, so what the TUI binds for its attach applies to browser tabs as well: F1 and `C-d` detach a tab's client, and **`M-1`..`M-9` (`switch-client -t <session>`) move it onto another session**, the very thing this reset exists to stop. The browser keeps a typed Alt+digit out of the terminal (`terminal-ui.js` swallows it for its own tab switching), but Esc followed by a digit within tmux's `escape-time` (500 ms on tmux 3.4) arrives as the same `M-<digit>`. This lasts for the length of the attach, and indefinitely after a TUI that is killed before `restore()` runs. Not fixed here; scoping those bindings to the TUI's own client is the open follow-up. ⚠️ The consequence for every terminal attach: `Ctrl+B d` does not exist on this socket. Anything that attaches a user's terminal claims its own way out for the attach, as `beginAttachHandoff` and `scripts/tmux-manager.sh` (F1 in `root`) do, and never advertises tmux's stock chord. Tests: `test/tmux-prefix-table.test.ts` (real tmux on a private socket, driven through a pty attach client), the wiring in `test/tmux-manager.test.ts`, the script in `test/tmux-manager-script-attach.test.ts`. + ### Reverse-proxy base path **Reverse-proxy base path** (`--base-url` / `CODEMAN_BASE_URL`, default `/`; `src/config/base-path.ts` is the pure single-source, normalized to `''` for root or `/foo`): lets Codeman be mounted under a sub-path behind a proxy that **forwards the prefix unchanged** (does NOT strip it). Deliberately few choke points, mirrored ingress/egress: diff --git a/docs/tui.md b/docs/tui.md index 9907d5f1..55d981e6 100644 --- a/docs/tui.md +++ b/docs/tui.md @@ -178,8 +178,11 @@ bar first named the wrong letter (tmux binds lowercase `d` to `detach-client` an capital `D` to `choose-client`), and once corrected it still failed for anyone who kept Ctrl held, because that sends `Ctrl+D`, which tmux leaves unbound. So the TUI claims `F1` in tmux's prefix-less key table for the length of the attach and gives -it back afterwards. The chord still works; it is simply not what you are told to -press. +it back afterwards. The held-Ctrl chord (`Ctrl+B Ctrl+D`) works too, as the TUI +claims it for the attach as well. Plain `Ctrl+B d` does not: on Codeman's socket +the prefix table holds nothing but `send-prefix`, because the browser's terminals +are tmux clients on the same server and must not be able to `switch-client` or +`detach-client` themselves (`src/tmux-prefix-table.ts`). You do not have to remember any of it. For as long as the attach lasts the pane wears a bar across the top: diff --git a/docs/wiki/Core-Concepts.md b/docs/wiki/Core-Concepts.md index 60da25e6..3aab2bae 100644 --- a/docs/wiki/Core-Concepts.md +++ b/docs/wiki/Core-Concepts.md @@ -97,7 +97,10 @@ would. That indirection buys: - **Real scrollback.** History is held by tmux, so reconnecting replays what happened while you were gone instead of starting from blank. - **Attach from anywhere else.** The same session is reachable from a terminal over SSH - with `codeman tui`, or plain `tmux -L codeman attach`. + with `codeman tui` (F1 detaches), `./scripts/tmux-manager.sh` (likewise F1), or plain + `tmux -L codeman attach`. A plain attach has no detach key: Codeman keeps tmux's prefix + table on its socket down to `send-prefix`, so `Ctrl+B d` does nothing there; leave it + with `tmux -L codeman detach-client` from another shell, or bind a key yourself. - **Secrets off the command line.** Environment overrides are injected with socket-scoped `tmux setenv` rather than being visible in the spawn command. diff --git a/scripts/tmux-manager.sh b/scripts/tmux-manager.sh index 169535ef..a3777ac9 100755 --- a/scripts/tmux-manager.sh +++ b/scripts/tmux-manager.sh @@ -27,6 +27,13 @@ SESSIONS_FILE="${HOME}/.codeman/mux-sessions.json" CODEMAN_TMUX_SOCKET="${CODEMAN_TMUX_SOCKET:-codeman}" TMUX_CMD=(tmux -L "$CODEMAN_TMUX_SOCKET") +# Codeman keeps the prefix table on its socket down to send-prefix (browser tabs +# are attach clients on the same server, src/tmux-prefix-table.ts), so Ctrl+B d +# does not detach here. An attach claims F1 in the root table instead, like +# `codeman tui` does, and only when nothing else holds it. +DETACH_KEY="F1" +CLAIMED_DETACH_KEY=0 + # Cached data CACHED_JSON="" @@ -36,12 +43,46 @@ REFRESH_INTERVAL=5 # Refresh data every 5 seconds # Cleanup on exit cleanup() { + release_detach_key tput cnorm 2>/dev/null stty echo 2>/dev/null echo "" } trap cleanup EXIT +# Command bound to $DETACH_KEY in the root table; empty when the key is free +detach_key_binding() { + "${TMUX_CMD[@]}" list-keys -T root 2>/dev/null \ + | sed -nE "s/^bind-key +(-[^T ][^ ]* +)*-T root ${DETACH_KEY} +//p" | sed -E 's/ +$//' +} + +release_detach_key() { + if [[ $CLAIMED_DETACH_KEY -eq 1 ]]; then + [[ "$(detach_key_binding)" == "detach-client" ]] && "${TMUX_CMD[@]}" unbind-key -T root "$DETACH_KEY" 2>/dev/null + CLAIMED_DETACH_KEY=0 + fi +} + +# Attach to a session with a key that gets you back out +attach_session() { + local mux_name=$1 bound status + bound=$(detach_key_binding) + if [[ -z "$bound" ]] && "${TMUX_CMD[@]}" bind-key -T root "$DETACH_KEY" detach-client 2>/dev/null; then + CLAIMED_DETACH_KEY=1 + bound="detach-client" + fi + if [[ "$bound" == "detach-client" ]]; then + echo -e "${CYAN}Attaching... (${DETACH_KEY} to detach)${NC}" + else + echo -e "${CYAN}Attaching... (${DETACH_KEY} is taken; detach from another shell: tmux -L $CODEMAN_TMUX_SOCKET detach-client -s $mux_name)${NC}" + fi + sleep 0.3 + "${TMUX_CMD[@]}" attach-session -t "$mux_name" + status=$? + release_detach_key + return $status +} + # Check dependencies check_dependencies() { if ! command -v jq &> /dev/null; then @@ -352,9 +393,7 @@ interactive_menu() { if check_alive "$mux_name"; then tput cnorm clear - echo -e "${CYAN}Attaching... (Ctrl+B D to detach)${NC}" - sleep 0.3 - "${TMUX_CMD[@]}" attach-session -t "$mux_name" + attach_session "$mux_name" tput civis need_full_redraw=1 force_refresh @@ -483,7 +522,11 @@ main() { [[ -z "${2:-}" ]] && { echo "Usage: $0 attach <N>"; exit 1; } force_refresh local mux_name=$(get_session_field $(($2-1)) "muxName") - check_alive "$mux_name" && "${TMUX_CMD[@]}" attach-session -t "$mux_name" || echo "Session dead or not found" + if check_alive "$mux_name"; then + attach_session "$mux_name" + else + echo "Session dead or not found" + fi ;; kill) [[ -z "${2:-}" ]] && { echo "Usage: $0 kill <N|N,M|N-M>"; exit 1; } diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index 92af82b8..fbcd2733 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -125,6 +125,7 @@ import { import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js'; import { ensureDeepSeekStatusShim } from './deepseek-status-shim.js'; +import { restrictTmuxPrefixTable } from './tmux-prefix-table.js'; /** How long a cached process snapshot stays usable. */ const PROC_SNAPSHOT_TTL_MS = 2000; @@ -2491,6 +2492,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { /* Non-critical */ } + // Browser keystrokes reach the attach client, so tmux's stock prefix table would + // let Ctrl+B ( or s move a tab onto another session. Before the agent starts. + restrictTmuxPrefixTable(this.tmux()); + // Per-CLI env: API keys, config blobs, config-sourced vars, status bridges. All of // it is registry data, so this is one unconditional call rather than a per-mode ladder. this._configureCliEnv( @@ -3176,6 +3181,11 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { active = new Map(); } + // Sessions that survived an upgrade may sit on a server whose prefix table was never + // reduced (createSession does it for new ones), so cover them at boot too. Only with + // panes listed: on an empty socket `list-keys` spins up a throwaway server instead. + if (active.size > 0) restrictTmuxPrefixTable(this.tmux()); + // Check tracked sessions against the live pane list. for (const [sessionId, session] of this.sessions) { const pid = active.get(session.muxName); diff --git a/src/tmux-prefix-table.ts b/src/tmux-prefix-table.ts new file mode 100644 index 00000000..4e37716c --- /dev/null +++ b/src/tmux-prefix-table.ts @@ -0,0 +1,138 @@ +/** + * @fileoverview Keep tmux's prefix table on Codeman's socket down to `send-prefix`. + * + * Codeman drives tmux through commands, never through key bindings, but every + * keystroke typed into a browser terminal reaches the `tmux attach-session` + * client behind it. With tmux's stock prefix table that client obeys the whole + * menu: Ctrl+B then `(`, `)`, `L`, `s` or `w` runs `switch-client` / + * `choose-tree` and re-attaches the tab's client to a DIFFERENT session, so the + * tab shows, and types into, another session's agent while Codeman still labels + * it as the original. Ctrl+B `d` detaches the client, `$` renames the session + * under Codeman's feet, `:` opens tmux's command prompt. Ctrl+B is not a rare + * key in a terminal (readline backward-char, Claude Code's background chord), + * and whatever is typed next lands in that menu. + * + * The prefix itself stays: Claude Code running inside tmux asks for + * "ctrl+b ctrl+b" to background a command, which only works through + * `send-prefix`. So the table keeps exactly that one binding, on whatever key + * the socket's prefix is (the socket reads the user's `~/.tmux.conf`). + * + * `codeman tui` shares the socket (src/tui/tui-app.ts, beginAttachHandoff). For + * the length of an attach it claims a held-Ctrl detach alias (`C-d`) in the + * prefix table, and its one-key way out (F1) and Alt+1..9 in the `root` table. + * `root` is never touched here, and a `C-<letter> detach-client` binding is left + * alone (and put back when a reset clears the rest of the table around it), so + * creating a session while a TUI attach is open does not take the attach's way + * out away. The reset only runs while the table holds anything else, i.e. once + * per tmux server in practice. + * + * That tolerance is server-wide like every key table: while such an alias is + * bound, a browser tab's client can detach itself with it too. The same goes + * for the `root` keys an attach claims, including Alt+1..9, which run + * `switch-client`: a tab that receives Esc then a digit within tmux's + * `escape-time` is moved onto another session for as long as those bindings + * exist. Leaving `root` alone keeps the attach working; it does not close that. + * + * @module tmux-prefix-table + */ + +import { execSync } from 'node:child_process'; +import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js'; +import { shellescape } from './remote-hosts.js'; + +/** tmux's stock prefix, used when `show-options` cannot be read. */ +const DEFAULT_PREFIX = 'C-b'; + +/** One `list-keys -T prefix` line: key and command. */ +const PREFIX_LINE = /^bind-key\s+(?:-\S+\s+)*?-T\s+prefix\s+(\S+)\s+(.+)$/; + +/** The held-Ctrl detach alias `codeman tui` claims during an attach (heldCtrlAlias). */ +const TUI_DETACH_ALIAS = /^C-[a-z]$/; + +/** + * A key as `list-keys` prints it, without tmux's backslash escaping: `show-options` + * prints the prefix `C-\` as is, while the listing shows `C-\\` (and `\#`, `\$`, `\"`). + */ +function unescapeListedKey(key: string): string { + return key.replace(/\\(.)/g, '$1'); +} + +/** + * Whether the prefix table (as printed by `tmux list-keys -T prefix`) still holds + * a binding other than `<prefix> send-prefix` or a TUI detach alias, or lacks + * `<prefix> send-prefix`. Unparseable output counts as "needs a reset": + * resetting is idempotent, skipping one leaves `switch-client` reachable from + * the browser. A prefix of `None` expects no `send-prefix` binding at all. + */ +export function prefixTableNeedsReset(listKeysOutput: string, prefix: string = DEFAULT_PREFIX): boolean { + let hasSendPrefix = false; + for (const raw of listKeysOutput.split('\n')) { + const line = raw.trim(); + if (!line) continue; + const match = PREFIX_LINE.exec(line); + if (!match) return true; + const key = unescapeListedKey(match[1]); + const command = match[2].trim(); + if (command === 'send-prefix' && key === prefix) { + hasSendPrefix = true; + continue; + } + if (command === 'detach-client' && TUI_DETACH_ALIAS.test(key) && key !== prefix) continue; + return true; + } + return prefix === 'None' ? false : !hasSendPrefix; +} + +/** The `C-<letter> detach-client` bindings in a `list-keys -T prefix` listing (see prefixTableNeedsReset). */ +function tuiDetachAliases(listKeysOutput: string, prefix: string): string[] { + const keys: string[] = []; + for (const raw of listKeysOutput.split('\n')) { + const match = PREFIX_LINE.exec(raw.trim()); + if (!match) continue; + const key = unescapeListedKey(match[1]); + if (match[2].trim() === 'detach-client' && TUI_DETACH_ALIAS.test(key) && key !== prefix) keys.push(key); + } + return keys; +} + +/** Runs one tmux command line and returns its stdout. */ +export type TmuxRunner = (command: string) => string; + +const defaultRunner: TmuxRunner = (command) => + execSync(command, { encoding: 'utf8', timeout: EXEC_TIMEOUT_MS, stdio: ['ignore', 'pipe', 'ignore'] }); + +/** + * Reduce the prefix table of the tmux server behind `tmux` (e.g. `tmux -L 'codeman'`) + * to `<prefix> send-prefix`, when it holds anything more. Never throws: a failure + * only leaves tmux's stock bindings in place. Returns whether a reset ran. + */ +export function restrictTmuxPrefixTable(tmux: string, run: TmuxRunner = defaultRunner): boolean { + let prefix = DEFAULT_PREFIX; + try { + prefix = run(`${tmux} show-options -gv prefix`).trim() || DEFAULT_PREFIX; + } catch { + /* keep tmux's stock prefix */ + } + let listing: string | null; + try { + listing = run(`${tmux} list-keys -T prefix`); + } catch { + listing = null; // an empty table makes list-keys fail on some tmux builds + } + if (listing !== null && !prefixTableNeedsReset(listing, prefix)) return false; + try { + run(`${tmux} unbind-key -a -T prefix`); + } catch { + /* an empty table makes unbind-key -a fail too ("table prefix doesn't exist"); nothing to clear */ + } + try { + if (prefix !== 'None') run(`${tmux} bind-key -T prefix ${shellescape(prefix)} send-prefix`); + // `unbind-key -a` also took a running TUI attach's held-Ctrl way out; give it back. + for (const key of tuiDetachAliases(listing ?? '', prefix)) { + run(`${tmux} bind-key -T prefix ${shellescape(key)} detach-client`); + } + return true; + } catch { + return false; + } +} diff --git a/src/tui/tui-app.ts b/src/tui/tui-app.ts index b482ad15..cc837aee 100644 --- a/src/tui/tui-app.ts +++ b/src/tui/tui-app.ts @@ -317,6 +317,18 @@ export function detachChord(prefix?: string, key?: string): string { return `${formatPrefixKey(prefix)} then ${key || DEFAULT_DETACH_KEY}`; } +/** + * The chord an attach advertises when it could not claim the one-key way out. + * + * With no key bound to `detach-client` in the prefix table there is no stock + * `d` to name: Codeman keeps only `send-prefix` there (src/tmux-prefix-table.ts), + * so the held-Ctrl alias the attach claimed is the chord that works. + */ +export function fallbackDetachChord(prefix?: string, detachKey?: string, heldAlias?: string): string { + if (!detachKey && heldAlias) return detachChord(prefix, formatPrefixKey(heldAlias)); + return detachChord(prefix, detachKey); +} + /** `#` opens `#[…]`/`#{…}` in a tmux format, so a name carrying one must double it. */ function escapeTmuxFormat(value: string): string { return value.replace(/#/g, '##'); @@ -355,10 +367,10 @@ export function buildAttachBanner(options: { /** Alt+1..9 really switch sessions, so the bar may say so. */ switchKeys?: boolean; }): Record<string, string> { - const chord = escapeTmuxFormat(detachChord(options.prefix, options.detachKey)); + const chord = escapeTmuxFormat(fallbackDetachChord(options.prefix, options.detachKey, options.heldAlias)); // Named on the bar because it is what people actually type: keeping Ctrl held // is the common way to press this, and the bar has to say that it works. - const held = options.heldAlias ? escapeTmuxFormat(formatPrefixKey(options.heldAlias)) : ''; + const held = options.heldAlias && options.detachKey ? escapeTmuxFormat(formatPrefixKey(options.heldAlias)) : ''; const alias = held ? ` (or ${held})` : ''; const label = escapeTmuxFormat(truncateLabel((options.label ?? '').trim(), ATTACH_BANNER_LABEL_MAX)); // ONE option, not `status-left`/`status-right`/`status-style`: `status-format[0]` @@ -624,7 +636,7 @@ export async function beginAttachHandoff( if (target === muxName) await client.followAttachingClient(target); } return { - chord: oneKey ? ONE_KEY_DETACH : detachChord(prefix, detachKey), + chord: oneKey ? ONE_KEY_DETACH : fallbackDetachChord(prefix, detachKey, claimed && alias ? alias : undefined), async restore(): Promise<void> { for (const key of switchKeys) await client.unbindSwitchKey(key); if (oneKey) await client.unbindDetachKey(ONE_KEY_DETACH, 'root'); @@ -702,7 +714,7 @@ export interface TuiKeymapContext { /** False in degraded mode, where the only verb that works is attach. */ server: boolean; approval?: TuiApprovalKeys; - /** tmux's detach chord as this socket reports it. Defaults to the stock `Ctrl+B D`. */ + /** How the last attach said to leave it. Defaults to F1, the key an attach claims. */ detach?: string; /** A dead-row card is offering `r` to resume, so the footer has to say so. */ resumeOffer?: boolean; @@ -761,7 +773,7 @@ export function helpKeysFor(glyphs: TuiGlyphSet, context: TuiKeymapContext): Arr ['tab', 'next session'], // The one key that is not the TUI's: an attach hands the terminal to tmux, // and leaving it is the question every first attach asks. - [context.detach ?? detachChord(), 'detach from an attached session, back to here'], + [context.detach ?? ONE_KEY_DETACH, 'detach from an attached session, back to here'], ]; if (context.server) { keys.push( @@ -1109,11 +1121,12 @@ class TuiApp { private readonly glyphs: TuiGlyphSet; private readonly socket = resolveTmuxSocketName(); /** - * How to leave an attach, in the local tmux's own prefix. Read per attach - * (a session can override the prefix) and remembered so the help overlay - * names the real chord even before the first attach. + * How to leave an attach. Read per attach (F1 may be the user's, a session + * can override the prefix) and remembered for the help overlay; until the + * first attach it names F1, what an attach claims unless the user's config + * already holds it. */ - private detachChordLabel = detachChord(); + private detachChordLabel: string = ONE_KEY_DETACH; /** True for the length of one resume. The only thing standing between a resume and a loop. */ private resuming = false; diff --git a/test/tmux-manager-script-attach.test.ts b/test/tmux-manager-script-attach.test.ts new file mode 100644 index 00000000..6998fc06 --- /dev/null +++ b/test/tmux-manager-script-attach.test.ts @@ -0,0 +1,123 @@ +/** + * @fileoverview scripts/tmux-manager.sh attaches with a way back out. + * + * Codeman keeps the prefix table on its socket down to `send-prefix` + * (src/tmux-prefix-table.ts), so tmux's `Ctrl+B d` no longer detaches there. The + * script's attach claims F1 in the root table for the length of the attach instead, + * and leaves a binding the user owns alone. + * + * Runs the real script against a REAL tmux server on a private throwaway socket + * (never Codeman's own) with a throwaway HOME, through a pty like a terminal would. + * + * Port: N/A (no server) + */ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { execSync } from 'node:child_process'; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import * as pty from 'node-pty'; + +const hasTools = (() => { + try { + execSync('tmux -V && jq --version', { stdio: 'ignore' }); + return true; + } catch { + return false; + } +})(); + +describe.skipIf(!hasTools)('scripts/tmux-manager.sh attach (real tmux, private socket)', () => { + const socket = `codeman-script-test-${process.pid}`; + const tmux = `tmux -L ${socket} -f /dev/null`; + const run = (cmd: string): string => execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }); + const rootKeys = () => run(`${tmux} list-keys -T root`); + const clients = () => run(`${tmux} list-clients -F '#{client_session}'`).trim(); + let home = ''; + + async function waitFor(check: () => boolean, ms = 4000): Promise<boolean> { + const deadline = Date.now() + ms; + while (Date.now() < deadline) { + if (check()) return true; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + return check(); + } + + /** `tmux-manager.sh attach 1` in a pty; resolves its output once it exits. */ + function attach(): { script: pty.IPty; exited: Promise<string> } { + const env = { ...process.env, HOME: home, CODEMAN_TMUX_SOCKET: socket } as Record<string, string>; + delete env.TMUX; // the suite may itself run inside tmux + const script = pty.spawn('bash', ['scripts/tmux-manager.sh', 'attach', '1'], { + name: 'xterm-256color', + cols: 100, + rows: 24, + cwd: process.cwd(), + env, + }); + let output = ''; + script.onData((data) => (output += data)); + const exited = new Promise<string>((resolve) => script.onExit(() => resolve(output))); + return { script, exited }; + } + + beforeAll(() => { + home = mkdtempSync(join(tmpdir(), 'codeman-script-home-')); + mkdirSync(join(home, '.codeman')); + writeFileSync(join(home, '.codeman', 'mux-sessions.json'), JSON.stringify([{ name: 'alpha', muxName: 'alpha' }])); + run(`${tmux} new-session -d -s alpha -x 80 -y 24 'cat'`); + // What Codeman leaves on its socket: nothing but send-prefix in the prefix table. + run(`${tmux} unbind-key -a -T prefix`); + run(`${tmux} bind-key -T prefix C-b send-prefix`); + }); + + afterAll(() => { + let socketPath = ''; + try { + socketPath = run(`${tmux} display-message -p '#{socket_path}'`).trim(); + run(`${tmux} kill-server`); // our own throwaway socket only + } catch { + /* already gone */ + } + if (socketPath.endsWith(`/${socket}`)) rmSync(socketPath, { force: true }); + if (home) rmSync(home, { recursive: true, force: true }); + }); + + it('F1 detaches back out of the attach, and the claim is given back afterwards', async () => { + expect(rootKeys()).not.toMatch(/-T root F1\s/); + const { script, exited } = attach(); + try { + expect(await waitFor(() => clients() === 'alpha')).toBe(true); + expect(rootKeys()).toMatch(/-T root F1\s+detach-client/); + + script.write('\x1bOP'); // F1 + const output = await exited; + + expect(output).toContain('F1 to detach'); + expect(clients()).toBe(''); + expect(rootKeys()).not.toMatch(/-T root F1\s/); + expect(run(`${tmux} has-session -t alpha && echo alive`).trim()).toBe('alive'); + } finally { + script.kill(); + } + }); + + it("leaves an F1 binding of the user's own alone and says how to detach instead", async () => { + run(`${tmux} bind-key -T root F1 display-message mine`); + const { script, exited } = attach(); + try { + expect(await waitFor(() => clients() === 'alpha')).toBe(true); + expect(rootKeys()).toMatch(/-T root F1\s+display-message mine/); + + run(`${tmux} detach-client -s alpha`); + const output = await exited; + + expect(output).toContain('detach-client -s alpha'); + expect(output).not.toContain('F1 to detach'); + expect(rootKeys()).toMatch(/-T root F1\s+display-message mine/); + } finally { + script.kill(); + run(`${tmux} unbind-key -T root F1`); + } + }); +}); diff --git a/test/tmux-manager.test.ts b/test/tmux-manager.test.ts index cf0889e1..ebd5f927 100644 --- a/test/tmux-manager.test.ts +++ b/test/tmux-manager.test.ts @@ -727,20 +727,99 @@ describe('TmuxManager (unit)', () => { }); }); - describe('tmux launch cwd hardening', () => { - async function importWithTmuxCommandsEnabled(): Promise<typeof TmuxManager> { - const originalVitest = process.env.VITEST; - vi.resetModules(); + async function importWithTmuxCommandsEnabled(): Promise<typeof TmuxManager> { + const originalVitest = process.env.VITEST; + vi.resetModules(); + delete process.env.VITEST; + const module = await import('../src/tmux-manager.js'); + if (originalVitest === undefined) { delete process.env.VITEST; - const module = await import('../src/tmux-manager.js'); - if (originalVitest === undefined) { - delete process.env.VITEST; - } else { - process.env.VITEST = originalVitest; + } else { + process.env.VITEST = originalVitest; + } + return module.TmuxManager; + } + + describe('prefix table on the codeman socket', () => { + it("reduces the socket's prefix table to send-prefix before the agent starts", async () => { + // Browser keystrokes reach the attach client; with the stock table Ctrl+B ) or s + // would move a tab onto another session (the behavior itself: tmux-prefix-table.test.ts). + mockedExecSync.mockImplementation((cmd: string) => { + if (typeof cmd === 'string' && cmd.includes('which tmux')) return '/usr/bin/tmux\n'; + if (typeof cmd === 'string' && cmd.includes('display-message') && cmd.includes('#{pane_pid}')) return '4242\n'; + // Not tmux's stock C-b, so a hardcoded prefix cannot pass for the one read from tmux. + if (typeof cmd === 'string' && cmd.includes(' show-options -gv prefix')) return 'C-a\n'; + if (typeof cmd === 'string' && cmd.includes(' list-keys -T prefix')) { + return 'bind-key -T prefix C-a send-prefix\nbind-key -T prefix ) switch-client -n\n'; + } + return ''; + }); + const NonTestTmuxManager = await importWithTmuxCommandsEnabled(); + const nonTestManager = new NonTestTmuxManager(); + + try { + await nonTestManager.createSession({ + sessionId: 'abc12345-1234-5678-90ab-cdef12345678', + workingDir: '/project', + mode: 'shell', + }); + const commands = mockedExecSync.mock.calls.map(([cmd]) => String(cmd)); + expect(commands).toContain(`tmux -L 'codeman' unbind-key -a -T prefix`); + const bind = commands.indexOf(`tmux -L 'codeman' bind-key -T prefix 'C-a' send-prefix`); + expect(bind).toBeGreaterThan(commands.indexOf(`tmux -L 'codeman' unbind-key -a -T prefix`)); + expect(bind).toBeLessThan(commands.findIndex((c) => c.includes(' respawn-pane '))); + } finally { + nonTestManager.destroy(); } - return module.TmuxManager; + }); + + /** execSync for a boot-time reconcile: `panes` is what `list-panes -a` prints. */ + function mockReconcileTmux(panes: string): void { + mockedExecSync.mockImplementation((cmd: string) => { + if (typeof cmd === 'string' && cmd.includes('which tmux')) return '/usr/bin/tmux\n'; + if (typeof cmd === 'string' && cmd.includes(' list-panes -a ')) return panes; + if (typeof cmd === 'string' && cmd.includes(' show-options -gv prefix')) return 'C-a\n'; + if (typeof cmd === 'string' && cmd.includes(' list-keys -T prefix')) { + return 'bind-key -T prefix C-a send-prefix\nbind-key -T prefix ) switch-client -n\n'; + } + return ''; + }); } + it('reduces it at boot too, for sessions that survived an upgrade', async () => { + mockReconcileTmux('codeman-abc12345|4242|0||\n'); + const NonTestTmuxManager = await importWithTmuxCommandsEnabled(); + const nonTestManager = new NonTestTmuxManager(); + + try { + await nonTestManager.reconcileSessions(); + const commands = mockedExecSync.mock.calls.map(([cmd]) => String(cmd)); + const unbind = commands.indexOf(`tmux -L 'codeman' unbind-key -a -T prefix`); + expect(unbind).toBeGreaterThan(-1); + expect(commands.indexOf(`tmux -L 'codeman' bind-key -T prefix 'C-a' send-prefix`)).toBeGreaterThan(unbind); + } finally { + nonTestManager.destroy(); + } + }); + + it('leaves the key tables alone at boot when the socket lists no pane', async () => { + // On an empty socket `list-keys` would start a throwaway tmux server. + mockReconcileTmux(''); + const NonTestTmuxManager = await importWithTmuxCommandsEnabled(); + const nonTestManager = new NonTestTmuxManager(); + + try { + await nonTestManager.reconcileSessions(); + const commands = mockedExecSync.mock.calls.map(([cmd]) => String(cmd)); + expect(commands.some((c) => c.includes(' list-panes -a '))).toBe(true); + expect(commands.filter((c) => /show-options -gv prefix|list-keys|unbind-key|bind-key/.test(c))).toEqual([]); + } finally { + nonTestManager.destroy(); + } + }); + }); + + describe('tmux launch cwd hardening', () => { beforeEach(() => { mockedExecSync.mockImplementation((cmd: string) => { if (typeof cmd === 'string' && cmd.includes('which tmux')) { diff --git a/test/tmux-prefix-table.test.ts b/test/tmux-prefix-table.test.ts new file mode 100644 index 00000000..0219c60f --- /dev/null +++ b/test/tmux-prefix-table.test.ts @@ -0,0 +1,280 @@ +/** + * @fileoverview The tmux prefix table on Codeman's socket holds only + * `<prefix> send-prefix`, so keys typed into a browser terminal cannot move its + * attach client onto another session, without taking `codeman tui`'s way out away. + * + * Two layers here (the createSession wiring is in tmux-manager.test.ts): + * - the decision (`prefixTableNeedsReset`) on real `list-keys` shapes, + * - the effect against a REAL tmux server on a private throwaway socket (never + * Codeman's own), driven through a real attach client in a pty, the way a + * browser tab drives one. + * + * Port: N/A (no server) + */ +import { afterAll, beforeAll, describe, expect, it } from 'vitest'; +import { execSync } from 'node:child_process'; +import { rmSync } from 'node:fs'; +import * as pty from 'node-pty'; +import { prefixTableNeedsReset, restrictTmuxPrefixTable } from '../src/tmux-prefix-table.js'; + +/** A trimmed copy of tmux 3.4's stock prefix table, as `list-keys -T prefix` prints it. */ +const STOCK = [ + 'bind-key -T prefix C-b send-prefix', + 'bind-key -T prefix C-o rotate-window', + 'bind-key -T prefix C-z suspend-client', + 'bind-key -T prefix ( switch-client -p', + 'bind-key -T prefix ) switch-client -n', + 'bind-key -T prefix L switch-client -l', + 'bind-key -T prefix d detach-client', + 'bind-key -T prefix s choose-tree -Zs', + 'bind-key -T prefix w choose-tree -Zw', + 'bind-key -r -T prefix Up select-pane -U', +].join('\n'); + +const RESTRICTED = 'bind-key -T prefix C-b send-prefix\n'; + +describe('prefixTableNeedsReset', () => { + it("resets tmux's stock table (switch-client, choose-tree, detach-client on d)", () => { + expect(prefixTableNeedsReset(STOCK)).toBe(true); + }); + + it('leaves a table that holds only C-b send-prefix alone', () => { + expect(prefixTableNeedsReset(RESTRICTED)).toBe(false); + }); + + it("leaves a codeman tui attach's held-Ctrl detach alias alone", () => { + expect(prefixTableNeedsReset(`${RESTRICTED}bind-key -T prefix C-d detach-client\n`)).toBe( + false + ); + }); + + it('resets when a single foreign binding is back, or the stock detach key d', () => { + expect(prefixTableNeedsReset(`${RESTRICTED}bind-key -T prefix s choose-tree -Zs\n`)).toBe( + true + ); + expect(prefixTableNeedsReset(`${RESTRICTED}bind-key -T prefix d detach-client\n`)).toBe( + true + ); + }); + + it('resets an empty table or one without send-prefix, and output it cannot read', () => { + expect(prefixTableNeedsReset('')).toBe(true); + expect(prefixTableNeedsReset('bind-key -T prefix C-d detach-client\n')).toBe(true); + expect(prefixTableNeedsReset('something unexpected\n')).toBe(true); + }); + + it("follows the socket's configured prefix instead of assuming C-b", () => { + const ctrlA = 'bind-key -T prefix C-a send-prefix\n'; + expect(prefixTableNeedsReset(ctrlA, 'C-a')).toBe(false); + // C-b send-prefix under a C-a prefix is a leftover, not the binding we want. + expect(prefixTableNeedsReset(RESTRICTED, 'C-a')).toBe(true); + expect(prefixTableNeedsReset('', 'None')).toBe(false); + }); + + it('reads a key tmux lists backslash-escaped as the key itself', () => { + // `prefix C-\` is printed as is by show-options, but listed as `C-\\`. + expect(prefixTableNeedsReset('bind-key -T prefix C-\\\\ send-prefix\n', 'C-\\')).toBe(false); + }); +}); + +describe('restrictTmuxPrefixTable (recorded commands)', () => { + /** A runner that answers from `replies` (a thrown Error stands for a failing command) and records every call. */ + function fakeTmux(replies: Record<string, string | Error>): { run: (cmd: string) => string; calls: string[] } { + const calls: string[] = []; + const run = (cmd: string): string => { + calls.push(cmd); + const sub = cmd.replace(/^tmux -L 'codeman' /, ''); + const reply = replies[sub] ?? ''; + if (reply instanceof Error) throw reply; + return reply; + }; + return { run, calls }; + } + + it('still binds send-prefix when the table is empty and unbind-key -a fails on it', () => { + // tmux 3.4 on an empty prefix table: both commands exit 1 with "table prefix doesn't exist". + const missing = new Error("table prefix doesn't exist"); + const tmux = fakeTmux({ + 'show-options -gv prefix': 'C-b\n', + 'list-keys -T prefix': missing, + 'unbind-key -a -T prefix': missing, + }); + expect(restrictTmuxPrefixTable(`tmux -L 'codeman'`, tmux.run)).toBe(true); + expect(tmux.calls).toContain(`tmux -L 'codeman' bind-key -T prefix 'C-b' send-prefix`); + }); + + it('does not reset again when the listed prefix is only escaped differently', () => { + const tmux = fakeTmux({ + 'show-options -gv prefix': 'C-\\\n', + 'list-keys -T prefix': 'bind-key -T prefix C-\\\\ send-prefix\n', + }); + expect(restrictTmuxPrefixTable(`tmux -L 'codeman'`, tmux.run)).toBe(false); + expect(tmux.calls.filter((c) => c.includes('bind-key'))).toEqual([]); + }); +}); + +const hasTmux = (() => { + try { + execSync('tmux -V', { stdio: 'ignore' }); + return true; + } catch { + return false; + } +})(); + +describe.skipIf(!hasTmux)('restrictTmuxPrefixTable against a real tmux (private socket)', () => { + // Throwaway server, never Codeman's socket; -f /dev/null keeps the user's config out. + const socket = `codeman-prefix-test-${process.pid}`; + const tmux = `tmux -L ${socket} -f /dev/null`; + const run = (cmd: string): string => execSync(cmd, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }); + const keys = (table: string) => run(`${tmux} list-keys -T ${table}`); + let client: pty.IPty | null = null; + + /** The session the attach client is looking at right now. */ + const clientSession = () => run(`${tmux} list-clients -F '#{client_session}'`).trim(); + + async function waitFor(check: () => boolean, ms = 3000): Promise<boolean> { + const deadline = Date.now() + ms; + while (Date.now() < deadline) { + if (check()) return true; + await new Promise((resolve) => setTimeout(resolve, 50)); + } + return check(); + } + + /** Types keys into the attach client, then gives tmux time to act on them. */ + async function type(keysToSend: string): Promise<void> { + for (const ch of keysToSend) { + client!.write(ch); + await new Promise((resolve) => setTimeout(resolve, 100)); + } + await new Promise((resolve) => setTimeout(resolve, 300)); + } + + beforeAll(async () => { + // `cat -v` prints a received Ctrl+B as ^B, which is how send-prefix shows up. + run(`${tmux} new-session -d -s alpha -x 80 -y 24 'cat -v'`); + run(`${tmux} new-session -d -s beta -x 80 -y 24 'cat -v'`); + const env = { ...process.env } as Record<string, string>; + delete env.TMUX; // the suite may itself run inside tmux + // What a browser tab is: a pty running `tmux attach-session` (tmux-manager getAttachArgs). + client = pty.spawn('tmux', ['-L', socket, 'attach-session', '-t', 'alpha'], { + name: 'xterm-256color', + cols: 80, + rows: 24, + env, + }); + expect(await waitFor(() => clientSession() === 'alpha')).toBe(true); + }); + + afterAll(() => { + client?.kill(); + let socketPath = ''; + try { + socketPath = run(`${tmux} display-message -p '#{socket_path}'`).trim(); + run(`${tmux} kill-server`); // our own throwaway socket only + } catch { + /* already gone */ + } + // tmux leaves the socket file behind; remove ours (and only ours). + if (socketPath.endsWith(`/${socket}`)) rmSync(socketPath, { force: true }); + }); + + it("with tmux's stock table, Ctrl+B ) moves the attach client onto another session", async () => { + // The bug, reproduced so the next test means something. + await type('\x02)'); + expect(await waitFor(() => clientSession() === 'beta')).toBe(true); + run(`${tmux} switch-client -c $(${tmux} list-clients -F '#{client_name}') -t alpha`); + expect(await waitFor(() => clientSession() === 'alpha')).toBe(true); + }); + + it('after the reset, Ctrl+B ) and Ctrl+B s leave the client where it is, Ctrl+B Ctrl+B reaches the pane', async () => { + const rootBefore = keys('root'); + + expect(restrictTmuxPrefixTable(tmux, run)).toBe(true); + + const prefix = keys('prefix') + .split('\n') + .filter((l) => l.trim()); + expect(prefix).toHaveLength(1); + expect(prefix[0]).toMatch(/-T prefix C-b\s+send-prefix$/); + expect(keys('root')).toBe(rootBefore); + expect(run(`${tmux} show-options -gv prefix`).trim()).toBe('C-b'); + + await type('\x02)'); + await type('\x02s'); + expect(clientSession()).toBe('alpha'); + expect(run(`${tmux} display-message -p -t alpha '#{pane_in_mode}'`).trim()).toBe('0'); + + await type('\x02\x02'); + expect(await waitFor(() => run(`${tmux} capture-pane -p -t alpha`).includes('^B'))).toBe(true); + }); + + it("keeps a codeman tui attach's way out when another session is created mid-attach", () => { + // What beginAttachHandoff claims: C-d in the prefix table, F1 and M-1 in root. + run(`${tmux} bind-key -T prefix C-d detach-client`); + run(`${tmux} bind-key -T root F1 detach-client`); + run(`${tmux} bind-key -T root M-1 switch-client -t alpha`); + + expect(restrictTmuxPrefixTable(tmux, run)).toBe(false); + + expect(keys('prefix')).toMatch(/-T prefix C-d\s+detach-client/); + expect(keys('root')).toMatch(/-T root F1\s+detach-client/); + expect(keys('root')).toMatch(/-T root M-1\s+switch-client -t alpha/); + }); + + it("gives a running attach's held-Ctrl way out back when the reset clears a stock table", () => { + // A tmux server whose table is still stock while a codeman tui attach holds C-d: + // `unbind-key -a` takes C-d along with the rest. + try { + run(`${tmux} bind-key -T prefix d detach-client`); + run(`${tmux} bind-key -T prefix ')' switch-client -n`); + run(`${tmux} bind-key -T prefix C-d detach-client`); + expect(restrictTmuxPrefixTable(tmux, run)).toBe(true); + const prefix = keys('prefix') + .split('\n') + .filter((l) => l.trim()); + expect(prefix).toHaveLength(2); + expect(keys('prefix')).toMatch(/-T prefix C-b\s+send-prefix/); + expect(keys('prefix')).toMatch(/-T prefix C-d\s+detach-client/); + } finally { + run(`${tmux} unbind-key -T prefix C-d`); + } + }); + + it('binds send-prefix to the configured prefix when the socket uses another one', () => { + run(`${tmux} set-option -g prefix C-a`); + try { + expect(restrictTmuxPrefixTable(tmux, run)).toBe(true); + const prefix = keys('prefix') + .split('\n') + .filter((l) => l.trim()); + expect(prefix).toEqual([expect.stringMatching(/-T prefix C-a\s+send-prefix$/)]); + } finally { + run(`${tmux} set-option -g prefix C-b`); + } + }); + + it('binds send-prefix on a server whose prefix table is empty', () => { + // On an empty table tmux fails both `list-keys -T prefix` and `unbind-key -a -T prefix`. + run(`${tmux} unbind-key -a -T prefix`); + expect(() => run(`${tmux} unbind-key -a -T prefix`)).toThrow(); + + expect(restrictTmuxPrefixTable(tmux, run)).toBe(true); + const prefix = keys('prefix') + .split('\n') + .filter((l) => l.trim()); + expect(prefix).toEqual([expect.stringMatching(/-T prefix C-b\s+send-prefix$/)]); + }); + + it('resets once, not on every call, for a prefix tmux lists escaped (C-\\)', () => { + run(`${tmux} set-option -g prefix 'C-\\'`); + try { + expect(restrictTmuxPrefixTable(tmux, run)).toBe(true); + expect(keys('prefix')).toMatch(/-T prefix C-\\\\\s+send-prefix/); + expect(restrictTmuxPrefixTable(tmux, run)).toBe(false); + } finally { + run(`${tmux} set-option -g prefix C-b`); + } + }); +}); diff --git a/test/tui/tui-app.test.ts b/test/tui/tui-app.test.ts index bdc34735..309be727 100644 --- a/test/tui/tui-app.test.ts +++ b/test/tui/tui-app.test.ts @@ -13,6 +13,7 @@ import { describe, it, expect } from 'vitest'; import { applyLiveMetrics, applyMuxNames, + beginAttachHandoff, buildAttachBanner, buildListLines, confirmKillStep, @@ -38,6 +39,7 @@ import { tmuxSocketFromEnv, } from '../../src/tui/tui-app.js'; import { createTuiModel, stateSince } from '../../src/tui/tui-model.js'; +import type { TuiClient } from '../../src/tui/tui-client.js'; import { glyphsFor } from '../../src/tui/tui-render.js'; import type { TuiLiveSessionMetrics, TuiTmuxSession } from '../../src/tui/tui-client.js'; import type { TuiConfirmState, TuiRow, TuiSessionRow } from '../../src/tui/tui-types.js'; @@ -596,6 +598,40 @@ describe('the held-Ctrl detach alias', () => { }); }); +describe('the way out when F1 is taken, on a socket with no detach key', () => { + // Codeman keeps only `<prefix> send-prefix` in its socket's prefix table + // (src/tmux-prefix-table.ts), so tmux's stock `Ctrl+B then d` does nothing there. + it('names the held-Ctrl chord on the bar instead of the stock d', () => { + const bar = buildAttachBanner({ prefix: 'C-b', heldAlias: 'C-d' })['status-format[0]']; + expect(bar).toContain('#[bold]Ctrl+B then Ctrl+D#[nobold]'); + expect(bar).not.toContain('then d'); + }); + + it('hands the same chord back from the attach handoff, for the help overlay and the hint line', async () => { + const free = new Set(['C-d', 'M-1']); + const client = { + readPrefixKey: async () => 'C-b', + readDetachKey: async () => null, + readPrefixBinding: async (key: string) => (free.has(key) ? null : 'display-message mine'), + bindDetachKey: async () => true, + unbindDetachKey: async () => true, + bindSwitchKey: async () => true, + unbindSwitchKey: async () => true, + readSessionOptions: async () => null, + applySessionOptions: async () => true, + restoreSessionOptions: async () => true, + readWindowSizing: async () => null, + presizeWindow: async () => true, + followAttachingClient: async () => true, + restoreWindowSizing: async () => true, + } as unknown as TuiClient; + + const handoff = await beginAttachHandoff(client, 'codeman-abc12345', 'w1-case'); + + expect(handoff.chord).toBe('Ctrl+B then Ctrl+D'); + }); +}); + describe('naming a session the TUI starts', () => { it("follows the web UI's w<n>-<case> convention", () => { expect(nextSessionName('mirofish', [])).toBe('w1-mirofish'); @@ -737,8 +773,11 @@ describe('the way out of an attach', () => { const keys = helpKeysFor(GLYPHS, { server: true, detach: 'Ctrl+A then d' }); const detach = keys.find(([key]) => key === 'Ctrl+A then d'); expect(detach?.[1]).toContain('detach'); - // Degraded mode still attaches, so it still needs the way out. - expect(helpKeysFor(GLYPHS, { server: false }).map(([key]) => key)).toContain('Ctrl+B then d'); + // Degraded mode still attaches, so it still needs the way out. Before the first + // attach that is F1: Codeman's socket has no `d` in its prefix table to name. + const before = helpKeysFor(GLYPHS, { server: false }).map(([key]) => key); + expect(before).toContain(ONE_KEY_DETACH); + expect(before).not.toContain('Ctrl+B then d'); }); }); diff --git a/test/tui/tui-e2e.test.ts b/test/tui/tui-e2e.test.ts index 6e93d011..15c001a5 100644 --- a/test/tui/tui-e2e.test.ts +++ b/test/tui/tui-e2e.test.ts @@ -558,6 +558,12 @@ describe('codeman tui (under a pty)', () => { term.write('?'); await waitFor(() => frameLines(output).some((line) => line.includes('Keys')), 'the help overlay'); expect(frameLines(output).join('\n')).toContain('kill (y to confirm)'); + // Nothing has been attached yet, so this is the dashboard's starting label: + // F1, what an attach claims, not tmux's stock chord, which Codeman's socket + // does not bind (src/tmux-prefix-table.ts). + const detachLine = frameLines(output).find((line) => line.includes('detach from an attached session')); + expect(detachLine).toContain('F1'); + expect(detachLine).not.toContain('Ctrl+B'); term.write('\u001b'); await waitFor(() => !frameLines(output).some((line) => line.includes('Keys')), 'escape to close the overlay'); From 63108686967c25d15bc06c8fc09ad64ea2d9acea Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Thu, 22 Oct 2026 19:25:04 +0200 Subject: [PATCH 84/96] fix(test): bound the temp-HOME removal by a deadline, not an attempt count Merge-time fix for #660. afterAll runs under vitest's 10 s hookTimeout, and each failed removal attempt costs rmSync's own retry sleeps (50 + 100 + 150 ms) plus the 250 ms wait, so 20 attempts overran it: with a writer still filling $HOME/.claude/backups, the file failed with "Hook timed out in 10000ms" after its test passed, and the "leave it to the exit backstop" branch was never reached. The loop now stops after about 5 s. Checked with a temporary (uncommitted) test that leaves a child process writing there for 20 s: before, the file failed at 11 s with the hook timeout; after, it passes in under 6 s. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- test/setup.ts | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/test/setup.ts b/test/setup.ts index e4f67be7..01724de3 100644 --- a/test/setup.ts +++ b/test/setup.ts @@ -125,13 +125,18 @@ afterAll(async () => { // with ENOTEMPTY after every test has passed. The usual culprit is a real agent CLI on a developer // machine (the `claude` a test's code path probes or spawns) flushing its own `~/.claude.json` and // `~/.claude/backups/` into the temp HOME a moment after the test ends; CI has no such CLI. Keep - // removing until it goes quiet, and if it never does, leave the temp dir to the `exit` backstop - // below: a leaked temp folder must not fail a test file that passed. - for (let attempt = 0; attempt < 20; attempt++) { + // removing until it goes quiet, for at most about 5 s, and if it never does, leave the temp dir to + // the `exit` backstop below: a leaked temp folder must not fail a test file that passed. + // ⚠️ The bound is a deadline, not an attempt count: this hook runs under vitest's 10 s + // `hookTimeout`, and each failed attempt costs rmSync's own retry sleeps (50 + 100 + 150 ms) plus + // the wait below, so 20 attempts overran it and failed the file with "Hook timed out". + const deadline = Date.now() + 5_000; + for (;;) { try { rmSync(testHome, { recursive: true, force: true, maxRetries: 3, retryDelay: 50 }); break; } catch { + if (Date.now() >= deadline) break; await new Promise((resolve) => setTimeout(resolve, 250)); } } From 6d2763d50bc1fe1fc3bed12c017ef109c7fafaff Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Thu, 22 Oct 2026 19:25:52 +0200 Subject: [PATCH 85/96] docs(test): a bare vitest is isolated now, and the guards need the sandbox HOME Merge-time fix for #660. CLAUDE.md and architecture-invariants still said a raw npx vitest skips setup.ts, contradicting the updated Testing note: both now say it resolves the root vitest.config.ts (and why that file must stay). The architecture-invariants entry for safeRmHomeTree adds the sandbox-HOME requirement (isSandboxedHome, TEST_HOME_PREFIX), the same rule for isUnderTestHome, the route-harness assert and the 2026-10-10 incident. CLAUDE.md's repo-root preamble lists vitest.config.ts with its reason, so a tidy-up does not move it into config/ and reopen #640. The assertSandboxedHome JSDoc no longer claims every route test is covered: only the ones built on createRouteTestHarness are. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- CLAUDE.md | 4 ++-- docs/architecture-invariants.md | 4 ++-- test/mocks/test-helpers.ts | 3 ++- 3 files changed, 6 insertions(+), 5 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index e6f2f9b2..a04e8e3c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -6,7 +6,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co > > **This file is in `.prettierignore` on purpose.** Prettier's markdown printer escapes underscores inside the glob-heavy paths used throughout (`agent-*.jsonl` became `agent-\_.jsonl`, collapsing backtick spans and corrupting a whole paragraph). Do not remove the ignore entry, and do not run `prettier --write` on it. > -> **Repo root is kept short on purpose** (the README sits below the file listing on GitHub). Config lives in `config/` (`eslint.config.js`, `knip.json`, the vitest configs), Prettier's config is the `"prettier"` key in `package.json`, and `SECURITY.md` is under `.github/`. Root-only files are the ones tools genuinely require there: `CLAUDE.md` + `AGENTS.md` (loaded from the root by Claude Code / Codex), `CHANGELOG.md` (changesets writes it next to `package.json`), `tsconfig.json`, `.editorconfig`, `.nvmrc`/`.npmrc`, `.prettierignore` (resolved relative to cwd), `LICENSE` (GitHub detection), `.dockerignore` (the build context is the repo root, so Docker resolves it there and nowhere else) and `install.sh` (its raw URL is the published install one-liner). `.claude-plugin/marketplace.json` is root-only for the same reason: `/plugin marketplace add Ark0N/Codeman` reads it from the repo root and nowhere else, which makes the repo its own plugin marketplace. The one plugin it lists is `plugins/codeman/` (manifest + README + a MIRROR of `skills/codeman/`), and ⚠️ the plugin is a small separate directory on purpose: `claude plugin install` copies the plugin root into its cache, and a plugin root that carries a `package.json` gets an **npm install** at install time (measured with the repo root as plugin root: 832 MB, 511 packages and this repo's postinstall on every installer's machine), while a symlink to `skills/codeman` would dangle in the copy. `skills/codeman/` stays the single source; `scripts/sync-plugin.mjs` mirrors it and writes `package.json`'s version into both manifests inside `version-packages`, and `test/plugin-manifest.test.ts` pins the byte-identity, the versions, the absence of a `package.json` in the plugin root and that no other component (`commands/`, `agents/`, `hooks/`, `.mcp.json`, `settings.json`) rides along. `npm run check:plugin` runs that drift check plus both strict validations with EXPLICIT paths (`plugins/codeman`, `.claude-plugin/marketplace.json`): a bare `.` argument copied out of prose reads as a full stop and gets dropped, which surfaces as `missing required argument 'path'`. Needs the `claude` CLI, so it is a local check, not a CI step. Don't relocate those. +> **Repo root is kept short on purpose** (the README sits below the file listing on GitHub). Config lives in `config/` (`eslint.config.js`, `knip.json`, the vitest configs), Prettier's config is the `"prettier"` key in `package.json`, and `SECURITY.md` is under `.github/`. Root-only files are the ones tools genuinely require there: `CLAUDE.md` + `AGENTS.md` (loaded from the root by Claude Code / Codex), `CHANGELOG.md` (changesets writes it next to `package.json`), `tsconfig.json`, `.editorconfig`, `.nvmrc`/`.npmrc`, `.prettierignore` (resolved relative to cwd), `vitest.config.ts` (a one-line re-export of `config/vitest.ci.config.ts`: vitest finds its config by walking up from cwd, so without it a bare `npx vitest` ran with no setup file and wrote into the real `~/.codeman`, #640), `LICENSE` (GitHub detection), `.dockerignore` (the build context is the repo root, so Docker resolves it there and nowhere else) and `install.sh` (its raw URL is the published install one-liner). `.claude-plugin/marketplace.json` is root-only for the same reason: `/plugin marketplace add Ark0N/Codeman` reads it from the repo root and nowhere else, which makes the repo its own plugin marketplace. The one plugin it lists is `plugins/codeman/` (manifest + README + a MIRROR of `skills/codeman/`), and ⚠️ the plugin is a small separate directory on purpose: `claude plugin install` copies the plugin root into its cache, and a plugin root that carries a `package.json` gets an **npm install** at install time (measured with the repo root as plugin root: 832 MB, 511 packages and this repo's postinstall on every installer's machine), while a symlink to `skills/codeman` would dangle in the copy. `skills/codeman/` stays the single source; `scripts/sync-plugin.mjs` mirrors it and writes `package.json`'s version into both manifests inside `version-packages`, and `test/plugin-manifest.test.ts` pins the byte-identity, the versions, the absence of a `package.json` in the plugin root and that no other component (`commands/`, `agents/`, `hooks/`, `.mcp.json`, `settings.json`) rides along. `npm run check:plugin` runs that drift check plus both strict validations with EXPLICIT paths (`plugins/codeman`, `.claude-plugin/marketplace.json`): a bare `.` argument copied out of prose reads as a full stop and gets dropped, which surfaces as `missing required argument 'path'`. Needs the `claude` CLI, so it is a local check, not a CI step. Don't relocate those. ## Quick Reference @@ -335,7 +335,7 @@ A raw `npx vitest` used to find no config, so `setup.ts` never ran and the suite **Config**: Vitest with `globals: true`, `fileParallelism: false`. Timeout 30s, teardown 60s. `config/vitest.config.ts` is the everything-config behind `test:all`; `config/vitest.ci.config.ts` is the gate and derives its excludes from `config/test-suites.ts`, which is also what `vitest.browser.config.ts` and `vitest.perf.config.ts` derive their includes from — so the exclusions and the runners cannot drift apart. Keep shared options in sync across them. -**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) +**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()`. ⚠️ A bare `npx vitest` gets `setup.ts` only through the root `vitest.config.ts` (a re-export of the gate config): keep that file, and never point `--config` at a config without `setupFiles`. → [architecture-invariants#test-isolation-tmux-docker-and-home](docs/architecture-invariants.md#test-isolation-tmux-docker-and-home) **Ports**: every in-process test server binds port 0 (the one exception, `test/codex-predictive-echo.test.ts`, starts a separate lab server process on 3222). A test that constructs `WebServer` reads `server.boundPort` (mobile tests: `createTestServer()`, then `server.boundPort`); a raw `http`/`net`/Fastify/`ws` server listens on `0` and reads `address().port`. `test/test-ports-guard.test.ts` fails a `WebServer` built on any other port and a raw `listen` on a number or `…PORT` constant. A test that needs a port nothing listens on binds 0, reads it and closes (`closedPort()` in `test/daemon-control.test.ts`), never "the server's port + 1". Never 3000 (the live instance). diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 896e8d73..3e056e49 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -1170,9 +1170,9 @@ The repair is a **chmod, not a rebuild**: the prebuilt binary is fine. `scripts/ ⚠️ `CODEMAN_DATA_DIR` is the one that matters: `getDataDir()` reads it as an ABSOLUTE override before it ever looks at `homedir()`, so one inherited from the shell (a second instance, a beta run, a shell left over from `codeman web -d`) bypasses the temp HOME entirely, and a bare suite run once overwrote the real `remote-hosts.json` with a route test's fixture. `os.homedir()` itself DOES follow `$HOME`, so the temp HOME is what redirects everything else; `CODEMAN_INSTANCE` must be stripped in the setup file and never in a hook, because `config/instance.ts` captures it into a module-level const on first import. -Tests that delete case trees go through `safeRmHomeTree()` (`test/mocks`), which refuses any path outside the temp HOME, so a wrong anchor leaves a temp dir behind instead of deleting `~/codeman-cases`. +Tests that delete case trees go through `safeRmHomeTree()` (`test/mocks`), which refuses any path outside the temp HOME, so a wrong anchor leaves a temp dir behind instead of deleting `~/codeman-cases`. ⚠️ It also refuses everything unless HOME IS the throwaway dir `test/setup.ts` made (`isSandboxedHome()`: its basename starts with `TEST_HOME_PREFIX`, `codeman-vitest-`, the one thing a real home cannot have). "Under HOME" alone is vacuous when setup never ran: on 2026-10-10 a bare `vitest` run had the developer's real home as HOME, every real file was "under the test HOME", and the guard written to prevent exactly that deleted the real linked-cases registry. `isUnderTestHome()` (for single-file deletes such as `linked-cases.json`) carries the same requirement, and the shared route harness `createRouteTestHarness()` calls `assertSandboxedHome()`, so a route test built on it refuses to start against a real `~/.codeman` (a route test that builds its own app from `createMockRouteContext()` is not covered by that). -⚠️ Raw `npx vitest` without `--config` skips `setup.ts` and with it the temp-HOME isolation. +⚠️ A bare `npx vitest` (no `--config`) resolves the root `vitest.config.ts`, which re-exports `config/vitest.ci.config.ts`, so it runs `setup.ts` and gets the temp HOME like `npm test`. Vitest looks for its config by walking up from cwd, so this holds from `test/` as well, while `packages/xterm-zerolag-input` keeps its own config. Before that file existed a bare run found no config, ran without the setup file, and wrote fixtures into the real `~/.codeman` (#640: a `clis.json` with a test entry, `users.json`, `webhook.json`, `.env`, settings values). `test/test-env-isolation.test.ts` pins it; a `--config` pointing at a config without `setupFiles` would still skip the isolation. ### install.sh: the public installer diff --git a/test/mocks/test-helpers.ts b/test/mocks/test-helpers.ts index c930e0a2..12c0a280 100644 --- a/test/mocks/test-helpers.ts +++ b/test/mocks/test-helpers.ts @@ -61,7 +61,8 @@ export function isSandboxedHome(): boolean { /** * Throw unless the suite is running under `test/setup.ts`'s temp HOME. The shared route harness - * calls this, so every route test refuses to start against a real `~/.codeman`. + * (`createRouteTestHarness`) calls this, so a route test built on it refuses to start against a real + * `~/.codeman`. A route test that builds its own app from `createMockRouteContext` is not covered. */ export function assertSandboxedHome(what = 'this test'): void { if (!isSandboxedHome()) { From 5a677bf03cda51e0e228b192e3c7d5d7d44e1ee4 Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Thu, 22 Oct 2026 19:29:25 +0200 Subject: [PATCH 86/96] fix(tabs): a Claude worktree session gets its case's default group too Merge-time fix for #676 (review finding 1 and nit 7). The Git worktrees modal launches Claude through POST /api/sessions with workingDir <case>/.claude/worktrees/<name>, which inferLocalCaseName did not recognise, so the session landed in Ungrouped while the same gesture for any other CLI (quick-start with the case name) landed in the group. The inference now strips a trailing .claude/worktrees/<name> lexically before its two checks, reusing CLAUDE_WORKTREE_PATH (hoisted to module scope). It also runs only for a named workingDir, never on the server's own cwd, as the hook and skill injection above already do. Route tests for both, failing without the fix; api-reference and architecture-invariants say so. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- docs/api-reference.md | 3 +- docs/architecture-invariants.md | 2 +- src/web/routes/session-routes.ts | 21 +++++++----- test/routes/case-default-group-routes.test.ts | 33 +++++++++++++++++++ 4 files changed, 49 insertions(+), 10 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index 068b5d0a..4837a376 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -794,7 +794,8 @@ its creating write when nothing stronger applies; the order is: an explicit `tab then the session it was spawned from (`parentSessionId`), then the case default, else Ungrouped. `POST /api/v1/quick-start` uses its `caseName` (local, linked, remote and Docker cases); `POST /api/v1/sessions` uses the case whose folder `workingDir` is (a direct child of -the caller's case space, or a linked case's exact path). When a case default places the +the caller's case space, a linked case's exact path, or a Claude worktree of either, +`<case>/.claude/worktrees/<name>`); without a `workingDir` there is no case. When a case default places the session, the create response carries `tabLayout` as it does for `tabGroupId`. Deleting the case forgets the value; existing sessions never move. diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 096344e0..5c009870 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -331,7 +331,7 @@ So: `_confirmIdle()` (session.ts) requires the pane to go quiet, and then asks t - **Edits are named operations, saved serially.** `createEditCoordinator` applies `createGroup` / `renameGroup` / `deleteGroup` / `reorderGroup` / `moveRef` to the rail at once, then sends ONE `PUT /api/tab-layout {baseVersion, layout}` at a time; edits made meanwhile wait and go out on the version that write returns. A 409 carries the server's layout: the in-flight operations are replayed onto it (an operation that no longer applies is dropped and reported) and re-sent, at most `maxAttempts` times; a 400 re-reads once (a second 400 is reported as a failed save, not as a race); anything else reports and re-reads. A `moveRef` moves the session together with the sessions that still follow it and marks a hand-moved child `placement: 'manual'`, mirroring the server's `moveRef`, and `normalizeLayout` keeps `placement` because whole layouts are written back. `_onTabLayoutChanged` / `_applyTabLayout` defer a read while a write is in flight and rebase unsaved edits onto a read otherwise. ⚠️ A FAILED read (`_applyTabLayout(null)`) while edits are pending keeps the held layout and the editor and re-reads after the write settles: disposing there would orphan the in-flight write, whose 409 then never gets its rebase. Any path that does drop unsaved work says so in a toast. On `pagehide`, unconfirmed operations go out in a `keepalive` PUT AND into sessionStorage as `{ owner, baseVersion, savedAt, operations }`; after reload they replay onto the fresh layout (a no-op when the keepalive landed), but only for the same owner, within `TAB_LAYOUT_PENDING_MAX_AGE_MS` (60 s), and never onto a layout older than the copy's base. A "Move to <group>" with no anchor carries no `index`, so a replay still puts the row last. - **Every way in has a keyboard path.** The session row menu (Shift+F10 in the tree, the rail's overflow button) gains Move up/down, Move to <group>, Move to Ungrouped and Move to new group in the vertical rail (only "new group" before the first group exists; nothing on the strip). A group header opens its menu with Shift+F10 / ContextMenu, right-click or its hover glyph (a non-focusable, `aria-hidden` span: a treeitem holds no interactive children), and F2 renames it inline (Enter commits and refocuses the header; a commit by BLUR leaves focus where it went, since refocusing from inside the blur handler overrides the user's click). The glyph stays visible under `@media (hover: none)`: a touch tablet has no hover and no long-press `contextmenu`. Group names in "Move to" labels are quoted, so a group named "New group" or "ungrouped" cannot read (or translate, case-insensitively) like the fixed entries. The menu closes on Escape (which it consumes before the global Escape handler), a pointer outside, Tab, focus leaving it, a resize, a second open and any full re-render. It borrows the `.tab-rail-action-menu` class for its look only: `closeTabRailActionMenu()` excludes `.tab-layout-group-action-menu`, so closing the row menu (every `session:deleted` does) cannot strand the group menu's listeners. The inline group editor shares `_activeRename` with the session rename, so only the CURRENT editor may release `_inlineRenameActive`. - **New, child and web tabs land in groups.** A group menu's "New session" (`createSessionInTabGroup`) runs ONE launch with `run({ count: 1, tabGroupId })`; every launch path puts `tabGroupId` on its create request (`POST /api/sessions` or `/api/quick-start`), and `TabLayoutService.sessionCreated(owner, id, { tabGroupId }, register)` places the session at the end of that group in the SAME versioned write that first records it. The session enters the live map inside the owner lock (`register`, rolled back if the write fails), so no queued layout edit can commit it first. The group is looked up in the creating owner's own layout, so an unknown, deleted or foreign id is ignored rather than failing the spawn, and an explicit group is a hand placement (a child placed this way is `manual`). The create response carries the committed `tabLayout`, adopted before the tab is drawn. A session spawned by another one (`parentSessionId`) and placed by nobody lands after its parent: the server's normalization puts it after the parent's rows in the parent's container, and `project({ parents })` draws a not-yet-stored child there at once. Whether it then FOLLOWS its parent is the synced `spawnedTabsFollowParent` setting (default OFF), which `sessionCreated()` reads fresh through the `childrenFollowParent` dep: off, the child is marked `manual` in that same creating write; on, it follows until moved by hand. Deciding at creation is enough, so flipping the setting leaves existing tabs alone. A hand move makes a following child `manual`; with the setting on, the row menu notes where a child sits (following, placed by hand, parent closed, parent loop; plain text, never a focusable menu item) and offers "Follow parent again" only to a `manual` child, which clears `placement` and is re-derived by the server on save. With the setting off the row menu says nothing about placement. A legacy `PUT /api/session-order` (strip or phone drag, a same-section Ctrl+Shift+{/}, a device without a layout, an admin's cross-owner order) marks a child `manual` ONLY when the requested order splits it from its parent's block (`applyLegacySessionRank`, `childrenSplitFromParent`); a PUT of the current order, or one that moves a family together, leaves every stored placement alone. The browser never echoes a create or delete through that PUT, with or without groups: the service already re-projects the order and broadcasts it (`session:orderChanged`), and `syncSessionOrder` adopts it. A child whose parent is gone stands on its own and is never re-adopted. Saved web tabs are layout refs like sessions, but OPENNESS stays per device: a grouped web tab renders (and takes an Alt+N slot) only while it is open on this device, and reopens where the layout keeps it. -- **A case's default session group** (`caseDefaultGroups` in state.json, keyed by the layout owner key then case name; a group id or `null` for Ungrouped) is the fallback placement for a new session of that case. It is read and written ONLY through `TabLayoutService` (`caseDefaultGroup`, `setCaseDefaultGroup`, `forgetCaseDefaultGroup`, and `sessionCreated`'s `caseName` hint), under the owner lock, never by a route or the store directly. Precedence inside `sessionCreated`'s single write: an explicit `tabGroupId` found in the owner's layout, then a parent that places the child (`hasPlacingParent`, the same conditions as `pinCreatedChild`), then the case default, else normal placement. The id is looked up in the creating owner's own layout, so a deleted or foreign id never fails a spawn; a stale id is forgotten only AFTER the creating write lands (a failed write rolls back and changes nothing). `POST /api/quick-start` and `POST /api/ralph-loop/start` pass their `caseName`; `POST /api/sessions` infers it LEXICALLY from `workingDir` (`inferLocalCaseName`: a direct child of the caller's case space, or an exact linked-case path for an admin or single-user), never for a remote attach and never touching the filesystem. Cron jobs, the one-shot run route and reboot restore keep normal placement. The Case settings selector (`onCaseDefaultGroupChanged`, session-ui.js) reads it on popover open and is redrawn from every adopted layout (`_refreshCaseDefaultGroupOptions` in `_applyTabLayout`). +- **A case's default session group** (`caseDefaultGroups` in state.json, keyed by the layout owner key then case name; a group id or `null` for Ungrouped) is the fallback placement for a new session of that case. It is read and written ONLY through `TabLayoutService` (`caseDefaultGroup`, `setCaseDefaultGroup`, `forgetCaseDefaultGroup`, and `sessionCreated`'s `caseName` hint), under the owner lock, never by a route or the store directly. Precedence inside `sessionCreated`'s single write: an explicit `tabGroupId` found in the owner's layout, then a parent that places the child (`hasPlacingParent`, the same conditions as `pinCreatedChild`), then the case default, else normal placement. The id is looked up in the creating owner's own layout, so a deleted or foreign id never fails a spawn; a stale id is forgotten only AFTER the creating write lands (a failed write rolls back and changes nothing). `POST /api/quick-start` and `POST /api/ralph-loop/start` pass their `caseName`; `POST /api/sessions` infers it LEXICALLY from `workingDir` (`inferLocalCaseName`: a direct child of the caller's case space, or an exact linked-case path for an admin or single-user, or a Claude worktree of either, `<case>/.claude/worktrees/<name>`, which is how the Git worktrees modal launches Claude while every other CLI goes through quick-start), only for a named `workingDir` (never the server's own cwd), never for a remote attach and never touching the filesystem. Cron jobs, the one-shot run route and reboot restore keep normal placement. The Case settings selector (`onCaseDefaultGroupChanged`, session-ui.js) reads it on popover open and is redrawn from every adopted layout (`_refreshCaseDefaultGroupOptions` in `_applyTabLayout`). - **Only the grouped rail is a tree.** `#sessionTabs` ships as `role=tablist` with `role=tab` rows, and the header strip, sidebar and flat rail keep exactly that. While grouped, `_applyTabListRole` makes it `role=tree` (and restores `tablist` + its label when grouping ends), named-group headers are level-1 `treeitem`s that `aria-owns` their rows' `role=group` (rows sit beside the header, not inside it), and ungrouped rows plus a collapsed group's kept selection are level-1 items. A collapsed header owns nothing, a group with no open rows is a leaf (no `aria-expanded`, no owned group), and the "Ungrouped" heading is `aria-hidden`. Rows are re-roled in the DOM by `_applyTabTreeSemantics` after render, never by rewriting their markup, so a grouped row's content stays the flat row's. - **One tab stop in the tree.** Exactly one treeitem carries `tabindex=0` (the focused or selected item); every control inside a row drops to `-1`, which is why Shift+F10 / ContextMenu open a row's actions from the keyboard. Focus survives a full re-render by identity (`group:`/`session:`/`webview:`; a row a collapse just hid hands focus to its header), but only when focus was already inside the rail. The tree walk (`_tabTreeItems`) follows painted order WITHIN each group when the rail is sorted; the flat list keeps its own whole-list computed-order walk. `aria-posinset`/`aria-setsize` follow painted order too, so the incremental render path re-runs `_applyTabTreePositions` after it re-sorts rows in place. ⚠️ `_handleTabTreeKeydown` acts only when the key lands on the treeitem ITSELF: a key on a focused in-row control (close, overflow, the rename input) is that control's, or Enter on the overflow button re-selects the row instead of reopening its menu. ⚠️ The roving `tabindex=-1` also hides every item but the stop from the keyboard-dismiss selector's `[tabindex]` arm, which is why `MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR` lists `[role="treeitem"]` (see Dismissing the on-screen keyboard). diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index 96b9d455..a896d586 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -207,6 +207,9 @@ const CODEMAN_CONFIG_DIR = getDataDir(); const SAFE_CASE_NAME = /^[a-zA-Z0-9_-]+$/; +/** `<repo>/.claude/worktrees/<name>`: the layout Claude Code's own worktree feature creates. */ +const CLAUDE_WORKTREE_PATH = /^(.*)\/\.claude\/worktrees\/([^/]+)\/?$/; + /** Did the committed layout put this session in a named group (not Ungrouped)? */ function landedInGroup(layout: TabLayout | undefined, sessionId: string): boolean { return !!layout?.groups.some((group) => group.refs.some((ref) => ref.kind === 'session' && ref.id === sessionId)); @@ -215,12 +218,16 @@ function landedInGroup(layout: TabLayout | undefined, sessionId: string): boolea /** * The case a POST /api/sessions `workingDir` belongs to, for its default session * group: a direct child of the caller's case space, or (admin / single-user, as - * GET /api/cases lists them) a linked case's exact path. Lexical only: the - * browser sends the path the case list gave it, and this must never touch a - * possibly-dead mount. Anything else is no case, which keeps today's placement. + * GET /api/cases lists them) a linked case's exact path, or a Claude worktree + * (`<case>/.claude/worktrees/<name>`) of either. Lexical only: the browser sends + * the path the case list gave it, and this must never touch a possibly-dead + * mount. Anything else is no case, which keeps today's placement. */ async function inferLocalCaseName(req: FastifyRequest, workingDir: string): Promise<string | undefined> { - const target = resolvePath(workingDir); + // The Git worktrees modal launches Claude through this route in the worktree, and every other CLI + // through quick-start with the case name: both are sessions of that case. + const resolved = resolvePath(workingDir); + const target = CLAUDE_WORKTREE_PATH.exec(resolved)?.[1] || resolved; const user = getAuthUser(req); if (dirname(target) === resolvePath(resolveCasesDir(user))) { const name = basename(target); @@ -1442,7 +1449,8 @@ export function registerSessionRoutes( parentSessionId: resolveParentSessionId(ctx, req, body.parentSessionId, owner), }); - const placementCase = remote ? undefined : await inferLocalCaseName(req, workingDir); + // Only a named workingDir: without one the session starts in the server's own cwd, which is no case. + const placementCase = remote || !body.workingDir ? undefined : await inferLocalCaseName(req, workingDir); const placement = { ...(body.tabGroupId ? { tabGroupId: body.tabGroupId } : {}), ...(placementCase ? { caseName: placementCase } : {}), @@ -4722,9 +4730,6 @@ export function registerSessionRoutes( worktreeRepo?: string; }; - /** `<repo>/.claude/worktrees/<name>` — the layout Claude Code's own worktree feature creates. */ - const CLAUDE_WORKTREE_PATH = /^(.*)\/\.claude\/worktrees\/([^/]+)\/?$/; - /** * Recover cwd / branch / worktree from a transcript chunk. * diff --git a/test/routes/case-default-group-routes.test.ts b/test/routes/case-default-group-routes.test.ts index 43041f19..07ba2945 100644 --- a/test/routes/case-default-group-routes.test.ts +++ b/test/routes/case-default-group-routes.test.ts @@ -141,6 +141,39 @@ describe('case default group: create routes', () => { expect(data.tabLayout).toEqual(world.layouts[OWNER]); }); + it('POST /api/sessions in a Claude worktree of the case lands in the case default group too', async () => { + // What the Git worktrees modal sends for Claude; every other CLI goes through quick-start with the case name. + const worktree = join(caseDir, '.claude', 'worktrees', 'feat'); + await mkdir(worktree, { recursive: true }); + const res = await harness.app.inject({ + method: 'POST', + url: '/api/sessions', + payload: { name: 'w1-cdg-wt', mode: 'shell', workingDir: worktree }, + }); + expect(res.statusCode).toBe(200); + const data = unwrap(res.body); + expect(groupOf(world.layouts[OWNER], data.session.id)).toBe('g-ops'); + expect(data.tabLayout).toEqual(world.layouts[OWNER]); + }); + + it('POST /api/sessions without a workingDir infers no case from the server cwd', async () => { + const cwd = process.cwd(); + process.chdir(caseDir); // the server's own cwd happens to be a case folder + try { + const res = await harness.app.inject({ + method: 'POST', + url: '/api/sessions', + payload: { name: 'w1-nowd', mode: 'shell' }, + }); + expect(res.statusCode).toBe(200); + const data = unwrap(res.body); + expect(Object.keys(data)).toEqual(['session']); + expect(groupOf(world.layouts[OWNER], data.session.id)).toBeNull(); + } finally { + process.chdir(cwd); + } + }); + it('POST /api/sessions outside any case gets no case default and keeps its response shape', async () => { const res = await harness.app.inject({ method: 'POST', From 3ca1d4832e9d16d1b3869d1f4cab921ea6b82464 Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Thu, 22 Oct 2026 19:29:50 +0200 Subject: [PATCH 87/96] test(ralph): pin the case name a Ralph loop start registers with Merge-time fix for #676 (review finding 2). POST /api/ralph-loop/start now passes { caseName } to addSession so the session lands in its case's default session group, and nothing tested it. The registration-order test asserts the placement argument; it fails against the route without the change. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- test/routes/ralph-routes.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/test/routes/ralph-routes.test.ts b/test/routes/ralph-routes.test.ts index 4f2eaa56..23d55e11 100644 --- a/test/routes/ralph-routes.test.ts +++ b/test/routes/ralph-routes.test.ts @@ -476,6 +476,8 @@ describe('ralph-routes', () => { payload: { taskDescription: 'test task', completionPhrase: 'DONE', caseName: 'registration-order' }, }); await vi.waitFor(() => expect(harness.ctx.addSession).toHaveBeenCalledTimes(1)); + // The case rides the registration, so the session lands in the case's default session group. + expect(harness.ctx.addSession).toHaveBeenCalledWith(expect.anything(), { caseName: 'registration-order' }); expect(harness.ctx.persistSessionState).not.toHaveBeenCalled(); expect(harness.ctx.setupSessionListeners).not.toHaveBeenCalled(); From a3efc9b9637e9d2ee42ac85b30f62f86cd4c98d7 Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Thu, 22 Oct 2026 19:31:21 +0200 Subject: [PATCH 88/96] fix(tabs): keep the case-default GET read-only, forget a stale id outside the rollback Merge-time fix for #676 (review nits 4 and 5). sessionCreated forgot a stale case default inside the try whose catch rolls the registration back; it now does so after the try, once the session is committed, so the cleanup can never drop a committed session from the live map. caseDefaultGroup (behind GET /api/cases/:name/default-group) no longer deletes a stale id as a side effect: it reports Ungrouped and leaves the value for the next creation to forget. The two tests that expected the read to clear it now expect it to write nothing; the creation test still pins the cleanup. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- docs/api-reference.md | 3 ++- src/tab-layout-service.ts | 24 +++++++++---------- src/web/routes/case-routes.ts | 3 ++- test/case-default-group.test.ts | 6 +++-- test/routes/case-default-group-routes.test.ts | 5 ++-- 5 files changed, 23 insertions(+), 18 deletions(-) diff --git a/docs/api-reference.md b/docs/api-reference.md index 4837a376..99a42de1 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -783,7 +783,8 @@ non-string) is a `400 INVALID_INPUT`. Requests without the field are unchanged. - `GET /api/v1/cases/:name/default-group` → `{ caseName, groupId, groups }`: `groupId` is a group id from the caller's own tab layout or `null` (Ungrouped), `groups` the caller's current named groups (`{ id, name }`, in layout order). A stored id whose group was - deleted reads back as `null` and is forgotten. + deleted reads back as `null` (the read changes nothing; the next session created for the + case forgets it). - `PUT /api/v1/cases/:name/default-group` with `{ "groupId": string | null }` saves it. An id not in the caller's layout is a `400 INVALID_INPUT` that leaves the old value in place. diff --git a/src/tab-layout-service.ts b/src/tab-layout-service.ts index a6c781d6..a809a80a 100644 --- a/src/tab-layout-service.ts +++ b/src/tab-layout-service.ts @@ -584,6 +584,7 @@ export class TabLayoutService { const caseDefault = caseName ? this.deps.caseDefaults?.getCaseDefaultGroup(owner, caseName) : undefined; let staleCaseDefault = false; const rollback = register?.(); + let layout: TabLayout; try { const groupId = placement.tabGroupId; const adjust = @@ -600,33 +601,32 @@ export class TabLayoutService { return follow ? placed : pinCreatedChild(placed, sessionId, metadata); } : undefined; - const layout = await this.getUnlocked(owner, adjust); - // A default whose group was deleted is ignored above and forgotten here, - // only once the creation's own write has landed. - if (staleCaseDefault && caseName) this.deps.caseDefaults?.deleteCaseDefaultGroup(owner, caseName); - return layout; + layout = await this.getUnlocked(owner, adjust); } catch (error) { if (typeof rollback === 'function') rollback(); throw error; } + // A default whose group was deleted is ignored above and forgotten here, only once the + // creation's own write has landed, and outside the rollback: the session is committed by now, + // and nothing this cleanup does may drop it from the live map. + if (staleCaseDefault && caseName) this.deps.caseDefaults?.deleteCaseDefaultGroup(owner, caseName); + return layout; }); } /** * A case's default session group and the owner's named groups to choose from. - * A stored id that is no longer one of this owner's groups is forgotten and - * reported as `null` (Ungrouped), which is also where creation would put it. + * A stored id that is no longer one of this owner's groups is reported as + * `null` (Ungrouped), which is also where creation would put it. Read-only: + * the next creation for the case forgets the stale id. */ async caseDefaultGroup(owner: string, caseName: string): Promise<CaseDefaultGroupView> { return this.withOwner(owner, async () => { const { authoritative } = await this.prepareUnlocked(owner); const groups = authoritative.groups.map((group) => ({ id: group.id, name: group.name })); const stored = this.deps.caseDefaults?.getCaseDefaultGroup(owner, caseName); - if (typeof stored === 'string' && !groups.some((group) => group.id === stored)) { - this.deps.caseDefaults?.deleteCaseDefaultGroup(owner, caseName); - return { groupId: null, groups }; - } - return { groupId: typeof stored === 'string' ? stored : null, groups }; + const known = typeof stored === 'string' && groups.some((group) => group.id === stored); + return { groupId: known ? stored : null, groups }; }); } diff --git a/src/web/routes/case-routes.ts b/src/web/routes/case-routes.ts index 6d5c7c42..8a679b91 100644 --- a/src/web/routes/case-routes.ts +++ b/src/web/routes/case-routes.ts @@ -1717,7 +1717,8 @@ export function registerCaseRoutes( * The tab group new sessions of this case land in when the create request names * no group and no parent places them. Scoped to the caller's own tab layout: the * options are their groups, and a stored id that is no longer one of them reads - * as Ungrouped (null) and is forgotten. Not admin-gated in multi-user mode: the + * as Ungrouped (null); the read writes nothing (the next creation for the case + * forgets it). Not admin-gated in multi-user mode: the * value is per owner (like the layout it points into), never server-wide. */ app.get('/api/cases/:name/default-group', async (req, reply) => { diff --git a/test/case-default-group.test.ts b/test/case-default-group.test.ts index 5b2c79b3..8a8c8c3c 100644 --- a/test/case-default-group.test.ts +++ b/test/case-default-group.test.ts @@ -299,7 +299,7 @@ describe('TabLayoutService case default placement', () => { }); describe('TabLayoutService case default reads and writes', () => { - it('reports the selection with the owner group options, clearing a stale id', async () => { + it('reports the selection with the owner group options, and a stale id as Ungrouped without writing', async () => { const h = createHarness({ layouts: { alice: grouped() }, live: facts() }); expect(await h.service.caseDefaultGroup('alice', 'app')).toEqual({ groupId: null, @@ -310,7 +310,9 @@ describe('TabLayoutService case default reads and writes', () => { }); h.defaults.alice = { app: 'gone' }; expect((await h.service.caseDefaultGroup('alice', 'app')).groupId).toBeNull(); - expect(h.caseDefaults.getCaseDefaultGroup('alice', 'app')).toBeUndefined(); + // A read never writes: the next creation for the case is what forgets the stale id. + expect(h.caseDefaults.deleteCaseDefaultGroup).not.toHaveBeenCalled(); + expect(h.caseDefaults.getCaseDefaultGroup('alice', 'app')).toBe('gone'); }); it('saves a known group or null, and refuses an id that is not in the owner layout', async () => { diff --git a/test/routes/case-default-group-routes.test.ts b/test/routes/case-default-group-routes.test.ts index 07ba2945..e34f4147 100644 --- a/test/routes/case-default-group-routes.test.ts +++ b/test/routes/case-default-group-routes.test.ts @@ -343,10 +343,11 @@ describe('case default group: GET/PUT /api/cases/:name/default-group', () => { expect(data.groups[1]).toEqual({ id: 'g-ops', name: 'Operations' }); }); - it('a deleted group reads back as Ungrouped and is cleared', async () => { + it('a deleted group reads back as Ungrouped, and the read changes nothing', async () => { world.defaults[OWNER] = { [CASE]: 'g-gone' }; expect((await get()).json().data.groupId).toBeNull(); - expect(CASE in world.defaults[OWNER]).toBe(false); + // GET is read-only; the next session created for the case forgets the stale id. + expect(world.defaults[OWNER][CASE]).toBe('g-gone'); }); it.each([ From 9b8cb82c3bc84eb1df0ebb321e1f5ddc63441410 Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Thu, 22 Oct 2026 19:32:12 +0200 Subject: [PATCH 89/96] fix(tabs): leave the default-group options alone when the groups did not change Merge-time fix for #676 (review nit 6). Once a Case settings popover had been opened, every adopted tab layout (each create, delete and reorder) replaced both selects' options, even with the popover hidden and the group list unchanged, so a session created elsewhere could reset a native dropdown the user had open. _refreshCaseDefaultGroupOptions now returns early when the ids and names are the same. A vm case pins it. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- docs/architecture-invariants.md | 2 +- src/web/public/session-ui.js | 10 ++++++++-- test/case-default-group-ui.test.ts | 21 +++++++++++++++++++++ 3 files changed, 30 insertions(+), 3 deletions(-) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 5c009870..2b99c514 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -331,7 +331,7 @@ So: `_confirmIdle()` (session.ts) requires the pane to go quiet, and then asks t - **Edits are named operations, saved serially.** `createEditCoordinator` applies `createGroup` / `renameGroup` / `deleteGroup` / `reorderGroup` / `moveRef` to the rail at once, then sends ONE `PUT /api/tab-layout {baseVersion, layout}` at a time; edits made meanwhile wait and go out on the version that write returns. A 409 carries the server's layout: the in-flight operations are replayed onto it (an operation that no longer applies is dropped and reported) and re-sent, at most `maxAttempts` times; a 400 re-reads once (a second 400 is reported as a failed save, not as a race); anything else reports and re-reads. A `moveRef` moves the session together with the sessions that still follow it and marks a hand-moved child `placement: 'manual'`, mirroring the server's `moveRef`, and `normalizeLayout` keeps `placement` because whole layouts are written back. `_onTabLayoutChanged` / `_applyTabLayout` defer a read while a write is in flight and rebase unsaved edits onto a read otherwise. ⚠️ A FAILED read (`_applyTabLayout(null)`) while edits are pending keeps the held layout and the editor and re-reads after the write settles: disposing there would orphan the in-flight write, whose 409 then never gets its rebase. Any path that does drop unsaved work says so in a toast. On `pagehide`, unconfirmed operations go out in a `keepalive` PUT AND into sessionStorage as `{ owner, baseVersion, savedAt, operations }`; after reload they replay onto the fresh layout (a no-op when the keepalive landed), but only for the same owner, within `TAB_LAYOUT_PENDING_MAX_AGE_MS` (60 s), and never onto a layout older than the copy's base. A "Move to <group>" with no anchor carries no `index`, so a replay still puts the row last. - **Every way in has a keyboard path.** The session row menu (Shift+F10 in the tree, the rail's overflow button) gains Move up/down, Move to <group>, Move to Ungrouped and Move to new group in the vertical rail (only "new group" before the first group exists; nothing on the strip). A group header opens its menu with Shift+F10 / ContextMenu, right-click or its hover glyph (a non-focusable, `aria-hidden` span: a treeitem holds no interactive children), and F2 renames it inline (Enter commits and refocuses the header; a commit by BLUR leaves focus where it went, since refocusing from inside the blur handler overrides the user's click). The glyph stays visible under `@media (hover: none)`: a touch tablet has no hover and no long-press `contextmenu`. Group names in "Move to" labels are quoted, so a group named "New group" or "ungrouped" cannot read (or translate, case-insensitively) like the fixed entries. The menu closes on Escape (which it consumes before the global Escape handler), a pointer outside, Tab, focus leaving it, a resize, a second open and any full re-render. It borrows the `.tab-rail-action-menu` class for its look only: `closeTabRailActionMenu()` excludes `.tab-layout-group-action-menu`, so closing the row menu (every `session:deleted` does) cannot strand the group menu's listeners. The inline group editor shares `_activeRename` with the session rename, so only the CURRENT editor may release `_inlineRenameActive`. - **New, child and web tabs land in groups.** A group menu's "New session" (`createSessionInTabGroup`) runs ONE launch with `run({ count: 1, tabGroupId })`; every launch path puts `tabGroupId` on its create request (`POST /api/sessions` or `/api/quick-start`), and `TabLayoutService.sessionCreated(owner, id, { tabGroupId }, register)` places the session at the end of that group in the SAME versioned write that first records it. The session enters the live map inside the owner lock (`register`, rolled back if the write fails), so no queued layout edit can commit it first. The group is looked up in the creating owner's own layout, so an unknown, deleted or foreign id is ignored rather than failing the spawn, and an explicit group is a hand placement (a child placed this way is `manual`). The create response carries the committed `tabLayout`, adopted before the tab is drawn. A session spawned by another one (`parentSessionId`) and placed by nobody lands after its parent: the server's normalization puts it after the parent's rows in the parent's container, and `project({ parents })` draws a not-yet-stored child there at once. Whether it then FOLLOWS its parent is the synced `spawnedTabsFollowParent` setting (default OFF), which `sessionCreated()` reads fresh through the `childrenFollowParent` dep: off, the child is marked `manual` in that same creating write; on, it follows until moved by hand. Deciding at creation is enough, so flipping the setting leaves existing tabs alone. A hand move makes a following child `manual`; with the setting on, the row menu notes where a child sits (following, placed by hand, parent closed, parent loop; plain text, never a focusable menu item) and offers "Follow parent again" only to a `manual` child, which clears `placement` and is re-derived by the server on save. With the setting off the row menu says nothing about placement. A legacy `PUT /api/session-order` (strip or phone drag, a same-section Ctrl+Shift+{/}, a device without a layout, an admin's cross-owner order) marks a child `manual` ONLY when the requested order splits it from its parent's block (`applyLegacySessionRank`, `childrenSplitFromParent`); a PUT of the current order, or one that moves a family together, leaves every stored placement alone. The browser never echoes a create or delete through that PUT, with or without groups: the service already re-projects the order and broadcasts it (`session:orderChanged`), and `syncSessionOrder` adopts it. A child whose parent is gone stands on its own and is never re-adopted. Saved web tabs are layout refs like sessions, but OPENNESS stays per device: a grouped web tab renders (and takes an Alt+N slot) only while it is open on this device, and reopens where the layout keeps it. -- **A case's default session group** (`caseDefaultGroups` in state.json, keyed by the layout owner key then case name; a group id or `null` for Ungrouped) is the fallback placement for a new session of that case. It is read and written ONLY through `TabLayoutService` (`caseDefaultGroup`, `setCaseDefaultGroup`, `forgetCaseDefaultGroup`, and `sessionCreated`'s `caseName` hint), under the owner lock, never by a route or the store directly. Precedence inside `sessionCreated`'s single write: an explicit `tabGroupId` found in the owner's layout, then a parent that places the child (`hasPlacingParent`, the same conditions as `pinCreatedChild`), then the case default, else normal placement. The id is looked up in the creating owner's own layout, so a deleted or foreign id never fails a spawn; a stale id is forgotten only AFTER the creating write lands (a failed write rolls back and changes nothing). `POST /api/quick-start` and `POST /api/ralph-loop/start` pass their `caseName`; `POST /api/sessions` infers it LEXICALLY from `workingDir` (`inferLocalCaseName`: a direct child of the caller's case space, or an exact linked-case path for an admin or single-user, or a Claude worktree of either, `<case>/.claude/worktrees/<name>`, which is how the Git worktrees modal launches Claude while every other CLI goes through quick-start), only for a named `workingDir` (never the server's own cwd), never for a remote attach and never touching the filesystem. Cron jobs, the one-shot run route and reboot restore keep normal placement. The Case settings selector (`onCaseDefaultGroupChanged`, session-ui.js) reads it on popover open and is redrawn from every adopted layout (`_refreshCaseDefaultGroupOptions` in `_applyTabLayout`). +- **A case's default session group** (`caseDefaultGroups` in state.json, keyed by the layout owner key then case name; a group id or `null` for Ungrouped) is the fallback placement for a new session of that case. It is read and written ONLY through `TabLayoutService` (`caseDefaultGroup`, `setCaseDefaultGroup`, `forgetCaseDefaultGroup`, and `sessionCreated`'s `caseName` hint), under the owner lock, never by a route or the store directly. Precedence inside `sessionCreated`'s single write: an explicit `tabGroupId` found in the owner's layout, then a parent that places the child (`hasPlacingParent`, the same conditions as `pinCreatedChild`), then the case default, else normal placement. The id is looked up in the creating owner's own layout, so a deleted or foreign id never fails a spawn; a stale id is forgotten only AFTER the creating write lands (a failed write rolls back and changes nothing). `POST /api/quick-start` and `POST /api/ralph-loop/start` pass their `caseName`; `POST /api/sessions` infers it LEXICALLY from `workingDir` (`inferLocalCaseName`: a direct child of the caller's case space, or an exact linked-case path for an admin or single-user, or a Claude worktree of either, `<case>/.claude/worktrees/<name>`, which is how the Git worktrees modal launches Claude while every other CLI goes through quick-start), only for a named `workingDir` (never the server's own cwd), never for a remote attach and never touching the filesystem. Cron jobs, the one-shot run route and reboot restore keep normal placement. The Case settings selector (`onCaseDefaultGroupChanged`, session-ui.js) reads it on popover open and is redrawn from an adopted layout whose groups changed (`_refreshCaseDefaultGroupOptions` in `_applyTabLayout`; an unchanged group list is skipped, since every create, delete and reorder broadcasts a layout and a rebuild would reset an open dropdown). - **Only the grouped rail is a tree.** `#sessionTabs` ships as `role=tablist` with `role=tab` rows, and the header strip, sidebar and flat rail keep exactly that. While grouped, `_applyTabListRole` makes it `role=tree` (and restores `tablist` + its label when grouping ends), named-group headers are level-1 `treeitem`s that `aria-owns` their rows' `role=group` (rows sit beside the header, not inside it), and ungrouped rows plus a collapsed group's kept selection are level-1 items. A collapsed header owns nothing, a group with no open rows is a leaf (no `aria-expanded`, no owned group), and the "Ungrouped" heading is `aria-hidden`. Rows are re-roled in the DOM by `_applyTabTreeSemantics` after render, never by rewriting their markup, so a grouped row's content stays the flat row's. - **One tab stop in the tree.** Exactly one treeitem carries `tabindex=0` (the focused or selected item); every control inside a row drops to `-1`, which is why Shift+F10 / ContextMenu open a row's actions from the keyboard. Focus survives a full re-render by identity (`group:`/`session:`/`webview:`; a row a collapse just hid hands focus to its header), but only when focus was already inside the rail. The tree walk (`_tabTreeItems`) follows painted order WITHIN each group when the rail is sorted; the flat list keeps its own whole-list computed-order walk. `aria-posinset`/`aria-setsize` follow painted order too, so the incremental render path re-runs `_applyTabTreePositions` after it re-sorts rows in place. ⚠️ `_handleTabTreeKeydown` acts only when the key lands on the treeitem ITSELF: a key on a focused in-row control (close, overflow, the rename input) is that control's, or Enter on the overflow button re-selects the row instead of reopening its menu. ⚠️ The roving `tabindex=-1` also hides every item but the stop from the keyboard-dismiss selector's `[tabindex]` arm, which is why `MOBILE_KEYBOARD_DISMISS_EXEMPT_SELECTOR` lists `[role="treeitem"]` (see Dismissing the on-screen keyboard). diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index 73bf6303..2c52fa65 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -3429,12 +3429,18 @@ Object.assign(CodemanApp.prototype, { /** * The tab layout changed (a group was created, renamed or deleted): redraw the * options from it. A deleted selection shows as Ungrouped, which is where the - * server now places new sessions (it forgets the stale ID on its next read). + * server now places new sessions (it forgets the stale ID at the next creation). + * Every create, delete and reorder broadcasts a layout, so an unchanged group + * list is left alone: rebuilding it would reset a dropdown the user has open. */ _refreshCaseDefaultGroupOptions(layout) { const state = this._caseDefaultGroupState; if (!state || !layout || !Array.isArray(layout.groups)) return; - state.groups = layout.groups.map((group) => ({ id: group.id, name: group.name })); + const groups = layout.groups.map((group) => ({ id: group.id, name: group.name })); + const unchanged = groups.length === state.groups.length + && groups.every((group, i) => group.id === state.groups[i].id && group.name === state.groups[i].name); + if (unchanged) return; + state.groups = groups; const value = this._renderCaseDefaultGroupSelects(state.groups, state.groupId); if (!value) state.groupId = null; }, diff --git a/test/case-default-group-ui.test.ts b/test/case-default-group-ui.test.ts index d85f3175..f5903b7a 100644 --- a/test/case-default-group-ui.test.ts +++ b/test/case-default-group-ui.test.ts @@ -137,6 +137,27 @@ describe('Case settings: Default session group selector', () => { expect(h.elements.caseDefaultGroupMobile.value).toBe(''); }); + it('leaves the options alone when a layout broadcast does not change the groups', async () => { + const h = harness({ + groups: [ + { id: 'g1', name: 'Core' }, + { id: 'g2', name: 'Ops' }, + ], + groupId: 'g2', + }); + await h.app._loadCaseDefaultGroup(); + const before = h.elements.caseDefaultGroup.options; + // A session created or moved elsewhere: same groups, other refs. + h.app._refreshCaseDefaultGroupOptions({ + groups: [ + { id: 'g1', name: 'Core', refs: [{ kind: 'session', id: 's9' }] }, + { id: 'g2', name: 'Ops', refs: [] }, + ], + }); + expect(h.elements.caseDefaultGroup.options).toBe(before); // not rebuilt, so an open dropdown stays open + expect(h.elements.caseDefaultGroup.value).toBe('g2'); + }); + it('the layout refresh is wired into _applyTabLayout', () => { const app = readFileSync(resolve(PUBLIC, 'app.js'), 'utf8'); const body = app.slice(app.indexOf(' _applyTabLayout(layout) {')); From 34cf798b1050a82cb2174c0b6a4a70a36e4bd0f0 Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Thu, 22 Oct 2026 19:32:17 +0200 Subject: [PATCH 90/96] feat(copilot): plan-usage chip row for the monthly premium-request quota (#662) Squash of PR #662. The header plan-usage chip gains a Copilot row: the calendar-month premium-request quota, with the request counts and the reset in the tooltip, read every 10 minutes from GitHub's copilot_internal/user endpoint with the Copilot CLI's own sign-in token. It rides showPlanUsageLimits and is on by default while the CLI is installed and signed in; any failure leaves the chip without the row. Merge-time fixes by the maintainer: the gates (setting, then token, then the CLI probe) live in readCopilotPlanUsageIfEnabled() so a host with no Copilot sign-in never runs the login-shell probe each tick, with a test that Plan Usage Limits off means no request; the X-GitHub-Api-Version header the Copilot CLI itself does not send is dropped; and CLAUDE.md, architecture-invariants, README and copilot-integration describe the read. The hooks-config import in server.ts was merged with #666's. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- .changeset/copilot-plan-usage.md | 5 + CLAUDE.md | 2 +- README.md | 2 +- docs/architecture-invariants.md | 2 +- docs/cli-registry.md | 2 +- docs/copilot-integration.md | 1 + docs/wiki/Agent-CLIs.md | 11 +- docs/wiki/FAQ.md | 8 +- docs/wiki/Installation.md | 5 +- docs/wiki/Keeping-Agents-Running.md | 8 +- docs/wiki/Security.md | 11 +- docs/wiki/The-Dashboard.md | 2 +- src/usage-telemetry.ts | 68 ++++++- src/utils/copilot-plan-usage.ts | 117 ++++++++++++ src/utils/index.ts | 1 + src/web/plan-usage-latest.ts | 15 +- src/web/public/app.js | 30 ++- src/web/public/index.html | 2 +- src/web/server.ts | 45 ++++- test/copilot-plan-usage.test.ts | 278 ++++++++++++++++++++++++++++ test/plan-usage-chip.test.ts | 40 ++++ test/plan-usage-latest.test.ts | 13 ++ 22 files changed, 642 insertions(+), 26 deletions(-) create mode 100644 .changeset/copilot-plan-usage.md create mode 100644 src/utils/copilot-plan-usage.ts create mode 100644 test/copilot-plan-usage.test.ts diff --git a/.changeset/copilot-plan-usage.md b/.changeset/copilot-plan-usage.md new file mode 100644 index 00000000..4b69efb3 --- /dev/null +++ b/.changeset/copilot-plan-usage.md @@ -0,0 +1,5 @@ +--- +'aicodeman': minor +--- + +The plan-usage chip in the header gains a `Copilot` row: the calendar-month premium-request quota, with the request counts and the reset in the tooltip. The host reads it every 10 minutes from GitHub's `copilot_internal/user` endpoint with the Copilot CLI's own sign-in token. It is on by default while the CLI is installed and signed in (an absent Plan Usage Limits setting reads as on), and turning Plan Usage Limits off in App Settings stops the request. Any failure leaves the chip without a Copilot row. The FAQ, Installation and Security pages say so. diff --git a/CLAUDE.md b/CLAUDE.md index 4b2cf2eb..2a061c91 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -157,7 +157,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Auto-resume on usage limit** (opt-in per session, top of the Respawn tab): when Claude halts on a subscription limit, `usage-limit-patterns.ts` (pure, unit-tested) parses the reset time and `SessionAutoOps` arms a timer for reset+2min, then sends Esc + `continue`. ⚠️ Respawn cycles are blocked while paused (`isLimitPaused` guard in `onIdleDetected`), which is what prevents `/clear` from wiping the paused conversation. Claude-mode only. → [architecture-invariants#auto-resume-on-usage-limit](docs/architecture-invariants.md#auto-resume-on-usage-limit) -**Plan-usage chip** (`showPlanUsageLimits`, per-device: desktop default **ON**, handhelds OFF): resolve DISPLAY only through `planUsageChipEnabled()` (settings-ui.js). The same setting is the server-side COLLECTION switch, read fresh by `readPlanUsageTelemetryEnabled()` (hooks-config.ts) at every claude create/respawn. ⚠️ An ABSENT key reads as ON in the reader; `GET /api/settings` must never write. ⚠️ A save sends `showPlanUsageLimits` ONLY when it flips the chip on that device (`planUsageCollectionFlip()`), or a phone switches collection off for every desktop. Claude data comes from the statusLine exporter, injected as an EPHEMERAL `claude --settings` flag (`resolveStatusLineCliCommand`), never written to disk, WRAPPING a user's own statusLine, posting to `POST /api/status-telemetry`. Codex comes from a read-only `account/rateLimits/read` poll (main bucket only). The statusline's `prompt_cache` object (Claude Code v2.1.251+) is kept by `StatusTelemetrySchema` (every documented scalar field, null-tolerant; `parsePromptCache()` in usage-telemetry.ts normalizes only the stored subset) and shown in the footer as `cache:until HH:MM` / `cache:cold` (only when the user has no statusline of their own) and in Session Options → Respawn (`#promptCacheStatus`, fed by `Session.promptCache` in `toState()` plus the change-only `session:promptCache` SSE event, worded by the pure `CodemanPromptCache.format`): warm-until/cold, the re-cache stake, the hit rate, the last miss's causes, and a `Context compacted at HH:MM` note after a compaction or a tool-result clearing (stamped on the first warm report whose `recache_tokens_if_cold` is null after one that carried it, never from `expected_rebuilds`, which Claude Code bumps only at the next request; NEVER an alert). ⚠️ `Session.setPromptCache()` stamps `compactedAt` onto a NEW object (never mutating the parsed report) and the route broadcasts the STORED object; the last report is restored on restart (constructor `promptCache`, like `displayModel`) and the readout is time-aware (a passed `expiresAt` reads cold, not stale-warm). It is the source of truth for the cache TTL, never the plan tier. → [architecture-invariants#plan-usage-chip-statusline-telemetry](docs/architecture-invariants.md#plan-usage-chip-statusline-telemetry), `docs/usage-limits-display-plan.md` +**Plan-usage chip** (`showPlanUsageLimits`, per-device: desktop default **ON**, handhelds OFF): resolve DISPLAY only through `planUsageChipEnabled()` (settings-ui.js). The same setting is the server-side COLLECTION switch, read fresh by `readPlanUsageTelemetryEnabled()` (hooks-config.ts) at every claude create/respawn. ⚠️ An ABSENT key reads as ON in the reader; `GET /api/settings` must never write. ⚠️ A save sends `showPlanUsageLimits` ONLY when it flips the chip on that device (`planUsageCollectionFlip()`), or a phone switches collection off for every desktop. Claude data comes from the statusLine exporter, injected as an EPHEMERAL `claude --settings` flag (`resolveStatusLineCliCommand`), never written to disk, WRAPPING a user's own statusLine, posting to `POST /api/status-telemetry`. Codex comes from a read-only `account/rateLimits/read` poll (main bucket only). Copilot comes from a read-only `GET https://api.github.com/copilot_internal/user` (`refreshCopilotPlanUsage()` in server.ts, at startup and every 10 minutes; `parseCopilotQuotaResponse()` keeps only the metered `premium_interactions` quota, as a `monthly` window with `used`/`limit`) sent with Copilot's OWN sign-in token (`resolveCopilotToken()`: `COPILOT_GITHUB_TOKEN`/`GH_TOKEN`/`GITHUB_TOKEN`, else the plain-text token in `<COPILOT_HOME or ~/.copilot>/config.json`; a keychain sign-in shows no row). It is **on by default**, like the Codex poll: it rides `showPlanUsageLimits` (absent = ON), and Plan Usage Limits off stops the request. ⚠️ The gates live in `readCopilotPlanUsageIfEnabled()` (copilot-plan-usage.ts) in this order: the setting, then a token, then `isCopilotAvailable()`, whose resolver can run a login-shell probe that a host with no sign-in must never pay each tick. ⚠️ `redirect: 'error'`, the token is never logged, stored or broadcast, any failure is silently no row, and the row is admin-only on the wire like Codex's. The statusline's `prompt_cache` object (Claude Code v2.1.251+) is kept by `StatusTelemetrySchema` (every documented scalar field, null-tolerant; `parsePromptCache()` in usage-telemetry.ts normalizes only the stored subset) and shown in the footer as `cache:until HH:MM` / `cache:cold` (only when the user has no statusline of their own) and in Session Options → Respawn (`#promptCacheStatus`, fed by `Session.promptCache` in `toState()` plus the change-only `session:promptCache` SSE event, worded by the pure `CodemanPromptCache.format`): warm-until/cold, the re-cache stake, the hit rate, the last miss's causes, and a `Context compacted at HH:MM` note after a compaction or a tool-result clearing (stamped on the first warm report whose `recache_tokens_if_cold` is null after one that carried it, never from `expected_rebuilds`, which Claude Code bumps only at the next request; NEVER an alert). ⚠️ `Session.setPromptCache()` stamps `compactedAt` onto a NEW object (never mutating the parsed report) and the route broadcasts the STORED object; the last report is restored on restart (constructor `promptCache`, like `displayModel`) and the readout is time-aware (a passed `expiresAt` reads cold, not stale-warm). It is the source of truth for the cache TTL, never the plan tier. → [architecture-invariants#plan-usage-chip-statusline-telemetry](docs/architecture-invariants.md#plan-usage-chip-statusline-telemetry), `docs/usage-limits-display-plan.md` **Prompt-cache keep-warm** (`src/prompt-cache-keepwarm.ts`; per-session opt-in on the Respawn tab, app-wide default `promptCacheKeepWarm` + cap `promptCacheKeepWarmMaxHours` in App Settings → System → Automation, SYNCED, default OFF / 2 h; step 2 of Discussion #554): one keep-alive turn (`KEEP_WARM_NUDGE`, under 100 chars) five minutes before an idle claude session's cache would expire, so the next prompt reads the conversation from cache instead of re-writing it at 2x. Codeman owns the idle policy for these sessions: a launch with keep-warm on also carries `"idleCompaction": false`, folded LAST into the ONE `--settings` JSON (`session-cli-registry-bridge.ts`), decided once per create/respawn by `decideIdleCompactionOptOut()` (tmux-manager.ts: keep-warm on via `keepWarmOn()`, exporter `injected`, claude's `idleCompactionSetting` gate at 2.1.290, fail-closed like `--name`) and recorded as `MuxSession.idleCompactionOff`; a keep-warm-off launch stays byte-identical (`test/cli-registry-spawn-golden.test.ts`). Switched on after launch it is best-effort and the readout says idle compaction may still run until the next launch. ⚠️ The deadline is `Session.promptCache.expiresAt` from the statusline, never idle detection or the plan tier; a `5m` TTL is a hard stop, and so is a launch whose `statusLineExporter` is not `injected`. ⚠️ Every guard lives in the pure `decideKeepWarm()` (effective opt-in, where an agent-spawned session (`parentSessionId`) does not inherit the app default; exporter injected; warm; no `compactedAt` this gap; tokens at stake; live pane (pid AND no `paneExit`); no limit pause (auto-resume's, or a limit message on screen); no respawn/Ralph/task/waiter; inside the cap since `lastRealPromptAt`; idle; no dialog; EMPTY composer via `composerIsEmpty()`, continuation lines included, read from a STYLED capture so Claude Code's faint prompt suggestion is not taken for a draft; no recent keystroke); add a guard there, never in the service. ⚠️ The nudge IS a submit and moves `lastSubmitAt` (the response viewer's conversation correlation needs it); the window `beginKeepAliveTurn()` opens keeps it off `lastRealPromptAt` (the cap's own persisted clock) and opens its `stop`/`idle_prompt` hooks pre-acknowledged (`preAcknowledged` on `notePrompt`), so nothing alerts, and leaves an idle item already pending exactly as found (only a human spends an idle alert; `session:working`/`session:idle` carry `keepAlive: true` so the page's stuck timer ignores the turn); the keep-alive's own `idle_prompt` ends that window (`endKeepAliveTurn()`; `KEEP_WARM_TURN_WINDOW_MS` is only a ceiling, since a slow turn's idle prompt lands a minute after it ends), so does a nudge whose write failed (retried a minute later, never past the last-chance line), and so does any other submit: a `fromUser` write, a programmatic write of anything but the nudge, or a `prompt_submitted` hook whose prompt is not the nudge. Read My Mind drops its exact text (`isCapturablePrompt`). → [architecture-invariants#prompt-cache-keep-warm](docs/architecture-invariants.md#prompt-cache-keep-warm) diff --git a/README.md b/README.md index bad01443..cc2f4096 100644 --- a/README.md +++ b/README.md @@ -479,7 +479,7 @@ PTY Output → 16ms Server Batch → DEC 2026 Wrap → SSE → Client rAF → xt - **OSC 52 clipboard**: a program in a session that sets the clipboard with OSC 52 (Claude Code's `/copy`, a tmux yank, vim) puts the text on your browser's clipboard, in the main terminal and in every tile. Clipboard read requests are refused, and replaying a buffer never copies - **MCP server sync** _(opt-in)_: copies the user-level MCP servers each installed CLI has (Claude, Codex, Gemini, OpenCode, Antigravity, and GitHub Copilot CLI) into the others' own config files. Additive only, a server you switched off is never copied, **Preview** shows the changes first, and the previous file is kept as `.codeman-bak`. App Settings → Agents & CLIs; admin only in multi-user mode - **CJK / IME input** — full composition support for Chinese / Japanese / Korean, with Ctrl- and Alt-modified navigation keys passed through to the CLI -- **Plan usage in the header** — live Claude subscription usage (the 5-hour and weekly windows) from a statusline exporter Codeman hands to `claude` at spawn and never writes into your settings files, plus Codex limits from its own app-server; per device, on for desktops and off for phones +- **Plan usage in the header** — live Claude subscription usage (the 5-hour and weekly windows) from a statusline exporter Codeman hands to `claude` at spawn and never writes into your settings files, plus Codex limits from its own app-server and the GitHub Copilot monthly premium-request quota (the server asks `api.github.com` for it every 10 minutes with Copilot's own sign-in token: on by default while Copilot CLI is installed and signed in, and turning off Plan Usage Limits stops it); per device, on for desktops and off for phones - **Prompt-cache readout**: each Claude session shows whether its prompt cache is warm (`cache:until HH:MM`) or cold, in the footer (when you have no statusline of your own) and in Session Options → Respawn, with the cost of a re-cache and a note after a compaction. Display only; it survives a restart - **Session list, your way** — the header strip, a left sidebar with a filter box, or a vertical rail whose detailed rows carry created and state stamps and sort by activity; the phone home screen and the desktop home rail use the same order. On the rail, **Search sessions** narrows the list to the tabs that match, across every group, and a tab with an alert stays visible even when its name does not. Named **tab groups** collapse, reorder and take drags; a group's menu has **New session**, and a worker an agent spawns lands right after its parent (**Spawned tabs follow their parent**, off by default, keeps it moving with the parent). **Add to Focus** pins a shortcut to any tab in a section at the top of the rail, and **CLI Logos on Tabs** can be switched off per device - **Terminal looks** — seven skins, four of them light, per-device font family and weight (the bundled JetBrains Mono covers weights 100 to 800), and opt-in animations for tabs, agent windows, the terminal pane, connection lines and [tile-grid tiles](#tile-grid), all in App Settings → Animations (with a lab to try them) diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 1bd5ac24..2d2e72bf 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -159,7 +159,7 @@ Session create additionally pre-seeds the skill's §0 preamble cache (`seedAgent ### Plan-usage chip (statusLine telemetry) -**Plan-usage chip** (`showPlanUsageLimits`, per-device: desktop default **ON** since 1.9.3, handhelds OFF) renders compact Claude and Codex provider rows. Claude Code (v2.1.80+) pipes a JSON blob to a configured `statusLine.command` on each render; on Pro/Max it carries a `rate_limits` object (`five_hour`/`seven_day` windows only — no Opus weekly field — each `{used_percentage 0-100, resets_at epoch-SECONDS}`). ⚠️ **Injected as an EPHEMERAL `claude --settings` CLI flag at spawn (2026-09-07), never written to disk** — `resolveStatusLineCliCommand()`/`ensureStatusLineExporterScript()` in `hooks-config.ts` (`generateStatusLineCommand()`/`applyStatusLineConfig()` remain, but only as the legacy disk-write self-heal path: a workspace an older Codeman build touched gets its stale `.claude/settings.local.json` entry stripped the first time a session starts there again). The exporter WRAPS a user's own real statusline (`findEffectiveUserStatusLineCommand()`, walking Claude Code's own settings precedence) rather than replacing it, and POSTs the `rate_limits` blob to `POST /api/status-telemetry`. That route (auth-exempt like `/api/hook-event` — localhost-only, hook-secret-gated whenever auth is active, COD-91) parses via `usage-telemetry.ts` (pure, unit-tested), broadcasts SSE `session:statusTelemetry` (de-duped per session by `telemetrySignature` since the statusline fires on every assistant message), and returns a compact plain-text footer for the exporter to **print-through** (foreground POST in the no-wrap branch so its own stdout becomes the footer, printing NOTHING on failure, `curl -sfk` plus `|| true`, since a bare brand word on the statusline is what discussion #405 opened with; backgrounded — `>/dev/null 2>&1 </dev/null &`, closing stdin too — only in the wrap branch, where the user's own command owns the footer; `curl --max-time 5` bounds a hung, not just refused, Codeman). Main Codex subscription usage comes from the signed-in host CLI's read-only app-server `account/rateLimits/read` request at startup and every 5 minutes; `usage-telemetry.ts` selects only the main `codex` bucket (never model-specific buckets such as Spark), maps whatever 5-hour/7-day windows it supplies, and omits the provider row when unavailable. Credentials stay inside the CLI and no auth material is sent to the browser. `plan-usage-latest.ts` merges both process-wide sources and replays them in the SSE init snapshot (`getLightState`) so `#planUsageChip` renders immediately on page load/reconnect. `planUsageChipEnabled()` remains the single resolver behind the checkbox and chip visibility (DISPLAY only) — the SAME `showPlanUsageLimits` setting also doubles as the server-side telemetry COLLECTION switch, read FRESH from `settings.json` by `readPlanUsageTelemetryEnabled()` at every claude session create/respawn (`TmuxManager.createSession`/`respawnPane`), never cached, with no per-session field and no per-request wire field — applies uniformly across every claude-creation path (interactive Run, cron, Ralph Loop API, quick-start) and survives a Codeman restart by construction (nothing per-session to lose). ⚠️ An ABSENT key reads as ON, the same way an absent `workspaceHooksEnabled` does: the desktop chip already shows as on for an install that never touched the setting, and the exporter posts only to this Codeman over loopback. Resolving the default in the reader is what keeps `GET /api/settings` a plain read. It briefly reconciled the key on first read (persisting `true` when absent), but `readJsonConfig()` answers `{}` for ANY read failure, not only ENOENT, and every page load hits that route, so one unlucky read replaced the whole settings file with a one-key file; pinned by `test/routes/system-routes-settings-get-plan-usage-default.test.ts`. ⚠️ The client sends `showPlanUsageLimits` in a settings save ONLY when that save FLIPS the chip relative to what the device had (`planUsageCollectionFlip()` in settings-ui.js): the chip defaults OFF on handhelds, so sending it on every save let a phone saving its font size persist `false` and switch collection off for every desktop, whose chip then went stale with no error anywhere. An explicit toggle on any device still writes the switch. Injection covers LOCAL tmux-spawned claude sessions only: the non-tmux direct-PTY fallback (`Session.startInteractive` when tmux is unavailable) and the remote/docker pane builders do not carry the flag. Registry-gated on `getCli(mode)?.capabilities.statusLineTelemetry` rather than a hardcoded mode string. **Distinct from auto-resume** (which reacts to the Claude limit _message_; this proactively shows live percentages). Design: `docs/usage-limits-display-plan.md`. Tests: `test/usage-telemetry.test.ts`, `test/codex-plan-usage.test.ts`, `test/plan-usage-chip.test.ts`, `test/plan-usage-latest.test.ts`, `test/hooks-config.test.ts` (statusline exporter script + `readPlanUsageTelemetryEnabled`), `test/statusline-cli-flag.test.ts`. +**Plan-usage chip** (`showPlanUsageLimits`, per-device: desktop default **ON** since 1.9.3, handhelds OFF) renders compact Claude, Codex and Copilot provider rows. Claude Code (v2.1.80+) pipes a JSON blob to a configured `statusLine.command` on each render; on Pro/Max it carries a `rate_limits` object (`five_hour`/`seven_day` windows only — no Opus weekly field — each `{used_percentage 0-100, resets_at epoch-SECONDS}`). ⚠️ **Injected as an EPHEMERAL `claude --settings` CLI flag at spawn (2026-09-07), never written to disk** — `resolveStatusLineCliCommand()`/`ensureStatusLineExporterScript()` in `hooks-config.ts` (`generateStatusLineCommand()`/`applyStatusLineConfig()` remain, but only as the legacy disk-write self-heal path: a workspace an older Codeman build touched gets its stale `.claude/settings.local.json` entry stripped the first time a session starts there again). The exporter WRAPS a user's own real statusline (`findEffectiveUserStatusLineCommand()`, walking Claude Code's own settings precedence) rather than replacing it, and POSTs the `rate_limits` blob to `POST /api/status-telemetry`. That route (auth-exempt like `/api/hook-event` — localhost-only, hook-secret-gated whenever auth is active, COD-91) parses via `usage-telemetry.ts` (pure, unit-tested), broadcasts SSE `session:statusTelemetry` (de-duped per session by `telemetrySignature` since the statusline fires on every assistant message), and returns a compact plain-text footer for the exporter to **print-through** (foreground POST in the no-wrap branch so its own stdout becomes the footer, printing NOTHING on failure, `curl -sfk` plus `|| true`, since a bare brand word on the statusline is what discussion #405 opened with; backgrounded — `>/dev/null 2>&1 </dev/null &`, closing stdin too — only in the wrap branch, where the user's own command owns the footer; `curl --max-time 5` bounds a hung, not just refused, Codeman). Main Codex subscription usage comes from the signed-in host CLI's read-only app-server `account/rateLimits/read` request at startup and every 5 minutes; `usage-telemetry.ts` selects only the main `codex` bucket (never model-specific buckets such as Spark), maps whatever 5-hour/7-day windows it supplies, and omits the provider row when unavailable. Credentials stay inside the CLI and no auth material is sent to the browser. GitHub Copilot's monthly premium-request quota comes from `GET https://api.github.com/copilot_internal/user` (the snapshot the editors show; not part of GitHub's documented REST API, so read-only, best-effort and silent: any failure, an unlimited quota or a zero entitlement means no row) at startup and every 10 minutes (`refreshCopilotPlanUsage()`, `COPILOT_USAGE_POLL_INTERVAL_MS`). `parseCopilotQuotaResponse()` (usage-telemetry.ts) keeps only `premium_interactions` as a `monthly` window with `used` and `limit` (`chat` and `completions` are unlimited on every plan seen), and `telemetrySignature()` carries the limit, so a plan change mid-month re-renders even when `used` and the reset did not move. It is sent with Copilot's OWN sign-in token, resolved by `resolveCopilotToken()` the way the CLI does (`COPILOT_GITHUB_TOKEN`, `GH_TOKEN`, `GITHUB_TOKEN`, else the plain-text `authTokens` entry for a github.com sign-in in `<COPILOT_HOME or ~/.copilot>/config.json`); a sign-in kept in the OS keychain is not read, so it shows no row. ⚠️ The read is **on by default**, like the Codex poll: it rides the same `showPlanUsageLimits` collection switch (an absent key reads as ON, so it runs even for a phone-only user whose chip is hidden), and turning Plan Usage Limits off stops the request. ⚠️ `readCopilotPlanUsageIfEnabled()` (copilot-plan-usage.ts) holds the three gates in order: the setting, then a token, then `isCopilotAvailable()`, whose resolver ends in a login-shell probe that a host without a Copilot sign-in must never run on each tick; a null clears the row. ⚠️ The token is never logged, stored or broadcast, the request refuses redirects (`redirect: 'error'`, so a redirect cannot carry the token elsewhere), and the row is admin-only on the wire like Codex's (`deriveSseHint`, and the init snapshot's `planUsage`). `plan-usage-latest.ts` merges the three process-wide sources (a Claude statusline sample keeps the `codex` and `copilot` keys) and replays them in the SSE init snapshot (`getLightState`) so `#planUsageChip` renders immediately on page load/reconnect. `planUsageChipEnabled()` remains the single resolver behind the checkbox and chip visibility (DISPLAY only) — the SAME `showPlanUsageLimits` setting also doubles as the server-side telemetry COLLECTION switch, read FRESH from `settings.json` by `readPlanUsageTelemetryEnabled()` at every claude session create/respawn (`TmuxManager.createSession`/`respawnPane`), never cached, with no per-session field and no per-request wire field — applies uniformly across every claude-creation path (interactive Run, cron, Ralph Loop API, quick-start) and survives a Codeman restart by construction (nothing per-session to lose). ⚠️ An ABSENT key reads as ON, the same way an absent `workspaceHooksEnabled` does: the desktop chip already shows as on for an install that never touched the setting, and the exporter posts only to this Codeman over loopback. Resolving the default in the reader is what keeps `GET /api/settings` a plain read. It briefly reconciled the key on first read (persisting `true` when absent), but `readJsonConfig()` answers `{}` for ANY read failure, not only ENOENT, and every page load hits that route, so one unlucky read replaced the whole settings file with a one-key file; pinned by `test/routes/system-routes-settings-get-plan-usage-default.test.ts`. ⚠️ The client sends `showPlanUsageLimits` in a settings save ONLY when that save FLIPS the chip relative to what the device had (`planUsageCollectionFlip()` in settings-ui.js): the chip defaults OFF on handhelds, so sending it on every save let a phone saving its font size persist `false` and switch collection off for every desktop, whose chip then went stale with no error anywhere. An explicit toggle on any device still writes the switch. Injection covers LOCAL tmux-spawned claude sessions only: the non-tmux direct-PTY fallback (`Session.startInteractive` when tmux is unavailable) and the remote/docker pane builders do not carry the flag. Registry-gated on `getCli(mode)?.capabilities.statusLineTelemetry` rather than a hardcoded mode string. **Distinct from auto-resume** (which reacts to the Claude limit _message_; this proactively shows live percentages). Design: `docs/usage-limits-display-plan.md`. Tests: `test/usage-telemetry.test.ts`, `test/codex-plan-usage.test.ts`, `test/copilot-plan-usage.test.ts`, `test/plan-usage-chip.test.ts`, `test/plan-usage-latest.test.ts`, `test/hooks-config.test.ts` (statusline exporter script + `readPlanUsageTelemetryEnabled`), `test/statusline-cli-flag.test.ts`. Further detail: the handheld OFF default comes from the mobile block in `getDefaultSettings()`. `planUsageChipEnabled()` backs the two call sites that must never disagree (the App Settings checkbox and the chip's visibility). The absent-key-reads-as-ON rule mirrors `readWorkspaceHooksEnabled()`. The same statusline payload carries a `prompt_cache` object since Claude Code v2.1.251 (`warm`, `ttl`, `expires_at` in epoch seconds, `recache_tokens_if_cold`, `misses`, `expected_rebuilds`, `hit_ratio`, `last_miss_at`, `last_miss_cause`); `StatusTelemetrySchema` lists every documented scalar field null-tolerant and keeps `causes` as `unknown` (the object gains fields across CLI versions and several are documented as possibly null, so a stricter shape would 400 the whole post and blank the footer — the recurring `.optional()`/null trap), while `parsePromptCache()` (usage-telemetry.ts) normalizes only the subset Codeman stores, so a hostile localhost payload cannot bloat state. The footer shows it as `cache:until HH:MM` (server-local clock, never a countdown: the footer only re-renders on statusline triggers) while warm and `cache:cold` otherwise. Claude re-runs the statusline when `expires_at` passes, so the footer flips to cold on its own. ⚠️ A user's OWN statusline wins the footer (the exporter then posts in the background and prints theirs), so the state ALSO lives on `Session.promptCache` (`setPromptCache()`, in `toState()` for the Session Options → Respawn readout `#promptCacheStatus`, worded by the pure `CodemanPromptCache.format` in constants.js, which also shows the hit rate, the last miss's `causes`, and — after a compaction (`/compact` or Claude Code's own idle compaction) or a tool-result clearing, the first warm report whose `recache_tokens_if_cold` is null after one that carried it — a `Context compacted at HH:MM` note that is NEVER an alert) and the route broadcasts `session:promptCache` (the STORED object, so the compaction stamp rides along) only when the report CHANGES (the statusline fires per assistant message). ⚠️ `setPromptCache()` returns a NEW object when it stamps `compactedAt`, never mutating the parser's output, which is what keeps broadcasting the stored object meaningful; the stamp needs a KNOWN prior `recache_tokens_if_cold` (a first report already inside the null window, such as a mid-session restart's, gets none), is carried while the window holds and dropped at the next request, and never reads `expected_rebuilds`: Claude Code's tracker clears the window and counts the expected rebuild in that same next request, so no single report carries both (2.1.294-2.1.296), and `adoptClaudeSessionId()` drops the whole report on a conversation switch so a post-`/clear` pane never shows the old prefix's warm-until. The last report is restored on a Codeman restart (the constructor's `promptCache`, set directly so it is not re-stamped, like `displayModel`) so the readout is not blank until the pane's next render; the readout is time-aware, so a value whose `expiresAt` has passed reads as cold rather than as a stale warm (and `parsePromptCache` validates the converted-to-ms expiry against the Date range, so a huge `expires_at` cannot render `NaN:NaN`). ⚠️ A launch without the exporter never reports, so `TmuxManager._resolveStatusLine()` records why on the mux record at every create and respawn (`MuxSession.statusLine`, published as `SessionState.statusLineExporter`: `injected`, `collection-off`, `workspace-statusline`, `remote`) and the readout names the cause instead of "not reported yet" forever. It is a spawn-time fact: flipping Plan Usage on later changes nothing until the next launch. ⚠️ This object, not the plan tier, is the source of truth for the TTL: Claude Code requests the hour only for a subscription within plan usage, and drops to five minutes on an API key, a cloud provider or extra-usage credits. diff --git a/docs/cli-registry.md b/docs/cli-registry.md index 2bc1253e..72c28a2d 100644 --- a/docs/cli-registry.md +++ b/docs/cli-registry.md @@ -350,6 +350,6 @@ The rules the module keeps and the tests pin: it only ADDS (a name already defin ## GitHub Copilot CLI -`copilot` (npm `@github/copilot`) is a stock entry. A bare launch is `copilot`, in its own Manual Approval mode. The launch parameters follow the grok/omp shape: `model` (`--model`), `resumeId` (`--resume <id>`, an id, id prefix or session name: a single word that starts with a letter or digit, so a flag-shaped value such as `--yolo` is dropped rather than read as its own flag) and `continueSession` (`--continue`, skipped when a resume id is set), plus `allowAll` (`--yolo`: allow every tool, path and URL). The legacy config object is `copilotConfig` (`{ model, allowAll, resumeSessionId, continueSession }`). The Run button sends `allowAll: true` like the other agent CLIs' bypass switches, and `allowAll` is declared in `privilegedParams`, so a non-granted multi-user owner has it forced back to `false` (an absent config is never materialized: a bare spawn is already safe). Session history resume (Past Sessions) sends only `copilotConfig.continueSession`, which continues the most recent conversation Copilot has for that directory; `resumeSessionId` is for API callers. The tab names the model it reads from the footer (`modelDetect`). Its `altScreen` is `strip-mux-and-mouse`, like opencode: Copilot turns mouse tracking on itself, and left in, a plain drag is reported to the TUI instead of selecting (Auto Copy and copy-on-select did nothing); stripped, a drag is a local selection, clicks go through the hand-encoded tap, and the wheel is forwarded as SGR reports while `cliMouseTracking` is on. It declares `mcpConfig` (`~/.copilot/mcp-config.json`, relocated by `COPILOT_HOME`) and no `credStore`: its token sits in `config.json` in plain text on a host without a keyring, so a Docker case is not seeded and you sign in inside the container. `env.allowedKeys` is empty on purpose: a headless host sets `COPILOT_GITHUB_TOKEN`, which the `COPILOT_` prefix admits, and `GH_TOKEN`/`GITHUB_TOKEN` stay out of the one global allowlist. +`copilot` (npm `@github/copilot`) is a stock entry. A bare launch is `copilot`, in its own Manual Approval mode. The launch parameters follow the grok/omp shape: `model` (`--model`), `resumeId` (`--resume <id>`, an id, id prefix or session name: a single word that starts with a letter or digit, so a flag-shaped value such as `--yolo` is dropped rather than read as its own flag) and `continueSession` (`--continue`, skipped when a resume id is set), plus `allowAll` (`--yolo`: allow every tool, path and URL). The legacy config object is `copilotConfig` (`{ model, allowAll, resumeSessionId, continueSession }`). The Run button sends `allowAll: true` like the other agent CLIs' bypass switches, and `allowAll` is declared in `privilegedParams`, so a non-granted multi-user owner has it forced back to `false` (an absent config is never materialized: a bare spawn is already safe). Session history resume (Past Sessions) sends only `copilotConfig.continueSession`, which continues the most recent conversation Copilot has for that directory; `resumeSessionId` is for API callers. The tab names the model it reads from the footer (`modelDetect`). Its `altScreen` is `strip-mux-and-mouse`, like opencode: Copilot turns mouse tracking on itself, and left in, a plain drag is reported to the TUI instead of selecting (Auto Copy and copy-on-select did nothing); stripped, a drag is a local selection, clicks go through the hand-encoded tap, and the wheel is forwarded as SGR reports while `cliMouseTracking` is on. It declares `mcpConfig` (`~/.copilot/mcp-config.json`, relocated by `COPILOT_HOME`) and no `credStore`: its token sits in `config.json` in plain text on a host without a keyring, so a Docker case is not seeded and you sign in inside the container. `env.allowedKeys` is empty on purpose: a headless host sets `COPILOT_GITHUB_TOKEN`, which the `COPILOT_` prefix admits, and `GH_TOKEN`/`GITHUB_TOKEN` stay out of the one global allowlist. **Plan usage:** the header chip gains a `Copilot` row with one `mo` window, the calendar-month premium-request quota (`quota_snapshots.premium_interactions` from `GET api.github.com/copilot_internal/user`, an undocumented endpoint the editors use; the tooltip also shows the request counts and reset). It is polled every 10 minutes by the host, read-only, and only when the CLI is installed, a token resolves (`COPILOT_GITHUB_TOKEN`, `GH_TOKEN`, `GITHUB_TOKEN`, then `authTokens` in `<COPILOT_HOME or ~/.copilot>/config.json`) and plan-usage display is on. Any failure leaves the chip without a Copilot row; an unlimited quota has nothing to meter and is omitted. Measured on 1.0.94 inside tmux: an alternate-screen TUI with mouse tracking on, whose composer is the `❯` between two rules. While a turn runs the footer reads `◉ Working esc edit prompt` (the dot alternates `◉`/`◎`), which is the `workDetect` working line. Keystrokes sent with `tmux send-keys` reach the composer, Shift+Enter inserts a newline, and a shell tool call completes inside tmux; github/copilot-cli#4180 (a driven PTY ignores input) and #4223 (a shell command never completes under tmux) report both failing on 1.0.70 to 1.0.74 and neither reproduces on 1.0.94. `COPILOT_HOME` and `COPILOT_ALLOW_ALL` are privileged env keys, so a non-granted multi-user owner cannot set them through `envOverrides`, and so are the other `COPILOT_*` variables (read from the 1.0.98 bundle, far more than `copilot help environment` lists) that switch approvals, run code with no approval row or send the sign-in token elsewhere. The `COPILOT_` prefix admits whatever a later release adds, so the list needs a look whenever the measured version moves. diff --git a/docs/copilot-integration.md b/docs/copilot-integration.md index 4a0c383b..66955e29 100644 --- a/docs/copilot-integration.md +++ b/docs/copilot-integration.md @@ -57,6 +57,7 @@ Codeman never logs Copilot in or stores its credentials. | Model detection | The right-hand field of the footer's last row, at rest and mid-turn. | | Status probe | `GET /api/copilot/status`, plus an entry in the CLI-installed probes and `codeman doctor`. | | MCP | The entry declares `mcpConfig` (`~/.copilot/mcp-config.json`, dialect `copilot-json`, relocated by `COPILOT_HOME`), so [MCP server sync](./cli-registry.md) treats it as a target and a source. `copilot mcp disable` is honoured (read from `settings.json`, never written). | +| Plan usage | The header chip's **Copilot** row: the monthly premium-request quota from `GET https://api.github.com/copilot_internal/user`, read at startup and every 10 minutes with Copilot's own sign-in token (`COPILOT_GITHUB_TOKEN`, `GH_TOKEN` or `GITHUB_TOKEN`, else the plain-text token in `<COPILOT_HOME or ~/.copilot>/config.json`; a sign-in kept in the OS keychain shows no row). On by default, like the Codex usage poll: it rides Plan Usage Limits (`showPlanUsageLimits`), and turning that off stops the request. Read-only, redirects refused, the token never logged or stored; any failure leaves the row out. | | Docker | The agent image installs `@github/copilot`. No credential directory is seeded (see below). | ### Your own endpoint (BYOK) diff --git a/docs/wiki/Agent-CLIs.md b/docs/wiki/Agent-CLIs.md index 6ff5a8c0..414f5393 100644 --- a/docs/wiki/Agent-CLIs.md +++ b/docs/wiki/Agent-CLIs.md @@ -72,7 +72,7 @@ output. The other CLIs expose no equivalent. | Docker cases, remote SSH cases | Yes | Yes | | Precise idle detection | Yes | Codex, Pi, OpenCode, OMP, Copilot, Gemini and Antigravity: same screen check, via their own prompt and working line. DeepSeek: reports its end of turn itself. Grok: generic cues only, unreliable | | Auto-resume when a usage limit resets | Yes | No | -| Plan usage chip | Yes | No | +| Plan usage chip | Yes | Codex (limits) and Copilot (monthly premium requests); the others no | | Approvals Inbox | Yes | DeepSeek and Codex as alerts (answer in the terminal); others no | | Read My Mind | Yes | No | | Ralph loop and its task tracker | Yes | No | @@ -280,6 +280,15 @@ GitHub's `copilot`, installed with `npm install -g @github/copilot` (CLI managem that for you). Codeman recognises it only when `--version` answers `GitHub Copilot CLI`, and `GET /api/copilot/status` reports the path and version that resolved. +**Quota in the plan usage chip.** While Copilot CLI is installed and signed in, the chip gains +a **Copilot** row with the calendar-month premium-request quota (requests used and the reset in +the tooltip). This is **on by default**, like the Codex usage poll: every 10 minutes the server +asks `api.github.com` for the quota with Copilot's own sign-in token (read-only, redirects +refused, the token never logged or stored). The token is read from `COPILOT_GITHUB_TOKEN`, +`GH_TOKEN` or `GITHUB_TOKEN`, or from the plain-text `~/.copilot/config.json`, so a sign-in kept +in the OS keychain (a macOS or desktop Linux keyring) shows no row. Any failure just leaves the row out. Turn off +**Plan Usage Limits** in App Settings to stop the request. + **Signing in.** Codeman does not log it in: run `copilot` once and use `/login`, or sign in inside a Codeman tab. Copilot keeps the token in the OS keychain when there is one; a headless server has none, so it offers to save it as **plain text in `~/.copilot/config.json`**, which is diff --git a/docs/wiki/FAQ.md b/docs/wiki/FAQ.md index 2ec5b738..fc6fc960 100644 --- a/docs/wiki/FAQ.md +++ b/docs/wiki/FAQ.md @@ -29,8 +29,12 @@ are Claude-only; [Agent CLIs](Agent-CLIs) has the table. ### Does Codeman send my code or prompts anywhere? -No. There is no telemetry, no analytics, and no phone-home. The only network traffic -Codeman itself makes is between your browser and your server. +No. There is no telemetry, no analytics, and no phone-home. Beyond the traffic between +your browser and your server, the server makes a few requests of its own for features you use +(the Codex usage poll, web push, webhooks). One is on by default: while GitHub Copilot CLI is +installed and signed in, every 10 minutes the server asks `api.github.com` for your own +premium-request quota with Copilot's own token. Turn off Plan Usage Limits in App Settings to +stop it (see [Agent CLIs](Agent-CLIs)). Your agent CLI is a separate matter: Claude Code talks to Anthropic, Codex talks to OpenAI, and so on. That traffic is the CLI's, on your own account, exactly as it would be in a diff --git a/docs/wiki/Installation.md b/docs/wiki/Installation.md index 051116be..ef0585ca 100644 --- a/docs/wiki/Installation.md +++ b/docs/wiki/Installation.md @@ -12,7 +12,10 @@ Getting Codeman onto a machine, verifying it works, updating it, and removing it | **An agent CLI** | At least one of [Claude Code](https://docs.anthropic.com/en/docs/claude-code), [OpenCode](https://opencode.ai), [Codex](https://developers.openai.com/codex/cli), [Antigravity](https://antigravity.google), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Pi](https://pi.dev), [Grok Build](https://github.com/xai-org/grok-build), [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness), [OMP](https://github.com/can1357/oh-my-pi). Plain shell sessions need none. See [Agent CLIs](Agent-CLIs). | Codeman itself sends no telemetry and phones no home. The only network traffic is your -browser to your server, and whatever the agent CLI you chose does on its own. +browser to your server, and whatever the agent CLI you chose does on its own, plus the +server's own few requests (Codex usage, web push, webhooks) and, on by default while Copilot +CLI is installed and signed in, a read of your own GitHub Copilot quota every 10 minutes +(Plan Usage Limits in App Settings turns it off; see [Agent CLIs](Agent-CLIs)). ## Route A: the installer (recommended) diff --git a/docs/wiki/Keeping-Agents-Running.md b/docs/wiki/Keeping-Agents-Running.md index 35f81870..e484147b 100644 --- a/docs/wiki/Keeping-Agents-Running.md +++ b/docs/wiki/Keeping-Agents-Running.md @@ -119,8 +119,9 @@ Recent Claude Code builds can compact a large idle session on their own shortly ## The plan usage chip -**Claude only.** A header chip showing live subscription usage, on by default on desktop and -off on phones. +A header chip showing live subscription usage: Claude (the 5-hour and weekly windows), Codex +(its limits) and, while Copilot CLI is installed and signed in, Copilot's monthly premium-request +quota. On by default on desktop and off on phones. It works through a status line exporter that Codeman hands to `claude` as an ephemeral setting when it spawns the session, never written to disk, which posts Claude's own rate @@ -128,7 +129,8 @@ limit data back to Codeman. Your own status line (project-local, project, then `~/.claude/settings.json`) is wrapped and printed through, and a `claude` you run by hand outside Codeman sees nothing of it. Workspaces an older Codeman wrote the exporter into are cleaned up the first time a session starts there. Codex limits come from a read-only poll of -its own app-server. Known limit: sessions inside a Docker case do not feed the chip yet. +its own app-server, and the Copilot quota from a read-only request to `api.github.com` every +10 minutes with Copilot's own token (on by default; Plan Usage Limits off stops it). Known limit: sessions inside a Docker case do not feed the chip yet. The chip and the exporter are the same setting. Turning the chip on without the exporter would leave it showing a dash forever, so resolve it in one place: **App Settings**. A diff --git a/docs/wiki/Security.md b/docs/wiki/Security.md index 8dbaaa38..12f576d1 100644 --- a/docs/wiki/Security.md +++ b/docs/wiki/Security.md @@ -101,8 +101,15 @@ Stated plainly, because a security page that only lists strengths is not useful: ## Privacy -No telemetry, no analytics, no phone-home. Codeman's only network traffic is between your -browser and your server. Your agent CLI's traffic is its own, on your account. +No telemetry, no analytics, no phone-home. Your agent CLI's traffic is its own, on your +account. Beyond the traffic between your browser and your server, the server itself makes a few +requests for features you use: the Codex usage poll, web push, and webhooks if you set one up. + +The GitHub Copilot quota read behind the plan usage chip's **Copilot** row is **on by default +while Copilot CLI is installed and signed in** (an absent plan-usage setting reads as on, even +for a phone-only user whose chip is hidden). Every 10 minutes the server asks `api.github.com`, +and nothing else, for your premium-request quota, with Copilot's own sign-in token. The token is +never logged and redirects are refused. Turn off **Plan Usage Limits** in App Settings to stop it. Two features send data outward, both off by default and both stated where they appear: voice dictation through your Claude login, and the Read My Mind prediction call. diff --git a/docs/wiki/The-Dashboard.md b/docs/wiki/The-Dashboard.md index 5583d3f6..b40ab49b 100644 --- a/docs/wiki/The-Dashboard.md +++ b/docs/wiki/The-Dashboard.md @@ -148,7 +148,7 @@ The right side of the header. Almost all of these are off until you enable them | CPU / MEM | On | Server resource use. Drawn as a compact pill by default; see Header Stats Style below. | | File Viewer | On | Toggles the file browser panel. | | Settings gear | Always on | App Settings. | -| Plan usage chip | On, desktop only | Live Claude subscription usage. Claude-only, and needs its telemetry exporter, which the same setting installs. | +| Plan usage chip | On, desktop only | Live subscription usage: Claude (needs its telemetry exporter, which the same setting installs), Codex limits and the Copilot monthly quota. | | Session Manager | Off | The full session list, live and historical. | | Approvals bell | Off | Cross-session queue of prompts waiting on a human. Appears only when the count is above zero. Never shown on phones. | | Read My Mind 🧠 | Off | Predicts your next prompt for this case. Claude-only. | diff --git a/src/usage-telemetry.ts b/src/usage-telemetry.ts index 6e4ead75..cdd16cfe 100644 --- a/src/usage-telemetry.ts +++ b/src/usage-telemetry.ts @@ -40,12 +40,20 @@ export interface UsageWindow { usedPercentage: number; /** Epoch MILLISECONDS when the window resets (statusline reports seconds). */ resetAt: number; + /** + * Absolute counts, for a window measured in requests rather than as a bare percent (Copilot's + * monthly premium-request quota). Absent for the Claude and Codex windows. + */ + used?: number; + limit?: number; } /** Normalized telemetry Codeman broadcasts to the UI. */ export interface StatusTelemetry { fiveHour?: UsageWindow; sevenDay?: UsageWindow; + /** A calendar-month quota (GitHub Copilot's premium requests); not a rolling 5h/7d window. */ + monthly?: UsageWindow; /** Context-window percent used, 0–100 (bonus field from the same payload). */ contextUsedPercentage?: number; /** Session cost in USD (bonus field). */ @@ -196,6 +204,51 @@ export function parseCodexRateLimitsResponse(value: unknown): StatusTelemetry | return telemetry.fiveHour || telemetry.sevenDay ? telemetry : null; } +/** + * Normalize GitHub Copilot's quota snapshot (`GET /copilot_internal/user`) into the chip's monthly + * window. Only `premium_interactions` is a metered quota: `chat` and `completions` report + * `unlimited: true` on every plan seen, and an unlimited premium quota has nothing to meter, so it + * yields null and the chip shows no Copilot row rather than a bar stuck at 0%. + */ +export function parseCopilotQuotaResponse(value: unknown): StatusTelemetry | null { + if (!value || typeof value !== 'object') return null; + const raw = value as { + quota_snapshots?: { + premium_interactions?: { + entitlement?: unknown; + remaining?: unknown; + percent_remaining?: unknown; + unlimited?: unknown; + }; + }; + quota_reset_date_utc?: unknown; + quota_reset_date?: unknown; + }; + const snap = raw.quota_snapshots?.premium_interactions; + if (!snap || typeof snap !== 'object' || snap.unlimited === true) return null; + // A zero (or negative) quota that is not 'unlimited' would show a stuck 0% or 100% row. + if (typeof snap.entitlement === 'number' && snap.entitlement <= 0) return null; + + const finite = (v: unknown): v is number => typeof v === 'number' && Number.isFinite(v); + const limit = finite(snap.entitlement) && snap.entitlement > 0 ? snap.entitlement : undefined; + let usedPct: number | null = null; + if (finite(snap.percent_remaining)) usedPct = 100 - snap.percent_remaining; + else if (limit !== undefined && finite(snap.remaining)) usedPct = (1 - snap.remaining / limit) * 100; + if (usedPct === null) return null; + + const resetRaw = typeof raw.quota_reset_date_utc === 'string' ? raw.quota_reset_date_utc : raw.quota_reset_date; + const parsedReset = typeof resetRaw === 'string' ? Date.parse(resetRaw) : NaN; + const monthly: UsageWindow = { + usedPercentage: clampPct(usedPct), + resetAt: Number.isFinite(parsedReset) && parsedReset > 0 ? parsedReset : 0, + }; + if (limit !== undefined && finite(snap.remaining)) { + monthly.limit = limit; + monthly.used = Math.max(0, Math.round(limit - snap.remaining)); + } + return { monthly }; +} + /** * Current-session status for the in-terminal statusline footer. This is the * "status of the current session" the user sees in Claude's footer — distinct @@ -286,10 +339,21 @@ export function formatSessionStatusText(s: SessionStatus | null, now: number = D * localStorage write + identical chip re-render each time. */ export function telemetrySignature(t: StatusTelemetry): string { - return JSON.stringify([ + const parts: Array<number | null> = [ t.fiveHour ? Math.round(t.fiveHour.usedPercentage) : null, t.fiveHour?.resetAt ?? null, t.sevenDay ? Math.round(t.sevenDay.usedPercentage) : null, t.sevenDay?.resetAt ?? null, - ]); + ]; + // Appended only when present, so the Claude and Codex signatures are byte-identical to before. + // The limit is part of it: a plan change mid-month (300 to 1500 with 100 used) keeps `used` and the reset. + if (t.monthly) { + parts.push( + Math.round(t.monthly.usedPercentage), + t.monthly.used ?? null, + t.monthly.limit ?? null, + t.monthly.resetAt + ); + } + return JSON.stringify(parts); } diff --git a/src/utils/copilot-plan-usage.ts b/src/utils/copilot-plan-usage.ts new file mode 100644 index 00000000..9722283e --- /dev/null +++ b/src/utils/copilot-plan-usage.ts @@ -0,0 +1,117 @@ +/** + * @fileoverview GitHub Copilot's monthly premium-request quota, for the header plan-usage chip. + * + * The Copilot CLI has no scriptable usage command (`copilot billing` exists only inside the TUI), but + * the same quota snapshot the editors show is `GET https://api.github.com/copilot_internal/user`, + * readable with the CLI's own sign-in token. The endpoint is not part of GitHub's documented REST API, + * so this is read-only, best-effort and silent: any failure (offline, 401, a changed shape) yields + * `null` and the chip simply has no Copilot row. + * + * ⚠️ The token is never logged, never put in an error and only ever sent to api.github.com over HTTPS + * (`redirect: 'error'`, so a redirect cannot carry it elsewhere). + * + * @module utils/copilot-plan-usage + */ + +import { readFile } from 'node:fs/promises'; +import { homedir } from 'node:os'; +import { join } from 'node:path'; +import { parseCopilotQuotaResponse, type StatusTelemetry } from '../usage-telemetry.js'; + +export const COPILOT_QUOTA_URL = 'https://api.github.com/copilot_internal/user'; +const REQUEST_TIMEOUT_MS = 10_000; + +/** Env names Copilot itself reads for a headless sign-in, in its own precedence order. */ +const TOKEN_ENV_KEYS = ['COPILOT_GITHUB_TOKEN', 'GH_TOKEN', 'GITHUB_TOKEN'] as const; + +/** The CLI writes `// ...` header comments above the JSON in config.json. */ +function parseCommentedJson(raw: string): unknown { + return JSON.parse( + raw + .split('\n') + .filter((line) => !line.trimStart().startsWith('//')) + .join('\n') + ); +} + +/** + * The sign-in token Copilot would use: a token in the environment first, then the plain-text + * `authTokens` entry the CLI keeps in `<COPILOT_HOME or ~/.copilot>/config.json` when no OS keychain + * is available. Only github.com sign-ins are read from the file (the quota URL is github.com's). + */ +export async function resolveCopilotToken( + env: Record<string, string | undefined> = process.env, + home: string = homedir() +): Promise<string | null> { + for (const key of TOKEN_ENV_KEYS) { + const value = env[key]?.trim(); + if (value) return value; + } + const dir = env.COPILOT_HOME?.trim() || join(home, '.copilot'); + try { + const config = parseCommentedJson(await readFile(join(dir, 'config.json'), 'utf8')) as { + lastLoggedInUser?: { host?: string; login?: string }; + authTokens?: Record<string, { token?: unknown } | undefined>; + }; + const { host, login } = config.lastLoggedInUser ?? {}; + if (host !== 'https://github.com' || !login || !config.authTokens) return null; + for (const key of [`${host}:${login}`, `${host}:${login}:github`]) { + const token = config.authTokens[key]?.token; + if (typeof token === 'string' && token.trim()) return token.trim(); + } + } catch { + /* no config, unreadable, or not JSON: no token */ + } + return null; +} + +export interface ReadCopilotPlanUsageOptions { + /** Defaults to `resolveCopilotToken()`. */ + token?: string | null; + /** Injectable for tests. */ + fetchFn?: typeof fetch; +} + +/** The monthly premium-request window, or null when there is no token, no meter or any failure. */ +export async function readCopilotPlanUsage(opts: ReadCopilotPlanUsageOptions = {}): Promise<StatusTelemetry | null> { + const token = opts.token === undefined ? await resolveCopilotToken() : opts.token; + if (!token) return null; + const doFetch = opts.fetchFn ?? fetch; + try { + const response = await doFetch(COPILOT_QUOTA_URL, { + headers: { + Authorization: `Bearer ${token}`, + Accept: 'application/json', + 'User-Agent': 'codeman', + }, + redirect: 'error', + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + if (!response.ok) return null; + return parseCopilotQuotaResponse(await response.json()); + } catch { + return null; + } +} + +export interface CopilotPlanUsageGates { + /** `showPlanUsageLimits` as the server reads it (`readPlanUsageTelemetryEnabled()`: absent = on). */ + planUsageEnabled: () => Promise<boolean>; + /** `resolveCopilotToken()`: env vars and a file read, nothing spawned. */ + resolveToken: () => Promise<string | null>; + /** `isCopilotAvailable()`: its resolver may run a synchronous login-shell probe. */ + copilotInstalled: () => boolean; + read: (token: string) => Promise<StatusTelemetry | null>; +} + +/** + * The server's poll: Copilot's quota, or null when Plan Usage Limits is off, there is no sign-in + * token or the CLI is not installed. Cheapest gate first, so a host with no Copilot sign-in never + * runs the CLI resolver on each tick, and nothing at all is read while Plan Usage Limits is off. + */ +export async function readCopilotPlanUsageIfEnabled(gates: CopilotPlanUsageGates): Promise<StatusTelemetry | null> { + if (!(await gates.planUsageEnabled())) return null; + const token = await gates.resolveToken(); + if (!token || !gates.copilotInstalled()) return null; + return gates.read(token); +} diff --git a/src/utils/index.ts b/src/utils/index.ts index 717cb1dc..93124e5a 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -76,6 +76,7 @@ export { getCopilotNotFoundMessage, getCopilotCliVersion, } from './copilot-cli-resolver.js'; +export { readCopilotPlanUsage, resolveCopilotToken } from './copilot-plan-usage.js'; export { boundedPathExists, describeUnknownPath, diff --git a/src/web/plan-usage-latest.ts b/src/web/plan-usage-latest.ts index 66f92409..ce06156e 100644 --- a/src/web/plan-usage-latest.ts +++ b/src/web/plan-usage-latest.ts @@ -1,7 +1,7 @@ /** * @fileoverview Process-wide last-known plan-usage telemetry (account-global). * - * The Claude status-telemetry route and host Codex poll merge their latest + * The Claude status-telemetry route and the host Codex and Copilot polls merge their latest * values here. The SSE init snapshot (`getLightState`) replays the combined * value so the header "Plan Usage Limits" chip shows immediately on a fresh * page load / SSE reconnect — before either source emits another sample, and @@ -15,8 +15,14 @@ let latest: Record<string, unknown> | null = null; export function setLatestPlanUsage(value: Record<string, unknown>): Record<string, unknown> { + // The Claude status route replaces the Claude part; the host-polled providers ride along. const codex = latest?.codex; - latest = { ...value, ...(codex !== undefined ? { codex } : {}) }; + const copilot = latest?.copilot; + latest = { + ...value, + ...(codex !== undefined ? { codex } : {}), + ...(copilot !== undefined ? { copilot } : {}), + }; return latest; } @@ -25,6 +31,11 @@ export function setLatestCodexPlanUsage(value: object | null): Record<string, un return latest; } +export function setLatestCopilotPlanUsage(value: object | null): Record<string, unknown> { + latest = { ...(latest ?? {}), copilot: value }; + return latest; +} + export function getLatestPlanUsage(): Record<string, unknown> | null { return latest; } diff --git a/src/web/public/app.js b/src/web/public/app.js index f70fffb3..d2644c11 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -3605,7 +3605,7 @@ class CodemanApp { } } - // Claude + Codex plan usage limits — account-global, so the latest sample + // Claude, Codex and Copilot plan usage limits — account-global, so the latest sample // drives the shared header chip. _onSessionStatusTelemetry(data) { this.updatePlanUsageChip(data); @@ -3665,19 +3665,27 @@ class CodemanApp { }; // The provider label only earns its space when there is more than one // provider to tell apart: a machine with Claude alone shows bare windows. - const hasWindows = (usage) => pct(usage?.fiveHour) !== null || pct(usage?.sevenDay) !== null; - const labelled = hasWindows(data) && hasWindows(data.codex); + const hasWindows = (usage) => + pct(usage?.fiveHour) !== null || pct(usage?.sevenDay) !== null || pct(usage?.monthly) !== null; + const labelled = [data, data.codex, data.copilot].filter(hasWindows).length > 1; const row = (provider, usage, idle) => { // hasWindows() gates the row, so a placeholder can only ever appear // ALONGSIDE a real reading — a provider reporting nothing still renders // nothing, never a row of em dashes. if (!hasWindows(usage)) return ''; - const windows = [seg('5h', pct(usage?.fiveHour), idle), seg('7d', pct(usage?.sevenDay), idle)].filter(Boolean); + // `mo` is Copilot's calendar-month premium-request quota; it is the only window that provider has. + const windows = [ + seg('5h', pct(usage?.fiveHour), idle), + seg('7d', pct(usage?.sevenDay), idle), + seg('mo', pct(usage?.monthly), false), + ].filter(Boolean); if (!windows.length) return ''; const label = labelled ? `<span class="pu-provider">${provider}</span>` : ''; return `<span class="pu-row">${label}<span class="pu-windows">${windows.join('<span class="pu-sep">·</span>')}</span></span>`; }; - const rows = [row('Claude', data, true), row('Codex', data.codex, false)].filter(Boolean); + const rows = [row('Claude', data, true), row('Codex', data.codex, false), row('Copilot', data.copilot, false)].filter( + Boolean + ); chip.innerHTML = rows.length ? rows.join('') : '—'; const resetStr = (w) => (w && w.resetAt ? new Date(w.resetAt).toLocaleString() : '—'); const details = (provider, usage, idle) => { @@ -3687,10 +3695,20 @@ class CodemanApp { if (five !== null) lines.push(`5-hour limit: ${five}% used (resets ${resetStr(usage.fiveHour)})`); else if (idle && seven !== null) lines.push('5-hour limit: no active session window'); if (seven !== null) lines.push(`Weekly limit: ${seven}% used (resets ${resetStr(usage.sevenDay)})`); + const month = pct(usage?.monthly); + if (month !== null) { + // Counts are coerced finite numbers, so interpolating them stays XSS-safe like the rest. + const m = usage.monthly; + const counts = + Number.isFinite(m.used) && Number.isFinite(m.limit) ? `${Math.round(m.used)} of ${Math.round(m.limit)} requests, ` : ''; + lines.push(`Premium requests this month: ${month}% used (${counts}resets ${resetStr(m)})`); + } return lines.length ? `${provider} plan usage\n${lines.join('\n')}` : ''; }; chip.title = - [details('Claude', data, true), details('Codex', data.codex, false)].filter(Boolean).join('\n\n') || + [details('Claude', data, true), details('Codex', data.codex, false), details('Copilot', data.copilot, false)] + .filter(Boolean) + .join('\n\n') || 'Plan usage limits'; } diff --git a/src/web/public/index.html b/src/web/public/index.html index 06ed2445..bcdec4d7 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -197,7 +197,7 @@ <button class="btn-icon-header btn-split btn-split--hidden" onclick="app.openSplitPicker(event)" title="Split: open a second session beside this one" aria-label="Split: open a second session beside this one" aria-pressed="false"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2" y="3" width="20" height="18" rx="2"/><line x1="12" y1="3" x2="12" y2="21"/></svg></button> <button class="btn-icon-header btn-tile-grid btn-tile-grid--hidden" onclick="app.toggleTileGrid()" oncontextmenu="app.openTileCountMenu(event)" aria-describedby="tileGridHint" aria-label="Tiles: show several sessions side by side (right-click for how many)" aria-pressed="false"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="8" height="8" rx="1"/><rect x="13" y="3" width="8" height="8" rx="1"/><rect x="3" y="13" width="8" height="8" rx="1"/><rect x="13" y="13" width="8" height="8" rx="1"/></svg></button> <button class="btn-icon-header btn-ultracode-agents btn-ultracode-agents--hidden" onclick="app.toggleUltracodeAgentsPanel()" title="Ultracode / Workflow agents" aria-label="Open ultracode workflow agents"><svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="6" cy="6" r="2.5"/><circle cx="6" cy="18" r="2.5"/><circle cx="18" cy="12" r="2.5"/><path d="M8.2 7.2 15.6 11M8.2 16.8 15.6 13"/></svg></button> - <div class="header-plan-usage header-plan-usage--hidden" id="planUsageChip" title="Claude and Codex plan usage limits">—</div> + <div class="header-plan-usage header-plan-usage--hidden" id="planUsageChip" title="Claude, Codex and Copilot plan usage limits">—</div> <button class="btn-icon-header btn-notifications" onclick="app.toggleNotifications()" title="Notifications" aria-label="Toggle notifications" style="display:none;"> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg> <span class="notification-badge" id="notifBadge" style="display:none;">0</span> diff --git a/src/web/server.ts b/src/web/server.ts index 4148bdda..de30f762 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -100,6 +100,7 @@ import { applyWorkspaceHooks, pruneAgentSessionPreambles, readKeepWarmSettingsOrThrow, + readPlanUsageTelemetryEnabled, removeAgentSessionPreamble, } from '../hooks-config.js'; import { PushSubscriptionStore } from '../push-store.js'; @@ -170,9 +171,15 @@ import { MAX_PASTE_IMAGE_BYTES } from '../config/buffer-limits.js'; import { resolveTerminalHistoryConfig } from '../config/terminal-history.js'; import { SseEvent } from './sse-events.js'; import { MAX_ROUTE_PARAM_LENGTH } from './route-helpers.js'; -import { getLatestPlanUsage, setLatestCodexPlanUsage } from './plan-usage-latest.js'; +import { getLatestPlanUsage, setLatestCodexPlanUsage, setLatestCopilotPlanUsage } from './plan-usage-latest.js'; import { telemetrySignature } from '../usage-telemetry.js'; import { readCodexPlanUsage, resolveCodexBinaryPath } from '../utils/codex-cli-resolver.js'; +import { isCopilotAvailable } from '../utils/copilot-cli-resolver.js'; +import { + readCopilotPlanUsage, + readCopilotPlanUsageIfEnabled, + resolveCopilotToken, +} from '../utils/copilot-plan-usage.js'; import type { ScheduledRun } from './ports/index.js'; import { registerAuthMiddleware, @@ -237,6 +244,8 @@ const __dirname = dirname(fileURLToPath(import.meta.url)); // while capping growth of `sseClientsById` and blocking pathological inputs. const SSE_CLIENT_ID_RE = /^[A-Za-z0-9_-]{8,64}$/; const CODEX_USAGE_POLL_INTERVAL_MS = 5 * 60_000; +// Copilot's quota is read over the network from GitHub, and a monthly figure moves slowly. +const COPILOT_USAGE_POLL_INTERVAL_MS = 10 * 60_000; const CUSTOM_MODEL_REDISCOVER_INTERVAL_MS = 5 * 60_000; // Much shorter than the model-LIST refresh above on purpose: this catches an actual // eviction (a session's model no longer loaded, silently swapped out by another @@ -406,6 +415,8 @@ export class WebServer extends EventEmitter { private lastRecordedTokens: Map<string, { input: number; output: number }> = new Map(); private codexUsageRefreshInFlight = false; private lastCodexUsageSignature: string | null = null; + private copilotUsageRefreshInFlight = false; + private lastCopilotUsageSignature: string | null = null; // Server startup time for respawn grace period calculation private readonly serverStartTime: number = Date.now(); // Pending respawn start timers (for cleanup on shutdown) @@ -2958,6 +2969,34 @@ export class WebServer extends EventEmitter { } } + /** + * Copilot's monthly premium-request quota for the shared plan-usage chip. Host-scoped and read-only + * like the Codex poll, and additionally gated by `readCopilotPlanUsageIfEnabled()`: only while + * plan-usage display is on (so turning the chip off also stops the outbound request), only with a + * sign-in token and only with the Copilot CLI installed; nothing is sent to GitHub otherwise. + */ + private async refreshCopilotPlanUsage(): Promise<void> { + if (this.copilotUsageRefreshInFlight) return; + this.copilotUsageRefreshInFlight = true; + try { + // A null clears the row, so switching the chip off also takes the last quota off the page. + const usage = await readCopilotPlanUsageIfEnabled({ + planUsageEnabled: readPlanUsageTelemetryEnabled, + resolveToken: () => resolveCopilotToken(), + copilotInstalled: isCopilotAvailable, + read: (token) => readCopilotPlanUsage({ token }), + }); + const signature = usage ? telemetrySignature(usage) : ''; + if (signature === this.lastCopilotUsageSignature) return; + this.lastCopilotUsageSignature = signature; + const snapshot = setLatestCopilotPlanUsage(usage); + this.cachedLightState = null; + this.broadcast(SseEvent.SessionStatusTelemetry, snapshot); + } finally { + this.copilotUsageRefreshInFlight = false; + } + } + async start(): Promise<void> { // Multi-user first boot: create the initial admin from CODEMAN_USERNAME/PASSWORD // if there are no users yet, else refuse to start (there would be no way in). @@ -3132,6 +3171,10 @@ export class WebServer extends EventEmitter { this.cleanup.setInterval(() => void this.refreshCodexPlanUsage(), CODEX_USAGE_POLL_INTERVAL_MS, { description: 'Codex plan-usage refresh', }); + void this.refreshCopilotPlanUsage(); + this.cleanup.setInterval(() => void this.refreshCopilotPlanUsage(), COPILOT_USAGE_POLL_INTERVAL_MS, { + description: 'Copilot plan-usage refresh', + }); } // Custom Model Endpoint Profiles (docs/custom-model-endpoints-plan.md): keeps diff --git a/test/copilot-plan-usage.test.ts b/test/copilot-plan-usage.test.ts new file mode 100644 index 00000000..fd5f6cab --- /dev/null +++ b/test/copilot-plan-usage.test.ts @@ -0,0 +1,278 @@ +/** + * @fileoverview GitHub Copilot's monthly premium-request quota for the shared plan-usage chip: + * the quota parser, the sign-in token lookup and the read-only HTTP reader. + * + * The fixture is the shape `GET /copilot_internal/user` returned for a Business seat (values rounded); + * `chat` and `completions` are `unlimited`, only `premium_interactions` is metered. + */ +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { parseCopilotQuotaResponse, telemetrySignature } from '../src/usage-telemetry.js'; +import { + COPILOT_QUOTA_URL, + readCopilotPlanUsage, + readCopilotPlanUsageIfEnabled, + resolveCopilotToken, +} from '../src/utils/copilot-plan-usage.js'; + +const RESPONSE = { + copilot_plan: 'business', + quota_reset_date_utc: '2026-11-01T00:00:00.000Z', + quota_reset_date: '2026-11-01', + quota_snapshots: { + chat: { entitlement: 0, remaining: 0, percent_remaining: 100, unlimited: true }, + completions: { entitlement: 0, remaining: 0, percent_remaining: 100, unlimited: true }, + premium_interactions: { + entitlement: 5000, + remaining: 3785, + percent_remaining: 75.7, + unlimited: false, + overage_permitted: true, + overage_count: 0, + }, + }, +}; + +describe('parseCopilotQuotaResponse', () => { + it('maps premium_interactions to a monthly window with counts and the reset instant', () => { + expect(parseCopilotQuotaResponse(RESPONSE)).toEqual({ + monthly: { + usedPercentage: expect.closeTo(24.3, 5), + resetAt: Date.parse('2026-11-01T00:00:00.000Z'), + used: 1215, + limit: 5000, + }, + }); + }); + + it('ignores the unlimited chat and completions buckets entirely', () => { + const only = { quota_snapshots: { chat: RESPONSE.quota_snapshots.chat } }; + expect(parseCopilotQuotaResponse(only)).toBeNull(); + }); + + it('has nothing to meter for an unlimited premium quota', () => { + const unlimited = { + quota_snapshots: { + premium_interactions: { entitlement: 0, remaining: 0, percent_remaining: 100, unlimited: true }, + }, + }; + expect(parseCopilotQuotaResponse(unlimited)).toBeNull(); + }); + + it('derives the percent from the counts when percent_remaining is missing', () => { + const result = parseCopilotQuotaResponse({ + quota_snapshots: { premium_interactions: { entitlement: 300, remaining: 75 } }, + quota_reset_date: '2026-11-01', + }); + expect(result?.monthly?.usedPercentage).toBe(75); + expect(result?.monthly?.used).toBe(225); + }); + + it('clamps overage to 100% and keeps the real count', () => { + const result = parseCopilotQuotaResponse({ + quota_snapshots: { premium_interactions: { entitlement: 300, remaining: -40, percent_remaining: -13.3 } }, + }); + expect(result?.monthly?.usedPercentage).toBe(100); + expect(result?.monthly?.used).toBe(340); + }); + + it('rejects anything it cannot read as a quota', () => { + for (const bad of [ + null, + undefined, + 'x', + 3, + {}, + { quota_snapshots: {} }, + { quota_snapshots: { premium_interactions: 'x' } }, + ]) { + expect(parseCopilotQuotaResponse(bad)).toBeNull(); + } + }); + + it('returns nothing for a zero quota that is not unlimited', () => { + expect( + parseCopilotQuotaResponse({ + quota_snapshots: { premium_interactions: { entitlement: 0, remaining: 0, percent_remaining: 0 } }, + }) + ).toBeNull(); + }); + + it('changes the signature when only the limit changes (a plan change mid-month)', () => { + // 100 requests used and the same reported percentage under both plans: only the entitlement differs. + const quota = (entitlement: number) => + parseCopilotQuotaResponse({ + quota_snapshots: { + premium_interactions: { entitlement, remaining: entitlement - 100, percent_remaining: 66.7 }, + }, + quota_reset_date_utc: '2026-11-01T00:00:00.000Z', + })!; + expect(telemetrySignature(quota(300))).not.toBe(telemetrySignature(quota(1500))); + }); + + it('feeds the change-detection signature without disturbing the Claude/Codex one', () => { + const copilot = parseCopilotQuotaResponse(RESPONSE)!; + expect(telemetrySignature(copilot)).not.toBe(telemetrySignature({})); + // A Claude-only payload has exactly the four-element signature it always had. + expect(JSON.parse(telemetrySignature({ fiveHour: { usedPercentage: 10, resetAt: 1 } }))).toEqual([ + 10, + 1, + null, + null, + ]); + }); +}); + +describe('resolveCopilotToken', () => { + const dirs: string[] = []; + afterEach(() => { + for (const d of dirs.splice(0)) rmSync(d, { recursive: true, force: true }); + }); + const home = (config?: string) => { + const dir = mkdtempSync(join(tmpdir(), 'copilot-token-')); + dirs.push(dir); + if (config !== undefined) { + mkdirSync(join(dir, '.copilot')); + writeFileSync(join(dir, '.copilot', 'config.json'), config); + } + return dir; + }; + const CONFIG = [ + '// User settings belong in settings.json.', + JSON.stringify({ + lastLoggedInUser: { host: 'https://github.com', login: 'octo' }, + authTokens: { + 'https://github.com:octo': { token: 'file-token' }, + 'https://github.com:other': { token: 'other-token' }, + }, + }), + ].join('\n'); + + it('prefers the environment, in the order Copilot itself reads it', async () => { + const h = home(CONFIG); + await expect(resolveCopilotToken({ GITHUB_TOKEN: 'c', GH_TOKEN: 'b', COPILOT_GITHUB_TOKEN: 'a' }, h)).resolves.toBe( + 'a' + ); + await expect(resolveCopilotToken({ GITHUB_TOKEN: 'c', GH_TOKEN: 'b' }, h)).resolves.toBe('b'); + await expect(resolveCopilotToken({ GITHUB_TOKEN: 'c' }, h)).resolves.toBe('c'); + }); + + it("falls back to the signed-in user's token in the commented config.json", async () => { + await expect(resolveCopilotToken({}, home(CONFIG))).resolves.toBe('file-token'); + }); + + it('honours COPILOT_HOME', async () => { + const elsewhere = home(); + mkdirSync(join(elsewhere, 'cp')); + writeFileSync(join(elsewhere, 'cp', 'config.json'), CONFIG.replace('file-token', 'relocated')); + await expect(resolveCopilotToken({ COPILOT_HOME: join(elsewhere, 'cp') }, home())).resolves.toBe('relocated'); + }); + + it('reads no file token for another host, a missing file or a broken one', async () => { + const ghe = CONFIG.replace('"host":"https://github.com"', '"host":"https://ghe.example.com"'); + await expect(resolveCopilotToken({}, home(ghe))).resolves.toBeNull(); + await expect(resolveCopilotToken({}, home())).resolves.toBeNull(); + await expect(resolveCopilotToken({}, home('{ not json'))).resolves.toBeNull(); + }); +}); + +describe('readCopilotPlanUsage', () => { + const ok = (body: unknown, status = 200) => + vi.fn(async () => new Response(JSON.stringify(body), { status })) as unknown as typeof fetch; + + it('asks api.github.com with the bearer token, no redirects, and normalizes the answer', async () => { + const fetchFn = ok(RESPONSE); + const usage = await readCopilotPlanUsage({ token: 'tok', fetchFn }); + expect(usage?.monthly?.limit).toBe(5000); + const [url, init] = (fetchFn as unknown as ReturnType<typeof vi.fn>).mock.calls[0] as [string, RequestInit]; + expect(url).toBe(COPILOT_QUOTA_URL); + expect((init.headers as Record<string, string>).Authorization).toBe('Bearer tok'); + expect(init.redirect).toBe('error'); + }); + + it('resolves the token itself when none is passed, and sends it in the header', async () => { + vi.stubEnv('COPILOT_GITHUB_TOKEN', 'env-token'); + try { + const fetchFn = ok(RESPONSE); + expect(await readCopilotPlanUsage({ fetchFn })).not.toBeNull(); + const init = (fetchFn as unknown as ReturnType<typeof vi.fn>).mock.calls[0][1] as { + headers: Record<string, string>; + }; + expect(init.headers.Authorization).toBe('Bearer env-token'); + } finally { + vi.unstubAllEnvs(); + } + }); + + it('sends nothing when there is no token', async () => { + const fetchFn = ok(RESPONSE); + expect(await readCopilotPlanUsage({ token: null, fetchFn })).toBeNull(); + expect(fetchFn).not.toHaveBeenCalled(); + }); + + it('answers null, never throws, on an HTTP error, a network failure or a changed shape', async () => { + expect(await readCopilotPlanUsage({ token: 't', fetchFn: ok({ message: 'Bad credentials' }, 401) })).toBeNull(); + expect(await readCopilotPlanUsage({ token: 't', fetchFn: ok({ unexpected: true }) })).toBeNull(); + const down = vi.fn(async () => { + throw new TypeError('fetch failed'); + }) as unknown as typeof fetch; + expect(await readCopilotPlanUsage({ token: 't', fetchFn: down })).toBeNull(); + }); + + it('never puts the token in anything it returns', async () => { + const usage = await readCopilotPlanUsage({ token: 'super-secret-token', fetchFn: ok(RESPONSE) }); + expect(JSON.stringify(usage)).not.toContain('super-secret-token'); + }); +}); + +/** + * The server's 10-minute poll goes through these gates in this order: Plan Usage Limits on, then a + * sign-in token, then the CLI installed, so a host with no Copilot sign-in never runs the CLI + * resolver (whose last step is a login-shell probe) on every tick. + */ +describe('readCopilotPlanUsageIfEnabled', () => { + const gates = (over: Partial<Parameters<typeof readCopilotPlanUsageIfEnabled>[0]> = {}) => { + const fetchFn = vi.fn( + async () => new Response(JSON.stringify(RESPONSE), { status: 200 }) + ) as unknown as typeof fetch; + const deps = { + planUsageEnabled: vi.fn(async () => true), + resolveToken: vi.fn(async (): Promise<string | null> => 'tok'), + copilotInstalled: vi.fn(() => true), + read: vi.fn((token: string) => readCopilotPlanUsage({ token, fetchFn })), + ...over, + }; + return { deps, fetchFn }; + }; + + it('sends no request, and looks for no token or CLI, while Plan Usage Limits is off', async () => { + const { deps, fetchFn } = gates({ planUsageEnabled: vi.fn(async () => false) }); + expect(await readCopilotPlanUsageIfEnabled(deps)).toBeNull(); + expect(fetchFn).not.toHaveBeenCalled(); + expect(deps.read).not.toHaveBeenCalled(); + expect(deps.resolveToken).not.toHaveBeenCalled(); + expect(deps.copilotInstalled).not.toHaveBeenCalled(); + }); + + it('never probes for the CLI on a host with no Copilot sign-in', async () => { + const { deps, fetchFn } = gates({ resolveToken: vi.fn(async () => null) }); + expect(await readCopilotPlanUsageIfEnabled(deps)).toBeNull(); + expect(deps.copilotInstalled).not.toHaveBeenCalled(); + expect(fetchFn).not.toHaveBeenCalled(); + }); + + it('sends no request when the CLI is not installed, whatever token the environment holds', async () => { + const { deps, fetchFn } = gates({ copilotInstalled: vi.fn(() => false) }); + expect(await readCopilotPlanUsageIfEnabled(deps)).toBeNull(); + expect(fetchFn).not.toHaveBeenCalled(); + }); + + it('reads the quota with the resolved token once every gate passes', async () => { + const { deps, fetchFn } = gates(); + expect((await readCopilotPlanUsageIfEnabled(deps))?.monthly?.limit).toBe(5000); + expect(deps.read).toHaveBeenCalledWith('tok'); + expect(fetchFn).toHaveBeenCalledTimes(1); + }); +}); diff --git a/test/plan-usage-chip.test.ts b/test/plan-usage-chip.test.ts index 6cd6a46e..ce25f2cf 100644 --- a/test/plan-usage-chip.test.ts +++ b/test/plan-usage-chip.test.ts @@ -139,4 +139,44 @@ describe('header plan usage chip', () => { expect(chip.innerHTML).toContain('12%'); expect(chip.title).toContain('Codex plan usage'); }); + + it('shows Copilot as a monthly window with request counts in the tooltip', () => { + const { CodemanApp, chip } = loadCodemanAppClass(); + const app = Object.create((CodemanApp as { prototype: object }).prototype) as UsageApp; + + app.updatePlanUsageChip({ + fiveHour: { usedPercentage: 10, resetAt: 1000 }, + sevenDay: { usedPercentage: 20, resetAt: 2000 }, + copilot: { monthly: { usedPercentage: 24.3, resetAt: 3000, used: 1215, limit: 5000 } }, + }); + + expect(chip.innerHTML).toContain('class="pu-provider">Copilot</span>'); + expect(chip.innerHTML).toContain('mo'); + expect(chip.innerHTML).toContain('24%'); + expect(chip.title).toContain('Copilot plan usage'); + expect(chip.title).toContain('1215 of 5000 requests'); + // Only Copilot's own row carries a monthly window. + const claudeRow = chip.innerHTML.slice(0, chip.innerHTML.indexOf('Copilot')); + expect(claudeRow).not.toContain('>mo<'); + }); + + it('shows a lone Copilot row without a provider label or a Claude placeholder', () => { + const { CodemanApp, chip } = loadCodemanAppClass(); + const app = Object.create((CodemanApp as { prototype: object }).prototype) as UsageApp; + + app.updatePlanUsageChip({ copilot: { monthly: { usedPercentage: 80, resetAt: 3000 } } }); + + expect(chip.innerHTML).toContain('80%'); + expect(chip.innerHTML).not.toContain('pu-provider'); + expect(chip.innerHTML).not.toContain('pu-win-idle'); + }); + + it('renders no Copilot row when the provider reports nothing', () => { + const { CodemanApp, chip } = loadCodemanAppClass(); + const app = Object.create((CodemanApp as { prototype: object }).prototype) as UsageApp; + + app.updatePlanUsageChip({ sevenDay: { usedPercentage: 30, resetAt: 2000 }, copilot: null }); + + expect(chip.innerHTML).not.toContain('Copilot'); + }); }); diff --git a/test/plan-usage-latest.test.ts b/test/plan-usage-latest.test.ts index e3424da1..7d9f4f5d 100644 --- a/test/plan-usage-latest.test.ts +++ b/test/plan-usage-latest.test.ts @@ -16,3 +16,16 @@ it('preserves the Codex row when a later Claude status-line sample arrives', () codex: { sevenDay: { usedPercentage: 40, resetAt: 3000 } }, }); }); + +it('preserves the Copilot row when a later Claude status-line sample arrives', () => { + const module = latestModule as Record<string, unknown>; + const setCopilot = module.setLatestCopilotPlanUsage as (value: unknown) => unknown; + const setClaude = module.setLatestPlanUsage as (value: Record<string, unknown>) => unknown; + + setCopilot({ monthly: { usedPercentage: 12, resetAt: 5000, used: 36, limit: 300 } }); + expect(setClaude({ sessionId: 's2', fiveHour: { usedPercentage: 50, resetAt: 2000 } })).toMatchObject({ + sessionId: 's2', + fiveHour: { usedPercentage: 50, resetAt: 2000 }, + copilot: { monthly: { usedPercentage: 12, resetAt: 5000, used: 36, limit: 300 } }, + }); +}); From 0c1578581f157036312641aa18e3b3200184b389 Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Thu, 22 Oct 2026 19:32:23 +0200 Subject: [PATCH 91/96] docs: case default groups in CLAUDE.md, and the Cases handler count Merge-time fix for #676 (review finding 3 and nit 9). The State Files entry for state.json names the new caseDefaultGroups key, and the Owner tab layouts paragraph says where a case's default session group sits in the placement order of sessionCreated() and that only TabLayoutService reads or writes it. The wiki endpoint map counts the two new case routes (37 to 39). Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- CLAUDE.md | 4 ++-- docs/wiki/HTTP-API.md | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index bb78756b..62696863 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -188,7 +188,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Unified session list**: `GET /api/sessions/unified` merges live sessions, persisted state, lifecycle-log history and transcript files into one deduped list (pure core `src/services/unified-session-service.ts`), backing the Cmd+K Session Manager, pinning and cross-device tab order (`PUT /api/session-order`, `src/session-order.ts`). ⚠️ Transcript history is THREE stores (`~/.claude/projects`, `~/.omp/agent/sessions`, `~/.codex/sessions`), folded via the `claudeSessionId → Codeman id` alias map (not Claude-only despite the name). ⚠️ `resumeId` is set by a SCANNER row only, never a live session; every surface that re-projects these rows (phone overview included) must carry it through, or a tap silently starts a second conversation. → [architecture-invariants#unified-session-list-and-session-manager](docs/architecture-invariants.md#unified-session-list-and-session-manager) -**Owner tab layouts** (`tab-layout*.ts` + `GET`/`PUT /api/tab-layout`): named tab GROUPS over the flat strip, scoped per owner (`@single` when multi-user is off), persisted as `tabLayouts` in state.json. The frontend reads AND edits it (`tab-layout-browser.js` + the grouped-rail block in app.js): the vertical rail draws the owner's groups as collapsible sections (collapse is per-device localStorage), and with no groups or a failed read the rail is the flat list. Groups are created, renamed, reordered and deleted, and rows moved between them, from the row/group menus (Shift+F10 on a header too) and by pointer drag in the grouped rail. A ref may carry `focus: true` ("Add to Focus" in the row/web-tab menu, the named `setFocus` operation): the rail then draws a SHORTCUT to it in a pinned, collapsible Focus section (`_renderTabFocus()`), its own element in `#tabRail` ABOVE `#sessionTabs`, so it shows on the flat rail too and is never a row, tree item, Alt+N slot, lineage anchor or drag target. ⚠️ Every copy of a ref (`copyRef` in tab-layout-browser.js, `parseRef`/`mapRef` in tab-layout.ts) must carry `focus` like `placement`: the browser PUTs whole layouts, so a dropped field is erased on the next save by whoever saves. ⚠️ Grouping is a render layer only: `sessionOrder`, Alt+N and every other order consumer still read the server-projected session order, and a grouped row's markup is the flat row's markup. ⚠️ Only the GROUPED rail is an ARIA tree (`role=tree`, headers owning `role=group`s, one roving `tabindex=0`); the strip, sidebar and flat rail stay `tablist`/`tab`. ⚠️ Every browser write is a named operation through ONE serialized `PUT /api/tab-layout` at a time (`createEditCoordinator`): a 409 replays the operations onto the server's layout and retries (bounded), and an SSE reload is deferred while a write is in flight. Never PUT the layout from anywhere else in the frontend (the `pagehide` keepalive in `_persistPendingTabLayoutEdits` is the one deliberate exception). ⚠️ Placing a NEW session in a group rides its create request (`tabGroupId` on `POST /api/sessions` / `/api/quick-start`) into `TabLayoutService.sessionCreated()`, which registers the session inside the owner lock and places it in the same write; never place a created session with a follow-up PUT. A spawned child lands after its parent; whether it keeps FOLLOWING it until moved by hand is the synced `spawnedTabsFollowParent` setting (default OFF: pinned `manual` in the creating write), read fresh in `sessionCreated()`. ⚠️ A legacy order PUT marks a child `manual` ONLY when the requested order splits it from its parent's block (`applyLegacySessionRank`), and the browser never echoes create/delete through `PUT /api/session-order`: the server already re-projected and broadcast it. Web-tab OPENNESS stays per device. ⚠️ `TabLayoutService` is the single mutation boundary (one completed server action = at most one versioned write); never write layout state from a route or manager directly. ⚠️ The layout PROJECTS onto `PUT /api/session-order` via `tab-layout-legacy-order.ts`; change both sides together. ⚠️ Reconciliation is gated on a SUCCESSFUL restore (`markRestorationComplete`/`assertDeletionReady()`): a failed restore must leave the layout untouched or live tabs get pruned. → [architecture-invariants#owner-tab-layouts](docs/architecture-invariants.md#owner-tab-layouts) +**Owner tab layouts** (`tab-layout*.ts` + `GET`/`PUT /api/tab-layout`): named tab GROUPS over the flat strip, scoped per owner (`@single` when multi-user is off), persisted as `tabLayouts` in state.json. The frontend reads AND edits it (`tab-layout-browser.js` + the grouped-rail block in app.js): the vertical rail draws the owner's groups as collapsible sections (collapse is per-device localStorage), and with no groups or a failed read the rail is the flat list. Groups are created, renamed, reordered and deleted, and rows moved between them, from the row/group menus (Shift+F10 on a header too) and by pointer drag in the grouped rail. A ref may carry `focus: true` ("Add to Focus" in the row/web-tab menu, the named `setFocus` operation): the rail then draws a SHORTCUT to it in a pinned, collapsible Focus section (`_renderTabFocus()`), its own element in `#tabRail` ABOVE `#sessionTabs`, so it shows on the flat rail too and is never a row, tree item, Alt+N slot, lineage anchor or drag target. ⚠️ Every copy of a ref (`copyRef` in tab-layout-browser.js, `parseRef`/`mapRef` in tab-layout.ts) must carry `focus` like `placement`: the browser PUTs whole layouts, so a dropped field is erased on the next save by whoever saves. ⚠️ Grouping is a render layer only: `sessionOrder`, Alt+N and every other order consumer still read the server-projected session order, and a grouped row's markup is the flat row's markup. ⚠️ Only the GROUPED rail is an ARIA tree (`role=tree`, headers owning `role=group`s, one roving `tabindex=0`); the strip, sidebar and flat rail stay `tablist`/`tab`. ⚠️ Every browser write is a named operation through ONE serialized `PUT /api/tab-layout` at a time (`createEditCoordinator`): a 409 replays the operations onto the server's layout and retries (bounded), and an SSE reload is deferred while a write is in flight. Never PUT the layout from anywhere else in the frontend (the `pagehide` keepalive in `_persistPendingTabLayoutEdits` is the one deliberate exception). ⚠️ Placing a NEW session in a group rides its create request (`tabGroupId` on `POST /api/sessions` / `/api/quick-start`) into `TabLayoutService.sessionCreated()`, which registers the session inside the owner lock and places it in the same write; never place a created session with a follow-up PUT. A spawned child lands after its parent; whether it keeps FOLLOWING it until moved by hand is the synced `spawnedTabsFollowParent` setting (default OFF: pinned `manual` in the creating write), read fresh in `sessionCreated()`. A case's **default session group** (Case settings; `caseDefaultGroups` in state.json, per owner and case) is the fallback placement in that same write, below `tabGroupId` and the parent, from the create request's `caseName` (quick-start, Ralph start, or inferred lexically from a `POST /api/sessions` `workingDir`); it is read and written ONLY through `TabLayoutService`. ⚠️ A legacy order PUT marks a child `manual` ONLY when the requested order splits it from its parent's block (`applyLegacySessionRank`), and the browser never echoes create/delete through `PUT /api/session-order`: the server already re-projected and broadcast it. Web-tab OPENNESS stays per device. ⚠️ `TabLayoutService` is the single mutation boundary (one completed server action = at most one versioned write); never write layout state from a route or manager directly. ⚠️ The layout PROJECTS onto `PUT /api/session-order` via `tab-layout-legacy-order.ts`; change both sides together. ⚠️ Reconciliation is gated on a SUCCESSFUL restore (`markRestorationComplete`/`assertDeletionReady()`): a failed restore must leave the layout untouched or live tabs get pruned. → [architecture-invariants#owner-tab-layouts](docs/architecture-invariants.md#owner-tab-layouts) **Hook events**: Claude Code hooks trigger via `/api/hook-event` (`permission_prompt`, `elicitation_dialog`, `elicitation_complete`, `elicitation_response`, `idle_prompt`, `stop`, `teammate_idle`, `task_completed`, `prompt_submitted`); see `src/hooks-config.ts` and `docs/claude-code-hooks-reference.md`. ⚠️ Every claude session installs the hooks block into its workspace (add-only merge) from every create path and from `restoreMuxSessions()`, gated by `workspaceHooksEnabled` (SYNCED, default ON). ⚠️ Route that decision through `applyWorkspaceHooks`, never call `ensureCodemanHooks` at a new site, or the setting silently stops applying. ⚠️ An AskUserQuestion / plan-selection dialog arrives as `permission_prompt` (RED alert), not `elicitation_dialog` (MCP elicitation). → [architecture-invariants#hook-events-and-workspace-hook-installation](docs/architecture-invariants.md#hook-events-and-workspace-hook-installation) @@ -302,7 +302,7 @@ One module per domain in `src/web/routes/` (plus a barrel; `ls src/web/routes/` ## State Files -All in `~/.codeman/`: `state.json` (sessions, settings, respawn, orchestrator, cron jobs/runs, owner tab layouts), `mux-sessions.json` (tmux recovery), `settings.json` (user prefs), `push-keys.json` + `push-subscriptions.json`, `session-lifecycle.jsonl` (audit log), `update-status.json` (self-updater progress, polled across the service restart), `docker-env-applied.json` (Compose deployment only: sha256 of the Dockerfile + compose file the running container was built from, written by `Start-Codeman.sh`, read by the self-updater's environment gate), `docker-build-source.json` (Compose deployment only: the checkout's HEAD commit and `package-lock.json` hash the `codeman-node-modules`/`codeman-dist` volumes currently reflect, written by both `Start-Codeman.sh` and a successful in-place self-update, compared to detect and refresh a volume left stale by an externally-triggered rebuild), `linked-cases.json`, `webviews.json` (saved web-tab dashboard URLs), `remote-hosts.json` + `remote-cases.json`, `docker-hosts.json` + `docker-cases.json` + `docker-exports/`, `subagent-window-states.json` + `subagent-parents.json` (subagent window layout, GET/PUT `/api/subagent-window-states`/`-parents`), `hook-secret` (per-instance), `users.json` (multi-user, mode 0600) + `admin-audit.jsonl`, `intents.json` (Read My Mind intent profiles, mode 0600), `webhook.json` (webhook notifications: enabled/service/scope plus the ntfy/Slack/Discord URL, a bearer secret, so mode 0600, kept out of `settings.json` and never returned by `/api/webhook`, which is admin-only in multi-user mode), `certs/` (self-signed TLS for `--https`), `.env` (CODEMAN_USERNAME/PASSWORD fallback for the `codeman attach` CLI), `install.log` (installer step output, written by `install.sh`'s `run_step`) and `tailscale-rename` (the node name before `install.sh` renamed it, so uninstall can offer it back; both installer-route only). Transient: `self-update-runner.sh`. Multi-user case spaces live OUTSIDE the data dir at `~/codeman-users/<username>/cases` (shared across instances like `~/codeman-cases`, override `CODEMAN_USER_SPACES_DIR`). +All in `~/.codeman/`: `state.json` (sessions, settings, respawn, orchestrator, cron jobs/runs, owner tab layouts, case default groups), `mux-sessions.json` (tmux recovery), `settings.json` (user prefs), `push-keys.json` + `push-subscriptions.json`, `session-lifecycle.jsonl` (audit log), `update-status.json` (self-updater progress, polled across the service restart), `docker-env-applied.json` (Compose deployment only: sha256 of the Dockerfile + compose file the running container was built from, written by `Start-Codeman.sh`, read by the self-updater's environment gate), `docker-build-source.json` (Compose deployment only: the checkout's HEAD commit and `package-lock.json` hash the `codeman-node-modules`/`codeman-dist` volumes currently reflect, written by both `Start-Codeman.sh` and a successful in-place self-update, compared to detect and refresh a volume left stale by an externally-triggered rebuild), `linked-cases.json`, `webviews.json` (saved web-tab dashboard URLs), `remote-hosts.json` + `remote-cases.json`, `docker-hosts.json` + `docker-cases.json` + `docker-exports/`, `subagent-window-states.json` + `subagent-parents.json` (subagent window layout, GET/PUT `/api/subagent-window-states`/`-parents`), `hook-secret` (per-instance), `users.json` (multi-user, mode 0600) + `admin-audit.jsonl`, `intents.json` (Read My Mind intent profiles, mode 0600), `webhook.json` (webhook notifications: enabled/service/scope plus the ntfy/Slack/Discord URL, a bearer secret, so mode 0600, kept out of `settings.json` and never returned by `/api/webhook`, which is admin-only in multi-user mode), `certs/` (self-signed TLS for `--https`), `.env` (CODEMAN_USERNAME/PASSWORD fallback for the `codeman attach` CLI), `install.log` (installer step output, written by `install.sh`'s `run_step`) and `tailscale-rename` (the node name before `install.sh` renamed it, so uninstall can offer it back; both installer-route only). Transient: `self-update-runner.sh`. Multi-user case spaces live OUTSIDE the data dir at `~/codeman-users/<username>/cases` (shared across instances like `~/codeman-cases`, override `CODEMAN_USER_SPACES_DIR`). **Generated top-level dirs** (all gitignored — don't edit or commit): `dist/` (esbuild output), `out/`, `coverage/`, `test-results/`, `tmp/`, `screenshots-echo-diag/`. The committed gesture bundle (`src/web/public/gesture/gesture-codeman.js`) IS tracked, but its runtime wasm/model assets (`src/web/public/gesture/wasm/`, `*.task`) are fetched and gitignored. diff --git a/docs/wiki/HTTP-API.md b/docs/wiki/HTTP-API.md index 0f940586..50252b24 100644 --- a/docs/wiki/HTTP-API.md +++ b/docs/wiki/HTTP-API.md @@ -72,7 +72,7 @@ Roughly 235 handlers across 26 route modules. By domain: | ------------------- | -------- | --------------------------------------------------- | | System | 56 | Status, settings, digest, updates, tunnel. | | Sessions | 34 | Create, input, terminal, wait, last response, kill. | -| Cases | 37 | Create, link, clone, worktrees, remote and docker cases. | +| Cases | 39 | Create, link, clone, worktrees, remote and docker cases. | | Files | 17 | Preview, edit, raw, attachments, path picker. | | Orchestrator | 10 | Plans and phases. | | Ralph | 9 | Loop control and configuration. | From 33d697afe3aee1e8f200b348266795b0202de01d Mon Sep 17 00:00:00 2001 From: Devvyn <22340871+opticon454@users.noreply.github.com> Date: Thu, 22 Oct 2026 19:32:24 +0200 Subject: [PATCH 92/96] fix(custom-model): warn when parallel llama.cpp slots leave too little context per request (#658) Squash of PR #658. Discovery records each llama.cpp model's parallel slot count (from --parallel/-np, or /props total_slots when the launch command states none), and a Claude launch warns when the context split across those slots leaves less than 40K tokens per request. An explicit --parallel beside a /props context records a slot count of 1, so a later round overwrites an earlier count, and a non-2xx /props is remembered per launch command while a network error is retried. Merge-time fix by the maintainer: the architecture-invariants sentence on the explicit --parallel case now says it records a slot count of 1. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- .changeset/slot-aware-context.md | 5 + CLAUDE.md | 2 +- docs/api-reference.md | 5 +- docs/architecture-invariants.md | 4 +- docs/custom-model-endpoints.md | 11 +- docs/wiki/Custom-Model-Endpoints.md | 28 +++ docs/wiki/Troubleshooting.md | 14 ++ src/custom-model-hosts.ts | 16 ++ src/web/public/session-ui.js | 42 +++- src/web/routes/custom-model-routes.ts | 146 +++++++++++-- src/web/routes/session-routes.ts | 8 +- src/web/schemas.ts | 2 + .../custom-model-endpoint-rediscovery.test.ts | 200 +++++++++++++++++- test/custom-model-run-menu-ui.test.ts | 68 +++++- test/custom-model-slot-awareness.test.ts | 80 +++++++ test/routes/quick-start-custom-model.test.ts | 41 ++++ test/routes/session-custom-model.test.ts | 72 +++++++ 17 files changed, 710 insertions(+), 34 deletions(-) create mode 100644 .changeset/slot-aware-context.md create mode 100644 test/custom-model-slot-awareness.test.ts diff --git a/.changeset/slot-aware-context.md b/.changeset/slot-aware-context.md new file mode 100644 index 00000000..4a39c89a --- /dev/null +++ b/.changeset/slot-aware-context.md @@ -0,0 +1,5 @@ +--- +'aicodeman': patch +--- + +A custom llama.cpp model whose parallel slots share a context pool too small for concurrent Claude Code requests now gets an in-app warning. Four simultaneous 40K-token requests against a 4-slot ~131K pool all failed with "Context size has been exceeded" while the old total-context check stayed silent. Discovery records each model's slot count (from `--parallel`/`-np`, or `/props` `total_slots` when the launch command states none) and the warning names the fix: `--parallel 1`, or a context large enough for every slot. diff --git a/CLAUDE.md b/CLAUDE.md index 2a061c91..30af1eae 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -181,7 +181,7 @@ Codeman is a Claude Code session manager with web interface and autonomous Ralph **Custom Model Endpoint Profiles** (opt-in, `customModelEndpointsEnabled`, SYNCED, default OFF; `docs/custom-model-endpoints.md`): points a session at a user-configured OpenAI-compatible endpoint (store `custom-model-hosts.ts`, `~/.codeman/custom-model-hosts.json`, 0600). `authStyle` is `bearer` or `api-key`, never both headers (hangs the server). The per-CLI redirect is registry data, `capabilities.customModelInjection` (`env` / `configContentEnv` / `configDir` / `unsupported`), computed by the pure `custom-model-injection.ts`; ⚠️ `configDir` writes an isolated per-session config, NEVER the user's real `~/.codex`/`~/.pi`/`~/.omp`/grok config. ⚠️ Claude applies via `Session.restartCli()` (a relaunch in the existing pane, so the conversation is pinned as `resumeSessionId`), the rest one-shot at launch; retired env keys must also be `setenv -u`'d (`_pendingEnvUnsets`), since tmux env survives `respawn-pane`. ⚠️ Remote/Docker sessions are refused (400). ⚠️ Persist only KEYS (`__customModel`), never values (they carry the API key). ⚠️ Every redirectable var must be in that CLI's `privilegedEnvKeys`, and `ANTHROPIC_*` stays out of claude's `allowedPrefixes`. ⚠️ The Run-menu picker builds entries from `window.__codemanCustomModelClis` (escaped via `escapeScriptJson()`), never a hardcoded CLI id list, and launches through `run()` via a temporary `_runMode` swap, never `setRunMode()`. → [architecture-invariants#custom-model-endpoint-profiles](docs/architecture-invariants.md#custom-model-endpoint-profiles) -⚠️ **llama-swap endpoints** (one model at a time): the apply routes check `GET /running` and return `requiresConfirmation` before evicting a model another live session uses; `confirmedSwap` and `confirmedContext` are SEPARATE flags and must stay so. Claude alone gets a context floor (`CLAUDE_MIN_SAFE_CONTEXT_TOKENS`); context is parsed from `/running`'s `cmd`, never trusted from `/props`. Backend log lines come from llama-swap's `/api/events` `upstream` source, never `/logs`. → [architecture-invariants#custom-model-endpoint-profiles](docs/architecture-invariants.md#custom-model-endpoint-profiles) +⚠️ **llama-swap endpoints** (one model at a time): the apply routes check `GET /running` and return `requiresConfirmation` before evicting a model another live session uses; `confirmedSwap` and `confirmedContext` are SEPARATE flags and must stay so. Claude alone gets a context floor (`CLAUDE_MIN_SAFE_CONTEXT_TOKENS`); context is parsed from `/running`'s `cmd`, never trusted from `/props`. The floor also divides that context by the model's parallel slot count (`modelSlotCounts`: `--parallel`/`-np` from `cmd`, else `/props` `total_slots`; `perSlotContext()`), and `/props` only ever RAISES that warning: it supplies the context only when `cmd` states none and the count only when `cmd` has no `--parallel`, never a larger pool. `/props` is probed once per launch command (it refreshes llama-swap's last-use time, so a `ttl` would never expire), and a `/props` context beside an explicit `--parallel N` is already per slot, so it is not divided again. Backend log lines come from llama-swap's `/api/events` `upstream` source, never `/logs`. → [architecture-invariants#custom-model-endpoint-profiles](docs/architecture-invariants.md#custom-model-endpoint-profiles) **MCP server sync** (opt-in, `mcpSyncEnabled`, SYNCED, default OFF; `src/mcp-sync.ts`, `GET`/`POST /api/mcp-sync`): copies each installed, enabled CLI's user-level MCP servers into the others. It is the ONE subsystem that writes another CLI's REAL user config (`~/.claude.json`, `~/.codex/config.toml`, `~/.gemini/*`, opencode's), which is why it is opt-in and admin-only in multi-user mode (both verbs 403 for a non-admin, and the Settings group is hidden for them). Where each CLI keeps the file is registry data, `capabilities.mcpConfig` (`{ path, format, relocation? }`), never a branch on the id. ⚠️ Tools that sync but are not run modes (GitHub Copilot CLI, the first) are plain data in `MCP_SYNC_ONLY_TOOLS` (`src/mcp-sync-targets.ts`), take part only when installed or already configured, and their companion `settings.json` (`disabledMcpServers`) is read, never written; a test must clear their relocation vars too. ⚠️ ADDITIVE only: a name already defined, in any shape, is never edited or removed (a different same-name definition is a reported conflict), and a server switched off in its own CLI is never copied. ⚠️ Never write a file that did not parse; re-parse the NEW text and require every added server to read back before the tmp+rename (written through a symlink, previous file kept as `<file>.codeman-bak`, one apply at a time, else 409). ⚠️ A file that receives copied `env`/`headers` (secrets) is left `0600`, and so is the backup. ⚠️ Responses carry server NAMES only, never env values, headers or file text: a parse failure is reported by line and column (`describeMcpSyncError`), never the parser's own message (smol-toml and V8 both quote source). ⚠️ `mcpConfig.relocation` names the env var the CLI reads to move its file (`CLAUDE_CONFIG_DIR`, `CODEX_HOME`, `XDG_CONFIG_HOME`, `GEMINI_CLI_HOME`, and `COPILOT_HOME` for Copilot), resolved from the SERVER env at call time; a relative value reports the target `skipped`, never a guessed write, and a per-session `envOverrides` relocation is not followed. Tests must pass `home` (which drops the `process.env` default) or clear those vars first. → `docs/cli-registry.md` (MCP server sync), `docs/api-reference.md`, `docs/wiki/Settings-Reference.md` diff --git a/docs/api-reference.md b/docs/api-reference.md index 97f3bc22..d14cc05c 100644 --- a/docs/api-reference.md +++ b/docs/api-reference.md @@ -996,7 +996,10 @@ confirmed? } | { clear: true }` applies (or clears) the session's just because a swap is needed at all — only when it would disrupt someone else. - `{requiresContextWarning: true, modelId, contextLength, -minSafeContextTokens}` — Claude Code's own fixed per-turn overhead +minSafeContextTokens, slotCount?}` — `slotCount` is present whenever the model runs more than + one parallel llama.cpp slot, and the warning also fires when the context + divided across those slots is below the floor even though the whole context + is not. Claude Code's own fixed per-turn overhead (system prompt + tool schemas) can exceed a small model's entire discovered context on its own, before any conversation history exists to compact, guaranteeing the very first message fails regardless of diff --git a/docs/architecture-invariants.md b/docs/architecture-invariants.md index 2d2e72bf..ec0f3561 100644 --- a/docs/architecture-invariants.md +++ b/docs/architecture-invariants.md @@ -647,9 +647,9 @@ Either way the actual launch (`runCustomModelEntry`) routes through `run()` itse ⚠️ **That launch-time check cannot catch a swap caused by a DIFFERENT session's LATER, ordinary use** — confirmed live: a second Codex session picking a different model launched with no warning at all (nothing conflicted at that exact instant), yet it silently evicted the first session's model regardless, since llama-swap has no push notification of its own. `detectCustomModelSwapDisplacements()` (`custom-model-routes.ts`) is a separate periodic sweep (`server.ts`, `CUSTOM_MODEL_SWAP_CHECK_INTERVAL_MS` = 20s) that compares each live custom-model session's own `modelId` against what `/running` actually reports loaded, broadcasting a `custom-model:swapped-out` SSE event — shown as a global toast, never tied to the displaced session's own tab, since the whole point is telling the user before they type into it — the first time a mismatch appears, via a caller-owned de-dupe `Set` cleared once that session's own model is loaded and ready again so a later, genuinely new displacement notifies again rather than staying silently un-notified forever after the first one. -⚠️ **Context length is read from the REAL launch command, never `/props`** — `/props?model=`'s `default_generation_settings.n_ctx` was confirmed live to report a `--fit-ctx`-launched backend's theoretical/trained maximum rather than the real runtime-configured size (a measured 154112-vs-16384 discrepancy, caught only because the unfixed value still overflowed), so discovery parses the actual configured size straight out of `/running`'s own `cmd` field instead (`parseCtxFromCmd`: `--fit-ctx <N>` first, then plain llama.cpp `-c`/`--ctx-size`), falling back to `/props` only when `cmd` states no recognizable flag at all. +⚠️ **Context length is read from the REAL launch command, never `/props`** — `/props?model=`'s `default_generation_settings.n_ctx` was confirmed live to report a `--fit-ctx`-launched backend's theoretical/trained maximum rather than the real runtime-configured size (a measured 154112-vs-16384 discrepancy, caught only because the unfixed value still overflowed), so discovery parses the actual configured size straight out of `/running`'s own `cmd` field instead (`parseCtxFromCmd`: `--fit-ctx <N>` first, then plain llama.cpp `-c`/`--ctx-size`), falling back to `/props` only when `cmd` states no recognizable flag at all. The same rule covers the slot check: `/props` supplies `total_slots` only when `cmd` has no `--parallel`, and it NEVER widens the pool the slots divide (an earlier cut let a larger `/props` `n_ctx` become the pool and skip the warning; the 154112-vs-16384 measurement is exactly why that is wrong). `/props` refreshes llama-swap's last-use time for the model, so `discoverModels` probes it once per launch command (`propsByCmd`, in memory, keyed by host and model id and compared with `/running`'s `cmd`); probing on every 5-minute sweep would keep a model with a `ttl` loaded forever. With an explicit `--parallel N` llama.cpp reports `n_ctx` per slot, so a `/props` context beside it is stored with a slot count of 1: it is not divided a second time, and the 1 overwrites a count an earlier discovery round stored (`applyDiscoveredModels` merges onto what was learned before, so recording no count would keep the stale one). -⚠️ **Claude alone gets a context-window FLOOR check, on top of the ceiling `contextLengthVar` already fixes** — `exceedsSafeContextFloor()` (gated on the registry declaring `contextLengthVar`, so a no-op for every other CLI by construction) compares a model's discovered context against `CLAUDE_MIN_SAFE_CONTEXT_TOKENS` (40000): confirmed live, twice, that Claude Code's own system prompt and tool schemas cost roughly 36.4K tokens on the very first message, before any conversation history exists to compact, so a smaller real context fails outright regardless of what `CLAUDE_CODE_MAX_CONTEXT_TOKENS` says (that var only controls when HISTORY gets compacted, and there is none yet on message one). Below the floor, the apply returns `{requiresContextWarning, modelId, contextLength, minSafeContextTokens}` instead of launching, shown as an in-app dialog naming the actual fix: give the model an explicit larger `-c`/`--ctx-size` in llama-swap's config instead of relying on `--fit-ctx` auto-fit, which optimizes for the biggest MODEL that fits rather than the biggest CONTEXT. +⚠️ **Claude alone gets a context-window FLOOR check, on top of the ceiling `contextLengthVar` already fixes** — `exceedsSafeContextFloor()` (gated on the registry declaring `contextLengthVar`, so a no-op for every other CLI by construction) compares a model's discovered context against `CLAUDE_MIN_SAFE_CONTEXT_TOKENS` (40000): confirmed live, twice, that Claude Code's own system prompt and tool schemas cost roughly 36.4K tokens on the very first message, before any conversation history exists to compact, so a smaller real context fails outright regardless of what `CLAUDE_CODE_MAX_CONTEXT_TOKENS` says (that var only controls when HISTORY gets compacted, and there is none yet on message one). Below the floor, or when `contextLength / slotCount` (`perSlotContext()`, from `modelSlotCounts`; an unknown count is one slot) is below it, the apply returns `{requiresContextWarning, modelId, contextLength, minSafeContextTokens}` (plus `slotCount` when it is above 1) instead of launching, shown as an in-app dialog naming the actual fix: give the model an explicit larger `-c`/`--ctx-size` in llama-swap's config instead of relying on `--fit-ctx` auto-fit, which optimizes for the biggest MODEL that fits rather than the biggest CONTEXT. ⚠️ **A fresh, isolated `CLAUDE_CONFIG_DIR` looks like a brand-new Claude Code profile and replays its ENTIRE first-run sequence on every launch** — the theme picker, the security-notes screen, the per-project "trust this folder?" dialog, and (running with a bypass-permissions flag) a one-time warning about it, confirmed live, none of which a real, already-onboarded profile shows again. `skipFirstRunPrompts` (claude's entry only, requires `apiKeyTrustFile` since it reuses the same file) pre-seeds that same "already been through this" state: `hasCompletedOnboarding` and this session's own `projects[workingDir].hasTrustDialogAccepted` merge into the same `.claude.json` the API-key trust file already writes to, and `skipDangerousModePermissionPrompt` merges into `settings.json` (a different file, same corrupt-tolerant merge). diff --git a/docs/custom-model-endpoints.md b/docs/custom-model-endpoints.md index f37ddebe..f76c21a1 100644 --- a/docs/custom-model-endpoints.md +++ b/docs/custom-model-endpoints.md @@ -97,6 +97,13 @@ llama-swap's own launch command instead (`GET /running`'s `cmd` field — hand-written command might use), and only falls back to the `/props` probe when `cmd` states no recognizable flag at all. +**Parallel slots are read the same way.** `--parallel N` / `-np N` from `cmd` gives +the slot count (`modelSlotCounts`); only when `cmd` has none does discovery read +`total_slots` from `/props`, because a server started without the flag picks the +number itself. `/props` never widens the context the slot check divides, and it is +probed once per launch command rather than on every sweep, because llama-swap +treats the call as use of the model and a `ttl` would otherwise never expire. + **File size is discovered too, when the server states one.** llama-swap writes a GB figure into an auto-discovered model's own `description` (`"Auto-discovered 16.35 GB - parameters auto-fitted by llama.cpp"`), parsed @@ -438,11 +445,11 @@ construction, never a hardcoded mode check). If the model's discovered context (`modelContextLengths`, from discovery above) is below `CLAUDE_MIN_SAFE_CONTEXT_TOKENS` (40000, comfortably above the measured ~36.4K overhead), the response is `{requiresContextWarning: true, modelId, -contextLength, minSafeContextTokens}` instead of applying — nothing is +contextLength, minSafeContextTokens}` (plus `slotCount` when the model runs more than one parallel slot) instead of applying — nothing is restarted or created yet. A context length that was never discovered at all skips the check entirely (nothing to compare, so it fails open rather than warning on every model an endpoint hasn't reported a size for). -Retrying with `confirmedContext: true` launches anyway (the legacy `confirmed: true` still means both questions). +The same warning fires when the context divided across the model's parallel slots (`contextLength / slotCount`) is below the floor, because Claude Code sends several 40K-token requests at once and the slots share one pool. Retrying with `confirmedContext: true` launches anyway (the legacy `confirmed: true` still means both questions). The Run-menu picker shows this as an in-app modal (`#customModelContextWarningModal`, matching the llama-swap conflict diff --git a/docs/wiki/Custom-Model-Endpoints.md b/docs/wiki/Custom-Model-Endpoints.md index a10f6f7b..9d2b6a30 100644 --- a/docs/wiki/Custom-Model-Endpoints.md +++ b/docs/wiki/Custom-Model-Endpoints.md @@ -150,6 +150,34 @@ relying on auto-fit (`--fit-ctx`), which sizes the context around fitting the bi rather than the biggest context — for example adding `-c 65536` to that model's llama-swap entry. "Launch anyway" is still there if you want to try regardless. +**A context that is big enough overall can still be too small once it is split across +slots.** `llama-server` runs several parallel slots by default (four on the build this was measured on) and, +unless you say otherwise, they all share one pool of context. Claude Code sends more than one +request at a time: the main session plus every subagent it runs in parallel, and each of those +requests carries the roughly 40K tokens of system prompt and tools on its own. Four requests +of 40K tokens do not fit in a shared 131K pool, so the server refuses them with HTTP 500 +`Context size has been exceeded`, even though any single request would have been fine. +Discovery therefore also records how many slots each loaded model has. It reads an explicit +`--parallel` or `-np` from the launch command, and otherwise asks the server for +`total_slots`, because a server started without the flag chooses the number itself. When the +context divided by the slot count drops below what Claude Code needs, picking that model shows +the same in-app dialog, this time explaining the shared slots. The division uses the context +Codeman read from the launch command (`--fit-ctx` or `-c`), never a larger figure from the +server's `/props`, which has been measured reporting far more than the server really runs +with. So a default llama-swap model with auto slots shows the dialog, which names the fix and +lets you launch anyway. The fix is a single slot: + +```yaml +cmd: >- + llama-server -m /models/your-model.gguf --parallel 1 ... +``` + +With one slot the active conversation gets the whole context, requests from parallel +subagents queue behind it instead of failing, and Claude Code's repeated prompt prefix stays +in the cache instead of being processed again in a different slot each time. The alternative +is a context large enough that every slot can hold 40K tokens. A slot count Codeman could not +read is treated as one slot, never guessed. + ## Which harnesses actually work | Harness | Status | diff --git a/docs/wiki/Troubleshooting.md b/docs/wiki/Troubleshooting.md index cd191493..ab74e79c 100644 --- a/docs/wiki/Troubleshooting.md +++ b/docs/wiki/Troubleshooting.md @@ -161,6 +161,20 @@ CODEMAN_INSTANCE=beta CODEMAN_PORT=5000 codeman web `codeman web -d` and `codeman service install` both refuse to start a second server on one data directory for exactly this reason. +### `Context size has been exceeded` from a local llama.cpp model + +Sessions pointed at a llama.cpp or llama-swap endpoint (see [Custom Model +Endpoints](Custom-Model-Endpoints)) fail with `500 Context size has been exceeded`, usually +when Claude Code runs subagents, or an agent fails with `Prompt is too long` while the main +session carries on. The server runs several parallel slots that share one pool of context, +and every Claude Code request carries about 40K tokens of its own system prompt and tools, so +a few concurrent requests use the pool up. Start the model with a single slot by adding +`--parallel 1` to its launch command, or give it a context large enough that each slot holds +40K tokens. Check the current count with `GET /props` on the server (on llama-swap, +`GET /props?model=<id>` for a loaded model): `total_slots` is the number of slots. `n_ctx` is the +shared context only when the slot count is automatic; with an explicit `--parallel N` it is the +size of each slot. + ## The terminal ### I cannot scroll back through history diff --git a/src/custom-model-hosts.ts b/src/custom-model-hosts.ts index bd90cef7..8ad2c17d 100644 --- a/src/custom-model-hosts.ts +++ b/src/custom-model-hosts.ts @@ -61,6 +61,22 @@ export interface CustomModelHost { * has no entry for simply gets no context-length env override applied — never a guess. */ modelContextLengths?: Record<string, number>; + /** + * Discovered number of parallel server slots per model id, keyed like `modelContextLengths` + * and populated on the same loaded-models-only basis. Read from the launch command's + * `--parallel`/`-np` when it states one, otherwise from llama.cpp's own `/props` + * `total_slots` (a llama-server started without `--parallel` picks the count itself, so + * the command cannot say). llama-server splits its context across these slots (by default + * they share one unified KV pool) and Claude Code sends several requests at once (the + * main session plus each parallel subagent), so with N slots each request can only rely + * on roughly `contextLength / N` tokens. Used only by the context warning; a model this + * has no entry for is treated as a single slot, never a guess. The division always uses + * `modelContextLengths` (the launch command's figure, or `/props` only when the command states + * none): `/props` never widens the pool. An explicit `--parallel N` whose context came from + * `/props` is recorded as 1 slot, because that figure is already per slot, and that also + * overwrites any count an earlier round stored. + */ + modelSlotCounts?: Record<string, number>; /** * Discovered file size (GB) per model id, keyed by the same strings as `models`. * Populated during discovery by parsing llama-swap's own `description` field for an diff --git a/src/web/public/session-ui.js b/src/web/public/session-ui.js index 5009c2d5..d3eca870 100644 --- a/src/web/public/session-ui.js +++ b/src/web/public/session-ui.js @@ -1114,11 +1114,33 @@ Object.assign(CodemanApp.prototype, { * history yet for compaction to trim. Same promise-based pattern as * `_confirmModelSwap`; `_resolveContextWarningConfirm` settles it. */ - _confirmContextWarning(modelId, contextLength, minSafeContextTokens) { + _confirmContextWarning(modelId, contextLength, minSafeContextTokens, slotCount) { const modal = document.getElementById('customModelContextWarningModal'); const messageEl = document.getElementById('customModelContextWarningMessage'); if (messageEl) { const known = typeof contextLength === 'number'; + const slots = Number.isFinite(slotCount) && slotCount > 1 ? Math.floor(slotCount) : 1; + // Enough total context, but split across parallel server slots: llama-server shares its + // context between --parallel slots, and this CLI sends concurrent requests (the main + // session plus each parallel subagent), so several 40k-token requests spend the shared + // pool and the server answers HTTP 500 "Context size has been exceeded". + if (known && slots > 1 && contextLength >= minSafeContextTokens) { + messageEl.textContent = + `${modelId}'s ${contextLength.toLocaleString()} tokens of context are split across ${slots} parallel ` + + `server slots, about ${Math.floor(contextLength / slots).toLocaleString()} each. This CLI ` + + `needs roughly ${minSafeContextTokens.toLocaleString()}+ tokens just for its own system prompt and tools on ` + + `every request, and it sends several requests at once (the main session plus each parallel subagent). ` + + `Concurrent requests can use up the shared context, and the server then fails them with ` + + `"Context size has been exceeded".\n\n` + + `To fix this, start the model with a single slot: add "--parallel 1" to its llama-swap cmd. The active ` + + `conversation then gets the whole context, other requests queue instead of failing, and the CLI's ` + + `repeated prompt prefix stays cached. Alternatively give the model a larger context (raise --fit-ctx or ` + + `--ctx-size) so every slot has at least ${minSafeContextTokens.toLocaleString()} tokens.`; + modal?.classList.add('active'); + return new Promise((resolve) => { + this._resolveContextWarningConfirmPromise = resolve; + }); + } messageEl.textContent = `${modelId} is configured with ` + (known ? `only ${contextLength.toLocaleString()} tokens of` : 'an unknown (too small)') + @@ -1129,7 +1151,11 @@ Object.assign(CodemanApp.prototype, { `instead of relying on auto-fit (--fit-ctx), which optimizes for the biggest MODEL that fits, not the ` + `biggest CONTEXT — e.g. add "-c 65536" (or as large a --ctx-size as your hardware holds) to its llama-swap ` + `config entry. A smaller model at a much larger explicit context often fits in the same VRAM a bigger ` + - `model's auto-fit context gets shrunk to make room for.`; + `model's auto-fit context gets shrunk to make room for.` + + (slots > 1 + ? `\n\nThis server also runs ${slots} parallel slots that share that context; add "--parallel 1" to ` + + `the same cmd so a single conversation gets all of it.` + : ''); } modal?.classList.add('active'); return new Promise((resolve) => { @@ -1254,8 +1280,13 @@ Object.assign(CodemanApp.prototype, { let answered = {}; let data = await post(bodyObj); if (data?.data?.requiresContextWarning) { - const { modelId, contextLength, minSafeContextTokens } = data.data; - const proceed = await this._confirmContextWarning(modelId, contextLength, minSafeContextTokens); + const { modelId, contextLength, minSafeContextTokens, slotCount } = data.data; + const proceed = await this._confirmContextWarning( + modelId, + contextLength, + minSafeContextTokens, + slotCount + ); if (!proceed) { this._lastCustomModelLaunchResult = undefined; return { success: false, error: 'Launch cancelled — context window too small' }; @@ -1371,7 +1402,8 @@ Object.assign(CodemanApp.prototype, { const proceed = await this._confirmContextWarning( payload.modelId, payload.contextLength, - payload.minSafeContextTokens + payload.minSafeContextTokens, + payload.slotCount ); if (!proceed) { switchingToast?.dismiss(); diff --git a/src/web/routes/custom-model-routes.ts b/src/web/routes/custom-model-routes.ts index 7f96a7c6..3339ae3c 100644 --- a/src/web/routes/custom-model-routes.ts +++ b/src/web/routes/custom-model-routes.ts @@ -56,11 +56,31 @@ export const CLAUDE_MIN_SAFE_CONTEXT_TOKENS = 40000; */ export function exceedsSafeContextFloor( entry: Pick<CliEntry, 'capabilities'>, - contextLength: number | undefined + contextLength: number | undefined, + slotCount?: number ): boolean { const cap = entry.capabilities.customModelInjection; if (cap.kind !== 'env' || !cap.contextLengthVar) return false; - return typeof contextLength === 'number' && contextLength < CLAUDE_MIN_SAFE_CONTEXT_TOKENS; + if (typeof contextLength !== 'number') return false; + if (contextLength < CLAUDE_MIN_SAFE_CONTEXT_TOKENS) return true; + return perSlotContext(contextLength, slotCount) < CLAUDE_MIN_SAFE_CONTEXT_TOKENS; +} + +/** + * The context one request can rely on when the server runs `slotCount` parallel slots. + * llama-server divides its context across `--parallel` slots (by default they share ONE + * unified KV pool), and Claude Code sends concurrent requests: the main session plus every + * parallel subagent, each carrying the full `CLAUDE_MIN_SAFE_CONTEXT_TOKENS` of its own + * system prompt and tools. With N busy slots each request therefore gets roughly + * `contextLength / N`, and once the pool is spent llama.cpp answers HTTP 500 "Context size + * has been exceeded" (measured: 4 slots, ~131k pool, four simultaneous 40k-token requests + * all failed; the same requests against one slot all succeeded). An unknown or single slot + * count leaves the context whole. + */ +export function perSlotContext(contextLength: number, slotCount: number | undefined): number { + const slots = + typeof slotCount === 'number' && Number.isFinite(slotCount) && slotCount > 1 ? Math.floor(slotCount) : 1; + return Math.floor(contextLength / slots); } function adminOnly(req: FastifyRequest, reply: { code: (n: number) => unknown }): ApiResponse<never> | null { @@ -109,7 +129,7 @@ function applyStoredApiKey(incoming: CustomModelHost, existing: CustomModelHost) } /** - * `modelContextLengths`/`modelSizesGB` are server-populated by discovery, never + * `modelContextLengths`/`modelSlotCounts`/`modelSizesGB` are server-populated by discovery, never * user-entered, and PUT replaces the whole record — so merge them back in from the * stored host rather than trust whatever the editor's body carried (or omitted). * The editor only ever sends `models`/`lastDiscoveredAt` verbatim from its cached @@ -117,7 +137,12 @@ function applyStoredApiKey(incoming: CustomModelHost, existing: CustomModelHost) * future caller forgets, same class of bug `applyStoredApiKey` exists to prevent. */ function applyDiscoveredFields(incoming: CustomModelHost, existing: CustomModelHost): CustomModelHost { - return { ...incoming, modelContextLengths: existing.modelContextLengths, modelSizesGB: existing.modelSizesGB }; + return { + ...incoming, + modelContextLengths: existing.modelContextLengths, + modelSlotCounts: existing.modelSlotCounts, + modelSizesGB: existing.modelSizesGB, + }; } function authHeaders(host: Pick<CustomModelHost, 'apiKey' | 'authStyle'>): Record<string, string> { @@ -135,6 +160,8 @@ export interface DiscoveryResult { models: string[]; /** See `CustomModelHost.modelContextLengths` — only ever populated for models already loaded. */ contextLengths: Record<string, number>; + /** See `CustomModelHost.modelSlotCounts`; populated on the same loaded-models-only basis as `contextLengths`. */ + slotCounts: Record<string, number>; /** See `CustomModelHost.modelSizesGB` — populated for every model whose own listing states one. */ sizesGB: Record<string, number>; } @@ -157,7 +184,7 @@ function parseSizeGB(description: unknown): number | undefined { /** * Best-effort: fetches `GET /props?model=<id>` (llama.cpp-native, llama-swap-proxied) for - * ONE already-loaded model and pulls its real `n_ctx` out. Never called for a model that + * ONE already-loaded model and pulls its `n_ctx` and `total_slots` out. Never called for a model that * isn't already loaded — see the caller and `CustomModelHost.modelContextLengths` for why * that's a hard safety requirement, not just a nicety: llama-swap treats this endpoint's * `?model=` as a routing hint, and asking it about an unloaded model risks triggering an @@ -173,20 +200,39 @@ function parseSizeGB(description: unknown): number | undefined { * (below), which reads the actual launch flag `/running` reports, is the primary source; * this is only used when that parse comes up empty (no recognized flag in `cmd`, or `cmd` * itself unavailable). + * + * ⚠️ `/props` only ever RAISES the slot warning, never skips it: its `n_ctx` is the context + * fallback when `cmd` states no context flag at all, and `total_slots` is the slot count when + * `cmd` has no `--parallel`. It never widens the pool the slot check divides. And it is not + * free: llama-swap routes it through the model's process handler, which refreshes the model's + * last-use time, so probing a model with a `ttl` on every 5-minute sweep would keep it loaded + * forever. `discoverModels` therefore probes once per launch command (`propsByCmd`). + * + * Returns `undefined` when the request itself failed (network error, timeout, unreadable body), + * which is retried next sweep; a non-2xx status, or a 2xx without the fields, gives an object with + * them unset, which `propsForLaunch` remembers. */ -async function fetchContextLength( +async function fetchServerProps( host: Pick<CustomModelHost, 'baseUrl'>, modelId: string, headers: Record<string, string> -): Promise<number | undefined> { +): Promise<{ nCtx?: number; totalSlots?: number } | undefined> { try { const url = new URL(`${host.baseUrl.replace(/\/+$/, '')}/props`); url.searchParams.set('model', modelId); const res = await webviewFetch(url, { headers, signal: AbortSignal.timeout(PROPS_TIMEOUT_MS) }); - if (!res.ok) return undefined; - const body = (await res.json()) as { n_ctx?: unknown; default_generation_settings?: { n_ctx?: unknown } }; + // A non-2xx answer (an upstream that does not serve /props) is a real answer: empty, so it is + // remembered per launch command instead of being re-probed every sweep. + if (!res.ok) return {}; + const body = (await res.json()) as { + n_ctx?: unknown; + total_slots?: unknown; + default_generation_settings?: { n_ctx?: unknown }; + }; const nCtx = body.n_ctx ?? body.default_generation_settings?.n_ctx; - return typeof nCtx === 'number' && Number.isFinite(nCtx) && nCtx > 0 ? nCtx : undefined; + const positive = (v: unknown): number | undefined => + typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : undefined; + return { nCtx: positive(nCtx), totalSlots: positive(body.total_slots) }; } catch { return undefined; } @@ -196,7 +242,7 @@ async function fetchContextLength( * Parses the REAL configured context size out of llama-swap's own launch command for a * model (`/running`'s `cmd` field, e.g. `"llama-server -m ... --fit-ctx 16384 ..."`) — * the primary source for `modelContextLengths`, preferred over `/props`'s `n_ctx` (see - * `fetchContextLength`'s own doc comment for why that field is unreliable here). Checks + * `fetchServerProps`'s own doc comment for why that field is unreliable here). Checks * `--fit-ctx` first (llama-swap's own auto-fit flag), then the plain llama.cpp * `-c`/`--ctx-size`/`--ctx_size` flags a hand-written launch command might use instead. * Returns `undefined` when `cmd` has none of these — not every launch command needs to @@ -211,6 +257,51 @@ function parseCtxFromCmd(cmd: unknown): number | undefined { return Number.isFinite(value) && value > 0 ? value : undefined; } +/** + * Parses an explicit `--parallel N` / `-np N` (or `--parallel=N`) out of a launch command. + * Returns `undefined` when the command states none, including `-np -1` (llama.cpp's "pick + * automatically"), because a llama-server started without it chooses the slot count itself + * (4 on the build this was measured on) and only `/props`'s `total_slots` can say what it chose. + */ +function parseSlotsFromCmd(cmd: unknown): number | undefined { + if (typeof cmd !== 'string') return undefined; + const match = /(?:^|\s)(?:-np|--parallel)(?:\s+|=)(\d+)(?=\s|$)/.exec(cmd); + if (!match) return undefined; + const value = Number(match[1]); + return Number.isFinite(value) && value > 0 ? value : undefined; +} + +/** The `/props` answer for the launch command a model was last probed under, by host and model id. */ +const propsByCmd = new Map<string, { cmd: string; props: { nCtx?: number; totalSlots?: number } }>(); + +/** Forget what was probed (tests only: a changed launch command already probes again). */ +export function clearPropsProbeCache(): void { + propsByCmd.clear(); +} + +/** + * `/props` for one loaded model, but only once per launch command: the probe refreshes the model's + * last-use time in llama-swap, so repeating it on every rediscovery sweep would stop a model with a + * `ttl` from ever unloading. A changed `cmd` (a reloaded or reconfigured model) probes again; a + * `cmd` that is not a string cannot be told apart, so it is probed each time; a probe that did not + * answer (network error, timeout) is not remembered; a non-2xx status is. Held in memory only, so a restart probes once more. + */ +async function propsForLaunch( + host: Pick<CustomModelHost, 'baseUrl'>, + modelId: string, + cmd: unknown, + headers: Record<string, string> +): Promise<{ nCtx?: number; totalSlots?: number } | undefined> { + const key = `${host.baseUrl}\0${modelId}`; + if (typeof cmd === 'string') { + const cached = propsByCmd.get(key); + if (cached?.cmd === cmd) return cached.props; + } + const props = await fetchServerProps(host, modelId, headers); + if (props && typeof cmd === 'string') propsByCmd.set(key, { cmd, props }); + return props; +} + async function discoverModels( host: Pick<CustomModelHost, 'baseUrl' | 'apiKey' | 'authStyle'> ): Promise<DiscoveryResult> { @@ -239,6 +330,7 @@ async function discoverModels( // fetchContextLength must only ever run against a model this server itself calls loaded. const hasStatusField = entries.some((m) => m && typeof m === 'object' && 'status' in m); const contextLengths: Record<string, number> = {}; + const slotCounts: Record<string, number> = {}; if (hasStatusField) { const loadedIds = entries .filter((m) => m.status && typeof m.status === 'object' && (m.status as { value?: unknown }).value === 'loaded') @@ -252,13 +344,32 @@ async function discoverModels( const swapStatus = await getLlamaSwapStatus(host); const cmdById = new Map(swapStatus.running.map((r) => [r.model, r.cmd])); for (const id of loadedIds) { - const fromCmd = parseCtxFromCmd(cmdById.get(id)); - const ctx = fromCmd ?? (await fetchContextLength(host, id, headers)); + const cmd = cmdById.get(id); + let ctx = parseCtxFromCmd(cmd); + const cmdSlots = parseSlotsFromCmd(cmd); + let slots = cmdSlots; + // /props is only needed for whatever the launch command did not answer. The context + // still comes from the command whenever it states one (see fetchServerProps's doc + // comment for why /props's n_ctx is only a fallback); the slot count usually cannot + // come from the command at all, since an unset --parallel is chosen by the server. + if (ctx === undefined || slots === undefined) { + const props = await propsForLaunch(host, id, cmd, headers); + const ctxFromProps = ctx === undefined && props?.nCtx !== undefined; + ctx ??= props?.nCtx; + slots ??= props?.totalSlots; + // With an explicit --parallel N, llama.cpp reports /props's n_ctx PER SLOT (the unified + // KV pool is only the default when the count is auto), so it is already the figure one + // request can use, so the count is 1: recording N would divide it a second time, and + // recording nothing would leave an earlier round's count in place (applyDiscoveredModels + // merges onto what was learned before). + if (cmdSlots !== undefined && ctxFromProps) slots = 1; + } if (ctx !== undefined) contextLengths[id] = ctx; + if (slots !== undefined) slotCounts[id] = slots; } } } - return { models, contextLengths, sizesGB }; + return { models, contextLengths, slotCounts, sizesGB }; } /** @@ -573,7 +684,7 @@ export function triggerLlamaSwapLoad( } function applyDiscoveredModels(host: CustomModelHost, result: DiscoveryResult): CustomModelHost { - const { models, contextLengths, sizesGB } = result; + const { models, contextLengths, slotCounts, sizesGB } = result; const defaultModelId = host.defaultModelId && models.includes(host.defaultModelId) ? host.defaultModelId : undefined; // Merge onto what's already known rather than replacing: a model not probed this round // (not currently loaded) keeps whatever context length an earlier round already learned @@ -581,6 +692,10 @@ function applyDiscoveredModels(host: CustomModelHost, result: DiscoveryResult): const merged = { ...host.modelContextLengths, ...contextLengths }; const kept = Object.fromEntries(Object.entries(merged).filter(([id]) => models.includes(id))); const modelContextLengths = Object.keys(kept).length > 0 ? kept : undefined; + // Slot counts follow the same loaded-only, keep-what-an-earlier-round-learned rule. + const mergedSlots = { ...host.modelSlotCounts, ...slotCounts }; + const keptSlots = Object.fromEntries(Object.entries(mergedSlots).filter(([id]) => models.includes(id))); + const modelSlotCounts = Object.keys(keptSlots).length > 0 ? keptSlots : undefined; // sizesGB, unlike contextLengths, is populated for every model in the SAME pass (no // loaded-only restriction — see parseSizeGB), so this is closer to a plain replace, but // still merges onto the previous round rather than dropping a size for a model whose @@ -593,6 +708,7 @@ function applyDiscoveredModels(host: CustomModelHost, result: DiscoveryResult): models, defaultModelId, modelContextLengths, + modelSlotCounts, modelSizesGB, lastDiscoveredAt: new Date().toISOString(), }; diff --git a/src/web/routes/session-routes.ts b/src/web/routes/session-routes.ts index d8dbb40c..7d08288d 100644 --- a/src/web/routes/session-routes.ts +++ b/src/web/routes/session-routes.ts @@ -1535,6 +1535,7 @@ export function registerSessionRoutes( return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Model endpoint not found'); } const contextLength = endpoint.modelContextLengths?.[body.modelId]; + const slotCount = endpoint.modelSlotCounts?.[body.modelId]; // Some CLIs (today: only claude) carry enough of their own fixed system-prompt/tool- // schema overhead that a small enough real context guarantees a first-message failure @@ -1545,12 +1546,13 @@ export function registerSessionRoutes( // `confirmed`, which still means both) — NOT by `confirmedSwap`: this warning is // about the caller's own session, and the swap warning below is about someone // else's, so an answer to one is not consent to the other. - if (!(body.confirmed || body.confirmedContext) && exceedsSafeContextFloor(entry, contextLength)) { + if (!(body.confirmed || body.confirmedContext) && exceedsSafeContextFloor(entry, contextLength, slotCount)) { return { requiresContextWarning: true, modelId: body.modelId, contextLength, minSafeContextTokens: CLAUDE_MIN_SAFE_CONTEXT_TOKENS, + ...(slotCount && slotCount > 1 ? { slotCount } : {}), }; } @@ -4332,6 +4334,7 @@ export function registerSessionRoutes( const cmEndpoint = cmHosts.find((h) => h.id === customModel.endpointId); if (!cmEndpoint) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Model endpoint not found'); const cmContextLength = cmEndpoint.modelContextLengths?.[customModel.modelId]; + const cmSlotCount = cmEndpoint.modelSlotCounts?.[customModel.modelId]; // See the dedicated route's own comment for the full reasoning: some CLIs' own fixed // overhead can exceed a small enough real context on the very first message, @@ -4341,13 +4344,14 @@ export function registerSessionRoutes( // `confirmedSwap`. if ( !(customModel.confirmed || customModel.confirmedContext) && - exceedsSafeContextFloor(cmEntry, cmContextLength) + exceedsSafeContextFloor(cmEntry, cmContextLength, cmSlotCount) ) { return { requiresContextWarning: true, modelId: customModel.modelId, contextLength: cmContextLength, minSafeContextTokens: CLAUDE_MIN_SAFE_CONTEXT_TOKENS, + ...(cmSlotCount && cmSlotCount > 1 ? { slotCount: cmSlotCount } : {}), }; } diff --git a/src/web/schemas.ts b/src/web/schemas.ts index 725760ef..623038ea 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -2274,6 +2274,8 @@ export const CustomModelHostSchema = z.object({ // round-tripping the GET response back through PUT (edit-save) doesn't drop it. modelContextLengths: z.record(z.string().max(200), z.number().int().positive().max(100_000_000)).optional(), // Same reasoning as modelContextLengths above. + modelSlotCounts: z.record(z.string().max(200), z.number().int().positive().max(4096)).optional(), + // Same reasoning as modelContextLengths above. modelSizesGB: z.record(z.string().max(200), z.number().positive().max(100_000)).optional(), }); diff --git a/test/custom-model-endpoint-rediscovery.test.ts b/test/custom-model-endpoint-rediscovery.test.ts index b0dedf7c..fd60f845 100644 --- a/test/custom-model-endpoint-rediscovery.test.ts +++ b/test/custom-model-endpoint-rediscovery.test.ts @@ -15,7 +15,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { getDataDir } from '../src/config/instance.js'; import { readCustomModelHosts, writeCustomModelHosts, type CustomModelHost } from '../src/custom-model-hosts.js'; -import { refreshAllCustomModelHosts } from '../src/web/routes/custom-model-routes.js'; +import { clearPropsProbeCache, refreshAllCustomModelHosts } from '../src/web/routes/custom-model-routes.js'; import { webviewFetch } from '../src/web/webview-egress.js'; vi.mock('../src/web/webview-egress.js', async () => { @@ -31,6 +31,7 @@ function host(overrides: Partial<CustomModelHost> & Pick<CustomModelHost, 'id' | beforeEach(() => { fetchMock.mockReset(); + clearPropsProbeCache(); }); describe('refreshAllCustomModelHosts (the periodic re-discovery sweep)', () => { @@ -211,8 +212,9 @@ describe('refreshAllCustomModelHosts: context-length enrichment (llama.cpp/llama it('prefers the REAL configured context size parsed from /running’s launch command over /props’s unreliable n_ctx', async () => { // Confirmed live: llama-swap launched a model with --fit-ctx 16384 (the real, working // limit — the actual server then refused a request over it), but /props reported - // n_ctx: 154112 for the same model, well over what it would really accept. /props must - // never be reached at all once the /running command parse already answered it. + // n_ctx: 154112 for the same model, well over what it would really accept. The context + // must therefore keep coming from the /running command; /props is now also probed, but + // only for `total_slots`, which the command does not state. const dir = getDataDir(); await writeCustomModelHosts(dir, [host({ id: 'ep', baseUrl: 'http://localhost:8080' })]); fetchMock.mockImplementation(async (url: URL) => { @@ -235,7 +237,9 @@ describe('refreshAllCustomModelHosts: context-length enrichment (llama.cpp/llama { status: 200 } ); } - if (url.pathname === '/props') throw new Error('must never be reached — the cmd parse already answered it'); + if (url.pathname === '/props') { + return new Response(JSON.stringify({ n_ctx: 154112, total_slots: 4 }), { status: 200 }); + } throw new Error(`unexpected request: ${url.href}`); }); @@ -243,6 +247,8 @@ describe('refreshAllCustomModelHosts: context-length enrichment (llama.cpp/llama const [updated] = await readCustomModelHosts(dir); expect(updated.modelContextLengths).toEqual({ 'qwen3.8-27b': 16384 }); + expect(updated.modelSlotCounts).toEqual({ 'qwen3.8-27b': 4 }); + // /props's 154112 never becomes the context, and nothing else stores it }); it('falls back to /props when /running has no cmd, or the cmd states no recognizable context flag', async () => { @@ -283,13 +289,197 @@ describe('refreshAllCustomModelHosts: context-length enrichment (llama.cpp/llama { status: 200 } ); } - throw new Error(`unexpected request: ${url.href}`); // /props must never be reached + if (url.pathname === '/props') return new Response('not found', { status: 404 }); // slot probe only + throw new Error(`unexpected request: ${url.href}`); }); await refreshAllCustomModelHosts(); const [updated] = await readCustomModelHosts(dir); expect(updated.modelContextLengths).toEqual({ a: 8192 }); + expect(updated.modelSlotCounts).toBeUndefined(); + }); +}); + +describe('refreshAllCustomModelHosts: parallel-slot enrichment (llama.cpp splits its context across --parallel slots)', () => { + /** One loaded model whose launch command is `cmd`; `/props` behaviour is supplied by the test. */ + function serve(cmd: string, props: () => Response) { + fetchMock.mockImplementation(async (url: URL) => { + if (url.pathname === '/v1/models') { + return new Response(JSON.stringify({ data: [{ id: 'm', status: { value: 'loaded' } }] }), { status: 200 }); + } + if (url.pathname === '/running') { + return new Response(JSON.stringify({ running: [{ model: 'm', state: 'ready', cmd }] }), { status: 200 }); + } + if (url.pathname === '/props') return props(); + throw new Error(`unexpected request: ${url.href}`); + }); + } + const propsMustNotBeReached = () => { + throw new Error('/props must not be probed: the launch command already answered both questions'); + }; + + it.each([ + ['--parallel 1', 1], + ['-np 2', 2], + ['--parallel=3', 3], + ['--fit-ctx 65536 --parallel 1 --host 0.0.0.0', 1], + ])('reads an explicit %s from the launch command without probing /props', async (flags, slots) => { + const dir = getDataDir(); + await writeCustomModelHosts(dir, [host({ id: 'ep', baseUrl: 'http://localhost:8080' })]); + serve(`llama-server -m /models/m.gguf --ctx-size 65536 ${flags}`, propsMustNotBeReached); + + await refreshAllCustomModelHosts(); + + const [updated] = await readCustomModelHosts(dir); + expect(updated.modelSlotCounts).toEqual({ m: slots }); + expect(updated.modelContextLengths).toEqual({ m: 65536 }); + }); + + it('asks /props for total_slots when the command states no --parallel (llama-server picks the count itself)', async () => { + const dir = getDataDir(); + await writeCustomModelHosts(dir, [host({ id: 'ep', baseUrl: 'http://localhost:8080' })]); + serve( + 'llama-server -m /models/m.gguf --fit-ctx 65536', + () => new Response(JSON.stringify({ n_ctx: 133888, total_slots: 4 }), { status: 200 }) + ); + + await refreshAllCustomModelHosts(); + + const [updated] = await readCustomModelHosts(dir); + expect(updated.modelSlotCounts).toEqual({ m: 4 }); + // the context still comes from the command's floor, never from /props's n_ctx + expect(updated.modelContextLengths).toEqual({ m: 65536 }); + }); + + it('probes /props once per launch command: a repeat sweep reuses the answer, a changed command probes again', async () => { + const dir = getDataDir(); + await writeCustomModelHosts(dir, [host({ id: 'ep', baseUrl: 'http://localhost:8080' })]); + let probes = 0; + const answer = () => (probes++, new Response(JSON.stringify({ n_ctx: 133888, total_slots: 4 }), { status: 200 })); + serve('llama-server -m /models/m.gguf --fit-ctx 65536', answer); + await refreshAllCustomModelHosts(); + await refreshAllCustomModelHosts(); + expect(probes).toBe(1); // a /props call refreshes the model's last-use time in llama-swap, so not every sweep + expect((await readCustomModelHosts(dir))[0].modelSlotCounts).toEqual({ m: 4 }); + + serve('llama-server -m /models/m.gguf --fit-ctx 65536 --flash-attn on', answer); + await refreshAllCustomModelHosts(); + expect(probes).toBe(2); + }); + + it('does not divide a /props context a second time when --parallel is explicit and the command states no context', async () => { + const dir = getDataDir(); + await writeCustomModelHosts(dir, [host({ id: 'ep', baseUrl: 'http://localhost:8080' })]); + // with an explicit count llama.cpp reports n_ctx per slot: 65,536 each is already what one request gets + serve( + 'llama-server -m /models/m.gguf --parallel 4', + () => new Response(JSON.stringify({ n_ctx: 65536, total_slots: 4 }), { status: 200 }) + ); + + await refreshAllCustomModelHosts(); + + const [updated] = await readCustomModelHosts(dir); + expect(updated.modelContextLengths).toEqual({ m: 65536 }); + expect(updated.modelSlotCounts).toEqual({ m: 1 }); // already per slot, so one request gets all of it + }); + + it('a later round with an explicit --parallel replaces the slot count an earlier round learned', async () => { + const dir = getDataDir(); + await writeCustomModelHosts(dir, [host({ id: 'ep', baseUrl: 'http://localhost:8080' })]); + // round 1: auto slots, the server chose 4 sharing 131,072 tokens + serve( + 'llama-server -m /models/m.gguf', + () => new Response(JSON.stringify({ n_ctx: 131072, total_slots: 4 }), { status: 200 }) + ); + await refreshAllCustomModelHosts(); + expect((await readCustomModelHosts(dir))[0].modelSlotCounts).toEqual({ m: 4 }); + + // round 2: the user followed the dialog and added --parallel 1 + serve( + 'llama-server -m /models/m.gguf --parallel 1', + () => new Response(JSON.stringify({ n_ctx: 131072, total_slots: 1 }), { status: 200 }) + ); + await refreshAllCustomModelHosts(); + expect((await readCustomModelHosts(dir))[0].modelSlotCounts).toEqual({ m: 1 }); + + // round 3: --parallel 2 with a per-slot /props context must not bring a pool back + serve( + 'llama-server -m /models/m.gguf --parallel 2', + () => new Response(JSON.stringify({ n_ctx: 65536, total_slots: 2 }), { status: 200 }) + ); + await refreshAllCustomModelHosts(); + const [updated] = await readCustomModelHosts(dir); + expect(updated.modelSlotCounts).toEqual({ m: 1 }); + expect(updated.modelContextLengths).toEqual({ m: 65536 }); + }); + + it('remembers a /props that answers non-2xx per launch command, but retries a network failure', async () => { + const dir = getDataDir(); + await writeCustomModelHosts(dir, [host({ id: 'ep', baseUrl: 'http://localhost:8080' })]); + let probes = 0; + serve('llama-server -m /models/m.gguf --fit-ctx 65536', () => (probes++, new Response('nope', { status: 404 }))); + await refreshAllCustomModelHosts(); + await refreshAllCustomModelHosts(); + expect(probes).toBe(1); // no probe every sweep: a /props call keeps a model with a ttl loaded + + clearPropsProbeCache(); + probes = 0; + serve('llama-server -m /models/m.gguf --fit-ctx 65536', () => { + probes++; + throw new TypeError('fetch failed'); + }); + await refreshAllCustomModelHosts(); + await refreshAllCustomModelHosts(); + expect(probes).toBe(2); // a network error says nothing about the server, so it is tried again + }); + + it('treats -np -1 (llama.cpp auto) as unstated and falls back to /props', async () => { + const dir = getDataDir(); + await writeCustomModelHosts(dir, [host({ id: 'ep', baseUrl: 'http://localhost:8080' })]); + serve( + 'llama-server -m /models/m.gguf --fit-ctx 65536 -np -1', + () => new Response(JSON.stringify({ total_slots: 4 }), { status: 200 }) + ); + + await refreshAllCustomModelHosts(); + + const [updated] = await readCustomModelHosts(dir); + expect(updated.modelSlotCounts).toEqual({ m: 4 }); + }); + + it('a failed or slot-less /props leaves no slot count rather than failing the sweep or guessing one', async () => { + const dir = getDataDir(); + await writeCustomModelHosts(dir, [host({ id: 'ep', baseUrl: 'http://localhost:8080' })]); + serve( + 'llama-server -m /models/m.gguf --fit-ctx 65536', + () => new Response(JSON.stringify({ n_ctx: 8192 }), { status: 200 }) + ); + + await expect(refreshAllCustomModelHosts()).resolves.toBeUndefined(); + + const [updated] = await readCustomModelHosts(dir); + expect(updated.modelSlotCounts).toBeUndefined(); + expect(updated.modelContextLengths).toEqual({ m: 65536 }); + }); + + it('keeps a previously-learned slot count for a model that is no longer loaded, and drops it once the model disappears', async () => { + const dir = getDataDir(); + await writeCustomModelHosts(dir, [ + host({ + id: 'ep', + baseUrl: 'http://localhost:8080', + models: ['a', 'b'], + modelContextLengths: { a: 65536, b: 65536 }, + modelSlotCounts: { a: 4, b: 2 }, + }), + ]); + fetchMock.mockResolvedValue(new Response(JSON.stringify({ data: [{ id: 'a' }] }), { status: 200 })); + + await refreshAllCustomModelHosts(); + + const [updated] = await readCustomModelHosts(dir); + expect(updated.modelSlotCounts).toEqual({ a: 4 }); }); }); diff --git a/test/custom-model-run-menu-ui.test.ts b/test/custom-model-run-menu-ui.test.ts index c437822c..967e4457 100644 --- a/test/custom-model-run-menu-ui.test.ts +++ b/test/custom-model-run-menu-ui.test.ts @@ -1281,7 +1281,7 @@ describe("Custom Model Endpoint Profiles: requiresContextWarning (this CLI's own await app.runCustomModelEntry('claude', 'llama-box', 'qwen3'); - expect(confirmArgs).toEqual(['qwen3', 16384, 40000]); + expect(confirmArgs).toEqual(['qwen3', 16384, 40000, undefined]); expect(applyBodies).toEqual([ { endpointId: 'llama-box', modelId: 'qwen3' }, { endpointId: 'llama-box', modelId: 'qwen3', confirmedContext: true }, @@ -1289,6 +1289,29 @@ describe("Custom Model Endpoint Profiles: requiresContextWarning (this CLI's own expect(win.localStorage.getItem('codeman:customModelLastUsed:claude:llama-box')).toBe('qwen3'); }); + it('forwards the slot count from the apply response into the modal', async () => { + const { app, applyBodies } = launchHarness([ + { + requiresContextWarning: true, + modelId: 'qwen3', + contextLength: 131072, + minSafeContextTokens: 40000, + slotCount: 4, + }, + { customModel: { endpointId: 'llama-box' }, restarted: true, modelSwapInProgress: false }, + ]); + let confirmArgs: unknown[] | undefined; + app._confirmContextWarning = async (...args: unknown[]) => { + confirmArgs = args; + return true; + }; + + await app.runCustomModelEntry('claude', 'llama-box', 'qwen3'); + + expect(confirmArgs).toEqual(['qwen3', 131072, 40000, 4]); + expect(applyBodies).toHaveLength(2); + }); + it('declining the in-app context-warning modal keeps the native backend, never re-sends the apply, and must NEVER record this model as "last used" — it cannot work with this CLI at all', async () => { const { win, app, applyBodies } = launchHarness([ { requiresContextWarning: true, modelId: 'qwen3', contextLength: 16384, minSafeContextTokens: 40000 }, @@ -1361,6 +1384,49 @@ describe('Custom Model Endpoint Profiles: _confirmContextWarning (in-app modal, expect(win.document.getElementById('customModelContextWarningModal')!.classList.contains('active')).toBe(false); }); + it('explains shared parallel slots, and the --parallel 1 fix, when the context is enough only as a whole', async () => { + const { win, app } = bootApp({}); + void app._confirmContextWarning('qwen3.8-27b-ud-q4_k_xl', 131072, 40000, 4); + const message = win.document.getElementById('customModelContextWarningMessage')!.textContent!; + expect(message).toContain('qwen3.8-27b-ud-q4_k_xl'); + expect(message).toContain('4 parallel'); + expect(message).toContain('131,072'); + expect(message).toContain('32,768'); // per slot + expect(message).toContain('--parallel 1'); + expect(message).toContain('Context size has been exceeded'); + // this is not the "first message fails outright" case, so it must not claim that + expect(message).not.toMatch(/first message will fail/i); + expect(win.document.getElementById('customModelContextWarningModal')!.classList.contains('active')).toBe(true); + app._resolveContextWarningConfirm(false); + }); + + it('says the context is split across the slots, about N each, from the discovered context alone', async () => { + const { win, app } = bootApp({}); + void app._confirmContextWarning('qwen3', 65536, 40000, 4); + const message = win.document.getElementById('customModelContextWarningMessage')!.textContent!; + expect(message).toContain('65,536 tokens of context are split across 4 parallel server slots, about 16,384 each'); + app._resolveContextWarningConfirm(false); + }); + + it('keeps the too-small-context message for a small model and adds the slot advice when slots are known', async () => { + const { win, app } = bootApp({}); + void app._confirmContextWarning('qwen3', 16384, 40000, 4); + const message = win.document.getElementById('customModelContextWarningMessage')!.textContent!; + expect(message).toContain('16,384'); + expect(message).toMatch(/first message will fail/i); + expect(message).toContain('4 parallel slots'); + expect(message).toContain('--parallel 1'); + app._resolveContextWarningConfirm(false); + }); + + it('does not mention slots when there is one (or none known)', async () => { + const { win, app } = bootApp({}); + void app._confirmContextWarning('qwen3', 16384, 40000, 1); + const message = win.document.getElementById('customModelContextWarningMessage')!.textContent!; + expect(message).not.toMatch(/parallel/i); + app._resolveContextWarningConfirm(false); + }); + it('describes an unknown context length without printing a bogus number', async () => { const { win, app } = bootApp({}); void app._confirmContextWarning('qwen3', undefined, 40000); diff --git a/test/custom-model-slot-awareness.test.ts b/test/custom-model-slot-awareness.test.ts new file mode 100644 index 00000000..821ef91f --- /dev/null +++ b/test/custom-model-slot-awareness.test.ts @@ -0,0 +1,80 @@ +/** + * @fileoverview Unit tests for the parallel-slot awareness of the custom-model context floor + * (`perSlotContext` / `exceedsSafeContextFloor`, src/web/routes/custom-model-routes.ts). + * + * llama-server splits its context across `--parallel` slots (by default they share one + * unified KV pool) and Claude Code sends concurrent requests, each carrying its own ~40k + * tokens of system prompt and tools. Measured against a real llama-swap server: with 4 slots + * sharing ~131k tokens, four simultaneous 40k-token requests all failed with HTTP 500 + * "Context size has been exceeded"; the same requests against one slot all succeeded. + * + * Port: N/A (pure functions, no server). + */ +import { describe, it, expect } from 'vitest'; +import { + CLAUDE_MIN_SAFE_CONTEXT_TOKENS, + exceedsSafeContextFloor, + perSlotContext, +} from '../src/web/routes/custom-model-routes.js'; +import type { CliEntry } from '../src/config/cli-registry/types.js'; + +const withContextVar = { + capabilities: { customModelInjection: { kind: 'env', contextLengthVar: 'CLAUDE_CODE_MAX_CONTEXT_TOKENS' } }, +} as unknown as Pick<CliEntry, 'capabilities'>; +const withoutContextVar = { + capabilities: { customModelInjection: { kind: 'env' } }, +} as unknown as Pick<CliEntry, 'capabilities'>; + +describe('perSlotContext', () => { + it('divides the context across the slots', () => { + expect(perSlotContext(131072, 4)).toBe(32768); + expect(perSlotContext(262144, 4)).toBe(65536); + }); + + it('rounds down, since a fraction of a token is not usable', () => { + expect(perSlotContext(100000, 3)).toBe(33333); + }); + + it.each([undefined, 0, 1, -2, Number.NaN, Number.POSITIVE_INFINITY])( + 'leaves the context whole for an unusable or single slot count (%s)', + (slots) => { + expect(perSlotContext(65536, slots as number | undefined)).toBe(65536); + } + ); +}); + +describe('exceedsSafeContextFloor with a slot count', () => { + it('divides only the context it is given, so a larger /props figure can never skip the warning', () => { + // a --fit-ctx floor of 65,536 over 4 slots is 16,384 each, whatever /props might say about the server + expect(exceedsSafeContextFloor(withContextVar, 65536, 4)).toBe(true); + expect(exceedsSafeContextFloor(withContextVar, 65536, 1)).toBe(false); + // the measured failure: ~131k over 4 slots is ~33k each + expect(exceedsSafeContextFloor(withContextVar, 131072, 4)).toBe(true); + expect(exceedsSafeContextFloor(withContextVar, 262144, 4)).toBe(false); + }); + + it('still warns when the context itself is below the minimum', () => { + expect(exceedsSafeContextFloor(withContextVar, 16384, 4)).toBe(true); + expect(exceedsSafeContextFloor(withContextVar, 16384, 1)).toBe(true); + }); + + it('keeps its existing behaviour when no slot count is given', () => { + expect(exceedsSafeContextFloor(withContextVar, 16384)).toBe(true); + expect(exceedsSafeContextFloor(withContextVar, CLAUDE_MIN_SAFE_CONTEXT_TOKENS)).toBe(false); + expect(exceedsSafeContextFloor(withContextVar, undefined)).toBe(false); + }); + + it('flags a context that only clears the floor as a whole, not per slot', () => { + expect(exceedsSafeContextFloor(withContextVar, 131072, 4)).toBe(true); + expect(exceedsSafeContextFloor(withContextVar, 131072, 1)).toBe(false); + }); + + it('accepts exactly the floor per slot', () => { + expect(exceedsSafeContextFloor(withContextVar, CLAUDE_MIN_SAFE_CONTEXT_TOKENS * 4, 4)).toBe(false); + expect(exceedsSafeContextFloor(withContextVar, CLAUDE_MIN_SAFE_CONTEXT_TOKENS * 4 - 4, 4)).toBe(true); + }); + + it('never applies to a CLI that declares no context variable, whatever the slots', () => { + expect(exceedsSafeContextFloor(withoutContextVar, 4096, 8)).toBe(false); + }); +}); diff --git a/test/routes/quick-start-custom-model.test.ts b/test/routes/quick-start-custom-model.test.ts index 3bc0da96..17a0402f 100644 --- a/test/routes/quick-start-custom-model.test.ts +++ b/test/routes/quick-start-custom-model.test.ts @@ -316,6 +316,47 @@ describe('POST /api/quick-start: customModel (one-shot custom-model launch)', () expect(ctx.sessions.size).toBe(2); }); + describe('parallel slots share the context (the quick-start path has its own slot branch)', () => { + const slotEndpoint = (contextLength: number, slots: number): CustomModelHost => ({ + id: 'ep-slots', + label: 'multi-slot box', + baseUrl: 'http://192.168.1.52:8080', + apiKey: 'k', + modelContextLengths: { m: contextLength }, + modelSlotCounts: { m: slots }, + }); + + it('warns when the context divided across the slots is below the floor, and reports the slot count', async () => { + await writeCustomModelHosts(getDataDir(), [ENDPOINT, slotEndpoint(131072, 4)]); // ~33k per slot + + const res = await quickStart({ + caseName: 'cm-slots-warn', + mode: 'claude', + customModel: { endpointId: 'ep-slots', modelId: 'm' }, + }); + + const body = res.json(); + expect(body.requiresContextWarning).toBe(true); + expect(body.contextLength).toBe(131072); + expect(body.slotCount).toBe(4); + expect(ctx.sessions.size).toBe(1); // nothing was created + }); + + it('launches when every slot can hold the floor', async () => { + await writeCustomModelHosts(getDataDir(), [ENDPOINT, slotEndpoint(262144, 4)]); // 65,536 per slot + + const res = await quickStart({ + caseName: 'cm-slots-ok', + mode: 'claude', + customModel: { endpointId: 'ep-slots', modelId: 'm' }, + }); + + expect(res.statusCode).toBe(200); + expect(res.json().requiresContextWarning).toBeUndefined(); + expect(ctx.sessions.size).toBe(2); + }); + }); + it('does not warn when nothing about context was discovered', async () => { const res = await quickStart({ caseName: 'cm-no-ctx-data', diff --git a/test/routes/session-custom-model.test.ts b/test/routes/session-custom-model.test.ts index 1c29618d..9c8062d6 100644 --- a/test/routes/session-custom-model.test.ts +++ b/test/routes/session-custom-model.test.ts @@ -449,6 +449,78 @@ describe('POST /api/sessions/:id/custom-model', () => { expect(session.restartCli).not.toHaveBeenCalled(); }); + // llama-server splits its context across --parallel slots, and Claude Code sends several + // requests at once, so a context that clears the floor as a whole can still fail per slot. + describe('parallel slots share the context', () => { + const slotEndpoint = (contextLength: number, slots?: number): CustomModelHost => ({ + id: 'ep-slots', + label: 'multi-slot box', + baseUrl: 'http://192.168.1.52:8080', + apiKey: 'k', + modelContextLengths: { m: contextLength }, + ...(slots === undefined ? {} : { modelSlotCounts: { m: slots } }), + }); + + async function apply(endpoint: CustomModelHost) { + const { app, ctx } = await setup(); + await writeCustomModelHosts(getDataDir(), [CLAUDE_ENDPOINT, endpoint]); + const session = ctx.sessions.get('test-session-1')!; + session.mode = 'claude'; + const res = await app.inject({ + method: 'POST', + url: '/api/sessions/test-session-1/custom-model', + payload: { endpointId: endpoint.id, modelId: 'm' }, + }); + return { body: res.json(), session }; + } + + it('warns when the context divided across the slots is below the floor, and reports the slot count', async () => { + // the measured case: ~131k shared by 4 slots is ~33k each, under the 40k a request needs + const { body, session } = await apply(slotEndpoint(131072, 4)); + + expect(body.requiresContextWarning).toBe(true); + expect(body.contextLength).toBe(131072); + expect(body.slotCount).toBe(4); + expect(session.setCustomModel).not.toHaveBeenCalled(); + }); + + it('divides the discovered context across the slots even when it clears the floor as a whole', async () => { + // 65,536 over 4 slots is 16,384 each: nothing the server reports can widen this pool + const { body } = await apply(slotEndpoint(65536, 4)); + + expect(body.requiresContextWarning).toBe(true); + expect(body.contextLength).toBe(65536); + expect(body.slotCount).toBe(4); + }); + + it('does not warn once every slot can hold the floor', async () => { + const { body, session } = await apply(slotEndpoint(262144, 4)); // 65,536 each + + expect(body.requiresContextWarning).toBeUndefined(); + expect(session.setCustomModel).toHaveBeenCalledTimes(1); + }); + + it('does not warn for a single slot with the same context that failed across four', async () => { + const { body, session } = await apply(slotEndpoint(131072, 1)); + + expect(body.requiresContextWarning).toBeUndefined(); + expect(session.setCustomModel).toHaveBeenCalledTimes(1); + }); + + it('an unknown slot count is treated as one slot, never guessed', async () => { + const { body } = await apply(slotEndpoint(131072)); + + expect(body.requiresContextWarning).toBeUndefined(); + }); + + it('omits slotCount from the warning when the context is too small on its own with a single slot', async () => { + const { body } = await apply(slotEndpoint(16384, 1)); + + expect(body.requiresContextWarning).toBe(true); + expect(body.slotCount).toBeUndefined(); + }); + }); + it('applies once confirmed, skipping the context check the second time', async () => { const { app, ctx } = await setup(); await writeCustomModelHosts(getDataDir(), [CLAUDE_ENDPOINT, SMALL_CTX_ENDPOINT]); From 3198a6972c33513354195322b16a65cdaf81afda Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Thu, 22 Oct 2026 19:32:47 +0200 Subject: [PATCH 93/96] fix(release): stamp the codeman skill preamble 1.44.0 #670 stamped the preamble with the version it expected to ship in, 1.43.1. The release is 1.44.0, so the stamp would name a version that never existed. Any new value makes cached preambles from before #670's sendwait change stale, which is what the stamp is for. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- plugins/codeman/skills/codeman/SKILL.md | 12 ++++++------ plugins/codeman/skills/codeman/preamble.sh | 4 ++-- plugins/codeman/skills/codeman/reference/recipes.md | 2 +- skills/codeman/SKILL.md | 12 ++++++------ skills/codeman/preamble.sh | 4 ++-- skills/codeman/reference/recipes.md | 2 +- 6 files changed, 18 insertions(+), 18 deletions(-) diff --git a/plugins/codeman/skills/codeman/SKILL.md b/plugins/codeman/skills/codeman/SKILL.md index 9aedd574..d48aaa00 100644 --- a/plugins/codeman/skills/codeman/SKILL.md +++ b/plugins/codeman/skills/codeman/SKILL.md @@ -70,7 +70,7 @@ later call opens with, and your first REAL call performs them anyway: ```bash . "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null -[ "${CODEMAN_PREAMBLE:-}" = 1.43.1 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; } +[ "${CODEMAN_PREAMBLE:-}" = 1.44.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; } ``` ⚠️ **Never spend a Bash call on this check alone.** §1's block opens with this same @@ -98,8 +98,8 @@ PRE="${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" mkdir -p "$(dirname "$PRE")" # Rewrite unless the file already ends with THIS version's stamp, so a stale or a # half-written file self-heals here instead of costing you a round trip to rm it. -grep -qs '^CODEMAN_PREAMBLE=1.43.1$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE' -# ---- Codeman agent preamble 1.43.1 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ---- +grep -qs '^CODEMAN_PREAMBLE=1.44.0$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE' +# ---- Codeman agent preamble 1.44.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ---- # Positional parameters are always written with braces, ${1} and never a bare dollar-digit: Claude Code # replaces a bare one in a skill body with the matching word of the skill's arguments, and §0 is pasted # from that body (test/agent-skill.test.ts pins it). @@ -420,10 +420,10 @@ last_text() { # The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept # bare on purpose: the write condition above anchors on it with $, so an inline comment # here would fail that match and rewrite this file on every single bootstrap. -CODEMAN_PREAMBLE=1.43.1 +CODEMAN_PREAMBLE=1.44.0 PREAMBLE ) -. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.43.1 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; } +. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.44.0 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; } ``` Every later Bash call that touches the API starts with the same two loader lines from @@ -474,7 +474,7 @@ and no per-call body to hand-build. ```bash . "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null # §0 loader -[ "${CODEMAN_PREAMBLE:-}" = 1.43.1 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; } +[ "${CODEMAN_PREAMBLE:-}" = 1.44.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; } N=(alpha beta) # INVENT one fresh case name per worker; never list cases first # (a name may carry a mode: `beta:deepseek`, see below) T=('reply with one line: the absolute path of your working directory' diff --git a/plugins/codeman/skills/codeman/preamble.sh b/plugins/codeman/skills/codeman/preamble.sh index a0e95a32..5de248af 100644 --- a/plugins/codeman/skills/codeman/preamble.sh +++ b/plugins/codeman/skills/codeman/preamble.sh @@ -1,4 +1,4 @@ -# ---- Codeman agent preamble 1.43.1 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ---- +# ---- Codeman agent preamble 1.44.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ---- # Positional parameters are always written with braces, ${1} and never a bare dollar-digit: Claude Code # replaces a bare one in a skill body with the matching word of the skill's arguments, and §0 is pasted # from that body (test/agent-skill.test.ts pins it). @@ -319,4 +319,4 @@ last_text() { # The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept # bare on purpose: the write condition above anchors on it with $, so an inline comment # here would fail that match and rewrite this file on every single bootstrap. -CODEMAN_PREAMBLE=1.43.1 +CODEMAN_PREAMBLE=1.44.0 diff --git a/plugins/codeman/skills/codeman/reference/recipes.md b/plugins/codeman/skills/codeman/reference/recipes.md index 99ef3108..c03bb194 100644 --- a/plugins/codeman/skills/codeman/reference/recipes.md +++ b/plugins/codeman/skills/codeman/reference/recipes.md @@ -21,7 +21,7 @@ by sourcing the preamble file the §0 bootstrap wrote, and checking its version ```bash . "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null -[ "${CODEMAN_PREAMBLE:-}" = 1.43.1 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; } +[ "${CODEMAN_PREAMBLE:-}" = 1.44.0 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; } ``` Do **not** re-paste the preamble body into each call. Sourcing it is what retires the diff --git a/skills/codeman/SKILL.md b/skills/codeman/SKILL.md index 9aedd574..d48aaa00 100644 --- a/skills/codeman/SKILL.md +++ b/skills/codeman/SKILL.md @@ -70,7 +70,7 @@ later call opens with, and your first REAL call performs them anyway: ```bash . "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null -[ "${CODEMAN_PREAMBLE:-}" = 1.43.1 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; } +[ "${CODEMAN_PREAMBLE:-}" = 1.44.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; } ``` ⚠️ **Never spend a Bash call on this check alone.** §1's block opens with this same @@ -98,8 +98,8 @@ PRE="${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" mkdir -p "$(dirname "$PRE")" # Rewrite unless the file already ends with THIS version's stamp, so a stale or a # half-written file self-heals here instead of costing you a round trip to rm it. -grep -qs '^CODEMAN_PREAMBLE=1.43.1$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE' -# ---- Codeman agent preamble 1.43.1 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ---- +grep -qs '^CODEMAN_PREAMBLE=1.44.0$' "$PRE" || (umask 077; cat > "$PRE" <<'PREAMBLE' +# ---- Codeman agent preamble 1.44.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ---- # Positional parameters are always written with braces, ${1} and never a bare dollar-digit: Claude Code # replaces a bare one in a skill body with the matching word of the skill's arguments, and §0 is pasted # from that body (test/agent-skill.test.ts pins it). @@ -420,10 +420,10 @@ last_text() { # The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept # bare on purpose: the write condition above anchors on it with $, so an inline comment # here would fail that match and rewrite this file on every single bootstrap. -CODEMAN_PREAMBLE=1.43.1 +CODEMAN_PREAMBLE=1.44.0 PREAMBLE ) -. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.43.1 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; } +. "$PRE"; [ "${CODEMAN_PREAMBLE:-}" = 1.44.0 ] || { echo "preamble at $PRE is stale or truncated: rm it and re-run this block"; exit 1; } ``` Every later Bash call that touches the API starts with the same two loader lines from @@ -474,7 +474,7 @@ and no per-call body to hand-build. ```bash . "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null # §0 loader -[ "${CODEMAN_PREAMBLE:-}" = 1.43.1 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; } +[ "${CODEMAN_PREAMBLE:-}" = 1.44.0 ] || { echo "preamble missing or stale; run the full §0 block"; exit 1; } N=(alpha beta) # INVENT one fresh case name per worker; never list cases first # (a name may carry a mode: `beta:deepseek`, see below) T=('reply with one line: the absolute path of your working directory' diff --git a/skills/codeman/preamble.sh b/skills/codeman/preamble.sh index a0e95a32..5de248af 100644 --- a/skills/codeman/preamble.sh +++ b/skills/codeman/preamble.sh @@ -1,4 +1,4 @@ -# ---- Codeman agent preamble 1.43.1 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ---- +# ---- Codeman agent preamble 1.44.0 (seeded by Codeman at session spawn; the SKILL.md §0 bootstrap rewrites it when missing or stale) ---- # Positional parameters are always written with braces, ${1} and never a bare dollar-digit: Claude Code # replaces a bare one in a skill body with the matching word of the skill's arguments, and §0 is pasted # from that body (test/agent-skill.test.ts pins it). @@ -319,4 +319,4 @@ last_text() { # The stamp is the LAST line on purpose (a truncated write leaves it unset) and is kept # bare on purpose: the write condition above anchors on it with $, so an inline comment # here would fail that match and rewrite this file on every single bootstrap. -CODEMAN_PREAMBLE=1.43.1 +CODEMAN_PREAMBLE=1.44.0 diff --git a/skills/codeman/reference/recipes.md b/skills/codeman/reference/recipes.md index 99ef3108..c03bb194 100644 --- a/skills/codeman/reference/recipes.md +++ b/skills/codeman/reference/recipes.md @@ -21,7 +21,7 @@ by sourcing the preamble file the §0 bootstrap wrote, and checking its version ```bash . "${XDG_CACHE_HOME:-$HOME/.cache}/codeman-agent-$CODEMAN_SESSION_ID.sh" 2>/dev/null -[ "${CODEMAN_PREAMBLE:-}" = 1.43.1 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; } +[ "${CODEMAN_PREAMBLE:-}" = 1.44.0 ] || { echo "preamble missing or stale; re-run the §0 bootstrap"; exit 1; } ``` Do **not** re-paste the preamble body into each call. Sourcing it is what retires the From dda16d3e2ae12fc3e77bfd5ebeaecbe1539ac50c Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Thu, 22 Oct 2026 19:37:36 +0200 Subject: [PATCH 94/96] test(tabs): stub process.cwd instead of changing directory in the no-workingDir case Merge-time fix for #676. The test for nit 7 changed the worker's real cwd, which also throws under a threads pool; a process.cwd spy gives the route the same case-folder cwd with nothing to restore by hand. It still fails without the workingDir gate. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- test/routes/case-default-group-routes.test.ts | 26 ++++++++----------- 1 file changed, 11 insertions(+), 15 deletions(-) diff --git a/test/routes/case-default-group-routes.test.ts b/test/routes/case-default-group-routes.test.ts index e34f4147..637d4dad 100644 --- a/test/routes/case-default-group-routes.test.ts +++ b/test/routes/case-default-group-routes.test.ts @@ -157,21 +157,17 @@ describe('case default group: create routes', () => { }); it('POST /api/sessions without a workingDir infers no case from the server cwd', async () => { - const cwd = process.cwd(); - process.chdir(caseDir); // the server's own cwd happens to be a case folder - try { - const res = await harness.app.inject({ - method: 'POST', - url: '/api/sessions', - payload: { name: 'w1-nowd', mode: 'shell' }, - }); - expect(res.statusCode).toBe(200); - const data = unwrap(res.body); - expect(Object.keys(data)).toEqual(['session']); - expect(groupOf(world.layouts[OWNER], data.session.id)).toBeNull(); - } finally { - process.chdir(cwd); - } + vi.spyOn(process, 'cwd').mockReturnValue(caseDir); // the server's own cwd happens to be a case folder + const res = await harness.app.inject({ + method: 'POST', + url: '/api/sessions', + payload: { name: 'w1-nowd', mode: 'shell' }, + }); + expect(res.statusCode).toBe(200); + const data = unwrap(res.body); + expect(data.session.workingDir).toBe(caseDir); + expect(Object.keys(data)).toEqual(['session']); + expect(groupOf(world.layouts[OWNER], data.session.id)).toBeNull(); }); it('POST /api/sessions outside any case gets no case default and keeps its response shape', async () => { From e8fc08beb3e7221597b01502c0615987f5788ac5 Mon Sep 17 00:00:00 2001 From: JD <jd@jds.haus> Date: Thu, 22 Oct 2026 13:53:48 -0400 Subject: [PATCH 95/96] fix(mobile): hand a swipe-locked drag back to the scroll past the swipe's drift limit A scroll whose first move past the tap slop leaned sideways (a thumb arc) locked as 'swipe' for the whole contact, and SwipeHandler drops a swipe at 50 px of vertical drift, so the gesture did nothing. Past that limit the drag is now a 'y' scroll. Also brings the mobile-handlers fileoverview to the current 500 ms / 50 px limits and notes in the Mobile Guide that a selection pauses the swipe and a wide terminal pans instead. --- docs/wiki/Mobile-Guide.md | 3 ++- src/web/public/mobile-handlers.js | 2 +- src/web/public/terminal-ui.js | 16 ++++++++++++++-- test/session-swipe-gesture.test.ts | 14 +++++++++++++- 4 files changed, 30 insertions(+), 5 deletions(-) diff --git a/docs/wiki/Mobile-Guide.md b/docs/wiki/Mobile-Guide.md index 11a78292..159d562c 100644 --- a/docs/wiki/Mobile-Guide.md +++ b/docs/wiki/Mobile-Guide.md @@ -37,7 +37,8 @@ a bottom sheet rather than a dropdown. On a folding phone (iPhone Duo) dialogs s the hinge, and opening or closing the device is treated as the device changing shape, never as the keyboard appearing. -**Swipe left and right** on the terminal to switch sessions. +**Swipe left and right** on the terminal to switch sessions. A visible text selection pauses +the swipe, and a terminal wider than the screen pans sideways instead. ## The home screen diff --git a/src/web/public/mobile-handlers.js b/src/web/public/mobile-handlers.js index 9bfd9acb..3c3ba549 100644 --- a/src/web/public/mobile-handlers.js +++ b/src/web/public/mobile-handlers.js @@ -9,7 +9,7 @@ * toolbar/accessory bar repositioning, terminal resize on keyboard open/close, * and input scroll-into-view. Uses 100px threshold for iOS address bar drift. * - SwipeHandler — Horizontal swipe detection on the terminal area for session switching. - * 80px minimum distance, 300ms maximum time, 100px max vertical drift. + * 80px minimum distance, 500ms maximum time, 50px max vertical drift. * * All three have init()/cleanup() lifecycle methods. They are re-initialized after SSE * reconnect (in handleInit) to prevent stale closures. diff --git a/src/web/public/terminal-ui.js b/src/web/public/terminal-ui.js index 95d80e0a..8143c967 100644 --- a/src/web/public/terminal-ui.js +++ b/src/web/public/terminal-ui.js @@ -1200,7 +1200,8 @@ Object.assign(CodemanApp.prototype, { // slop, so a diagonal drag cannot pan and scroll at the same time. 'swipe' // is a sideways drag on a terminal that cannot pan: it is the session // swipe's (SwipeHandler, mobile-handlers.js), so it neither scrolls nor - // ends as a tap on the session being left. Published per gesture as + // ends as a tap on the session being left, until it drifts past the + // swipe's vertical limit and becomes a 'y' scroll. Published per gesture as // `this._terminalGestureAxis`, which SwipeHandler reads to stand aside // for a scroll or a pan. let panAxis = null; @@ -1335,7 +1336,18 @@ Object.assign(CodemanApp.prototype, { // fling, so a jittery tap would both position the cursor AND scroll. if (!didScroll) return; ev.preventDefault(); - if (panAxis === 'swipe') return; + if (panAxis === 'swipe') { + // Past the swipe's own vertical drift limit the session swipe has + // given up, so the drag is a scroll after all (a thumb arc whose + // first move leaned sideways): hand it to 'y'. + const drift = typeof SwipeHandler !== 'undefined' ? SwipeHandler.maxVerticalDrift : 50; + if (Math.abs(touchY - touchStartY) <= drift) return; + panAxis = 'y'; + this._terminalGestureAxis = 'y'; + touchLastX = touchX; + touchLastY = touchY; + return; + } if (panAxis === 'x') { // Pan the container, and touch nothing the vertical path owns — // no pixelAccum, no velocity, so touchend cannot turn a sideways diff --git a/test/session-swipe-gesture.test.ts b/test/session-swipe-gesture.test.ts index 9acece0b..5eb13c54 100644 --- a/test/session-swipe-gesture.test.ts +++ b/test/session-swipe-gesture.test.ts @@ -147,6 +147,18 @@ describe('terminal touch handler hands sideways drags to the swipe', () => { expect(TERMINAL_SOURCE).toContain("panAxis = travelX > travelY ? (canPanHorizontally() ? 'x' : 'swipe') : 'y';"); expect(TERMINAL_SOURCE).toContain('this._terminalGestureAxis = panAxis;'); // A 'swipe' is neither a scroll nor (didScroll is set) a tap. - expect(TERMINAL_SOURCE).toContain("if (panAxis === 'swipe') return;"); + expect(TERMINAL_SOURCE).toContain("if (panAxis === 'swipe') {"); + }); + + it("hands a 'swipe' drag back to the scroll once it passes the swipe's own drift limit", () => { + // A thumb arc whose first move leans sideways (+9 px across, +5 px down) locks + // as 'swipe'; SwipeHandler drops it past maxVerticalDrift, so without the + // handback the 300 px of downward travel that follows would do nothing. + const start = TERMINAL_SOURCE.indexOf("if (panAxis === 'swipe') {"); + const block = TERMINAL_SOURCE.slice(start, TERMINAL_SOURCE.indexOf("if (panAxis === 'x') {", start)); + expect(block).toContain('SwipeHandler.maxVerticalDrift'); + expect(block).toContain('if (Math.abs(touchY - touchStartY) <= drift) return;'); + expect(block).toContain("panAxis = 'y';"); + expect(block).toContain("this._terminalGestureAxis = 'y';"); }); }); From 002de32a088c9cbede1eab79c5957290ee85f060 Mon Sep 17 00:00:00 2001 From: Codeman maintainer <noreply@anthropic.com> Date: Thu, 22 Oct 2026 20:33:07 +0200 Subject: [PATCH 96/96] chore: version packages Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> --- .changeset/18dce807.md | 5 --- .changeset/b569450a.md | 5 --- .changeset/copilot-plan-usage.md | 5 --- .changeset/file-viewer-window.md | 5 --- .changeset/max-sessions-env.md | 5 --- .changeset/multiline-input-paste.md | 5 --- .changeset/slot-aware-context.md | 5 --- .changeset/tmux-prefix-table.md | 5 --- .claude-plugin/marketplace.json | 2 +- CHANGELOG.md | 42 ++++++++++++++++++++++ CLAUDE.md | 2 +- config/mcp-registry/server.json | 4 +-- package-lock.json | 4 +-- package.json | 2 +- plugins/codeman/.claude-plugin/plugin.json | 2 +- src/web/public/whats-new.json | 18 +++++----- 16 files changed, 59 insertions(+), 57 deletions(-) delete mode 100644 .changeset/18dce807.md delete mode 100644 .changeset/b569450a.md delete mode 100644 .changeset/copilot-plan-usage.md delete mode 100644 .changeset/file-viewer-window.md delete mode 100644 .changeset/max-sessions-env.md delete mode 100644 .changeset/multiline-input-paste.md delete mode 100644 .changeset/slot-aware-context.md delete mode 100644 .changeset/tmux-prefix-table.md diff --git a/.changeset/18dce807.md b/.changeset/18dce807.md deleted file mode 100644 index 9eb3a1b8..00000000 --- a/.changeset/18dce807.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"aicodeman": minor ---- - -Config backups: timestamped snapshots of the files that hold your customization (settings, CLI overrides, custom endpoints, webhook, linked cases and, in multi-user mode, accounts), taken at startup and whenever one changes. On by default into `~/.codeman/backups/config`; the folder and the retention (keep N backups, drop older than N days) are configurable in Settings → System, with Back up now and Restore. Retention only deletes folders Codeman made, never the newest backup. A comparison view shows what changed between two backups, or between a backup and the running config, without ever showing a credential's value. diff --git a/.changeset/b569450a.md b/.changeset/b569450a.md deleted file mode 100644 index 578f328d..00000000 --- a/.changeset/b569450a.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"aicodeman": patch ---- - -Test suite: a bare `npx vitest run` now resolves the isolated CI config instead of running with no setup file (which wrote fixtures into the real `~/.codeman`), and the shared temp-HOME teardown retries on `ENOTEMPTY`. diff --git a/.changeset/copilot-plan-usage.md b/.changeset/copilot-plan-usage.md deleted file mode 100644 index 4b69efb3..00000000 --- a/.changeset/copilot-plan-usage.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'aicodeman': minor ---- - -The plan-usage chip in the header gains a `Copilot` row: the calendar-month premium-request quota, with the request counts and the reset in the tooltip. The host reads it every 10 minutes from GitHub's `copilot_internal/user` endpoint with the Copilot CLI's own sign-in token. It is on by default while the CLI is installed and signed in (an absent Plan Usage Limits setting reads as on), and turning Plan Usage Limits off in App Settings stops the request. Any failure leaves the chip without a Copilot row. The FAQ, Installation and Security pages say so. diff --git a/.changeset/file-viewer-window.md b/.changeset/file-viewer-window.md deleted file mode 100644 index e3b3ae98..00000000 --- a/.changeset/file-viewer-window.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'aicodeman': minor ---- - -The File Viewer window can be resized and expanded, and jumping between files now has Back and Forward. Drag the corner grip to resize it, or use the Expand button to fill the screen (remembered across opens). Following a link from one document to another adds it to a list that the ← and → buttons, or Alt+← and Alt+→, walk through; opening something new from the middle of the list drops the forward entries, and closing the viewer clears it. Leaving unsaved edits still asks first. diff --git a/.changeset/max-sessions-env.md b/.changeset/max-sessions-env.md deleted file mode 100644 index 5691f2c2..00000000 --- a/.changeset/max-sessions-env.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'aicodeman': minor ---- - -The cap of 50 concurrent sessions can now be set with `CODEMAN_MAX_SESSIONS` (1 to 462, read at startup; a larger value is clamped to 462), for a machine with the memory for more sessions or a small one that should run fewer. Without it nothing changes. A value that is not a positive integer falls back to 50, so a typo never blocks session creation. In multi-user mode the per-user cap still defaults to half the global cap, so it follows the new value unless `CODEMAN_MAX_SESSIONS_PER_USER` is set. diff --git a/.changeset/multiline-input-paste.md b/.changeset/multiline-input-paste.md deleted file mode 100644 index f72280ac..00000000 --- a/.changeset/multiline-input-paste.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'aicodeman': patch ---- - -A multi-line prompt sent through the mux (`POST /api/sessions/:id/input` with `useMux`) now keeps its line breaks. `sendInput` used to strip every `\n` before the text reached tmux, so `"line one\nline two\r"` arrived as `line oneline two`. The lines now go in as one bracketed paste, the same paste a single-line prompt already uses, and the Enter after it submits them together; multi-line text without an Enter is pasted too instead of typed. A shell runs each line. The submit check that re-presses a stranded Enter does not yet recognise a multi-line paste in the composer, so the unattended senders stay single-line: cron still rejects multi-line prompts, respawn's update and kickstart prompts and the auto-compact focus prompt now have their lines joined with a space (they used to run together), `codeman agent send` and the TUI keep refusing or joining multi-line input, and the agent skill's `sendwait` now joins the lines of a multi-line prompt with a space too. diff --git a/.changeset/slot-aware-context.md b/.changeset/slot-aware-context.md deleted file mode 100644 index 4a39c89a..00000000 --- a/.changeset/slot-aware-context.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -'aicodeman': patch ---- - -A custom llama.cpp model whose parallel slots share a context pool too small for concurrent Claude Code requests now gets an in-app warning. Four simultaneous 40K-token requests against a 4-slot ~131K pool all failed with "Context size has been exceeded" while the old total-context check stayed silent. Discovery records each model's slot count (from `--parallel`/`-np`, or `/props` `total_slots` when the launch command states none) and the warning names the fix: `--parallel 1`, or a context large enough for every slot. diff --git a/.changeset/tmux-prefix-table.md b/.changeset/tmux-prefix-table.md deleted file mode 100644 index d711b9c2..00000000 --- a/.changeset/tmux-prefix-table.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"aicodeman": patch ---- - -Browser terminals can no longer be moved onto another session by tmux's prefix keys: typing Ctrl+B then `)`, `(`, `s` or `w` in a tab switched its tmux client to a different session while the tab kept the original's name. Codeman's tmux socket now keeps only Ctrl+B Ctrl+B (send-prefix) in its prefix table, so `Ctrl+B d` no longer detaches a terminal attached there: `codeman tui` and `scripts/tmux-manager.sh` detach with F1. diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index 94fc1523..4c0e9fff 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -10,7 +10,7 @@ "name": "codeman", "source": "./plugins/codeman", "description": "Drive Codeman from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts only inside a Codeman-managed session.", - "version": "1.43.0", + "version": "1.44.0", "author": { "name": "Ark0N", "url": "https://github.com/Ark0N" diff --git a/CHANGELOG.md b/CHANGELOG.md index a8097581..086ef850 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,47 @@ # aicodeman +## 1.44.0 + +### Minor Changes + +- ### Thanks + - @opticon454 for five PRs: config backups you can compare and restore (#661, ten review rounds), a File Viewer you can resize, expand and walk Back and Forward through (#659), GitHub Copilot's premium-request quota in the plan-usage chip (#662), a warning when llama.cpp's parallel slots leave too little context per request (#658, from #641), and a bare `npx vitest` that can no longer write into your real `~/.codeman` (#660, from #640). + - @JDProfresh for four PRs: the opt-in prompt-cache keep-alive (#666, step 2 of #554), Markdown links to file paths that open in the File Viewer (#665), a phone keyboard that stays up while you press accessory keys (#673), and a session swipe that no longer taps or scrolls the session you are leaving (#674), with the iPhone testing behind both phone fixes. + - @christianhaberl for four PRs: `CODEMAN_MAX_SESSIONS` (#668), tmux prefix keys that can no longer move a browser tab onto another session (#669), multi-line prompts that keep their line breaks (#670), and rediscovered tmux sessions that keep their own folder (#667). Also for the earlier #224, #225, #226 and the session sidebar (#307), whose commits carried an agent identity that GitHub credits to another account. + - @johoja12, a first-time contributor, for scroll batches that stay with the tab that made them, smoother trackpad scrolling and wheel scrolling in fullscreen Codex (#645), measured against the real CLI through five review rounds. + - @ludovicfourrage for touch scrolling in apps that track the mouse themselves (#664), and the careful write-up in #663. + - @aakhter for a default tab group per case (#676). + + **Config backups (#661).** Codeman now keeps timestamped copies of the files that hold your setup: settings, CLI overrides, custom model endpoints, the webhook, linked cases and, in multi-user mode, the accounts. It takes one at startup and whenever one of them changes, into `~/.codeman/backups/config` by default. App Settings → System lets you pick the folder and the retention (keep N backups, drop ones older than N days), back up now, compare two backups or a backup with what is running (credentials are never shown, only whether they changed), and restore one. A restore first saves what it is about to replace, so it can be undone, and retention only ever deletes folders Codeman made and never the newest backup. + + **Keep an idle Claude session's prompt cache warm (#666, opt-in).** Claude Code's prompt cache expires after an hour of quiet, and the next prompt then pays to re-read the whole conversation. Switch on **Keep the cache warm while idle** in a session's Respawn tab (or **Keep prompt caches warm** in App Settings → System → Automation for every new session) and Codeman sends a one-line keep-alive turn about five minutes before the cache would expire. It only does that when nothing else is going on: an empty prompt, no open dialog, nobody typing, no respawn, Ralph loop or waiting agent, no usage-limit pause. It stops on its own a while after your last prompt (**Keep warm for at most**, default 2 hours). A session started with it on also launches with Claude Code's own idle compaction off (Claude Code 2.1.290 or newer). The keep-alive raises no alert, push, webhook or Away-digest row, and the readout's Status line says what it is doing. + + **A default tab group per case (#676).** Case settings has a **Default session group**: every new session for that case lands straight in that tab group, in the same write that creates it, so it never flashes in Ungrouped first. An explicit group on the create request and a spawned child following its parent still win. It is stored per user and survives a group rename. + + **A File Viewer you can resize, with Back and Forward (#659).** Drag the corner grip to resize the window, or press Expand to fill the screen (remembered on this device). Following a link from one file to another builds a history that the ← and → buttons and Alt+← / Alt+→ walk through. Phones keep the full-screen viewer. + + **Copilot's premium-request quota in the plan-usage chip (#662).** With the GitHub Copilot CLI installed and signed in, the header chip gains a Copilot row for the month's premium requests, with the counts and the reset date in the tooltip. Codeman reads it every 10 minutes from GitHub with the Copilot CLI's own sign-in token. It is on by default, like the Codex row, and turning Plan Usage Limits off in App Settings stops the request. + + **More (or fewer) sessions with `CODEMAN_MAX_SESSIONS` (#668).** The cap of 50 concurrent sessions can be set at startup, from 1 to 462. A value that is not a positive integer falls back to 50, and the per-user cap in multi-user mode follows it unless `CODEMAN_MAX_SESSIONS_PER_USER` is set. + + **Multi-line prompts keep their line breaks (#670).** A prompt sent through the API with `useMux` used to arrive as one welded line (`line oneline two`). Its lines now go into the pane as one bracketed paste, followed by the Enter that submits them together. The senders that run unattended (cron, respawn's update and kickstart prompts, the auto-compact focus, `codeman agent send`, the TUI and the skill's `sendwait`) stay single-line and join lines with a space, because the submit check cannot yet recognise a multi-line paste in the composer. + + **A warning for llama.cpp slots that split the context too thin (#658).** A custom llama.cpp model that runs several parallel slots splits its context between them, and four 40K-token Claude requests against a 4-slot, 131K pool all failed with "Context size has been exceeded". Discovery now records each model's slot count, and a Claude launch warns when one slot gets less than 40K tokens, naming the fix (`--parallel 1`, or a context large enough for every slot). + + **Fixes.** + - **tmux prefix keys stay out of browser tabs (#669).** Ctrl+B then `)`, `(`, `s` or `w` in a tab switched its tmux client to another session while the tab kept the first one's name. Codeman's tmux socket now keeps only Ctrl+B Ctrl+B (send-prefix) in its prefix table, set at session start and at boot. This also means `Ctrl+B d` no longer detaches a terminal attached to that socket: `codeman tui` and `scripts/tmux-manager.sh` detach with F1. + - **Scrolling stays with its tab, and fullscreen Codex scrolls (#645).** A queued wheel batch can no longer land in a tab you switched to, trackpads keep their fractional travel instead of dropping it, the first wheel report of a tab visit goes out at once, and Codex gets wheel reports while it has mouse tracking on (measured on Codex 0.163.0, where an approval prompt's selection is unaffected). + - **Touch scrolling in apps that track the mouse (#664).** A finger drag and its fling now scroll a program in the pane that asked for SGR mouse reports (a TUI in a shell tab, for example) instead of scrolling a local buffer that cannot show it. Other mouse encodings are left alone. + - **A session swipe only switches sessions (#674).** A sideways swipe on the terminal no longer also opens a link, taps a CLI menu or toggles the keyboard on the session you are leaving. A deliberate swipe gets 500 ms instead of 300, a selection or a second finger pauses it, and a scroll that starts on a sideways thumb arc still scrolls. + - **The phone keyboard stays up (#673).** Arrow, Tab, Esc and the other accessory keys that act on the terminal no longer blur it for a moment, so the toolbar stops bouncing on every tap. + - **Markdown links to files open in the File Viewer (#665).** A link like Codex's `[label](/abs/path.json)` in the response viewer now opens the file instead of a same-origin 404 tab, and its label keeps the prose font. + - **Rediscovered sessions keep their folder (#667).** A tmux session Codeman finds again on its socket (after `mux-sessions.json` was lost) now gets the directory tmux reports for its pane instead of the server's own, so a respawn goes back to the right project. + - **Web tabs send POST bodies to dashboards that read Content-Length.** Codeman's web-tab proxy now relays the browser's Content-Length, so a dashboard built on Python's `http.server` or a small Go or C server no longer sees an empty body on every POST made through a web tab. + + **Fixes applied while landing.** A restore that fails partway now says which files it restored, which it could not and where the pre-restore copy is, re-reads what was written and refills the Settings form; backup passes list the manifests once, and a pre-restore copy equal to the newest backup is not made twice (#661). A failed keep-alive write is retried before the expiry instead of never, an unreadable `settings.json` is logged once instead of every minute for every session, the keep-alive's own hooks stay out of the run summary, and saving App Settings re-plans keep-alives only when their settings changed (#666). The Copilot quota read checks the setting and the sign-in token before it ever probes for the CLI, so a machine without Copilot runs no login shell every 10 minutes, and drops a header the Copilot CLI itself does not send (#662). The prefix-table reset still binds send-prefix on an empty table and no longer reruns on every session for a prefix tmux lists escaped (#669). Claude worktree sessions follow the case's default group like every other CLI's (#676). The remote-history PATH change that rode along in #645 was left for its own PR. Smaller touches: the Expand button keeps its label while pressed and Alt+← / Alt+→ are listed in the shortcut overlay and help (#659); the scroll log names xterm's mouse encoding (#664); the response-viewer link test covers `?query` and folder links (#665). + + **Docs and tests.** A bare `npx vitest run` from the repo now uses the isolated CI config instead of running without the setup file, the helpers that delete test folders refuse to run outside a sandboxed HOME, and the temp-HOME teardown is bounded so a stuck writer cannot time the hook out (#660). New sections and lines for config backups, the keep-alive and the Copilot quota in the architecture notes and `CLAUDE.md`, a test that a deleted pane directory is never adopted (#667), the codeman skill preamble re-stamped 1.44.0, and browser tests for the touch-scroll and swipe fixes. + ## 1.43.0 ### Minor Changes diff --git a/CLAUDE.md b/CLAUDE.md index 56663bb6..02704d0f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,7 +52,7 @@ Uses [Semantic Versioning](https://semver.org/) (`MAJOR.MINOR.PATCH`) via `@chan When the user says **COM** (`COM` = patch, `COM minor`, `COM major`): **Read [`docs/releasing.md`](docs/releasing.md) and follow every step in order**; never improvise a release from memory. It covers the changeset, `npm run version-packages`, the `**Version**` line below, the branch check and explicit-path commit, deploy, the getcodeman.com badge, watching BOTH CI workflows, the MCP registry publish, and the Discussions announcement. -**Version**: 1.43.0 (must match `package.json`) +**Version**: 1.44.0 (must match `package.json`) ## Project Overview diff --git a/config/mcp-registry/server.json b/config/mcp-registry/server.json index 85e3d416..b91bb567 100644 --- a/config/mcp-registry/server.json +++ b/config/mcp-registry/server.json @@ -3,7 +3,7 @@ "name": "io.github.Ark0N/codeman", "title": "Codeman", "description": "Start, prompt and read AI coding agent sessions (Claude Code, Codex, Gemini CLI and more) in Codeman", - "version": "1.43.0", + "version": "1.44.0", "websiteUrl": "https://getcodeman.com", "repository": { "url": "https://github.com/Ark0N/Codeman", @@ -13,7 +13,7 @@ { "registryType": "npm", "identifier": "aicodeman", - "version": "1.43.0", + "version": "1.44.0", "transport": { "type": "stdio" }, diff --git a/package-lock.json b/package-lock.json index 48f7b36b..eb76ba3c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "aicodeman", - "version": "1.43.0", + "version": "1.44.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "aicodeman", - "version": "1.43.0", + "version": "1.44.0", "hasInstallScript": true, "license": "MIT", "workspaces": [ diff --git a/package.json b/package.json index 987e7a44..b69c7834 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "aicodeman", - "version": "1.43.0", + "version": "1.44.0", "description": "Self-hosted mission control for AI coding agents: run Claude Code, Codex, OpenCode, Gemini, DeepSeek, Grok and more 24/7 in tmux, from any device.", "mcpName": "io.github.Ark0N/codeman", "type": "module", diff --git a/plugins/codeman/.claude-plugin/plugin.json b/plugins/codeman/.claude-plugin/plugin.json index f5a870c6..9d1d276e 100644 --- a/plugins/codeman/.claude-plugin/plugin.json +++ b/plugins/codeman/.claude-plugin/plugin.json @@ -1,7 +1,7 @@ { "name": "codeman", "description": "Drive Codeman, the self-hosted session manager for AI coding agents, from inside a Claude Code session: spawn worker sessions, prompt them, wait for them, read their answers, clean up. Acts only inside a Codeman-managed session.", - "version": "1.43.0", + "version": "1.44.0", "author": { "name": "Ark0N", "url": "https://github.com/Ark0N" diff --git a/src/web/public/whats-new.json b/src/web/public/whats-new.json index 3c5c2f80..b3f2ad64 100644 --- a/src/web/public/whats-new.json +++ b/src/web/public/whats-new.json @@ -1,13 +1,13 @@ { - "version": "1.43.0", + "version": "1.44.0", "highlights": [ - "Security notices for the CLIs you run", - "GitHub Copilot CLI is a run mode (#592)", - "Pin tab names on the CLI (#646, optional)", - "Antigravity tabs show when they are working (#648)", - "Custom-folder cases below the cases folder (#567)", - "A steady WS pill (#647)", - "Clickable diff and patch files (#644)", - "Mixed uploads say what they skipped (#651)" + "Config backups (#661)", + "Keep an idle Claude session's prompt cache warm (#666, opt-in)", + "A default tab group per case (#676)", + "A File Viewer you can resize, with Back and Forward (#659)", + "Copilot's premium-request quota in the plan-usage chip (#662)", + "More (or fewer) sessions with `CODEMAN_MAX_SESSIONS` (#668)", + "Multi-line prompts keep their line breaks (#670)", + "A warning for llama.cpp slots that split the context too thin (#658)" ] }