From 692c894760125dd35c6974f3a0a8d1ae42d49d8d Mon Sep 17 00:00:00 2001 From: arkon Date: Sat, 28 Mar 2026 05:14:39 +0100 Subject: [PATCH] fix: correct process tree detection and prevent timer starvation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 1. Rewrote getActiveChildProcesses() to use a single `ps --ppid` call instead of two-level pgrep. The pane PID is typically claude itself (bash exec'd into it), not a bash wrapper — so direct children of pane_pid ARE the tool processes. 2. Added timer restart in tryStartAiCheck() when skipping due to child processes. Without this, the pre-filter and no-output timers (both one-shot) would never fire again, permanently stalling idle detection for sessions with silent long-running processes. Co-Authored-By: Claude Opus 4.6 --- src/respawn-controller.ts | 3 ++ src/session.ts | 68 ++++++++++++--------------------------- 2 files changed, 23 insertions(+), 48 deletions(-) diff --git a/src/respawn-controller.ts b/src/respawn-controller.ts index 0b9419e0..f80155c3 100644 --- a/src/respawn-controller.ts +++ b/src/respawn-controller.ts @@ -2079,11 +2079,14 @@ export class RespawnController extends EventEmitter { } // Check for active child processes (bash tools, test suites, builds, etc.) + // These may produce no terminal output, so restart timers to retry periodically. 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})`); + this.startNoOutputTimer(); + this.startPreFilterTimer(); return; } diff --git a/src/session.ts b/src/session.ts index 4f4b2b48..a60557cb 100644 --- a/src/session.ts +++ b/src/session.ts @@ -542,8 +542,13 @@ export class Session extends EventEmitter { } /** - * 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. + * Check if the session's process tree has active child processes beyond Claude itself. + * Detects running bash tools, test suites, builds, servers, etc. that Claude spawned. + * + * The tmux pane PID is typically "claude" directly (bash exec'd into it). When Claude + * runs a bash tool, it spawns child processes: claude → bash → npm/node/python/etc. + * We check direct children of the pane PID, filtering out "claude" itself (for the rare + * case where bash wraps claude and didn't exec). * * 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). @@ -552,62 +557,29 @@ export class Session extends EventEmitter { 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}`, { + + // Single call: get direct children with their command names + const output = execSync(`ps -o pid=,comm= --ppid ${panePid} 2>/dev/null`, { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS, }).trim(); - if (!claudeChildren) return []; + if (!output) 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 - } + for (const line of output.split('\n')) { + const match = line.trim().match(/^(\d+)\s+(.+)/); + if (!match) continue; + const pid = parseInt(match[1], 10); + const command = match[2].trim(); + // Skip the claude process itself (pane_pid may be bash wrapping claude) + if (command === 'claude') continue; + activeProcesses.push({ pid, command }); } return activeProcesses; } catch { - // pgrep fails with exit code 1 when no matches — that's normal (no children) + // ps returns exit code 1 when no matches — normal (no children) return []; } }