From 406c18cefe4506539816e232b1152eb9e443e3ce Mon Sep 17 00:00:00 2001 From: Noah Waldner Date: Fri, 27 Feb 2026 11:46:40 +0100 Subject: [PATCH] fix layout --- .prettierrc | 2 +- src/ai-checker-base.ts | 50 +-- src/ai-idle-checker.ts | 13 +- src/ai-plan-checker.ts | 10 +- src/bash-tool-parser.ts | 18 +- src/cli.ts | 52 +-- src/file-stream-manager.ts | 17 +- src/hooks-config.ts | 5 +- src/image-watcher.ts | 24 +- src/index.ts | 4 +- src/plan-orchestrator.ts | 30 +- src/push-store.ts | 5 +- src/ralph-loop.ts | 6 +- src/ralph-tracker.ts | 109 ++--- src/respawn-controller.ts | 186 ++------ src/run-summary.ts | 20 +- src/session.ts | 63 +-- src/state-store.ts | 56 +-- src/subagent-watcher.ts | 89 +--- src/task-queue.ts | 10 +- src/task-tracker.ts | 9 +- src/team-watcher.ts | 11 +- src/templates/claude-md.ts | 13 +- src/tmux-manager.ts | 93 ++-- src/transcript-watcher.ts | 7 +- src/tunnel-manager.ts | 5 +- src/types.ts | 10 +- src/utils/cleanup-manager.ts | 5 +- src/utils/index.ts | 19 +- src/utils/regex-patterns.ts | 3 +- src/utils/token-validation.ts | 5 +- src/web/schemas.ts | 19 +- src/web/server.ts | 792 ++++++++++------------------------ 33 files changed, 452 insertions(+), 1308 deletions(-) diff --git a/.prettierrc b/.prettierrc index 9fede287..4ed049da 100644 --- a/.prettierrc +++ b/.prettierrc @@ -2,7 +2,7 @@ "singleQuote": true, "semi": true, "tabWidth": 2, - "printWidth": 100, + "printWidth": 120, "trailingComma": "es5", "endOfLine": "lf" } diff --git a/src/ai-checker-base.ts b/src/ai-checker-base.ts index a7030bea..f5d1ef4e 100644 --- a/src/ai-checker-base.ts +++ b/src/ai-checker-base.ts @@ -205,9 +205,7 @@ export abstract class AiCheckerBase< super(); this.sessionId = sessionId; // Filter out undefined values to prevent overwriting defaults - const filteredConfig = Object.fromEntries( - Object.entries(config).filter(([, v]) => v !== undefined) - ) as Partial; + const filteredConfig = Object.fromEntries(Object.entries(config).filter(([, v]) => v !== undefined)) as Partial; this.config = { ...defaultConfig, ...filteredConfig }; } @@ -283,15 +281,11 @@ 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'); } @@ -346,9 +340,7 @@ export abstract class AiCheckerBase< /** Update configuration at runtime */ updateConfig(config: Partial): void { // Filter out undefined values to prevent overwriting existing config - const filteredConfig = Object.fromEntries( - Object.entries(config).filter(([, v]) => v !== undefined) - ) as Partial; + const filteredConfig = Object.fromEntries(Object.entries(config).filter(([, v]) => v !== undefined)) as Partial; this.config = { ...this.config, ...filteredConfig }; if (config.enabled === false) { this.disable('Disabled by config'); @@ -374,9 +366,7 @@ 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; + stripped.length > this.config.maxContextChars ? stripped.slice(-this.config.maxContextChars) : stripped; // Build the prompt const prompt = this.buildPrompt(trimmed); @@ -385,10 +375,7 @@ 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 @@ -419,14 +406,10 @@ 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( @@ -468,9 +451,7 @@ 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); }); @@ -487,10 +468,7 @@ 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); @@ -560,9 +538,7 @@ 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 d3213a40..6f1d311b 100644 --- a/src/ai-idle-checker.ts +++ b/src/ai-idle-checker.ts @@ -123,12 +123,7 @@ Remember: When uncertain, answer WORKING.`; * Manages AI-powered idle detection by spawning a fresh Claude CLI session * to analyze terminal output and provide a definitive IDLE/WORKING verdict. */ -export class AiIdleChecker extends AiCheckerBase< - AiCheckVerdict, - AiIdleCheckConfig, - AiCheckResult, - AiCheckState -> { +export class AiIdleChecker extends AiCheckerBase { protected readonly muxNamePrefix = 'codeman-aicheck-'; protected readonly doneMarker = '__AICHECK_DONE__'; protected readonly tempFilePrefix = 'codeman-aicheck'; @@ -170,11 +165,7 @@ 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 93d32851..f73271b0 100644 --- a/src/ai-plan-checker.ts +++ b/src/ai-plan-checker.ts @@ -105,9 +105,7 @@ 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; @@ -134,11 +132,7 @@ 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 b6e3ba4b..eee92db0 100644 --- a/src/bash-tool-parser.ts +++ b/src/bash-tool-parser.ts @@ -85,15 +85,13 @@ const INVALID_PATH_PATTERN = /^[/~]-|\/dev\/null$/; * 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 ========== @@ -421,9 +419,7 @@ 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); + trimPoint > 0 ? this._lineBuffer.slice(trimPoint + 1) : this._lineBuffer.slice(-MAX_LINE_BUFFER_SIZE / 2); } // Process complete lines @@ -449,9 +445,7 @@ 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); + trimPoint > 0 ? this._lineBuffer.slice(trimPoint + 1) : this._lineBuffer.slice(-MAX_LINE_BUFFER_SIZE / 2); } const lines = this._lineBuffer.split('\n'); @@ -505,9 +499,7 @@ 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]); } diff --git a/src/cli.ts b/src/cli.ts index 3443c7e5..b4461043 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -21,10 +21,7 @@ const pkg = require('../package.json') as { version: string }; const program = new Command(); -program - .name('codeman') - .description('Claude Code session manager with autonomous Ralph Loop') - .version(pkg.version); +program.name('codeman').description('Claude Code session manager with autonomous Ralph Loop').version(pkg.version); // ============ Session Commands ============ @@ -95,9 +92,7 @@ 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}`); } } @@ -115,9 +110,7 @@ sessionCmd 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(''); @@ -198,9 +191,7 @@ 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(); @@ -278,10 +269,7 @@ taskCmd // ============ Ralph Loop Commands ============ -const ralphCmd = program - .command('ralph') - .alias('r') - .description('Control the Ralph autonomous loop'); +const ralphCmd = program.command('ralph').alias('r').description('Control the Ralph autonomous loop'); ralphCmd .command('start') @@ -303,9 +291,7 @@ 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) => { @@ -359,12 +345,7 @@ ralphCmd }); function printStats(stats: ReturnType['getStats']>) { - const statusColor = - stats.status === 'running' - ? chalk.green - : stats.status === 'paused' - ? chalk.yellow - : chalk.gray; + const statusColor = 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)}`); @@ -407,8 +388,7 @@ 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; @@ -431,8 +411,7 @@ program console.log(` Completed: ${taskCounts.completed}`); console.log(` Failed: ${taskCounts.failed}`); - const statusColor = - loopStatus === 'running' ? chalk.green : loopStatus === 'paused' ? chalk.yellow : chalk.gray; + const statusColor = loopStatus === 'running' ? chalk.green : loopStatus === 'paused' ? chalk.yellow : chalk.gray; console.log(chalk.bold('\nRalph Loop:')); console.log(` Status: ${statusColor(loopStatus)}`); console.log(''); @@ -505,27 +484,20 @@ 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 777b153f..fa187894 100644 --- a/src/file-stream-manager.ts +++ b/src/file-stream-manager.ts @@ -141,15 +141,7 @@ 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; @@ -178,8 +170,7 @@ export class FileStreamManager extends EventEmitter { }; } } catch (err) { - const errorCode = - err instanceof Error && 'code' in err ? (err as NodeJS.ErrnoException).code : 'UNKNOWN'; + 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) @@ -391,9 +382,7 @@ export class FileStreamManager extends EventEmitter { } // Resolve to absolute path - let absolutePath = isAbsolute(expandedPath) - ? resolve(expandedPath) - : resolve(workingDir, expandedPath); + let absolutePath = isAbsolute(expandedPath) ? resolve(expandedPath) : resolve(workingDir, expandedPath); // Resolve symlinks to prevent symlink attacks — validate the real target, // not the symlink itself. Fall back to resolved path if file doesn't exist yet. diff --git a/src/hooks-config.ts b/src/hooks-config.ts index 758656c3..f9843c71 100644 --- a/src/hooks-config.ts +++ b/src/hooks-config.ts @@ -71,10 +71,7 @@ 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 a5838174..84812df8 100644 --- a/src/image-watcher.ts +++ b/src/image-watcher.ts @@ -111,11 +111,7 @@ 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(); @@ -194,11 +190,7 @@ 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); } } @@ -213,11 +205,7 @@ 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); } @@ -322,11 +310,7 @@ 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 392a1e1b..9dde2a9f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -28,9 +28,7 @@ function trackError(): void { }, 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/plan-orchestrator.ts b/src/plan-orchestrator.ts index d1231840..8d61482d 100644 --- a/src/plan-orchestrator.ts +++ b/src/plan-orchestrator.ts @@ -172,18 +172,11 @@ export class PlanOrchestrator { 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 { @@ -433,10 +426,7 @@ 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(() => { @@ -517,18 +507,12 @@ 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, + typeof data.enrichedTaskDescription === 'string' ? data.enrichedTaskDescription : taskDescription, durationMs, }; diff --git a/src/push-store.ts b/src/push-store.ts index 4316011a..9a27ae76 100644 --- a/src/push-store.ts +++ b/src/push-store.ts @@ -95,10 +95,7 @@ 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 35f5892a..f561a756 100644 --- a/src/ralph-loop.ts +++ b/src/ralph-loop.ts @@ -406,11 +406,7 @@ export class RalphLoop extends EventEmitter { // 2. Min duration not reached // 3. We have idle sessions const counts = this.taskQueue.getCount(); - return ( - counts.pending === 0 && - !this.isMinDurationReached() && - this.sessionManager.getIdleSessions().length > 0 - ); + return counts.pending === 0 && !this.isMinDurationReached() && this.sessionManager.getIdleSessions().length > 0; } private async generateFollowUpTasks(): Promise { diff --git a/src/ralph-tracker.ts b/src/ralph-tracker.ts index 14a9ff9d..46e5e9a6 100644 --- a/src/ralph-tracker.ts +++ b/src/ralph-tracker.ts @@ -34,12 +34,7 @@ import { PlanTaskStatus, TddPhase, } from './types.js'; -import { - ANSI_ESCAPE_PATTERN_SIMPLE, - fuzzyPhraseMatch, - todoContentHash, - stringSimilarity, -} from './utils/index.js'; +import { ANSI_ESCAPE_PATTERN_SIMPLE, fuzzyPhraseMatch, todoContentHash, stringSimilarity } from './utils/index.js'; import { MAX_LINE_BUFFER_SIZE } from './config/buffer-limits.js'; import { MAX_TODOS_PER_SESSION } from './config/map-limits.js'; @@ -246,8 +241,7 @@ 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. @@ -297,15 +291,13 @@ 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 @@ -318,8 +310,7 @@ 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 ---------- @@ -394,8 +385,7 @@ 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 @@ -1704,8 +1694,7 @@ export class RalphTracker extends EventEmitter { 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'); + !line.toLowerCase().includes('completion phrase') && !line.toLowerCase().includes('output exactly'); if (isNotInPromptContext && isNotExplanation) { this.handleBareCompletionPhrase(expectedPhrase); @@ -1791,10 +1780,7 @@ 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); } } @@ -2085,10 +2071,8 @@ 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 @@ -2196,11 +2180,7 @@ export class RalphTracker extends EventEmitter { const taskNum = parseInt(match[1], 10); const statusStr = match[2].trim(); const status: RalphTodoStatus = - statusStr === 'completed' - ? 'completed' - : statusStr === 'in progress' - ? 'in_progress' - : 'pending'; + statusStr === 'completed' ? 'completed' : statusStr === 'in progress' ? 'in_progress' : 'pending'; const content = this._taskNumberToContent.get(taskNum); if (content) { this.upsertTodo(content, status); @@ -2606,14 +2586,7 @@ export class RalphTracker extends EventEmitter { ]; // Moderate: Bugs, features, enhancements - const moderatePatterns = [ - /\bbug\b/, - /\bfeature\b/, - /\benhance(?:ment)?\b/, - /\bimplement\b/, - /\badd\b/, - /\bfix\b/, - ]; + const moderatePatterns = [/\bbug\b/, /\bfeature\b/, /\benhance(?:ment)?\b/, /\bimplement\b/, /\badd\b/, /\bfix\b/]; for (const pattern of complexPatterns) { if (pattern.test(lower)) return 'complex'; @@ -2895,11 +2868,7 @@ 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; } @@ -3103,9 +3072,7 @@ 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; } @@ -3131,9 +3098,7 @@ 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; } @@ -3145,9 +3110,7 @@ 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; } @@ -3278,11 +3241,7 @@ export class RalphTracker extends EventEmitter { * @param status - Overall status from RALPH_STATUS * @fires circuitBreakerUpdate - If state changes */ - private updateCircuitBreaker( - hasProgress: boolean, - testsStatus: RalphTestsStatus, - status: RalphStatusValue - ): void { + private updateCircuitBreaker(hasProgress: boolean, testsStatus: RalphTestsStatus, status: RalphStatusValue): void { const prevState = this._circuitBreaker.state; if (hasProgress) { @@ -3323,10 +3282,7 @@ 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'; @@ -3568,9 +3524,7 @@ 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({ @@ -3724,10 +3678,7 @@ 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); @@ -3763,23 +3714,16 @@ 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) { @@ -3932,10 +3876,7 @@ 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 5b813ee5..5322ff9b 100644 --- a/src/respawn-controller.ts +++ b/src/respawn-controller.ts @@ -42,10 +42,7 @@ 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 { - MAX_RESPAWN_BUFFER_SIZE, - TRIM_RESPAWN_BUFFER_TO as RESPAWN_BUFFER_TRIM_SIZE, -} from './config/buffer-limits.js'; +import { MAX_RESPAWN_BUFFER_SIZE, TRIM_RESPAWN_BUFFER_TO as RESPAWN_BUFFER_TRIM_SIZE } from './config/buffer-limits.js'; import type { RespawnCycleMetrics, RespawnAggregateMetrics, @@ -539,8 +536,7 @@ 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.', + 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 @@ -734,10 +730,8 @@ export class RespawnController extends EventEmitter { // ========== Timer Tracking for UI Countdown Display ========== /** Active timers being tracked for UI display */ - private activeTimers: Map< - string, - { name: string; startedAt: number; durationMs: number; endsAt: number } - > = new Map(); + private activeTimers: Map = + new Map(); /** Recent action log entries (for UI display, max 20) */ private recentActions: ActionLogEntry[] = []; @@ -952,14 +946,12 @@ 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) */ @@ -1041,8 +1033,7 @@ 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 @@ -1501,10 +1492,7 @@ 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; } @@ -1559,9 +1547,7 @@ 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 { @@ -1880,10 +1866,7 @@ 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.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})` ); @@ -1902,13 +1885,8 @@ 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); } } @@ -2013,21 +1991,13 @@ export class RespawnController extends EventEmitter { * Start a tracked timer with UI countdown support. * Emits timerStarted event and tracks the timer for UI display. */ - private startTrackedTimer( - name: string, - durationMs: number, - callback: () => void, - reason?: string - ): NodeJS.Timeout { + private startTrackedTimer(name: string, durationMs: number, callback: () => void, reason?: string): NodeJS.Timeout { const now = Date.now(); const endsAt = now + durationMs; 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); @@ -2100,9 +2070,7 @@ 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) @@ -2149,10 +2117,7 @@ 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)'); @@ -2205,10 +2170,7 @@ 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'); } } @@ -2242,9 +2204,7 @@ 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; } @@ -2302,11 +2262,7 @@ export class RespawnController extends EventEmitter { if (result.verdict === 'IDLE') { // Cancel any pending confirmation timers - AI has spoken - this.cancelTrackedTimer( - 'completion-confirm', - this.completionConfirmTimer, - 'AI verdict: IDLE' - ); + this.cancelTrackedTimer('completion-confirm', this.completionConfirmTimer, 'AI verdict: IDLE'); this.completionConfirmTimer = null; this.cancelTrackedTimer('pre-filter', this.preFilterTimer, 'AI verdict: IDLE'); this.preFilterTimer = null; @@ -2316,19 +2272,13 @@ export class RespawnController extends EventEmitter { 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.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` - ); + 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(); @@ -2521,10 +2471,7 @@ export class RespawnController extends EventEmitter { 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.logAction('plan-check', `Verdict: PLAN_MODE but state is ${this._state}, not sending Enter`); return; } this.emit('planCheckCompleted', result); @@ -2554,9 +2501,7 @@ 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') { @@ -2602,11 +2547,7 @@ 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; } @@ -2624,11 +2565,7 @@ 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 @@ -2649,11 +2586,7 @@ 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; } @@ -2671,11 +2604,7 @@ 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; @@ -2721,10 +2650,7 @@ 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` @@ -2835,10 +2761,7 @@ 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) { @@ -2878,9 +2801,7 @@ 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(); @@ -2936,10 +2857,7 @@ 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.logAction('ralph', `Exit signal detected: ${statusBlock.recommendation || 'Task complete'}`); this.emit('respawnBlocked', { reason: 'exit_signal', details: statusBlock.recommendation || 'Task complete', @@ -2952,10 +2870,7 @@ 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.logAction('ralph', `Claude reported BLOCKED: ${statusBlock.recommendation || 'Needs human intervention'}`); this.emit('respawnBlocked', { reason: 'status_blocked', details: statusBlock.recommendation || 'Needs human intervention', @@ -2979,11 +2894,7 @@ 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; } @@ -3383,9 +3294,7 @@ 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; } @@ -3511,8 +3420,7 @@ 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(); } @@ -3664,27 +3572,19 @@ 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) { @@ -3702,10 +3602,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 f6dc23a9..bf27842a 100644 --- a/src/run-summary.ts +++ b/src/run-summary.ts @@ -203,8 +203,7 @@ 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( @@ -212,7 +211,11 @@ export class RunSummaryTracker { 'info', `Token milestone: ${this.formatTokens(currentMilestone)}`, `Input: ${this.formatTokens(inputTokens)}, Output: ${this.formatTokens(outputTokens)}`, - { total, input: inputTokens, output: outputTokens } + { + total, + input: inputTokens, + output: outputTokens, + } ); } } @@ -303,13 +306,10 @@ export class RunSummaryTracker { * Record session started. */ recordSessionStarted(mode: string, workingDir: string): void { - this.addEvent( - 'session_started', - 'success', - 'Session started', - `Mode: ${mode}, Dir: ${workingDir}`, - { mode, workingDir } - ); + this.addEvent('session_started', 'success', 'Session started', `Mode: ${mode}, Dir: ${workingDir}`, { + mode, + workingDir, + }); } /** diff --git a/src/session.ts b/src/session.ts index 13770108..4150aebe 100644 --- a/src/session.ts +++ b/src/session.ts @@ -37,12 +37,7 @@ import { RalphTracker } from './ralph-tracker.js'; import { BashToolParser } from './bash-tool-parser.js'; import { BufferAccumulator } from './utils/buffer-accumulator.js'; import { LRUMap } from './utils/lru-map.js'; -import { - ANSI_ESCAPE_PATTERN_FULL, - TOKEN_PATTERN, - SPINNER_PATTERN, - MAX_SESSION_TOKENS, -} from './utils/index.js'; +import { ANSI_ESCAPE_PATTERN_FULL, TOKEN_PATTERN, SPINNER_PATTERN, MAX_SESSION_TOKENS } from './utils/index.js'; import { MAX_TERMINAL_BUFFER_SIZE, TRIM_TERMINAL_TO as TERMINAL_BUFFER_TRIM_SIZE, @@ -263,10 +258,7 @@ 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; @@ -944,8 +936,7 @@ export class Session extends EventEmitter { const modeLabel = this.mode === 'opencode' ? 'OpenCode' : 'Claude'; console.log( - `[Session] Starting interactive ${modeLabel} session` + - (this._useMux ? ` (with ${this._mux!.backend})` : '') + `[Session] Starting interactive ${modeLabel} session` + (this._useMux ? ` (with ${this._mux!.backend})` : '') ); // If mux wrapping is enabled, create or attach to a mux session @@ -953,10 +944,7 @@ export class Session extends EventEmitter { 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; } @@ -1359,10 +1347,7 @@ export class Session extends EventEmitter { 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; } @@ -1569,13 +1554,7 @@ export class Session extends EventEmitter { model ? `(model: ${model})` : '' ); - const args = [ - '-p', - '--verbose', - '--dangerously-skip-permissions', - '--output-format', - 'stream-json', - ]; + const args = ['-p', '--verbose', '--dangerously-skip-permissions', '--output-format', 'stream-json']; if (model) { args.push('--model', model); } @@ -1663,11 +1642,7 @@ 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'; @@ -1745,8 +1720,7 @@ 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; + ((msg as unknown as Record).sessionId as string | undefined) ?? msg.session_id; if (msgSessionId && !this._claudeSessionId) { this._claudeSessionId = msgSessionId; } @@ -1938,9 +1912,7 @@ 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; } @@ -2057,9 +2029,7 @@ 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 () => { @@ -2072,9 +2042,7 @@ export class Session extends EventEmitter { if (this._isStopped) return; // Send /compact command with optional prompt - const compactCmd = this._autoCompactPrompt - ? `/compact ${this._autoCompactPrompt}\r` - : '/compact\r'; + const compactCmd = this._autoCompactPrompt ? `/compact ${this._autoCompactPrompt}\r` : '/compact\r'; await this.writeViaMux(compactCmd); this.emit('autoCompact', { tokens: totalTokens, @@ -2113,9 +2081,7 @@ 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 () => { @@ -2370,10 +2336,7 @@ 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 diff --git a/src/state-store.ts b/src/state-store.ts index c32919e6..106b196e 100644 --- a/src/state-store.ts +++ b/src/state-store.ts @@ -14,15 +14,7 @@ * @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'; @@ -195,9 +187,7 @@ 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; } @@ -211,10 +201,7 @@ 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'); @@ -277,9 +264,7 @@ 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; } @@ -292,10 +277,7 @@ 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'); @@ -510,9 +492,7 @@ 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 @@ -544,10 +524,7 @@ export class StateStore { * @param activeSessions Map of active session states */ getAggregateStats( - activeSessions: Record< - string, - { inputTokens?: number; outputTokens?: number; totalCost?: number } - > + activeSessions: Record ): { totalInputTokens: number; totalOutputTokens: number; @@ -631,9 +608,7 @@ 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; } @@ -662,10 +637,7 @@ export class StateStore { // Accumulate tokens todayEntry.inputTokens += inputTokens; todayEntry.outputTokens += outputTokens; - todayEntry.estimatedCost = this.calculateEstimatedCost( - todayEntry.inputTokens, - todayEntry.outputTokens - ); + todayEntry.estimatedCost = this.calculateEstimatedCost(todayEntry.inputTokens, todayEntry.outputTokens); // Only increment session count for unique sessions if (sessionId && !this.dailySessionIds.has(sessionId)) { @@ -740,10 +712,7 @@ 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; } @@ -761,10 +730,7 @@ 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 725b8e90..2443c710 100644 --- a/src/subagent-watcher.ts +++ b/src/subagent-watcher.ts @@ -170,20 +170,14 @@ 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< - string, - Map - >(); + private pendingToolCalls = new 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< - string, - { descriptions: Map; timestamp: number } - >(); + private parentDescriptionCache = new 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>(); @@ -364,10 +358,7 @@ export class SubagentWatcher extends EventEmitter { 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 } @@ -829,10 +820,7 @@ 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 { @@ -843,9 +831,7 @@ export class SubagentWatcher extends EventEmitter { const text = content.text.trim(); if (text.length > 0) { const preview = - text.length > TEXT_PREVIEW_LENGTH - ? text.substring(0, TEXT_PREVIEW_LENGTH) + '...' - : text; + text.length > TEXT_PREVIEW_LENGTH ? text.substring(0, TEXT_PREVIEW_LENGTH) + '...' : text; lines.push(`${this.formatTime(entry.timestamp)} 💬 ${preview.replace(/\n/g, ' ')}`); } } @@ -856,18 +842,14 @@ 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)}`); } } } @@ -987,11 +969,7 @@ export class SubagentWatcher extends EventEmitter { for (const line of lines) { try { const entry = JSON.parse(line); - if ( - entry.type === 'user' && - entry.toolUseResult?.agentId && - entry.toolUseResult?.description - ) { + if (entry.type === 'user' && entry.toolUseResult?.agentId && entry.toolUseResult?.description) { descriptions.set(entry.toolUseResult.agentId, entry.toolUseResult.description); } } catch { @@ -1115,11 +1093,7 @@ 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); @@ -1206,11 +1180,7 @@ 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) { @@ -1316,11 +1286,7 @@ 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); @@ -1338,12 +1304,7 @@ export class SubagentWatcher extends EventEmitter { /** * Tail a file from a specific position */ - private async tailFile( - filePath: string, - agentId: string, - sessionId: string, - fromPosition: number - ): Promise { + private async tailFile(filePath: string, agentId: string, sessionId: string, fromPosition: number): Promise { return new Promise((resolve) => { let position = fromPosition; @@ -1383,11 +1344,7 @@ 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) @@ -1410,11 +1367,7 @@ export class SubagentWatcher extends EventEmitter { // Check if this is first user message and description is missing if (info && !info.description && entry.type === 'user' && entry.message?.content) { // First try parent transcript (most reliable) - let description = await this.extractDescriptionFromParentTranscript( - info.projectHash, - info.sessionId, - agentId - ); + let description = await this.extractDescriptionFromParentTranscript(info.projectHash, info.sessionId, agentId); // Fallback: extract smart title from the prompt content if (!description) { let text: string | undefined; @@ -1601,9 +1554,7 @@ 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)) { @@ -1618,10 +1569,7 @@ 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) { @@ -1688,10 +1636,7 @@ 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 add202b1..bf668d0b 100644 --- a/src/task-queue.ts +++ b/src/task-queue.ts @@ -166,11 +166,7 @@ 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 @@ -214,9 +210,7 @@ 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). */ diff --git a/src/task-tracker.ts b/src/task-tracker.ts index cfd36965..5440e609 100644 --- a/src/task-tracker.ts +++ b/src/task-tracker.ts @@ -51,11 +51,7 @@ const MAX_PENDING_TOOL_USES = 100; * Used as fallback when JSON parsing doesn't capture the launch. * Capture group 1: Agent/task type name */ -const LAUNCH_PATTERNS = [ - /Launching\s+(\w+)\s+agent/i, - /Starting\s+(\w+)\s+task/i, - /Spawning\s+(\w+)\s+agent/i, -]; +const LAUNCH_PATTERNS = [/Launching\s+(\w+)\s+agent/i, /Starting\s+(\w+)\s+task/i, /Spawning\s+(\w+)\s+agent/i]; /** * Patterns that indicate a task has completed. @@ -365,8 +361,7 @@ 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); diff --git a/src/team-watcher.ts b/src/team-watcher.ts index 08f7d0d4..4e2c5421 100644 --- a/src/team-watcher.ts +++ b/src/team-watcher.ts @@ -108,9 +108,7 @@ 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()); } /** Check if a session has active teammates (for idle detection) */ @@ -190,8 +188,7 @@ 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); @@ -256,9 +253,7 @@ 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; } diff --git a/src/templates/claude-md.ts b/src/templates/claude-md.ts index 0ccc50c3..dca10f83 100644 --- a/src/templates/claude-md.ts +++ b/src/templates/claude-md.ts @@ -44,11 +44,7 @@ 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 @@ -75,12 +71,7 @@ export function generateClaudeMd( 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 710ce355..44f01d3e 100644 --- a/src/tmux-manager.ts +++ b/src/tmux-manager.ts @@ -182,9 +182,7 @@ 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'); } @@ -206,8 +204,7 @@ 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}`; } @@ -374,17 +371,7 @@ 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)) { @@ -416,17 +403,13 @@ 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" && `; } @@ -623,16 +606,7 @@ 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; @@ -756,9 +730,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { 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; } @@ -859,9 +831,7 @@ 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); @@ -932,11 +902,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { 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; @@ -966,9 +932,7 @@ 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})`); } } } @@ -1043,7 +1007,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { const pgrepOutput = execSync( `for p in ${sessionPids.join(' ')}; do children=$(pgrep -P $p 2>/dev/null | tr '\\n' ','); echo "$p:$children"; done`, - { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS } + { + encoding: 'utf-8', + timeout: EXEC_TIMEOUT_MS, + } ).trim(); for (const line of pgrepOutput.split('\n')) { @@ -1069,10 +1036,10 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { // Step 3: Single ps call const pidArray = Array.from(allPids); if (pidArray.length > 0) { - const psOutput = execSync( - `ps -o pid=,rss=,pcpu= -p ${pidArray.join(',')} 2>/dev/null || true`, - { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS } - ).trim(); + const psOutput = execSync(`ps -o pid=,rss=,pcpu= -p ${pidArray.join(',')} 2>/dev/null || true`, { + encoding: 'utf-8', + timeout: EXEC_TIMEOUT_MS, + }).trim(); const processStats = new Map(); for (const line of psOutput.split('\n')) { @@ -1419,9 +1386,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { } // Build target: sessionName.paneId (e.g., "codeman-abc12345.%1") - const target = paneTarget.startsWith('%') - ? `${muxName}.${paneTarget}` - : `${muxName}.%${paneTarget}`; + const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`; try { const hasCarriageReturn = input.includes('\r'); @@ -1470,9 +1435,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { return null; } - const target = paneTarget.startsWith('%') - ? `${muxName}.${paneTarget}` - : `${muxName}.%${paneTarget}`; + const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`; try { return execSync(`tmux capture-pane -p -e -t ${shellescape(target)} -S -5000`, { @@ -1504,15 +1467,13 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { return false; } - const target = paneTarget.startsWith('%') - ? `${muxName}.${paneTarget}` - : `${muxName}.%${paneTarget}`; + const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`; try { - execSync( - `tmux pipe-pane -O -t ${shellescape(target)} ${shellescape('cat >> ' + outputFile)}`, - { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS } - ); + execSync(`tmux pipe-pane -O -t ${shellescape(target)} ${shellescape('cat >> ' + outputFile)}`, { + encoding: 'utf-8', + timeout: EXEC_TIMEOUT_MS, + }); return true; } catch (err) { console.error('[TmuxManager] Failed to start pipe-pane:', err); @@ -1534,9 +1495,7 @@ export class TmuxManager extends EventEmitter implements TerminalMultiplexer { return false; } - const target = paneTarget.startsWith('%') - ? `${muxName}.${paneTarget}` - : `${muxName}.%${paneTarget}`; + const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`; try { execSync(`tmux pipe-pane -t ${shellescape(target)}`, { diff --git a/src/transcript-watcher.ts b/src/transcript-watcher.ts index 69dd83db..356e0397 100644 --- a/src/transcript-watcher.ts +++ b/src/transcript-watcher.ts @@ -86,12 +86,7 @@ const POLL_INTERVAL_MS = 1000; const MAX_MESSAGE_LENGTH = 500; /** Patterns that indicate plan mode / approval prompt */ -const PLAN_MODE_PATTERNS = [ - /ExitPlanMode/i, - /AskUserQuestion/i, - /Ready for user approval/i, - /approve.*plan/i, -]; +const PLAN_MODE_PATTERNS = [/ExitPlanMode/i, /AskUserQuestion/i, /Ready for user approval/i, /approve.*plan/i]; // ========== TranscriptWatcher Class ========== diff --git a/src/tunnel-manager.ts b/src/tunnel-manager.ts index f2c3cf74..4d1e6a99 100644 --- a/src/tunnel-manager.ts +++ b/src/tunnel-manager.ts @@ -133,10 +133,7 @@ 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 5ac47863..1ff36b6c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -145,15 +145,7 @@ export interface SessionConfig { /** * Available session colors for visual differentiation */ -export type SessionColor = - | 'default' - | 'red' - | 'orange' - | 'yellow' - | 'green' - | 'blue' - | 'purple' - | 'pink'; +export type SessionColor = 'default' | 'red' | 'orange' | 'yellow' | 'green' | 'blue' | 'purple' | 'pink'; /** * Current state of a session diff --git a/src/utils/cleanup-manager.ts b/src/utils/cleanup-manager.ts index 281f5a92..8f1ea99d 100644 --- a/src/utils/cleanup-manager.ts +++ b/src/utils/cleanup-manager.ts @@ -229,10 +229,7 @@ export class CleanupManager implements Disposable { * @param description - Human-readable description * @returns Registration ID */ - registerStream( - stream: { destroy?: () => void; close?: () => void }, - description: string - ): string { + registerStream(stream: { destroy?: () => void; close?: () => void }, description: string): string { return this.registerCleanup( 'stream', () => { diff --git a/src/utils/index.ts b/src/utils/index.ts index e3a02f34..8f1cac54 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -19,22 +19,9 @@ export { createAnsiPatternSimple, stripAnsi, } from './regex-patterns.js'; -export { - MAX_SESSION_TOKENS, - validateTokenCounts, - validateTokensAndCost, -} from './token-validation.js'; -export { - stringSimilarity, - normalizePhrase, - fuzzyPhraseMatch, - todoContentHash, -} from './string-similarity.js'; +export { MAX_SESSION_TOKENS, validateTokenCounts, validateTokensAndCost } from './token-validation.js'; +export { stringSimilarity, normalizePhrase, fuzzyPhraseMatch, todoContentHash } from './string-similarity.js'; 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/regex-patterns.ts b/src/utils/regex-patterns.ts index dc933e09..590043b1 100644 --- a/src/utils/regex-patterns.ts +++ b/src/utils/regex-patterns.ts @@ -18,8 +18,7 @@ * Note: Has global flag - reset lastIndex before exec() if reusing. */ // eslint-disable-next-line no-control-regex -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. diff --git a/src/utils/token-validation.ts b/src/utils/token-validation.ts index 5900d793..bb554318 100644 --- a/src/utils/token-validation.ts +++ b/src/utils/token-validation.ts @@ -21,10 +21,7 @@ export const MAX_SESSION_TOKENS = 500_000; * @param outputTokens - Output token count to validate * @returns Object with isValid flag and optional error reason */ -export function validateTokenCounts( - inputTokens: number, - outputTokens: number -): { isValid: boolean; reason?: string } { +export function validateTokenCounts(inputTokens: number, outputTokens: number): { isValid: boolean; reason?: string } { if (inputTokens < 0 || outputTokens < 0) { return { isValid: false, diff --git a/src/web/schemas.ts b/src/web/schemas.ts index ba109fad..0b96f671 100644 --- a/src/web/schemas.ts +++ b/src/web/schemas.ts @@ -153,10 +153,7 @@ export const ResizeSchema = z.object({ export const CreateCaseSchema = z.object({ name: z .string() - .regex( - /^[a-zA-Z0-9_-]+$/, - 'Invalid case name format. Use only letters, numbers, hyphens, underscores.' - ), + .regex(/^[a-zA-Z0-9_-]+$/, 'Invalid case name format. Use only letters, numbers, hyphens, underscores.'), description: z.string().max(1000).optional(), }); @@ -169,10 +166,7 @@ export const CreateCaseSchema = z.object({ export const QuickStartSchema = z.object({ caseName: z .string() - .regex( - /^[a-zA-Z0-9_-]+$/, - 'Invalid case name format. Use only letters, numbers, hyphens, underscores.' - ) + .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, @@ -185,14 +179,7 @@ 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(), }); diff --git a/src/web/server.ts b/src/web/server.ts index 004a3db0..27924992 100644 --- a/src/web/server.ts +++ b/src/web/server.ts @@ -16,16 +16,7 @@ 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'; @@ -224,9 +215,7 @@ 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 @@ -291,18 +280,13 @@ function autoConfigureRalph( if (ralphConfig && ralphConfig.completionPromise) { session.ralphTracker.enable(); - session.ralphTracker.startLoop( - ralphConfig.completionPromise, - ralphConfig.maxIterations ?? undefined - ); + session.ralphTracker.startLoop(ralphConfig.completionPromise, ralphConfig.maxIterations ?? undefined); // Restore iteration count if available 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( @@ -322,9 +306,7 @@ function autoConfigureRalph( 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, @@ -385,12 +367,7 @@ 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; @@ -409,8 +386,7 @@ 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) @@ -606,10 +582,7 @@ 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); }, }; @@ -680,8 +653,7 @@ 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({ @@ -780,11 +752,7 @@ 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'); @@ -871,9 +839,7 @@ 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'); @@ -881,16 +847,13 @@ 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(), @@ -923,10 +886,8 @@ export class WebServer extends EventEmitter { // Global stats endpoint this.app.get('/api/stats', async () => { - const activeSessionTokens: Record< - string, - { inputTokens?: number; outputTokens?: number; totalCost?: number } - > = {}; + const activeSessionTokens: Record = + {}; for (const [sessionId, session] of this.sessions) { activeSessionTokens[sessionId] = { inputTokens: session.inputTokens, @@ -944,10 +905,8 @@ 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< - string, - { inputTokens?: number; outputTokens?: number; totalCost?: number } - > = {}; + const activeSessionTokens: Record = + {}; for (const [sessionId, session] of this.sessions) { activeSessionTokens[sessionId] = { inputTokens: session.inputTokens, @@ -970,10 +929,7 @@ 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() }; @@ -1032,17 +988,13 @@ 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()), @@ -1066,10 +1018,7 @@ export class WebServer extends EventEmitter { 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(); @@ -1106,11 +1055,7 @@ export class WebServer extends EventEmitter { const modelConfig = await this.getModelConfig(); const mode = body.mode || 'claude'; const model = - mode === 'opencode' - ? body.openCodeConfig?.model - : mode !== 'shell' - ? modelConfig?.defaultModel - : undefined; + mode === 'opencode' ? body.openCodeConfig?.model : mode !== 'shell' ? modelConfig?.defaultModel : undefined; const claudeModeConfig = await this.getClaudeModeConfig(); const session = new Session({ workingDir, @@ -1350,10 +1295,7 @@ 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 []; @@ -1396,9 +1338,7 @@ 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); @@ -1440,11 +1380,7 @@ export class WebServer extends EventEmitter { // 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) { @@ -1459,10 +1395,7 @@ 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 { @@ -1544,10 +1477,7 @@ export class WebServer extends EventEmitter { }, }; } catch (err) { - return createErrorResponse( - ApiErrorCode.OPERATION_FAILED, - `Failed to read file: ${getErrorMessage(err)}` - ); + return createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read file: ${getErrorMessage(err)}`); } }); @@ -1563,9 +1493,7 @@ 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; } @@ -1573,11 +1501,7 @@ 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; } @@ -1623,12 +1547,7 @@ export class WebServer extends EventEmitter { } catch (err) { reply .code(500) - .send( - createErrorResponse( - ApiErrorCode.OPERATION_FAILED, - `Failed to read file: ${getErrorMessage(err)}` - ) - ); + .send(createErrorResponse(ApiErrorCode.OPERATION_FAILED, `Failed to read file: ${getErrorMessage(err)}`)); } }); @@ -1644,9 +1563,7 @@ 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; } @@ -1689,9 +1606,7 @@ 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', () => { @@ -1721,14 +1636,13 @@ 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) { @@ -1737,10 +1651,7 @@ 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) @@ -1938,10 +1849,7 @@ export class WebServer extends EventEmitter { }; } 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}`); } @@ -1988,10 +1896,7 @@ 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); @@ -2091,10 +1996,7 @@ 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); @@ -2120,9 +2022,7 @@ export class WebServer extends EventEmitter { .writeViaMux(inputStr) .then((ok) => { if (!ok) { - console.warn( - `[Server] writeViaMux failed for session ${id}, falling back to direct write` - ); + console.warn(`[Server] writeViaMux failed for session ${id}, falling back to direct write`); session.write(inputStr); } }) @@ -2140,10 +2040,7 @@ 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); @@ -2273,10 +2170,7 @@ 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 @@ -2344,10 +2238,7 @@ 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); @@ -2373,8 +2264,7 @@ 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, @@ -2382,23 +2272,15 @@ 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); @@ -2410,9 +2292,7 @@ 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'); } @@ -2432,10 +2312,7 @@ 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 { @@ -2490,9 +2367,7 @@ 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'); } @@ -2505,18 +2380,12 @@ 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 @@ -2746,21 +2615,14 @@ export class WebServer extends EventEmitter { try { const stat = statSync(workingDir); if (!stat.isDirectory()) { - return createErrorResponse( - ApiErrorCode.INVALID_INPUT, - 'workingDir is not a directory' - ); + 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 - ); + const run = await this.startScheduledRun(prompt, workingDir || process.cwd(), durationMinutes ?? 60); return { success: true, run }; } ); @@ -2813,9 +2675,7 @@ 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)) { @@ -2835,116 +2695,105 @@ 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' - ); - } - 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)); - } + 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)); + } + }); // 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); - } - } - - // 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)); + // 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)); + } + }); this.app.get('/api/cases/:name', async (req) => { const { name } = req.params as { name: string }; @@ -2952,9 +2801,7 @@ 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 { @@ -2991,9 +2838,7 @@ 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]; } @@ -3092,10 +2937,7 @@ 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}`); } }); @@ -3111,10 +2953,7 @@ export class WebServer extends EventEmitter { 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; @@ -3158,10 +2997,7 @@ 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)}`); } } @@ -3170,11 +3006,7 @@ export class WebServer extends EventEmitter { const niceConfig = await this.getGlobalNiceConfig(); const qsModelConfig = await this.getModelConfig(); const qsModel = - mode === 'opencode' - ? openCodeConfig?.model - : mode !== 'shell' - ? qsModelConfig?.defaultModel - : undefined; + mode === 'opencode' ? openCodeConfig?.model : mode !== 'shell' ? qsModelConfig?.defaultModel : undefined; const qsClaudeModeConfig = await this.getClaudeModeConfig(); const session = new Session({ workingDir: casePath, @@ -3289,14 +3121,7 @@ export class WebServer extends EventEmitter { 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); @@ -3319,10 +3144,7 @@ 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)}`); } } @@ -3399,15 +3221,13 @@ 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'; @@ -3426,8 +3246,7 @@ export class WebServer extends EventEmitter { 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'); @@ -3521,14 +3340,9 @@ export class WebServer extends EventEmitter { 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)); @@ -3649,8 +3463,7 @@ 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 }); @@ -3658,20 +3471,14 @@ 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 @@ -3688,8 +3495,7 @@ 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; @@ -3711,9 +3517,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; content, priority, verificationCriteria: - typeof obj.verificationCriteria === 'string' - ? obj.verificationCriteria - : 'Task completed successfully', + typeof obj.verificationCriteria === 'string' ? obj.verificationCriteria : 'Task completed successfully', tddPhase, dependencies: Array.isArray(obj.dependencies) ? obj.dependencies.map(String) : [], status: 'pending' as const, @@ -3734,10 +3538,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; 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 { @@ -3783,12 +3584,7 @@ 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 @@ -3834,10 +3630,7 @@ 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 { @@ -3877,10 +3670,7 @@ 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(); @@ -3920,9 +3710,7 @@ 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]; } @@ -3988,9 +3776,7 @@ 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]; } @@ -4514,10 +4300,7 @@ 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 @@ -4550,10 +4333,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; 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)) { @@ -4610,10 +4390,7 @@ 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({ @@ -4631,10 +4408,7 @@ 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) { @@ -4732,11 +4506,7 @@ 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); @@ -4870,9 +4640,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; if (controller) { const config = controller.getConfig(); const timerInfo = this.respawnTimers.get(session.id); - const durationMinutes = timerInfo - ? Math.round((timerInfo.endAt - timerInfo.startedAt) / 60000) - : undefined; + const durationMinutes = timerInfo ? Math.round((timerInfo.endAt - timerInfo.startedAt) / 60000) : undefined; state.respawnConfig = { ...config, durationMinutes }; // Use config.enabled instead of controller.state - this way the respawn // will be restored on server restart even if it was temporarily stopped @@ -4892,11 +4660,7 @@ 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, @@ -4941,10 +4705,7 @@ 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(); @@ -4978,11 +4739,7 @@ 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); @@ -4994,11 +4751,7 @@ 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({ @@ -5029,9 +4782,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; // Save the config BEFORE removing controller, so it can be restored on restart const config = controller.getConfig(); const timerInfo = this.respawnTimers.get(sessionId); - const durationMinutes = timerInfo - ? Math.round((timerInfo.endAt - timerInfo.startedAt) / 60000) - : undefined; + const durationMinutes = timerInfo ? Math.round((timerInfo.endAt - timerInfo.startedAt) / 60000) : undefined; this.saveRespawnConfig(sessionId, config, durationMinutes); controller.stop(); @@ -5110,10 +4861,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; 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 }; @@ -5330,12 +5078,7 @@ 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); }, @@ -5503,20 +5246,17 @@ 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 }); @@ -5533,17 +5273,14 @@ 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 }); @@ -5620,11 +5357,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; * @param config - The persisted respawn configuration * @param source - Source of the config for logging (e.g., 'state.json' or 'mux-sessions.json') */ - private restoreRespawnController( - session: Session, - config: PersistedRespawnConfig, - source: string - ): void { + private restoreRespawnController(session: Session, config: PersistedRespawnConfig, source: string): void { const controller = new RespawnController(session, { idleTimeoutMs: config.idleTimeoutMs, updatePrompt: config.updatePrompt, @@ -5665,9 +5398,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; 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 @@ -5748,11 +5479,7 @@ 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 {}; @@ -5772,11 +5499,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; ); } - 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(); @@ -5803,9 +5526,7 @@ 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' }); } }); @@ -5851,9 +5572,7 @@ 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 @@ -5883,9 +5602,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; 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); @@ -5928,10 +5645,7 @@ 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 @@ -5987,10 +5701,7 @@ 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; } @@ -5999,10 +5710,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; respawnStatus[sessionId] = controller.getStatus(); } - const activeSessionTokens: Record< - string, - { inputTokens?: number; outputTokens?: number; totalCost?: number } - > = {}; + const activeSessionTokens: Record = {}; for (const [sessionId, session] of this.sessions) { activeSessionTokens[sessionId] = { inputTokens: session.inputTokens, @@ -6404,9 +6112,7 @@ 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'); } @@ -6435,9 +6141,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; // 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'); } @@ -6524,9 +6228,7 @@ 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(); @@ -6559,10 +6261,7 @@ 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, @@ -6570,14 +6269,8 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; ); } // Auto-clear - if ( - savedState.autoClearEnabled !== undefined || - savedState.autoClearThreshold !== undefined - ) { - session.setAutoClear( - savedState.autoClearEnabled ?? false, - savedState.autoClearThreshold - ); + if (savedState.autoClearEnabled !== undefined || savedState.autoClearThreshold !== undefined) { + session.setAutoClear(savedState.autoClearEnabled ?? false, savedState.autoClearThreshold); } // Token tracking if ( @@ -6606,9 +6299,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; 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(); @@ -6635,18 +6326,11 @@ 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); } } } @@ -6658,11 +6342,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; 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}:`, @@ -6681,9 +6361,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; 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}`); } } @@ -6697,9 +6375,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; 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}`); } } @@ -6824,9 +6500,7 @@ NOW: Generate the implementation plan for the task above. Think step by step.`; this.respawnControllers.clear(); // Stop all scheduled runs first (they have their own session cleanup) - await Promise.allSettled( - Array.from(this.scheduledRuns.keys()).map((id) => this.stopScheduledRun(id)) - ); + await Promise.allSettled(Array.from(this.scheduledRuns.keys()).map((id) => this.stopScheduledRun(id))); // On server shutdown, DO NOT call cleanupSession — it tears down session state, // removes listeners, kills PTY processes, and broadcasts session:deleted.