Merge remote-tracking branch 'origin/master' into relprep/pr-678

This commit is contained in:
Codeman maintainer committed 2026-10-24 13:55:07 +02:00
commit 886bf9fa0b
185 files changed
+13452 -644

No files matched your search

+3 -3
View File
@@ -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. SuchLine truncated
**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 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)
@@ -70,13 +70,13 @@ 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 (`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)
**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.
+8 -2
View File
@@ -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);
+13 -2
View File
@@ -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;
}
+1
View File
@@ -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';
+10
View File
@@ -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;
}
+3 -1
View File
@@ -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>;
+80 -12
View File
@@ -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'],
@@ -685,6 +686,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'],
},
];
@@ -2992,6 +3001,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;
@@ -2999,6 +3014,36 @@ 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&#9;://` 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;
// 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';
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);
@@ -3502,8 +3547,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(() => {
@@ -3525,8 +3572,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);
@@ -3590,7 +3638,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);
@@ -3650,19 +3698,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) => {
@@ -3672,10 +3728,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';
}
@@ -3971,8 +4037,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) {
@@ -7725,6 +7791,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))
+49 -1
View File
@@ -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,51 @@ 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 keepWarmWindow = Number.isFinite(status.budgetEndsAt)
? status.budgetEndsAt > now
? `stops ${clock(status.budgetEndsAt)}`
: 'window closed'
: '';
// 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 === true
? 'idle compaction off until relaunch'
: '';
let parts;
switch (status.state) {
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)}`, keepWarmWindow, idleCompaction];
break;
case 'waiting':
parts = ['waiting', status.reason || 'retrying', `retry ${clock(status.nextCheckAt)}`, keepWarmWindow, idleCompaction];
break;
case 'nudged':
parts = [`sent ${clock(status.lastNudgeAt)} (${status.nudges}\u00d7)`, keepWarmWindow, 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
@@ -1513,6 +1558,8 @@ if (typeof window !== 'undefined') {
};
window.CodemanPromptCache = {
format: formatPromptCacheStatus,
formatKeepWarm: formatKeepWarmStatus,
keepWarmUnavailable: (statusLine) => !!PROMPT_CACHE_NOT_COLLECTED[statusLine],
};
window.CodemanSessionOrder = {
RANK: SESSION_ACTIVITY_RANK,
@@ -1709,6 +1756,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',
+23
View File
@@ -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.":
@@ -962,6 +965,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': '自动清空',
@@ -1039,6 +1055,12 @@
'Source type filter': '来源类型筛选',
'Copy content': '复制内容',
'Edit file': '编辑文件',
'Back (Alt+←)': '后退 (Alt+←)',
'Forward (Alt+→)': '前进 (Alt+→)',
'Back to the previous file': '返回上一个文件',
'Forward to the next file': '前进到下一个文件',
Expand: '展开',
'Drag to resize': '拖动以调整大小',
'Rendered markdown': '渲染 Markdown',
'Line numbers': '行号',
'Wrap lines': '自动换行',
@@ -1235,6 +1257,7 @@
'Voice input (Ctrl+Shift+V)': '语音输入(Ctrl+Shift+V)',
'Insert Newline': '插入换行',
'Close Panels': '关闭面板',
'File Viewer Back / Forward': '文件查看器后退 / 前进',
'Previous / Next Session': '上一个 / 下一个会话',
'Next Session': '下一个会话',
'Previous Session': '上一个会话',
+86 -1
View File
@@ -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>
@@ -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>&larr;</button>
<button class="btn-icon-sm" id="filePreviewForwardBtn" onclick="app.stepFilePreviewHistory(1)" title="Forward (Alt+→)" aria-label="Forward to the next file" hidden disabled>&rarr;</button>
<button class="btn-icon-sm" id="filePreviewPrevBtn" onclick="app.stepFolderPreview(-1)" title="Previous file in folder (←)" aria-label="Previous file in folder" hidden>&lsaquo;</button>
<button class="btn-icon-sm" id="filePreviewNextBtn" onclick="app.stepFolderPreview(1)" title="Next file in folder (→)" aria-label="Next file in folder" hidden>&rsaquo;</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">&#x2398;</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">&times;</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>
@@ -774,6 +778,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 +827,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>
@@ -922,6 +936,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>
@@ -1334,8 +1349,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 &rarr; System &rarr; 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>
@@ -3242,6 +3268,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">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" 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" 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">
<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. 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>
<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">
@@ -3252,6 +3320,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>
+23 -5
View File
@@ -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();
@@ -1415,9 +1432,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 });
+48 -16
View File
@@ -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.
@@ -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();
},
};
+13
View File
@@ -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));
}
+152 -1
View File
@@ -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,148 @@ 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 {
// 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;
}
},
/** 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);
// 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');
},
/**
* 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;
// 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;
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) => {
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;
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 +4766,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');
+193 -5
View File
@@ -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();
@@ -2631,6 +2663,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 +2951,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;
@@ -3272,6 +3361,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 +3410,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 +3437,103 @@ 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 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;
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;
},
// ═══════════════════════════════════════════════════════════════
// Create Case Modal
// ═══════════════════════════════════════════════════════════════
+191 -3
View File
@@ -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);
@@ -551,6 +555,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).
@@ -597,6 +607,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();
@@ -1358,10 +1371,178 @@ 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' : '';
}
},
// ── 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');
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}`;
// 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;
}
const kb = (n) => (n < 1024 ? `${n} B` : `${(n / 1024).toFixed(1)} KB`);
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('${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>`;
});
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() {
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) {
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 */ }
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 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();
}
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();
},
/** Preview (apply=false) or run (apply=true) the MCP server sync across enabled CLIs. */
@@ -2737,6 +2918,9 @@ 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(),
...this._configBackupRetentionPayload(_prev),
readMyMindEnabled: document.getElementById('appSettingsReadMyMind').checked,
ultracodeFloatingWindows: document.getElementById('appSettingsUltracodeFloatingWindows').checked,
showMultiMonitorButton: document.getElementById('appSettingsShowMultiMonitorButton').checked,
@@ -2760,6 +2944,10 @@ 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:
// 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,
+68 -4
View File
@@ -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;
@@ -12223,10 +12244,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 +12264,36 @@ 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. 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;
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;
@@ -14926,6 +14982,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;
+41 -22
View File
@@ -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
@@ -51,7 +52,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
*/
@@ -229,6 +230,7 @@
}
async connect() {
this._wheelNativeStarted = false;
const savedFontSize = this.fontSize ?? parseInt(localStorage.getItem('codeman-font-size'), 10);
this.terminal = new Terminal({
theme: { ...global.codemanCurrentXtermTheme() },
@@ -1051,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
@@ -1073,11 +1075,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
@@ -1090,19 +1092,24 @@
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.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;
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;
}
@@ -1180,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
@@ -1202,6 +1209,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);
@@ -1209,16 +1218,26 @@
}
// 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
// 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
// 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);
// 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);
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;
+147 -84
View File
@@ -121,17 +121,7 @@
: delta / 25; // DOM_DELTA_PIXEL (Chrome/WebKit, and every trackpad)
}
// 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;
@@ -147,6 +137,43 @@
return `\x1b[<${btn};${pos.col};${pos.row}M`.repeat(ticks);
}
// 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) {
if (!Number.isFinite(lines) || !lines) return { state: state || {}, ticks: 0 };
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) {
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);
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. 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) || [];
return reports.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));
@@ -388,9 +415,10 @@
PAGE_KEY_SCREEN_FRACTION,
PAGE_KEY_MAX_PER_BATCH,
wheelDeltaLines,
wheelDeltaWholeLines,
SGR_WHEEL_MAX_TICKS,
sgrWheelReports,
wheelTicksForGesture,
coalesceScrollBytes,
pageKeysForTravel,
pageKeysForGesture,
PAGE_KEY_GESTURE_GAP_MS,
@@ -1098,7 +1126,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);
@@ -1114,8 +1142,9 @@ Object.assign(CodemanApp.prototype, {
ev.preventDefault();
ev.stopPropagation();
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._wheelScrollLines(ev));
this._forwardScrollToApp(ev.clientX, ev.clientY, this._wheelScrollLinesFloat(ev));
return;
}
// Local scrolling accumulates FRACTIONAL lines: a macOS trackpad emits
@@ -1141,9 +1170,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.
@@ -1168,7 +1201,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);
@@ -1196,8 +1229,14 @@ 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, 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;
/**
* Can this gesture pan sideways? Only while the terminal is wider than
@@ -1242,6 +1281,7 @@ Object.assign(CodemanApp.prototype, {
touchStartY = touchLastY;
touchStartX = touchLastX;
panAxis = null;
this._terminalGestureAxis = null;
velocity = 0;
pixelAccum = 0;
isTouching = true;
@@ -1315,10 +1355,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 +1369,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') {
// 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
@@ -1347,7 +1399,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)) {
@@ -5786,41 +5838,6 @@ 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.
// 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);
},
/** 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
@@ -5830,6 +5847,24 @@ Object.assign(CodemanApp.prototype, {
return window.CodemanTerminalInput.wheelDeltaLines(ev, this.terminal?.rows);
},
// 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;
// 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 });
},
// `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.
@@ -5860,11 +5895,11 @@ Object.assign(CodemanApp.prototype, {
// same reports by hand, but only while the CLI is actually tracking the mouse. Not gated on a
// version; a stale-false flag falls through to the PageUp/PageDown fallback like claude's.
if (sessionMode === 'copilot') return session?.cliMouseTracking === true;
if (sessionMode !== 'claude') return false;
if (!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
if (sessionMode !== 'claude' && sessionMode !== 'codex') return false;
if (sessionMode === 'claude' && !this._cliVersionAtLeast(session?.cliVersion, '2.1.187')) return false;
// 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
@@ -5895,34 +5930,54 @@ 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) {
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
* 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, 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);
},
@@ -5942,9 +5997,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
@@ -5976,8 +6030,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.
@@ -6021,6 +6075,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.
@@ -6033,6 +6088,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,
@@ -6060,19 +6116,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})`
);
},
@@ -6083,7 +6144,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
+9 -9
View File
@@ -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)"
]
}
+8 -1
View File
@@ -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 } };
+87 -4
View File
@@ -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,49 @@ 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); 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) => {
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> => {
+298
View File
@@ -0,0 +1,298 @@
/**
* @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).
* 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 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`, `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. 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';
import { createRequire } from 'node:module';
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify';
import { ApiErrorCode, createErrorResponse, getErrorMessage, type ApiResponse } from '../../types.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';
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,
ConfigBackupCorruptError,
ConfigBackupNotFoundError,
ConfigBackupPartialRestoreError,
defaultConfigBackupDir,
diffConfigBackups,
listConfigBackups,
readConfigBackupSettings,
resolveConfigBackupDir,
restoreConfigBackup,
runConfigBackupPass,
withRecordedRetention,
readConfigBackupOrigin,
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.
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; `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;
dir: string;
defaultDir: string;
tracked: readonly string[];
backups: ConfigBackupSummary[];
}
/**
* 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.
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). */
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;
}
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> {
// 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);
return runConfigBackupPass({
dataDir: getDataDir(),
backupDir: dir,
reason,
appVersion: APP_VERSION,
force: opts.force,
settings: fromFile,
raw: unreadable ? {} : raw,
// An unreadable settings.json still gets its snapshot, but no pass deletes anything on its say-so.
prune: !unreadable,
});
}
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;
let eff: Awaited<ReturnType<typeof readEffectiveBackupSettings>>;
try {
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: {
settings,
dir,
defaultDir: defaultConfigBackupDir(getDataDir()),
tracked: CONFIG_BACKUP_FILES,
backups: present && origin ? listConfigBackups(dir, origin) : [],
},
};
});
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(err instanceof BackupDirUnavailableError ? 503 : 500);
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Backup failed: ${getErrorMessage(err)}`);
}
});
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(err instanceof BackupDirUnavailableError ? 503 : 500);
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Diff failed: ${getErrorMessage(err)}`);
}
});
/**
* 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;
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,
untouched: out.untouched,
safetySnapshot: out.safetySnapshot,
restartRequired: afterRestore(req, id, out.restored),
},
};
} 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.`);
}
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)}`);
}
});
}
+131 -15
View File
@@ -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(),
};
+26 -5
View File
@@ -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,9 +184,21 @@ 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];
// 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>)
@@ -209,6 +222,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: () => {
@@ -219,11 +233,16 @@ 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,
timestamp: Date.now(),
@@ -259,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);
}
+1
View File
@@ -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 {
+2 -2
View File
@@ -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);
+2
View File
@@ -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,
+107 -18
View File
@@ -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';
@@ -55,6 +55,7 @@ import {
AutoClearSchema,
AutoCompactSchema,
AutoResumeSchema,
KeepWarmSchema,
PinSessionSchema,
ImageWatcherSchema,
FlickerFilterSchema,
@@ -76,7 +77,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 +108,7 @@ import {
ownerFor,
parseBody,
persistAndBroadcastSession,
readJsonConfig,
resolveAgentCaseOrigin,
resolveCasesDir,
resolveParentSessionId,
@@ -155,7 +157,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';
@@ -204,6 +214,42 @@ 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_-]+$/;
/** `<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));
}
/**
* 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, 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> {
// 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);
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/;
@@ -1024,7 +1070,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 {
@@ -1412,9 +1458,14 @@ 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);
// 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 } : {}),
};
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 +1483,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 ==========
@@ -1526,6 +1581,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
@@ -1536,12 +1592,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 } : {}),
};
}
@@ -3556,6 +3613,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) => {
@@ -4290,6 +4380,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,
@@ -4299,13 +4390,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 } : {}),
};
}
@@ -4461,7 +4553,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 +4645,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
@@ -4684,9 +4776,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.
*
+10 -6
View File
@@ -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
+19 -2
View File
@@ -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,15 @@ 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 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.
// 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();
}
// Handle ultracode/workflow run watcher toggle dynamically (default OFF).
// Either the docked panel OR the floating windows keep the watcher running.
+1
View File
@@ -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
+33 -2
View File
@@ -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
@@ -1521,6 +1525,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(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(),
/**
* Read My Mind predictor model override. Empty/absent = the AI-checker
* default (opus: prediction quality is the product and it runs only on an
@@ -1871,6 +1887,9 @@ 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(),
@@ -1905,7 +1924,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 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. */
@@ -1978,10 +1997,20 @@ 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
// its open-tab order (max sessions is 50) — 500 leaves ample headroom while
// 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),
});
@@ -2266,6 +2295,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(),
});
+130 -4
View File
@@ -96,7 +96,13 @@ 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,
readKeepWarmSettingsOrThrow,
readPlanUsageTelemetryEnabled,
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 +119,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, plainFrame } from '../session-submit-verifier.js';
import { detectUsageLimitPause } from '../usage-limit-patterns.js';
import { stopDeepSeekWeb } from '../deepseek-web-server.js';
import {
wireRespawnListeners,
@@ -162,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,
@@ -208,6 +223,8 @@ import {
registerWebviewRoutes,
registerTabLayoutRoutes,
registerMcpSyncRoutes,
registerConfigBackupRoutes,
runConfigBackup,
registerWebhookRoutes,
registerDoctorRoutes,
registerCustomModelRoutes,
@@ -229,6 +246,12 @@ 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;
// 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
@@ -317,6 +340,50 @@ 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: readKeepWarmSettingsOrThrow,
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 -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(plainFrame(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
@@ -354,6 +421,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)
@@ -470,6 +539,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 +795,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, () => {
@@ -747,6 +817,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),
@@ -1179,6 +1250,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(),
@@ -1416,6 +1488,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 +1959,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 +1994,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),
@@ -2900,6 +2977,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).
@@ -3067,6 +3172,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) {
@@ -3074,6 +3191,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
@@ -3611,6 +3732,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 +4188,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.
+15 -3
View File
@@ -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;
}
/**
@@ -234,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
@@ -244,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
@@ -260,8 +271,9 @@ 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);
if (tracker) {
tracker.recordIdle();
+5 -2
View File
@@ -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,
+27 -6
View File
@@ -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]);