/**
 * SessionWatchdog — Auto-remediation for stuck Claude sessions (Instar port).
 *
 * Detects when a Claude session has a long-running bash command and escalates
 * from gentle (Ctrl+C) to forceful (SIGKILL + session kill). Adapted from
 * Dawn Server's SessionWatchdog for Instar's self-contained architecture.
 *
 * Escalation pipeline:
 *   Level 0: Monitoring (default)
 *   Level 1: Ctrl+C via tmux send-keys
 *   Level 2: SIGTERM the stuck child PID
 *   Level 3: SIGKILL the stuck child PID
 *   Level 4: Kill tmux session
 */
import { execFile } from 'node:child_process';
import { promisify } from 'node:util';
import { EventEmitter } from 'node:events';
import fs from 'node:fs';
import path from 'node:path';
import { maybeRotateJsonl } from '../utils/jsonl-rotation.js';
import { evaluateThrottleSettle, RATE_LIMIT_SETTLED_CAPTURE_LINES, RATE_LIMIT_DEFAULT_SETTLE_MS, } from './rateLimitDetection.js';
const execFileAsync = promisify(execFile);
/**
 * ASYNC shell exec — the watchdog poll runs every 30s over EVERY running
 * session, several `ps`/`pgrep` probes each. With the old `spawnSync` those
 * probes blocked the single Node event loop for the full duration of each
 * subprocess; under load (dozens of sessions, a busy box) the cumulative stall
 * made the server miss its own /health window → false "server temporarily down"
 * + a restart loop (2026-06-07 incident). execFile is non-blocking: the event
 * loop (and /health) stays responsive while `ps` runs. Returns captured stdout
 * (or '' on a non-zero exit / timeout), matching the old spawnSync semantics
 * where a pgrep/egrep no-match simply yielded ''.
 */
async function shellExecAsync(cmd, timeout = 5000) {
    try {
        const { stdout } = await execFileAsync('/bin/sh', ['-c', cmd], {
            encoding: 'utf-8',
            timeout,
            maxBuffer: 4 * 1024 * 1024,
        });
        return stdout ?? '';
    }
    catch (err) {
        // @silent-fallback-ok — a failed process probe is not a degradation: a
        // pgrep/egrep no-match, a dead PID, or a timeout legitimately means "no
        // match", identical to the prior synchronous behavior where spawnSync simply
        // yielded an empty stdout string. Return any captured stdout, else ''.
        const e = err;
        return e && typeof e.stdout === 'string' ? e.stdout : '';
    }
}
import { createRequire } from 'node:module';
// ESM module: a bare CJS `require` is undefined and throws at runtime. Bind a
// real require via createRequire so the lazy load below is genuinely ESM-legal
// (mirrors NativeModuleHealer.ts).
const require = createRequire(import.meta.url);
export var EscalationLevel;
(function (EscalationLevel) {
    EscalationLevel[EscalationLevel["Monitoring"] = 0] = "Monitoring";
    EscalationLevel[EscalationLevel["CtrlC"] = 1] = "CtrlC";
    EscalationLevel[EscalationLevel["SigTerm"] = 2] = "SigTerm";
    EscalationLevel[EscalationLevel["SigKill"] = 3] = "SigKill";
    EscalationLevel[EscalationLevel["KillSession"] = 4] = "KillSession";
})(EscalationLevel || (EscalationLevel = {}));
// Processes that are long-running by design.
// Entries can be strings (substring match via `includes`) or regex (token match).
// MCP regex token boundaries (lookahead): $ \s / . @ — end, whitespace, path,
// file-extension, npm-version-pin. Extend (docker `:tag`, pip `==ver`) when a
// live case proves it necessary.
const EXCLUDED_PATTERNS = [
    'playwright-persistent',
    'chrome-native-host', 'caffeinate',
    // Any *-mcp executable is an MCP stdio server, long-running by design
    // (waits on stdin for the host client). This catches workspace-mcp,
    // exa-mcp-server, foo-mcp, claude-in-chrome-mcp, bar-mcp-server.js, etc.
    // Character class includes `-` so multi-hyphen names (claude-in-chrome-mcp)
    // are consumed whole. Trailing lookahead allows end, whitespace, slash,
    // `.` (so `-mcp-server.js` style entry points match), or `@` (so
    // version-pinned invocations like `foo-mcp@1.2.3` match).
    /(?:^|[\s/@])[\w.@-]+-mcp(?:-server)?(?=$|[\s/.@])/,
    // Package-style "@scope/mcp" where the last token is bare "mcp"
    // (e.g. @playwright/mcp, @modelcontextprotocol/mcp). Lookahead allows `@`
    // so `@playwright/mcp@latest` and other version pins match.
    /(?:^|\s)[@\w./-]+\/mcp(?=$|[\s/@])/,
    'mcp-remote', '/mcp/', '.mcp/',
    'mcp-stdio-entry', 'mcp-stdio.js', '/mcp-stdio',
    // Shell-snapshot sourcing is session initialization, not a stuck command
    '.claude/shell-snapshots',
];
// Commands whose job is to consume stdin from a pipeline. When they appear
// as a "stuck" child it's almost always because zsh -c "cmd | consumer"
// exec'd the last pipeline member into place — the consumer is waiting on
// the upstream producer, which is the real long-running work. Before
// escalating, check if the consumer has an active pipeline sibling.
//
// Regex matches the executable name (first whitespace-delimited token,
// optionally leading path). Consumers that require a file argument to be
// meaningful (e.g. `tail FILE`) are still flagged if a file arg is present,
// because then they aren't reading from a pipe.
const STDIN_CONSUMER_PATTERNS = [
    { cmd: /^(?:\S*\/)?tail(?:\s|$)/, requiresNoFileArg: true },
    { cmd: /^(?:\S*\/)?head(?:\s|$)/, requiresNoFileArg: true },
    { cmd: /^(?:\S*\/)?less(?:\s|$)/, requiresNoFileArg: true },
    { cmd: /^(?:\S*\/)?more(?:\s|$)/, requiresNoFileArg: true },
    { cmd: /^(?:\S*\/)?cat(?:\s|$)/, requiresNoFileArg: true },
    { cmd: /^(?:\S*\/)?grep(?:\s|$)/, requiresNoFileArg: true },
    { cmd: /^(?:\S*\/)?sort(?:\s|$)/, requiresNoFileArg: true },
    { cmd: /^(?:\S*\/)?uniq(?:\s|$)/, requiresNoFileArg: true },
    { cmd: /^(?:\S*\/)?awk(?:\s|$)/, requiresNoFileArg: true },
    { cmd: /^(?:\S*\/)?sed(?:\s|$)/, requiresNoFileArg: true },
    { cmd: /^(?:\S*\/)?tr(?:\s|$)/, requiresNoFileArg: false },
    { cmd: /^(?:\S*\/)?wc(?:\s|$)/, requiresNoFileArg: true },
    { cmd: /^(?:\S*\/)?xargs(?:\s|$)/, requiresNoFileArg: false },
    { cmd: /^(?:\S*\/)?jq(?:\s|$)/, requiresNoFileArg: true },
];
const EXCLUDED_PREFIXES = [
    '/bin/zsh -c -l source',
    '/bin/bash -c -l source',
    // Shell-snapshot commands don't always include -l flag
    '/bin/zsh -c source',
    '/bin/bash -c source',
];
// Escalation delays (ms to wait before advancing to next level)
const ESCALATION_DELAYS = {
    [EscalationLevel.Monitoring]: 0,
    [EscalationLevel.CtrlC]: 0,
    [EscalationLevel.SigTerm]: 15_000,
    [EscalationLevel.SigKill]: 10_000,
    [EscalationLevel.KillSession]: 5_000,
};
const DEFAULT_STUCK_THRESHOLD_MS = 180_000; // 3 minutes
const DEFAULT_POLL_INTERVAL_MS = 30_000;
const MAX_RETRIES = 2;
export class SessionWatchdog extends EventEmitter {
    config;
    sessionManager;
    state;
    interval = null;
    escalationState = new Map();
    interventionHistory = [];
    enabled = true;
    /** Liveness of the poll loop (GUARD-POSTURE-ENDPOINT-SPEC §2.2): 0 = never polled. */
    lastPollAt = 0;
    running = false;
    stuckThresholdMs;
    pollIntervalMs;
    /** Deterministic ceiling (ms) past which a command is killed even when the LLM
     *  stuck-judge is unavailable/errors. Below it, the watchdog fails CLOSED (no
     *  interrupt) rather than fail-open. 0 disables the ceiling (pure fail-closed). */
    hardCeilingMs;
    logPath;
    /** Intelligence provider — gates escalation entry with LLM command analysis */
    intelligence = null;
    /** Temporarily exempted commands (LLM confirmed as legitimate long-running) */
    temporaryExclusions = new Set(); // PIDs
    /** Counter for LLM gate overrides (said "legitimate") — for telemetry */
    llmGateOverrides = 0;
    /** Pending outcome checks — maps sessionName to intervention event */
    pendingOutcomeChecks = new Map();
    /** Cooldowns for compaction-idle detection — prevents repeated emissions */
    compactionIdleCooldowns = new Map(); // sessionName → timestamp
    static COMPACTION_IDLE_COOLDOWN_MS = 300_000; // 5 minutes
    /** Cooldowns for rate-limited detection — prevents repeated emissions */
    rateLimitedCooldowns = new Map(); // sessionName → timestamp
    static RATE_LIMITED_COOLDOWN_MS = 60_000; // 1 minute (sentinel dedupes too)
    /**
     * Settle tracking for the throttle backstop — per session, the fingerprint of
     * the last poll's pane and when it first appeared unchanged. A throttled pane
     * that stays byte-identical across polls means the turn ended (no spinner
     * animating), i.e. the session is genuinely stuck and safe to recover.
     */
    throttleSettle = new Map();
    /** How long a throttled pane must be byte-identical before recovery (configurable for tests/tuning). */
    rateLimitSettleMs;
    constructor(config, sessionManager, state) {
        super();
        this.config = config;
        this.sessionManager = sessionManager;
        this.state = state;
        const wdConfig = config.monitoring.watchdog;
        this.stuckThresholdMs = (wdConfig?.stuckCommandSec ?? 180) * 1000;
        this.pollIntervalMs = wdConfig?.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS;
        // Default 30 min. `?? `, not `||`, so an explicit 0 (disable ceiling) is honored.
        this.hardCeilingMs = (wdConfig?.hardCeilingSec ?? 1800) * 1000;
        this.rateLimitSettleMs = wdConfig?.rateLimitSettleMs ?? RATE_LIMIT_DEFAULT_SETTLE_MS;
        // Persistent log path
        this.logPath = path.join(config.stateDir, 'watchdog-interventions.jsonl');
    }
    start() {
        if (this.interval)
            return;
        console.log(`[Watchdog] Starting (poll: ${this.pollIntervalMs / 1000}s, threshold: ${this.stuckThresholdMs / 1000}s)`);
        this.interval = setInterval(() => this.poll(), this.pollIntervalMs);
        setTimeout(() => this.poll(), 5000);
    }
    stop() {
        if (this.interval) {
            clearInterval(this.interval);
            this.interval = null;
        }
    }
    setEnabled(enabled) {
        this.enabled = enabled;
        if (!enabled) {
            this.escalationState.clear();
        }
    }
    isEnabled() {
        return this.enabled;
    }
    isManaging(sessionName) {
        const s = this.escalationState.get(sessionName);
        return s !== undefined && s.level > EscalationLevel.Monitoring;
    }
    getStatus() {
        const runningSessions = this.sessionManager.listRunningSessions();
        const sessions = runningSessions.map(s => ({
            name: s.tmuxSession,
            escalation: this.escalationState.get(s.tmuxSession) ?? null,
        }));
        return {
            enabled: this.enabled,
            sessions,
            interventionHistory: this.interventionHistory.slice(-20),
        };
    }
    // --- Core polling ---
    async poll() {
        this.lastPollAt = Date.now();
        if (!this.enabled || this.running)
            return;
        this.running = true;
        try {
            const sessions = this.sessionManager.listRunningSessions();
            for (const session of sessions) {
                try {
                    await this.checkSession(session.tmuxSession);
                }
                catch (err) {
                    console.error(`[Watchdog] Error checking "${session.tmuxSession}":`, err);
                }
                // Yield to the event loop between sessions so a poll over many sessions
                // can never monopolize the loop and starve /health (which shares it).
                await new Promise((resolve) => setImmediate(resolve));
            }
        }
        finally {
            this.running = false;
        }
    }
    async checkSession(tmuxSession) {
        const existing = this.escalationState.get(tmuxSession);
        if (existing && existing.level > EscalationLevel.Monitoring) {
            await this.handleEscalation(tmuxSession, existing);
            return;
        }
        // Find Claude PID in the tmux session. If we can't locate it, we skip
        // the stuck-command path but still run compaction-idle detection —
        // that path is output-based and doesn't strictly need a PID (its own
        // process guard is null-safe).
        const claudePid = await this.getClaudePid(tmuxSession);
        if (!claudePid) {
            await this.checkCompactionIdle(tmuxSession);
            return;
        }
        const children = await this.getChildProcesses(claudePid);
        const stuckChild = children.find(c => {
            if (this.isExcluded(c.command))
                return false;
            if (this.temporaryExclusions.has(c.pid))
                return false;
            // Stdin consumers (tail, grep, sort...) get a much longer grace
            // period. They're typically part of a pipeline where the producer
            // is the real work, and the 3-minute default threshold is too
            // aggressive for long-running builds, tests, or measurements.
            const threshold = this.isStdinConsumerCommand(c.command)
                ? Math.max(this.stuckThresholdMs, 600_000) // at least 10 minutes
                : this.stuckThresholdMs;
            return c.elapsedMs > threshold;
        });
        if (stuckChild) {
            // Pipeline guard: if the stuck child is a pure stdin consumer (tail,
            // grep, sort, etc.) and its process group or descendants still
            // contain active sibling producers, the "stuck" process is just
            // waiting for upstream output from a legitimate long-running
            // pipeline. Skip escalation.
            if (await this.hasActivePipelineSibling(stuckChild.pid, stuckChild.command)) {
                console.log(`[Watchdog] "${tmuxSession}": ${stuckChild.command.slice(0, 60)} ` +
                    `is consuming an active pipeline — skipping escalation`);
                this.temporaryExclusions.add(stuckChild.pid);
                return;
            }
            // LLM gate: check if this command is legitimately long-running before escalating.
            // Pass recent tmux output so the LLM can see what the session is actually doing,
            // not just the (often truncated) child command name.
            const recentOutput = this.sessionManager.captureOutput(tmuxSession, 30) ?? '';
            const isStuck = await this.isCommandStuck(stuckChild.command, stuckChild.elapsedMs, recentOutput);
            if (!isStuck) {
                // LLM says legitimate — temporarily exclude this PID from future checks
                this.temporaryExclusions.add(stuckChild.pid);
                return;
            }
            // Fail-closed for stdin-consumers when the LLM isn't available or is
            // uncertain: killing a `tail`/`grep` in an apparent pipeline has high
            // blast radius (takes down the whole producer pipeline), so we only
            // escalate these with explicit LLM confirmation. Without an LLM, we
            // skip and log — a true orphaned consumer will be cleaned up when
            // its tmux session terminates.
            if (this.isStdinConsumerCommand(stuckChild.command) && !this.intelligence) {
                console.log(`[Watchdog] "${tmuxSession}": stdin-consumer ${stuckChild.command.slice(0, 60)} ` +
                    `without LLM gate — refusing to escalate (fail-closed for pipeline safety)`);
                this.temporaryExclusions.add(stuckChild.pid);
                return;
            }
            const state = {
                level: EscalationLevel.CtrlC,
                levelEnteredAt: Date.now(),
                stuckChildPid: stuckChild.pid,
                stuckCommand: stuckChild.command,
                retryCount: existing?.retryCount ?? 0,
            };
            this.escalationState.set(tmuxSession, state);
            console.log(`[Watchdog] "${tmuxSession}": stuck command (${Math.round(stuckChild.elapsedMs / 1000)}s): ` +
                `${stuckChild.command.slice(0, 80)} — sending Ctrl+C`);
            this.sessionManager.sendKey(tmuxSession, 'C-c');
            this.recordIntervention(tmuxSession, EscalationLevel.CtrlC, 'Sent Ctrl+C', stuckChild);
        }
        else if (existing) {
            this.escalationState.delete(tmuxSession);
        }
        // Clean up temporary exclusions for dead processes
        for (const pid of this.temporaryExclusions) {
            if (!this.isProcessAlive(pid)) {
                this.temporaryExclusions.delete(pid);
            }
        }
        // Post-compaction idle detection — catch sessions that compacted and are
        // sitting at a bare prompt with no one at the terminal to nudge them.
        // This is the polling-based fallback for the PreCompact event path which
        // is unreliable (Claude Code doesn't always fire the event).
        await this.checkCompactionIdle(tmuxSession);
        // Server-side throttle detection — catch sessions stopped at a prompt after
        // Anthropic's "temporarily limiting requests" throttle exhausted Claude's
        // own retries. Signal-only: emits 'rate-limited' for the RateLimitSentinel
        // to own recovery (backoff-before-nudge).
        this.checkRateLimited(tmuxSession);
    }
    /**
     * Detects sessions stuck on a server-side capacity throttle (NOT the user's
     * usage quota, NOT mid-retry) and hands recovery to the RateLimitSentinel via
     * the 'rate-limited' event.
     *
     * Detection uses the SETTLED-OUTPUT signal: the throttle string is present in
     * the (widened) recent pane AND that pane has not changed since the previous
     * poll. An actively-working Claude session animates its spinner + elapsed
     * timer every tick, so byte-identical output across polls proves no work is
     * being produced — the turn ended on the throttle.
     *
     * This deliberately REPLACES the old gates that made this sentinel never fire
     * in the wild (2026-05-30 incident):
     *   - the active-child-process check (a lingering background shell masked the
     *     throttle on busy dev sessions), and
     *   - the at-prompt / 20-line-window check (Claude's input box + footer push
     *     the "API Error" line out of view).
     * The settle guard + the wider window (RATE_LIMIT_SETTLED_CAPTURE_LINES)
     * cover both failure modes while staying false-positive-safe.
     */
    checkRateLimited(tmuxSession) {
        const output = this.sessionManager.captureOutput(tmuxSession, RATE_LIMIT_SETTLED_CAPTURE_LINES);
        const now = Date.now();
        const { decision, next } = evaluateThrottleSettle(output, this.throttleSettle.get(tmuxSession), now, { settleMs: this.rateLimitSettleMs });
        if (decision === 'no-throttle') {
            // Throttle absent / mid-retry / usage-limit, or session moved on → reset.
            this.throttleSettle.delete(tmuxSession);
            return;
        }
        // Throttle present — record/refresh the settle clock either way.
        if (next)
            this.throttleSettle.set(tmuxSession, next);
        if (decision === 'waiting') {
            // Pane still changing, or not settled long enough — recheck next poll.
            return;
        }
        // decision === 'settled' — genuinely stuck on the throttle. Respect the
        // emit cooldown so we don't restart recovery every poll (the sentinel also
        // dedupes). After the sentinel escalates and clears (~30s), a still-stuck
        // pane re-emits past this cooldown — that unbounded retry is exactly the
        // "a session can never hang forever on a 429" guarantee.
        const lastEmitted = this.rateLimitedCooldowns.get(tmuxSession);
        if (lastEmitted && (now - lastEmitted) < SessionWatchdog.RATE_LIMITED_COOLDOWN_MS)
            return;
        const settledMs = now - (next?.since ?? now);
        console.log(`[Watchdog] "${tmuxSession}": rate-limited detected — server throttle, ` +
            `pane settled ${Math.round(settledMs / 1000)}s (handing to RateLimitSentinel)`);
        this.rateLimitedCooldowns.set(tmuxSession, now);
        this.emit('rate-limited', tmuxSession);
    }
    /**
     * Detects sessions that just went through context compaction and are idle
     * at a prompt. Emits 'compaction-idle' so server.ts can activate triage
     * to reinject pending user messages.
     *
     * Defense-in-depth against false positives:
     *   1. Compaction marker must appear in last 10 lines (recency — avoids stale buffer)
     *   2. Prompt must be in last 3 lines (structural — avoids > in content)
     *   3. No active child processes (process-level — Claude is truly idle, not mid-tool)
     *   4. Cooldown per session (prevents re-triggering on same compaction)
     *   5. server.ts checks message history before activating triage (data-level guard)
     *   6. TriageOrchestrator Pattern 2b re-validates before reinjecting (redundant check)
     */
    async checkCompactionIdle(tmuxSession) {
        // Cooldown — don't re-emit for the same session within 5 minutes
        const lastEmitted = this.compactionIdleCooldowns.get(tmuxSession);
        if (lastEmitted && (Date.now() - lastEmitted) < SessionWatchdog.COMPACTION_IDLE_COOLDOWN_MS) {
            return;
        }
        // Guard 1: Structural process check — if Claude has active child processes,
        // it's executing tools/commands, not stalled. Skip entirely.
        const claudePid = await this.getClaudePid(tmuxSession);
        if (claudePid) {
            const children = await this.getChildProcesses(claudePid);
            const activeChildren = children.filter(c => !this.isExcluded(c.command));
            if (activeChildren.length > 0)
                return;
        }
        // Capture recent tmux output — only last 10 lines to ensure compaction is recent.
        // If the compaction marker has scrolled beyond 10 lines, the session has had
        // enough activity since compaction that it clearly recovered.
        const output = this.sessionManager.captureOutput(tmuxSession, 10);
        if (!output)
            return;
        // Guard 2: Compaction marker must be present in this narrow window
        if (!/Conversation compacted|✱.*compacted/i.test(output))
            return;
        // Guard 3: Prompt must be in the last 3 lines (tighter than general heuristics).
        // A bare `>` in markdown, code output, or file content won't appear as the
        // final line of tmux output — it would have subsequent content after it.
        const lines = output.split('\n').filter(l => l.trim());
        const tail = lines.slice(-3).join('\n');
        const atPrompt = tail.includes('❯') ||
            tail.includes('bypass permissions') ||
            /^>\s*$/m.test(tail) ||
            tail.trim() === '>';
        if (!atPrompt)
            return;
        // All guards passed — session compacted recently and is truly idle
        console.log(`[Watchdog] "${tmuxSession}": compaction-idle detected — session compacted and at prompt`);
        this.compactionIdleCooldowns.set(tmuxSession, Date.now());
        this.emit('compaction-idle', tmuxSession);
    }
    async handleEscalation(tmuxSession, state) {
        const now = Date.now();
        if (!this.isProcessAlive(state.stuckChildPid)) {
            console.log(`[Watchdog] "${tmuxSession}": stuck process ${state.stuckChildPid} died — recovered`);
            this.emit('recovery', tmuxSession, state.level);
            this.escalationState.delete(tmuxSession);
            return;
        }
        const timeInLevel = now - state.levelEnteredAt;
        const nextLevel = state.level + 1;
        if (nextLevel > EscalationLevel.KillSession) {
            if (state.retryCount >= MAX_RETRIES) {
                console.log(`[Watchdog] "${tmuxSession}": max retries reached — giving up`);
                this.escalationState.delete(tmuxSession);
                return;
            }
            state.level = EscalationLevel.CtrlC;
            state.levelEnteredAt = now;
            state.retryCount++;
            this.sessionManager.sendKey(tmuxSession, 'C-c');
            this.recordIntervention(tmuxSession, EscalationLevel.CtrlC, `Retry ${state.retryCount}: Sent Ctrl+C`, {
                pid: state.stuckChildPid, command: state.stuckCommand, elapsedMs: 0,
            });
            return;
        }
        const delayForNext = ESCALATION_DELAYS[nextLevel] ?? 15_000;
        if (timeInLevel < delayForNext)
            return;
        state.level = nextLevel;
        state.levelEnteredAt = now;
        const child = { pid: state.stuckChildPid, command: state.stuckCommand, elapsedMs: 0 };
        switch (state.level) {
            case EscalationLevel.SigTerm:
                console.log(`[Watchdog] "${tmuxSession}": sending SIGTERM to ${state.stuckChildPid}`);
                this.sendSignal(state.stuckChildPid, 'SIGTERM');
                this.recordIntervention(tmuxSession, EscalationLevel.SigTerm, `SIGTERM ${state.stuckChildPid}`, child);
                break;
            case EscalationLevel.SigKill:
                console.log(`[Watchdog] "${tmuxSession}": sending SIGKILL to ${state.stuckChildPid}`);
                this.sendSignal(state.stuckChildPid, 'SIGKILL');
                this.recordIntervention(tmuxSession, EscalationLevel.SigKill, `SIGKILL ${state.stuckChildPid}`, child);
                break;
            case EscalationLevel.KillSession: {
                // UNIFIED-SESSION-LIFECYCLE §P0 #5: route the final-level kill through the
                // single ReapAuthority instead of the raw `tmux kill-session`. The
                // authority consults the P2 KEEP-guard (relay-lease / active subagent /
                // recent-user-message / etc.) and the awake-machine lease gate before any
                // autonomous kill — so a session the guards would have kept survives a
                // buggy watchdog, gains exactly-once sessionComplete/sessionReaped
                // emission, and lands in the reap-log with its reason.
                console.log(`[Watchdog] "${tmuxSession}": requesting session kill via ReapAuthority`);
                const sess = this.sessionManager
                    .listRunningSessions()
                    .find((s) => s.tmuxSession === tmuxSession);
                if (!sess) {
                    // Session already gone — nothing to kill.
                    this.recordIntervention(tmuxSession, EscalationLevel.KillSession, 'session already gone', child);
                    this.escalationState.delete(tmuxSession);
                    break;
                }
                const result = await this.sessionManager.terminateSession(sess.id, 'watchdog-stuck', {
                    disposition: 'terminal',
                    finalStatus: 'killed',
                });
                if (result.terminated) {
                    this.recordIntervention(tmuxSession, EscalationLevel.KillSession, 'terminated via ReapAuthority', child);
                }
                else {
                    // The authority refused (protected / lease / a KEEP). Stand down — the
                    // guards own this decision; the §P5 backstop will surface a
                    // persistently-stale session for an operator decision rather than the
                    // watchdog re-escalating against a guarded session every tick.
                    console.log(`[Watchdog] "${tmuxSession}": kill refused — ${result.skipped} (standing down)`);
                    this.recordIntervention(tmuxSession, EscalationLevel.KillSession, `kept (${result.skipped})`, child);
                }
                this.escalationState.delete(tmuxSession);
                break;
            }
        }
    }
    /**
     * LLM gate: Before entering escalation, ask whether the command is
     * legitimately long-running or actually stuck. This prevents the watchdog
     * from killing legitimate builds, installs, or data processing.
     *
     * Returns true if the command appears stuck and should be escalated.
     * Returns false if the LLM thinks it's a legitimate long-running task.
     *
     * When the LLM judge is UNAVAILABLE (no provider) or ERRORS (rate-limited /
     * circuit-open / timeout — common under load), this FAILS CLOSED: it does NOT
     * interrupt below the deterministic hard ceiling, and only returns "stuck"
     * once the command has run past `hardCeilingMs`. This is the "No Silent
     * Degradation to Brittle Fallback" rule applied to a DESTRUCTIVE action
     * (Ctrl+C kills the running command): a false interrupt of a legitimate long
     * build/test is frequent and costly, so the safe direction when the judge
     * can't run is to leave the command alone — while the hard ceiling still
     * deterministically recovers a genuinely hung command (e.g. `crontab -`
     * waiting on stdin) without any LLM. (Previously this fail-opened, which under
     * load Ctrl+C'd every command running past the 3-min threshold.)
     */
    async isCommandStuck(command, elapsedMs, recentOutput = '') {
        if (!this.intelligence)
            return this.hardCeilingExceeded(elapsedMs); // No LLM → fail CLOSED (ceiling only)
        const elapsedMin = Math.round(elapsedMs / 60000);
        // Keep the output sample small — enough for context, not enough to blow the token budget.
        const outputSample = recentOutput ? recentOutput.slice(-1500) : '';
        const promptLines = [
            'You are evaluating whether a running process is stuck or legitimately long-running.',
            '',
            `Command: ${command.slice(0, 200)}`,
            `Running for: ${elapsedMin} minutes`,
        ];
        if (outputSample) {
            promptLines.push('', 'Recent terminal output (tail of the session\'s tmux pane — shows what the agent is doing):', '---', outputSample, '---');
        }
        promptLines.push('', 'Legitimate long-running commands include:', '- Package installs (npm install, pip install, cargo build, etc.)', '- Large builds (webpack, tsc with many files, docker build)', '- Database migrations or data processing', '- Test suites (pytest, vitest, jest with many tests)', '- Network operations (curl large files, git clone large repos)', '- Interactive processes (vim, less, ssh sessions)', '- Pipeline consumers (tail, grep, sort) whose producer is still running', '', 'Likely stuck commands include:', '- Simple commands that should complete in seconds (ls, cat, echo)', '- Commands with no output that normally produce output quickly', '- Processes that appear to be waiting for input that will never come', '', 'Note: if the terminal output shows an active tool/test/build still producing results,', 'the command is legitimate even if the bare command name looks trivial.', '', 'Is this command stuck or legitimate? Respond with exactly one word: stuck or legitimate.');
        const prompt = promptLines.join('\n');
        try {
            const response = await this.intelligence.evaluate(prompt, {
                maxTokens: 5,
                temperature: 0,
                attribution: { component: 'SessionWatchdog' }, // attribution for /metrics/features
            });
            const answer = response.trim().toLowerCase();
            if (answer === 'legitimate') {
                console.log(`[Watchdog] LLM says "${command.slice(0, 60)}" is legitimate — skipping escalation`);
                this.llmGateOverrides++;
                return false;
            }
            return true;
        }
        catch (err) {
            // LLM judge errored (rate-limit / circuit-open / timeout — common under
            // load). A destructive Ctrl+C must NOT fail-open to a brittle default
            // (that interrupted legitimate builds/tests under load). Fail CLOSED: only
            // escalate once the deterministic hard ceiling is exceeded.
            const stuck = this.hardCeilingExceeded(elapsedMs);
            console.warn(`[Watchdog] LLM command check failed; failing CLOSED ` +
                `(${stuck ? 'past hard ceiling → escalate' : 'no interrupt'}):`, err);
            return stuck;
        }
    }
    /**
     * Deterministic backstop for the fail-closed paths: true once a command has
     * run past `hardCeilingMs`. A ceiling of 0 disables it (pure fail-closed —
     * never interrupt without a positive LLM "stuck" verdict).
     */
    hardCeilingExceeded(elapsedMs) {
        return this.hardCeilingMs > 0 && elapsedMs > this.hardCeilingMs;
    }
    // --- Process utilities (self-contained, no shared module) ---
    /**
     * Resolve the framework-CLI PID inside the given tmux pane. Was
     * Claude-only — generalized for v1.0.0 provider portability so the
     * watchdog also finds Codex sessions (and future frameworks) running
     * inside Instar-managed panes.
     *
     * @deprecated method name retained for v0.x compat — internal callers
     * use the framework-generic shape.
     */
    async getClaudePid(tmuxSession) {
        return this.getFrameworkPid(tmuxSession);
    }
    async getFrameworkPid(tmuxSession) {
        try {
            // Get pane PID
            const panePidStr = (await shellExecAsync(`${this.config.sessions.tmuxPath} list-panes -t "=${tmuxSession}" -F "#{pane_pid}" 2>/dev/null`)).trim().split('\n')[0];
            if (!panePidStr)
                return null;
            const panePid = parseInt(panePidStr, 10);
            if (isNaN(panePid))
                return null;
            // Instar typically spawns the framework CLI directly as the pane's
            // root process, not as a child of a shell. Check the pane_pid's
            // own command first — if it matches a known framework binary,
            // return it. Without this, pgrep -P finds no match and the
            // watchdog silently no-ops.
            const paneCmd = (await shellExecAsync(`ps -p ${panePid} -o comm= 2>/dev/null`)).trim();
            // eslint-disable-next-line @typescript-eslint/no-var-requires
            const { listProcessSignals } = require('./frameworkProcessSignals.js');
            const signals = listProcessSignals();
            for (const signal of signals) {
                const bareName = signal.framework === 'claude-code' ? 'claude' : signal.framework === 'codex-cli' ? 'codex' : null;
                if (!bareName)
                    continue;
                // Match either bare binary name (`claude`, `-claude`, `codex`,
                // `-codex`) or a path tail (`/usr/local/bin/claude`).
                if (paneCmd === bareName
                    || paneCmd === `-${bareName}`
                    || paneCmd.endsWith(`/${bareName}`)) {
                    return panePid;
                }
            }
            // Fallback: pane runs a shell wrapper that has a framework CLI as
            // a child. egrep alternation over every framework's bracket needle.
            const needle = signals.map((s) => s.psGrepNeedle).join('|');
            const childPidStr = (await shellExecAsync(`pgrep -P ${panePid} 2>/dev/null | xargs -I@ ps -p @ -o pid=,command= 2>/dev/null | egrep -i '${needle}' | head -1 | awk '{print $1}'`)).trim();
            if (!childPidStr)
                return null;
            const pid = parseInt(childPidStr, 10);
            return isNaN(pid) ? null : pid;
        }
        catch {
            // @silent-fallback-ok — process detection returns null
            return null;
        }
    }
    async getChildProcesses(pid) {
        try {
            const childPidsStr = (await shellExecAsync(`pgrep -P ${pid} 2>/dev/null`)).trim();
            if (!childPidsStr)
                return [];
            const childPids = childPidsStr.split('\n').filter(Boolean).join(',');
            if (!childPids)
                return [];
            const output = (await shellExecAsync(`ps -o pid=,etime=,command= -p ${childPids} 2>/dev/null`)).trim();
            if (!output)
                return [];
            const results = [];
            for (const line of output.split('\n')) {
                const match = line.trim().match(/^(\d+)\s+([\d:.-]+)\s+(.+)$/);
                if (!match)
                    continue;
                const childPid = parseInt(match[1], 10);
                if (isNaN(childPid))
                    continue;
                results.push({
                    pid: childPid,
                    command: match[3],
                    elapsedMs: this.parseElapsed(match[2]),
                });
            }
            return results;
        }
        catch {
            // @silent-fallback-ok — process enumeration returns empty
            return [];
        }
    }
    /**
     * Detects whether the given process is a pipeline stdin consumer (tail,
     * grep, sort, etc.) whose process group still contains an active sibling
     * producer. Such cases are false positives for the stuck-command detector:
     * `zsh -c "python ... | tail -40"` execs tail into place, so the child
     * of claude shows up as `tail -40` even though the real work is the
     * python producer still running in the same pgid.
     *
     * Returns true if this PID looks like a waiting consumer in an active
     * pipeline — in which case escalation should be skipped.
     */
    async hasActivePipelineSibling(pid, command) {
        // Step 1: is the command a stdin-consuming candidate at all?
        const consumer = STDIN_CONSUMER_PATTERNS.find(p => p.cmd.test(command));
        if (!consumer)
            return false;
        // Step 2: if the consumer was given a file arg, it's not reading from
        // a pipe (e.g. `tail -f /var/log/foo` genuinely tails that file).
        if (consumer.requiresNoFileArg && this.hasFileArgument(command))
            return false;
        // Step 3: find the process group and peers.
        try {
            const pgidStr = (await shellExecAsync(`ps -o pgid= -p ${pid} 2>/dev/null`)).trim();
            const pgid = parseInt(pgidStr, 10);
            // Check process group peers (pipelines share pgid on macOS/Linux).
            if (!isNaN(pgid) && pgid > 0) {
                const peersOutput = (await shellExecAsync(`ps -o pid=,command= -g ${pgid} 2>/dev/null`)).trim();
                for (const line of peersOutput.split('\n')) {
                    const match = line.trim().match(/^(\d+)\s+(.+)$/);
                    if (!match)
                        continue;
                    const peerPid = parseInt(match[1], 10);
                    const peerCmd = match[2];
                    if (peerPid === pid)
                        continue; // self
                    if (peerCmd.startsWith('ps ') || peerCmd.startsWith('sh -c'))
                        continue; // our own probe
                    if (this.isExcluded(peerCmd))
                        continue;
                    return true;
                }
            }
            // Fallback: also check direct descendants of the consumer PID. In
            // some shell exec patterns the producer becomes a CHILD of the
            // exec'd consumer rather than a pgid sibling.
            const childPidsStr = (await shellExecAsync(`pgrep -P ${pid} 2>/dev/null`)).trim();
            if (childPidsStr) {
                const childPids = childPidsStr.split('\n').filter(Boolean).join(',');
                if (childPids) {
                    const childOutput = (await shellExecAsync(`ps -o pid=,command= -p ${childPids} 2>/dev/null`)).trim();
                    for (const line of childOutput.split('\n')) {
                        const match = line.trim().match(/^(\d+)\s+(.+)$/);
                        if (!match)
                            continue;
                        const childCmd = match[2];
                        if (this.isExcluded(childCmd))
                            continue;
                        return true;
                    }
                }
            }
            return false;
        }
        catch {
            // @silent-fallback-ok — on error, fall through to normal escalation path
            return false;
        }
    }
    /**
     * Cheap check whether a command is a pure stdin consumer (tail, grep,
     * sort, etc.) without needing pgid lookups. Used to apply extended
     * grace periods and fail-closed escalation for this class of commands.
     */
    isStdinConsumerCommand(command) {
        const consumer = STDIN_CONSUMER_PATTERNS.find(p => p.cmd.test(command));
        if (!consumer)
            return false;
        if (consumer.requiresNoFileArg && this.hasFileArgument(command))
            return false;
        return true;
    }
    /**
     * Heuristic: does a stdin-consumer command have a file argument? If so,
     * it's reading from the file (not a pipe) and the pipeline guard doesn't
     * apply. We strip flags (tokens starting with `-`) and anything that
     * looks like a flag value, then check if a non-flag token remains past
     * the executable name.
     */
    hasFileArgument(command) {
        const tokens = command.split(/\s+/).slice(1); // drop executable
        for (let i = 0; i < tokens.length; i++) {
            const tok = tokens[i];
            if (!tok)
                continue;
            if (tok.startsWith('-')) {
                // Skip short-option values for well-known flags that take one
                // (tail -n N, head -c N, grep -A N, etc.). This is heuristic —
                // we prefer false-negatives (no file detected) over false-positives.
                if (/^-[nNcACB]$/.test(tok))
                    i++; // next token is the value
                continue;
            }
            // Non-flag token that isn't the executable → file argument
            return true;
        }
        return false;
    }
    isExcluded(command) {
        for (const pattern of EXCLUDED_PATTERNS) {
            if (typeof pattern === 'string') {
                if (command.includes(pattern))
                    return true;
            }
            else if (pattern.test(command)) {
                return true;
            }
        }
        for (const prefix of EXCLUDED_PREFIXES) {
            if (command.startsWith(prefix))
                return true;
        }
        return false;
    }
    parseElapsed(elapsed) {
        let days = 0;
        let timePart = elapsed;
        if (elapsed.includes('-')) {
            const [d, t] = elapsed.split('-');
            days = parseInt(d, 10);
            timePart = t;
        }
        const parts = timePart.split(':').map(Number);
        let seconds = 0;
        if (parts.length === 3)
            seconds = parts[0] * 3600 + parts[1] * 60 + parts[2];
        else if (parts.length === 2)
            seconds = parts[0] * 60 + parts[1];
        else
            seconds = parts[0];
        return (days * 86400 + seconds) * 1000;
    }
    sendSignal(pid, signal) {
        try {
            process.kill(pid, signal);
        }
        catch (err) {
            // @silent-fallback-ok — ESRCH expected for dead processes
            if (err.code !== 'ESRCH') {
                console.error(`[Watchdog] Failed to send ${signal} to ${pid}:`, err);
            }
        }
    }
    isProcessAlive(pid) {
        try {
            process.kill(pid, 0);
            return true;
        }
        catch {
            // @silent-fallback-ok — signal 0 check
            return false;
        }
    }
    recordIntervention(sessionName, level, action, child) {
        const event = {
            sessionName,
            level,
            action,
            stuckCommand: child.command.slice(0, 200),
            stuckPid: child.pid,
            timestamp: Date.now(),
        };
        this.interventionHistory.push(event);
        if (this.interventionHistory.length > 50) {
            this.interventionHistory = this.interventionHistory.slice(-50);
        }
        this.emit('intervention', event);
        // Schedule outcome check — 60s later, was the session still alive?
        if (level === EscalationLevel.CtrlC) {
            // Only track outcome from the first intervention (Ctrl+C)
            this.pendingOutcomeChecks.set(sessionName, event);
            setTimeout(() => this.checkOutcome(sessionName, event), 60_000);
        }
        // Persist to JSONL
        this.persistEvent(event);
    }
    /**
     * Check session health 60s after an intervention.
     * Did the session recover (still producing output) or die?
     */
    checkOutcome(sessionName, event) {
        const pending = this.pendingOutcomeChecks.get(sessionName);
        if (!pending || pending.timestamp !== event.timestamp)
            return;
        this.pendingOutcomeChecks.delete(sessionName);
        const sessions = this.sessionManager.listRunningSessions();
        const stillRunning = sessions.some(s => s.tmuxSession === sessionName);
        event.outcome = stillRunning ? 'recovered' : 'died';
        event.outcomeDelayMs = Date.now() - event.timestamp;
        // Persist the outcome update
        this.persistEvent({ ...event, _outcomeUpdate: true });
        this.emit('outcome', { sessionName, outcome: event.outcome, level: event.level });
    }
    /**
     * Append an event to the persistent JSONL log.
     * 30-day retention, auto-rotated.
     */
    persistEvent(event) {
        try {
            const dir = path.dirname(this.logPath);
            if (!fs.existsSync(dir)) {
                fs.mkdirSync(dir, { recursive: true });
            }
            fs.appendFileSync(this.logPath, JSON.stringify(event) + '\n');
            maybeRotateJsonl(this.logPath); // 10MB default, keep 75%
        }
        catch {
            // @silent-fallback-ok — persistence failure is non-critical
        }
    }
    /**
     * Read persistent intervention log entries since a given time.
     */
    readLog(sinceMs) {
        try {
            if (!fs.existsSync(this.logPath))
                return [];
            const content = fs.readFileSync(this.logPath, 'utf-8').trim();
            if (!content)
                return [];
            const since = sinceMs ?? 0;
            return content.split('\n')
                .map(line => { try {
                return JSON.parse(line);
            }
            catch {
                return null;
            } })
                .filter((e) => e !== null && e.timestamp >= since);
        }
        catch {
            // @silent-fallback-ok — log read failure returns empty
            return [];
        }
    }
    /**
     * Get aggregated watchdog stats for a time window.
     * Used by TelemetryCollector for Baseline submissions.
     */
    getStats(sinceMs) {
        const events = this.readLog(sinceMs);
        const levelNames = ['monitoring', 'ctrl-c', 'sigterm', 'sigkill', 'kill-session'];
        const stats = {
            interventionsTotal: 0,
            interventionsByLevel: {},
            recoveries: 0,
            sessionDeaths: 0,
            outcomeUnknown: 0,
            llmGateOverrides: this.llmGateOverrides,
        };
        for (const event of events) {
            // Skip outcome update entries
            if (event._outcomeUpdate) {
                if (event.outcome === 'recovered')
                    stats.recoveries++;
                else if (event.outcome === 'died')
                    stats.sessionDeaths++;
                else
                    stats.outcomeUnknown++;
                continue;
            }
            stats.interventionsTotal++;
            const levelName = levelNames[event.level] ?? `level-${event.level}`;
            stats.interventionsByLevel[levelName] = (stats.interventionsByLevel[levelName] || 0) + 1;
        }
        return stats;
    }
    /**
     * Rotate the persistent log — remove entries older than 30 days.
     */
    rotateLog() {
        try {
            if (!fs.existsSync(this.logPath))
                return;
            const content = fs.readFileSync(this.logPath, 'utf-8').trim();
            if (!content)
                return;
            const cutoff = Date.now() - 30 * 24 * 60 * 60 * 1000;
            const lines = content.split('\n');
            const fresh = lines.filter(line => {
                try {
                    const e = JSON.parse(line);
                    return e.timestamp >= cutoff;
                }
                catch {
                    return false;
                }
            });
            if (fresh.length < lines.length) {
                fs.writeFileSync(this.logPath, fresh.join('\n') + (fresh.length > 0 ? '\n' : ''));
            }
        }
        catch {
            // @silent-fallback-ok — rotation failure is non-critical
        }
    }
    /** Sync in-memory runtime read for the GuardRegistry (GET /guards).
     *  Cheap property read ONLY — getStatus() lists sessions. */
    guardStatus() {
        return { enabled: this.enabled, lastTickAt: this.lastPollAt };
    }
}
//# sourceMappingURL=SessionWatchdog.js.map