diff --git a/src/respawn-controller.ts b/src/respawn-controller.ts index 31289e11..0b9419e0 100644 --- a/src/respawn-controller.ts +++ b/src/respawn-controller.ts @@ -2078,6 +2078,15 @@ export class RespawnController extends EventEmitter { return; } + // Check for active child processes (bash tools, test suites, builds, etc.) + const activeProcesses = this.session.getActiveChildProcesses(); + if (activeProcesses.length > 0) { + const names = activeProcesses.map((p) => p.command).join(', '); + this.log(`Skipping AI check - ${activeProcesses.length} active child process(es): ${names}`); + this.logAction('detection', `Skipped AI check: child processes running (${names})`); + return; + } + // 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}`); @@ -2682,6 +2691,18 @@ export class RespawnController extends EventEmitter { return; } + // Safety check: if child processes are running (bash tools, test suites, builds, etc.) + const activeProcesses = this.session.getActiveChildProcesses(); + if (activeProcesses.length > 0) { + const names = activeProcesses.map((p) => p.command).join(', '); + this.log(`Idle confirmation rejected - ${activeProcesses.length} active child process(es): ${names}`); + this.logAction('detection', `Rejected: child processes running (${names})`); + this.setState('watching'); + this.startNoOutputTimer(); + this.startPreFilterTimer(); + return; + } + this.log(`Idle confirmed via: ${reason}`); const status = this.getDetectionStatus(); this.log( diff --git a/src/session.ts b/src/session.ts index 7c4a872a..4f4b2b48 100644 --- a/src/session.ts +++ b/src/session.ts @@ -29,6 +29,7 @@ */ import { EventEmitter } from 'node:events'; +import { execSync } from 'node:child_process'; import { v4 as uuidv4 } from 'uuid'; import * as pty from 'node-pty'; import { @@ -65,6 +66,7 @@ import { MAX_MESSAGES, MAX_LINE_BUFFER_SIZE, } from './config/buffer-limits.js'; +import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js'; import { buildInteractiveArgs, buildPromptArgs, @@ -539,6 +541,77 @@ export class Session extends EventEmitter { return this._isWorking; } + /** + * Check if the Claude CLI process inside this session's tmux pane has active child processes. + * This detects running bash tools, test suites, builds, servers, etc. that Claude spawned. + * + * Returns an array of {pid, command} for each child process, or empty array if none. + * Returns empty array if no mux session or on error (fail-open to avoid blocking respawn). + */ + getActiveChildProcesses(): { pid: number; command: string }[] { + if (!this._muxSession) return []; + + try { + // Get direct children of the pane PID (typically just the claude process) + const panePid = this._muxSession.pid; + const claudeChildren = execSync(`pgrep -P ${panePid}`, { + encoding: 'utf-8', + timeout: EXEC_TIMEOUT_MS, + }).trim(); + if (!claudeChildren) return []; + + const claudePids = claudeChildren + .split('\n') + .map((p) => parseInt(p, 10)) + .filter((p) => !Number.isNaN(p)); + + // For each Claude process, check for its children (the actual running tools/processes) + const activeProcesses: { pid: number; command: string }[] = []; + for (const claudePid of claudePids) { + try { + const toolChildren = execSync(`pgrep -P ${claudePid}`, { + encoding: 'utf-8', + timeout: EXEC_TIMEOUT_MS, + }).trim(); + if (!toolChildren) continue; + + const toolPids = toolChildren + .split('\n') + .map((p) => parseInt(p, 10)) + .filter((p) => !Number.isNaN(p)); + + if (toolPids.length === 0) continue; + + // Get command names for the child processes in a single ps call + try { + const psOutput = execSync(`ps -o pid=,comm= -p ${toolPids.join(',')} 2>/dev/null`, { + encoding: 'utf-8', + timeout: EXEC_TIMEOUT_MS, + }).trim(); + for (const line of psOutput.split('\n')) { + const match = line.trim().match(/^(\d+)\s+(.+)/); + if (match) { + activeProcesses.push({ pid: parseInt(match[1], 10), command: match[2].trim() }); + } + } + } catch { + // ps failed — just record PIDs without command names + for (const pid of toolPids) { + activeProcesses.push({ pid, command: 'unknown' }); + } + } + } catch { + // No children for this Claude process + } + } + + return activeProcesses; + } catch { + // pgrep fails with exit code 1 when no matches — that's normal (no children) + return []; + } + } + get lastPromptTime(): number { return this._lastPromptTime; } diff --git a/test/mocks/mock-session.ts b/test/mocks/mock-session.ts index e1337532..12189665 100644 --- a/test/mocks/mock-session.ts +++ b/test/mocks/mock-session.ts @@ -17,6 +17,7 @@ export class MockSession extends EventEmitter { status: 'idle' | 'working' = 'idle'; pid: number = 12345; isWorking: boolean = false; + private _activeChildProcesses: { pid: number; command: string }[] = []; ralphTracker: null = null; writeBuffer: string[] = []; terminalBuffer: string = ''; @@ -52,9 +53,7 @@ export class MockSession extends EventEmitter { /** Check if a specific command was written */ hasWritten(pattern: string | RegExp): boolean { - return this.writeBuffer.some((data) => - typeof pattern === 'string' ? data.includes(pattern) : pattern.test(data), - ); + return this.writeBuffer.some((data) => (typeof pattern === 'string' ? data.includes(pattern) : pattern.test(data))); } // ========== Terminal Output Simulation ========== @@ -113,10 +112,7 @@ export class MockSession extends EventEmitter { */ simulatePlanModePrompt(): void { this.simulateTerminalOutput( - 'Would you like to proceed with this plan?\n' + - '\u276f 1. Yes\n' + - ' 2. No\n' + - ' 3. Type your own\n', + 'Would you like to proceed with this plan?\n' + '\u276f 1. Yes\n' + ' 2. No\n' + ' 3. Type your own\n' ); } @@ -131,11 +127,7 @@ export class MockSession extends EventEmitter { /** Simulate token count display */ simulateTokenCount(tokens: number | string): void { const formatted = - typeof tokens === 'number' - ? tokens >= 1000 - ? `${(tokens / 1000).toFixed(1)}k` - : String(tokens) - : tokens; + typeof tokens === 'number' ? (tokens >= 1000 ? `${(tokens / 1000).toFixed(1)}k` : String(tokens)) : tokens; this.simulateTerminalOutput(`${formatted} tokens used`); } @@ -167,6 +159,16 @@ export class MockSession extends EventEmitter { return this._muxName; } + /** Check for active child processes (mock returns configurable list) */ + getActiveChildProcesses(): { pid: number; command: string }[] { + return this._activeChildProcesses; + } + + /** Set active child processes for testing */ + setActiveChildProcesses(processes: { pid: number; command: string }[]): void { + this._activeChildProcesses = processes; + } + // ========== Route-test conveniences ========== /** Session display name */