/** * @fileoverview Claudeman 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 fastifyStatic from '@fastify/static'; import path, { join, dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { existsSync, mkdirSync, writeFileSync, readdirSync, readFileSync } from 'node:fs'; import fs from 'node:fs/promises'; import { execSync } from 'node:child_process'; 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 { fileStreamManager } from '../file-stream-manager.js'; import { RespawnController, RespawnConfig, RespawnState } from '../respawn-controller.js'; import { SpawnOrchestrator, type SessionCreator } from '../spawn-orchestrator.js'; import type { SpawnOrchestratorConfig } from '../spawn-types.js'; import { ScreenManager } from '../screen-manager.js'; import { getStore } from '../state-store.js'; import { generateClaudeMd } from '../templates/claude-md.js'; import { parseRalphLoopConfig, extractCompletionPhrase } from '../ralph-config.js'; import { writeHooksConfig } from '../hooks-config.js'; import { subagentWatcher, type SubagentInfo, type SubagentToolCall, type SubagentProgress, type SubagentMessage, type SubagentToolResult } from '../subagent-watcher.js'; import { TranscriptWatcher } from '../transcript-watcher.js'; import { v4 as uuidv4 } from 'uuid'; import { createRequire } from 'node:module'; import { RunSummaryTracker } from '../run-summary.js'; // Load version from package.json const require = createRequire(import.meta.url); const { version: APP_VERSION } = require('../../package.json'); import { getErrorMessage, ApiErrorCode, createErrorResponse, type CreateSessionRequest, type RunPromptRequest, type SessionInputRequest, type ResizeRequest, type CreateCaseRequest, type QuickStartRequest, type CreateScheduledRunRequest, type QuickRunRequest, type HookEventRequest, type ApiResponse, type SessionResponse, type QuickStartResponse, type CaseInfo, type PersistedRespawnConfig, type NiceConfig, DEFAULT_NICE_CONFIG, } from '../types.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 session:output events for 50ms const OUTPUT_BATCH_INTERVAL = 50; // 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; // 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; // Maximum concurrent sessions to prevent resource exhaustion const MAX_CONCURRENT_SESSIONS = 50; // 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; // 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; // SSE batch flush threshold (number of items) const BATCH_FLUSH_THRESHOLD = 1024; // Pre-compiled regex for terminal buffer cleaning (avoids per-request compilation) const CLAUDE_BANNER_PATTERN = /\x1b\[1mClaud/; 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 | 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 ~/.claudeman/certs/ and reused across restarts. */ function getOrCreateSelfSignedCert(): { key: string; cert: string } { const certsDir = join(homedir(), '.claudeman', '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 }); // 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=claudeman" ` + `-addext "subjectAltName=DNS:localhost,IP:127.0.0.1,IP:0.0.0.0"`, { stdio: 'pipe' } ); return { key: readFileSync(keyPath, 'utf-8'), cert: readFileSync(certPath, 'utf-8'), }; } export class WebServer extends EventEmitter { 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(); private scheduledRuns: Map = new Map(); private sseClients: Set = new Set(); private store = getStore(); private port: number; private https: boolean; private screenManager: ScreenManager; // Terminal batching for performance private terminalBatches: Map = new Map(); private terminalBatchTimer: NodeJS.Timeout | null = null; // Scheduled runs cleanup timer private scheduledCleanupTimer: NodeJS.Timeout | null = null; // SSE event batching private outputBatches: Map = new Map(); private outputBatchTimer: NodeJS.Timeout | null = null; 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; // Spawn1337 agent orchestrator private spawnOrchestrator: SpawnOrchestrator; // 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(); // Grace period before starting restored respawn controllers (2 minutes) private static readonly RESPAWN_RESTORE_GRACE_PERIOD_MS = 2 * 60 * 1000; constructor(port: number = 3000, https: boolean = false) { super(); this.port = port; this.https = https; if (https) { const { key, cert } = getOrCreateSelfSignedCert(); this.app = Fastify({ logger: false, https: { key, cert } }); } else { this.app = Fastify({ logger: false }); } this.screenManager = new ScreenManager(); // Set up screen manager event listeners this.screenManager.on('screenCreated', (screen) => { this.broadcast('screen:created', screen); }); this.screenManager.on('screenKilled', (data) => { this.broadcast('screen:killed', data); }); this.screenManager.on('screenDied', (data) => { this.broadcast('screen:died', data); }); this.screenManager.on('statsUpdated', (screens) => { this.broadcast('screen:statsUpdated', screens); }); // Initialize spawn orchestrator this.spawnOrchestrator = new SpawnOrchestrator(); this.setupSpawnOrchestratorListeners(); // Set up subagent watcher listeners this.setupSubagentWatcherListeners(); } /** * 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 { subagentWatcher.on('subagent:discovered', (info: SubagentInfo) => { this.broadcast('subagent:discovered', info); }); subagentWatcher.on('subagent:updated', (info: SubagentInfo) => { this.broadcast('subagent:updated', info); }); subagentWatcher.on('subagent:tool_call', (data: SubagentToolCall) => { this.broadcast('subagent:tool_call', data); }); subagentWatcher.on('subagent:tool_result', (data: SubagentToolResult) => { this.broadcast('subagent:tool_result', data); }); subagentWatcher.on('subagent:progress', (data: SubagentProgress) => { this.broadcast('subagent:progress', data); }); subagentWatcher.on('subagent:message', (data: SubagentMessage) => { this.broadcast('subagent:message', data); }); subagentWatcher.on('subagent:completed', (info: SubagentInfo) => { this.broadcast('subagent:completed', info); }); subagentWatcher.on('subagent:error', (error: Error, agentId?: string) => { console.error(`[SubagentWatcher] Error${agentId ? ` for ${agentId}` : ''}:`, error.message); }); } private async setupRoutes(): Promise { // Serve static files await this.app.register(fastifyStatic, { root: join(__dirname, 'public'), prefix: '/', }); // SSE endpoint for real-time updates this.app.get('/api/events', (req, reply) => { 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); }); }); // API Routes this.app.get('/api/status', async () => this.getFullState()); // Cleanup stale sessions from state file this.app.post('/api/cleanup-state', async () => { const cleaned = this.cleanupStaleSessions(); return { success: true, cleanedSessions: cleaned }; }); // 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) => { const body = req.body as Record | null; if (!body || typeof body !== 'object') { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Request body must be a JSON object'); } this.store.setConfig(body as Partial>); return { success: true, config: this.store.getConfig() }; }); // Debug/monitoring endpoint - lightweight, only runs when called this.app.get('/api/debug/memory', async () => { const mem = process.memoryUsage(); const spawnState = this.spawnOrchestrator.getState(); 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, }, counts: { sessions: this.sessions.size, sseClients: this.sseClients.size, respawnControllers: this.respawnControllers.size, runSummaryTrackers: this.runSummaryTrackers.size, scheduledRuns: this.scheduledRuns.size, terminalBatches: this.terminalBatches.size, outputBatches: this.outputBatches.size, pendingRespawnStarts: this.pendingRespawnStarts.size, respawnTimers: this.respawnTimers.size, subagents: subagentWatcher.getSubagents().length, }, spawn: { activeAgents: spawnState.activeCount, queuedAgents: spawnState.queuedCount, totalSpawned: spawnState.totalSpawned, totalCompleted: spawnState.totalCompleted, totalFailed: spawnState.totalFailed, }, uptime: { seconds: Math.round(process.uptime()), formatted: formatUptime(process.uptime()), }, timestamp: Date.now(), }; }); // Session management this.app.get('/api/sessions', async () => this.getSessionsState()); this.app.post('/api/sessions', async (req): Promise => { // Prevent unbounded session creation if (this.sessions.size >= MAX_CONCURRENT_SESSIONS) { return { success: false, error: `Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached. Delete some sessions first.` }; } const body = req.body as CreateSessionRequest & { mode?: 'claude' | 'shell'; name?: string }; const workingDir = body.workingDir || process.cwd(); const globalNice = this.getGlobalNiceConfig(); const session = new Session({ workingDir, mode: body.mode || 'claude', name: body.name || '', screenManager: this.screenManager, useScreen: true, niceConfig: globalNice, }); this.sessions.set(session.id, session); this.store.incrementSessionsCreated(); this.persistSessionState(session); this.setupSessionListeners(session); this.broadcast('session:created', session.toDetailedState()); return { success: true, session: session.toDetailedState() }; }); // Rename a session this.app.put('/api/sessions/:id/name', async (req) => { const { id } = req.params as { id: string }; const body = req.body as { name: string }; 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 screen name if this session has a screen this.screenManager.updateScreenName(id, session.name); this.persistSessionState(session); this.broadcast('session:updated', session.toDetailedState()); return { success: true, name: session.name }; }); this.app.delete('/api/sessions/:id', async (req): Promise => { const { id } = req.params as { id: string }; const query = req.query as { killScreen?: string }; const killScreen = query.killScreen !== 'false'; // Default to true if (!this.sessions.has(id)) { return { success: false, error: 'Session not found' }; } await this.cleanupSession(id, killScreen); 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); 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'); } // Include respawn controller state if active const controller = this.respawnControllers.get(id); return { ...session.toDetailedState(), respawnEnabled: controller?.getConfig()?.enabled ?? false, respawnConfig: controller?.getConfig() ?? null, respawn: controller?.getStatus() ?? null, }; }); 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 { success: false, error: '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) const fullPath = resolve(session.workingDir, filePath); if (!fullPath.startsWith(session.workingDir)) { 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)}`, } }; } // Read text file with line limit const maxLines = parseInt(lines || '500', 10); 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({ success: false, error: 'Session not found' }); return; } if (!filePath) { reply.code(400).send({ success: false, error: 'Missing path parameter' }); return; } // Validate path is within working directory const fullPath = resolve(session.workingDir, filePath); if (!fullPath.startsWith(session.workingDir)) { reply.code(400).send({ success: false, error: 'Path must be within working directory' }); return; } try { 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({ success: false, error: `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({ success: false, error: 'Session not found' }); return; } if (!filePath) { reply.code(400).send({ success: false, error: '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 { enabled, completionPhrase, maxIterations, reset, disableAutoEnable } = req.body as { enabled?: boolean; completionPhrase?: string; maxIterations?: number; reset?: boolean | 'full'; // true = soft reset (keep enabled), 'full' = complete reset disableAutoEnable?: boolean; // Prevent auto-enable on pattern detection }; const session = this.sessions.get(id); if (!session) { return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); } // 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(); } else { session.ralphTracker.disable(); } // Persist Ralph enabled state this.screenManager.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 { content } = req.body as { content: string }; const session = this.sessions.get(id); if (!session) { return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); } if (!content || typeof content !== 'string') { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Content is required'); } 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 = path.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 = path.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}`); } }); // Run prompt in session this.app.post('/api/sessions/:id/run', async (req): Promise => { const { id } = req.params as { id: string }; const { prompt } = req.body as RunPromptRequest; 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) if (this.store.getConfig().ralphEnabled) { autoConfigureRalph(session, session.workingDir, () => {}); if (!session.ralphTracker.enabled) { session.ralphTracker.enable(); } } await session.startInteractive(); this.broadcast('session:interactive', { id }); this.broadcast('session:updated', { session: session.toDetailedState() }); 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(); this.broadcast('session:interactive', { id, mode: 'shell' }); this.broadcast('session:updated', { session: session.toDetailedState() }); return { success: true }; } catch (err) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); } }); // Send input to interactive session // useScreen: true uses writeViaScreen 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 { input, useScreen } = req.body as SessionInputRequest & { useScreen?: boolean }; const session = this.sessions.get(id); if (!session) { return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); } if (input === undefined || input === null) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Input is required'); } const inputStr = String(input); if (inputStr.length > MAX_INPUT_LENGTH) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Input exceeds maximum length (${MAX_INPUT_LENGTH} bytes)`); } // Use writeViaScreen for programmatic input (more reliable for screen sessions) let success = false; if (useScreen) { success = session.writeViaScreen(inputStr); if (!success) { console.warn(`[Server] writeViaScreen failed for session ${id}, falling back to direct write`); // Fallback to direct write if screen write fails session.write(inputStr); success = true; // Direct write doesn't return status, assume success } } else { session.write(inputStr); success = true; } return { success }; }); // Resize session terminal this.app.post('/api/sessions/:id/resize', async (req): Promise => { const { id } = req.params as { id: string }; const { cols, rows } = req.body as ResizeRequest; const session = this.sessions.get(id); if (!session) { return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); } if (!Number.isInteger(cols) || !Number.isInteger(rows) || cols < 1 || rows < 1) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'cols and rows must be positive integers'); } 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'); } // Clean the buffer: remove junk before actual Claude content let 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) { // Find the start of that line let lineStart = claudeMatch.index; while (lineStart > 0 && cleanBuffer[lineStart - 1] !== '\n') { lineStart--; } cleanBuffer = cleanBuffer.slice(lineStart); } // Remove Ctrl+L and leading whitespace cleanBuffer = cleanBuffer .replace(CTRL_L_PATTERN, '') .replace(LEADING_WHITESPACE_PATTERN, ''); // Optionally truncate to last N bytes for faster initial load const tailBytes = query.tail ? parseInt(query.tail, 10) : 0; const fullSize = cleanBuffer.length; let truncated = false; if (tailBytes > 0 && cleanBuffer.length > tailBytes) { cleanBuffer = cleanBuffer.slice(-tailBytes); truncated = true; } 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 screens.json const preConfig = this.screenManager.getScreen(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 }; const body = req.body as Partial | undefined; const session = this.sessions.get(id); if (!session) { return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); } // Create or get existing controller let controller = this.respawnControllers.get(id); if (!controller) { // Merge request body with pre-saved config from screens.json const preConfig = this.screenManager.getScreen(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 screen 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.screenManager.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 }; const config = req.body 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.screenManager.getScreen(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.screenManager.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 body = req.body as { respawnConfig?: Partial; durationMinutes?: number } | undefined; 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) if (this.store.getConfig().ralphEnabled) { autoConfigureRalph(session, session.workingDir, () => {}); if (!session.ralphTracker.enabled) { session.ralphTracker.enable(); } } // Start interactive session await session.startInteractive(); this.broadcast('session:interactive', { id }); this.broadcast('session:updated', { session: session.toDetailedState() }); // 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 body = req.body as { config?: Partial; durationMinutes?: number } | undefined; const session = this.sessions.get(id); if (!session) { return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); } // 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.screenManager.getScreen(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 screen 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 body = req.body as { enabled: boolean; threshold?: number }; const session = this.sessions.get(id); if (!session) { return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); } if (body.enabled === undefined) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'enabled field is required'); } if (body.threshold !== undefined && (typeof body.threshold !== 'number' || body.threshold < 0)) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'threshold must be a positive number'); } session.setAutoClear(body.enabled, body.threshold); this.persistSessionState(session); this.broadcast('session:updated', session.toDetailedState()); 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 body = req.body as { enabled: boolean; threshold?: number; prompt?: string }; const session = this.sessions.get(id); if (!session) { return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); } if (body.enabled === undefined) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'enabled field is required'); } if (body.threshold !== undefined && (typeof body.threshold !== 'number' || body.threshold < 0)) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'threshold must be a positive number'); } session.setAutoCompact(body.enabled, body.threshold, body.prompt); this.persistSessionState(session); this.broadcast('session:updated', session.toDetailedState()); return { success: true, data: { autoCompact: { enabled: session.autoCompactEnabled, threshold: session.autoCompactThreshold, prompt: session.autoCompactPrompt, }, }, }; }); // 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 { prompt, workingDir } = req.body as QuickRunRequest; if (!prompt || typeof prompt !== 'string' || prompt.trim() === '') { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'prompt is required'); } const dir = workingDir || process.cwd(); const session = new Session({ workingDir: dir }); this.sessions.set(session.id, session); this.store.incrementSessionsCreated(); this.persistSessionState(session); this.setupSessionListeners(session); this.broadcast('session:created', session.toDetailedState()); try { const result = await session.runPrompt(prompt); // Clean up session after completion to prevent memory leak await this.cleanupSession(session.id); return { success: true, sessionId: session.id, ...result }; } catch (err) { // Clean up session on error too await this.cleanupSession(session.id); 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 }> => { const { prompt, workingDir, durationMinutes } = req.body as CreateScheduledRunRequest; const run = await this.startScheduledRun(prompt, workingDir || process.cwd(), durationMinutes); 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 { success: false, error: '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(), 'claudeman-cases'); this.app.get('/api/cases', async (): Promise => { const cases: CaseInfo[] = []; // Get cases from casesDir if (existsSync(casesDir)) { const entries = readdirSync(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')), }); } } } // Get linked cases const linkedCasesFile = join(homedir(), '.claudeman', 'linked-cases.json'); try { if (existsSync(linkedCasesFile)) { const linkedCases: Record = JSON.parse(readFileSync(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 { // Ignore errors reading linked cases } return cases; }); this.app.post('/api/cases', async (req): Promise<{ success: boolean; case?: { name: string; path: string }; error?: string }> => { const { name, description } = req.body as CreateCaseRequest; if (!name || !/^[a-zA-Z0-9_-]+$/.test(name)) { return { success: false, error: 'Invalid case name. Use only letters, numbers, hyphens, underscores.' }; } const casePath = join(casesDir, name); // Security: Path traversal protection - ensure resolved path is within casesDir const resolvedPath = resolve(casePath); const resolvedBase = resolve(casesDir); if (!resolvedPath.startsWith(resolvedBase + '/') && resolvedPath !== resolvedBase) { return { success: false, error: 'Invalid case path' }; } if (existsSync(casePath)) { return { success: false, error: 'Case already exists' }; } try { mkdirSync(casePath, { recursive: true }); mkdirSync(join(casePath, 'src'), { recursive: true }); // Read settings to get custom template path const templatePath = this.getDefaultClaudeMdPath(); const claudeMd = generateClaudeMd(name, description || '', templatePath); writeFileSync(join(casePath, 'CLAUDE.md'), claudeMd); // Write .mcp.json for Claude Code to discover spawn tools this.writeMcpConfig(casePath); // Write .claude/settings.local.json with hooks for desktop notifications writeHooksConfig(casePath); this.broadcast('case:created', { name, path: casePath }); return { success: true, case: { name, path: casePath } }; } catch (err) { return { success: false, error: getErrorMessage(err) }; } }); // Link an existing folder as a case this.app.post('/api/cases/link', async (req): Promise<{ success: boolean; case?: { name: string; path: string }; error?: string }> => { const { name, path: folderPath } = req.body as { name: string; path: string }; if (!name || !/^[a-zA-Z0-9_-]+$/.test(name)) { return { success: false, error: 'Invalid case name. Use only letters, numbers, hyphens, underscores.' }; } if (!folderPath) { return { success: false, error: 'Folder path is required.' }; } // Expand ~ to home directory const expandedPath = folderPath.startsWith('~') ? join(homedir(), folderPath.slice(1)) : folderPath; // Validate the folder exists if (!existsSync(expandedPath)) { return { success: false, error: `Folder not found: ${expandedPath}` }; } // Check if case name already exists in casesDir const casePath = join(casesDir, name); if (existsSync(casePath)) { return { success: false, error: 'A case with this name already exists in claudeman-cases.' }; } // Load existing linked cases const linkedCasesFile = join(homedir(), '.claudeman', 'linked-cases.json'); let linkedCases: Record = {}; try { if (existsSync(linkedCasesFile)) { linkedCases = JSON.parse(readFileSync(linkedCasesFile, 'utf-8')); } } catch { // Ignore parse errors, start fresh } // Check if name is already linked if (linkedCases[name]) { return { success: false, error: `Case "${name}" is already linked to ${linkedCases[name]}` }; } // Save the linked case linkedCases[name] = expandedPath; try { const claudemanDir = join(homedir(), '.claudeman'); if (!existsSync(claudemanDir)) { mkdirSync(claudemanDir, { recursive: true }); } writeFileSync(linkedCasesFile, JSON.stringify(linkedCases, null, 2)); this.broadcast('case:linked', { name, path: expandedPath }); return { success: true, case: { name, path: expandedPath } }; } catch (err) { return { success: false, error: 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(), '.claudeman', 'linked-cases.json'); try { if (existsSync(linkedCasesFile)) { const linkedCases: Record = JSON.parse(readFileSync(linkedCasesFile, 'utf-8')); if (linkedCases[name]) { const linkedPath = linkedCases[name]; return { name, path: linkedPath, hasClaudeMd: existsSync(join(linkedPath, 'CLAUDE.md')), linked: true, }; } } } catch { // Ignore 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(), '.claudeman', 'linked-cases.json'); try { if (existsSync(linkedCasesFile)) { const linkedCases: Record = JSON.parse(readFileSync(linkedCasesFile, 'utf-8')); if (linkedCases[name]) { casePath = linkedCases[name]; } } } catch { // Ignore errors } 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 = readFileSync(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, }); } } const stats = { total: todos.length, pending: todos.filter(t => t.status === 'pending').length, inProgress: todos.filter(t => t.status === 'in_progress').length, completed: todos.filter(t => t.status === 'completed').length, }; 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 { success: false, error: `Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached.` }; } const { caseName = 'testcase', mode = 'claude' } = req.body as QuickStartRequest; // Validate case name if (!/^[a-zA-Z0-9_-]+$/.test(caseName)) { return { success: false, error: 'Invalid case name. Use only letters, numbers, hyphens, underscores.' }; } const casePath = join(casesDir, caseName); // Security: Path traversal protection - ensure resolved path is within casesDir const resolvedPath = resolve(casePath); const resolvedBase = resolve(casesDir); if (!resolvedPath.startsWith(resolvedBase + '/') && resolvedPath !== resolvedBase) { return { success: false, error: '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 = this.getDefaultClaudeMdPath(); const claudeMd = generateClaudeMd(caseName, '', templatePath); writeFileSync(join(casePath, 'CLAUDE.md'), claudeMd); // Write .mcp.json for Claude Code to discover spawn tools this.writeMcpConfig(casePath); // Write .claude/settings.local.json with hooks for desktop notifications writeHooksConfig(casePath); this.broadcast('case:created', { name: caseName, path: casePath }); } catch (err) { return { success: false, error: `Failed to create case: ${getErrorMessage(err)}` }; } } // Create a new session with the case as working directory // Apply global Nice priority config if enabled in settings const niceConfig = this.getGlobalNiceConfig(); const session = new Session({ workingDir: casePath, screenManager: this.screenManager, useScreen: true, mode: mode, niceConfig: niceConfig, }); // 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(); } } this.sessions.set(session.id, session); this.store.incrementSessionsCreated(); this.persistSessionState(session); this.setupSessionListeners(session); this.broadcast('session:created', session.toDetailedState()); // Start in the appropriate mode try { if (mode === 'shell') { await session.startShell(); this.broadcast('session:interactive', { id: session.id, mode: 'shell' }); } else { await session.startInteractive(); this.broadcast('session:interactive', { id: session.id }); } this.broadcast('session:updated', { session: session.toDetailedState() }); // Save lastUsedCase to settings for TUI/web sync try { const settingsFilePath = join(homedir(), '.claudeman', 'settings.json'); let settings: Record = {}; if (existsSync(settingsFilePath)) { settings = JSON.parse(readFileSync(settingsFilePath, 'utf-8')); } settings.lastUsedCase = caseName; const dir = dirname(settingsFilePath); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } writeFileSync(settingsFilePath, JSON.stringify(settings, null, 2)); } catch { // Non-critical, ignore settings save errors } return { success: true, sessionId: session.id, casePath, caseName, }; } catch (err) { // Clean up session on error to prevent orphaned resources await this.cleanupSession(session.id); return { success: false, error: getErrorMessage(err) }; } }); // Generate implementation plan from task description using Claude interface GeneratePlanRequest { taskDescription: string; detailLevel?: 'brief' | 'standard' | 'detailed'; } interface PlanItem { content: string; priority: 'P0' | 'P1' | 'P2' | null; } this.app.post('/api/generate-plan', async (req): Promise => { const { taskDescription, detailLevel = 'standard' } = req.body as GeneratePlanRequest; if (!taskDescription || typeof taskDescription !== 'string') { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Task description is required'); } if (taskDescription.length > 10000) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Task description too long (max 10000 chars)'); } // 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: - content: specific action (verb phrase, 15-120 chars, be descriptive!) - priority: "P0" (critical/blocking), "P1" (required), "P2" (enhancement) ## EXAMPLE OUTPUT [ {"content": "Create project structure with src/, tests/, and config directories", "priority": "P0"}, {"content": "Define TypeScript interfaces for User, Session, and AuthToken types", "priority": "P0"}, {"content": "Write failing unit tests for password hashing (valid password, empty, too short)", "priority": "P0"}, {"content": "Implement password hashing with bcrypt, configurable salt rounds", "priority": "P0"}, {"content": "Run password tests and debug until all pass", "priority": "P0"}, {"content": "Write failing tests for JWT token generation and validation", "priority": "P0"}, {"content": "Implement JWT service with access/refresh token support", "priority": "P0"}, {"content": "Run JWT tests and verify token expiration handling works", "priority": "P0"}, {"content": "Write integration tests for login flow (valid creds, invalid, locked account)", "priority": "P1"}, {"content": "Implement login endpoint with rate limiting and audit logging", "priority": "P1"}, {"content": "Add error handling for network failures and database timeouts", "priority": "P1"}, {"content": "Run full test suite and fix any failures", "priority": "P1"}, {"content": "Verify all original requirements are implemented and tested", "priority": "P1"} ] ## CRITICAL RULES 1. EVERY implementation step should have a corresponding test step BEFORE it 2. Include "Run tests and debug/fix" steps after implementation blocks 3. Be SPECIFIC - not "Add tests" but "Write tests for X covering Y and Z" 4. Think about what could fail and add defensive steps 5. End with verification that ALL original requirements are met 6. 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(), screenManager: this.screenManager, useScreen: false, // No screen needed for one-shot mode: 'claude', }); // Use Opus 4.5 for plan generation (better reasoning) const modelToUse = '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 items = parsed.map((item: unknown, idx: number) => { if (typeof item !== 'object' || item === null) { return { content: `Step ${idx + 1}`, priority: null }; } 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; } return { content, priority }; }); // 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 } } }); // ============ App Settings Endpoints ============ const settingsPath = join(homedir(), '.claudeman', 'settings.json'); this.app.get('/api/settings', async () => { try { if (existsSync(settingsPath)) { const content = readFileSync(settingsPath, 'utf-8'); return JSON.parse(content); } } catch (err) { console.error('Failed to read settings:', err); } return {}; }); this.app.put('/api/settings', async (req) => { const settings = req.body as Record; try { const dir = dirname(settingsPath); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } writeFileSync(settingsPath, JSON.stringify(settings, 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'); } 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 body = req.body as Partial; // Validate inputs if (body.niceValue !== undefined) { if (typeof body.niceValue !== 'number' || body.niceValue < -20 || body.niceValue > 19) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Nice value must be between -20 and 19'); } } session.setNice(body); this.persistSessionState(session); this.broadcast('session:updated', { session: session.toDetailedState() }); return { success: true, nice: session.niceConfig, note: 'Nice priority only affects newly created screen sessions, not currently running ones.', }; }); // ============ Subagent Window State Endpoints ============ // Persists minimized/open window states for cross-browser sync const windowStatesPath = join(homedir(), '.claudeman', 'subagent-window-states.json'); this.app.get('/api/subagent-window-states', async () => { try { if (existsSync(windowStatesPath)) { const content = readFileSync(windowStatesPath, 'utf-8'); return JSON.parse(content); } } catch (err) { console.error('Failed to read subagent window states:', err); } return { minimized: {}, open: [] }; }); this.app.put('/api/subagent-window-states', async (req) => { const states = req.body as Record; try { const dir = dirname(windowStatesPath); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); } writeFileSync(windowStatesPath, JSON.stringify(states, null, 2)); return { success: true }; } catch (err) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); } }); // ============ Screen Management Endpoints ============ // Get all tracked screens with stats this.app.get('/api/screens', async () => { const screens = await this.screenManager.getScreensWithStats(); return { screens, screenAvailable: ScreenManager.isScreenAvailable() }; }); // Kill a screen session this.app.delete('/api/screens/:sessionId', async (req) => { const { sessionId } = req.params as { sessionId: string }; const success = await this.screenManager.killScreen(sessionId); return { success }; }); // Reconcile screens (find dead ones) this.app.post('/api/screens/reconcile', async () => { const result = await this.screenManager.reconcileScreens(); return result; }); // Start stats collection this.app.post('/api/screens/stats/start', async () => { this.screenManager.startStatsCollection(STATS_COLLECTION_INTERVAL_MS); return { success: true }; }); // Stop stats collection this.app.post('/api/screens/stats/stop', async () => { this.screenManager.stopStatsCollection(); return { success: true }; }); // System stats endpoint for frontend header display this.app.get('/api/system/stats', async () => { return this.getSystemStats(); }); // ========== Spawn1337 Agent Protocol Endpoints ========== this.app.get('/api/spawn/agents', async () => { return { success: true, data: this.spawnOrchestrator.getAllAgentStatuses() }; }); this.app.get('/api/spawn/agents/:agentId', async (req) => { const { agentId } = req.params as { agentId: string }; const status = this.spawnOrchestrator.getAgentStatus(agentId); if (!status) { return createErrorResponse(ApiErrorCode.NOT_FOUND, `Agent ${agentId} not found`); } return { success: true, data: status }; }); this.app.get('/api/spawn/agents/:agentId/result', async (req) => { const { agentId } = req.params as { agentId: string }; const result = this.spawnOrchestrator.readAgentResult(agentId); if (!result) { return createErrorResponse(ApiErrorCode.NOT_FOUND, `No result found for agent ${agentId}`); } return { success: true, data: result }; }); this.app.get('/api/spawn/agents/:agentId/progress', async (req) => { const { agentId } = req.params as { agentId: string }; const progress = this.spawnOrchestrator.readAgentProgress(agentId); return { success: true, data: progress }; }); this.app.get('/api/spawn/agents/:agentId/messages', async (req) => { const { agentId } = req.params as { agentId: string }; const messages = this.spawnOrchestrator.readAgentMessages(agentId); return { success: true, data: messages }; }); this.app.post('/api/spawn/agents/:agentId/message', async (req) => { const { agentId } = req.params as { agentId: string }; const { content } = req.body as { content: string }; if (!content) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Message content is required'); } await this.spawnOrchestrator.sendMessageToAgent(agentId, content); return { success: true }; }); this.app.post('/api/spawn/agents/:agentId/cancel', async (req) => { const { agentId } = req.params as { agentId: string }; const { reason } = (req.body as { reason?: string }) || {}; await this.spawnOrchestrator.cancelAgent(agentId, reason || 'Cancelled via API'); return { success: true }; }); this.app.delete('/api/spawn/agents/:agentId', async (req) => { const { agentId } = req.params as { agentId: string }; await this.spawnOrchestrator.cancelAgent(agentId, 'Force killed via API'); return { success: true }; }); this.app.get('/api/spawn/status', async () => { return { success: true, data: this.spawnOrchestrator.getState() }; }); this.app.put('/api/spawn/config', async (req) => { const config = req.body as Partial; this.spawnOrchestrator.updateConfig(config); return { success: true, data: this.spawnOrchestrator.config }; }); this.app.post('/api/spawn/trigger', async (req) => { const { taskContent, parentSessionId, parentWorkingDir } = req.body as { taskContent: string; parentSessionId: string; parentWorkingDir?: string; }; if (!taskContent || !parentSessionId) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'taskContent and parentSessionId are required'); } const session = this.sessions.get(parentSessionId); const workingDir = parentWorkingDir || session?.workingDir || process.cwd(); const agentId = await this.spawnOrchestrator.triggerSpawn(taskContent, parentSessionId, workingDir); if (!agentId) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Failed to parse task spec'); } return { success: true, data: { agentId } }; }); // ========== 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 } }; }); // ========== Hook Events ========== this.app.post('/api/hook-event', async (req) => { const { event, sessionId, data } = req.body as HookEventRequest; const validEvents = ['idle_prompt', 'permission_prompt', 'elicitation_dialog', 'stop'] as const; if (!event || !validEvents.includes(event as typeof validEvents[number])) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid event type'); } if (!sessionId || !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 if (data && typeof data === 'object' && 'transcript_path' in data) { const transcriptPath = String(data.transcript_path); if (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 }); // Track in run summary const summaryTracker = this.runSummaryTrackers.get(sessionId); if (summaryTracker) { summaryTracker.recordHookEvent(event, safeData); } return { success: true }; }); } /** * 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); } } /** Persists full session state including respawn config to state.json */ private persistSessionState(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 screen 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.screenManager.updateRespawnConfig(sessionId, persistedConfig); } // Get system CPU and memory usage private getSystemStats(): { cpu: number; memory: { usedMB: number; totalMB: number; percent: number } } { try { // Memory stats const totalMem = totalmem(); const freeMem = freemem(); const usedMem = totalMem - freeMem; // CPU load average (1 min) as percentage (rough approximation) const load = loadavg()[0]; const cpuCount = cpus().length; 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, killScreen: boolean = true): Promise { // Guard against concurrent cleanup of the same session if (this.cleaningUp.has(sessionId)) return; this.cleaningUp.add(sessionId); try { await this._doCleanupSession(sessionId, killScreen); } finally { this.cleaningUp.delete(sessionId); } } private async _doCleanupSession(sessionId: string, killScreen: boolean): Promise { const session = this.sessions.get(sessionId); // Kill all subagents spawned by this session if (session && killScreen) { try { await subagentWatcher.killSubagentsForSession(session.workingDir); } 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 and pending state updates this.terminalBatches.delete(sessionId); this.outputBatches.delete(sessionId); this.taskUpdateBatches.delete(sessionId); this.stateUpdatePending.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 (killScreen && (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}`); } session.removeAllListeners(); // Close any active file streams for this session fileStreamManager.closeSessionStreams(sessionId); await session.stop(killScreen); this.sessions.delete(sessionId); // Only remove from state.json if we're also killing the screen. // When killScreen=false (server shutdown), preserve state for recovery. if (killScreen) { this.store.removeSession(sessionId); } } this.broadcast('session:deleted', { id: sessionId }); } private setupSessionListeners(session: Session): void { // 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); session.on('output', (data) => { // Use batching for better performance at high throughput this.batchOutputData(session.id, data); }); session.on('terminal', (data) => { // Use batching for better performance at high throughput this.batchTerminalData(session.id, data); }); session.on('clearTerminal', () => { // Tell clients to clear their terminal (after screen attach) this.broadcast('session:clearTerminal', { id: session.id }); }); session.on('message', (msg: ClaudeMessage) => { this.broadcast('session:message', { id: session.id, message: msg }); }); session.on('error', (error) => { this.broadcast('session:error', { id: session.id, error }); // Track in run summary const tracker = this.runSummaryTrackers.get(session.id); if (tracker) tracker.recordError('Session error', String(error)); }); session.on('completion', (result, cost) => { this.broadcast('session:completion', { id: session.id, result, cost }); this.broadcast('session:updated', session.toDetailedState()); 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); }); session.on('exit', (code) => { // Wrap in try/catch to ensure cleanup always happens try { this.broadcast('session:exit', { id: session.id, code }); this.broadcast('session:updated', session.toDetailedState()); 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); } }); session.on('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); } }); session.on('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 session.on('taskCreated', (task: BackgroundTask) => { this.broadcast('task:created', { sessionId: session.id, task }); this.broadcastSessionStateDebounced(session.id); }); session.on('taskUpdated', (task: BackgroundTask) => { // Use batching for better performance at high update rates this.batchTaskUpdate(session.id, task); }); session.on('taskCompleted', (task: BackgroundTask) => { this.broadcast('task:completed', { sessionId: session.id, task }); this.broadcastSessionStateDebounced(session.id); }); session.on('taskFailed', (task: BackgroundTask, error: string) => { this.broadcast('task:failed', { sessionId: session.id, task, error }); this.broadcastSessionStateDebounced(session.id); }); session.on('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); }); session.on('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); }); // Ralph tracking events session.on('ralphLoopUpdate', (state: RalphTrackerState) => { this.broadcast('session:ralphLoopUpdate', { sessionId: session.id, state }); // Persist Ralph state this.store.updateRalphState(session.id, { loop: state }); }); session.on('ralphTodoUpdate', (todos: RalphTodoItem[]) => { this.broadcast('session:ralphTodoUpdate', { sessionId: session.id, todos }); // Persist Ralph state this.store.updateRalphState(session.id, { todos }); }); session.on('ralphCompletionDetected', (phrase: string) => { this.broadcast('session:ralphCompletionDetected', { sessionId: session.id, phrase }); // Track in run summary const tracker = this.runSummaryTrackers.get(session.id); if (tracker) tracker.recordRalphCompletion(phrase); }); // RALPH_STATUS block events session.on('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}` ); } }); session.on('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 ); } }); session.on('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) session.on('bashToolStart', (tool: ActiveBashTool) => { this.broadcast('session:bashToolStart', { sessionId: session.id, tool }); }); session.on('bashToolEnd', (tool: ActiveBashTool) => { this.broadcast('session:bashToolEnd', { sessionId: session.id, tool }); }); session.on('bashToolsUpdate', (tools: ActiveBashTool[]) => { this.broadcast('session:bashToolsUpdate', { sessionId: session.id, tools }); }); } private setupRespawnListeners(sessionId: string, controller: RespawnController): void { // 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 }); // 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 setupSpawnOrchestratorListeners(): void { const sessionCreator: SessionCreator = { createAgentSession: async (workingDir: string, name: string) => { const globalNice = this.getGlobalNiceConfig(); const session = new Session({ workingDir, screenManager: this.screenManager, useScreen: true, mode: 'claude', name: `spawn:${name}`, niceConfig: globalNice, }); this.sessions.set(session.id, session); this.store.incrementSessionsCreated(); this.setupSessionListeners(session); session.parentAgentId = name; await session.startInteractive(); this.broadcast('session:created', session.toDetailedState()); this.broadcast('session:interactive', { id: session.id }); this.persistSessionState(session); // Configure ralph tracker for completion detection session.ralphTracker.enable(); return { sessionId: session.id }; }, writeToSession: (sessionId: string, data: string) => { const session = this.sessions.get(sessionId); if (session) { session.writeViaScreen(data); } }, getSessionTokens: (sessionId: string) => { const session = this.sessions.get(sessionId); return session ? session.totalTokens : 0; }, getSessionCost: (sessionId: string) => { const session = this.sessions.get(sessionId); return session ? session.totalCost : 0; }, stopSession: async (sessionId: string) => { // Use cleanupSession to properly clean up all resources (respawn controllers, // run summary trackers, file streams, Ralph state, etc.) await this.cleanupSession(sessionId); }, onSessionCompletion: (sessionId: string, handler: (phrase: string) => void) => { const session = this.sessions.get(sessionId); if (session) { session.on('ralphCompletionDetected', handler); } }, removeSessionCompletionHandler: (sessionId: string, handler: (phrase: string) => void) => { const session = this.sessions.get(sessionId); if (session) { session.off('ralphCompletionDetected', handler); } }, }; this.spawnOrchestrator.setSessionCreator(sessionCreator); // Forward orchestrator events as SSE broadcasts this.spawnOrchestrator.on('queued', (data) => this.broadcast('spawn:queued', data)); this.spawnOrchestrator.on('initializing', (data) => this.broadcast('spawn:initializing', data)); this.spawnOrchestrator.on('started', (data) => this.broadcast('spawn:started', data)); this.spawnOrchestrator.on('progress', (data) => this.broadcast('spawn:progress', data)); this.spawnOrchestrator.on('message', (data) => this.broadcast('spawn:message', data)); this.spawnOrchestrator.on('completed', (data) => this.broadcast('spawn:completed', data)); this.spawnOrchestrator.on('failed', (data) => this.broadcast('spawn:failed', data)); this.spawnOrchestrator.on('timeout', (data) => this.broadcast('spawn:timeout', data)); this.spawnOrchestrator.on('cancelled', (data) => this.broadcast('spawn:cancelled', data)); this.spawnOrchestrator.on('budgetWarning', (data) => this.broadcast('spawn:budgetWarning', data)); this.spawnOrchestrator.on('stateUpdate', (data) => this.broadcast('spawn:stateUpdate', data)); } 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(); 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 'screens.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); // 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 getDefaultClaudeMdPath(): string | undefined { const settingsPath = join(homedir(), '.claudeman', 'settings.json'); try { if (existsSync(settingsPath)) { const content = readFileSync(settingsPath, 'utf-8'); const settings = JSON.parse(content); if (settings.defaultClaudeMdPath) { return settings.defaultClaudeMdPath; } } } catch (err) { console.error('Failed to read settings:', err); } return undefined; } // Helper to get global Nice priority config from settings private getGlobalNiceConfig(): NiceConfig | undefined { const settingsPath = join(homedir(), '.claudeman', 'settings.json'); try { if (existsSync(settingsPath)) { const content = readFileSync(settingsPath, 'utf-8'); const settings = JSON.parse(content); if (settings.nice && settings.nice.enabled) { return { enabled: settings.nice.enabled ?? false, niceValue: settings.nice.niceValue ?? DEFAULT_NICE_CONFIG.niceValue, }; } } } catch (err) { console.error('Failed to read Nice priority settings:', err); } return undefined; } /** * Write .mcp.json to a case directory for Claude Code to discover spawn MCP tools. */ private writeMcpConfig(casePath: string): void { const projectRoot = join(__dirname, '..', '..'); const mcpServerPath = join(projectRoot, 'dist', 'mcp-server.js'); const mcpConfig = { mcpServers: { 'claudeman-spawn': { command: 'node', args: [mcpServerPath], }, }, }; writeFileSync(join(casePath, '.mcp.json'), JSON.stringify(mcpConfig, null, 2) + '\n'); } 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 this.runScheduledLoop(id); 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); 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); 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); } 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); run.sessionId = null; } this.broadcast('scheduled:stopped', run); } private getSessionsState() { return Array.from(this.sessions.values()).map(s => { const controller = this.respawnControllers.get(s.id); return { ...s.toDetailedState(), 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() { return Array.from(this.sessions.values()).map(s => { const controller = this.respawnControllers.get(s.id); const detailed = s.toDetailedState(); return { ...detailed, // Exclude full buffers - they're fetched on-demand terminalBuffer: '', textOutput: '', respawnEnabled: controller?.getConfig()?.enabled ?? false, respawnConfig: controller?.getConfig() ?? null, respawn: controller?.getStatus() ?? null, }; }); } // 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()); return this.store.cleanupStaleSessions(activeSessionIds); } private getFullState() { // Build respawn status map const respawnStatus: Record> = {}; for (const [sessionId, controller] of this.respawnControllers) { respawnStatus[sessionId] = controller.getStatus(); } // Build active sessions token map for aggregate calculation const activeSessionTokens: Record = {}; for (const [sessionId, session] of this.sessions) { activeSessionTokens[sessionId] = { inputTokens: session.inputTokens, outputTokens: session.outputTokens, totalCost: session.totalCost, }; } return { version: APP_VERSION, sessions: this.getSessionsState(), scheduledRuns: Array.from(this.scheduledRuns.values()), respawnStatus, globalStats: this.store.getAggregateStats(activeSessionTokens), subagents: subagentWatcher.getRecentSubagents(60), // Last hour of subagent activity timestamp: Date.now(), }; } /** * Get lightweight state for SSE init - excludes full terminal buffers * to prevent browser freezes. Terminal buffers are fetched on-demand. */ private getLightState() { 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, }; } return { version: APP_VERSION, sessions: this.getLightSessionsState(), scheduledRuns: Array.from(this.scheduledRuns.values()), respawnStatus, globalStats: this.store.getAggregateStats(activeSessionTokens), subagents: subagentWatcher.getRecentSubagents(60), timestamp: Date.now(), }; } 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 private sendSSEPreformatted(reply: FastifyReply, message: string): void { try { reply.raw.write(message); } catch { this.sseClients.delete(reply); } } private broadcast(event: string, data: unknown): void { // 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) // Flushes immediately if batch > 1KB for snappier response to large outputs private batchTerminalData(sessionId: string, data: string): void { // Skip if server is stopping if (this._isStopping) return; const existing = this.terminalBatches.get(sessionId) || ''; const newBatch = existing + data; this.terminalBatches.set(sessionId, newBatch); // Flush immediately if batch is large for responsiveness if (newBatch.length > BATCH_FLUSH_THRESHOLD) { if (this.terminalBatchTimer) { clearTimeout(this.terminalBatchTimer); this.terminalBatchTimer = null; } this.flushTerminalBatches(); return; } // Start batch timer if not already running (16ms = 60fps) if (!this.terminalBatchTimer) { this.terminalBatchTimer = setTimeout(() => { this.flushTerminalBatches(); this.terminalBatchTimer = null; }, TERMINAL_BATCH_INTERVAL); } } private flushTerminalBatches(): void { for (const [sessionId, data] of this.terminalBatches) { if (data.length > 0) { // 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; this.broadcast('session:terminal', { id: sessionId, data: syncData }); } } this.terminalBatches.clear(); } // Batch session:output events at 50ms for better performance private batchOutputData(sessionId: string, data: string): void { // Skip if server is stopping if (this._isStopping) return; const existing = this.outputBatches.get(sessionId) || ''; this.outputBatches.set(sessionId, existing + data); if (!this.outputBatchTimer) { this.outputBatchTimer = setTimeout(() => { this.flushOutputBatches(); this.outputBatchTimer = null; }, OUTPUT_BATCH_INTERVAL); } } private flushOutputBatches(): void { for (const [sessionId, data] of this.outputBatches) { if (data.length > 0) { this.broadcast('session:output', { id: sessionId, data }); } } this.outputBatches.clear(); } // Batch task:updated events at 100ms - only send latest update per session private batchTaskUpdate(sessionId: string, task: BackgroundTask): void { // Skip if server is stopping if (this._isStopping) return; this.taskUpdateBatches.set(sessionId, task); if (!this.taskUpdateBatchTimer) { this.taskUpdateBatchTimer = setTimeout(() => { this.flushTaskUpdateBatches(); this.taskUpdateBatchTimer = null; }, TASK_UPDATE_BATCH_INTERVAL); } } private flushTaskUpdateBatches(): void { 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 { for (const sessionId of this.stateUpdatePending) { const session = this.sessions.get(sessionId); if (session) { // Single expensive serialization per batch interval this.broadcast('session:updated', session.toDetailedState()); } } this.stateUpdatePending.clear(); } /** * 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 || (client.raw as any).connection; 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); } 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(); // Restore screen sessions BEFORE accepting connections // This prevents race conditions where clients connect before state is ready await this.restoreScreenSessions(); // Clean up stale sessions from state file that don't have active screens this.cleanupStaleSessions(); await this.app.listen({ port: this.port, host: '0.0.0.0' }); const protocol = this.https ? 'https' : 'http'; console.log(`Claudeman web interface running at ${protocol}://localhost:${this.port}`); // Set API URL for child processes (MCP server, spawned sessions) process.env.CLAUDEMAN_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 (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'); } } /** * Check if subagent tracking is enabled in settings (default: true) */ private isSubagentTrackingEnabled(): boolean { const settingsPath = join(homedir(), '.claudeman', 'settings.json'); try { if (existsSync(settingsPath)) { const content = readFileSync(settingsPath, 'utf-8'); const settings = JSON.parse(content); // Default to true if not explicitly set return settings.subagentTrackingEnabled ?? true; } } catch (err) { console.error('Failed to read subagent tracking setting:', err); } return true; // Default enabled } private async restoreScreenSessions(): Promise { try { // Reconcile screens to find which ones are still alive (also discovers unknown screens) const { alive, dead, discovered } = await this.screenManager.reconcileScreens(); if (discovered.length > 0) { console.log(`[Server] Discovered ${discovered.length} unknown screen session(s)`); } if (alive.length > 0 || discovered.length > 0) { console.log(`[Server] Found ${alive.length + discovered.length} alive screen session(s) from previous run`); // For each alive screen, create a Session object if it doesn't exist const screens = this.screenManager.getScreens(); for (const screen of screens) { if (!this.sessions.has(screen.sessionId)) { // Create a session object for this screen with the existing screenSession const session = new Session({ id: screen.sessionId, // Preserve the original session ID workingDir: screen.workingDir, mode: screen.mode, name: screen.name || screen.screenName, screenManager: this.screenManager, useScreen: true, screenSession: screen // Pass the existing screen so startInteractive() can attach to it }); // Restore ALL session settings from state.json (single source of truth) const savedState = this.store.getSession(screen.sessionId); 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 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, }); } // Respawn controller if (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 screens.json if state.json didn't have it if (!this.respawnControllers.has(session.id) && screen.respawnConfig?.enabled) { try { this.restoreRespawnController(session, screen.respawnConfig, 'screens.json'); } catch (err) { console.error(`[Server] Failed to restore respawn from screens.json for session ${session.id}:`, err); } } // Fallback: restore Ralph state from state-inner.json if not already set if (!session.ralphTracker.enabled) { const ralphState = this.store.getRalphState(screen.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 if (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); this.setupSessionListeners(session); this.persistSessionState(session); // Mark it as restored (not started yet - user needs to attach) console.log(`[Server] Restored session ${session.id} from screen ${screen.screenName}`); } } // Start stats collection to show screen info this.screenManager.startStatsCollection(STATS_COLLECTION_INTERVAL_MS); } if (dead.length > 0) { console.log(`[Server] Cleaned up ${dead.length} dead screen session(s)`); } } catch (err) { console.error('[Server] Failed to restore screen sessions:', err); } } async stop(): Promise { // 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; } // Clear all SSE clients this.sseClients.clear(); // Clear batch timers if (this.terminalBatchTimer) { clearTimeout(this.terminalBatchTimer); this.terminalBatchTimer = null; } this.terminalBatches.clear(); if (this.outputBatchTimer) { clearTimeout(this.outputBatchTimer); this.outputBatchTimer = null; } this.outputBatches.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 screen manager and flush pending saves this.screenManager.destroy(); // 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 spawn orchestrator and all agents await this.spawnOrchestrator.stopAll(); this.spawnOrchestrator.removeAllListeners(); // Stop all scheduled runs first (they have their own session cleanup) for (const [id] of this.scheduledRuns) { await this.stopScheduledRun(id); } // Properly clean up all remaining sessions (removes listeners, clears state, etc.) // Don't kill screens on server stop - they can be reattached on restart const sessionIds = Array.from(this.sessions.keys()); for (const sessionId of sessionIds) { await this.cleanupSession(sessionId, false); } // Flush state store to prevent data loss from debounced saves this.store.flushAll(); // Stop subagent watcher subagentWatcher.stop(); await this.app.close(); } } export async function startWebServer(port: number = 3000, https: boolean = false): Promise { const server = new WebServer(port, https); await server.start(); return server; }