From 8be7c9eb597c0c9424af49f2baa7c6b0229c154b Mon Sep 17 00:00:00 2001 From: Noah Waldner Date: Fri, 27 Feb 2026 11:29:24 +0100 Subject: [PATCH] adjust formatting --- src/ai-checker-base.ts | 58 +- src/ai-idle-checker.ts | 12 +- src/ai-plan-checker.ts | 16 +- src/bash-tool-parser.ts | 39 +- src/cli.ts | 99 +- src/file-stream-manager.ts | 18 +- src/hooks-config.ts | 5 +- src/image-watcher.ts | 41 +- src/index.ts | 8 +- src/mux-interface.ts | 9 +- src/plan-orchestrator.ts | 236 ++++- src/push-store.ts | 5 +- src/ralph-loop.ts | 5 +- src/ralph-tracker.ts | 382 ++++--- src/respawn-controller.ts | 603 +++++++---- src/run-summary.ts | 28 +- src/session-manager.ts | 7 +- src/session.ts | 300 ++++-- src/state-store.ts | 81 +- src/subagent-watcher.ts | 211 ++-- src/task-queue.ts | 30 +- src/task-tracker.ts | 39 +- src/team-watcher.ts | 33 +- src/templates/claude-md.ts | 13 +- src/tmux-manager.ts | 383 ++++--- src/transcript-watcher.ts | 13 +- src/tunnel-manager.ts | 10 +- src/types.ts | 50 +- src/utils/buffer-accumulator.ts | 2 +- src/utils/claude-cli-resolver.ts | 2 +- src/utils/cleanup-manager.ts | 77 +- src/utils/index.ts | 6 +- src/utils/opencode-cli-resolver.ts | 21 +- src/utils/regex-patterns.ts | 4 +- src/utils/string-similarity.ts | 12 +- src/web/schemas.ts | 311 ++++-- src/web/server.ts | 1535 ++++++++++++++++++++-------- 37 files changed, 3182 insertions(+), 1522 deletions(-) diff --git a/src/ai-checker-base.ts b/src/ai-checker-base.ts index dc9711c7..a7030bea 100644 --- a/src/ai-checker-base.ts +++ b/src/ai-checker-base.ts @@ -115,7 +115,7 @@ export abstract class AiCheckerBase< V extends string, C extends AiCheckerConfigBase, R extends AiCheckerResultBase, - S extends AiCheckerStateBase + S extends AiCheckerStateBase, > extends EventEmitter { protected config: C; protected sessionId: string; @@ -283,11 +283,15 @@ export abstract class AiCheckerBase< if (result.verdict === this.getPositiveVerdict()) { this.consecutiveErrors = 0; this._status = 'ready'; - this.log(`${this.checkDescription} verdict: ${result.verdict} (${result.durationMs}ms) - ${result.reasoning}`); + this.log( + `${this.checkDescription} verdict: ${result.verdict} (${result.durationMs}ms) - ${result.reasoning}` + ); } else if (result.verdict === this.getNegativeVerdict()) { this.consecutiveErrors = 0; this.startCooldown(this.config.cooldownMs); - this.log(`${this.checkDescription} verdict: ${result.verdict} (${result.durationMs}ms) - ${result.reasoning}`); + this.log( + `${this.checkDescription} verdict: ${result.verdict} (${result.durationMs}ms) - ${result.reasoning}` + ); } else { this.handleError('Unexpected verdict'); } @@ -369,9 +373,10 @@ export abstract class AiCheckerBase< // Prepare the terminal buffer (strip ANSI, trim to maxContextChars) const stripped = terminalBuffer.replace(ANSI_ESCAPE_PATTERN_SIMPLE, ''); - const trimmed = stripped.length > this.config.maxContextChars - ? stripped.slice(-this.config.maxContextChars) - : stripped; + const trimmed = + stripped.length > this.config.maxContextChars + ? stripped.slice(-this.config.maxContextChars) + : stripped; // Build the prompt const prompt = this.buildPrompt(trimmed); @@ -380,7 +385,10 @@ export abstract class AiCheckerBase< const shortId = this.sessionId.slice(0, 8); const timestamp = Date.now(); this.checkTempFile = join(tmpdir(), `${this.tempFilePrefix}-${shortId}-${timestamp}.txt`); - this.checkPromptFile = join(tmpdir(), `${this.tempFilePrefix}-prompt-${shortId}-${timestamp}.txt`); + this.checkPromptFile = join( + tmpdir(), + `${this.tempFilePrefix}-prompt-${shortId}-${timestamp}.txt` + ); this.checkMuxName = `${this.muxNamePrefix}${shortId}`; // Security: Validate mux name before use in shell commands @@ -411,16 +419,19 @@ export abstract class AiCheckerBase< // No existing session, that's fine } - const muxProcess = childSpawn('tmux', [ - 'new-session', '-d', '-s', this.checkMuxName, - 'bash', '-c', fullCmd - ], { - detached: true, - stdio: 'ignore', - }); + const muxProcess = childSpawn( + 'tmux', + ['new-session', '-d', '-s', this.checkMuxName, 'bash', '-c', fullCmd], + { + detached: true, + stdio: 'ignore', + } + ); muxProcess.unref(); } catch (err) { - throw new Error(`Failed to spawn ${this.checkDescription} tmux session: ${err instanceof Error ? err.message : String(err)}`); + throw new Error( + `Failed to spawn ${this.checkDescription} tmux session: ${err instanceof Error ? err.message : String(err)}` + ); } // Poll the temp file for completion @@ -457,7 +468,9 @@ export abstract class AiCheckerBase< 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`)); + reject( + new Error(`${this.checkDescription} timed out after ${this.config.checkTimeoutMs}ms`) + ); } }, this.config.checkTimeoutMs); }); @@ -474,7 +487,10 @@ export abstract class AiCheckerBase< // Delegate to subclass for verdict parsing const parsed = this.parseVerdict(output); if (!parsed) { - return this.createErrorResult(`Could not parse verdict from: "${output.substring(0, 100)}"`, durationMs); + return this.createErrorResult( + `Could not parse verdict from: "${output.substring(0, 100)}"`, + durationMs + ); } return this.createResult(parsed.verdict, parsed.reasoning, durationMs); @@ -530,7 +546,9 @@ export abstract class AiCheckerBase< private handleError(errorMsg: string): void { this.consecutiveErrors++; - this.log(`${this.checkDescription} error (${this.consecutiveErrors}/${this.config.maxConsecutiveErrors}): ${errorMsg}`); + this.log( + `${this.checkDescription} error (${this.consecutiveErrors}/${this.config.maxConsecutiveErrors}): ${errorMsg}` + ); if (this.consecutiveErrors >= this.config.maxConsecutiveErrors) { this.disable(`${this.config.maxConsecutiveErrors} consecutive errors: ${errorMsg}`); @@ -542,7 +560,9 @@ export abstract class AiCheckerBase< this.config.errorCooldownMs * backoffMultiplier, 5 * 60 * 1000 // Max 5 minutes ); - this.log(`Exponential backoff: ${Math.round(backoffCooldownMs / 1000)}s (error #${this.consecutiveErrors})`); + this.log( + `Exponential backoff: ${Math.round(backoffCooldownMs / 1000)}s (error #${this.consecutiveErrors})` + ); this.startCooldown(backoffCooldownMs); } } diff --git a/src/ai-idle-checker.ts b/src/ai-idle-checker.ts index b5154602..d3213a40 100644 --- a/src/ai-idle-checker.ts +++ b/src/ai-idle-checker.ts @@ -33,13 +33,13 @@ import { // ========== Types ========== -export interface AiIdleCheckConfig extends AiCheckerConfigBase {} +export type AiIdleCheckConfig = AiCheckerConfigBase; export type AiCheckVerdict = 'IDLE' | 'WORKING' | 'ERROR'; -export interface AiCheckResult extends AiCheckerResultBase {} +export type AiCheckResult = AiCheckerResultBase; -export interface AiCheckState extends AiCheckerStateBase {} +export type AiCheckState = AiCheckerStateBase; // ========== Constants ========== @@ -170,7 +170,11 @@ export class AiIdleChecker extends AiCheckerBase< return { verdict: 'ERROR', reasoning, durationMs }; } - protected createResult(verdict: AiCheckVerdict, reasoning: string, durationMs: number): AiCheckResult { + protected createResult( + verdict: AiCheckVerdict, + reasoning: string, + durationMs: number + ): AiCheckResult { return { verdict, reasoning, durationMs }; } } diff --git a/src/ai-plan-checker.ts b/src/ai-plan-checker.ts index f302f88f..93d32851 100644 --- a/src/ai-plan-checker.ts +++ b/src/ai-plan-checker.ts @@ -32,13 +32,13 @@ import { // ========== Types ========== -export interface AiPlanCheckConfig extends AiCheckerConfigBase {} +export type AiPlanCheckConfig = AiCheckerConfigBase; export type AiPlanCheckVerdict = 'PLAN_MODE' | 'NOT_PLAN_MODE' | 'ERROR'; -export interface AiPlanCheckResult extends AiCheckerResultBase {} +export type AiPlanCheckResult = AiCheckerResultBase; -export interface AiPlanCheckState extends AiCheckerStateBase {} +export type AiPlanCheckState = AiCheckerStateBase; // ========== Constants ========== @@ -105,7 +105,9 @@ export class AiPlanChecker extends AiCheckerBase< return AI_PLAN_CHECK_PROMPT.replace('{TERMINAL_BUFFER}', terminalBuffer); } - protected parseVerdict(output: string): { verdict: AiPlanCheckVerdict; reasoning: string } | null { + protected parseVerdict( + output: string + ): { verdict: AiPlanCheckVerdict; reasoning: string } | null { const match = output.match(VERDICT_PATTERN); if (!match) return null; @@ -132,7 +134,11 @@ export class AiPlanChecker extends AiCheckerBase< return { verdict: 'ERROR', reasoning, durationMs }; } - protected createResult(verdict: AiPlanCheckVerdict, reasoning: string, durationMs: number): AiPlanCheckResult { + protected createResult( + verdict: AiPlanCheckVerdict, + reasoning: string, + durationMs: number + ): AiPlanCheckResult { return { verdict, reasoning, durationMs }; } } diff --git a/src/bash-tool-parser.ts b/src/bash-tool-parser.ts index 6e6e92c5..b6e3ba4b 100644 --- a/src/bash-tool-parser.ts +++ b/src/bash-tool-parser.ts @@ -73,25 +73,27 @@ const FOLLOW_MODE_PATTERN = /\s-[A-Za-z]*f[A-Za-z]*\s|\s--follow\s/; * Note: This is a simpler approach - we run it on each command string * rather than trying to match globally. */ -const FILE_PATH_PATTERN = /(?:^|\s|['"]|=)([\/~][^\s'"<>|;&\n]+)/g; +const FILE_PATH_PATTERN = /(?:^|\s|['"]|=)([/~][^\s'"<>|;&\n]+)/g; /** * Pattern to detect paths that are likely not real files (flags, etc.) */ -const INVALID_PATH_PATTERN = /^[\/~]-|\/dev\/null$/; +const INVALID_PATH_PATTERN = /^[/~]-|\/dev\/null$/; /** * Pattern to detect command suggestions in plain text output. * Matches lines like "tail -f /path/to/file" without the ● Bash() wrapper. * This catches commands Claude mentions but doesn't execute. */ -const TEXT_COMMAND_PATTERN = /^\s*(tail|cat|head|less|grep|watch|multitail)\s+(?:-[^\s]+\s+)*([\/~][^\s'"<>|;&\n]+)/; +const TEXT_COMMAND_PATTERN = + /^\s*(tail|cat|head|less|grep|watch|multitail)\s+(?:-[^\s]+\s+)*([/~][^\s'"<>|;&\n]+)/; /** * Pattern to detect log file paths mentioned in text (even without commands). * Matches paths ending in .log, .txt, .out, or in common log directories. */ -const LOG_FILE_MENTION_PATTERN = /([\/~][^\s'"<>|;&\n]*(?:\.log|\.txt|\.out|\/log\/[^\s'"<>|;&\n]+))/g; +const LOG_FILE_MENTION_PATTERN = + /([/~][^\s'"<>|;&\n]*(?:\.log|\.txt|\.out|\/log\/[^\s'"<>|;&\n]+))/g; // ========== Event Interfaces ========== @@ -245,7 +247,7 @@ export class BashToolParser extends EventEmitter { */ private isShallowRootPath(path: string): boolean { if (!path.startsWith('/')) return false; - const parts = path.split('/').filter(p => p !== ''); + const parts = path.split('/').filter((p) => p !== ''); return parts.length === 1; } @@ -354,10 +356,10 @@ export class BashToolParser extends EventEmitter { isFilePathTracked(filePath: string): boolean { const normalizedNew = this.normalizePath(filePath); - return Array.from(this._activeTools.values()).some(t => { + return Array.from(this._activeTools.values()).some((t) => { if (t.status !== 'running') return false; - return t.filePaths.some(existingPath => { + return t.filePaths.some((existingPath) => { const normalizedExisting = this.normalizePath(existingPath); return normalizedExisting === normalizedNew; }); @@ -418,9 +420,10 @@ export class BashToolParser extends EventEmitter { // Prevent unbounded growth if (this._lineBuffer.length > MAX_LINE_BUFFER_SIZE) { const trimPoint = this._lineBuffer.lastIndexOf('\n', MAX_LINE_BUFFER_SIZE / 2); - this._lineBuffer = trimPoint > 0 - ? this._lineBuffer.slice(trimPoint + 1) - : this._lineBuffer.slice(-MAX_LINE_BUFFER_SIZE / 2); + this._lineBuffer = + trimPoint > 0 + ? this._lineBuffer.slice(trimPoint + 1) + : this._lineBuffer.slice(-MAX_LINE_BUFFER_SIZE / 2); } // Process complete lines @@ -445,9 +448,10 @@ export class BashToolParser extends EventEmitter { if (this._lineBuffer.length > MAX_LINE_BUFFER_SIZE) { const trimPoint = this._lineBuffer.lastIndexOf('\n', MAX_LINE_BUFFER_SIZE / 2); - this._lineBuffer = trimPoint > 0 - ? this._lineBuffer.slice(trimPoint + 1) - : this._lineBuffer.slice(-MAX_LINE_BUFFER_SIZE / 2); + this._lineBuffer = + trimPoint > 0 + ? this._lineBuffer.slice(trimPoint + 1) + : this._lineBuffer.slice(-MAX_LINE_BUFFER_SIZE / 2); } const lines = this._lineBuffer.split('\n'); @@ -472,7 +476,6 @@ export class BashToolParser extends EventEmitter { * Process a single pre-stripped line of terminal output. */ private processCleanLine(cleanLine: string): void { - // Check for tool start const startMatch = cleanLine.match(BASH_TOOL_START_PATTERN); if (startMatch) { @@ -484,7 +487,7 @@ export class BashToolParser extends EventEmitter { const filePaths = this.extractFilePaths(command); // Skip if any file path is already tracked (cross-pattern dedup) - if (filePaths.some(fp => this.isFilePathTracked(fp))) { + if (filePaths.some((fp) => this.isFilePathTracked(fp))) { return; } @@ -502,8 +505,9 @@ export class BashToolParser extends EventEmitter { // Enforce max tools limit if (this._activeTools.size >= MAX_ACTIVE_TOOLS) { // Remove oldest tool - const oldest = Array.from(this._activeTools.entries()) - .sort((a, b) => a[1].startedAt - b[1].startedAt)[0]; + const oldest = Array.from(this._activeTools.entries()).sort( + (a, b) => a[1].startedAt - b[1].startedAt + )[0]; if (oldest) { this._activeTools.delete(oldest[0]); } @@ -671,6 +675,7 @@ export class BashToolParser extends EventEmitter { */ private stripAnsi(str: string): string { // Comprehensive ANSI pattern + // eslint-disable-next-line no-control-regex return str.replace(/\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[=>])/g, ''); } diff --git a/src/cli.ts b/src/cli.ts index 425d6c03..3443c7e5 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -28,10 +28,7 @@ program // ============ Session Commands ============ -const sessionCmd = program - .command('session') - .alias('s') - .description('Manage Claude sessions'); +const sessionCmd = program.command('session').alias('s').description('Manage Claude sessions'); sessionCmd .command('start') @@ -83,11 +80,12 @@ sessionCmd console.log(' (none)'); } else { for (const session of sessions) { - const status = session.status === 'idle' - ? chalk.green('idle') - : session.status === 'busy' - ? chalk.yellow('busy') - : chalk.red(session.status); + const status = + session.status === 'idle' + ? chalk.green('idle') + : session.status === 'busy' + ? chalk.yellow('busy') + : chalk.red(session.status); console.log(` ${chalk.cyan(session.id.slice(0, 8))} ${status} ${session.workingDir}`); } } @@ -97,7 +95,9 @@ sessionCmd console.log(chalk.bold('\nStopped Sessions:')); for (const session of stoppedSessions) { const name = session.name ? ` (${session.name})` : ''; - console.log(` ${chalk.gray(session.id.slice(0, 8))} ${chalk.gray('stopped')}${name} ${session.workingDir}`); + console.log( + ` ${chalk.gray(session.id.slice(0, 8))} ${chalk.gray('stopped')}${name} ${session.workingDir}` + ); } } @@ -106,15 +106,18 @@ sessionCmd if (sessions.length === 0 && activeSessions.length > 0) { console.log(chalk.bold('\nActive Sessions (from web server):')); for (const session of activeSessions) { - const status = session.status === 'idle' - ? chalk.green('idle') - : session.status === 'busy' - ? chalk.yellow('busy') - : chalk.red(session.status); + const status = + session.status === 'idle' + ? chalk.green('idle') + : session.status === 'busy' + ? chalk.yellow('busy') + : chalk.red(session.status); const name = session.name ? ` (${session.name})` : ''; const mode = session.mode === 'shell' ? chalk.gray(' [shell]') : ''; const cost = session.totalCost ? chalk.gray(` $${session.totalCost.toFixed(4)}`) : ''; - console.log(` ${chalk.cyan(session.id.slice(0, 8))} ${status}${name}${mode}${cost} ${session.workingDir}`); + console.log( + ` ${chalk.cyan(session.id.slice(0, 8))} ${status}${name}${mode}${cost} ${session.workingDir}` + ); } } console.log(''); @@ -126,9 +129,7 @@ sessionCmd .option('-e, --errors', 'Show stderr instead of stdout') .action((id, options) => { const manager = getSessionManager(); - const output = options.errors - ? manager.getSessionError(id) - : manager.getSessionOutput(id); + const output = options.errors ? manager.getSessionError(id) : manager.getSessionOutput(id); if (output === null) { console.log(chalk.yellow(`Session ${id} not found or not active`)); @@ -145,10 +146,7 @@ sessionCmd // ============ Task Commands ============ -const taskCmd = program - .command('task') - .alias('t') - .description('Manage tasks'); +const taskCmd = program.command('task').alias('t').description('Manage tasks'); taskCmd .command('add ') @@ -200,12 +198,16 @@ taskCmd for (const task of tasks) { const color = statusColors[task.status]; const prompt = task.prompt.slice(0, 40) + (task.prompt.length > 40 ? '...' : ''); - console.log(` ${chalk.cyan(task.id.slice(0, 8))} ${color(task.status.padEnd(10))} [${task.priority}] ${prompt}`); + console.log( + ` ${chalk.cyan(task.id.slice(0, 8))} ${color(task.status.padEnd(10))} [${task.priority}] ${prompt}` + ); } const counts = queue.getCount(); console.log(chalk.bold('\nSummary:')); - console.log(` Pending: ${counts.pending}, Running: ${counts.running}, Completed: ${counts.completed}, Failed: ${counts.failed}`); + console.log( + ` Pending: ${counts.pending}, Running: ${counts.running}, Completed: ${counts.completed}, Failed: ${counts.failed}` + ); console.log(''); }); @@ -301,7 +303,9 @@ ralphCmd } loop.on('taskAssigned', (taskId, sessionId) => { - console.log(chalk.cyan(`→ Task ${taskId.slice(0, 8)} assigned to session ${sessionId.slice(0, 8)}`)); + console.log( + chalk.cyan(`→ Task ${taskId.slice(0, 8)} assigned to session ${sessionId.slice(0, 8)}`) + ); }); loop.on('taskCompleted', (taskId) => { @@ -356,16 +360,20 @@ ralphCmd function printStats(stats: ReturnType['getStats']>) { const statusColor = - stats.status === 'running' ? chalk.green : - stats.status === 'paused' ? chalk.yellow : - chalk.gray; + stats.status === 'running' + ? chalk.green + : stats.status === 'paused' + ? chalk.yellow + : chalk.gray; console.log(chalk.bold('\nRalph Loop Status:')); console.log(` Status: ${statusColor(stats.status)}`); console.log(` Elapsed: ${stats.elapsedHours.toFixed(2)} hours`); if (stats.minDurationMs) { const minHours = stats.minDurationMs / (1000 * 60 * 60); - console.log(` Min Duration: ${minHours.toFixed(2)} hours (${stats.minDurationReached ? 'reached' : 'not reached'})`); + console.log( + ` Min Duration: ${minHours.toFixed(2)} hours (${stats.minDurationReached ? 'reached' : 'not reached'})` + ); } console.log(chalk.bold('\nTasks:')); @@ -399,7 +407,8 @@ program const loopStatus = loop.status; // Use live sessions if available, otherwise fall back to stored state - const activeCount = sessions.length || storedValues.filter((s) => s.status !== 'stopped').length; + const activeCount = + sessions.length || storedValues.filter((s) => s.status !== 'stopped').length; const idleCount = sessions.length ? sessions.filter((s) => s.isIdle()).length : storedValues.filter((s) => s.status === 'idle').length; @@ -423,9 +432,7 @@ program console.log(` Failed: ${taskCounts.failed}`); const statusColor = - loopStatus === 'running' ? chalk.green : - loopStatus === 'paused' ? chalk.yellow : - chalk.gray; + loopStatus === 'running' ? chalk.green : loopStatus === 'paused' ? chalk.yellow : chalk.gray; console.log(chalk.bold('\nRalph Loop:')); console.log(` Status: ${statusColor(loopStatus)}`); console.log(''); @@ -481,11 +488,12 @@ program console.log(' (none)'); } else { for (const session of sessions) { - const status = session.status === 'idle' - ? chalk.green('idle') - : session.status === 'busy' - ? chalk.yellow('busy') - : chalk.red(session.status); + const status = + session.status === 'idle' + ? chalk.green('idle') + : session.status === 'busy' + ? chalk.yellow('busy') + : chalk.red(session.status); console.log(` ${chalk.cyan(session.id.slice(0, 8))} ${status} ${session.workingDir}`); } } @@ -497,20 +505,27 @@ program .command('web') .description('Start the web interface') .option('-p, --port ', 'Port to listen on', '3000') - .option('--https', 'Enable HTTPS with self-signed certificate (only needed for remote access, not localhost)') + .option( + '--https', + 'Enable HTTPS with self-signed certificate (only needed for remote access, not localhost)' + ) .action(async (options) => { const { startWebServer } = await import('./web/server.js'); const port = parseInt(options.port, 10); const https = !!options.https; const protocol = https ? 'https' : 'http'; - console.log(chalk.cyan(`Starting Codeman web interface on port ${port}${https ? ' (HTTPS)' : ''}...`)); + console.log( + chalk.cyan(`Starting Codeman web interface on port ${port}${https ? ' (HTTPS)' : ''}...`) + ); try { const server = await startWebServer(port, https); console.log(chalk.green(`\n✓ Web interface running at ${protocol}://localhost:${port}`)); if (https) { - console.log(chalk.yellow(' Note: Accept the self-signed certificate in your browser on first visit')); + console.log( + chalk.yellow(' Note: Accept the self-signed certificate in your browser on first visit') + ); } console.log(chalk.gray(' Press Ctrl+C to stop\n')); diff --git a/src/file-stream-manager.ts b/src/file-stream-manager.ts index 55baea4c..777b153f 100644 --- a/src/file-stream-manager.ts +++ b/src/file-stream-manager.ts @@ -141,7 +141,15 @@ export class FileStreamManager extends EventEmitter { * @returns Result with stream ID on success, error on failure */ async createStream(options: CreateStreamOptions): Promise { - const { sessionId, filePath, workingDir, lines = DEFAULT_TAIL_LINES, onData, onEnd, onError } = options; + const { + sessionId, + filePath, + workingDir, + lines = DEFAULT_TAIL_LINES, + onData, + onEnd, + onError, + } = options; // Check concurrent stream limit for this session const currentCount = this.sessionStreamCounts.get(sessionId) || 0; @@ -170,8 +178,12 @@ export class FileStreamManager extends EventEmitter { }; } } 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)); + 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' }; } diff --git a/src/hooks-config.ts b/src/hooks-config.ts index f9843c71..758656c3 100644 --- a/src/hooks-config.ts +++ b/src/hooks-config.ts @@ -71,7 +71,10 @@ export function generateHooksConfig(): { hooks: Record } { * Updates env vars in .claude/settings.local.json for the given case path. * Merges with existing env field; removes vars set to empty string. */ -export async function updateCaseEnvVars(casePath: string, envVars: Record): Promise { +export async function updateCaseEnvVars( + casePath: string, + envVars: Record +): Promise { const claudeDir = join(casePath, '.claude'); if (!existsSync(claudeDir)) { await mkdir(claudeDir, { recursive: true }); diff --git a/src/image-watcher.ts b/src/image-watcher.ts index 87c43dc1..a5838174 100644 --- a/src/image-watcher.ts +++ b/src/image-watcher.ts @@ -24,15 +24,7 @@ export interface ImageWatcherEvents { // ========== Constants ========== /** Supported image file extensions (lowercase) */ -const IMAGE_EXTENSIONS = new Set([ - '.png', - '.jpg', - '.jpeg', - '.gif', - '.webp', - '.bmp', - '.svg', -]); +const IMAGE_EXTENSIONS = new Set(['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.svg']); /** Time to wait for file writes to stabilize (ms) */ const STABILITY_THRESHOLD_MS = 500; @@ -119,7 +111,11 @@ export class ImageWatcher extends EventEmitter { try { watcher.close(); } catch (error) { - this.emit('image:error', error instanceof Error ? error : new Error(String(error)), sessionId); + this.emit( + 'image:error', + error instanceof Error ? error : new Error(String(error)), + sessionId + ); } } this.sessionWatchers.clear(); @@ -171,7 +167,12 @@ export class ImageWatcher extends EventEmitter { // Ignore common heavy directories for performance ignored: (path: string) => { // Skip node_modules, .git, and other heavy directories - if (path.includes('/node_modules/') || path.includes('/.git/') || path.includes('/dist/') || path.includes('/.next/')) { + if ( + path.includes('/node_modules/') || + path.includes('/.git/') || + path.includes('/dist/') || + path.includes('/.next/') + ) { return true; } const ext = extname(path).toLowerCase(); @@ -193,7 +194,11 @@ export class ImageWatcher extends EventEmitter { this.sessionWatchers.set(sessionId, watcher); } catch (error) { - this.emit('image:error', error instanceof Error ? error : new Error(String(error)), sessionId); + this.emit( + 'image:error', + error instanceof Error ? error : new Error(String(error)), + sessionId + ); } } @@ -208,7 +213,11 @@ export class ImageWatcher extends EventEmitter { try { watcher.close(); } catch (error) { - this.emit('image:error', error instanceof Error ? error : new Error(String(error)), sessionId); + this.emit( + 'image:error', + error instanceof Error ? error : new Error(String(error)), + sessionId + ); } this.sessionWatchers.delete(sessionId); } @@ -313,7 +322,11 @@ export class ImageWatcher extends EventEmitter { this.emit('image:detected', event); } catch (error) { // File may have been deleted between detection and stat - this.emit('image:error', error instanceof Error ? error : new Error(String(error)), sessionId); + this.emit( + 'image:error', + error instanceof Error ? error : new Error(String(error)), + sessionId + ); } } } diff --git a/src/index.ts b/src/index.ts index ea502157..392a1e1b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,10 +23,14 @@ let errorResetTimer: ReturnType | null = null; function trackError(): void { consecutiveErrors++; if (errorResetTimer) clearTimeout(errorResetTimer); - errorResetTimer = setTimeout(() => { consecutiveErrors = 0; }, ERROR_RESET_MS); + errorResetTimer = setTimeout(() => { + consecutiveErrors = 0; + }, ERROR_RESET_MS); if (consecutiveErrors >= MAX_CONSECUTIVE_ERRORS) { - console.error(`[FATAL] ${MAX_CONSECUTIVE_ERRORS} consecutive unhandled errors — exiting for systemd restart`); + console.error( + `[FATAL] ${MAX_CONSECUTIVE_ERRORS} consecutive unhandled errors — exiting for systemd restart` + ); process.exit(1); } } diff --git a/src/mux-interface.ts b/src/mux-interface.ts index e3a91ecc..6007e472 100644 --- a/src/mux-interface.ts +++ b/src/mux-interface.ts @@ -7,7 +7,14 @@ */ import type { EventEmitter } from 'node:events'; -import type { ProcessStats, PersistedRespawnConfig, NiceConfig, ClaudeMode, SessionMode, OpenCodeConfig } from './types.js'; +import type { + ProcessStats, + PersistedRespawnConfig, + NiceConfig, + ClaudeMode, + SessionMode, + OpenCodeConfig, +} from './types.js'; /** * Multiplexer session metadata. diff --git a/src/plan-orchestrator.ts b/src/plan-orchestrator.ts index 98bf9141..d1231840 100644 --- a/src/plan-orchestrator.ts +++ b/src/plan-orchestrator.ts @@ -19,10 +19,7 @@ import { Session } from './session.js'; import type { TerminalMultiplexer } from './mux-interface.js'; import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; -import { - RESEARCH_AGENT_PROMPT, - PLANNER_PROMPT, -} from './prompts/index.js'; +import { RESEARCH_AGENT_PROMPT, PLANNER_PROMPT } from './prompts/index.js'; import { PlanTaskStatus, TddPhase } from './types.js'; // ============================================================================ @@ -170,16 +167,23 @@ export class PlanOrchestrator { mux: TerminalMultiplexer, workingDir: string = process.cwd(), outputDir?: string, - modelConfig?: { defaultModel?: string; agentTypeOverrides?: Record }, + modelConfig?: { defaultModel?: string; agentTypeOverrides?: Record } ) { this.mux = mux; this.workingDir = workingDir; this.outputDir = outputDir; - this.researchModel = modelConfig?.agentTypeOverrides?.explore || modelConfig?.defaultModel || DEFAULT_MODEL; - this.plannerModel = modelConfig?.agentTypeOverrides?.review || modelConfig?.defaultModel || DEFAULT_MODEL; + this.researchModel = + modelConfig?.agentTypeOverrides?.explore || modelConfig?.defaultModel || DEFAULT_MODEL; + this.plannerModel = + modelConfig?.agentTypeOverrides?.review || modelConfig?.defaultModel || DEFAULT_MODEL; } - private saveAgentOutput(agentType: string, prompt: string, result: unknown, durationMs: number): void { + private saveAgentOutput( + agentType: string, + prompt: string, + result: unknown, + durationMs: number + ): void { if (!this.outputDir) return; try { @@ -193,7 +197,11 @@ export class PlanOrchestrator { } const promptPath = join(agentDir, 'prompt.md'); - writeFileSync(promptPath, `# ${agentType} Agent Prompt\n\nGenerated: ${new Date().toISOString()}\nDuration: ${(durationMs / 1000).toFixed(1)}s\n\n## Task\n${this.taskDescription}\n\n## Prompt\n${prompt}\n`, 'utf-8'); + writeFileSync( + promptPath, + `# ${agentType} Agent Prompt\n\nGenerated: ${new Date().toISOString()}\nDuration: ${(durationMs / 1000).toFixed(1)}s\n\n## Task\n${this.taskDescription}\n\n## Prompt\n${prompt}\n`, + 'utf-8' + ); const resultPath = join(agentDir, 'result.json'); writeFileSync(resultPath, JSON.stringify(result, null, 2), 'utf-8'); @@ -227,9 +235,9 @@ export class PlanOrchestrator { private generateSummary(result: DetailedPlanResult): string { const items = result.items || []; - const p0 = items.filter(i => i.priority === 'P0'); - const p1 = items.filter(i => i.priority === 'P1'); - const p2 = items.filter(i => i.priority === 'P2'); + const p0 = items.filter((i) => i.priority === 'P0'); + const p1 = items.filter((i) => i.priority === 'P1'); + const p2 = items.filter((i) => i.priority === 'P2'); let md = `# Plan Summary\n\n`; md += `Generated: ${new Date().toISOString()}\n`; @@ -392,10 +400,29 @@ export class PlanOrchestrator { const startTime = Date.now(); if (this.cancelled) { - return { success: false, findings: { externalResources: [], codebasePatterns: [], technicalRecommendations: [], potentialChallenges: [], recommendedTools: [] }, enrichedTaskDescription: taskDescription, error: 'Cancelled', durationMs: 0 }; + return { + success: false, + findings: { + externalResources: [], + codebasePatterns: [], + technicalRecommendations: [], + potentialChallenges: [], + recommendedTools: [], + }, + enrichedTaskDescription: taskDescription, + error: 'Cancelled', + durationMs: 0, + }; } - onSubagent?.({ type: 'started', agentId, agentType: 'research', model: this.researchModel, status: 'running', detail: 'Researching...' }); + onSubagent?.({ + type: 'started', + agentId, + agentType: 'research', + model: this.researchModel, + status: 'running', + detail: 'Researching...', + }); const session = new Session({ workingDir: this.workingDir, @@ -406,12 +433,22 @@ export class PlanOrchestrator { this.runningSessions.add(session); - const prompt = RESEARCH_AGENT_PROMPT.replace('{TASK}', taskDescription).replace('{WORKING_DIR}', this.workingDir); + const prompt = RESEARCH_AGENT_PROMPT.replace('{TASK}', taskDescription).replace( + '{WORKING_DIR}', + this.workingDir + ); // Start progress interval before try block to ensure cleanup in finally const progressInterval = setInterval(() => { const elapsed = Math.floor((Date.now() - startTime) / 1000); - onSubagent?.({ type: 'progress', agentId, agentType: 'research', model: this.researchModel, status: 'running', detail: `${elapsed}s elapsed` }); + onSubagent?.({ + type: 'progress', + agentId, + agentType: 'research', + model: this.researchModel, + status: 'running', + detail: `${elapsed}s elapsed`, + }); }, 30000); try { @@ -424,14 +461,54 @@ export class PlanOrchestrator { // Extract JSON from response const jsonMatch = response.match(/\{[\s\S]*\}/); if (!jsonMatch) { - onSubagent?.({ type: 'failed', agentId, agentType: 'research', model: this.researchModel, status: 'failed', error: 'No JSON found', durationMs }); - return { success: false, findings: { externalResources: [], codebasePatterns: [], technicalRecommendations: [], potentialChallenges: [], recommendedTools: [] }, enrichedTaskDescription: taskDescription, error: 'No JSON in response', durationMs }; + onSubagent?.({ + type: 'failed', + agentId, + agentType: 'research', + model: this.researchModel, + status: 'failed', + error: 'No JSON found', + durationMs, + }); + return { + success: false, + findings: { + externalResources: [], + codebasePatterns: [], + technicalRecommendations: [], + potentialChallenges: [], + recommendedTools: [], + }, + enrichedTaskDescription: taskDescription, + error: 'No JSON in response', + durationMs, + }; } const parsed = tryParseJSON(jsonMatch[0]); if (!parsed.success) { - onSubagent?.({ type: 'failed', agentId, agentType: 'research', model: this.researchModel, status: 'failed', error: parsed.error, durationMs }); - return { success: false, findings: { externalResources: [], codebasePatterns: [], technicalRecommendations: [], potentialChallenges: [], recommendedTools: [] }, enrichedTaskDescription: taskDescription, error: parsed.error, durationMs }; + onSubagent?.({ + type: 'failed', + agentId, + agentType: 'research', + model: this.researchModel, + status: 'failed', + error: parsed.error, + durationMs, + }); + return { + success: false, + findings: { + externalResources: [], + codebasePatterns: [], + technicalRecommendations: [], + potentialChallenges: [], + recommendedTools: [], + }, + enrichedTaskDescription: taskDescription, + error: parsed.error, + durationMs, + }; } const data = parsed.data as Record; @@ -440,24 +517,58 @@ export class PlanOrchestrator { findings: { externalResources: Array.isArray(data.externalResources) ? data.externalResources : [], codebasePatterns: Array.isArray(data.codebasePatterns) ? data.codebasePatterns : [], - technicalRecommendations: Array.isArray(data.technicalRecommendations) ? data.technicalRecommendations : [], - potentialChallenges: Array.isArray(data.potentialChallenges) ? data.potentialChallenges : [], + technicalRecommendations: Array.isArray(data.technicalRecommendations) + ? data.technicalRecommendations + : [], + potentialChallenges: Array.isArray(data.potentialChallenges) + ? data.potentialChallenges + : [], recommendedTools: Array.isArray(data.recommendedTools) ? data.recommendedTools : [], }, - enrichedTaskDescription: typeof data.enrichedTaskDescription === 'string' ? data.enrichedTaskDescription : taskDescription, + enrichedTaskDescription: + typeof data.enrichedTaskDescription === 'string' + ? data.enrichedTaskDescription + : taskDescription, durationMs, }; this.saveAgentOutput('research', prompt, result, durationMs); - onSubagent?.({ type: 'completed', agentId, agentType: 'research', model: this.researchModel, status: 'completed', durationMs }); + onSubagent?.({ + type: 'completed', + agentId, + agentType: 'research', + model: this.researchModel, + status: 'completed', + durationMs, + }); return result; } catch (err) { this.runningSessions.delete(session); const durationMs = Date.now() - startTime; const error = err instanceof Error ? err.message : String(err); - onSubagent?.({ type: 'failed', agentId, agentType: 'research', model: this.researchModel, status: 'failed', error, durationMs }); - return { success: false, findings: { externalResources: [], codebasePatterns: [], technicalRecommendations: [], potentialChallenges: [], recommendedTools: [] }, enrichedTaskDescription: taskDescription, error, durationMs }; + onSubagent?.({ + type: 'failed', + agentId, + agentType: 'research', + model: this.researchModel, + status: 'failed', + error, + durationMs, + }); + return { + success: false, + findings: { + externalResources: [], + codebasePatterns: [], + technicalRecommendations: [], + potentialChallenges: [], + recommendedTools: [], + }, + enrichedTaskDescription: taskDescription, + error, + durationMs, + }; } finally { // Always clear the progress interval to prevent memory leaks clearInterval(progressInterval); @@ -469,7 +580,13 @@ export class PlanOrchestrator { researchContext: string, onProgress?: ProgressCallback, onSubagent?: SubagentCallback - ): Promise<{ success: boolean; items?: PlanItem[]; gaps?: string[]; warnings?: string[]; error?: string }> { + ): Promise<{ + success: boolean; + items?: PlanItem[]; + gaps?: string[]; + warnings?: string[]; + error?: string; + }> { const agentId = `planner-${Date.now()}`; const startTime = Date.now(); @@ -477,7 +594,14 @@ export class PlanOrchestrator { return { success: false, error: 'Cancelled' }; } - onSubagent?.({ type: 'started', agentId, agentType: 'planner', model: this.plannerModel, status: 'running', detail: 'Generating plan...' }); + onSubagent?.({ + type: 'started', + agentId, + agentType: 'planner', + model: this.plannerModel, + status: 'running', + detail: 'Generating plan...', + }); const session = new Session({ workingDir: this.workingDir, @@ -488,14 +612,22 @@ export class PlanOrchestrator { this.runningSessions.add(session); - const prompt = PLANNER_PROMPT - .replace('{TASK}', taskDescription) - .replace('{RESEARCH_CONTEXT}', researchContext || ''); + const prompt = PLANNER_PROMPT.replace('{TASK}', taskDescription).replace( + '{RESEARCH_CONTEXT}', + researchContext || '' + ); // Start progress interval before try block to ensure cleanup in finally const progressInterval = setInterval(() => { const elapsed = Math.floor((Date.now() - startTime) / 1000); - onSubagent?.({ type: 'progress', agentId, agentType: 'planner', model: this.plannerModel, status: 'running', detail: `${elapsed}s elapsed` }); + onSubagent?.({ + type: 'progress', + agentId, + agentType: 'planner', + model: this.plannerModel, + status: 'running', + detail: `${elapsed}s elapsed`, + }); }, 30000); try { @@ -508,13 +640,29 @@ export class PlanOrchestrator { // Extract JSON from response const jsonMatch = response.match(/\{[\s\S]*\}/); if (!jsonMatch) { - onSubagent?.({ type: 'failed', agentId, agentType: 'planner', model: this.plannerModel, status: 'failed', error: 'No JSON found', durationMs }); + onSubagent?.({ + type: 'failed', + agentId, + agentType: 'planner', + model: this.plannerModel, + status: 'failed', + error: 'No JSON found', + durationMs, + }); return { success: false, error: 'No JSON in response' }; } const parsed = tryParseJSON(jsonMatch[0]); if (!parsed.success) { - onSubagent?.({ type: 'failed', agentId, agentType: 'planner', model: this.plannerModel, status: 'failed', error: parsed.error, durationMs }); + onSubagent?.({ + type: 'failed', + agentId, + agentType: 'planner', + model: this.plannerModel, + status: 'failed', + error: parsed.error, + durationMs, + }); return { success: false, error: parsed.error }; } @@ -524,7 +672,15 @@ export class PlanOrchestrator { const warnings: string[] = Array.isArray(data.warnings) ? data.warnings : []; this.saveAgentOutput('planner', prompt, { items, gaps, warnings }, durationMs); - onSubagent?.({ type: 'completed', agentId, agentType: 'planner', model: this.plannerModel, status: 'completed', itemCount: items.length, durationMs }); + onSubagent?.({ + type: 'completed', + agentId, + agentType: 'planner', + model: this.plannerModel, + status: 'completed', + itemCount: items.length, + durationMs, + }); onProgress?.('planning', `Generated ${items.length} tasks`); @@ -533,7 +689,15 @@ export class PlanOrchestrator { this.runningSessions.delete(session); const durationMs = Date.now() - startTime; const error = err instanceof Error ? err.message : String(err); - onSubagent?.({ type: 'failed', agentId, agentType: 'planner', model: this.plannerModel, status: 'failed', error, durationMs }); + onSubagent?.({ + type: 'failed', + agentId, + agentType: 'planner', + model: this.plannerModel, + status: 'failed', + error, + durationMs, + }); return { success: false, error }; } finally { // Always clear the progress interval to prevent memory leaks diff --git a/src/push-store.ts b/src/push-store.ts index 9a27ae76..4316011a 100644 --- a/src/push-store.ts +++ b/src/push-store.ts @@ -95,7 +95,10 @@ export class PushSubscriptionStore { } /** Update push preferences for a subscription */ - updatePreferences(id: string, preferences: Record): PushSubscriptionRecord | null { + updatePreferences( + id: string, + preferences: Record + ): PushSubscriptionRecord | null { const sub = this.subscriptions.get(id); if (!sub) return null; sub.pushPreferences = preferences; diff --git a/src/ralph-loop.ts b/src/ralph-loop.ts index cf3980dd..35f5892a 100644 --- a/src/ralph-loop.ts +++ b/src/ralph-loop.ts @@ -282,10 +282,7 @@ export class RalphLoop extends EventEmitter { this.store.setRalphLoopState({ lastCheckAt: Date.now() }); // Run independent checks in parallel for better performance - await Promise.all([ - this.checkTimeouts(), - this.assignTasks(), - ]); + await Promise.all([this.checkTimeouts(), this.assignTasks()]); // Check if we should auto-generate tasks (depends on assignment results) if (this.autoGenerateTasks && this.shouldGenerateTasks()) { diff --git a/src/ralph-tracker.ts b/src/ralph-tracker.ts index 868a2e11..4d1f5b34 100644 --- a/src/ralph-tracker.ts +++ b/src/ralph-tracker.ts @@ -151,8 +151,19 @@ const MAX_PLAN_HISTORY = 10; * P1-002: Configurable false positive prevention */ const COMMON_COMPLETION_PHRASES = new Set([ - 'DONE', 'COMPLETE', 'FINISHED', 'OK', 'YES', 'TRUE', 'SUCCESS', - 'READY', 'COMPLETED', 'PASSED', 'END', 'STOP', 'EXIT', + 'DONE', + 'COMPLETE', + 'FINISHED', + 'OK', + 'YES', + 'TRUE', + 'SUCCESS', + 'READY', + 'COMPLETED', + 'PASSED', + 'END', + 'STOP', + 'EXIT', ]); /** @@ -235,7 +246,8 @@ const TODO_NATIVE_PATTERN = /^[\s⎿]*(☐|☒|◐|✓)\s+([^☐☒◐✓\n]{3,} */ const TODO_TASK_CREATED_PATTERN = /✔\s*Task\s*#(\d+)\s*created:\s*(.+)/g; const TODO_TASK_SUMMARY_PATTERN = /✔\s*#(\d+)\s+(.+)/g; -const TODO_TASK_STATUS_PATTERN = /✔\s*Task\s*#(\d+)\s*updated:\s*status\s*→\s*(in progress|completed|pending)/g; +const TODO_TASK_STATUS_PATTERN = + /✔\s*Task\s*#(\d+)\s*updated:\s*status\s*→\s*(in progress|completed|pending)/g; /** * Matches plain checkmark TodoWrite output without task numbers. @@ -249,9 +261,9 @@ const TODO_PLAIN_CHECKMARK_PATTERN = /✔\s+(.+)/g; * Prevents false positives from tool invocations and Claude commentary */ const TODO_EXCLUDE_PATTERNS = [ - /^(?:Bash|Search|Read|Write|Glob|Grep|Edit|Task)\s*\(/i, // Tool invocations - /^(?:I'll |Let me |Now I|First,|Task \d+:|Result:|Error:)/i, // Claude commentary - /^\S+\([^)]+\)$/, // Generic function call pattern + /^(?:Bash|Search|Read|Write|Glob|Grep|Edit|Task)\s*\(/i, // Tool invocations + /^(?:I'll |Let me |Now I|First,|Task \d+:|Result:|Error:)/i, // Claude commentary + /^\S+\([^)]+\)$/, // Generic function call pattern ]; // ---------- Loop Status Patterns ---------- @@ -285,13 +297,15 @@ const CYCLE_PATTERN = /cycle\s*#?(\d+)|respawn cycle #(\d+)/i; * Examples: "Iteration 5/50", "[5/50]", "iteration #5", "iter. 3 of 10" * Capture groups: (1,2) for "Iteration X/Y" format, (3,4) for "[X/Y]" format */ -const ITERATION_PATTERN = /(?:iteration|iter\.?)\s*#?(\d+)(?:\s*(?:\/|of)\s*(\d+))?|\[(\d+)\/(\d+)\]/i; +const ITERATION_PATTERN = + /(?:iteration|iter\.?)\s*#?(\d+)(?:\s*(?:\/|of)\s*(\d+))?|\[(\d+)\/(\d+)\]/i; /** * Matches Ralph loop start command or announcement * Examples: "/ralph-loop:ralph-loop", "Starting Ralph Wiggum loop", "ralph loop beginning" */ -const RALPH_START_PATTERN = /\/ralph-loop|starting ralph(?:\s+wiggum)?\s+loop|ralph loop (?:started|beginning)/i; +const RALPH_START_PATTERN = + /\/ralph-loop|starting ralph(?:\s+wiggum)?\s+loop|ralph loop (?:started|beginning)/i; /** * Matches max iterations configuration @@ -304,7 +318,8 @@ const MAX_ITERATIONS_PATTERN = /max[_-]?iterations?\s*[=:]\s*(\d+)/i; * Matches TodoWrite tool usage indicators * Examples: "TodoWrite", "todos updated", "Todos have been modified" */ -const TODOWRITE_PATTERN = /TodoWrite|todo(?:s)?\s*(?:updated|written|saved)|Todos have been modified/i; +const TODOWRITE_PATTERN = + /TodoWrite|todo(?:s)?\s*(?:updated|written|saved)|Todos have been modified/i; // ---------- Task Completion Detection Patterns ---------- @@ -313,7 +328,8 @@ const TODOWRITE_PATTERN = /TodoWrite|todo(?:s)?\s*(?:updated|written|saved)|Todo * Examples: "All 8 files have been created", "All tasks completed", "Everything is done" * Used to mark all tracked todos as complete at once */ -const ALL_COMPLETE_PATTERN = /all\s+(?:\d+\s+)?(?:tasks?|files?|items?)\s+(?:have\s+been\s+|are\s+)?(?:completed?|done|finished|created)|completed?\s+all\s+(?:\d+\s+)?tasks?|all\s+done|everything\s+(?:is\s+)?(?:completed?|done)|finished\s+all\s+tasks?/i; +const ALL_COMPLETE_PATTERN = + /all\s+(?:\d+\s+)?(?:tasks?|files?|items?)\s+(?:have\s+been\s+|are\s+)?(?:completed?|done|finished|created)|completed?\s+all\s+(?:\d+\s+)?tasks?|all\s+done|everything\s+(?:is\s+)?(?:completed?|done)|finished\s+all\s+tasks?/i; /** * Extracts count from "all N items" messages @@ -327,7 +343,8 @@ const ALL_COUNT_PATTERN = /all\s+(\d+)\s+(?:tasks?|files?|items?)/i; * Examples: "Task #5 is done", "marked as completed", "todo 3 finished" * Used to update specific todo items by number */ -const TASK_DONE_PATTERN = /(?:task|item|todo)\s*(?:#?\d+|"\s*[^"]+\s*")?\s*(?:is\s+)?(?:done|completed?|finished)|(?:completed?|done|finished)\s+(?:task|item)\s*(?:#?\d+)?|marking\s+(?:.*?\s+)?(?:as\s+)?completed?|marked\s+(?:.*?\s+)?(?:as\s+)?completed?/i; +const TASK_DONE_PATTERN = + /(?:task|item|todo)\s*(?:#?\d+|"\s*[^"]+\s*")?\s*(?:is\s+)?(?:done|completed?|finished)|(?:completed?|done|finished)\s+(?:task|item)\s*(?:#?\d+)?|marking\s+(?:.*?\s+)?(?:as\s+)?completed?|marked\s+(?:.*?\s+)?(?:as\s+)?completed?/i; // ---------- Utility Patterns ---------- @@ -377,7 +394,8 @@ const RALPH_TESTS_STATUS_PATTERN = /^TESTS_STATUS:\s*(PASSING|FAILING|NOT_RUN)\s * Matches WORK_TYPE field * Captures: IMPLEMENTATION | TESTING | DOCUMENTATION | REFACTORING */ -const RALPH_WORK_TYPE_PATTERN = /^WORK_TYPE:\s*(IMPLEMENTATION|TESTING|DOCUMENTATION|REFACTORING)\s*$/i; +const RALPH_WORK_TYPE_PATTERN = + /^WORK_TYPE:\s*(IMPLEMENTATION|TESTING|DOCUMENTATION|REFACTORING)\s*$/i; /** * Matches EXIT_SIGNAL field @@ -411,48 +429,48 @@ const COMPLETION_INDICATOR_PATTERNS = [ /** 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 + /\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 + /\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 + /\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 ========== @@ -950,7 +968,7 @@ export class RalphTracker extends EventEmitter { const wasEnabled = this._loopState.enabled; this._loopState = createInitialRalphTrackerState(); - this._loopState.enabled = wasEnabled; // Keep enabled status + this._loopState.enabled = wasEnabled; // Keep enabled status this._todos.clear(); this._completionPhraseCount.clear(); this._taskNumberToContent.clear(); @@ -1241,7 +1259,7 @@ export class RalphTracker extends EventEmitter { // Check if all todos are complete (adds 20 points) const todoArray = Array.from(this._todos.values()); - if (todoArray.length > 0 && todoArray.every(t => t.status === 'completed')) { + if (todoArray.length > 0 && todoArray.every((t) => t.status === 'completed')) { signals.allTodosComplete = true; score += 20; } @@ -1262,10 +1280,12 @@ export class RalphTracker extends EventEmitter { if (context) { const lowerContext = context.toLowerCase(); // Deduct points if phrase appears in prompt-like context - if (lowerContext.includes('output:') || - lowerContext.includes('completion phrase') || - lowerContext.includes('output exactly') || - lowerContext.includes('when done')) { + if ( + lowerContext.includes('output:') || + lowerContext.includes('completion phrase') || + lowerContext.includes('output exactly') || + lowerContext.includes('when done') + ) { signals.contextAppropriate = false; score -= 20; } else { @@ -1335,7 +1355,6 @@ export class RalphTracker extends EventEmitter { * Use this when the caller has already stripped ANSI to avoid redundant regex work. */ processCleanData(cleanData: string): void { - // If tracker is disabled, only check for patterns that should auto-enable it if (!this._loopState.enabled) { // Don't auto-enable if explicitly disabled by user setting @@ -1396,14 +1415,21 @@ export class RalphTracker extends EventEmitter { // substrings that any pattern could match are present in the data. // This avoids 12 regex tests on every PTY chunk (the common case). if ( - !data.includes('<') && // , TodoWrite - !data.includes('ralph') && !data.includes('Ralph') && - !data.includes('Todo') && !data.includes('todo') && - !data.includes('Iteration') && !data.includes('[') && - !data.includes('\u2610') && !data.includes('\u2612') && // ☐ ☒ + !data.includes('<') && // , TodoWrite + !data.includes('ralph') && + !data.includes('Ralph') && + !data.includes('Todo') && + !data.includes('todo') && + !data.includes('Iteration') && + !data.includes('[') && + !data.includes('\u2610') && + !data.includes('\u2612') && // ☐ ☒ !data.includes('\u2714') && // ✔ - !data.includes('Loop') && !data.includes('complete') && - !data.includes('COMPLETE') && !data.includes('Done') && !data.includes('DONE') + !data.includes('Loop') && + !data.includes('complete') && + !data.includes('COMPLETE') && + !data.includes('Done') && + !data.includes('DONE') ) { return false; } @@ -1677,8 +1703,9 @@ export class RalphTracker extends EventEmitter { // Avoid false positives: don't trigger on prompt context const isNotInPromptContext = !line.includes('') && !line.includes('output:'); // Also avoid triggering on "completion phrase is X" explanatory text - const isNotExplanation = !line.toLowerCase().includes('completion phrase') && - !line.toLowerCase().includes('output exactly'); + const isNotExplanation = + !line.toLowerCase().includes('completion phrase') && + !line.toLowerCase().includes('output exactly'); if (isNotInPromptContext && isNotExplanation) { this.handleBareCompletionPhrase(expectedPhrase); @@ -1764,7 +1791,10 @@ export class RalphTracker extends EventEmitter { this._completionPhraseCount.set(entries[i][0], entries[i][1]); } // Always keep the expected phrase if set - if (this._loopState.completionPhrase && !this._completionPhraseCount.has(this._loopState.completionPhrase)) { + if ( + this._loopState.completionPhrase && + !this._completionPhraseCount.has(this._loopState.completionPhrase) + ) { this._completionPhraseCount.set(this._loopState.completionPhrase, 1); } } @@ -1786,7 +1816,7 @@ export class RalphTracker extends EventEmitter { if (matchedPhrase) { // Use the matched phrase (canonical) for tracking - const canonicalCount = (this._completionPhraseCount.get(matchedPhrase) || 0); + const canonicalCount = this._completionPhraseCount.get(matchedPhrase) || 0; // Require 2nd+ occurrence of canonical phrase OR explicitly active loop. // First occurrence (count=1) is the prompt echo — not actual completion. if (canonicalCount >= 2 || this._loopState.active) { @@ -1870,7 +1900,9 @@ export class RalphTracker extends EventEmitter { // Check for common phrases if (COMMON_COMPLETION_PHRASES.has(normalized)) { - console.warn(`[RalphTracker] Warning: Completion phrase "${phrase}" is very common and may cause false positives. Consider using: "${suggestedPhrase}"`); + console.warn( + `[RalphTracker] Warning: Completion phrase "${phrase}" is very common and may cause false positives. Consider using: "${suggestedPhrase}"` + ); this.emit('phraseValidationWarning', { phrase, reason: 'common', @@ -1881,7 +1913,9 @@ export class RalphTracker extends EventEmitter { // Check for short phrases if (normalized.length < MIN_RECOMMENDED_PHRASE_LENGTH) { - console.warn(`[RalphTracker] Warning: Completion phrase "${phrase}" is too short (${normalized.length} chars). Consider using: "${suggestedPhrase}"`); + console.warn( + `[RalphTracker] Warning: Completion phrase "${phrase}" is too short (${normalized.length} chars). Consider using: "${suggestedPhrase}"` + ); this.emit('phraseValidationWarning', { phrase, reason: 'short', @@ -1892,7 +1926,9 @@ export class RalphTracker extends EventEmitter { // Check for numeric-only phrases if (/^\d+$/.test(normalized)) { - console.warn(`[RalphTracker] Warning: Completion phrase "${phrase}" is numeric-only and may cause false positives. Consider using: "${suggestedPhrase}"`); + console.warn( + `[RalphTracker] Warning: Completion phrase "${phrase}" is numeric-only and may cause false positives. Consider using: "${suggestedPhrase}"` + ); this.emit('phraseValidationWarning', { phrase, reason: 'numeric', @@ -1971,14 +2007,16 @@ export class RalphTracker extends EventEmitter { if (currentIter !== this._lastObservedIteration) { this._lastIterationChangeTime = Date.now(); this._lastObservedIteration = currentIter; - this._iterationStallWarned = false; // Reset warning on iteration change + this._iterationStallWarned = false; // Reset warning on iteration change // P1-004: Reset circuit breaker on successful iteration progress // If we're making progress, the loop is healthy - if (this._circuitBreaker.state === 'HALF_OPEN' || - this._circuitBreaker.consecutiveNoProgress > 0 || - this._circuitBreaker.consecutiveSameError > 0 || - this._circuitBreaker.consecutiveTestsFailure > 0) { + if ( + this._circuitBreaker.state === 'HALF_OPEN' || + this._circuitBreaker.consecutiveNoProgress > 0 || + this._circuitBreaker.consecutiveSameError > 0 || + this._circuitBreaker.consecutiveTestsFailure > 0 + ) { this._circuitBreaker.consecutiveNoProgress = 0; this._circuitBreaker.consecutiveSameError = 0; this._circuitBreaker.lastProgressIteration = currentIter; @@ -2047,8 +2085,10 @@ export class RalphTracker extends EventEmitter { // Pre-compute which pattern categories might match (60-75% faster) const hasCheckbox = line.includes('['); const hasTodoIndicator = line.includes('Todo:'); - const hasNativeCheckbox = line.includes('☐') || line.includes('☒') || line.includes('◐') || line.includes('✓'); - const hasStatus = line.includes('(pending)') || line.includes('(in_progress)') || line.includes('(completed)'); + const hasNativeCheckbox = + line.includes('☐') || line.includes('☒') || line.includes('◐') || line.includes('✓'); + const hasStatus = + line.includes('(pending)') || line.includes('(in_progress)') || line.includes('(completed)'); const hasCheckmark = line.includes('✔'); // Quick check: skip lines that can't possibly contain todos @@ -2106,7 +2146,7 @@ export class RalphTracker extends EventEmitter { const content = match[2].trim(); // Skip if content matches exclude patterns (tool invocations, commentary) - const shouldExclude = TODO_EXCLUDE_PATTERNS.some(pattern => pattern.test(content)); + const shouldExclude = TODO_EXCLUDE_PATTERNS.some((pattern) => pattern.test(content)); if (shouldExclude) continue; // Skip if content is too short or looks like partial garbage @@ -2155,9 +2195,12 @@ export class RalphTracker extends EventEmitter { while ((match = TODO_TASK_STATUS_PATTERN.exec(line)) !== null) { const taskNum = parseInt(match[1], 10); const statusStr = match[2].trim(); - const status: RalphTodoStatus = statusStr === 'completed' ? 'completed' - : statusStr === 'in progress' ? 'in_progress' - : 'pending'; + const status: RalphTodoStatus = + statusStr === 'completed' + ? 'completed' + : statusStr === 'in progress' + ? 'in_progress' + : 'pending'; const content = this._taskNumberToContent.get(taskNum); if (content) { this.upsertTodo(content, status); @@ -2172,7 +2215,7 @@ export class RalphTracker extends EventEmitter { while ((match = TODO_PLAIN_CHECKMARK_PATTERN.exec(line)) !== null) { const content = match[1].trim(); // Skip if content matches exclude patterns - const shouldExclude = TODO_EXCLUDE_PATTERNS.some(pattern => pattern.test(content)); + const shouldExclude = TODO_EXCLUDE_PATTERNS.some((pattern) => pattern.test(content)); if (shouldExclude) continue; if (content.length < 5) continue; // Skip status/created/updated prefixed content (already handled above) @@ -2204,17 +2247,17 @@ export class RalphTracker extends EventEmitter { switch (icon) { case '✓': case '✅': - case '☒': // Claude Code checked checkbox - case '◉': // Filled circle (completed) - case '●': // Solid circle (completed) + case '☒': // Claude Code checked checkbox + case '◉': // Filled circle (completed) + case '●': // Solid circle (completed) return 'completed'; - case '◐': // Half-filled circle (in progress) + case '◐': // Half-filled circle (in progress) case '⏳': case '⌛': case '🔄': return 'in_progress'; - case '☐': // Claude Code empty checkbox - case '○': // Empty circle + case '☐': // Claude Code empty checkbox + case '○': // Empty circle default: return 'pending'; } @@ -2280,10 +2323,10 @@ export class RalphTracker extends EventEmitter { // Clean content: remove ANSI codes, collapse whitespace, trim const cleanContent = content - .replace(ANSI_ESCAPE_PATTERN_SIMPLE, '') // Remove ANSI escape codes - .replace(/\s+/g, ' ') // Collapse whitespace + .replace(ANSI_ESCAPE_PATTERN_SIMPLE, '') // Remove ANSI escape codes + .replace(/\s+/g, ' ') // Collapse whitespace .trim(); - if (cleanContent.length < 5) return; // Skip very short content + if (cleanContent.length < 5) return; // Skip very short content // Parse priority from content const priority = this.parsePriority(cleanContent); @@ -2402,8 +2445,8 @@ export class RalphTracker extends EventEmitter { private normalizeTodoContent(content: string): string { if (!content) return ''; return content - .replace(/\s+/g, ' ') // Collapse whitespace - .replace(/[^a-zA-Z0-9\s.,!?'"-]/g, '') // Remove special chars (keep punctuation) + .replace(/\s+/g, ' ') // Collapse whitespace + .replace(/[^a-zA-Z0-9\s.,!?'"-]/g, '') // Remove special chars (keep punctuation) .trim() .toLowerCase(); } @@ -2507,7 +2550,7 @@ export class RalphTracker extends EventEmitter { if (normalized.length < 30) { threshold = 0.95; // Very strict for short strings } else if (normalized.length < 60) { - threshold = 0.90; // Strict for medium strings + threshold = 0.9; // Strict for medium strings } else { threshold = TODO_SIMILARITY_THRESHOLD; // 0.85 for longer strings } @@ -2609,10 +2652,10 @@ export class RalphTracker extends EventEmitter { // Default estimates (in ms) based on typical task durations const defaults = { - trivial: 1 * 60 * 1000, // 1 minute - simple: 3 * 60 * 1000, // 3 minutes - moderate: 10 * 60 * 1000, // 10 minutes - complex: 30 * 60 * 1000, // 30 minutes + trivial: 1 * 60 * 1000, // 1 minute + simple: 3 * 60 * 1000, // 3 minutes + moderate: 10 * 60 * 1000, // 10 minutes + complex: 30 * 60 * 1000, // 30 minutes }; return defaults[complexity]; } @@ -2671,9 +2714,9 @@ export class RalphTracker extends EventEmitter { public getTodoProgress(): RalphTodoProgress { const todos = Array.from(this._todos.values()); const total = todos.length; - const completed = todos.filter(t => t.status === 'completed').length; - const inProgress = todos.filter(t => t.status === 'in_progress').length; - const pending = todos.filter(t => t.status === 'pending').length; + const completed = todos.filter((t) => t.status === 'completed').length; + const inProgress = todos.filter((t) => t.status === 'in_progress').length; + const pending = todos.filter((t) => t.status === 'pending').length; const percentComplete = total > 0 ? Math.round((completed / total) * 100) : 0; @@ -2852,7 +2895,11 @@ export class RalphTracker extends EventEmitter { * @param config - Partial configuration to apply * @fires loopUpdate - When loop state changes */ - configure(config: { enabled?: boolean; completionPhrase?: string; maxIterations?: number }): void { + configure(config: { + enabled?: boolean; + completionPhrase?: string; + maxIterations?: number; + }): void { if (config.enabled !== undefined) { this._loopState.enabled = config.enabled; } @@ -2983,7 +3030,7 @@ export class RalphTracker extends EventEmitter { // Ensure enabled flag exists (backwards compatibility) this._loopState = { ...loopState, - enabled: loopState.enabled ?? false, // Override after spread for backwards compat + enabled: loopState.enabled ?? false, // Override after spread for backwards compat }; this._todos.clear(); for (const todo of todos) { @@ -3056,7 +3103,9 @@ export class RalphTracker extends EventEmitter { if (['IN_PROGRESS', 'COMPLETE', 'BLOCKED'].includes(value)) { block.status = value as RalphStatusValue; } else { - parseErrors.push(`Invalid STATUS value: "${value}". Expected: IN_PROGRESS, COMPLETE, or BLOCKED`); + parseErrors.push( + `Invalid STATUS value: "${value}". Expected: IN_PROGRESS, COMPLETE, or BLOCKED` + ); } matched = true; } @@ -3068,7 +3117,9 @@ export class RalphTracker extends EventEmitter { if (!Number.isNaN(value) && value >= 0) { block.tasksCompletedThisLoop = value; } else { - parseErrors.push(`Invalid TASKS_COMPLETED_THIS_LOOP value: "${tasksMatch[1]}". Expected: non-negative integer`); + parseErrors.push( + `Invalid TASKS_COMPLETED_THIS_LOOP value: "${tasksMatch[1]}". Expected: non-negative integer` + ); } matched = true; } @@ -3080,7 +3131,9 @@ export class RalphTracker extends EventEmitter { if (!Number.isNaN(value) && value >= 0) { block.filesModified = value; } else { - parseErrors.push(`Invalid FILES_MODIFIED value: "${filesMatch[1]}". Expected: non-negative integer`); + parseErrors.push( + `Invalid FILES_MODIFIED value: "${filesMatch[1]}". Expected: non-negative integer` + ); } matched = true; } @@ -3092,7 +3145,9 @@ export class RalphTracker extends EventEmitter { if (['PASSING', 'FAILING', 'NOT_RUN'].includes(value)) { block.testsStatus = value as RalphTestsStatus; } else { - parseErrors.push(`Invalid TESTS_STATUS value: "${value}". Expected: PASSING, FAILING, or NOT_RUN`); + parseErrors.push( + `Invalid TESTS_STATUS value: "${value}". Expected: PASSING, FAILING, or NOT_RUN` + ); } matched = true; } @@ -3104,7 +3159,9 @@ export class RalphTracker extends EventEmitter { if (['IMPLEMENTATION', 'TESTING', 'DOCUMENTATION', 'REFACTORING'].includes(value)) { block.workType = value as RalphWorkType; } else { - parseErrors.push(`Invalid WORK_TYPE value: "${value}". Expected: IMPLEMENTATION, TESTING, DOCUMENTATION, or REFACTORING`); + parseErrors.push( + `Invalid WORK_TYPE value: "${value}". Expected: IMPLEMENTATION, TESTING, DOCUMENTATION, or REFACTORING` + ); } matched = true; } @@ -3126,7 +3183,7 @@ export class RalphTracker extends EventEmitter { // Track unknown fields for debugging (only if looks like a field) if (!matched && trimmedLine.includes(':')) { const fieldName = trimmedLine.split(':')[0].trim().toUpperCase(); - if (fieldName && !['#', '//'].some(c => fieldName.startsWith(c))) { + if (fieldName && !['#', '//'].some((c) => fieldName.startsWith(c))) { unknownFields.push(fieldName); } } @@ -3266,7 +3323,10 @@ export class RalphTracker extends EventEmitter { // Track tests failure if (testsStatus === 'FAILING') { this._circuitBreaker.consecutiveTestsFailure++; - if (this._circuitBreaker.consecutiveTestsFailure >= 5 && this._circuitBreaker.state !== 'OPEN') { + if ( + this._circuitBreaker.consecutiveTestsFailure >= 5 && + this._circuitBreaker.state !== 'OPEN' + ) { this._circuitBreaker.state = 'OPEN'; this._circuitBreaker.reason = `Tests failing for ${this._circuitBreaker.consecutiveTestsFailure} iterations`; this._circuitBreaker.reasonCode = 'tests_failing_too_long'; @@ -3319,7 +3379,11 @@ export class RalphTracker extends EventEmitter { /** * Get cumulative stats from status blocks. */ - get cumulativeStats(): { filesModified: number; tasksCompleted: number; completionIndicators: number } { + get cumulativeStats(): { + filesModified: number; + tasksCompleted: number; + completionIndicators: number; + } { return { filesModified: this._totalFilesModified, tasksCompleted: this._totalTasksCompleted, @@ -3504,7 +3568,9 @@ export class RalphTracker extends EventEmitter { } // Parse priority from content if not in a priority section - const parsedPriority = inCompletedSection ? null : (currentPriority || this.parsePriority(content)); + const parsedPriority = inCompletedSection + ? null + : currentPriority || this.parsePriority(content); const id = this.generateTodoId(content); newTodos.push({ @@ -3535,17 +3601,19 @@ export class RalphTracker extends EventEmitter { * Initialize plan tasks from generated plan items. * Called when wizard generates a new plan. */ - initializePlanTasks(items: Array<{ - id?: string; - content: string; - priority?: 'P0' | 'P1' | 'P2' | null; - verificationCriteria?: string; - testCommand?: string; - dependencies?: string[]; - tddPhase?: TddPhase; - pairedWith?: string; - complexity?: 'low' | 'medium' | 'high'; - }>): void { + initializePlanTasks( + items: Array<{ + id?: string; + content: string; + priority?: 'P0' | 'P1' | 'P2' | null; + verificationCriteria?: string; + testCommand?: string; + dependencies?: string[]; + tddPhase?: TddPhase; + pairedWith?: string; + complexity?: 'low' | 'medium' | 'high'; + }> + ): void { // Save current plan to history before replacing if (this._planTasks.size > 0) { this._savePlanToHistory('Plan replaced with new generation'); @@ -3580,11 +3648,14 @@ export class RalphTracker extends EventEmitter { /** * Update a specific plan task's status, attempts, or error. */ - updatePlanTask(taskId: string, update: { - status?: PlanTaskStatus; - error?: string; - incrementAttempts?: boolean; - }): { success: boolean; task?: EnhancedPlanTask; error?: string } { + updatePlanTask( + taskId: string, + update: { + status?: PlanTaskStatus; + error?: string; + incrementAttempts?: boolean; + } + ): { success: boolean; task?: EnhancedPlanTask; error?: string } { const task = this._planTasks.get(taskId); if (!task) { return { success: false, error: 'Task not found' }; @@ -3635,7 +3706,7 @@ export class RalphTracker extends EventEmitter { for (const [_, task] of this._planTasks) { if (task.dependencies.includes(completedTaskId)) { // Check if all dependencies are now complete - const allDepsComplete = task.dependencies.every(depId => { + const allDepsComplete = task.dependencies.every((depId) => { const dep = this._planTasks.get(depId); return dep && dep.status === 'completed'; }); @@ -3653,8 +3724,10 @@ export class RalphTracker extends EventEmitter { */ private _checkForCheckpoint(): void { const currentIteration = this._loopState.cycleCount; - if (this._checkpointIterations.includes(currentIteration) && - currentIteration > this._lastCheckpointIteration) { + if ( + this._checkpointIterations.includes(currentIteration) && + currentIteration > this._lastCheckpointIteration + ) { this._lastCheckpointIteration = currentIteration; const checkpoint = this.generateCheckpointReview(); this.emit('planCheckpoint', checkpoint); @@ -3669,17 +3742,17 @@ export class RalphTracker extends EventEmitter { const summary = { total: tasks.length, - completed: tasks.filter(t => t.status === 'completed').length, - failed: tasks.filter(t => t.status === 'failed').length, - blocked: tasks.filter(t => t.status === 'blocked').length, - pending: tasks.filter(t => t.status === 'pending').length, - inProgress: tasks.filter(t => t.status === 'in_progress').length, + completed: tasks.filter((t) => t.status === 'completed').length, + failed: tasks.filter((t) => t.status === 'failed').length, + blocked: tasks.filter((t) => t.status === 'blocked').length, + pending: tasks.filter((t) => t.status === 'pending').length, + inProgress: tasks.filter((t) => t.status === 'in_progress').length, }; // Find stuck tasks (3+ attempts or blocked) const stuckTasks = tasks - .filter(t => t.attempts >= 3 || t.status === 'blocked') - .map(t => ({ + .filter((t) => t.attempts >= 3 || t.status === 'blocked') + .map((t) => ({ id: t.id, content: t.content, attempts: t.attempts, @@ -3690,18 +3763,23 @@ export class RalphTracker extends EventEmitter { const recommendations: string[] = []; if (stuckTasks.length > 0) { - recommendations.push(`${stuckTasks.length} task(s) are stuck. Consider breaking them into smaller steps.`); + recommendations.push( + `${stuckTasks.length} task(s) are stuck. Consider breaking them into smaller steps.` + ); } if (summary.failed > summary.completed && summary.total > 5) { - recommendations.push('More tasks have failed than completed. Review approach and consider plan adjustment.'); + recommendations.push( + 'More tasks have failed than completed. Review approach and consider plan adjustment.' + ); } - const progressPercent = summary.total > 0 - ? Math.round((summary.completed / summary.total) * 100) - : 0; + const progressPercent = + summary.total > 0 ? Math.round((summary.completed / summary.total) * 100) : 0; if (progressPercent < 20 && this._loopState.cycleCount > 10) { - recommendations.push('Progress is slow. Consider simplifying tasks or reviewing dependencies.'); + recommendations.push( + 'Progress is slow. Consider simplifying tasks or reviewing dependencies.' + ); } if (summary.total > 0 && summary.blocked > summary.total / 3) { @@ -3749,7 +3827,7 @@ export class RalphTracker extends EventEmitter { summary: string; stats: { total: number; completed: number; failed: number }; }> { - return this._planHistory.map(h => { + return this._planHistory.map((h) => { const tasks = Array.from(h.tasks.values()); return { version: h.version, @@ -3757,8 +3835,8 @@ export class RalphTracker extends EventEmitter { summary: h.summary, stats: { total: tasks.length, - completed: tasks.filter(t => t.status === 'completed').length, - failed: tasks.filter(t => t.status === 'failed').length, + completed: tasks.filter((t) => t.status === 'completed').length, + failed: tasks.filter((t) => t.status === 'failed').length, }, }; }); @@ -3767,8 +3845,12 @@ export class RalphTracker extends EventEmitter { /** * Rollback to a previous plan version. */ - rollbackToVersion(version: number): { success: boolean; plan?: EnhancedPlanTask[]; error?: string } { - const historyEntry = this._planHistory.find(h => h.version === version); + rollbackToVersion(version: number): { + success: boolean; + plan?: EnhancedPlanTask[]; + error?: string; + } { + const historyEntry = this._planHistory.find((h) => h.version === version); if (!historyEntry) { return { success: false, error: `Version ${version} not found in history` }; } @@ -3807,7 +3889,7 @@ export class RalphTracker extends EventEmitter { // Generate unique ID const existingIds = Array.from(this._planTasks.keys()); const prefix = task.priority || 'P1'; - let counter = existingIds.filter(id => id.startsWith(prefix)).length + 1; + let counter = existingIds.filter((id) => id.startsWith(prefix)).length + 1; let id = `${prefix}-${String(counter).padStart(3, '0')}`; while (this._planTasks.has(id)) { counter++; @@ -3850,8 +3932,10 @@ export class RalphTracker extends EventEmitter { */ isCheckpointDue(): boolean { const currentIteration = this._loopState.cycleCount; - return this._checkpointIterations.includes(currentIteration) && - currentIteration > this._lastCheckpointIteration; + return ( + this._checkpointIterations.includes(currentIteration) && + currentIteration > this._lastCheckpointIteration + ); } /** diff --git a/src/respawn-controller.ts b/src/respawn-controller.ts index da3113f7..5b813ee5 100644 --- a/src/respawn-controller.ts +++ b/src/respawn-controller.ts @@ -41,11 +41,7 @@ import { AiIdleChecker, type AiCheckResult, type AiCheckState } from './ai-idle- import { AiPlanChecker, type AiPlanCheckResult } from './ai-plan-checker.js'; import type { TeamWatcher } from './team-watcher.js'; import { BufferAccumulator } from './utils/buffer-accumulator.js'; -import { - ANSI_ESCAPE_PATTERN_SIMPLE, - TOKEN_PATTERN, - assertNever, -} from './utils/index.js'; +import { ANSI_ESCAPE_PATTERN_SIMPLE, TOKEN_PATTERN, assertNever } from './utils/index.js'; import { MAX_RESPAWN_BUFFER_SIZE, TRIM_RESPAWN_BUFFER_TO as RESPAWN_BUFFER_TRIM_SIZE, @@ -542,45 +538,46 @@ export interface RespawnEvents { /** Default configuration values */ const DEFAULT_CONFIG: RespawnConfig = { - idleTimeoutMs: 10000, // 10 seconds of no activity after prompt (legacy, still used as fallback) - updatePrompt: 'write a brief progress summary to CLAUDE.md noting what you accomplished, then continue working.', - interStepDelayMs: 1000, // 1 second between steps + idleTimeoutMs: 10000, // 10 seconds of no activity after prompt (legacy, still used as fallback) + updatePrompt: + 'write a brief progress summary to CLAUDE.md noting what you accomplished, then continue working.', + interStepDelayMs: 1000, // 1 second between steps enabled: true, - sendClear: true, // send /clear after update prompt - sendInit: true, // send /init after /clear - completionConfirmMs: 10000, // 10 seconds of silence after completion message - noOutputTimeoutMs: 30000, // 30 seconds fallback if no output at all - autoAcceptPrompts: true, // auto-accept plan mode prompts (not questions) - autoAcceptDelayMs: 8000, // 8 seconds before auto-accepting - aiIdleCheckEnabled: true, // use AI to confirm idle state + sendClear: true, // send /clear after update prompt + sendInit: true, // send /init after /clear + completionConfirmMs: 10000, // 10 seconds of silence after completion message + noOutputTimeoutMs: 30000, // 30 seconds fallback if no output at all + autoAcceptPrompts: true, // auto-accept plan mode prompts (not questions) + autoAcceptDelayMs: 8000, // 8 seconds before auto-accepting + aiIdleCheckEnabled: true, // use AI to confirm idle state aiIdleCheckModel: 'claude-opus-4-5-20251101', - aiIdleCheckMaxContext: 16000, // ~4k tokens - aiIdleCheckTimeoutMs: 90000, // 90 seconds (thinking can be slow) + aiIdleCheckMaxContext: 16000, // ~4k tokens + aiIdleCheckTimeoutMs: 90000, // 90 seconds (thinking can be slow) aiIdleCheckCooldownMs: 180000, // 3 minutes after WORKING verdict - aiPlanCheckEnabled: true, // use AI to confirm plan mode before auto-accept + aiPlanCheckEnabled: true, // use AI to confirm plan mode before auto-accept aiPlanCheckModel: 'claude-opus-4-5-20251101', - aiPlanCheckMaxContext: 8000, // ~2k tokens (plan mode UI is compact) - aiPlanCheckTimeoutMs: 60000, // 60 seconds (thinking can be slow) - aiPlanCheckCooldownMs: 30000, // 30 seconds after NOT_PLAN_MODE - stuckStateDetectionEnabled: true, // detect stuck states - stuckStateWarningMs: 300000, // 5 minutes warning threshold - stuckStateRecoveryMs: 600000, // 10 minutes recovery threshold - maxStuckRecoveries: 3, // max recovery attempts + aiPlanCheckMaxContext: 8000, // ~2k tokens (plan mode UI is compact) + aiPlanCheckTimeoutMs: 60000, // 60 seconds (thinking can be slow) + aiPlanCheckCooldownMs: 30000, // 30 seconds after NOT_PLAN_MODE + stuckStateDetectionEnabled: true, // detect stuck states + stuckStateWarningMs: 300000, // 5 minutes warning threshold + stuckStateRecoveryMs: 600000, // 10 minutes recovery threshold + maxStuckRecoveries: 3, // max recovery attempts // P2-001: Adaptive timing - adaptiveTimingEnabled: true, // Use adaptive timing based on historical patterns - adaptiveMinConfirmMs: 5000, // Minimum 5 seconds - adaptiveMaxConfirmMs: 30000, // Maximum 30 seconds + adaptiveTimingEnabled: true, // Use adaptive timing based on historical patterns + adaptiveMinConfirmMs: 5000, // Minimum 5 seconds + adaptiveMaxConfirmMs: 30000, // Maximum 30 seconds // P2-002: Skip-clear optimization - skipClearWhenLowContext: true, // Skip /clear when token count is low - skipClearThresholdPercent: 30, // Skip if below 30% of max context + skipClearWhenLowContext: true, // Skip /clear when token count is low + skipClearThresholdPercent: 30, // Skip if below 30% of max context // P2-004: Cycle metrics - trackCycleMetrics: true, // Track and persist cycle metrics + trackCycleMetrics: true, // Track and persist cycle metrics // P2-001: Confidence scoring - minIdleConfidence: 65, // Minimum confidence to trigger idle (0-100) - confidenceWeightCompletion: 40, // Weight for completion message - confidenceWeightSilence: 25, // Weight for output silence - confidenceWeightTokens: 20, // Weight for token stability - confidenceWeightNoWorking: 15, // Weight for working pattern absence + minIdleConfidence: 65, // Minimum confidence to trigger idle (0-100) + confidenceWeightCompletion: 40, // Weight for completion message + confidenceWeightSilence: 25, // Weight for output silence + confidenceWeightTokens: 20, // Weight for token stability + confidenceWeightNoWorking: 15, // Weight for working pattern absence }; /** @@ -737,7 +734,10 @@ export class RespawnController extends EventEmitter { // ========== Timer Tracking for UI Countdown Display ========== /** Active timers being tracked for UI display */ - private activeTimers: Map = new Map(); + private activeTimers: Map< + string, + { name: string; startedAt: number; durationMs: number; endsAt: number } + > = new Map(); /** Recent action log entries (for UI display, max 20) */ private recentActions: ActionLogEntry[] = []; @@ -818,9 +818,9 @@ export class RespawnController extends EventEmitter { * Used as secondary signals, not primary detection. */ private readonly PROMPT_PATTERNS = [ - '❯', // Standard prompt - '\u276f', // Unicode variant - '⏵', // Claude Code prompt variant + '❯', // Standard prompt + '\u276f', // Unicode variant + '⏵', // Claude Code prompt variant ]; /** @@ -829,15 +829,55 @@ export class RespawnController extends EventEmitter { * Note: ✻ and ✽ removed - they appear in completion messages too. */ private readonly WORKING_PATTERNS = [ - 'Thinking', 'Writing', 'Reading', 'Running', 'Searching', - 'Editing', 'Creating', 'Deleting', 'Analyzing', 'Executing', - 'Synthesizing', 'Brewing', // Claude's processing indicators - 'Compiling', 'Building', 'Installing', 'Fetching', 'Downloading', - 'Processing', 'Generating', 'Loading', 'Starting', 'Updating', - 'Checking', 'Validating', 'Testing', 'Formatting', 'Linting', - '⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏', // Spinner chars - '◐', '◓', '◑', '◒', // Alternative spinners - '⣾', '⣽', '⣻', '⢿', '⡿', '⣟', '⣯', '⣷', // Braille spinners + 'Thinking', + 'Writing', + 'Reading', + 'Running', + 'Searching', + 'Editing', + 'Creating', + 'Deleting', + 'Analyzing', + 'Executing', + 'Synthesizing', + 'Brewing', // Claude's processing indicators + 'Compiling', + 'Building', + 'Installing', + 'Fetching', + 'Downloading', + 'Processing', + 'Generating', + 'Loading', + 'Starting', + 'Updating', + 'Checking', + 'Validating', + 'Testing', + 'Formatting', + 'Linting', + '⠋', + '⠙', + '⠹', + '⠸', + '⠼', + '⠴', + '⠦', + '⠧', + '⠇', + '⠏', // Spinner chars + '◐', + '◓', + '◑', + '◒', // Alternative spinners + '⣾', + '⣽', + '⣻', + '⢿', + '⡿', + '⣟', + '⣯', + '⣷', // Braille spinners ]; /** @@ -912,12 +952,14 @@ export class RespawnController extends EventEmitter { // Ensure AI check timeouts are positive if (c.aiIdleCheckTimeoutMs <= 0) c.aiIdleCheckTimeoutMs = DEFAULT_CONFIG.aiIdleCheckTimeoutMs; if (c.aiIdleCheckCooldownMs < 0) c.aiIdleCheckCooldownMs = DEFAULT_CONFIG.aiIdleCheckCooldownMs; - if (c.aiIdleCheckMaxContext <= 0) c.aiIdleCheckMaxContext = DEFAULT_CONFIG.aiIdleCheckMaxContext; + if (c.aiIdleCheckMaxContext <= 0) + c.aiIdleCheckMaxContext = DEFAULT_CONFIG.aiIdleCheckMaxContext; // Ensure plan check timeouts are positive if (c.aiPlanCheckTimeoutMs <= 0) c.aiPlanCheckTimeoutMs = DEFAULT_CONFIG.aiPlanCheckTimeoutMs; if (c.aiPlanCheckCooldownMs < 0) c.aiPlanCheckCooldownMs = DEFAULT_CONFIG.aiPlanCheckCooldownMs; - if (c.aiPlanCheckMaxContext <= 0) c.aiPlanCheckMaxContext = DEFAULT_CONFIG.aiPlanCheckMaxContext; + if (c.aiPlanCheckMaxContext <= 0) + c.aiPlanCheckMaxContext = DEFAULT_CONFIG.aiPlanCheckMaxContext; } /** Wire up AI checker events to controller events (removes existing listeners first to prevent duplicates) */ @@ -999,7 +1041,8 @@ export class RespawnController extends EventEmitter { const completionMessageDetected = this.completionMessageTime !== null; const outputSilent = msSinceLastOutput >= this.config.completionConfirmMs; const tokensStable = msSinceTokenChange >= this.config.completionConfirmMs; - const workingPatternsAbsent = msSinceLastWorking >= RespawnController.MIN_WORKING_PATTERN_ABSENCE_MS; + const workingPatternsAbsent = + msSinceLastWorking >= RespawnController.MIN_WORKING_PATTERN_ABSENCE_MS; // Calculate confidence level (0-100) using configurable weights // P2-001: Configurable confidence scoring @@ -1200,7 +1243,7 @@ export class RespawnController extends EventEmitter { const prevState = this._state; this._state = newState; this.stateEnteredAt = Date.now(); - this.stuckStateWarned = false; // Reset warning for new state + this.stuckStateWarned = false; // Reset warning for new state this.log(`State: ${prevState} → ${newState}`); this.logAction('state', `${prevState} → ${newState}`); this.emit('stateChanged', newState, prevState); @@ -1458,7 +1501,10 @@ export class RespawnController extends EventEmitter { // Completion message during these states is ignored break; default: - assertNever(this._state, `Unhandled RespawnState in completion detection: ${this._state}`); + assertNever( + this._state, + `Unhandled RespawnState in completion detection: ${this._state}` + ); } return; } @@ -1513,12 +1559,16 @@ export class RespawnController extends EventEmitter { const stripped = data.replace(ANSI_ESCAPE_PATTERN_SIMPLE, '').trim(); if (stripped.length > 2) { if (this._state === 'ai_checking') { - this.log(`Substantial output during AI check ("${stripped.substring(0, 40)}..."), cancelling`); + this.log( + `Substantial output during AI check ("${stripped.substring(0, 40)}..."), cancelling` + ); this.aiChecker.cancel(); this.setState('watching'); } else { // Real content (not just escape codes or single chars) - cancel confirmation - this.log(`Substantial output during confirmation ("${stripped.substring(0, 40)}..."), cancelling idle detection`); + this.log( + `Substantial output during confirmation ("${stripped.substring(0, 40)}..."), cancelling idle detection` + ); this.cancelCompletionConfirm(); } return; @@ -1526,7 +1576,7 @@ export class RespawnController extends EventEmitter { } // Legacy fallback: detect prompt characters (still useful for waiting_* states) - const hasPrompt = this.PROMPT_PATTERNS.some(pattern => data.includes(pattern)); + const hasPrompt = this.PROMPT_PATTERNS.some((pattern) => data.includes(pattern)); if (hasPrompt) { this.promptDetected = true; this.workingDetected = false; @@ -1706,7 +1756,7 @@ export class RespawnController extends EventEmitter { if (this._state === 'stopped') return; const prompt = this.config.kickstartPrompt!; this.logAction('command', `Sending kickstart: "${prompt.substring(0, 40)}..."`); - await this.session.writeViaMux(prompt + '\r'); // \r triggers key.return in Ink/Claude CLI + await this.session.writeViaMux(prompt + '\r'); // \r triggers key.return in Ink/Claude CLI this.emit('stepSent', 'kickstart', prompt); this.setState('waiting_kickstart'); this.promptDetected = false; @@ -1830,12 +1880,20 @@ export class RespawnController extends EventEmitter { if (durationMs >= this.config.stuckStateRecoveryMs) { if (this.stuckRecoveryCount < this.config.maxStuckRecoveries) { this.stuckRecoveryCount++; - this.logAction('stuck', `Recovery attempt ${this.stuckRecoveryCount}/${this.config.maxStuckRecoveries}`); - this.log(`Stuck-state recovery triggered (state: ${this._state}, duration: ${Math.round(durationMs / 1000)}s, attempt: ${this.stuckRecoveryCount})`); + this.logAction( + 'stuck', + `Recovery attempt ${this.stuckRecoveryCount}/${this.config.maxStuckRecoveries}` + ); + this.log( + `Stuck-state recovery triggered (state: ${this._state}, duration: ${Math.round(durationMs / 1000)}s, attempt: ${this.stuckRecoveryCount})` + ); this.emit('stuckStateRecovery', this._state, durationMs, this.stuckRecoveryCount); this.handleStuckStateRecovery(); } else { - this.logAction('stuck', `Max recoveries (${this.config.maxStuckRecoveries}) reached - manual intervention needed`); + this.logAction( + 'stuck', + `Max recoveries (${this.config.maxStuckRecoveries}) reached - manual intervention needed` + ); this.log(`Stuck-state: max recoveries reached, manual intervention needed`); } return; @@ -1844,8 +1902,13 @@ export class RespawnController extends EventEmitter { // Check for warning threshold if (durationMs >= this.config.stuckStateWarningMs && !this.stuckStateWarned) { this.stuckStateWarned = true; - this.logAction('stuck', `Warning: in state '${this._state}' for ${Math.round(durationMs / 1000)}s`); - this.log(`Stuck-state warning: state '${this._state}' for ${Math.round(durationMs / 1000)}s without progress`); + this.logAction( + 'stuck', + `Warning: in state '${this._state}' for ${Math.round(durationMs / 1000)}s` + ); + this.log( + `Stuck-state warning: state '${this._state}' for ${Math.round(durationMs / 1000)}s without progress` + ); this.emit('stuckStateWarning', this._state, durationMs); } } @@ -1961,7 +2024,10 @@ export class RespawnController extends EventEmitter { this.activeTimers.set(name, { name, startedAt: now, durationMs, endsAt }); this.emit('timerStarted', { name, durationMs, endsAt, reason }); - this.logAction('timer', `Started ${name}: ${Math.round(durationMs / 1000)}s${reason ? ` (${reason})` : ''}`); + this.logAction( + 'timer', + `Started ${name}: ${Math.round(durationMs / 1000)}s${reason ? ` (${reason})` : ''}` + ); return setTimeout(() => { this.activeTimers.delete(name); @@ -1989,7 +2055,7 @@ export class RespawnController extends EventEmitter { */ getActiveTimers(): ActiveTimerInfo[] { const now = Date.now(); - return Array.from(this.activeTimers.values()).map(t => ({ + return Array.from(this.activeTimers.values()).map((t) => ({ name: t.name, remainingMs: Math.max(0, t.endsAt - now), totalMs: t.durationMs, @@ -2034,11 +2100,13 @@ export class RespawnController extends EventEmitter { // Always update the rolling window first to maintain continuity this.workingPatternWindow += data; if (this.workingPatternWindow.length > RespawnController.WORKING_PATTERN_WINDOW_SIZE) { - this.workingPatternWindow = this.workingPatternWindow.slice(-RespawnController.WORKING_PATTERN_WINDOW_SIZE); + this.workingPatternWindow = this.workingPatternWindow.slice( + -RespawnController.WORKING_PATTERN_WINDOW_SIZE + ); } // Check the rolling window (includes current data, catches both complete and split patterns) - return this.WORKING_PATTERNS.some(pattern => this.workingPatternWindow.includes(pattern)); + return this.WORKING_PATTERNS.some((pattern) => this.workingPatternWindow.includes(pattern)); } /** @@ -2081,7 +2149,10 @@ export class RespawnController extends EventEmitter { this.noOutputTimer = null; if (this._state === 'watching' || this._state === 'confirming_idle') { const msSinceOutput = Date.now() - this.lastOutputTime; - this.logAction('detection', `No-output fallback: ${Math.round(msSinceOutput / 1000)}s silence`); + this.logAction( + 'detection', + `No-output fallback: ${Math.round(msSinceOutput / 1000)}s silence` + ); // If AI check is disabled or errored out, go directly to idle if (!this.config.aiIdleCheckEnabled || this.aiChecker.status === 'disabled') { this.onIdleConfirmed('no-output fallback (AI check disabled)'); @@ -2134,7 +2205,10 @@ export class RespawnController extends EventEmitter { const tokensStableMet = msSinceTokenChange >= this.config.completionConfirmMs; if (silenceMet && noWorkingMet && tokensStableMet) { - this.logAction('detection', `Pre-filter passed: silence=${Math.round(msSinceOutput / 1000)}s`); + this.logAction( + 'detection', + `Pre-filter passed: silence=${Math.round(msSinceOutput / 1000)}s` + ); this.tryStartAiCheck('pre-filter'); } } @@ -2168,14 +2242,18 @@ export class RespawnController extends EventEmitter { // If AI check is disabled or errored out, fall back to direct idle confirmation if (!this.config.aiIdleCheckEnabled || this.aiChecker.status === 'disabled') { - this.log(`AI check unavailable (${this.aiChecker.status}), confirming idle directly via: ${reason}`); + this.log( + `AI check unavailable (${this.aiChecker.status}), confirming idle directly via: ${reason}` + ); this.onIdleConfirmed(reason); return; } // If on cooldown, don't start check - wait for cooldown to expire if (this.aiChecker.isOnCooldown()) { - this.log(`AI check on cooldown (${Math.ceil(this.aiChecker.getCooldownRemainingMs() / 1000)}s remaining), waiting...`); + this.log( + `AI check on cooldown (${Math.ceil(this.aiChecker.getCooldownRemainingMs() / 1000)}s remaining), waiting...` + ); return; } @@ -2207,67 +2285,80 @@ export class RespawnController extends EventEmitter { // Get the terminal buffer for analysis const buffer = this.terminalBuffer.value; - this.aiChecker.check(buffer).then((result) => { - // If state changed while checking (e.g., cancelled), ignore result - if (this._state !== 'ai_checking') { - this.log(`AI check result ignored (state is now ${this._state})`); - return; - } + this.aiChecker + .check(buffer) + .then((result) => { + // If state changed while checking (e.g., cancelled), ignore result + if (this._state !== 'ai_checking') { + this.log(`AI check result ignored (state is now ${this._state})`); + return; + } - // Validate this is the result for the current check (not a stale one) - if (this._currentAiCheckId !== checkId) { - this.log(`AI check result ignored (stale check ID: ${checkId.substring(0, 8)})`); - return; - } + // Validate this is the result for the current check (not a stale one) + if (this._currentAiCheckId !== checkId) { + this.log(`AI check result ignored (stale check ID: ${checkId.substring(0, 8)})`); + return; + } - if (result.verdict === 'IDLE') { - // Cancel any pending confirmation timers - AI has spoken - this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimer, 'AI verdict: IDLE'); - this.completionConfirmTimer = null; - this.cancelTrackedTimer('pre-filter', this.preFilterTimer, 'AI verdict: IDLE'); - this.preFilterTimer = null; + if (result.verdict === 'IDLE') { + // Cancel any pending confirmation timers - AI has spoken + this.cancelTrackedTimer( + 'completion-confirm', + this.completionConfirmTimer, + 'AI verdict: IDLE' + ); + this.completionConfirmTimer = null; + this.cancelTrackedTimer('pre-filter', this.preFilterTimer, 'AI verdict: IDLE'); + this.preFilterTimer = null; - this.logAction('ai-check', `Verdict: IDLE - ${result.reasoning}`); - this.emit('aiCheckCompleted', result); - this.onIdleConfirmed(`ai-check: idle (${result.reasoning})`); - } else if (result.verdict === 'WORKING') { - // Cancel timers and go to cooldown - this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimer, 'AI verdict: WORKING'); - this.completionConfirmTimer = null; + this.logAction('ai-check', `Verdict: IDLE - ${result.reasoning}`); + this.emit('aiCheckCompleted', result); + this.onIdleConfirmed(`ai-check: idle (${result.reasoning})`); + } else if (result.verdict === 'WORKING') { + // Cancel timers and go to cooldown + this.cancelTrackedTimer( + 'completion-confirm', + this.completionConfirmTimer, + 'AI verdict: WORKING' + ); + this.completionConfirmTimer = null; - this.logAction('ai-check', `Verdict: WORKING - ${result.reasoning}`); - this.emit('aiCheckCompleted', result); - this.setState('watching'); - this.log(`AI check says WORKING, returning to watching with ${this.config.aiIdleCheckCooldownMs}ms cooldown`); - // Restart timers so the controller retries after cooldown expires - this.startNoOutputTimer(); - this.startPreFilterTimer(); - } else { - // ERROR verdict - this.logAction('ai-check', `Error: ${result.reasoning}`); - this.emit('aiCheckFailed', result.reasoning); - this.setState('watching'); - // Restart timers to allow retry - this.startNoOutputTimer(); - this.startPreFilterTimer(); - } - }).catch((err) => { - // Validate this is the error for the current check - if (this._currentAiCheckId !== checkId) { - return; // Stale check, ignore error - } - if (this._state === 'stopped') return; // Guard against stopped state - if (this._state === 'ai_checking') { - const errorMsg = err instanceof Error ? err.message : String(err); - this.logAction('ai-check', `Failed: ${errorMsg.substring(0, 50)}`); - this.emit('aiCheckFailed', errorMsg); - this.setState('watching'); - this.log(`AI check error: ${errorMsg}`); - // Restart timers to allow retry - this.startNoOutputTimer(); - this.startPreFilterTimer(); - } - }); + this.logAction('ai-check', `Verdict: WORKING - ${result.reasoning}`); + this.emit('aiCheckCompleted', result); + this.setState('watching'); + this.log( + `AI check says WORKING, returning to watching with ${this.config.aiIdleCheckCooldownMs}ms cooldown` + ); + // Restart timers so the controller retries after cooldown expires + this.startNoOutputTimer(); + this.startPreFilterTimer(); + } else { + // ERROR verdict + this.logAction('ai-check', `Error: ${result.reasoning}`); + this.emit('aiCheckFailed', result.reasoning); + this.setState('watching'); + // Restart timers to allow retry + this.startNoOutputTimer(); + this.startPreFilterTimer(); + } + }) + .catch((err) => { + // Validate this is the error for the current check + if (this._currentAiCheckId !== checkId) { + return; // Stale check, ignore error + } + if (this._state === 'stopped') return; // Guard against stopped state + if (this._state === 'ai_checking') { + const errorMsg = err instanceof Error ? err.message : String(err); + this.logAction('ai-check', `Failed: ${errorMsg.substring(0, 50)}`); + this.emit('aiCheckFailed', errorMsg); + this.setState('watching'); + this.log(`AI check error: ${errorMsg}`); + // Restart timers to allow retry + this.startNoOutputTimer(); + this.startPreFilterTimer(); + } + }); } // ========== Auto-Accept Prompt Methods ========== @@ -2351,7 +2442,9 @@ export class RespawnController extends EventEmitter { // Stage 2: AI confirmation (if enabled and available) if (this.config.aiPlanCheckEnabled && this.planChecker.status !== 'disabled') { if (this.planChecker.isOnCooldown()) { - this.log(`Skipping auto-accept: plan checker on cooldown (${Math.ceil(this.planChecker.getCooldownRemainingMs() / 1000)}s remaining)`); + this.log( + `Skipping auto-accept: plan checker on cooldown (${Math.ceil(this.planChecker.getCooldownRemainingMs() / 1000)}s remaining)` + ); return; } if (this.planChecker.status === 'checking') { @@ -2396,7 +2489,7 @@ export class RespawnController extends EventEmitter { // Working patterns before the selector are from earlier work and don't matter. const selectorIndex = stripped.lastIndexOf(selectorMatch[0]); const afterSelector = stripped.slice(selectorIndex + selectorMatch[0].length); - const hasWorking = this.WORKING_PATTERNS.some(pattern => afterSelector.includes(pattern)); + const hasWorking = this.WORKING_PATTERNS.some((pattern) => afterSelector.includes(pattern)); if (hasWorking) return false; return true; @@ -2416,36 +2509,42 @@ export class RespawnController extends EventEmitter { this.logAction('plan-check', 'Spawning AI plan checker'); this.emit('planCheckStarted'); - this.planChecker.check(buffer).then((result) => { - // Discard stale result if new output arrived during check - if (this.lastOutputTime > this.planCheckStartTime) { - this.logAction('plan-check', 'Result discarded (output arrived during check)'); - return; - } - - if (result.verdict === 'PLAN_MODE') { - // Don't send Enter if state changed (e.g., AI idle check started or respawn cycle began) - if (this._state !== 'watching') { - this.logAction('plan-check', `Verdict: PLAN_MODE but state is ${this._state}, not sending Enter`); + this.planChecker + .check(buffer) + .then((result) => { + // Discard stale result if new output arrived during check + if (this.lastOutputTime > this.planCheckStartTime) { + this.logAction('plan-check', 'Result discarded (output arrived during check)'); return; } - this.emit('planCheckCompleted', result); - this.logAction('plan-check', 'Verdict: PLAN_MODE - sending Enter immediately'); - this.sendAutoAcceptEnter(); - // No cooldown needed - we're taking action - } else if (result.verdict === 'NOT_PLAN_MODE') { - this.emit('planCheckCompleted', result); - this.logAction('plan-check', `Verdict: NOT_PLAN_MODE - ${result.reasoning}`); - } else { - // ERROR verdict - this.emit('planCheckFailed', result.reasoning); - this.logAction('plan-check', `Error: ${result.reasoning}`); - } - }).catch((err) => { - const errorMsg = err instanceof Error ? err.message : String(err); - this.emit('planCheckFailed', errorMsg); - this.logAction('plan-check', `Failed: ${errorMsg.substring(0, 50)}`); - }); + + if (result.verdict === 'PLAN_MODE') { + // Don't send Enter if state changed (e.g., AI idle check started or respawn cycle began) + if (this._state !== 'watching') { + this.logAction( + 'plan-check', + `Verdict: PLAN_MODE but state is ${this._state}, not sending Enter` + ); + return; + } + this.emit('planCheckCompleted', result); + this.logAction('plan-check', 'Verdict: PLAN_MODE - sending Enter immediately'); + this.sendAutoAcceptEnter(); + // No cooldown needed - we're taking action + } else if (result.verdict === 'NOT_PLAN_MODE') { + this.emit('planCheckCompleted', result); + this.logAction('plan-check', `Verdict: NOT_PLAN_MODE - ${result.reasoning}`); + } else { + // ERROR verdict + this.emit('planCheckFailed', result.reasoning); + this.logAction('plan-check', `Error: ${result.reasoning}`); + } + }) + .catch((err) => { + const errorMsg = err instanceof Error ? err.message : String(err); + this.emit('planCheckFailed', errorMsg); + this.logAction('plan-check', `Failed: ${errorMsg.substring(0, 50)}`); + }); } /** @@ -2455,7 +2554,9 @@ export class RespawnController extends EventEmitter { */ private sendAutoAcceptEnter(): void { const msSinceOutput = Date.now() - this.lastOutputTime; - this.log(`Auto-accepting plan mode prompt (${msSinceOutput}ms silence, pre-filter + AI confirmed)`); + this.log( + `Auto-accepting plan mode prompt (${msSinceOutput}ms silence, pre-filter + AI confirmed)` + ); // Cancel any pending AI idle checks - we're about to make Claude work if (this.aiChecker.status === 'checking') { @@ -2501,7 +2602,11 @@ export class RespawnController extends EventEmitter { */ signalStopHook(): void { // Only process in states where we're watching for idle - if (this._state !== 'watching' && this._state !== 'confirming_idle' && this._state !== 'ai_checking') { + if ( + this._state !== 'watching' && + this._state !== 'confirming_idle' && + this._state !== 'ai_checking' + ) { this.log(`Stop hook received but ignoring (state is ${this._state})`); return; } @@ -2519,7 +2624,11 @@ export class RespawnController extends EventEmitter { } // Cancel completion confirm timer - hook takes precedence - this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimer, 'Stop hook received'); + this.cancelTrackedTimer( + 'completion-confirm', + this.completionConfirmTimer, + 'Stop hook received' + ); this.completionConfirmTimer = null; // Cancel pre-filter timer - hook takes precedence @@ -2540,7 +2649,11 @@ export class RespawnController extends EventEmitter { */ signalIdlePrompt(): void { // Only process in states where we're watching for idle - if (this._state !== 'watching' && this._state !== 'confirming_idle' && this._state !== 'ai_checking') { + if ( + this._state !== 'watching' && + this._state !== 'confirming_idle' && + this._state !== 'ai_checking' + ) { this.log(`idle_prompt received but ignoring (state is ${this._state})`); return; } @@ -2558,7 +2671,11 @@ export class RespawnController extends EventEmitter { } // Cancel all other detection timers - this is definitive - this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimer, 'idle_prompt received'); + this.cancelTrackedTimer( + 'completion-confirm', + this.completionConfirmTimer, + 'idle_prompt received' + ); this.completionConfirmTimer = null; this.cancelTrackedTimer('pre-filter', this.preFilterTimer, 'idle_prompt received'); this.preFilterTimer = null; @@ -2604,7 +2721,10 @@ export class RespawnController extends EventEmitter { } // No new output - confirm idle via hook signal - this.logAction('hook', `${hookType} confirmed after ${RespawnController.HOOK_CONFIRM_DELAY_MS}ms`); + this.logAction( + 'hook', + `${hookType} confirmed after ${RespawnController.HOOK_CONFIRM_DELAY_MS}ms` + ); this.onIdleConfirmed(`${hookType} hook (confirmed)`); }, `confirming ${hookType} hook` @@ -2715,7 +2835,10 @@ export class RespawnController extends EventEmitter { const msSinceOutput = Date.now() - this.lastOutputTime; if (msSinceOutput >= this.config.completionConfirmMs) { - this.logAction('step', `${step} confirmed after ${Math.round(msSinceOutput / 1000)}s silence`); + this.logAction( + 'step', + `${step} confirmed after ${Math.round(msSinceOutput / 1000)}s silence` + ); // Proceed with the step completion switch (step) { @@ -2755,7 +2878,9 @@ export class RespawnController extends EventEmitter { // Safety check: if Session thinks it's still working, don't trigger idle // This catches cases where our detection missed working patterns if (this.session.isWorking) { - this.log(`Idle confirmation rejected - Session reports isWorking=true (reason was: ${reason})`); + this.log( + `Idle confirmation rejected - Session reports isWorking=true (reason was: ${reason})` + ); this.logAction('detection', 'Rejected: Session still working'); this.setState('watching'); this.startNoOutputTimer(); @@ -2765,11 +2890,13 @@ export class RespawnController extends EventEmitter { this.log(`Idle confirmed via: ${reason}`); const status = this.getDetectionStatus(); - this.log(`Detection status: confidence=${status.confidenceLevel}%, ` + - `completion=${status.completionMessageDetected}, ` + - `silent=${status.outputSilent}, ` + - `tokensStable=${status.tokensStable}, ` + - `noWorking=${status.workingPatternsAbsent}`); + this.log( + `Detection status: confidence=${status.confidenceLevel}%, ` + + `completion=${status.completionMessageDetected}, ` + + `silent=${status.outputSilent}, ` + + `tokensStable=${status.tokensStable}, ` + + `noWorking=${status.workingPatternsAbsent}` + ); // ========== Agent Teams Integration ========== // Check if session has active teammates — don't respawn while team is working @@ -2777,7 +2904,10 @@ export class RespawnController extends EventEmitter { const count = this.teamWatcher.getActiveTeammateCount(this.session.id); this.log(`Respawn blocked - ${count} active teammate(s) working`); this.logAction('team', `Active teammates: ${count}`); - this.emit('respawnBlocked', { reason: 'active_teammates', details: `${count} teammate(s) still working` }); + this.emit('respawnBlocked', { + reason: 'active_teammates', + details: `${count} teammate(s) still working`, + }); this.setState('watching'); this.startNoOutputTimer(); this.startPreFilterTimer(); @@ -2792,7 +2922,10 @@ export class RespawnController extends EventEmitter { if (circuitBreaker.state === 'OPEN') { this.log(`Respawn blocked - Circuit breaker OPEN: ${circuitBreaker.reason}`); this.logAction('ralph', `Circuit breaker OPEN: ${circuitBreaker.reason}`); - this.emit('respawnBlocked', { reason: 'circuit_breaker_open', details: circuitBreaker.reason }); + this.emit('respawnBlocked', { + reason: 'circuit_breaker_open', + details: circuitBreaker.reason, + }); this.setState('watching'); // Don't restart timers - wait for manual reset or circuit breaker resolution return; @@ -2803,8 +2936,14 @@ export class RespawnController extends EventEmitter { const statusBlock = ralphTracker?.lastStatusBlock; if (statusBlock?.exitSignal) { this.log(`Respawn paused - RALPH_STATUS EXIT_SIGNAL=true`); - this.logAction('ralph', `Exit signal detected: ${statusBlock.recommendation || 'Task complete'}`); - this.emit('respawnBlocked', { reason: 'exit_signal', details: statusBlock.recommendation || 'Task complete' }); + this.logAction( + 'ralph', + `Exit signal detected: ${statusBlock.recommendation || 'Task complete'}` + ); + this.emit('respawnBlocked', { + reason: 'exit_signal', + details: statusBlock.recommendation || 'Task complete', + }); this.setState('watching'); // Don't restart timers - loop is complete return; @@ -2813,8 +2952,14 @@ export class RespawnController extends EventEmitter { // Check if STATUS=BLOCKED - trigger circuit breaker if (statusBlock?.status === 'BLOCKED') { this.log(`Respawn blocked - RALPH_STATUS reports BLOCKED`); - this.logAction('ralph', `Claude reported BLOCKED: ${statusBlock.recommendation || 'Needs human intervention'}`); - this.emit('respawnBlocked', { reason: 'status_blocked', details: statusBlock.recommendation || 'Needs human intervention' }); + this.logAction( + 'ralph', + `Claude reported BLOCKED: ${statusBlock.recommendation || 'Needs human intervention'}` + ); + this.emit('respawnBlocked', { + reason: 'status_blocked', + details: statusBlock.recommendation || 'Needs human intervention', + }); this.setState('watching'); return; } @@ -2834,7 +2979,11 @@ export class RespawnController extends EventEmitter { */ private onIdleDetected(): void { // Accept watching, confirming_idle, and ai_checking states - if (this._state !== 'watching' && this._state !== 'confirming_idle' && this._state !== 'ai_checking') { + if ( + this._state !== 'watching' && + this._state !== 'confirming_idle' && + this._state !== 'ai_checking' + ) { return; } @@ -2846,7 +2995,10 @@ export class RespawnController extends EventEmitter { if (this.session.status === 'error') { this.log('Skipping respawn cycle - session is in error state'); this.logAction('health', 'Respawn skipped: Session error state'); - this.emit('respawnBlocked', { reason: 'session_error', details: 'Session is in error state' }); + this.emit('respawnBlocked', { + reason: 'session_error', + details: 'Session is in error state', + }); this.setState('watching'); return; } @@ -2907,7 +3059,7 @@ export class RespawnController extends EventEmitter { this.logAction('ralph', `Using RECOMMENDATION: ${rec.substring(0, 50)}...`); } - const input = updatePrompt + '\r'; // \r triggers Enter in Ink/Claude CLI + const input = updatePrompt + '\r'; // \r triggers Enter in Ink/Claude CLI this.logAction('command', `Sending: "${updatePrompt.substring(0, 50)}..."`); await this.session.writeViaMux(input); this.emit('stepSent', 'update', updatePrompt); @@ -2937,7 +3089,7 @@ export class RespawnController extends EventEmitter { this.stepTimer = null; if (this._state === 'stopped') return; this.logAction('command', 'Sending: /clear'); - await this.session.writeViaMux('/clear\r'); // \r triggers Enter in Ink/Claude CLI + await this.session.writeViaMux('/clear\r'); // \r triggers Enter in Ink/Claude CLI this.emit('stepSent', 'clear', '/clear'); this.setState('waiting_clear'); this.promptDetected = false; @@ -2981,7 +3133,7 @@ export class RespawnController extends EventEmitter { this.stepTimer = null; if (this._state === 'stopped') return; this.logAction('command', 'Sending: /init'); - await this.session.writeViaMux('/init\r'); // \r triggers Enter in Ink/Claude CLI + await this.session.writeViaMux('/init\r'); // \r triggers Enter in Ink/Claude CLI this.emit('stepSent', 'init', '/init'); this.setState('waiting_init'); this.promptDetected = false; @@ -3052,9 +3204,13 @@ export class RespawnController extends EventEmitter { this.config = { ...this.config, ...filteredConfig }; // Sync AI checker config if relevant fields changed - if (config.aiIdleCheckEnabled !== undefined || config.aiIdleCheckModel !== undefined || - config.aiIdleCheckMaxContext !== undefined || config.aiIdleCheckTimeoutMs !== undefined || - config.aiIdleCheckCooldownMs !== undefined) { + if ( + config.aiIdleCheckEnabled !== undefined || + config.aiIdleCheckModel !== undefined || + config.aiIdleCheckMaxContext !== undefined || + config.aiIdleCheckTimeoutMs !== undefined || + config.aiIdleCheckCooldownMs !== undefined + ) { this.aiChecker.updateConfig({ enabled: this.config.aiIdleCheckEnabled, model: this.config.aiIdleCheckModel, @@ -3065,9 +3221,13 @@ export class RespawnController extends EventEmitter { } // Sync plan checker config if relevant fields changed - if (config.aiPlanCheckEnabled !== undefined || config.aiPlanCheckModel !== undefined || - config.aiPlanCheckMaxContext !== undefined || config.aiPlanCheckTimeoutMs !== undefined || - config.aiPlanCheckCooldownMs !== undefined) { + if ( + config.aiPlanCheckEnabled !== undefined || + config.aiPlanCheckModel !== undefined || + config.aiPlanCheckMaxContext !== undefined || + config.aiPlanCheckTimeoutMs !== undefined || + config.aiPlanCheckCooldownMs !== undefined + ) { this.planChecker.updateConfig({ enabled: this.config.aiPlanCheckEnabled, model: this.config.aiPlanCheckModel, @@ -3223,7 +3383,9 @@ export class RespawnController extends EventEmitter { const usagePercent = (currentTokens / maxContext) * 100; if (usagePercent < thresholdPercent) { - this.log(`Skip-clear optimization: ${usagePercent.toFixed(1)}% < ${thresholdPercent}% threshold`); + this.log( + `Skip-clear optimization: ${usagePercent.toFixed(1)}% < ${thresholdPercent}% threshold` + ); this.logAction('optimization', `Skipping /clear (${usagePercent.toFixed(1)}% context used)`); return true; } @@ -3276,7 +3438,7 @@ export class RespawnController extends EventEmitter { const now = Date.now(); const metrics: RespawnCycleMetrics = { - ...this.currentCycleMetrics as RespawnCycleMetrics, + ...(this.currentCycleMetrics as RespawnCycleMetrics), completedAt: now, durationMs: now - (this.currentCycleMetrics.startedAt ?? now), outcome, @@ -3299,7 +3461,9 @@ export class RespawnController extends EventEmitter { // Clear current cycle this.currentCycleMetrics = null; - this.log(`Cycle #${metrics.cycleNumber} metrics: ${outcome}, duration=${metrics.durationMs}ms, idle_detection=${metrics.idleDetectionMs}ms`); + this.log( + `Cycle #${metrics.cycleNumber} metrics: ${outcome}, duration=${metrics.durationMs}ms, idle_detection=${metrics.idleDetectionMs}ms` + ); } /** @@ -3333,8 +3497,8 @@ export class RespawnController extends EventEmitter { } // Recalculate averages using all recent metrics - const durations = this.recentCycleMetrics.map(m => m.durationMs); - const idleTimes = this.recentCycleMetrics.map(m => m.idleDetectionMs); + const durations = this.recentCycleMetrics.map((m) => m.durationMs); + const idleTimes = this.recentCycleMetrics.map((m) => m.idleDetectionMs); if (durations.length > 0) { agg.avgCycleDurationMs = Math.round(durations.reduce((a, b) => a + b, 0) / durations.length); @@ -3347,9 +3511,8 @@ export class RespawnController extends EventEmitter { } // Calculate success rate - agg.successRate = agg.totalCycles > 0 - ? Math.round((agg.successfulCycles / agg.totalCycles) * 100) - : 100; + agg.successRate = + agg.totalCycles > 0 ? Math.round((agg.successfulCycles / agg.totalCycles) * 100) : 100; agg.lastUpdatedAt = Date.now(); } @@ -3392,18 +3555,18 @@ export class RespawnController extends EventEmitter { // Weighted average (cycle success is most important) const weights = { cycleSuccess: 0.35, - circuitBreaker: 0.20, - iterationProgress: 0.20, + circuitBreaker: 0.2, + iterationProgress: 0.2, aiChecker: 0.15, - stuckRecovery: 0.10, + stuckRecovery: 0.1, }; const score = Math.round( components.cycleSuccess * weights.cycleSuccess + - components.circuitBreaker * weights.circuitBreaker + - components.iterationProgress * weights.iterationProgress + - components.aiChecker * weights.aiChecker + - components.stuckRecovery * weights.stuckRecovery + components.circuitBreaker * weights.circuitBreaker + + components.iterationProgress * weights.iterationProgress + + components.aiChecker * weights.aiChecker + + components.stuckRecovery * weights.stuckRecovery ); // Determine status @@ -3446,10 +3609,14 @@ export class RespawnController extends EventEmitter { const cb = tracker.circuitBreakerStatus; switch (cb.state) { - case 'CLOSED': return 100; - case 'HALF_OPEN': return 50; - case 'OPEN': return 0; - default: return 100; + case 'CLOSED': + return 100; + case 'HALF_OPEN': + return 50; + case 'OPEN': + return 0; + default: + return 100; } } @@ -3497,19 +3664,27 @@ export class RespawnController extends EventEmitter { const recommendations: string[] = []; if (components.cycleSuccess < 70) { - recommendations.push('Cycle success rate is low. Check for recurring errors or stuck states.'); + recommendations.push( + 'Cycle success rate is low. Check for recurring errors or stuck states.' + ); } if (components.circuitBreaker < 50) { - recommendations.push('Circuit breaker is open or half-open. Review recent errors and consider manual reset.'); + recommendations.push( + 'Circuit breaker is open or half-open. Review recent errors and consider manual reset.' + ); } if (components.iterationProgress < 50) { recommendations.push('Iteration progress has stalled. Check if Claude is stuck on a task.'); } if (components.aiChecker < 50) { - recommendations.push('AI idle checker has errors. May need to check Claude CLI availability.'); + recommendations.push( + 'AI idle checker has errors. May need to check Claude CLI availability.' + ); } if (components.stuckRecovery < 50) { - recommendations.push('Multiple stuck-state recoveries occurred. Consider increasing timeouts.'); + recommendations.push( + 'Multiple stuck-state recoveries occurred. Consider increasing timeouts.' + ); } if (recommendations.length === 0) { @@ -3527,8 +3702,10 @@ export class RespawnController extends EventEmitter { status: HealthStatus, components: RalphLoopHealthScore['components'] ): string { - const lowest = Object.entries(components).reduce((min, [key, val]) => - val < min.val ? { key, val } : min, { key: '', val: 100 }); + const lowest = Object.entries(components).reduce( + (min, [key, val]) => (val < min.val ? { key, val } : min), + { key: '', val: 100 } + ); if (status === 'excellent') { return `Ralph Loop is operating excellently (${score}/100). All systems healthy.`; diff --git a/src/run-summary.ts b/src/run-summary.ts index 7a9c0fae..f6dc23a9 100644 --- a/src/run-summary.ts +++ b/src/run-summary.ts @@ -139,13 +139,10 @@ export class RunSummaryTracker { // Record state transition if (oldState && oldState !== newState) { this.stats.stateTransitions++; - this.addEvent( - 'respawn_state_change', - 'info', - `State: ${oldState} → ${newState}`, - details, - { from: oldState, to: newState } - ); + this.addEvent('respawn_state_change', 'info', `State: ${oldState} → ${newState}`, details, { + from: oldState, + to: newState, + }); } // Update state tracking @@ -206,7 +203,8 @@ export class RunSummaryTracker { } // Check for milestone - const currentMilestone = Math.floor(total / TOKEN_MILESTONE_INTERVAL) * TOKEN_MILESTONE_INTERVAL; + const currentMilestone = + Math.floor(total / TOKEN_MILESTONE_INTERVAL) * TOKEN_MILESTONE_INTERVAL; if (currentMilestone > this.lastTokenMilestone && currentMilestone > 0) { this.lastTokenMilestone = currentMilestone; this.addEvent( @@ -264,13 +262,9 @@ export class RunSummaryTracker { * Record a Ralph completion detection. */ recordRalphCompletion(phrase: string): void { - this.addEvent( - 'ralph_completion', - 'success', - 'Ralph completion detected', - `Phrase: ${phrase}`, - { phrase } - ); + this.addEvent('ralph_completion', 'success', 'Ralph completion detected', `Phrase: ${phrase}`, { + phrase, + }); } /** @@ -278,9 +272,7 @@ export class RunSummaryTracker { */ recordHookEvent(eventType: string, data?: Record): void { const severity: RunSummaryEventSeverity = - eventType === 'stop' ? 'warning' : - eventType === 'permission_prompt' ? 'info' : - 'info'; + eventType === 'stop' ? 'warning' : eventType === 'permission_prompt' ? 'info' : 'info'; this.addEvent( 'hook_event', diff --git a/src/session-manager.ts b/src/session-manager.ts index 70420d89..387c9cbe 100644 --- a/src/session-manager.ts +++ b/src/session-manager.ts @@ -102,7 +102,7 @@ export class SessionManager extends EventEmitter { // Create a new lock promise that others will wait on // Define unlock first to ensure it's always in scope before promise assignment let unlock!: () => void; - const lockPromise = new Promise(resolve => { + const lockPromise = new Promise((resolve) => { unlock = resolve; }); this._sessionCreationLock = lockPromise; @@ -196,9 +196,7 @@ export class SessionManager extends EventEmitter { * Uses Promise.allSettled to ensure all sessions are stopped even if some fail. */ async stopAllSessions(): Promise { - const stopPromises = Array.from(this.sessions.keys()).map((id) => - this.stopSession(id) - ); + const stopPromises = Array.from(this.sessions.keys()).map((id) => this.stopSession(id)); const results = await Promise.allSettled(stopPromises); // Log any failures but don't throw - best effort cleanup for (const result of results) { @@ -292,4 +290,3 @@ export function getSessionManager(): SessionManager { } return managerInstance; } - diff --git a/src/session.ts b/src/session.ts index 8d64a404..46905498 100644 --- a/src/session.ts +++ b/src/session.ts @@ -18,7 +18,19 @@ import { EventEmitter } from 'node:events'; import { v4 as uuidv4 } from 'uuid'; import * as pty from 'node-pty'; -import { SessionState, SessionStatus, SessionConfig, RalphTrackerState, RalphTodoItem, ActiveBashTool, NiceConfig, DEFAULT_NICE_CONFIG, type ClaudeMode, type SessionMode, type OpenCodeConfig } from './types.js'; +import { + SessionState, + SessionStatus, + SessionConfig, + RalphTrackerState, + RalphTodoItem, + ActiveBashTool, + NiceConfig, + DEFAULT_NICE_CONFIG, + type ClaudeMode, + type SessionMode, + type OpenCodeConfig, +} from './types.js'; import type { TerminalMultiplexer, MuxSession } from './mux-interface.js'; import { TaskTracker, type BackgroundTask } from './task-tracker.js'; import { RalphTracker } from './ralph-tracker.js'; @@ -96,11 +108,7 @@ import { getAugmentedPath } from './utils/claude-cli-resolver.js'; * @param operation - Description of the operation for error messages * @returns Promise that resolves/rejects with the original result or timeout error */ -function withTimeout( - promise: Promise, - timeoutMs: number, - operation: string -): Promise { +function withTimeout(promise: Promise, timeoutMs: number, operation: string): Promise { let timeoutId: NodeJS.Timeout; const timeoutPromise = new Promise((_, reject) => { @@ -193,7 +201,12 @@ export interface SessionEvents { /** Active Bash tools list updated */ bashToolsUpdate: (tools: ActiveBashTool[]) => void; /** CLI info (version, model, account) updated */ - cliInfoUpdated: (info: { version: string | null; model: string | null; accountType: string | null; latestVersion: string | null }) => void; + cliInfoUpdated: (info: { + version: string | null; + model: string | null; + accountType: string | null; + latestVersion: string | null; + }) => void; } // SessionMode is imported from types.ts (single source of truth) @@ -247,7 +260,10 @@ export class Session extends EventEmitter { private _status: SessionStatus = 'idle'; private _currentTaskId: string | null = null; // Use BufferAccumulator for hot-path buffers to reduce GC pressure - private _terminalBuffer = new BufferAccumulator(MAX_TERMINAL_BUFFER_SIZE, TERMINAL_BUFFER_TRIM_SIZE); + private _terminalBuffer = new BufferAccumulator( + MAX_TERMINAL_BUFFER_SIZE, + TERMINAL_BUFFER_TRIM_SIZE + ); private _textOutput = new BufferAccumulator(MAX_TEXT_OUTPUT_SIZE, TEXT_OUTPUT_TRIM_SIZE); private _errorBuffer: string = ''; private _lastActivityAt: number; @@ -258,7 +274,7 @@ export class Session extends EventEmitter { private _lineBufferFlushTimer: NodeJS.Timeout | null = null; private resolvePromise: ((value: { result: string; cost: number }) => void) | null = null; private rejectPromise: ((reason: Error) => void) | null = null; - private _promptResolved: boolean = false; // Guard against race conditions in runPrompt + private _promptResolved: boolean = false; // Guard against race conditions in runPrompt private _isWorking: boolean = false; private _lastPromptTime: number = 0; private activityTimeout: NodeJS.Timeout | null = null; @@ -356,7 +372,9 @@ export class Session extends EventEmitter { // Task descriptions parsed from terminal output (e.g., "Explore(Description)") // Used to correlate with SubagentWatcher discoveries for better window titles // Uses LRUMap for automatic eviction at MAX_TASK_DESCRIPTIONS limit - private _recentTaskDescriptions: LRUMap = new LRUMap({ maxSize: Session.MAX_TASK_DESCRIPTIONS }); + private _recentTaskDescriptions: LRUMap = new LRUMap({ + maxSize: Session.MAX_TASK_DESCRIPTIONS, + }); // Throttle expensive PTY processing (Ralph, bash parser, task descriptions) // Accumulates clean data between processing windows to avoid running regex on every chunk @@ -365,26 +383,28 @@ export class Session extends EventEmitter { private _expensiveProcessTimer: NodeJS.Timeout | null = null; private static readonly EXPENSIVE_PROCESS_INTERVAL_MS = 150; // Process at most every 150ms - constructor(config: Partial & { - workingDir: string; - mode?: SessionMode; - name?: string; - /** Terminal multiplexer instance (tmux) */ - mux?: TerminalMultiplexer; - /** Whether to use multiplexer wrapping */ - useMux?: boolean; - /** Existing mux session for restored sessions */ - muxSession?: MuxSession; - niceConfig?: NiceConfig; // Nice prioritying configuration - /** Claude model override (e.g., 'opus', 'sonnet', 'haiku') */ - model?: string; - /** Claude CLI startup permission mode */ - claudeMode?: ClaudeMode; - /** Comma-separated allowed tools (for 'allowedTools' mode) */ - allowedTools?: string; - /** OpenCode configuration (only for mode === 'opencode') */ - openCodeConfig?: OpenCodeConfig; - }) { + constructor( + config: Partial & { + workingDir: string; + mode?: SessionMode; + name?: string; + /** Terminal multiplexer instance (tmux) */ + mux?: TerminalMultiplexer; + /** Whether to use multiplexer wrapping */ + useMux?: boolean; + /** Existing mux session for restored sessions */ + muxSession?: MuxSession; + niceConfig?: NiceConfig; // Nice prioritying configuration + /** Claude model override (e.g., 'opus', 'sonnet', 'haiku') */ + model?: string; + /** Claude CLI startup permission mode */ + claudeMode?: ClaudeMode; + /** Comma-separated allowed tools (for 'allowedTools' mode) */ + allowedTools?: string; + /** OpenCode configuration (only for mode === 'opencode') */ + openCodeConfig?: OpenCodeConfig; + } + ) { super(); this.setMaxListeners(25); @@ -472,7 +492,6 @@ export class Session extends EventEmitter { this._bashToolParser.on('toolStart', this._bashToolHandlers.toolStart); this._bashToolParser.on('toolEnd', this._bashToolHandlers.toolEnd); this._bashToolParser.on('toolsUpdate', this._bashToolHandlers.toolsUpdate); - } get status(): SessionStatus { @@ -673,17 +692,23 @@ export class Session extends EventEmitter { restoreTokens(inputTokens: number, outputTokens: number, totalCost: number): void { // Sanity check: reject absurdly large individual values if (inputTokens > MAX_SESSION_TOKENS || outputTokens > MAX_SESSION_TOKENS) { - console.warn(`[Session ${this.id}] Rejected absurd restored tokens: input=${inputTokens}, output=${outputTokens}`); + console.warn( + `[Session ${this.id}] Rejected absurd restored tokens: input=${inputTokens}, output=${outputTokens}` + ); return; } // Check token sum doesn't overflow MAX_SESSION_TOKENS if (inputTokens + outputTokens > MAX_SESSION_TOKENS) { - console.warn(`[Session ${this.id}] Rejected token sum overflow: input=${inputTokens} + output=${outputTokens} = ${inputTokens + outputTokens} > ${MAX_SESSION_TOKENS}`); + console.warn( + `[Session ${this.id}] Rejected token sum overflow: input=${inputTokens} + output=${outputTokens} = ${inputTokens + outputTokens} > ${MAX_SESSION_TOKENS}` + ); return; } // Reject negative values if (inputTokens < 0 || outputTokens < 0 || totalCost < 0) { - console.warn(`[Session ${this.id}] Rejected negative restored tokens: input=${inputTokens}, output=${outputTokens}, cost=${totalCost}`); + console.warn( + `[Session ${this.id}] Rejected negative restored tokens: input=${inputTokens}, output=${outputTokens}, cost=${totalCost}` + ); return; } @@ -722,7 +747,9 @@ export class Session extends EventEmitter { if (threshold !== undefined) { // Validate threshold bounds if (threshold < Session.MIN_AUTO_THRESHOLD || threshold > Session.MAX_AUTO_THRESHOLD) { - console.warn(`[Session ${this.id}] Invalid autoClear threshold ${threshold}, must be between ${Session.MIN_AUTO_THRESHOLD} and ${Session.MAX_AUTO_THRESHOLD}. Using default ${Session.DEFAULT_AUTO_CLEAR_THRESHOLD}.`); + console.warn( + `[Session ${this.id}] Invalid autoClear threshold ${threshold}, must be between ${Session.MIN_AUTO_THRESHOLD} and ${Session.MAX_AUTO_THRESHOLD}. Using default ${Session.DEFAULT_AUTO_CLEAR_THRESHOLD}.` + ); this._autoClearThreshold = Session.DEFAULT_AUTO_CLEAR_THRESHOLD; } else { this._autoClearThreshold = threshold; @@ -747,7 +774,9 @@ export class Session extends EventEmitter { if (threshold !== undefined) { // Validate threshold bounds if (threshold < Session.MIN_AUTO_THRESHOLD || threshold > Session.MAX_AUTO_THRESHOLD) { - console.warn(`[Session ${this.id}] Invalid autoCompact threshold ${threshold}, must be between ${Session.MIN_AUTO_THRESHOLD} and ${Session.MAX_AUTO_THRESHOLD}. Using default ${Session.DEFAULT_AUTO_COMPACT_THRESHOLD}.`); + console.warn( + `[Session ${this.id}] Invalid autoCompact threshold ${threshold}, must be between ${Session.MIN_AUTO_THRESHOLD} and ${Session.MAX_AUTO_THRESHOLD}. Using default ${Session.DEFAULT_AUTO_COMPACT_THRESHOLD}.` + ); this._autoCompactThreshold = Session.DEFAULT_AUTO_COMPACT_THRESHOLD; } else { this._autoCompactThreshold = threshold; @@ -911,14 +940,20 @@ export class Session extends EventEmitter { this._lastActivityAt = Date.now(); const modeLabel = this.mode === 'opencode' ? 'OpenCode' : 'Claude'; - console.log(`[Session] Starting interactive ${modeLabel} session` + (this._useMux ? ` (with ${this._mux!.backend})` : '')); + console.log( + `[Session] Starting interactive ${modeLabel} session` + + (this._useMux ? ` (with ${this._mux!.backend})` : '') + ); // If mux wrapping is enabled, create or attach to a mux session if (this._useMux && this._mux) { try { // Verify stale mux session — tmux may have been destroyed (e.g., killed externally) if (this._muxSession && !this._mux.muxSessionExists(this._muxSession.muxName)) { - console.log('[Session] Stale mux session detected (tmux gone):', this._muxSession.muxName); + console.log( + '[Session] Stale mux session detected (tmux gone):', + this._muxSession.muxName + ); this._muxSession = null; } @@ -928,16 +963,21 @@ export class Session extends EventEmitter { if (this._muxSession && this._mux.isPaneDead(this._muxSession.muxName)) { console.log('[Session] Dead pane detected, respawning:', this._muxSession.muxName); const newPid = await this._mux.respawnPane({ - sessionId: this.id, workingDir: this.workingDir, mode: this.mode, - niceConfig: this._niceConfig, model: this._model, claudeMode: this._claudeMode, - allowedTools: this._allowedTools, openCodeConfig: this._openCodeConfig, + sessionId: this.id, + workingDir: this.workingDir, + mode: this.mode, + niceConfig: this._niceConfig, + model: this._model, + claudeMode: this._claudeMode, + allowedTools: this._allowedTools, + openCodeConfig: this._openCodeConfig, }); if (!newPid) { console.error('[Session] Failed to respawn pane, will create new session'); needsNewSession = true; } else { // Wait a moment for the respawned process to fully start - await new Promise(resolve => setTimeout(resolve, MUX_STARTUP_DELAY_MS)); + await new Promise((resolve) => setTimeout(resolve, MUX_STARTUP_DELAY_MS)); } } @@ -948,9 +988,14 @@ export class Session extends EventEmitter { } else { // Create a new mux session this._muxSession = await this._mux.createSession({ - sessionId: this.id, workingDir: this.workingDir, mode: this.mode, - name: this._name, niceConfig: this._niceConfig, model: this._model, - claudeMode: this._claudeMode, allowedTools: this._allowedTools, + sessionId: this.id, + workingDir: this.workingDir, + mode: this.mode, + name: this._name, + niceConfig: this._niceConfig, + model: this._model, + claudeMode: this._claudeMode, + allowedTools: this._allowedTools, openCodeConfig: this._openCodeConfig, }); console.log('[Session] Created mux session:', this._muxSession.muxName); @@ -962,13 +1007,21 @@ export class Session extends EventEmitter { this.ptyProcess = pty.spawn( this._mux.getAttachCommand(), this._mux.getAttachArgs(this._muxSession!.muxName), - { - name: 'xterm-256color', - cols: 120, - rows: 40, - cwd: this.workingDir, - env: { ...process.env, LANG: 'en_US.UTF-8', LC_ALL: 'en_US.UTF-8', TERM: 'xterm-256color', COLORTERM: undefined, CLAUDECODE: undefined }, - }); + { + name: 'xterm-256color', + cols: 120, + rows: 40, + cwd: this.workingDir, + env: { + ...process.env, + LANG: 'en_US.UTF-8', + LC_ALL: 'en_US.UTF-8', + TERM: 'xterm-256color', + COLORTERM: undefined, + CLAUDECODE: undefined, + }, + } + ); // Set claudeSessionId immediately since we passed --session-id to Claude // The mux manager passes --session-id ${sessionId} to Claude @@ -1010,9 +1063,7 @@ export class Session extends EventEmitter { // Clean the buffer - remove mux init junk before actual content // Strip: cursor movement (\x1b[nA/B/C/D), positioning (\x1b[n;nH), // clear screen (\x1b[2J), scroll region (\x1b[n;nr), and whitespace - this._terminalBuffer.set( - bufferValue.replace(LEADING_ANSI_WHITESPACE_PATTERN, '') - ); + this._terminalBuffer.set(bufferValue.replace(LEADING_ANSI_WHITESPACE_PATTERN, '')); // Signal client to refresh this.emit('clearTerminal'); } @@ -1081,9 +1132,7 @@ export class Session extends EventEmitter { this.ptyProcess.onData((rawData: string) => { // Filter out focus escape sequences and Ctrl+L (form feed) - const data = rawData - .replace(FOCUS_ESCAPE_FILTER, '') - .replace(CTRL_L_PATTERN, ''); // Remove Ctrl+L + const data = rawData.replace(FOCUS_ESCAPE_FILTER, '').replace(CTRL_L_PATTERN, ''); // Remove Ctrl+L if (!data) return; // Skip if only filtered sequences // BufferAccumulator handles auto-trimming when max size exceeded @@ -1252,8 +1301,12 @@ export class Session extends EventEmitter { // Only check if spinner didn't already trigger working state if (!this._isWorking) { const cleanData = getCleanData(); - if (cleanData.includes('Thinking') || cleanData.includes('Writing') || - cleanData.includes('Reading') || cleanData.includes('Running')) { + if ( + cleanData.includes('Thinking') || + cleanData.includes('Writing') || + cleanData.includes('Reading') || + cleanData.includes('Running') + ) { this._isWorking = true; this._status = 'busy'; this.emit('working'); @@ -1293,14 +1346,20 @@ export class Session extends EventEmitter { // Use user's default shell or bash const shell = process.env.SHELL || '/bin/bash'; - console.log('[Session] Starting shell session with:', shell + (this._useMux ? ` (with ${this._mux!.backend})` : '')); + console.log( + '[Session] Starting shell session with:', + shell + (this._useMux ? ` (with ${this._mux!.backend})` : '') + ); // If mux wrapping is enabled, create or attach to a mux session if (this._useMux && this._mux) { try { // Verify stale mux session — tmux may have been destroyed externally if (this._muxSession && !this._mux.muxSessionExists(this._muxSession.muxName)) { - console.log('[Session] Stale mux session detected (tmux gone):', this._muxSession.muxName); + console.log( + '[Session] Stale mux session detected (tmux gone):', + this._muxSession.muxName + ); this._muxSession = null; } @@ -1308,12 +1367,17 @@ export class Session extends EventEmitter { let needsNewSession = false; if (this._muxSession && this._mux.isPaneDead(this._muxSession.muxName)) { console.log('[Session] Dead pane detected, respawning:', this._muxSession.muxName); - const newPid = await this._mux.respawnPane({ sessionId: this.id, workingDir: this.workingDir, mode: 'shell', niceConfig: this._niceConfig }); + const newPid = await this._mux.respawnPane({ + sessionId: this.id, + workingDir: this.workingDir, + mode: 'shell', + niceConfig: this._niceConfig, + }); if (!newPid) { console.error('[Session] Failed to respawn pane, will create new session'); needsNewSession = true; } else { - await new Promise(resolve => setTimeout(resolve, MUX_STARTUP_DELAY_MS)); + await new Promise((resolve) => setTimeout(resolve, MUX_STARTUP_DELAY_MS)); } } @@ -1323,7 +1387,13 @@ export class Session extends EventEmitter { console.log('[Session] Attaching to existing mux session:', this._muxSession!.muxName); } else { // Create a new mux session - this._muxSession = await this._mux.createSession({ sessionId: this.id, workingDir: this.workingDir, mode: 'shell', name: this._name, niceConfig: this._niceConfig }); + this._muxSession = await this._mux.createSession({ + sessionId: this.id, + workingDir: this.workingDir, + mode: 'shell', + name: this._name, + niceConfig: this._niceConfig, + }); console.log('[Session] Created mux session:', this._muxSession.muxName); // No extra sleep — createSession() already waits for tmux readiness } @@ -1333,13 +1403,21 @@ export class Session extends EventEmitter { this.ptyProcess = pty.spawn( this._mux.getAttachCommand(), this._mux.getAttachArgs(this._muxSession!.muxName), - { - name: 'xterm-256color', - cols: 120, - rows: 40, - cwd: this.workingDir, - env: { ...process.env, LANG: 'en_US.UTF-8', LC_ALL: 'en_US.UTF-8', TERM: 'xterm-256color', COLORTERM: undefined, CLAUDECODE: undefined }, - }); + { + name: 'xterm-256color', + cols: 120, + rows: 40, + cwd: this.workingDir, + env: { + ...process.env, + LANG: 'en_US.UTF-8', + LC_ALL: 'en_US.UTF-8', + TERM: 'xterm-256color', + COLORTERM: undefined, + CLAUDECODE: undefined, + }, + } + ); } catch (spawnErr) { console.error('[Session] Failed to spawn PTY for shell mux attachment:', spawnErr); this.emit('error', `Failed to attach to mux session: ${spawnErr}`); @@ -1474,7 +1552,7 @@ export class Session extends EventEmitter { this._messages = []; this._lineBuffer = ''; this._lastActivityAt = Date.now(); - this._promptResolved = false; // Reset race condition guard + this._promptResolved = false; // Reset race condition guard this.resolvePromise = resolve; this.rejectPromise = reject; @@ -1482,13 +1560,18 @@ export class Session extends EventEmitter { try { // Spawn claude in a real PTY const model = options?.model; - console.log('[Session] Spawning PTY for claude with prompt:', prompt.substring(0, 50), model ? `(model: ${model})` : ''); + console.log( + '[Session] Spawning PTY for claude with prompt:', + prompt.substring(0, 50), + model ? `(model: ${model})` : '' + ); const args = [ '-p', '--verbose', '--dangerously-skip-permissions', - '--output-format', 'stream-json', + '--output-format', + 'stream-json', ]; if (model) { args.push('--model', model); @@ -1517,7 +1600,10 @@ export class Session extends EventEmitter { }); } catch (spawnErr) { console.error('[Session] Failed to spawn Claude PTY for runPrompt:', spawnErr); - this.emit('error', `Failed to spawn Claude: ${spawnErr instanceof Error ? spawnErr.message : String(spawnErr)}`); + this.emit( + 'error', + `Failed to spawn Claude: ${spawnErr instanceof Error ? spawnErr.message : String(spawnErr)}` + ); throw spawnErr; } @@ -1561,7 +1647,7 @@ export class Session extends EventEmitter { this.rejectPromise = null; // Find result from parsed messages or use text output - const resultMsg = this._messages.find(m => m.type === 'result'); + const resultMsg = this._messages.find((m) => m.type === 'result'); if (resultMsg && !resultMsg.is_error) { this._status = 'idle'; @@ -1574,18 +1660,24 @@ export class Session extends EventEmitter { } else if (exitCode !== 0 || (resultMsg && resultMsg.is_error)) { this._status = 'error'; if (reject) { - reject(new Error(this._errorBuffer || this._textOutput.value || 'Process exited with error')); + reject( + new Error( + this._errorBuffer || this._textOutput.value || 'Process exited with error' + ) + ); } } else { this._status = 'idle'; if (resolve) { - resolve({ result: this._textOutput.value || this._terminalBuffer.value, cost: this._totalCost }); + resolve({ + result: this._textOutput.value || this._terminalBuffer.value, + cost: this._totalCost, + }); } } this.emit('exit', exitCode); }); - } catch (err) { this._status = 'error'; reject(err); @@ -1649,8 +1741,9 @@ export class Session extends EventEmitter { // Extract Claude session ID from messages (can be in any message type) // Support both sessionId (camelCase) and session_id (snake_case) - const msgSessionId = (msg as unknown as Record).sessionId as string | undefined - ?? msg.session_id; + const msgSessionId = + ((msg as unknown as Record).sessionId as string | undefined) ?? + msg.session_id; if (msgSessionId && !this._claudeSessionId) { this._claudeSessionId = msgSessionId; } @@ -1689,7 +1782,10 @@ export class Session extends EventEmitter { } } catch (parseErr) { // Not JSON, just regular output - this is expected for non-JSON lines - console.debug('[Session] Line not JSON (expected for text output):', parseErr instanceof Error ? parseErr.message : parseErr); + console.debug( + '[Session] Line not JSON (expected for text output):', + parseErr instanceof Error ? parseErr.message : parseErr + ); this._textOutput.append(line + '\n'); } } else if (trimmed) { @@ -1829,7 +1925,9 @@ export class Session extends EventEmitter { // Safety: Reject M values that would result in > 500k tokens // Claude's context window is ~200k, so anything claiming millions is likely a false match if (tokenCount > 0.5) { - console.warn(`[Session ${this.id}] Rejected suspicious M token value: ${tokenMatch[0]} (would be ${tokenCount * 1000000} tokens)`); + console.warn( + `[Session ${this.id}] Rejected suspicious M token value: ${tokenMatch[0]} (would be ${tokenCount * 1000000} tokens)` + ); return; } tokenCount *= 1000000; @@ -1837,7 +1935,9 @@ export class Session extends EventEmitter { // Safety: Absolute maximum tokens per session if (tokenCount > MAX_SESSION_TOKENS) { - console.warn(`[Session ${this.id}] Rejected token count exceeding max: ${tokenCount} > ${MAX_SESSION_TOKENS}`); + console.warn( + `[Session ${this.id}] Rejected token count exceeding max: ${tokenCount} > ${MAX_SESSION_TOKENS}` + ); return; } @@ -1850,7 +1950,9 @@ export class Session extends EventEmitter { // Safety: Reject suspiciously large jumps (max 100k per update) const MAX_DELTA_PER_UPDATE = 100_000; if (delta > MAX_DELTA_PER_UPDATE) { - console.warn(`[Session ${this.id}] Rejected suspicious token jump: ${currentTotal} -> ${tokenCount} (delta: ${delta})`); + console.warn( + `[Session ${this.id}] Rejected suspicious token jump: ${currentTotal} -> ${tokenCount} (delta: ${delta})` + ); return; } @@ -1874,7 +1976,12 @@ export class Session extends EventEmitter { if (this._cliInfoParsed) return; // Quick pre-checks - if (!cleanData.includes('Claude') && !cleanData.includes('current:') && !cleanData.includes('Opus') && !cleanData.includes('Sonnet')) { + if ( + !cleanData.includes('Claude') && + !cleanData.includes('current:') && + !cleanData.includes('Opus') && + !cleanData.includes('Sonnet') + ) { return; } let changed = false; @@ -1947,7 +2054,9 @@ export class Session extends EventEmitter { const totalTokens = this._totalInputTokens + this._totalOutputTokens; if (totalTokens >= this._autoCompactThreshold) { this._isCompacting = true; - console.log(`[Session] Auto-compact triggered: ${totalTokens} tokens >= ${this._autoCompactThreshold} threshold`); + console.log( + `[Session] Auto-compact triggered: ${totalTokens} tokens >= ${this._autoCompactThreshold} threshold` + ); // Wait for Claude to be idle before compacting const checkAndCompact = async () => { @@ -1967,7 +2076,7 @@ export class Session extends EventEmitter { this.emit('autoCompact', { tokens: totalTokens, threshold: this._autoCompactThreshold, - prompt: this._autoCompactPrompt || undefined + prompt: this._autoCompactPrompt || undefined, }); // Wait a moment then re-enable (longer than clear since compact takes time) @@ -2001,7 +2110,9 @@ export class Session extends EventEmitter { const totalTokens = this._totalInputTokens + this._totalOutputTokens; if (totalTokens >= this._autoClearThreshold) { this._isClearing = true; - console.log(`[Session] Auto-clear triggered: ${totalTokens} tokens >= ${this._autoClearThreshold} threshold`); + console.log( + `[Session] Auto-clear triggered: ${totalTokens} tokens >= ${this._autoClearThreshold} threshold` + ); // Wait for Claude to be idle before clearing const checkAndClear = async () => { @@ -2121,7 +2232,7 @@ export class Session extends EventEmitter { async sendInput(input: string): Promise { this._status = 'busy'; this._lastActivityAt = Date.now(); - this.runPrompt(input).catch(err => { + this.runPrompt(input).catch((err) => { const errorMsg = err instanceof Error ? err.message : String(err); this.emit('error', errorMsg); }); @@ -2256,11 +2367,14 @@ export class Session extends EventEmitter { try { this.ptyProcess.kill(); } catch (err) { - console.warn('[Session] Failed to send SIGTERM to PTY process (may already be dead):', err); + console.warn( + '[Session] Failed to send SIGTERM to PTY process (may already be dead):', + err + ); } // Give it a moment to terminate gracefully - await new Promise(resolve => setTimeout(resolve, GRACEFUL_SHUTDOWN_DELAY_MS)); + await new Promise((resolve) => setTimeout(resolve, GRACEFUL_SHUTDOWN_DELAY_MS)); // Force kill with SIGKILL if still alive try { diff --git a/src/state-store.ts b/src/state-store.ts index cd872b90..c32919e6 100644 --- a/src/state-store.ts +++ b/src/state-store.ts @@ -14,11 +14,28 @@ * @module state-store */ -import { readFileSync, writeFileSync, existsSync, mkdirSync, renameSync, unlinkSync, copyFileSync } from 'node:fs'; +import { + readFileSync, + writeFileSync, + existsSync, + mkdirSync, + renameSync, + unlinkSync, + copyFileSync, +} from 'node:fs'; import { writeFile, rename, unlink, copyFile, access } from 'node:fs/promises'; import { homedir } from 'node:os'; import { dirname, join } from 'node:path'; -import { AppState, createInitialState, RalphSessionState, createInitialRalphSessionState, GlobalStats, createInitialGlobalStats, TokenStats, TokenUsageEntry } from './types.js'; +import { + AppState, + createInitialState, + RalphSessionState, + createInitialRalphSessionState, + GlobalStats, + createInitialGlobalStats, + TokenStats, + TokenUsageEntry, +} from './types.js'; import { MAX_SESSION_TOKENS } from './utils/index.js'; /** Debounce delay for batching state writes (ms) */ @@ -178,7 +195,9 @@ export class StateStore { // Circuit breaker: stop attempting writes after too many failures if (this.circuitBreakerOpen) { - console.warn('[StateStore] Circuit breaker open - skipping save (too many consecutive failures)'); + console.warn( + '[StateStore] Circuit breaker open - skipping save (too many consecutive failures)' + ); return; } @@ -192,7 +211,10 @@ export class StateStore { try { json = JSON.stringify(this.state); } catch (err) { - console.error('[StateStore] Failed to serialize state (circular reference or invalid data):', err); + console.error( + '[StateStore] Failed to serialize state (circular reference or invalid data):', + err + ); this.consecutiveSaveFailures++; if (this.consecutiveSaveFailures >= MAX_CONSECUTIVE_FAILURES) { console.error('[StateStore] Circuit breaker OPEN - serialization failing repeatedly'); @@ -255,7 +277,9 @@ export class StateStore { } if (this.circuitBreakerOpen) { - console.warn('[StateStore] Circuit breaker open - skipping save (too many consecutive failures)'); + console.warn( + '[StateStore] Circuit breaker open - skipping save (too many consecutive failures)' + ); return; } @@ -268,7 +292,10 @@ export class StateStore { try { json = JSON.stringify(this.state); } catch (err) { - console.error('[StateStore] Failed to serialize state (circular reference or invalid data):', err); + console.error( + '[StateStore] Failed to serialize state (circular reference or invalid data):', + err + ); this.consecutiveSaveFailures++; if (this.consecutiveSaveFailures >= MAX_CONSECUTIVE_FAILURES) { console.error('[StateStore] Circuit breaker OPEN - serialization failing repeatedly'); @@ -299,7 +326,11 @@ export class StateStore { } catch (err) { console.error('[StateStore] Failed to write state file:', err); this.consecutiveSaveFailures++; - try { if (existsSync(tempPath)) unlinkSync(tempPath); } catch { /* ignore */ } + try { + if (existsSync(tempPath)) unlinkSync(tempPath); + } catch { + /* ignore */ + } if (this.consecutiveSaveFailures >= MAX_CONSECUTIVE_FAILURES) { console.error('[StateStore] Circuit breaker OPEN - writes failing repeatedly'); this.circuitBreakerOpen = true; @@ -384,7 +415,10 @@ export class StateStore { * @param activeSessionIds - Set of currently active session IDs * @returns Number of sessions cleaned up */ - cleanupStaleSessions(activeSessionIds: Set): { count: number; cleaned: Array<{ id: string; name?: string }> } { + cleanupStaleSessions(activeSessionIds: Set): { + count: number; + cleaned: Array<{ id: string; name?: string }>; + } { const allSessionIds = Object.keys(this.state.sessions); const cleaned: Array<{ id: string; name?: string }> = []; @@ -476,12 +510,16 @@ export class StateStore { addToGlobalStats(inputTokens: number, outputTokens: number, cost: number): void { // Sanity check: reject absurdly large values if (inputTokens > MAX_SESSION_TOKENS || outputTokens > MAX_SESSION_TOKENS) { - console.warn(`[StateStore] Rejected absurd global stats: input=${inputTokens}, output=${outputTokens}`); + console.warn( + `[StateStore] Rejected absurd global stats: input=${inputTokens}, output=${outputTokens}` + ); return; } // Reject negative values if (inputTokens < 0 || outputTokens < 0 || cost < 0) { - console.warn(`[StateStore] Rejected negative global stats: input=${inputTokens}, output=${outputTokens}, cost=${cost}`); + console.warn( + `[StateStore] Rejected negative global stats: input=${inputTokens}, output=${outputTokens}, cost=${cost}` + ); return; } @@ -505,7 +543,12 @@ export class StateStore { * Returns aggregate stats combining global (deleted sessions) + active sessions. * @param activeSessions Map of active session states */ - getAggregateStats(activeSessions: Record): { + getAggregateStats( + activeSessions: Record< + string, + { inputTokens?: number; outputTokens?: number; totalCost?: number } + > + ): { totalInputTokens: number; totalOutputTokens: number; totalCost: number; @@ -588,7 +631,9 @@ export class StateStore { // Claude's context window is ~200k, so 1M per recording is already very generous const MAX_TOKENS_PER_RECORDING = 1_000_000; if (inputTokens > MAX_TOKENS_PER_RECORDING || outputTokens > MAX_TOKENS_PER_RECORDING) { - console.warn(`[StateStore] Rejected absurd token values: input=${inputTokens}, output=${outputTokens}`); + console.warn( + `[StateStore] Rejected absurd token values: input=${inputTokens}, output=${outputTokens}` + ); return; } @@ -602,7 +647,7 @@ export class StateStore { } // Find or create today's entry - let todayEntry = stats.daily.find(e => e.date === today); + let todayEntry = stats.daily.find((e) => e.date === today); if (!todayEntry) { todayEntry = { date: today, @@ -695,7 +740,10 @@ export class StateStore { try { json = JSON.stringify(data); } catch (err) { - console.error('[StateStore] Failed to serialize Ralph state (circular reference or invalid data):', err); + console.error( + '[StateStore] Failed to serialize Ralph state (circular reference or invalid data):', + err + ); // Keep dirty flag true for retry - don't throw, let caller continue return; } @@ -713,7 +761,10 @@ export class StateStore { unlinkSync(tempPath); } } catch (cleanupErr) { - console.warn('[StateStore] Failed to cleanup temp file during Ralph state save error:', cleanupErr); + console.warn( + '[StateStore] Failed to cleanup temp file during Ralph state save error:', + cleanupErr + ); } // Don't throw - let caller continue, retry on next save } diff --git a/src/subagent-watcher.ts b/src/subagent-watcher.ts index e9008055..cf01b8e7 100644 --- a/src/subagent-watcher.ts +++ b/src/subagent-watcher.ts @@ -77,16 +77,18 @@ export interface SubagentTranscriptEntry { input_tokens?: number; output_tokens?: number; }; - content: string | Array<{ - type: 'text' | 'tool_use' | 'tool_result'; - text?: string; - name?: string; - id?: string; // tool_use_id for tool_use blocks - tool_use_id?: string; // tool_use_id for tool_result blocks - input?: Record; - content?: string | Array<{ type: string; text?: string }>; // tool_result content - is_error?: boolean; // For tool_result errors - }>; + content: + | string + | Array<{ + type: 'text' | 'tool_use' | 'tool_result'; + text?: string; + name?: string; + id?: string; // tool_use_id for tool_use blocks + tool_use_id?: string; // tool_use_id for tool_result blocks + input?: Record; + content?: string | Array<{ type: string; text?: string }>; // tool_result content + is_error?: boolean; // For tool_result errors + }>; }; data?: { type: string; @@ -135,11 +137,11 @@ const MAX_TRACKED_AGENTS = 500; // Maximum agents to track (LRU eviction when ex // Internal Claude Code agent patterns to filter out (not real user-initiated subagents) const INTERNAL_AGENT_PATTERNS = [ - /^\[?SUGGESTION MODE/i, // Claude Code's internal suggestion mode - /^Suggest what user might/i, // Suggestion mode prompt variant - /aprompt/i, // Internal prompt agent (anywhere in string) - /^a\s?prompt/i, // Variants of internal prompt agent - /^prompt$/i, // Just "prompt" + /^\[?SUGGESTION MODE/i, // Claude Code's internal suggestion mode + /^Suggest what user might/i, // Suggestion mode prompt variant + /aprompt/i, // Internal prompt agent (anywhere in string) + /^a\s?prompt/i, // Variants of internal prompt agent + /^prompt$/i, // Just "prompt" ]; // Minimum description length - very short descriptions are likely internal or malformed @@ -168,14 +170,20 @@ export class SubagentWatcher extends EventEmitter { private knownSubagentDirs = new Set(); // 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>(); + private pendingToolCalls = new Map< + string, + Map + >(); // Guard to prevent concurrent liveness checks (prevents duplicate completed events) private _isCheckingLiveness = false; // Counter for throttling full directory scans (only scan every FULL_SCAN_EVERY_N_POLLS) private _pollCount = 0; // Short-lived cache for parsed parent transcript descriptions (TTL: 5s) // Key: "{projectHash}/{sessionId}", Value: { descriptions: Map, timestamp } - private parentDescriptionCache = new Map; timestamp: number }>(); + private parentDescriptionCache = new Map< + string, + { descriptions: Map; timestamp: number } + >(); // Store error handlers for FSWatchers to enable proper cleanup (prevent memory leaks) private dirWatcherErrorHandlers = new Map void>(); private fileWatcherErrorHandlers = new Map void>(); @@ -194,7 +202,7 @@ export class SubagentWatcher extends EventEmitter { if (!description) return false; // Filter out very short descriptions (likely internal or malformed) if (description.length < MIN_DESCRIPTION_LENGTH) return true; - return INTERNAL_AGENT_PATTERNS.some(pattern => pattern.test(description)); + return INTERNAL_AGENT_PATTERNS.some((pattern) => pattern.test(description)); } /** @@ -263,7 +271,7 @@ export class SubagentWatcher extends EventEmitter { if (await this.checkSubagentFileAlive(info)) continue; // Tier 2: Cached PID check (~0.1ms per agent) - if (info.pid && await this.checkPidAlive(info.pid)) continue; + if (info.pid && (await this.checkPidAlive(info.pid))) continue; // Tiers 1+2 failed — need expensive scan for this agent needsFullScan.push(info); @@ -322,26 +330,44 @@ export class SubagentWatcher extends EventEmitter { resolve(stdout); }); }); - const pids = pgrepOutput.trim().split('\n').filter(Boolean).map(s => parseInt(s, 10)).filter(n => !Number.isNaN(n)); + const pids = pgrepOutput + .trim() + .split('\n') + .filter(Boolean) + .map((s) => parseInt(s, 10)) + .filter((n) => !Number.isNaN(n)); // Read /proc for all PIDs in parallel - await Promise.all(pids.map(async (pid) => { - let environ = ''; - let cmdline = ''; - try { environ = await readFile(`/proc/${pid}/environ`, 'utf8'); } catch { /* skip */ } - try { cmdline = await readFile(`/proc/${pid}/cmdline`, 'utf8'); } catch { /* skip */ } - // Skip main Codeman-managed Claude processes — only track subagents - if (environ.includes('CODEMAN_MUX=1')) return; - if (environ || cmdline) { - result.set(pid, { environ, cmdline }); - } - })); + await Promise.all( + pids.map(async (pid) => { + let environ = ''; + let cmdline = ''; + try { + environ = await readFile(`/proc/${pid}/environ`, 'utf8'); + } catch { + /* skip */ + } + try { + cmdline = await readFile(`/proc/${pid}/cmdline`, 'utf8'); + } catch { + /* skip */ + } + // Skip main Codeman-managed Claude processes — only track subagents + if (environ.includes('CODEMAN_MUX=1')) return; + if (environ || cmdline) { + result.set(pid, { environ, cmdline }); + } + }) + ); // Update cached PIDs on tracked agents for (const [pid, procInfo] of result) { for (const [_agentId, info] of this.agentInfo) { if (info.status !== 'active' && info.status !== 'idle') continue; - if (procInfo.environ.includes(info.sessionId) || procInfo.cmdline.includes(info.sessionId)) { + if ( + procInfo.environ.includes(info.sessionId) || + procInfo.cmdline.includes(info.sessionId) + ) { info.pid = pid; break; // Each PID belongs to at most one agent } @@ -567,9 +593,7 @@ export class SubagentWatcher extends EventEmitter { */ getSubagentsForSession(workingDir: string): SubagentInfo[] { const projectHash = this.getProjectHash(workingDir); - return Array.from(this.agentInfo.values()).filter( - (info) => info.projectHash === projectHash - ); + return Array.from(this.agentInfo.values()).filter((info) => info.projectHash === projectHash); } /** @@ -805,7 +829,10 @@ export class SubagentWatcher extends EventEmitter { if (typeof entry.message.content === 'string') { const text = entry.message.content.trim(); if (text.length > 0) { - const preview = text.length > TEXT_PREVIEW_LENGTH ? text.substring(0, TEXT_PREVIEW_LENGTH) + '...' : text; + const preview = + text.length > TEXT_PREVIEW_LENGTH + ? text.substring(0, TEXT_PREVIEW_LENGTH) + '...' + : text; lines.push(`${this.formatTime(entry.timestamp)} 💬 ${preview.replace(/\n/g, ' ')}`); } } else { @@ -815,7 +842,10 @@ export class SubagentWatcher extends EventEmitter { } else if (content.type === 'text' && content.text) { const text = content.text.trim(); if (text.length > 0) { - const preview = text.length > TEXT_PREVIEW_LENGTH ? text.substring(0, TEXT_PREVIEW_LENGTH) + '...' : text; + const preview = + text.length > TEXT_PREVIEW_LENGTH + ? text.substring(0, TEXT_PREVIEW_LENGTH) + '...' + : text; lines.push(`${this.formatTime(entry.timestamp)} 💬 ${preview.replace(/\n/g, ' ')}`); } } @@ -826,14 +856,18 @@ export class SubagentWatcher extends EventEmitter { if (typeof entry.message.content === 'string') { const text = entry.message.content.trim(); if (text.length < 100 && !text.includes('{')) { - lines.push(`${this.formatTime(entry.timestamp)} 📥 User: ${text.substring(0, USER_TEXT_PREVIEW_LENGTH)}`); + lines.push( + `${this.formatTime(entry.timestamp)} 📥 User: ${text.substring(0, USER_TEXT_PREVIEW_LENGTH)}` + ); } } else { const firstContent = entry.message.content[0]; if (firstContent?.type === 'text' && firstContent.text) { const text = firstContent.text.trim(); if (text.length < 100 && !text.includes('{')) { - lines.push(`${this.formatTime(entry.timestamp)} 📥 User: ${text.substring(0, USER_TEXT_PREVIEW_LENGTH)}`); + lines.push( + `${this.formatTime(entry.timestamp)} 📥 User: ${text.substring(0, USER_TEXT_PREVIEW_LENGTH)}` + ); } } } @@ -932,14 +966,18 @@ export class SubagentWatcher extends EventEmitter { // Check cache first (covers burst of simultaneous agent discoveries) const cached = this.parentDescriptionCache.get(cacheKey); - if (cached && (Date.now() - cached.timestamp) < CACHE_TTL_MS) { + if (cached && Date.now() - cached.timestamp < CACHE_TTL_MS) { return cached.descriptions.get(agentId); } try { // The parent session's transcript is at: ~/.claude/projects/{projectHash}/{sessionId}.jsonl const transcriptPath = join(CLAUDE_PROJECTS_DIR, projectHash, `${sessionId}.jsonl`); - try { await statAsync(transcriptPath); } catch { return undefined; } + try { + await statAsync(transcriptPath); + } catch { + return undefined; + } const content = await readFile(transcriptPath, 'utf8'); const lines = content.split('\n').filter((l) => l.trim()); @@ -982,7 +1020,11 @@ export class SubagentWatcher extends EventEmitter { let lineCount = 0; let resolved = false; rl.on('line', (line) => { - if (resolved || lineCount >= 5) { rl.close(); stream.destroy(); return; } + if (resolved || lineCount >= 5) { + rl.close(); + stream.destroy(); + return; + } lineCount++; if (!line.trim()) return; try { @@ -1008,8 +1050,12 @@ export class SubagentWatcher extends EventEmitter { // Skip malformed lines } }); - rl.on('close', () => { if (!resolved) resolve(undefined); }); - rl.on('error', () => { if (!resolved) resolve(undefined); }); + rl.on('close', () => { + if (!resolved) resolve(undefined); + }); + rl.on('error', () => { + if (!resolved) resolve(undefined); + }); }); } catch { // Failed to read file @@ -1021,7 +1067,11 @@ export class SubagentWatcher extends EventEmitter { * Scan for all subagent directories (async to avoid blocking event loop) */ private async scanForSubagents(): Promise { - try { await statAsync(CLAUDE_PROJECTS_DIR); } catch { return; } + try { + await statAsync(CLAUDE_PROJECTS_DIR); + } catch { + return; + } try { const projects = await readdir(CLAUDE_PROJECTS_DIR); @@ -1065,7 +1115,11 @@ export class SubagentWatcher extends EventEmitter { /** * Watch a subagent directory for new/updated files */ - private async watchSubagentDir(dir: string, projectHash: string, sessionId: string): Promise { + private async watchSubagentDir( + dir: string, + projectHash: string, + sessionId: string + ): Promise { if (this.knownSubagentDirs.has(dir)) return; this.knownSubagentDirs.add(dir); @@ -1123,7 +1177,12 @@ export class SubagentWatcher extends EventEmitter { * @param sessionId Claude session ID * @param isInitialScan If true, skip files older than STARTUP_MAX_FILE_AGE_MS */ - private async watchAgentFile(filePath: string, projectHash: string, sessionId: string, isInitialScan: boolean = false): Promise { + private async watchAgentFile( + filePath: string, + projectHash: string, + sessionId: string, + isInitialScan: boolean = false + ): Promise { if (this.fileWatchers.has(filePath)) return; const agentId = basename(filePath).replace('agent-', '').replace('.jsonl', ''); @@ -1147,7 +1206,11 @@ export class SubagentWatcher extends EventEmitter { // Extract description - prefer reading from parent transcript (most reliable) // The parent transcript has the exact Task tool call with description parameter - let description = await this.extractDescriptionFromParentTranscript(projectHash, sessionId, agentId); + let description = await this.extractDescriptionFromParentTranscript( + projectHash, + sessionId, + agentId + ); // Fallback: extract a smart title from the subagent's prompt if parent lookup failed if (!description) { @@ -1192,12 +1255,14 @@ export class SubagentWatcher extends EventEmitter { this.emit('subagent:discovered', info); // 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); - }); + 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 try { @@ -1251,7 +1316,11 @@ export class SubagentWatcher extends EventEmitter { // Handle watcher errors to prevent unhandled exceptions // Store handler reference for proper cleanup const errorHandler = (error: Error) => { - this.emit('subagent:error', error instanceof Error ? error : new Error(String(error)), agentId); + this.emit( + 'subagent:error', + error instanceof Error ? error : new Error(String(error)), + agentId + ); watcher.close(); this.fileWatcherErrorHandlers.delete(filePath); this.fileWatchers.delete(filePath); @@ -1314,7 +1383,11 @@ export class SubagentWatcher extends EventEmitter { /** * Process a transcript entry and emit appropriate events */ - private async processEntry(entry: SubagentTranscriptEntry, agentId: string, sessionId: string): Promise { + private async processEntry( + entry: SubagentTranscriptEntry, + agentId: string, + sessionId: string + ): Promise { const info = this.agentInfo.get(agentId); // Extract model from assistant messages (first one sets the model) @@ -1378,9 +1451,11 @@ export class SubagentWatcher extends EventEmitter { resultCount: entry.data.resultCount, // Extract hook event info if present hookEvent: entry.data.hookEvent, - hookName: entry.data.hookName || (entry.data.hookEvent && entry.data.tool_name - ? `${entry.data.hookEvent}:${entry.data.tool_name}` - : undefined), + hookName: + entry.data.hookName || + (entry.data.hookEvent && entry.data.tool_name + ? `${entry.data.hookEvent}:${entry.data.tool_name}` + : undefined), }; this.emit('subagent:progress', progress); } else if (entry.type === 'assistant' && entry.message?.content) { @@ -1526,13 +1601,15 @@ export class SubagentWatcher extends EventEmitter { /** * Extract text content from tool_result content field */ - private extractToolResultContent(content: string | Array<{ type: string; text?: string }> | undefined): string { + private extractToolResultContent( + content: string | Array<{ type: string; text?: string }> | undefined + ): string { if (!content) return ''; if (typeof content === 'string') return content; if (Array.isArray(content)) { return content - .filter(c => c.type === 'text' && c.text) - .map(c => c.text) + .filter((c) => c.type === 'text' && c.text) + .map((c) => c.text) .join('\n'); } return ''; @@ -1541,7 +1618,10 @@ export class SubagentWatcher extends EventEmitter { /** * Get truncated input for display (keeps primary param, truncates large content) */ - private getTruncatedInput(_tool: string, input: Record): Record { + private getTruncatedInput( + _tool: string, + input: Record + ): Record { const truncated: Record = {}; for (const [key, value] of Object.entries(input)) { if (typeof value === 'string' && value.length > INPUT_TRUNCATE_LENGTH) { @@ -1608,7 +1688,10 @@ export class SubagentWatcher extends EventEmitter { details = input.file_path as string; } else if (name === 'Bash' && input.command) { const cmd = input.command as string; - details = cmd.length > COMMAND_DISPLAY_LENGTH ? cmd.substring(0, COMMAND_DISPLAY_LENGTH) + '...' : cmd; + details = + cmd.length > COMMAND_DISPLAY_LENGTH + ? cmd.substring(0, COMMAND_DISPLAY_LENGTH) + '...' + : cmd; } else if (name === 'Glob' && input.pattern) { details = input.pattern as string; } else if (name === 'Grep' && input.pattern) { diff --git a/src/task-queue.ts b/src/task-queue.ts index 23850cad..add202b1 100644 --- a/src/task-queue.ts +++ b/src/task-queue.ts @@ -166,7 +166,11 @@ export class TaskQueue extends EventEmitter { * @param visited - Set of already visited nodes (for DFS) * @returns true if adding this dependency would create a cycle */ - private wouldCreateCycle(taskId: string, depId: string, visited: Set = new Set()): boolean { + private wouldCreateCycle( + taskId: string, + depId: string, + visited: Set = new Set() + ): boolean { // Direct self-reference if (depId === taskId) return true; // Already visited this node in current path @@ -196,7 +200,9 @@ export class TaskQueue extends EventEmitter { private validateDependencies(taskId: string, dependencies: string[]): void { for (const depId of dependencies) { if (this.wouldCreateCycle(taskId, depId)) { - throw new Error(`Circular dependency detected: adding dependency ${depId} to task ${taskId} would create a cycle`); + throw new Error( + `Circular dependency detected: adding dependency ${depId} to task ${taskId} would create a cycle` + ); } } } @@ -208,14 +214,23 @@ export class TaskQueue extends EventEmitter { /** Gets the currently running task for a session, if any. */ getRunningTaskForSession(sessionId: string): Task | null { - return this.getAllTasks().find( - (t) => t.isRunning() && t.assignedSessionId === sessionId - ) || null; + return ( + this.getAllTasks().find((t) => t.isRunning() && t.assignedSessionId === sessionId) || null + ); } /** Gets counts of tasks by status (single-pass). */ - getCount(): { total: number; pending: number; running: number; completed: number; failed: number } { - let pending = 0, running = 0, completed = 0, failed = 0; + getCount(): { + total: number; + pending: number; + running: number; + completed: number; + failed: number; + } { + let pending = 0, + running = 0, + completed = 0, + failed = 0; for (const task of this.tasks.values()) { if (task.isPending()) pending++; else if (task.isRunning()) running++; @@ -258,7 +273,6 @@ export class TaskQueue extends EventEmitter { this.tasks.clear(); return count; } - } // Singleton instance diff --git a/src/task-tracker.ts b/src/task-tracker.ts index 6006569e..cfd36965 100644 --- a/src/task-tracker.ts +++ b/src/task-tracker.ts @@ -61,11 +61,7 @@ const LAUNCH_PATTERNS = [ * Patterns that indicate a task has completed. * Used as fallback when JSON parsing doesn't capture the result. */ -const COMPLETE_PATTERNS = [ - /Task\s+completed/i, - /Agent\s+finished/i, - /Background\s+task\s+done/i, -]; +const COMPLETE_PATTERNS = [/Task\s+completed/i, /Agent\s+finished/i, /Background\s+task\s+done/i]; // ========== Type Definitions ========== @@ -218,7 +214,10 @@ export class TaskTracker extends EventEmitter { private taskStack: string[] = []; /** Pending tool_use blocks waiting for results (with timestamp for cleanup) */ - private pendingToolUses: Map = new Map(); + private pendingToolUses: Map< + string, + { description: string; subagentType: string; parentId: string | null; createdAt: number } + > = new Map(); /** * Creates a new TaskTracker instance. @@ -316,7 +315,12 @@ export class TaskTracker extends EventEmitter { const parentId = this.taskStack.length > 0 ? this.taskStack[this.taskStack.length - 1] : null; // Store pending tool use - task starts when we see activity - this.pendingToolUses.set(toolUseId, { description, subagentType, parentId, createdAt: Date.now() }); + this.pendingToolUses.set(toolUseId, { + description, + subagentType, + parentId, + createdAt: Date.now(), + }); // Clean up old pending entries to prevent unbounded growth this.cleanupOldPendingToolUses(); @@ -361,9 +365,8 @@ export class TaskTracker extends EventEmitter { if (task) { task.status = block.is_error ? 'failed' : 'completed'; task.endTime = Date.now(); - task.output = typeof block.content === 'string' - ? block.content - : JSON.stringify(block.content); + task.output = + typeof block.content === 'string' ? block.content : JSON.stringify(block.content); // Remove from stack const stackIndex = this.taskStack.indexOf(toolUseId); @@ -610,13 +613,21 @@ export class TaskTracker extends EventEmitter { * - failed: Tasks that ended with errors */ getStats(): { total: number; running: number; completed: number; failed: number } { - let running = 0, completed = 0, failed = 0; + let running = 0, + completed = 0, + failed = 0; for (const task of this.tasks.values()) { switch (task.status) { - case 'running': running++; break; - case 'completed': completed++; break; - case 'failed': failed++; break; + case 'running': + running++; + break; + case 'completed': + completed++; + break; + case 'failed': + failed++; + break; default: assertNever(task.status, `Unhandled BackgroundTask status: ${task.status}`); } diff --git a/src/team-watcher.ts b/src/team-watcher.ts index 4087859e..08f7d0d4 100644 --- a/src/team-watcher.ts +++ b/src/team-watcher.ts @@ -86,13 +86,13 @@ export class TeamWatcher extends EventEmitter { getTeamTasks(teamName: string): TeamTask[] { const tasks = this.teamTasks.get(teamName); if (!tasks) return []; - return tasks.filter(t => !t.metadata?._internal); + return tasks.filter((t) => !t.metadata?._internal); } /** Count active (non-completed) tasks for a team */ getActiveTaskCount(teamName: string): number { const tasks = this.getTeamTasks(teamName); - return tasks.filter(t => t.status !== 'completed').length; + return tasks.filter((t) => t.status !== 'completed').length; } /** Get inbox messages for a team member (or all members) */ @@ -108,8 +108,8 @@ export class TeamWatcher extends EventEmitter { messages.push(...msgs); } } - return messages.sort((a, b) => - new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime() + return messages.sort( + (a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime() ); } @@ -119,7 +119,7 @@ export class TeamWatcher extends EventEmitter { if (!team) return false; // Check if any non-lead members exist (they are active by definition while present) - const teammates = team.members.filter(m => m.agentType !== 'team-lead'); + const teammates = team.members.filter((m) => m.agentType !== 'team-lead'); if (teammates.length === 0) return false; // Check if team has active (non-completed) tasks @@ -132,19 +132,17 @@ export class TeamWatcher extends EventEmitter { const team = this.teams.get(teamName); if (!team) return []; - return team.members - .filter((m): m is TeamMember & { tmuxPaneId: string } => - m.agentType !== 'team-lead' && - !!m.tmuxPaneId && - m.tmuxPaneId !== 'in-process' - ); + return team.members.filter( + (m): m is TeamMember & { tmuxPaneId: string } => + m.agentType !== 'team-lead' && !!m.tmuxPaneId && m.tmuxPaneId !== 'in-process' + ); } /** Get count of active teammates for a session */ getActiveTeammateCount(sessionId: string): number { const team = this.getTeamForSession(sessionId); if (!team) return 0; - return team.members.filter(m => m.agentType !== 'team-lead').length; + return team.members.filter((m) => m.agentType !== 'team-lead').length; } // ========== Private Methods ========== @@ -192,7 +190,8 @@ export class TeamWatcher extends EventEmitter { if (await this.isLocked(join(this.teamsDir, entry, 'config.json'))) continue; const config = await this.readJson(configPath); - if (!config || !config.name || !config.leadSessionId || !Array.isArray(config.members)) continue; + if (!config || !config.name || !config.leadSessionId || !Array.isArray(config.members)) + continue; const existing = this.teams.get(entry); this.teams.set(entry, config); @@ -257,7 +256,9 @@ export class TeamWatcher extends EventEmitter { let taskFiles: string[]; try { - taskFiles = (await readdir(teamTaskDir)).filter(f => f.endsWith('.json') && f !== '.lock'); + taskFiles = (await readdir(teamTaskDir)).filter( + (f) => f.endsWith('.json') && f !== '.lock' + ); } catch { continue; } @@ -285,7 +286,7 @@ export class TeamWatcher extends EventEmitter { let inboxFiles: string[]; try { - inboxFiles = (await readdir(inboxDir)).filter(f => f.endsWith('.json')); + inboxFiles = (await readdir(inboxDir)).filter((f) => f.endsWith('.json')); } catch { continue; } @@ -314,7 +315,7 @@ export class TeamWatcher extends EventEmitter { this.inboxCache.set(cacheKey, messages); // Emit new messages — compare by timestamp to handle deletions/reordering - const prevTimestamps = new Set(previous?.map(m => m.timestamp) || []); + const prevTimestamps = new Set(previous?.map((m) => m.timestamp) || []); for (const msg of messages) { if (!prevTimestamps.has(msg.timestamp)) { this.emit('inboxMessage', { teamName, member: memberName, message: msg }); diff --git a/src/templates/claude-md.ts b/src/templates/claude-md.ts index dca10f83..0ccc50c3 100644 --- a/src/templates/claude-md.ts +++ b/src/templates/claude-md.ts @@ -44,7 +44,11 @@ const MINIMAL_FALLBACK = `# CLAUDE.md - Project Configuration * @param templatePath - Optional path to a custom template file (from settings.json) * @returns The generated CLAUDE.md content */ -export function generateClaudeMd(caseName: string, description: string = '', templatePath?: string): string { +export function generateClaudeMd( + caseName: string, + description: string = '', + templatePath?: string +): string { const date = new Date().toISOString().split('T')[0]; // 1. Try custom template from settings.json @@ -71,7 +75,12 @@ export function generateClaudeMd(caseName: string, description: string = '', tem return replacePlaceholders(MINIMAL_FALLBACK, caseName, description, date); } -function replacePlaceholders(template: string, caseName: string, description: string, date: string): string { +function replacePlaceholders( + template: string, + caseName: string, + description: string, + date: string +): string { return template .replace(/\[PROJECT_NAME\]/g, caseName) .replace(/\[PROJECT_DESCRIPTION\]/g, description || 'A new project') diff --git a/src/tmux-manager.ts b/src/tmux-manager.ts index af62c836..710ce355 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -30,10 +30,25 @@ import { existsSync, readFileSync, mkdirSync } from 'node:fs'; import { writeFile, rename } from 'node:fs/promises'; import { dirname, join } from 'node:path'; import { homedir } from 'node:os'; -import { ProcessStats, PersistedRespawnConfig, getErrorMessage, DEFAULT_NICE_CONFIG, type PaneInfo, type ClaudeMode, type SessionMode, type OpenCodeConfig } from './types.js'; +import { + ProcessStats, + PersistedRespawnConfig, + getErrorMessage, + DEFAULT_NICE_CONFIG, + type PaneInfo, + type ClaudeMode, + type SessionMode, + type OpenCodeConfig, +} from './types.js'; import { wrapWithNice } from './utils/nice-wrapper.js'; import { SAFE_PATH_PATTERN } from './utils/regex-patterns.js'; -import type { TerminalMultiplexer, MuxSession, MuxSessionWithStats, CreateSessionOptions, RespawnPaneOptions } from './mux-interface.js'; +import type { + TerminalMultiplexer, + MuxSession, + MuxSessionWithStats, + CreateSessionOptions, + RespawnPaneOptions, +} from './mux-interface.js'; // Claude CLI PATH resolution — shared utility import { findClaudeDir } from './utils/claude-cli-resolver.js'; @@ -103,11 +118,23 @@ function isValidMuxName(name: string): boolean { * Prevents command injection via malformed paths. */ function isValidPath(path: string): boolean { - if (path.includes(';') || path.includes('&') || path.includes('|') || - path.includes('$') || path.includes('`') || path.includes('(') || - path.includes(')') || path.includes('{') || path.includes('}') || - path.includes('<') || path.includes('>') || path.includes("'") || - path.includes('"') || path.includes('\n') || path.includes('\r')) { + if ( + path.includes(';') || + path.includes('&') || + path.includes('|') || + path.includes('$') || + path.includes('`') || + path.includes('(') || + path.includes(')') || + path.includes('{') || + path.includes('}') || + path.includes('<') || + path.includes('>') || + path.includes("'") || + path.includes('"') || + path.includes('\n') || + path.includes('\r') + ) { return false; } if (path.includes('..')) { @@ -155,7 +182,9 @@ function buildOpenCodeCommand(config?: OpenCodeConfig): string { // Continue existing session if (config?.continueSession) { - const safeId = /^[a-zA-Z0-9_-]+$/.test(config.continueSession) ? config.continueSession : undefined; + const safeId = /^[a-zA-Z0-9_-]+$/.test(config.continueSession) + ? config.continueSession + : undefined; if (safeId) parts.push('--session', safeId); if (safeId && config.forkSession) parts.push('--fork'); } @@ -177,7 +206,8 @@ function buildSpawnCommand(options: { }): string { if (options.mode === 'claude') { // Validate model to prevent command injection - const safeModel = (options.model && /^[a-zA-Z0-9._-]+$/.test(options.model)) ? options.model : undefined; + const safeModel = + options.model && /^[a-zA-Z0-9._-]+$/.test(options.model) ? options.model : undefined; const modelFlag = safeModel ? ` --model ${safeModel}` : ''; return `claude${buildClaudePermissionFlags(options.claudeMode, options.allowedTools)} --session-id "${options.sessionId}"${modelFlag}`; } @@ -204,7 +234,9 @@ function setOpenCodeEnvVars(muxName: string): void { timeout: EXEC_TIMEOUT_MS, stdio: ['pipe', 'pipe', 'pipe'], }); - } catch { /* Non-critical — key may not be needed */ } + } catch { + /* Non-critical — key may not be needed */ + } } } } @@ -225,7 +257,9 @@ function setOpenCodeConfigContent(muxName: string, config?: OpenCodeConfig): voi const existing = JSON.parse(config.configContent) as Record; Object.assign(permConfig, existing); permConfig.permission = { '*': 'allow' }; - } catch { /* invalid JSON, use default permConfig */ } + } catch { + /* invalid JSON, use default permConfig */ + } } jsonContent = JSON.stringify(permConfig); } else if (config.configContent) { @@ -247,7 +281,9 @@ function setOpenCodeConfigContent(muxName: string, config?: OpenCodeConfig): voi timeout: EXEC_TIMEOUT_MS, stdio: ['pipe', 'pipe', 'pipe'], }); - } catch { /* Non-critical */ } + } catch { + /* Non-critical */ + } } } @@ -338,7 +374,17 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { * In test mode: creates an in-memory session only (no real tmux session). */ async createSession(options: CreateSessionOptions): Promise { - const { sessionId, workingDir, mode, name, niceConfig, model, claudeMode, allowedTools, openCodeConfig } = options; + const { + sessionId, + workingDir, + mode, + name, + niceConfig, + model, + claudeMode, + allowedTools, + openCodeConfig, + } = options; const muxName = `codeman-${sessionId.slice(0, 8)}`; if (!isValidMuxName(muxName)) { @@ -370,13 +416,17 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { if (mode === 'claude') { const claudeDir = findClaudeDir(); if (!claudeDir) { - throw new Error('Claude CLI not found. Install it with: curl -fsSL https://claude.ai/install.sh | bash'); + throw new Error( + 'Claude CLI not found. Install it with: curl -fsSL https://claude.ai/install.sh | bash' + ); } pathExport = `export PATH="${claudeDir}:$PATH" && `; } else if (mode === 'opencode') { const openCodeDir = resolveOpenCodeDir(); if (!openCodeDir) { - throw new Error('OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash'); + throw new Error( + 'OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash' + ); } pathExport = `export PATH="${openCodeDir}:$PATH" && `; } @@ -394,7 +444,14 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { if (mode === 'claude') envExports.splice(2, 0, 'unset CLAUDECODE'); const envExportsStr = envExports.join(' && '); - const baseCmd = buildSpawnCommand({ mode, sessionId, model, claudeMode, allowedTools, openCodeConfig }); + const baseCmd = buildSpawnCommand({ + mode, + sessionId, + model, + claudeMode, + allowedTools, + openCodeConfig, + }); const config = niceConfig || DEFAULT_NICE_CONFIG; const cmd = wrapWithNice(baseCmd, config); @@ -412,15 +469,22 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { // (Production uses systemd which has a clean env, but dev/test may be nested.) const cleanEnv = { ...process.env }; delete cleanEnv.TMUX; - execSync( - `tmux new-session -ds "${muxName}" -c "${workingDir}" -x 120 -y 40`, - { cwd: workingDir, timeout: EXEC_TIMEOUT_MS, stdio: 'ignore', env: cleanEnv } - ); + execSync(`tmux new-session -ds "${muxName}" -c "${workingDir}" -x 120 -y 40`, { + cwd: workingDir, + timeout: EXEC_TIMEOUT_MS, + stdio: 'ignore', + env: cleanEnv, + }); // Set remain-on-exit now that the server is running — must be before respawn-pane try { - execSync(`tmux set-option -t "${muxName}" remain-on-exit on`, { timeout: EXEC_TIMEOUT_MS, stdio: 'ignore' }); - } catch { /* Non-critical */ } + execSync(`tmux set-option -t "${muxName}" remain-on-exit on`, { + timeout: EXEC_TIMEOUT_MS, + stdio: 'ignore', + }); + } catch { + /* Non-critical */ + } // For OpenCode: set sensitive env vars and config via tmux setenv // (not visible in ps output or tmux history, inherited by panes) @@ -430,13 +494,13 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { } // Replace the shell with the actual command (no echo in terminal) - execSync( - `tmux respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`, - { timeout: EXEC_TIMEOUT_MS, stdio: 'ignore' } - ); + execSync(`tmux respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`, { + timeout: EXEC_TIMEOUT_MS, + stdio: 'ignore', + }); // Wait for tmux session to be queryable - await new Promise(resolve => setTimeout(resolve, TMUX_CREATION_WAIT_MS)); + await new Promise((resolve) => setTimeout(resolve, TMUX_CREATION_WAIT_MS)); // Non-critical tmux config — run in parallel to avoid blocking event loop. // These configure UX niceties (no status bar, true color). @@ -445,18 +509,28 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { const configPromises: Promise[] = [ // Disable tmux status bar — Codeman's web UI provides session info execAsync(`tmux set-option -t "${muxName}" status off`, { timeout: EXEC_TIMEOUT_MS }) - .then(() => {}).catch(() => { /* Non-critical — session still works with status bar */ }), + .then(() => {}) + .catch(() => { + /* Non-critical — session still works with status bar */ + }), // Override global remain-on-exit with session-level setting execAsync(`tmux set-option -t "${muxName}" remain-on-exit on`, { timeout: EXEC_TIMEOUT_MS }) - .then(() => {}).catch(() => { /* Already set globally as fallback */ }), + .then(() => {}) + .catch(() => { + /* Already set globally as fallback */ + }), ]; // Enable 24-bit true color passthrough — server-wide, set once per lifetime if (!this.trueColorConfigured) { configPromises.push( execAsync(`tmux set-option -sa terminal-overrides ",*:Tc"`, { timeout: EXEC_TIMEOUT_MS }) - .then(() => { this.trueColorConfigured = true; }) - .catch(() => { /* Non-critical — colors limited to 256 */ }) + .then(() => { + this.trueColorConfigured = true; + }) + .catch(() => { + /* Non-critical — colors limited to 256 */ + }) ); } @@ -467,7 +541,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { // Get the PID of the pane process (retry for tmux server cold-start) let pid = this.getPanePid(muxName); for (let i = 0; !pid && i < GET_PID_MAX_RETRIES; i++) { - await new Promise(resolve => setTimeout(resolve, GET_PID_RETRY_MS)); + await new Promise((resolve) => setTimeout(resolve, GET_PID_RETRY_MS)); pid = this.getPanePid(muxName); } if (!pid) { @@ -507,10 +581,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { } try { - const output = execSync( - `tmux display-message -t "${muxName}" -p '#{pane_pid}'`, - { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS } - ).trim(); + const output = execSync(`tmux display-message -t "${muxName}" -p '#{pane_pid}'`, { + encoding: 'utf-8', + timeout: EXEC_TIMEOUT_MS, + }).trim(); const pid = parseInt(output, 10); return Number.isNaN(pid) ? null : pid; } catch { @@ -533,10 +607,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { if (IS_TEST_MODE) return false; if (!isValidMuxName(muxName)) return false; try { - const output = execSync( - `tmux display-message -t "${muxName}" -p '#{pane_dead}'`, - { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS } - ).trim(); + const output = execSync(`tmux display-message -t "${muxName}" -p '#{pane_dead}'`, { + encoding: 'utf-8', + timeout: EXEC_TIMEOUT_MS, + }).trim(); return output === '1'; } catch { return false; @@ -549,7 +623,16 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { * preserving the session and its scrollback buffer. */ async respawnPane(options: RespawnPaneOptions): Promise { - const { sessionId, workingDir, mode, niceConfig, model, claudeMode, allowedTools, openCodeConfig } = options; + const { + sessionId, + workingDir, + mode, + niceConfig, + model, + claudeMode, + allowedTools, + openCodeConfig, + } = options; const session = this.sessions.get(sessionId); if (!session) return null; const muxName = session.muxName; @@ -578,7 +661,14 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { if (mode === 'claude') envExports.splice(2, 0, 'unset CLAUDECODE'); const envExportsStr = envExports.join(' && '); - const baseCmd = buildSpawnCommand({ mode, sessionId, model, claudeMode, allowedTools, openCodeConfig }); + const baseCmd = buildSpawnCommand({ + mode, + sessionId, + model, + claudeMode, + allowedTools, + openCodeConfig, + }); const config = niceConfig || DEFAULT_NICE_CONFIG; const cmd = wrapWithNice(baseCmd, config); const fullCmd = `${pathExport}${envExportsStr} && ${cmd}`; @@ -590,12 +680,11 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { setOpenCodeConfigContent(muxName, openCodeConfig); } - await execAsync( - `tmux respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`, - { timeout: EXEC_TIMEOUT_MS } - ); + await execAsync(`tmux respawn-pane -k -t "${muxName}" bash -c ${JSON.stringify(fullCmd)}`, { + timeout: EXEC_TIMEOUT_MS, + }); // Wait for the respawned process to start - await new Promise(resolve => setTimeout(resolve, TMUX_CREATION_WAIT_MS)); + await new Promise((resolve) => setTimeout(resolve, TMUX_CREATION_WAIT_MS)); const pid = this.getPanePid(muxName); if (pid) session.pid = pid; return pid; @@ -628,7 +717,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { timeout: EXEC_TIMEOUT_MS, }).trim(); if (output) { - for (const childPid of output.split('\n').map(p => parseInt(p, 10)).filter(p => !Number.isNaN(p))) { + for (const childPid of output + .split('\n') + .map((p) => parseInt(p, 10)) + .filter((p) => !Number.isNaN(p))) { pids.push(childPid); pids.push(...this.getChildPids(childPid)); } @@ -655,16 +747,18 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { const checkInterval = 100; while (Date.now() - startTime < maxWaitMs) { - const aliveCount = pids.filter(pid => this.isProcessAlive(pid)).length; + const aliveCount = pids.filter((pid) => this.isProcessAlive(pid)).length; if (aliveCount === 0) { return true; } - await new Promise(resolve => setTimeout(resolve, checkInterval)); + await new Promise((resolve) => setTimeout(resolve, checkInterval)); } - const stillAlive = pids.filter(pid => this.isProcessAlive(pid)); + const stillAlive = pids.filter((pid) => this.isProcessAlive(pid)); if (stillAlive.length > 0) { - console.warn(`[TmuxManager] ${stillAlive.length} processes still alive after kill: ${stillAlive.join(', ')}`); + console.warn( + `[TmuxManager] ${stillAlive.length} processes still alive after kill: ${stillAlive.join(', ')}` + ); } return stillAlive.length === 0; } @@ -717,7 +811,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { } } - await new Promise(resolve => setTimeout(resolve, TMUX_KILL_WAIT_MS)); + await new Promise((resolve) => setTimeout(resolve, TMUX_KILL_WAIT_MS)); childPids = this.getChildPids(currentPid); for (const childPid of childPids) { @@ -735,7 +829,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { if (this.isProcessAlive(currentPid)) { try { process.kill(-currentPid, 'SIGTERM'); - await new Promise(resolve => setTimeout(resolve, GRACEFUL_SHUTDOWN_WAIT_MS)); + await new Promise((resolve) => setTimeout(resolve, GRACEFUL_SHUTDOWN_WAIT_MS)); if (this.isProcessAlive(currentPid)) { process.kill(-currentPid, 'SIGKILL'); } @@ -765,7 +859,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { // Verify all processes are dead const allDead = await this.verifyProcessesDead(allPids, 2000); if (!allDead) { - console.error(`[TmuxManager] Warning: Some processes may still be alive for session ${session.muxName}`); + console.error( + `[TmuxManager] Warning: Some processes may still be alive for session ${session.muxName}` + ); } this.lastPaneCount.delete(session.muxName); @@ -829,14 +925,18 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { // Discover unknown codeman sessions try { - const output = execSync( - "tmux list-sessions -F '#{session_name}' 2>/dev/null || true", - { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS } - ).trim(); + const output = execSync("tmux list-sessions -F '#{session_name}' 2>/dev/null || true", { + encoding: 'utf-8', + timeout: EXEC_TIMEOUT_MS, + }).trim(); for (const line of output.split('\n')) { const sessionName = line.trim(); - if (!sessionName || (!sessionName.startsWith('codeman-') && !sessionName.startsWith('claudeman-'))) continue; + if ( + !sessionName || + (!sessionName.startsWith('codeman-') && !sessionName.startsWith('claudeman-')) + ) + continue; // Check if this session is already known let isKnown = false; @@ -866,7 +966,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { }; this.sessions.set(sessionId, session); discovered.push(sessionId); - console.log(`[TmuxManager] Discovered unknown tmux session: ${sessionName} (PID ${pid})`); + console.log( + `[TmuxManager] Discovered unknown tmux session: ${sessionName} (PID ${pid})` + ); } } } @@ -890,26 +992,26 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { } try { - const psOutput = execSync( - `ps -o rss=,pcpu= -p ${session.pid} 2>/dev/null || echo "0 0"`, - { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS } - ).trim(); + const psOutput = execSync(`ps -o rss=,pcpu= -p ${session.pid} 2>/dev/null || echo "0 0"`, { + encoding: 'utf-8', + timeout: EXEC_TIMEOUT_MS, + }).trim(); - const [rss, cpu] = psOutput.split(/\s+/).map(x => parseFloat(x) || 0); + const [rss, cpu] = psOutput.split(/\s+/).map((x) => parseFloat(x) || 0); let childCount = 0; try { - const childOutput = execSync( - `pgrep -P ${session.pid} | wc -l`, - { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS } - ).trim(); + const childOutput = execSync(`pgrep -P ${session.pid} | wc -l`, { + encoding: 'utf-8', + timeout: EXEC_TIMEOUT_MS, + }).trim(); childCount = parseInt(childOutput, 10) || 0; } catch { // No children or command failed } return { - memoryMB: Math.round(rss / 1024 * 10) / 10, + memoryMB: Math.round((rss / 1024) * 10) / 10, cpuPercent: Math.round(cpu * 10) / 10, childCount, updatedAt: Date.now(), @@ -921,7 +1023,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { async getSessionsWithStats(): Promise { if (IS_TEST_MODE) { - return Array.from(this.sessions.values()).map(s => ({ + return Array.from(this.sessions.values()).map((s) => ({ ...s, stats: { memoryMB: 0, cpuPercent: 0, childCount: 0, updatedAt: Date.now() }, })); @@ -932,7 +1034,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { return []; } - const sessionPids = sessions.map(s => s.pid); + const sessionPids = sessions.map((s) => s.pid); const statsMap = new Map(); try { @@ -950,8 +1052,8 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { if (!Number.isNaN(sessionPid)) { const children = (childrenStr || '') .split(',') - .map(s => parseInt(s.trim(), 10)) - .filter(n => !Number.isNaN(n) && n > 0); + .map((s) => parseInt(s.trim(), 10)) + .filter((n) => !Number.isNaN(n) && n > 0); descendantMap.set(sessionPid, children); } } @@ -1002,7 +1104,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { } statsMap.set(sessionPid, { - memoryMB: Math.round(totalRss / 1024 * 10) / 10, + memoryMB: Math.round((totalRss / 1024) * 10) / 10, cpuPercent: Math.round(totalCpu * 10) / 10, childCount: children.length, updatedAt: Date.now(), @@ -1011,7 +1113,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { } } catch { // Fall back to individual queries - const statsPromises = sessions.map(session => this.getProcessStats(session.sessionId)); + const statsPromises = sessions.map((session) => this.getProcessStats(session.sessionId)); const results = await Promise.allSettled(statsPromises); return sessions.map((session, i) => ({ ...session, @@ -1019,7 +1121,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { })); } - return sessions.map(session => ({ + return sessions.map((session) => ({ ...session, stats: statsMap.get(session.pid) || undefined, })); @@ -1145,7 +1247,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { async sendInput(sessionId: string, input: string): Promise { const session = this.sessions.get(sessionId); if (!session) { - console.error(`[TmuxManager] sendInput failed: no session found for ${sessionId}. Known: ${Array.from(this.sessions.keys()).join(', ')}`); + console.error( + `[TmuxManager] sendInput failed: no session found for ${sessionId}. Known: ${Array.from(this.sessions.keys()).join(', ')}` + ); return false; } @@ -1154,7 +1258,9 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { return true; } - console.log(`[TmuxManager] sendInput to ${session.muxName}, input length: ${input.length}, hasCarriageReturn: ${input.includes('\r')}`); + console.log( + `[TmuxManager] sendInput to ${session.muxName}, input length: ${input.length}, hasCarriageReturn: ${input.includes('\r')}` + ); if (!isValidMuxName(session.muxName)) { console.error('[TmuxManager] Invalid session name in sendInput:', session.muxName); @@ -1170,27 +1276,23 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { // Ink (Claude CLI's terminal framework) needs them split — sending both in a // single tmux invocation (via \;) causes Ink to interpret Enter as a newline // character in the input buffer rather than as form submission. - await execAsync( - `tmux send-keys -t "${session.muxName}" -l ${shellescape(textPart)}`, - { timeout: EXEC_TIMEOUT_MS } - ); - await new Promise(resolve => setTimeout(resolve, 50)); - await execAsync( - `tmux send-keys -t "${session.muxName}" Enter`, - { timeout: EXEC_TIMEOUT_MS } - ); + await execAsync(`tmux send-keys -t "${session.muxName}" -l ${shellescape(textPart)}`, { + timeout: EXEC_TIMEOUT_MS, + }); + await new Promise((resolve) => setTimeout(resolve, 50)); + await execAsync(`tmux send-keys -t "${session.muxName}" Enter`, { + timeout: EXEC_TIMEOUT_MS, + }); } else if (textPart) { // Text only, no Enter - await execAsync( - `tmux send-keys -t "${session.muxName}" -l ${shellescape(textPart)}`, - { timeout: EXEC_TIMEOUT_MS } - ); + await execAsync(`tmux send-keys -t "${session.muxName}" -l ${shellescape(textPart)}`, { + timeout: EXEC_TIMEOUT_MS, + }); } else if (hasCarriageReturn) { // Enter only - await execAsync( - `tmux send-keys -t "${session.muxName}" Enter`, - { timeout: EXEC_TIMEOUT_MS } - ); + await execAsync(`tmux send-keys -t "${session.muxName}" Enter`, { + timeout: EXEC_TIMEOUT_MS, + }); } return true; @@ -1215,10 +1317,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { } try { - execSync( - `tmux set-option -t "${muxName}" mouse on`, - { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS } - ); + execSync(`tmux set-option -t "${muxName}" mouse on`, { + encoding: 'utf-8', + timeout: EXEC_TIMEOUT_MS, + }); console.log(`[TmuxManager] Mouse mode ON for ${muxName}`); return true; } catch (err) { @@ -1239,10 +1341,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { } try { - execSync( - `tmux set-option -t "${muxName}" mouse off`, - { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS } - ); + execSync(`tmux set-option -t "${muxName}" mouse off`, { + encoding: 'utf-8', + timeout: EXEC_TIMEOUT_MS, + }); console.log(`[TmuxManager] Mouse mode OFF for ${muxName}`); return true; } catch (err) { @@ -1283,16 +1385,19 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS } ).trim(); - return output.split('\n').map(line => { - const [paneId, indexStr, pidStr, widthStr, heightStr] = line.split(':'); - return { - paneId, - paneIndex: parseInt(indexStr, 10), - panePid: parseInt(pidStr, 10), - width: parseInt(widthStr, 10), - height: parseInt(heightStr, 10), - }; - }).filter(p => !Number.isNaN(p.paneIndex)); + return output + .split('\n') + .map((line) => { + const [paneId, indexStr, pidStr, widthStr, heightStr] = line.split(':'); + return { + paneId, + paneIndex: parseInt(indexStr, 10), + panePid: parseInt(pidStr, 10), + width: parseInt(widthStr, 10), + height: parseInt(heightStr, 10), + }; + }) + .filter((p) => !Number.isNaN(p.paneIndex)); } catch { return []; } @@ -1323,24 +1428,24 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { const textPart = input.replace(/\r/g, '').replace(/\n/g, '').trimEnd(); if (textPart && hasCarriageReturn) { - execSync( - `tmux send-keys -t ${shellescape(target)} -l ${shellescape(textPart)}`, - { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS } - ); - execSync( - `tmux send-keys -t ${shellescape(target)} Enter`, - { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS } - ); + execSync(`tmux send-keys -t ${shellescape(target)} -l ${shellescape(textPart)}`, { + encoding: 'utf-8', + timeout: EXEC_TIMEOUT_MS, + }); + execSync(`tmux send-keys -t ${shellescape(target)} Enter`, { + encoding: 'utf-8', + timeout: EXEC_TIMEOUT_MS, + }); } else if (textPart) { - execSync( - `tmux send-keys -t ${shellescape(target)} -l ${shellescape(textPart)}`, - { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS } - ); + execSync(`tmux send-keys -t ${shellescape(target)} -l ${shellescape(textPart)}`, { + encoding: 'utf-8', + timeout: EXEC_TIMEOUT_MS, + }); } else if (hasCarriageReturn) { - execSync( - `tmux send-keys -t ${shellescape(target)} Enter`, - { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS } - ); + execSync(`tmux send-keys -t ${shellescape(target)} Enter`, { + encoding: 'utf-8', + timeout: EXEC_TIMEOUT_MS, + }); } return true; @@ -1370,10 +1475,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { : `${muxName}.%${paneTarget}`; try { - return execSync( - `tmux capture-pane -p -e -t ${shellescape(target)} -S -5000`, - { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS } - ); + return execSync(`tmux capture-pane -p -e -t ${shellescape(target)} -S -5000`, { + encoding: 'utf-8', + timeout: EXEC_TIMEOUT_MS, + }); } catch (err) { console.error('[TmuxManager] Failed to capture pane buffer:', err); return null; @@ -1434,10 +1539,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { : `${muxName}.%${paneTarget}`; try { - execSync( - `tmux pipe-pane -t ${shellescape(target)}`, - { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS } - ); + execSync(`tmux pipe-pane -t ${shellescape(target)}`, { + encoding: 'utf-8', + timeout: EXEC_TIMEOUT_MS, + }); return true; } catch (err) { console.error('[TmuxManager] Failed to stop pipe-pane:', err); diff --git a/src/transcript-watcher.ts b/src/transcript-watcher.ts index 42f68699..69dd83db 100644 --- a/src/transcript-watcher.ts +++ b/src/transcript-watcher.ts @@ -410,12 +410,13 @@ export class TranscriptWatcher extends EventEmitter { if (entry.type !== 'assistant' || !entry.message?.content) return; const content = entry.message.content; - const textToCheck = typeof content === 'string' - ? content - : content - .filter((b): b is { type: 'text'; text: string } => b.type === 'text' && !!b.text) - .map(b => b.text) - .join(' '); + const textToCheck = + typeof content === 'string' + ? content + : content + .filter((b): b is { type: 'text'; text: string } => b.type === 'text' && !!b.text) + .map((b) => b.text) + .join(' '); // Also check for tool_use with ExitPlanMode or AskUserQuestion if (Array.isArray(content)) { diff --git a/src/tunnel-manager.ts b/src/tunnel-manager.ts index 62a9f03c..f2c3cf74 100644 --- a/src/tunnel-manager.ts +++ b/src/tunnel-manager.ts @@ -112,7 +112,10 @@ export class TunnelManager extends EventEmitter { const binary = this.resolveCloudflared(); if (!binary) { - this.emit('error', 'cloudflared not found. Install from https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/'); + this.emit( + 'error', + 'cloudflared not found. Install from https://developers.cloudflare.com/cloudflare-one/connections/connect-networks/downloads/' + ); return; } @@ -130,7 +133,10 @@ export class TunnelManager extends EventEmitter { detached: false, }); } catch (err) { - this.emit('error', `Failed to spawn cloudflared: ${err instanceof Error ? err.message : String(err)}`); + this.emit( + 'error', + `Failed to spawn cloudflared: ${err instanceof Error ? err.message : String(err)}` + ); return; } diff --git a/src/types.ts b/src/types.ts index d0841727..5ac47863 100644 --- a/src/types.ts +++ b/src/types.ts @@ -80,16 +80,16 @@ export type TddPhase = 'setup' | 'test' | 'impl' | 'verify' | 'review'; /** Types of session lifecycle events recorded to the audit log */ export type LifecycleEventType = - | 'created' // Session object created - | 'started' // PTY process launched (interactive/shell/prompt) - | 'exit' // PTY process exited (with exit code) - | 'deleted' // cleanupSession() called — session removed - | 'detached' // Server shutdown — PTY left alive in tmux for recovery - | 'recovered' // Session restored from tmux on server restart - | 'stale_cleaned' // Removed from state.json by cleanupStaleSessions() - | 'mux_died' // tmux session died (detected by reconciliation) - | 'server_started' // Server started (marker for restart detection) - | 'server_stopped'; // Server shutting down + | 'created' // Session object created + | 'started' // PTY process launched (interactive/shell/prompt) + | 'exit' // PTY process exited (with exit code) + | 'deleted' // cleanupSession() called — session removed + | 'detached' // Server shutdown — PTY left alive in tmux for recovery + | 'recovered' // Session restored from tmux on server restart + | 'stale_cleaned' // Removed from state.json by cleanupStaleSessions() + | 'mux_died' // tmux session died (detected by reconciliation) + | 'server_started' // Server started (marker for restart detection) + | 'server_stopped'; // Server shutting down /** A single entry in the session lifecycle audit log */ export interface LifecycleEntry { @@ -472,11 +472,11 @@ export interface RespawnConfig { * Outcome of a respawn cycle */ export type CycleOutcome = - | 'success' // Cycle completed normally - | 'stuck_recovery' // Stuck-state recovery triggered - | 'blocked' // Blocked by circuit breaker or exit signal - | 'error' // Error during cycle - | 'cancelled'; // Cancelled (e.g., controller stopped) + | 'success' // Cycle completed normally + | 'stuck_recovery' // Stuck-state recovery triggered + | 'blocked' // Blocked by circuit breaker or exit signal + | 'error' // Error during cycle + | 'cancelled'; // Cancelled (e.g., controller stopped) /** * Metrics for a single respawn cycle. @@ -687,7 +687,13 @@ export const ErrorMessages: Record = { /** * Hook event types triggered by Claude Code's hooks system */ -export type HookEventType = 'idle_prompt' | 'permission_prompt' | 'elicitation_dialog' | 'stop' | 'teammate_idle' | 'task_completed'; +export type HookEventType = + | 'idle_prompt' + | 'permission_prompt' + | 'elicitation_dialog' + | 'stop' + | 'teammate_idle' + | 'task_completed'; // ========== API Response Types ========== @@ -842,12 +848,12 @@ export const DEFAULT_CONFIG: AppConfig = { maxConcurrentSessions: 5, stateFilePath: '', respawn: { - idleTimeoutMs: 5000, // 5 seconds of no activity after prompt + idleTimeoutMs: 5000, // 5 seconds of no activity after prompt updatePrompt: 'update all the docs and CLAUDE.md', - interStepDelayMs: 1000, // 1 second between steps - enabled: false, // disabled by default - sendClear: true, // send /clear after update prompt - sendInit: true, // send /init after /clear + interStepDelayMs: 1000, // 1 second between steps + enabled: false, // disabled by default + sendClear: true, // send /clear after update prompt + sendInit: true, // send /init after /clear }, lastUsedCase: null, ralphEnabled: false, @@ -1117,7 +1123,7 @@ export function createInitialCircuitBreakerStatus(): CircuitBreakerStatus { */ export function createInitialRalphTrackerState(): RalphTrackerState { return { - enabled: false, // Disabled by default, auto-enables when Ralph patterns detected + enabled: false, // Disabled by default, auto-enables when Ralph patterns detected active: false, completionPhrase: null, startedAt: null, diff --git a/src/utils/buffer-accumulator.ts b/src/utils/buffer-accumulator.ts index d61b949b..7e1eeccc 100644 --- a/src/utils/buffer-accumulator.ts +++ b/src/utils/buffer-accumulator.ts @@ -149,7 +149,7 @@ export class BufferAccumulator { * @returns True if buffer ends with the suffix */ endsWith(suffix: string): boolean { - if (!suffix) return true; // All strings end with empty string + if (!suffix) return true; // All strings end with empty string if (suffix.length > this.totalLength) return false; return this.tail(suffix.length) === suffix; } diff --git a/src/utils/claude-cli-resolver.ts b/src/utils/claude-cli-resolver.ts index 5999267b..82cbe5f2 100644 --- a/src/utils/claude-cli-resolver.ts +++ b/src/utils/claude-cli-resolver.ts @@ -57,7 +57,7 @@ export function findClaudeDir(): string | null { } } - _claudeDir = ''; // mark as searched, not found + _claudeDir = ''; // mark as searched, not found return null; } diff --git a/src/utils/cleanup-manager.ts b/src/utils/cleanup-manager.ts index 67a4a0f1..281f5a92 100644 --- a/src/utils/cleanup-manager.ts +++ b/src/utils/cleanup-manager.ts @@ -115,11 +115,7 @@ export class CleanupManager implements Disposable { * @param options - Optional configuration * @returns Timer ID for manual clearing if needed */ - setTimeout( - callback: () => void, - delay: number, - options?: TimerOptions - ): string { + setTimeout(callback: () => void, delay: number, options?: TimerOptions): string { const id = uuidv4(); const timeoutId = setTimeout(() => { // Remove registration when timer fires naturally @@ -148,11 +144,7 @@ export class CleanupManager implements Disposable { * @param options - Optional configuration * @returns Interval ID for manual clearing if needed */ - setInterval( - callback: () => void, - delay: number, - options?: TimerOptions - ): string { + setInterval(callback: () => void, delay: number, options?: TimerOptions): string { const id = uuidv4(); const intervalId = setInterval(() => { // Don't execute if stopped @@ -179,11 +171,7 @@ export class CleanupManager implements Disposable { * @param description - Human-readable description * @returns Registration ID for manual removal if needed */ - registerCleanup( - type: CleanupResourceType, - cleanup: () => void, - description: string - ): string { + registerCleanup(type: CleanupResourceType, cleanup: () => void, description: string): string { const id = uuidv4(); this.register({ id, @@ -202,10 +190,7 @@ export class CleanupManager implements Disposable { * @param description - Human-readable description * @returns Registration ID */ - registerWatcher( - watcher: { close: () => void }, - description: string - ): string { + registerWatcher(watcher: { close: () => void }, description: string): string { return this.registerCleanup('watcher', () => watcher.close(), description); } @@ -218,19 +203,23 @@ export class CleanupManager implements Disposable { * @param description - Human-readable description * @returns Registration ID */ - registerListener void) => void; off?: (event: string, listener: () => void) => void }>( - emitter: T, - event: string, - listener: () => void, - description: string - ): string { - return this.registerCleanup('listener', () => { - if (emitter.removeListener) { - emitter.removeListener(event, listener); - } else if (emitter.off) { - emitter.off(event, listener); - } - }, description); + registerListener< + T extends { + removeListener?: (event: string, listener: () => void) => void; + off?: (event: string, listener: () => void) => void; + }, + >(emitter: T, event: string, listener: () => void, description: string): string { + return this.registerCleanup( + 'listener', + () => { + if (emitter.removeListener) { + emitter.removeListener(event, listener); + } else if (emitter.off) { + emitter.off(event, listener); + } + }, + description + ); } /** @@ -244,13 +233,17 @@ export class CleanupManager implements Disposable { stream: { destroy?: () => void; close?: () => void }, description: string ): string { - return this.registerCleanup('stream', () => { - if (stream.destroy) { - stream.destroy(); - } else if (stream.close) { - stream.close(); - } - }, description); + return this.registerCleanup( + 'stream', + () => { + if (stream.destroy) { + stream.destroy(); + } else if (stream.close) { + stream.close(); + } + }, + description + ); } /** @@ -297,8 +290,10 @@ export class CleanupManager implements Disposable { this.registrations.clear(); if (errors.length > 0) { - console.error(`[CleanupManager] ${errors.length} errors during disposal:`, - errors.map(e => e.description).join(', ')); + console.error( + `[CleanupManager] ${errors.length} errors during disposal:`, + errors.map((e) => e.description).join(', ') + ); } } diff --git a/src/utils/index.ts b/src/utils/index.ts index aaf790c0..e3a02f34 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -33,4 +33,8 @@ export { export { assertNever } from './type-safety.js'; export { wrapWithNice } from './nice-wrapper.js'; export { findClaudeDir, getAugmentedPath } from './claude-cli-resolver.js'; -export { resolveOpenCodeDir, isOpenCodeAvailable, getOpenCodeAugmentedPath } from './opencode-cli-resolver.js'; +export { + resolveOpenCodeDir, + isOpenCodeAvailable, + getOpenCodeAugmentedPath, +} from './opencode-cli-resolver.js'; diff --git a/src/utils/opencode-cli-resolver.ts b/src/utils/opencode-cli-resolver.ts index 4a223d58..4600d7a5 100644 --- a/src/utils/opencode-cli-resolver.ts +++ b/src/utils/opencode-cli-resolver.ts @@ -17,13 +17,13 @@ const EXEC_TIMEOUT_MS = 5000; /** Common directories where the OpenCode CLI binary may be installed */ const OPENCODE_SEARCH_DIRS = [ - join(homedir(), '.opencode', 'bin'), // Default install location - join(homedir(), '.local', 'bin'), // Alternative install location - '/usr/local/bin', // Homebrew / system - join(homedir(), 'go', 'bin'), // Go install - join(homedir(), '.bun', 'bin'), // Bun global - join(homedir(), '.npm-global', 'bin'), // npm global - join(homedir(), 'bin'), // User bin + join(homedir(), '.opencode', 'bin'), // Default install location + join(homedir(), '.local', 'bin'), // Alternative install location + '/usr/local/bin', // Homebrew / system + join(homedir(), 'go', 'bin'), // Go install + join(homedir(), '.bun', 'bin'), // Bun global + join(homedir(), '.npm-global', 'bin'), // npm global + join(homedir(), 'bin'), // User bin ]; /** Cached directory containing the opencode binary (empty string = searched but not found) */ @@ -41,7 +41,10 @@ export function resolveOpenCodeDir(): string | null { // Try `which` first (respects current PATH) try { - const result = execSync('which opencode', { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS }).trim(); + const result = execSync('which opencode', { + encoding: 'utf-8', + timeout: EXEC_TIMEOUT_MS, + }).trim(); if (result && existsSync(result)) { _openCodeDir = dirname(result); return _openCodeDir; @@ -58,7 +61,7 @@ export function resolveOpenCodeDir(): string | null { } } - _openCodeDir = ''; // mark as searched, not found + _openCodeDir = ''; // mark as searched, not found return null; } diff --git a/src/utils/regex-patterns.ts b/src/utils/regex-patterns.ts index 14ec9894..c0b7b24b 100644 --- a/src/utils/regex-patterns.ts +++ b/src/utils/regex-patterns.ts @@ -17,7 +17,8 @@ * Use this when you need complete ANSI stripping including OSC sequences. * Note: Has global flag - reset lastIndex before exec() if reusing. */ -export const ANSI_ESCAPE_PATTERN_FULL = /\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[=>])/g; +export const ANSI_ESCAPE_PATTERN_FULL = + /\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[=>])/g; /** * Simple ANSI CSI-only pattern for basic escape code stripping. @@ -75,4 +76,3 @@ export function stripAnsi(text: string): string { export const SPINNER_PATTERN = /[⠋⠙⠹⠸⠼⠴⠦⠧]/; export const SAFE_PATH_PATTERN = /^[a-zA-Z0-9_\/\-. ~]+$/; - diff --git a/src/utils/string-similarity.ts b/src/utils/string-similarity.ts index 869949df..4c26b394 100644 --- a/src/utils/string-similarity.ts +++ b/src/utils/string-similarity.ts @@ -55,8 +55,8 @@ export function levenshteinDistance(a: string, b: string): number { for (let i = 1; i <= m; i++) { const cost = a[i - 1] === b[j - 1] ? 0 : 1; curr[i] = Math.min( - prev[i] + 1, // deletion - curr[i - 1] + 1, // insertion + prev[i] + 1, // deletion + curr[i - 1] + 1, // insertion prev[i - 1] + cost // substitution ); } @@ -161,11 +161,7 @@ export function normalizePhrase(phrase: string): string { * fuzzyPhraseMatch('TASK_DONE', 'TASKDONE') // true (separator) * fuzzyPhraseMatch('COMPLETE', 'FINISHED') // false (different word) */ -export function fuzzyPhraseMatch( - phrase1: string, - phrase2: string, - maxDistance = 2 -): boolean { +export function fuzzyPhraseMatch(phrase1: string, phrase2: string, maxDistance = 2): boolean { const norm1 = normalizePhrase(phrase1); const norm2 = normalizePhrase(phrase2); @@ -201,7 +197,7 @@ export function todoContentHash(content: string): string { let hash = 0; for (let i = 0; i < normalized.length; i++) { const char = normalized.charCodeAt(i); - hash = ((hash << 5) - hash) + char; + hash = (hash << 5) - hash + char; hash = hash & hash; // Convert to 32-bit integer } return hash.toString(36); diff --git a/src/web/schemas.ts b/src/web/schemas.ts index 3c6fdadc..ba109fad 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -15,11 +15,23 @@ import { SAFE_PATH_PATTERN } from '../utils/regex-patterns.js'; /** Validate a path string: no shell metacharacters, no traversal, must be absolute */ export function isValidWorkingDir(p: string): boolean { if (!p || !p.startsWith('/')) return false; - if (p.includes(';') || p.includes('&') || p.includes('|') || - p.includes('$') || p.includes('`') || p.includes('(') || - p.includes(')') || p.includes('{') || p.includes('}') || - p.includes('<') || p.includes('>') || p.includes("'") || - p.includes('"') || p.includes('\n') || p.includes('\r')) { + if ( + p.includes(';') || + p.includes('&') || + p.includes('|') || + p.includes('$') || + p.includes('`') || + p.includes('(') || + p.includes(')') || + p.includes('{') || + p.includes('}') || + p.includes('<') || + p.includes('>') || + p.includes("'") || + p.includes('"') || + p.includes('\n') || + p.includes('\r') + ) { return false; } if (p.includes('..')) return false; @@ -38,25 +50,35 @@ const ALLOWED_ENV_PREFIXES = ['CLAUDE_CODE_', 'OPENCODE_']; /** Env var keys that are always blocked (security-sensitive) */ const BLOCKED_ENV_KEYS = new Set([ - 'PATH', 'LD_PRELOAD', 'LD_LIBRARY_PATH', 'NODE_OPTIONS', - 'CODEMAN_MUX_NAME', 'CODEMAN_TMUX', - 'OPENCODE_SERVER_PASSWORD', // Security-sensitive: server auth password + 'PATH', + 'LD_PRELOAD', + 'LD_LIBRARY_PATH', + 'NODE_OPTIONS', + 'CODEMAN_MUX_NAME', + 'CODEMAN_TMUX', + 'OPENCODE_SERVER_PASSWORD', // Security-sensitive: server auth password ]); /** Validate that an env var key is allowed */ function isAllowedEnvKey(key: string): boolean { if (BLOCKED_ENV_KEYS.has(key)) return false; - return ALLOWED_ENV_PREFIXES.some(prefix => key.startsWith(prefix)); + return ALLOWED_ENV_PREFIXES.some((prefix) => key.startsWith(prefix)); } /** Zod schema for env overrides with allowlist enforcement */ -const safeEnvOverridesSchema = z.record(z.string(), z.string()).optional().refine( - (val) => { - if (!val) return true; - return Object.keys(val).every(isAllowedEnvKey); - }, - { message: 'envOverrides contains blocked or disallowed env var keys. Only CLAUDE_CODE_* and OPENCODE_* keys are allowed.' }, -); +const safeEnvOverridesSchema = z + .record(z.string(), z.string()) + .optional() + .refine( + (val) => { + if (!val) return true; + return Object.keys(val).every(isAllowedEnvKey); + }, + { + message: + 'envOverrides contains blocked or disallowed env var keys. Only CLAUDE_CODE_* and OPENCODE_* keys are allowed.', + } + ); // ========== Session Routes ========== @@ -65,16 +87,37 @@ const safeEnvOverridesSchema = z.record(z.string(), z.string()).optional().refin * Creates a new session with optional working directory, mode, and name. */ /** Schema for OpenCode-specific configuration */ -const OpenCodeConfigSchema = z.object({ - model: z.string().max(100).regex(/^[a-zA-Z0-9._\-/]+$/).optional(), - autoAllowTools: z.boolean().optional(), - continueSession: z.string().max(100).regex(/^[a-zA-Z0-9_-]+$/).optional(), - forkSession: z.boolean().optional(), - configContent: z.string().max(10000).refine( - (val) => { try { JSON.parse(val); return true; } catch { return false; } }, - { message: 'configContent must be valid JSON' }, - ).optional(), -}).optional(); +const OpenCodeConfigSchema = z + .object({ + model: z + .string() + .max(100) + .regex(/^[a-zA-Z0-9._\-/]+$/) + .optional(), + autoAllowTools: z.boolean().optional(), + continueSession: z + .string() + .max(100) + .regex(/^[a-zA-Z0-9_-]+$/) + .optional(), + forkSession: z.boolean().optional(), + configContent: z + .string() + .max(10000) + .refine( + (val) => { + try { + JSON.parse(val); + return true; + } catch { + return false; + } + }, + { message: 'configContent must be valid JSON' } + ) + .optional(), + }) + .optional(); export const CreateSessionSchema = z.object({ workingDir: safePathSchema.optional(), @@ -108,7 +151,12 @@ export const ResizeSchema = z.object({ * 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.'), + name: z + .string() + .regex( + /^[a-zA-Z0-9_-]+$/, + 'Invalid case name format. Use only letters, numbers, hyphens, underscores.' + ), description: z.string().max(1000).optional(), }); @@ -119,7 +167,13 @@ export const CreateCaseSchema = z.object({ * 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(), + caseName: z + .string() + .regex( + /^[a-zA-Z0-9_-]+$/, + 'Invalid case name format. Use only letters, numbers, hyphens, underscores.' + ) + .optional(), mode: z.enum(['claude', 'shell', 'opencode']).optional(), openCodeConfig: OpenCodeConfigSchema, }); @@ -131,7 +185,14 @@ export const QuickStartSchema = z.object({ * Receives Claude Code hook events. */ export const HookEventSchema = z.object({ - event: z.enum(['permission_prompt', 'elicitation_dialog', 'idle_prompt', 'stop', 'teammate_idle', 'task_completed']), + event: z.enum([ + 'permission_prompt', + 'elicitation_dialog', + 'idle_prompt', + 'stop', + 'teammate_idle', + 'task_completed', + ]), sessionId: z.string().min(1), data: z.record(z.string(), z.unknown()).nullable().optional(), }); @@ -175,86 +236,100 @@ export const RespawnConfigSchema = z.object({ * 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(); +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 * Explicit allowlist of known settings fields — prevents arbitrary key persistence. */ -const NotificationEventSchema = z.object({ - enabled: z.boolean().optional(), - browser: z.boolean().optional(), - audio: z.boolean().optional(), - push: z.boolean().optional(), -}).optional(); +const NotificationEventSchema = z + .object({ + enabled: z.boolean().optional(), + browser: z.boolean().optional(), + audio: z.boolean().optional(), + push: z.boolean().optional(), + }) + .optional(); -export const SettingsUpdateSchema = z.object({ - // Paths - defaultClaudeMdPath: z.string().max(500).optional(), - defaultWorkingDir: z.string().max(500).optional(), - lastUsedCase: z.string().max(200).optional(), - // Feature toggles - ralphTrackerEnabled: z.boolean().optional(), - subagentTrackingEnabled: z.boolean().optional(), - subagentActiveTabOnly: z.boolean().optional(), - imageWatcherEnabled: z.boolean().optional(), - tunnelEnabled: z.boolean().optional(), - tabTwoRows: z.boolean().optional(), - agentTeamsEnabled: z.boolean().optional(), - // UI visibility - showFontControls: z.boolean().optional(), - showSystemStats: z.boolean().optional(), - showTokenCount: z.boolean().optional(), - showCost: z.boolean().optional(), - showLifecycleLog: z.boolean().optional(), - showMonitor: z.boolean().optional(), - showProjectInsights: z.boolean().optional(), - showFileBrowser: z.boolean().optional(), - showSubagents: z.boolean().optional(), - // Claude CLI settings - claudeMode: z.string().max(50).optional(), - allowedTools: z.string().max(2000).optional(), - // CPU priority - nice: z.object({ - enabled: z.boolean().optional(), - niceValue: z.number().int().min(-20).max(19).optional(), - }).optional(), - // Notification preferences (cross-device sync) - notificationPreferences: z.object({ - enabled: z.boolean().optional(), - browserNotifications: z.boolean().optional(), - audioAlerts: z.boolean().optional(), - stuckThresholdMs: z.number().optional(), - muteCritical: z.boolean().optional(), - muteWarning: z.boolean().optional(), - muteInfo: z.boolean().optional(), - eventTypes: z.object({ - permission_prompt: NotificationEventSchema, - elicitation_dialog: NotificationEventSchema, - idle_prompt: NotificationEventSchema, - stop: NotificationEventSchema, - session_error: NotificationEventSchema, - respawn_cycle: NotificationEventSchema, - token_milestone: NotificationEventSchema, - ralph_complete: NotificationEventSchema, - subagent_spawn: NotificationEventSchema, - subagent_complete: NotificationEventSchema, - }).optional(), - _version: z.number().optional(), - }).optional(), - // Voice settings (cross-device sync) - voiceSettings: z.object({ - apiKey: z.string().max(200).optional(), - language: z.string().max(20).optional(), - keyterms: z.string().max(500).optional(), - insertMode: z.string().max(20).optional(), - }).optional(), -}).strict(); +export const SettingsUpdateSchema = z + .object({ + // Paths + defaultClaudeMdPath: z.string().max(500).optional(), + defaultWorkingDir: z.string().max(500).optional(), + lastUsedCase: z.string().max(200).optional(), + // Feature toggles + ralphTrackerEnabled: z.boolean().optional(), + subagentTrackingEnabled: z.boolean().optional(), + subagentActiveTabOnly: z.boolean().optional(), + imageWatcherEnabled: z.boolean().optional(), + tunnelEnabled: z.boolean().optional(), + tabTwoRows: z.boolean().optional(), + agentTeamsEnabled: z.boolean().optional(), + // UI visibility + showFontControls: z.boolean().optional(), + showSystemStats: z.boolean().optional(), + showTokenCount: z.boolean().optional(), + showCost: z.boolean().optional(), + showLifecycleLog: z.boolean().optional(), + showMonitor: z.boolean().optional(), + showProjectInsights: z.boolean().optional(), + showFileBrowser: z.boolean().optional(), + showSubagents: z.boolean().optional(), + // Claude CLI settings + claudeMode: z.string().max(50).optional(), + allowedTools: z.string().max(2000).optional(), + // CPU priority + nice: z + .object({ + enabled: z.boolean().optional(), + niceValue: z.number().int().min(-20).max(19).optional(), + }) + .optional(), + // Notification preferences (cross-device sync) + notificationPreferences: z + .object({ + enabled: z.boolean().optional(), + browserNotifications: z.boolean().optional(), + audioAlerts: z.boolean().optional(), + stuckThresholdMs: z.number().optional(), + muteCritical: z.boolean().optional(), + muteWarning: z.boolean().optional(), + muteInfo: z.boolean().optional(), + eventTypes: z + .object({ + permission_prompt: NotificationEventSchema, + elicitation_dialog: NotificationEventSchema, + idle_prompt: NotificationEventSchema, + stop: NotificationEventSchema, + session_error: NotificationEventSchema, + respawn_cycle: NotificationEventSchema, + token_milestone: NotificationEventSchema, + ralph_complete: NotificationEventSchema, + subagent_spawn: NotificationEventSchema, + subagent_complete: NotificationEventSchema, + }) + .optional(), + _version: z.number().optional(), + }) + .optional(), + // Voice settings (cross-device sync) + voiceSettings: z + .object({ + apiKey: z.string().max(200).optional(), + language: z.string().max(20).optional(), + keyterms: z.string().max(500).optional(), + insertMode: z.string().max(20).optional(), + }) + .optional(), + }) + .strict(); /** * Schema for POST /api/sessions/:id/input with length limit @@ -381,10 +456,12 @@ export const CpuLimitSchema = z.object({ export const ModelConfigUpdateSchema = z.record(z.string(), z.unknown()); /** PUT /api/subagent-window-states */ -export const SubagentWindowStatesSchema = z.object({ - minimized: z.record(z.string(), z.boolean()).optional(), - open: z.array(z.string()).optional(), -}).passthrough(); +export const SubagentWindowStatesSchema = z + .object({ + minimized: z.record(z.string(), z.boolean()).optional(), + open: z.array(z.string()).optional(), + }) + .passthrough(); /** PUT /api/subagent-parents */ export const SubagentParentMapSchema = z.record(z.string(), z.string()); @@ -423,14 +500,22 @@ export const PushPreferencesUpdateSchema = z.object({ /** POST /api/ralph-loop/start */ export const RalphLoopStartSchema = z.object({ - caseName: z.string().regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format').optional().default('testcase'), + caseName: z + .string() + .regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format') + .optional() + .default('testcase'), taskDescription: z.string().min(1).max(100000), completionPhrase: z.string().max(100).default('COMPLETE'), maxIterations: z.number().int().min(0).max(1000).nullable().default(10), enableRespawn: z.boolean().default(false), - planItems: z.array(z.object({ - content: z.string(), - priority: z.string().optional(), - enabled: z.boolean().default(true), - })).optional(), + planItems: z + .array( + z.object({ + content: z.string(), + priority: z.string().optional(), + enabled: z.boolean().default(true), + }) + ) + .optional(), }); diff --git a/src/web/server.ts b/src/web/server.ts index c8db4adf..4a76e171 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -16,13 +16,29 @@ import fastifyCookie from '@fastify/cookie'; import fastifyStatic from '@fastify/static'; import { join, dirname, resolve, relative, isAbsolute } from 'node:path'; import { fileURLToPath } from 'node:url'; -import { existsSync, statSync, mkdirSync, writeFileSync, readdirSync, readFileSync, rmSync, chmodSync } from 'node:fs'; +import { + existsSync, + statSync, + mkdirSync, + writeFileSync, + readdirSync, + readFileSync, + rmSync, + chmodSync, +} from 'node:fs'; import fs from 'node:fs/promises'; import { execSync } from 'node:child_process'; import { randomBytes, timingSafeEqual } from 'node:crypto'; import { homedir, totalmem, freemem, loadavg, cpus } from 'node:os'; import { EventEmitter } from 'node:events'; -import { Session, ClaudeMessage, type BackgroundTask, type RalphTrackerState, type RalphTodoItem, type ActiveBashTool } from '../session.js'; +import { + Session, + ClaudeMessage, + type BackgroundTask, + type RalphTrackerState, + type RalphTodoItem, + type ActiveBashTool, +} from '../session.js'; import type { ClaudeMode } from '../types.js'; import { fileStreamManager } from '../file-stream-manager.js'; import { RespawnController, RespawnConfig, RespawnState } from '../respawn-controller.js'; @@ -32,7 +48,14 @@ import { getStore } from '../state-store.js'; import { generateClaudeMd } from '../templates/claude-md.js'; import { parseRalphLoopConfig, extractCompletionPhrase } from '../ralph-config.js'; import { writeHooksConfig, updateCaseEnvVars } from '../hooks-config.js'; -import { subagentWatcher, type SubagentInfo, type SubagentToolCall, type SubagentProgress, type SubagentMessage, type SubagentToolResult } from '../subagent-watcher.js'; +import { + subagentWatcher, + type SubagentInfo, + type SubagentToolCall, + type SubagentProgress, + type SubagentMessage, + type SubagentToolResult, +} from '../subagent-watcher.js'; import { imageWatcher } from '../image-watcher.js'; import { TranscriptWatcher } from '../transcript-watcher.js'; import { TeamWatcher } from '../team-watcher.js'; @@ -127,8 +150,8 @@ const TASK_UPDATE_BATCH_INTERVAL = 100; // When terminal supports this, it buffers all output between start/end markers // and renders atomically, eliminating partial-frame flicker from Ink redraws. // Supported by: WezTerm, Kitty, Ghostty, iTerm2 3.5+, Windows Terminal, VSCode terminal -const DEC_SYNC_START = '\x1b[?2026h'; // Begin synchronized update -const DEC_SYNC_END = '\x1b[?2026l'; // End synchronized update (flush to screen) +const DEC_SYNC_START = '\x1b[?2026h'; // Begin synchronized update +const DEC_SYNC_END = '\x1b[?2026l'; // End synchronized update (flush to screen) // State update debounce interval (batch expensive toDetailedState() calls) const STATE_UPDATE_DEBOUNCE_INTERVAL = 500; // Cache TTL for getLightSessionsState() — avoids re-serializing all sessions on every SSE init / /api/sessions call @@ -199,14 +222,22 @@ function formatUptime(seconds: number): string { * Extracts only relevant fields and limits total size to prevent * oversized payloads from being broadcast to all connected clients. */ -function sanitizeHookData(data: Record | null | undefined): Record { +function sanitizeHookData( + data: Record | null | undefined +): Record { if (!data || typeof data !== 'object') return {}; // Only forward known safe fields from Claude Code hook stdin const safeFields: Record = {}; const allowedKeys = [ - 'hook_event_name', 'tool_name', 'tool_input', 'session_id', - 'cwd', 'permission_mode', 'stop_hook_active', 'transcript_path', + 'hook_event_name', + 'tool_name', + 'tool_input', + 'session_id', + 'cwd', + 'permission_mode', + 'stop_hook_active', + 'transcript_path', ]; for (const key of allowedKeys) { @@ -248,7 +279,11 @@ function sanitizeHookData(data: Record | null | undefined): Rec * The ralph-loop.local.md file has priority because it contains * the exact configuration from an active Ralph loop session. */ -function autoConfigureRalph(session: Session, workingDir: string, broadcast: (event: string, data: unknown) => void): void { +function autoConfigureRalph( + session: Session, + workingDir: string, + broadcast: (event: string, data: unknown) => void +): void { // First, try to read the official Ralph Wiggum plugin state file const ralphConfig = parseRalphLoopConfig(workingDir); @@ -263,10 +298,14 @@ function autoConfigureRalph(session: Session, workingDir: string, broadcast: (ev if (ralphConfig.iteration > 0) { // The tracker's cycleCount will be updated when we detect iteration patterns // in the terminal output, but we can set maxIterations now - console.log(`[auto-detect] Ralph loop at iteration ${ralphConfig.iteration}/${ralphConfig.maxIterations ?? '∞'}`); + console.log( + `[auto-detect] Ralph loop at iteration ${ralphConfig.iteration}/${ralphConfig.maxIterations ?? '∞'}` + ); } - console.log(`[auto-detect] Configured Ralph loop for session ${session.id} from ralph-loop.local.md: ${ralphConfig.completionPromise}`); + console.log( + `[auto-detect] Configured Ralph loop for session ${session.id} from ralph-loop.local.md: ${ralphConfig.completionPromise}` + ); broadcast('session:ralphLoopUpdate', { sessionId: session.id, state: session.ralphTracker.loopState, @@ -281,7 +320,9 @@ function autoConfigureRalph(session: Session, workingDir: string, broadcast: (ev if (completionPhrase) { session.ralphTracker.enable(); session.ralphTracker.startLoop(completionPhrase); - console.log(`[auto-detect] Configured Ralph loop for session ${session.id} from CLAUDE.md: ${completionPhrase}`); + console.log( + `[auto-detect] Configured Ralph loop for session ${session.id} from CLAUDE.md: ${completionPhrase}` + ); broadcast('session:ralphLoopUpdate', { sessionId: session.id, state: session.ralphTracker.loopState, @@ -310,9 +351,9 @@ function getOrCreateSelfSignedCert(): { key: string; cert: string } { // Generate self-signed cert valid for 365 days, covering localhost and common LAN access patterns execSync( `openssl req -x509 -newkey rsa:2048 -nodes ` + - `-keyout "${keyPath}" -out "${certPath}" ` + - `-days 365 -subj "/CN=codeman" ` + - `-addext "subjectAltName=DNS:localhost,IP:127.0.0.1,IP:0.0.0.0"`, + `-keyout "${keyPath}" -out "${certPath}" ` + + `-days 365 -subj "/CN=codeman" ` + + `-addext "subjectAltName=DNS:localhost,IP:127.0.0.1,IP:0.0.0.0"`, { stdio: 'pipe' } ); @@ -342,7 +383,12 @@ interface SessionListenerRefs { taskFailed: (task: BackgroundTask, error: string) => void; autoClear: (data: { tokens: number; threshold: number }) => void; autoCompact: (data: { tokens: number; threshold: number; prompt?: string }) => void; - cliInfoUpdated: (data: { version?: string; model?: string; accountType?: string; latestVersion?: string }) => void; + cliInfoUpdated: (data: { + version?: string; + model?: string; + accountType?: string; + latestVersion?: string; + }) => void; ralphLoopUpdate: (state: RalphTrackerState) => void; ralphTodoUpdate: (todos: RalphTodoItem[]) => void; ralphCompletionDetected: (phrase: string) => void; @@ -361,7 +407,8 @@ export class WebServer extends EventEmitter { private app: FastifyInstance; private sessions: Map = new Map(); private respawnControllers: Map = new Map(); - private respawnTimers: Map = new Map(); + private respawnTimers: Map = + new Map(); private runSummaryTrackers: Map = new Map(); private transcriptWatchers: Map = new Map(); // Store session listener references for explicit cleanup (prevents memory leaks) @@ -377,13 +424,13 @@ export class WebServer extends EventEmitter { private mux: TerminalMultiplexer; // Terminal batching for performance private terminalBatches: Map = new Map(); - private terminalBatchSizes: Map = new Map(); // Running total avoids O(n) reduce per push - private terminalBatchTimers: Map = new Map(); // Per-session timers (staggered flushes) + private terminalBatchSizes: Map = new Map(); // Running total avoids O(n) reduce per push + private terminalBatchTimers: Map = new Map(); // Per-session timers (staggered flushes) // Adaptive batching: track rapid events to extend batch window (per-session) // StaleExpirationMap auto-cleans entries for sessions that stop generating output private lastTerminalEventTime: StaleExpirationMap = 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 + refreshOnGet: false, // Don't refresh on reads, only on explicit sets }); // Scheduled runs cleanup timer private scheduledCleanupTimer: NodeJS.Timeout | null = null; @@ -558,7 +605,10 @@ export class WebServer extends EventEmitter { this.imageWatcherHandlers = { detected: (event: ImageDetectedEvent) => this.broadcast('image:detected', event), error: (error: Error, sessionId?: string) => { - console.error(`[ImageWatcher] Error${sessionId ? ` for ${sessionId}` : ''}:`, error.message); + console.error( + `[ImageWatcher] Error${sessionId ? ` for ${sessionId}` : ''}:`, + error.message + ); }, }; @@ -629,7 +679,8 @@ export class WebServer extends EventEmitter { const authPassword = process.env.CODEMAN_PASSWORD; if (authPassword) { const authUsername = process.env.CODEMAN_USERNAME || 'admin'; - const expectedHeader = 'Basic ' + Buffer.from(`${authUsername}:${authPassword}`).toString('base64'); + const expectedHeader = + 'Basic ' + Buffer.from(`${authUsername}:${authPassword}`).toString('base64'); // Session token store — active sessions extend TTL on access this.authSessions = new StaleExpirationMap({ @@ -715,7 +766,10 @@ export class WebServer extends EventEmitter { this.app.addHook('onRequest', (req, reply, done) => { reply.header('X-Content-Type-Options', 'nosniff'); reply.header('X-Frame-Options', 'SAMEORIGIN'); - reply.header('Content-Security-Policy', "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; img-src 'self' data: blob:; connect-src 'self' wss://api.deepgram.com; font-src 'self' https://cdn.jsdelivr.net; frame-ancestors 'self'"); + reply.header( + 'Content-Security-Policy', + "default-src 'self'; script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; img-src 'self' data: blob:; connect-src 'self' wss://api.deepgram.com; font-src 'self' https://cdn.jsdelivr.net; frame-ancestors 'self'" + ); if (this.https) { reply.header('Strict-Transport-Security', 'max-age=31536000; includeSubDomains'); } @@ -725,7 +779,11 @@ export class WebServer extends EventEmitter { if (origin) { try { const url = new URL(origin); - if (url.hostname === 'localhost' || url.hostname === '127.0.0.1' || url.hostname === '::1') { + if ( + url.hostname === 'localhost' || + url.hostname === '127.0.0.1' || + url.hostname === '::1' + ) { reply.header('Access-Control-Allow-Origin', origin); reply.header('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS'); reply.header('Access-Control-Allow-Headers', 'Content-Type, Authorization'); @@ -776,7 +834,7 @@ export class WebServer extends EventEmitter { reply.raw.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', - 'Connection': 'keep-alive', + Connection: 'keep-alive', 'X-Accel-Buffering': 'no', // Disable nginx buffering }); @@ -812,7 +870,9 @@ export class WebServer extends EventEmitter { this.app.get('/api/tunnel/qr', async (_req, reply) => { const url = this.tunnelManager.getUrl(); if (!url) { - return reply.code(404).send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'Tunnel not running')); + return reply + .code(404) + .send(createErrorResponse(ApiErrorCode.NOT_FOUND, 'Tunnel not running')); } try { const QRCode = require('qrcode'); @@ -820,13 +880,16 @@ export class WebServer extends EventEmitter { // Return as data URI to avoid Fastify compress issues with SVG content-type return { svg }; } catch (err) { - return reply.code(500).send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err))); + return reply + .code(500) + .send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err))); } }); // OpenCode CLI availability check this.app.get('/api/opencode/status', async () => { - const { isOpenCodeAvailable, resolveOpenCodeDir } = await import('../utils/opencode-cli-resolver.js'); + const { isOpenCodeAvailable, resolveOpenCodeDir } = + await import('../utils/opencode-cli-resolver.js'); return { available: isOpenCodeAvailable(), path: resolveOpenCodeDir(), @@ -841,7 +904,12 @@ export class WebServer extends EventEmitter { // Session lifecycle audit log this.app.get('/api/session-lifecycle', async (req) => { - const query = req.query as { sessionId?: string; event?: string; since?: string; limit?: string }; + const query = req.query as { + sessionId?: string; + event?: string; + since?: string; + limit?: string; + }; const lifecycleLog = getLifecycleLog(); const entries = await lifecycleLog.query({ sessionId: query.sessionId, @@ -854,7 +922,10 @@ export class WebServer extends EventEmitter { // Global stats endpoint this.app.get('/api/stats', async () => { - const activeSessionTokens: Record = {}; + const activeSessionTokens: Record< + string, + { inputTokens?: number; outputTokens?: number; totalCost?: number } + > = {}; for (const [sessionId, session] of this.sessions) { activeSessionTokens[sessionId] = { inputTokens: session.inputTokens, @@ -872,7 +943,10 @@ export class WebServer extends EventEmitter { // Token stats with daily history this.app.get('/api/token-stats', async () => { // Get aggregate totals (global + active sessions) - const activeSessionTokens: Record = {}; + const activeSessionTokens: Record< + string, + { inputTokens?: number; outputTokens?: number; totalCost?: number } + > = {}; for (const [sessionId, session] of this.sessions) { activeSessionTokens[sessionId] = { inputTokens: session.inputTokens, @@ -895,7 +969,10 @@ export class WebServer extends EventEmitter { // 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}`); + return createErrorResponse( + ApiErrorCode.INVALID_INPUT, + `Invalid config: ${parseResult.error.message}` + ); } this.store.setConfig(parseResult.data as Partial>); return { success: true, config: this.store.getConfig() }; @@ -931,15 +1008,15 @@ export class WebServer extends EventEmitter { return { memory: { rss: mem.rss, - rssMB: Math.round(mem.rss / 1024 / 1024 * 10) / 10, + rssMB: Math.round((mem.rss / 1024 / 1024) * 10) / 10, heapUsed: mem.heapUsed, - heapUsedMB: Math.round(mem.heapUsed / 1024 / 1024 * 10) / 10, + heapUsedMB: Math.round((mem.heapUsed / 1024 / 1024) * 10) / 10, heapTotal: mem.heapTotal, - heapTotalMB: Math.round(mem.heapTotal / 1024 / 1024 * 10) / 10, + heapTotalMB: Math.round((mem.heapTotal / 1024 / 1024) * 10) / 10, external: mem.external, - externalMB: Math.round(mem.external / 1024 / 1024 * 10) / 10, + externalMB: Math.round((mem.external / 1024 / 1024) * 10) / 10, arrayBuffers: mem.arrayBuffers, - arrayBuffersMB: Math.round(mem.arrayBuffers / 1024 / 1024 * 10) / 10, + arrayBuffersMB: Math.round((mem.arrayBuffers / 1024 / 1024) * 10) / 10, }, mapSizes: { server: serverMapSizes, @@ -954,13 +1031,17 @@ export class WebServer extends EventEmitter { fileWatchers: subagentStats.fileWatcherCount, dirWatchers: subagentStats.dirWatcherCount, transcriptWatchers: this.transcriptWatchers.size, - total: subagentStats.fileWatcherCount + subagentStats.dirWatcherCount + this.transcriptWatchers.size, + total: + subagentStats.fileWatcherCount + + subagentStats.dirWatcherCount + + this.transcriptWatchers.size, }, timers: { respawnTimers: this.respawnTimers.size, pendingRespawnStarts: this.pendingRespawnStarts.size, subagentIdleTimers: subagentStats.idleTimerCount, - total: this.respawnTimers.size + this.pendingRespawnStarts.size + subagentStats.idleTimerCount, + total: + this.respawnTimers.size + this.pendingRespawnStarts.size + subagentStats.idleTimerCount, }, uptime: { seconds: Math.round(process.uptime()), @@ -976,12 +1057,18 @@ export class WebServer extends EventEmitter { this.app.post('/api/sessions', async (req) => { // Prevent unbounded session creation if (this.sessions.size >= MAX_CONCURRENT_SESSIONS) { - return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `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 result = CreateSessionSchema.safeParse(req.body); if (!result.success) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed'); + return createErrorResponse( + ApiErrorCode.INVALID_INPUT, + result.error.issues[0]?.message ?? 'Validation failed' + ); } const body = result.data; const workingDir = body.workingDir || process.cwd(); @@ -1007,16 +1094,22 @@ export class WebServer extends EventEmitter { if (body.mode === 'opencode') { const { isOpenCodeAvailable } = await import('../utils/opencode-cli-resolver.js'); if (!isOpenCodeAvailable()) { - return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash'); + return createErrorResponse( + ApiErrorCode.OPERATION_FAILED, + 'OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash' + ); } } const globalNice = await this.getGlobalNiceConfig(); const modelConfig = await this.getModelConfig(); const mode = body.mode || 'claude'; - const model = mode === 'opencode' - ? body.openCodeConfig?.model - : (mode !== 'shell' ? modelConfig?.defaultModel : undefined); + const model = + mode === 'opencode' + ? body.openCodeConfig?.model + : mode !== 'shell' + ? modelConfig?.defaultModel + : undefined; const claudeModeConfig = await this.getClaudeModeConfig(); const session = new Session({ workingDir, @@ -1147,7 +1240,7 @@ export class WebServer extends EventEmitter { textOutput: session.textOutput, messages: session.messages, errorBuffer: session.errorBuffer, - } + }, }; }); @@ -1166,7 +1259,7 @@ export class WebServer extends EventEmitter { loop: session.ralphLoopState, todos: session.ralphTodos, todoStats: session.ralphTodoStats, - } + }, }; }); @@ -1206,7 +1299,7 @@ export class WebServer extends EventEmitter { success: true, data: { tools: session.activeTools, - } + }, }; }); @@ -1225,7 +1318,22 @@ export class WebServer extends EventEmitter { const workingDir = session.workingDir; // Default excludes - large/generated directories - const excludeDirs = new Set(['.git', 'node_modules', 'dist', 'build', '__pycache__', '.cache', '.next', '.nuxt', 'coverage', '.venv', 'venv', '.tox', 'target', 'vendor']); + const excludeDirs = new Set([ + '.git', + 'node_modules', + 'dist', + 'build', + '__pycache__', + '.cache', + '.next', + '.nuxt', + 'coverage', + '.venv', + 'venv', + '.tox', + 'target', + 'vendor', + ]); interface FileTreeNode { name: string; @@ -1241,7 +1349,10 @@ export class WebServer extends EventEmitter { let truncated = false; const maxFiles = 5000; - const scanDirectory = async (dirPath: string, currentDepth: number): Promise => { + const scanDirectory = async ( + dirPath: string, + currentDepth: number + ): Promise => { if (currentDepth > maxDepth || totalFiles + totalDirectories > maxFiles) { truncated = true; return []; @@ -1284,7 +1395,9 @@ export class WebServer extends EventEmitter { }); } else { totalFiles++; - const ext = entry.name.includes('.') ? entry.name.split('.').pop()?.toLowerCase() : undefined; + const ext = entry.name.includes('.') + ? entry.name.split('.').pop()?.toLowerCase() + : undefined; let size: number | undefined; try { const stat = await fs.stat(fullPath); @@ -1319,14 +1432,18 @@ export class WebServer extends EventEmitter { totalFiles, totalDirectories, truncated, - } + }, }; }); // Get file content for preview (File Browser) this.app.get('/api/sessions/:id/file-content', async (req) => { const { id } = req.params as { id: string }; - const { path: filePath, lines, raw } = req.query as { path?: string; lines?: string; raw?: string }; + const { + path: filePath, + lines, + raw, + } = req.query as { path?: string; lines?: string; raw?: string }; const session = this.sessions.get(id); if (!session) { @@ -1341,7 +1458,10 @@ export class WebServer extends EventEmitter { const fullPath = resolve(session.workingDir, filePath); const relativePath = relative(session.workingDir, fullPath); if (relativePath.startsWith('..') || isAbsolute(relativePath)) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Path must be within working directory'); + return createErrorResponse( + ApiErrorCode.INVALID_INPUT, + 'Path must be within working directory' + ); } try { @@ -1349,7 +1469,34 @@ export class WebServer extends EventEmitter { // Check if it's a binary/media file const ext = filePath.split('.').pop()?.toLowerCase() || ''; - const binaryExts = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'ico', 'svg', 'bmp', 'mp4', 'webm', 'mov', 'avi', 'mp3', 'wav', 'ogg', 'pdf', 'zip', 'tar', 'gz', 'exe', 'dll', 'so', 'woff', 'woff2', 'ttf', 'eot']); + const binaryExts = new Set([ + 'png', + 'jpg', + 'jpeg', + 'gif', + 'webp', + 'ico', + 'svg', + 'bmp', + 'mp4', + 'webm', + 'mov', + 'avi', + 'mp3', + 'wav', + 'ogg', + 'pdf', + 'zip', + 'tar', + 'gz', + 'exe', + 'dll', + 'so', + 'woff', + 'woff2', + 'ttf', + 'eot', + ]); const imageExts = new Set(['png', 'jpg', 'jpeg', 'gif', 'webp', 'svg', 'bmp', 'ico']); const videoExts = new Set(['mp4', 'webm', 'mov', 'avi']); @@ -1363,14 +1510,17 @@ export class WebServer extends EventEmitter { type: imageExts.has(ext) ? 'image' : videoExts.has(ext) ? 'video' : 'binary', extension: ext, url: `/api/sessions/${id}/file-raw?path=${encodeURIComponent(filePath)}`, - } + }, }; } // 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)`); + 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) @@ -1390,10 +1540,13 @@ export class WebServer extends EventEmitter { totalLines: allLines.length, truncated: truncatedContent, extension: ext, - } + }, }; } catch (err) { - return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read file: ${getErrorMessage(err)}`); + return createErrorResponse( + ApiErrorCode.OPERATION_FAILED, + `Failed to read file: ${getErrorMessage(err)}` + ); } }); @@ -1409,7 +1562,9 @@ export class WebServer extends EventEmitter { } if (!filePath) { - reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter')); + reply + .code(400) + .send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter')); return; } @@ -1417,7 +1572,11 @@ export class WebServer extends EventEmitter { const fullPath = resolve(session.workingDir, filePath); 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')); + reply + .code(400) + .send( + createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Path must be within working directory') + ); return; } @@ -1426,24 +1585,49 @@ export class WebServer extends EventEmitter { 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)`)); + 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 = { - 'png': 'image/png', 'jpg': 'image/jpeg', 'jpeg': 'image/jpeg', 'gif': 'image/gif', - 'webp': 'image/webp', 'svg': 'image/svg+xml', 'ico': 'image/x-icon', 'bmp': 'image/bmp', - 'mp4': 'video/mp4', 'webm': 'video/webm', 'mov': 'video/quicktime', - 'mp3': 'audio/mpeg', 'wav': 'audio/wav', 'ogg': 'audio/ogg', - 'pdf': 'application/pdf', 'json': 'application/json', + png: 'image/png', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + gif: 'image/gif', + webp: 'image/webp', + svg: 'image/svg+xml', + ico: 'image/x-icon', + bmp: 'image/bmp', + mp4: 'video/mp4', + webm: 'video/webm', + mov: 'video/quicktime', + mp3: 'audio/mpeg', + wav: 'audio/wav', + ogg: 'audio/ogg', + pdf: 'application/pdf', + json: 'application/json', }; const content = await fs.readFile(fullPath); reply.header('Content-Type', mimeTypes[ext] || 'application/octet-stream'); reply.send(content); } catch (err) { - reply.code(500).send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read file: ${getErrorMessage(err)}`)); + reply + .code(500) + .send( + createErrorResponse( + ApiErrorCode.OPERATION_FAILED, + `Failed to read file: ${getErrorMessage(err)}` + ) + ); } }); @@ -1459,7 +1643,9 @@ export class WebServer extends EventEmitter { } if (!filePath) { - reply.code(400).send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter')); + reply + .code(400) + .send(createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Missing path parameter')); return; } @@ -1467,7 +1653,7 @@ export class WebServer extends EventEmitter { reply.raw.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', - 'Connection': 'keep-alive', + Connection: 'keep-alive', 'X-Accel-Buffering': 'no', }); @@ -1502,7 +1688,9 @@ export class WebServer extends EventEmitter { streamRef.id = result.streamId; // Notify client of successful connection - reply.raw.write(`data: ${JSON.stringify({ type: 'connected', streamId: result.streamId, filePath })}\n\n`); + reply.raw.write( + `data: ${JSON.stringify({ type: 'connected', streamId: result.streamId, filePath })}\n\n` + ); // Handle client disconnect req.raw.on('close', () => { @@ -1532,13 +1720,14 @@ export class WebServer extends EventEmitter { if (!ralphResult.success) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); } - const { enabled, completionPhrase, maxIterations, reset, disableAutoEnable } = ralphResult.data as { - enabled?: boolean; - completionPhrase?: string; - maxIterations?: number; - reset?: boolean | 'full'; - disableAutoEnable?: boolean; - }; + const { enabled, completionPhrase, maxIterations, reset, disableAutoEnable } = + ralphResult.data as { + enabled?: boolean; + completionPhrase?: string; + maxIterations?: number; + reset?: boolean | 'full'; + disableAutoEnable?: boolean; + }; const session = this.sessions.get(id); if (!session) { @@ -1547,7 +1736,10 @@ export class WebServer extends EventEmitter { // Ralph tracker is not supported for opencode sessions if (session.mode === 'opencode') { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Ralph tracker is not supported for opencode sessions'); + return createErrorResponse( + ApiErrorCode.INVALID_INPUT, + 'Ralph tracker is not supported for opencode sessions' + ); } // Handle reset first (before other config) @@ -1599,7 +1791,7 @@ export class WebServer extends EventEmitter { this.persistSessionState(session); this.broadcast('session:ralphLoopUpdate', { sessionId: id, - state: session.ralphLoopState + state: session.ralphLoopState, }); return { success: true }; @@ -1634,7 +1826,7 @@ export class WebServer extends EventEmitter { circuitBreaker: session.ralphTracker.circuitBreakerStatus, cumulativeStats: session.ralphTracker.cumulativeStats, exitGateMet: session.ralphTracker.exitGateMet, - } + }, }; }); @@ -1653,7 +1845,7 @@ export class WebServer extends EventEmitter { data: { content, todoCount: session.ralphTracker.todos.length, - } + }, }; }); @@ -1679,7 +1871,7 @@ export class WebServer extends EventEmitter { data: { importedCount, todos: session.ralphTracker.todos, - } + }, }; }); @@ -1707,7 +1899,7 @@ export class WebServer extends EventEmitter { data: { filePath, todoCount: session.ralphTracker.todos.length, - } + }, }; } catch (error) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to write file: ${error}`); @@ -1741,11 +1933,14 @@ export class WebServer extends EventEmitter { filePath, importedCount, todos: session.ralphTracker.todos, - } + }, }; } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') { - return createErrorResponse(ApiErrorCode.NOT_FOUND, '@fix_plan.md not found in working directory'); + return createErrorResponse( + ApiErrorCode.NOT_FOUND, + '@fix_plan.md not found in working directory' + ); } return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read file: ${error}`); } @@ -1780,7 +1975,7 @@ export class WebServer extends EventEmitter { data: { filePath, contentLength: content.length, - } + }, }; } catch (error) { return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to write file: ${error}`); @@ -1792,7 +1987,10 @@ export class WebServer extends EventEmitter { const { id } = req.params as { id: string }; const result = RunPromptSchema.safeParse(req.body); if (!result.success) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed'); + return createErrorResponse( + ApiErrorCode.INVALID_INPUT, + result.error.issues[0]?.message ?? 'Validation failed' + ); } const { prompt } = result.data; const session = this.sessions.get(id); @@ -1806,7 +2004,7 @@ export class WebServer extends EventEmitter { } // Run async, don't wait - session.runPrompt(prompt).catch(err => { + session.runPrompt(prompt).catch((err) => { this.broadcast('session:error', { id, error: err.message }); }); @@ -1830,7 +2028,11 @@ export class WebServer extends EventEmitter { try { // Auto-detect completion phrase from CLAUDE.md BEFORE starting (only if globally enabled and not explicitly disabled by user) // Ralph tracker is not supported for opencode sessions - if (session.mode !== 'opencode' && this.store.getConfig().ralphEnabled && !session.ralphTracker.autoEnableDisabled) { + if ( + session.mode !== 'opencode' && + this.store.getConfig().ralphEnabled && + !session.ralphTracker.autoEnableDisabled + ) { autoConfigureRalph(session, session.workingDir, () => {}); if (!session.ralphTracker.enabled) { session.ralphTracker.enable(); @@ -1838,7 +2040,12 @@ export class WebServer extends EventEmitter { } await session.startInteractive(); - getLifecycleLog().log({ event: 'started', sessionId: id, name: session.name, mode: session.mode }); + getLifecycleLog().log({ + event: 'started', + sessionId: id, + name: session.name, + mode: session.mode, + }); this.broadcast('session:interactive', { id }); this.broadcast('session:updated', { session: this.getSessionStateWithRespawn(session) }); @@ -1863,7 +2070,12 @@ export class WebServer extends EventEmitter { try { await session.startShell(); - getLifecycleLog().log({ event: 'started', sessionId: id, name: session.name, mode: 'shell' }); + getLifecycleLog().log({ + event: 'started', + sessionId: id, + name: session.name, + mode: 'shell', + }); this.broadcast('session:interactive', { id, mode: 'shell' }); this.broadcast('session:updated', { session: this.getSessionStateWithRespawn(session) }); return { success: true }; @@ -1878,7 +2090,10 @@ export class WebServer extends EventEmitter { const { id } = req.params as { id: string }; const result = SessionInputWithLimitSchema.safeParse(req.body); if (!result.success) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed'); + return createErrorResponse( + ApiErrorCode.INVALID_INPUT, + result.error.issues[0]?.message ?? 'Validation failed' + ); } const { input, useMux } = result.data; const session = this.sessions.get(id); @@ -1889,7 +2104,10 @@ export class WebServer extends EventEmitter { const inputStr = String(input); if (inputStr.length > MAX_INPUT_LENGTH) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, `Input exceeds maximum length (${MAX_INPUT_LENGTH} bytes)`); + return createErrorResponse( + ApiErrorCode.INVALID_INPUT, + `Input exceeds maximum length (${MAX_INPUT_LENGTH} bytes)` + ); } // Write input to PTY. Direct write is synchronous; writeViaMux @@ -1897,14 +2115,19 @@ export class WebServer extends EventEmitter { if (useMux) { // Fire-and-forget: don't block HTTP response on tmux child process. // Fallback to direct write on failure. - session.writeViaMux(inputStr).then(ok => { - if (!ok) { - console.warn(`[Server] writeViaMux failed for session ${id}, falling back to direct write`); + session + .writeViaMux(inputStr) + .then((ok) => { + if (!ok) { + console.warn( + `[Server] writeViaMux failed for session ${id}, falling back to direct write` + ); + session.write(inputStr); + } + }) + .catch(() => { session.write(inputStr); - } - }).catch(() => { - session.write(inputStr); - }); + }); } else { session.write(inputStr); } @@ -1916,7 +2139,10 @@ export class WebServer extends EventEmitter { const { id } = req.params as { id: string }; const result = ResizeSchema.safeParse(req.body); if (!result.success) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed'); + return createErrorResponse( + ApiErrorCode.INVALID_INPUT, + result.error.issues[0]?.message ?? 'Validation failed' + ); } const { cols, rows } = result.data; const session = this.sessions.get(id); @@ -1927,7 +2153,10 @@ export class WebServer extends EventEmitter { // 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})`); + return createErrorResponse( + ApiErrorCode.INVALID_INPUT, + `Terminal dimensions exceed maximum (${MAX_TERMINAL_COLS}x${MAX_TERMINAL_ROWS})` + ); } session.resize(cols, rows); @@ -1979,9 +2208,7 @@ export class WebServer extends EventEmitter { } // Remove Ctrl+L and leading whitespace (cheap on tailed subset) - cleanBuffer = cleanBuffer - .replace(CTRL_L_PATTERN, '') - .replace(LEADING_WHITESPACE_PATTERN, ''); + cleanBuffer = cleanBuffer.replace(CTRL_L_PATTERN, '').replace(LEADING_WHITESPACE_PATTERN, ''); return { terminalBuffer: cleanBuffer, @@ -2045,7 +2272,10 @@ export class WebServer extends EventEmitter { // Respawn is not supported for opencode sessions if (session.mode === 'opencode') { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Respawn is not supported for opencode sessions'); + return createErrorResponse( + ApiErrorCode.INVALID_INPUT, + 'Respawn is not supported for opencode sessions' + ); } // Create or get existing controller @@ -2113,7 +2343,10 @@ export class WebServer extends EventEmitter { // 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}`); + return createErrorResponse( + ApiErrorCode.INVALID_INPUT, + `Invalid respawn config: ${parseResult.error.message}` + ); } const config = parseResult.data as Partial; const session = this.sessions.get(id); @@ -2139,7 +2372,8 @@ export class WebServer extends EventEmitter { const merged: PersistedRespawnConfig = { enabled: config.enabled ?? currentConfig?.enabled ?? false, idleTimeoutMs: config.idleTimeoutMs ?? currentConfig?.idleTimeoutMs ?? 10000, - updatePrompt: config.updatePrompt ?? currentConfig?.updatePrompt ?? 'update all the docs and CLAUDE.md', + updatePrompt: + config.updatePrompt ?? currentConfig?.updatePrompt ?? 'update all the docs and CLAUDE.md', interStepDelayMs: config.interStepDelayMs ?? currentConfig?.interStepDelayMs ?? 1000, sendClear: config.sendClear ?? currentConfig?.sendClear ?? true, sendInit: config.sendInit ?? currentConfig?.sendInit ?? true, @@ -2147,15 +2381,23 @@ export class WebServer extends EventEmitter { autoAcceptPrompts: config.autoAcceptPrompts ?? currentConfig?.autoAcceptPrompts ?? true, autoAcceptDelayMs: config.autoAcceptDelayMs ?? currentConfig?.autoAcceptDelayMs ?? 8000, aiIdleCheckEnabled: config.aiIdleCheckEnabled ?? currentConfig?.aiIdleCheckEnabled ?? true, - aiIdleCheckModel: config.aiIdleCheckModel ?? currentConfig?.aiIdleCheckModel ?? 'claude-opus-4-5-20251101', - aiIdleCheckMaxContext: config.aiIdleCheckMaxContext ?? currentConfig?.aiIdleCheckMaxContext ?? 16000, - aiIdleCheckTimeoutMs: config.aiIdleCheckTimeoutMs ?? currentConfig?.aiIdleCheckTimeoutMs ?? 90000, - aiIdleCheckCooldownMs: config.aiIdleCheckCooldownMs ?? currentConfig?.aiIdleCheckCooldownMs ?? 180000, + aiIdleCheckModel: + config.aiIdleCheckModel ?? currentConfig?.aiIdleCheckModel ?? 'claude-opus-4-5-20251101', + aiIdleCheckMaxContext: + config.aiIdleCheckMaxContext ?? currentConfig?.aiIdleCheckMaxContext ?? 16000, + aiIdleCheckTimeoutMs: + config.aiIdleCheckTimeoutMs ?? currentConfig?.aiIdleCheckTimeoutMs ?? 90000, + aiIdleCheckCooldownMs: + config.aiIdleCheckCooldownMs ?? currentConfig?.aiIdleCheckCooldownMs ?? 180000, aiPlanCheckEnabled: config.aiPlanCheckEnabled ?? currentConfig?.aiPlanCheckEnabled ?? true, - aiPlanCheckModel: config.aiPlanCheckModel ?? currentConfig?.aiPlanCheckModel ?? 'claude-opus-4-5-20251101', - aiPlanCheckMaxContext: config.aiPlanCheckMaxContext ?? currentConfig?.aiPlanCheckMaxContext ?? 8000, - aiPlanCheckTimeoutMs: config.aiPlanCheckTimeoutMs ?? currentConfig?.aiPlanCheckTimeoutMs ?? 60000, - aiPlanCheckCooldownMs: config.aiPlanCheckCooldownMs ?? currentConfig?.aiPlanCheckCooldownMs ?? 30000, + aiPlanCheckModel: + config.aiPlanCheckModel ?? currentConfig?.aiPlanCheckModel ?? 'claude-opus-4-5-20251101', + aiPlanCheckMaxContext: + config.aiPlanCheckMaxContext ?? currentConfig?.aiPlanCheckMaxContext ?? 8000, + aiPlanCheckTimeoutMs: + config.aiPlanCheckTimeoutMs ?? currentConfig?.aiPlanCheckTimeoutMs ?? 60000, + aiPlanCheckCooldownMs: + config.aiPlanCheckCooldownMs ?? currentConfig?.aiPlanCheckCooldownMs ?? 30000, durationMinutes: currentConfig?.durationMinutes, }; this.mux.updateRespawnConfig(id, merged); @@ -2167,11 +2409,16 @@ export class WebServer extends EventEmitter { // Start interactive session WITH respawn enabled this.app.post('/api/sessions/:id/interactive-respawn', async (req) => { const { id } = req.params as { id: string }; - const irResult = req.body ? InteractiveRespawnSchema.safeParse(req.body) : { success: true as const, data: {} }; + const irResult = req.body + ? InteractiveRespawnSchema.safeParse(req.body) + : { success: true as const, data: {} }; if (!irResult.success) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); } - const body = irResult.data as { respawnConfig?: Partial; durationMinutes?: number }; + const body = irResult.data as { + respawnConfig?: Partial; + durationMinutes?: number; + }; const session = this.sessions.get(id); if (!session) { @@ -2184,7 +2431,10 @@ export class WebServer extends EventEmitter { // Respawn is not supported for opencode sessions if (session.mode === 'opencode') { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Respawn is not supported for opencode sessions'); + return createErrorResponse( + ApiErrorCode.INVALID_INPUT, + 'Respawn is not supported for opencode sessions' + ); } try { @@ -2198,7 +2448,13 @@ export class WebServer extends EventEmitter { // Start interactive session await session.startInteractive(); - getLifecycleLog().log({ event: 'started', sessionId: id, name: session.name, mode: session.mode, reason: 'interactive_respawn' }); + getLifecycleLog().log({ + event: 'started', + sessionId: id, + name: session.name, + mode: session.mode, + reason: 'interactive_respawn', + }); this.broadcast('session:interactive', { id }); this.broadcast('session:updated', { session: this.getSessionStateWithRespawn(session) }); @@ -2233,7 +2489,9 @@ export class WebServer extends EventEmitter { // Enable respawn on an EXISTING interactive session this.app.post('/api/sessions/:id/respawn/enable', async (req) => { const { id } = req.params as { id: string }; - const reResult = req.body ? RespawnEnableSchema.safeParse(req.body) : { success: true as const, data: {} }; + const reResult = req.body + ? RespawnEnableSchema.safeParse(req.body) + : { success: true as const, data: {} }; if (!reResult.success) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); } @@ -2246,12 +2504,18 @@ export class WebServer extends EventEmitter { // Respawn is not supported for opencode sessions if (session.mode === 'opencode') { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Respawn is not supported for opencode sessions'); + return createErrorResponse( + ApiErrorCode.INVALID_INPUT, + 'Respawn is not supported for opencode sessions' + ); } // Check if session is running (has a PID) if (!session.pid) { - return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Session is not running. Start it first.'); + return createErrorResponse( + ApiErrorCode.OPERATION_FAILED, + 'Session is not running. Start it first.' + ); } // Stop existing controller if any @@ -2407,7 +2671,10 @@ export class WebServer extends EventEmitter { this.app.post('/api/run', async (req) => { // Prevent unbounded session creation if (this.sessions.size >= MAX_CONCURRENT_SESSIONS) { - return createErrorResponse(ApiErrorCode.SESSION_BUSY, `Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached`); + return createErrorResponse( + ApiErrorCode.SESSION_BUSY, + `Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached` + ); } const qrResult = QuickRunSchema.safeParse(req.body); @@ -2438,7 +2705,12 @@ export class WebServer extends EventEmitter { this.store.incrementSessionsCreated(); this.persistSessionState(session); await this.setupSessionListeners(session); - getLifecycleLog().log({ event: 'created', sessionId: session.id, name: session.name, reason: 'run_prompt' }); + getLifecycleLog().log({ + event: 'created', + sessionId: session.id, + name: session.name, + reason: 'run_prompt', + }); this.broadcast('session:created', this.getSessionStateWithRespawn(session)); @@ -2459,28 +2731,38 @@ export class WebServer extends EventEmitter { return Array.from(this.scheduledRuns.values()); }); - this.app.post('/api/scheduled', async (req): Promise<{ success: boolean; run: ScheduledRun } | ApiResponse> => { - const srResult = ScheduledRunSchema.safeParse(req.body); - if (!srResult.success) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); - } - const { prompt, workingDir, durationMinutes } = srResult.data; - - // Validate workingDir exists and is a directory - if (workingDir) { - try { - const stat = statSync(workingDir); - if (!stat.isDirectory()) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'workingDir is not a directory'); - } - } catch { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'workingDir does not exist'); + this.app.post( + '/api/scheduled', + async (req): Promise<{ success: boolean; run: ScheduledRun } | ApiResponse> => { + const srResult = ScheduledRunSchema.safeParse(req.body); + if (!srResult.success) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); } - } + const { prompt, workingDir, durationMinutes } = srResult.data; - const run = await this.startScheduledRun(prompt, workingDir || process.cwd(), durationMinutes ?? 60); - return { success: true, run }; - }); + // Validate workingDir exists and is a directory + if (workingDir) { + try { + const stat = statSync(workingDir); + if (!stat.isDirectory()) { + return createErrorResponse( + ApiErrorCode.INVALID_INPUT, + 'workingDir is not a directory' + ); + } + } catch { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'workingDir does not exist'); + } + } + + const run = await this.startScheduledRun( + prompt, + workingDir || process.cwd(), + durationMinutes ?? 60 + ); + return { success: true, run }; + } + ); this.app.delete('/api/scheduled/:id', async (req) => { const { id } = req.params as { id: string }; @@ -2530,10 +2812,12 @@ export class WebServer extends EventEmitter { // Get linked cases const linkedCasesFile = join(homedir(), '.codeman', 'linked-cases.json'); try { - const linkedCases: Record = JSON.parse(await fs.readFile(linkedCasesFile, 'utf-8')); + const linkedCases: Record = JSON.parse( + await fs.readFile(linkedCasesFile, 'utf-8') + ); for (const [name, path] of Object.entries(linkedCases)) { // Only add if not already in cases (avoid duplicates) and path exists - if (!cases.some(c => c.name === name) && existsSync(path)) { + if (!cases.some((c) => c.name === name) && existsSync(path)) { cases.push({ name, path, @@ -2550,101 +2834,116 @@ export class WebServer extends EventEmitter { return cases; }); - this.app.post('/api/cases', async (req): Promise> => { - const result = CreateCaseSchema.safeParse(req.body); - if (!result.success) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed'); + this.app.post( + '/api/cases', + async (req): Promise> => { + 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 - use relative path check + const resolvedPath = resolve(casePath); + const resolvedBase = resolve(casesDir); + const relPath = relative(resolvedBase, resolvedPath); + if (relPath.startsWith('..') || isAbsolute(relPath)) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path'); + } + + if (existsSync(casePath)) { + return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Case already exists'); + } + + try { + mkdirSync(casePath, { recursive: true }); + mkdirSync(join(casePath, 'src'), { recursive: true }); + + // Read settings to get custom template path + const templatePath = await this.getDefaultClaudeMdPath(); + const claudeMd = generateClaudeMd(name, description || '', templatePath); + writeFileSync(join(casePath, 'CLAUDE.md'), claudeMd); + + // Write .claude/settings.local.json with hooks for desktop notifications + await writeHooksConfig(casePath); + + this.broadcast('case:created', { name, path: casePath }); + + return { success: true, data: { case: { name, path: casePath } } }; + } catch (err) { + return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); + } } - const { name, description } = result.data; - - const casePath = join(casesDir, name); - - // Security: Path traversal protection - use relative path check - const resolvedPath = resolve(casePath); - const resolvedBase = resolve(casesDir); - const relPath = relative(resolvedBase, resolvedPath); - if (relPath.startsWith('..') || isAbsolute(relPath)) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid case path'); - } - - if (existsSync(casePath)) { - return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'Case already exists'); - } - - try { - mkdirSync(casePath, { recursive: true }); - mkdirSync(join(casePath, 'src'), { recursive: true }); - - // Read settings to get custom template path - const templatePath = await this.getDefaultClaudeMdPath(); - const claudeMd = generateClaudeMd(name, description || '', templatePath); - writeFileSync(join(casePath, 'CLAUDE.md'), claudeMd); - - // Write .claude/settings.local.json with hooks for desktop notifications - await writeHooksConfig(casePath); - - this.broadcast('case:created', { name, path: casePath }); - - return { success: true, data: { case: { name, path: casePath } } }; - } catch (err) { - return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); - } - }); + ); // Link an existing folder as a case - this.app.post('/api/cases/link', async (req): Promise> => { - const lcResult = LinkCaseSchema.safeParse(req.body); - if (!lcResult.success) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); - } - const { name, path: folderPath } = lcResult.data; + this.app.post( + '/api/cases/link', + async (req): Promise> => { + const lcResult = LinkCaseSchema.safeParse(req.body); + if (!lcResult.success) { + return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); + } + const { name, path: folderPath } = lcResult.data; - // Expand ~ to home directory - const expandedPath = folderPath.startsWith('~') - ? join(homedir(), folderPath.slice(1)) - : folderPath; + // Expand ~ to home directory + const expandedPath = folderPath.startsWith('~') + ? join(homedir(), folderPath.slice(1)) + : folderPath; - // Validate the folder exists - if (!existsSync(expandedPath)) { - return createErrorResponse(ApiErrorCode.NOT_FOUND, `Folder not found: ${expandedPath}`); - } + // Validate the folder exists + if (!existsSync(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 createErrorResponse(ApiErrorCode.ALREADY_EXISTS, 'A case with this name already exists in codeman-cases.'); - } + // Check if case name already exists in casesDir + const casePath = join(casesDir, name); + if (existsSync(casePath)) { + return createErrorResponse( + ApiErrorCode.ALREADY_EXISTS, + 'A case with this name already exists in codeman-cases.' + ); + } - // Load existing linked cases - const linkedCasesFile = join(homedir(), '.codeman', 'linked-cases.json'); - let linkedCases: Record = {}; - try { - linkedCases = JSON.parse(await fs.readFile(linkedCasesFile, 'utf-8')); - } catch (err) { - if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { - console.warn('[Server] Failed to read linked cases:', err); + // Load existing linked cases + const linkedCasesFile = join(homedir(), '.codeman', 'linked-cases.json'); + let linkedCases: Record = {}; + try { + linkedCases = JSON.parse(await fs.readFile(linkedCasesFile, 'utf-8')); + } catch (err) { + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') { + console.warn('[Server] Failed to read linked cases:', err); + } + } + + // Check if name is already linked + if (linkedCases[name]) { + return createErrorResponse( + ApiErrorCode.ALREADY_EXISTS, + `Case "${name}" is already linked to ${linkedCases[name]}` + ); + } + + // Save the linked case + linkedCases[name] = expandedPath; + try { + const codemanDir = join(homedir(), '.codeman'); + if (!existsSync(codemanDir)) { + mkdirSync(codemanDir, { recursive: true }); + } + await fs.writeFile(linkedCasesFile, JSON.stringify(linkedCases, null, 2)); + this.broadcast('case:linked', { name, path: expandedPath }); + return { success: true, data: { case: { name, path: expandedPath } } }; + } catch (err) { + return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); } } - - // Check if name is already linked - if (linkedCases[name]) { - return createErrorResponse(ApiErrorCode.ALREADY_EXISTS, `Case "${name}" is already linked to ${linkedCases[name]}`); - } - - // Save the linked case - linkedCases[name] = expandedPath; - try { - const codemanDir = join(homedir(), '.codeman'); - if (!existsSync(codemanDir)) { - mkdirSync(codemanDir, { recursive: true }); - } - await fs.writeFile(linkedCasesFile, JSON.stringify(linkedCases, null, 2)); - this.broadcast('case:linked', { name, path: expandedPath }); - return { success: true, data: { case: { name, path: expandedPath } } }; - } catch (err) { - return createErrorResponse(ApiErrorCode.OPERATION_FAILED, getErrorMessage(err)); - } - }); + ); this.app.get('/api/cases/:name', async (req) => { const { name } = req.params as { name: string }; @@ -2652,7 +2951,9 @@ export class WebServer extends EventEmitter { // First check linked cases const linkedCasesFile = join(homedir(), '.codeman', 'linked-cases.json'); try { - const linkedCases: Record = JSON.parse(await fs.readFile(linkedCasesFile, 'utf-8')); + const linkedCases: Record = JSON.parse( + await fs.readFile(linkedCasesFile, 'utf-8') + ); if (linkedCases[name]) { const linkedPath = linkedCases[name]; return { @@ -2689,7 +2990,9 @@ export class WebServer extends EventEmitter { const linkedCasesFile = join(homedir(), '.codeman', 'linked-cases.json'); try { - const linkedCases: Record = JSON.parse(await fs.readFile(linkedCasesFile, 'utf-8')); + const linkedCases: Record = JSON.parse( + await fs.readFile(linkedCasesFile, 'utf-8') + ); if (linkedCases[name]) { casePath = linkedCases[name]; } @@ -2711,7 +3014,11 @@ export class WebServer extends EventEmitter { const content = await fs.readFile(fixPlanPath, 'utf-8'); // Parse todos from the content (similar to ralph-tracker's importFixPlanMarkdown) - const todos: Array<{ content: string; status: 'pending' | 'in_progress' | 'completed'; priority: string | null }> = []; + const todos: Array<{ + content: string; + status: 'pending' | 'in_progress' | 'completed'; + priority: string | null; + }> = []; const todoPattern = /^-\s*\[([ xX\-])\]\s*(.+)$/; const p0HeaderPattern = /^##\s*(High Priority|Critical|P0|Critical Path)/i; const p1HeaderPattern = /^##\s*(Standard|P1|Medium Priority)/i; @@ -2766,7 +3073,9 @@ export class WebServer extends EventEmitter { } // Calculate stats in a single pass for better performance - let pending = 0, inProgress = 0, completed = 0; + let pending = 0, + inProgress = 0, + completed = 0; for (const t of todos) { if (t.status === 'pending') pending++; else if (t.status === 'in_progress') inProgress++; @@ -2782,7 +3091,10 @@ export class WebServer extends EventEmitter { stats, }; } catch (err) { - return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read @fix_plan.md: ${err}`); + return createErrorResponse( + ApiErrorCode.OPERATION_FAILED, + `Failed to read @fix_plan.md: ${err}` + ); } }); @@ -2790,12 +3102,18 @@ export class WebServer extends EventEmitter { this.app.post('/api/quick-start', async (req): Promise => { // Prevent unbounded session creation if (this.sessions.size >= MAX_CONCURRENT_SESSIONS) { - return createErrorResponse(ApiErrorCode.SESSION_BUSY, `Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached.`); + return createErrorResponse( + ApiErrorCode.SESSION_BUSY, + `Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached.` + ); } const result = QuickStartSchema.safeParse(req.body); if (!result.success) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed'); + return createErrorResponse( + ApiErrorCode.INVALID_INPUT, + result.error.issues[0]?.message ?? 'Validation failed' + ); } const { caseName = 'testcase', mode = 'claude', openCodeConfig } = result.data; @@ -2803,7 +3121,10 @@ export class WebServer extends EventEmitter { if (mode === 'opencode') { const { isOpenCodeAvailable } = await import('../utils/opencode-cli-resolver.js'); if (!isOpenCodeAvailable()) { - return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash'); + return createErrorResponse( + ApiErrorCode.OPERATION_FAILED, + 'OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash' + ); } } @@ -2836,7 +3157,10 @@ export class WebServer extends EventEmitter { this.broadcast('case:created', { name: caseName, path: casePath }); } catch (err) { - return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to create case: ${getErrorMessage(err)}`); + return createErrorResponse( + ApiErrorCode.OPERATION_FAILED, + `Failed to create case: ${getErrorMessage(err)}` + ); } } @@ -2844,9 +3168,12 @@ export class WebServer extends EventEmitter { // Apply global Nice priority config and model config from settings const niceConfig = await this.getGlobalNiceConfig(); const qsModelConfig = await this.getModelConfig(); - const qsModel = mode === 'opencode' - ? openCodeConfig?.model - : (mode !== 'shell' ? qsModelConfig?.defaultModel : undefined); + const qsModel = + mode === 'opencode' + ? openCodeConfig?.model + : mode !== 'shell' + ? qsModelConfig?.defaultModel + : undefined; const qsClaudeModeConfig = await this.getClaudeModeConfig(); const session = new Session({ workingDir: casePath, @@ -2874,19 +3201,34 @@ export class WebServer extends EventEmitter { this.store.incrementSessionsCreated(); this.persistSessionState(session); await this.setupSessionListeners(session); - getLifecycleLog().log({ event: 'created', sessionId: session.id, name: session.name, reason: 'quick_start' }); + getLifecycleLog().log({ + event: 'created', + sessionId: session.id, + name: session.name, + reason: 'quick_start', + }); this.broadcast('session:created', this.getSessionStateWithRespawn(session)); // Start in the appropriate mode try { if (mode === 'shell') { await session.startShell(); - getLifecycleLog().log({ event: 'started', sessionId: session.id, name: session.name, mode: 'shell' }); + getLifecycleLog().log({ + event: 'started', + sessionId: session.id, + name: session.name, + mode: 'shell', + }); this.broadcast('session:interactive', { id: session.id, mode: 'shell' }); } else { // Both 'claude' and 'opencode' modes use startInteractive() await session.startInteractive(); - getLifecycleLog().log({ event: 'started', sessionId: session.id, name: session.name, mode }); + getLifecycleLog().log({ + event: 'started', + sessionId: session.id, + name: session.name, + mode, + }); this.broadcast('session:interactive', { id: session.id, mode }); } this.broadcast('session:updated', { session: this.getSessionStateWithRespawn(session) }); @@ -2933,14 +3275,27 @@ export class WebServer extends EventEmitter { this.app.post('/api/ralph-loop/start', async (req): Promise => { // Prevent unbounded session creation if (this.sessions.size >= MAX_CONCURRENT_SESSIONS) { - return createErrorResponse(ApiErrorCode.SESSION_BUSY, `Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached.`); + return createErrorResponse( + ApiErrorCode.SESSION_BUSY, + `Maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached.` + ); } const rlResult = RalphLoopStartSchema.safeParse(req.body); if (!rlResult.success) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, rlResult.error.issues[0]?.message ?? 'Validation failed'); + return createErrorResponse( + ApiErrorCode.INVALID_INPUT, + rlResult.error.issues[0]?.message ?? 'Validation failed' + ); } - const { caseName, taskDescription, completionPhrase, maxIterations, enableRespawn, planItems } = rlResult.data; + const { + caseName, + taskDescription, + completionPhrase, + maxIterations, + enableRespawn, + planItems, + } = rlResult.data; const casePath = join(casesDir, caseName); @@ -2963,7 +3318,10 @@ export class WebServer extends EventEmitter { await writeHooksConfig(casePath); this.broadcast('case:created', { name: caseName, path: casePath }); } catch (err) { - return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to create case: ${getErrorMessage(err)}`); + return createErrorResponse( + ApiErrorCode.OPERATION_FAILED, + `Failed to create case: ${getErrorMessage(err)}` + ); } } @@ -2991,19 +3349,43 @@ export class WebServer extends EventEmitter { session.ralphTracker.startLoop(completionPhrase, maxIterations ?? undefined); // Build fix_plan markdown from plan items if provided - const enabledItems = planItems?.filter(i => i.enabled) ?? []; + const enabledItems = planItems?.filter((i) => i.enabled) ?? []; let planContent = ''; if (enabledItems.length > 0) { - const p0 = enabledItems.filter(i => i.priority === 'P0'); - const p1 = enabledItems.filter(i => i.priority === 'P1'); - const p2 = enabledItems.filter(i => i.priority === 'P2'); - const noPri = enabledItems.filter(i => !i.priority); + const p0 = enabledItems.filter((i) => i.priority === 'P0'); + const p1 = enabledItems.filter((i) => i.priority === 'P1'); + const p2 = enabledItems.filter((i) => i.priority === 'P2'); + const noPri = enabledItems.filter((i) => !i.priority); planContent = '# Implementation Plan\n\n'; planContent += `Generated: ${new Date().toISOString().slice(0, 10)}\n\n`; - if (p0.length > 0) { planContent += '## Critical Path (P0)\n\n'; p0.forEach(i => { planContent += `- [ ] ${i.content}\n`; }); planContent += '\n'; } - if (p1.length > 0) { planContent += '## Standard (P1)\n\n'; p1.forEach(i => { planContent += `- [ ] ${i.content}\n`; }); planContent += '\n'; } - if (p2.length > 0) { planContent += '## Nice-to-Have (P2)\n\n'; p2.forEach(i => { planContent += `- [ ] ${i.content}\n`; }); planContent += '\n'; } - if (noPri.length > 0) { planContent += '## Tasks\n\n'; noPri.forEach(i => { planContent += `- [ ] ${i.content}\n`; }); planContent += '\n'; } + if (p0.length > 0) { + planContent += '## Critical Path (P0)\n\n'; + p0.forEach((i) => { + planContent += `- [ ] ${i.content}\n`; + }); + planContent += '\n'; + } + if (p1.length > 0) { + planContent += '## Standard (P1)\n\n'; + p1.forEach((i) => { + planContent += `- [ ] ${i.content}\n`; + }); + planContent += '\n'; + } + if (p2.length > 0) { + planContent += '## Nice-to-Have (P2)\n\n'; + p2.forEach((i) => { + planContent += `- [ ] ${i.content}\n`; + }); + planContent += '\n'; + } + if (noPri.length > 0) { + planContent += '## Tasks\n\n'; + noPri.forEach((i) => { + planContent += `- [ ] ${i.content}\n`; + }); + planContent += '\n'; + } // Import into tracker and write to disk session.ralphTracker.importFixPlanMarkdown(planContent); @@ -3016,13 +3398,15 @@ export class WebServer extends EventEmitter { let fullPrompt = taskDescription + '\n\n---\n\n'; if (hasPlan) { fullPrompt += '## Task Plan\n\n'; - fullPrompt += 'A task plan has been written to `@fix_plan.md`. Use this to track progress:\n'; + fullPrompt += + 'A task plan has been written to `@fix_plan.md`. Use this to track progress:\n'; fullPrompt += '- Reference the plan at the start of each iteration\n'; fullPrompt += '- Update task checkboxes as you complete items\n'; fullPrompt += '- Work through items in priority order (P0 > P1 > P2)\n\n'; } fullPrompt += '## Iteration Protocol\n\n'; - fullPrompt += 'This is an autonomous loop. Files from previous iterations persist. On each iteration:\n'; + fullPrompt += + 'This is an autonomous loop. Files from previous iterations persist. On each iteration:\n'; fullPrompt += '1. Check what work has already been done\n'; fullPrompt += '2. Make incremental progress toward completion\n'; fullPrompt += '3. Commit meaningful changes with descriptive messages\n\n'; @@ -3038,10 +3422,11 @@ export class WebServer extends EventEmitter { fullPrompt += '- Changes are committed\n\n'; fullPrompt += '## If Stuck\n\n'; fullPrompt += 'If you encounter the same error for 3+ iterations:\n'; - fullPrompt += '1. Document what you\'ve tried\n'; + fullPrompt += "1. Document what you've tried\n"; fullPrompt += '2. Identify the specific blocker\n'; fullPrompt += '3. Try an alternative approach\n'; - fullPrompt += '4. If truly blocked, output `BLOCKED` with an explanation\n'; + fullPrompt += + '4. If truly blocked, output `BLOCKED` with an explanation\n'; // Write prompt to file const promptPath = join(casePath, '@ralph_prompt.md'); @@ -3052,13 +3437,23 @@ export class WebServer extends EventEmitter { this.store.incrementSessionsCreated(); this.persistSessionState(session); await this.setupSessionListeners(session); - getLifecycleLog().log({ event: 'created', sessionId: session.id, name: session.name, reason: 'ralph_loop_start' }); + getLifecycleLog().log({ + event: 'created', + sessionId: session.id, + name: session.name, + reason: 'ralph_loop_start', + }); this.broadcast('session:created', this.getSessionStateWithRespawn(session)); // Start interactive mode try { await session.startInteractive(); - getLifecycleLog().log({ event: 'started', sessionId: session.id, name: session.name, mode: 'claude' }); + getLifecycleLog().log({ + event: 'started', + sessionId: session.id, + name: session.name, + mode: 'claude', + }); this.broadcast('session:interactive', { id: session.id, mode: 'claude' }); this.broadcast('session:updated', { session: this.getSessionStateWithRespawn(session) }); } catch (err) { @@ -3068,7 +3463,8 @@ export class WebServer extends EventEmitter { // Enable respawn if requested if (enableRespawn) { - const ralphUpdatePrompt = 'Before /clear: Update CLAUDE.md with discoveries and notes, mark completed tasks in @fix_plan.md, write a brief progress summary to a file so the next iteration can continue seamlessly.'; + const ralphUpdatePrompt = + 'Before /clear: Update CLAUDE.md with discoveries and notes, mark completed tasks in @fix_plan.md, write a brief progress summary to a file so the next iteration can continue seamlessly.'; const ralphKickstartPrompt = `You are in a Ralph Wiggum loop. Read @fix_plan.md for task status, continue on the next uncompleted task, output ${completionPhrase} when ALL tasks are complete.`; const controller = new RespawnController(session, { updatePrompt: ralphUpdatePrompt, @@ -3081,19 +3477,28 @@ export class WebServer extends EventEmitter { controller.start(); this.saveRespawnConfig(session.id, controller.getConfig()); this.persistSessionState(session); - this.broadcast('respawn:started', { sessionId: session.id, status: controller.getStatus() }); + this.broadcast('respawn:started', { + sessionId: session.id, + status: controller.getStatus(), + }); } // Save lastUsedCase try { const settingsFilePath = join(homedir(), '.codeman', 'settings.json'); let settings: Record = {}; - try { settings = JSON.parse(await fs.readFile(settingsFilePath, 'utf-8')); } catch { /* ignore */ } + try { + settings = JSON.parse(await fs.readFile(settingsFilePath, 'utf-8')); + } catch { + /* ignore */ + } settings.lastUsedCase = caseName; const dir = dirname(settingsFilePath); if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); fs.writeFile(settingsFilePath, JSON.stringify(settings, null, 2)).catch(() => {}); - } catch { /* non-critical */ } + } catch { + /* non-critical */ + } const sessionId = session.id; @@ -3101,7 +3506,7 @@ export class WebServer extends EventEmitter { setImmediate(() => { const pollReady = async () => { for (let attempt = 0; attempt < 60; attempt++) { - await new Promise(r => setTimeout(r, 500)); + await new Promise((r) => setTimeout(r, 500)); const s = this.sessions.get(sessionId); if (!s) return; // session was deleted // Check terminal output for prompt indicator @@ -3111,16 +3516,21 @@ export class WebServer extends EventEmitter { } } // Small extra delay for CLI to settle - await new Promise(r => setTimeout(r, 2000)); + await new Promise((r) => setTimeout(r, 2000)); const s = this.sessions.get(sessionId); if (!s) return; try { - await s.writeViaMux('Read @ralph_prompt.md and follow the instructions. Start working immediately.\r'); + await s.writeViaMux( + 'Read @ralph_prompt.md and follow the instructions. Start working immediately.\r' + ); } catch (err) { - console.warn(`[RalphLoop] Failed to send prompt to session ${sessionId}:`, getErrorMessage(err)); + console.warn( + `[RalphLoop] Failed to send prompt to session ${sessionId}:`, + getErrorMessage(err) + ); } }; - pollReady().catch(err => console.error('[RalphLoop] pollReady error:', err)); + pollReady().catch((err) => console.error('[RalphLoop] pollReady error:', err)); }); return { @@ -3137,16 +3547,16 @@ export class WebServer extends EventEmitter { if (!gpResult.success) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'Invalid request body'); } - const { - taskDescription, - detailLevel = 'standard' - } = gpResult.data; + const { taskDescription, detailLevel = 'standard' } = gpResult.data; // Build sophisticated prompt based on Ralph Wiggum methodology const detailConfig = { brief: { style: 'high-level milestones', testDepth: 'basic' }, standard: { style: 'balanced implementation steps', testDepth: 'thorough' }, - detailed: { style: 'granular sub-tasks with full TDD coverage', testDepth: 'comprehensive' }, + detailed: { + style: 'granular sub-tasks with full TDD coverage', + testDepth: 'comprehensive', + }, }; const levelConfig = detailConfig[detailLevel] || detailConfig.standard; @@ -3238,7 +3648,8 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; // Use configured model for plan generation, falling back to opus const planModelConfig = await this.getModelConfig(); - const modelToUse = planModelConfig?.agentTypeOverrides?.implement || planModelConfig?.defaultModel || 'opus'; + const modelToUse = + planModelConfig?.agentTypeOverrides?.implement || planModelConfig?.defaultModel || 'opus'; try { const { result, cost } = await session.runPrompt(prompt, { model: modelToUse }); @@ -3246,14 +3657,20 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; // Parse JSON from result const jsonMatch = result.match(/\[[\s\S]*\]/); if (!jsonMatch) { - return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Failed to parse plan - no JSON array found'); + return createErrorResponse( + ApiErrorCode.OPERATION_FAILED, + 'Failed to parse plan - no JSON array found' + ); } let items: PlanItem[]; try { const parsed = JSON.parse(jsonMatch[0]); if (!Array.isArray(parsed)) { - return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Invalid response - expected array'); + return createErrorResponse( + ApiErrorCode.OPERATION_FAILED, + 'Invalid response - expected array' + ); } // Validate and normalize items with enhanced fields @@ -3270,7 +3687,8 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; }; } const obj = item as Record; - const content = typeof obj.content === 'string' ? obj.content.slice(0, 200) : `Step ${idx + 1}`; + const content = + typeof obj.content === 'string' ? obj.content.slice(0, 200) : `Step ${idx + 1}`; let priority: 'P0' | 'P1' | 'P2' | null = null; if (obj.priority === 'P0' || obj.priority === 'P1' || obj.priority === 'P2') { priority = obj.priority; @@ -3278,7 +3696,12 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; // Parse tddPhase let tddPhase: 'setup' | 'test' | 'impl' | 'verify' | undefined; - if (obj.tddPhase === 'setup' || obj.tddPhase === 'test' || obj.tddPhase === 'impl' || obj.tddPhase === 'verify') { + if ( + obj.tddPhase === 'setup' || + obj.tddPhase === 'test' || + obj.tddPhase === 'impl' || + obj.tddPhase === 'verify' + ) { tddPhase = obj.tddPhase; } @@ -3286,9 +3709,10 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; id: obj.id ? String(obj.id) : `task-${idx}`, content, priority, - verificationCriteria: typeof obj.verificationCriteria === 'string' - ? obj.verificationCriteria - : 'Task completed successfully', + verificationCriteria: + typeof obj.verificationCriteria === 'string' + ? obj.verificationCriteria + : 'Task completed successfully', tddPhase, dependencies: Array.isArray(obj.dependencies) ? obj.dependencies.map(String) : [], status: 'pending' as const, @@ -3297,18 +3721,22 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; }; }); // No artificial limit - let Claude generate what's needed - } catch (parseErr) { - return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Failed to parse plan JSON: ' + getErrorMessage(parseErr)); + return createErrorResponse( + ApiErrorCode.OPERATION_FAILED, + 'Failed to parse plan JSON: ' + getErrorMessage(parseErr) + ); } return { success: true, data: { items, costUsd: cost }, }; - } catch (err) { - return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Plan generation failed: ' + getErrorMessage(err)); + return createErrorResponse( + ApiErrorCode.OPERATION_FAILED, + 'Plan generation failed: ' + getErrorMessage(err) + ); } finally { // Clean up the temporary session try { @@ -3354,7 +3782,12 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; } const detailedModelConfig = await this.getModelConfig(); - const orchestrator = new PlanOrchestrator(this.mux, process.cwd(), outputDir, detailedModelConfig ?? undefined); + const orchestrator = new PlanOrchestrator( + this.mux, + process.cwd(), + outputDir, + detailedModelConfig ?? undefined + ); // Store orchestrator for potential cancellation via API (not on disconnect) // Plan generation continues even if browser disconnects - only explicit cancel stops it @@ -3400,7 +3833,10 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; this.broadcast('plan:completed', { orchestratorId, success: result.success }); if (!result.success) { - return createErrorResponse(ApiErrorCode.OPERATION_FAILED, result.error || 'Plan generation failed'); + return createErrorResponse( + ApiErrorCode.OPERATION_FAILED, + result.error || 'Plan generation failed' + ); } return { @@ -3416,8 +3852,15 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; } catch (err) { // Clean up on error too this.activePlanOrchestrators.delete(orchestratorId); - this.broadcast('plan:completed', { orchestratorId, success: false, error: getErrorMessage(err) }); - return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Detailed plan generation failed: ' + getErrorMessage(err)); + this.broadcast('plan:completed', { + orchestratorId, + success: false, + error: getErrorMessage(err), + }); + return createErrorResponse( + ApiErrorCode.OPERATION_FAILED, + 'Detailed plan generation failed: ' + getErrorMessage(err) + ); } }); @@ -3433,7 +3876,10 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; if (orchestratorId) { const orchestrator = this.activePlanOrchestrators.get(orchestratorId); if (!orchestrator) { - return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Plan generation not found or already completed'); + return createErrorResponse( + ApiErrorCode.NOT_FOUND, + 'Plan generation not found or already completed' + ); } console.log(`[API] Cancelling plan generation ${orchestratorId}`); await orchestrator.cancel(); @@ -3473,7 +3919,9 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; if (!existsSync(casePath)) { const linkedCasesFile = join(homedir(), '.codeman', 'linked-cases.json'); try { - const linkedCases: Record = JSON.parse(await fs.readFile(linkedCasesFile, 'utf-8')); + const linkedCases: Record = JSON.parse( + await fs.readFile(linkedCasesFile, 'utf-8') + ); if (linkedCases[caseName]) { casePath = linkedCases[caseName]; } @@ -3539,7 +3987,9 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; if (!existsSync(casePath)) { const linkedCasesFile = join(homedir(), '.codeman', 'linked-cases.json'); try { - const linkedCases: Record = JSON.parse(await fs.readFile(linkedCasesFile, 'utf-8')); + const linkedCases: Record = JSON.parse( + await fs.readFile(linkedCasesFile, 'utf-8') + ); if (linkedCases[caseName]) { casePath = linkedCases[caseName]; } @@ -3585,12 +4035,17 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; repaired = repaired.replace(/,(\s*[\]}])/g, '$1'); // Fix unescaped control characters within JSON strings repaired = repaired.replace(/"([^"\\]|\\.)*"/g, (match) => { - return match - .replace(/\n/g, '\\n') - .replace(/\r/g, '\\r') - .replace(/\t/g, '\\t') - // eslint-disable-next-line no-control-regex - .replace(/[\x00-\x1f]/g, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}`); + return ( + match + .replace(/\n/g, '\\n') + .replace(/\r/g, '\\r') + .replace(/\t/g, '\\t') + // eslint-disable-next-line no-control-regex + .replace( + /[\x00-\x1f]/g, + (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, '0')}` + ) + ); }); parsed = JSON.parse(repaired); } catch { @@ -3753,7 +4208,11 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; mkdirSync(dir, { recursive: true }); } let existing: Record = {}; - try { existing = JSON.parse(await fs.readFile(settingsPath, 'utf-8')); } catch { /* ignore */ } + try { + existing = JSON.parse(await fs.readFile(settingsPath, 'utf-8')); + } catch { + /* ignore */ + } const merged = { ...existing, ...settings }; await fs.writeFile(settingsPath, JSON.stringify(merged, null, 2)); @@ -3963,7 +4422,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; const sessions = await this.mux.getSessionsWithStats(); return { sessions, - muxAvailable: this.mux.isAvailable() + muxAvailable: this.mux.isAvailable(), }; }); @@ -4056,7 +4515,10 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; if (killed) { return { success: true, data: { agentId, status: 'killed' } }; } - return createErrorResponse(ApiErrorCode.OPERATION_FAILED, 'Subagent not found or already completed'); + return createErrorResponse( + ApiErrorCode.OPERATION_FAILED, + 'Subagent not found or already completed' + ); }); // Trigger cleanup of stale subagents @@ -4071,7 +4533,6 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; return { success: true, data: { cleared } }; }); - // ========== Agent Teams ========== // List all discovered teams @@ -4085,13 +4546,15 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; return { success: true, data: this.teamWatcher.getTeamTasks(name) }; }); - // ========== Hook Events ========== this.app.post('/api/hook-event', async (req) => { const result = HookEventSchema.safeParse(req.body); if (!result.success) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed'); + return createErrorResponse( + ApiErrorCode.INVALID_INPUT, + result.error.issues[0]?.message ?? 'Validation failed' + ); } const { event, sessionId, data } = result.data; if (!this.sessions.has(sessionId)) { @@ -4148,7 +4611,10 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; this.app.post('/api/push/subscribe', async (req) => { const result = PushSubscribeSchema.safeParse(req.body); if (!result.success) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed'); + return createErrorResponse( + ApiErrorCode.INVALID_INPUT, + result.error.issues[0]?.message ?? 'Validation failed' + ); } const { endpoint, keys, userAgent, pushPreferences } = result.data; const record = this.pushStore.addSubscription({ @@ -4166,7 +4632,10 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; const { id } = req.params as { id: string }; const result = PushPreferencesUpdateSchema.safeParse(req.body); if (!result.success) { - return createErrorResponse(ApiErrorCode.INVALID_INPUT, result.error.issues[0]?.message ?? 'Validation failed'); + return createErrorResponse( + ApiErrorCode.INVALID_INPUT, + result.error.issues[0]?.message ?? 'Validation failed' + ); } const updated = this.pushStore.updatePreferences(id, result.data.pushPreferences); if (!updated) { @@ -4237,7 +4706,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; pos = nextBoundary === -1 ? body.length : nextBoundary; } - const filePart = parts.find(p => p.headers.includes('name="file"')); + const filePart = parts.find((p) => p.headers.includes('name="file"')); if (!filePart || filePart.data.length === 0) { return createErrorResponse(ApiErrorCode.INVALID_INPUT, 'No file uploaded'); } @@ -4251,7 +4720,12 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; } const ctMatch = filePart.headers.match(/Content-Type:\s*image\/(png|jpeg|webp|gif)/i); if (ctMatch) { - const map: Record = { png: '.png', jpeg: '.jpg', webp: '.webp', gif: '.gif' }; + const map: Record = { + png: '.png', + jpeg: '.jpg', + webp: '.webp', + gif: '.gif', + }; ext = map[ctMatch[1].toLowerCase()] ?? ext; } @@ -4259,7 +4733,11 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; if (!existsSync(SCREENSHOTS_DIR)) { mkdirSync(SCREENSHOTS_DIR, { recursive: true }); } - const timestamp = new Date().toISOString().replace(/[:.]/g, '-').replace('T', '_').slice(0, 19); + const timestamp = new Date() + .toISOString() + .replace(/[:.]/g, '-') + .replace('T', '_') + .slice(0, 19); const filename = `screenshot_${timestamp}${ext}`; const filepath = join(SCREENSHOTS_DIR, filename); await fs.writeFile(filepath, filePart.data); @@ -4273,11 +4751,11 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; return { files: [] }; } const files = readdirSync(SCREENSHOTS_DIR) - .filter(f => /\.(png|jpg|jpeg|webp|gif)$/i.test(f)) + .filter((f) => /\.(png|jpg|jpeg|webp|gif)$/i.test(f)) .sort() .reverse() .slice(0, 50) - .map(name => ({ name, path: join(SCREENSHOTS_DIR, name) })); + .map((name) => ({ name, path: join(SCREENSHOTS_DIR, name) })); return { files }; }); @@ -4295,7 +4773,13 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; return createErrorResponse(ApiErrorCode.NOT_FOUND, 'Screenshot not found'); } const ext = name.match(/\.(png|jpg|jpeg|webp|gif)$/i)?.[1]?.toLowerCase() ?? 'png'; - const mimeMap: Record = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', webp: 'image/webp', gif: 'image/gif' }; + const mimeMap: Record = { + png: 'image/png', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + webp: 'image/webp', + gif: 'image/gif', + }; reply.type(mimeMap[ext] ?? 'image/png'); return fs.readFile(filepath); }); @@ -4333,7 +4817,12 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; }); watcher.on('transcript:tool_end', (toolName: string, isError: boolean) => { - this.broadcast('transcript:tool_end', { sessionId, toolName, isError, timestamp: Date.now() }); + this.broadcast('transcript:tool_end', { + sessionId, + toolName, + isError, + timestamp: Date.now(), + }); }); watcher.on('transcript:error', (error: Error) => { @@ -4353,7 +4842,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; private stopTranscriptWatcher(sessionId: string): void { const watcher = this.transcriptWatchers.get(sessionId); if (watcher) { - watcher.removeAllListeners(); // Prevent memory leaks from attached listeners + watcher.removeAllListeners(); // Prevent memory leaks from attached listeners watcher.stop(); this.transcriptWatchers.delete(sessionId); } @@ -4363,13 +4852,16 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; private persistSessionState(session: Session): void { const existing = this.persistDebounceTimers.get(session.id); if (existing) clearTimeout(existing); - this.persistDebounceTimers.set(session.id, setTimeout(() => { - this.persistDebounceTimers.delete(session.id); - // Session may have been removed during debounce - if (this.sessions.has(session.id)) { - this._persistSessionStateNow(session); - } - }, 100)); + this.persistDebounceTimers.set( + session.id, + setTimeout(() => { + this.persistDebounceTimers.delete(session.id); + // Session may have been removed during debounce + if (this.sessions.has(session.id)) { + this._persistSessionStateNow(session); + } + }, 100) + ); } /** Persists full session state including respawn config to state.json */ @@ -4401,7 +4893,11 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; } // Helper to save respawn config to mux session for persistence - private saveRespawnConfig(sessionId: string, config: RespawnConfig, durationMinutes?: number): void { + private saveRespawnConfig( + sessionId: string, + config: RespawnConfig, + durationMinutes?: number + ): void { const persistedConfig: PersistedRespawnConfig = { enabled: config.enabled, idleTimeoutMs: config.idleTimeoutMs, @@ -4430,7 +4926,10 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; } // Get system CPU and memory usage - private getSystemStats(): { cpu: number; memory: { usedMB: number; totalMB: number; percent: number } } { + private getSystemStats(): { + cpu: number; + memory: { usedMB: number; totalMB: number; percent: number }; + } { try { const totalMem = totalmem(); @@ -4443,7 +4942,10 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; const pageSize = parseInt(vmstat.match(/page size of (\d+)/)?.[1] || '4096', 10); const wired = parseInt(vmstat.match(/Pages wired down:\s+(\d+)/)?.[1] || '0', 10); const active = parseInt(vmstat.match(/Pages active:\s+(\d+)/)?.[1] || '0', 10); - const compressed = parseInt(vmstat.match(/Pages occupied by compressor:\s+(\d+)/)?.[1] || '0', 10); + const compressed = parseInt( + vmstat.match(/Pages occupied by compressor:\s+(\d+)/)?.[1] || '0', + 10 + ); usedMem = (wired + active + compressed) * pageSize; } catch { usedMem = totalMem - freemem(); @@ -4462,13 +4964,13 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; memory: { usedMB: Math.round(usedMem / (1024 * 1024)), totalMB: Math.round(totalMem / (1024 * 1024)), - percent: Math.round((usedMem / totalMem) * 100) - } + percent: Math.round((usedMem / totalMem) * 100), + }, }; } catch { return { cpu: 0, - memory: { usedMB: 0, totalMB: 0, percent: 0 } + memory: { usedMB: 0, totalMB: 0, percent: 0 }, }; } } @@ -4477,7 +4979,11 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; // Track sessions currently being cleaned up to prevent concurrent cleanup races private cleaningUp: Set = new Set(); - private async cleanupSession(sessionId: string, killMux: boolean = true, reason?: string): Promise { + private async cleanupSession( + sessionId: string, + killMux: boolean = true, + reason?: string + ): Promise { // Guard against concurrent cleanup of the same session if (this.cleaningUp.has(sessionId)) return; this.cleaningUp.add(sessionId); @@ -4489,7 +4995,11 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; } } - private async _doCleanupSession(sessionId: string, killMux: boolean, reason?: string): Promise { + private async _doCleanupSession( + sessionId: string, + killMux: boolean, + reason?: string + ): Promise { const session = this.sessions.get(sessionId); const lifecycleLog = getLifecycleLog(); lifecycleLog.log({ @@ -4580,19 +5090,31 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; // Broadcast Ralph cleared to update UI this.broadcast('session:ralphLoopUpdate', { sessionId, - state: { enabled: false, active: false, completionPhrase: null, startedAt: null, cycleCount: 0, maxIterations: null, lastActivity: Date.now(), elapsedHours: null } + state: { + enabled: false, + active: false, + completionPhrase: null, + startedAt: null, + cycleCount: 0, + maxIterations: null, + lastActivity: Date.now(), + elapsedHours: null, + }, }); this.broadcast('session:ralphTodoUpdate', { sessionId, todos: [], - stats: { total: 0, pending: 0, inProgress: 0, completed: 0 } + stats: { total: 0, pending: 0, inProgress: 0, completed: 0 }, }); // Stop session and remove listeners if (session) { // Accumulate tokens to global stats before removing session // This preserves lifetime usage even after sessions are deleted - if (killMux && (session.inputTokens > 0 || session.outputTokens > 0 || session.totalCost > 0)) { + if ( + killMux && + (session.inputTokens > 0 || session.outputTokens > 0 || session.totalCost > 0) + ) { this.store.addToGlobalStats(session.inputTokens, session.outputTokens, session.totalCost); // Record to daily stats (for what hasn't been recorded yet via periodic recording) const lastRecorded = this.lastRecordedTokens.get(sessionId) || { input: 0, output: 0 }; @@ -4602,7 +5124,9 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; this.store.recordDailyUsage(deltaInput, deltaOutput, sessionId); } this.lastRecordedTokens.delete(sessionId); - console.log(`[Server] Added to global stats: ${session.inputTokens + session.outputTokens} tokens, $${session.totalCost.toFixed(4)} from session ${sessionId}`); + console.log( + `[Server] Added to global stats: ${session.inputTokens + session.outputTokens} tokens, $${session.totalCost.toFixed(4)} from session ${sessionId}` + ); } // Explicitly remove stored listeners to break closure references (prevents memory leak) @@ -4665,7 +5189,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; } // Start watching for new images in this session's working directory (if enabled globally and per-session) - if (await this.isImageWatcherEnabled() && session.imageWatcherEnabled) { + if ((await this.isImageWatcherEnabled()) && session.imageWatcherEnabled) { imageWatcher.watchSession(session.id, session.workingDir); } @@ -4693,7 +5217,11 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; error: (error) => { this.broadcast('session:error', { id: session.id, error }); - this.sendPushNotifications('session:error', { sessionId: session.id, sessionName: session.name, error: String(error) }); + this.sendPushNotifications('session:error', { + sessionId: session.id, + sessionName: session.name, + error: String(error), + }); // Track in run summary const tracker = this.runSummaryTrackers.get(session.id); if (tracker) tracker.recordError('Session error', String(error)); @@ -4709,7 +5237,12 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; }, exit: (code) => { - getLifecycleLog().log({ event: 'exit', sessionId: session.id, name: session.name, exitCode: code }); + getLifecycleLog().log({ + event: 'exit', + sessionId: session.id, + name: session.name, + exitCode: code, + }); // Wrap in try/catch to ensure cleanup always happens try { this.broadcast('session:exit', { id: session.id, code }); @@ -4798,7 +5331,12 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; }, // Claude Code CLI info parsed from terminal (version, model, account) - cliInfoUpdated: (data: { version?: string; model?: string; accountType?: string; latestVersion?: string }) => { + cliInfoUpdated: (data: { + version?: string; + model?: string; + accountType?: string; + latestVersion?: string; + }) => { this.broadcast('session:cliInfo', { sessionId: session.id, ...data }); this.broadcastSessionStateDebounced(session.id); }, @@ -4818,7 +5356,11 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; ralphCompletionDetected: (phrase: string) => { this.broadcast('session:ralphCompletionDetected', { sessionId: session.id, phrase }); - this.sendPushNotifications('session:ralphCompletionDetected', { sessionId: session.id, sessionName: session.name, phrase }); + this.sendPushNotifications('session:ralphCompletionDetected', { + sessionId: session.id, + sessionName: session.name, + phrase, + }); // Track in run summary const tracker = this.runSummaryTrackers.get(session.id); if (tracker) tracker.recordRalphCompletion(phrase); @@ -4844,12 +5386,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; // Track state changes in run summary const tracker = this.runSummaryTrackers.get(session.id); if (tracker && status.state === 'OPEN') { - tracker.addEvent( - 'warning', - 'warning', - 'Circuit Breaker Opened', - status.reason - ); + tracker.addEvent('warning', 'warning', 'Circuit Breaker Opened', status.reason); } }, @@ -4937,7 +5474,11 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; controller.on('respawnBlocked', (data: { reason: string; details: string }) => { this.broadcast('respawn:blocked', { sessionId, reason: data.reason, details: data.details }); const sessionForPush = this.sessions.get(sessionId); - this.sendPushNotifications('respawn:blocked', { sessionId, sessionName: sessionForPush?.name ?? sessionId.slice(0, 8), reason: data.reason }); + this.sendPushNotifications('respawn:blocked', { + sessionId, + sessionName: sessionForPush?.name ?? sessionId.slice(0, 8), + reason: data.reason, + }); // Track in run summary (lazy lookup) const tracker = getTracker(); if (tracker) tracker.recordWarning(`Respawn blocked: ${data.reason}`, data.details); @@ -4963,12 +5504,20 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; this.broadcast('respawn:aiCheckStarted', { sessionId }); }); - controller.on('aiCheckCompleted', (result: { verdict: string; reasoning: string; durationMs: number }) => { - this.broadcast('respawn:aiCheckCompleted', { sessionId, verdict: result.verdict, reasoning: result.reasoning, durationMs: result.durationMs }); - // Track in run summary (lazy lookup) - const tracker = getTracker(); - if (tracker) tracker.recordAiCheckResult(result.verdict); - }); + controller.on( + 'aiCheckCompleted', + (result: { verdict: string; reasoning: string; durationMs: number }) => { + this.broadcast('respawn:aiCheckCompleted', { + sessionId, + verdict: result.verdict, + reasoning: result.reasoning, + durationMs: result.durationMs, + }); + // Track in run summary (lazy lookup) + const tracker = getTracker(); + if (tracker) tracker.recordAiCheckResult(result.verdict); + } + ); controller.on('aiCheckFailed', (error: string) => { this.broadcast('respawn:aiCheckFailed', { sessionId, error }); @@ -4985,9 +5534,17 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; this.broadcast('respawn:planCheckStarted', { sessionId }); }); - controller.on('planCheckCompleted', (result: { verdict: string; reasoning: string; durationMs: number }) => { - this.broadcast('respawn:planCheckCompleted', { sessionId, verdict: result.verdict, reasoning: result.reasoning, durationMs: result.durationMs }); - }); + controller.on( + 'planCheckCompleted', + (result: { verdict: string; reasoning: string; durationMs: number }) => { + this.broadcast('respawn:planCheckCompleted', { + sessionId, + verdict: result.verdict, + reasoning: result.reasoning, + durationMs: result.durationMs, + }); + } + ); controller.on('planCheckFailed', (error: string) => { this.broadcast('respawn:planCheckFailed', { sessionId, error }); @@ -5032,28 +5589,30 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; const now = Date.now(); const endAt = now + durationMinutes * 60 * 1000; - const timer = setTimeout(() => { - // Stop respawn when time is up - const controller = this.respawnControllers.get(sessionId); - if (controller) { - controller.stop(); - controller.removeAllListeners(); - this.respawnControllers.delete(sessionId); - this.broadcast('respawn:stopped', { sessionId, reason: 'duration_expired' }); - } - this.respawnTimers.delete(sessionId); - // Update persisted state (respawn no longer active) - const session = this.sessions.get(sessionId); - if (session) { - this.persistSessionState(session); - } - }, durationMinutes * 60 * 1000); + const timer = setTimeout( + () => { + // Stop respawn when time is up + const controller = this.respawnControllers.get(sessionId); + if (controller) { + controller.stop(); + controller.removeAllListeners(); + this.respawnControllers.delete(sessionId); + this.broadcast('respawn:stopped', { sessionId, reason: 'duration_expired' }); + } + this.respawnTimers.delete(sessionId); + // Update persisted state (respawn no longer active) + const session = this.sessions.get(sessionId); + if (session) { + this.persistSessionState(session); + } + }, + durationMinutes * 60 * 1000 + ); this.respawnTimers.set(sessionId, { timer, endAt, startedAt: now }); this.broadcast('respawn:timerStarted', { sessionId, durationMinutes, endAt, startedAt: now }); } - /** * Restore a RespawnController from persisted configuration. * Creates the controller, sets up listeners, but does NOT start it. @@ -5100,12 +5659,16 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; const delayMs = Math.max(0, WebServer.RESPAWN_RESTORE_GRACE_PERIOD_MS - timeSinceStart); if (delayMs > 0) { - console.log(`[Server] Restored respawn controller for session ${session.id} from ${source} (will start in ${Math.ceil(delayMs / 1000)}s)`); + 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`); + console.log( + `[Server] Skipping restored respawn start - session ${session.id} no longer exists` + ); return; } // Double-check controller still exists and is stopped @@ -5120,7 +5683,9 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; } else { // Grace period has passed, start immediately controller.start(); - console.log(`[Server] Restored respawn controller for session ${session.id} from ${source} (started immediately)`); + console.log( + `[Server] Restored respawn controller for session ${session.id} from ${source} (started immediately)` + ); } if (config.durationMinutes && config.durationMinutes > 0) { @@ -5184,19 +5749,35 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; const claudeMode = settings.claudeMode as string | undefined; const allowedTools = settings.allowedTools as string | undefined; // Only return valid modes - if (claudeMode === 'dangerously-skip-permissions' || claudeMode === 'normal' || claudeMode === 'allowedTools') { + if ( + claudeMode === 'dangerously-skip-permissions' || + claudeMode === 'normal' || + claudeMode === 'allowedTools' + ) { return { claudeMode, allowedTools }; } return {}; } // Helper to get model configuration from settings - private async getModelConfig(): Promise<{ defaultModel?: string; agentTypeOverrides?: Record } | null> { + private async getModelConfig(): Promise<{ + defaultModel?: string; + agentTypeOverrides?: Record; + } | null> { const settings = await this.readSettings(); - return (settings.modelConfig as { defaultModel?: string; agentTypeOverrides?: Record }) || null; + return ( + (settings.modelConfig as { + defaultModel?: string; + agentTypeOverrides?: Record; + }) || null + ); } - private async startScheduledRun(prompt: string, workingDir: string, durationMinutes: number): Promise { + private async startScheduledRun( + prompt: string, + workingDir: string, + durationMinutes: number + ): Promise { const id = uuidv4(); const now = Date.now(); @@ -5223,7 +5804,9 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; 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)}`); + failedRun.logs.push( + `[${new Date().toISOString()}] Error: ${err instanceof Error ? err.message : String(err)}` + ); this.broadcast('scheduled:stopped', { id, reason: 'error' }); } }); @@ -5244,7 +5827,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; // Check session limit before creating new session if (this.sessions.size >= MAX_CONCURRENT_SESSIONS) { addLog(`Waiting: maximum concurrent sessions (${MAX_CONCURRENT_SESSIONS}) reached`); - await new Promise(r => setTimeout(r, SESSION_LIMIT_WAIT_MS)); + await new Promise((r) => setTimeout(r, SESSION_LIMIT_WAIT_MS)); continue; } @@ -5269,7 +5852,9 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; run.completedTasks++; run.totalCost += result.cost; - addLog(`Task completed. Cost: $${result.cost.toFixed(4)}. Total tasks: ${run.completedTasks}`); + addLog( + `Task completed. Cost: $${result.cost.toFixed(4)}. Total tasks: ${run.completedTasks}` + ); this.broadcast('scheduled:updated', run); // Clean up the session after iteration to prevent memory leaks @@ -5277,7 +5862,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; run.sessionId = null; // Small pause between iterations - await new Promise(r => setTimeout(r, ITERATION_PAUSE_MS)); + await new Promise((r) => setTimeout(r, ITERATION_PAUSE_MS)); } catch (err) { addLog(`Error: ${getErrorMessage(err)}`); this.broadcast('scheduled:updated', run); @@ -5293,13 +5878,15 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; } // Continue despite errors - await new Promise(r => setTimeout(r, SESSION_LIMIT_WAIT_MS)); + await new Promise((r) => setTimeout(r, SESSION_LIMIT_WAIT_MS)); } } if (run.status === 'running') { run.status = 'completed'; - addLog(`Scheduled run completed. Total tasks: ${run.completedTasks}, Total cost: $${run.totalCost.toFixed(4)}`); + addLog( + `Scheduled run completed. Total tasks: ${run.completedTasks}, Total cost: $${run.totalCost.toFixed(4)}` + ); } this.broadcast('scheduled:completed', run); @@ -5342,12 +5929,15 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; */ private getLightSessionsState() { const now = Date.now(); - if (this.cachedSessionsList && (now - this.cachedSessionsList.timestamp) < SESSIONS_LIST_CACHE_TTL) { + if ( + this.cachedSessionsList && + now - this.cachedSessionsList.timestamp < SESSIONS_LIST_CACHE_TTL + ) { return this.cachedSessionsList.data; } // getSessionStateWithRespawn already uses toLightDetailedState() which // excludes terminalBuffer and textOutput — no extra stripping needed - const data = Array.from(this.sessions.values()).map(s => this.getSessionStateWithRespawn(s)); + const data = Array.from(this.sessions.values()).map((s) => this.getSessionStateWithRespawn(s)); this.cachedSessionsList = { data, timestamp: now }; return data; } @@ -5398,7 +5988,10 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; */ private getLightState() { const now = Date.now(); - if (this.cachedLightState && now - this.cachedLightState.timestamp < WebServer.LIGHT_STATE_CACHE_TTL_MS) { + if ( + this.cachedLightState && + now - this.cachedLightState.timestamp < WebServer.LIGHT_STATE_CACHE_TTL_MS + ) { return this.cachedLightState.data; } @@ -5407,7 +6000,10 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; respawnStatus[sessionId] = controller.getStatus(); } - const activeSessionTokens: Record = {}; + const activeSessionTokens: Record< + string, + { inputTokens?: number; outputTokens?: number; totalCost?: number } + > = {}; for (const [sessionId, session] of this.sessions) { activeSessionTokens[sessionId] = { inputTokens: session.inputTokens, @@ -5456,7 +6052,9 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; // Tell it to reload the active session's buffer to recover. try { reply.raw.write(`event: session:needsRefresh\ndata: {}\n\n`); - } catch { /* client gone */ } + } catch { + /* client gone */ + } }); } } catch { @@ -5542,10 +6140,13 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; // Each session flushes independently — prevents one busy session from // forcing all sessions to flush at its rate (thundering herd) if (!this.terminalBatchTimers.has(sessionId)) { - this.terminalBatchTimers.set(sessionId, setTimeout(() => { - this.terminalBatchTimers.delete(sessionId); - this.flushSessionTerminalBatch(sessionId); - }, sessionInterval)); + this.terminalBatchTimers.set( + sessionId, + setTimeout(() => { + this.terminalBatchTimers.delete(sessionId); + this.flushSessionTerminalBatch(sessionId); + }, sessionInterval) + ); } } @@ -5645,8 +6246,18 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; // ========== Web Push ========== /** Map SSE event names to push notification payloads */ - private static readonly PUSH_EVENT_MAP: Record }> = { - 'hook:permission_prompt': { title: 'Permission Required', urgency: 'critical', actions: [{ action: 'approve', title: 'Approve' }, { action: 'deny', title: 'Deny' }] }, + private static readonly PUSH_EVENT_MAP: Record< + string, + { title: string; urgency: string; actions?: Array<{ action: string; title: string }> } + > = { + 'hook:permission_prompt': { + title: 'Permission Required', + urgency: 'critical', + actions: [ + { action: 'approve', title: 'Approve' }, + { action: 'deny', title: 'Deny' }, + ], + }, 'hook:elicitation_dialog': { title: 'Question Asked', urgency: 'critical' }, 'hook:idle_prompt': { title: 'Waiting for Input', urgency: 'warning' }, 'hook:stop': { title: 'Response Complete', urgency: 'info' }, @@ -5794,7 +6405,9 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; // Security warning: server binds to 0.0.0.0 (all interfaces) — warn if no auth configured if (!process.env.CODEMAN_PASSWORD) { - console.warn('\n⚠ WARNING: No CODEMAN_PASSWORD set — server is accessible without authentication.'); + console.warn( + '\n⚠ WARNING: No CODEMAN_PASSWORD set — server is accessible without authentication.' + ); console.warn(' Anyone on your network can access and control Claude sessions.'); console.warn(' Set CODEMAN_PASSWORD environment variable to enable auth.\n'); } @@ -5813,14 +6426,19 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; }, SSE_HEALTH_CHECK_INTERVAL); // Start token recording timer (every 5 minutes for long-running sessions) - this.tokenRecordingTimer = setInterval(() => { - this.recordPeriodicTokenUsage(); - }, 5 * 60 * 1000); + this.tokenRecordingTimer = setInterval( + () => { + this.recordPeriodicTokenUsage(); + }, + 5 * 60 * 1000 + ); // Start subagent watcher for Claude Code background agent visibility (if enabled) if (await this.isSubagentTrackingEnabled()) { subagentWatcher.start(); - console.log('Subagent watcher started - monitoring ~/.claude/projects for background agent activity'); + console.log( + 'Subagent watcher started - monitoring ~/.claude/projects for background agent activity' + ); } else { console.log('Subagent watcher disabled by user settings'); } @@ -5842,7 +6460,6 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; // Start team watcher for agent team awareness (always on — lightweight polling) this.teamWatcher.start(); console.log('Team watcher started - monitoring ~/.claude/teams/ for agent team activity'); - } /** @@ -5908,7 +6525,9 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; } if (alive.length > 0 || discovered.length > 0) { - console.log(`[Server] Found ${alive.length + discovered.length} alive mux session(s) from previous run`); + console.log( + `[Server] Found ${alive.length + discovered.length} alive mux session(s) from previous run` + ); // For each alive mux session, create a Session object if it doesn't exist const muxSessions = this.mux.getSessions(); @@ -5924,13 +6543,13 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; // Create a session object for this mux session const recoveryClaudeMode = await this.getClaudeModeConfig(); const session = new Session({ - id: muxSession.sessionId, // Preserve the original session ID + id: muxSession.sessionId, // Preserve the original session ID workingDir: muxSession.workingDir, mode: muxSession.mode, name: sessionName, mux: this.mux, useMux: true, - muxSession: muxSession, // Pass the existing session so startInteractive() can attach to it + muxSession: muxSession, // Pass the existing session so startInteractive() can attach to it claudeMode: recoveryClaudeMode.claudeMode, allowedTools: recoveryClaudeMode.allowedTools, }); @@ -5941,7 +6560,10 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; } if (savedState) { // Auto-compact - if (savedState.autoCompactEnabled !== undefined || savedState.autoCompactThreshold !== undefined) { + if ( + savedState.autoCompactEnabled !== undefined || + savedState.autoCompactThreshold !== undefined + ) { session.setAutoCompact( savedState.autoCompactEnabled ?? false, savedState.autoCompactThreshold, @@ -5949,14 +6571,21 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; ); } // Auto-clear - if (savedState.autoClearEnabled !== undefined || savedState.autoClearThreshold !== undefined) { + if ( + savedState.autoClearEnabled !== undefined || + savedState.autoClearThreshold !== undefined + ) { session.setAutoClear( savedState.autoClearEnabled ?? false, savedState.autoClearThreshold ); } // Token tracking - if (savedState.inputTokens !== undefined || savedState.outputTokens !== undefined || savedState.totalCost !== undefined) { + if ( + savedState.inputTokens !== undefined || + savedState.outputTokens !== undefined || + savedState.totalCost !== undefined + ) { session.restoreTokens( savedState.inputTokens ?? 0, savedState.outputTokens ?? 0, @@ -5969,14 +6598,18 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; }); const totalTokens = (savedState.inputTokens ?? 0) + (savedState.outputTokens ?? 0); if (totalTokens > 0) { - console.log(`[Server] Restored tokens for session ${session.id}: ${totalTokens} tokens, $${(savedState.totalCost ?? 0).toFixed(4)}`); + console.log( + `[Server] Restored tokens for session ${session.id}: ${totalTokens} tokens, $${(savedState.totalCost ?? 0).toFixed(4)}` + ); } } // Ralph / Todo tracker (not supported for opencode sessions) if (session.mode !== 'opencode') { if (savedState.ralphAutoEnableDisabled) { session.ralphTracker.disableAutoEnable(); - console.log(`[Server] Restored Ralph auto-enable disabled for session ${session.id}`); + console.log( + `[Server] Restored Ralph auto-enable disabled for session ${session.id}` + ); } else if (savedState.ralphEnabled) { // If Ralph was enabled and not explicitly disabled, allow re-enabling on restart session.ralphTracker.enableAutoEnable(); @@ -5986,7 +6619,9 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; if (savedState.ralphCompletionPhrase) { session.ralphTracker.startLoop(savedState.ralphCompletionPhrase); } - console.log(`[Server] Restored Ralph tracker for session ${session.id} (phrase: ${savedState.ralphCompletionPhrase || 'none'})`); + console.log( + `[Server] Restored Ralph tracker for session ${session.id} (phrase: ${savedState.ralphCompletionPhrase || 'none'})` + ); } } // Nice priority config @@ -6001,41 +6636,71 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; session.flickerFilterEnabled = savedState.flickerFilterEnabled; } // Respawn controller (not supported for opencode sessions) - if (session.mode !== 'opencode' && savedState.respawnEnabled && savedState.respawnConfig) { + if ( + session.mode !== 'opencode' && + savedState.respawnEnabled && + savedState.respawnConfig + ) { try { this.restoreRespawnController(session, savedState.respawnConfig, 'state.json'); } catch (err) { - console.error(`[Server] Failed to restore respawn for session ${session.id}:`, err); + console.error( + `[Server] Failed to restore respawn for session ${session.id}:`, + err + ); } } } // Fallback: restore respawn from mux-sessions.json if state.json didn't have it (not supported for opencode) - if (session.mode !== 'opencode' && !this.respawnControllers.has(session.id) && muxSession.respawnConfig?.enabled) { + if ( + session.mode !== 'opencode' && + !this.respawnControllers.has(session.id) && + muxSession.respawnConfig?.enabled + ) { try { - this.restoreRespawnController(session, muxSession.respawnConfig, 'mux-sessions.json'); + this.restoreRespawnController( + session, + muxSession.respawnConfig, + 'mux-sessions.json' + ); } catch (err) { - console.error(`[Server] Failed to restore respawn from mux-sessions.json for session ${session.id}:`, err); + console.error( + `[Server] Failed to restore respawn from mux-sessions.json for session ${session.id}:`, + err + ); } } // Fallback: restore Ralph state from state-inner.json if not already set and not explicitly disabled // Ralph tracker is not supported for opencode sessions - if (session.mode !== 'opencode' && !session.ralphTracker.enabled && !session.ralphTracker.autoEnableDisabled) { + if ( + session.mode !== 'opencode' && + !session.ralphTracker.enabled && + !session.ralphTracker.autoEnableDisabled + ) { const ralphState = this.store.getRalphState(muxSession.sessionId); if (ralphState?.loop?.enabled) { session.ralphTracker.restoreState(ralphState.loop, ralphState.todos); - console.log(`[Server] Restored Ralph state from inner store for session ${session.id}`); + console.log( + `[Server] Restored Ralph state from inner store for session ${session.id}` + ); } } // Fallback: auto-detect completion phrase from CLAUDE.md (not supported for opencode) - if (session.mode !== 'opencode' && session.ralphTracker.enabled && !session.ralphTracker.loopState.completionPhrase) { + if ( + session.mode !== 'opencode' && + session.ralphTracker.enabled && + !session.ralphTracker.loopState.completionPhrase + ) { const claudeMdPath = join(session.workingDir, 'CLAUDE.md'); const completionPhrase = extractCompletionPhrase(claudeMdPath); if (completionPhrase) { session.ralphTracker.startLoop(completionPhrase); - console.log(`[Server] Auto-detected completion phrase for session ${session.id}: ${completionPhrase}`); + console.log( + `[Server] Auto-detected completion phrase for session ${session.id}: ${completionPhrase}` + ); } } @@ -6044,7 +6709,11 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; this.persistSessionState(session); // Mark it as restored (not started yet - user needs to attach) - getLifecycleLog().log({ event: 'recovered', sessionId: session.id, name: session.name }); + getLifecycleLog().log({ + event: 'recovered', + sessionId: session.id, + name: session.name, + }); console.log(`[Server] Restored session ${session.id} from mux ${muxSession.muxName}`); } } @@ -6157,7 +6826,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; // Stop all scheduled runs first (they have their own session cleanup) await Promise.allSettled( - Array.from(this.scheduledRuns.keys()).map(id => this.stopScheduledRun(id)) + Array.from(this.scheduledRuns.keys()).map((id) => this.stopScheduledRun(id)) ); // On server shutdown, DO NOT call cleanupSession — it tears down session state, @@ -6266,7 +6935,11 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; } } -export async function startWebServer(port: number = 3000, https: boolean = false, testMode: boolean = false): Promise { +export async function startWebServer( + port: number = 3000, + https: boolean = false, + testMode: boolean = false +): Promise { const server = new WebServer(port, https, testMode); await server.start(); return server;