chore: bump version to 0.1477

Location: Ko Samui

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-02-04 07:24:32 +01:00
co-authored by Claude Opus 4.5
parent 99fa04df29
commit 8f0bbf33d6
27 changed files with 1098 additions and 448 deletions
+231 -109
View File
@@ -12,7 +12,7 @@
import Fastify, { FastifyInstance, FastifyReply } from 'fastify';
import fastifyStatic from '@fastify/static';
import path, { join, dirname, resolve } from 'node:path';
import path, { join, dirname, resolve, relative, isAbsolute } from 'node:path';
import { fileURLToPath } from 'node:url';
import { existsSync, mkdirSync, writeFileSync, readdirSync, readFileSync, rmSync } from 'node:fs';
import fs from 'node:fs/promises';
@@ -42,15 +42,8 @@ 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,
@@ -60,6 +53,18 @@ import {
type ImageDetectedEvent,
DEFAULT_NICE_CONFIG,
} from '../types.js';
import {
CreateSessionSchema,
RunPromptSchema,
SessionInputSchema,
ResizeSchema,
CreateCaseSchema,
QuickStartSchema,
HookEventSchema,
ConfigUpdateSchema,
RespawnConfigSchema,
} from './schemas.js';
import { StaleExpirationMap } from '../utils/index.js';
const __dirname = dirname(fileURLToPath(import.meta.url));
@@ -317,15 +322,20 @@ export class WebServer extends EventEmitter {
// Terminal batching for performance
private terminalBatches: Map<string, string> = new Map();
private terminalBatchTimer: NodeJS.Timeout | null = null;
// Adaptive batching: track rapid events to extend batch window
private lastTerminalEventTime: Map<string, number> = new Map();
private adaptiveBatchInterval: number = TERMINAL_BATCH_INTERVAL;
// Adaptive batching: track rapid events to extend batch window (per-session)
// StaleExpirationMap auto-cleans entries for sessions that stop generating output
private lastTerminalEventTime: StaleExpirationMap<string, number> = new StaleExpirationMap({
ttlMs: 5 * 60 * 1000, // 5 minutes - auto-expire stale session timing data
refreshOnGet: false, // Don't refresh on reads, only on explicit sets
});
// Per-session adaptive batch intervals (sessions with rapid output get longer batches)
private adaptiveBatchIntervals: Map<string, number> = new Map();
// Scheduled runs cleanup timer
private scheduledCleanupTimer: NodeJS.Timeout | null = null;
// SSE event batching
private outputBatches: Map<string, string> = new Map();
private outputBatchTimer: NodeJS.Timeout | null = null;
private taskUpdateBatches: Map<string, BackgroundTask> = new Map();
private taskUpdateBatches: Map<string, { sessionId: string; task: BackgroundTask }> = new Map();
private taskUpdateBatchTimer: NodeJS.Timeout | null = null;
// State update batching (reduce expensive toDetailedState() serialization)
private stateUpdatePending: Set<string> = new Set();
@@ -551,11 +561,12 @@ export class WebServer extends EventEmitter {
});
this.app.put('/api/config', async (req) => {
const body = req.body as Record<string, unknown> | null;
if (!body || typeof body !== 'object') {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Request body must be a JSON object');
// Validate request body against schema to prevent arbitrary config injection
const parseResult = ConfigUpdateSchema.safeParse(req.body);
if (!parseResult.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Invalid config: ${parseResult.error.message}`);
}
this.store.setConfig(body as Partial<ReturnType<typeof this.store.getConfig>>);
this.store.setConfig(parseResult.data as Partial<ReturnType<typeof this.store.getConfig>>);
return { success: true, config: this.store.getConfig() };
});
@@ -635,10 +646,14 @@ export class WebServer extends EventEmitter {
this.app.post('/api/sessions', async (req): Promise<SessionResponse> => {
// 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.` };
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached. Delete some sessions first.`);
}
const body = req.body as CreateSessionRequest & { mode?: 'claude' | 'shell'; name?: string };
const result = CreateSessionSchema.safeParse(req.body);
if (!result.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed');
}
const body = result.data;
const workingDir = body.workingDir || process.cwd();
const globalNice = this.getGlobalNiceConfig();
const session = new Session({
@@ -705,7 +720,7 @@ export class WebServer extends EventEmitter {
const killScreen = query.killScreen !== 'false'; // Default to true
if (!this.sessions.has(id)) {
return { success: false, error: 'Session not found' };
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
await this.cleanupSession(id, killScreen);
@@ -769,7 +784,7 @@ export class WebServer extends EventEmitter {
const session = this.sessions.get(id);
if (!session) {
return { success: false, error: 'Session not found' };
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
return {
@@ -949,9 +964,10 @@ export class WebServer extends EventEmitter {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter');
}
// Validate path is within working directory (security)
// Validate path is within working directory (security: proper path traversal check)
const fullPath = resolve(session.workingDir, filePath);
if (!fullPath.startsWith(session.workingDir)) {
const relativePath = relative(session.workingDir, fullPath);
if (relativePath.startsWith('..') || isAbsolute(relativePath)) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Path must be within working directory');
}
@@ -978,8 +994,15 @@ export class WebServer extends EventEmitter {
};
}
// Read text file with line limit
const maxLines = parseInt(lines || '500', 10);
// Validate file size before reading (DoS protection - prevent memory exhaustion)
const MAX_TEXT_FILE_SIZE = 10 * 1024 * 1024; // 10MB
if (stat.size > MAX_TEXT_FILE_SIZE) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `File too large (${Math.round(stat.size / 1024 / 1024)}MB > ${MAX_TEXT_FILE_SIZE / 1024 / 1024}MB limit)`);
}
// Read text file with line limit (bounded to prevent DoS)
const MAX_LINES_LIMIT = 10000;
const maxLines = Math.min(parseInt(lines || '500', 10) || 500, MAX_LINES_LIMIT);
const content = await fs.readFile(fullPath, 'utf-8');
const allLines = content.split('\n');
const truncatedContent = allLines.length > maxLines;
@@ -1008,23 +1031,32 @@ export class WebServer extends EventEmitter {
const session = this.sessions.get(id);
if (!session) {
reply.code(404).send({ success: false, error: 'Session not found' });
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'));
return;
}
if (!filePath) {
reply.code(400).send({ success: false, error: 'Missing path parameter' });
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter'));
return;
}
// Validate path is within working directory
// Validate path is within working directory (security: proper path traversal check)
const fullPath = resolve(session.workingDir, filePath);
if (!fullPath.startsWith(session.workingDir)) {
reply.code(400).send({ success: false, error: 'Path must be within working directory' });
const relativePath = relative(session.workingDir, fullPath);
if (relativePath.startsWith('..') || isAbsolute(relativePath)) {
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Path must be within working directory'));
return;
}
try {
// Validate file size before reading (DoS protection - prevent memory exhaustion)
const MAX_RAW_FILE_SIZE = 50 * 1024 * 1024; // 50MB for raw files
const stat = await fs.stat(fullPath);
if (stat.size > MAX_RAW_FILE_SIZE) {
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, `File too large (${Math.round(stat.size / 1024 / 1024)}MB > ${MAX_RAW_FILE_SIZE / 1024 / 1024}MB limit)`));
return;
}
const ext = filePath.split('.').pop()?.toLowerCase() || '';
const mimeTypes: Record<string, string> = {
'png': 'image/png', 'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'gif': 'image/gif',
@@ -1038,7 +1070,7 @@ export class WebServer extends EventEmitter {
reply.header('Content-Type', mimeTypes[ext] || 'application/octet-stream');
reply.send(content);
} catch (err) {
reply.code(500).send({ success: false, error: `Failed to read file: ${getErrorMessage(err)}` });
reply.code(500).send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read file: ${getErrorMessage(err)}`));
}
});
@@ -1049,12 +1081,12 @@ export class WebServer extends EventEmitter {
const session = this.sessions.get(id);
if (!session) {
reply.code(404).send({ success: false, error: 'Session not found' });
reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found'));
return;
}
if (!filePath) {
reply.code(400).send({ success: false, error: 'Missing path parameter' });
reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter'));
return;
}
@@ -1376,7 +1408,11 @@ export class WebServer extends EventEmitter {
// Run prompt in session
this.app.post('/api/sessions/:id/run', async (req): Promise<ApiResponse> => {
const { id } = req.params as { id: string };
const { prompt } = req.body as RunPromptRequest;
const result = RunPromptSchema.safeParse(req.body);
if (!result.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed');
}
const { prompt } = result.data;
const session = this.sessions.get(id);
if (!session) {
@@ -1455,17 +1491,17 @@ export class WebServer extends EventEmitter {
// useScreen: true uses writeViaScreen which is more reliable for programmatic input
this.app.post('/api/sessions/:id/input', async (req): Promise<ApiResponse> => {
const { id } = req.params as { id: string };
const { input, useScreen } = req.body as SessionInputRequest & { useScreen?: boolean };
const result = SessionInputSchema.safeParse(req.body);
if (!result.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed');
}
const { input, useScreen } = result.data;
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)`);
@@ -1491,16 +1527,18 @@ export class WebServer extends EventEmitter {
// Resize session terminal
this.app.post('/api/sessions/:id/resize', async (req): Promise<ApiResponse> => {
const { id } = req.params as { id: string };
const { cols, rows } = req.body as ResizeRequest;
const result = ResizeSchema.safeParse(req.body);
if (!result.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed');
}
const { cols, rows } = result.data;
const session = this.sessions.get(id);
if (!session) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
if (!Number.isInteger(cols) || !Number.isInteger(rows) || cols < 1 || rows < 1) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'cols and rows must be positive integers');
}
// Note: Zod already validates that cols and rows are positive integers within bounds
if (cols > MAX_TERMINAL_COLS || rows > MAX_TERMINAL_ROWS) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Terminal dimensions exceed maximum (${MAX_TERMINAL_COLS}x${MAX_TERMINAL_ROWS})`);
}
@@ -1665,7 +1703,12 @@ export class WebServer extends EventEmitter {
// 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<RespawnConfig>;
// Validate respawn config to prevent arbitrary field injection
const parseResult = RespawnConfigSchema.safeParse(req.body);
if (!parseResult.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Invalid respawn config: ${parseResult.error.message}`);
}
const config = parseResult.data as Partial<RespawnConfig>;
const session = this.sessions.get(id);
if (!session) {
@@ -1993,7 +2036,7 @@ export class WebServer extends EventEmitter {
const run = this.scheduledRuns.get(id);
if (!run) {
return { success: false, error: 'Scheduled run not found' };
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Scheduled run not found');
}
await this.stopScheduledRun(id);
@@ -2047,31 +2090,33 @@ export class WebServer extends EventEmitter {
}
}
}
} catch {
// Ignore errors reading linked cases
} catch (err) {
// Log but don't fail - linked cases are optional
console.warn('[Server] Failed to read linked cases:', err);
}
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.' };
this.app.post('/api/cases', async (req): Promise<ApiResponse<{ case: { name: string; path: string } }>> => {
const result = CreateCaseSchema.safeParse(req.body);
if (!result.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed');
}
const { name, description } = result.data;
const casePath = join(casesDir, name);
// Security: Path traversal protection - ensure resolved path is within casesDir
// Security: Path traversal protection - use relative path check
const resolvedPath = resolve(casePath);
const resolvedBase = resolve(casesDir);
if (!resolvedPath.startsWith(resolvedBase + '/') && resolvedPath !== resolvedBase) {
return { success: false, error: 'Invalid case path' };
const relPath = relative(resolvedBase, resolvedPath);
if (relPath.startsWith('..') || isAbsolute(relPath)) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path');
}
if (existsSync(casePath)) {
return { success: false, error: 'Case already exists' };
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Case already exists');
}
try {
@@ -2088,22 +2133,22 @@ export class WebServer extends EventEmitter {
this.broadcast('case:created', { name, path: casePath });
return { success: true, case: { name, path: casePath } };
return { success: true, data: { case: { name, path: casePath } } };
} catch (err) {
return { success: false, error: getErrorMessage(err) };
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 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 }> => {
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 };
if (!name || !/^[a-zA-Z0-9_-]+$/.test(name)) {
return { success: false, error: 'Invalid case name. Use only letters, numbers, hyphens, underscores.' };
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name. Use only letters, numbers, hyphens, underscores.');
}
if (!folderPath) {
return { success: false, error: 'Folder path is required.' };
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Folder path is required.');
}
// Expand ~ to home directory
@@ -2113,13 +2158,13 @@ export class WebServer extends EventEmitter {
// Validate the folder exists
if (!existsSync(expandedPath)) {
return { success: false, error: `Folder not found: ${expandedPath}` };
return createErrorResponse(ApiErrorCode.NOT_FOUND, `Folder not found: ${expandedPath}`);
}
// Check if case name already exists in casesDir
const casePath = join(casesDir, name);
if (existsSync(casePath)) {
return { success: false, error: 'A case with this name already exists in claudeman-cases.' };
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'A case with this name already exists in claudeman-cases.');
}
// Load existing linked cases
@@ -2135,7 +2180,7 @@ export class WebServer extends EventEmitter {
// Check if name is already linked
if (linkedCases[name]) {
return { success: false, error: `Case "${name}" is already linked to ${linkedCases[name]}` };
return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, `Case "${name}" is already linked to ${linkedCases[name]}`);
}
// Save the linked case
@@ -2147,9 +2192,9 @@ export class WebServer extends EventEmitter {
}
writeFileSync(linkedCasesFile, JSON.stringify(linkedCases, null, 2));
this.broadcast('case:linked', { name, path: expandedPath });
return { success: true, case: { name, path: expandedPath } };
return { success: true, data: { case: { name, path: expandedPath } } };
} catch (err) {
return { success: false, error: getErrorMessage(err) };
return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err));
}
});
@@ -2276,12 +2321,14 @@ export class WebServer extends EventEmitter {
}
}
const stats = {
total: todos.length,
pending: todos.filter(t => t.status === 'pending').length,
inProgress: todos.filter(t => t.status === 'in_progress').length,
completed: todos.filter(t => t.status === 'completed').length,
};
// Calculate stats in a single pass for better performance
let pending = 0, inProgress = 0, completed = 0;
for (const t of todos) {
if (t.status === 'pending') pending++;
else if (t.status === 'in_progress') inProgress++;
else if (t.status === 'completed') completed++;
}
const stats = { total: todos.length, pending, inProgress, completed };
return {
success: true,
@@ -2302,19 +2349,19 @@ export class WebServer extends EventEmitter {
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 result = QuickStartSchema.safeParse(req.body);
if (!result.success) {
return { success: false, error: result.error.issues[0]?.message ?? 'Validation failed' };
}
const { caseName = 'testcase', mode = 'claude' } = result.data;
const casePath = join(casesDir, caseName);
// Security: Path traversal protection - ensure resolved path is within casesDir
// Security: Path traversal protection - use relative path check
const resolvedPath = resolve(casePath);
const resolvedBase = resolve(casesDir);
if (!resolvedPath.startsWith(resolvedBase + '/') && resolvedPath !== resolvedBase) {
const relPath = relative(resolvedBase, resolvedPath);
if (relPath.startsWith('..') || isAbsolute(relPath)) {
return { success: false, error: 'Invalid case path' };
}
@@ -2389,11 +2436,13 @@ export class WebServer extends EventEmitter {
mkdirSync(dir, { recursive: true });
}
// Use async write to avoid blocking event loop
fs.writeFile(settingsFilePath, JSON.stringify(settings, null, 2)).catch(() => {
// Non-critical, ignore settings save errors
fs.writeFile(settingsFilePath, JSON.stringify(settings, null, 2)).catch((err) => {
// Non-critical but log for debugging
console.warn('[Server] Failed to save settings (lastUsedCase):', err);
});
} catch {
// Non-critical, ignore settings save errors
} catch (err) {
// Non-critical but log for debugging
console.warn('[Server] Failed to prepare settings update:', err);
}
return {
@@ -2626,10 +2675,11 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
if (caseName) {
const casesDir = join(homedir(), 'claudeman-cases');
const casePath = join(casesDir, caseName);
// Security: Path traversal protection
// Security: Path traversal protection - use relative path check
const resolvedCase = resolve(casePath);
const resolvedBase = resolve(casesDir);
if (resolvedCase.startsWith(resolvedBase) && existsSync(casePath)) {
const relPath = relative(resolvedBase, resolvedCase);
if (!relPath.startsWith('..') && !isAbsolute(relPath) && existsSync(casePath)) {
outputDir = join(casePath, 'ralph-wizard');
// Clear old ralph-wizard directory to ensure fresh prompts for each generation
@@ -2748,10 +2798,11 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
const casesDir = join(homedir(), 'claudeman-cases');
const casePath = join(casesDir, caseName);
// Security: Path traversal protection
// Security: Path traversal protection - use relative path check
const resolvedCase = resolve(casePath);
const resolvedBase = resolve(casesDir);
if (!resolvedCase.startsWith(resolvedBase)) {
const relPath = relative(resolvedBase, resolvedCase);
if (relPath.startsWith('..') || isAbsolute(relPath)) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
}
@@ -2800,10 +2851,11 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
reply.header('Pragma', 'no-cache');
reply.header('Expires', '0');
// Security: Path traversal protection for case name
// Security: Path traversal protection for case name - use relative path check
const resolvedCase = resolve(casePath);
const resolvedBase = resolve(casesDir);
if (!resolvedCase.startsWith(resolvedBase)) {
const relPath = relative(resolvedBase, resolvedCase);
if (relPath.startsWith('..') || isAbsolute(relPath)) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case name');
}
@@ -2827,13 +2879,23 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
const content = readFileSync(fullPath, 'utf-8');
const isJson = filePath.endsWith('.json');
// Parse JSON content safely (may contain invalid JSON)
let parsed: unknown = null;
if (isJson) {
try {
parsed = JSON.parse(content);
} catch {
// Invalid JSON - return null for parsed, content still available as raw string
}
}
return {
success: true,
data: {
content,
filePath: decodedPath,
isJson,
parsed: isJson ? JSON.parse(content) : null,
parsed,
},
};
});
@@ -3236,12 +3298,12 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
// ========== 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');
const result = HookEventSchema.safeParse(req.body);
if (!result.success) {
return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed');
}
if (!sessionId || !this.sessions.has(sessionId)) {
const { event, sessionId, data } = result.data;
if (!this.sessions.has(sessionId)) {
return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Session not found');
}
@@ -3269,7 +3331,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
}
// Sanitize forwarded data: only include known safe fields, limit size
const safeData = sanitizeHookData(data);
const safeData = sanitizeHookData(data as Record<string, unknown> | undefined);
this.broadcast(`hook:${event}`, { sessionId, timestamp: Date.now(), ...safeData });
// Track in run summary
@@ -3507,6 +3569,8 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
this.outputBatches.delete(sessionId);
this.taskUpdateBatches.delete(sessionId);
this.stateUpdatePending.delete(sessionId);
this.lastTerminalEventTime.delete(sessionId);
this.adaptiveBatchIntervals.delete(sessionId);
// Reset Ralph tracker on the session before cleanup
if (session) {
@@ -4032,6 +4096,11 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
console.log(`[Server] Restored respawn controller for session ${session.id} from ${source} (will start in ${Math.ceil(delayMs / 1000)}s)`);
const timer = setTimeout(() => {
this.pendingRespawnStarts.delete(session.id);
// Verify session still exists (may have been deleted during grace period)
if (!this.sessions.has(session.id)) {
console.log(`[Server] Skipping restored respawn start - session ${session.id} no longer exists`);
return;
}
// Double-check controller still exists and is stopped
const ctrl = this.respawnControllers.get(session.id);
if (ctrl && ctrl.state === 'stopped') {
@@ -4112,8 +4181,16 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
this.scheduledRuns.set(id, run);
this.broadcast('scheduled:created', run);
// Start the run loop
this.runScheduledLoop(id);
// Start the run loop (fire-and-forget with error handling)
this.runScheduledLoop(id).catch((err) => {
console.error(`[WebServer] Scheduled run ${id} failed:`, err);
const failedRun = this.scheduledRuns.get(id);
if (failedRun && failedRun.status === 'running') {
failedRun.status = 'stopped';
failedRun.logs.push(`[${new Date().toISOString()}] Error: ${err instanceof Error ? err.message : String(err)}`);
this.broadcast('scheduled:stopped', { id, reason: 'error' });
}
});
return run;
}
@@ -4378,21 +4455,23 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
const newBatch = existing + data;
this.terminalBatches.set(sessionId, newBatch);
// Adaptive batching: detect rapid events and extend batch window
// Adaptive batching: detect rapid events and extend batch window (per-session)
const now = Date.now();
const lastEvent = this.lastTerminalEventTime.get(sessionId) || 0;
const lastEvent = this.lastTerminalEventTime.get(sessionId) ?? 0;
const eventGap = now - lastEvent;
this.lastTerminalEventTime.set(sessionId, now);
// Adjust batch interval based on event frequency
// Adjust batch interval based on event frequency (per-session)
// Rapid events (<10ms gap) = 50ms batch, moderate (<20ms) = 32ms, else 16ms
let sessionInterval: number;
if (eventGap > 0 && eventGap < 10) {
this.adaptiveBatchInterval = 50;
sessionInterval = 50;
} else if (eventGap > 0 && eventGap < 20) {
this.adaptiveBatchInterval = 32;
sessionInterval = 32;
} else {
this.adaptiveBatchInterval = TERMINAL_BATCH_INTERVAL;
sessionInterval = TERMINAL_BATCH_INTERVAL;
}
this.adaptiveBatchIntervals.set(sessionId, sessionInterval);
// Flush immediately if batch is large for responsiveness
if (newBatch.length > BATCH_FLUSH_THRESHOLD) {
@@ -4405,17 +4484,27 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
}
// Start batch timer if not already running (uses adaptive interval)
// Pick the minimum interval across all pending sessions for responsiveness
if (!this.terminalBatchTimer) {
const batchInterval = Math.min(
...Array.from(this.adaptiveBatchIntervals.values()),
TERMINAL_BATCH_INTERVAL
);
this.terminalBatchTimer = setTimeout(() => {
this.flushTerminalBatches();
this.terminalBatchTimer = null;
// Reset adaptive interval after flush
this.adaptiveBatchInterval = TERMINAL_BATCH_INTERVAL;
}, this.adaptiveBatchInterval);
// Clear per-session intervals after flush (they'll be recalculated on next event)
this.adaptiveBatchIntervals.clear();
}, batchInterval);
}
}
private flushTerminalBatches(): void {
// Skip if server is stopping (timer may have been queued before stop() was called)
if (this._isStopping) {
this.terminalBatches.clear();
return;
}
for (const [sessionId, data] of this.terminalBatches) {
if (data.length > 0) {
// Wrap with DEC mode 2026 synchronized output markers
@@ -4446,6 +4535,11 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
}
private flushOutputBatches(): void {
// Skip if server is stopping (timer may have been queued before stop() was called)
if (this._isStopping) {
this.outputBatches.clear();
return;
}
for (const [sessionId, data] of this.outputBatches) {
if (data.length > 0) {
this.broadcast('session:output', { id: sessionId, data });
@@ -4454,12 +4548,15 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
this.outputBatches.clear();
}
// Batch task:updated events at 100ms - only send latest update per session
// Batch task:updated events at 100ms - only send latest update per task
// Key is sessionId:taskId to avoid collisions when multiple tasks update concurrently
private batchTaskUpdate(sessionId: string, task: BackgroundTask): void {
// Skip if server is stopping
if (this._isStopping) return;
this.taskUpdateBatches.set(sessionId, task);
// Use composite key to avoid losing updates when multiple tasks update in same batch window
const key = `${sessionId}:${task.id}`;
this.taskUpdateBatches.set(key, { sessionId, task });
if (!this.taskUpdateBatchTimer) {
this.taskUpdateBatchTimer = setTimeout(() => {
@@ -4470,7 +4567,12 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
}
private flushTaskUpdateBatches(): void {
for (const [sessionId, task] of this.taskUpdateBatches) {
// Skip if server is stopping (timer may have been queued before stop() was called)
if (this._isStopping) {
this.taskUpdateBatches.clear();
return;
}
for (const [, { sessionId, task }] of this.taskUpdateBatches) {
this.broadcast('task:updated', { sessionId, task });
}
this.taskUpdateBatches.clear();
@@ -4496,6 +4598,11 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
}
private flushStateUpdates(): void {
// Skip if server is stopping (timer may have been queued before stop() was called)
if (this._isStopping) {
this.stateUpdatePending.clear();
return;
}
for (const sessionId of this.stateUpdatePending) {
const session = this.sessions.get(sessionId);
if (session) {
@@ -4905,6 +5012,21 @@ NOW: Generate the implementation plan for the task above. Think step by step.`;
// Stop image watcher
imageWatcher.stop();
// Destroy file stream manager (clears cleanup timer and kills remaining tail processes)
fileStreamManager.destroy();
// Clear remaining Maps that accumulate session references
this.respawnTimers.clear();
this.runSummaryTrackers.clear();
this.transcriptWatchers.clear();
this.sessionListenerRefs.clear();
this.scheduledRuns.clear();
// Dispose StaleExpirationMap (stops internal cleanup timer)
this.lastTerminalEventTime.dispose();
this.adaptiveBatchIntervals.clear();
this.activePlanOrchestrators.clear();
this.cleaningUp.clear();
await this.app.close();
}
}