/** * @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 { join, dirname, resolve } from 'node:path'; import { fileURLToPath } from 'node:url'; import { existsSync, mkdirSync, writeFileSync, readdirSync, readFileSync } from 'node:fs'; 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, } 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()); // 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 session = new Session({ workingDir, mode: body.mode || 'claude', name: body.name || '', screenManager: this.screenManager, useScreen: true }); 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, } }; }); // 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, } }; }); // 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 this.app.post('/api/sessions/:id/input', async (req): Promise => { const { id } = req.params as { id: string }; const { input } = req.body as SessionInputRequest; 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)`); } session.write(inputStr); return { success: true }; }); // Resize session terminal this.app.post('/api/sessions/:id/resize', async (req): Promise => { const { id } = req.params as { id: string }; const { 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')), }; }); // 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 const session = new Session({ workingDir: casePath, screenManager: this.screenManager, useScreen: true, mode: mode, }); // 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) }; } }); // ============ 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)); } }); // ============ 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 session = new Session({ workingDir, screenManager: this.screenManager, useScreen: true, mode: 'claude', name: `spawn:${name}`, }); 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; } /** * 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)`); } } 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(); 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'})`); } // 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; }