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
+57 -10
View File
@@ -31,6 +31,28 @@ import { EventEmitter } from 'node:events';
import { getAugmentedPath } from './session.js';
import { ANSI_ESCAPE_PATTERN_SIMPLE } from './utils/index.js';
// ========== Security Validation ==========
/**
* Validates that a model name is safe for shell use.
* Model names should only contain alphanumeric characters, hyphens, underscores, and dots.
*/
function isValidModelName(model: string): boolean {
if (!model || typeof model !== 'string') return false;
// Allow: alphanumeric, hyphens, underscores, dots, slashes (for model paths like claude/opus-4.5)
// Max length 100 to prevent abuse
return /^[a-zA-Z0-9._/-]+$/.test(model) && model.length <= 100;
}
/**
* Validates that a screen name is safe for shell use.
* Screen names should only contain alphanumeric characters, hyphens, and underscores.
*/
function isValidScreenName(screenName: string): boolean {
if (!screenName || typeof screenName !== 'string') return false;
return /^[a-zA-Z0-9_-]+$/.test(screenName) && screenName.length <= 100;
}
// ========== Types ==========
/** Base configuration shared by all AI checkers */
@@ -351,6 +373,11 @@ export abstract class AiCheckerBase<
// ========== Private Methods ==========
private async runCheck(terminalBuffer: string): Promise<R> {
// Security: Validate model name before use in shell commands
if (!isValidModelName(this.config.model)) {
throw new Error(`Invalid model name: ${this.config.model.substring(0, 50)}`);
}
// Prepare the terminal buffer (strip ANSI, trim to maxContextChars)
const stripped = terminalBuffer.replace(ANSI_ESCAPE_PATTERN_SIMPLE, '');
const trimmed = stripped.length > this.config.maxContextChars
@@ -367,6 +394,11 @@ export abstract class AiCheckerBase<
this.checkPromptFile = join(tmpdir(), `${this.tempFilePrefix}-prompt-${shortId}-${timestamp}.txt`);
this.checkScreenName = `${this.screenNamePrefix}${shortId}`;
// Security: Validate screen name before use in shell commands
if (!isValidScreenName(this.checkScreenName)) {
throw new Error(`Invalid screen name generated: ${this.checkScreenName.substring(0, 50)}`);
}
// Ensure output temp file exists (empty) so we can poll it
writeFileSync(this.checkTempFile, '');
@@ -375,16 +407,17 @@ export abstract class AiCheckerBase<
writeFileSync(this.checkPromptFile, prompt);
// Build the command - read prompt from file via stdin to avoid argument size limits
const modelArg = `--model ${this.config.model}`;
// Quote model name to prevent command injection (model names should be simple alphanumeric but be safe)
const modelArg = `--model "${this.config.model.replace(/"/g, '\\"')}"`;
const augmentedPath = getAugmentedPath();
const claudeCmd = `cat "${this.checkPromptFile}" | claude -p ${modelArg} --output-format text`;
const fullCmd = `export PATH="${augmentedPath}"; ${claudeCmd} > "${this.checkTempFile}" 2>&1; echo "${this.doneMarker}" >> "${this.checkTempFile}"; rm -f "${this.checkPromptFile}"`;
// Spawn screen
try {
// Kill any leftover screen with this name first
// Kill any leftover screen with this name first (screen name already validated above)
try {
execSync(`screen -X -S ${this.checkScreenName} quit 2>/dev/null`, { timeout: 3000 });
execSync(`screen -X -S "${this.checkScreenName}" quit 2>/dev/null`, { timeout: 3000 });
} catch {
// No existing screen, that's fine
}
@@ -407,9 +440,12 @@ export abstract class AiCheckerBase<
const startTime = this.checkStartTime;
this.checkResolve = resolve;
// Guard flag to prevent both poll and timeout from resolving (race condition)
let resolved = false;
this.checkPollTimer = setInterval(() => {
if (this.checkCancelled) {
// Cancel was already handled by cancel() calling resolve
if (this.checkCancelled || resolved) {
// Cancel or already resolved - stop polling
return;
}
@@ -417,19 +453,21 @@ export abstract class AiCheckerBase<
if (!this.checkTempFile || !existsSync(this.checkTempFile)) return;
const content = readFileSync(this.checkTempFile, 'utf-8');
if (content.includes(this.doneMarker)) {
resolved = true; // Mark as resolved first to prevent timeout race
const durationMs = Date.now() - startTime;
const result = this.parseOutput(content, durationMs);
this.checkResolve = null;
resolve(result);
}
} catch {
// File might not be ready yet, keep polling
// File might not be ready yet or was deleted during cleanup, keep polling
}
}, POLL_INTERVAL_MS);
// Set timeout
this.checkTimeoutTimer = setTimeout(() => {
if (this._status === 'checking' && !this.checkCancelled) {
if (this._status === 'checking' && !this.checkCancelled && !resolved) {
resolved = true; // Mark as resolved first to prevent poll race
this.checkResolve = null;
reject(new Error(`${this.checkDescription} timed out after ${this.config.checkTimeoutMs}ms`));
}
@@ -467,12 +505,21 @@ export abstract class AiCheckerBase<
this.checkTimeoutTimer = null;
}
// Kill the screen
// Kill the screen with fallback for stubborn processes
if (this.checkScreenName) {
const screenName = this.checkScreenName;
// Screen name was validated before use, but still quote for defense-in-depth
try {
execSync(`screen -X -S ${this.checkScreenName} quit 2>/dev/null`, { timeout: 3000 });
execSync(`screen -X -S "${screenName}" quit 2>/dev/null`, { timeout: 2000 });
} catch {
// Screen may already be dead
// First attempt failed - try force kill via pkill as fallback
// Escape regex metacharacters in screen name to prevent pattern injection
const escapedName = screenName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
try {
execSync(`pkill -f "SCREEN.*${escapedName}" 2>/dev/null`, { timeout: 1000 });
} catch {
// Screen may already be dead or no matching process
}
}
this.checkScreenName = null;
}
+13 -2
View File
@@ -169,7 +169,9 @@ export class FileStreamManager extends EventEmitter {
error: `File too large (${Math.round(stats.size / 1024 / 1024)}MB > ${MAX_FILE_SIZE / 1024 / 1024}MB limit)`,
};
}
} catch {
} catch (err) {
const errorCode = err instanceof Error && 'code' in err ? (err as NodeJS.ErrnoException).code : 'UNKNOWN';
console.warn(`[FileStreamManager] Failed to stat file "${absolutePath}" (${errorCode}):`, err instanceof Error ? err.message : String(err));
return { success: false, error: 'File not found or not accessible' };
}
@@ -250,17 +252,26 @@ export class FileStreamManager extends EventEmitter {
stream.active = false;
// Remove all event listeners to prevent memory leaks (closures hold references)
stream.process.stdout?.removeAllListeners();
stream.process.stderr?.removeAllListeners();
stream.process.removeAllListeners();
// Kill the tail process
try {
stream.process.kill('SIGTERM');
// Force kill after 1 second if still running
setTimeout(() => {
const forceKillTimer = setTimeout(() => {
try {
stream.process.kill('SIGKILL');
} catch {
// Already dead
}
}, 1000);
// Clear the force kill timer if process exits naturally
stream.process.once('exit', () => {
clearTimeout(forceKillTimer);
});
} catch {
// Process may have already exited
}
+9 -6
View File
@@ -253,15 +253,18 @@ export class PlanOrchestrator {
return md;
}
cancel(): void {
async cancel(): Promise<void> {
this.cancelled = true;
// Stop all running sessions and await cleanup to prevent PTY process leaks
const stopPromises: Promise<void>[] = [];
for (const session of this.runningSessions) {
try {
session.stop();
} catch (err) {
console.error('[PlanOrchestrator] Failed to stop session during cancel:', err);
}
stopPromises.push(
session.stop().catch((err) => {
console.error('[PlanOrchestrator] Failed to stop session during cancel:', err);
})
);
}
await Promise.all(stopPromises);
this.runningSessions.clear();
}
+26 -8
View File
@@ -184,6 +184,11 @@ export class RalphLoop extends EventEmitter {
this.tasksCompleted = 0;
this.tasksGenerated = 0;
// Re-setup event handlers if they were cleaned up during stop()
if (!this.sessionEventHandlers) {
this.setupEventHandlers();
}
this.store.setRalphLoopState({
status: 'running',
startedAt: this.startedAt,
@@ -209,6 +214,9 @@ export class RalphLoop extends EventEmitter {
this.loopTimer = null;
}
// Clean up event handlers to prevent memory leaks
this.cleanupEventHandlers();
this.store.setRalphLoopState({
status: 'stopped',
lastCheckAt: Date.now(),
@@ -251,10 +259,15 @@ export class RalphLoop extends EventEmitter {
this.tick()
.catch((err) => {
this.emit('error', err);
// Only emit if still running (stop() may have been called during tick())
if (this._status === 'running') {
this.emit('error', err);
}
})
.finally(() => {
if (this._status === 'running') {
// Guard: only reschedule if still running AND no timer is pending
// (prevents race where stop() clears timer between our check and setTimeout)
if (this._status === 'running' && this.loopTimer === null) {
this.loopTimer = setTimeout(() => this.runLoop(), this.pollIntervalMs);
}
});
@@ -263,13 +276,13 @@ export class RalphLoop extends EventEmitter {
private async tick(): Promise<void> {
this.store.setRalphLoopState({ lastCheckAt: Date.now() });
// Check for timed out tasks
await this.checkTimeouts();
// Run independent checks in parallel for better performance
await Promise.all([
this.checkTimeouts(),
this.assignTasks(),
]);
// Assign tasks to idle sessions
await this.assignTasks();
// Check if we should auto-generate tasks
// Check if we should auto-generate tasks (depends on assignment results)
if (this.autoGenerateTasks && this.shouldGenerateTasks()) {
await this.generateFollowUpTasks();
}
@@ -480,3 +493,8 @@ export function destroyRalphLoop(): void {
loopInstance = null;
}
}
// Ensure cleanup on process exit (prevents orphaned event handlers and circular references)
process.on('exit', () => {
destroyRalphLoop();
});
+99 -73
View File
@@ -398,6 +398,55 @@ const COMPLETION_INDICATOR_PATTERNS = [
/project\s+(?:is\s+)?(?:completed?|done|finished)/i,
];
// ---------- Priority Detection Patterns ----------
// Pre-compiled for performance; avoids repeated allocation in parsePriority()
/** P0 (Critical) priority patterns - highest severity issues */
const P0_PRIORITY_PATTERNS = [
/\bP0\b|\(P0\)|:?\s*P0\s*:/, // Explicit P0
/\bCRITICAL\b/, // Critical keyword
/\bBLOCKER\b/, // Blocker
/\bURGENT\b/, // Urgent
/\bSECURITY\b/, // Security issues
/\bCRASH(?:ES|ING)?\b/, // Crash, crashes, crashing
/\bBROKEN\b/, // Broken
/\bDATA\s*LOSS\b/, // Data loss
/\bPRODUCTION\s*(?:DOWN|ISSUE|BUG)\b/, // Production issues
/\bHOTFIX\b/, // Hotfix
/\bSEVERITY\s*1\b/, // Severity 1
];
/** P1 (High) priority patterns - important issues requiring attention */
const P1_PRIORITY_PATTERNS = [
/\bP1\b|\(P1\)|:?\s*P1\s*:/, // Explicit P1
/\bHIGH\s*PRIORITY\b/, // High priority
/\bIMPORTANT\b/, // Important
/\bBUG\b/, // Bug
/\bFIX\b/, // Fix (as task type)
/\bERROR\b/, // Error
/\bFAIL(?:S|ED|ING|URE)?\b/, // Fail variants
/\bREGRESSION\b/, // Regression
/\bMUST\s*(?:HAVE|FIX|DO)\b/, // Must have/fix/do
/\bSEVERITY\s*2\b/, // Severity 2
/\bREQUIRED\b/, // Required
];
/** P2 (Medium) priority patterns - lower priority improvements */
const P2_PRIORITY_PATTERNS = [
/\bP2\b|\(P2\)|:?\s*P2\s*:/, // Explicit P2
/\bNICE\s*TO\s*HAVE\b/, // Nice to have
/\bLOW\s*PRIORITY\b/, // Low priority
/\bREFACTOR\b/, // Refactor
/\bCLEANUP\b/, // Cleanup
/\bIMPROVE(?:MENT)?\b/, // Improve/Improvement
/\bOPTIMIZ(?:E|ATION)\b/, // Optimize/Optimization
/\bCONSIDER\b/, // Consider
/\bWOULD\s*BE\s*NICE\b/, // Would be nice
/\bENHANCE(?:MENT)?\b/, // Enhance/Enhancement
/\bTECH(?:NICAL)?\s*DEBT\b/, // Tech debt
/\bDOCUMENT(?:ATION)?\b/, // Documentation
];
// ========== Event Types ==========
/**
@@ -553,6 +602,8 @@ export class RalphTracker extends EventEmitter {
/** File watcher for @fix_plan.md */
private _fixPlanWatcher: FSWatcher | null = null;
/** Error handler for FSWatcher (stored for cleanup to prevent memory leak) */
private _fixPlanWatcherErrorHandler: ((err: Error) => void) | null = null;
/** Debounce timer for file change events */
private _fixPlanReloadTimer: NodeJS.Timeout | null = null;
@@ -610,8 +661,8 @@ export class RalphTracker extends EventEmitter {
/** Whether stall warning has been emitted */
private _iterationStallWarned: boolean = false;
/** Alternate completion phrases (P1-003: multi-phrase support) */
private _alternateCompletionPhrases: string[] = [];
/** Alternate completion phrases (P1-003: multi-phrase support) - Set for O(1) lookup */
private _alternateCompletionPhrases: Set<string> = new Set();
// ========== P1-009: Progress Estimation ==========
@@ -644,9 +695,9 @@ export class RalphTracker extends EventEmitter {
* @param phrase - Additional phrase that can trigger completion
*/
addAlternateCompletionPhrase(phrase: string): void {
if (!this._alternateCompletionPhrases.includes(phrase)) {
this._alternateCompletionPhrases.push(phrase);
this._loopState.alternateCompletionPhrases = [...this._alternateCompletionPhrases];
if (!this._alternateCompletionPhrases.has(phrase)) {
this._alternateCompletionPhrases.add(phrase);
this._loopState.alternateCompletionPhrases = Array.from(this._alternateCompletionPhrases);
this.emit('loopUpdate', this.loopState);
}
}
@@ -656,10 +707,8 @@ export class RalphTracker extends EventEmitter {
* @param phrase - Phrase to remove
*/
removeAlternateCompletionPhrase(phrase: string): void {
const index = this._alternateCompletionPhrases.indexOf(phrase);
if (index !== -1) {
this._alternateCompletionPhrases.splice(index, 1);
this._loopState.alternateCompletionPhrases = [...this._alternateCompletionPhrases];
if (this._alternateCompletionPhrases.delete(phrase)) {
this._loopState.alternateCompletionPhrases = Array.from(this._alternateCompletionPhrases);
this.emit('loopUpdate', this.loopState);
}
}
@@ -785,6 +834,15 @@ export class RalphTracker extends EventEmitter {
this.handleFixPlanChange();
});
}
// Add error handler to prevent unhandled errors and clean up on failure
// Store handler reference for proper cleanup in stopWatchingFixPlan()
if (this._fixPlanWatcher) {
this._fixPlanWatcherErrorHandler = (err: Error) => {
console.log(`[RalphTracker] FSWatcher error for @fix_plan.md: ${err.message}`);
this.stopWatchingFixPlan();
};
this._fixPlanWatcher.on('error', this._fixPlanWatcherErrorHandler);
}
} catch (err) {
console.log(`[RalphTracker] Could not watch @fix_plan.md: ${err}`);
}
@@ -810,6 +868,11 @@ export class RalphTracker extends EventEmitter {
*/
stopWatchingFixPlan(): void {
if (this._fixPlanWatcher) {
// Remove error handler before closing to prevent memory leak
if (this._fixPlanWatcherErrorHandler) {
this._fixPlanWatcher.off('error', this._fixPlanWatcherErrorHandler);
this._fixPlanWatcherErrorHandler = null;
}
this._fixPlanWatcher.close();
this._fixPlanWatcher = null;
}
@@ -1448,7 +1511,8 @@ export class RalphTracker extends EventEmitter {
// Check if the count matches our todo count (e.g., "All 8 files created")
const countMatch = line.match(ALL_COUNT_PATTERN);
const mentionedCount = countMatch ? parseInt(countMatch[1]) : null;
const parsedCount = countMatch ? parseInt(countMatch[1], 10) : NaN;
const mentionedCount = Number.isNaN(parsedCount) ? null : parsedCount;
const todoCount = this._todos.size;
// If a count is mentioned, it should match our todo count (within reason)
@@ -1494,7 +1558,8 @@ export class RalphTracker extends EventEmitter {
// Only act on explicit task number references like "Task 8 is done"
const taskNumMatch = line.match(/task\s*#?(\d+)/i);
if (taskNumMatch) {
const taskNum = parseInt(taskNumMatch[1]);
const taskNum = parseInt(taskNumMatch[1], 10);
if (Number.isNaN(taskNum)) return;
// Find the nth todo (by order) and mark it complete
let count = 0;
for (const [_id, todo] of this._todos) {
@@ -1850,8 +1915,8 @@ export class RalphTracker extends EventEmitter {
// Check for max iterations setting
const maxIterMatch = line.match(MAX_ITERATIONS_PATTERN);
if (maxIterMatch) {
const maxIter = parseInt(maxIterMatch[1]);
if (!isNaN(maxIter) && maxIter > 0) {
const maxIter = parseInt(maxIterMatch[1], 10);
if (!Number.isNaN(maxIter) && maxIter > 0) {
this._loopState.maxIterations = maxIter;
this._loopState.lastActivity = Date.now();
// Use debounced emit for settings changes
@@ -1863,10 +1928,11 @@ export class RalphTracker extends EventEmitter {
const iterMatch = line.match(ITERATION_PATTERN);
if (iterMatch) {
// Pattern captures: group 1&2 for "Iteration X/Y", group 3&4 for "[X/Y]"
const currentIter = parseInt(iterMatch[1] || iterMatch[3]);
const maxIter = iterMatch[2] || iterMatch[4] ? parseInt(iterMatch[2] || iterMatch[4]) : null;
const currentIter = parseInt(iterMatch[1] || iterMatch[3], 10);
const maxIterStr = iterMatch[2] || iterMatch[4];
const maxIter = maxIterStr ? parseInt(maxIterStr, 10) : null;
if (!isNaN(currentIter)) {
if (!Number.isNaN(currentIter)) {
this.activateLoopIfNeeded();
// Track iteration changes for stall detection
if (currentIter !== this._lastObservedIteration) {
@@ -1892,7 +1958,7 @@ export class RalphTracker extends EventEmitter {
}
}
this._loopState.cycleCount = currentIter;
if (maxIter !== null && !isNaN(maxIter)) {
if (maxIter !== null && !Number.isNaN(maxIter)) {
this._loopState.maxIterations = maxIter;
}
this._loopState.lastActivity = Date.now();
@@ -1913,8 +1979,8 @@ export class RalphTracker extends EventEmitter {
// Check for cycle count (legacy pattern)
const cycleMatch = line.match(CYCLE_PATTERN);
if (cycleMatch) {
const cycleNum = parseInt(cycleMatch[1] || cycleMatch[2]);
if (!isNaN(cycleNum) && cycleNum > this._loopState.cycleCount) {
const cycleNum = parseInt(cycleMatch[1] || cycleMatch[2], 10);
if (!Number.isNaN(cycleNum) && cycleNum > this._loopState.cycleCount) {
this._loopState.cycleCount = cycleNum;
this._loopState.lastActivity = Date.now();
// Use debounced emit for cycle updates
@@ -2119,68 +2185,23 @@ export class RalphTracker extends EventEmitter {
private parsePriority(content: string): RalphTodoPriority {
const upper = content.toUpperCase();
// P0 patterns - Critical issues
const p0Patterns = [
/\bP0\b|\(P0\)|:?\s*P0\s*:/, // Explicit P0
/\bCRITICAL\b/, // Critical keyword
/\bBLOCKER\b/, // Blocker
/\bURGENT\b/, // Urgent
/\bSECURITY\b/, // Security issues
/\bCRASH(?:ES|ING)?\b/, // Crash, crashes, crashing
/\bBROKEN\b/, // Broken
/\bDATA\s*LOSS\b/, // Data loss
/\bPRODUCTION\s*(?:DOWN|ISSUE|BUG)\b/, // Production issues
/\bHOTFIX\b/, // Hotfix
/\bSEVERITY\s*1\b/, // Severity 1
];
// P1 patterns - High priority issues
const p1Patterns = [
/\bP1\b|\(P1\)|:?\s*P1\s*:/, // Explicit P1
/\bHIGH\s*PRIORITY\b/, // High priority
/\bIMPORTANT\b/, // Important
/\bBUG\b/, // Bug
/\bFIX\b/, // Fix (as task type)
/\bERROR\b/, // Error
/\bFAIL(?:S|ED|ING|URE)?\b/, // Fail variants
/\bREGRESSION\b/, // Regression
/\bMUST\s*(?:HAVE|FIX|DO)\b/, // Must have/fix/do
/\bSEVERITY\s*2\b/, // Severity 2
/\bREQUIRED\b/, // Required
];
// P2 patterns - Lower priority
const p2Patterns = [
/\bP2\b|\(P2\)|:?\s*P2\s*:/, // Explicit P2
/\bNICE\s*TO\s*HAVE\b/, // Nice to have
/\bLOW\s*PRIORITY\b/, // Low priority
/\bREFACTOR\b/, // Refactor
/\bCLEANUP\b/, // Cleanup
/\bIMPROVE(?:MENT)?\b/, // Improve/Improvement
/\bOPTIMIZ(?:E|ATION)\b/, // Optimize/Optimization
/\bCONSIDER\b/, // Consider
/\bWOULD\s*BE\s*NICE\b/, // Would be nice
/\bENHANCE(?:MENT)?\b/, // Enhance/Enhancement
/\bTECH(?:NICAL)?\s*DEBT\b/, // Tech debt
/\bDOCUMENT(?:ATION)?\b/, // Documentation
];
// Check P0 first (highest priority wins)
for (const pattern of p0Patterns) {
// Uses pre-compiled module-level patterns for performance
for (const pattern of P0_PRIORITY_PATTERNS) {
if (pattern.test(upper)) {
return 'P0';
}
}
// Check P1
for (const pattern of p1Patterns) {
for (const pattern of P1_PRIORITY_PATTERNS) {
if (pattern.test(upper)) {
return 'P1';
}
}
// Check P2
for (const pattern of p2Patterns) {
for (const pattern of P2_PRIORITY_PATTERNS) {
if (pattern.test(upper)) {
return 'P2';
}
@@ -2281,12 +2302,17 @@ export class RalphTracker extends EventEmitter {
return;
}
// Add new todo
if (this._todos.size >= MAX_TODOS_PER_SESSION) {
// Remove oldest todo to make room
// Add new todo with guaranteed eviction if at capacity
// Use while loop to ensure we always have room (handles edge case where findOldestTodo returns undefined)
while (this._todos.size >= MAX_TODOS_PER_SESSION) {
const oldest = this.findOldestTodo();
if (oldest) {
this._todos.delete(oldest.id);
} else {
// Safety valve: if somehow no oldest found, clear a random entry
const firstKey = this._todos.keys().next().value;
if (firstKey) this._todos.delete(firstKey);
else break; // Map is empty somehow, exit loop
}
}
@@ -2985,7 +3011,7 @@ export class RalphTracker extends EventEmitter {
const tasksMatch = trimmedLine.match(RALPH_TASKS_COMPLETED_PATTERN);
if (tasksMatch) {
const value = parseInt(tasksMatch[1], 10);
if (!isNaN(value) && value >= 0) {
if (!Number.isNaN(value) && value >= 0) {
block.tasksCompletedThisLoop = value;
} else {
parseErrors.push(`Invalid TASKS_COMPLETED_THIS_LOOP value: "${tasksMatch[1]}". Expected: non-negative integer`);
@@ -2997,7 +3023,7 @@ export class RalphTracker extends EventEmitter {
const filesMatch = trimmedLine.match(RALPH_FILES_MODIFIED_PATTERN);
if (filesMatch) {
const value = parseInt(filesMatch[1], 10);
if (!isNaN(value) && value >= 0) {
if (!Number.isNaN(value) && value >= 0) {
block.filesModified = value;
} else {
parseErrors.push(`Invalid FILES_MODIFIED value: "${filesMatch[1]}". Expected: non-negative integer`);
+39
View File
@@ -43,6 +43,7 @@ import { BufferAccumulator } from './utils/buffer-accumulator.js';
import {
ANSI_ESCAPE_PATTERN_SIMPLE,
TOKEN_PATTERN,
assertNever,
} from './utils/index.js';
import {
MAX_RESPAWN_BUFFER_SIZE,
@@ -1323,9 +1324,14 @@ export class RespawnController extends EventEmitter {
*
* If in 'watching' state, immediately checks for idle condition.
* Otherwise, continues from current state.
* Re-setups terminal listener if it was removed (e.g., after stop()).
*/
resume(): void {
this.log('Resuming respawn');
// Re-setup terminal listener if it was removed (robustness for stop->resume case)
if (!this.terminalHandler) {
this.setupTerminalListener();
}
if (this._state === 'watching') {
this.checkIdleAndMaybeStart();
}
@@ -1410,6 +1416,7 @@ export class RespawnController extends EventEmitter {
// In waiting states, also use confirmation timer (same detection logic)
// This ensures we wait for Claude to finish before proceeding
// Note: 'watching' is already handled above and returns early
switch (this._state) {
case 'waiting_update':
this.startStepConfirmTimer('update');
@@ -1423,6 +1430,19 @@ export class RespawnController extends EventEmitter {
case 'waiting_kickstart':
this.startStepConfirmTimer('kickstart');
break;
// Non-waiting states: completion message is ignored
case 'confirming_idle':
case 'ai_checking':
case 'sending_update':
case 'sending_clear':
case 'sending_init':
case 'monitoring_init':
case 'sending_kickstart':
case 'stopped':
// Completion message during these states is ignored
break;
default:
assertNever(this._state, `Unhandled RespawnState in completion detection: ${this._state}`);
}
return;
}
@@ -1512,6 +1532,19 @@ export class RespawnController extends EventEmitter {
case 'waiting_kickstart':
this.startStepConfirmTimer('kickstart');
break;
// Non-waiting states: prompt detection is informational only
case 'watching':
case 'confirming_idle':
case 'ai_checking':
case 'sending_update':
case 'sending_clear':
case 'sending_init':
case 'sending_kickstart':
case 'stopped':
// Prompt detection during these states doesn't trigger action
break;
default:
assertNever(this._state, `Unhandled RespawnState in prompt detection: ${this._state}`);
}
}
}
@@ -3243,6 +3276,12 @@ export class RespawnController extends EventEmitter {
case 'error':
agg.errorCycles++;
break;
case 'cancelled':
// Cancelled cycles don't count towards any specific category
// but are still counted in totalCycles
break;
default:
assertNever(metrics.outcome, `Unhandled CycleOutcome: ${metrics.outcome}`);
}
// Recalculate averages using all recent metrics
+25 -12
View File
@@ -136,6 +136,10 @@ function isValidPath(path: string): boolean {
path.includes('"') || path.includes('\n') || path.includes('\r')) {
return false;
}
// Check for path traversal attempts (prevents escaping workingDir)
if (path.includes('..')) {
return false;
}
return SAFE_PATH_PATTERN.test(path);
}
@@ -268,8 +272,9 @@ export class ScreenManager extends EventEmitter {
// Base command for the mode (just the executable, env vars are exported separately)
// For Claude sessions: pass --session-id to use the SAME ID as the Claudeman session
// This ensures subagents (discovered via file path) can be directly matched to the correct tab
// Note: sessionId is quoted for safety even though it should be a valid UUID
const baseCmd = mode === 'claude'
? `claude --dangerously-skip-permissions --session-id ${sessionId}`
? `claude --dangerously-skip-permissions --session-id "${sessionId}"`
: '$SHELL';
// Apply nice priority if configured
@@ -355,7 +360,7 @@ export class ScreenManager extends EventEmitter {
timeout: EXEC_TIMEOUT_MS
}).trim();
if (output) {
for (const childPid of output.split('\n').map(p => parseInt(p, 10)).filter(p => !isNaN(p))) {
for (const childPid of output.split('\n').map(p => parseInt(p, 10)).filter(p => !Number.isNaN(p))) {
pids.push(childPid);
// Recursively get grandchildren
pids.push(...this.getChildPids(childPid));
@@ -381,7 +386,8 @@ export class ScreenManager extends EventEmitter {
// Verify all PIDs are dead, with retry
private async verifyProcessesDead(pids: number[], maxWaitMs: number = 1000): Promise<boolean> {
const startTime = Date.now();
const checkInterval = 50;
// 100ms interval balances responsiveness with CPU usage (was 50ms, too aggressive)
const checkInterval = 100;
while (Date.now() - startTime < maxWaitMs) {
const aliveCount = pids.filter(pid => this.isProcessAlive(pid)).length;
@@ -463,7 +469,7 @@ export class ScreenManager extends EventEmitter {
// Strategy 3: Kill screen session by name
try {
execSync(`screen -S ${screen.screenName} -X quit`, {
execSync(`screen -S "${screen.screenName}" -X quit`, {
timeout: EXEC_TIMEOUT_MS
});
} catch {
@@ -652,11 +658,11 @@ export class ScreenManager extends EventEmitter {
for (const line of pgrepOutput.split('\n')) {
const [pidStr, childrenStr] = line.split(':');
const screenPid = parseInt(pidStr, 10);
if (!isNaN(screenPid)) {
if (!Number.isNaN(screenPid)) {
const children = (childrenStr || '')
.split(',')
.map(s => parseInt(s.trim(), 10))
.filter(n => !isNaN(n) && n > 0);
.filter(n => !Number.isNaN(n) && n > 0);
descendantMap.set(screenPid, children);
}
}
@@ -685,7 +691,7 @@ export class ScreenManager extends EventEmitter {
const pid = parseInt(parts[0], 10);
const rss = parseFloat(parts[1]) || 0;
const cpu = parseFloat(parts[2]) || 0;
if (!isNaN(pid)) {
if (!Number.isNaN(pid)) {
processStats.set(pid, { rss, cpu });
}
}
@@ -848,21 +854,24 @@ export class ScreenManager extends EventEmitter {
// Send text first (if any)
if (escapedText) {
const textCmd = `screen -S ${screen.screenName} -p 0 -X stuff "${escapedText}"`;
const textCmd = `screen -S "${screen.screenName}" -p 0 -X stuff "${escapedText}"`;
execSync(textCmd, { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS });
}
// Send carriage return separately (Enter key for Ink)
// Use a synchronous sleep to ensure screen processes the text first
// Use synchronous delay to ensure screen processes the text first
if (hasCarriageReturn) {
// Delay to let screen process the text before sending Enter
// This prevents race conditions where Enter arrives before the text is processed
// 100ms is needed for reliability - screen's internal buffering can be slow
if (escapedText) {
execSync('sleep 0.1', { timeout: 1000 });
// Use Atomics.wait for synchronous delay instead of shell command
const sharedBuffer = new SharedArrayBuffer(4);
const sharedArray = new Int32Array(sharedBuffer);
Atomics.wait(sharedArray, 0, 0, 100);
}
const crCmd = `screen -S ${screen.screenName} -p 0 -X stuff "$(printf '\\015')"`;
const crCmd = `screen -S "${screen.screenName}" -p 0 -X stuff "$(printf '\\015')"`;
// Try up to CR_MAX_ATTEMPTS times with increasing delays
let success = false;
@@ -873,7 +882,11 @@ export class ScreenManager extends EventEmitter {
} catch (crErr) {
console.warn(`[ScreenManager] Carriage return attempt ${attempt}/${CR_MAX_ATTEMPTS} failed`);
if (attempt < CR_MAX_ATTEMPTS) {
execSync(`sleep 0.${attempt}`, { timeout: 1000 }); // 0.1s, 0.2s delays
// Use synchronous sleep without shell command (security: avoid shell injection vectors)
const sleepMs = attempt * 100; // 100ms, 200ms, 300ms delays
const sharedBuffer = new SharedArrayBuffer(4);
const sharedArray = new Int32Array(sharedBuffer);
Atomics.wait(sharedArray, 0, 0, sleepMs);
}
}
}
+46 -32
View File
@@ -993,6 +993,11 @@ export class Session extends EventEmitter {
.replace(CTRL_L_PATTERN, ''); // Remove Ctrl+L
if (!data) return; // Skip if only filtered sequences
// Strip ANSI once for all downstream pattern-matching operations
// This avoids redundant regex operations in parseTokensFromStatusLine,
// parseClaudeCodeInfo, parseTaskDescriptionsFromTerminalData, and working detection
const cleanData = data.replace(ANSI_ESCAPE_PATTERN_FULL, '');
// BufferAccumulator handles auto-trimming when max size exceeded
this._terminalBuffer.append(data);
this._lastActivityAt = Date.now();
@@ -1000,21 +1005,21 @@ export class Session extends EventEmitter {
this.emit('terminal', data);
this.emit('output', data);
// Forward to Ralph tracker to detect Ralph loops and todos
// Forward to Ralph tracker to detect Ralph loops and todos (handles own stripping)
this._ralphTracker.processTerminalData(data);
// Forward to Bash tool parser to detect file-viewing commands
// Forward to Bash tool parser to detect file-viewing commands (handles own stripping)
this._bashToolParser.processTerminalData(data);
// Parse token count from status line (e.g., "123.4k tokens" or "5234 tokens")
this.parseTokensFromStatusLine(data);
this.parseTokensFromStatusLine(cleanData);
// Parse Claude Code CLI info (version, model, account type) from startup
this.parseClaudeCodeInfo(data);
this.parseClaudeCodeInfo(cleanData);
// Parse task descriptions from terminal output (e.g., "Explore(Check files)")
// This enables correlating subagent windows with their short descriptions
this.parseTaskDescriptionsFromTerminalData(data);
this.parseTaskDescriptionsFromTerminalData(cleanData);
// Detect if Claude is working or at prompt
// The prompt line contains "❯" when waiting for input
@@ -1042,14 +1047,13 @@ export class Session extends EventEmitter {
}
// Detect when Claude starts working (thinking, writing, etc)
// Strip ANSI/OSC sequences to avoid false positives from window titles like "3 File Reading Task"
const cleanDataForWorkingCheck = data.replace(ANSI_ESCAPE_PATTERN_FULL, '');
if (cleanDataForWorkingCheck.includes('Thinking') || cleanDataForWorkingCheck.includes('Writing') ||
cleanDataForWorkingCheck.includes('Reading') || cleanDataForWorkingCheck.includes('Running') ||
cleanDataForWorkingCheck.includes('⠋') || cleanDataForWorkingCheck.includes('⠙') ||
cleanDataForWorkingCheck.includes('⠹') || cleanDataForWorkingCheck.includes('⠸') ||
cleanDataForWorkingCheck.includes('⠼') || cleanDataForWorkingCheck.includes('⠴') ||
cleanDataForWorkingCheck.includes('⠦') || cleanDataForWorkingCheck.includes('⠧')) {
// Using pre-cleaned data avoids false positives from window titles like "3 File Reading Task"
if (cleanData.includes('Thinking') || cleanData.includes('Writing') ||
cleanData.includes('Reading') || cleanData.includes('Running') ||
cleanData.includes('⠋') || cleanData.includes('⠙') ||
cleanData.includes('⠹') || cleanData.includes('⠸') ||
cleanData.includes('⠼') || cleanData.includes('⠴') ||
cleanData.includes('⠦') || cleanData.includes('⠧')) {
if (!this._isWorking) {
this._isWorking = true;
this._status = 'busy';
@@ -1504,31 +1508,43 @@ export class Session extends EventEmitter {
}
/**
* Parse task descriptions from raw terminal data (may contain multiple lines).
* Called from interactive mode's onData handler.
* Parse task descriptions from terminal data (may contain multiple lines).
* Called from interactive mode's onData handler with ANSI-stripped data.
* @param cleanData - Terminal data with ANSI codes already stripped
*/
private parseTaskDescriptionsFromTerminalData(data: string): void {
private parseTaskDescriptionsFromTerminalData(cleanData: string): void {
// Quick pre-check: skip if no parentheses present
if (!data.includes('(') || !data.includes(')')) return;
if (!cleanData.includes('(') || !cleanData.includes(')')) return;
// Split by newlines and process each line
const lines = data.split(NEWLINE_SPLIT_PATTERN);
// Split by newlines and process each line (data already ANSI-stripped)
const lines = cleanData.split(NEWLINE_SPLIT_PATTERN);
for (const line of lines) {
this.parseTaskDescriptionsFromLine(line);
this.parseTaskDescriptionsDirect(line);
}
}
/**
* Parse task descriptions from terminal output.
* Parse task descriptions from terminal output line.
* Claude Code outputs Task tool calls as "ToolName(Description)" in the terminal.
* We capture these descriptions to use as window titles for subagents.
* Called from processOutput() with potentially non-cleaned data.
*/
private parseTaskDescriptionsFromLine(line: string): void {
// Quick pre-check: skip expensive regex if no common tool patterns present
if (!line.includes('(') || !line.includes(')')) return;
// Strip ANSI codes before matching - terminal output has embedded codes like [1mExplore[0m
// Strip ANSI codes - may still be present from processOutput() path
const cleanLine = line.replace(ANSI_ESCAPE_PATTERN_FULL, '');
this.parseTaskDescriptionsDirect(cleanLine);
}
/**
* Parse task descriptions from a pre-cleaned line (no ANSI codes).
* Internal method used by both parseTaskDescriptionsFromTerminalData and parseTaskDescriptionsFromLine.
*/
private parseTaskDescriptionsDirect(cleanLine: string): void {
// Quick pre-check: skip expensive regex if no common tool patterns present
if (!cleanLine.includes('(') || !cleanLine.includes(')')) return;
// Reset regex lastIndex for global pattern
TASK_TOOL_PATTERN.lastIndex = 0;
@@ -1611,15 +1627,13 @@ export class Session extends EventEmitter {
// - Max tokens per session: 500k (Claude's context is ~200k)
// - Max delta per update: 100k (prevents sudden jumps from parsing errors)
// - Rejects "M" suffix values > 0.5 (500k) to prevent false matches
private parseTokensFromStatusLine(data: string): void {
private parseTokensFromStatusLine(cleanData: string): void {
// Quick pre-check: skip expensive regex if "token" not present (performance optimization)
if (!data.includes('token')) return;
// Remove ANSI escape codes for cleaner parsing (use pre-compiled pattern)
const cleanData = data.replace(ANSI_ESCAPE_PATTERN_FULL, '');
if (!cleanData.includes('token')) return;
// Match patterns: "123.4k tokens", "5234 tokens", "1.2M tokens"
// The status line typically shows total tokens like "1.2k tokens" near the prompt
// Note: ANSI codes are already stripped by caller for performance
const tokenMatch = cleanData.match(TOKEN_PATTERN);
if (tokenMatch) {
@@ -1672,16 +1686,15 @@ export class Session extends EventEmitter {
// Parse Claude Code CLI info from terminal startup output
// Extracts version, model, and account type for display in Claudeman UI
private parseClaudeCodeInfo(data: string): void {
// Note: Expects cleanData with ANSI codes already stripped by caller
private parseClaudeCodeInfo(cleanData: string): void {
// Only parse once per session (during startup)
if (this._cliInfoParsed) return;
// Quick pre-checks
if (!data.includes('Claude') && !data.includes('current:') && !data.includes('Opus') && !data.includes('Sonnet')) {
if (!cleanData.includes('Claude') && !cleanData.includes('current:') && !cleanData.includes('Opus') && !cleanData.includes('Sonnet')) {
return;
}
const cleanData = data.replace(ANSI_ESCAPE_PATTERN_FULL, '');
let changed = false;
// Match "Claude Code v2.1.27" or "Claude Code vX.Y.Z"
@@ -1921,7 +1934,8 @@ export class Session extends EventEmitter {
this._status = 'busy';
this._lastActivityAt = Date.now();
this.runPrompt(input).catch(err => {
this.emit('error', err.message);
const errorMsg = err instanceof Error ? err.message : String(err);
this.emit('error', errorMsg);
});
}
+32 -6
View File
@@ -74,7 +74,9 @@ export class StateStore {
private ensureDir(): void {
const dir = dirname(this.filePath);
if (!existsSync(dir)) {
mkdirSync(dir, { recursive: true });
// Use restrictive permissions (0o700) - owner only can read/write/traverse
// State files may contain sensitive session data
mkdirSync(dir, { recursive: true, mode: 0o700 });
}
}
@@ -584,7 +586,7 @@ export class StateStore {
if (!this.ralphStateDirty) {
return;
}
this.ralphStateDirty = false;
// Clear dirty flag only on success to enable retry on failure
this.ensureDir();
const data = Object.fromEntries(this.ralphStates);
// Atomic write: write to temp file, then rename (atomic on POSIX)
@@ -594,13 +596,17 @@ export class StateStore {
json = JSON.stringify(data, null, 2);
} catch (err) {
console.error('[StateStore] Failed to serialize Ralph state (circular reference or invalid data):', err);
throw err;
// Keep dirty flag true for retry - don't throw, let caller continue
return;
}
try {
writeFileSync(tempPath, json, 'utf-8');
renameSync(tempPath, this.ralphStatePath);
// Success - clear dirty flag
this.ralphStateDirty = false;
} catch (err) {
console.error('[StateStore] Failed to write Ralph state file:', err);
// Keep dirty flag true for retry on next save
// Try to clean up temp file on error
try {
if (existsSync(tempPath)) {
@@ -609,7 +615,7 @@ export class StateStore {
} catch (cleanupErr) {
console.warn('[StateStore] Failed to cleanup temp file during Ralph state save error:', cleanupErr);
}
throw err;
// Don't throw - let caller continue, retry on next save
}
}
@@ -655,8 +661,28 @@ export class StateStore {
/** Flushes all pending saves (main and inner state). Call before shutdown. */
flushAll(): void {
this.saveNow();
this.saveRalphStatesNow();
// Save both states, catching errors to ensure both are attempted
let mainError: unknown = null;
let ralphError: unknown = null;
try {
this.saveNow();
} catch (err) {
mainError = err;
console.error('[StateStore] Error flushing main state:', err);
}
try {
this.saveRalphStatesNow();
} catch (err) {
ralphError = err;
console.error('[StateStore] Error flushing Ralph state:', err);
}
// Log summary if any errors occurred
if (mainError || ralphError) {
console.warn('[StateStore] flushAll completed with errors - some state may not be persisted');
}
}
}
+71 -17
View File
@@ -12,7 +12,7 @@ import { createInterface } from 'node:readline';
import { homedir } from 'node:os';
import { join, basename } from 'node:path';
import { execSync } from 'node:child_process';
import { PENDING_TOOL_CALL_TTL_MS } from './config/map-limits.js';
import { PENDING_TOOL_CALL_TTL_MS, MAX_PENDING_TOOL_CALLS } from './config/map-limits.js';
// ========== Types ==========
@@ -165,6 +165,11 @@ export class SubagentWatcher extends EventEmitter {
// Map of agentId -> Map of toolUseId -> { toolName, timestamp } (for linking tool_result to tool_call)
// Includes timestamp for TTL-based cleanup of orphaned entries
private pendingToolCalls = new Map<string, Map<string, { toolName: string; timestamp: number }>>();
// Guard to prevent concurrent liveness checks (prevents duplicate completed events)
private _isCheckingLiveness = false;
// Store error handlers for FSWatchers to enable proper cleanup (prevent memory leaks)
private dirWatcherErrorHandlers = new Map<string, (error: Error) => void>();
private fileWatcherErrorHandlers = new Map<string, (error: Error) => void>();
constructor() {
super();
@@ -222,19 +227,29 @@ export class SubagentWatcher extends EventEmitter {
if (this.livenessInterval) return;
this.livenessInterval = setInterval(async () => {
for (const [agentId, info] of this.agentInfo) {
if (info.status === 'active' || info.status === 'idle') {
const alive = await this.checkSubagentAlive(agentId);
if (!alive) {
info.status = 'completed';
// Clean up pendingToolCalls for this agent to prevent memory leak
this.pendingToolCalls.delete(agentId);
this.emit('subagent:completed', info);
// Guard: prevent concurrent liveness checks (avoids duplicate completed events)
if (this._isCheckingLiveness) return;
this._isCheckingLiveness = true;
try {
for (const [agentId, info] of this.agentInfo) {
// Re-check status in case another check completed this agent
if (info.status === 'active' || info.status === 'idle') {
const alive = await this.checkSubagentAlive(agentId);
if (!alive && (info.status === 'active' || info.status === 'idle')) {
// Double-check status after async call to prevent race
info.status = 'completed';
// Clean up pendingToolCalls for this agent to prevent memory leak
this.pendingToolCalls.delete(agentId);
this.emit('subagent:completed', info);
}
}
}
// Periodically clean up stale completed agents (older than 24 hours)
this.cleanupStaleAgents();
} finally {
this._isCheckingLiveness = false;
}
// Periodically clean up stale completed agents (older than 24 hours)
this.cleanupStaleAgents();
}, LIVENESS_CHECK_MS);
}
@@ -288,11 +303,25 @@ export class SubagentWatcher extends EventEmitter {
this.livenessInterval = null;
}
// Remove error handlers before closing watchers to prevent memory leak
for (const [filePath, handler] of this.fileWatcherErrorHandlers) {
const watcher = this.fileWatchers.get(filePath);
if (watcher) watcher.off('error', handler);
}
this.fileWatcherErrorHandlers.clear();
for (const watcher of this.fileWatchers.values()) {
watcher.close();
}
this.fileWatchers.clear();
// Remove error handlers before closing watchers to prevent memory leak
for (const [dir, handler] of this.dirWatcherErrorHandlers) {
const watcher = this.dirWatchers.get(dir);
if (watcher) watcher.off('error', handler);
}
this.dirWatcherErrorHandlers.clear();
for (const watcher of this.dirWatchers.values()) {
watcher.close();
}
@@ -560,7 +589,7 @@ export class SubagentWatcher extends EventEmitter {
for (const pidStr of pids) {
const pid = parseInt(pidStr, 10);
if (isNaN(pid)) continue;
if (Number.isNaN(pid)) continue;
try {
// Check /proc/{pid}/environ for session ID
@@ -899,11 +928,15 @@ export class SubagentWatcher extends EventEmitter {
});
// Handle watcher errors to prevent unhandled exceptions
watcher.on('error', (error) => {
// Store handler reference for proper cleanup
const errorHandler = (error: Error) => {
this.emit('subagent:error', error instanceof Error ? error : new Error(String(error)));
this.dirWatcherErrorHandlers.delete(dir);
this.dirWatchers.delete(dir);
this.knownSubagentDirs.delete(dir);
});
};
watcher.on('error', errorHandler);
this.dirWatcherErrorHandlers.set(dir, errorHandler);
this.dirWatchers.set(dir, watcher);
} catch {
@@ -974,6 +1007,9 @@ export class SubagentWatcher extends EventEmitter {
// Read existing content
this.tailFile(filePath, agentId, sessionId, 0).then((position) => {
this.filePositions.set(filePath, position);
}).catch((err) => {
// Log but don't throw - non-critical background operation
console.warn(`[SubagentWatcher] Failed to read initial content for ${agentId}:`, err);
});
// Watch for changes
@@ -1026,10 +1062,14 @@ export class SubagentWatcher extends EventEmitter {
});
// Handle watcher errors to prevent unhandled exceptions
watcher.on('error', (error) => {
// Store handler reference for proper cleanup
const errorHandler = (error: Error) => {
this.emit('subagent:error', error instanceof Error ? error : new Error(String(error)), agentId);
this.fileWatcherErrorHandlers.delete(filePath);
this.fileWatchers.delete(filePath);
});
};
watcher.on('error', errorHandler);
this.fileWatcherErrorHandlers.set(filePath, errorHandler);
this.fileWatchers.set(filePath, watcher);
this.resetIdleTimer(agentId);
@@ -1175,7 +1215,21 @@ export class SubagentWatcher extends EventEmitter {
if (!this.pendingToolCalls.has(agentId)) {
this.pendingToolCalls.set(agentId, new Map());
}
this.pendingToolCalls.get(agentId)!.set(content.id, {
const agentCalls = this.pendingToolCalls.get(agentId)!;
// Enforce size limit to prevent memory leak from rapid tool calls
if (agentCalls.size >= MAX_PENDING_TOOL_CALLS) {
// Evict oldest entry by timestamp
let oldestId: string | null = null;
let oldestTime = Infinity;
for (const [id, call] of agentCalls) {
if (call.timestamp < oldestTime) {
oldestTime = call.timestamp;
oldestId = id;
}
}
if (oldestId) agentCalls.delete(oldestId);
}
agentCalls.set(content.id, {
toolName: content.name,
timestamp: Date.now(),
});
+58 -7
View File
@@ -21,6 +21,7 @@
*/
import { EventEmitter } from 'node:events';
import { assertNever } from './utils/index.js';
// ========== Configuration Constants ==========
@@ -68,6 +69,54 @@ const COMPLETE_PATTERNS = [
// ========== Type Definitions ==========
/**
* Content block for tool_use messages from Claude.
* Emitted when Claude invokes a tool.
*/
interface ClaudeToolUseBlock {
type: 'tool_use';
/** Unique identifier for this tool invocation */
id: string;
/** Name of the tool being invoked */
name: string;
/** Parameters passed to the tool */
input?: {
description?: string;
prompt?: string;
subagent_type?: string;
[key: string]: unknown;
};
}
/**
* Content block for tool_result messages from Claude.
* Emitted when a tool completes execution.
*/
interface ClaudeToolResultBlock {
type: 'tool_result';
/** ID of the tool_use this result corresponds to */
tool_use_id: string;
/** Whether the tool execution resulted in an error */
is_error?: boolean;
/** Result content (string or structured data) */
content?: string | unknown;
}
/**
* Union type for content blocks we care about.
*/
type ClaudeContentBlock = ClaudeToolUseBlock | ClaudeToolResultBlock | { type: string };
/**
* Claude JSON message structure.
* This is the format Claude Code outputs for streaming events.
*/
interface ClaudeMessage {
message?: {
content?: ClaudeContentBlock[];
};
}
/**
* Represents a background task spawned by Claude Code.
*
@@ -189,14 +238,14 @@ export class TaskTracker extends EventEmitter {
* @fires taskCompleted - When a task finishes successfully
* @fires taskFailed - When a task finishes with error
*/
processMessage(msg: any): void {
processMessage(msg: ClaudeMessage | null | undefined): void {
if (!msg || !msg.message?.content) return;
for (const block of msg.message.content) {
if (block.type === 'tool_use' && block.name === 'Task') {
this.handleTaskToolUse(block);
if (block.type === 'tool_use' && (block as ClaudeToolUseBlock).name === 'Task') {
this.handleTaskToolUse(block as ClaudeToolUseBlock);
} else if (block.type === 'tool_result') {
this.handleToolResult(block);
this.handleToolResult(block as ClaudeToolResultBlock);
}
}
}
@@ -256,7 +305,7 @@ export class TaskTracker extends EventEmitter {
* @param block - The tool_use content block
* @fires taskCreated
*/
private handleTaskToolUse(block: any): void {
private handleTaskToolUse(block: ClaudeToolUseBlock): void {
const toolUseId = block.id;
const params = block.input || {};
@@ -305,7 +354,7 @@ export class TaskTracker extends EventEmitter {
* @fires taskCompleted - If result is success
* @fires taskFailed - If result is error
*/
private handleToolResult(block: any): void {
private handleToolResult(block: ClaudeToolResultBlock): void {
const toolUseId = block.tool_use_id;
const task = this.tasks.get(toolUseId);
@@ -416,7 +465,7 @@ export class TaskTracker extends EventEmitter {
* @fires taskCreated
*/
private createTaskFromTerminal(agentType: string, _context: string): void {
const taskId = `terminal-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
const taskId = `terminal-${Date.now()}-${Math.random().toString(36).slice(2, 11)}`;
const parentId = this.taskStack.length > 0 ? this.taskStack[this.taskStack.length - 1] : null;
const task: BackgroundTask = {
@@ -547,6 +596,8 @@ export class TaskTracker extends EventEmitter {
case 'running': running++; break;
case 'completed': completed++; break;
case 'failed': failed++; break;
default:
assertNever(task.status, `Unhandled BackgroundTask status: ${task.status}`);
}
}
+11
View File
@@ -230,6 +230,17 @@ export class TranscriptWatcher extends EventEmitter {
}
});
// Add error handler to prevent unhandled errors and fall back to polling
this.fileWatcher.on('error', (err) => {
this.emit('transcript:error', err as Error);
this.fileWatcher?.close();
this.fileWatcher = null;
// Fall back to polling on error
if (this._isRunning) {
this.startPolling();
}
});
// Initial read
this.processNewContent();
} catch (err) {
+5 -12
View File
@@ -763,19 +763,12 @@ export interface HookEventRequest {
// ========== API Response Types ==========
/**
* Standard API response wrapper
* Standard API response wrapper (discriminated union for type safety)
* @template T Type of the data payload
*/
export interface ApiResponse<T = unknown> {
/** Whether the request succeeded */
success: boolean;
/** Error message if failed */
error?: string;
/** Error code for programmatic handling */
errorCode?: ApiErrorCode;
/** Response data payload */
data?: T;
}
export type ApiResponse<T = unknown> =
| { success: true; data?: T }
| { success: false; error: string; errorCode: ApiErrorCode };
/**
* Creates a standardized error response
@@ -783,7 +776,7 @@ export interface ApiResponse<T = unknown> {
* @param details Optional detailed error message
* @returns Formatted error response
*/
export function createErrorResponse(code: ApiErrorCode, details?: string): ApiResponse {
export function createErrorResponse(code: ApiErrorCode, details?: string): ApiResponse<never> {
return {
success: false,
error: details || ErrorMessages[code],
+1
View File
@@ -33,3 +33,4 @@ export {
fuzzyPhraseMatch,
todoContentHash,
} from './string-similarity.js';
export { assertNever } from './type-safety.js';
+13
View File
@@ -199,10 +199,15 @@ export class LRUMap<K, V> extends Map<K, V> {
const now = Date.now();
const cutoff = now - maxAge;
let evicted = 0;
let deletedNewest = false;
// Iterate from oldest to newest
for (const [key, value] of super.entries()) {
if (getTimestamp(value) < cutoff) {
// Track if we're deleting the newest key
if (key === this._newestKey) {
deletedNewest = true;
}
super.delete(key);
this.onEvict?.(key, value);
evicted++;
@@ -213,6 +218,14 @@ export class LRUMap<K, V> extends Map<K, V> {
}
}
// Update _newestKey if we deleted it (find new newest from remaining entries)
if (deletedNewest) {
this._newestKey = undefined;
for (const k of super.keys()) {
this._newestKey = k; // Last one becomes newest
}
}
return evicted;
}
+35
View File
@@ -0,0 +1,35 @@
/**
* @fileoverview Type safety utilities for exhaustive checking.
*
* @module utils/type-safety
*/
/**
* Assert that a value should never occur at runtime.
* Used in switch/case default blocks to ensure exhaustive handling.
*
* If TypeScript sees a code path where `value` is not `never`,
* it will report a compile-time error, catching missing cases.
*
* @param value - The value that should be of type `never`
* @param message - Optional custom error message
* @throws Error if called at runtime (indicates unhandled case)
*
* @example
* type Status = 'active' | 'inactive' | 'pending';
* function handleStatus(status: Status): void {
* switch (status) {
* case 'active': // handle active
* break;
* case 'inactive': // handle inactive
* break;
* case 'pending': // handle pending
* break;
* default:
* assertNever(status); // Compile error if case is missing
* }
* }
*/
export function assertNever(value: never, message?: string): never {
throw new Error(message ?? `Unexpected value: ${JSON.stringify(value)}`);
}
+21 -4
View File
@@ -1444,8 +1444,12 @@ class ClaudemanApp {
};
window.addEventListener('resize', throttledResize);
const resizeObserver = new ResizeObserver(throttledResize);
resizeObserver.observe(container);
// Store resize observer for cleanup (prevents memory leak on terminal re-init)
if (this.terminalResizeObserver) {
this.terminalResizeObserver.disconnect();
}
this.terminalResizeObserver = new ResizeObserver(throttledResize);
this.terminalResizeObserver.observe(container);
// Handle keyboard input with batching for rapid keystrokes
this._pendingInput = '';
@@ -1860,10 +1864,11 @@ class ClaudemanApp {
}
}, true); // Use capture phase to handle before terminal
// Token stats click handler
// Token stats click handler (with guard to prevent duplicate handlers on reconnect)
const tokenEl = this.$('headerTokens');
if (tokenEl) {
if (tokenEl && !tokenEl._statsHandlerAttached) {
tokenEl.classList.add('clickable');
tokenEl._statsHandlerAttached = true;
tokenEl.addEventListener('click', () => this.openTokenStats());
}
@@ -2913,6 +2918,18 @@ class ClaudemanApp {
clearInterval(this.notificationManager.titleFlashInterval);
this.notificationManager.titleFlashInterval = null;
}
// Clear notification manager grouping timeouts (prevents orphaned timers)
if (this.notificationManager?.groupingMap) {
for (const { timeout } of this.notificationManager.groupingMap.values()) {
clearTimeout(timeout);
}
this.notificationManager.groupingMap.clear();
}
// Disconnect terminal resize observer (prevents memory leak on reconnect)
if (this.terminalResizeObserver) {
this.terminalResizeObserver.disconnect();
this.terminalResizeObserver = null;
}
// Clear any other orphaned timers
if (this.planLoadingTimer) {
clearInterval(this.planLoadingTimer);
+141
View File
@@ -0,0 +1,141 @@
/**
* @fileoverview Zod validation schemas for API routes
*
* This module contains Zod schemas for validating API request bodies.
* Schemas are used in src/web/server.ts route handlers.
*
* @module web/schemas
*/
import { z } from 'zod';
// ========== Session Routes ==========
/**
* Schema for POST /api/sessions
* Creates a new session with optional working directory, mode, and name.
*/
export const CreateSessionSchema = z.object({
workingDir: z.string().optional(),
mode: z.enum(['claude', 'shell']).optional(),
name: z.string().max(100).optional(),
});
/**
* Schema for POST /api/sessions/:id/run
* Runs a prompt in a session.
*/
export const RunPromptSchema = z.object({
prompt: z.string().min(1).max(100000),
});
/**
* Schema for POST /api/sessions/:id/input
* Sends input to an interactive session.
*/
export const SessionInputSchema = z.object({
input: z.string(),
useScreen: z.boolean().optional(),
});
/**
* Schema for POST /api/sessions/:id/resize
* Resizes a session's terminal.
*/
export const ResizeSchema = z.object({
cols: z.number().int().min(1).max(500),
rows: z.number().int().min(1).max(200),
});
// ========== Case Routes ==========
/**
* Schema for POST /api/cases
* Creates a new case folder.
*/
export const CreateCaseSchema = z.object({
name: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format. Use only letters, numbers, hyphens, underscores.'),
description: z.string().max(1000).optional(),
});
// ========== Quick Start ==========
/**
* Schema for POST /api/quick-start
* Creates case (if needed) and starts interactive session.
*/
export const QuickStartSchema = z.object({
caseName: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format. Use only letters, numbers, hyphens, underscores.').optional(),
mode: z.enum(['claude', 'shell']).optional(),
});
// ========== Hook Events ==========
/**
* Schema for POST /api/hook-event
* Receives Claude Code hook events.
*/
export const HookEventSchema = z.object({
event: z.enum(['permission_prompt', 'elicitation_dialog', 'idle_prompt', 'stop']),
sessionId: z.string().min(1),
data: z.unknown().optional(),
});
// ========== Configuration ==========
/**
* Schema for respawn configuration (partial updates allowed)
* Used in PUT /api/config and respawn endpoints.
*/
export const RespawnConfigSchema = z.object({
idleTimeoutMs: z.number().int().min(1000).max(600000).optional(),
updatePrompt: z.string().max(10000).optional(),
interStepDelayMs: z.number().int().min(100).max(60000).optional(),
enabled: z.boolean().optional(),
sendClear: z.boolean().optional(),
sendUpdate: z.boolean().optional(),
sendInit: z.boolean().optional(),
sendKickstart: z.boolean().optional(),
kickstartPrompt: z.string().max(10000).optional(),
aiIdleCheckEnabled: z.boolean().optional(),
aiIdleCheckTimeoutMs: z.number().int().min(10000).max(300000).optional(),
aiIdleCheckModel: z.string().max(100).optional(),
completionConfirmMs: z.number().int().min(1000).max(60000).optional(),
noOutputTimeoutMs: z.number().int().min(5000).max(600000).optional(),
maxIterations: z.number().int().min(0).max(10000).optional(),
stuckStateWarningMs: z.number().int().min(60000).max(3600000).optional(),
autoAcceptEnabled: z.boolean().optional(),
autoAcceptDelayMs: z.number().int().min(1000).max(60000).optional(),
planModeEnabled: z.boolean().optional(),
planCheckTimeoutMs: z.number().int().min(10000).max(300000).optional(),
planCheckModel: z.string().max(100).optional(),
}).strict();
/**
* Schema for PUT /api/config
* Updates application configuration with whitelist of allowed fields.
*/
export const ConfigUpdateSchema = z.object({
pollIntervalMs: z.number().int().min(100).max(60000).optional(),
defaultTimeoutMs: z.number().int().min(1000).max(3600000).optional(),
maxConcurrentSessions: z.number().int().min(1).max(50).optional(),
respawn: RespawnConfigSchema.optional(),
}).strict();
/**
* Schema for PUT /api/settings
* User settings with allowed fields only.
*/
export const SettingsUpdateSchema = z.object({
defaultClaudeMdPath: z.string().max(500).optional(),
lastUsedCase: z.string().max(200).optional(),
// Add other known settings fields as needed
}).passthrough(); // Allow additional fields but validate known ones
/**
* Schema for POST /api/sessions/:id/input with length limit
*/
export const SessionInputWithLimitSchema = z.object({
input: z.string().max(100000), // 100KB max input
useScreen: z.boolean().optional(),
});
+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();
}
}