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 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-02-15 05:26:29 +01:00
co-authored by Claude Opus 4.6
parent 20896ac173
commit f223501164
6 changed files with 251 additions and 105 deletions
+1
View File
@@ -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);
+19 -1
View File
@@ -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<typeof setTimeout> | 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);
}
+28 -25
View File
@@ -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<string | undefined> {
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<string | undefined> {
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<void> {
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<void> {
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<void> {
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<void> {
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
+47 -40
View File
@@ -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) ==========
+113
View File
@@ -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(),
});
+43 -39
View File
@@ -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<RespawnConfig> | undefined;
const body = req.body ? RespawnConfigSchema.parse(req.body) as Partial<RespawnConfig> : 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<RespawnConfig>; 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<RespawnConfig>; 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<ApiResponse<{ case: { name: string; path: string } }>> => {
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<ApiResponse> => {
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<ApiResponse> => {
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<NiceConfig>;
const body = CpuLimitSchema.parse(req.body) as Partial<NiceConfig>;
// Validate inputs
if (body.niceValue !== undefined) {