Files
Codeman/src/web/routes/session-routes.ts
T
Ark0NandClaude Opus 4.6 2ee9ad72e8 refactor: SSE event handlers, LLM context optimization, @fileoverview docs (#29)
* refactor: extract SSE event handlers into named class methods

Replace ~80 inline addListener closures in connectSSE() with a
declarative _SSE_HANDLER_MAP array that drives registration in a
single loop. Each handler is now a named _on* method on CodemanApp,
making them individually addressable for LLM navigation.

Add SSE_EVENTS constant object in constants.js to eliminate magic
event-type strings scattered across the frontend.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: fix inaccuracies in CLAUDE.md

- Fix types barrel path: src/types.ts → src/types/index.ts
- Update app.js line count: ~12K → ~11.5K
- Correct route handler counts (113 → 111, per-group fixes)
- Add code style, ESM gotcha, env vars, route test, lifecycle log docs
- Add Node 22 CI note, test teardown timeout, port range

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* docs: add mobile screenshots and QR auth security writeup to README

Add 3 mobile screenshots (landing, idle, active) and expand the
mobile section with QR auth security design details and a
touch-optimized interface subsection.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: bundle xterm-zerolag-input as vendor IIFE and add pre-commit hook

Build and postinstall now bundle the local xterm-zerolag-input package
as an IIFE at vendor/xterm-zerolag-input.js with global LocalEchoOverlay
shim. Add git pre-commit hook that runs prettier --check on staged .ts
files to catch format issues before CI.

Also bump constants.js and app.js cache-bust versions to 0.3.0 and add
tunnel upload URL display row in settings.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat: add cloudflared install support and interactive launch menu

- Add optional cloudflared dependency detection and installation
  across 6 distro families (macOS, Debian, Fedora, Arch, Alpine, SUSE)
- Add tunnel systemd service setup helper
- Replace post-install instructions with interactive launch menu
  (run now / systemd service / skip)
- Uninstall now cleans up both codeman-web and codeman-tunnel services

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* chore: gitignore readme-preview.mjs

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: WIP — SSE event constants, @fileoverview docs, CLAUDE.md compression

- Migrate broadcast() string literals → SseEvent.* typed constants
- Add @fileoverview with cross-domain references to all 13 type domain files
- Add @fileoverview to frontend JS modules (constants, mobile, voice, etc.)
- Add section dividers to route files for LLM scanability
- Compress CLAUDE.md: flat file list → domain table, fix counts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor: optimize codebase for LLM context window efficiency

CLAUDE.md: 456 → 309 lines (32% reduction)
- Merge Commands into compact table, remove redundant bash block
- Convert Security section to dense table format
- Merge Performance + Resource Limits, Debugging + Troubleshooting
- Compress Tunnel, Memory Leak, Scripts, Screenshots sections
- Remove Key Patterns that duplicate @fileoverview in source files

Backend @fileoverview enhancements (10 priority files):
- session.ts: key methods, events, cross-domain refs
- respawn-controller.ts: state machine, idle detection layers
- ralph-tracker.ts: exports, circuit breaker, events
- ralph-loop.ts: lifecycle, persistence, events
- subagent-watcher.ts: watched patterns, teammate detection
- server.ts: coordination list, port interfaces
- state-store.ts: dual-file persistence, migration
- session-manager.ts: lifecycle methods, mutex guard
- hooks-config.ts: hook events list, categories
- sse-events.ts: category breakdown (~90 events, 17 categories)

Frontend app.js: add 6 section dividers, update @fileoverview line refs

Fix: escape glob `*/` in JSDoc that broke ESLint parser

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: address PR #29 review bugs

- server.ts: replace hardcoded 'session:needsRefresh' with SseEvent constant
- install.sh: fix Alpine cloudflared install for non-root (download to tmpfile first)
- install.sh: replace Arch pacman (AUR-only) with direct binary download
- index.html: bump all 8 remaining cache-bust versions from v0.2.9 to v0.3.0
- mobile-handlers.js: fix @dependency annotation (keyboard-accessory.js, not constants.js)
- types/push.ts: fix layer number (4, not 5)
- subagent-watcher.ts: fix watched pattern path to include {session} segment
- constants.js: fix SSE_EVENTS count in @fileoverview (~73, not ~65)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-03 22:40:18 +01:00

926 lines
33 KiB
TypeScript

/**
* @fileoverview Session management routes.
* Covers session CRUD, input/output, terminal buffer, quick-start, quick-run,
* auto-clear, auto-compact, image watcher, flicker filter, and logout.
*/
import { FastifyInstance } from 'fastify';
import { join, dirname, resolve, relative, isAbsolute } from 'node:path';
import { existsSync, statSync, mkdirSync, writeFileSync } from 'node:fs';
import fs from 'node:fs/promises';
import {
ApiErrorCode,
createErrorResponse,
getErrorMessage,
type ApiResponse,
type QuickStartResponse,
type SessionColor,
} from '../../types.js';
import { Session } from '../../session.js';
import { SseEvent } from '../sse-events.js';
import {
CreateSessionSchema,
SessionNameSchema,
SessionColorSchema,
RunPromptSchema,
SessionInputWithLimitSchema,
ResizeSchema,
AutoClearSchema,
AutoCompactSchema,
ImageWatcherSchema,
FlickerFilterSchema,
QuickRunSchema,
QuickStartSchema,
} from '../schemas.js';
import { autoConfigureRalph, CASES_DIR, SETTINGS_PATH } from '../route-helpers.js';
import { AUTH_COOKIE_NAME } from '../middleware/auth.js';
import { writeHooksConfig, updateCaseEnvVars } from '../../hooks-config.js';
import { generateClaudeMd } from '../../templates/claude-md.js';
import { imageWatcher } from '../../image-watcher.js';
import { getLifecycleLog } from '../../session-lifecycle-log.js';
import type { SessionPort, EventPort, ConfigPort, InfraPort, AuthPort } from '../ports/index.js';
import { MAX_CONCURRENT_SESSIONS } from '../../config/map-limits.js';
import { RunSummaryTracker } from '../../run-summary.js';
import { MAX_INPUT_LENGTH, MAX_SESSION_NAME_LENGTH } from '../../config/terminal-limits.js';
// Pre-compiled regex for terminal buffer cleaning (avoids per-request compilation)
// eslint-disable-next-line no-control-regex
const CLAUDE_BANNER_PATTERN = /\x1b\[1mClaud/;
// eslint-disable-next-line no-control-regex
const CTRL_L_PATTERN = /\x0c/g;
const LEADING_WHITESPACE_PATTERN = /^[\s\r\n]+/;
export function registerSessionRoutes(
app: FastifyInstance,
ctx: SessionPort & EventPort & ConfigPort & InfraPort & AuthPort
): void {
// ═══════════════════════════════════════════════════════════════
// Auth
// ═══════════════════════════════════════════════════════════════
// ========== Logout ==========
app.post('/api/logout', async (req, reply) => {
// Invalidate server-side session token (not just the browser cookie)
const sessionToken = req.cookies[AUTH_COOKIE_NAME];
if (sessionToken) {
ctx.authSessions?.delete(sessionToken);
}
reply.clearCookie(AUTH_COOKIE_NAME, { path: '/' });
return { success: true };
});
// ═══════════════════════════════════════════════════════════════
// Session CRUD (list, create, rename, color, delete, detail)
// ═══════════════════════════════════════════════════════════════
// ========== Session Listing ==========
app.get('/api/sessions', async () => {
return ctx.getLightSessionsState();
});
// ========== Session Creation ==========
app.post('/api/sessions', async (req) => {
// Prevent unbounded session creation
if (ctx.sessions.size >= MAX_CONCURRENT_SESSIONS) {
return createErrorResponse(
ApiErrorCode.OPERATION_FAILED,
`Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached. Delete some sessions first.`
);
}
const result = CreateSessionSchema.safeParse(req.body);
if (!result.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed');
}
const body = result.data;
const workingDir = body.workingDir || process.cwd();
// Validate workingDir exists and is a directory
if (body.workingDir) {
try {
const stat = statSync(workingDir);
if (!stat.isDirectory()) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'workingDir is not a directory');
}
} catch {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'workingDir does not exist');
}
}
// Write env overrides to .claude/settings.local.json if provided
if (body.envOverrides && Object.keys(body.envOverrides).length > 0) {
await updateCaseEnvVars(workingDir, body.envOverrides);
}
// Check OpenCode availability if requested
if (body.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'
);
}
}
const globalNice = await ctx.getGlobalNiceConfig();
const modelConfig = await ctx.getModelConfig();
const mode = body.mode || 'claude';
const model =
mode === 'opencode' ? body.openCodeConfig?.model : mode !== 'shell' ? modelConfig?.defaultModel : undefined;
const claudeModeConfig = await ctx.getClaudeModeConfig();
const session = new Session({
workingDir,
mode,
name: body.name || '',
mux: ctx.mux,
useMux: true,
niceConfig: globalNice,
model,
claudeMode: claudeModeConfig.claudeMode,
allowedTools: claudeModeConfig.allowedTools,
openCodeConfig: mode === 'opencode' ? body.openCodeConfig : undefined,
});
ctx.addSession(session);
ctx.store.incrementSessionsCreated();
ctx.persistSessionState(session);
await ctx.setupSessionListeners(session);
getLifecycleLog().log({ event: 'created', sessionId: session.id, name: session.name });
// Use light state for broadcast + response — buffers are fetched on-demand via /terminal.
// Avoids serializing 2-3MB of terminal+text buffers per session creation.
const lightState = ctx.getSessionStateWithRespawn(session);
ctx.broadcast(SseEvent.SessionCreated, lightState);
return { success: true, session: lightState };
});
// ========== Rename Session ==========
app.put('/api/sessions/:id/name', async (req) => {
const { id } = req.params as { id: string };
const result = SessionNameSchema.safeParse(req.body);
if (!result.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
}
const body = result.data;
const session = ctx.sessions.get(id);
if (!session) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
const name = String(body.name || '').slice(0, MAX_SESSION_NAME_LENGTH);
session.name = name;
// Also update the mux session name if applicable
ctx.mux.updateSessionName(id, session.name);
ctx.persistSessionState(session);
ctx.broadcast(SseEvent.SessionUpdated, ctx.getSessionStateWithRespawn(session));
return { success: true, name: session.name };
});
// ========== Set Session Color ==========
app.put('/api/sessions/:id/color', async (req) => {
const { id } = req.params as { id: string };
const result = SessionColorSchema.safeParse(req.body);
if (!result.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
}
const body = result.data;
const session = ctx.sessions.get(id);
if (!session) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
const validColors = ['default', 'red', 'orange', 'yellow', 'green', 'blue', 'purple', 'pink'];
if (!validColors.includes(body.color)) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid color');
}
session.setColor(body.color as SessionColor);
ctx.persistSessionState(session);
ctx.broadcast(SseEvent.SessionUpdated, ctx.getSessionStateWithRespawn(session));
return { success: true, color: session.color };
});
// ========== Delete Session ==========
app.delete('/api/sessions/:id', async (req): Promise<ApiResponse> => {
const { id } = req.params as { id: string };
const query = req.query as { killMux?: string };
const killMux = query.killMux !== 'false'; // Default to true
if (!ctx.sessions.has(id)) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
await ctx.cleanupSession(id, killMux, 'user_delete');
return { success: true };
});
// ========== Delete All Sessions ==========
app.delete('/api/sessions', async (): Promise<ApiResponse<{ killed: number }>> => {
const sessionIds = Array.from(ctx.sessions.keys());
let killed = 0;
for (const id of sessionIds) {
if (ctx.sessions.has(id)) {
await ctx.cleanupSession(id, true, 'user_bulk_delete');
killed++;
}
}
return { success: true, data: { killed } };
});
// ========== Get Session Detail ==========
app.get('/api/sessions/:id', async (req) => {
const { id } = req.params as { id: string };
const session = ctx.sessions.get(id);
if (!session) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
// Use light state (no full buffers) — terminal buffer available via /terminal endpoint.
// Full buffers were 2-3MB and caused slowness when polled frequently (e.g. Ralph wizard).
return ctx.getSessionStateWithRespawn(session);
});
// ═══════════════════════════════════════════════════════════════
// Session Data (output, ralph state, run summary, active tools)
// ═══════════════════════════════════════════════════════════════
// ========== Get Session Output ==========
app.get('/api/sessions/:id/output', async (req) => {
const { id } = req.params as { id: string };
const session = ctx.sessions.get(id);
if (!session) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
return {
success: true,
data: {
textOutput: session.textOutput,
messages: session.messages,
errorBuffer: session.errorBuffer,
},
};
});
// ========== Get Ralph State ==========
app.get('/api/sessions/:id/ralph-state', async (req) => {
const { id } = req.params as { id: string };
const session = ctx.sessions.get(id);
if (!session) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
return {
success: true,
data: {
loop: session.ralphLoopState,
todos: session.ralphTodos,
todoStats: session.ralphTodoStats,
},
};
});
// ========== Get Run Summary ==========
app.get('/api/sessions/:id/run-summary', async (req) => {
const { id } = req.params as { id: string };
const session = ctx.sessions.get(id);
if (!session) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
const tracker = ctx.runSummaryTrackers.get(id);
if (!tracker) {
// Create a fresh tracker if one doesn't exist (shouldn't happen normally)
const newTracker = new RunSummaryTracker(id, session.name);
ctx.runSummaryTrackers.set(id, newTracker);
return { success: true, summary: newTracker.getSummary() };
}
// Update session name in case it changed
tracker.setSessionName(session.name);
return { success: true, summary: tracker.getSummary() };
});
// ========== Get Active Tools ==========
app.get('/api/sessions/:id/active-tools', async (req) => {
const { id } = req.params as { id: string };
const session = ctx.sessions.get(id);
if (!session) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
return {
success: true,
data: {
tools: session.activeTools,
},
};
});
// ═══════════════════════════════════════════════════════════════
// Session Execution (run prompt, interactive mode, shell mode)
// ═══════════════════════════════════════════════════════════════
// ========== Run Prompt ==========
app.post('/api/sessions/:id/run', async (req): Promise<ApiResponse> => {
const { id } = req.params as { id: string };
const result = RunPromptSchema.safeParse(req.body);
if (!result.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed');
}
const { prompt } = result.data;
const session = ctx.sessions.get(id);
if (!session) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
if (session.isBusy()) {
return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
}
// Run async, don't wait
session.runPrompt(prompt).catch((err) => {
ctx.broadcast(SseEvent.SessionError, { id, error: err.message });
});
ctx.broadcast(SseEvent.SessionRunning, { id, prompt });
return { success: true };
});
// ========== Start Interactive Mode ==========
app.post('/api/sessions/:id/interactive', async (req): Promise<ApiResponse> => {
const { id } = req.params as { id: string };
const session = ctx.sessions.get(id);
if (!session) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
if (session.isBusy()) {
return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
}
try {
// Auto-detect completion phrase from CLAUDE.md BEFORE starting (only if globally enabled and not explicitly disabled by user)
// Ralph tracker is not supported for opencode sessions
if (
session.mode !== 'opencode' &&
ctx.store.getConfig().ralphEnabled &&
!session.ralphTracker.autoEnableDisabled
) {
autoConfigureRalph(session, session.workingDir, ctx);
if (!session.ralphTracker.enabled) {
session.ralphTracker.enable();
}
}
await session.startInteractive();
getLifecycleLog().log({
event: 'started',
sessionId: id,
name: session.name,
mode: session.mode,
});
ctx.broadcast(SseEvent.SessionInteractive, { id });
ctx.broadcast(SseEvent.SessionUpdated, { session: ctx.getSessionStateWithRespawn(session) });
return { success: true };
} catch (err) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
}
});
// ========== Start Shell Mode ==========
app.post('/api/sessions/:id/shell', async (req): Promise<ApiResponse> => {
const { id } = req.params as { id: string };
const session = ctx.sessions.get(id);
if (!session) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
if (session.isBusy()) {
return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy');
}
try {
await session.startShell();
getLifecycleLog().log({
event: 'started',
sessionId: id,
name: session.name,
mode: 'shell',
});
ctx.broadcast(SseEvent.SessionInteractive, { id, mode: 'shell' });
ctx.broadcast(SseEvent.SessionUpdated, { session: ctx.getSessionStateWithRespawn(session) });
return { success: true };
} catch (err) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
}
});
// ═══════════════════════════════════════════════════════════════
// Terminal I/O (input, resize, buffer)
// ═══════════════════════════════════════════════════════════════
// ========== Send Input ==========
app.post('/api/sessions/:id/input', async (req): Promise<ApiResponse> => {
const { id } = req.params as { id: string };
const result = SessionInputWithLimitSchema.safeParse(req.body);
if (!result.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed');
}
const { input, useMux } = result.data;
const session = ctx.sessions.get(id);
if (!session) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
const inputStr = String(input);
if (inputStr.length > MAX_INPUT_LENGTH) {
return createErrorResponse(
ApiErrorCode.INVALID_INPUT,
`Input exceeds maximum length (${MAX_INPUT_LENGTH} bytes)`
);
}
// Write input to PTY. Direct write is synchronous; writeViaMux
// (tmux send-keys) is fire-and-forget to avoid blocking the HTTP response.
if (useMux) {
// Fire-and-forget: don't block HTTP response on tmux child process.
// Fallback to direct write on failure.
session
.writeViaMux(inputStr)
.then((ok) => {
if (!ok) {
console.warn(`[Server] writeViaMux failed for session ${id}, falling back to direct write`);
session.write(inputStr);
}
})
.catch(() => {
session.write(inputStr);
});
} else {
session.write(inputStr);
}
return { success: true };
});
// ========== Resize Terminal ==========
app.post('/api/sessions/:id/resize', async (req): Promise<ApiResponse> => {
const { id } = req.params as { id: string };
const result = ResizeSchema.safeParse(req.body);
if (!result.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed');
}
const { cols, rows } = result.data;
const session = ctx.sessions.get(id);
if (!session) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
session.resize(cols, rows);
return { success: true };
});
// ========== Get Terminal Buffer ==========
// Query params:
// tail=<bytes> - Only return last N bytes (faster initial load)
app.get('/api/sessions/:id/terminal', async (req) => {
const { id } = req.params as { id: string };
const query = req.query as { tail?: string };
const session = ctx.sessions.get(id);
if (!session) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
const tailBytes = query.tail ? parseInt(query.tail, 10) : 0;
const fullSize = session.terminalBufferLength;
let truncated = false;
let cleanBuffer: string;
if (tailBytes > 0 && fullSize > tailBytes) {
// Fast path: tail from the end, skip expensive banner search on full 2MB buffer.
// Banner is near the top and gets discarded by tail anyway.
cleanBuffer = session.terminalBuffer.slice(-tailBytes);
truncated = true;
// Avoid starting mid-ANSI-escape: find first newline within the first 4KB
// and start from there. This prevents xterm.js from parsing a partial escape
// sequence which corrupts cursor position for all subsequent Ink redraws.
const firstNewline = cleanBuffer.indexOf('\n');
if (firstNewline > 0 && firstNewline < 4096) {
cleanBuffer = cleanBuffer.slice(firstNewline + 1);
}
} else {
// Full buffer: clean junk before actual Claude content
cleanBuffer = session.terminalBuffer;
// Find where Claude banner starts (has color codes before "Claude")
const claudeMatch = cleanBuffer.match(CLAUDE_BANNER_PATTERN);
if (claudeMatch && claudeMatch.index !== undefined && claudeMatch.index > 0) {
let lineStart = claudeMatch.index;
while (lineStart > 0 && cleanBuffer[lineStart - 1] !== '\n') {
lineStart--;
}
cleanBuffer = cleanBuffer.slice(lineStart);
}
}
// Remove Ctrl+L and leading whitespace (cheap on tailed subset)
cleanBuffer = cleanBuffer.replace(CTRL_L_PATTERN, '').replace(LEADING_WHITESPACE_PATTERN, '');
return {
terminalBuffer: cleanBuffer,
status: session.status,
fullSize,
truncated,
};
});
// ═══════════════════════════════════════════════════════════════
// Session Settings (auto-clear, auto-compact, image watcher, flicker filter)
// ═══════════════════════════════════════════════════════════════
// ========== Auto-Clear ==========
app.post('/api/sessions/:id/auto-clear', async (req) => {
const { id } = req.params as { id: string };
const acResult = AutoClearSchema.safeParse(req.body);
if (!acResult.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
}
const body = acResult.data;
const session = ctx.sessions.get(id);
if (!session) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
session.setAutoClear(body.enabled, body.threshold);
ctx.persistSessionState(session);
ctx.broadcast(SseEvent.SessionUpdated, ctx.getSessionStateWithRespawn(session));
return {
success: true,
data: {
autoClear: {
enabled: session.autoClearEnabled,
threshold: session.autoClearThreshold,
},
},
};
});
// ========== Auto-Compact ==========
app.post('/api/sessions/:id/auto-compact', async (req) => {
const { id } = req.params as { id: string };
const compactResult = AutoCompactSchema.safeParse(req.body);
if (!compactResult.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
}
const body = compactResult.data;
const session = ctx.sessions.get(id);
if (!session) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
session.setAutoCompact(body.enabled, body.threshold, body.prompt);
ctx.persistSessionState(session);
ctx.broadcast(SseEvent.SessionUpdated, ctx.getSessionStateWithRespawn(session));
return {
success: true,
data: {
autoCompact: {
enabled: session.autoCompactEnabled,
threshold: session.autoCompactThreshold,
prompt: session.autoCompactPrompt,
},
},
};
});
// ========== Image Watcher ==========
app.post('/api/sessions/:id/image-watcher', async (req) => {
const { id } = req.params as { id: string };
const iwResult = ImageWatcherSchema.safeParse(req.body);
if (!iwResult.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
}
const body = iwResult.data;
const session = ctx.sessions.get(id);
if (!session) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
if (body.enabled) {
imageWatcher.watchSession(session.id, session.workingDir);
} else {
imageWatcher.unwatchSession(session.id);
}
// Store state on session for persistence
session.imageWatcherEnabled = body.enabled;
ctx.persistSessionState(session);
return {
success: true,
data: {
imageWatcherEnabled: body.enabled,
},
};
});
// ========== Flicker Filter ==========
app.post('/api/sessions/:id/flicker-filter', async (req) => {
const { id } = req.params as { id: string };
const ffResult = FlickerFilterSchema.safeParse(req.body);
if (!ffResult.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
}
const body = ffResult.data;
const session = ctx.sessions.get(id);
if (!session) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
session.flickerFilterEnabled = body.enabled;
ctx.persistSessionState(session);
ctx.broadcast(SseEvent.SessionUpdated, ctx.getSessionStateWithRespawn(session));
return {
success: true,
data: {
flickerFilterEnabled: body.enabled,
},
};
});
// ═══════════════════════════════════════════════════════════════
// Quick Actions (quick-run, quick-start)
// ═══════════════════════════════════════════════════════════════
// ========== Quick Run ==========
app.post('/api/run', async (req) => {
// Prevent unbounded session creation
if (ctx.sessions.size >= MAX_CONCURRENT_SESSIONS) {
return createErrorResponse(
ApiErrorCode.SESSION_BUSY,
`Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached`
);
}
const qrResult = QuickRunSchema.safeParse(req.body);
if (!qrResult.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body');
}
const { prompt, workingDir } = qrResult.data;
if (!prompt.trim()) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'prompt is required');
}
const dir = workingDir || process.cwd();
// Validate workingDir exists and is a directory
if (workingDir) {
try {
const stat = statSync(dir);
if (!stat.isDirectory()) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'workingDir is not a directory');
}
} catch {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'workingDir does not exist');
}
}
const session = new Session({ workingDir: dir });
ctx.addSession(session);
ctx.store.incrementSessionsCreated();
ctx.persistSessionState(session);
await ctx.setupSessionListeners(session);
getLifecycleLog().log({
event: 'created',
sessionId: session.id,
name: session.name,
reason: 'run_prompt',
});
ctx.broadcast(SseEvent.SessionCreated, ctx.getSessionStateWithRespawn(session));
try {
const result = await session.runPrompt(prompt);
// Clean up session after completion to prevent memory leak
await ctx.cleanupSession(session.id, true, 'run_prompt_complete');
return { success: true, sessionId: session.id, ...result };
} catch (err) {
// Clean up session on error too
await ctx.cleanupSession(session.id, true, 'run_prompt_error');
return { success: false, sessionId: session.id, error: getErrorMessage(err) };
}
});
// ========== Quick Start ==========
app.post('/api/quick-start', async (req): Promise<QuickStartResponse> => {
// Prevent unbounded session creation
if (ctx.sessions.size >= MAX_CONCURRENT_SESSIONS) {
return createErrorResponse(
ApiErrorCode.SESSION_BUSY,
`Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached.`
);
}
const result = QuickStartSchema.safeParse(req.body);
if (!result.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed');
}
const { caseName = 'testcase', mode = 'claude', openCodeConfig } = result.data;
// 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'
);
}
}
const casePath = join(CASES_DIR, caseName);
// Security: Path traversal protection - use relative path check
const resolvedPath = resolve(casePath);
const resolvedBase = resolve(CASES_DIR);
const relPath = relative(resolvedBase, resolvedPath);
if (relPath.startsWith('..') || isAbsolute(relPath)) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path');
}
// Create case folder and CLAUDE.md if it doesn't exist
if (!existsSync(casePath)) {
try {
mkdirSync(casePath, { recursive: true });
mkdirSync(join(casePath, '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);
// Write .claude/settings.local.json with hooks for desktop notifications
// (Claude-specific — OpenCode uses its own plugin system)
if (mode !== 'opencode') {
await writeHooksConfig(casePath);
}
ctx.broadcast(SseEvent.CaseCreated, { name: caseName, path: casePath });
} catch (err) {
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to create case: ${getErrorMessage(err)}`);
}
}
// Create a new session with the case as working directory
// Apply global Nice priority config and model config from settings
const niceConfig = await ctx.getGlobalNiceConfig();
const qsModelConfig = await ctx.getModelConfig();
const qsModel =
mode === 'opencode' ? openCodeConfig?.model : mode !== 'shell' ? qsModelConfig?.defaultModel : undefined;
const qsClaudeModeConfig = await ctx.getClaudeModeConfig();
const session = new Session({
workingDir: casePath,
mux: ctx.mux,
useMux: true,
mode: mode,
niceConfig: niceConfig,
model: qsModel,
claudeMode: qsClaudeModeConfig.claudeMode,
allowedTools: qsClaudeModeConfig.allowedTools,
openCodeConfig: mode === 'opencode' ? openCodeConfig : undefined,
});
// 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 (!session.ralphTracker.enabled) {
session.ralphTracker.enable();
session.ralphTracker.enableAutoEnable(); // Allow re-enabling on restart
}
}
ctx.addSession(session);
ctx.store.incrementSessionsCreated();
ctx.persistSessionState(session);
await ctx.setupSessionListeners(session);
getLifecycleLog().log({
event: 'created',
sessionId: session.id,
name: session.name,
reason: 'quick_start',
});
ctx.broadcast(SseEvent.SessionCreated, ctx.getSessionStateWithRespawn(session));
// Start in the appropriate mode
try {
if (mode === 'shell') {
await session.startShell();
getLifecycleLog().log({
event: 'started',
sessionId: session.id,
name: session.name,
mode: 'shell',
});
ctx.broadcast(SseEvent.SessionInteractive, { id: session.id, mode: 'shell' });
} else {
// Both 'claude' and 'opencode' modes use startInteractive()
await session.startInteractive();
getLifecycleLog().log({
event: 'started',
sessionId: session.id,
name: session.name,
mode,
});
ctx.broadcast(SseEvent.SessionInteractive, { id: session.id, mode });
}
ctx.broadcast(SseEvent.SessionUpdated, { session: ctx.getSessionStateWithRespawn(session) });
// Save lastUsedCase to settings for TUI/web sync
try {
const settingsFilePath = SETTINGS_PATH;
let settings: Record<string, unknown> = {};
try {
settings = JSON.parse(await fs.readFile(settingsFilePath, 'utf-8'));
} catch (err) {
if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err;
}
settings.lastUsedCase = caseName;
const dir = dirname(settingsFilePath);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
}
// Use async write to avoid blocking event loop
fs.writeFile(settingsFilePath, JSON.stringify(settings, null, 2)).catch((err) => {
// Non-critical but log for debugging
console.warn('[Server] Failed to save settings (lastUsedCase):', err);
});
} catch (err) {
// Non-critical but log for debugging
console.warn('[Server] Failed to prepare settings update:', err);
}
return {
success: true,
sessionId: session.id,
casePath,
caseName,
};
} catch (err) {
// Clean up session on error to prevent orphaned resources
await ctx.cleanupSession(session.id, true, 'quick_start_error');
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
}
});
}