import type { ChildProcess } from 'node:child_process'; import { type CircuitBreakerSnapshot, type CircuitBreakerConfig } from './circuit-breaker.js'; export type { CircuitBreakerSnapshot, CircuitBreakerConfig } from './circuit-breaker.js'; export interface TrackedProcess { pid: number; name: string; /** Display-safe redacted command string — safe for logs, /ps, crash dumps. * Contains [REDACTED] in place of sensitive flag values. */ command: string; startedAt: number; sessionId?: string | undefined; /** The raw ChildProcess handle. Never call .kill() directly on this — * use `kill()` below which handles process groups correctly on POSIX * and degrades gracefully on Windows. * * `null` for entries mirrored from the persistent registry * (process-registry-persistent.ts): those track processes owned by OTHER * host instances, so there is no live handle in this process. All * registry paths must tolerate a null child (kill by PID, liveness by * `process.kill(pid, 0)` probe). */ child: ChildProcess | null; /** True only when this child was spawned as a POSIX process-group/session * leader (for example `spawn(..., { detached: true })`) and `pid` is the * actual `child.pid`. Negative-PID signaling is host-wide dangerous for * values like -1, so tests and manually registered entries must not opt in. */ processGroupLeader?: boolean | undefined; /** True once the process has been kill()ed but not yet exited. * We keep it in the registry until 'close' fires so callers can * distinguish "still running" from "just exited". */ killed: boolean; /** If true, kill() and killAll() will refuse to kill this process. * Used for infrastructure processes (browser, dev servers, …) that * must outlive the agent session. */ protected: boolean; /** True for an explicitly detached/background tool launch. */ background: boolean; } export { redactCommand } from './_redact-command.js'; interface KillOpts { /** SIGKILL instead of SIGTERM. Default: false (SIGTERM first). */ force?: boolean | undefined; /** MS to wait between SIGTERM and SIGKILL on POSIX. Default: 2000. */ graceMs?: number | undefined; /** Leave explicitly backgrounded jobs alive. Default false. */ preserveBackground?: boolean | undefined; /** * Also kill processes marked `protected` (browser open, etc.). * Default false. Host-process shutdown should pass true so protected * children cannot strand the parent event loop (issue #322). */ includeProtected?: boolean | undefined; } /** * Snapshot of the armed auto kill/reset countdown, or null when nothing is * armed. `remainingMs` ticks down in real time; the TUI statusline renders it. */ export interface BreakerCountdown { remainingMs: number; totalMs: number; } type BreakerCountdownListener = (snapshot: BreakerCountdown | null) => void; export interface RegistryStats { activeCount: number; backgroundCount: number; totalCount: number; breaker: CircuitBreakerSnapshot; } interface Win32TreeKillOptions { /** * Upper bound for taskkill itself before the caller's fallback may run. * This is deliberately separate from POSIX SIGTERM grace: on Windows the * direct-child fallback must not fire while taskkill is still walking the * child tree, or it can orphan grandchildren that keep stdio open. */ timeoutMs?: number | undefined; onSettled?: (() => void) | undefined; } /** * Kill an entire process tree on Windows via `taskkill /T /F`. * * TerminateProcess (what `child.kill()` maps to) has no process-group * semantics, so killing a shell wrapper (`cmd.exe /c …`) orphans its * grandchildren (node, vitest forks, dev servers). The orphans inherit the * parent's stdio pipe handles and can keep streaming into this process for * the rest of the session — which both prevents the child's 'close' event * from ever firing and grows in-memory output buffers without bound. * * Returns true if taskkill was spawned, false if spawning it failed (caller * should fall back to a direct `child.kill()`). Callers that need a direct * fallback should pass `onSettled`; it runs after taskkill exits, errors, or * exceeds `timeoutMs`, avoiding the race where killing cmd.exe first prevents * taskkill from enumerating and killing grandchildren. */ export declare function killWin32Tree(pid: number, opts?: Win32TreeKillOptions): boolean; export declare class ProcessRegistryImpl { private readonly processes; private readonly breaker; /** * Auto kill/reset config. When the breaker trips and `autoKillResetMs > 0`, * a countdown is armed; on expiry all tracked processes are killed and the * breaker is reset to closed (forced recovery). Zero means manual recovery * only (`/kill reset`). */ private autoKillResetMs; private autoKillTimer; private autoKillArmedAt; private breakerCountdownListeners; constructor(breakerConfig?: CircuitBreakerConfig); register(info: Omit & { protected?: boolean | undefined; background?: boolean | undefined; }): void; private _isSafeSignalPid; private _canSignalProcessGroup; private _killChildDirect; private _killPosix; /** Unregister a process by PID. Called on 'close' / 'exit' events. */ unregister(pid: number): void; /** Get a single process by PID. */ get(pid: number): TrackedProcess | undefined; /** Get all tracked processes. */ list(): TrackedProcess[]; /** Get processes filtered by name (e.g. 'bash', 'exec'). */ byName(name: string): TrackedProcess[]; /** Get processes filtered by session. */ bySession(sessionId: string): TrackedProcess[]; /** Count of active (non-killed) processes. */ get activeCount(): number; /** Count of active jobs explicitly launched in background mode. */ get activeBackgroundCount(): number; /** * Combined stats for observability — used by /ps and the TUI status bar. */ stats(): RegistryStats; /** * Returns true if the circuit allows a new bash/exec call to proceed. * When false, callers MUST NOT spawn a process. */ get canProceed(): boolean; /** * Called before spawning a process. Returns true if allowed; false if * the circuit breaker is open. * * @param bypass - If true, skip circuit breaker check (for background processes). */ beforeCall(bypass?: boolean): boolean; /** * Called after a process finishes. `durationMs` is wall-clock time; * `failed` is true for non-zero exit codes. * * @param bypass - If true, do not update circuit breaker state (for background processes). */ afterCall(durationMs: number, failed: boolean, bypass?: boolean): void; /** Force-open the circuit breaker (Ctrl+C, /kill force). */ forceBreakerOpen(): void; /** Force-reset the circuit breaker to closed (/kill reset). */ forceBreakerReset(): void; /** * Configure circuit-breaker protection at runtime. Called from `/settings` * (instant, all modes) and on TUI mount (applies persisted config). * * - `enabled` toggles whether the breaker gates `bash`/`exec`. * - `autoKillResetMs` arms the auto kill/reset countdown when the breaker * trips (0 = manual recovery only). * * Re-applies cleanly on every call: cancels a pending countdown when the * timeout is cleared or protection disabled, and re-arms if the breaker is * currently open under the new settings. */ setBreakerConfig(cfg: { enabled?: boolean | undefined; autoKillResetMs?: number | undefined; }): void; /** * Live countdown to the next auto kill/reset, or null when nothing is armed. * The TUI polls this on a 1s tick while armed so the statusline decrements. */ getBreakerCountdown(): BreakerCountdown | null; /** * Subscribe to countdown arm/cancel events. Returns an unsubscribe function. * Use {@link getBreakerCountdown} for the live ticking value between events. */ onBreakerCountdownChange(listener: BreakerCountdownListener): () => void; private _emitBreakerCountdown; /** * Arm the auto kill/reset countdown. Idempotent: re-arming resets the window * (a fresh trip after a failed half-open probe restarts the clock). No-op * when protection is off or no timeout is configured. */ private _armAutoKillReset; private _cancelAutoKillReset; private _clearAutoKillTimer; /** Kill a single process by PID. * * On POSIX: sends SIGTERM to the *process group* (-pid) so that * runaway grandchild processes (`sleep 9999 & disown`) are also killed. * After `graceMs` a SIGKILL is sent if the process hasn't exited. * * On Windows: `child.kill()` maps to TerminateProcess — process groups * are not meaningfully supported. A second `force=true` call sends * SIGKILL (which maps to TerminateProcess again — the distinction is * in the exit code, not the signal). * * Returns true if the process was found and kill was attempted. */ kill(pid: number, opts?: KillOpts): boolean; /** * Kill all tracked processes. * Returns the PIDs that were kill()ed. */ killAll(opts?: KillOpts): number[]; /** * Kill all processes for a specific session. * Returns the PIDs that were kill()ed. */ killSession(sessionId: string, opts?: KillOpts): number[]; /** * Check whether a tracked process entry is stale — the child has exited * (exitCode !== null) AND it's been in the registry long enough that the * OS may have reused the PID for a new, unrelated process. * * P3 #24 (before-release.md): on POSIX, PIDs are reused after process * exit. If a tracked process exits but its 'close' event hasn't fired yet * (or was missed), the registry still holds the entry. A new process * gets the same PID, and PID-based lookups (get, kill, shouldBlockKill) * may incorrectly protect or target the wrong process. * * The 60s threshold is conservative — the OS typically waits much longer * before reusing a PID, but we want to clean up before that becomes a risk. */ private _isStaleEntry; /** * Remove a stale entry for a specific PID before any PID-based lookup. * This prevents PID reuse from causing the registry to act on a dead * process that has been replaced by a new one with the same PID. */ private _pruneStale; /** * Remove every stale entry, not just one PID. `list()`/`stats()` — the * surfaces the TUI status bar and `/ps` poll — must prune too: a child * whose 'close' event never fires (e.g. Windows grandchildren holding stdio * open) would otherwise linger in the registry until someone looks up its * exact PID, and PID reuse meanwhile makes `get()`/`kill()` target the * wrong process. RAM-leak audit 2026-07-31, MEDIUM. */ private _pruneAllStale; } export declare function getProcessRegistry(): ProcessRegistryImpl; /** Reset for tests. */ export declare function _resetProcessRegistry(): void; export type { KillOpts }; //# sourceMappingURL=process-registry.d.ts.map