/** * @fileoverview Codeman web server and REST API * * Provides a Fastify-based web server with: * - REST API for session management, respawn control, and monitoring * - Server-Sent Events (SSE) for real-time updates at /api/events * - Static file serving for the web UI * - 60fps terminal streaming with batched updates * * @module web/server */ import Fastify, { FastifyInstance, FastifyReply } from 'fastify'; import fastifyCompress from '@fastify/compress'; import fastifyCookie from '@fastify/cookie'; import fastifyStatic from '@fastify/static'; import { join, dirname, resolve, relative, isAbsolute } from 'node:path'; import { fileURLToPath } from 'node:url'; import { existsSync, statSync, mkdirSync, writeFileSync, readdirSync, readFileSync, rmSync, chmodSync } from 'node:fs'; import fs from 'node:fs/promises'; import { execSync } from 'node:child_process'; import { randomBytes, timingSafeEqual } from 'node:crypto'; import { homedir, totalmem, freemem, loadavg, cpus } from 'node:os'; import { EventEmitter } from 'node:events'; import { Session, ClaudeMessage, type BackgroundTask, type RalphTrackerState, type RalphTodoItem, type ActiveBashTool, } from '../session.js'; import type { ClaudeMode } from '../types.js'; import { fileStreamManager } from '../file-stream-manager.js'; import { RespawnController, RespawnConfig, RespawnState } from '../respawn-controller.js'; import type { TerminalMultiplexer } from '../mux-interface.js'; import { createMultiplexer } from '../mux-factory.js'; import { getStore } from '../state-store.js'; import { generateClaudeMd } from '../templates/claude-md.js'; import { parseRalphLoopConfig, extractCompletionPhrase } from '../ralph-config.js'; import { writeHooksConfig, updateCaseEnvVars } from '../hooks-config.js'; import { subagentWatcher, type SubagentInfo, type SubagentToolCall, type SubagentProgress, type SubagentMessage, type SubagentToolResult, } from '../subagent-watcher.js'; import { imageWatcher } from '../image-watcher.js'; import { TranscriptWatcher } from '../transcript-watcher.js'; import { TeamWatcher } from '../team-watcher.js'; import { TunnelManager } from '../tunnel-manager.js'; import { v4 as uuidv4 } from 'uuid'; import { createRequire } from 'node:module'; import { RunSummaryTracker } from '../run-summary.js'; import { PlanOrchestrator, type DetailedPlanResult } from '../plan-orchestrator.js'; import { getLifecycleLog } from '../session-lifecycle-log.js'; import { PushSubscriptionStore } from '../push-store.js'; import webpush from 'web-push'; // Load version from package.json const require = createRequire(import.meta.url); const { version: APP_VERSION } = require('../../package.json'); import { getErrorMessage, ApiErrorCode, createErrorResponse, type ApiResponse, type QuickStartResponse, type CaseInfo, type PersistedRespawnConfig, type NiceConfig, type ImageDetectedEvent, DEFAULT_NICE_CONFIG, } from '../types.js'; import { CreateSessionSchema, RunPromptSchema, SessionInputWithLimitSchema, ResizeSchema, CreateCaseSchema, QuickStartSchema, HookEventSchema, ConfigUpdateSchema, RespawnConfigSchema, SessionNameSchema, SessionColorSchema, RalphConfigSchema, FixPlanImportSchema, RalphPromptWriteSchema, AutoClearSchema, AutoCompactSchema, ImageWatcherSchema, FlickerFilterSchema, QuickRunSchema, ScheduledRunSchema, LinkCaseSchema, GeneratePlanSchema, GeneratePlanDetailedSchema, CancelPlanSchema, PlanTaskUpdateSchema, PlanTaskAddSchema, CpuLimitSchema, SettingsUpdateSchema, ModelConfigUpdateSchema, SubagentWindowStatesSchema, SubagentParentMapSchema, InteractiveRespawnSchema, RespawnEnableSchema, PushSubscribeSchema, PushPreferencesUpdateSchema, RalphLoopStartSchema, isValidWorkingDir, } from './schemas.js'; import { StaleExpirationMap } from '../utils/index.js'; import { MAX_CONCURRENT_SESSIONS, MAX_SSE_CLIENTS } from '../config/map-limits.js'; const __dirname = dirname(fileURLToPath(import.meta.url)); interface ScheduledRun { id: string; prompt: string; workingDir: string; durationMinutes: number; startedAt: number; endAt: number; status: 'running' | 'completed' | 'failed' | 'stopped'; sessionId: string | null; completedTasks: number; totalCost: number; logs: string[]; } // Batch terminal data for performance - collect for 16ms (60fps) before sending const TERMINAL_BATCH_INTERVAL = 16; // Batch task:updated events for 100ms const TASK_UPDATE_BATCH_INTERVAL = 100; // DEC mode 2026 - Synchronized Output // When terminal supports this, it buffers all output between start/end markers // and renders atomically, eliminating partial-frame flicker from Ink redraws. // Supported by: WezTerm, Kitty, Ghostty, iTerm2 3.5+, Windows Terminal, VSCode terminal const DEC_SYNC_START = '\x1b[?2026h'; // Begin synchronized update const DEC_SYNC_END = '\x1b[?2026l'; // End synchronized update (flush to screen) // State update debounce interval (batch expensive toDetailedState() calls) const STATE_UPDATE_DEBOUNCE_INTERVAL = 500; // Cache TTL for getLightSessionsState() — avoids re-serializing all sessions on every SSE init / /api/sessions call const SESSIONS_LIST_CACHE_TTL = 1000; // Scheduled runs cleanup interval (check every 5 minutes) const SCHEDULED_CLEANUP_INTERVAL = 5 * 60 * 1000; // Completed scheduled runs max age (1 hour) const SCHEDULED_RUN_MAX_AGE = 60 * 60 * 1000; // SSE client health check interval (every 30 seconds) const SSE_HEALTH_CHECK_INTERVAL = 30 * 1000; // Maximum allowed input length for session write (64KB) const MAX_INPUT_LENGTH = 64 * 1024; // Maximum terminal resize dimensions const MAX_TERMINAL_COLS = 500; const MAX_TERMINAL_ROWS = 200; // Maximum session name length const MAX_SESSION_NAME_LENGTH = 128; // Maximum hook data size (prevents oversized SSE broadcasts) const MAX_HOOK_DATA_SIZE = 8 * 1024; // Maximum screenshot upload size (10MB) const MAX_SCREENSHOT_SIZE = 10 * 1024 * 1024; // Auth session cookie TTL (24h — matches autonomous run length) const AUTH_SESSION_TTL_MS = 24 * 60 * 60 * 1000; // Auth session cookie name const AUTH_COOKIE_NAME = 'codeman_session'; // Max concurrent auth sessions const MAX_AUTH_SESSIONS = 100; // Max failed auth attempts per IP before rate-limiting const AUTH_FAILURE_MAX = 10; // Failed auth attempt tracking window (15 minutes) const AUTH_FAILURE_WINDOW_MS = 15 * 60 * 1000; // Screenshots directory const SCREENSHOTS_DIR = join(homedir(), '.codeman', 'screenshots'); // Stats collection interval (2 seconds) const STATS_COLLECTION_INTERVAL_MS = 2000; // Session limit wait time before retrying (5 seconds) const SESSION_LIMIT_WAIT_MS = 5000; // Pause between scheduled run iterations (2 seconds) const ITERATION_PAUSE_MS = 2000; // Terminal batch flush threshold - flush immediately if batch exceeds this size // Set high (32KB) to allow effective batching; avg Ink events are ~14KB const BATCH_FLUSH_THRESHOLD = 32 * 1024; // 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]+/; /** * Formats uptime in seconds to a human-readable string. */ function formatUptime(seconds: number): string { const days = Math.floor(seconds / 86400); const hours = Math.floor((seconds % 86400) / 3600); const minutes = Math.floor((seconds % 3600) / 60); const secs = Math.floor(seconds % 60); const parts: string[] = []; if (days > 0) parts.push(`${days}d`); if (hours > 0) parts.push(`${hours}h`); if (minutes > 0) parts.push(`${minutes}m`); if (secs > 0 || parts.length === 0) parts.push(`${secs}s`); return parts.join(' '); } /** * Sanitizes hook event data before broadcasting via SSE. * Extracts only relevant fields and limits total size to prevent * oversized payloads from being broadcast to all connected clients. */ function sanitizeHookData(data: Record | null | undefined): Record { if (!data || typeof data !== 'object') return {}; // Only forward known safe fields from Claude Code hook stdin const safeFields: Record = {}; const allowedKeys = [ 'hook_event_name', 'tool_name', 'tool_input', 'session_id', 'cwd', 'permission_mode', 'stop_hook_active', 'transcript_path', ]; for (const key of allowedKeys) { if (key in data && data[key] !== undefined) { safeFields[key] = data[key]; } } // For tool_input, extract only summary fields (not full file content) if (safeFields.tool_input && typeof safeFields.tool_input === 'object') { const input = safeFields.tool_input as Record; const summary: Record = {}; if (input.command) summary.command = String(input.command).slice(0, 500); if (input.file_path) summary.file_path = String(input.file_path).slice(0, 500); if (input.description) summary.description = String(input.description).slice(0, 200); if (input.query) summary.query = String(input.query).slice(0, 200); if (input.url) summary.url = String(input.url).slice(0, 500); if (input.pattern) summary.pattern = String(input.pattern).slice(0, 200); if (input.prompt) summary.prompt = String(input.prompt).slice(0, 200); safeFields.tool_input = summary; } // Final size check - drop if serialized data exceeds limit const serialized = JSON.stringify(safeFields); if (serialized.length > MAX_HOOK_DATA_SIZE) { return { tool_name: safeFields.tool_name, _truncated: true }; } return safeFields; } /** * Auto-configure Ralph tracker for a session. * * Priority order: * 1. .claude/ralph-loop.local.md (official Ralph Wiggum plugin state) * 2. CLAUDE.md tags (fallback) * * The ralph-loop.local.md file has priority because it contains * the exact configuration from an active Ralph loop session. */ function autoConfigureRalph( session: Session, workingDir: string, broadcast: (event: string, data: unknown) => void ): void { // First, try to read the official Ralph Wiggum plugin state file const ralphConfig = parseRalphLoopConfig(workingDir); if (ralphConfig && ralphConfig.completionPromise) { session.ralphTracker.enable(); session.ralphTracker.startLoop(ralphConfig.completionPromise, ralphConfig.maxIterations ?? undefined); // Restore iteration count if available if (ralphConfig.iteration > 0) { // The tracker's cycleCount will be updated when we detect iteration patterns // in the terminal output, but we can set maxIterations now console.log(`[auto-detect] Ralph loop at iteration ${ralphConfig.iteration}/${ralphConfig.maxIterations ?? '∞'}`); } console.log( `[auto-detect] Configured Ralph loop for session ${session.id} from ralph-loop.local.md: ${ralphConfig.completionPromise}` ); broadcast('session:ralphLoopUpdate', { sessionId: session.id, state: session.ralphTracker.loopState, }); return; } // Fallback: try CLAUDE.md const claudeMdPath = join(workingDir, 'CLAUDE.md'); const completionPhrase = extractCompletionPhrase(claudeMdPath); if (completionPhrase) { session.ralphTracker.enable(); session.ralphTracker.startLoop(completionPhrase); console.log(`[auto-detect] Configured Ralph loop for session ${session.id} from CLAUDE.md: ${completionPhrase}`); broadcast('session:ralphLoopUpdate', { sessionId: session.id, state: session.ralphTracker.loopState, }); } } /** * Get or generate a self-signed TLS certificate for HTTPS. * Certs are stored in ~/.codeman/certs/ and reused across restarts. */ function getOrCreateSelfSignedCert(): { key: string; cert: string } { const certsDir = join(homedir(), '.codeman', 'certs'); const keyPath = join(certsDir, 'server.key'); const certPath = join(certsDir, 'server.crt'); if (existsSync(keyPath) && existsSync(certPath)) { return { key: readFileSync(keyPath, 'utf-8'), cert: readFileSync(certPath, 'utf-8'), }; } mkdirSync(certsDir, { recursive: true, mode: 0o700 }); // Generate self-signed cert valid for 365 days, covering localhost and common LAN access patterns execSync( `openssl req -x509 -newkey rsa:2048 -nodes ` + `-keyout "${keyPath}" -out "${certPath}" ` + `-days 365 -subj "/CN=codeman" ` + `-addext "subjectAltName=DNS:localhost,IP:127.0.0.1,IP:0.0.0.0"`, { stdio: 'pipe' } ); // Restrict private key to owner-only (prevent other local users from reading it) chmodSync(keyPath, 0o600); return { key: readFileSync(keyPath, 'utf-8'), cert: readFileSync(certPath, 'utf-8'), }; } /** Stored listener references for session cleanup (prevents memory leaks) */ interface SessionListenerRefs { terminal: (data: string) => void; clearTerminal: () => void; needsRefresh: () => void; message: (msg: ClaudeMessage) => void; error: (error: string) => void; completion: (result: string, cost: number) => void; exit: (code: number | null) => void; working: () => void; idle: () => void; taskCreated: (task: BackgroundTask) => void; taskUpdated: (task: BackgroundTask) => void; taskCompleted: (task: BackgroundTask) => void; taskFailed: (task: BackgroundTask, error: string) => void; autoClear: (data: { tokens: number; threshold: number }) => void; autoCompact: (data: { tokens: number; threshold: number; prompt?: string }) => void; cliInfoUpdated: (data: { version?: string; model?: string; accountType?: string; latestVersion?: string }) => void; ralphLoopUpdate: (state: RalphTrackerState) => void; ralphTodoUpdate: (todos: RalphTodoItem[]) => void; ralphCompletionDetected: (phrase: string) => void; ralphStatusBlockDetected: (block: import('../types.js').RalphStatusBlock) => void; ralphCircuitBreakerUpdate: (status: import('../types.js').CircuitBreakerStatus) => void; ralphExitGateMet: (data: { completionIndicators: number; exitSignal: boolean }) => void; bashToolStart: (tool: ActiveBashTool) => void; bashToolEnd: (tool: ActiveBashTool) => void; bashToolsUpdate: (tools: ActiveBashTool[]) => void; } export class WebServer extends EventEmitter { /** Cached CPU count — doesn't change at runtime */ private static readonly CPU_COUNT = cpus().length; private app: FastifyInstance; private sessions: Map = new Map(); private respawnControllers: Map = new Map(); private respawnTimers: Map = new Map(); private runSummaryTrackers: Map = new Map(); private transcriptWatchers: Map = new Map(); // Store session listener references for explicit cleanup (prevents memory leaks) private sessionListenerRefs: Map = new Map(); private scheduledRuns: Map = new Map(); private sseClients: Set = new Set(); /** Clients with backpressure — skip writes until 'drain' fires */ private backpressuredClients: Set = new Set(); private store = getStore(); private port: number; private https: boolean; private testMode: boolean; private mux: TerminalMultiplexer; // Terminal batching for performance private terminalBatches: Map = new Map(); private terminalBatchSizes: Map = new Map(); // Running total avoids O(n) reduce per push private terminalBatchTimers: Map = new Map(); // Per-session timers (staggered flushes) // Adaptive batching: track rapid events to extend batch window (per-session) // StaleExpirationMap auto-cleans entries for sessions that stop generating output private lastTerminalEventTime: StaleExpirationMap = new StaleExpirationMap({ ttlMs: 5 * 60 * 1000, // 5 minutes - auto-expire stale session timing data refreshOnGet: false, // Don't refresh on reads, only on explicit sets }); // Scheduled runs cleanup timer private scheduledCleanupTimer: NodeJS.Timeout | null = null; // SSE event batching private taskUpdateBatches: Map = new Map(); private taskUpdateBatchTimer: NodeJS.Timeout | null = null; // State update batching (reduce expensive toDetailedState() serialization) private stateUpdatePending: Set = new Set(); private stateUpdateTimer: NodeJS.Timeout | null = null; // SSE client health check timer private sseHealthCheckTimer: NodeJS.Timeout | null = null; // Flag to prevent new timers during shutdown private _isStopping: boolean = false; // Cached light state for SSE init (avoids rebuilding on every reconnect) private cachedLightState: { data: Record; timestamp: number } | null = null; private static readonly LIGHT_STATE_CACHE_TTL_MS = 1000; // Cached sessions list for getLightSessionsState() (avoids re-serializing all sessions on every call) private cachedSessionsList: { data: unknown[]; timestamp: number } | null = null; // Token recording for daily stats (track what's been recorded to avoid double-counting) private lastRecordedTokens: Map = new Map(); private tokenRecordingTimer: NodeJS.Timeout | null = null; // Server startup time for respawn grace period calculation private readonly serverStartTime: number = Date.now(); // Pending respawn start timers (for cleanup on shutdown) private pendingRespawnStarts: Map = new Map(); // Active plan orchestrators (for cancellation via API) private activePlanOrchestrators: Map = new Map(); private persistDebounceTimers: Map> = new Map(); // Grace period before starting restored respawn controllers (2 minutes) private static readonly RESPAWN_RESTORE_GRACE_PERIOD_MS = 2 * 60 * 1000; // Stored listener handlers for cleanup private subagentWatcherHandlers: { discovered: (info: SubagentInfo) => void; updated: (info: SubagentInfo) => void; toolCall: (data: SubagentToolCall) => void; toolResult: (data: SubagentToolResult) => void; progress: (data: SubagentProgress) => void; message: (data: SubagentMessage) => void; completed: (info: SubagentInfo) => void; error: (error: Error, agentId?: string) => void; } | null = null; private imageWatcherHandlers: { detected: (event: ImageDetectedEvent) => void; error: (error: Error, sessionId?: string) => void; } | null = null; private tunnelManager: TunnelManager = new TunnelManager(); private authSessions: StaleExpirationMap | null = null; private authFailures: StaleExpirationMap | null = null; private pushStore: PushSubscriptionStore = new PushSubscriptionStore(); private teamWatcher: TeamWatcher = new TeamWatcher(); private teamWatcherHandlers: { teamCreated: (config: unknown) => void; teamUpdated: (config: unknown) => void; teamRemoved: (config: unknown) => void; taskUpdated: (data: unknown) => void; } | null = null; constructor(port: number = 3000, https: boolean = false, testMode: boolean = false) { super(); this.setMaxListeners(0); this.port = port; this.https = https; this.testMode = testMode; if (https) { const { key, cert } = getOrCreateSelfSignedCert(); this.app = Fastify({ logger: false, https: { key, cert } }); } else { this.app = Fastify({ logger: false }); } this.mux = createMultiplexer(); // Set up mux event listeners this.mux.on('sessionCreated', (session) => { this.broadcast('mux:created', session); }); this.mux.on('sessionKilled', (data) => { this.broadcast('mux:killed', data); }); this.mux.on('sessionDied', (data) => { getLifecycleLog().log({ event: 'mux_died', sessionId: (data as { sessionId?: string }).sessionId || 'unknown', extra: data as Record, }); this.broadcast('mux:died', data); }); this.mux.on('statsUpdated', (sessions) => { this.broadcast('mux:statsUpdated', sessions); }); // Set up subagent watcher listeners this.setupSubagentWatcherListeners(); // Set up image watcher listeners this.setupImageWatcherListeners(); // Set up team watcher listeners this.setupTeamWatcherListeners(); // Set up tunnel manager listeners this.tunnelManager.on('started', (data: { url: string }) => { this.broadcast('tunnel:started', data); }); this.tunnelManager.on('stopped', () => { this.broadcast('tunnel:stopped', {}); }); this.tunnelManager.on('error', (message: string) => { this.broadcast('tunnel:error', { message }); }); this.tunnelManager.on('progress', (data: { message: string }) => { this.broadcast('tunnel:progress', data); }); } /** * Set up event listeners for subagent watcher. * Broadcasts real-time subagent activity to SSE clients. * * The SubagentWatcher now extracts descriptions directly from the parent session's * transcript, which contains the exact Task tool call with the description parameter. * This is more reliable than the previous timing-based correlation approach. */ private setupSubagentWatcherListeners(): void { // Store handlers for cleanup on shutdown this.subagentWatcherHandlers = { discovered: (info: SubagentInfo) => this.broadcast('subagent:discovered', info), updated: (info: SubagentInfo) => this.broadcast('subagent:updated', info), toolCall: (data: SubagentToolCall) => this.broadcast('subagent:tool_call', data), toolResult: (data: SubagentToolResult) => this.broadcast('subagent:tool_result', data), progress: (data: SubagentProgress) => this.broadcast('subagent:progress', data), message: (data: SubagentMessage) => this.broadcast('subagent:message', data), completed: (info: SubagentInfo) => this.broadcast('subagent:completed', info), error: (error: Error, agentId?: string) => { console.error(`[SubagentWatcher] Error${agentId ? ` for ${agentId}` : ''}:`, error.message); }, }; subagentWatcher.on('subagent:discovered', this.subagentWatcherHandlers.discovered); subagentWatcher.on('subagent:updated', this.subagentWatcherHandlers.updated); subagentWatcher.on('subagent:tool_call', this.subagentWatcherHandlers.toolCall); subagentWatcher.on('subagent:tool_result', this.subagentWatcherHandlers.toolResult); subagentWatcher.on('subagent:progress', this.subagentWatcherHandlers.progress); subagentWatcher.on('subagent:message', this.subagentWatcherHandlers.message); subagentWatcher.on('subagent:completed', this.subagentWatcherHandlers.completed); subagentWatcher.on('subagent:error', this.subagentWatcherHandlers.error); } /** * Clean up subagent watcher listeners to prevent memory leaks. */ private cleanupSubagentWatcherListeners(): void { if (this.subagentWatcherHandlers) { subagentWatcher.off('subagent:discovered', this.subagentWatcherHandlers.discovered); subagentWatcher.off('subagent:updated', this.subagentWatcherHandlers.updated); subagentWatcher.off('subagent:tool_call', this.subagentWatcherHandlers.toolCall); subagentWatcher.off('subagent:tool_result', this.subagentWatcherHandlers.toolResult); subagentWatcher.off('subagent:progress', this.subagentWatcherHandlers.progress); subagentWatcher.off('subagent:message', this.subagentWatcherHandlers.message); subagentWatcher.off('subagent:completed', this.subagentWatcherHandlers.completed); subagentWatcher.off('subagent:error', this.subagentWatcherHandlers.error); this.subagentWatcherHandlers = null; } } /** * Set up event listeners for image watcher. * Broadcasts image detection events to SSE clients for auto-popup. */ private setupImageWatcherListeners(): void { // Store handlers for cleanup on shutdown this.imageWatcherHandlers = { detected: (event: ImageDetectedEvent) => this.broadcast('image:detected', event), error: (error: Error, sessionId?: string) => { console.error(`[ImageWatcher] Error${sessionId ? ` for ${sessionId}` : ''}:`, error.message); }, }; imageWatcher.on('image:detected', this.imageWatcherHandlers.detected); imageWatcher.on('image:error', this.imageWatcherHandlers.error); } /** * Clean up image watcher listeners to prevent memory leaks. */ private cleanupImageWatcherListeners(): void { if (this.imageWatcherHandlers) { imageWatcher.off('image:detected', this.imageWatcherHandlers.detected); imageWatcher.off('image:error', this.imageWatcherHandlers.error); this.imageWatcherHandlers = null; } } /** * Set up event listeners for team watcher. * Broadcasts team activity events to SSE clients. */ private setupTeamWatcherListeners(): void { this.teamWatcherHandlers = { teamCreated: (config: unknown) => this.broadcast('team:created', config), teamUpdated: (config: unknown) => this.broadcast('team:updated', config), teamRemoved: (config: unknown) => this.broadcast('team:removed', config), taskUpdated: (data: unknown) => this.broadcast('team:taskUpdated', data), }; this.teamWatcher.on('teamCreated', this.teamWatcherHandlers.teamCreated); this.teamWatcher.on('teamUpdated', this.teamWatcherHandlers.teamUpdated); this.teamWatcher.on('teamRemoved', this.teamWatcherHandlers.teamRemoved); this.teamWatcher.on('taskUpdated', this.teamWatcherHandlers.taskUpdated); } /** * Clean up team watcher listeners to prevent memory leaks. */ private cleanupTeamWatcherListeners(): void { if (this.teamWatcherHandlers) { this.teamWatcher.off('teamCreated', this.teamWatcherHandlers.teamCreated); this.teamWatcher.off('teamUpdated', this.teamWatcherHandlers.teamUpdated); this.teamWatcher.off('teamRemoved', this.teamWatcherHandlers.teamRemoved); this.teamWatcher.off('taskUpdated', this.teamWatcherHandlers.taskUpdated); this.teamWatcherHandlers = null; } } private async setupRoutes(): Promise { // Allow multipart/form-data for screenshot uploads — skip Fastify's body parser // so the route handler can read the raw stream directly. this.app.addContentTypeParser('multipart/form-data', (_req, _payload, done) => { done(null); }); // Enable gzip/brotli compression for all responses. // Massive win: 793KB uncompressed → ~120KB compressed for static assets. // Threshold 1024 = don't compress tiny responses (headers > savings). await this.app.register(fastifyCompress, { threshold: 1024, }); // Cookie plugin (needed for auth session tokens) await this.app.register(fastifyCookie); // Optional HTTP Basic Auth with session cookies and rate limiting const authPassword = process.env.CODEMAN_PASSWORD; if (authPassword) { const authUsername = process.env.CODEMAN_USERNAME || 'admin'; const expectedHeader = 'Basic ' + Buffer.from(`${authUsername}:${authPassword}`).toString('base64'); // Session token store — active sessions extend TTL on access this.authSessions = new StaleExpirationMap({ ttlMs: AUTH_SESSION_TTL_MS, refreshOnGet: true, }); // Failure counter per IP — decay naturally after 15 minutes this.authFailures = new StaleExpirationMap({ ttlMs: AUTH_FAILURE_WINDOW_MS, refreshOnGet: false, }); this.app.addHook('onRequest', (req, reply, done) => { // Hook events come from local Claude Code hooks (curl from localhost) — no auth headers available. // Safe: validated by HookEventSchema, only triggers broadcasts. // Security: restrict bypass to localhost only — prevents forged hook events via tunnel/LAN. if (req.url === '/api/hook-event' && req.method === 'POST') { const ip = req.ip; if (ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1') { done(); return; } // Non-localhost hook requests fall through to normal auth } const clientIp = req.ip; // Rate limit: reject if too many failed attempts from this IP const failures = this.authFailures!.get(clientIp) ?? 0; if (failures >= AUTH_FAILURE_MAX) { reply.code(429).send('Too Many Requests — try again later'); return; } // Check session cookie first (avoids re-sending credentials on every request) // Use get() instead of has() so refreshOnGet extends the TTL on active sessions const sessionToken = req.cookies[AUTH_COOKIE_NAME]; if (sessionToken && this.authSessions!.get(sessionToken) !== undefined) { done(); return; } // Check Basic Auth header (timing-safe comparison to prevent side-channel attacks) const auth = req.headers.authorization; const authBuf = Buffer.from(auth ?? ''); const expectedBuf = Buffer.from(expectedHeader); if (authBuf.length === expectedBuf.length && timingSafeEqual(authBuf, expectedBuf)) { // Issue session token cookie so browser doesn't need to re-send credentials const token = randomBytes(32).toString('hex'); // Evict oldest if at capacity (prevent unbounded growth) if (this.authSessions!.size >= MAX_AUTH_SESSIONS) { const oldestKey = this.authSessions!.keys().next().value; if (oldestKey !== undefined) this.authSessions!.delete(oldestKey); } this.authSessions!.set(token, clientIp); // Reset failure count on successful auth this.authFailures!.delete(clientIp); reply.setCookie(AUTH_COOKIE_NAME, token, { httpOnly: true, secure: this.https, sameSite: 'lax', maxAge: AUTH_SESSION_TTL_MS / 1000, // seconds path: '/', }); done(); return; } // Auth failed — track failure count this.authFailures!.set(clientIp, failures + 1); reply.header('WWW-Authenticate', 'Basic realm="Codeman"'); reply.code(401).send('Unauthorized'); }); } // Security headers + CORS on every response this.app.addHook('onRequest', (req, reply, done) => { reply.header('X-Content-Type-Options', 'nosniff'); reply.header('X-Frame-Options', 'SAMEORIGIN'); reply.header( 'Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; img-src 'self' data: blob:; connect-src 'self' wss://api.deepgram.com; font-src 'self' https://cdn.jsdelivr.net; frame-ancestors 'self'" ); if (this.https) { reply.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); } // CORS: restrict to same-origin (localhost) only const origin = req.headers.origin; if (origin) { try { const url = new URL(origin); if (url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '::1') { reply.header('Access-Control-Allow-Origin', origin); reply.header('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS'); reply.header('Access-Control-Allow-Headers', 'Content-Type, Authorization'); reply.header('Access-Control-Max-Age', '86400'); } } catch { // Invalid origin header — do not set CORS headers } } // Handle CORS preflight if (req.method === 'OPTIONS') { reply.code(204).send(); done(); return; } done(); }); // Service worker must never be cached — browsers check for SW updates on navigation this.app.get('/sw.js', async (_req, reply) => { return reply .header('Cache-Control', 'no-cache, no-store') .header('Service-Worker-Allowed', '/') .type('application/javascript') .sendFile('sw.js', join(__dirname, 'public')); }); // Serve static files — versioned assets (?v=X) are immutable, cache aggressively // preCompressed: serve pre-built .br/.gz files (from build step) to avoid per-request CPU compression await this.app.register(fastifyStatic, { root: join(__dirname, 'public'), prefix: '/', maxAge: '1y', immutable: true, preCompressed: true, }); // SSE endpoint for real-time updates this.app.get('/api/events', (req, reply) => { // Enforce SSE client limit to prevent memory exhaustion from too many connections if (this.sseClients.size >= MAX_SSE_CLIENTS) { reply.code(503).send('Too many SSE connections'); return; } reply.raw.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive', 'X-Accel-Buffering': 'no', // Disable nginx buffering }); this.sseClients.add(reply); // Send initial state // Use light state for SSE init to avoid sending 2MB+ terminal buffers // Buffers are fetched on-demand when switching tabs this.sendSSE(reply, 'init', this.getLightState()); req.raw.on('close', () => { this.sseClients.delete(reply); this.backpressuredClients.delete(reply); }); }); // API Routes // Logout: invalidate session cookie this.app.post('/api/logout', async (req, reply) => { const sessionToken = req.cookies[AUTH_COOKIE_NAME]; if (sessionToken && this.authSessions) { this.authSessions.delete(sessionToken); } reply.clearCookie(AUTH_COOKIE_NAME, { path: '/' }); return { success: true }; }); this.app.get('/api/status', async () => this.getLightState()); this.app.get('/api/tunnel/status', async () => this.tunnelManager.getStatus()); this.app.get('/api/tunnel/qr', async (_req, reply) => { const url = this.tunnelManager.getUrl(); if (!url) { return reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'Tunnel not running')); } try { const QRCode = require('qrcode'); const svg: string = await QRCode.toString(url, { type: 'svg', margin: 2, width: 256 }); // Return as data URI to avoid Fastify compress issues with SVG content-type return { svg }; } catch (err) { return reply.code(500).send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err))); } }); // OpenCode CLI availability check this.app.get('/api/opencode/status', async () => { const { isOpenCodeAvailable, resolveOpenCodeDir } = await import('../utils/opencode-cli-resolver.js'); return { available: isOpenCodeAvailable(), path: resolveOpenCodeDir(), }; }); // Cleanup stale sessions from state file this.app.post('/api/cleanup-state', async () => { const cleaned = this.cleanupStaleSessions(); return { success: true, cleanedSessions: cleaned }; }); // Session lifecycle audit log this.app.get('/api/session-lifecycle', async (req) => { const query = req.query as { sessionId?: string; event?: string; since?: string; limit?: string; }; const lifecycleLog = getLifecycleLog(); const entries = await lifecycleLog.query({ sessionId: query.sessionId, event: query.event as import('../types.js').LifecycleEventType, since: query.since ? Number(query.since) : undefined, limit: query.limit ? Math.min(Number(query.limit), 1000) : 200, }); return { success: true, entries }; }); // Global stats endpoint this.app.get('/api/stats', async () => { const activeSessionTokens: Record = {}; for (const [sessionId, session] of this.sessions) { activeSessionTokens[sessionId] = { inputTokens: session.inputTokens, outputTokens: session.outputTokens, totalCost: session.totalCost, }; } return { success: true, stats: this.store.getAggregateStats(activeSessionTokens), raw: this.store.getGlobalStats(), }; }); // Token stats with daily history this.app.get('/api/token-stats', async () => { // Get aggregate totals (global + active sessions) const activeSessionTokens: Record = {}; for (const [sessionId, session] of this.sessions) { activeSessionTokens[sessionId] = { inputTokens: session.inputTokens, outputTokens: session.outputTokens, totalCost: session.totalCost, }; } return { success: true, daily: this.store.getDailyStats(30), totals: this.store.getAggregateStats(activeSessionTokens), }; }); this.app.get('/api/config', async () => { return { success: true, config: this.store.getConfig() }; }); this.app.put('/api/config', async (req) => { // Validate request body against schema to prevent arbitrary config injection const parseResult = ConfigUpdateSchema.safeParse(req.body); if (!parseResult.success) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Invalid config: ${parseResult.error.message}`); } this.store.setConfig(parseResult.data as Partial>); return { success: true, config: this.store.getConfig() }; }); // Debug/monitoring endpoint - lightweight, only runs when called // Returns comprehensive memory metrics for debugging memory leaks this.app.get('/api/debug/memory', async () => { const mem = process.memoryUsage(); const subagentStats = subagentWatcher.getStats(); // Calculate total Map entries for memory estimation const serverMapSizes = { sessions: this.sessions.size, sseClients: this.sseClients.size, respawnControllers: this.respawnControllers.size, runSummaryTrackers: this.runSummaryTrackers.size, transcriptWatchers: this.transcriptWatchers.size, scheduledRuns: this.scheduledRuns.size, terminalBatches: this.terminalBatches.size, taskUpdateBatches: this.taskUpdateBatches.size, stateUpdatePending: this.stateUpdatePending.size, lastRecordedTokens: this.lastRecordedTokens.size, pendingRespawnStarts: this.pendingRespawnStarts.size, respawnTimers: this.respawnTimers.size, activePlanOrchestrators: this.activePlanOrchestrators.size, cleaningUp: this.cleaningUp.size, }; const totalServerMapEntries = Object.values(serverMapSizes).reduce((a, b) => a + b, 0); const totalSubagentMapEntries = Object.values(subagentStats).reduce((a, b) => a + b, 0); return { memory: { rss: mem.rss, rssMB: Math.round((mem.rss / 1024 / 1024) * 10) / 10, heapUsed: mem.heapUsed, heapUsedMB: Math.round((mem.heapUsed / 1024 / 1024) * 10) / 10, heapTotal: mem.heapTotal, heapTotalMB: Math.round((mem.heapTotal / 1024 / 1024) * 10) / 10, external: mem.external, externalMB: Math.round((mem.external / 1024 / 1024) * 10) / 10, arrayBuffers: mem.arrayBuffers, arrayBuffersMB: Math.round((mem.arrayBuffers / 1024 / 1024) * 10) / 10, }, mapSizes: { server: serverMapSizes, subagentWatcher: subagentStats, totals: { serverEntries: totalServerMapEntries, subagentEntries: totalSubagentMapEntries, allEntries: totalServerMapEntries + totalSubagentMapEntries, }, }, watchers: { fileWatchers: subagentStats.fileWatcherCount, dirWatchers: subagentStats.dirWatcherCount, transcriptWatchers: this.transcriptWatchers.size, total: subagentStats.fileWatcherCount + subagentStats.dirWatcherCount + this.transcriptWatchers.size, }, timers: { respawnTimers: this.respawnTimers.size, pendingRespawnStarts: this.pendingRespawnStarts.size, subagentIdleTimers: subagentStats.idleTimerCount, total: this.respawnTimers.size + this.pendingRespawnStarts.size + subagentStats.idleTimerCount, }, uptime: { seconds: Math.round(process.uptime()), formatted: formatUptime(process.uptime()), }, timestamp: Date.now(), }; }); // Session management this.app.get('/api/sessions', async () => this.getLightSessionsState()); this.app.post('/api/sessions', async (req) => { // Prevent unbounded session creation if (this.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 this.getGlobalNiceConfig(); const modelConfig = await this.getModelConfig(); const mode = body.mode || 'claude'; const model = mode === 'opencode' ? body.openCodeConfig?.model : mode !== 'shell' ? modelConfig?.defaultModel : undefined; const claudeModeConfig = await this.getClaudeModeConfig(); const session = new Session({ workingDir, mode, name: body.name || '', mux: this.mux, useMux: true, niceConfig: globalNice, model, claudeMode: claudeModeConfig.claudeMode, allowedTools: claudeModeConfig.allowedTools, openCodeConfig: mode === 'opencode' ? body.openCodeConfig : undefined, }); this.sessions.set(session.id, session); this.store.incrementSessionsCreated(); this.persistSessionState(session); await this.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 = this.getSessionStateWithRespawn(session); this.broadcast('session:created', lightState); return { success: true, session: lightState }; }); // Rename a session this.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 = this.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 this.mux.updateSessionName(id, session.name); this.persistSessionState(session); this.broadcast('session:updated', this.getSessionStateWithRespawn(session)); return { success: true, name: session.name }; }); // Set session color this.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 = this.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 import('../types.js').SessionColor); this.persistSessionState(session); this.broadcast('session:updated', this.getSessionStateWithRespawn(session)); return { success: true, color: session.color }; }); this.app.delete('/api/sessions/:id', async (req): Promise => { const { id } = req.params as { id: string }; const query = req.query as { killMux?: string }; const killMux = query.killMux !== 'false'; // Default to true if (!this.sessions.has(id)) { return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); } await this.cleanupSession(id, killMux, 'user_delete'); return { success: true }; }); // Kill all sessions at once this.app.delete('/api/sessions', async (): Promise> => { const sessionIds = Array.from(this.sessions.keys()); let killed = 0; for (const id of sessionIds) { if (this.sessions.has(id)) { await this.cleanupSession(id, true, 'user_bulk_delete'); killed++; } } return { success: true, data: { killed } }; }); this.app.get('/api/sessions/:id', async (req) => { const { id } = req.params as { id: string }; const session = this.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 this.getSessionStateWithRespawn(session); }); this.app.get('/api/sessions/:id/output', async (req) => { const { id } = req.params as { id: string }; const session = this.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 (Ralph loop + todos) for a session this.app.get('/api/sessions/:id/ralph-state', async (req) => { const { id } = req.params as { id: string }; const session = this.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 for a session (what happened while you were away) this.app.get('/api/sessions/:id/run-summary', async (req) => { const { id } = req.params as { id: string }; const session = this.sessions.get(id); if (!session) { return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); } const tracker = this.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); this.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 Bash tools for a session (file-viewing commands) this.app.get('/api/sessions/:id/active-tools', async (req) => { const { id } = req.params as { id: string }; const session = this.sessions.get(id); if (!session) { return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); } return { success: true, data: { tools: session.activeTools, }, }; }); // Get file tree for session's working directory (File Browser) this.app.get('/api/sessions/:id/files', async (req) => { const { id } = req.params as { id: string }; const { depth, showHidden } = req.query as { depth?: string; showHidden?: string }; const session = this.sessions.get(id); if (!session) { return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); } const maxDepth = Math.min(parseInt(depth || '5', 10), 10); const includeHidden = showHidden === 'true'; const workingDir = session.workingDir; // Default excludes - large/generated directories const excludeDirs = new Set([ '.git', 'node_modules', 'dist', 'build', '__pycache__', '.cache', '.next', '.nuxt', 'coverage', '.venv', 'venv', '.tox', 'target', 'vendor', ]); interface FileTreeNode { name: string; path: string; type: 'file' | 'directory'; size?: number; extension?: string; children?: FileTreeNode[]; } let totalFiles = 0; let totalDirectories = 0; let truncated = false; const maxFiles = 5000; const scanDirectory = async (dirPath: string, currentDepth: number): Promise => { if (currentDepth > maxDepth || totalFiles + totalDirectories > maxFiles) { truncated = true; return []; } try { const entries = await fs.readdir(dirPath, { withFileTypes: true }); const nodes: FileTreeNode[] = []; // Sort: directories first, then alphabetically entries.sort((a, b) => { if (a.isDirectory() && !b.isDirectory()) return -1; if (!a.isDirectory() && b.isDirectory()) return 1; return a.name.localeCompare(b.name); }); for (const entry of entries) { if (totalFiles + totalDirectories > maxFiles) { truncated = true; break; } // Skip hidden files unless requested if (!includeHidden && entry.name.startsWith('.')) continue; // Skip excluded directories if (entry.isDirectory() && excludeDirs.has(entry.name)) continue; const fullPath = join(dirPath, entry.name); const relativePath = fullPath.slice(workingDir.length + 1); if (entry.isDirectory()) { totalDirectories++; const children = await scanDirectory(fullPath, currentDepth + 1); nodes.push({ name: entry.name, path: relativePath, type: 'directory', children, }); } else { totalFiles++; const ext = entry.name.includes('.') ? entry.name.split('.').pop()?.toLowerCase() : undefined; let size: number | undefined; try { const stat = await fs.stat(fullPath); size = stat.size; } catch { // Skip if can't stat } nodes.push({ name: entry.name, path: relativePath, type: 'file', size, extension: ext, }); } } return nodes; } catch (err) { // Can't read directory (permission denied, etc.) return []; } }; const tree = await scanDirectory(workingDir, 1); return { success: true, data: { root: workingDir, tree, totalFiles, totalDirectories, truncated, }, }; }); // Get file content for preview (File Browser) this.app.get('/api/sessions/:id/file-content', async (req) => { const { id } = req.params as { id: string }; const { path: filePath, lines, raw } = req.query as { path?: string; lines?: string; raw?: string }; const session = this.sessions.get(id); if (!session) { return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); } if (!filePath) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter'); } // Validate path is within working directory (security: proper path traversal check) const fullPath = resolve(session.workingDir, filePath); const relativePath = relative(session.workingDir, fullPath); if (relativePath.startsWith('..') || isAbsolute(relativePath)) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Path must be within working directory'); } try { const stat = await fs.stat(fullPath); // Check if it's a binary/media file const ext = filePath.split('.').pop()?.toLowerCase() || ''; const binaryExts = new Set([ 'png', 'jpg', 'jpeg', 'gif', 'webp', 'ico', 'svg', 'bmp', 'mp4', 'webm', 'mov', 'avi', 'mp3', 'wav', 'ogg', 'pdf', 'zip', 'tar', 'gz', 'exe', 'dll', 'so', 'woff', 'woff2', 'ttf', 'eot', ]); const imageExts = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico']); const videoExts = new Set(['mp4', 'webm', 'mov', 'avi']); if (raw === 'true' || binaryExts.has(ext)) { // Return metadata for binary files return { success: true, data: { path: filePath, size: stat.size, type: imageExts.has(ext) ? 'image' : videoExts.has(ext) ? 'video' : 'binary', extension: ext, url: `/api/sessions/${id}/file-raw?path=${encodeURIComponent(filePath)}`, }, }; } // Validate file size before reading (DoS protection - prevent memory exhaustion) const MAX_TEXT_FILE_SIZE = 10 * 1024 * 1024; // 10MB if (stat.size > MAX_TEXT_FILE_SIZE) { return createErrorResponse( ApiErrorCode.INVALID_INPUT, `File too large (${Math.round(stat.size / 1024 / 1024)}MB > ${MAX_TEXT_FILE_SIZE / 1024 / 1024}MB limit)` ); } // Read text file with line limit (bounded to prevent DoS) const MAX_LINES_LIMIT = 10000; const maxLines = Math.min(parseInt(lines || '500', 10) || 500, MAX_LINES_LIMIT); const content = await fs.readFile(fullPath, 'utf-8'); const allLines = content.split('\n'); const truncatedContent = allLines.length > maxLines; const displayContent = truncatedContent ? allLines.slice(0, maxLines).join('\n') : content; return { success: true, data: { path: filePath, content: displayContent, size: stat.size, totalLines: allLines.length, truncated: truncatedContent, extension: ext, }, }; } catch (err) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read file: ${getErrorMessage(err)}`); } }); // Serve raw file content (for images/binary files) this.app.get('/api/sessions/:id/file-raw', async (req, reply) => { const { id } = req.params as { id: string }; const { path: filePath } = req.query as { path?: string }; const session = this.sessions.get(id); if (!session) { reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found')); return; } if (!filePath) { reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter')); return; } // Validate path is within working directory (security: proper path traversal check) const fullPath = resolve(session.workingDir, filePath); const relativePath = relative(session.workingDir, fullPath); if (relativePath.startsWith('..') || isAbsolute(relativePath)) { reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Path must be within working directory')); return; } try { // Validate file size before reading (DoS protection - prevent memory exhaustion) const MAX_RAW_FILE_SIZE = 50 * 1024 * 1024; // 50MB for raw files const stat = await fs.stat(fullPath); if (stat.size > MAX_RAW_FILE_SIZE) { reply .code(400) .send( createErrorResponse( ApiErrorCode.INVALID_INPUT, `File too large (${Math.round(stat.size / 1024 / 1024)}MB > ${MAX_RAW_FILE_SIZE / 1024 / 1024}MB limit)` ) ); return; } const ext = filePath.split('.').pop()?.toLowerCase() || ''; const mimeTypes: Record = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp', svg: 'image/svg+xml', ico: 'image/x-icon', bmp: 'image/bmp', mp4: 'video/mp4', webm: 'video/webm', mov: 'video/quicktime', mp3: 'audio/mpeg', wav: 'audio/wav', ogg: 'audio/ogg', pdf: 'application/pdf', json: 'application/json', }; const content = await fs.readFile(fullPath); reply.header('Content-Type', mimeTypes[ext] || 'application/octet-stream'); reply.send(content); } catch (err) { reply .code(500) .send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read file: ${getErrorMessage(err)}`)); } }); // Stream file content via tail -f (SSE endpoint) this.app.get('/api/sessions/:id/tail-file', async (req, reply) => { const { id } = req.params as { id: string }; const { path: filePath, lines } = req.query as { path?: string; lines?: string }; const session = this.sessions.get(id); if (!session) { reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found')); return; } if (!filePath) { reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter')); return; } // Set up SSE headers reply.raw.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive', 'X-Accel-Buffering': 'no', }); // Track stream for cleanup const streamRef: { id?: string } = {}; // Create the file stream const result = await fileStreamManager.createStream({ sessionId: id, filePath, workingDir: session.workingDir, lines: lines ? parseInt(lines, 10) : undefined, onData: (data) => { // Send data as SSE event reply.raw.write(`data: ${JSON.stringify({ type: 'data', content: data })}\n\n`); }, onEnd: () => { reply.raw.write(`data: ${JSON.stringify({ type: 'end' })}\n\n`); reply.raw.end(); }, onError: (error) => { reply.raw.write(`data: ${JSON.stringify({ type: 'error', error })}\n\n`); }, }); if (!result.success) { reply.raw.write(`data: ${JSON.stringify({ type: 'error', error: result.error })}\n\n`); reply.raw.end(); return; } streamRef.id = result.streamId; // Notify client of successful connection reply.raw.write(`data: ${JSON.stringify({ type: 'connected', streamId: result.streamId, filePath })}\n\n`); // Handle client disconnect req.raw.on('close', () => { if (streamRef.id) { fileStreamManager.closeStream(streamRef.id); } }); }); // Close a file stream this.app.delete('/api/sessions/:id/tail-file/:streamId', async (req) => { const { id, streamId } = req.params as { id: string; streamId: string }; const session = this.sessions.get(id); if (!session) { return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); } const closed = fileStreamManager.closeStream(streamId); return { success: closed }; }); // Configure Ralph (Ralph Wiggum) settings this.app.post('/api/sessions/:id/ralph-config', async (req) => { const { id } = req.params as { id: string }; const ralphResult = RalphConfigSchema.safeParse(req.body); if (!ralphResult.success) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); } const { enabled, completionPhrase, maxIterations, reset, disableAutoEnable } = ralphResult.data as { enabled?: boolean; completionPhrase?: string; maxIterations?: number; reset?: boolean | 'full'; disableAutoEnable?: boolean; }; const session = this.sessions.get(id); if (!session) { return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); } // Ralph tracker is not supported for opencode sessions if (session.mode === 'opencode') { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Ralph tracker is not supported for opencode sessions'); } // Handle reset first (before other config) if (reset) { if (reset === 'full') { session.ralphTracker.fullReset(); } else { session.ralphTracker.reset(); } } // Configure auto-enable behavior if (disableAutoEnable !== undefined) { if (disableAutoEnable) { session.ralphTracker.disableAutoEnable(); } else { session.ralphTracker.enableAutoEnable(); } } // Enable/disable the tracker if (enabled !== undefined) { if (enabled) { session.ralphTracker.enable(); // Allow re-enabling on restart if user explicitly enabled session.ralphTracker.enableAutoEnable(); } else { session.ralphTracker.disable(); // Prevent re-enabling on restart when user explicitly disabled session.ralphTracker.disableAutoEnable(); } // Persist Ralph enabled state this.mux.updateRalphEnabled(id, enabled); } // Configure the Ralph tracker if (completionPhrase !== undefined) { // Start loop with completion phrase to set it up for watching if (completionPhrase) { session.ralphTracker.startLoop(completionPhrase, maxIterations || undefined); } } if (maxIterations !== undefined) { session.ralphTracker.setMaxIterations(maxIterations || null); } // Persist and broadcast the update this.persistSessionState(session); this.broadcast('session:ralphLoopUpdate', { sessionId: id, state: session.ralphLoopState, }); return { success: true }; }); // Reset circuit breaker for Ralph tracker this.app.post('/api/sessions/:id/ralph-circuit-breaker/reset', async (req) => { const { id } = req.params as { id: string }; const session = this.sessions.get(id); if (!session) { return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); } session.ralphTracker.resetCircuitBreaker(); return { success: true }; }); // Get Ralph status block and circuit breaker state this.app.get('/api/sessions/:id/ralph-status', async (req) => { const { id } = req.params as { id: string }; const session = this.sessions.get(id); if (!session) { return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); } return { success: true, data: { lastStatusBlock: session.ralphTracker.lastStatusBlock, circuitBreaker: session.ralphTracker.circuitBreakerStatus, cumulativeStats: session.ralphTracker.cumulativeStats, exitGateMet: session.ralphTracker.exitGateMet, }, }; }); // Generate @fix_plan.md content from todos this.app.get('/api/sessions/:id/fix-plan', async (req) => { const { id } = req.params as { id: string }; const session = this.sessions.get(id); if (!session) { return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); } const content = session.ralphTracker.generateFixPlanMarkdown(); return { success: true, data: { content, todoCount: session.ralphTracker.todos.length, }, }; }); // Import todos from @fix_plan.md content this.app.post('/api/sessions/:id/fix-plan/import', async (req) => { const { id } = req.params as { id: string }; const importResult = FixPlanImportSchema.safeParse(req.body); if (!importResult.success) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); } const { content } = importResult.data; const session = this.sessions.get(id); if (!session) { return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); } const importedCount = session.ralphTracker.importFixPlanMarkdown(content); this.persistSessionState(session); return { success: true, data: { importedCount, todos: session.ralphTracker.todos, }, }; }); // Write @fix_plan.md to session's working directory this.app.post('/api/sessions/:id/fix-plan/write', async (req) => { const { id } = req.params as { id: string }; const session = this.sessions.get(id); if (!session) { return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); } const workingDir = session.workingDir; if (!workingDir) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Session has no working directory'); } const content = session.ralphTracker.generateFixPlanMarkdown(); const filePath = join(workingDir, '@fix_plan.md'); try { await fs.writeFile(filePath, content, 'utf-8'); return { success: true, data: { filePath, todoCount: session.ralphTracker.todos.length, }, }; } catch (error) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to write file: ${error}`); } }); // Read @fix_plan.md from session's working directory and import this.app.post('/api/sessions/:id/fix-plan/read', async (req) => { const { id } = req.params as { id: string }; const session = this.sessions.get(id); if (!session) { return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); } const workingDir = session.workingDir; if (!workingDir) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Session has no working directory'); } const filePath = join(workingDir, '@fix_plan.md'); try { const content = await fs.readFile(filePath, 'utf-8'); const importedCount = session.ralphTracker.importFixPlanMarkdown(content); this.persistSessionState(session); return { success: true, data: { filePath, importedCount, todos: session.ralphTracker.todos, }, }; } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { return createErrorResponse(ApiErrorCode.NOT_FOUND, '@fix_plan.md not found in working directory'); } return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read file: ${error}`); } }); // Write Ralph prompt to file in session's working directory // This avoids mux input escaping issues with long multi-line prompts this.app.post('/api/sessions/:id/ralph-prompt/write', async (req) => { const { id } = req.params as { id: string }; const promptResult = RalphPromptWriteSchema.safeParse(req.body); if (!promptResult.success) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); } const { content } = promptResult.data; const session = this.sessions.get(id); if (!session) { return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); } const workingDir = session.workingDir; if (!workingDir) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Session has no working directory'); } const filePath = join(workingDir, '@ralph_prompt.md'); try { await fs.writeFile(filePath, content, 'utf-8'); return { success: true, data: { filePath, contentLength: content.length, }, }; } catch (error) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to write file: ${error}`); } }); // Run prompt in session this.app.post('/api/sessions/:id/run', async (req): Promise => { 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 = this.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) => { this.broadcast('session:error', { id, error: err.message }); }); this.broadcast('session:running', { id, prompt }); return { success: true }; }); // Start interactive Claude session (persists even if browser disconnects) this.app.post('/api/sessions/:id/interactive', async (req): Promise => { const { id } = req.params as { id: string }; const session = this.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' && this.store.getConfig().ralphEnabled && !session.ralphTracker.autoEnableDisabled ) { autoConfigureRalph(session, session.workingDir, () => {}); if (!session.ralphTracker.enabled) { session.ralphTracker.enable(); } } await session.startInteractive(); getLifecycleLog().log({ event: 'started', sessionId: id, name: session.name, mode: session.mode, }); this.broadcast('session:interactive', { id }); this.broadcast('session:updated', { session: this.getSessionStateWithRespawn(session) }); return { success: true }; } catch (err) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); } }); // Start a plain shell session (no Claude) this.app.post('/api/sessions/:id/shell', async (req): Promise => { const { id } = req.params as { id: string }; const session = this.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', }); this.broadcast('session:interactive', { id, mode: 'shell' }); this.broadcast('session:updated', { session: this.getSessionStateWithRespawn(session) }); return { success: true }; } catch (err) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); } }); // Send input to interactive session // useMux: true uses writeViaMux which is more reliable for programmatic input this.app.post('/api/sessions/:id/input', async (req): Promise => { 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 = this.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 session terminal this.app.post('/api/sessions/:id/resize', async (req): Promise => { 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 = this.sessions.get(id); if (!session) { return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); } // Note: Zod already validates that cols and rows are positive integers within bounds if (cols > MAX_TERMINAL_COLS || rows > MAX_TERMINAL_ROWS) { return createErrorResponse( ApiErrorCode.INVALID_INPUT, `Terminal dimensions exceed maximum (${MAX_TERMINAL_COLS}x${MAX_TERMINAL_ROWS})` ); } session.resize(cols, rows); return { success: true }; }); // Get session terminal buffer (for reconnecting) // Query params: // tail= - Only return last N bytes (faster initial load) this.app.get('/api/sessions/:id/terminal', async (req) => { const { id } = req.params as { id: string }; const query = req.query as { tail?: string }; const session = this.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, }; }); // ============ Respawn Controller Endpoints ============ // Get respawn status for a session this.app.get('/api/sessions/:id/respawn', async (req) => { const { id } = req.params as { id: string }; const controller = this.respawnControllers.get(id); if (!controller) { return { enabled: false, status: null }; } return { enabled: true, ...controller.getStatus(), }; }); // Get respawn config (from running controller or pre-saved) this.app.get('/api/sessions/:id/respawn/config', async (req) => { const { id } = req.params as { id: string }; const controller = this.respawnControllers.get(id); if (controller) { return { success: true, config: controller.getConfig(), active: true }; } // Return pre-saved config from mux-sessions.json const preConfig = this.mux.getSession(id)?.respawnConfig; if (preConfig) { return { success: true, config: preConfig, active: false }; } return { success: true, config: null, active: false }; }); // Start respawn controller for a session this.app.post('/api/sessions/:id/respawn/start', async (req) => { const { id } = req.params as { id: string }; let body: Partial | undefined; if (req.body) { const result = RespawnConfigSchema.safeParse(req.body); if (!result.success) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid respawn config'); } body = result.data as Partial; } const session = this.sessions.get(id); if (!session) { return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); } // Respawn is not supported for opencode sessions if (session.mode === 'opencode') { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Respawn is not supported for opencode sessions'); } // Create or get existing controller let controller = this.respawnControllers.get(id); if (!controller) { // Merge request body with pre-saved config from mux-sessions.json const preConfig = this.mux.getSession(id)?.respawnConfig; const config = body || preConfig ? { ...preConfig, ...body } : undefined; controller = new RespawnController(session, config); this.respawnControllers.set(id, controller); this.setupRespawnListeners(id, controller); } else if (body) { controller.updateConfig(body); } controller.start(); // Persist respawn config to mux session and state.json this.saveRespawnConfig(id, controller.getConfig()); this.persistSessionState(session); this.broadcast('respawn:started', { sessionId: id, status: controller.getStatus() }); return { success: true, status: controller.getStatus() }; }); // Stop respawn controller for a session this.app.post('/api/sessions/:id/respawn/stop', async (req) => { const { id } = req.params as { id: string }; const controller = this.respawnControllers.get(id); if (!controller) { return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Respawn controller not found'); } controller.stop(); // Remove controller from map so persistSessionState doesn't save respawnEnabled: true this.respawnControllers.delete(id); // Clear any timed respawn const timerInfo = this.respawnTimers.get(id); if (timerInfo) { clearTimeout(timerInfo.timer); this.respawnTimers.delete(id); } // Clear persisted respawn config this.mux.clearRespawnConfig(id); // Update state.json (respawnConfig removed) const session = this.sessions.get(id); if (session) { this.persistSessionState(session); } this.broadcast('respawn:stopped', { sessionId: id }); return { success: true }; }); // Update respawn configuration (works with or without running controller) this.app.put('/api/sessions/:id/respawn/config', async (req) => { const { id } = req.params as { id: string }; // Validate respawn config to prevent arbitrary field injection const parseResult = RespawnConfigSchema.safeParse(req.body); if (!parseResult.success) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Invalid respawn config: ${parseResult.error.message}`); } const config = parseResult.data as Partial; const session = this.sessions.get(id); if (!session) { return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); } const controller = this.respawnControllers.get(id); if (controller) { // Update running controller controller.updateConfig(config); this.saveRespawnConfig(id, controller.getConfig()); this.persistSessionState(session); this.broadcast('respawn:configUpdated', { sessionId: id, config: controller.getConfig() }); return { success: true, config: controller.getConfig() }; } // No controller running - save as pre-config for when respawn starts const existing = this.mux.getSession(id); const currentConfig = existing?.respawnConfig; const merged: PersistedRespawnConfig = { enabled: config.enabled ?? currentConfig?.enabled ?? false, idleTimeoutMs: config.idleTimeoutMs ?? currentConfig?.idleTimeoutMs ?? 10000, updatePrompt: config.updatePrompt ?? currentConfig?.updatePrompt ?? 'update all the docs and CLAUDE.md', interStepDelayMs: config.interStepDelayMs ?? currentConfig?.interStepDelayMs ?? 1000, sendClear: config.sendClear ?? currentConfig?.sendClear ?? true, sendInit: config.sendInit ?? currentConfig?.sendInit ?? true, kickstartPrompt: config.kickstartPrompt ?? currentConfig?.kickstartPrompt, autoAcceptPrompts: config.autoAcceptPrompts ?? currentConfig?.autoAcceptPrompts ?? true, autoAcceptDelayMs: config.autoAcceptDelayMs ?? currentConfig?.autoAcceptDelayMs ?? 8000, aiIdleCheckEnabled: config.aiIdleCheckEnabled ?? currentConfig?.aiIdleCheckEnabled ?? true, aiIdleCheckModel: config.aiIdleCheckModel ?? currentConfig?.aiIdleCheckModel ?? 'claude-opus-4-5-20251101', aiIdleCheckMaxContext: config.aiIdleCheckMaxContext ?? currentConfig?.aiIdleCheckMaxContext ?? 16000, aiIdleCheckTimeoutMs: config.aiIdleCheckTimeoutMs ?? currentConfig?.aiIdleCheckTimeoutMs ?? 90000, aiIdleCheckCooldownMs: config.aiIdleCheckCooldownMs ?? currentConfig?.aiIdleCheckCooldownMs ?? 180000, aiPlanCheckEnabled: config.aiPlanCheckEnabled ?? currentConfig?.aiPlanCheckEnabled ?? true, aiPlanCheckModel: config.aiPlanCheckModel ?? currentConfig?.aiPlanCheckModel ?? 'claude-opus-4-5-20251101', aiPlanCheckMaxContext: config.aiPlanCheckMaxContext ?? currentConfig?.aiPlanCheckMaxContext ?? 8000, aiPlanCheckTimeoutMs: config.aiPlanCheckTimeoutMs ?? currentConfig?.aiPlanCheckTimeoutMs ?? 60000, aiPlanCheckCooldownMs: config.aiPlanCheckCooldownMs ?? currentConfig?.aiPlanCheckCooldownMs ?? 30000, durationMinutes: currentConfig?.durationMinutes, }; this.mux.updateRespawnConfig(id, merged); this.persistSessionState(session); this.broadcast('respawn:configUpdated', { sessionId: id, config: merged }); return { success: true, config: merged }; }); // Start interactive session WITH respawn enabled this.app.post('/api/sessions/:id/interactive-respawn', async (req) => { const { id } = req.params as { id: string }; const irResult = req.body ? InteractiveRespawnSchema.safeParse(req.body) : { success: true as const, data: {} }; if (!irResult.success) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); } const body = irResult.data as { respawnConfig?: Partial; durationMinutes?: number; }; const session = this.sessions.get(id); if (!session) { return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); } if (session.isBusy()) { return createErrorResponse(ApiErrorCode.SESSION_BUSY, 'Session is busy'); } // Respawn is not supported for opencode sessions if (session.mode === 'opencode') { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Respawn is not supported for opencode sessions'); } try { // Auto-detect completion phrase from CLAUDE.md BEFORE starting (only if globally enabled and not explicitly disabled by user) if (this.store.getConfig().ralphEnabled && !session.ralphTracker.autoEnableDisabled) { autoConfigureRalph(session, session.workingDir, () => {}); if (!session.ralphTracker.enabled) { session.ralphTracker.enable(); } } // Start interactive session await session.startInteractive(); getLifecycleLog().log({ event: 'started', sessionId: id, name: session.name, mode: session.mode, reason: 'interactive_respawn', }); this.broadcast('session:interactive', { id }); this.broadcast('session:updated', { session: this.getSessionStateWithRespawn(session) }); // Create and start respawn controller const controller = new RespawnController(session, body?.respawnConfig); this.respawnControllers.set(id, controller); this.setupRespawnListeners(id, controller); controller.start(); // Set up timed stop if duration specified if (body?.durationMinutes && body.durationMinutes > 0) { this.setupTimedRespawn(id, body.durationMinutes); } // Persist full session state with respawn config this.persistSessionState(session); this.broadcast('respawn:started', { sessionId: id, status: controller.getStatus() }); return { success: true, data: { message: 'Interactive session with respawn started', respawnStatus: controller.getStatus(), }, }; } catch (err) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); } }); // Enable respawn on an EXISTING interactive session this.app.post('/api/sessions/:id/respawn/enable', async (req) => { const { id } = req.params as { id: string }; const reResult = req.body ? RespawnEnableSchema.safeParse(req.body) : { success: true as const, data: {} }; if (!reResult.success) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); } const body = reResult.data as { config?: Partial; durationMinutes?: number }; const session = this.sessions.get(id); if (!session) { return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); } // Respawn is not supported for opencode sessions if (session.mode === 'opencode') { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Respawn is not supported for opencode sessions'); } // Check if session is running (has a PID) if (!session.pid) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Session is not running. Start it first.'); } // Stop existing controller if any const existingController = this.respawnControllers.get(id); if (existingController) { existingController.stop(); } // Create and start new respawn controller (merge with pre-saved config) const preConfig = this.mux.getSession(id)?.respawnConfig; const config = body?.config || preConfig ? { ...preConfig, ...body?.config } : undefined; const controller = new RespawnController(session, config); this.respawnControllers.set(id, controller); this.setupRespawnListeners(id, controller); controller.start(); // Set up timed stop if duration specified if (body?.durationMinutes && body.durationMinutes > 0) { this.setupTimedRespawn(id, body.durationMinutes); } // Persist respawn config to mux session and state.json this.saveRespawnConfig(id, controller.getConfig(), body?.durationMinutes); this.persistSessionState(session); this.broadcast('respawn:started', { sessionId: id, status: controller.getStatus() }); return { success: true, message: 'Respawn enabled on existing session', respawnStatus: controller.getStatus(), }; }); // Set auto-clear on a session this.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 = this.sessions.get(id); if (!session) { return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); } session.setAutoClear(body.enabled, body.threshold); this.persistSessionState(session); this.broadcast('session:updated', this.getSessionStateWithRespawn(session)); return { success: true, data: { autoClear: { enabled: session.autoClearEnabled, threshold: session.autoClearThreshold, }, }, }; }); // Set auto-compact on a session this.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 = this.sessions.get(id); if (!session) { return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); } session.setAutoCompact(body.enabled, body.threshold, body.prompt); this.persistSessionState(session); this.broadcast('session:updated', this.getSessionStateWithRespawn(session)); return { success: true, data: { autoCompact: { enabled: session.autoCompactEnabled, threshold: session.autoCompactThreshold, prompt: session.autoCompactPrompt, }, }, }; }); // Toggle image watcher for a session this.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 = this.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; this.persistSessionState(session); return { success: true, data: { imageWatcherEnabled: body.enabled, }, }; }); // Toggle flicker filter for a session this.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 = this.sessions.get(id); if (!session) { return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); } session.flickerFilterEnabled = body.enabled; this.persistSessionState(session); this.broadcast('session:updated', this.getSessionStateWithRespawn(session)); return { success: true, data: { flickerFilterEnabled: body.enabled, }, }; }); // Quick run (create session, run prompt, return result, then cleanup) this.app.post('/api/run', async (req) => { // Prevent unbounded session creation if (this.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 }); this.sessions.set(session.id, session); this.store.incrementSessionsCreated(); this.persistSessionState(session); await this.setupSessionListeners(session); getLifecycleLog().log({ event: 'created', sessionId: session.id, name: session.name, reason: 'run_prompt', }); this.broadcast('session:created', this.getSessionStateWithRespawn(session)); try { const result = await session.runPrompt(prompt); // Clean up session after completion to prevent memory leak await this.cleanupSession(session.id, true, 'run_prompt_complete'); return { success: true, sessionId: session.id, ...result }; } catch (err) { // Clean up session on error too await this.cleanupSession(session.id, true, 'run_prompt_error'); return { success: false, sessionId: session.id, error: getErrorMessage(err) }; } }); // Scheduled runs this.app.get('/api/scheduled', async () => { return Array.from(this.scheduledRuns.values()); }); this.app.post( '/api/scheduled', async (req): Promise<{ success: boolean; run: ScheduledRun } | ApiResponse> => { const srResult = ScheduledRunSchema.safeParse(req.body); if (!srResult.success) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); } const { prompt, workingDir, durationMinutes } = srResult.data; // Validate workingDir exists and is a directory if (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'); } } const run = await this.startScheduledRun(prompt, workingDir || process.cwd(), durationMinutes ?? 60); return { success: true, run }; } ); this.app.delete('/api/scheduled/:id', async (req) => { const { id } = req.params as { id: string }; const run = this.scheduledRuns.get(id); if (!run) { return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Scheduled run not found'); } await this.stopScheduledRun(id); return { success: true }; }); this.app.get('/api/scheduled/:id', async (req) => { const { id } = req.params as { id: string }; const run = this.scheduledRuns.get(id); if (!run) { return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Scheduled run not found'); } return run; }); // Case management const casesDir = join(homedir(), 'codeman-cases'); this.app.get('/api/cases', async (): Promise => { const cases: CaseInfo[] = []; // Get cases from casesDir try { const entries = await fs.readdir(casesDir, { withFileTypes: true }); for (const e of entries) { if (e.isDirectory()) { cases.push({ name: e.name, path: join(casesDir, e.name), hasClaudeMd: existsSync(join(casesDir, e.name, 'CLAUDE.md')), }); } } } catch { // casesDir may not exist yet } // Get linked cases const linkedCasesFile = join(homedir(), '.codeman', 'linked-cases.json'); try { const linkedCases: Record = JSON.parse(await fs.readFile(linkedCasesFile, 'utf-8')); for (const [name, path] of Object.entries(linkedCases)) { // Only add if not already in cases (avoid duplicates) and path exists if (!cases.some((c) => c.name === name) && existsSync(path)) { cases.push({ name, path, hasClaudeMd: existsSync(join(path, 'CLAUDE.md')), }); } } } catch (err) { if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { console.warn('[Server] Failed to read linked cases:', err); } } return cases; }); this.app.post('/api/cases', async (req): Promise> => { const result = CreateCaseSchema.safeParse(req.body); if (!result.success) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed'); } const { name, description } = result.data; const casePath = join(casesDir, name); // Security: Path traversal protection - use relative path check const resolvedPath = resolve(casePath); const resolvedBase = resolve(casesDir); const relPath = relative(resolvedBase, resolvedPath); if (relPath.startsWith('..') || isAbsolute(relPath)) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path'); } if (existsSync(casePath)) { return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Case already exists'); } try { mkdirSync(casePath, { recursive: true }); mkdirSync(join(casePath, 'src'), { recursive: true }); // Read settings to get custom template path const templatePath = await this.getDefaultClaudeMdPath(); const claudeMd = generateClaudeMd(name, description || '', templatePath); writeFileSync(join(casePath, 'CLAUDE.md'), claudeMd); // Write .claude/settings.local.json with hooks for desktop notifications await writeHooksConfig(casePath); this.broadcast('case:created', { name, path: casePath }); return { success: true, data: { case: { name, path: casePath } } }; } catch (err) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); } }); // Link an existing folder as a case this.app.post('/api/cases/link', async (req): Promise> => { const lcResult = LinkCaseSchema.safeParse(req.body); if (!lcResult.success) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); } const { name, path: folderPath } = lcResult.data; // Expand ~ to home directory const expandedPath = folderPath.startsWith('~') ? join(homedir(), folderPath.slice(1)) : folderPath; // Validate the folder exists if (!existsSync(expandedPath)) { return createErrorResponse(ApiErrorCode.NOT_FOUND, `Folder not found: ${expandedPath}`); } // Check if case name already exists in casesDir const casePath = join(casesDir, name); if (existsSync(casePath)) { return createErrorResponse( ApiErrorCode.ALREADY_EXISTS, 'A case with this name already exists in codeman-cases.' ); } // Load existing linked cases const linkedCasesFile = join(homedir(), '.codeman', 'linked-cases.json'); let linkedCases: Record = {}; try { linkedCases = JSON.parse(await fs.readFile(linkedCasesFile, 'utf-8')); } catch (err) { if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { console.warn('[Server] Failed to read linked cases:', err); } } // Check if name is already linked if (linkedCases[name]) { return createErrorResponse( ApiErrorCode.ALREADY_EXISTS, `Case "${name}" is already linked to ${linkedCases[name]}` ); } // Save the linked case linkedCases[name] = expandedPath; try { const codemanDir = join(homedir(), '.codeman'); if (!existsSync(codemanDir)) { mkdirSync(codemanDir, { recursive: true }); } await fs.writeFile(linkedCasesFile, JSON.stringify(linkedCases, null, 2)); this.broadcast('case:linked', { name, path: expandedPath }); return { success: true, data: { case: { name, path: expandedPath } } }; } catch (err) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); } }); this.app.get('/api/cases/:name', async (req) => { const { name } = req.params as { name: string }; // First check linked cases const linkedCasesFile = join(homedir(), '.codeman', 'linked-cases.json'); try { const linkedCases: Record = JSON.parse(await fs.readFile(linkedCasesFile, 'utf-8')); if (linkedCases[name]) { const linkedPath = linkedCases[name]; return { name, path: linkedPath, hasClaudeMd: existsSync(join(linkedPath, 'CLAUDE.md')), linked: true, }; } } catch { // ENOENT or parse errors - fall through to casesDir check } // Then check casesDir const casePath = join(casesDir, name); if (!existsSync(casePath)) { return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Case not found'); } return { name, path: casePath, hasClaudeMd: existsSync(join(casePath, 'CLAUDE.md')), }; }); // Read @fix_plan.md from a case directory (for wizard to detect existing plans) this.app.get('/api/cases/:name/fix-plan', async (req) => { const { name } = req.params as { name: string }; // Get case path (check linked cases first, then casesDir) let casePath: string | null = null; const linkedCasesFile = join(homedir(), '.codeman', 'linked-cases.json'); try { const linkedCases: Record = JSON.parse(await fs.readFile(linkedCasesFile, 'utf-8')); if (linkedCases[name]) { casePath = linkedCases[name]; } } catch { // ENOENT or parse errors - fall through to casesDir } if (!casePath) { casePath = join(casesDir, name); } const fixPlanPath = join(casePath, '@fix_plan.md'); if (!existsSync(fixPlanPath)) { return { success: true, exists: false, content: null, todos: [] }; } try { const content = await fs.readFile(fixPlanPath, 'utf-8'); // Parse todos from the content (similar to ralph-tracker's importFixPlanMarkdown) const todos: Array<{ content: string; status: 'pending' | 'in_progress' | 'completed'; priority: string | null; }> = []; const todoPattern = /^-\s*\[([ xX-])\]\s*(.+)$/; const p0HeaderPattern = /^##\s*(High Priority|Critical|P0|Critical Path)/i; const p1HeaderPattern = /^##\s*(Standard|P1|Medium Priority)/i; const p2HeaderPattern = /^##\s*(Nice to Have|P2|Low Priority)/i; const completedHeaderPattern = /^##\s*Completed/i; let currentPriority: string | null = null; let inCompletedSection = false; for (const line of content.split('\n')) { const trimmed = line.trim(); if (p0HeaderPattern.test(trimmed)) { currentPriority = 'P0'; inCompletedSection = false; continue; } if (p1HeaderPattern.test(trimmed)) { currentPriority = 'P1'; inCompletedSection = false; continue; } if (p2HeaderPattern.test(trimmed)) { currentPriority = 'P2'; inCompletedSection = false; continue; } if (completedHeaderPattern.test(trimmed)) { inCompletedSection = true; continue; } const match = trimmed.match(todoPattern); if (match) { const [, checkboxState, taskContent] = match; let status: 'pending' | 'in_progress' | 'completed'; if (inCompletedSection || checkboxState === 'x' || checkboxState === 'X') { status = 'completed'; } else if (checkboxState === '-') { status = 'in_progress'; } else { status = 'pending'; } todos.push({ content: taskContent.trim(), status, priority: inCompletedSection ? null : currentPriority, }); } } // Calculate stats in a single pass for better performance let pending = 0, inProgress = 0, completed = 0; for (const t of todos) { if (t.status === 'pending') pending++; else if (t.status === 'in_progress') inProgress++; else if (t.status === 'completed') completed++; } const stats = { total: todos.length, pending, inProgress, completed }; return { success: true, exists: true, content, todos, stats, }; } catch (err) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read @fix_plan.md: ${err}`); } }); // Quick Start: Create case (if needed) and start interactive session in one click this.app.post('/api/quick-start', async (req): Promise => { // Prevent unbounded session creation if (this.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(casesDir, caseName); // Security: Path traversal protection - use relative path check const resolvedPath = resolve(casePath); const resolvedBase = resolve(casesDir); 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 this.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); } this.broadcast('case:created', { 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 this.getGlobalNiceConfig(); const qsModelConfig = await this.getModelConfig(); const qsModel = mode === 'opencode' ? openCodeConfig?.model : mode !== 'shell' ? qsModelConfig?.defaultModel : undefined; const qsClaudeModeConfig = await this.getClaudeModeConfig(); const session = new Session({ workingDir: casePath, mux: this.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' && this.store.getConfig().ralphEnabled) { autoConfigureRalph(session, casePath, () => {}); // no broadcast yet if (!session.ralphTracker.enabled) { session.ralphTracker.enable(); session.ralphTracker.enableAutoEnable(); // Allow re-enabling on restart } } this.sessions.set(session.id, session); this.store.incrementSessionsCreated(); this.persistSessionState(session); await this.setupSessionListeners(session); getLifecycleLog().log({ event: 'created', sessionId: session.id, name: session.name, reason: 'quick_start', }); this.broadcast('session:created', this.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', }); this.broadcast('session:interactive', { 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, }); this.broadcast('session:interactive', { id: session.id, mode }); } this.broadcast('session:updated', { session: this.getSessionStateWithRespawn(session) }); // Save lastUsedCase to settings for TUI/web sync try { const settingsFilePath = join(homedir(), '.codeman', 'settings.json'); let settings: Record = {}; 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 this.cleanupSession(session.id, true, 'quick_start_error'); return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); } }); // ========== Ralph Loop Start (replaces 6-8 serial API calls from frontend) ========== this.app.post('/api/ralph-loop/start', async (req): Promise => { // Prevent unbounded session creation if (this.sessions.size >= MAX_CONCURRENT_SESSIONS) { return createErrorResponse( ApiErrorCode.SESSION_BUSY, `Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached.` ); } const rlResult = RalphLoopStartSchema.safeParse(req.body); if (!rlResult.success) { return createErrorResponse( ApiErrorCode.INVALID_INPUT, rlResult.error.issues[0]?.message ?? 'Validation failed' ); } const { caseName, taskDescription, completionPhrase, maxIterations, enableRespawn, planItems } = rlResult.data; const casePath = join(casesDir, caseName); // Security: Path traversal protection const rlResolvedPath = resolve(casePath); const rlResolvedBase = resolve(casesDir); const rlRelPath = relative(rlResolvedBase, rlResolvedPath); if (rlRelPath.startsWith('..') || isAbsolute(rlRelPath)) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path'); } // Create case folder if it doesn't exist (reuse quick-start logic) if (!existsSync(casePath)) { try { mkdirSync(casePath, { recursive: true }); mkdirSync(join(casePath, 'src'), { recursive: true }); const templatePath = await this.getDefaultClaudeMdPath(); const claudeMd = generateClaudeMd(caseName, '', templatePath); writeFileSync(join(casePath, 'CLAUDE.md'), claudeMd); await writeHooksConfig(casePath); this.broadcast('case:created', { name: caseName, path: casePath }); } catch (err) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to create case: ${getErrorMessage(err)}`); } } // Create session const niceConfig = await this.getGlobalNiceConfig(); const rlModelConfig = await this.getModelConfig(); const rlClaudeModeConfig = await this.getClaudeModeConfig(); const session = new Session({ workingDir: casePath, mux: this.mux, useMux: true, mode: 'claude', niceConfig, model: rlModelConfig?.defaultModel, claudeMode: rlClaudeModeConfig.claudeMode, allowedTools: rlClaudeModeConfig.allowedTools, }); // Configure Ralph tracker autoConfigureRalph(session, casePath, () => {}); if (!session.ralphTracker.enabled) { session.ralphTracker.enable(); session.ralphTracker.enableAutoEnable(); } session.ralphTracker.startLoop(completionPhrase, maxIterations ?? undefined); // Build fix_plan markdown from plan items if provided const enabledItems = planItems?.filter((i) => i.enabled) ?? []; let planContent = ''; if (enabledItems.length > 0) { const p0 = enabledItems.filter((i) => i.priority === 'P0'); const p1 = enabledItems.filter((i) => i.priority === 'P1'); const p2 = enabledItems.filter((i) => i.priority === 'P2'); const noPri = enabledItems.filter((i) => !i.priority); planContent = '# Implementation Plan\n\n'; planContent += `Generated: ${new Date().toISOString().slice(0, 10)}\n\n`; if (p0.length > 0) { planContent += '## Critical Path (P0)\n\n'; p0.forEach((i) => { planContent += `- [ ] ${i.content}\n`; }); planContent += '\n'; } if (p1.length > 0) { planContent += '## Standard (P1)\n\n'; p1.forEach((i) => { planContent += `- [ ] ${i.content}\n`; }); planContent += '\n'; } if (p2.length > 0) { planContent += '## Nice-to-Have (P2)\n\n'; p2.forEach((i) => { planContent += `- [ ] ${i.content}\n`; }); planContent += '\n'; } if (noPri.length > 0) { planContent += '## Tasks\n\n'; noPri.forEach((i) => { planContent += `- [ ] ${i.content}\n`; }); planContent += '\n'; } // Import into tracker and write to disk session.ralphTracker.importFixPlanMarkdown(planContent); const fixPlanPath = join(casePath, '@fix_plan.md'); writeFileSync(fixPlanPath, planContent, 'utf-8'); } // Build full prompt const hasPlan = enabledItems.length > 0; let fullPrompt = taskDescription + '\n\n---\n\n'; if (hasPlan) { fullPrompt += '## Task Plan\n\n'; fullPrompt += 'A task plan has been written to `@fix_plan.md`. Use this to track progress:\n'; fullPrompt += '- Reference the plan at the start of each iteration\n'; fullPrompt += '- Update task checkboxes as you complete items\n'; fullPrompt += '- Work through items in priority order (P0 > P1 > P2)\n\n'; } fullPrompt += '## Iteration Protocol\n\n'; fullPrompt += 'This is an autonomous loop. Files from previous iterations persist. On each iteration:\n'; fullPrompt += '1. Check what work has already been done\n'; fullPrompt += '2. Make incremental progress toward completion\n'; fullPrompt += '3. Commit meaningful changes with descriptive messages\n\n'; fullPrompt += '## Verification\n\n'; fullPrompt += 'After each significant change:\n'; fullPrompt += '- Run tests to verify (npm test, pytest, etc.)\n'; fullPrompt += '- Check for type/lint errors if applicable\n'; fullPrompt += '- If tests fail, read the error, fix it, and retry\n\n'; fullPrompt += '## Completion Criteria\n\n'; fullPrompt += `Output \`${completionPhrase}\` when ALL of the following are true:\n`; fullPrompt += '- All requirements from the task description are implemented\n'; fullPrompt += '- All tests pass\n'; fullPrompt += '- Changes are committed\n\n'; fullPrompt += '## If Stuck\n\n'; fullPrompt += 'If you encounter the same error for 3+ iterations:\n'; fullPrompt += "1. Document what you've tried\n"; fullPrompt += '2. Identify the specific blocker\n'; fullPrompt += '3. Try an alternative approach\n'; fullPrompt += '4. If truly blocked, output `BLOCKED` with an explanation\n'; // Write prompt to file const promptPath = join(casePath, '@ralph_prompt.md'); writeFileSync(promptPath, fullPrompt, 'utf-8'); // Register session this.sessions.set(session.id, session); this.store.incrementSessionsCreated(); this.persistSessionState(session); await this.setupSessionListeners(session); getLifecycleLog().log({ event: 'created', sessionId: session.id, name: session.name, reason: 'ralph_loop_start', }); this.broadcast('session:created', this.getSessionStateWithRespawn(session)); // Start interactive mode try { await session.startInteractive(); getLifecycleLog().log({ event: 'started', sessionId: session.id, name: session.name, mode: 'claude', }); this.broadcast('session:interactive', { id: session.id, mode: 'claude' }); this.broadcast('session:updated', { session: this.getSessionStateWithRespawn(session) }); } catch (err) { await this.cleanupSession(session.id, true, 'ralph_loop_start_error'); return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); } // Enable respawn if requested if (enableRespawn) { const ralphUpdatePrompt = 'Before /clear: Update CLAUDE.md with discoveries and notes, mark completed tasks in @fix_plan.md, write a brief progress summary to a file so the next iteration can continue seamlessly.'; const ralphKickstartPrompt = `You are in a Ralph Wiggum loop. Read @fix_plan.md for task status, continue on the next uncompleted task, output ${completionPhrase} when ALL tasks are complete.`; const controller = new RespawnController(session, { updatePrompt: ralphUpdatePrompt, sendClear: true, sendInit: true, kickstartPrompt: ralphKickstartPrompt, }); this.respawnControllers.set(session.id, controller); this.setupRespawnListeners(session.id, controller); controller.start(); this.saveRespawnConfig(session.id, controller.getConfig()); this.persistSessionState(session); this.broadcast('respawn:started', { sessionId: session.id, status: controller.getStatus(), }); } // Save lastUsedCase try { const settingsFilePath = join(homedir(), '.codeman', 'settings.json'); let settings: Record = {}; try { settings = JSON.parse(await fs.readFile(settingsFilePath, 'utf-8')); } catch { /* ignore */ } settings.lastUsedCase = caseName; const dir = dirname(settingsFilePath); if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); fs.writeFile(settingsFilePath, JSON.stringify(settings, null, 2)).catch(() => {}); } catch { /* non-critical */ } const sessionId = session.id; // Async: poll for CLI readiness, then send prompt setImmediate(() => { const pollReady = async () => { for (let attempt = 0; attempt < 60; attempt++) { await new Promise((r) => setTimeout(r, 500)); const s = this.sessions.get(sessionId); if (!s) return; // session was deleted // Check terminal output for prompt indicator const termBuf = s.getTerminalBuffer().slice(-2048); if (termBuf.includes('❯') || termBuf.includes('tokens')) { break; } } // Small extra delay for CLI to settle await new Promise((r) => setTimeout(r, 2000)); const s = this.sessions.get(sessionId); if (!s) return; try { await s.writeViaMux('Read @ralph_prompt.md and follow the instructions. Start working immediately.\r'); } catch (err) { console.warn(`[RalphLoop] Failed to send prompt to session ${sessionId}:`, getErrorMessage(err)); } }; pollReady().catch((err) => console.error('[RalphLoop] pollReady error:', err)); }); return { success: true, data: { sessionId, caseName }, }; }); // Use enhanced PlanItem from orchestrator (has verification, dependencies, tracking) type PlanItem = import('../plan-orchestrator.js').PlanItem; this.app.post('/api/generate-plan', async (req): Promise => { const gpResult = GeneratePlanSchema.safeParse(req.body); if (!gpResult.success) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); } const { taskDescription, detailLevel = 'standard' } = gpResult.data; // Build sophisticated prompt based on Ralph Wiggum methodology const detailConfig = { brief: { style: 'high-level milestones', testDepth: 'basic' }, standard: { style: 'balanced implementation steps', testDepth: 'thorough' }, detailed: { style: 'granular sub-tasks with full TDD coverage', testDepth: 'comprehensive', }, }; const levelConfig = detailConfig[detailLevel] || detailConfig.standard; const prompt = `You are an expert software architect breaking down a task into a thorough implementation plan. ## TASK TO IMPLEMENT ${taskDescription} ## YOUR MISSION Create a detailed, actionable implementation plan following Test-Driven Development (TDD) methodology. Think deeply about: - What are ALL the components, modules, and features needed? - What could go wrong? Add defensive steps for error handling. - How will we verify each part works? Tests before implementation. - What edge cases need handling? - What's the logical order of dependencies? ## DETAIL LEVEL: ${detailLevel.toUpperCase()} Style: ${levelConfig.style} Generate as many steps as needed to properly cover the task - don't artificially limit yourself. For complex projects, this could be 30, 50, or even 100+ steps. Quality over brevity. ## PLAN STRUCTURE Your plan MUST include these phases in order: ### Phase 1: Foundation & Setup - Project structure, dependencies, configuration - Database schemas, type definitions, interfaces ### Phase 2: Core Implementation (TDD Cycle) For EACH feature: 1. Write failing tests first (unit tests) 2. Implement the feature 3. Run tests, debug until passing 4. Refactor if needed ### Phase 3: Integration & Edge Cases - Integration tests for feature interactions - Edge case handling (errors, boundaries, invalid input) - Error messages and user feedback ### Phase 4: Verification & Hardening - Run full test suite - Fix any failing tests - Add missing test coverage - Final verification that ALL requirements are met ## OUTPUT FORMAT Return ONLY a JSON array. Each item MUST have: - id: unique identifier (e.g., "P0-001", "P1-002") - content: specific action (verb phrase, 15-120 chars, be descriptive!) - priority: "P0" (critical/blocking), "P1" (required), "P2" (enhancement) - verificationCriteria: HOW to verify this step is complete (required!) - tddPhase: "setup" | "test" | "impl" | "verify" - dependencies: array of task IDs this depends on (empty if none) ## EXAMPLE OUTPUT [ {"id": "P0-001", "content": "Create project structure with src/, tests/, and config directories", "priority": "P0", "verificationCriteria": "Directories exist, package.json initialized", "tddPhase": "setup", "dependencies": []}, {"id": "P0-002", "content": "Define TypeScript interfaces for User, Session, and AuthToken types", "priority": "P0", "verificationCriteria": "Types compile without errors, exported from types.ts", "tddPhase": "setup", "dependencies": ["P0-001"]}, {"id": "P0-003", "content": "Write failing unit tests for password hashing (valid password, empty, too short)", "priority": "P0", "verificationCriteria": "Tests exist, fail with 'not implemented'", "tddPhase": "test", "dependencies": ["P0-002"]}, {"id": "P0-004", "content": "Implement password hashing with bcrypt, configurable salt rounds", "priority": "P0", "verificationCriteria": "npm test -- --grep='password' passes", "tddPhase": "impl", "dependencies": ["P0-003"]}, {"id": "P0-005", "content": "Write failing tests for JWT token generation and validation", "priority": "P0", "verificationCriteria": "Tests exist, fail with 'not implemented'", "tddPhase": "test", "dependencies": ["P0-004"]}, {"id": "P0-006", "content": "Implement JWT service with access/refresh token support", "priority": "P0", "verificationCriteria": "npm test -- --grep='JWT' passes", "tddPhase": "impl", "dependencies": ["P0-005"]}, {"id": "P1-001", "content": "Write integration tests for login flow (valid creds, invalid, locked account)", "priority": "P1", "verificationCriteria": "Integration tests exist, fail until endpoint implemented", "tddPhase": "test", "dependencies": ["P0-006"]}, {"id": "P1-002", "content": "Implement login endpoint with rate limiting and audit logging", "priority": "P1", "verificationCriteria": "All login tests pass, endpoint returns 200/401 correctly", "tddPhase": "impl", "dependencies": ["P1-001"]}, {"id": "P1-003", "content": "Run full test suite and verify all tests pass", "priority": "P1", "verificationCriteria": "npm test exits with code 0, coverage > 80%", "tddPhase": "verify", "dependencies": ["P1-002"]} ] ## CRITICAL RULES 1. EVERY task MUST have verificationCriteria - this is non-negotiable! 2. EVERY implementation step should have a corresponding test step BEFORE it 3. Use tddPhase: "test" for writing tests, "impl" for implementation 4. Dependencies must form a valid DAG - no cycles 5. Be SPECIFIC - not "Add tests" but "Write tests for X covering Y and Z" 6. End with verification that ALL original requirements are met 7. Use P0 for foundation and core features, P1 for required work, P2 for nice-to-have NOW: Generate the implementation plan for the task above. Think step by step.`; // Create temporary session for the AI call using Opus 4.5 for deep reasoning const session = new Session({ workingDir: process.cwd(), mux: this.mux, useMux: false, // No mux needed for one-shot mode: 'claude', }); // Use configured model for plan generation, falling back to opus const planModelConfig = await this.getModelConfig(); const modelToUse = planModelConfig?.agentTypeOverrides?.implement || planModelConfig?.defaultModel || 'opus'; try { const { result, cost } = await session.runPrompt(prompt, { model: modelToUse }); // Parse JSON from result const jsonMatch = result.match(/\[[\s\S]*\]/); if (!jsonMatch) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Failed to parse plan - no JSON array found'); } let items: PlanItem[]; try { const parsed = JSON.parse(jsonMatch[0]); if (!Array.isArray(parsed)) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Invalid response - expected array'); } // Validate and normalize items with enhanced fields items = parsed.map((item: unknown, idx: number) => { if (typeof item !== 'object' || item === null) { return { id: `task-${idx}`, content: `Step ${idx + 1}`, priority: null, verificationCriteria: 'Task completed successfully', status: 'pending' as const, attempts: 0, version: 1, }; } const obj = item as Record; const content = typeof obj.content === 'string' ? obj.content.slice(0, 200) : `Step ${idx + 1}`; let priority: 'P0' | 'P1' | 'P2' | null = null; if (obj.priority === 'P0' || obj.priority === 'P1' || obj.priority === 'P2') { priority = obj.priority; } // Parse tddPhase let tddPhase: 'setup' | 'test' | 'impl' | 'verify' | undefined; if ( obj.tddPhase === 'setup' || obj.tddPhase === 'test' || obj.tddPhase === 'impl' || obj.tddPhase === 'verify' ) { tddPhase = obj.tddPhase; } return { id: obj.id ? String(obj.id) : `task-${idx}`, content, priority, verificationCriteria: typeof obj.verificationCriteria === 'string' ? obj.verificationCriteria : 'Task completed successfully', tddPhase, dependencies: Array.isArray(obj.dependencies) ? obj.dependencies.map(String) : [], status: 'pending' as const, attempts: 0, version: 1, }; }); // No artificial limit - let Claude generate what's needed } catch (parseErr) { return createErrorResponse( ApiErrorCode.OPERATION_FAILED, 'Failed to parse plan JSON: ' + getErrorMessage(parseErr) ); } return { success: true, data: { items, costUsd: cost }, }; } catch (err) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Plan generation failed: ' + getErrorMessage(err)); } finally { // Clean up the temporary session try { await session.stop(); } catch { // Ignore cleanup errors } } }); // Generate detailed implementation plan using subagent orchestration // This spawns multiple specialist subagents in parallel for thorough analysis this.app.post('/api/generate-plan-detailed', async (req): Promise => { const gpdResult = GeneratePlanDetailedSchema.safeParse(req.body); if (!gpdResult.success) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); } const { taskDescription, caseName } = gpdResult.data; // Determine output directory for saving wizard results let outputDir: string | undefined; if (caseName) { const casesDir = join(homedir(), 'codeman-cases'); const casePath = join(casesDir, caseName); // Security: Path traversal protection - use relative path check const resolvedCase = resolve(casePath); const resolvedBase = resolve(casesDir); const relPath = relative(resolvedBase, resolvedCase); if (!relPath.startsWith('..') && !isAbsolute(relPath) && existsSync(casePath)) { outputDir = join(casePath, 'ralph-wizard'); // Clear old ralph-wizard directory to ensure fresh prompts for each generation // This prevents stale prompts from previous runs being shown when clicking on agents if (existsSync(outputDir)) { try { rmSync(outputDir, { recursive: true, force: true }); console.log(`[API] Cleared old ralph-wizard directory: ${outputDir}`); } catch (err) { console.warn(`[API] Failed to clear ralph-wizard directory:`, err); } } } } const detailedModelConfig = await this.getModelConfig(); const orchestrator = new PlanOrchestrator(this.mux, process.cwd(), outputDir, detailedModelConfig ?? undefined); // Store orchestrator for potential cancellation via API (not on disconnect) // Plan generation continues even if browser disconnects - only explicit cancel stops it const orchestratorId = `plan-${Date.now()}`; this.activePlanOrchestrators.set(orchestratorId, orchestrator); // Broadcast the orchestrator ID so frontend can cancel if needed this.broadcast('plan:started', { orchestratorId }); // Track progress for SSE updates const progressUpdates: Array<{ phase: string; detail: string; timestamp: number }> = []; const onProgress = (phase: string, detail: string) => { const update = { phase, detail, timestamp: Date.now() }; progressUpdates.push(update); // Broadcast progress to connected clients this.broadcast('plan:progress', update); }; // Broadcast plan subagent events for UI visibility const onSubagent = (event: { type: string; agentId: string; agentType: string; model: string; status: string; detail?: string; itemCount?: number; durationMs?: number; error?: string; }) => { this.broadcast('plan:subagent', event); }; try { const result: DetailedPlanResult = await orchestrator.generateDetailedPlan( taskDescription, onProgress, onSubagent ); // Clean up orchestrator from active map this.activePlanOrchestrators.delete(orchestratorId); this.broadcast('plan:completed', { orchestratorId, success: result.success }); if (!result.success) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, result.error || 'Plan generation failed'); } return { success: true, data: { items: result.items, costUsd: result.costUsd, metadata: result.metadata, progressLog: progressUpdates, orchestratorId, }, }; } catch (err) { // Clean up on error too this.activePlanOrchestrators.delete(orchestratorId); this.broadcast('plan:completed', { orchestratorId, success: false, error: getErrorMessage(err), }); return createErrorResponse( ApiErrorCode.OPERATION_FAILED, 'Detailed plan generation failed: ' + getErrorMessage(err) ); } }); // Cancel active plan generation this.app.post('/api/cancel-plan-generation', async (req): Promise => { const cpResult = CancelPlanSchema.safeParse(req.body); if (!cpResult.success) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); } const { orchestratorId } = cpResult.data; // If specific orchestrator ID provided, cancel just that one if (orchestratorId) { const orchestrator = this.activePlanOrchestrators.get(orchestratorId); if (!orchestrator) { return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Plan generation not found or already completed'); } console.log(`[API] Cancelling plan generation ${orchestratorId}`); await orchestrator.cancel(); this.activePlanOrchestrators.delete(orchestratorId); this.broadcast('plan:cancelled', { orchestratorId }); return { success: true, data: { cancelled: orchestratorId } }; } // Otherwise cancel all active plan generations const cancelled: string[] = []; for (const [id, orchestrator] of this.activePlanOrchestrators) { console.log(`[API] Cancelling plan generation ${id}`); await orchestrator.cancel(); cancelled.push(id); this.broadcast('plan:cancelled', { orchestratorId: id }); } this.activePlanOrchestrators.clear(); return { success: true, data: { cancelled } }; }); // Get ralph-wizard files for a case (prompts and results) this.app.get('/api/cases/:caseName/ralph-wizard/files', async (req) => { const { caseName } = req.params as { caseName: string }; const casesDir = join(homedir(), 'codeman-cases'); let casePath = join(casesDir, caseName); // Security: Path traversal protection - use relative path check const resolvedCase = resolve(casePath); const resolvedBase = resolve(casesDir); const relPath = relative(resolvedBase, resolvedCase); if (relPath.startsWith('..') || isAbsolute(relPath)) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name'); } // Check linked cases if path doesn't exist if (!existsSync(casePath)) { const linkedCasesFile = join(homedir(), '.codeman', 'linked-cases.json'); try { const linkedCases: Record = JSON.parse(await fs.readFile(linkedCasesFile, 'utf-8')); if (linkedCases[caseName]) { casePath = linkedCases[caseName]; } } catch { // No linked cases file } } const wizardDir = join(casePath, 'ralph-wizard'); if (!existsSync(wizardDir)) { return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Ralph wizard directory not found'); } // List all subdirectories and their files const files: Array<{ agentType: string; promptFile?: string; resultFile?: string }> = []; const entries = readdirSync(wizardDir, { withFileTypes: true }); for (const entry of entries) { if (entry.isDirectory()) { const agentDir = join(wizardDir, entry.name); const agentFiles: { agentType: string; promptFile?: string; resultFile?: string } = { agentType: entry.name, }; if (existsSync(join(agentDir, 'prompt.md'))) { agentFiles.promptFile = `${entry.name}/prompt.md`; } if (existsSync(join(agentDir, 'result.json'))) { agentFiles.resultFile = `${entry.name}/result.json`; } if (agentFiles.promptFile || agentFiles.resultFile) { files.push(agentFiles); } } } return { success: true, data: { files, caseName } }; }); // Read a specific ralph-wizard file // Cache disabled to ensure fresh prompts when starting new plan generations this.app.get('/api/cases/:caseName/ralph-wizard/file/:filePath', async (req, reply) => { const { caseName, filePath } = req.params as { caseName: string; filePath: string }; const casesDir = join(homedir(), 'codeman-cases'); let casePath = join(casesDir, caseName); // Prevent browser caching - prompts change between plan generations reply.header('Cache-Control', 'no-store, no-cache, must-revalidate'); reply.header('Pragma', 'no-cache'); reply.header('Expires', '0'); // Security: Path traversal protection for case name - use relative path check const resolvedCase = resolve(casePath); const resolvedBase = resolve(casesDir); const relPath = relative(resolvedBase, resolvedCase); if (relPath.startsWith('..') || isAbsolute(relPath)) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name'); } // Check linked cases if path doesn't exist if (!existsSync(casePath)) { const linkedCasesFile = join(homedir(), '.codeman', 'linked-cases.json'); try { const linkedCases: Record = JSON.parse(await fs.readFile(linkedCasesFile, 'utf-8')); if (linkedCases[caseName]) { casePath = linkedCases[caseName]; } } catch { // No linked cases file } } const wizardDir = join(casePath, 'ralph-wizard'); // Decode the file path (it may be URL encoded) const decodedPath = decodeURIComponent(filePath); const fullPath = join(wizardDir, decodedPath); // Security: ensure path is within wizard directory const resolvedPath = resolve(fullPath); const resolvedWizard = resolve(wizardDir); if (!resolvedPath.startsWith(resolvedWizard)) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid file path'); } let content: string; try { content = await fs.readFile(fullPath, 'utf-8'); } catch (err) { if ((err as NodeJS.ErrnoException).code === 'ENOENT') { return createErrorResponse(ApiErrorCode.NOT_FOUND, 'File not found'); } throw err; } const isJson = filePath.endsWith('.json'); // Parse JSON content safely (may contain invalid JSON or unescaped control characters) let parsed: unknown = null; if (isJson) { try { parsed = JSON.parse(content); } catch { // Try repairing common JSON issues (unescaped control characters, trailing commas) try { let repaired = content; // Fix trailing commas before closing brackets repaired = repaired.replace(/,(\s*[\]}])/g, '$1'); // Fix unescaped control characters within JSON strings repaired = repaired.replace(/"([^"\\]|\\.)*"/g, (match) => { return match .replace(/\n/g, '\\n') .replace(/\r/g, '\\r') .replace(/\t/g, '\\t') .replace( // eslint-disable-next-line no-control-regex /[\x00-\x1f]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}` ); }); parsed = JSON.parse(repaired); } catch { // Still invalid - return null for parsed, content available as raw string } } } return { success: true, data: { content, filePath: decodedPath, isJson, parsed, }, }; }); // ============ Plan Management Endpoints ============ // These endpoints support runtime plan adaptation with checkpoints, failure tracking, and versioning // Update a specific plan task (status, attempts, errors) this.app.patch('/api/sessions/:id/plan/task/:taskId', async (req) => { const { id, taskId } = req.params as { id: string; taskId: string }; const session = this.sessions.get(id); if (!session) { return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); } const tracker = session.ralphTracker; if (!tracker) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Ralph tracker not available'); } const ptuResult = PlanTaskUpdateSchema.safeParse(req.body); if (!ptuResult.success) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); } const update = ptuResult.data as { status?: 'pending' | 'in_progress' | 'completed' | 'failed' | 'blocked'; error?: string; incrementAttempts?: boolean; }; const result = tracker.updatePlanTask(taskId, update); if (!result.success) { return createErrorResponse(ApiErrorCode.NOT_FOUND, result.error || 'Task not found'); } this.broadcast('session:planTaskUpdate', { sessionId: id, taskId, update: result.task }); return { success: true, data: result.task }; }); // Trigger a checkpoint review (at iterations 5, 10, 20, etc.) this.app.post('/api/sessions/:id/plan/checkpoint', async (req) => { const { id } = req.params as { id: string }; const session = this.sessions.get(id); if (!session) { return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); } const tracker = session.ralphTracker; if (!tracker) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Ralph tracker not available'); } const checkpoint = tracker.generateCheckpointReview(); this.broadcast('session:planCheckpoint', { sessionId: id, checkpoint }); return { success: true, data: checkpoint }; }); // Get plan version history this.app.get('/api/sessions/:id/plan/history', async (req) => { const { id } = req.params as { id: string }; const session = this.sessions.get(id); if (!session) { return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); } const tracker = session.ralphTracker; if (!tracker) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Ralph tracker not available'); } return { success: true, data: tracker.getPlanHistory() }; }); // Rollback to a previous plan version this.app.post('/api/sessions/:id/plan/rollback/:version', async (req) => { const { id, version } = req.params as { id: string; version: string }; const session = this.sessions.get(id); if (!session) { return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); } const tracker = session.ralphTracker; if (!tracker) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Ralph tracker not available'); } const result = tracker.rollbackToVersion(parseInt(version, 10)); if (!result.success) { return createErrorResponse(ApiErrorCode.NOT_FOUND, result.error || 'Version not found'); } this.broadcast('session:planRollback', { sessionId: id, version: parseInt(version, 10) }); return { success: true, data: result.plan }; }); // Add a new task to the plan (for runtime adaptation) this.app.post('/api/sessions/:id/plan/task', async (req) => { const { id } = req.params as { id: string }; const session = this.sessions.get(id); if (!session) { return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); } const tracker = session.ralphTracker; if (!tracker) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Ralph tracker not available'); } const ptaResult = PlanTaskAddSchema.safeParse(req.body); if (!ptaResult.success) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); } const task = ptaResult.data; const result = tracker.addPlanTask(task); this.broadcast('session:planTaskAdded', { sessionId: id, task: result.task }); return { success: true, data: result.task }; }); // ============ App Settings Endpoints ============ const settingsPath = join(homedir(), '.codeman', 'settings.json'); this.app.get('/api/settings', async () => { try { const content = await fs.readFile(settingsPath, 'utf-8'); return JSON.parse(content); } catch (err) { if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { console.error('Failed to read settings:', err); } } return {}; }); this.app.put('/api/settings', async (req) => { const settingsResult = SettingsUpdateSchema.safeParse(req.body); if (!settingsResult.success) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid settings'); } const settings = settingsResult.data as Record; try { const dir = dirname(settingsPath); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } let existing: Record = {}; try { existing = JSON.parse(await fs.readFile(settingsPath, 'utf-8')); } catch { /* ignore */ } const merged = { ...existing, ...settings }; await fs.writeFile(settingsPath, JSON.stringify(merged, null, 2)); // Handle subagent tracking toggle dynamically const subagentEnabled = settings.subagentTrackingEnabled ?? true; if (subagentEnabled && !subagentWatcher.isRunning()) { subagentWatcher.start(); console.log('Subagent watcher started via settings change'); } else if (!subagentEnabled && subagentWatcher.isRunning()) { subagentWatcher.stop(); console.log('Subagent watcher stopped via settings change'); } // Handle image watcher toggle dynamically const imageWatcherEnabled = settings.imageWatcherEnabled ?? false; if (imageWatcherEnabled && !imageWatcher.isRunning()) { imageWatcher.start(); // Re-watch all active sessions that have image watcher enabled for (const session of this.sessions.values()) { if (session.imageWatcherEnabled) { imageWatcher.watchSession(session.id, session.workingDir); } } console.log('Image watcher started via settings change'); } else if (!imageWatcherEnabled && imageWatcher.isRunning()) { imageWatcher.stop(); console.log('Image watcher stopped via settings change'); } // Handle tunnel toggle dynamically if ('tunnelEnabled' in settings) { const tunnelEnabled = settings.tunnelEnabled as boolean; if (tunnelEnabled && !this.tunnelManager.isRunning()) { this.tunnelManager.start(this.port, this.https); console.log('Tunnel started via settings change'); } else if (!tunnelEnabled && this.tunnelManager.isRunning()) { this.tunnelManager.stop(); console.log('Tunnel stopped via settings change'); } } return { success: true }; } catch (err) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); } }); // ============ Model Configuration Endpoints ============ this.app.get('/api/execution/model-config', async () => { try { const content = await fs.readFile(settingsPath, 'utf-8'); const settings = JSON.parse(content); return { success: true, data: settings.modelConfig || {} }; } catch (err) { if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { console.error('Failed to read model config:', err); } return { success: true, data: {} }; } }); this.app.put('/api/execution/model-config', async (req) => { const mcResult = ModelConfigUpdateSchema.safeParse(req.body); if (!mcResult.success) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid model config'); } const modelConfig = mcResult.data as Record; try { let settings: Record = {}; try { const content = await fs.readFile(settingsPath, 'utf-8'); settings = JSON.parse(content); } catch { // File doesn't exist yet, start fresh } settings.modelConfig = modelConfig; const dir = dirname(settingsPath); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } await fs.writeFile(settingsPath, JSON.stringify(settings, null, 2)); return { success: true }; } catch (err) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); } }); // ============ CPU Priority Endpoints ============ // Get Nice priority config for a session this.app.get('/api/sessions/:id/cpu-limit', async (req) => { const { id } = req.params as { id: string }; const session = this.sessions.get(id); if (!session) { return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); } return { success: true, nice: session.niceConfig, }; }); // Update Nice priority config for a session // Note: Changes only apply to NEW sessions, not running ones this.app.post('/api/sessions/:id/cpu-limit', async (req) => { const { id } = req.params as { id: string }; const session = this.sessions.get(id); if (!session) { return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); } const clResult = CpuLimitSchema.safeParse(req.body); if (!clResult.success) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); } const body = clResult.data as Partial; session.setNice(body); this.persistSessionState(session); this.broadcast('session:updated', { session: this.getSessionStateWithRespawn(session) }); return { success: true, nice: session.niceConfig, note: 'Nice priority only affects newly created mux sessions, not currently running ones.', }; }); // ============ Subagent Window State Endpoints ============ // Persists minimized/open window states for cross-browser sync const windowStatesPath = join(homedir(), '.codeman', 'subagent-window-states.json'); this.app.get('/api/subagent-window-states', async () => { try { const content = await fs.readFile(windowStatesPath, 'utf-8'); return JSON.parse(content); } catch (err) { if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { console.error('Failed to read subagent window states:', err); } } return { minimized: {}, open: [] }; }); this.app.put('/api/subagent-window-states', async (req) => { const swResult = SubagentWindowStatesSchema.safeParse(req.body); if (!swResult.success) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid window states'); } const states = swResult.data as Record; try { const dir = dirname(windowStatesPath); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } await fs.writeFile(windowStatesPath, JSON.stringify(states, null, 2)); return { success: true }; } catch (err) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); } }); // ============ Subagent Parent Associations ============ // Persists which TAB each agent window connects to. // This is the PERMANENT record of agent -> tab associations. const parentMapPath = join(homedir(), '.codeman', 'subagent-parents.json'); this.app.get('/api/subagent-parents', async () => { try { const content = await fs.readFile(parentMapPath, 'utf-8'); return JSON.parse(content); } catch (err) { if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { console.error('Failed to read subagent parent map:', err); } } return {}; }); this.app.put('/api/subagent-parents', async (req) => { const spResult = SubagentParentMapSchema.safeParse(req.body); if (!spResult.success) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid parent map'); } const parentMap = spResult.data; try { const dir = dirname(parentMapPath); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } await fs.writeFile(parentMapPath, JSON.stringify(parentMap, null, 2)); return { success: true }; } catch (err) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); } }); // ============ Mux Session Management Endpoints ============ // Get all tracked mux sessions with stats this.app.get('/api/mux-sessions', async () => { const sessions = await this.mux.getSessionsWithStats(); return { sessions, muxAvailable: this.mux.isAvailable(), }; }); // Kill a mux session this.app.delete('/api/mux-sessions/:sessionId', async (req) => { const { sessionId } = req.params as { sessionId: string }; const success = await this.mux.killSession(sessionId); return { success }; }); // Reconcile mux sessions (find dead ones) this.app.post('/api/mux-sessions/reconcile', async () => { const result = await this.mux.reconcileSessions(); return result; }); // Start stats collection this.app.post('/api/mux-sessions/stats/start', async () => { this.mux.startStatsCollection(STATS_COLLECTION_INTERVAL_MS); return { success: true }; }); // Stop stats collection this.app.post('/api/mux-sessions/stats/stop', async () => { this.mux.stopStatsCollection(); return { success: true }; }); // System stats endpoint for frontend header display this.app.get('/api/system/stats', async () => { return this.getSystemStats(); }); // ========== Subagent Monitoring (Claude Code Background Agents) ========== // List all known subagents this.app.get('/api/subagents', async (req) => { const { minutes } = req.query as { minutes?: string }; const subagents = minutes ? subagentWatcher.getRecentSubagents(parseInt(minutes, 10)) : subagentWatcher.getSubagents(); return { success: true, data: subagents }; }); // Get subagents for a specific session (by working directory) this.app.get('/api/sessions/:id/subagents', async (req) => { const { id } = req.params as { id: string }; const session = this.sessions.get(id); if (!session) { return createErrorResponse(ApiErrorCode.NOT_FOUND, `Session ${id} not found`); } const subagents = subagentWatcher.getSubagentsForSession(session.workingDir); return { success: true, data: subagents }; }); // Get a specific subagent's info this.app.get('/api/subagents/:agentId', async (req) => { const { agentId } = req.params as { agentId: string }; const info = subagentWatcher.getSubagent(agentId); if (!info) { return createErrorResponse(ApiErrorCode.NOT_FOUND, `Subagent ${agentId} not found`); } return { success: true, data: info }; }); // Get a subagent's transcript this.app.get('/api/subagents/:agentId/transcript', async (req) => { const { agentId } = req.params as { agentId: string }; const { limit, format } = req.query as { limit?: string; format?: 'raw' | 'formatted' }; const limitNum = limit ? parseInt(limit, 10) : undefined; const transcript = await subagentWatcher.getTranscript(agentId, limitNum); if (format === 'formatted') { const formatted = subagentWatcher.formatTranscript(transcript); return { success: true, data: { formatted, entryCount: transcript.length } }; } return { success: true, data: transcript }; }); // Kill a subagent this.app.delete('/api/subagents/:agentId', async (req) => { const { agentId } = req.params as { agentId: string }; const info = subagentWatcher.getSubagent(agentId); if (!info) { return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Subagent not found'); } const killed = await subagentWatcher.killSubagent(agentId); if (killed) { return { success: true, data: { agentId, status: 'killed' } }; } return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Subagent not found or already completed'); }); // Trigger cleanup of stale subagents this.app.post('/api/subagents/cleanup', async () => { const removed = subagentWatcher.cleanupNow(); return { success: true, data: { removed, remaining: subagentWatcher.getSubagents().length } }; }); // Clear all tracked subagents (memory only - does not delete files) this.app.delete('/api/subagents', async () => { const cleared = subagentWatcher.clearAll(); return { success: true, data: { cleared } }; }); // ========== Agent Teams ========== // List all discovered teams this.app.get('/api/teams', async () => { return { success: true, data: this.teamWatcher.getTeams() }; }); // Get tasks for a specific team this.app.get('/api/teams/:name/tasks', async (req) => { const { name } = req.params as { name: string }; return { success: true, data: this.teamWatcher.getTeamTasks(name) }; }); // ========== Hook Events ========== this.app.post('/api/hook-event', async (req) => { const result = HookEventSchema.safeParse(req.body); if (!result.success) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed'); } const { event, sessionId, data } = result.data; if (!this.sessions.has(sessionId)) { return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); } // Signal the respawn controller based on hook event type const controller = this.respawnControllers.get(sessionId); if (controller) { if (event === 'elicitation_dialog') { // Block auto-accept for question prompts controller.signalElicitation(); } else if (event === 'stop') { // DEFINITIVE idle signal - Claude finished responding controller.signalStopHook(); } else if (event === 'idle_prompt') { // DEFINITIVE idle signal - Claude has been idle for 60+ seconds controller.signalIdlePrompt(); } } // Start transcript watching if transcript_path is provided and safe if (data && 'transcript_path' in data) { const transcriptPath = String(data.transcript_path); if (transcriptPath && isValidWorkingDir(transcriptPath)) { this.startTranscriptWatcher(sessionId, transcriptPath); } } // Sanitize forwarded data: only include known safe fields, limit size const safeData = sanitizeHookData(data); this.broadcast(`hook:${event}`, { sessionId, timestamp: Date.now(), ...safeData }); // Send push notifications for hook events const session = this.sessions.get(sessionId); const sessionName = session?.name ?? sessionId.slice(0, 8); this.sendPushNotifications(`hook:${event}`, { sessionId, sessionName, ...safeData }); // Track in run summary const summaryTracker = this.runSummaryTrackers.get(sessionId); if (summaryTracker) { summaryTracker.recordHookEvent(event, safeData); } return { success: true }; }); // ========== Web Push ========== this.app.get('/api/push/vapid-key', async () => { return { success: true, data: { publicKey: this.pushStore.getPublicKey() } }; }); this.app.post('/api/push/subscribe', async (req) => { const result = PushSubscribeSchema.safeParse(req.body); if (!result.success) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed'); } const { endpoint, keys, userAgent, pushPreferences } = result.data; const record = this.pushStore.addSubscription({ id: uuidv4(), endpoint, keys, userAgent: userAgent ?? req.headers['user-agent'] ?? '', createdAt: Date.now(), pushPreferences: pushPreferences ?? {}, }); return { success: true, data: { id: record.id } }; }); this.app.put('/api/push/subscribe/:id', async (req) => { const { id } = req.params as { id: string }; const result = PushPreferencesUpdateSchema.safeParse(req.body); if (!result.success) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed'); } const updated = this.pushStore.updatePreferences(id, result.data.pushPreferences); if (!updated) { return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Subscription not found'); } return { success: true }; }); this.app.delete('/api/push/subscribe/:id', async (req) => { const { id } = req.params as { id: string }; const removed = this.pushStore.removeSubscription(id); if (!removed) { return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Subscription not found'); } return { success: true }; }); // Screenshot upload endpoint (accepts multipart/form-data) // Upload form served as static file: /upload.html (src/web/public/upload.html) this.app.post('/api/screenshots', async (req, reply) => { const contentType = req.headers['content-type'] ?? ''; if (!contentType.includes('multipart/form-data')) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Expected multipart/form-data'); } // Parse multipart boundary const boundaryMatch = contentType.match(/boundary=(.+?)(?:;|$)/); if (!boundaryMatch) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing boundary'); } // Collect raw body const chunks: Buffer[] = []; let totalSize = 0; for await (const chunk of req.raw) { totalSize += chunk.length; if (totalSize > MAX_SCREENSHOT_SIZE) { reply.status(413); return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'File too large (max 10MB)'); } chunks.push(chunk as Buffer); } const body = Buffer.concat(chunks); // Extract file from multipart body const boundary = '--' + boundaryMatch[1]; const boundaryBuf = Buffer.from(boundary); const parts: { headers: string; data: Buffer }[] = []; let pos = 0; // Find each part between boundaries while (pos < body.length) { const start = body.indexOf(boundaryBuf, pos); if (start === -1) break; const afterBoundary = start + boundaryBuf.length; // Check for closing boundary (--) if (body[afterBoundary] === 0x2d && body[afterBoundary + 1] === 0x2d) break; // Skip \r\n after boundary const headerStart = afterBoundary + 2; const headerEnd = body.indexOf(Buffer.from('\r\n\r\n'), headerStart); if (headerEnd === -1) break; const headers = body.subarray(headerStart, headerEnd).toString(); const dataStart = headerEnd + 4; const nextBoundary = body.indexOf(boundaryBuf, dataStart); // Data ends 2 bytes before next boundary (\r\n) const dataEnd = nextBoundary === -1 ? body.length : nextBoundary - 2; parts.push({ headers, data: body.subarray(dataStart, dataEnd) }); pos = nextBoundary === -1 ? body.length : nextBoundary; } const filePart = parts.find((p) => p.headers.includes('name="file"')); if (!filePart || filePart.data.length === 0) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'No file uploaded'); } // Determine extension from Content-Type or filename let ext = '.png'; const filenameMatch = filePart.headers.match(/filename="(.+?)"/); if (filenameMatch) { const origExt = filenameMatch[1].match(/\.(png|jpg|jpeg|webp|gif)$/i); if (origExt) ext = origExt[0].toLowerCase(); } const ctMatch = filePart.headers.match(/Content-Type:\s*image\/(png|jpeg|webp|gif)/i); if (ctMatch) { const map: Record = { png: '.png', jpeg: '.jpg', webp: '.webp', gif: '.gif', }; ext = map[ctMatch[1].toLowerCase()] ?? ext; } // Save to ~/.codeman/screenshots/ if (!existsSync(SCREENSHOTS_DIR)) { mkdirSync(SCREENSHOTS_DIR, { recursive: true }); } const timestamp = new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19); const filename = `screenshot_${timestamp}${ext}`; const filepath = join(SCREENSHOTS_DIR, filename); await fs.writeFile(filepath, filePart.data); return { success: true, path: filepath, filename }; }); // List screenshots this.app.get('/api/screenshots', async () => { if (!existsSync(SCREENSHOTS_DIR)) { return { files: [] }; } const files = readdirSync(SCREENSHOTS_DIR) .filter((f) => /\.(png|jpg|jpeg|webp|gif)$/i.test(f)) .sort() .reverse() .slice(0, 50) .map((name) => ({ name, path: join(SCREENSHOTS_DIR, name) })); return { files }; }); // Serve individual screenshot this.app.get('/api/screenshots/:name', async (req, reply) => { const { name } = req.params as { name: string }; // Prevent path traversal if (name.includes('/') || name.includes('\\') || name.includes('..')) { reply.status(400); return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid filename'); } const filepath = join(SCREENSHOTS_DIR, name); if (!existsSync(filepath)) { reply.status(404); return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Screenshot not found'); } const ext = name.match(/\.(png|jpg|jpeg|webp|gif)$/i)?.[1]?.toLowerCase() ?? 'png'; const mimeMap: Record = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp', gif: 'image/gif', }; reply.type(mimeMap[ext] ?? 'image/png'); return fs.readFile(filepath); }); } /** * Start a transcript watcher for a session. * Creates a new watcher or updates an existing one with the new transcript path. */ private startTranscriptWatcher(sessionId: string, transcriptPath: string): void { let watcher = this.transcriptWatchers.get(sessionId); if (!watcher) { watcher = new TranscriptWatcher(); // Wire up transcript events to the respawn controller watcher.on('transcript:complete', () => { const controller = this.respawnControllers.get(sessionId); if (controller) { controller.signalTranscriptComplete(); } this.broadcast('transcript:complete', { sessionId, timestamp: Date.now() }); }); watcher.on('transcript:plan_mode', () => { const controller = this.respawnControllers.get(sessionId); if (controller) { controller.signalTranscriptPlanMode(); } this.broadcast('transcript:plan_mode', { sessionId, timestamp: Date.now() }); }); watcher.on('transcript:tool_start', (toolName: string) => { this.broadcast('transcript:tool_start', { sessionId, toolName, timestamp: Date.now() }); }); watcher.on('transcript:tool_end', (toolName: string, isError: boolean) => { this.broadcast('transcript:tool_end', { sessionId, toolName, isError, timestamp: Date.now(), }); }); watcher.on('transcript:error', (error: Error) => { console.error(`[Transcript] Error for session ${sessionId}:`, error.message); }); this.transcriptWatchers.set(sessionId, watcher); } // Start or update the watcher with the transcript path watcher.updatePath(transcriptPath); } /** * Stop the transcript watcher for a session. */ private stopTranscriptWatcher(sessionId: string): void { const watcher = this.transcriptWatchers.get(sessionId); if (watcher) { watcher.removeAllListeners(); // Prevent memory leaks from attached listeners watcher.stop(); this.transcriptWatchers.delete(sessionId); } } /** Debounced wrapper — coalesces rapid persistSessionState calls per session */ private persistSessionState(session: Session): void { const existing = this.persistDebounceTimers.get(session.id); if (existing) clearTimeout(existing); this.persistDebounceTimers.set( session.id, setTimeout(() => { this.persistDebounceTimers.delete(session.id); // Session may have been removed during debounce if (this.sessions.has(session.id)) { this._persistSessionStateNow(session); } }, 100) ); } /** Persists full session state including respawn config to state.json */ private _persistSessionStateNow(session: Session): void { const state = session.toState(); const controller = this.respawnControllers.get(session.id); if (controller) { const config = controller.getConfig(); const timerInfo = this.respawnTimers.get(session.id); const durationMinutes = timerInfo ? Math.round((timerInfo.endAt - timerInfo.startedAt) / 60000) : undefined; state.respawnConfig = { ...config, durationMinutes }; // Use config.enabled instead of controller.state - this way the respawn // will be restored on server restart even if it was temporarily stopped // due to errors. Intentional stops via /respawn/stop call clearRespawnConfig(). state.respawnEnabled = config.enabled; } else { // Don't overwrite respawnConfig if it exists in state - preserve it for restart const existingState = this.store.getSession(session.id); if (existingState?.respawnConfig) { state.respawnConfig = existingState.respawnConfig; state.respawnEnabled = existingState.respawnConfig.enabled ?? false; } else { state.respawnEnabled = false; } } this.store.setSession(session.id, state); } // Helper to save respawn config to mux session for persistence private saveRespawnConfig(sessionId: string, config: RespawnConfig, durationMinutes?: number): void { const persistedConfig: PersistedRespawnConfig = { enabled: config.enabled, idleTimeoutMs: config.idleTimeoutMs, updatePrompt: config.updatePrompt, interStepDelayMs: config.interStepDelayMs, sendClear: config.sendClear, sendInit: config.sendInit, kickstartPrompt: config.kickstartPrompt, autoAcceptPrompts: config.autoAcceptPrompts, autoAcceptDelayMs: config.autoAcceptDelayMs, completionConfirmMs: config.completionConfirmMs, noOutputTimeoutMs: config.noOutputTimeoutMs, aiIdleCheckEnabled: config.aiIdleCheckEnabled, aiIdleCheckModel: config.aiIdleCheckModel, aiIdleCheckMaxContext: config.aiIdleCheckMaxContext, aiIdleCheckTimeoutMs: config.aiIdleCheckTimeoutMs, aiIdleCheckCooldownMs: config.aiIdleCheckCooldownMs, aiPlanCheckEnabled: config.aiPlanCheckEnabled, aiPlanCheckModel: config.aiPlanCheckModel, aiPlanCheckMaxContext: config.aiPlanCheckMaxContext, aiPlanCheckTimeoutMs: config.aiPlanCheckTimeoutMs, aiPlanCheckCooldownMs: config.aiPlanCheckCooldownMs, durationMinutes, }; this.mux.updateRespawnConfig(sessionId, persistedConfig); } // Get system CPU and memory usage private getSystemStats(): { cpu: number; memory: { usedMB: number; totalMB: number; percent: number }; } { try { const totalMem = totalmem(); // macOS: os.freemem() only returns truly free pages, not cached/purgeable memory. // Use vm_stat to get accurate used memory (wired + active + compressed). let usedMem: number; if (process.platform === 'darwin') { try { const vmstat = execSync('vm_stat', { encoding: 'utf-8', timeout: 2000 }); const pageSize = parseInt(vmstat.match(/page size of (\d+)/)?.[1] || '4096', 10); const wired = parseInt(vmstat.match(/Pages wired down:\s+(\d+)/)?.[1] || '0', 10); const active = parseInt(vmstat.match(/Pages active:\s+(\d+)/)?.[1] || '0', 10); const compressed = parseInt(vmstat.match(/Pages occupied by compressor:\s+(\d+)/)?.[1] || '0', 10); usedMem = (wired + active + compressed) * pageSize; } catch { usedMem = totalMem - freemem(); } } else { usedMem = totalMem - freemem(); } // CPU load average (1 min) as percentage (rough approximation) const load = loadavg()[0]; const cpuCount = WebServer.CPU_COUNT; const cpuPercent = Math.min(100, Math.round((load / cpuCount) * 100)); return { cpu: cpuPercent, memory: { usedMB: Math.round(usedMem / (1024 * 1024)), totalMB: Math.round(totalMem / (1024 * 1024)), percent: Math.round((usedMem / totalMem) * 100), }, }; } catch { return { cpu: 0, memory: { usedMB: 0, totalMB: 0, percent: 0 }, }; } } // Clean up all resources associated with a session // Track sessions currently being cleaned up to prevent concurrent cleanup races private cleaningUp: Set = new Set(); private async cleanupSession(sessionId: string, killMux: boolean = true, reason?: string): Promise { // Guard against concurrent cleanup of the same session if (this.cleaningUp.has(sessionId)) return; this.cleaningUp.add(sessionId); try { await this._doCleanupSession(sessionId, killMux, reason); } finally { this.cleaningUp.delete(sessionId); } } private async _doCleanupSession(sessionId: string, killMux: boolean, reason?: string): Promise { const session = this.sessions.get(sessionId); const lifecycleLog = getLifecycleLog(); lifecycleLog.log({ event: killMux ? 'deleted' : 'detached', sessionId, name: session?.name, mode: session?.mode, reason: reason || 'unknown', }); // Stop watching @fix_plan.md for this session if (session) { session.ralphTracker.stopWatchingFixPlan(); } // Kill all subagents spawned by this session (scoped to sessionId to avoid cross-session kills) if (session && killMux) { try { await subagentWatcher.killSubagentsForSession(session.workingDir, sessionId); } catch (err) { console.error(`[Server] Failed to kill subagents for session ${sessionId}:`, err); } } // Stop and remove respawn controller - but save config first for restart recovery const controller = this.respawnControllers.get(sessionId); if (controller) { // Save the config BEFORE removing controller, so it can be restored on restart const config = controller.getConfig(); const timerInfo = this.respawnTimers.get(sessionId); const durationMinutes = timerInfo ? Math.round((timerInfo.endAt - timerInfo.startedAt) / 60000) : undefined; this.saveRespawnConfig(sessionId, config, durationMinutes); controller.stop(); controller.removeAllListeners(); this.respawnControllers.delete(sessionId); // Notify UI that respawn is stopped for this session this.broadcast('respawn:stopped', { sessionId, reason: 'session_cleanup' }); } // Clear respawn timer const timerInfo = this.respawnTimers.get(sessionId); if (timerInfo) { clearTimeout(timerInfo.timer); this.respawnTimers.delete(sessionId); } // Clear pending respawn start timer (from restoration grace period) const pendingStart = this.pendingRespawnStarts.get(sessionId); if (pendingStart) { clearTimeout(pendingStart); this.pendingRespawnStarts.delete(sessionId); } // Stop transcript watcher this.stopTranscriptWatcher(sessionId); // Stop and remove run summary tracker const summaryTracker = this.runSummaryTrackers.get(sessionId); if (summaryTracker) { summaryTracker.recordSessionStopped(); summaryTracker.stop(); this.runSummaryTrackers.delete(sessionId); } // Clear batches, per-session timers, and pending state updates this.terminalBatches.delete(sessionId); this.terminalBatchSizes.delete(sessionId); const batchTimer = this.terminalBatchTimers.get(sessionId); if (batchTimer) { clearTimeout(batchTimer); this.terminalBatchTimers.delete(sessionId); } this.taskUpdateBatches.delete(sessionId); this.stateUpdatePending.delete(sessionId); this.lastTerminalEventTime.delete(sessionId); // Reset Ralph tracker on the session before cleanup if (session) { session.ralphTracker.fullReset(); } // Clear Ralph state from store this.store.removeRalphState(sessionId); // Broadcast Ralph cleared to update UI this.broadcast('session:ralphLoopUpdate', { sessionId, state: { enabled: false, active: false, completionPhrase: null, startedAt: null, cycleCount: 0, maxIterations: null, lastActivity: Date.now(), elapsedHours: null, }, }); this.broadcast('session:ralphTodoUpdate', { sessionId, todos: [], stats: { total: 0, pending: 0, inProgress: 0, completed: 0 }, }); // Stop session and remove listeners if (session) { // Accumulate tokens to global stats before removing session // This preserves lifetime usage even after sessions are deleted if (killMux && (session.inputTokens > 0 || session.outputTokens > 0 || session.totalCost > 0)) { this.store.addToGlobalStats(session.inputTokens, session.outputTokens, session.totalCost); // Record to daily stats (for what hasn't been recorded yet via periodic recording) const lastRecorded = this.lastRecordedTokens.get(sessionId) || { input: 0, output: 0 }; const deltaInput = session.inputTokens - lastRecorded.input; const deltaOutput = session.outputTokens - lastRecorded.output; if (deltaInput > 0 || deltaOutput > 0) { this.store.recordDailyUsage(deltaInput, deltaOutput, sessionId); } this.lastRecordedTokens.delete(sessionId); console.log( `[Server] Added to global stats: ${session.inputTokens + session.outputTokens} tokens, $${session.totalCost.toFixed(4)} from session ${sessionId}` ); } // Explicitly remove stored listeners to break closure references (prevents memory leak) const listeners = this.sessionListenerRefs.get(sessionId); if (listeners) { session.off('terminal', listeners.terminal); session.off('clearTerminal', listeners.clearTerminal); session.off('needsRefresh', listeners.needsRefresh); session.off('message', listeners.message); session.off('error', listeners.error); session.off('completion', listeners.completion); session.off('exit', listeners.exit); session.off('working', listeners.working); session.off('idle', listeners.idle); session.off('taskCreated', listeners.taskCreated); session.off('taskUpdated', listeners.taskUpdated); session.off('taskCompleted', listeners.taskCompleted); session.off('taskFailed', listeners.taskFailed); session.off('autoClear', listeners.autoClear); session.off('autoCompact', listeners.autoCompact); session.off('cliInfoUpdated', listeners.cliInfoUpdated); session.off('ralphLoopUpdate', listeners.ralphLoopUpdate); session.off('ralphTodoUpdate', listeners.ralphTodoUpdate); session.off('ralphCompletionDetected', listeners.ralphCompletionDetected); session.off('ralphStatusBlockDetected', listeners.ralphStatusBlockDetected); session.off('ralphCircuitBreakerUpdate', listeners.ralphCircuitBreakerUpdate); session.off('ralphExitGateMet', listeners.ralphExitGateMet); session.off('bashToolStart', listeners.bashToolStart); session.off('bashToolEnd', listeners.bashToolEnd); session.off('bashToolsUpdate', listeners.bashToolsUpdate); this.sessionListenerRefs.delete(sessionId); } session.removeAllListeners(); // Close any active file streams for this session fileStreamManager.closeSessionStreams(sessionId); // Stop watching for images in this session's directory imageWatcher.unwatchSession(sessionId); await session.stop(killMux); this.sessions.delete(sessionId); // Only remove from state.json if we're also killing the mux session. // When killMux=false (server shutdown), preserve state for recovery. if (killMux) { this.store.removeSession(sessionId); } } this.broadcast('session:deleted', { id: sessionId }); } private async setupSessionListeners(session: Session): Promise { // Create run summary tracker for this session const summaryTracker = new RunSummaryTracker(session.id, session.name); this.runSummaryTrackers.set(session.id, summaryTracker); summaryTracker.recordSessionStarted(session.mode, session.workingDir); // Set working directory for Ralph tracker to auto-load @fix_plan.md (not supported for opencode sessions) if (session.mode !== 'opencode') { session.ralphTracker.setWorkingDir(session.workingDir); } // Start watching for new images in this session's working directory (if enabled globally and per-session) if ((await this.isImageWatcherEnabled()) && session.imageWatcherEnabled) { imageWatcher.watchSession(session.id, session.workingDir); } // Store all listener references for explicit cleanup on session delete // This prevents memory leaks from closure references keeping objects alive const listeners: SessionListenerRefs = { terminal: (data) => { // Use batching for better performance at high throughput this.batchTerminalData(session.id, data); }, clearTerminal: () => { // Tell clients to clear their terminal (after mux attach) this.broadcast('session:clearTerminal', { id: session.id }); }, needsRefresh: () => { // Tell clients to reload the terminal buffer (e.g., after OpenCode TUI stabilizes) this.broadcast('session:needsRefresh', { id: session.id }); }, message: (msg: ClaudeMessage) => { this.broadcast('session:message', { id: session.id, message: msg }); }, error: (error) => { this.broadcast('session:error', { id: session.id, error }); this.sendPushNotifications('session:error', { sessionId: session.id, sessionName: session.name, error: String(error), }); // Track in run summary const tracker = this.runSummaryTrackers.get(session.id); if (tracker) tracker.recordError('Session error', String(error)); }, completion: (result, cost) => { this.broadcast('session:completion', { id: session.id, result, cost }); this.broadcast('session:updated', this.getSessionStateWithRespawn(session)); this.persistSessionState(session); // Track tokens in run summary (completion event has updated token values) const tracker = this.runSummaryTrackers.get(session.id); if (tracker) tracker.recordTokens(session.inputTokens, session.outputTokens); }, exit: (code) => { getLifecycleLog().log({ event: 'exit', sessionId: session.id, name: session.name, exitCode: code, }); // Wrap in try/catch to ensure cleanup always happens try { this.broadcast('session:exit', { id: session.id, code }); this.broadcast('session:updated', this.getSessionStateWithRespawn(session)); this.persistSessionState(session); } catch (err) { console.error(`[Server] Error broadcasting session exit for ${session.id}:`, err); } // Always clean up respawn controller, even if broadcast failed try { const controller = this.respawnControllers.get(session.id); if (controller) { controller.stop(); controller.removeAllListeners(); this.respawnControllers.delete(session.id); } // Also clean up the respawn timer to prevent orphaned timers const timerInfo = this.respawnTimers.get(session.id); if (timerInfo) { clearTimeout(timerInfo.timer); this.respawnTimers.delete(session.id); } } catch (err) { console.error(`[Server] Error cleaning up respawn controller for ${session.id}:`, err); } }, working: () => { this.broadcast('session:working', { id: session.id }); // Track in run summary const tracker = this.runSummaryTrackers.get(session.id); if (tracker) { tracker.recordWorking(); tracker.recordTokens(session.inputTokens, session.outputTokens); } }, idle: () => { this.broadcast('session:idle', { id: session.id }); // Use debounced state update (idle can fire frequently) this.broadcastSessionStateDebounced(session.id); // Track in run summary const tracker = this.runSummaryTrackers.get(session.id); if (tracker) { tracker.recordIdle(); tracker.recordTokens(session.inputTokens, session.outputTokens); } }, // Background task events - use debounced state updates to reduce serialization overhead taskCreated: (task: BackgroundTask) => { this.broadcast('task:created', { sessionId: session.id, task }); this.broadcastSessionStateDebounced(session.id); }, taskUpdated: (task: BackgroundTask) => { // Use batching for better performance at high update rates this.batchTaskUpdate(session.id, task); }, taskCompleted: (task: BackgroundTask) => { this.broadcast('task:completed', { sessionId: session.id, task }); this.broadcastSessionStateDebounced(session.id); }, taskFailed: (task: BackgroundTask, error: string) => { this.broadcast('task:failed', { sessionId: session.id, task, error }); this.broadcastSessionStateDebounced(session.id); }, autoClear: (data: { tokens: number; threshold: number }) => { this.broadcast('session:autoClear', { sessionId: session.id, ...data }); this.broadcastSessionStateDebounced(session.id); // Track in run summary const tracker = this.runSummaryTrackers.get(session.id); if (tracker) tracker.recordAutoClear(data.tokens, data.threshold); }, autoCompact: (data: { tokens: number; threshold: number; prompt?: string }) => { this.broadcast('session:autoCompact', { sessionId: session.id, ...data }); this.broadcastSessionStateDebounced(session.id); // Track in run summary const tracker = this.runSummaryTrackers.get(session.id); if (tracker) tracker.recordAutoCompact(data.tokens, data.threshold); }, // Claude Code CLI info parsed from terminal (version, model, account) cliInfoUpdated: (data: { version?: string; model?: string; accountType?: string; latestVersion?: string }) => { this.broadcast('session:cliInfo', { sessionId: session.id, ...data }); this.broadcastSessionStateDebounced(session.id); }, // Ralph tracking events ralphLoopUpdate: (state: RalphTrackerState) => { this.broadcast('session:ralphLoopUpdate', { sessionId: session.id, state }); // Persist Ralph state this.store.updateRalphState(session.id, { loop: state }); }, ralphTodoUpdate: (todos: RalphTodoItem[]) => { this.broadcast('session:ralphTodoUpdate', { sessionId: session.id, todos }); // Persist Ralph state this.store.updateRalphState(session.id, { todos }); }, ralphCompletionDetected: (phrase: string) => { this.broadcast('session:ralphCompletionDetected', { sessionId: session.id, phrase }); this.sendPushNotifications('session:ralphCompletionDetected', { sessionId: session.id, sessionName: session.name, phrase, }); // Track in run summary const tracker = this.runSummaryTrackers.get(session.id); if (tracker) tracker.recordRalphCompletion(phrase); }, // RALPH_STATUS block events ralphStatusBlockDetected: (block: import('../types.js').RalphStatusBlock) => { this.broadcast('session:ralphStatusUpdate', { sessionId: session.id, block }); // Track in run summary const tracker = this.runSummaryTrackers.get(session.id); if (tracker) { tracker.addEvent( block.status === 'BLOCKED' ? 'warning' : 'idle_detected', block.status === 'BLOCKED' ? 'warning' : 'info', `Ralph Status: ${block.status}`, `Tasks: ${block.tasksCompletedThisLoop}, Files: ${block.filesModified}, Tests: ${block.testsStatus}` ); } }, ralphCircuitBreakerUpdate: (status: import('../types.js').CircuitBreakerStatus) => { this.broadcast('session:circuitBreakerUpdate', { sessionId: session.id, status }); // Track state changes in run summary const tracker = this.runSummaryTrackers.get(session.id); if (tracker && status.state === 'OPEN') { tracker.addEvent('warning', 'warning', 'Circuit Breaker Opened', status.reason); } }, ralphExitGateMet: (data: { completionIndicators: number; exitSignal: boolean }) => { this.broadcast('session:exitGateMet', { sessionId: session.id, ...data }); // Track in run summary const tracker = this.runSummaryTrackers.get(session.id); if (tracker) { tracker.addEvent( 'ralph_completion', 'success', 'Exit Gate Met', `Indicators: ${data.completionIndicators}, EXIT_SIGNAL: ${data.exitSignal}` ); } }, // Bash tool tracking events (for clickable file paths) bashToolStart: (tool: ActiveBashTool) => { this.broadcast('session:bashToolStart', { sessionId: session.id, tool }); }, bashToolEnd: (tool: ActiveBashTool) => { this.broadcast('session:bashToolEnd', { sessionId: session.id, tool }); }, bashToolsUpdate: (tools: ActiveBashTool[]) => { this.broadcast('session:bashToolsUpdate', { sessionId: session.id, tools }); }, }; // Store listener refs for cleanup this.sessionListenerRefs.set(session.id, listeners); // Attach all listeners to the session session.on('terminal', listeners.terminal); session.on('clearTerminal', listeners.clearTerminal); session.on('needsRefresh', listeners.needsRefresh); session.on('message', listeners.message); session.on('error', listeners.error); session.on('completion', listeners.completion); session.on('exit', listeners.exit); session.on('working', listeners.working); session.on('idle', listeners.idle); session.on('taskCreated', listeners.taskCreated); session.on('taskUpdated', listeners.taskUpdated); session.on('taskCompleted', listeners.taskCompleted); session.on('taskFailed', listeners.taskFailed); session.on('autoClear', listeners.autoClear); session.on('autoCompact', listeners.autoCompact); session.on('cliInfoUpdated', listeners.cliInfoUpdated); session.on('ralphLoopUpdate', listeners.ralphLoopUpdate); session.on('ralphTodoUpdate', listeners.ralphTodoUpdate); session.on('ralphCompletionDetected', listeners.ralphCompletionDetected); session.on('ralphStatusBlockDetected', listeners.ralphStatusBlockDetected); session.on('ralphCircuitBreakerUpdate', listeners.ralphCircuitBreakerUpdate); session.on('ralphExitGateMet', listeners.ralphExitGateMet); session.on('bashToolStart', listeners.bashToolStart); session.on('bashToolEnd', listeners.bashToolEnd); session.on('bashToolsUpdate', listeners.bashToolsUpdate); } private setupRespawnListeners(sessionId: string, controller: RespawnController): void { // Wire team watcher for team-aware idle detection controller.setTeamWatcher(this.teamWatcher); // Helper to get tracker lazily (may not exist at setup time for restored sessions) const getTracker = () => this.runSummaryTrackers.get(sessionId); controller.on('stateChanged', (state: RespawnState, prevState: RespawnState) => { this.broadcast('respawn:stateChanged', { sessionId, state, prevState }); // Track in run summary (lazy lookup since tracker may be created after controller) const tracker = getTracker(); if (tracker) tracker.recordStateChange(state, `${prevState} → ${state}`); }); controller.on('respawnCycleStarted', (cycleNumber: number) => { this.broadcast('respawn:cycleStarted', { sessionId, cycleNumber }); }); controller.on('respawnCycleCompleted', (cycleNumber: number) => { this.broadcast('respawn:cycleCompleted', { sessionId, cycleNumber }); }); controller.on('respawnBlocked', (data: { reason: string; details: string }) => { this.broadcast('respawn:blocked', { sessionId, reason: data.reason, details: data.details }); const sessionForPush = this.sessions.get(sessionId); this.sendPushNotifications('respawn:blocked', { sessionId, sessionName: sessionForPush?.name ?? sessionId.slice(0, 8), reason: data.reason, }); // Track in run summary (lazy lookup) const tracker = getTracker(); if (tracker) tracker.recordWarning(`Respawn blocked: ${data.reason}`, data.details); }); controller.on('stepSent', (step: string, input: string) => { this.broadcast('respawn:stepSent', { sessionId, step, input }); }); controller.on('stepCompleted', (step: string) => { this.broadcast('respawn:stepCompleted', { sessionId, step }); }); controller.on('detectionUpdate', (detection: unknown) => { this.broadcast('respawn:detectionUpdate', { sessionId, detection }); }); controller.on('autoAcceptSent', () => { this.broadcast('respawn:autoAcceptSent', { sessionId }); }); controller.on('aiCheckStarted', () => { this.broadcast('respawn:aiCheckStarted', { sessionId }); }); controller.on('aiCheckCompleted', (result: { verdict: string; reasoning: string; durationMs: number }) => { this.broadcast('respawn:aiCheckCompleted', { sessionId, verdict: result.verdict, reasoning: result.reasoning, durationMs: result.durationMs, }); // Track in run summary (lazy lookup) const tracker = getTracker(); if (tracker) tracker.recordAiCheckResult(result.verdict); }); controller.on('aiCheckFailed', (error: string) => { this.broadcast('respawn:aiCheckFailed', { sessionId, error }); // Track in run summary (lazy lookup) const tracker = getTracker(); if (tracker) tracker.recordError('AI check failed', error); }); controller.on('aiCheckCooldown', (active: boolean, endsAt: number | null) => { this.broadcast('respawn:aiCheckCooldown', { sessionId, active, endsAt }); }); controller.on('planCheckStarted', () => { this.broadcast('respawn:planCheckStarted', { sessionId }); }); controller.on('planCheckCompleted', (result: { verdict: string; reasoning: string; durationMs: number }) => { this.broadcast('respawn:planCheckCompleted', { sessionId, verdict: result.verdict, reasoning: result.reasoning, durationMs: result.durationMs, }); }); controller.on('planCheckFailed', (error: string) => { this.broadcast('respawn:planCheckFailed', { sessionId, error }); }); // Timer tracking events for UI countdown display controller.on('timerStarted', (timer) => { this.broadcast('respawn:timerStarted', { sessionId, timer }); }); controller.on('timerCancelled', (timerName, reason) => { this.broadcast('respawn:timerCancelled', { sessionId, timerName, reason }); }); controller.on('timerCompleted', (timerName) => { this.broadcast('respawn:timerCompleted', { sessionId, timerName }); }); controller.on('actionLog', (action) => { this.broadcast('respawn:actionLog', { sessionId, action }); }); controller.on('log', (message: string) => { this.broadcast('respawn:log', { sessionId, message }); }); controller.on('error', (error: Error) => { this.broadcast('respawn:error', { sessionId, error: error.message }); // Track in run summary (lazy lookup) const tracker = getTracker(); if (tracker) tracker.recordError('Respawn error', error.message); }); } private setupTimedRespawn(sessionId: string, durationMinutes: number): void { // Clear existing timer if any const existing = this.respawnTimers.get(sessionId); if (existing) { clearTimeout(existing.timer); } const now = Date.now(); const endAt = now + durationMinutes * 60 * 1000; const timer = setTimeout( () => { // Stop respawn when time is up const controller = this.respawnControllers.get(sessionId); if (controller) { controller.stop(); controller.removeAllListeners(); this.respawnControllers.delete(sessionId); this.broadcast('respawn:stopped', { sessionId, reason: 'duration_expired' }); } this.respawnTimers.delete(sessionId); // Update persisted state (respawn no longer active) const session = this.sessions.get(sessionId); if (session) { this.persistSessionState(session); } }, durationMinutes * 60 * 1000 ); this.respawnTimers.set(sessionId, { timer, endAt, startedAt: now }); this.broadcast('respawn:timerStarted', { sessionId, durationMinutes, endAt, startedAt: now }); } /** * Restore a RespawnController from persisted configuration. * Creates the controller, sets up listeners, but does NOT start it. * * @param session - The session to attach the controller to * @param config - The persisted respawn configuration * @param source - Source of the config for logging (e.g., 'state.json' or 'mux-sessions.json') */ private restoreRespawnController(session: Session, config: PersistedRespawnConfig, source: string): void { const controller = new RespawnController(session, { idleTimeoutMs: config.idleTimeoutMs, updatePrompt: config.updatePrompt, interStepDelayMs: config.interStepDelayMs, enabled: true, sendClear: config.sendClear, sendInit: config.sendInit, kickstartPrompt: config.kickstartPrompt, completionConfirmMs: config.completionConfirmMs, noOutputTimeoutMs: config.noOutputTimeoutMs, autoAcceptPrompts: config.autoAcceptPrompts, autoAcceptDelayMs: config.autoAcceptDelayMs, aiIdleCheckEnabled: config.aiIdleCheckEnabled, aiIdleCheckModel: config.aiIdleCheckModel, aiIdleCheckMaxContext: config.aiIdleCheckMaxContext, aiIdleCheckTimeoutMs: config.aiIdleCheckTimeoutMs, aiIdleCheckCooldownMs: config.aiIdleCheckCooldownMs, aiPlanCheckEnabled: config.aiPlanCheckEnabled, aiPlanCheckModel: config.aiPlanCheckModel, aiPlanCheckMaxContext: config.aiPlanCheckMaxContext, aiPlanCheckTimeoutMs: config.aiPlanCheckTimeoutMs, aiPlanCheckCooldownMs: config.aiPlanCheckCooldownMs, }); this.respawnControllers.set(session.id, controller); this.setupRespawnListeners(session.id, controller); // Calculate delay: wait until 2 minutes after server start before starting respawn // This prevents false idle detection immediately after a server restart/rebuild const timeSinceStart = Date.now() - this.serverStartTime; const delayMs = Math.max(0, WebServer.RESPAWN_RESTORE_GRACE_PERIOD_MS - timeSinceStart); if (delayMs > 0) { console.log( `[Server] Restored respawn controller for session ${session.id} from ${source} (will start in ${Math.ceil(delayMs / 1000)}s)` ); const timer = setTimeout(() => { this.pendingRespawnStarts.delete(session.id); // Verify session still exists (may have been deleted during grace period) if (!this.sessions.has(session.id)) { console.log(`[Server] Skipping restored respawn start - session ${session.id} no longer exists`); return; } // Double-check controller still exists and is stopped const ctrl = this.respawnControllers.get(session.id); if (ctrl && ctrl.state === 'stopped') { ctrl.start(); this.broadcast('respawn:started', { sessionId: session.id }); console.log(`[Server] Restored respawn controller started for session ${session.id}`); } }, delayMs); this.pendingRespawnStarts.set(session.id, timer); } else { // Grace period has passed, start immediately controller.start(); console.log( `[Server] Restored respawn controller for session ${session.id} from ${source} (started immediately)` ); } if (config.durationMinutes && config.durationMinutes > 0) { this.setupTimedRespawn(session.id, config.durationMinutes); } } // Helper to get custom CLAUDE.md template path from settings private async getDefaultClaudeMdPath(): Promise { const settingsPath = join(homedir(), '.codeman', 'settings.json'); try { const content = await fs.readFile(settingsPath, 'utf-8'); const settings = JSON.parse(content); if (settings.defaultClaudeMdPath) { return settings.defaultClaudeMdPath; } } catch (err) { if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { console.error('Failed to read settings:', err); } } return undefined; } // Read ~/.codeman/settings.json once and return the parsed object. // Cached for 2s to avoid redundant reads during session creation bursts. private _settingsCache: { data: Record; ts: number } | null = null; private async readSettings(): Promise> { const now = Date.now(); if (this._settingsCache && now - this._settingsCache.ts < 2000) { return this._settingsCache.data; } const settingsPath = join(homedir(), '.codeman', 'settings.json'); try { const content = await fs.readFile(settingsPath, 'utf-8'); const data = JSON.parse(content) as Record; this._settingsCache = { data, ts: now }; return data; } catch { return {}; } } // Helper to get global Nice priority config from settings private async getGlobalNiceConfig(): Promise { const settings = await this.readSettings(); const nice = settings.nice as { enabled?: boolean; niceValue?: number } | undefined; if (nice && nice.enabled) { return { enabled: nice.enabled ?? false, niceValue: nice.niceValue ?? DEFAULT_NICE_CONFIG.niceValue, }; } return undefined; } // Helper to get Claude CLI startup mode from settings private async getClaudeModeConfig(): Promise<{ claudeMode?: ClaudeMode; allowedTools?: string }> { const settings = await this.readSettings(); const claudeMode = settings.claudeMode as string | undefined; const allowedTools = settings.allowedTools as string | undefined; // Only return valid modes if (claudeMode === 'dangerously-skip-permissions' || claudeMode === 'normal' || claudeMode === 'allowedTools') { return { claudeMode, allowedTools }; } return {}; } // Helper to get model configuration from settings private async getModelConfig(): Promise<{ defaultModel?: string; agentTypeOverrides?: Record; } | null> { const settings = await this.readSettings(); return ( (settings.modelConfig as { defaultModel?: string; agentTypeOverrides?: Record; }) || null ); } private async startScheduledRun(prompt: string, workingDir: string, durationMinutes: number): Promise { const id = uuidv4(); const now = Date.now(); const run: ScheduledRun = { id, prompt, workingDir, durationMinutes, startedAt: now, endAt: now + durationMinutes * 60 * 1000, status: 'running', sessionId: null, completedTasks: 0, totalCost: 0, logs: [`[${new Date().toISOString()}] Scheduled run started`], }; this.scheduledRuns.set(id, run); this.broadcast('scheduled:created', run); // Start the run loop (fire-and-forget with error handling) this.runScheduledLoop(id).catch((err) => { console.error(`[WebServer] Scheduled run ${id} failed:`, err); const failedRun = this.scheduledRuns.get(id); if (failedRun && failedRun.status === 'running') { failedRun.status = 'stopped'; failedRun.logs.push(`[${new Date().toISOString()}] Error: ${err instanceof Error ? err.message : String(err)}`); this.broadcast('scheduled:stopped', { id, reason: 'error' }); } }); return run; } private async runScheduledLoop(runId: string): Promise { const run = this.scheduledRuns.get(runId); if (!run || run.status !== 'running') return; const addLog = (msg: string) => { run.logs.push(`[${new Date().toISOString()}] ${msg}`); this.broadcast('scheduled:log', { id: runId, log: run.logs[run.logs.length - 1] }); }; while (Date.now() < run.endAt && run.status === 'running') { // Check session limit before creating new session if (this.sessions.size >= MAX_CONCURRENT_SESSIONS) { addLog(`Waiting: maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached`); await new Promise((r) => setTimeout(r, SESSION_LIMIT_WAIT_MS)); continue; } let session: Session | null = null; try { // Create a session for this iteration session = new Session({ workingDir: run.workingDir }); this.sessions.set(session.id, session); this.store.incrementSessionsCreated(); this.persistSessionState(session); await this.setupSessionListeners(session); run.sessionId = session.id; addLog(`Starting task iteration with session ${session.id.slice(0, 8)}`); this.broadcast('scheduled:updated', run); // Run the prompt const timeRemaining = Math.round((run.endAt - Date.now()) / 60000); const enhancedPrompt = `${run.prompt}\n\nNote: You have approximately ${timeRemaining} minutes remaining in this scheduled run. Work efficiently.`; const result = await session.runPrompt(enhancedPrompt); run.completedTasks++; run.totalCost += result.cost; addLog(`Task completed. Cost: $${result.cost.toFixed(4)}. Total tasks: ${run.completedTasks}`); this.broadcast('scheduled:updated', run); // Clean up the session after iteration to prevent memory leaks await this.cleanupSession(session.id, true, 'scheduled_run'); run.sessionId = null; // Small pause between iterations await new Promise((r) => setTimeout(r, ITERATION_PAUSE_MS)); } catch (err) { addLog(`Error: ${getErrorMessage(err)}`); this.broadcast('scheduled:updated', run); // Clean up the session on error too if (session) { try { await this.cleanupSession(session.id, true, 'scheduled_run_error'); } catch { // Ignore cleanup errors } run.sessionId = null; } // Continue despite errors await new Promise((r) => setTimeout(r, SESSION_LIMIT_WAIT_MS)); } } if (run.status === 'running') { run.status = 'completed'; addLog(`Scheduled run completed. Total tasks: ${run.completedTasks}, Total cost: $${run.totalCost.toFixed(4)}`); } this.broadcast('scheduled:completed', run); } private async stopScheduledRun(id: string): Promise { const run = this.scheduledRuns.get(id); if (!run) return; run.status = 'stopped'; run.logs.push(`[${new Date().toISOString()}] Run stopped by user`); // Use cleanupSession for proper resource cleanup (listeners, respawn, etc.) if (run.sessionId && this.sessions.has(run.sessionId)) { await this.cleanupSession(run.sessionId, true, 'scheduled_run_stopped'); run.sessionId = null; } this.broadcast('scheduled:stopped', run); } /** * Get session state with respawn controller info included. * Use this for session:updated broadcasts to preserve respawn state on the frontend. */ private getSessionStateWithRespawn(session: Session) { const controller = this.respawnControllers.get(session.id); return { ...session.toLightDetailedState(), respawnEnabled: controller?.getConfig()?.enabled ?? false, respawnConfig: controller?.getConfig() ?? null, respawn: controller?.getStatus() ?? null, }; } /** * Get lightweight session state for SSE init - excludes full terminal buffers * to prevent browser freezes on SSE reconnect. Full buffers are fetched * on-demand when switching tabs via /api/sessions/:id/buffer */ private getLightSessionsState() { const now = Date.now(); if (this.cachedSessionsList && now - this.cachedSessionsList.timestamp < SESSIONS_LIST_CACHE_TTL) { return this.cachedSessionsList.data; } // getSessionStateWithRespawn already uses toLightDetailedState() which // excludes terminalBuffer and textOutput — no extra stripping needed const data = Array.from(this.sessions.values()).map((s) => this.getSessionStateWithRespawn(s)); this.cachedSessionsList = { data, timestamp: now }; return data; } // Clean up old completed scheduled runs private cleanupScheduledRuns(): void { const now = Date.now(); const toDelete: string[] = []; for (const [id, run] of this.scheduledRuns) { // Only clean up completed, failed, or stopped runs if (run.status !== 'running') { const age = now - (run.endAt || run.startedAt); if (age > SCHEDULED_RUN_MAX_AGE) { toDelete.push(id); } } } for (const id of toDelete) { this.scheduledRuns.delete(id); this.broadcast('scheduled:deleted', { id }); } if (toDelete.length > 0) { console.log(`[Server] Cleaned up ${toDelete.length} old scheduled run(s)`); } } /** * Cleans up stale sessions from state file that don't have active sessions. * Called on startup and can be called via API endpoint. * @returns Number of sessions cleaned up */ private cleanupStaleSessions(): number { const activeSessionIds = new Set(this.sessions.keys()); const result = this.store.cleanupStaleSessions(activeSessionIds); const lifecycleLog = getLifecycleLog(); for (const s of result.cleaned) { lifecycleLog.log({ event: 'stale_cleaned', sessionId: s.id, name: s.name }); } return result.count; } /** * Get lightweight state for SSE init - excludes full terminal buffers * to prevent browser freezes. Terminal buffers are fetched on-demand. */ private getLightState() { const now = Date.now(); if (this.cachedLightState && now - this.cachedLightState.timestamp < WebServer.LIGHT_STATE_CACHE_TTL_MS) { return this.cachedLightState.data; } const respawnStatus: Record> = {}; for (const [sessionId, controller] of this.respawnControllers) { respawnStatus[sessionId] = controller.getStatus(); } const activeSessionTokens: Record = {}; for (const [sessionId, session] of this.sessions) { activeSessionTokens[sessionId] = { inputTokens: session.inputTokens, outputTokens: session.outputTokens, totalCost: session.totalCost, }; } const result = { version: APP_VERSION, sessions: this.getLightSessionsState(), scheduledRuns: Array.from(this.scheduledRuns.values()), respawnStatus, globalStats: this.store.getAggregateStats(activeSessionTokens), subagents: subagentWatcher.getRecentSubagents(15), // 15 min to avoid stale agents timestamp: now, }; this.cachedLightState = { data: result, timestamp: now }; return result; } private sendSSE(reply: FastifyReply, event: string, data: unknown): void { try { reply.raw.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); } catch { this.sseClients.delete(reply); } } // Optimized: send pre-formatted SSE message to a client // Returns false if client is backpressured or dead private sendSSEPreformatted(reply: FastifyReply, message: string): void { // Skip backpressured clients to prevent unbounded memory growth. // Terminal data dropped here is recovered via session:needsRefresh on drain. if (this.backpressuredClients.has(reply)) return; try { const ok = reply.raw.write(message); if (!ok) { // Buffer is full — mark as backpressured, resume on drain this.backpressuredClients.add(reply); reply.raw.once('drain', () => { this.backpressuredClients.delete(reply); // Client may have missed terminal data during backpressure. // Tell it to reload the active session's buffer to recover. try { reply.raw.write(`event: session:needsRefresh\ndata: {}\n\n`); } catch { /* client gone */ } }); } } catch { this.sseClients.delete(reply); this.backpressuredClients.delete(reply); } } private broadcast(event: string, data: unknown): void { // Invalidate caches on state-changing broadcasts, but NOT on high-frequency // streaming events that don't change session metadata (terminal data, // detection updates). These fire every 16ms-2s and would make the 1s TTL // caches permanently empty — defeating their purpose. if ( (event.startsWith('session:') || event.startsWith('respawn:')) && event !== 'session:terminal' && event !== 'session:needsRefresh' && event !== 'respawn:detectionUpdate' ) { this.cachedLightState = null; this.cachedSessionsList = null; } // Performance optimization: serialize JSON once for all clients let message: string; try { message = `event: ${event}\ndata: ${JSON.stringify(data)}\n\n`; } catch (err) { // Handle circular references or non-serializable values console.error(`[Server] Failed to serialize SSE event "${event}":`, err); return; } for (const client of this.sseClients) { this.sendSSEPreformatted(client, message); } } // Batch terminal data for better performance (60fps) // Uses per-session timers with adaptive intervals to prevent thundering herd: // each session flushes independently rather than all sessions flushing in one burst. private batchTerminalData(sessionId: string, data: string): void { // Skip if server is stopping if (this._isStopping) return; let chunks = this.terminalBatches.get(sessionId); if (!chunks) { chunks = []; this.terminalBatches.set(sessionId, chunks); } chunks.push(data); const prevSize = this.terminalBatchSizes.get(sessionId) ?? 0; const totalLength = prevSize + data.length; this.terminalBatchSizes.set(sessionId, totalLength); // Adaptive batching: detect rapid events and extend batch window (per-session) const now = Date.now(); const lastEvent = this.lastTerminalEventTime.get(sessionId) ?? 0; const eventGap = now - lastEvent; this.lastTerminalEventTime.set(sessionId, now); // Adjust batch interval based on event frequency (per-session) // Rapid events (<10ms gap) = 50ms batch, moderate (<20ms) = 32ms, else 16ms let sessionInterval: number; if (eventGap > 0 && eventGap < 10) { sessionInterval = 50; } else if (eventGap > 0 && eventGap < 20) { sessionInterval = 32; } else { sessionInterval = TERMINAL_BATCH_INTERVAL; } // Flush immediately if batch is large for responsiveness if (totalLength > BATCH_FLUSH_THRESHOLD) { const existingTimer = this.terminalBatchTimers.get(sessionId); if (existingTimer) { clearTimeout(existingTimer); this.terminalBatchTimers.delete(sessionId); } this.flushSessionTerminalBatch(sessionId); return; } // Start per-session batch timer if not already running // Each session flushes independently — prevents one busy session from // forcing all sessions to flush at its rate (thundering herd) if (!this.terminalBatchTimers.has(sessionId)) { this.terminalBatchTimers.set( sessionId, setTimeout(() => { this.terminalBatchTimers.delete(sessionId); this.flushSessionTerminalBatch(sessionId); }, sessionInterval) ); } } /** Flush a single session's batched terminal data */ private flushSessionTerminalBatch(sessionId: string): void { if (this._isStopping) { this.terminalBatches.delete(sessionId); this.terminalBatchSizes.delete(sessionId); return; } const chunks = this.terminalBatches.get(sessionId); if (chunks && chunks.length > 0) { // Join chunks only at flush time (avoids O(n^2) string concatenation in batchTerminalData) const data = chunks.join(''); // Wrap with DEC mode 2026 synchronized output markers // Terminal buffers all output between markers and renders atomically, // eliminating partial-frame flicker from Ink's full-screen redraws. // Unsupported terminals ignore these sequences harmlessly. const syncData = DEC_SYNC_START + data + DEC_SYNC_END; // Fast path: build SSE message directly without JSON.stringify on wrapper object. // Only the terminal data string needs escaping; sessionId is a UUID (safe to template). const escapedData = JSON.stringify(syncData); const message = `event: session:terminal\ndata: {"id":"${sessionId}","data":${escapedData}}\n\n`; for (const client of this.sseClients) { this.sendSSEPreformatted(client, message); } } this.terminalBatches.delete(sessionId); this.terminalBatchSizes.delete(sessionId); } // Batch task:updated events at 100ms - only send latest update per task // Key is sessionId:taskId to avoid collisions when multiple tasks update concurrently private batchTaskUpdate(sessionId: string, task: BackgroundTask): void { // Skip if server is stopping if (this._isStopping) return; // Use composite key to avoid losing updates when multiple tasks update in same batch window const key = `${sessionId}:${task.id}`; this.taskUpdateBatches.set(key, { sessionId, task }); if (!this.taskUpdateBatchTimer) { this.taskUpdateBatchTimer = setTimeout(() => { this.flushTaskUpdateBatches(); this.taskUpdateBatchTimer = null; }, TASK_UPDATE_BATCH_INTERVAL); } } private flushTaskUpdateBatches(): void { // Skip if server is stopping (timer may have been queued before stop() was called) if (this._isStopping) { this.taskUpdateBatches.clear(); return; } for (const [, { sessionId, task }] of this.taskUpdateBatches) { this.broadcast('task:updated', { sessionId, task }); } this.taskUpdateBatches.clear(); } /** * Debounce expensive session:updated broadcasts. * Instead of calling toDetailedState() on every event, batch requests * and only serialize once per STATE_UPDATE_DEBOUNCE_INTERVAL. */ private broadcastSessionStateDebounced(sessionId: string): void { // Skip if server is stopping if (this._isStopping) return; this.stateUpdatePending.add(sessionId); if (!this.stateUpdateTimer) { this.stateUpdateTimer = setTimeout(() => { this.flushStateUpdates(); this.stateUpdateTimer = null; }, STATE_UPDATE_DEBOUNCE_INTERVAL); } } private flushStateUpdates(): void { // Skip if server is stopping (timer may have been queued before stop() was called) if (this._isStopping) { this.stateUpdatePending.clear(); return; } for (const sessionId of this.stateUpdatePending) { const session = this.sessions.get(sessionId); if (session) { // Single expensive serialization per batch interval this.broadcast('session:updated', this.getSessionStateWithRespawn(session)); } } this.stateUpdatePending.clear(); } // ========== Web Push ========== /** Map SSE event names to push notification payloads */ private static readonly PUSH_EVENT_MAP: Record< string, { title: string; urgency: string; actions?: Array<{ action: string; title: string }> } > = { 'hook:permission_prompt': { title: 'Permission Required', urgency: 'critical', actions: [ { action: 'approve', title: 'Approve' }, { action: 'deny', title: 'Deny' }, ], }, 'hook:elicitation_dialog': { title: 'Question Asked', urgency: 'critical' }, 'hook:idle_prompt': { title: 'Waiting for Input', urgency: 'warning' }, 'hook:stop': { title: 'Response Complete', urgency: 'info' }, 'session:error': { title: 'Session Error', urgency: 'critical' }, 'respawn:blocked': { title: 'Respawn Blocked', urgency: 'critical' }, 'session:ralphCompletionDetected': { title: 'Task Complete', urgency: 'warning' }, }; /** * Send push notifications for a given event to all subscribed devices. * Only events in PUSH_EVENT_MAP trigger push. Per-subscription preferences are checked. * Expired subscriptions (410/404) are auto-removed. */ private sendPushNotifications(event: string, data: Record): void { const template = WebServer.PUSH_EVENT_MAP[event]; if (!template) return; const subscriptions = this.pushStore.getAll(); if (subscriptions.length === 0) return; const vapidKeys = this.pushStore.getVapidKeys(); webpush.setVapidDetails('mailto:codeman@localhost', vapidKeys.publicKey, vapidKeys.privateKey); const sessionName = (data.sessionName as string) || ''; const sessionId = (data.sessionId as string) || ''; // Build body text from event data let body = sessionName ? `[${sessionName}]` : ''; if (event === 'session:error' && data.error) { body += body ? ' ' : ''; body += String(data.error).slice(0, 200); } else if (event === 'respawn:blocked' && data.reason) { body += body ? ' ' : ''; body += String(data.reason); } else if (event === 'session:ralphCompletionDetected' && data.phrase) { body += body ? ' ' : ''; body += String(data.phrase); } else if (event === 'hook:permission_prompt' && data.tool_name) { body += body ? ' ' : ''; body += `Tool: ${String(data.tool_name)}`; } const payload = JSON.stringify({ title: template.title, body, tag: `codeman-${event}-${sessionId}`, sessionId, urgency: template.urgency, actions: template.actions, }); for (const sub of subscriptions) { // Check per-subscription preferences if (sub.pushPreferences[event] === false) continue; const pushSub = { endpoint: sub.endpoint, keys: sub.keys, }; webpush.sendNotification(pushSub, payload).catch((err: { statusCode?: number }) => { // Auto-remove expired/invalid subscriptions if (err.statusCode === 410 || err.statusCode === 404) { this.pushStore.removeByEndpoint(sub.endpoint); } }); } } /** * Clean up dead SSE clients and send keep-alive comments. * Keep-alive prevents proxy/load-balancer timeouts on idle connections. * Dead client cleanup prevents memory leaks from abruptly terminated connections. */ private cleanupDeadSSEClients(): void { const deadClients: FastifyReply[] = []; for (const client of this.sseClients) { try { // Check if the underlying socket is still writable const socket = client.raw.socket; if (!socket || socket.destroyed || !socket.writable) { deadClients.push(client); } else { // Send SSE comment as keep-alive (comments start with ':') client.raw.write(':keepalive\n\n'); } } catch { // Error accessing socket means client is dead deadClients.push(client); } } // Remove dead clients for (const client of deadClients) { this.sseClients.delete(client); this.backpressuredClients.delete(client); } if (deadClients.length > 0) { console.log(`[Server] Cleaned up ${deadClients.length} dead SSE client(s)`); } } /** * Records token usage for long-running sessions periodically. * Called every 5 minutes to capture usage in daily stats without waiting for session deletion. */ private recordPeriodicTokenUsage(): void { for (const [sessionId, session] of this.sessions) { const last = this.lastRecordedTokens.get(sessionId) || { input: 0, output: 0 }; const deltaInput = session.inputTokens - last.input; const deltaOutput = session.outputTokens - last.output; if (deltaInput > 0 || deltaOutput > 0) { this.store.recordDailyUsage(deltaInput, deltaOutput, sessionId); this.lastRecordedTokens.set(sessionId, { input: session.inputTokens, output: session.outputTokens, }); } } } async start(): Promise { await this.setupRoutes(); const lifecycleLog = getLifecycleLog(); lifecycleLog.log({ event: 'server_started', sessionId: '*' }); await lifecycleLog.trimIfNeeded(); // Restore mux sessions BEFORE accepting connections // This prevents race conditions where clients connect before state is ready // CRITICAL: Skip in test mode to prevent tests from picking up user sessions if (!this.testMode) { await this.restoreMuxSessions(); } // Clean up stale sessions from state file that don't have active mux sessions this.cleanupStaleSessions(); await this.app.listen({ port: this.port, host: '0.0.0.0' }); const protocol = this.https ? 'https' : 'http'; console.log(`Codeman web interface running at ${protocol}://localhost:${this.port}`); // Security warning: server binds to 0.0.0.0 (all interfaces) — warn if no auth configured if (!process.env.CODEMAN_PASSWORD) { console.warn('\n⚠ WARNING: No CODEMAN_PASSWORD set — server is accessible without authentication.'); console.warn(' Anyone on your network can access and control Claude sessions.'); console.warn(' Set CODEMAN_PASSWORD environment variable to enable auth.\n'); } // Set API URL for child processes (MCP server, spawned sessions) process.env.CODEMAN_API_URL = `${protocol}://localhost:${this.port}`; // Start scheduled runs cleanup timer this.scheduledCleanupTimer = setInterval(() => { this.cleanupScheduledRuns(); }, SCHEDULED_CLEANUP_INTERVAL); // Start SSE client health check timer (prevents memory leaks from dead connections) this.sseHealthCheckTimer = setInterval(() => { this.cleanupDeadSSEClients(); }, SSE_HEALTH_CHECK_INTERVAL); // Start token recording timer (every 5 minutes for long-running sessions) this.tokenRecordingTimer = setInterval( () => { this.recordPeriodicTokenUsage(); }, 5 * 60 * 1000 ); // Start subagent watcher for Claude Code background agent visibility (if enabled) if (await this.isSubagentTrackingEnabled()) { subagentWatcher.start(); console.log('Subagent watcher started - monitoring ~/.claude/projects for background agent activity'); } else { console.log('Subagent watcher disabled by user settings'); } // Start image watcher for auto-popup of screenshots (if enabled) if (await this.isImageWatcherEnabled()) { imageWatcher.start(); console.log('Image watcher started - monitoring session directories for new images'); } else { console.log('Image watcher disabled by user settings'); } // Start Cloudflare tunnel if enabled in settings if (await this.isTunnelEnabled()) { this.tunnelManager.start(this.port, this.https); console.log('Cloudflare tunnel starting on boot (enabled in settings)'); } // Start team watcher for agent team awareness (always on — lightweight polling) this.teamWatcher.start(); console.log('Team watcher started - monitoring ~/.claude/teams/ for agent team activity'); } /** * Check if subagent tracking is enabled in settings (default: true) */ private async isSubagentTrackingEnabled(): Promise { const settingsPath = join(homedir(), '.codeman', 'settings.json'); try { const content = await fs.readFile(settingsPath, 'utf-8'); const settings = JSON.parse(content); // Default to true if not explicitly set return settings.subagentTrackingEnabled ?? true; } catch (err) { if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { console.error('Failed to read subagent tracking setting:', err); } } return true; // Default enabled } /** * Check if image watcher is enabled in settings (default: false) */ private async isImageWatcherEnabled(): Promise { const settingsPath = join(homedir(), '.codeman', 'settings.json'); try { const content = await fs.readFile(settingsPath, 'utf-8'); const settings = JSON.parse(content); // Default to false if not explicitly set (matches UI default) return settings.imageWatcherEnabled ?? false; } catch (err) { if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { console.error('Failed to read image watcher setting:', err); } } return false; // Default disabled (matches UI default) } /** * Check if Cloudflare tunnel is enabled in settings (default: false) */ private async isTunnelEnabled(): Promise { const settingsPath = join(homedir(), '.codeman', 'settings.json'); try { const content = await fs.readFile(settingsPath, 'utf-8'); const settings = JSON.parse(content); return settings.tunnelEnabled ?? false; } catch (err) { if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { console.error('Failed to read tunnel setting:', err); } } return false; } private async restoreMuxSessions(): Promise { try { // Reconcile mux sessions to find which ones are still alive (also discovers unknown ones) const { alive, dead, discovered } = await this.mux.reconcileSessions(); if (discovered.length > 0) { console.log(`[Server] Discovered ${discovered.length} unknown mux session(s)`); } if (alive.length > 0 || discovered.length > 0) { console.log(`[Server] Found ${alive.length + discovered.length} alive mux session(s) from previous run`); // For each alive mux session, create a Session object if it doesn't exist const muxSessions = this.mux.getSessions(); for (const muxSession of muxSessions) { if (!this.sessions.has(muxSession.sessionId)) { // Restore session settings from state.json (single source of truth) const savedState = this.store.getSession(muxSession.sessionId); // Determine the correct session name (priority: savedState > muxSession > muxName) // This ensures renamed sessions keep their name after server restart const sessionName = savedState?.name || muxSession.name || muxSession.muxName; // Create a session object for this mux session const recoveryClaudeMode = await this.getClaudeModeConfig(); const session = new Session({ id: muxSession.sessionId, // Preserve the original session ID workingDir: muxSession.workingDir, mode: muxSession.mode, name: sessionName, mux: this.mux, useMux: true, muxSession: muxSession, // Pass the existing session so startInteractive() can attach to it claudeMode: recoveryClaudeMode.claudeMode, allowedTools: recoveryClaudeMode.allowedTools, }); // Update session name if it was a "Restored:" placeholder or doesn't match saved name if (savedState?.name && muxSession.name !== savedState.name) { this.mux.updateSessionName(muxSession.sessionId, savedState.name); } if (savedState) { // Auto-compact if (savedState.autoCompactEnabled !== undefined || savedState.autoCompactThreshold !== undefined) { session.setAutoCompact( savedState.autoCompactEnabled ?? false, savedState.autoCompactThreshold, savedState.autoCompactPrompt ); } // Auto-clear if (savedState.autoClearEnabled !== undefined || savedState.autoClearThreshold !== undefined) { session.setAutoClear(savedState.autoClearEnabled ?? false, savedState.autoClearThreshold); } // Token tracking if ( savedState.inputTokens !== undefined || savedState.outputTokens !== undefined || savedState.totalCost !== undefined ) { session.restoreTokens( savedState.inputTokens ?? 0, savedState.outputTokens ?? 0, savedState.totalCost ?? 0 ); // Initialize lastRecordedTokens to prevent re-counting restored tokens as new daily usage this.lastRecordedTokens.set(session.id, { input: savedState.inputTokens ?? 0, output: savedState.outputTokens ?? 0, }); const totalTokens = (savedState.inputTokens ?? 0) + (savedState.outputTokens ?? 0); if (totalTokens > 0) { console.log( `[Server] Restored tokens for session ${session.id}: ${totalTokens} tokens, $${(savedState.totalCost ?? 0).toFixed(4)}` ); } } // Ralph / Todo tracker (not supported for opencode sessions) if (session.mode !== 'opencode') { if (savedState.ralphAutoEnableDisabled) { session.ralphTracker.disableAutoEnable(); console.log(`[Server] Restored Ralph auto-enable disabled for session ${session.id}`); } else if (savedState.ralphEnabled) { // If Ralph was enabled and not explicitly disabled, allow re-enabling on restart session.ralphTracker.enableAutoEnable(); } if (savedState.ralphEnabled) { session.ralphTracker.enable(); if (savedState.ralphCompletionPhrase) { session.ralphTracker.startLoop(savedState.ralphCompletionPhrase); } console.log( `[Server] Restored Ralph tracker for session ${session.id} (phrase: ${savedState.ralphCompletionPhrase || 'none'})` ); } } // Nice priority config if (savedState.niceEnabled !== undefined) { session.setNice({ enabled: savedState.niceEnabled, niceValue: savedState.niceValue, }); } // Flicker filter (frontend-applied but persisted) if (savedState.flickerFilterEnabled !== undefined) { session.flickerFilterEnabled = savedState.flickerFilterEnabled; } // Respawn controller (not supported for opencode sessions) if (session.mode !== 'opencode' && savedState.respawnEnabled && savedState.respawnConfig) { try { this.restoreRespawnController(session, savedState.respawnConfig, 'state.json'); } catch (err) { console.error(`[Server] Failed to restore respawn for session ${session.id}:`, err); } } } // Fallback: restore respawn from mux-sessions.json if state.json didn't have it (not supported for opencode) if ( session.mode !== 'opencode' && !this.respawnControllers.has(session.id) && muxSession.respawnConfig?.enabled ) { try { this.restoreRespawnController(session, muxSession.respawnConfig, 'mux-sessions.json'); } catch (err) { console.error( `[Server] Failed to restore respawn from mux-sessions.json for session ${session.id}:`, err ); } } // Fallback: restore Ralph state from state-inner.json if not already set and not explicitly disabled // Ralph tracker is not supported for opencode sessions if ( session.mode !== 'opencode' && !session.ralphTracker.enabled && !session.ralphTracker.autoEnableDisabled ) { const ralphState = this.store.getRalphState(muxSession.sessionId); if (ralphState?.loop?.enabled) { session.ralphTracker.restoreState(ralphState.loop, ralphState.todos); console.log(`[Server] Restored Ralph state from inner store for session ${session.id}`); } } // Fallback: auto-detect completion phrase from CLAUDE.md (not supported for opencode) if ( session.mode !== 'opencode' && session.ralphTracker.enabled && !session.ralphTracker.loopState.completionPhrase ) { const claudeMdPath = join(session.workingDir, 'CLAUDE.md'); const completionPhrase = extractCompletionPhrase(claudeMdPath); if (completionPhrase) { session.ralphTracker.startLoop(completionPhrase); console.log(`[Server] Auto-detected completion phrase for session ${session.id}: ${completionPhrase}`); } } this.sessions.set(session.id, session); await this.setupSessionListeners(session); this.persistSessionState(session); // Mark it as restored (not started yet - user needs to attach) getLifecycleLog().log({ event: 'recovered', sessionId: session.id, name: session.name, }); console.log(`[Server] Restored session ${session.id} from mux ${muxSession.muxName}`); } } // Start stats collection for mux sessions this.mux.startStatsCollection(STATS_COLLECTION_INTERVAL_MS); } // Start mouse mode sync (tmux only) — toggles mouse on/off based on pane count. // Mouse off = native xterm.js selection; mouse on = tmux pane clicking (split layouts). // Always start, even with no sessions — new sessions may be created later. if ('startMouseModeSync' in this.mux) { (this.mux as { startMouseModeSync: (ms?: number) => void }).startMouseModeSync(); } if (dead.length > 0) { console.log(`[Server] Cleaned up ${dead.length} dead mux session(s)`); } } catch (err) { console.error('[Server] Failed to restore mux sessions:', err); } } async stop(): Promise { getLifecycleLog().log({ event: 'server_stopped', sessionId: '*' }); // Set stopping flag to prevent new timer creation during shutdown this._isStopping = true; // Clear SSE health check timer if (this.sseHealthCheckTimer) { clearInterval(this.sseHealthCheckTimer); this.sseHealthCheckTimer = null; } // Gracefully close all SSE connections before clearing for (const client of this.sseClients) { try { // Send a final event to notify clients of shutdown this.sendSSE(client, 'server:shutdown', { reason: 'Server stopping' }); client.raw.end(); } catch { // Client may already be disconnected } } this.sseClients.clear(); this.backpressuredClients.clear(); // Clear per-session batch timers for (const timer of this.terminalBatchTimers.values()) { clearTimeout(timer); } this.terminalBatchTimers.clear(); this.terminalBatches.clear(); this.terminalBatchSizes.clear(); if (this.taskUpdateBatchTimer) { clearTimeout(this.taskUpdateBatchTimer); this.taskUpdateBatchTimer = null; } this.taskUpdateBatches.clear(); if (this.stateUpdateTimer) { clearTimeout(this.stateUpdateTimer); this.stateUpdateTimer = null; } this.stateUpdatePending.clear(); // Clear token recording timer if (this.tokenRecordingTimer) { clearInterval(this.tokenRecordingTimer); this.tokenRecordingTimer = null; } this.lastRecordedTokens.clear(); // Clear scheduled cleanup timer if (this.scheduledCleanupTimer) { clearInterval(this.scheduledCleanupTimer); this.scheduledCleanupTimer = null; } // Stop multiplexer and flush pending saves this.mux.destroy(); // Flush any pending persist-debounce timers and persist dirty sessions for (const [sessionId, timer] of this.persistDebounceTimers) { clearTimeout(timer); const session = this.sessions.get(sessionId); if (session) { this._persistSessionStateNow(session); } } this.persistDebounceTimers.clear(); // Clear cached state this.cachedLightState = null; this.cachedSessionsList = null; // Clear all pending respawn start timers (from restoration grace period) for (const timer of this.pendingRespawnStarts.values()) { clearTimeout(timer); } this.pendingRespawnStarts.clear(); // Stop all respawn controllers and remove listeners for (const controller of this.respawnControllers.values()) { controller.stop(); controller.removeAllListeners(); } this.respawnControllers.clear(); // Stop all scheduled runs first (they have their own session cleanup) await Promise.allSettled(Array.from(this.scheduledRuns.keys()).map((id) => this.stopScheduledRun(id))); // On server shutdown, DO NOT call cleanupSession — it tears down session state, // removes listeners, kills PTY processes, and broadcasts session:deleted. // Instead, just persist current state and let the PTY die naturally when process exits. // The tmux sessions survive independently, and restoreMuxSessions() will find them on restart. for (const [sessionId, session] of this.sessions) { // Persist final state so recovery has up-to-date tokens, ralph state, etc. this._persistSessionStateNow(session); // Remove listeners to avoid spurious events during teardown const listeners = this.sessionListenerRefs.get(sessionId); if (listeners) { session.off('terminal', listeners.terminal); session.off('clearTerminal', listeners.clearTerminal); session.off('needsRefresh', listeners.needsRefresh); session.off('message', listeners.message); session.off('error', listeners.error); session.off('completion', listeners.completion); session.off('exit', listeners.exit); session.off('working', listeners.working); session.off('idle', listeners.idle); session.off('taskCreated', listeners.taskCreated); session.off('taskUpdated', listeners.taskUpdated); session.off('taskCompleted', listeners.taskCompleted); session.off('taskFailed', listeners.taskFailed); session.off('autoClear', listeners.autoClear); session.off('autoCompact', listeners.autoCompact); session.off('cliInfoUpdated', listeners.cliInfoUpdated); session.off('ralphLoopUpdate', listeners.ralphLoopUpdate); session.off('ralphTodoUpdate', listeners.ralphTodoUpdate); session.off('ralphCompletionDetected', listeners.ralphCompletionDetected); session.off('ralphStatusBlockDetected', listeners.ralphStatusBlockDetected); session.off('ralphCircuitBreakerUpdate', listeners.ralphCircuitBreakerUpdate); session.off('ralphExitGateMet', listeners.ralphExitGateMet); session.off('bashToolStart', listeners.bashToolStart); session.off('bashToolEnd', listeners.bashToolEnd); session.off('bashToolsUpdate', listeners.bashToolsUpdate); this.sessionListenerRefs.delete(sessionId); } session.removeAllListeners(); // Close file streams and image watchers (these are server-side resources) fileStreamManager.closeSessionStreams(sessionId); imageWatcher.unwatchSession(sessionId); } // Don't delete sessions from the map or state.json — recovery needs them // Flush state store to prevent data loss from debounced saves this.store.flushAll(); // Clean up watcher listeners to prevent memory leaks this.cleanupSubagentWatcherListeners(); this.cleanupImageWatcherListeners(); this.cleanupTeamWatcherListeners(); // Stop subagent watcher subagentWatcher.stop(); // Stop image watcher imageWatcher.stop(); // Stop team watcher this.teamWatcher.stop(); // Stop tunnel this.tunnelManager.stop(); this.tunnelManager.removeAllListeners(); // Destroy file stream manager (clears cleanup timer and kills remaining tail processes) fileStreamManager.destroy(); // Stop all remaining tracked resources before clearing their Maps for (const tracker of this.runSummaryTrackers.values()) { tracker.stop(); } for (const watcher of this.transcriptWatchers.values()) { watcher.removeAllListeners(); watcher.stop(); } for (const orchestrator of this.activePlanOrchestrators.values()) { orchestrator.cancel(); } // Clear remaining Maps that accumulate session references this.respawnTimers.clear(); this.runSummaryTrackers.clear(); this.transcriptWatchers.clear(); this.sessionListenerRefs.clear(); this.scheduledRuns.clear(); // Dispose StaleExpirationMaps (stops internal cleanup timers) this.lastTerminalEventTime.dispose(); if (this.authSessions) { this.authSessions.dispose(); this.authSessions = null; } if (this.authFailures) { this.authFailures.dispose(); this.authFailures = null; } this.activePlanOrchestrators.clear(); this.cleaningUp.clear(); // Dispose push store (flush pending saves) this.pushStore.dispose(); await this.app.close(); } } export async function startWebServer( port: number = 3000, https: boolean = false, testMode: boolean = false ): Promise { const server = new WebServer(port, https, testMode); await server.start(); return server; }