Merge master into PR #153 (unified Session Manager)

Resolve the 4 conflicted files toward master's merged #146 work while
keeping PR #153's genuinely-new additions:

- app.js: keep the full Escape chain (closeSessionManager +
  closeCommandPalette + closeShortcutOverlay).
- index.html: keep master's Command Palette modal markup alongside the
  PR's Session Manager modal + header button.
- styles.css: keep master's Command Palette + COD-157 shortcut CSS AND
  the PR's COD-130 session-row kebab-menu CSS (both inserted at the same
  spot — reunited each with its own closing brace).
- terminal-ui.js: resolve _buildHistoryItem's main-row click handler to
  master's options.onActivate contract with a liveness + claudeSessionId
  -aware resume default, preserving the PR's two-shape/badges/kebab body.
- panels-ui.js: the PR's pre-#146 Session Manager block auto-merged as a
  duplicate AFTER master's fixed block (last-key-wins regression) — drop
  it, keep master's implementation plus the PR's new
  _onSessionListMaybeChanged.

Backend projectKey plumbing and the SSE live-refresh listeners in app.js
merge additively and are kept as-is.
This commit is contained in:
Codeman maintainer
2026-07-13 00:34:43 +02:00
87 changed files with 13392 additions and 661 deletions
+82
View File
@@ -0,0 +1,82 @@
/**
* @fileoverview Main-thread wrapper for HEIC/HEIF → JPEG conversion.
*
* The actual decode/encode (`heic-jpeg-worker.ts`) is CPU-synchronous WASM + JS,
* so it runs in a dedicated `worker_threads` Worker per conversion — never on
* the event loop that serves every session's SSE/PTY/WS traffic. On top of
* the worker isolation this wrapper enforces:
* - the global converter concurrency cap (`runWithConversionLimit`, shared
* with the pdftoppm/soffice document converters) so N simultaneous uploads
* can't pin N cores / N × 256MB decode buffers at once;
* - a hard timeout that terminates the worker (a wedged WASM decode can't be
* cancelled cooperatively);
* - the paste-image size cap on the *output* — jpeg-js is a far less
* efficient encoder than HEVC, so a within-limit HEIC can inflate past
* MAX_PASTE_IMAGE_BYTES.
*/
import { Worker } from 'node:worker_threads';
import { runWithConversionLimit } from '../document-conversion-limiter.js';
import { MAX_PASTE_IMAGE_BYTES } from '../config/buffer-limits.js';
import { HEIC_JPEG_QUALITY, type HeicWorkerInput, type HeicWorkerResult } from './heic-jpeg-worker.js';
/** Hard cap on a single conversion; the worker is terminated when it fires. */
export const HEIC_CONVERSION_TIMEOUT_MS = 30_000;
// V8-heap guardrails for the conversion worker — defense in depth only: large
// TypedArray/WASM backing stores are external to the V8 heap, so the real
// memory bound is the 64MP dimension pre-check in heic-jpeg-worker.ts.
const WORKER_RESOURCE_LIMITS = { maxOldGenerationSizeMb: 1024, maxYoungGenerationSizeMb: 128, stackSizeMb: 8 };
function workerUrl(): URL {
// Compiled installs run the tsc-emitted .js sibling in dist/; dev under tsx
// runs the .ts source directly (tsx's loader propagates to worker threads).
const file = import.meta.url.endsWith('.ts') ? './heic-jpeg-worker.ts' : './heic-jpeg-worker.js';
return new URL(file, import.meta.url);
}
/**
* Convert HEIC/HEIF bytes to JPEG bytes off-thread. Rejects on invalid input,
* over-limit dimensions, oversized output, timeout, or worker failure.
*/
export async function convertHeicToJpeg(imageBytes: Buffer): Promise<Buffer> {
return runWithConversionLimit(
() =>
new Promise<Buffer>((resolve, reject) => {
const worker = new Worker(workerUrl(), {
workerData: { heicInput: imageBytes, quality: HEIC_JPEG_QUALITY } satisfies HeicWorkerInput,
resourceLimits: WORKER_RESOURCE_LIMITS,
});
let settled = false;
const settle = (fn: () => void): void => {
if (settled) return;
settled = true;
clearTimeout(timer);
fn();
void worker.terminate();
};
const timer = setTimeout(() => {
settle(() => reject(new Error(`HEIC conversion timed out after ${HEIC_CONVERSION_TIMEOUT_MS}ms`)));
}, HEIC_CONVERSION_TIMEOUT_MS);
worker.on('message', (msg: HeicWorkerResult) => {
settle(() => {
if (!msg.ok) {
reject(new Error(msg.error));
return;
}
const out = Buffer.from(msg.data.buffer, msg.data.byteOffset, msg.data.byteLength);
if (out.length > MAX_PASTE_IMAGE_BYTES) {
const maxMb = Math.round(MAX_PASTE_IMAGE_BYTES / (1024 * 1024));
reject(new Error(`converted JPEG (${out.length} bytes) exceeds the ${maxMb}MB upload limit`));
return;
}
resolve(out);
});
});
worker.on('error', (err) => settle(() => reject(err)));
worker.on('exit', (code) => {
settle(() => reject(new Error(`HEIC conversion worker exited unexpectedly (code ${code})`)));
});
})
);
}
+93
View File
@@ -0,0 +1,93 @@
/**
* @fileoverview HEIC/HEIF → JPEG conversion core + worker-thread entry.
*
* Spawned per conversion by `heic-jpeg-converter.ts` so the CPU-synchronous
* libheif WASM decode + jpeg-js encode never run on the server's main thread
* (on the event loop they would freeze every session's SSE/PTY/WS handling
* for seconds per photo). Input arrives via `workerData`; the result (or
* error message) is posted back as a single message and the thread exits.
*
* The conversion logic lives in this same file (exported, guarded bootstrap)
* rather than a sibling module: the worker runs from `.ts` source under tsx
* in dev, where relative `.js` imports don't resolve inside worker threads —
* only `node:` builtins are imported at top level. Unit tests import
* `convertHeicBufferToJpeg` directly; the bootstrap only runs when spawned
* with our `workerData` shape.
*
* Decompression-bomb guard: heic-decode's `.all` path exposes the
* header-declared {width, height} per image WITHOUT decoding pixels, while
* its plain decode path allocates `width * height * 4` bytes straight from
* those header values — a <1KB crafted file declaring 30000×30000 would
* demand a 3.6GB allocation. We reject anything above MAX_HEIC_DECODE_PIXELS
* before calling `decode()`.
*/
import { parentPort, workerData } from 'node:worker_threads';
/** Max header-declared pixel count we will decode (64MP ≈ 256MB RGBA). */
export const MAX_HEIC_DECODE_PIXELS = 64_000_000;
/** JPEG quality used for converted HEIC uploads (matches heic-convert's default). */
export const HEIC_JPEG_QUALITY = 0.92;
export interface HeicWorkerInput {
heicInput: Uint8Array;
quality: number;
}
export type HeicWorkerResult = { ok: true; data: Uint8Array } | { ok: false; error: string };
/**
* Convert HEIC/HEIF bytes to JPEG bytes. Throws on non-HEIC input, empty
* containers, over-limit dimensions, and non-JPEG encoder output.
*/
export async function convertHeicBufferToJpeg(input: Uint8Array, quality: number = HEIC_JPEG_QUALITY): Promise<Buffer> {
const { default: decode } = await import('heic-decode');
const buffer = Buffer.isBuffer(input) ? input : Buffer.from(input.buffer, input.byteOffset, input.byteLength);
const images = await decode.all({ buffer });
try {
if (images.length === 0) throw new Error('no image found in HEIC container');
const { width, height } = images[0];
if (
!Number.isSafeInteger(width) ||
!Number.isSafeInteger(height) ||
width <= 0 ||
height <= 0 ||
width * height > MAX_HEIC_DECODE_PIXELS
) {
throw new Error(
`HEIC dimensions ${width}x${height} exceed the ${Math.floor(MAX_HEIC_DECODE_PIXELS / 1_000_000)}MP decode limit`
);
}
const decoded = await images[0].decode();
const { encode } = await import('jpeg-js');
// Same output path as heic-convert's JPEG format (jpeg-js at quality*100).
const jpeg = encode(
{ data: decoded.data, width: decoded.width, height: decoded.height },
Math.floor(quality * 100)
).data;
const jpegBytes = Buffer.isBuffer(jpeg) ? jpeg : Buffer.from(jpeg);
if (jpegBytes.length < 3 || jpegBytes[0] !== 0xff || jpegBytes[1] !== 0xd8 || jpegBytes[2] !== 0xff) {
throw new Error('HEIC conversion did not produce JPEG bytes');
}
return jpegBytes;
} finally {
images.dispose();
}
}
// ── Worker bootstrap ──────────────────────────────────────────────────────
// Runs only when spawned by heic-jpeg-converter.ts: requires a parent port
// AND our exact workerData shape, so importing this module from the main
// thread (or a test runner's own worker pool) stays inert.
const request = workerData as HeicWorkerInput | null | undefined;
if (parentPort && request && request.heicInput instanceof Uint8Array && typeof request.quality === 'number') {
const port = parentPort;
try {
const jpegBytes = await convertHeicBufferToJpeg(request.heicInput, request.quality);
port.postMessage({ ok: true, data: jpegBytes } satisfies HeicWorkerResult);
} catch (err: unknown) {
const error = err instanceof Error ? err.message : String(err);
port.postMessage({ ok: false, error } satisfies HeicWorkerResult);
}
}
+10
View File
@@ -0,0 +1,10 @@
/**
* @fileoverview Cron port — exposes the CronService to
* route handlers via the shared route context.
*/
import type { CronService } from '../../cron/cron-service.js';
export interface CronPort {
readonly cron: CronService;
}
+1
View File
@@ -13,3 +13,4 @@ export type { ConfigPort } from './config-port.js';
export type { InfraPort, ScheduledRun } from './infra-port.js';
export type { AuthPort } from './auth-port.js';
export type { OrchestratorPort } from './orchestrator-port.js';
export type { CronPort } from './cron-port.js';
+531 -82
View File
@@ -174,6 +174,7 @@ const _SSE_HANDLER_MAP = [
[SSE_EVENTS.SESSION_LIMIT_PAUSE_SCHEDULED, '_onSessionLimitPauseScheduled'],
[SSE_EVENTS.SESSION_LIMIT_RESUME, '_onSessionLimitResume'],
[SSE_EVENTS.SESSION_LIMIT_RESUME_CANCELLED, '_onSessionLimitResumeCancelled'],
[SSE_EVENTS.SESSION_RESPAWN_BREAKER_TRIPPED, '_onSessionRespawnBreakerTripped'],
[SSE_EVENTS.SESSION_CLI_INFO, '_onSessionCliInfo'],
[SSE_EVENTS.SESSION_STATUS_TELEMETRY, '_onSessionStatusTelemetry'],
@@ -183,6 +184,12 @@ const _SSE_HANDLER_MAP = [
[SSE_EVENTS.SCHEDULED_COMPLETED, '_onScheduledCompleted'],
[SSE_EVENTS.SCHEDULED_STOPPED, '_onScheduledStopped'],
// Scheduled jobs (cron-style scheduler)
[SSE_EVENTS.CRON_JOBS_CHANGED, '_onCronJobsChanged'],
[SSE_EVENTS.CRON_JOB_DELETED, '_onCronJobsChanged'],
[SSE_EVENTS.CRON_RUN_CREATED, '_onCronRunChanged'],
[SSE_EVENTS.CRON_RUN_UPDATED, '_onCronRunChanged'],
// Respawn
[SSE_EVENTS.RESPAWN_STARTED, '_onRespawnStarted'],
[SSE_EVENTS.RESPAWN_STOPPED, '_onRespawnStopped'],
@@ -299,6 +306,134 @@ function parseSessionPrefix(name) {
return null;
}
const DEFAULT_SHORTCUTS = [
{
id: 'show-shortcuts',
group: 'Panels',
label: 'Show Shortcuts',
bindings: [
{ modifiers: ['ctrl'], key: '?', code: 'Slash' },
{ modifiers: ['ctrl', 'shift'], key: '?' },
{ modifiers: ['alt'], key: '?', code: 'Slash' },
],
action: 'showShortcutOverlay',
},
{
id: 'close-session',
group: 'Session',
label: 'Close Session',
bindings: [{ modifiers: ['ctrl'], key: 'w' }],
action: 'killActiveSession',
},
{
id: 'next-session',
group: 'Session',
label: 'Next Session',
bindings: [{ modifiers: ['ctrl'], key: 'Tab' }],
action: 'nextSession',
},
{
id: 'clear-terminal',
group: 'Terminal',
label: 'Clear Terminal',
bindings: [{ modifiers: ['ctrl'], key: 'l' }],
action: 'clearTerminal',
},
{
id: 'increase-font',
group: 'Terminal',
label: 'Increase Font',
bindings: [
{ modifiers: ['ctrl'], key: '=', code: 'Equal' },
{ modifiers: ['ctrl'], key: '+', code: 'Equal' },
],
action: 'increaseFontSize',
},
{
id: 'decrease-font',
group: 'Terminal',
label: 'Decrease Font',
bindings: [{ modifiers: ['ctrl'], key: '-', code: 'Minus' }],
action: 'decreaseFontSize',
},
{
id: 'voice-input',
group: 'Terminal',
label: 'Voice Input',
bindings: [{ modifiers: ['ctrl', 'shift'], key: 'V' }],
action: 'toggleVoiceInput',
},
{
id: 'restore-terminal-size',
group: 'Terminal',
label: 'Restore Terminal Size',
bindings: [{ modifiers: ['ctrl', 'shift'], key: 'R' }],
action: 'restoreTerminalSize',
},
{
id: 'move-tab-left',
group: 'Tabs',
label: 'Move Active Tab Left',
bindings: [{ modifiers: ['ctrl', 'shift'], key: '{', code: 'BracketLeft' }],
action: 'moveActiveTabLeft',
},
{
id: 'move-tab-right',
group: 'Tabs',
label: 'Move Active Tab Right',
bindings: [{ modifiers: ['ctrl', 'shift'], key: '}', code: 'BracketRight' }],
action: 'moveActiveTabRight',
},
{
id: 'command-palette',
group: 'Session',
label: 'Find Open Session',
bindings: [
{ modifiers: ['ctrl'], key: 'k', code: 'KeyK' },
{ modifiers: ['meta'], key: 'k', code: 'KeyK' },
{ modifiers: ['alt'], key: 'k', code: 'KeyK' },
],
action: 'openCommandPalette',
},
{
id: 'previous-next-session',
group: 'Session',
label: 'Previous / Next Session',
displayBindings: ['Alt/Option+[', 'Alt/Option+]'],
},
{
id: 'switch-tab-n',
group: 'Session',
label: 'Switch to Tab N',
displayBindings: ['Alt/Option+1-9'],
},
{
id: 'focus-tabs',
group: 'Tabs',
label: 'Focus Tabs',
displayBindings: ['ArrowLeft', 'ArrowRight', 'Home', 'End'],
},
{
id: 'activate-focused-tab',
group: 'Tabs',
label: 'Activate Focused Tab',
displayBindings: ['Enter', 'Space'],
},
{
id: 'insert-newline',
group: 'Terminal',
label: 'Insert Newline',
displayBindings: ['Shift+Enter', 'Ctrl+Enter'],
},
{
id: 'close-panels',
group: 'Panels',
label: 'Close Panels',
displayBindings: ['Escape'],
},
];
// ═══════════════════════════════════════════════════════════════
// CodemanApp Class — constructor and global state
// ═══════════════════════════════════════════════════════════════
@@ -320,6 +455,14 @@ class CodemanApp {
this._clientId = (typeof crypto !== 'undefined' && crypto.randomUUID)
? crypto.randomUUID()
: 'c-' + Math.random().toString(36).slice(2) + Date.now().toString(36);
// Per-TAB nonce for the WS registry key (COD-137). _loadReliableState()
// later replaces _clientId with the browser-wide localStorage identity
// (shared by every tab/window of this profile), so the WS upgrade sends
// `clientId:nonce` instead — a same-tab reconnect still supersedes its own
// socket, but two tabs on one session coexist instead of evicting each
// other in a 4010 ping-pong. Input frames keep the bare clientId for seq
// dedup.
this._wsTabNonce = this._clientId;
this.terminal = null;
this.fitAddon = null;
this.activeSessionId = null;
@@ -342,6 +485,7 @@ class CodemanApp {
this._initGeneration = 0; // dedup concurrent handleInit calls
this._initFallbackTimer = null; // fallback timer if SSE init doesn't arrive
this._selectGeneration = 0; // cancel stale selectSession loads
this._initialFullBufferLoad = true; // first buffer load after a page load fetches full tmux scrollback (COD-47)
this.terminalLoadStates = new Map(); // Map<sessionId, { generation, phase }>
this.respawnStatus = {};
this.respawnTimers = {}; // Track timed respawn timers
@@ -453,6 +597,8 @@ class CodemanApp {
this._ws = null; // WebSocket instance for active session
this._wsSessionId = null; // Session ID the WS is connected to
this._wsReady = false; // True when WS is open and ready for I/O
this._wsState = 'disconnected'; // connecting | connected | reconnecting | fallback | disconnected
this._wsLastRecvAt = 0; // ms timestamp of the last frame received on the active WS
// Terminal write batching with DEC 2026 sync support
this.pendingWrites = [];
@@ -496,6 +642,9 @@ class CodemanApp {
this._clientId = '';
this._seqCounters = new Map(); // sessionId -> last issued seq
this._pendingDeliveries = new Map(); // sessionId -> [{seq,data,useMux,ts,tries,sentAt}]
// Last rendered connection-indicator tuple; the hot input path skips DOM
// writes when the freshly computed descriptor is identical (COD-136).
this._lastIndicatorDescriptor = null;
this._postDraining = new Set(); // sessionIds with an in-flight POST drainer
this._persistReliableTimer = null;
this._reliableAckTimeoutMs = 4000; // unacked WS frame older than this ⇒ socket likely dead
@@ -801,33 +950,43 @@ class CodemanApp {
// ═══════════════════════════════════════════════════════════════
setupEventListeners() {
// Keyboard shortcut lookup table — data-driven to avoid 12 separate if-blocks.
// Each entry: { key, altKey? (alternative key match), ctrl? (require Ctrl/Cmd),
// shift? (require Shift), action }.
const SHORTCUTS = [
{ key: '?', altKey: '/', ctrl: true, action: () => this.showHelp() },
{ key: 'w', ctrl: true, action: () => this.killActiveSession() },
{ key: 'Tab', ctrl: true, action: () => this.nextSession() },
{ key: 'l', ctrl: true, action: () => this.clearTerminal() },
{ key: 'R', ctrl: true, shift: true, action: () => this.restoreTerminalSize() },
{ key: '=', altKey: '+', ctrl: true, action: () => this.increaseFontSize() },
{ key: '-', ctrl: true, action: () => this.decreaseFontSize() },
{ key: 'V', ctrl: true, shift: true, action: () => VoiceInput.toggle() },
{ key: '{', ctrl: true, shift: true, action: () => this.moveActiveTabLeft() },
{ key: '}', ctrl: true, shift: true, action: () => this.moveActiveTabRight() },
];
// Action name → handler map for the shortcut registry (DEFAULT_SHORTCUTS +
// user overrides from settings.shortcutOverrides, merged by
// getShortcutRegistry()). The command palette chord is deliberately NOT in
// this map — shouldOpenCommandPaletteFromShortcut() dispatches it above with
// focus-target awareness (it must fire from the terminal but not from inputs).
const SHORTCUT_ACTIONS = {
showShortcutOverlay: () => this.showShortcutOverlay(),
killActiveSession: () => this.killActiveSession(),
nextSession: () => this.nextSession(),
clearTerminal: () => this.clearTerminal(),
restoreTerminalSize: () => this.restoreTerminalSize(),
increaseFontSize: () => this.increaseFontSize(),
decreaseFontSize: () => this.decreaseFontSize(),
toggleVoiceInput: () => VoiceInput.toggle(),
moveActiveTabLeft: () => this.moveActiveTabLeft(),
moveActiveTabRight: () => this.moveActiveTabRight(),
};
// Use capture to handle before terminal
document.addEventListener('keydown', (e) => {
// Don't intercept keys during CJK IME composition
if (e.isComposing || e.keyCode === 229) return;
if (this.shouldOpenCommandPaletteFromShortcut?.(e)) {
e.preventDefault();
this.openCommandPalette();
return;
}
// Escape - close panels and modals (different logic: no preventDefault, no return)
if (e.key === 'Escape') {
this.closeAllPanels();
this.closeHelp();
if (this.attachmentHistoryDrawerOpen) this.closeAttachmentHistory();
this.closeSessionManager();
this.closeCommandPalette?.();
this.closeShortcutOverlay?.();
}
// Option/Alt session navigation uses physical key CODES, not e.key, so macOS
@@ -857,14 +1016,18 @@ class CodemanApp {
}
}
// Match against shortcut table
for (const s of SHORTCUTS) {
const keyMatch = e.key === s.key || (s.altKey && e.key === s.altKey);
const ctrlMatch = s.ctrl ? (e.ctrlKey || e.metaKey) : true;
const shiftMatch = s.shift ? e.shiftKey : !e.shiftKey;
if (keyMatch && ctrlMatch && shiftMatch) {
// Match against the shortcut registry so user rebinds and per-shortcut
// disables (App Settings → Shortcuts) take effect. Every dispatchable
// binding requires Ctrl/Cmd/Alt (capture enforces the same), so plain
// typing exits early without touching the registry.
if (!e.ctrlKey && !e.metaKey && !e.altKey) return;
for (const shortcut of this.getShortcutRegistry()) {
if (shortcut.disabled || !shortcut.action) continue;
const action = SHORTCUT_ACTIONS[shortcut.action];
if (!action) continue;
if (this.matchesShortcutEvent(e, shortcut)) {
e.preventDefault();
s.action();
action();
return;
}
}
@@ -1640,8 +1803,13 @@ class CodemanApp {
const data = (await res.json())?.data ?? {};
let lastResponse = data.text || '';
// Source 2: Terminal buffer fallback — strip ANSI, drop Claude CLI chrome
if (!lastResponse) {
// Source 2: Terminal buffer fallback — strip ANSI, drop Claude CLI chrome.
// Claude + shell only: _cleanTerminalBuffer knows Claude CLI's output, and
// shell sessions have no transcript source at all; for TUI modes
// (codex/opencode/gemini) it yields repaint garbage, so a clear
// placeholder beats a messy screen dump there.
const sessionMode = this.sessions.get(this.activeSessionId)?.mode || 'claude';
if (!lastResponse && (sessionMode === 'claude' || sessionMode === 'shell')) {
const termRes = await fetch(`/api/sessions/${this.activeSessionId}/terminal`);
const termData = (await termRes.json())?.data ?? {};
if (termData.terminalBuffer) {
@@ -1650,8 +1818,12 @@ class CodemanApp {
}
const body = document.getElementById('responseViewerBody');
body.innerHTML = this._renderMarkdown(lastResponse);
this._bindResponseViewerInteractions(body);
if (lastResponse) {
body.innerHTML = this._renderMarkdown(lastResponse);
this._bindResponseViewerInteractions(body);
} else {
body.textContent = 'No response yet — send a message in this session first.';
}
// Reset state for fresh open
const title = document.getElementById('responseViewerTitle');
@@ -1685,6 +1857,9 @@ class CodemanApp {
}
// Render conversation thread
const mode = this.sessions.get(this.activeSessionId)?.mode;
const agentLabel =
mode === 'codex' ? 'Codex' : mode === 'gemini' ? 'Gemini' : mode === 'opencode' ? 'OpenCode' : 'Claude';
body.innerHTML = '';
for (const msg of messages) {
const div = document.createElement('div');
@@ -1693,7 +1868,7 @@ class CodemanApp {
const role = document.createElement('div');
role.className = 'rv-role ' + (isUser ? 'rv-role-user' : 'rv-role-assistant');
role.textContent = isUser ? 'You' : 'Claude';
role.textContent = isUser ? 'You' : agentLabel;
div.appendChild(role);
const text = document.createElement('div');
@@ -1880,6 +2055,15 @@ class CodemanApp {
this.updateAutoResumeStatus(data.sessionId);
}
// COD-118: the interactive PTY exit circuit breaker tripped (repeated non-zero exits).
// The errored status itself arrives via session:updated; this just surfaces a toast for
// diagnostic clarity so a silently-looping session is obvious. Restart clears the breaker.
_onSessionRespawnBreakerTripped(data) {
const session = this.sessions.get(data.sessionId);
const label = session?.name || 'Session';
this.showToast?.(`${label} stopped: repeated crashes detected. Restart to retry.`, 'error');
}
_onSessionCliInfo(data) {
const session = this.sessions.get(data.sessionId);
if (session) {
@@ -1992,9 +2176,21 @@ class CodemanApp {
*/
_connectWs(sessionId) {
this._disconnectWs();
this._wsState = 'connecting';
this._updateConnectionIndicator();
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const url = `${proto}//${location.host}/ws/sessions/${sessionId}/terminal`;
// Pass a per-TAB identity on the upgrade URL so the server's connection
// registry scopes the per-session limit by connection (COD-137): a same-tab
// reconnect supersedes its own socket instead of consuming a new slot and
// tripping a spurious 4008, while two tabs of the same browser (which share
// the localStorage clientId) each keep their own socket. The bare clientId
// still rides the input frames for seq dedup. Omitted if clientId is
// unavailable (server then treats the upgrade as anonymous — still admitted
// up to the limit).
const cid = this._clientId ? `${this._clientId}:${this._wsTabNonce}` : '';
const cidQuery = cid ? `?cid=${encodeURIComponent(cid)}` : '';
const url = `${proto}//${location.host}/ws/sessions/${sessionId}/terminal${cidQuery}`;
const ws = new WebSocket(url);
this._ws = ws;
this._wsSessionId = sessionId;
@@ -2003,7 +2199,9 @@ class CodemanApp {
// Only mark ready if this is still the intended session
if (this._ws === ws) {
this._wsReady = true;
this._wsState = 'connected';
this._wsReconnectAttempts = 0;
this._updateConnectionIndicator();
// Send a typed resize over the fresh socket: syncs PTY dims after
// (re)connects AND registers the desktop sizing claim server-side —
// selectSession's earlier resizes ran before this WS existed, so they
@@ -2018,6 +2216,9 @@ class CodemanApp {
ws.onmessage = (event) => {
if (this._ws !== ws) return;
// Mark the socket as alive on every received frame (output, ACK, etc.) so
// the redeliver sweep only force-closes a genuinely silent connection.
this._wsLastRecvAt = Date.now();
try {
const msg = JSON.parse(event.data);
if (msg.t === 'o') {
@@ -2044,18 +2245,53 @@ class CodemanApp {
this._wsReady = false;
this._stopMobileResizeRetry();
// Reconnect on unexpected close (server restart, network blip, ping timeout).
// Don't reconnect if we intentionally disconnected (_disconnectWs nulls onclose)
// or if the server rejected the session (4004=not found, 4008=too many, 4009=terminated).
if (event.code < 4004 && this.activeSessionId === sessionId) {
const delay = Math.min(1000 * Math.pow(2, this._wsReconnectAttempts || 0), 10000);
this._wsReconnectAttempts = (this._wsReconnectAttempts || 0) + 1;
this._wsReconnectTimer = setTimeout(() => {
this._wsReconnectTimer = null;
if (this.activeSessionId === sessionId) {
this._connectWs(sessionId);
}
}, delay);
// Decide what to do next from the close code + how many consecutive
// reconnects we've already made (pure policy in constants.js):
// reconnect → transient (server restart, network blip, ping timeout);
// schedule a backoff retry while this session stays active.
// retry-fallback → too-many-connections / unknown rejection; show the HTTP
// fallback but keep retrying so we return to WS when it clears.
// give-up → 4004 (not found) / 4009 (terminated); the session is gone.
// _disconnectWs() nulls onclose for intentional disconnects, so we never land here for those.
const plan = window.CodemanWsReconnect.plan(event.code, this._wsReconnectAttempts || 0);
_crashDiag.log(
`WS CLOSE code=${event.code} reason=${event.reason || ''} action=${plan.action} attempts=${this._wsReconnectAttempts || 0}`
);
const stillActive = this.activeSessionId === sessionId;
if (plan.action === 'give-up') {
this._wsState = stillActive ? 'fallback' : 'disconnected';
this._updateConnectionIndicator();
} else if (plan.action === 'reconnect') {
if (stillActive) {
this._wsState = 'reconnecting';
this._updateConnectionIndicator();
const delay = plan.delayMs + Math.floor(Math.random() * 250); // jitter to de-sync herds
this._wsReconnectAttempts = (this._wsReconnectAttempts || 0) + 1;
this._wsReconnectTimer = setTimeout(() => {
this._wsReconnectTimer = null;
if (this.activeSessionId === sessionId) {
this._connectWs(sessionId);
}
}, delay);
} else {
this._wsState = 'disconnected';
this._updateConnectionIndicator();
}
} else {
// retry-fallback: surface the HTTP fallback, but keep trying on a bounded
// timer so the transport returns to WS once the transient condition clears.
this._wsState = stillActive ? 'fallback' : 'disconnected';
this._updateConnectionIndicator();
if (stillActive) {
this._wsReconnectAttempts = (this._wsReconnectAttempts || 0) + 1;
this._wsReconnectTimer = setTimeout(() => {
this._wsReconnectTimer = null;
if (this.activeSessionId === sessionId) {
this._connectWs(sessionId);
}
}, plan.delayMs);
}
}
};
@@ -2067,7 +2303,11 @@ class CodemanApp {
/** Close the active WebSocket connection (if any). */
_disconnectWs() {
this._clearTimer('_wsReconnectTimer');
this._wsReconnectAttempts = 0;
// Deliberately do NOT reset _wsReconnectAttempts here: _connectWs() calls
// this first, so a reset would restart the exponential backoff ladder at
// attempt 0 on every retry (≈0ms tight reconnect loop during an outage).
// ws.onopen zeroes the counter once a connection actually succeeds.
this._wsState = 'disconnected';
this._stopMobileResizeRetry();
if (this._ws) {
this._ws.onclose = null; // Prevent re-entrant cleanup
@@ -2262,7 +2502,13 @@ class CodemanApp {
this._ws && this._ws.readyState === WebSocket.OPEN && this._wsSessionId === sessionId;
if (isActiveWs) {
const oldest = list[0];
if (oldest && oldest.sentAt && Date.now() - oldest.sentAt > this._reliableAckTimeoutMs) {
// Only tear the socket down when the oldest unacked frame is stale AND the
// socket has been silent for the timeout: a connection still delivering
// output/ACKs is alive (the ACK is just behind), so force-closing it would
// cause needless WS↔HTTP flapping. A truly half-open socket goes quiet.
const stale = oldest && oldest.sentAt && Date.now() - oldest.sentAt > this._reliableAckTimeoutMs;
const silent = Date.now() - this._wsLastRecvAt > this._reliableAckTimeoutMs;
if (stale && silent) {
try {
this._ws.close(); // half-open: never recovers on its own — force reconnect
} catch {
@@ -2270,6 +2516,18 @@ class CodemanApp {
}
continue;
}
if (stale) {
// Stale but the socket is still delivering output: the ACK was lost,
// not the connection. Force-closing isn't warranted (the link is fine),
// but the fast path skips anything with sentAt!==0, so the stranded
// frame would never re-send. Reset sentAt=0 on every stale unacked
// frame so the _drainSession below re-drives them over the live socket
// (server dedups by seq, so a re-sent lost-ACK frame is harmless).
// Frames sent recently (not yet stale) are left untouched.
for (const rec of list) {
if (rec.sentAt && Date.now() - rec.sentAt > this._reliableAckTimeoutMs) rec.sentAt = 0;
}
}
}
this._drainSession(sessionId);
}
@@ -2380,39 +2638,107 @@ class CodemanApp {
}
}
// Pure render of the header connection indicator: reads only `this.*` state,
// touches NO DOM. Returns the exact { display, dotClass, text, title } tuple the
// writer applies. When hidden (display:'none') the other three are normalized to
// '' so the cache compare in _updateConnectionIndicator() is well-defined.
// Every branch/string here must stay byte-identical to what's rendered today.
_computeConnectionDescriptor() {
const { bytes: totalBytes, count } = this._pendingBytes();
const hasQueue = count > 0;
// Only surface a backlog once it's more than a few bytes. A single keystroke
// (1B) ACKs in milliseconds, so without this the label flickered "sending 1B"
// on every key press. Above this threshold means input is genuinely backing up.
const BACKLOG_HINT_BYTES = 4;
const showBacklog = totalBytes > BACKLOG_HINT_BYTES;
const formatBytes = (b) => (b < 1024 ? `${b}B` : `${(b / 1024).toFixed(1)}KB`);
const queuedSuffix = showBacklog ? ` · ${formatBytes(totalBytes)} queued` : '';
// Hard offline (browser reports no network) dominates everything.
if (!this.isOnline || this._connectionStatus === 'offline') {
return {
display: 'flex',
dotClass: 'connection-dot offline',
text: showBacklog ? `Offline (${formatBytes(totalBytes)} queued)` : 'Offline',
title: 'No network connection',
};
}
// With an active terminal, show its transport (WebSocket vs HTTP fallback).
if (this.activeSessionId) {
let cls, label, detail;
switch (this._wsState) {
case 'connected':
cls = 'connected'; label = 'WS'; detail = 'Terminal connected over WebSocket';
break;
case 'fallback':
cls = 'fallback'; label = 'HTTP'; detail = 'WebSocket unavailable — input sent over HTTP';
break;
case 'reconnecting':
cls = 'reconnecting'; label = 'WS…'; detail = 'Reconnecting WebSocket';
break;
case 'connecting':
default:
cls = 'reconnecting'; label = 'WS…'; detail = 'Connecting WebSocket';
break;
}
return {
display: 'flex',
dotClass: `connection-dot ${cls}`,
text: `${label}${queuedSuffix}`,
title: detail,
};
}
// No active terminal — reflect the SSE event stream only when it needs attention.
if (this._connectionStatus === 'reconnecting' || this._connectionStatus === 'disconnected') {
return {
display: 'flex',
dotClass: 'connection-dot reconnecting',
text: showBacklog ? `Reconnecting (${formatBytes(totalBytes)} queued)` : 'Reconnecting...',
title: 'Reconnecting to server',
};
}
// Idle dashboard, healthy stream — hide unless input is genuinely queued.
if (!hasQueue) {
return { display: 'none', dotClass: '', text: '', title: '' };
}
return {
display: 'flex',
dotClass: 'connection-dot draining',
text: showBacklog ? `Sending ${formatBytes(totalBytes)}...` : 'Sending...',
title: 'Delivering queued input',
};
}
_updateConnectionIndicator() {
const indicator = this.$('connectionIndicator');
const dot = this.$('connectionDot');
const text = this.$('connectionText');
if (!indicator || !dot || !text) return;
const status = this._connectionStatus;
// While the connection is healthy, never surface the input queue. With the
// reliable-delivery layer every keystroke is briefly "pending" until its ACK
// lands a few ms later — showing that flashed "Sending 1B…" on every single
// character. The indicator is only meaningful for an actual connection
// problem (reconnecting / offline), where the queued byte count reassures
// the user their typing is safely buffered and will be sent.
if (status === 'connected' || status === 'connecting') {
indicator.style.display = 'none';
// Called on EVERY keystroke (_reliableSend) and EVERY ACK (_ackDelivery).
// During fast typing the rendered tuple is usually identical, so skip the DOM
// writes when nothing changed (COD-136) — the compute above is DOM-free.
const next = this._computeConnectionDescriptor();
const prev = this._lastIndicatorDescriptor;
if (
prev &&
prev.display === next.display &&
prev.dotClass === next.dotClass &&
prev.text === next.text &&
prev.title === next.title
) {
return;
}
this._lastIndicatorDescriptor = next;
const { bytes: totalBytes, count } = this._pendingBytes();
const hasQueue = count > 0;
indicator.style.display = 'flex';
dot.className = 'connection-dot';
const formatBytes = (b) => (b < 1024 ? `${b}B` : `${(b / 1024).toFixed(1)}KB`);
if (status === 'reconnecting') {
dot.classList.add('reconnecting');
text.textContent = hasQueue ? `Reconnecting (${formatBytes(totalBytes)} queued)` : 'Reconnecting...';
} else {
// Offline or disconnected
dot.classList.add('offline');
text.textContent = hasQueue ? `Offline (${formatBytes(totalBytes)} queued)` : 'Offline';
indicator.style.display = next.display;
if (next.display !== 'none') {
dot.className = next.dotClass;
text.textContent = next.text;
indicator.title = next.title;
}
}
@@ -3599,17 +3925,40 @@ class CodemanApp {
// Track working directory for path normalization in Project Insights
this.currentSessionWorkingDir = session?.workingDir || null;
if (session && session.pid === null) {
// Session has no PTY attached — either restored after server restart
// or detached for some other reason. Re-attach regardless of status.
try {
const endpoint = session.mode === 'shell'
? `/api/sessions/${sessionId}/shell`
: `/api/sessions/${sessionId}/interactive`;
await fetch(endpoint, { method: 'POST' });
// Update local session state
session.status = 'busy';
} catch (err) {
console.error('Failed to attach to restored session:', err);
if (session.respawnBlocked) {
// COD-118: the PTY-exit circuit breaker tripped for this session — the
// automatic re-attach must NOT silently clear it (that would re-arm the
// crash loop on every tab click / page load). Restart only on explicit
// user confirmation; the confirmed request carries clearBreaker:true.
const label = session.name || 'Session';
if (window.confirm(`${label} was stopped after crashing repeatedly. Restart it?`)) {
try {
await fetch(`/api/sessions/${sessionId}/interactive`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ clearBreaker: true }),
});
session.respawnBlocked = false;
session.status = 'busy';
} catch (err) {
console.error('Failed to restart crash-looped session:', err);
}
}
} else {
// Session has no PTY attached — either restored after server restart
// or detached for some other reason. Re-attach regardless of status.
// Deliberately NO body: this automatic path must never clear a tripped
// PTY-exit breaker (COD-118).
try {
const endpoint = session.mode === 'shell'
? `/api/sessions/${sessionId}/shell`
: `/api/sessions/${sessionId}/interactive`;
await fetch(endpoint, { method: 'POST' });
// Update local session state
session.status = 'busy';
} catch (err) {
console.error('Failed to attach to restored session:', err);
}
}
}
@@ -3628,6 +3977,9 @@ class CodemanApp {
// the buffer write, causing 70KB+ single-frame flushes that stall WebGL.
// chunkedTerminalWrite also sets this, but we need it before the fetch too.
const bufferLoadOwner = this._beginBufferLoad(selectGen);
// COD-144: track whether the load painted nothing (empty fetch + no cache).
// For that just-created-session case we flush (not discard) queued SSE events.
let bufferWasEmpty = false;
try {
// Fit terminal to container BEFORE writing any buffer data.
// If the browser was resized while viewing another session, the terminal
@@ -3739,7 +4091,16 @@ class CodemanApp {
this._setTerminalLoadState(sessionId, selectGen, 'fetching');
_crashDiag.log('FETCH_START');
const res = await fetch(`/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`);
// The FIRST buffer load after a page load requests the full tmux scrollback
// (?full=1, COD-47) so history that scrolled off the server's byte buffer
// comes back after a reload. Tab switches keep the fast ?tail= frame path.
const useFullHistory = this._initialFullBufferLoad === true;
this._initialFullBufferLoad = false;
const res = await fetch(
useFullHistory
? `/api/sessions/${sessionId}/terminal?full=1`
: `/api/sessions/${sessionId}/terminal?tail=${TERMINAL_TAIL_SIZE}`
);
if (this._isStaleSelect(selectGen)) {
this._clearTerminalLoadState(sessionId, selectGen);
return;
@@ -3784,13 +4145,16 @@ class CodemanApp {
} else if (!cachedBuffer) {
// No fresh buffer and no cache — clear any stale content
this._resetTerminalForReplay();
bufferWasEmpty = true;
}
// Buffer load complete — unblock live SSE writes (queued events are discarded
// to prevent duplicate content). chunkedTerminalWrite calls _finishBufferLoad
// internally, but if we skipped the write (cache hit or empty), call it here.
// Buffer load complete — unblock live SSE writes. chunkedTerminalWrite calls
// _finishBufferLoad internally (discarding queued events to prevent duplicate
// content); if we skipped the write (cache hit or empty), call it here.
// COD-144: when the load painted nothing, FLUSH the queued events instead of
// discarding — a new session's prompt arrives only as a queued SSE event.
if (this._isLoadingBuffer) {
this._finishBufferLoad(bufferLoadOwner);
this._finishBufferLoad(bufferLoadOwner, { flushQueued: bufferWasEmpty });
}
// Drop the guard so user input clears state normally
this._restoringFlushedState = false;
@@ -4256,6 +4620,91 @@ class CodemanApp {
}
}
// ─── Shortcut Registry ───────────────────────────────────────────────────────
// Returns the merged shortcut list: DEFAULT_SHORTCUTS with any per-shortcut
// overrides from settings.shortcutOverrides applied on top.
getShortcutRegistry() {
const settings = this.loadAppSettingsFromStorage();
const shortcutOverrides = settings.shortcutOverrides || {};
return DEFAULT_SHORTCUTS.map((shortcut) => {
const override = shortcutOverrides[shortcut.id];
if (!override) return shortcut;
// Only binding-shaped fields may come from storage — id/label/group/action
// stay trusted so persisted data can never redirect a shortcut's action or
// spoof another row in the settings/overlay renderers.
const merged = { ...shortcut };
if (Array.isArray(override.bindings)) {
merged.bindings = override.bindings;
delete merged.displayBindings; // show the override, not the stale default label
}
if (typeof override.disabled === 'boolean') merged.disabled = override.disabled;
return merged;
});
}
matchesShortcutEvent(e, shortcut) {
if (!shortcut || !Array.isArray(shortcut.bindings)) return false;
return shortcut.bindings.some((binding) => {
const mods = binding.modifiers || [];
// Ctrl and Cmd are interchangeable as the primary modifier (parity with
// the legacy shortcut table), but every OTHER pressed modifier must be
// declared by the binding — a plain Ctrl+K binding must not also swallow
// Ctrl+Shift+K (the Firefox devtools chord).
const wantsPrimary = mods.includes('ctrl') || mods.includes('meta');
if (wantsPrimary !== !!(e.ctrlKey || e.metaKey)) return false;
if (mods.includes('shift') !== !!e.shiftKey) return false;
if (mods.includes('alt') !== !!e.altKey) return false;
// Match the physical key when the binding pins one (layout-independent),
// or the produced character otherwise (layout-dependent keys like '+').
if (binding.code && e.code === binding.code) return true;
if (binding.key && typeof e.key === 'string' && e.key.toLowerCase() === binding.key.toLowerCase()) return true;
return false;
});
}
// ─── Shortcut Overlay Modal ───────────────────────────────────────────────────
// Ctrl/Alt+? opens a floating overlay listing all keyboard shortcuts, grouped
// by category. Uses the merged registry so user overrides are reflected.
showShortcutOverlay() {
const modal = document.getElementById('shortcutOverlayModal');
if (!modal) return;
this.renderShortcutOverlay();
modal.classList.add('active');
modal.focus?.();
}
renderShortcutOverlay() {
const list = document.getElementById('shortcutOverlayList');
if (!list) return;
const registry = this.getShortcutRegistry();
const groups = {};
for (const shortcut of registry) {
const g = shortcut.group || 'General';
if (!groups[g]) groups[g] = [];
groups[g].push(shortcut);
}
const fmtBindings = (s) => {
if (s.displayBindings) return s.displayBindings.map((b) => `<kbd>${escapeHtml(b)}</kbd>`).join(' / ');
if (!s.bindings) return '';
return s.bindings.map((b) => {
const parts = [...(b.modifiers || []).map((m) => m.charAt(0).toUpperCase() + m.slice(1)), b.key || b.code || ''];
return `<kbd>${escapeHtml(parts.join('+'))}</kbd>`;
}).join(' / ');
};
list.innerHTML = Object.entries(groups).map(([group, items]) =>
`<div class="shortcut-overlay-group"><div class="shortcut-overlay-group-label">${escapeHtml(group)}</div>` +
items.map((s) => `<div class="shortcut-overlay-row"><span class="shortcut-overlay-label">${escapeHtml(s.label)}</span><span class="shortcut-overlay-keys">${fmtBindings(s)}</span></div>`).join('') +
`</div>`
).join('');
}
closeShortcutOverlay() {
const modal = document.getElementById('shortcutOverlayModal');
if (modal) modal.classList.remove('active');
}
}
// ═══════════════════════════════════════════════════════════════
+34
View File
@@ -156,6 +156,30 @@ function shouldAutoWrapTabs(input) {
return scrollWidth > clientWidth + 1;
}
// COD-134 — Terminal WebSocket reconnect policy.
//
// Decide what to do after a terminal WebSocket closes, given the close `code`
// and `attempt` (0-based count of consecutive reconnects already made):
// - transient closes (code < 4004: 1000/1001/1005/1006/etc.) → 'reconnect'
// with exponential backoff (0 on the first attempt; the caller adds jitter),
// 250ms → 500 → 1000 → ... capped at 10s.
// - 4004 (session not found) / 4009 (session terminated) → 'give-up': the
// session is gone, retrying only wastes connections.
// - 4008 (too many connections) and any other code >= 4004 → 'retry-fallback':
// show the HTTP fallback but keep retrying on a bounded 5s timer so the
// transport returns to WS once the transient condition clears (un-stick).
// Pure: no DOM, no side effects.
function planWsReconnect(code, attempt) {
if (code === 4004 || code === 4009) {
return { action: 'give-up', delayMs: 0 };
}
if (code >= 4004) {
return { action: 'retry-fallback', delayMs: 5000 };
}
const delayMs = attempt <= 0 ? 0 : Math.min(250 * Math.pow(2, attempt - 1), 10000);
return { action: 'reconnect', delayMs };
}
if (typeof window !== 'undefined') {
window.WEBGL_FALLBACK = WEBGL_FALLBACK;
window.evaluateWebGLLongTaskTrip = evaluateWebGLLongTaskTrip;
@@ -163,6 +187,9 @@ if (typeof window !== 'undefined') {
window.CodemanTabOverflow = {
shouldAutoWrapTabs,
};
window.CodemanWsReconnect = {
plan: planWsReconnect,
};
}
// Scheduler API — prioritize terminal writes over background UI updates.
@@ -293,6 +320,7 @@ const SSE_EVENTS = {
SESSION_LIMIT_PAUSE_SCHEDULED: 'session:limitPauseScheduled',
SESSION_LIMIT_RESUME: 'session:limitResume',
SESSION_LIMIT_RESUME_CANCELLED: 'session:limitResumeCancelled',
SESSION_RESPAWN_BREAKER_TRIPPED: 'session:respawnBreakerTripped',
SESSION_CLI_INFO: 'session:cliInfo',
SESSION_MESSAGE: 'session:message',
SESSION_INTERACTIVE: 'session:interactive',
@@ -307,6 +335,12 @@ const SSE_EVENTS = {
SCHEDULED_LOG: 'scheduled:log',
SCHEDULED_DELETED: 'scheduled:deleted',
// Cron jobs
CRON_JOBS_CHANGED: 'cron:jobsChanged',
CRON_JOB_DELETED: 'cron:jobDeleted',
CRON_RUN_CREATED: 'cron:runCreated',
CRON_RUN_UPDATED: 'cron:runUpdated',
// Respawn
RESPAWN_STARTED: 'respawn:started',
RESPAWN_STOPPED: 'respawn:stopped',
+328
View File
@@ -0,0 +1,328 @@
/**
* @fileoverview Cron Jobs UI mixed into
* CodemanApp.prototype. Renders the job list + create/edit form in the
* #cronModal, and reacts to cron:* SSE events.
*
* @mixin Extends CodemanApp.prototype via Object.assign
* @dependency app.js, api-client.js, constants.js (escapeHtml)
*/
Object.assign(CodemanApp.prototype, {
// ── SSE handlers ──────────────────────────────────────────────────────────
_onCronJobsChanged(data) {
if (data && Array.isArray(data.jobs)) {
this._cronJobs = data.jobs;
if (this._isCronOpen()) this.renderCronJobs();
} else if (this._isCronOpen()) {
this.refreshCron();
}
},
_onCronRunChanged() {
// A run's status changed — refresh the list so lastStatus stays current.
if (this._isCronOpen()) this.refreshCron();
},
// ── Modal open/close ──────────────────────────────────────────────────────
_isCronOpen() {
const el = document.getElementById('cronModal');
return !!el && el.classList.contains('active');
},
openCron() {
const el = document.getElementById('cronModal');
if (!el) return;
el.classList.add('active');
this.cancelCronJobForm();
this.refreshCron();
},
closeCron() {
const el = document.getElementById('cronModal');
if (el) el.classList.remove('active');
},
async refreshCron() {
const jobs = await this._apiJson('/api/cron/jobs');
this._cronJobs = Array.isArray(jobs) ? jobs : [];
this.renderCronJobs();
},
// ── List rendering ────────────────────────────────────────────────────────
renderCronJobs() {
const list = document.getElementById('cronJobList');
if (!list) return;
const jobs = this._cronJobs || [];
if (jobs.length === 0) {
list.innerHTML = '<div class="form-hint">No cron jobs yet. Click “+ New Job”.</div>';
return;
}
const rows = jobs.map((j) => {
const next = j.enabled ? this._fmtTime(j.nextRunAt) : '—';
const last = this._fmtTime(j.lastRunAt);
const status = j.lastStatus ? escapeHtml(j.lastStatus) : '—';
return `
<div class="cron-job-row">
<div class="cron-job-main">
<div class="cron-job-name">${escapeHtml(j.name || '(unnamed)')}
<span class="cron-badge">${escapeHtml(j.agentType)}</span>
<span class="cron-badge">${escapeHtml(this._fmtSchedule(j))}</span>
${j.enabled ? '' : '<span class="cron-badge cron-badge-off">disabled</span>'}
</div>
<div class="cron-job-meta">
<span title="${escapeHtml(j.workingDir || '')}">${escapeHtml(j.workingDir || '')}</span>
· next: ${escapeHtml(next)} · last: ${escapeHtml(last)} · status: ${status}
</div>
</div>
<div class="cron-job-actions">
<button class="btn-toolbar btn-sm btn-primary" onclick="app.runCronJob('${j.id}')">Run Now</button>
<button class="btn-toolbar btn-sm" onclick="app.toggleCronJob('${j.id}', ${j.enabled ? 'false' : 'true'})">${j.enabled ? 'Disable' : 'Enable'}</button>
<button class="btn-toolbar btn-sm" onclick="app.editCronJob('${j.id}')">Edit</button>
<button class="btn-toolbar btn-sm btn-danger" onclick="app.deleteCronJob('${j.id}')">Delete</button>
</div>
</div>`;
});
list.innerHTML = rows.join('');
},
_fmtTime(ts) {
if (!ts) return '—';
try {
return new Date(ts).toLocaleString();
} catch {
return '—';
}
},
_fmtSchedule(j) {
switch (j.scheduleType) {
case 'once':
return 'once';
case 'interval':
return `every ${j.intervalMinutes}m`;
case 'daily':
return `daily ${j.dailyTime || ''}`;
case 'weekly': {
const names = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const days = (j.weeklyDays || []).map((d) => names[d] || d).join(',');
return `weekly ${days} ${j.weeklyTime || ''}`;
}
default:
return j.scheduleType || '';
}
},
// ── Create / edit form ────────────────────────────────────────────────────
openCronJobForm(job) {
const form = document.getElementById('cronJobForm');
if (!form) return;
document.getElementById('cronFormError').textContent = '';
document.getElementById('cronFormTitle').textContent = job ? 'Edit Cron Job' : 'New Cron Job';
document.getElementById('schJobId').value = job ? job.id : '';
document.getElementById('schName').value = job ? job.name || '' : '';
document.getElementById('schAgentType').value = job ? job.agentType || 'claude' : 'claude';
document.getElementById('schWorkingDir').value = job ? job.workingDir || '' : '';
document.getElementById('schLaunchCommand').value = job ? job.launchCommand || '' : '';
document.getElementById('schPromptMode').value = job ? job.promptMode || 'inline_text' : 'inline_text';
document.getElementById('schPromptText').value = job ? job.promptText || '' : '';
document.getElementById('schPromptFilePath').value = job ? job.promptFilePath || '' : '';
document.getElementById('schInputMode').value = job ? job.inputMode || 'typed' : 'typed';
document.getElementById('schScheduleType').value = job ? job.scheduleType || 'once' : 'once';
document.getElementById('schRunAt').value = job && job.runAt ? this._toLocalInput(job.runAt) : '';
document.getElementById('schIntervalMinutes').value = job && job.intervalMinutes ? job.intervalMinutes : 60;
document.getElementById('schDailyTime').value = job ? job.dailyTime || '' : '';
document.getElementById('schWeeklyTime').value = job ? job.weeklyTime || '' : '';
const weekly = (job && job.weeklyDays) || [];
document.querySelectorAll('#schWeeklyDays input[type=checkbox]').forEach((cb) => {
cb.checked = weekly.includes(Number(cb.value));
});
document.getElementById('schConcurrencyPolicy').value = job ? job.concurrencyPolicy || 'warn_only' : 'warn_only';
document.getElementById('schAutoClosePrev').checked = job ? job.autoClosePreviousSession !== false : true;
document.getElementById('schEnabled').checked = job ? !!job.enabled : true;
document.getElementById('schNotes').value = job ? job.notes || '' : '';
this.onCronAgentTypeChange();
this.onCronPromptModeChange();
this.onCronScheduleTypeChange();
form.classList.remove('hidden');
},
editCronJob(id) {
const job = (this._cronJobs || []).find((j) => j.id === id);
if (job) this.openCronJobForm(job);
},
cancelCronJobForm() {
const form = document.getElementById('cronJobForm');
if (form) form.classList.add('hidden');
},
onCronAgentTypeChange() {
// Launch command is only meaningful for shell mode (first input line).
const isShell = document.getElementById('schAgentType').value === 'shell';
document.getElementById('schLaunchCommandRow').classList.toggle('hidden', !isShell);
},
onCronPromptModeChange() {
const mode = document.getElementById('schPromptMode').value;
document.getElementById('schPromptTextRow').classList.toggle('hidden', mode !== 'inline_text');
document.getElementById('schPromptFileRow').classList.toggle('hidden', mode !== 'prompt_file_path');
},
onCronScheduleTypeChange() {
const t = document.getElementById('schScheduleType').value;
document.getElementById('schRunAtRow').classList.toggle('hidden', t !== 'once');
document.getElementById('schIntervalRow').classList.toggle('hidden', t !== 'interval');
document.getElementById('schDailyRow').classList.toggle('hidden', t !== 'daily');
document.getElementById('schWeeklyDaysRow').classList.toggle('hidden', t !== 'weekly');
document.getElementById('schWeeklyTimeRow').classList.toggle('hidden', t !== 'weekly');
},
_toLocalInput(ts) {
// epoch-ms → 'YYYY-MM-DDTHH:MM' in local time for <input datetime-local>.
const d = new Date(ts);
const pad = (n) => String(n).padStart(2, '0');
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
},
_collectCronForm() {
const t = document.getElementById('schScheduleType').value;
const promptMode = document.getElementById('schPromptMode').value;
const body = {
name: document.getElementById('schName').value.trim(),
agentType: document.getElementById('schAgentType').value,
workingDir: document.getElementById('schWorkingDir').value.trim(),
promptMode,
inputMode: document.getElementById('schInputMode').value,
scheduleType: t,
concurrencyPolicy: document.getElementById('schConcurrencyPolicy').value,
autoClosePreviousSession: document.getElementById('schAutoClosePrev').checked,
enabled: document.getElementById('schEnabled').checked,
notes: document.getElementById('schNotes').value.trim() || undefined,
};
// Always sent for shell (an emptied field must clear a saved command on edit).
if (body.agentType === 'shell') body.launchCommand = document.getElementById('schLaunchCommand').value.trim();
if (promptMode === 'inline_text') {
// Prompt delivery is single-line only; trailing newlines are harmless, strip them.
body.promptText = document.getElementById('schPromptText').value.replace(/[\r\n]+$/, '');
} else {
body.promptFilePath = document.getElementById('schPromptFilePath').value.trim();
}
if (t === 'once') {
const v = document.getElementById('schRunAt').value;
body.runAt = v ? new Date(v).getTime() : undefined;
} else if (t === 'interval') {
body.intervalMinutes = Number(document.getElementById('schIntervalMinutes').value);
} else if (t === 'daily') {
body.dailyTime = document.getElementById('schDailyTime').value;
} else if (t === 'weekly') {
body.weeklyTime = document.getElementById('schWeeklyTime').value;
body.weeklyDays = Array.from(document.querySelectorAll('#schWeeklyDays input:checked')).map((cb) =>
Number(cb.value)
);
}
return body;
},
async saveCronJob() {
const errEl = document.getElementById('cronFormError');
errEl.textContent = '';
const body = this._collectCronForm();
if (!body.name) {
errEl.textContent = 'Name is required.';
return;
}
if (!body.workingDir) {
errEl.textContent = 'Working directory is required.';
return;
}
if (body.promptText !== undefined && /[\r\n]/.test(body.promptText)) {
errEl.textContent = 'Prompt must be a single line — multi-line prompts are not supported.';
return;
}
const id = document.getElementById('schJobId').value;
const res = id ? await this._apiPut(`/api/cron/jobs/${id}`, body) : await this._apiPost('/api/cron/jobs', body);
if (!res || !res.ok) {
let msg = 'Failed to save job.';
try {
const j = await res.json();
if (j && j.error) msg = j.error;
} catch {
/* ignore */
}
errEl.textContent = msg;
return;
}
this.showToast?.(id ? 'Cron job updated' : 'Cron job created', 'success');
this.cancelCronJobForm();
this.refreshCron();
},
// ── Actions ───────────────────────────────────────────────────────────────
async runCronJob(id) {
const job = (this._cronJobs || []).find((j) => j.id === id);
if (job) {
const active = this._countActiveAgents(job.agentType);
if (active > 0 && !confirm(`${active} ${job.agentType} session(s) already active. Run this job anyway?`)) {
return;
}
}
const res = await this._apiPost(`/api/cron/jobs/${id}/run`, {});
if (res && res.ok) {
this.showToast?.('Run started — opening session', 'success');
let data = null;
try {
data = await res.json();
} catch {
/* ignore */
}
const run = data && (data.data ? data.data.run : data.run);
if (run && run.sessionId) this._focusCronSession(run.sessionId);
this.refreshCron();
} else {
this.showToast?.('Failed to run job', 'error');
}
},
_focusCronSession(sessionId) {
// Best-effort: switch to the created session tab if it exists.
if (this.sessions && this.sessions.has(sessionId) && typeof this.switchSession === 'function') {
this.closeCron();
this.switchSession(sessionId);
}
},
_countActiveAgents(agentType) {
// Mirrors the server's countActiveAgents: only LIVE sessions count — a
// tab whose CLI already exited (stopped/error) doesn't block anything.
if (!this.sessions) return 0;
let n = 0;
for (const s of this.sessions.values()) {
if (s && s.mode === agentType && s.status !== 'stopped' && s.status !== 'error') n++;
}
return n;
},
async toggleCronJob(id, enabled) {
const res = await this._apiPut(`/api/cron/jobs/${id}/enabled`, { enabled });
if (res && res.ok) this.refreshCron();
else this.showToast?.('Failed to update job', 'error');
},
async deleteCronJob(id) {
if (!confirm('Delete this cron job and its run history?')) return;
const res = await this._apiDelete(`/api/cron/jobs/${id}`);
if (res && res.ok) {
this.showToast?.('Cron job deleted', 'success');
this.refreshCron();
} else {
this.showToast?.('Failed to delete job', 'error');
}
},
});
+262 -15
View File
@@ -358,7 +358,7 @@
<h3 class="history-title" id="historyTitle">Resume Conversation</h3>
<div class="history-list" id="historyList"></div>
</div>
<p class="welcome-hint">Or press <kbd>Ctrl</kbd>+<kbd>Enter</kbd> to start</p>
<p class="welcome-hint">Or click Run to start</p>
<button class="welcome-ralph-link" onclick="app.showRalphWizard()">Start Ralph Loop &rarr;</button>
</div>
</div>
@@ -427,7 +427,7 @@
<!-- Run AI -->
<div class="toolbar-group">
<div class="run-btn-group">
<button class="btn-toolbar btn-run" id="runBtn" onclick="app.run()" title="Run (Ctrl+Enter)">
<button class="btn-toolbar btn-run" id="runBtn" onclick="app.run()" title="Run">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><polygon points="5 3 19 12 5 21 5 3"/></svg>
<span id="runBtnLabel">Run</span>
</button>
@@ -469,7 +469,22 @@
<button class="tab-count-btn" onclick="app.incrementShellCount()">+</button>
</div>
<div class="case-select-group">
<select id="quickStartCase" class="toolbar-select" title="Select case">
<div class="case-combobox" id="quickStartCasePicker">
<input
type="text"
id="quickStartCaseSearch"
class="case-combobox-input"
role="combobox"
aria-controls="quickStartCaseList"
aria-expanded="false"
aria-autocomplete="list"
autocomplete="off"
spellcheck="false"
title="Select case"
>
<div id="quickStartCaseList" class="case-combobox-list hidden" role="listbox"></div>
</div>
<select id="quickStartCase" class="toolbar-select case-native-select" title="Select case" aria-hidden="true" tabindex="-1">
<option value="testcase">testcase</option>
</select>
<button class="btn-case-add" onclick="app.showCreateCaseModal()" title="Create new case">+</button>
@@ -541,6 +556,7 @@
<div class="toolbar-right">
<!-- Orchestrator button hidden until feature is ready -->
<!-- <button class="btn-toolbar btn-sm" onclick="app.toggleOrchestratorPanel()" title="Orchestrator Loop">&#x2699; Orchestrator</button> -->
<button class="btn-toolbar btn-sm" onclick="app.openCron()" title="Cron Jobs">&#x23F0; Cron</button>
<span class="version-display" id="versionDisplay" title="Codeman version">v0.0.0</span>
</div>
</footer>
@@ -554,18 +570,141 @@
<button class="modal-close" onclick="app.closeHelp()" aria-label="Close help">&times;</button>
</div>
<div class="modal-body">
<div class="shortcuts-grid">
<div><kbd>Ctrl</kbd>+<kbd>W</kbd></div><div>Close Session</div>
<div><kbd>Ctrl</kbd>+<kbd>Tab</kbd></div><div>Next Session</div>
<div><kbd>Alt/Option</kbd>+<kbd>[</kbd> / <kbd>Alt/Option</kbd>+<kbd>]</kbd></div><div>Previous / Next Session</div>
<div><kbd>Alt/Option</kbd>+<kbd>1-9</kbd></div><div>Switch to Tab N</div>
<div><kbd>Ctrl</kbd>+<kbd>L</kbd></div><div>Clear Terminal</div>
<div><kbd>Shift</kbd>+<kbd>Wheel</kbd></div><div>Scroll local history (when mouse passthrough is active)</div>
<div><kbd>Ctrl</kbd>+<kbd>+</kbd></div><div>Increase Font</div>
<div><kbd>Ctrl</kbd>+<kbd>-</kbd></div><div>Decrease Font</div>
<div><kbd>Ctrl</kbd>+<kbd>?</kbd></div><div>Show Help</div>
<div><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>V</kbd></div><div>Voice Input</div>
<div><kbd>Escape</kbd></div><div>Close Panels</div>
<section class="shortcut-section">
<h4>Session</h4>
<div class="shortcuts-grid">
<div><kbd>Ctrl</kbd>+<kbd>W</kbd></div><div>Close Session</div>
<div><kbd>Ctrl/Cmd/Option</kbd>+<kbd>K</kbd></div><div>Find Open Session</div>
<div><kbd>Ctrl</kbd>+<kbd>Tab</kbd></div><div>Next Session</div>
<div><kbd>Alt/Option</kbd>+<kbd>[</kbd> / <kbd>Alt/Option</kbd>+<kbd>]</kbd></div><div>Previous / Next Session</div>
<div><kbd>Alt/Option</kbd>+<kbd>1-9</kbd></div><div>Switch to Tab N</div>
</div>
</section>
<section class="shortcut-section">
<h4>Tabs</h4>
<div class="shortcuts-grid">
<div><kbd>Ctrl</kbd>+<kbd>{</kbd></div><div>Move Active Tab Left</div>
<div><kbd>Ctrl</kbd>+<kbd>}</kbd></div><div>Move Active Tab Right</div>
<div><kbd>ArrowLeft</kbd></div><div>Focus Previous Tab</div>
<div><kbd>ArrowRight</kbd></div><div>Focus Next Tab</div>
<div><kbd>Home</kbd></div><div>Focus First Tab</div>
<div><kbd>End</kbd></div><div>Focus Last Tab</div>
<div><kbd>Enter</kbd> / <kbd>Space</kbd></div><div>Activate Focused Tab</div>
</div>
</section>
<section class="shortcut-section">
<h4>Terminal</h4>
<div class="shortcuts-grid">
<div><kbd>Ctrl</kbd>+<kbd>L</kbd></div><div>Clear Terminal</div>
<div><kbd>Ctrl</kbd>+<kbd>+</kbd></div><div>Increase Font</div>
<div><kbd>Ctrl</kbd>+<kbd>-</kbd></div><div>Decrease Font</div>
<div><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>R</kbd></div><div>Restore Terminal Size</div>
<div><kbd>Shift</kbd>+<kbd>Enter</kbd></div><div>Insert Newline</div>
<div><kbd>Ctrl</kbd>+<kbd>Enter</kbd></div><div>Insert Newline</div>
<div><kbd>Ctrl</kbd>+<kbd>Shift</kbd>+<kbd>V</kbd></div><div>Voice Input</div>
<div><kbd>Shift</kbd>+<kbd>Wheel</kbd></div><div>Scroll local history (when mouse passthrough is active)</div>
</div>
</section>
<section class="shortcut-section">
<h4>Panels</h4>
<div class="shortcuts-grid">
<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>
</section>
</div>
</div>
</div>
<!-- Cron Jobs Modal -->
<div class="modal" id="cronModal">
<div class="modal-backdrop" onclick="app.closeCron()"></div>
<div class="modal-content modal-lg">
<div class="modal-header">
<h3>Cron Jobs</h3>
<button class="modal-close" onclick="app.closeCron()" aria-label="Close cron">&times;</button>
</div>
<div class="modal-body">
<p class="form-hint" style="margin-bottom:10px;">Times use the server's local timezone.</p>
<div style="margin-bottom:10px;">
<button class="btn-toolbar btn-sm btn-primary" onclick="app.openCronJobForm()">+ New Job</button>
<button class="btn-toolbar btn-sm" onclick="app.refreshCron()">Refresh</button>
</div>
<!-- Job list -->
<div id="cronJobList" class="cron-job-list"></div>
<!-- Create/Edit form (hidden until New/Edit) -->
<div id="cronJobForm" class="cron-job-form hidden">
<div class="form-section-header" id="cronFormTitle">New Cron Job</div>
<input type="hidden" id="schJobId">
<div class="form-row"><label>Name</label><input type="text" id="schName" placeholder="My nightly job"></div>
<div class="form-row"><label>Agent Type</label>
<select id="schAgentType" onchange="app.onCronAgentTypeChange()">
<option value="claude">Claude</option>
<option value="shell">Terminal / Shell</option>
<option value="opencode">OpenCode</option>
<option value="codex">Codex</option>
<option value="gemini">Gemini</option>
</select>
</div>
<div class="form-row"><label>Working Directory</label><input type="text" id="schWorkingDir" placeholder="/absolute/path"></div>
<div class="form-row hidden" id="schLaunchCommandRow"><label>Launch Command</label><input type="text" id="schLaunchCommand" placeholder="Optional — runs as the first command in the new shell"></div>
<div class="form-row"><label>Prompt Source</label>
<select id="schPromptMode" onchange="app.onCronPromptModeChange()">
<option value="inline_text">Inline text</option>
<option value="prompt_file_path">Prompt file path</option>
</select>
</div>
<div class="form-row" id="schPromptTextRow"><label>Prompt</label><textarea id="schPromptText" rows="4" placeholder="Prompt to send into the session"></textarea></div>
<div class="form-row hidden" id="schPromptFileRow"><label>Prompt File Path</label><input type="text" id="schPromptFilePath" placeholder="/absolute/path/to/prompt.md"></div>
<div class="form-row"><label>Input Mode</label>
<select id="schInputMode">
<option value="typed">Typed (via tmux)</option>
<option value="paste">Paste (direct)</option>
</select>
</div>
<div class="form-row"><label>Schedule Type</label>
<select id="schScheduleType" onchange="app.onCronScheduleTypeChange()">
<option value="once">Once</option>
<option value="interval">Interval</option>
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
</select>
</div>
<div class="form-row" id="schRunAtRow"><label>Run At</label><input type="datetime-local" id="schRunAt"></div>
<div class="form-row hidden" id="schIntervalRow"><label>Every (minutes)</label><input type="number" id="schIntervalMinutes" min="1" value="60"></div>
<div class="form-row hidden" id="schDailyRow"><label>Daily Time (HH:MM)</label><input type="time" id="schDailyTime"></div>
<div class="form-row hidden" id="schWeeklyDaysRow"><label>Weekdays</label>
<span id="schWeeklyDays" class="cron-weekdays">
<label><input type="checkbox" value="0">Sun</label>
<label><input type="checkbox" value="1">Mon</label>
<label><input type="checkbox" value="2">Tue</label>
<label><input type="checkbox" value="3">Wed</label>
<label><input type="checkbox" value="4">Thu</label>
<label><input type="checkbox" value="5">Fri</label>
<label><input type="checkbox" value="6">Sat</label>
</span>
</div>
<div class="form-row hidden" id="schWeeklyTimeRow"><label>Weekly Time (HH:MM)</label><input type="time" id="schWeeklyTime"></div>
<div class="form-row"><label>On auto-run, if same agent running</label>
<select id="schConcurrencyPolicy">
<option value="warn_only">Run anyway</option>
<option value="skip_if_same_agent_running">Skip this run</option>
</select>
</div>
<div class="form-row form-row-switch"><label title="Recurring schedules only: when the next run fires, the still-open session created by this job's previous run is closed first">Auto-close previous run's session</label>
<label class="switch"><input type="checkbox" id="schAutoClosePrev" checked><span class="slider"></span></label>
</div>
<div class="form-row form-row-switch"><label>Enabled</label>
<label class="switch"><input type="checkbox" id="schEnabled" checked><span class="slider"></span></label>
</div>
<div class="form-row"><label>Notes</label><input type="text" id="schNotes" placeholder="Optional"></div>
<div id="cronFormError" class="form-hint" style="color:var(--danger,#e55);"></div>
<div style="margin-top:10px;">
<button class="btn-toolbar btn-sm btn-primary" onclick="app.saveCronJob()">Save</button>
<button class="btn-toolbar btn-sm" onclick="app.cancelCronJobForm()">Cancel</button>
</div>
</div>
</div>
</div>
@@ -997,6 +1136,7 @@
<button class="modal-tab-btn" data-tab="settings-paths">Paths</button>
<button class="modal-tab-btn" data-tab="settings-notifications">Notifications</button>
<button class="modal-tab-btn" data-tab="settings-voice">Voice</button>
<button class="modal-tab-btn" data-tab="settings-shortcuts">Shortcuts</button>
</div>
<div class="modal-body">
<!-- Display Tab -->
@@ -1610,6 +1750,19 @@
</div>
</div>
</div>
<!-- Shortcuts tab -->
<div class="modal-tab-content hidden" id="settings-shortcuts">
<div class="settings-grid">
<div class="settings-section-header" style="grid-column: 1 / -1;">Keyboard Shortcuts</div>
<p class="form-hint" style="grid-column: 1 / -1; margin: 0 0 0.5rem;">
Customize keyboard shortcuts. Click the binding to capture a new key combination.
</p>
<div id="appSettingsShortcutsList" style="grid-column: 1 / -1;">
<!-- Populated by app.renderShortcutSettingsList() -->
</div>
</div>
</div>
</div>
<div class="form-actions">
<button class="btn-toolbar" onclick="app.closeAppSettings()">Cancel</button>
@@ -1618,6 +1771,23 @@
</div>
</div>
<!-- Shortcut Overlay Modal -->
<div class="modal shortcut-overlay-modal" id="shortcutOverlayModal" tabindex="-1">
<div class="modal-backdrop" onclick="app.closeShortcutOverlay()"></div>
<div class="modal-content">
<div class="modal-header">
<span class="modal-title">Keyboard Shortcuts</span>
<button class="modal-close" onclick="app.closeShortcutOverlay()" aria-label="Close">&#x2715;</button>
</div>
<div class="modal-body">
<div id="shortcutOverlayList"></div>
<div class="shortcut-overlay-footer">
<button class="btn btn-sm" onclick="app.closeShortcutOverlay(); app.showHelp()">Full shortcut reference</button>
</div>
</div>
</div>
</div>
<!-- Create Case Modal -->
<div class="modal" id="createCaseModal">
<div class="modal-backdrop" onclick="app.closeCreateCaseModal()"></div>
@@ -1629,6 +1799,7 @@
<div class="modal-tabs">
<button class="modal-tab-btn active" data-tab="case-create">Create New</button>
<button class="modal-tab-btn" data-tab="case-link">Link Existing</button>
<button class="modal-tab-btn" data-tab="case-remote">Remote</button>
<button class="modal-tab-btn" data-tab="case-manage">Manage</button>
</div>
<div class="modal-body">
@@ -1657,6 +1828,66 @@
<span class="form-hint">Absolute path to an existing project folder, e.g. /home/you/my-project</span>
</div>
</div>
<!-- Remote Tab -->
<div class="modal-tab-content hidden" id="case-remote">
<div class="form-row">
<label>Case Name</label>
<input type="text" id="remoteCaseName" placeholder="gpu-work" pattern="[a-zA-Z0-9_-]+" autocomplete="off" autocapitalize="off" spellcheck="false">
<span class="form-hint">Name to identify this remote case in Codeman</span>
</div>
<div class="form-row">
<label>Remote Path</label>
<input type="text" id="remoteCasePath" placeholder="/home/user/projects/work" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">
<span class="form-hint">Absolute path on the remote host. Codeman will not create or delete it.</span>
</div>
<div class="form-row">
<label>Host ID</label>
<input type="text" id="remoteHostId" placeholder="gpu-box" pattern="[a-zA-Z0-9_-]+" autocomplete="off" autocapitalize="off" spellcheck="false">
</div>
<div class="form-row">
<label>SSH Host/IP</label>
<input type="text" id="remoteHostAddress" placeholder="10.0.0.42" autocomplete="off" autocapitalize="off" spellcheck="false">
</div>
<div class="form-row">
<label>SSH Username</label>
<input type="text" id="remoteHostUsername" placeholder="ubuntu" autocomplete="off" autocapitalize="off" spellcheck="false">
</div>
<div class="form-row">
<label>SSH Port</label>
<input type="number" id="remoteHostPort" placeholder="22" min="1" max="65535" autocomplete="off">
<span class="form-hint">Optional. Leave blank for the default port 22.</span>
</div>
<div class="form-row">
<label>Codex Command Override</label>
<input type="text" id="remoteHostCodexCommand" placeholder="exec codx personal" autocomplete="off" autocapitalize="off" spellcheck="false">
<span class="form-hint">Optional. Leave blank to use exec codex on the remote host.</span>
</div>
<details class="advanced-options">
<summary>Advanced SSH</summary>
<div class="advanced-options-content">
<div class="form-row">
<label>Identity File</label>
<input type="text" id="remoteHostIdentityFile" placeholder="~/.ssh/remote_ed25519" autocomplete="off" autocapitalize="off" autocorrect="off" spellcheck="false">
<span class="form-hint">Optional. Path to a private key on this machine (passed to ssh -i). Never the key contents.</span>
</div>
<div class="form-row">
<label>SOCKS Proxy</label>
<input type="text" id="remoteHostSocksProxy" placeholder="127.0.0.1:1080" autocomplete="off" autocapitalize="off" spellcheck="false">
<span class="form-hint">Optional. host:port of a SOCKS5 proxy (e.g. cloudflared). Routes ssh through it via a ProxyCommand.</span>
</div>
<div class="form-row">
<label>Jump Host</label>
<input type="text" id="remoteHostJumpHost" placeholder="bastion@10.0.0.1:22" autocomplete="off" autocapitalize="off" spellcheck="false">
<span class="form-hint">Optional. [user@]host[:port] for ssh -J (jump/bastion host).</span>
</div>
<div class="form-row">
<label>Extra -o Options</label>
<textarea id="remoteHostExtraSshOptions" rows="3" placeholder="StrictHostKeyChecking=accept-new&#10;ConnectTimeout=10" autocomplete="off" autocapitalize="off" spellcheck="false"></textarea>
<span class="form-hint">Optional. One KEY=VALUE per line; each becomes an ssh -o option.</span>
</div>
</div>
</details>
</div>
<!-- Manage Tab -->
<div class="modal-tab-content hidden" id="case-manage">
<div class="case-manage-list" id="caseManageList">
@@ -1956,6 +2187,20 @@
</div>
</div>
<!-- Command Palette Modal -->
<div class="modal command-palette-modal" id="commandPaletteModal">
<div class="modal-backdrop" onclick="app.closeCommandPalette()"></div>
<div class="command-palette-shell" role="dialog" aria-modal="true" aria-labelledby="commandPaletteTitle">
<div class="command-palette-input-row">
<span class="command-palette-search-icon" aria-hidden="true">⌕</span>
<input type="search" id="commandPaletteSearch" class="command-palette-search" placeholder="Search open sessions or start a new one" autocomplete="off" maxlength="160" aria-labelledby="commandPaletteTitle">
<kbd>Esc</kbd>
</div>
<div class="command-palette-label" id="commandPaletteTitle">Open sessions</div>
<div id="commandPaletteList" class="command-palette-list"></div>
</div>
</div>
<!-- Session Manager Modal -->
<div class="modal" id="sessionManagerModal">
<div class="modal-backdrop" onclick="app.closeSessionManager()"></div>
@@ -1971,6 +2216,7 @@
</div>
</div>
<!-- Token Stats Modal -->
<div class="modal" id="tokenStatsModal">
<div class="modal-backdrop" onclick="app.closeTokenStats()"></div>
@@ -2079,6 +2325,7 @@
<script defer src="respawn-ui.js"></script>
<script defer src="ralph-panel.js"></script>
<script defer src="orchestrator-panel.js"></script>
<script defer src="cron-ui.js"></script>
<script defer src="settings-ui.js"></script>
<script defer src="panels-ui.js"></script>
<script defer src="ultracode-panel.js"></script>
+26 -1
View File
@@ -162,7 +162,11 @@ html.mobile-init .file-browser-panel {
}
.case-select-group {
max-width: 150px;
max-width: none;
}
.case-combobox {
width: 160px;
}
.toolbar-select {
@@ -194,6 +198,27 @@ html.mobile-init .file-browser-panel {
z-index: 1300;
}
.command-palette-modal {
padding: 10vh 0.75rem 0;
}
.command-palette-shell {
width: 100%;
max-height: 74vh;
}
.command-palette-input-row {
grid-template-columns: 20px minmax(0, 1fr);
}
.command-palette-input-row kbd {
display: none;
}
.command-palette-item {
min-height: 56px;
}
.modal-tabs {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
+355 -57
View File
@@ -250,39 +250,283 @@ Object.assign(CodemanApp.prototype, {
this.openImagePopup(data);
},
// ═══════════════════════════════════════════════════════════════
// Away Digest Modal
// Command Palette (COD-153)
// Fast Cmd/Ctrl+K switcher for currently open sessions, plus launch-new.
// ═══════════════════════════════════════════════════════════════
async openAwayDigest(range = 'since-last-visit') {
this.awayDigestRange = range;
this._awayDigestLoadedSuccessfully = false;
this._awayDigestSinceLastVisitGeneratedAt = undefined;
const modal = document.getElementById('awayDigestModal');
if (modal) modal.classList.add('active');
this.updateAwayDigestRangeControls();
await this.loadAwayDigest();
shouldOpenCommandPaletteFromShortcut(e) {
if (!e) return false;
// Every palette chord requires Ctrl/Cmd/Alt (capture enforces the same for
// rebinds), so plain typing exits before any registry work — this runs on
// the document AND xterm keydown hot paths.
if (!e.ctrlKey && !e.metaKey && !e.altKey) return false;
// Registry-aware chord check (COD-157): honors a rebound or disabled
// palette shortcut. Falls back to the default Ctrl/Cmd/Alt+K chord when the
// registry isn't available (isolated test harnesses).
const registryAvailable =
typeof this.getShortcutRegistry === 'function' && typeof this.matchesShortcutEvent === 'function';
const palette = registryAvailable
? this.getShortcutRegistry().find((s) => s.id === 'command-palette')
: null;
if (palette) {
if (palette.disabled || !this.matchesShortcutEvent(e, palette)) return false;
} else {
const key = (e.key || '').toLowerCase();
if (key !== 'k' && e.code !== 'KeyK') return false;
// Don't hijack chords with extra modifiers (Ctrl+Shift+K is the Firefox
// devtools console; matchesShortcutEvent applies the same rule above).
if (e.shiftKey) return false;
}
const target = e.target;
if (!target) return true;
const tagName = (target.tagName || '').toUpperCase();
const className = typeof target.className === 'string' ? target.className : '';
const isXtermHelper =
target.classList?.contains?.('xterm-helper-textarea') || className.includes('xterm-helper-textarea');
if (isXtermHelper) return true;
if (tagName === 'INPUT' || tagName === 'TEXTAREA' || tagName === 'SELECT') return false;
if (target.isContentEditable) return false;
if (typeof target.closest === 'function' && target.closest('[contenteditable="true"]')) return false;
return true;
},
closeAwayDigest() {
const modal = document.getElementById('awayDigestModal');
const generatedAt = this._awayDigestSinceLastVisitGeneratedAt;
if (Number.isFinite(generatedAt)) {
try {
localStorage.setItem(AWAY_DIGEST_LAST_VIEWED_KEY, String(generatedAt));
} catch (err) {
console.warn('Failed to save away digest last-viewed marker:', err);
openCommandPalette() {
const modal = document.getElementById('commandPaletteModal');
const search = document.getElementById('commandPaletteSearch');
if (!modal || !search) return;
this.commandPaletteActiveIndex = 0;
search.value = '';
modal.classList.add('active');
this._wireCommandPalette();
this.renderCommandPalette();
search.focus();
search.select?.();
},
closeCommandPalette() {
const modal = document.getElementById('commandPaletteModal');
if (modal) modal.classList.remove('active');
},
_wireCommandPalette() {
if (this._commandPaletteWired) return;
this._commandPaletteWired = true;
const modal = document.getElementById('commandPaletteModal');
const search = document.getElementById('commandPaletteSearch');
const list = document.getElementById('commandPaletteList');
search?.addEventListener('input', () => {
this.commandPaletteActiveIndex = 0;
this.renderCommandPalette();
});
search?.addEventListener('keydown', async (e) => {
if (e.key === 'ArrowDown') {
e.preventDefault();
this.moveCommandPaletteSelection(1);
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
this.moveCommandPaletteSelection(-1);
return;
}
if (e.key === 'Enter') {
e.preventDefault();
e.stopPropagation();
await this.activateCommandPaletteItem();
return;
}
if (e.key === 'Escape') {
e.preventDefault();
this.closeCommandPalette();
}
});
modal?.addEventListener('keydown', (e) => {
if (e.key === 'Escape') {
e.preventDefault();
this.closeCommandPalette();
}
});
list?.addEventListener?.('click', (e) => {
const row = e.target?.closest?.('[data-command-index]');
if (!row) return;
this.commandPaletteActiveIndex = Number(row.dataset.commandIndex) || 0;
void this.activateCommandPaletteItem();
});
},
buildCommandPaletteItems(query = '') {
const needle = query.trim().toLowerCase();
const orderedIds = [
...(Array.isArray(this.sessionOrder) ? this.sessionOrder : []),
...Array.from(this.sessions?.keys?.() || []).filter((id) => !this.sessionOrder?.includes?.(id)),
];
const seen = new Set();
const sessionItems = [];
for (const sessionId of orderedIds) {
if (seen.has(sessionId)) continue;
seen.add(sessionId);
const session = this.sessions?.get?.(sessionId);
if (!session) continue;
const title = this.getSessionName?.(session) || session.name || session.title || sessionId.slice(0, 8);
const subtitleParts = [session.workingDir, session.mode, session.status].filter(Boolean);
const haystack = [title, session.workingDir, session.mode, session.status, sessionId].filter(Boolean).join(' ').toLowerCase();
if (needle && !haystack.includes(needle)) continue;
sessionItems.push({
id: `session:${sessionId}`,
type: 'session',
sessionId,
title,
subtitle: subtitleParts.join(' · '),
});
}
sessionItems.push(this._buildCommandPaletteNewSessionItem(query));
sessionItems.push({ id: 'browse-sessions', type: 'browse-sessions', title: 'Browse all sessions…', subtitle: 'Open Session Manager' });
return sessionItems;
},
_buildCommandPaletteNewSessionItem(query = '') {
const mode = this.runMode || this._runMode || 'claude';
const labels = { claude: 'Claude', opencode: 'OpenCode', codex: 'Codex', gemini: 'Gemini' };
const caseName = this._findCommandPaletteCaseMatch(query) || document.getElementById('quickStartCase')?.value || 'testcase';
return {
id: 'new-session',
type: 'new-session',
caseName,
title: 'New session',
subtitle: `Run ${labels[mode] || mode} in ${caseName}`,
};
},
_findCommandPaletteCaseMatch(query = '') {
const needle = query.trim().toLowerCase();
if (!needle || !Array.isArray(this.cases)) return null;
const scoreCase = (caseItem) => {
const name = String(caseItem?.name || '').trim();
if (!name) return 0;
const haystack = [
name,
caseItem?.path,
caseItem?.casePath,
caseItem?.workingDir,
caseItem?.remote?.path,
caseItem?.remote?.hostId,
]
.filter(Boolean)
.join(' ')
.toLowerCase();
const lowerName = name.toLowerCase();
if (lowerName === needle) return 100;
if (lowerName.startsWith(needle)) return 90;
if (lowerName.includes(needle)) return 80;
if (haystack.includes(needle)) return 60;
return 0;
};
let best = null;
let bestScore = 0;
for (const caseItem of this.cases) {
const score = scoreCase(caseItem);
if (score > bestScore) {
best = caseItem;
bestScore = score;
}
}
if (modal) modal.classList.remove('active');
return best?.name || null;
},
renderCommandPalette() {
const search = document.getElementById('commandPaletteSearch');
const list = document.getElementById('commandPaletteList');
if (!list) return;
const query = search?.value || '';
const items = this.buildCommandPaletteItems(query);
this.commandPaletteItems = items;
this.commandPaletteActiveIndex = Math.max(0, Math.min(this.commandPaletteActiveIndex || 0, items.length - 1));
list.innerHTML = items
.map((item, index) => {
const active = index === this.commandPaletteActiveIndex ? ' active' : '';
const icon = item.type === 'new-session' ? '+' : item.type === 'browse-sessions' ? '≡' : '›';
const browse = item.type === 'browse-sessions' ? ' command-palette-item--browse' : '';
return `
<button class="command-palette-item${active}${browse}" type="button" data-command-index="${index}">
<span class="command-palette-icon" aria-hidden="true">${icon}</span>
<span class="command-palette-text">
<span class="command-palette-title">${escapeHtml(item.title)}</span>
<span class="command-palette-subtitle">${escapeHtml(item.subtitle || '')}</span>
</span>
</button>
`;
})
.join('');
},
moveCommandPaletteSelection(delta) {
const items = this.commandPaletteItems || this.buildCommandPaletteItems(document.getElementById('commandPaletteSearch')?.value || '');
if (!items.length) return;
this.commandPaletteActiveIndex = (this.commandPaletteActiveIndex + delta + items.length) % items.length;
this.renderCommandPalette();
},
async activateCommandPaletteItem(index = this.commandPaletteActiveIndex || 0) {
const item = (this.commandPaletteItems || [])[index];
if (!item) return;
this.closeCommandPalette();
if (item.type === 'session' && item.sessionId) {
await this.selectSession(item.sessionId);
return;
}
if (item.type === 'browse-sessions') {
this.openSessionManager();
return;
}
if (item.type === 'new-session') {
const caseSelect = document.getElementById('quickStartCase');
if (caseSelect && item.caseName) {
if (
caseSelect.tagName === 'SELECT' &&
typeof caseSelect.appendChild === 'function' &&
!Array.from(caseSelect.options || []).some((option) => option.value === item.caseName)
) {
const option = document.createElement('option');
option.value = item.caseName;
option.textContent = item.caseName;
caseSelect.appendChild(option);
}
// selectQuickStartCase keeps the searchable combobox, dir display, and
// persisted last-used case in sync with the palette's pick (COD-151);
// fall back to a bare value set when the picker mixin isn't loaded.
if (typeof this.selectQuickStartCase === 'function') {
this.selectQuickStartCase(item.caseName);
} else {
caseSelect.value = item.caseName;
}
}
await this.run();
}
},
// ═══════════════════════════════════════════════════════════════
// Session Manager Modal (COD-121)
// Persistent, header-reachable session list (GET /api/sessions/unified)
// reachable mid-session, with a server-side search box. Reuses the
// unit-2 history item renderer so clicking resumes/switches sessions.
// Unified session list (GET /api/sessions/unified) reachable mid-session,
// with a server-side search box. Reuses the history item renderer; clicking
// a live row switches to it, a history row resumes the conversation.
// ═══════════════════════════════════════════════════════════════
async openSessionManager() {
@@ -338,6 +582,95 @@ Object.assign(CodemanApp.prototype, {
if (modal) modal.classList.remove('active');
},
/** Replace the Session Manager list body with a single status line. */
_setSessionManagerMessage(list, message) {
list.replaceChildren();
const line = document.createElement('p');
line.className = 'empty-message';
line.textContent = message;
list.appendChild(line);
},
async _loadSessionManagerList(q = '') {
this._sessionManagerQuery = q;
const list = document.getElementById('sessionManagerList');
if (!list) return;
try {
const url = '/api/sessions/unified?limit=200' + (q ? '&q=' + encodeURIComponent(q) : '');
const res = await fetch(url);
const data = await res.json().catch(() => null);
// ApiResponse envelope: { success: true, data: { sessions, total } }.
// Surface failures instead of rendering them as an empty result set.
if (!res.ok || !data || data.success === false || !data.data) {
this._setSessionManagerMessage(list, data?.error || `Failed to load sessions (HTTP ${res.status})`);
return;
}
const sessions = data.data.sessions || [];
list.replaceChildren();
if (sessions.length === 0) {
this._setSessionManagerMessage(list, q ? 'No sessions match your search' : 'No sessions found');
return;
}
for (const s of sessions) {
// Adapt UnifiedSessionItem (lastActivityAt epoch-ms, optional fields) to
// the history-record shape _buildHistoryItem renders (lastModified date
// string, sizeBytes, firstPrompt).
const record = {
sessionId: s.sessionId,
workingDir: s.workingDir || '',
sizeBytes: s.sizeBytes ?? 0,
lastModified: new Date(s.lastActivityAt ?? s.createdAt ?? Date.now()).toISOString(),
firstPrompt: s.firstPrompt || s.name || '',
};
const isLive = !!this.sessions?.has?.(s.sessionId);
const item = this._buildHistoryItem(record, this.cases, {
showViewAll: false,
onActivate: () => {
this.closeSessionManager();
if (isLive) {
void this.selectSession(s.sessionId);
} else if (record.workingDir) {
// History rows are keyed by the Claude conversation UUID; resumed
// sessions carry theirs separately as claudeSessionId.
void this.resumeHistorySession(s.claudeSessionId || s.sessionId, record.workingDir);
}
},
});
list.appendChild(item);
}
} catch (err) {
console.error('[_loadSessionManagerList]', err);
this._setSessionManagerMessage(list, 'Failed to load sessions');
}
},
// ═══════════════════════════════════════════════════════════════
// Away Digest Modal
// ═══════════════════════════════════════════════════════════════
async openAwayDigest(range = 'since-last-visit') {
this.awayDigestRange = range;
this._awayDigestLoadedSuccessfully = false;
this._awayDigestSinceLastVisitGeneratedAt = undefined;
const modal = document.getElementById('awayDigestModal');
if (modal) modal.classList.add('active');
this.updateAwayDigestRangeControls();
await this.loadAwayDigest();
},
closeAwayDigest() {
const modal = document.getElementById('awayDigestModal');
const generatedAt = this._awayDigestSinceLastVisitGeneratedAt;
if (Number.isFinite(generatedAt)) {
try {
localStorage.setItem(AWAY_DIGEST_LAST_VIEWED_KEY, String(generatedAt));
} catch (err) {
console.warn('Failed to save away digest last-viewed marker:', err);
}
}
if (modal) modal.classList.remove('active');
},
/**
* COD-121: live-refresh the unified session list when sessions change
* (created/updated/deleted via SSE). Only touches surfaces that are currently
@@ -360,41 +693,6 @@ Object.assign(CodemanApp.prototype, {
}
},
async _loadSessionManagerList(q = '') {
this._sessionManagerQuery = q;
const list = document.getElementById('sessionManagerList');
if (!list) return;
try {
const url = '/api/sessions/unified?limit=200' + (q ? '&q=' + encodeURIComponent(q) : '');
const res = await fetch(url);
const data = await res.json();
const sessions = data.data?.sessions || [];
list.replaceChildren();
if (sessions.length === 0) {
const empty = document.createElement('p');
empty.className = 'empty-message';
empty.textContent = q ? 'No sessions match your search' : 'No sessions found';
list.appendChild(empty);
return;
}
for (const s of sessions) {
const item = this._buildHistoryItem(s, this.cases, { showViewAll: false });
// COD-130: scope the modal-close to the main (resume) row only, in the
// bubble phase. The ⋯ kebab button calls stopPropagation(), so clicking
// it (or its menu) no longer closes the Session Manager modal.
item.querySelector('.history-item-main')?.addEventListener('click', () => this.closeSessionManager());
list.appendChild(item);
}
} catch (err) {
console.error('[_loadSessionManagerList]', err);
list.replaceChildren();
const errLine = document.createElement('p');
errLine.className = 'empty-message';
errLine.textContent = 'Failed to load sessions';
list.appendChild(errLine);
}
},
setAwayDigestRange(range) {
this.awayDigestRange = range;
this._awayDigestLoadedSuccessfully = false;
+414 -55
View File
@@ -44,6 +44,203 @@ Object.assign(CodemanApp.prototype, {
// Quick Start
// ═══════════════════════════════════════════════════════════════
formatCasePickerLabel(c) {
return c?.location === 'remote' && c.remote?.hostId ? `${c.name} @ ${c.remote.hostId}` : c?.name || '';
},
buildCasePickerOptions(cases = []) {
const normalized = [];
const seen = new Set();
for (const c of cases) {
if (!c?.name || seen.has(c.name)) continue;
seen.add(c.name);
normalized.push(c);
}
if (!seen.has('testcase')) {
normalized.push({ name: 'testcase' });
}
return normalized
.map(c => {
const label = this.formatCasePickerLabel(c);
const searchText = [
c.name,
label,
c.path,
c.location,
c.remote?.hostId,
c.remote?.label,
c.remote?.path
].filter(Boolean).join(' ').toLowerCase();
return { name: c.name, label, case: c, searchText };
})
.sort((a, b) => a.label.localeCompare(b.label, undefined, { sensitivity: 'base', numeric: true }));
},
filterCasePickerOptions(options, query) {
const terms = String(query || '').trim().toLowerCase().split(/\s+/).filter(Boolean);
if (terms.length === 0) return options;
return options.filter(option => terms.every(term => option.searchText.includes(term)));
},
getCasePickerOptions() {
return this.buildCasePickerOptions(this.cases || []);
},
updateCasePickerInput(caseName) {
const input = document.getElementById('quickStartCaseSearch');
if (!input) return;
const option = this.getCasePickerOptions().find(item => item.name === caseName);
input.value = option?.label || caseName || 'testcase';
input.title = option?.label || input.value;
},
renderQuickStartCaseSelectOptions(select, options) {
if (!select) return;
select.innerHTML = options
.map(option => `<option value="${escapeHtml(option.name)}">${escapeHtml(option.label)}</option>`)
.join('');
},
openCasePicker(filter = '') {
const input = document.getElementById('quickStartCaseSearch');
const list = document.getElementById('quickStartCaseList');
if (!input || !list) return;
this._casePickerOpen = true;
this._casePickerFilter = filter;
this._casePickerActiveIndex = 0;
input.setAttribute('aria-expanded', 'true');
this.renderCasePickerList();
},
closeCasePicker() {
const input = document.getElementById('quickStartCaseSearch');
const list = document.getElementById('quickStartCaseList');
this._casePickerOpen = false;
this._casePickerFilter = '';
input?.setAttribute('aria-expanded', 'false');
input?.removeAttribute('aria-activedescendant');
list?.classList.add('hidden');
},
renderCasePickerList() {
const input = document.getElementById('quickStartCaseSearch');
const list = document.getElementById('quickStartCaseList');
const select = document.getElementById('quickStartCase');
if (!input || !list || !select) return;
const options = this.filterCasePickerOptions(this.getCasePickerOptions(), this._casePickerFilter || '');
const selectedName = select.value || 'testcase';
const maxIndex = Math.max(0, options.length - 1);
this._casePickerActiveIndex = Math.min(Math.max(this._casePickerActiveIndex || 0, 0), maxIndex);
if (options.length === 0) {
list.innerHTML = '<div class="case-combobox-empty">No cases match</div>';
list.classList.remove('hidden');
input.removeAttribute('aria-activedescendant');
return;
}
list.innerHTML = options
.map((option, index) => {
const active = index === this._casePickerActiveIndex;
const selected = option.name === selectedName;
const id = `quickStartCaseOption-${index}`;
return `
<button
type="button"
id="${id}"
class="case-combobox-option ${active ? 'active' : ''} ${selected ? 'selected' : ''}"
role="option"
aria-selected="${selected ? 'true' : 'false'}"
data-case="${escapeHtml(option.name)}"
title="${escapeHtml(option.label)}">
<span class="case-combobox-check">${selected ? '✓' : ''}</span>
<span class="case-combobox-option-label">${escapeHtml(option.label)}</span>
</button>
`;
})
.join('');
list.classList.remove('hidden');
input.setAttribute('aria-activedescendant', `quickStartCaseOption-${this._casePickerActiveIndex}`);
},
selectQuickStartCase(caseName, { save = true } = {}) {
const select = document.getElementById('quickStartCase');
if (!select) return;
select.value = caseName || 'testcase';
this.updateCasePickerInput(select.value);
this.closeCasePicker();
this.updateDirDisplayForCase(select.value);
this.updateMobileCaseLabel(select.value);
if (save) {
this.saveLastUsedCase(select.value);
}
},
setupQuickStartCasePicker() {
const select = document.getElementById('quickStartCase');
const input = document.getElementById('quickStartCaseSearch');
const list = document.getElementById('quickStartCaseList');
const picker = document.getElementById('quickStartCasePicker');
if (!select || !input || !list || !picker || input.dataset.listenerAdded) return;
input.addEventListener('focus', () => {
input.select?.();
this.openCasePicker('');
});
input.addEventListener('click', () => {
input.select?.();
this.openCasePicker('');
});
input.addEventListener('input', () => {
this.openCasePicker(input.value);
});
input.addEventListener('keydown', event => {
const options = this.filterCasePickerOptions(this.getCasePickerOptions(), this._casePickerFilter || input.value);
if (event.key === 'ArrowDown') {
event.preventDefault();
this._casePickerActiveIndex = Math.min((this._casePickerActiveIndex || 0) + 1, Math.max(0, options.length - 1));
this._casePickerOpen ? this.renderCasePickerList() : this.openCasePicker(input.value);
} else if (event.key === 'ArrowUp') {
event.preventDefault();
this._casePickerActiveIndex = Math.max((this._casePickerActiveIndex || 0) - 1, 0);
this._casePickerOpen ? this.renderCasePickerList() : this.openCasePicker(input.value);
} else if (event.key === 'Enter') {
const option = options[this._casePickerActiveIndex || 0];
if (option) {
event.preventDefault();
this.selectQuickStartCase(option.name);
this.run?.();
}
} else if (event.key === 'Escape') {
event.preventDefault();
this.updateCasePickerInput(select.value);
this.closeCasePicker();
} else if (event.key === 'Tab') {
this.updateCasePickerInput(select.value);
this.closeCasePicker();
}
});
list.addEventListener('mousedown', event => event.preventDefault());
list.addEventListener('click', event => {
const option = event.target.closest?.('.case-combobox-option');
if (option?.dataset?.case) {
this.selectQuickStartCase(option.dataset.case);
}
});
if (document.addEventListener && !this._casePickerDocumentListenerAdded) {
document.addEventListener('pointerdown', event => {
if (!picker.contains(event.target)) {
this.updateCasePickerInput(select.value);
this.closeCasePicker();
}
});
this._casePickerDocumentListenerAdded = true;
}
input.dataset.listenerAdded = 'true';
},
async loadQuickStartCases(selectCaseName = null, settingsPromise = null) {
try {
// Load settings to get lastUsedCase (reuse shared promise if provided)
@@ -64,25 +261,8 @@ Object.assign(CodemanApp.prototype, {
const select = document.getElementById('quickStartCase');
// Build options - existing cases first, then testcase as fallback if not present
let options = '';
const hasTestcase = cases.some(c => c.name === 'testcase');
const isMobile = MobileDetection.getDeviceType() === 'mobile';
const maxNameLength = isMobile ? 8 : 20; // Truncate to 8 chars on mobile
cases.forEach(c => {
const displayName = c.name.length > maxNameLength
? c.name.substring(0, maxNameLength) + '…'
: c.name;
options += `<option value="${escapeHtml(c.name)}">${escapeHtml(displayName)}</option>`;
});
// Add testcase option if it doesn't exist (will be created on first run)
if (!hasTestcase) {
options = `<option value="testcase">testcase</option>` + options;
}
select.innerHTML = options;
const options = this.getCasePickerOptions();
this.renderQuickStartCaseSelectOptions(select, options);
console.log('[loadQuickStartCases] Set options:', select.innerHTML.substring(0, 200));
// If a specific case was requested, select it
@@ -107,6 +287,9 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('dirDisplay').textContent = '~/codeman-cases/testcase';
this.updateMobileCaseLabel('testcase');
}
this.updateCasePickerInput(select.value);
this.renderCasePickerList();
this.closeCasePicker();
// Only add event listener once (on first load)
if (!select.dataset.listenerAdded) {
@@ -114,9 +297,11 @@ Object.assign(CodemanApp.prototype, {
this.updateDirDisplayForCase(select.value);
this.saveLastUsedCase(select.value);
this.updateMobileCaseLabel(select.value);
this.updateCasePickerInput(select.value);
});
select.dataset.listenerAdded = 'true';
}
this.setupQuickStartCasePicker();
} catch (err) {
console.error('Failed to load cases:', err);
}
@@ -328,6 +513,31 @@ Object.assign(CodemanApp.prototype, {
const workingDir = caseData.path;
if (!workingDir) throw new Error('Case path not found');
// Remote cases run over ssh — POST /api/sessions stat-validates workingDir on
// the LOCAL fs (a remote user@host:/path never exists locally), so route them
// through /api/quick-start, which resolves the remote case + launches via ssh.
if (caseData.location === 'remote') {
const remoteIds = [];
for (let i = 0; i < tabCount; i++) {
const res = await fetch('/api/quick-start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ caseName, mode: 'claude' })
});
const data = await res.json();
if (!data.success) throw new Error(data.error || 'Failed to start remote Claude session');
remoteIds.push(data.data.sessionId);
}
this.terminal.writeln(`\x1b[90m All ${tabCount} remote session(s) ready\x1b[0m`);
if (remoteIds[0]) {
await this.selectSession(remoteIds[0]);
this.loadQuickStartCases();
}
this.terminal.focus();
return;
}
let firstSessionId = null;
// Find the highest existing w-number for THIS case to avoid duplicates
@@ -483,9 +693,32 @@ Object.assign(CodemanApp.prototype, {
caseData = createCaseData.data.case;
}
const selectedCase = (this.cases || []).find(c => c.name === caseName);
const isRemoteCase = caseData.location === 'remote' || selectedCase?.location === 'remote';
const workingDir = caseData.path;
if (!workingDir) throw new Error('Case path not found');
// Remote cases run over ssh — route through /api/quick-start (see runClaude).
if (caseData.location === 'remote') {
const remoteIds = [];
for (let i = 0; i < shellCount; i++) {
const res = await fetch('/api/quick-start', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ caseName, mode: 'shell' })
});
const data = await res.json();
if (!data.success) throw new Error(data.error || 'Failed to start remote shell session');
remoteIds.push(data.data.sessionId);
}
if (remoteIds[0]) {
this.activeSessionId = remoteIds[0];
await this.selectSession(remoteIds[0]);
}
this.terminal.focus();
return;
}
// Find the highest existing s-number for THIS case to avoid duplicates
let startNumber = 1;
for (const [, session] of this.sessions) {
@@ -509,7 +742,7 @@ Object.assign(CodemanApp.prototype, {
fetch('/api/sessions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ workingDir, mode: 'shell', name })
body: JSON.stringify({ ...(isRemoteCase ? { caseName } : { workingDir }), mode: 'shell', name })
}).then(r => r.json())
);
const createResults = await Promise.all(createPromises);
@@ -551,6 +784,9 @@ Object.assign(CodemanApp.prototype, {
async runOpenCode() {
const caseName = document.getElementById('quickStartCase').value || 'testcase';
// Remote cases run the CLI on the REMOTE host — the local /api/opencode/status
// probe and the local-only config/env below don't apply (quick-start rejects them).
const isRemote = (this.cases || []).find(c => c.name === caseName)?.location === 'remote';
this.terminal.clear();
this.terminal.writeln(`\x1b[1;32m Starting OpenCode session in ${caseName}...\x1b[0m`);
@@ -559,13 +795,15 @@ Object.assign(CodemanApp.prototype, {
this.terminal.focus();
try {
// Check if OpenCode is available
const statusRes = await fetch('/api/opencode/status');
const status = (await statusRes.json()).data;
if (!status.available) {
this.terminal.writeln('\x1b[1;31m OpenCode CLI not found.\x1b[0m');
this.terminal.writeln('\x1b[90m Install with: curl -fsSL https://opencode.ai/install | bash\x1b[0m');
return;
// Check if OpenCode is available (local sessions only)
if (!isRemote) {
const statusRes = await fetch('/api/opencode/status');
const status = (await statusRes.json()).data;
if (!status.available) {
this.terminal.writeln('\x1b[1;31m OpenCode CLI not found.\x1b[0m');
this.terminal.writeln('\x1b[90m Install with: curl -fsSL https://opencode.ai/install | bash\x1b[0m');
return;
}
}
// Quick-start with opencode mode (auto-allow tools by default).
@@ -577,8 +815,10 @@ Object.assign(CodemanApp.prototype, {
body: JSON.stringify({
caseName,
mode: 'opencode',
openCodeConfig: { autoAllowTools: true },
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
...(isRemote ? {} : {
openCodeConfig: { autoAllowTools: true },
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
}),
})
});
const data = await res.json();
@@ -598,6 +838,9 @@ Object.assign(CodemanApp.prototype, {
async runCodex() {
const caseName = document.getElementById('quickStartCase').value || 'testcase';
// Remote cases run Codex on the REMOTE host — skip the local status probe and the
// local-only config/env below (quick-start rejects them for remote cases).
const isRemote = (this.cases || []).find(c => c.name === caseName)?.location === 'remote';
this.terminal.clear();
this.terminal.writeln(`\x1b[1;32m Starting Codex session in ${caseName}...\x1b[0m`);
@@ -605,12 +848,14 @@ Object.assign(CodemanApp.prototype, {
this.terminal.focus();
try {
const statusRes = await fetch('/api/codex/status');
const status = (await statusRes.json()).data;
if (!status.available) {
this.terminal.writeln('\x1b[1;31m Codex CLI not found.\x1b[0m');
this.terminal.writeln('\x1b[90m Install with: npm install -g @openai/codex\x1b[0m');
return;
if (!isRemote) {
const statusRes = await fetch('/api/codex/status');
const status = (await statusRes.json()).data;
if (!status.available) {
this.terminal.writeln('\x1b[1;31m Codex CLI not found.\x1b[0m');
this.terminal.writeln('\x1b[90m Install with: npm install -g @openai/codex\x1b[0m');
return;
}
}
const globalSettings = this.loadAppSettingsFromStorage();
@@ -621,11 +866,13 @@ Object.assign(CodemanApp.prototype, {
body: JSON.stringify({
caseName,
mode: 'codex',
codexConfig: {
dangerouslyBypassApprovals: globalSettings.codexDangerouslyBypassApprovals ?? false,
renderMode: 'hybrid',
},
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
...(isRemote ? {} : {
codexConfig: {
dangerouslyBypassApprovals: globalSettings.codexDangerouslyBypassApprovals ?? false,
renderMode: 'hybrid',
},
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
}),
})
});
const data = await res.json();
@@ -645,6 +892,9 @@ Object.assign(CodemanApp.prototype, {
async runGemini() {
const caseName = document.getElementById('quickStartCase').value || 'testcase';
// Remote cases run Gemini on the REMOTE host — skip the local status probe and the
// local-only config/env below (quick-start rejects them for remote cases).
const isRemote = (this.cases || []).find(c => c.name === caseName)?.location === 'remote';
this.terminal.clear();
this.terminal.writeln(`\x1b[1;32m Starting Gemini session in ${caseName}...\x1b[0m`);
@@ -652,12 +902,14 @@ Object.assign(CodemanApp.prototype, {
this.terminal.focus();
try {
const statusRes = await fetch('/api/gemini/status');
const status = (await statusRes.json()).data;
if (!status.available) {
this.terminal.writeln('\x1b[1;31m Gemini CLI not found.\x1b[0m');
this.terminal.writeln('\x1b[90m Install with: npm install -g @google/gemini-cli\x1b[0m');
return;
if (!isRemote) {
const statusRes = await fetch('/api/gemini/status');
const status = (await statusRes.json()).data;
if (!status.available) {
this.terminal.writeln('\x1b[1;31m Gemini CLI not found.\x1b[0m');
this.terminal.writeln('\x1b[90m Install with: npm install -g @google/gemini-cli\x1b[0m');
return;
}
}
const envOverrides = this.buildEnvOverrides(this.getCaseSettings(caseName), this.loadAppSettingsFromStorage());
@@ -667,8 +919,10 @@ Object.assign(CodemanApp.prototype, {
body: JSON.stringify({
caseName,
mode: 'gemini',
geminiConfig: { approvalMode: 'yolo' },
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
...(isRemote ? {} : {
geminiConfig: { approvalMode: 'yolo' },
...(Object.keys(envOverrides).length > 0 ? { envOverrides } : {}),
}),
})
});
const data = await res.json();
@@ -1254,6 +1508,23 @@ Object.assign(CodemanApp.prototype, {
document.getElementById('newCaseDescription').value = '';
document.getElementById('linkCaseName').value = '';
document.getElementById('linkCasePath').value = '';
const remoteFields = [
'remoteCaseName',
'remoteCasePath',
'remoteHostId',
'remoteHostAddress',
'remoteHostUsername',
'remoteHostPort',
'remoteHostCodexCommand',
'remoteHostIdentityFile',
'remoteHostSocksProxy',
'remoteHostJumpHost',
'remoteHostExtraSshOptions',
];
remoteFields.forEach(id => {
const el = document.getElementById(id);
if (el) el.value = '';
});
// Reset to first tab
this.caseModalTab = 'case-create';
this.switchCaseModalTab('case-create');
@@ -1295,13 +1566,16 @@ Object.assign(CodemanApp.prototype, {
this.renderCaseManageList();
} else {
submitBtn.style.display = '';
submitBtn.textContent = tabName === 'case-create' ? 'Create' : 'Link';
submitBtn.textContent =
tabName === 'case-create' ? 'Create' : tabName === 'case-remote' ? 'Link Remote' : 'Link';
}
// Focus appropriate input
if (tabName === 'case-create') {
document.getElementById('newCaseName').focus();
} else if (tabName === 'case-link') {
document.getElementById('linkCaseName').focus();
} else if (tabName === 'case-remote') {
document.getElementById('remoteCaseName').focus();
}
},
@@ -1317,6 +1591,8 @@ Object.assign(CodemanApp.prototype, {
try {
if (this.caseModalTab === 'case-create') {
await this.createCase();
} else if (this.caseModalTab === 'case-remote') {
await this.linkRemoteCase();
} else {
await this.linkCase();
}
@@ -1407,6 +1683,86 @@ Object.assign(CodemanApp.prototype, {
}
},
async linkRemoteCase() {
const name = document.getElementById('remoteCaseName').value.trim();
const remotePath = document.getElementById('remoteCasePath').value.trim();
const hostId = document.getElementById('remoteHostId').value.trim();
const host = document.getElementById('remoteHostAddress').value.trim();
const username = document.getElementById('remoteHostUsername').value.trim();
const codexCommand = document.getElementById('remoteHostCodexCommand').value.trim();
// COD-107 — port + advanced SSH connection options.
const portRaw = document.getElementById('remoteHostPort').value.trim();
const identityFile = document.getElementById('remoteHostIdentityFile').value.trim();
const socksProxy = document.getElementById('remoteHostSocksProxy').value.trim();
const jumpHost = document.getElementById('remoteHostJumpHost').value.trim();
const extraSshOptions = document.getElementById('remoteHostExtraSshOptions').value
.split('\n')
.map(line => line.trim())
.filter(line => line.length > 0);
if (!name || !remotePath || !hostId || !host || !username) {
this.showToast('Please complete all required remote fields', 'error');
return;
}
if (!/^[a-zA-Z0-9_-]+$/.test(name) || !/^[a-zA-Z0-9_-]+$/.test(hostId)) {
this.showToast('Invalid name. Use only letters, numbers, hyphens, underscores.', 'error');
return;
}
if (!remotePath.startsWith('/')) {
this.showToast('Remote path must be absolute', 'error');
return;
}
let port;
if (portRaw) {
port = Number(portRaw);
if (!Number.isInteger(port) || port < 1 || port > 65535) {
this.showToast('SSH port must be a number between 1 and 65535', 'error');
return;
}
}
try {
const hostPayload = {
id: hostId,
label: hostId,
host,
username,
...(port ? { port } : {}),
...(identityFile ? { identityFile } : {}),
...(socksProxy ? { socksProxy } : {}),
...(jumpHost ? { jumpHost } : {}),
...(extraSshOptions.length ? { extraSshOptions } : {}),
...(codexCommand ? { commands: { codex: codexCommand } } : {}),
};
const hostRes = await fetch('/api/remote-hosts', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(hostPayload)
});
const hostData = await hostRes.json();
if (!hostData.success && hostData.errorCode !== 'ALREADY_EXISTS') {
throw new Error(hostData.error || 'Failed to save remote host');
}
const caseRes = await fetch('/api/cases/remote-link', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name, hostId, remotePath })
});
const caseData = await caseRes.json();
if (caseData.success) {
this.closeCreateCaseModal();
this.showToast(`Remote case "${name}" linked`, 'success');
await this.loadQuickStartCases(name);
await this.saveLastUsedCase(name);
} else {
this.showToast(caseData.error || 'Failed to link remote case', 'error');
}
} catch (err) {
console.error('Failed to link remote case:', err);
this.showToast('Failed to link remote case: ' + err.message, 'error');
}
},
// ═══════════════════════════════════════════════════════════════
// Case Management (reorder + delete)
@@ -1484,7 +1840,14 @@ Object.assign(CodemanApp.prototype, {
// Refresh the dropdown
const select = document.getElementById('quickStartCase');
const currentCase = select.value;
if (currentCase === name) {
// Blur the native picker before reload so it doesn't show the stale value
select.blur?.();
}
await this.loadQuickStartCases(currentCase === name ? null : currentCase);
if (currentCase === name) {
await this.saveLastUsedCase(document.getElementById('quickStartCase')?.value || 'testcase');
}
} else {
this.showToast(data.error || 'Failed to delete case', 'error');
}
@@ -1521,11 +1884,7 @@ Object.assign(CodemanApp.prototype, {
// Build case list HTML
let html = '';
const cases = this.cases || [];
// Add testcase if not in list
const hasTestcase = cases.some(c => c.name === 'testcase');
const allCases = hasTestcase ? cases : [{ name: 'testcase' }, ...cases];
const allCases = this.getCasePickerOptions();
for (const c of allCases) {
const isSelected = c.name === currentCase;
@@ -1537,7 +1896,7 @@ Object.assign(CodemanApp.prototype, {
<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>
</span>
<span class="mobile-case-item-name">${escapeHtml(c.name)}</span>
<span class="mobile-case-item-name">${escapeHtml(c.label)}</span>
<span class="mobile-case-item-delete" onclick="event.stopPropagation(); app.deleteCaseMobile(${escapeHtml(JSON.stringify(c.name))})" title="Delete">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
+138
View File
@@ -471,6 +471,9 @@ Object.assign(CodemanApp.prototype, {
modal.querySelectorAll('.modal-tab-content').forEach(content => {
content.classList.toggle('hidden', content.id !== tabName);
});
// The Shortcuts tab renders lazily so the list reflects the CURRENT
// registry (defaults + overrides) every time it is opened.
if (tabName === 'settings-shortcuts') this.renderShortcutSettingsList?.();
},
closeAppSettings() {
@@ -1464,6 +1467,9 @@ Object.assign(CodemanApp.prototype, {
// with no UI left to turn it back off. Preserve the prior stored preference.
if (_prev.showTokenCount !== undefined) settings.showTokenCount = _prev.showTokenCount;
if (_prev.showCost !== undefined) settings.showCost = _prev.showCost;
// Shortcut overrides are edited from the Shortcuts tab (not rebuilt from the
// general-settings DOM), so the fresh rebuild would drop them on every save.
if (_prev.shortcutOverrides !== undefined) settings.shortcutOverrides = _prev.shortcutOverrides;
// Save to localStorage
this.saveAppSettingsToStorage(settings);
@@ -2387,6 +2393,138 @@ Object.assign(CodemanApp.prototype, {
}
},
// ─── Shortcut Settings (App Settings → Shortcuts tab) ────────────────────────
// Renders the list of shortcuts with capture buttons for key rebinding,
// and persists overrides under settings.shortcutOverrides (saved through
// saveAppSettingsToStorage so the device key + settings cache stay coherent).
renderShortcutSettingsList() {
const list = document.getElementById('appSettingsShortcutsList');
if (!list) return;
const registry = this.getShortcutRegistry
? this.getShortcutRegistry()
: typeof DEFAULT_SHORTCUTS !== 'undefined'
? DEFAULT_SHORTCUTS
: [];
const overrides = this.readShortcutOverridesFromSettings();
list.innerHTML = registry
.map((shortcut) => {
const bindingLabel = shortcut.displayBindings
? shortcut.displayBindings.join(' / ')
: (shortcut.bindings || []).map((b) => [...(b.modifiers || []), b.key || b.code || ''].join('+')).join(' / ');
// Only registry entries dispatched through matchesShortcutEvent() are
// configurable; fixed keys (Escape, tab arrows, …) render read-only.
const configurable = !!shortcut.action && Array.isArray(shortcut.bindings);
const overridden = !!overrides[shortcut.id];
const controls = configurable
? `<button type="button" class="shortcut-capture-btn" data-shortcut-action="capture" title="Capture new binding">Edit</button>
<button type="button" class="shortcut-reset-btn" data-shortcut-action="reset" title="Reset to default"${overridden ? '' : ' disabled'}>Reset</button>
<input class="shortcut-enabled-checkbox" type="checkbox" ${shortcut.disabled ? '' : 'checked'} data-shortcut-action="toggle" title="Enable/disable">`
: '';
return `<div class="shortcut-setting-row${configurable ? '' : ' shortcut-setting-row--fixed'}" data-shortcut-id="${escapeHtml(shortcut.id)}">
<label class="shortcut-setting-label">${escapeHtml(shortcut.label)}</label>
<input class="shortcut-binding-input" type="text" readonly value="${escapeHtml(bindingLabel)}" placeholder="(none)" data-id="${escapeHtml(shortcut.id)}">
${controls}
</div>`;
})
.join('');
this._wireShortcutSettingsList(list);
},
// Delegated handlers (no inline onclick — registry ids never land inside a
// JS string context, and the listeners survive re-renders).
_wireShortcutSettingsList(list) {
if (list.dataset.shortcutListenersAdded) return;
list.dataset.shortcutListenersAdded = 'true';
list.addEventListener('click', (e) => {
const btn = e.target?.closest?.('[data-shortcut-action]');
if (!btn) return;
const id = btn.closest?.('[data-shortcut-id]')?.dataset?.shortcutId;
if (!id) return;
if (btn.dataset.shortcutAction === 'capture') this.startShortcutCapture(id);
else if (btn.dataset.shortcutAction === 'reset') this.resetShortcutOverride(id);
});
list.addEventListener('change', (e) => {
const box = e.target;
if (!box?.matches?.('[data-shortcut-action="toggle"]')) return;
const id = box.closest?.('[data-shortcut-id]')?.dataset?.shortcutId;
if (id) this.toggleShortcutEnabled(id, box.checked);
});
},
readShortcutOverridesFromSettings() {
const settings = this.loadAppSettingsFromStorage();
return settings.shortcutOverrides || {};
},
startShortcutCapture(shortcutId) {
const input = document.querySelector(`.shortcut-binding-input[data-id="${shortcutId}"]`);
if (!input) return;
input.value = 'Press keys…';
input.focus();
this._capturingShortcutId = shortcutId;
// Persistent listener (NOT {once}) — the first keydown of a combo like
// Ctrl+Shift+P is the modifier itself ('Control'), which must not end the
// capture. The first non-modifier key completes it.
const onCaptureKeydown = (e) => {
e.preventDefault();
e.stopPropagation();
if (e.key === 'Control' || e.key === 'Shift' || e.key === 'Alt' || e.key === 'Meta') return;
input.removeEventListener('keydown', onCaptureKeydown);
this.onShortcutCaptureKeydown(e, shortcutId);
};
input.addEventListener('keydown', onCaptureKeydown);
},
onShortcutCaptureKeydown(e, shortcutId) {
e.preventDefault();
e.stopPropagation();
this._capturingShortcutId = null;
if (e.key === 'Escape') {
this.renderShortcutSettingsList();
return;
}
// Require a real chord: the dispatcher has no focus-target guard, so a
// bare-key binding would fire while typing in any input.
if (!e.ctrlKey && !e.metaKey && !e.altKey) {
this.renderShortcutSettingsList();
this.showToast?.('Shortcut must include Ctrl, Cmd, or Alt', 'error');
return;
}
const modifiers = [];
if (e.ctrlKey) modifiers.push('ctrl');
if (e.metaKey) modifiers.push('meta');
if (e.shiftKey) modifiers.push('shift');
if (e.altKey) modifiers.push('alt');
const settings = this.loadAppSettingsFromStorage();
const shortcutOverrides = { ...(settings.shortcutOverrides || {}) };
shortcutOverrides[shortcutId] = {
...(shortcutOverrides[shortcutId] || {}),
bindings: [{ modifiers, key: e.key, code: e.code }],
};
settings.shortcutOverrides = shortcutOverrides;
this.saveAppSettingsToStorage(settings);
this.renderShortcutSettingsList();
},
resetShortcutOverride(shortcutId) {
const settings = this.loadAppSettingsFromStorage();
const shortcutOverrides = { ...(settings.shortcutOverrides || {}) };
delete shortcutOverrides[shortcutId];
settings.shortcutOverrides = shortcutOverrides;
this.saveAppSettingsToStorage(settings);
this.renderShortcutSettingsList();
},
toggleShortcutEnabled(shortcutId, enabled) {
const settings = this.loadAppSettingsFromStorage();
const shortcutOverrides = { ...(settings.shortcutOverrides || {}) };
shortcutOverrides[shortcutId] = { ...(shortcutOverrides[shortcutId] || {}), disabled: !enabled };
settings.shortcutOverrides = shortcutOverrides;
this.saveAppSettingsToStorage(settings);
this.renderShortcutSettingsList();
},
closeAllPanels() {
this.closeSessionOptions();
this.closeAppSettings();
+406 -1
View File
@@ -626,6 +626,15 @@ body {
flex-shrink: 0;
}
.connection-dot.connected {
background: var(--green);
}
.connection-dot.fallback {
background: var(--yellow);
box-shadow: 0 0 6px var(--yellow);
}
.connection-dot.offline {
background: var(--red);
box-shadow: 0 0 6px var(--red);
@@ -3060,7 +3069,8 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
/* backdrop-filter creates a stacking context, trapping the popover's
z-index inside the toolbar. When the popover is open, raise the toolbar
above the CJK input (z-index 52) so the popover is interactable. */
.toolbar:has(.case-settings-popover:not(.hidden)) {
.toolbar:has(.case-settings-popover:not(.hidden)),
.toolbar:has(.case-combobox-list:not(.hidden)) {
z-index: 100;
}
@@ -3694,6 +3704,103 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
position: relative;
}
.case-combobox {
position: relative;
width: clamp(170px, 18vw, 280px);
}
.case-combobox-input {
width: 100%;
height: 100%;
min-height: 28px;
padding: 0.4rem 1.6rem 0.4rem 0.65rem;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.07);
border-radius: var(--btn-radius) 0 0 var(--btn-radius);
color: var(--text-dim);
font-size: 0.75rem;
font-family: inherit;
outline: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%238b8b97' stroke-width='2'%3E%3Cpath d='m6 9 6 6 6-6'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 0.5rem center;
transition: border-color var(--transition-smooth), background var(--transition-smooth), box-shadow var(--transition-smooth);
}
.case-combobox-input:hover,
.case-combobox-input:focus {
background-color: rgba(255, 255, 255, 0.07);
border-color: var(--accent);
color: var(--text);
box-shadow: 0 0 0 1px rgba(59, 130, 246, 0.2);
}
.case-combobox-list {
position: absolute;
left: 0;
bottom: calc(100% + 8px);
width: min(340px, calc(100vw - 24px));
max-height: min(320px, 55vh);
overflow-y: auto;
padding: 0.35rem;
background: rgba(22, 27, 35, 0.98);
border: 1px solid rgba(120, 141, 170, 0.28);
border-radius: 8px;
box-shadow: 0 18px 40px rgba(0, 0, 0, 0.45), 0 0 0 1px rgba(255, 255, 255, 0.04) inset;
z-index: 1000;
}
.case-combobox-list.hidden {
display: none;
}
.case-combobox-option {
display: grid;
grid-template-columns: 18px minmax(0, 1fr);
align-items: center;
width: 100%;
min-height: 34px;
padding: 0.35rem 0.45rem;
background: transparent;
border: 1px solid transparent;
border-radius: 6px;
color: var(--text-dim);
font: inherit;
font-size: 0.78rem;
text-align: left;
cursor: pointer;
}
.case-combobox-option:hover,
.case-combobox-option.active {
background: rgba(59, 130, 246, 0.16);
border-color: rgba(96, 165, 250, 0.28);
color: var(--text);
}
.case-combobox-option.selected {
color: var(--text);
}
.case-combobox-check {
color: var(--green);
font-weight: 700;
}
.case-combobox-option-label {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.case-combobox-empty {
padding: 0.85rem 0.75rem;
color: var(--text-muted);
font-size: 0.78rem;
text-align: center;
}
.toolbar-select {
padding: 0.4rem 1.5rem 0.4rem 0.6rem;
background: rgba(255, 255, 255, 0.05);
@@ -3712,6 +3819,16 @@ body.touch-device .terminal-container .xterm .xterm-helper-textarea {
transition: all var(--transition-smooth);
}
.toolbar-select.case-native-select {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
padding: 0;
opacity: 0;
pointer-events: none;
}
.toolbar-select:hover {
border-color: rgba(255, 255, 255, 0.12);
background: rgba(255, 255, 255, 0.07);
@@ -5443,6 +5560,157 @@ kbd {
padding: 1rem;
}
/* Command palette (COD-153) */
.command-palette-modal {
align-items: flex-start;
padding-top: min(16vh, 120px);
}
.command-palette-shell {
position: relative;
width: min(92vw, 620px);
max-height: min(70vh, 620px);
display: flex;
flex-direction: column;
overflow: hidden;
background: rgba(19, 19, 22, 0.97);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 10px;
box-shadow: 0 20px 70px rgba(0, 0, 0, 0.55), 0 4px 18px rgba(0, 0, 0, 0.35);
}
.command-palette-input-row {
display: grid;
grid-template-columns: 22px minmax(0, 1fr) auto;
align-items: center;
gap: 0.55rem;
padding: 0.65rem 0.75rem;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.command-palette-search-icon {
color: var(--text-muted);
font-size: 1rem;
text-align: center;
}
.command-palette-search {
min-width: 0;
width: 100%;
background: transparent;
border: 0;
color: var(--text);
font: inherit;
font-size: 0.95rem;
outline: 0;
}
.command-palette-search:focus-visible {
box-shadow: none;
}
.command-palette-search::placeholder {
color: var(--text-muted);
}
.command-palette-input-row kbd {
padding: 0.15rem 0.35rem;
color: var(--text-muted);
background: rgba(255, 255, 255, 0.06);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 4px;
font-size: 0.68rem;
}
.command-palette-label {
padding: 0.55rem 0.8rem 0.25rem;
color: var(--text-muted);
font-size: 0.68rem;
font-weight: 600;
letter-spacing: 0;
text-transform: uppercase;
}
.command-palette-list {
display: flex;
flex-direction: column;
gap: 0.2rem;
min-height: 0;
overflow-y: auto;
padding: 0.25rem 0.45rem 0.55rem;
}
.command-palette-item {
display: grid;
grid-template-columns: 26px minmax(0, 1fr);
align-items: center;
gap: 0.55rem;
width: 100%;
min-height: 52px;
padding: 0.45rem 0.55rem;
color: var(--text);
background: transparent;
border: 1px solid transparent;
border-radius: 7px;
text-align: left;
cursor: pointer;
}
.command-palette-item:hover,
.command-palette-item.active {
background: rgba(59, 130, 246, 0.14);
border-color: rgba(59, 130, 246, 0.28);
}
.command-palette-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
color: var(--accent-hover);
background: rgba(255, 255, 255, 0.06);
border-radius: 6px;
font-size: 0.9rem;
}
.command-palette-text {
min-width: 0;
display: flex;
flex-direction: column;
gap: 0.15rem;
}
.command-palette-title,
.command-palette-subtitle {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.command-palette-title {
font-size: 0.85rem;
font-weight: 600;
}
.command-palette-subtitle {
color: var(--text-muted);
font-size: 0.72rem;
}
/* COD-192: "Browse all sessions…" escape hatch — visually secondary */
.command-palette-item--browse {
margin-top: 4px;
}
.command-palette-item--browse .command-palette-icon {
color: var(--text-muted);
background: rgba(255, 255, 255, 0.03);
}
.command-palette-item--browse .command-palette-title {
color: var(--text-muted);
font-weight: 500;
}
/* Session Manager modal (COD-121) */
.session-manager-modal {
max-width: 720px;
@@ -5516,6 +5784,121 @@ kbd {
font-size: 0.72rem;
}
/* Shortcut settings rows (App Settings → Shortcuts, COD-157) */
.shortcut-setting-row {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 190px) auto auto auto;
align-items: center;
gap: 0.5rem;
padding: 0.4rem 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.shortcut-setting-row:last-child {
border-bottom: none;
}
.shortcut-setting-label {
color: var(--text);
font-size: 0.8rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.shortcut-binding-input {
min-width: 0;
padding: 0.3rem 0.5rem;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: var(--btn-radius);
color: var(--text-dim);
font-family: monospace;
font-size: 0.72rem;
cursor: default;
}
.shortcut-binding-input:focus {
border-color: var(--accent);
color: var(--text);
outline: none;
}
.shortcut-capture-btn,
.shortcut-reset-btn {
padding: 0.3rem 0.6rem;
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: var(--btn-radius);
color: var(--text-dim);
font-size: 0.72rem;
cursor: pointer;
transition: all var(--transition-smooth);
}
.shortcut-capture-btn:hover,
.shortcut-reset-btn:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.09);
color: var(--text);
}
.shortcut-reset-btn:disabled {
opacity: 0.4;
cursor: default;
}
.shortcut-enabled-checkbox {
accent-color: var(--accent);
cursor: pointer;
}
/* Shortcut overlay modal (registry-driven; opened with Ctrl+? / Alt+?) */
.shortcut-overlay-modal .modal-content {
max-width: 560px;
}
.shortcut-overlay-group {
margin-bottom: 1rem;
}
.shortcut-overlay-group:last-child {
margin-bottom: 0;
}
.shortcut-overlay-group-label {
margin-bottom: 0.35rem;
color: var(--text-muted);
font-size: 0.68rem;
font-weight: 600;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.shortcut-overlay-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.3rem 0;
}
.shortcut-overlay-label {
color: var(--text);
font-size: 0.8rem;
}
.shortcut-overlay-keys {
flex-shrink: 0;
color: var(--text-dim);
}
.shortcut-overlay-footer {
margin-top: 0.75rem;
padding-top: 0.75rem;
border-top: 1px solid var(--border);
text-align: right;
}
.away-digest-ranges {
display: flex;
flex-wrap: wrap;
@@ -11170,3 +11553,25 @@ html[data-skin="daylight-blue"] .welcome-btn-tunnel.active:hover {
background: linear-gradient(135deg, #7c3aed, #8b5cf6);
box-shadow: 0 0 28px -4px rgba(124, 58, 237, 0.5);
}
/* ── Cron Jobs ───────────────────────────────── */
.cron-job-list { display: flex; flex-direction: column; gap: 8px; }
.cron-job-row {
display: flex; justify-content: space-between; align-items: center; gap: 12px;
padding: 10px 12px; border: 1px solid var(--border, #333); border-radius: 8px;
background: var(--panel-bg, rgba(255,255,255,0.02));
}
.cron-job-main { min-width: 0; flex: 1; }
.cron-job-name { font-weight: 600; display: flex; align-items: center; gap: 6px; flex-wrap: wrap; }
.cron-job-meta { font-size: 12px; opacity: 0.7; margin-top: 4px; overflow: hidden; text-overflow: ellipsis; }
.cron-job-actions { display: flex; gap: 6px; flex-shrink: 0; flex-wrap: wrap; justify-content: flex-end; }
.cron-badge {
font-size: 11px; padding: 1px 6px; border-radius: 10px;
background: var(--accent-bg, rgba(120,160,255,0.15)); opacity: 0.9;
}
.cron-badge-off { background: rgba(200,80,80,0.18); }
.cron-weekdays { display: flex; gap: 10px; flex-wrap: wrap; }
.cron-weekdays label { display: inline-flex; align-items: center; gap: 3px; font-weight: 400; }
.cron-job-form {
margin-top: 14px; padding-top: 12px; border-top: 1px solid var(--border, #333);
}
+64 -16
View File
@@ -120,12 +120,28 @@ Object.assign(CodemanApp.prototype, {
this.terminal.attachCustomKeyEventHandler((ev) => {
if (ev.isComposing || ev.keyCode === 229) return false;
// Let the app's Alt/Option session-nav shortcuts reach the document keydown handler
// Let the app's Alt/Option session-nav and Command Palette shortcuts reach the document keydown handler
// (app.js switches tabs by PHYSICAL e.code) instead of xterm injecting ESC<char> into
// the PTY. Mirror app.js's gate exactly — same physical codes + modifier guard — so
// macOS Option layouts (Option+1 -> "¡", Option+[ -> "“") are suppressed here too and
// macOS Option layouts (Option+1 -> "¡", Option+[ -> "“", Option+K -> "˚") are suppressed here too and
// don't leak an escape sequence into the focused terminal on every tab switch.
if (ev.altKey && !ev.ctrlKey && !ev.shiftKey && /^(Digit[1-9]|BracketLeft|BracketRight)$/.test(ev.code || '')) {
if (
ev.altKey &&
!ev.ctrlKey &&
!ev.shiftKey &&
/^(Digit[1-9]|BracketLeft|BracketRight|KeyK)$/.test(ev.code || '')
) {
return false;
}
// Command palette chord (COD-153): keep it out of the PTY. The document
// CAPTURE handler has already opened the palette by the time xterm sees
// this keydown, but its preventDefault() does NOT stop xterm — without
// this gate Ctrl+K would ALSO write 0x0b (readline kill-line) into the
// live session behind the palette, truncating whatever the user had
// typed. Route through the registry-aware checker so a rebound or
// disabled palette shortcut restores normal terminal Ctrl+K.
if (ev.type === 'keydown' && this.shouldOpenCommandPaletteFromShortcut?.(ev)) {
return false;
}
@@ -1178,6 +1194,7 @@ Object.assign(CodemanApp.prototype, {
* @param {Array} cases linked cases (for #caseName label)
* @param {object} [options]
* @param {boolean} [options.showViewAll=true] show "View all in folder" button in detail panel
* @param {Function} [options.onActivate] main-row click handler override (default: resume the conversation)
*/
_buildHistoryItem(s, cases, options) {
const showViewAll = options?.showViewAll !== false;
@@ -1220,16 +1237,25 @@ Object.assign(CodemanApp.prototype, {
item.className = 'history-item';
item.title = s.workingDir || '';
// Main row: clickable surface that triggers resume (or focuses the live tab).
// Main row: clickable surface. A caller-supplied onActivate wins (the
// Session Manager routes live rows to selectSession and history rows to
// resume). Otherwise the default focuses the live tab when the row is a
// still-running session, else resumes the conversation — keyed by the Claude
// conversation UUID (claudeSessionId) when present, since resumed sessions
// carry theirs separately from their Codeman id.
const mainRow = document.createElement('div');
mainRow.className = 'history-item-main';
mainRow.addEventListener('click', () => {
if (isLive && this.sessions.has(s.sessionId)) {
this.selectSession(s.sessionId);
} else {
this.resumeHistorySession(s.sessionId, s.workingDir || '');
}
});
mainRow.addEventListener(
'click',
options?.onActivate ||
(() => {
if (isLive && this.sessions.has(s.sessionId)) {
this.selectSession(s.sessionId);
} else {
this.resumeHistorySession(s.claudeSessionId || s.sessionId, s.workingDir || '');
}
})
);
const textCol = document.createElement('div');
textCol.className = 'history-item-text';
@@ -2247,11 +2273,25 @@ Object.assign(CodemanApp.prototype, {
* Complete a buffer load: unblock live SSE writes.
* Called when chunkedTerminalWrite finishes (or is skipped for empty buffers).
*
* Queued SSE events are DISCARDED, not flushed. The loaded buffer from the API
* is the source of truth up to the response timestamp. SSE events queued during
* the fetch+write overlap with the buffer — flushing them writes duplicate data
* (especially Ink cursor-up redraws), corrupting the terminal display.
* By default queued SSE events are DISCARDED, not flushed. For an established
* session the loaded buffer from the API is the source of truth up to the
* response timestamp; SSE events queued during the fetch+write overlap already
* appear in that buffer, so flushing them writes duplicate data (especially Ink
* cursor-up redraws), corrupting the terminal display.
*
* COD-144: a brand-new session is the exception. Its terminal fetch can resolve
* BEFORE the PTY emits its first prompt, so the fetched buffer is empty and the
* prompt arrives only as a queued SSE event. Discarding it leaves the terminal
* blank until a tab-switch re-fetches a now-populated buffer. When the caller
* knows the load painted nothing (empty fetch + no cache), it passes
* `{ flushQueued: true }` so the queued events are REPLAYED through
* `batchTerminalWrite()` instead of dropped. Replay runs after `_isLoadingBuffer`
* is cleared, so the events write through normally and are not re-queued.
*
* After unblocking, new SSE/WS events deliver subsequent output normally.
*
* @param {string} [owner] Load token from `_beginBufferLoad`; a stale owner is a no-op.
* @param {{ flushQueued?: boolean }} [opts] When `flushQueued` is true, replay any queued events.
*/
_beginBufferLoad(owner) {
if (this._bufferLoadSeq === undefined) this._bufferLoadSeq = 0;
@@ -2262,13 +2302,21 @@ Object.assign(CodemanApp.prototype, {
return loadOwner;
},
_finishBufferLoad(owner) {
_finishBufferLoad(owner, opts) {
if (owner !== undefined && this._bufferLoadOwner !== owner) {
return false;
}
const queued = this._loadBufferQueue;
this._isLoadingBuffer = false;
this._loadBufferQueue = null;
this._bufferLoadOwner = null;
// COD-144: replay (rather than discard) queued live events when the load
// painted nothing — the queued prompt is the only content a new session has.
if (opts?.flushQueued && queued && queued.length) {
for (const data of queued) {
this.batchTerminalWrite(data);
}
}
return true;
},
+142 -1
View File
@@ -11,15 +11,30 @@ import { join, resolve } from 'node:path';
import { homedir } from 'node:os';
import type { ApiResponse, CaseInfo } from '../../types.js';
import { ApiErrorCode, createErrorResponse, getErrorMessage } from '../../types.js';
import { CreateCaseSchema, LinkCaseSchema, CaseOrderSchema } from '../schemas.js';
import {
CreateCaseSchema,
LinkCaseSchema,
CaseOrderSchema,
RemoteCaseLinkSchema,
RemoteHostSchema,
} from '../schemas.js';
import { generateClaudeMd } from '../../templates/claude-md.js';
import { writeHooksConfig } from '../../hooks-config.js';
import { CASES_DIR, SETTINGS_PATH, validatePathWithinBase, parseBody, readJsonConfig } from '../route-helpers.js';
import { SseEvent } from '../sse-events.js';
import type { EventPort, ConfigPort } from '../ports/index.js';
import { dataPath, getDataDir } from '../../config/instance.js';
import {
checkRemoteTmuxAvailable,
readRemoteCases,
readRemoteHosts,
remoteDisplayPath,
writeRemoteCases,
writeRemoteHosts,
} from '../../remote-hosts.js';
const LINKED_CASES_FILE = dataPath('linked-cases.json');
const CODEMAN_CONFIG_DIR = getDataDir();
const SAFE_CASE_NAME = /^[a-zA-Z0-9_-]+$/;
/** Read and parse linked-cases.json, returning empty object on missing/invalid file. */
@@ -53,6 +68,7 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
name: e.name,
path: join(CASES_DIR, e.name),
hasClaudeMd: existsSync(join(CASES_DIR, e.name, 'CLAUDE.md')),
location: 'local',
});
}
}
@@ -69,10 +85,39 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
name,
path,
hasClaudeMd: existsSync(join(path, 'CLAUDE.md')),
linked: true,
location: 'linked-local',
});
}
}
// Get remote cases
const remoteHosts = await readRemoteHosts(CODEMAN_CONFIG_DIR);
const remoteHostMap = new Map(remoteHosts.map((host) => [host.id, host]));
for (const remoteCase of await readRemoteCases(CODEMAN_CONFIG_DIR)) {
const host = remoteHostMap.get(remoteCase.hostId);
if (!host || !SAFE_CASE_NAME.test(remoteCase.name)) continue;
existingNames.add(remoteCase.name);
const remoteCaseInfo: CaseInfo = {
name: remoteCase.name,
path: remoteDisplayPath({ username: host.username, host: host.host, path: remoteCase.remotePath }),
hasClaudeMd: false,
location: 'remote',
remote: {
hostId: host.id,
host: host.host,
username: host.username,
path: remoteCase.remotePath,
},
};
const existingIndex = cases.findIndex((item) => item.name === remoteCase.name);
if (existingIndex === -1) {
cases.push(remoteCaseInfo);
} else {
cases[existingIndex] = remoteCaseInfo;
}
}
// Sort by persisted caseOrder from settings.json
const settings = await readJsonConfig<Record<string, unknown>>(SETTINGS_PATH, 'settings', {});
const caseOrder = Array.isArray(settings.caseOrder) ? (settings.caseOrder as string[]) : [];
@@ -120,6 +165,73 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
}
});
app.get('/api/remote-hosts', async () => readRemoteHosts(CODEMAN_CONFIG_DIR));
app.post('/api/remote-hosts', async (req): Promise<ApiResponse<{ host: unknown }>> => {
const host = parseBody(RemoteHostSchema, req.body);
const hosts = await readRemoteHosts(CODEMAN_CONFIG_DIR);
if (hosts.some((item) => item.id === host.id)) {
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Remote host already exists');
}
await writeRemoteHosts(CODEMAN_CONFIG_DIR, [...hosts, host]);
return { success: true, data: { host } };
});
app.put('/api/remote-hosts/:id', async (req): Promise<ApiResponse<{ host: unknown }>> => {
const { id } = req.params as { id: string };
const host = parseBody(RemoteHostSchema, { ...(req.body as object), id });
const hosts = await readRemoteHosts(CODEMAN_CONFIG_DIR);
const index = hosts.findIndex((item) => item.id === id);
if (index === -1) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Remote host not found');
const next = [...hosts];
next[index] = host;
await writeRemoteHosts(CODEMAN_CONFIG_DIR, next);
return { success: true, data: { host } };
});
app.delete('/api/remote-hosts/:id', async (req): Promise<ApiResponse<{ id: string }>> => {
const { id } = req.params as { id: string };
const cases = await readRemoteCases(CODEMAN_CONFIG_DIR);
if (cases.some((item) => item.hostId === id)) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Remote host is still used by remote cases');
}
const hosts = await readRemoteHosts(CODEMAN_CONFIG_DIR);
await writeRemoteHosts(
CODEMAN_CONFIG_DIR,
hosts.filter((item) => item.id !== id)
);
return { success: true, data: { id } };
});
app.post('/api/cases/remote-link', async (req): Promise<ApiResponse<{ case: unknown }>> => {
const remoteCase = { ...parseBody(RemoteCaseLinkSchema, req.body), type: 'remote' as const };
const hosts = await readRemoteHosts(CODEMAN_CONFIG_DIR);
const host = hosts.find((item) => item.id === remoteCase.hostId);
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Remote host not found');
const linkedCases = await readLinkedCases();
const remoteCases = await readRemoteCases(CODEMAN_CONFIG_DIR);
if (
remoteCases.some((item) => item.name === remoteCase.name) ||
linkedCases[remoteCase.name] ||
existsSync(join(CASES_DIR, remoteCase.name))
) {
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Case already exists');
}
// Courtesy validation: tmux is a hard prerequisite for durable remote sessions.
// Verify it up-front so linking surfaces a clear error now instead of a dead pane
// at first launch (also confirms the SSH connection actually works).
const tmuxCheck = await checkRemoteTmuxAvailable(host);
if (!tmuxCheck.ok) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, tmuxCheck.error || 'remote host is missing tmux');
}
await writeRemoteCases(CODEMAN_CONFIG_DIR, [...remoteCases, remoteCase]);
ctx.broadcast(SseEvent.CaseLinked, { name: remoteCase.name, path: remoteCase.remotePath, type: 'remote' });
return { success: true, data: { case: remoteCase } };
});
// Link an existing folder as a case
app.post('/api/cases/link', async (req): Promise<ApiResponse<{ case: { name: string; path: string } }>> => {
const { name, path: folderPath } = parseBody(LinkCaseSchema, req.body, 'Invalid request body');
@@ -173,6 +285,16 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
}
const remoteCases = await readRemoteCases(CODEMAN_CONFIG_DIR);
if (remoteCases.some((item) => item.name === name)) {
await writeRemoteCases(
CODEMAN_CONFIG_DIR,
remoteCases.filter((item) => item.name !== name)
);
ctx.broadcast(SseEvent.CaseDeleted, { name, type: 'remote-unlinked' });
return { success: true, data: { name } };
}
// Check linked cases first — unlink only, don't delete the actual directory
const linkedCases = await readLinkedCases();
if (linkedCases[name]) {
@@ -233,6 +355,25 @@ export function registerCaseRoutes(app: FastifyInstance, ctx: EventPort & Config
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
}
const remoteCases = await readRemoteCases(CODEMAN_CONFIG_DIR);
const remoteCase = remoteCases.find((item) => item.name === name);
if (remoteCase) {
const host = (await readRemoteHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === remoteCase.hostId);
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Remote host not found');
return {
name,
path: remoteDisplayPath({ username: host.username, host: host.host, path: remoteCase.remotePath }),
hasClaudeMd: false,
location: 'remote',
remote: {
hostId: host.id,
host: host.host,
username: host.username,
path: remoteCase.remotePath,
},
};
}
const casePath = await resolveCasePath(name);
if (!existsSync(casePath)) {
+80
View File
@@ -0,0 +1,80 @@
/**
* @fileoverview Cron Jobs routes.
*
* CRUD + enable/disable + Run Now + run history for `CronJob`s. These are
* separate from the legacy `/api/scheduled` (ScheduledRun) endpoints — see
* docs/cron-discovery.md §0.
*/
import { FastifyInstance } from 'fastify';
import { ApiErrorCode, createErrorResponse } from '../../types.js';
import { CronJobSchema, CronJobUpdateSchema, CronJobEnabledSchema } from '../schemas.js';
import { parseBody } from '../route-helpers.js';
import type { CronPort } from '../ports/index.js';
export function registerCronRoutes(app: FastifyInstance, ctx: CronPort): void {
// ── Jobs ────────────────────────────────────────────────────────────────
app.get('/api/cron/jobs', async () => {
return ctx.cron.listJobs();
});
app.post('/api/cron/jobs', async (req) => {
// No custom errorMessage: surface the schema's field-specific messages
// (e.g. "runAt is required for a one-time schedule").
const body = parseBody(CronJobSchema, req.body);
return { job: ctx.cron.createJob(body) };
});
app.get('/api/cron/jobs/:id', async (req) => {
const { id } = req.params as { id: string };
const job = ctx.cron.getJob(id);
if (!job) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Cron job not found');
return job;
});
app.put('/api/cron/jobs/:id', async (req) => {
const { id } = req.params as { id: string };
const body = parseBody(CronJobUpdateSchema, req.body);
const job = ctx.cron.updateJob(id, body);
if (!job) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Cron job not found');
return { job };
});
app.delete('/api/cron/jobs/:id', async (req) => {
const { id } = req.params as { id: string };
if (!ctx.cron.deleteJob(id)) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Cron job not found');
}
return {};
});
app.put('/api/cron/jobs/:id/enabled', async (req) => {
const { id } = req.params as { id: string };
const { enabled } = parseBody(CronJobEnabledSchema, req.body, 'Invalid request body');
const job = ctx.cron.setEnabled(id, enabled);
if (!job) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Cron job not found');
return { job };
});
// ── Run Now ──────────────────────────────────────────────────────────────
app.post('/api/cron/jobs/:id/run', async (req) => {
const { id } = req.params as { id: string };
const job = ctx.cron.getJob(id);
if (!job) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Cron job not found');
const run = await ctx.cron.runNow(id);
return { run, activeAgents: ctx.cron.countActiveAgents(job.agentType, job.id) };
});
// ── Run history ──────────────────────────────────────────────────────────
app.get('/api/cron/jobs/:id/runs', async (req) => {
const { id } = req.params as { id: string };
return ctx.cron.listRuns(id);
});
app.get('/api/cron/runs', async () => {
return ctx.cron.listRuns();
});
}
+1
View File
@@ -7,6 +7,7 @@ export { registerTeamRoutes } from './team-routes.js';
export { registerMuxRoutes } from './mux-routes.js';
export { registerFileRoutes } from './file-routes.js';
export { registerScheduledRoutes } from './scheduled-routes.js';
export { registerCronRoutes } from './cron-routes.js';
export { registerSystemRoutes } from './system-routes.js';
export { registerHookEventRoutes } from './hook-event-routes.js';
export { registerStatusTelemetryRoutes } from './status-telemetry-routes.js';
+4
View File
@@ -246,6 +246,10 @@ export function registerRespawnRoutes(
}
}
// Re-attach listener wiring if a prior PTY exit detached it (the wiring exit
// handler removes ALL session listeners; idempotent — no-op while still attached).
await ctx.setupSessionListeners(session);
// Start interactive session
await session.startInteractive();
getLifecycleLog().log({
+548 -76
View File
@@ -5,7 +5,7 @@
*/
import { FastifyInstance } from 'fastify';
import { join, dirname, extname } from 'node:path';
import { join, dirname, extname, basename } from 'node:path';
import { homedir } from 'node:os';
import { existsSync, statSync, mkdirSync, writeFileSync } from 'node:fs';
import { execFile } from 'node:child_process';
@@ -34,6 +34,7 @@ import {
FlickerFilterSchema,
QuickRunSchema,
QuickStartSchema,
InteractiveStartSchema,
} from '../schemas.js';
import {
autoConfigureRalph,
@@ -54,6 +55,7 @@ import {
} from '../../hooks-config.js';
import { generateClaudeMd } from '../../templates/claude-md.js';
import { imageWatcher } from '../../image-watcher.js';
import { convertHeicToJpeg } from '../heic-jpeg-converter.js';
import { getLifecycleLog } from '../../session-lifecycle-log.js';
import {
mergeUnifiedSessions,
@@ -70,10 +72,13 @@ import { RunSummaryTracker } from '../../run-summary.js';
import { MAX_INPUT_LENGTH, MAX_SESSION_NAME_LENGTH } from '../../config/terminal-limits.js';
import { MAX_PASTE_IMAGE_BYTES } from '../../config/buffer-limits.js';
import { dataPath } from '../../config/instance.js';
import { dataPath, getDataDir } from '../../config/instance.js';
import { checkRemoteTmuxAvailable, readRemoteCases, readRemoteHosts, toSessionRemote } from '../../remote-hosts.js';
import { LRUMap } from '../../utils/lru-map.js';
// Path to linked-cases registry (same file used by case-routes resolveCasePath)
const LINKED_CASES_FILE = dataPath('linked-cases.json');
const CODEMAN_CONFIG_DIR = getDataDir();
// Pre-compiled regex for terminal buffer cleaning (avoids per-request compilation)
// eslint-disable-next-line no-control-regex
@@ -198,6 +203,15 @@ export function imageMagicMatchesExt(data: Buffer, ext: string): boolean {
return u32be(0) === 0x52494646 && u32be(8) === 0x57454250;
case '.bmp':
return data[0] === 0x42 && data[1] === 0x4d;
case '.heic':
case '.heif': {
// ISO Base Media File Format: size + "ftyp" + major brand. The brand
// list matches heic-decode's own isHeic() — accepting more brands here
// would only route bytes into a conversion that always throws.
if (u32be(4) !== 0x66747970) return false;
const brand = data.subarray(8, 12).toString('ascii');
return ['heic', 'heix', 'hevc', 'hevx', 'mif1', 'msf1'].includes(brand);
}
default:
return false;
}
@@ -620,6 +634,14 @@ export function registerSessionRoutes(
app.post('/api/sessions/:id/interactive', async (req) => {
const { id } = req.params as { id: string };
// Body is optional (auto-reattach callers send none) — same idiom as /interactive-respawn.
const bodyResult = req.body
? InteractiveStartSchema.safeParse(req.body)
: { success: true as const, data: {} as { clearBreaker?: boolean } };
if (!bodyResult.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
}
const { clearBreaker } = bodyResult.data;
const session = findSessionOrFail(ctx, id);
if (session.isBusy()) {
@@ -642,6 +664,20 @@ export function registerSessionRoutes(
}
}
// COD-118: ONLY an explicit user-initiated restart (body {clearBreaker:true})
// clears a tripped PTY-exit circuit breaker. This endpoint is ALSO the frontend's
// automatic re-attach path (selectSession auto-POSTs it for any pid===null
// session), so an unconditional reset here would re-arm the exact crash loop
// the breaker exists to stop — auto-reattach sends no body and must not clear.
if (clearBreaker) {
session.resetRespawnBreaker();
}
// Re-attach listener wiring if a prior PTY exit detached it: the wiring exit
// handler removes ALL session listeners (incl. respawnBreakerTripped), and only
// session-create/boot-recovery paths ran setupSessionListeners before this fix —
// without this, a re-attached session's SSE/terminal/trip events go unobserved.
// setupSessionListeners is idempotent (no-op while refs are still attached).
await ctx.setupSessionListeners(session);
await session.startInteractive();
getLifecycleLog().log({
event: 'started',
@@ -669,6 +705,8 @@ export function registerSessionRoutes(
}
try {
// Re-attach listener wiring if a prior PTY exit detached it (see /interactive).
await ctx.setupSessionListeners(session);
await session.startShell();
getLifecycleLog().log({
event: 'started',
@@ -872,6 +910,14 @@ export function registerSessionRoutes(
const { id } = req.params as { id: string };
const session = findSessionOrFail(ctx, id);
// Codex sessions don't write to ~/.claude/projects — their transcripts
// live in ~/.codex/sessions/**. Branch to a Codex-specific reader so the
// response-viewer works for Codex panes too.
if (session.mode === 'codex') {
const codexQuery = req.query as { context?: string };
return await readCodexLastResponse(session, codexQuery.context === 'full');
}
// Scan ~/.claude/projects/*/ for the transcript file
const projectsDir = join(process.env.HOME || '/tmp', '.claude', 'projects');
@@ -985,15 +1031,346 @@ export function registerSessionRoutes(
};
});
function isCodexInjectedContext(text: string): boolean {
return (
/^# AGENTS\.md instructions\b/i.test(text) ||
/^<environment_context\b/i.test(text) ||
/^<turn_aborted\b/i.test(text) ||
/^<codex_internal_context\b/i.test(text) ||
/^<recommended_plugins\b/i.test(text) ||
/^<user_instructions\b/i.test(text) ||
/^# Options\b/i.test(text)
);
}
// ── Codex response-viewer support ───────────────────────────────────────────────────────
// Read the rollout's session_meta identity fields (plus turn_context cwd as
// a fallback when the huge session_meta line got truncated by the head read).
function readCodexRolloutMeta(head: string): { cwd?: string; originator?: string } {
let cwd: string | undefined;
let originator: string | undefined;
for (const line of head.split('\n')) {
if (!line) continue;
try {
const entry = JSON.parse(line) as {
type?: string;
payload?: { cwd?: string; originator?: string };
};
if (entry.type === 'session_meta') {
cwd ??= entry.payload?.cwd;
originator ??= entry.payload?.originator;
} else if (entry.type === 'turn_context') {
cwd ??= entry.payload?.cwd;
}
} catch {
// Malformed or truncated head line — keep scanning.
}
if (cwd && originator) break;
}
return { cwd, originator };
}
// The pane's last Enter (Session.codexLastSubmitAt) correlated against
// ~/.codex/history.jsonl, which logs every submitted user message as
// {session_id, ts}. This identifies the thread the pane is ACTUALLY on and
// is the only signal that survives /resume, /new and /fork typed inside the
// codex TUI itself. An entry is credited to this pane only when its Enter is
// the closest among all codex panes, so a menu keystroke in another pane
// can't steal the attribution.
const codexHistoryPinCache = new LRUMap<string, { submitAt: number; threadId: string }>({ maxSize: 1024 });
async function resolveCodexThreadFromHistory(
session: { id: string; codexLastSubmitAt?: number },
codexHome: string
): Promise<string | null> {
const submitAt = session.codexLastSubmitAt || 0;
if (!submitAt) return null;
const cached = codexHistoryPinCache.get(session.id);
if (cached && cached.submitAt === submitAt) return cached.threadId;
const histPath = join(codexHome, 'history.jsonl');
const st = await fs.stat(histPath).catch(() => null);
if (!st || st.size === 0) return null;
const tail = await readFileTail(histPath, Buffer.alloc(65536), st.size);
if (!tail) return null;
const WINDOW_MS = 15_000;
const otherSubmits: number[] = [];
for (const s of ctx.sessions.values()) {
if (s.id !== session.id && s.mode === 'codex' && s.codexLastSubmitAt) {
otherSubmits.push(s.codexLastSubmitAt);
}
}
let best: { threadId: string; dist: number } | undefined;
for (const line of tail.split('\n')) {
if (!line) continue;
let e: { session_id?: string; ts?: number };
try {
e = JSON.parse(line);
} catch {
continue; // first tail line may be cut mid-JSON
}
if (!e.session_id || typeof e.ts !== 'number') continue;
const tsMs = e.ts * 1000; // history timestamps are unix seconds
const dist = Math.abs(tsMs - submitAt);
if (dist > WINDOW_MS) continue;
if (otherSubmits.some((o) => Math.abs(tsMs - o) < dist)) continue; // another pane is closer
if (!best || dist < best.dist) best = { threadId: e.session_id, dist };
}
if (!best) return null;
codexHistoryPinCache.set(session.id, { submitAt, threadId: best.threadId });
return best.threadId;
}
// Locate THIS pane's rollout, in order of confidence:
// 0. history match — the thread the pane last submitted a message to
// (see resolveCodexThreadFromHistory); tracks the pane through
// /resume //new //fork typed inside the TUI.
// 1. originator match — Codeman spawns codex panes with
// CODEX_INTERNAL_ORIGINATOR_OVERRIDE=codeman_<sessionId>, which codex
// writes into session_meta.originator of every rollout it creates
// (including new files after /new in the same pane; newest match wins).
// 2. resume-id match — resumed rollouts keep their ORIGINAL session_meta
// (codex appends without rewriting it), so originator matching can't
// see them; but the rollout uuid is in the filename and we know the id.
// 3. legacy cwd+mtime heuristic — panes started before this feature, or
// TUI-resumed threads before their first tracked submit. Case-blind
// cwd compare (codex records the launch-time case, /mnt paths vary)
// and rollouts claimed by OTHER codeman panes are excluded.
async function findActiveCodexFile(session: {
id: string;
workingDir: string;
codexLastSubmitAt?: number;
codexConfig?: { resumeSessionId?: string };
}): Promise<string | null> {
const codexHome = process.env.CODEX_HOME || join(process.env.HOME || '/tmp', '.codex');
const sessionsDir = join(codexHome, 'sessions');
const files: Array<{ path: string; mtimeMs: number }> = [];
const walk = async (dir: string): Promise<void> => {
let entries: import('node:fs').Dirent[];
try {
entries = await fs.readdir(dir, { withFileTypes: true });
} catch {
return;
}
for (const entry of entries) {
const fullPath = join(dir, entry.name);
if (entry.isDirectory()) {
await walk(fullPath);
continue;
}
if (!entry.isFile() || !entry.name.endsWith('.jsonl')) continue;
const st = await fs.stat(fullPath).catch(() => null);
if (!st || st.size < 100) continue;
files.push({ path: fullPath, mtimeMs: st.mtimeMs });
}
};
await walk(sessionsDir);
files.sort((a, b) => b.mtimeMs - a.mtimeMs);
const historyThreadId = await resolveCodexThreadFromHistory(session, codexHome);
if (historyThreadId) {
const hit = files.find((f) => basename(f.path).endsWith(`-${historyThreadId}.jsonl`));
if (hit) return hit.path;
}
const rawResumeId = session.codexConfig?.resumeSessionId;
const resumeId =
rawResumeId && /^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(rawResumeId)
? rawResumeId
: undefined;
const idMatch = resumeId ? files.find((f) => basename(f.path).endsWith(`-${resumeId}.jsonl`)) : undefined;
// Scan newest-first for our originator; anything strictly older than the
// id match can never beat it, so the head reads stop there (mtime ties are
// still scanned — a /new rollout may land in the same clock tick). The
// 128 KiB head budget covers the session_meta line, which embeds full
// base_instructions (observed max ~22 KiB on codex 0.144).
const originator = `codeman_${session.id}`;
const wantCwd = session.workingDir.toLowerCase();
const headBuf = Buffer.alloc(131072);
let cwdFallback: { path: string; mtimeMs: number } | undefined;
for (const f of files) {
if (idMatch && f.mtimeMs < idMatch.mtimeMs) break;
const meta = await readCodexRolloutMetaCached(f.path, headBuf);
if (!meta) continue;
if (meta.originator === originator) return f.path; // newest-first → first hit wins
if (
!cwdFallback &&
!idMatch &&
meta.cwd?.toLowerCase() === wantCwd &&
// A rollout stamped by another codeman pane belongs to that pane.
!(meta.originator?.startsWith('codeman_') && meta.originator !== originator)
) {
cwdFallback = f;
}
}
return idMatch?.path ?? cwdFallback?.path ?? null;
}
// session_meta is written once when codex creates the rollout and never
// rewritten (verified: resume appends without touching it), so the parsed
// identity of a given path can be cached forever. This turns the per-request
// scan into stat calls plus head reads for new files only.
const codexRolloutMetaCache = new LRUMap<string, { cwd?: string; originator?: string }>({ maxSize: 4096 });
async function readCodexRolloutMetaCached(
filePath: string,
headBuf: Buffer
): Promise<{ cwd?: string; originator?: string } | null> {
const cached = codexRolloutMetaCache.get(filePath);
if (cached) return cached;
const head = await readFileHead(filePath, headBuf);
if (!head) return null;
const meta = readCodexRolloutMeta(head);
// Don't cache a still-incomplete head: a rollout being created may not
// have flushed session_meta/turn_context yet.
if (!meta.cwd && !meta.originator) return meta;
codexRolloutMetaCache.set(filePath, meta);
return meta;
}
function extractCodexBlockText(content: unknown, kinds: string[]): string {
if (typeof content === 'string') return content;
if (!Array.isArray(content)) return '';
return content
.filter(
(b): b is { type: string; text: string } =>
!!b &&
typeof b === 'object' &&
kinds.includes((b as { type?: string }).type || '') &&
typeof (b as { text?: string }).text === 'string'
)
.map((b) => b.text)
.join('\n\n');
}
// Single pass over a Codex rollout: track the last assistant message (for the
// default eye view) and, when `full`, the whole user/assistant thread.
//
// User turns come from event_msg/user_message when available: codex emits one
// per REAL user input, and injected context (AGENTS.md, environment_context,
// compaction summaries, …) never appears there — so no filtering heuristics.
// response_item user rows duplicate those inputs mixed with the injections;
// they are kept only as a fallback for old rollouts without event_msg rows.
async function readCodexLastResponse(
session: { id: string; workingDir: string; codexConfig?: { resumeSessionId?: string } },
full: boolean
): Promise<{
text: string;
timestamp: string;
messages?: Array<{ role: string; text: string; timestamp?: string }>;
}> {
const empty = full ? { text: '', timestamp: '', messages: [] } : { text: '', timestamp: '' };
const filePath = await findActiveCodexFile(session);
if (!filePath) return empty;
let content: string;
try {
content = await fs.readFile(filePath, 'utf8');
} catch {
return empty;
}
let lastText = '';
let lastTimestamp = '';
const messages: Array<{ role: string; text: string; timestamp?: string; legacyUser?: boolean }> = [];
// Multiset of event-sourced user texts: a real input appears BOTH as an
// event_msg and as a response_item row, so each event text cancels exactly
// one legacy twin. Legacy rows without an event twin (turns written by an
// older codex appending to the same rollout) survive — a file-wide boolean
// would wrongly drop them.
const eventUserTexts = new Map<string, number>();
for (const line of content.split('\n')) {
if (!line) continue;
let entry: {
timestamp?: string;
type?: string;
payload?: {
type?: string;
role?: string;
content?: unknown;
message?: unknown;
images?: unknown;
local_images?: unknown;
};
};
try {
entry = JSON.parse(line);
} catch {
continue;
}
if (full && entry.type === 'event_msg' && entry.payload?.type === 'user_message') {
let text = typeof entry.payload.message === 'string' ? entry.payload.message.trim() : '';
if (text && isCodexInjectedContext(text)) continue;
if (text) eventUserTexts.set(text, (eventUserTexts.get(text) || 0) + 1);
// Image-only (or image+text) inputs: the text field alone would make
// the turn vanish, so surface a placeholder.
const imageCount =
(Array.isArray(entry.payload.images) ? entry.payload.images.length : 0) +
(Array.isArray(entry.payload.local_images) ? entry.payload.local_images.length : 0);
if (imageCount > 0) text = text ? `${text}\n\n*[image ×${imageCount}]*` : `*[image ×${imageCount}]*`;
if (text) messages.push({ role: 'user', text, timestamp: entry.timestamp });
continue;
}
if (entry.type !== 'response_item' || entry.payload?.type !== 'message') continue;
const role = entry.payload?.role;
if (role === 'assistant') {
const text = extractCodexBlockText(entry.payload?.content, ['output_text', 'text']);
if (text) {
lastText = text;
lastTimestamp = entry.timestamp || '';
if (full) messages.push({ role: 'assistant', text, timestamp: entry.timestamp });
}
} else if (role === 'user' && full) {
const text = extractCodexBlockText(entry.payload?.content, ['input_text', 'text']).trim();
// Drop Codex's injected context turns (AGENTS.md, environment_context, …)
// so the thread shows real user prompts only.
if (text && !isCodexInjectedContext(text)) {
messages.push({ role: 'user', text, timestamp: entry.timestamp, legacyUser: true });
}
}
}
const thread = messages
.filter((m) => {
if (!m.legacyUser) return true;
const n = eventUserTexts.get(m.text) || 0;
if (n > 0) {
eventUserTexts.set(m.text, n - 1);
return false; // duplicate of an event_msg row already in the thread
}
return true;
})
.map(({ role, text, timestamp }) => ({ role, text, timestamp }));
return full
? { text: lastText, timestamp: lastTimestamp, messages: thread }
: { text: lastText, timestamp: lastTimestamp };
}
// ========== Get Terminal Buffer ==========
// Query params:
// tail=<bytes> - Only return last N bytes (faster initial load)
// full=1 - Full page reload: replay the entire tmux scrollback (COD-47)
app.get('/api/sessions/:id/terminal', async (req) => {
const { id } = req.params as { id: string };
const query = req.query as { tail?: string };
const query = req.query as { tail?: string; full?: string };
const session = findSessionOrFail(ctx, id);
// `full=1` is the EXPLICIT full-reload signal (COD-47): the browser reloaded
// the page and wants the whole scroll history back, so we capture the ENTIRE
// tmux scrollback and the user gets back history that scrolled off Codeman's
// byte buffer. Requests WITHOUT it — tab switches (`tail=`) and the legacy
// no-param callers (response-viewer fallback, clearTerminal refresh) — keep
// the fast visible-frame capture.
const tailBytes = query.tail ? parseInt(query.tail, 10) : 0;
const isFullReload = query.full === '1' || query.full === 'true';
const { tmuxHistoryLimit, terminalBufferMaxBytes } = await ctx.getTerminalHistoryConfig();
// Prepend the live tmux pane buffer so tab-switch replay shows the current
// on-screen frame, not just the accumulated byte history. This matters for
// TUI modes (codex/opencode) that repaint only their latest frame: the
@@ -1004,24 +1381,57 @@ export function registerSessionRoutes(
const muxName = session.muxName;
const liveMuxBuffer =
muxName && typeof ctx.mux.captureActivePaneBuffer === 'function'
? ctx.mux.captureActivePaneBuffer(muxName)
? ctx.mux.captureActivePaneBuffer(
muxName,
isFullReload
? { fullHistory: true, historyLimitLines: tmuxHistoryLimit, maxCaptureBytes: terminalBufferMaxBytes }
: undefined
)
: null;
const rawBuffer =
liveMuxBuffer !== null && liveMuxBuffer.length > 0
? session.terminalBufferLength > 0
const hasLiveMuxBuffer = liveMuxBuffer !== null && liveMuxBuffer.length > 0;
const source: 'history' | 'mux-visible' | 'mux-full-history' = hasLiveMuxBuffer
? isFullReload
? 'mux-full-history'
: 'mux-visible'
: 'history';
let rawBuffer: string;
if (liveMuxBuffer !== null && liveMuxBuffer.length > 0) {
// Full-history capture is the RENDERED form of everything already in the
// byte buffer (up to tmux eviction) — return it alone. Prepending the byte
// history would replay the whole conversation twice: `\x1b[2J` clears only
// the viewport, not xterm scrollback. The history+clear+frame concat stays
// for the visible-frame path, where the single pane frame lacks history.
rawBuffer = isFullReload
? liveMuxBuffer
: session.terminalBufferLength > 0
? `${session.terminalBuffer}\x1b[H\x1b[2J${liveMuxBuffer}`
: liveMuxBuffer
: session.terminalBuffer;
const tailBytes = query.tail ? parseInt(query.tail, 10) : 0;
: liveMuxBuffer;
} else {
rawBuffer = session.terminalBuffer;
}
const fullSize = rawBuffer.length;
let truncated = false;
let cleanBuffer: string;
// Cap the payload EARLY — before the regex normalization passes below run
// over it. A full-history tmux capture can be tens of MB of scrollback;
// normalizing all of it would stall the event loop only to discard most
// bytes anyway. Keep the most RECENT bytes (slice from the end) and align
// to a line boundary so we never start mid-ANSI-escape.
if (terminalBufferMaxBytes > 0 && rawBuffer.length > terminalBufferMaxBytes) {
rawBuffer = rawBuffer.slice(-terminalBufferMaxBytes);
truncated = true;
const capNewline = rawBuffer.indexOf('\n');
if (capNewline > 0 && capNewline < 4096) {
rawBuffer = rawBuffer.slice(capNewline + 1);
}
}
// Strip redundant Ink spinner/status redraws BEFORE tailing.
// During long thinking phases, Ink rewrites the same rows thousands of times
// (500KB+). Without stripping, tail mode returns only spinner frames and
// the terminal appears empty when switching tabs.
let strippedBuffer = stripInkRedrawBloat(rawBuffer);
let strippedBuffer = session.mode === 'shell' ? rawBuffer : stripInkRedrawBloat(rawBuffer);
// Strip alt-screen toggles and scrollback-erase from Codex/Claude byte
// streams. xterm.js obeys them by switching to its scrollback-less alt
@@ -1069,6 +1479,7 @@ export function registerSessionRoutes(
status: session.status,
fullSize,
truncated,
source,
};
});
@@ -1269,81 +1680,125 @@ export function registerSessionRoutes(
effort,
} = parseBody(QuickStartSchema, req.body);
// Check OpenCode availability if requested
if (mode === 'opencode') {
const { isOpenCodeAvailable } = await import('../../utils/opencode-cli-resolver.js');
if (!isOpenCodeAvailable()) {
// Resolve the remote case FIRST — the CLI executes on the REMOTE host over ssh,
// so the LOCAL availability gates below (isCodexAvailable() etc.) don't apply and
// would wrongly reject a machine that hasn't got the CLI installed locally.
let remote = undefined;
let casePath: string | null = null;
const remoteCases = await readRemoteCases(CODEMAN_CONFIG_DIR);
const remoteCase = remoteCases.find((item) => item.name === caseName);
if (remoteCase) {
const host = (await readRemoteHosts(CODEMAN_CONFIG_DIR)).find((item) => item.id === remoteCase.hostId);
if (!host) return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Remote host not found');
// Per-session config that is applied to the LOCAL tmux/CLI wrapper (env vars via
// tmux setenv, effort/model CLI args, codex/gemini/opencode config) does NOT
// cross ssh, so it would silently no-op. Reject rather than pretend it worked —
// remote command/env customization goes through the per-host command override.
if (
(envOverrides && Object.keys(envOverrides).length > 0) ||
effort ||
codexConfig ||
geminiConfig ||
openCodeConfig
) {
return createErrorResponse(
ApiErrorCode.OPERATION_FAILED,
'OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash'
ApiErrorCode.INVALID_INPUT,
'envOverrides, effort, and per-CLI config are not supported for remote cases (they do not cross ssh). Configure the remote command via the host command override instead.'
);
}
}
// Check Codex availability if requested
if (mode === 'codex') {
const { isCodexAvailable } = await import('../../utils/codex-cli-resolver.js');
if (!isCodexAvailable()) {
return createErrorResponse(
ApiErrorCode.OPERATION_FAILED,
'Codex CLI not found. Install with: npm install -g @openai/codex'
);
// tmux is a hard prerequisite on the remote host (the agent runs inside a remote
// tmux server so it survives ssh drops). Probe before spawning so a missing tmux
// surfaces a clear, structured error instead of a dead "tmux: command not found" pane.
const tmuxCheck = await checkRemoteTmuxAvailable(host);
if (!tmuxCheck.ok) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, tmuxCheck.error || 'remote host is missing tmux');
}
}
// Check Gemini availability if requested
if (mode === 'gemini') {
const { isGeminiAvailable } = await import('../../utils/gemini-cli-resolver.js');
if (!isGeminiAvailable()) {
return createErrorResponse(
ApiErrorCode.OPERATION_FAILED,
'Gemini CLI not found. Install with: npm install -g @google/gemini-cli'
);
casePath = remoteCase.remotePath;
remote = toSessionRemote(host, remoteCase);
} else {
// Check OpenCode availability if requested
if (mode === 'opencode') {
const { isOpenCodeAvailable } = await import('../../utils/opencode-cli-resolver.js');
if (!isOpenCodeAvailable()) {
return createErrorResponse(
ApiErrorCode.OPERATION_FAILED,
'OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash'
);
}
}
}
// Resolve case path: check linked-cases registry first, then fall back to CASES_DIR.
// This mirrors the behaviour of resolveCasePath() in case-routes so that linked
// external project directories are honoured by quick-start just like regular case routes.
let linkedCases: Record<string, string> = {};
try {
const raw = await fs.readFile(LINKED_CASES_FILE, 'utf-8');
linkedCases = JSON.parse(raw);
} catch {
// File missing or unparseable — treat as empty registry
}
const linkedCasePath = linkedCases[caseName];
const casePath = linkedCasePath || validatePathWithinBase(caseName, CASES_DIR);
if (!casePath) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path');
}
// Check Codex availability if requested
if (mode === 'codex') {
const { isCodexAvailable } = await import('../../utils/codex-cli-resolver.js');
if (!isCodexAvailable()) {
return createErrorResponse(
ApiErrorCode.OPERATION_FAILED,
'Codex CLI not found. Install with: npm install -g @openai/codex'
);
}
}
// Create case folder and CLAUDE.md if it doesn't exist (only for non-linked cases)
if (!existsSync(casePath)) {
// Check Gemini availability if requested
if (mode === 'gemini') {
const { isGeminiAvailable } = await import('../../utils/gemini-cli-resolver.js');
if (!isGeminiAvailable()) {
return createErrorResponse(
ApiErrorCode.OPERATION_FAILED,
'Gemini CLI not found. Install with: npm install -g @google/gemini-cli'
);
}
}
// Resolve case path: check linked-cases registry first, then fall back to CASES_DIR.
// This mirrors the behaviour of resolveCasePath() in case-routes so that linked
// external project directories are honoured by quick-start just like regular case routes.
let linkedCases: Record<string, string> = {};
try {
mkdirSync(casePath, { recursive: true });
mkdirSync(join(casePath, 'src'), { recursive: true });
const raw = await fs.readFile(LINKED_CASES_FILE, 'utf-8');
linkedCases = JSON.parse(raw);
} catch {
// File missing or unparseable — treat as empty registry
}
casePath = linkedCases[caseName] || validatePathWithinBase(caseName, CASES_DIR);
if (!casePath) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path');
}
}
// By this point casePath is guaranteed non-null: for remote cases it was set from remoteCase.remotePath,
// for local cases the !casePath guard above returned early. TypeScript can't narrow across the if/else.
const resolvedCasePath = casePath as string;
// Create case folder and CLAUDE.md if it doesn't exist (only for non-linked, non-remote cases)
if (!remote && !existsSync(resolvedCasePath)) {
try {
mkdirSync(resolvedCasePath, { recursive: true });
mkdirSync(join(resolvedCasePath, 'src'), { recursive: true });
// Read settings to get custom template path
const templatePath = await ctx.getDefaultClaudeMdPath();
const claudeMd = generateClaudeMd(caseName, '', templatePath);
writeFileSync(join(casePath, 'CLAUDE.md'), claudeMd);
writeFileSync(join(resolvedCasePath, 'CLAUDE.md'), claudeMd);
// Write .claude/settings.local.json with hooks for desktop notifications
// (Claude-specific — OpenCode, Codex, and Gemini use their own systems)
if (mode !== 'opencode' && mode !== 'codex' && mode !== 'gemini') {
await writeHooksConfig(casePath);
await writeHooksConfig(resolvedCasePath);
}
ctx.broadcast(SseEvent.CaseCreated, { name: caseName, path: casePath });
ctx.broadcast(SseEvent.CaseCreated, { name: caseName, path: resolvedCasePath });
} catch (err) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to create case: ${getErrorMessage(err)}`);
}
} else if (mode !== 'opencode') {
} else if (!remote && mode !== 'opencode') {
// COD-91 self-heal for an EXISTING case: refresh a pre-secret hooks block so the
// now-unconditional hook-secret gate keeps accepting its hook events. No-op when
// the hooks aren't ours or already carry the secret.
await refreshStaleHookSecret(casePath).catch(() => {});
// the hooks aren't ours or already carry the secret. Skipped for remote cases —
// resolvedCasePath is a REMOTE path that doesn't exist on the local filesystem.
await refreshStaleHookSecret(resolvedCasePath).catch(() => {});
}
// Strip stale disk entries for keys this request is actively setting (Claude only —
@@ -1352,10 +1807,11 @@ export function registerSessionRoutes(
mode !== 'opencode' &&
mode !== 'codex' &&
mode !== 'gemini' &&
!remote &&
envOverrides &&
Object.keys(envOverrides).length > 0
) {
await stripCaseEnvKeys(casePath, Object.keys(envOverrides));
await stripCaseEnvKeys(resolvedCasePath, Object.keys(envOverrides));
}
// Create a new session with the case as working directory
@@ -1375,7 +1831,7 @@ export function registerSessionRoutes(
const qsClaudeModeConfig = await ctx.getClaudeModeConfig();
const qsTerminalHistoryConfig = await ctx.getTerminalHistoryConfig();
const session = new Session({
workingDir: casePath,
workingDir: resolvedCasePath,
mux: ctx.mux,
useMux: true,
mode: mode,
@@ -1388,13 +1844,14 @@ export function registerSessionRoutes(
geminiConfig: mode === 'gemini' ? geminiConfig : undefined,
envOverrides,
effort,
remote,
tmuxHistoryLimit: qsTerminalHistoryConfig.tmuxHistoryLimit,
});
// Auto-detect completion phrase from CLAUDE.md BEFORE broadcasting
// so the initial state already has the phrase configured (only if globally enabled)
if (mode === 'claude' && ctx.store.getConfig().ralphEnabled) {
autoConfigureRalph(session, casePath, ctx);
if (mode === 'claude' && !remote && ctx.store.getConfig().ralphEnabled) {
autoConfigureRalph(session, resolvedCasePath, ctx);
if (!session.ralphTracker.enabled) {
session.ralphTracker.enable();
session.ralphTracker.enableAutoEnable(); // Allow re-enabling on restart
@@ -1463,7 +1920,7 @@ export function registerSessionRoutes(
return {
sessionId: session.id,
casePath,
casePath: resolvedCasePath,
caseName,
};
} catch (err) {
@@ -1875,7 +2332,7 @@ export function registerSessionRoutes(
// Paste Image (clipboard / drag-drop upload)
// ═══════════════════════════════════════════════════════════════
const ALLOWED_IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp']);
const ALLOWED_IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.heic', '.heif']);
// The per-file size cap (MAX_PASTE_IMAGE_BYTES) is enforced by @fastify/multipart (registered in server.ts).
app.post('/api/sessions/:id/paste-image', async (req, reply) => {
@@ -1967,7 +2424,7 @@ export function registerSessionRoutes(
const origExt = extname(part.filename).toLowerCase();
if (ALLOWED_IMAGE_EXTS.has(origExt)) ext = origExt;
}
const mimeMatch = (part.mimetype || '').toLowerCase().match(/^image\/(png|jpeg|jpg|webp|gif|bmp)$/);
const mimeMatch = (part.mimetype || '').toLowerCase().match(/^image\/(png|jpeg|jpg|webp|gif|bmp|heic|heif)$/);
if (mimeMatch) {
const map: Record<string, string> = {
png: '.png',
@@ -1976,6 +2433,8 @@ export function registerSessionRoutes(
webp: '.webp',
gif: '.gif',
bmp: '.bmp',
heic: '.heic',
heif: '.heif',
};
ext = map[mimeMatch[1]] ?? ext;
}
@@ -1988,14 +2447,27 @@ export function registerSessionRoutes(
);
}
// Sniff actual bytes — filename and Content-Type are both attacker-supplied.
// Polyglot HTML/PNG would otherwise pass and serve back with image/png MIME.
if (!imageMagicMatchesExt(imageBytes, ext)) {
// Diagnostic: on some Android galleries (e.g. MIUI) a WebP/HEIF is
// mislabeled as image/jpeg, so the declared ext passes the allowlist but
// the magic bytes do not. Log the real header so format mismatches can be
// pinned down without a reproduce-and-guess loop. The client now
// re-encodes images to JPEG/PNG before upload, so this should be rare.
// Route HEIC on the raw bytes, NOT the declared ext/mime: on some Android
// galleries (e.g. MIUI) a HEIF comes back mislabeled as image/jpeg, and
// browsers that cannot decode HEIF upload the original file as-is — so a
// HEIC payload can arrive under any declared type. Filename and
// Content-Type are attacker-supplied anyway; only the bytes are trusted.
if (imageMagicMatchesExt(imageBytes, '.heic')) {
try {
imageBytes = await convertHeicToJpeg(imageBytes);
ext = '.jpg';
} catch (err: unknown) {
console.warn(
`[paste-image] HEIC conversion failed: filename=${JSON.stringify(part.filename)} mime=${JSON.stringify(part.mimetype)} error=${getErrorMessage(err)}`
);
reply.code(415);
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Could not convert HEIC image to JPEG');
}
} else if (!imageMagicMatchesExt(imageBytes, ext)) {
// Sniff actual bytes — a polyglot HTML/PNG would otherwise pass and
// serve back with image/png MIME. Log the real header so format
// mismatches can be pinned down without a reproduce-and-guess loop. The
// client re-encodes images to JPEG/PNG before upload, so this is rare.
console.warn(
`[paste-image] magic mismatch: filename=${JSON.stringify(part.filename)} mime=${JSON.stringify(part.mimetype)} declaredExt=${ext} magic=${imageBytes.subarray(0, 12).toString('hex')}`
);
+210 -166
View File
@@ -34,6 +34,7 @@ import type { WebSocket } from 'ws';
import type { SessionPort } from '../ports/session-port.js';
import { MAX_INPUT_LENGTH } from '../../config/terminal-limits.js';
import { isAllowedRequestHost, isAllowedRequestOrigin, type HostPolicy } from '../network-auth-policy.js';
import { WsConnectionRegistry } from '../ws-connection-registry.js';
/** Micro-batch interval for terminal output (ms). Short enough for low latency,
* long enough to group Ink's rapid cursor-up redraw sequences into single frames. */
@@ -59,197 +60,240 @@ const DEC_2026_END = '\x1b[?2026l';
/** Max concurrent WS connections per session. Prevents listener/bandwidth multiplication. */
const MAX_WS_PER_SESSION = 5;
/** Track active WS connections per session for connection limiting. */
const sessionWsCount = new Map<string, number>();
/**
* Track live WS connections per session, keyed by clientId (COD-137).
* Replaces a bare counter that over-counted across the async-close gap on
* reconnect (spurious 4008). A same-`cid` reconnect supersedes its own socket
* (reclaims the slot) instead of consuming a new one; cid-less upgrades are
* admitted anonymously up to the cap. See ws-connection-registry.ts.
*/
const sessionWsRegistry = new WsConnectionRegistry<WebSocket>(MAX_WS_PER_SESSION);
export function registerWsRoutes(app: FastifyInstance, ctx: SessionPort, getHostPolicy: () => HostPolicy): void {
app.get<{ Params: { id: string } }>('/ws/sessions/:id/terminal', { websocket: true }, (socket: WebSocket, req) => {
// Reject cross-site WebSocket hijacking (CSWSH) and DNS-rebinding before doing
// anything: the upgrade must come from an allowed Host and (when the browser
// sends one — it always does for WS) a same-site Origin. Writing to this socket
// injects keystrokes into a --dangerously-skip-permissions agent, so this gate
// matters even on the default no-password install. See security review H5.
const policy = getHostPolicy();
if (!isAllowedRequestHost(req.headers.host, policy) || !isAllowedRequestOrigin(req.headers.origin, policy)) {
socket.close(4003, 'Forbidden');
return;
}
app.get<{ Params: { id: string }; Querystring: { cid?: string } }>(
'/ws/sessions/:id/terminal',
{ websocket: true },
(socket: WebSocket, req) => {
// Reject cross-site WebSocket hijacking (CSWSH) and DNS-rebinding before doing
// anything: the upgrade must come from an allowed Host and (when the browser
// sends one — it always does for WS) a same-site Origin. Writing to this socket
// injects keystrokes into a --dangerously-skip-permissions agent, so this gate
// matters even on the default no-password install. See security review H5.
const policy = getHostPolicy();
if (!isAllowedRequestHost(req.headers.host, policy) || !isAllowedRequestOrigin(req.headers.origin, policy)) {
socket.close(4003, 'Forbidden');
return;
}
const { id } = req.params;
const session = ctx.sessions.get(id);
const { id } = req.params;
const session = ctx.sessions.get(id);
if (!session) {
socket.close(4004, 'Session not found');
return;
}
if (!session) {
socket.close(4004, 'Session not found');
return;
}
// Enforce per-session connection limit
const currentCount = sessionWsCount.get(id) ?? 0;
if (currentCount >= MAX_WS_PER_SESSION) {
socket.close(4008, 'Too many connections');
return;
}
sessionWsCount.set(id, currentCount + 1);
// Structured transport logging — surfaces WS open/close/timeout churn so the
// tunnel-flap behavior (COD-134) is observable in the server logs. Fastify is
// configured logger:false, so we log via console (→ journald under systemd).
// Swallow socket errors — cleanup happens in 'close'
socket.on('error', () => {});
// Enforce per-session connection limit, scoped by clientId. A same-cid
// reconnect reclaims its own slot (registry evicts the stale socket), so a
// drop+reconnect burst can no longer over-count across the async-close gap
// and trip a spurious 4008. cid-less upgrades are admitted anonymously.
const cid = typeof req.query?.cid === 'string' && req.query.cid.length > 0 ? req.query.cid : null;
const { admitted, evictedSocket } = sessionWsRegistry.register(id, cid, socket);
if (!admitted) {
console.warn('[ws] terminal rejected: too many connections', {
sessionId: id,
wsCount: sessionWsRegistry.liveCount(id),
});
socket.close(4008, 'Too many connections');
return;
}
if (evictedSocket) {
// Same client reconnected; retire the stale socket so it doesn't linger.
console.info('[ws] terminal superseded by reconnect', { sessionId: id });
try {
evictedSocket.close(4010, 'Superseded by reconnect');
} catch {
/* socket may already be closing */
}
}
console.info('[ws] terminal open', { sessionId: id, wsCount: sessionWsRegistry.liveCount(id) });
// Per-connection micro-batch state
let batchChunks: string[] = [];
let batchSize = 0;
let batchTimer: ReturnType<typeof setTimeout> | null = null;
// Eagerly free the slot on error/terminate — don't wait for the async
// 'close' (idempotent with the 'close' handler below). This is what kills
// the reconnect over-count: the slot is released the instant the socket dies.
socket.on('error', () => {
sessionWsRegistry.unregister(id, socket);
});
const flushBatch = () => {
batchTimer = null;
if (batchChunks.length === 0 || socket.readyState !== 1) {
// Per-connection micro-batch state
let batchChunks: string[] = [];
let batchSize = 0;
let batchTimer: ReturnType<typeof setTimeout> | null = null;
const flushBatch = () => {
batchTimer = null;
if (batchChunks.length === 0 || socket.readyState !== 1) {
batchChunks = [];
batchSize = 0;
return;
}
const data = batchChunks.join('');
batchChunks = [];
batchSize = 0;
return;
}
const data = batchChunks.join('');
batchChunks = [];
batchSize = 0;
socket.send(`{"t":"o","d":${JSON.stringify(DEC_2026_START + data + DEC_2026_END)}}`);
};
socket.send(`{"t":"o","d":${JSON.stringify(DEC_2026_START + data + DEC_2026_END)}}`);
};
// Per-connection desktop sizing claim — registered on the first
// desktop-typed resize and released on socket close, so Session.resize()
// can ignore small-viewport resizes only while a desktop is actually
// connected (see Session._desktopSizeClaims).
const sizingToken = Symbol('ws-desktop-sizing');
let holdsDesktopClaim = false;
// Per-connection desktop sizing claim — registered on the first
// desktop-typed resize and released on socket close, so Session.resize()
// can ignore small-viewport resizes only while a desktop is actually
// connected (see Session._desktopSizeClaims).
const sizingToken = Symbol('ws-desktop-sizing');
let holdsDesktopClaim = false;
// Attach message handler synchronously BEFORE any async work
// (@fastify/websocket requirement to avoid dropped messages).
socket.on('message', (raw) => {
try {
const msg = JSON.parse(String(raw));
if (msg.t === 'i' && typeof msg.d === 'string') {
if (msg.d.length > MAX_INPUT_LENGTH) return;
// Reliable delivery: when the frame carries a clientId + seq, apply it
// exactly once (skip a duplicate redelivery) but ACK it regardless so
// the client can drop it from its durable queue. Frames without seq
// (legacy/other tools) are applied as-is — no behavior change.
const cid = typeof msg.cid === 'string' ? msg.cid : null;
const seq = Number.isInteger(msg.seq) ? (msg.seq as number) : null;
const apply = cid && seq !== null ? session.shouldApplyInput(cid, seq) : true;
if (apply) {
// Typed input from a claim-holding desktop keeps the claim "hot"
// and re-asserts the desktop layout after a mobile override.
if (holdsDesktopClaim) session.noteDesktopActivity();
session.write(msg.d);
// Attach message handler synchronously BEFORE any async work
// (@fastify/websocket requirement to avoid dropped messages).
socket.on('message', (raw) => {
try {
const msg = JSON.parse(String(raw));
if (msg.t === 'i' && typeof msg.d === 'string') {
if (msg.d.length > MAX_INPUT_LENGTH) return;
// Reliable delivery: when the frame carries a clientId + seq, apply it
// exactly once (skip a duplicate redelivery) but ACK it regardless so
// the client can drop it from its durable queue. Frames without seq
// (legacy/other tools) are applied as-is — no behavior change.
const cid = typeof msg.cid === 'string' ? msg.cid : null;
const seq = Number.isInteger(msg.seq) ? (msg.seq as number) : null;
const apply = cid && seq !== null ? session.shouldApplyInput(cid, seq) : true;
if (apply) {
// Typed input from a claim-holding desktop keeps the claim "hot"
// and re-asserts the desktop layout after a mobile override.
if (holdsDesktopClaim) session.noteDesktopActivity();
session.write(msg.d);
}
if (seq !== null && socket.readyState === 1) {
socket.send(`{"t":"ia","seq":${seq}}`);
}
} else if (
msg.t === 'z' &&
Number.isInteger(msg.c) &&
Number.isInteger(msg.r) &&
msg.c >= 1 &&
msg.c <= 500 &&
msg.r >= 1 &&
msg.r <= 200
) {
const viewportType = msg.v === 'mobile' || msg.v === 'tablet' || msg.v === 'desktop' ? msg.v : undefined;
if (viewportType === 'desktop') {
session.claimDesktopSizing(sizingToken);
holdsDesktopClaim = true;
} else if (viewportType) {
// The connection's viewport can change (e.g. browser window
// narrowed past the tablet breakpoint) — drop a stale claim.
session.releaseDesktopSizing(sizingToken);
holdsDesktopClaim = false;
}
const force = msg.f === true;
session.resize(msg.c, msg.r, { viewportType, force });
}
if (seq !== null && socket.readyState === 1) {
socket.send(`{"t":"ia","seq":${seq}}`);
}
} else if (
msg.t === 'z' &&
Number.isInteger(msg.c) &&
Number.isInteger(msg.r) &&
msg.c >= 1 &&
msg.c <= 500 &&
msg.r >= 1 &&
msg.r <= 200
) {
const viewportType = msg.v === 'mobile' || msg.v === 'tablet' || msg.v === 'desktop' ? msg.v : undefined;
if (viewportType === 'desktop') {
session.claimDesktopSizing(sizingToken);
holdsDesktopClaim = true;
} else if (viewportType) {
// The connection's viewport can change (e.g. browser window
// narrowed past the tablet breakpoint) — drop a stale claim.
session.releaseDesktopSizing(sizingToken);
holdsDesktopClaim = false;
}
const force = msg.f === true;
session.resize(msg.c, msg.r, { viewportType, force });
} catch {
// Ignore malformed messages
}
} catch {
// Ignore malformed messages
}
});
});
// Terminal output -> micro-batched WS send
const onTerminal = (data: string) => {
if (socket.readyState !== 1) return;
batchChunks.push(data);
batchSize += data.length;
// Terminal output -> micro-batched WS send
const onTerminal = (data: string) => {
if (socket.readyState !== 1) return;
batchChunks.push(data);
batchSize += data.length;
// Flush immediately for large batches (responsiveness during bulk output)
if (batchSize > WS_BATCH_FLUSH_THRESHOLD) {
if (batchTimer) {
clearTimeout(batchTimer);
// Flush immediately for large batches (responsiveness during bulk output)
if (batchSize > WS_BATCH_FLUSH_THRESHOLD) {
if (batchTimer) {
clearTimeout(batchTimer);
}
flushBatch();
return;
}
flushBatch();
return;
}
// Start timer if not already running
if (!batchTimer) {
batchTimer = setTimeout(flushBatch, WS_BATCH_INTERVAL_MS);
}
};
// Start timer if not already running
if (!batchTimer) {
batchTimer = setTimeout(flushBatch, WS_BATCH_INTERVAL_MS);
}
};
const onClearTerminal = () => {
if (socket.readyState === 1) {
socket.send('{"t":"c"}');
}
};
const onClearTerminal = () => {
if (socket.readyState === 1) {
socket.send('{"t":"c"}');
}
};
const onNeedsRefresh = () => {
if (socket.readyState === 1) {
socket.send('{"t":"r"}');
}
};
const onNeedsRefresh = () => {
if (socket.readyState === 1) {
socket.send('{"t":"r"}');
}
};
// Close WS when session exits (deleted, respawned, or crashed) — prevents
// orphaned listeners and stale writes to a dead PTY.
const onSessionExit = () => {
socket.close(4009, 'Session terminated');
};
// Close WS when session exits (deleted, respawned, or crashed) — prevents
// orphaned listeners and stale writes to a dead PTY.
const onSessionExit = () => {
socket.close(4009, 'Session terminated');
};
session.on('terminal', onTerminal);
session.on('clearTerminal', onClearTerminal);
session.on('needsRefresh', onNeedsRefresh);
session.on('exit', onSessionExit);
session.on('terminal', onTerminal);
session.on('clearTerminal', onClearTerminal);
session.on('needsRefresh', onNeedsRefresh);
session.on('exit', onSessionExit);
// Heartbeat: detect stale connections (especially through tunnels where
// TCP RST can take minutes to propagate).
let pongTimeout: ReturnType<typeof setTimeout> | null = null;
// Heartbeat: detect stale connections (especially through tunnels where
// TCP RST can take minutes to propagate).
let pongTimeout: ReturnType<typeof setTimeout> | null = null;
socket.on('pong', () => {
if (pongTimeout) {
clearTimeout(pongTimeout);
pongTimeout = null;
}
});
socket.on('pong', () => {
if (pongTimeout) {
clearTimeout(pongTimeout);
pongTimeout = null;
}
});
const pingInterval = setInterval(() => {
if (socket.readyState !== 1) return;
socket.ping();
pongTimeout = setTimeout(() => {
socket.terminate();
}, WS_PONG_TIMEOUT_MS);
}, WS_PING_INTERVAL_MS);
const pingInterval = setInterval(() => {
if (socket.readyState !== 1) return;
socket.ping();
pongTimeout = setTimeout(() => {
console.warn('[ws] terminal ping timeout — terminating', { sessionId: id });
// Free the slot eagerly — terminate()'s 'close' may lag, and a client
// reconnecting after a stale-connection drop must not be over-counted.
sessionWsRegistry.unregister(id, socket);
socket.terminate();
}, WS_PONG_TIMEOUT_MS);
}, WS_PING_INTERVAL_MS);
socket.on('close', () => {
clearInterval(pingInterval);
if (pongTimeout) clearTimeout(pongTimeout);
if (batchTimer) clearTimeout(batchTimer);
batchChunks = [];
session.off('terminal', onTerminal);
session.off('clearTerminal', onClearTerminal);
session.off('needsRefresh', onNeedsRefresh);
session.off('exit', onSessionExit);
session.releaseDesktopSizing(sizingToken);
socket.on('close', (code: number, reason: Buffer) => {
clearInterval(pingInterval);
if (pongTimeout) clearTimeout(pongTimeout);
if (batchTimer) clearTimeout(batchTimer);
batchChunks = [];
session.off('terminal', onTerminal);
session.off('clearTerminal', onClearTerminal);
session.off('needsRefresh', onNeedsRefresh);
session.off('exit', onSessionExit);
session.releaseDesktopSizing(sizingToken);
// Decrement per-session connection count
const count = sessionWsCount.get(id) ?? 1;
if (count <= 1) {
sessionWsCount.delete(id);
} else {
sessionWsCount.set(id, count - 1);
}
});
});
// Release this socket's slot. Idempotent and identity-matched: if this
// socket was already superseded (a same-cid reconnect took its slot) or
// eagerly unregistered on terminate/error, this is a no-op and the
// reconnected socket keeps the slot.
sessionWsRegistry.unregister(id, socket);
console.info('[ws] terminal close', {
sessionId: id,
code,
reason: String(reason),
wsCount: sessionWsRegistry.liveCount(id),
});
});
}
);
}
+165
View File
@@ -271,6 +271,94 @@ export const CreateCaseSchema = z.object({
description: z.string().max(1000).optional(),
});
const RemoteCommandOverridesSchema = z
.object({
shell: z.string().min(1).max(300).optional(),
claude: z.string().min(1).max(300).optional(),
opencode: z.string().min(1).max(300).optional(),
codex: z.string().min(1).max(300).optional(),
gemini: z.string().min(1).max(300).optional(),
})
.strict()
.optional();
// COD-107 — advanced SSH connection options. These ultimately exec as shell
// (ProxyCommand etc.), but are OPERATOR-entered host config (never attacker- or
// terminal-output-influenced), so we validate as defense-in-depth, not as the
// security boundary. Reject newline/NUL/backtick/`$(` shell-injection vectors.
const NO_SHELL_INJECTION = /^[^\n\r\0`]*$/;
const noCommandSubstitution = (s: string) => !s.includes('$(');
// `remotePath`/`identityFile` are shell-escaped, then the whole launch command is
// embedded via `JSON.stringify(...)` inside `bash -c "..."` (tmux-manager). That
// outer DOUBLE-quote layer re-exposes `$(...)`, backticks, and `$VAR` even though
// the inner value is single-quoted — so a `$(cmd)` in the path would run LOCALLY at
// launch. Reject `$` and backtick (and newline/CR/NUL) entirely at the boundary.
const NO_SHELL_META = /^[^\n\r\0`$]*$/;
export const RemoteHostSchema = z.object({
id: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid remote host id'),
label: z.string().min(1).max(100),
host: z
.string()
.min(1)
.max(255)
.regex(/^[a-zA-Z0-9._:-]+$/, 'Invalid SSH host'),
username: z
.string()
.min(1)
.max(100)
.regex(/^[a-zA-Z0-9._-]+$/, 'Invalid SSH username'),
port: z.number().int().min(1).max(65535).optional(),
// Identity (private-key) file PATH only — never key bytes. Reject shell
// metacharacters ($, backtick) that survive into the `bash -c` launch layer.
identityFile: z.string().min(1).max(4096).regex(NO_SHELL_META, 'Invalid identity file path').optional(),
// SOCKS5 proxy as host:port (e.g. 127.0.0.1:1080).
socksProxy: z
.string()
.regex(/^[\w.-]+:\d{1,5}$/, 'SOCKS proxy must be host:port')
.optional(),
// SSH jump host: a comma-separated chain of [user@]host[:port] hops. Structural
// ALLOWLIST (not an open denylist) — only chars valid in user/host/port/IPv6,
// so no shell metacharacter (;, |, &, space, $, quotes, …) can appear. The value
// is also shellescaped at command-build time (buildSshConnectionArgs); this is the
// belt to that suspenders.
jumpHost: z
.string()
.min(1)
.max(255)
.regex(
/^(?:[A-Za-z0-9._-]+@)?[A-Za-z0-9.:[\]-]+(?::\d{1,5})?(?:,(?:[A-Za-z0-9._-]+@)?[A-Za-z0-9.:[\]-]+(?::\d{1,5})?)*$/,
'Jump host must be [user@]host[:port] (comma-separated for multiple hops)'
)
.optional(),
// Arbitrary extra -o KEY=VALUE options (escape hatch); each must be KEY=VALUE.
extraSshOptions: z
.array(
z
.string()
.min(3)
.max(1024)
.regex(/^[A-Za-z][A-Za-z0-9]*=.+$/, 'Extra SSH option must be KEY=VALUE')
.regex(NO_SHELL_INJECTION, 'Invalid characters in SSH option')
.refine(noCommandSubstitution, 'Invalid characters in SSH option')
)
.max(32)
.optional(),
commands: RemoteCommandOverridesSchema,
});
export const RemoteCaseLinkSchema = z.object({
name: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format'),
hostId: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid remote host id'),
remotePath: z
.string()
.min(1)
.max(2000)
.regex(/^\//, 'Remote path must be absolute')
.regex(NO_SHELL_META, 'Invalid characters in remote path'),
});
// ========== Quick Start ==========
/**
@@ -603,6 +691,73 @@ export const ScheduledRunSchema = z.object({
durationMinutes: z.number().int().min(1).max(14400).optional(),
});
// ========== Cron Jobs ==========
/** '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. */
const noNewlines = (v: string) => !/[\r\n]/.test(v);
/** Shared field shape for creating/updating a scheduled job. */
const CronJobBaseSchema = z.object({
name: z.string().min(1).max(200),
agentType: z.enum(['claude', 'shell', 'opencode', 'codex', 'gemini']),
workingDir: safePathSchema,
launchCommand: z.string().max(2000).refine(noNewlines, 'launchCommand must be a single line').optional(),
promptMode: z.enum(['inline_text', 'prompt_file_path']),
promptText: z
.string()
.max(100000)
.refine(noNewlines, 'promptText must be a single line (multi-line prompts are not supported)')
.optional(),
promptFilePath: safePathSchema.optional(),
inputMode: z.enum(['paste', 'typed']),
scheduleType: z.enum(['once', 'interval', 'daily', 'weekly']),
runAt: z.number().int().positive().optional(),
intervalMinutes: z.number().int().min(1).max(525600).optional(),
dailyTime: hhmmSchema.optional(),
weeklyDays: z.array(z.number().int().min(0).max(6)).min(1).max(7).optional(),
weeklyTime: hhmmSchema.optional(),
enabled: z.boolean(),
notes: z.string().max(2000).optional(),
concurrencyPolicy: z.enum(['warn_only', 'skip_if_same_agent_running']),
autoClosePreviousSession: z.boolean().optional(),
});
/** Cross-field validation: required fields depend on promptMode + scheduleType. */
function refineCronJob(val: z.infer<typeof CronJobBaseSchema>, ctx: z.RefinementCtx): void {
const add = (message: string, path: string) => ctx.addIssue({ code: 'custom', message, path: [path] });
if (val.promptMode === 'inline_text' && !val.promptText) {
add('promptText is required when promptMode is inline_text', 'promptText');
}
if (val.promptMode === 'prompt_file_path' && !val.promptFilePath) {
add('promptFilePath is required when promptMode is prompt_file_path', 'promptFilePath');
}
if (val.scheduleType === 'once' && val.runAt === undefined) {
add('runAt is required for a one-time schedule', 'runAt');
}
if (val.scheduleType === 'interval' && val.intervalMinutes === undefined) {
add('intervalMinutes is required for an interval schedule', 'intervalMinutes');
}
if (val.scheduleType === 'daily' && !val.dailyTime) {
add('dailyTime is required for a daily schedule', 'dailyTime');
}
if (val.scheduleType === 'weekly' && (!val.weeklyTime || !val.weeklyDays?.length)) {
add('weeklyDays and weeklyTime are required for a weekly schedule', 'weeklyTime');
}
}
/** POST /api/cron/jobs — full job definition. */
export const CronJobSchema = CronJobBaseSchema.superRefine(refineCronJob);
/** PUT /api/cron/jobs/:id — partial update. */
export const CronJobUpdateSchema = CronJobBaseSchema.partial();
/** PUT /api/cron/jobs/:id/enabled */
export const CronJobEnabledSchema = z.object({ enabled: z.boolean() });
/** POST /api/cases/link */
export const LinkCaseSchema = z.object({
name: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format'),
@@ -673,6 +828,16 @@ export const SubagentWindowStatesSchema = z
/** PUT /api/subagent-parents */
export const SubagentParentMapSchema = z.record(z.string(), z.string());
/** POST /api/sessions/:id/interactive */
export const InteractiveStartSchema = z.object({
/**
* COD-118: explicit user-initiated restart — clears a tripped PTY-exit circuit
* breaker before starting. Automatic reconnect/re-attach callers (e.g. the
* frontend's selectSession auto-attach) must NOT send this flag.
*/
clearBreaker: z.boolean().optional(),
});
/** POST /api/sessions/:id/interactive-respawn */
export const InteractiveRespawnSchema = z.object({
respawnConfig: RespawnConfigSchema.optional(),
+65 -8
View File
@@ -62,6 +62,7 @@ import {
import { imageWatcher } from '../image-watcher.js';
import { workflowRunWatcher, summarizeRun } from '../workflow-run-watcher.js';
import { attachmentRegistry, buildFileThumbnailRoute, registerExternalAttachment } from '../attachment-registry.js';
import { registerGeneratedArtifactAttachment } from '../generated-artifact-attachments.js';
import {
buildDetectedAttachmentHistoryItem,
buildExternalAttachmentHistoryItem,
@@ -153,8 +154,10 @@ import {
registerClipboardRoutes,
registerSearchRoutes,
registerOrchestratorRoutes,
registerCronRoutes,
registerWsRoutes,
} from './routes/index.js';
import { CronService } from '../cron/cron-service.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -176,6 +179,7 @@ import {
ITERATION_PAUSE_MS,
STATS_COLLECTION_INTERVAL_MS,
INACTIVITY_TIMEOUT_MS,
CRON_TICK_INTERVAL,
} from '../config/server-timing.js';
/**
@@ -224,6 +228,8 @@ export class WebServer extends EventEmitter {
// Store session listener references for explicit cleanup (prevents memory leaks)
private sessionListenerRefs: Map<string, SessionListenerRefs> = new Map();
private scheduledRuns: Map<string, ScheduledRun> = new Map();
/** Cron service (assigned in setupRoutes). */
private cronService!: CronService;
private sse: SseStreamManager;
private store = getStore();
private port: number;
@@ -892,6 +898,13 @@ export class WebServer extends EventEmitter {
registerClipboardRoutes(this.app, ctx);
registerSearchRoutes(this.app, ctx);
registerOrchestratorRoutes(this.app, ctx);
// Cron: build the service from the same context, recompute
// due times for any persisted jobs, then expose it to its routes.
this.cronService = new CronService(ctx);
this.cronService.init();
registerCronRoutes(this.app, { ...ctx, cron: this.cronService });
registerWsRoutes(this.app, ctx, () => this.getHostPolicy());
}
@@ -1281,6 +1294,13 @@ export class WebServer extends EventEmitter {
}
private async setupSessionListeners(session: Session): Promise<void> {
// 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
// (terminal SSE, error/exit broadcasts, the COD-118 respawnBreakerTripped
// handler). Skip when the refs are still attached to avoid double-wiring.
if (this.sessionListenerRefs.has(session.id)) return;
// Create run summary tracker for this session
const summaryTracker = new RunSummaryTracker(session.id, session.name);
this.runSummaryTrackers.set(session.id, summaryTracker);
@@ -1346,7 +1366,8 @@ export class WebServer extends EventEmitter {
}
},
getStore: () => this.store,
registerAttachment: (id: string, filePath: string) => this.registerAttachment(id, filePath),
registerAttachment: (id: string, filePath: string, source: 'external' | 'codex-generated') =>
this.registerAttachment(id, filePath, source),
};
}
@@ -1359,16 +1380,31 @@ export class WebServer extends EventEmitter {
* session workspace — passive magic links can't expose arbitrary host files.
* Deliberate cross-workspace attachment goes through the explicit,
* Origin-guarded `POST /attachments` route (and `codeman attach`, which POSTs
* directly inside a managed session). Registration also enforces the COD-53
* blocklist as defense-in-depth.
* directly inside a managed session). Codex-mode `Saved to:` requests
* (`source: 'codex-generated'`) instead go through
* registerGeneratedArtifactAttachment, which stays force-confined unless the
* realpath-resolved target is inside the workspace or a home-anchored
* `~/.codex*` generated-artifact directory. Registration also enforces the
* COD-53 blocklist as defense-in-depth.
*/
private async registerAttachment(sessionId: string, filePath: string): Promise<void> {
private async registerAttachment(
sessionId: string,
filePath: string,
source: 'external' | 'codex-generated'
): Promise<void> {
const session = this.sessions.get(sessionId);
if (!session) return;
const event = await registerExternalAttachment(sessionId, filePath, {
sessionWorkingDir: session.workingDir,
forceWorkspaceConfinement: true,
});
const event =
source === 'codex-generated'
? await registerGeneratedArtifactAttachment({
sessionId,
filePath,
sessionWorkingDir: session.workingDir,
})
: await registerExternalAttachment(sessionId, filePath, {
sessionWorkingDir: session.workingDir,
forceWorkspaceConfinement: true,
});
const record = attachmentRegistry.get(sessionId, event.attachmentId);
if (record) {
session.upsertAttachmentHistory(
@@ -1782,6 +1818,7 @@ export class WebServer extends EventEmitter {
[SseEvent.HookStop]: { title: 'Response Complete', urgency: 'info' },
[SseEvent.SessionError]: { title: 'Session Error', urgency: 'critical' },
[SseEvent.RespawnBlocked]: { title: 'Respawn Blocked', urgency: 'critical' },
[SseEvent.SessionRespawnBreakerTripped]: { title: 'Session crash loop stopped', urgency: 'critical' },
[SseEvent.SessionRalphCompletionDetected]: { title: 'Task Complete', urgency: 'warning' },
};
@@ -1814,6 +1851,9 @@ export class WebServer extends EventEmitter {
} else if (event === SseEvent.SessionRalphCompletionDetected && data.phrase) {
body += body ? ' ' : '';
body += String(data.phrase);
} else if (event === SseEvent.SessionRespawnBreakerTripped && data.count) {
body += body ? ' ' : '';
body += `Stopped after ${Number(data.count)} rapid crashes — restart the session to retry`;
} else if (event === SseEvent.HookPermissionPrompt && data.tool_name) {
body += body ? ' ' : '';
body += `Tool: ${String(data.tool_name)}`;
@@ -1972,6 +2012,17 @@ export class WebServer extends EventEmitter {
{ description: 'scheduled runs cleanup' }
);
// Start the cron loop (fires due CronJobs).
this.cleanup.setInterval(
() => {
this.cronService.tickDueJobs().catch((err) => {
console.error('[cron] tick failed:', getErrorMessage(err));
});
},
CRON_TICK_INTERVAL,
{ description: 'scheduled jobs due-checker' }
);
// Start SSE client health check timer (prevents memory leaks from dead connections)
this.cleanup.setInterval(
() => {
@@ -2157,6 +2208,12 @@ export class WebServer extends EventEmitter {
envOverrides: savedEnvOverrides,
effort: savedState?.effort,
attachmentHistory: savedAttachmentHistory,
// Remote SSH metadata must round-trip on recovery: without it the
// attach cwd falls back to the (nonexistent-locally) remote path and
// respawn rebuilds a LOCAL command, breaking the pane and silently
// erasing `remote` from state.json on the next persist. mux-sessions.json
// round-trips MuxSession.remote; state.json carries SessionState.remote.
remote: muxSession.remote ?? savedState?.remote,
});
// Update session name if it was a "Restored:" placeholder or doesn't match saved name
+28 -4
View File
@@ -48,6 +48,7 @@ export interface SessionListenerRefs {
limitPauseScheduled: (data: { resetAt: number; resumeAt: number; matched: string }) => void;
limitResume: (data: { attempt: number }) => void;
limitResumeCancelled: (data: { reason: string }) => void;
respawnBreakerTripped: (data: { count: number }) => void;
cliInfoUpdated: (data: { version?: string; model?: string; accountType?: string; latestVersion?: string }) => void;
ralphLoopUpdate: (state: RalphTrackerState) => void;
ralphTodoUpdate: (todos: RalphTodoItem[]) => void;
@@ -58,7 +59,7 @@ export interface SessionListenerRefs {
bashToolStart: (tool: ActiveBashTool) => void;
bashToolEnd: (tool: ActiveBashTool) => void;
bashToolsUpdate: (tools: ActiveBashTool[]) => void;
attachmentRequested: (event: { path: string }) => void;
attachmentRequested: (event: { path: string; source: 'external' | 'codex-generated' }) => void;
}
/** Dependencies injected by WebServer — keeps listener creation decoupled from server internals. */
@@ -78,7 +79,7 @@ interface SessionListenerDeps {
removeSessionListenerRefs(sessionId: string): void;
cleanupRespawnOnExit(sessionId: string): void;
getStore(): import('../state-store.js').StateStore;
registerAttachment(sessionId: string, filePath: string): Promise<void>;
registerAttachment(sessionId: string, filePath: string, source: 'external' | 'codex-generated'): Promise<void>;
}
/**
@@ -270,6 +271,27 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe
deps.persistSessionState(session);
},
/**
* Broadcasts `session:respawnBreakerTripped` (COD-118) — repeated non-zero PTY exits
* tripped the circuit breaker; the session is now errored and respawn is blocked.
* Also pushes the errored state (`session:updated`) so the tab renders the error,
* persists it, and notifies for diagnostic visibility.
*/
respawnBreakerTripped: (data: { count: number }) => {
deps.broadcast(SseEvent.SessionRespawnBreakerTripped, { sessionId: session.id, ...data });
deps.broadcast(SseEvent.SessionUpdated, deps.getSessionStateWithRespawn(session));
deps.persistSessionState(session);
deps.sendPushNotifications(SseEvent.SessionRespawnBreakerTripped, {
sessionId: session.id,
sessionName: session.name,
count: data.count,
});
const tracker = deps.getRunSummaryTracker(session.id);
if (tracker) {
tracker.recordError('Respawn circuit breaker tripped', `${data.count} non-zero PTY exits within window`);
}
},
// ─── CLI Info ────────────────────────────────────────────
/** Broadcasts `session:cliInfo` — Claude Code version, model, account type parsed from terminal */
@@ -359,8 +381,8 @@ export function createSessionListeners(session: Session, deps: SessionListenerDe
},
/** Registers an explicit attachment card requested by terminal magic text. */
attachmentRequested: (event: { path: string }) => {
deps.registerAttachment(session.id, event.path).catch((err) => {
attachmentRequested: (event: { path: string; source: 'external' | 'codex-generated' }) => {
deps.registerAttachment(session.id, event.path, event.source).catch((err) => {
console.error(`[Attachment] Failed to register ${event.path} for ${session.id}:`, err);
});
},
@@ -387,6 +409,7 @@ export function attachSessionListeners(session: Session, refs: SessionListenerRe
session.on('limitPauseScheduled', refs.limitPauseScheduled);
session.on('limitResume', refs.limitResume);
session.on('limitResumeCancelled', refs.limitResumeCancelled);
session.on('respawnBreakerTripped', refs.respawnBreakerTripped);
session.on('cliInfoUpdated', refs.cliInfoUpdated);
session.on('ralphLoopUpdate', refs.ralphLoopUpdate);
session.on('ralphTodoUpdate', refs.ralphTodoUpdate);
@@ -420,6 +443,7 @@ export function detachSessionListeners(session: Session, refs: SessionListenerRe
session.off('limitPauseScheduled', refs.limitPauseScheduled);
session.off('limitResume', refs.limitResume);
session.off('limitResumeCancelled', refs.limitResumeCancelled);
session.off('respawnBreakerTripped', refs.respawnBreakerTripped);
session.off('cliInfoUpdated', refs.cliInfoUpdated);
session.off('ralphLoopUpdate', refs.ralphLoopUpdate);
session.off('ralphTodoUpdate', refs.ralphTodoUpdate);
+20
View File
@@ -80,6 +80,8 @@ export const SessionLimitPauseScheduled = 'session:limitPauseScheduled' as const
export const SessionLimitResume = 'session:limitResume' as const;
/** Pending usage-limit auto-resume cancelled (session resumed or feature disabled). */
export const SessionLimitResumeCancelled = 'session:limitResumeCancelled' as const;
/** Interactive-PTY exit circuit breaker tripped (COD-118): repeated non-zero exits; respawn blocked, session errored. */
export const SessionRespawnBreakerTripped = 'session:respawnBreakerTripped' as const;
/** CLI version/model info detected from session output. */
export const SessionCliInfo = 'session:cliInfo' as const;
/** General session message (e.g. status text). */
@@ -240,6 +242,17 @@ export const ScheduledLog = 'scheduled:log' as const;
/** Scheduled run deleted. */
export const ScheduledDeleted = 'scheduled:deleted' as const;
// ─── Cron Jobs ───────────────────────────────────
/** The scheduled-jobs list changed (created/updated/enabled/run-status). Payload: { jobs }. */
export const CronJobsChanged = 'cron:jobsChanged' as const;
/** A scheduled job was deleted. Payload: { id }. */
export const CronJobDeleted = 'cron:jobDeleted' as const;
/** A scheduled-job run (history record) was created. Payload: CronJobRun. */
export const CronRunCreated = 'cron:runCreated' as const;
/** A scheduled-job run (history record) was updated. Payload: CronJobRun. */
export const CronRunUpdated = 'cron:runUpdated' as const;
// ─── Teams ───────────────────────────────────────────────────────────────────
/** Agent team created. */
@@ -384,6 +397,7 @@ export const SseEvent = {
SessionLimitPauseScheduled,
SessionLimitResume,
SessionLimitResumeCancelled,
SessionRespawnBreakerTripped,
SessionCliInfo,
SessionMessage,
SessionInteractive,
@@ -469,6 +483,12 @@ export const SseEvent = {
ScheduledLog,
ScheduledDeleted,
// Cron jobs
CronJobsChanged,
CronJobDeleted,
CronRunCreated,
CronRunUpdated,
// Teams
TeamCreated,
TeamUpdated,
+123
View File
@@ -0,0 +1,123 @@
/**
* @fileoverview Per-session WebSocket connection registry (COD-137).
*
* Replaces the bare `Map<sessionId, number>` counter that previously gated
* `MAX_WS_PER_SESSION`. That counter had two defects:
*
* 1. Transient over-count on reconnect: a client that drops and immediately
* reconnects could land its new upgrade BEFORE the old socket's async
* `close` fired, so the count briefly exceeded the live connection number.
* A reconnect burst could hit the cap and the next upgrade was rejected
* with 4008 → the client fell back to HTTP. (The real spurious-4008 defect.)
* 2. No clientId scoping: the limit counted raw sockets, so a reconnecting
* client consumed a NEW slot instead of replacing its own.
*
* This registry tracks the live socket(s) per session keyed by a per-TAB
* connection identity (`cid`, parsed from the upgrade URL query). The browser
* sends `clientId:tabNonce`, NOT the bare localStorage clientId — that one is
* shared by every tab/window of a profile, so keying on it would make two tabs
* on one session evict each other in a 4010 ping-pong. A new upgrade for a
* `cid` that already holds a socket is a SUPERSEDE — the registry evicts the
* stale socket and reuses its slot, which makes a reconnect reclaim rather
* than double-count (fixes #1 and #2). The cid is opaque here; input-frame
* dedup uses the bare clientId separately (`session.shouldApplyInput`).
*
* Backward-compat: an upgrade with NO `cid` (legacy clients, other tools) is
* admitted anonymously — it counts toward the limit but never evicts another
* client, and several anonymous sockets can coexist up to the cap.
*
* The class is pure (no `ws`/Fastify imports) and generic over a minimal socket
* shape so it can be unit-tested with plain fakes. The route owns the actual
* socket close/terminate; the registry only decides admit/evict and tracks slots.
*/
/** Minimal socket shape the registry needs — satisfied by `ws` WebSocket. */
export interface RegistrableSocket {
/** Identity comparison only; never dereferenced beyond `===`. */
readonly readyState?: number;
}
export interface RegisterResult<S> {
/** Whether the new socket was admitted (false → caller should reject with 4008). */
admitted: boolean;
/**
* A stale socket whose slot the new socket reclaimed (same `cid`). The caller
* should close it. Present only on a keyed supersede; never set for anonymous
* upgrades or fresh slots.
*/
evictedSocket?: S;
}
/** A single live entry: the socket plus its clientId (null = anonymous). */
interface Entry<S> {
socket: S;
cid: string | null;
}
export class WsConnectionRegistry<S extends RegistrableSocket = RegistrableSocket> {
/** sessionId → live entries (keyed + anonymous). */
private readonly bySession = new Map<string, Entry<S>[]>();
constructor(private readonly maxPerSession: number) {}
/**
* Attempt to register a new socket for `(sessionId, cid)`.
*
* - cid present and already holds a socket → SUPERSEDE: evict the old one,
* reuse its slot, always admit.
* - otherwise → admit iff distinct-entry count < maxPerSession.
*
* A null/empty `cid` is anonymous: it never matches an existing entry and so
* never evicts; it just consumes a slot.
*/
register(sessionId: string, cid: string | null, socket: S): RegisterResult<S> {
const entries = this.bySession.get(sessionId) ?? [];
if (cid) {
const existingIdx = entries.findIndex((e) => e.cid === cid);
if (existingIdx !== -1) {
const evicted = entries[existingIdx].socket;
// Reuse the slot in place — no net change to the live count, so a
// reconnect can never be rejected by the cap.
entries[existingIdx] = { socket, cid };
this.bySession.set(sessionId, entries);
return { admitted: true, evictedSocket: evicted === socket ? undefined : evicted };
}
}
if (entries.length >= this.maxPerSession) {
return { admitted: false };
}
entries.push({ socket, cid: cid || null });
this.bySession.set(sessionId, entries);
return { admitted: true };
}
/**
* Remove a socket from its session. Idempotent — safe to call on `close`,
* `error`, AND eagerly on `terminate()` (the over-count fix relies on eager
* removal freeing the slot before the async `close` fires).
*
* Matches by socket identity, so a socket that was already superseded
* (replaced in-slot by a same-cid reconnect) is NOT removed by its late
* `close` — the new socket keeps the slot.
*/
unregister(sessionId: string, socket: S): void {
const entries = this.bySession.get(sessionId);
if (!entries) return;
const idx = entries.findIndex((e) => e.socket === socket);
if (idx === -1) return;
entries.splice(idx, 1);
if (entries.length === 0) {
this.bySession.delete(sessionId);
} else {
this.bySession.set(sessionId, entries);
}
}
/** Number of live entries for a session (0 if none). */
liveCount(sessionId: string): number {
return this.bySession.get(sessionId)?.length ?? 0;
}
}