feat: detect active child processes to prevent false idle during running tools

When Claude Code spawns bash tools (test suites, builds, servers), the
respawn controller could falsely detect idle if terminal output paused.
Now checks the process tree for active children of the Claude process
before triggering AI idle checks or confirming idle state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
arkon
2026-03-28 04:34:53 +01:00
co-authored by Claude Opus 4.6
parent d866c8f30e
commit ad0acb6d58
3 changed files with 108 additions and 12 deletions
+21
View File
@@ -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(
+73
View File
@@ -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;
}
+14 -12
View File
@@ -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 */