From f223501164c9776f57773196bf5d2216d531dfee Mon Sep 17 00:00:00 2001 From: arkon Date: Sun, 15 Feb 2026 05:26:29 +0100 Subject: [PATCH] fix: async I/O, map iteration bug, unified cleanup, Zod validation, error handling - Convert sync readFileSync/statSync to async in subagent-watcher.ts to unblock event loop on hot paths (transcript reads, liveness checks) - Fix Map mutation during iteration in closeSessionLogViewerWindows and closeSessionImagePopups (collect IDs first, then iterate to close) - Unify session cleanup into shared _cleanupSessionData() method called from both closeSession() and session:deleted handler to prevent leaks - Add Zod validation schemas for 20+ API routes that used raw type casts - Add consecutive error tracking (5 errors/60s triggers exit for systemd restart) and SIGHUP handler for SSH disconnect safety Co-Authored-By: Claude Opus 4.6 --- src/cli.ts | 1 + src/index.ts | 20 ++++++- src/subagent-watcher.ts | 53 ++++++++++--------- src/web/public/app.js | 87 +++++++++++++++++-------------- src/web/schemas.ts | 113 ++++++++++++++++++++++++++++++++++++++++ src/web/server.ts | 82 +++++++++++++++-------------- 6 files changed, 251 insertions(+), 105 deletions(-) diff --git a/src/cli.ts b/src/cli.ts index 89cf4b57..777b1042 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -525,6 +525,7 @@ program }; process.on('SIGTERM', () => shutdown('SIGTERM')); process.on('SIGINT', () => shutdown('SIGINT')); + process.on('SIGHUP', () => shutdown('SIGHUP')); } catch (err) { console.error(chalk.red(`✗ Failed to start web server: ${getErrorMessage(err)}`)); process.exit(1); diff --git a/src/index.ts b/src/index.ts index ba0a4003..de4c52e1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,12 +14,29 @@ import { program } from './cli.js'; // In web mode, we should NOT exit on transient errors — log and continue const isWebMode = process.argv.includes('web'); +// Track consecutive unhandled errors in web mode — restart after too many +let consecutiveErrors = 0; +const MAX_CONSECUTIVE_ERRORS = 5; +const ERROR_RESET_MS = 60000; // Reset counter after 1 minute of no errors +let errorResetTimer: ReturnType | null = null; + +function trackError(): void { + consecutiveErrors++; + if (errorResetTimer) clearTimeout(errorResetTimer); + errorResetTimer = setTimeout(() => { consecutiveErrors = 0; }, ERROR_RESET_MS); + + if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) { + console.error(`[FATAL] ${MAX_CONSECUTIVE_ERRORS} consecutive unhandled errors — exiting for systemd restart`); + process.exit(1); + } +} + // Handle uncaught errors process.on('uncaughtException', (err) => { console.error('Uncaught exception:', err.message); if (isWebMode) { - // Log full stack trace for debugging but keep the server running console.error('[RECOVERED] Server continuing after uncaught exception:', err.stack); + trackError(); } else { process.exit(1); } @@ -29,6 +46,7 @@ process.on('unhandledRejection', (reason) => { console.error('Unhandled rejection:', reason); if (isWebMode) { console.error('[RECOVERED] Server continuing after unhandled rejection'); + trackError(); } else { process.exit(1); } diff --git a/src/subagent-watcher.ts b/src/subagent-watcher.ts index 3c8a962d..b7559bef 100644 --- a/src/subagent-watcher.ts +++ b/src/subagent-watcher.ts @@ -6,7 +6,7 @@ */ import { EventEmitter } from 'node:events'; -import { watch, statSync, readdirSync, existsSync, readFileSync, FSWatcher } from 'node:fs'; +import { watch, existsSync, FSWatcher } from 'node:fs'; import { createReadStream } from 'node:fs'; import { createInterface } from 'node:readline'; import { homedir } from 'node:os'; @@ -277,8 +277,8 @@ export class SubagentWatcher extends EventEmitter { // Method 2: Check if the transcript file was recently modified // (within the last 60 seconds - gives some buffer for slow operations) try { - const stat = statSync(info.filePath); - const mtime = stat.mtime.getTime(); + const fileStat = await statAsync(info.filePath); + const mtime = fileStat.mtime.getTime(); const now = Date.now(); if (now - mtime < 60000) { return true; @@ -641,7 +641,7 @@ export class SubagentWatcher extends EventEmitter { const entries: SubagentTranscriptEntry[] = []; try { - const content = readFileSync(info.filePath, 'utf8'); + const content = await readFile(info.filePath, 'utf8'); const lines = content.split('\n').filter((l) => l.trim()); for (const line of lines) { @@ -794,17 +794,17 @@ export class SubagentWatcher extends EventEmitter { * We look for this format: * { "type": "user", "toolUseResult": { "agentId": "xxx", "description": "..." } } */ - private extractDescriptionFromParentTranscript( + private async extractDescriptionFromParentTranscript( projectHash: string, sessionId: string, agentId: string - ): string | undefined { + ): Promise { try { // The parent session's transcript is at: ~/.claude/projects/{projectHash}/{sessionId}.jsonl const transcriptPath = join(CLAUDE_PROJECTS_DIR, projectHash, `${sessionId}.jsonl`); - if (!existsSync(transcriptPath)) return undefined; + try { await statAsync(transcriptPath); } catch { return undefined; } - const content = readFileSync(transcriptPath, 'utf8'); + const content = await readFile(transcriptPath, 'utf8'); const lines = content.split('\n').filter((l) => l.trim()); // Look for user entry with toolUseResult containing the agentId @@ -831,9 +831,9 @@ export class SubagentWatcher extends EventEmitter { /** * Extract description from agent file by finding first user message */ - private extractDescriptionFromFile(filePath: string): string | undefined { + private async extractDescriptionFromFile(filePath: string): Promise { try { - const content = readFileSync(filePath, 'utf8'); + const content = await readFile(filePath, 'utf8'); const lines = content.split('\n').filter((l) => l.trim()); for (const line of lines.slice(0, 5)) { @@ -867,7 +867,7 @@ export class SubagentWatcher extends EventEmitter { * Scan for all subagent directories (async to avoid blocking event loop) */ private async scanForSubagents(): Promise { - if (!existsSync(CLAUDE_PROJECTS_DIR)) return; + try { await statAsync(CLAUDE_PROJECTS_DIR); } catch { return; } try { const projects = await readdir(CLAUDE_PROJECTS_DIR); @@ -889,8 +889,11 @@ export class SubagentWatcher extends EventEmitter { if (!sessionStat.isDirectory()) continue; const subagentDir = join(sessionPath, 'subagents'); - if (existsSync(subagentDir)) { - this.watchSubagentDir(subagentDir, project, session); + try { + await statAsync(subagentDir); + await this.watchSubagentDir(subagentDir, project, session); + } catch { + // subagent dir doesn't exist - skip } } catch { // Skip inaccessible session directories @@ -908,16 +911,16 @@ export class SubagentWatcher extends EventEmitter { /** * Watch a subagent directory for new/updated files */ - private watchSubagentDir(dir: string, projectHash: string, sessionId: string): void { + private async watchSubagentDir(dir: string, projectHash: string, sessionId: string): Promise { if (this.knownSubagentDirs.has(dir)) return; this.knownSubagentDirs.add(dir); // Watch existing files (initial scan - skip old files) try { - const files = readdirSync(dir); + const files = await readdir(dir); for (const file of files) { if (file.endsWith('.jsonl')) { - this.watchAgentFile(join(dir, file), projectHash, sessionId, true); + await this.watchAgentFile(join(dir, file), projectHash, sessionId, true); } } } catch { @@ -965,7 +968,7 @@ export class SubagentWatcher extends EventEmitter { * @param sessionId Claude session ID * @param isInitialScan If true, skip files older than STARTUP_MAX_FILE_AGE_MS */ - private watchAgentFile(filePath: string, projectHash: string, sessionId: string, isInitialScan: boolean = false): void { + private async watchAgentFile(filePath: string, projectHash: string, sessionId: string, isInitialScan: boolean = false): Promise { if (this.fileWatchers.has(filePath)) return; const agentId = basename(filePath).replace('agent-', '').replace('.jsonl', ''); @@ -973,7 +976,7 @@ export class SubagentWatcher extends EventEmitter { // Initial info - handle race condition where file may be deleted between discovery and stat let stat; try { - stat = statSync(filePath); + stat = await statAsync(filePath); } catch { // File was deleted between discovery and stat - skip this agent return; @@ -989,11 +992,11 @@ export class SubagentWatcher extends EventEmitter { // Extract description - prefer reading from parent transcript (most reliable) // The parent transcript has the exact Task tool call with description parameter - let description = this.extractDescriptionFromParentTranscript(projectHash, sessionId, agentId); + let description = await this.extractDescriptionFromParentTranscript(projectHash, sessionId, agentId); // Fallback: extract a smart title from the subagent's prompt if parent lookup failed if (!description) { - description = this.extractDescriptionFromFile(filePath); + description = await this.extractDescriptionFromFile(filePath); } // Skip internal Claude Code agents (e.g., suggestion mode) - not real subagents @@ -1038,7 +1041,7 @@ export class SubagentWatcher extends EventEmitter { const existingInfo = this.agentInfo.get(agentId); if (existingInfo) { try { - const newStat = statSync(filePath); + const newStat = await statAsync(filePath); existingInfo.lastActivityAt = Date.now(); existingInfo.fileSize = newStat.size; existingInfo.status = 'active'; @@ -1049,14 +1052,14 @@ export class SubagentWatcher extends EventEmitter { // Retry description extraction if missing (race condition fix) if (!existingInfo.description) { // First try parent transcript (most reliable) - let extractedDescription = this.extractDescriptionFromParentTranscript( + let extractedDescription = await this.extractDescriptionFromParentTranscript( existingInfo.projectHash, existingInfo.sessionId, agentId ); // Fallback to subagent file if (!extractedDescription) { - extractedDescription = this.extractDescriptionFromFile(filePath); + extractedDescription = await this.extractDescriptionFromFile(filePath); } if (extractedDescription) { // Check if this is an internal agent - if so, remove it @@ -1138,7 +1141,7 @@ export class SubagentWatcher extends EventEmitter { /** * Process a transcript entry and emit appropriate events */ - private processEntry(entry: SubagentTranscriptEntry, agentId: string, sessionId: string): void { + private async processEntry(entry: SubagentTranscriptEntry, agentId: string, sessionId: string): Promise { const info = this.agentInfo.get(agentId); // Extract model from assistant messages (first one sets the model) @@ -1161,7 +1164,7 @@ export class SubagentWatcher extends EventEmitter { // Check if this is first user message and description is missing if (info && !info.description && entry.type === 'user' && entry.message?.content) { // First try parent transcript (most reliable) - let description = this.extractDescriptionFromParentTranscript( + let description = await this.extractDescriptionFromParentTranscript( info.projectHash, info.sessionId, agentId diff --git a/src/web/public/app.js b/src/web/public/app.js index 86d1e32e..7fd1e85f 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -2112,33 +2112,7 @@ class ClaudemanApp { addListener('session:deleted', (e) => { const data = JSON.parse(e.data); - this.sessions.delete(data.id); - // Remove from tab order - const orderIndex = this.sessionOrder.indexOf(data.id); - if (orderIndex !== -1) { - this.sessionOrder.splice(orderIndex, 1); - this.saveSessionOrder(); - } - this.terminalBuffers.delete(data.id); - this._inputQueue.delete(data.id); // Clean up queued offline input for this session - this.ralphStates.delete(data.id); // Clean up ralph state for this session - this.ralphClosedSessions.delete(data.id); // Clean up closed tracking for this session - this.projectInsights.delete(data.id); // Clean up project insights for this session - this.closeSessionLogViewerWindows(data.id); // Close log viewer windows for this session - this.closeSessionImagePopups(data.id); // Close image popup windows for this session - this.closeSessionSubagentWindows(data.id, true); // Close subagent windows and cleanup activity data - - // Clean up idle timer for this session - const idleTimer = this.idleTimers.get(data.id); - if (idleTimer) { - clearTimeout(idleTimer); - this.idleTimers.delete(data.id); - } - // Clean up respawn state for this session - delete this.respawnStatus[data.id]; - delete this.respawnTimers[data.id]; - delete this.respawnCountdownTimers[data.id]; - delete this.respawnActionLogs[data.id]; + this._cleanupSessionData(data.id); if (this.activeSessionId === data.id) { this.activeSessionId = null; try { localStorage.removeItem('claudeman-active-session'); } catch {} @@ -3969,20 +3943,45 @@ class ClaudemanApp { } } + // Shared cleanup for all session data — called from both closeSession() and session:deleted handler + _cleanupSessionData(sessionId) { + this.sessions.delete(sessionId); + // Remove from tab order + const orderIndex = this.sessionOrder.indexOf(sessionId); + if (orderIndex !== -1) { + this.sessionOrder.splice(orderIndex, 1); + this.saveSessionOrder(); + } + this.terminalBuffers.delete(sessionId); + this.terminalBufferCache.delete(sessionId); + this._inputQueue.delete(sessionId); + this.ralphStates.delete(sessionId); + this.ralphClosedSessions.delete(sessionId); + this.projectInsights.delete(sessionId); + this.pendingHooks.delete(sessionId); + this.tabAlerts.delete(sessionId); + this.clearCountdownTimers(sessionId); + this.closeSessionLogViewerWindows(sessionId); + this.closeSessionImagePopups(sessionId); + this.closeSessionSubagentWindows(sessionId, true); + + // Clean up idle timer + const idleTimer = this.idleTimers.get(sessionId); + if (idleTimer) { + clearTimeout(idleTimer); + this.idleTimers.delete(sessionId); + } + // Clean up respawn state + delete this.respawnStatus[sessionId]; + delete this.respawnTimers[sessionId]; + delete this.respawnCountdownTimers[sessionId]; + delete this.respawnActionLogs[sessionId]; + } + async closeSession(sessionId, killScreen = true) { try { await fetch(`/api/sessions/${sessionId}?killScreen=${killScreen}`, { method: 'DELETE' }); - this.sessions.delete(sessionId); - // Remove from tab order - const orderIndex = this.sessionOrder.indexOf(sessionId); - if (orderIndex !== -1) { - this.sessionOrder.splice(orderIndex, 1); - this.saveSessionOrder(); - } - this.terminalBuffers.delete(sessionId); - this.terminalBufferCache.delete(sessionId); - this.ralphStates.delete(sessionId); - this.clearCountdownTimers(sessionId); + this._cleanupSessionData(sessionId); if (this.activeSessionId === sessionId) { this.activeSessionId = null; @@ -13937,11 +13936,15 @@ class ClaudemanApp { // Close all log viewer windows for a session closeSessionLogViewerWindows(sessionId) { + const toClose = []; for (const [windowId, data] of this.logViewerWindows) { if (data.sessionId === sessionId) { - this.closeLogViewerWindow(windowId); + toClose.push(windowId); } } + for (const windowId of toClose) { + this.closeLogViewerWindow(windowId); + } } // ========== Image Popup Windows (Auto-popup for Screenshots) ========== @@ -14067,11 +14070,15 @@ class ClaudemanApp { * Close all image popups for a session. */ closeSessionImagePopups(sessionId) { + const toClose = []; for (const [imageId, data] of this.imagePopups) { if (data.sessionId === sessionId) { - this.closeImagePopup(imageId); + toClose.push(imageId); } } + for (const imageId of toClose) { + this.closeImagePopup(imageId); + } } // ========== Mux Sessions (in Monitor Panel) ========== diff --git a/src/web/schemas.ts b/src/web/schemas.ts index ea5fa03b..07fa1ea1 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -140,3 +140,116 @@ export const SessionInputWithLimitSchema = z.object({ input: z.string().max(100000), // 100KB max input useScreen: z.boolean().optional(), }); + +// ========== Session Mutation Routes ========== + +/** PUT /api/sessions/:id/name */ +export const SessionNameSchema = z.object({ + name: z.string().min(0).max(128), +}); + +/** PUT /api/sessions/:id/color */ +export const SessionColorSchema = z.object({ + color: z.string().max(30), +}); + +/** POST /api/sessions/:id/ralph-config */ +export const RalphConfigSchema = z.object({ + enabled: z.boolean().optional(), + completionPhrase: z.string().max(500).optional(), + maxIterations: z.number().int().min(0).max(10000).optional(), + reset: z.boolean().optional(), + disableAutoEnable: z.boolean().optional(), +}); + +/** POST /api/sessions/:id/fix-plan/import */ +export const FixPlanImportSchema = z.object({ + content: z.string().max(500000), +}); + +/** POST /api/sessions/:id/ralph-prompt/write */ +export const RalphPromptWriteSchema = z.object({ + content: z.string().max(500000), +}); + +/** POST /api/sessions/:id/auto-clear */ +export const AutoClearSchema = z.object({ + enabled: z.boolean(), + threshold: z.number().int().min(0).max(1000000).optional(), +}); + +/** POST /api/sessions/:id/auto-compact */ +export const AutoCompactSchema = z.object({ + enabled: z.boolean(), + threshold: z.number().int().min(0).max(1000000).optional(), + prompt: z.string().max(10000).optional(), +}); + +/** POST /api/sessions/:id/image-watcher */ +export const ImageWatcherSchema = z.object({ + enabled: z.boolean(), +}); + +/** POST /api/sessions/:id/flicker-filter */ +export const FlickerFilterSchema = z.object({ + enabled: z.boolean(), +}); + +/** POST /api/run */ +export const QuickRunSchema = z.object({ + prompt: z.string().min(1).max(100000), + workingDir: z.string().max(1000).optional(), +}); + +/** POST /api/scheduled */ +export const ScheduledRunSchema = z.object({ + prompt: z.string().min(1).max(100000), + workingDir: z.string().max(1000).optional(), + durationMinutes: z.number().int().min(1).max(14400).optional(), +}); + +/** POST /api/cases/link */ +export const LinkCaseSchema = z.object({ + name: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format'), + path: z.string().min(1).max(1000), +}); + +/** POST /api/generate-plan */ +export const GeneratePlanSchema = z.object({ + taskDescription: z.string().min(1).max(100000), + detailLevel: z.enum(['brief', 'standard', 'detailed']).optional(), +}); + +/** POST /api/generate-plan-detailed */ +export const GeneratePlanDetailedSchema = z.object({ + taskDescription: z.string().min(1).max(100000), + caseName: z.string().max(200).optional(), +}); + +/** POST /api/cancel-plan-generation */ +export const CancelPlanSchema = z.object({ + orchestratorId: z.string().max(200).optional(), +}); + +/** PATCH /api/sessions/:id/plan/task/:taskId */ +export const PlanTaskUpdateSchema = z.object({ + status: z.enum(['pending', 'in_progress', 'completed', 'failed', 'blocked']).optional(), + error: z.string().max(10000).optional(), + incrementAttempts: z.boolean().optional(), +}); + +/** POST /api/sessions/:id/plan/task (add task) */ +export const PlanTaskAddSchema = z.object({ + content: z.string().min(1).max(10000), + priority: z.enum(['P0', 'P1', 'P2']).optional(), + verificationCriteria: z.string().max(10000).optional(), + dependencies: z.array(z.string().max(200)).optional(), + insertAfter: z.string().max(200).optional(), +}); + +/** POST /api/sessions/:id/cpu-limit */ +export const CpuLimitSchema = z.object({ + cpuLimit: z.number().int().min(0).max(100).optional(), + ioClass: z.enum(['idle', 'best-effort', 'realtime']).optional(), + ioLevel: z.number().int().min(0).max(7).optional(), +}); diff --git a/src/web/server.ts b/src/web/server.ts index 0288c6f3..9edaa67b 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -44,8 +44,6 @@ import { getErrorMessage, ApiErrorCode, createErrorResponse, - type CreateScheduledRunRequest, - type QuickRunRequest, type ApiResponse, type SessionResponse, type QuickStartResponse, @@ -65,6 +63,24 @@ import { HookEventSchema, ConfigUpdateSchema, RespawnConfigSchema, + SessionNameSchema, + SessionColorSchema, + RalphConfigSchema, + FixPlanImportSchema, + RalphPromptWriteSchema, + AutoClearSchema, + AutoCompactSchema, + ImageWatcherSchema, + FlickerFilterSchema, + QuickRunSchema, + ScheduledRunSchema, + LinkCaseSchema, + GeneratePlanSchema, + GeneratePlanDetailedSchema, + CancelPlanSchema, + PlanTaskUpdateSchema, + PlanTaskAddSchema, + CpuLimitSchema, } from './schemas.js'; import { StaleExpirationMap } from '../utils/index.js'; @@ -706,7 +722,7 @@ export class WebServer extends EventEmitter { // 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 body = SessionNameSchema.parse(req.body); const session = this.sessions.get(id); if (!session) { @@ -725,7 +741,7 @@ export class WebServer extends EventEmitter { // Set session color this.app.put('/api/sessions/:id/color', async (req) => { const { id } = req.params as { id: string }; - const body = req.body as { color: string }; + const body = SessionColorSchema.parse(req.body); const session = this.sessions.get(id); if (!session) { @@ -1184,7 +1200,7 @@ export class WebServer extends EventEmitter { // 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 { + const { enabled, completionPhrase, maxIterations, reset, disableAutoEnable } = RalphConfigSchema.parse(req.body) as { enabled?: boolean; completionPhrase?: string; maxIterations?: number; @@ -1307,7 +1323,7 @@ export class WebServer extends EventEmitter { // Import todos from @fix_plan.md content this.app.post('/api/sessions/:id/fix-plan/import', async (req) => { const { id } = req.params as { id: string }; - const { content } = req.body as { content: string }; + const { content } = FixPlanImportSchema.parse(req.body); const session = this.sessions.get(id); if (!session) { @@ -1402,7 +1418,7 @@ export class WebServer extends EventEmitter { // This avoids screen input escaping issues with long multi-line prompts this.app.post('/api/sessions/:id/ralph-prompt/write', async (req) => { const { id } = req.params as { id: string }; - const { content } = req.body as { content: string }; + const { content } = RalphPromptWriteSchema.parse(req.body); const session = this.sessions.get(id); if (!session) { @@ -1665,7 +1681,7 @@ export class WebServer extends EventEmitter { // 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 body = req.body ? RespawnConfigSchema.parse(req.body) as Partial : undefined; const session = this.sessions.get(id); if (!session) { @@ -1792,6 +1808,8 @@ export class WebServer extends EventEmitter { 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; + // Validate respawn config if present + if (body?.respawnConfig) RespawnConfigSchema.parse(body.respawnConfig); const session = this.sessions.get(id); if (!session) { @@ -1848,6 +1866,8 @@ export class WebServer extends EventEmitter { 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; + // Validate respawn config if present + if (body?.config) RespawnConfigSchema.parse(body.config); const session = this.sessions.get(id); if (!session) { @@ -1894,7 +1914,7 @@ export class WebServer extends EventEmitter { // 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 body = AutoClearSchema.parse(req.body); const session = this.sessions.get(id); if (!session) { @@ -1927,7 +1947,7 @@ export class WebServer extends EventEmitter { // 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 body = AutoCompactSchema.parse(req.body); const session = this.sessions.get(id); if (!session) { @@ -1961,7 +1981,7 @@ export class WebServer extends EventEmitter { // Toggle image watcher for a session this.app.post('/api/sessions/:id/image-watcher', async (req) => { const { id } = req.params as { id: string }; - const body = req.body as { enabled: boolean }; + const body = ImageWatcherSchema.parse(req.body); const session = this.sessions.get(id); if (!session) { @@ -1993,7 +2013,7 @@ export class WebServer extends EventEmitter { // Toggle flicker filter for a session this.app.post('/api/sessions/:id/flicker-filter', async (req) => { const { id } = req.params as { id: string }; - const body = req.body as { enabled: boolean }; + const body = FlickerFilterSchema.parse(req.body); const session = this.sessions.get(id); if (!session) { @@ -2023,9 +2043,9 @@ export class WebServer extends EventEmitter { return createErrorResponse(ApiErrorCode.SESSION_BUSY, `Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached`); } - const { prompt, workingDir } = req.body as QuickRunRequest; + const { prompt, workingDir } = QuickRunSchema.parse(req.body); - if (!prompt || typeof prompt !== 'string' || prompt.trim() === '') { + if (!prompt.trim()) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'prompt is required'); } const dir = workingDir || process.cwd(); @@ -2056,9 +2076,9 @@ export class WebServer extends EventEmitter { }); this.app.post('/api/scheduled', async (req): Promise<{ success: boolean; run: ScheduledRun }> => { - const { prompt, workingDir, durationMinutes } = req.body as CreateScheduledRunRequest; + const { prompt, workingDir, durationMinutes } = ScheduledRunSchema.parse(req.body); - const run = await this.startScheduledRun(prompt, workingDir || process.cwd(), durationMinutes); + const run = await this.startScheduledRun(prompt, workingDir || process.cwd(), durationMinutes ?? 60); return { success: true, run }; }); @@ -2171,7 +2191,7 @@ export class WebServer extends EventEmitter { // Link an existing folder as a case this.app.post('/api/cases/link', async (req): Promise> => { - const { name, path: folderPath } = req.body as { name: string; path: string }; + const { name, path: folderPath } = LinkCaseSchema.parse(req.body); if (!name || !/^[a-zA-Z0-9_-]+$/.test(name)) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name. Use only letters, numbers, hyphens, underscores.'); @@ -2486,12 +2506,6 @@ export class WebServer extends EventEmitter { } }); - // Generate implementation plan from task description using Claude - interface GeneratePlanRequest { - taskDescription: string; - detailLevel?: 'brief' | 'standard' | 'detailed'; - } - // Use enhanced PlanItem from orchestrator (has verification, dependencies, tracking) type PlanItem = import('../plan-orchestrator.js').PlanItem; @@ -2499,7 +2513,7 @@ export class WebServer extends EventEmitter { const { taskDescription, detailLevel = 'standard' - } = req.body as GeneratePlanRequest; + } = GeneratePlanSchema.parse(req.body); if (!taskDescription || typeof taskDescription !== 'string') { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Task description is required'); @@ -2688,7 +2702,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; // Generate detailed implementation plan using subagent orchestration // This spawns multiple specialist subagents in parallel for thorough analysis this.app.post('/api/generate-plan-detailed', async (req): Promise => { - const { taskDescription, caseName } = req.body as { taskDescription: string; caseName?: string }; + const { taskDescription, caseName } = GeneratePlanDetailedSchema.parse(req.body); if (!taskDescription || typeof taskDescription !== 'string') { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Task description is required'); @@ -2792,7 +2806,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; // Cancel active plan generation this.app.post('/api/cancel-plan-generation', async (req): Promise => { - const { orchestratorId } = req.body as { orchestratorId?: string }; + const { orchestratorId } = CancelPlanSchema.parse(req.body); // If specific orchestrator ID provided, cancel just that one if (orchestratorId) { @@ -2948,7 +2962,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Ralph tracker not available'); } - const update = req.body as { + const update = PlanTaskUpdateSchema.parse(req.body) as { status?: 'pending' | 'in_progress' | 'completed' | 'failed' | 'blocked'; error?: string; incrementAttempts?: boolean; @@ -3032,17 +3046,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Ralph tracker not available'); } - const task = req.body as { - content: string; - priority?: 'P0' | 'P1' | 'P2'; - verificationCriteria?: string; - dependencies?: string[]; - insertAfter?: string; // Task ID to insert after - }; - - if (!task.content) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Task content is required'); - } + const task = PlanTaskAddSchema.parse(req.body); const result = tracker.addPlanTask(task); this.broadcast('session:planTaskAdded', { sessionId: id, task: result.task }); @@ -3130,7 +3134,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'); } - const body = req.body as Partial; + const body = CpuLimitSchema.parse(req.body) as Partial; // Validate inputs if (body.niceValue !== undefined) {