/**
 * OrphanProcessReaper — Detect and clean up orphaned agentic-CLI
 * processes (Claude, Codex, future frameworks).
 *
 * Addresses the critical gap where framework CLI processes spawned
 * outside SessionManager (setup-wizard, login flow, corrupted state)
 * are invisible to the watchdog and accumulate indefinitely.
 *
 * Classification strategy:
 *   1. "tracked" — In a tmux session managed by SessionManager → leave alone
 *   2. "instar-orphan" — In a tmux session matching project naming but not tracked → auto-clean
 *   3. "external" — Not in a project-prefixed tmux session (user's own framework session, VS Code, etc.) → report only
 *
 * Safety: NEVER auto-kills framework sessions outside Instar.
 * External processes are only reported via Telegram for user decision.
 *
 * Provider-portability v1.0.0: was Claude-only. Now scans every
 * registered framework via `frameworkProcessSignals.ts`.
 */
import { spawnSync } from 'node:child_process';
import { EventEmitter } from 'node:events';
import { listProcessSignals, matchProcessSignal, } from './frameworkProcessSignals.js';
/** Drop-in replacement for execSync that avoids its security concerns. */
function shellExec(cmd, timeout = 5000) {
    return spawnSync('/bin/sh', ['-c', cmd], { encoding: 'utf-8', timeout }).stdout ?? '';
}
const DEFAULT_POLL_INTERVAL = 60_000; // 1 minute
const DEFAULT_ORPHAN_MAX_AGE = 3_600_000; // 1 hour
const DEFAULT_EXTERNAL_REPORT_AGE = 14_400_000; // 4 hours
const DEFAULT_HIGH_MEMORY_MB = 500;
export class OrphanProcessReaper extends EventEmitter {
    config;
    reaperConfig;
    sessionManager;
    interval = null;
    projectPrefix;
    lastReport = null;
    reportedExternalPids = new Set(); // Don't spam about same PIDs
    lastExternalAlertTime = 0; // Cooldown to avoid spamming about normal VS Code/terminal processes
    static EXTERNAL_ALERT_COOLDOWN_MS = 24 * 60 * 60_000; // 24 hours
    constructor(config, sessionManager, reaperConfig = {}) {
        super();
        this.config = config;
        this.sessionManager = sessionManager;
        this.reaperConfig = reaperConfig;
        // Project prefix used in tmux session naming (e.g., "the-portal-")
        this.projectPrefix = `${config.projectName}-`;
    }
    start() {
        if (this.interval)
            return;
        const pollMs = this.reaperConfig.pollIntervalMs ?? DEFAULT_POLL_INTERVAL;
        console.log(`[OrphanReaper] Starting (poll: ${pollMs / 1000}s, orphan max age: ${(this.reaperConfig.orphanMaxAgeMs ?? DEFAULT_ORPHAN_MAX_AGE) / 60000}m)`);
        this.interval = setInterval(() => this.poll(), pollMs);
        // First check after 10 seconds (let other systems initialize)
        setTimeout(() => this.poll(), 10_000);
    }
    stop() {
        if (this.interval) {
            clearInterval(this.interval);
            this.interval = null;
        }
    }
    getLastReport() {
        return this.lastReport;
    }
    /**
     * Run a scan immediately and return the report.
     * Can be called on-demand via API endpoints.
     */
    async scan() {
        return this.poll();
    }
    // ── Core Poll ──────────────────────────────────────────────────────
    async poll() {
        const claudeProcesses = this.findAllFrameworkProcesses();
        const tmuxSessions = this.listAllTmuxSessions();
        const trackedSessions = this.getTrackedSessionNames();
        // UNIFIED-SESSION-LIFECYCLE §P0 #6 — every tmux name instar has ever known
        // (running + completed + failed + killed). The orphan classifier uses
        // EXACT-id membership in this set rather than a project-prefix substring, so
        // a user-created tmux session that happens to share the prefix (e.g. user
        // typing `tmux new -s the-portal-myhand`) is NOT false-classified as orphan.
        const knownInstarSessions = this.sessionManager.listKnownTmuxSessions();
        const classified = this.classifyProcesses(claudeProcesses, tmuxSessions, trackedSessions, knownInstarSessions);
        const tracked = classified.filter(p => p.classification === 'tracked');
        const orphans = classified.filter(p => p.classification === 'instar-orphan');
        const external = classified.filter(p => p.classification === 'external');
        const report = {
            timestamp: new Date().toISOString(),
            tracked,
            orphans,
            external,
            totalMemoryMB: Math.round(classified.reduce((sum, p) => sum + p.rssKB, 0) / 1024),
            orphanMemoryMB: Math.round(orphans.reduce((sum, p) => sum + p.rssKB, 0) / 1024),
            externalMemoryMB: Math.round(external.reduce((sum, p) => sum + p.rssKB, 0) / 1024),
            actionsPerformed: [],
        };
        // Handle orphans
        const orphanMaxAge = this.reaperConfig.orphanMaxAgeMs ?? DEFAULT_ORPHAN_MAX_AGE;
        const autoKill = this.reaperConfig.autoKillOrphans !== false; // default true
        const serverSessionName = `${this.config.projectName}-server`;
        for (const orphan of orphans) {
            if (orphan.elapsedMs > orphanMaxAge && autoKill) {
                // §P0 #6 P2-style work check: 60-min age is NECESSARY, not SUFFICIENT.
                // An orphan with active child processes is doing real work — defer the
                // reap rather than killing a working pipeline.
                if (this.processHasActiveChildren(orphan.pid)) {
                    const msg = `Kept orphan PID ${orphan.pid} (age ${this.formatDuration(orphan.elapsedMs)}, tmux: ${orphan.tmuxSession || 'none'}) — active child processes (work check vetoed reap)`;
                    report.actionsPerformed.push(msg);
                    console.log(`[OrphanReaper] ${msg}`);
                    continue;
                }
                this.killProcess(orphan.pid);
                // Also kill the tmux session if it exists — but NEVER the server session.
                // §P0 #6 route through ReapAuthority when the orphan's tmuxSession
                // matches a CURRENTLY-tracked session (a race between this scan and a
                // tracking refresh): the authority's KEEP-guard + lease gate + reap-log
                // + sessionReaped emission then apply. Otherwise the state record is
                // already terminal — just clean up the stray tmux pane.
                if (orphan.tmuxSession && orphan.tmuxSession !== serverSessionName) {
                    const trackedNow = this.sessionManager
                        .listRunningSessions()
                        .find((s) => s.tmuxSession === orphan.tmuxSession);
                    if (trackedNow) {
                        await this.sessionManager.terminateSession(trackedNow.id, 'orphan-reap', {
                            disposition: 'terminal',
                            finalStatus: 'killed',
                        });
                    }
                    else {
                        this.killTmuxSession(orphan.tmuxSession);
                    }
                }
                const msg = `Killed orphan PID ${orphan.pid} (${Math.round(orphan.rssKB / 1024)}MB, running ${this.formatDuration(orphan.elapsedMs)}, tmux: ${orphan.tmuxSession || 'none'})`;
                report.actionsPerformed.push(msg);
                console.log(`[OrphanReaper] ${msg}`);
            }
        }
        // Report high-memory or old external processes (but don't auto-kill)
        const externalReportAge = this.reaperConfig.externalReportAgeMs ?? DEFAULT_EXTERNAL_REPORT_AGE;
        const highMemMB = this.reaperConfig.highMemoryThresholdMB ?? DEFAULT_HIGH_MEMORY_MB;
        const alertCallback = this.reaperConfig.alertCallback;
        const newExternalAlerts = [];
        for (const ext of external) {
            const isOld = ext.elapsedMs > externalReportAge;
            const isHighMem = ext.rssKB / 1024 > highMemMB;
            const alreadyReported = this.reportedExternalPids.has(ext.pid);
            if ((isOld || isHighMem) && !alreadyReported) {
                newExternalAlerts.push(ext);
                this.reportedExternalPids.add(ext.pid);
            }
        }
        // Clean up reported PIDs for processes that no longer exist
        for (const pid of this.reportedExternalPids) {
            if (!external.some(p => p.pid === pid)) {
                this.reportedExternalPids.delete(pid);
            }
        }
        // Send alert about external processes if any are notable.
        // Cooldown: only alert once per 24h — external processes are usually normal
        // (VS Code, terminal sessions) and constant alerts are noise.
        const reportExternal = this.reaperConfig.reportExternalProcesses !== false;
        const now = Date.now();
        const externalAlertCooledDown = now - this.lastExternalAlertTime > OrphanProcessReaper.EXTERNAL_ALERT_COOLDOWN_MS;
        if (reportExternal && newExternalAlerts.length > 0 && alertCallback && externalAlertCooledDown) {
            const totalExternal = external.length;
            const totalMemMB = Math.round(external.reduce((sum, p) => sum + p.rssKB, 0) / 1024);
            const processWord = totalExternal === 1 ? 'process' : 'processes';
            // Group by framework so the alert reads naturally regardless of
            // which CLI tools the user has installed and running.
            const byFramework = new Map();
            for (const p of external)
                byFramework.set(p.framework, (byFramework.get(p.framework) ?? 0) + 1);
            const displayNames = new Map();
            for (const s of listProcessSignals())
                displayNames.set(s.framework, s.displayName);
            const breakdown = Array.from(byFramework.entries())
                .map(([f, n]) => `${n} ${displayNames.get(f) ?? f}`)
                .join(', ');
            const msg = `Found ${totalExternal} agent-CLI ${processWord} running outside your agent (${breakdown}; using ${totalMemMB}MB of memory). This is usually from VS Code or a terminal session you have open — no action needed if that's the case.\n\nIf you're not actively using these elsewhere, reply "clean processes" to free up the memory.`;
            try {
                await alertCallback(msg);
                this.lastExternalAlertTime = now;
                report.actionsPerformed.push(`Reported ${totalExternal} external process(es) to user`);
            }
            catch (err) {
                console.error('[OrphanReaper] Failed to send alert:', err);
            }
        }
        // Alert about orphan kills
        if (report.actionsPerformed.length > 0 && alertCallback) {
            const orphanKills = report.actionsPerformed.filter(a => a.startsWith('Killed orphan'));
            if (orphanKills.length > 0) {
                const processWord = orphanKills.length === 1 ? 'process' : 'processes';
                const msg = `Cleaned up ${orphanKills.length} orphaned ${processWord} that were left over from previous agent sessions, freeing ~${report.orphanMemoryMB}MB of memory. No action needed — this is automatic maintenance.`;
                try {
                    await alertCallback(msg);
                }
                catch (err) {
                    console.error('[OrphanReaper] Failed to send orphan alert:', err);
                }
            }
        }
        this.lastReport = report;
        this.emit('scan', report);
        return report;
    }
    // ── Process Discovery ──────────────────────────────────────────────
    /**
     * Find ALL framework-CLI processes (Claude, Codex, future frameworks)
     * owned by the current user.
     *
     * Builds a single `ps … | egrep …` pipeline with the disjunction of
     * every signal's psGrepNeedle, then parses each line and tags it with
     * the matched framework via `matchProcessSignal`. A single ps call
     * keeps the impact off the runtime hot-path.
     */
    findAllFrameworkProcesses() {
        const signals = listProcessSignals();
        if (signals.length === 0)
            return [];
        try {
            const uid = process.getuid?.() ?? 0;
            // egrep alternation over every framework's bracket-escaped needle.
            // The bracket trick keeps grep itself from matching its own argv.
            const needleAlternation = signals
                .map(s => s.psGrepNeedle)
                .join('|');
            const output = shellExec(`ps -u ${uid} -o pid=,ppid=,rss=,etime=,command= 2>/dev/null | egrep -i '${needleAlternation}' | grep -v grep`, 10_000).trim();
            if (!output)
                return [];
            const processes = [];
            for (const line of output.split('\n')) {
                const match = line.trim().match(/^(\d+)\s+(\d+)\s+(\d+)\s+([\d:.-]+)\s+(.+)$/);
                if (!match)
                    continue;
                const pid = parseInt(match[1], 10);
                const ppid = parseInt(match[2], 10);
                const rssKB = parseInt(match[3], 10);
                const elapsed = this.parseElapsed(match[4]);
                const command = match[5];
                // Skip the instar server process itself
                if (command.includes('instar') && command.includes('server'))
                    continue;
                // Match against EVERY framework signal — the line passed the
                // first-stage grep, but might have been from a helper false-
                // positive (the helper-prefix check inside matchProcessSignal
                // catches those).
                const signal = matchProcessSignal(command, signals);
                if (!signal)
                    continue;
                processes.push({
                    pid,
                    ppid,
                    rssKB,
                    elapsedMs: elapsed,
                    command: command.slice(0, 300),
                    tmuxSession: null, // Will be resolved in classification
                    framework: signal.framework,
                });
            }
            return processes;
        }
        catch { // @silent-fallback-ok — process listing may fail if ps is unavailable
            return [];
        }
    }
    /**
     * @deprecated v0.x compat shim — internal callers use the framework-
     * generic name. Retained so external code that referenced this method
     * via reflection or tests doesn't break. Will be removed in v2.
     */
    findAllClaudeProcesses() {
        return this.findAllFrameworkProcesses();
    }
    /**
     * @deprecated v0.x compat shim — delegates to `matchProcessSignal`
     * with the Claude-code signal. New code should use the framework-
     * generic helpers from `frameworkProcessSignals.ts`.
     */
    isClaudeCodeProcess(command) {
        return matchProcessSignal(command)?.framework === 'claude-code';
    }
    /**
     * List all tmux sessions and map pane PIDs to session names.
     */
    listAllTmuxSessions() {
        const pidToSession = new Map();
        try {
            const output = shellExec(`${this.config.sessions.tmuxPath} list-panes -a -F "#{session_name}||#{pane_pid}" 2>/dev/null`).trim();
            if (!output)
                return pidToSession;
            for (const line of output.split('\n')) {
                const [sessionName, pidStr] = line.split('||');
                if (sessionName && pidStr) {
                    const pid = parseInt(pidStr, 10);
                    if (!isNaN(pid)) {
                        pidToSession.set(pid, sessionName);
                    }
                }
            }
        }
        catch {
            // tmux not running or no sessions
        }
        return pidToSession;
    }
    /**
     * Get tmux session names tracked by SessionManager.
     */
    getTrackedSessionNames() {
        const running = this.sessionManager.listRunningSessions();
        return new Set(running.map(s => s.tmuxSession));
    }
    // ── Classification ─────────────────────────────────────────────────
    /**
     * Classify each Claude process:
     *   - tracked: In a tmux session managed by SessionManager
     *   - instar-orphan: In a project-prefixed tmux session NOT tracked by SessionManager
     *   - external: Everything else (user sessions, VS Code, etc.)
     */
    classifyProcesses(processes, tmuxSessions, trackedSessions, knownInstarSessions) {
        return processes.map(proc => {
            // Resolve which tmux session this process belongs to
            // Check if this PID or its parent is a tmux pane
            const directSession = tmuxSessions.get(proc.pid);
            const parentSession = tmuxSessions.get(proc.ppid);
            // Also check grandparent — Claude spawns under node which is under tmux pane
            const grandparentPid = this.getParentPid(proc.ppid);
            const grandparentSession = grandparentPid ? tmuxSessions.get(grandparentPid) : null;
            const tmuxSession = directSession || parentSession || grandparentSession || null;
            proc.tmuxSession = tmuxSession;
            if (tmuxSession) {
                // In a tmux session — is it tracked?
                if (trackedSessions.has(tmuxSession)) {
                    return {
                        ...proc,
                        classification: 'tracked',
                        reason: `Managed by SessionManager in tmux "${tmuxSession}"`,
                    };
                }
                // EXACT-id orphan: the tmux session name matches one instar has
                // tracked (current or historical), but isn't in the live-tracked set.
                // This replaces the old prefix-startsWith match per
                // UNIFIED-SESSION-LIFECYCLE §P0 #6 — a user-created session that happens
                // to share the project prefix is NOT classified as orphan.
                if (knownInstarSessions.has(tmuxSession)) {
                    return {
                        ...proc,
                        classification: 'instar-orphan',
                        reason: `In instar-known tmux "${tmuxSession}" but not currently tracked by SessionManager`,
                    };
                }
                // In some other tmux session — likely user's own
                return {
                    ...proc,
                    classification: 'external',
                    reason: `In tmux session "${tmuxSession}" (not project-prefixed)`,
                };
            }
            // Not in any tmux session — could be VS Code, terminal, or spawn()-based orphan
            const parentCommand = this.getProcessCommand(proc.ppid);
            // VS Code spawns Claude via its integrated terminal
            if (parentCommand && (parentCommand.includes('code') ||
                parentCommand.includes('Code Helper') ||
                parentCommand.includes('Electron') ||
                parentCommand.includes('cursor') ||
                parentCommand.includes('windsurf'))) {
                return {
                    ...proc,
                    classification: 'external',
                    reason: `Spawned by IDE (parent: ${parentCommand.slice(0, 80)})`,
                };
            }
            // Terminal.app, iTerm, Warp, etc.
            if (parentCommand && (parentCommand.includes('Terminal') ||
                parentCommand.includes('iTerm') ||
                parentCommand.includes('Warp') ||
                parentCommand.includes('Alacritty') ||
                parentCommand.includes('kitty') ||
                parentCommand.includes('zsh') ||
                parentCommand.includes('bash'))) {
                return {
                    ...proc,
                    classification: 'external',
                    reason: `Spawned by terminal (parent: ${parentCommand.slice(0, 80)})`,
                };
            }
            // Non-tmux, non-IDE process — might be a spawn() orphan from Instar
            // (e.g., setup-wizard). If it's old, classify as orphan.
            const orphanAge = this.reaperConfig.orphanMaxAgeMs ?? DEFAULT_ORPHAN_MAX_AGE;
            if (proc.elapsedMs > orphanAge) {
                return {
                    ...proc,
                    classification: 'external',
                    reason: `Non-tmux process with unknown parent (pid ${proc.ppid}: ${(parentCommand || 'unknown').slice(0, 60)}). Old but cannot confirm Instar origin — reporting as external for safety.`,
                };
            }
            // Recent non-tmux process — assume external (user just started it)
            return {
                ...proc,
                classification: 'external',
                reason: `Non-tmux process, parent: ${(parentCommand || 'unknown').slice(0, 80)}`,
            };
        });
    }
    // ── Process Utilities ──────────────────────────────────────────────
    getParentPid(pid) {
        try {
            const output = shellExec(`ps -o ppid= -p ${pid} 2>/dev/null`).trim();
            const ppid = parseInt(output, 10);
            return isNaN(ppid) ? null : ppid;
        }
        catch { // @silent-fallback-ok — process may not exist
            return null;
        }
    }
    getProcessCommand(pid) {
        try {
            const output = shellExec(`ps -o command= -p ${pid} 2>/dev/null`).trim();
            return output || null;
        }
        catch { // @silent-fallback-ok — process may not exist
            return null;
        }
    }
    killProcess(pid) {
        try {
            process.kill(pid, 'SIGTERM');
            // Give it 5 seconds, then SIGKILL if needed
            setTimeout(() => {
                try {
                    process.kill(pid, 0); // Check if still alive
                    process.kill(pid, 'SIGKILL');
                    console.log(`[OrphanReaper] SIGKILL sent to PID ${pid} (SIGTERM wasn't enough)`);
                }
                catch { // @silent-fallback-ok — process already dead (expected)
                    // Already dead — good
                }
            }, 5000);
            return true;
        }
        catch (err) { // @silent-fallback-ok — kill may fail if process already exited
            if (err.code !== 'ESRCH') {
                console.error(`[OrphanReaper] Failed to kill PID ${pid}:`, err);
            }
            return false;
        }
    }
    /**
     * §P0 #6 work-check: does the process have at least one active child? Used as
     * a 60-min-age-is-necessary-not-sufficient gate before a reap. A pgrep-empty
     * orphan is genuinely idle and safe to reap; an orphan with running children
     * is doing real work and must be kept.
     *
     * Errors → `false` (cannot inspect ⇒ allow the reap to proceed, matching the
     * pre-Phase-2 behavior; this never makes the reaper MORE aggressive — the
     * helper is a soft veto layered on top of the existing age gate).
     */
    processHasActiveChildren(pid) {
        try {
            const out = shellExec(`pgrep -P ${pid} 2>/dev/null`).trim();
            return out.length > 0;
        }
        catch {
            // @silent-fallback-ok — cannot inspect (no permission / process gone) ⇒
            // do not block the reap; the age gate already gives 60 min of grace.
            return false;
        }
    }
    killTmuxSession(sessionName) {
        try {
            shellExec(`${this.config.sessions.tmuxPath} kill-session -t "=${sessionName}" 2>/dev/null`);
        }
        catch {
            // Already dead
        }
    }
    /**
     * Manually kill an external process by PID.
     * Called via user command (e.g., "clean 12345").
     */
    killExternalProcess(pid) {
        const report = this.lastReport;
        if (!report) {
            return { success: false, message: 'No scan report available. Run a scan first.' };
        }
        const process = report.external.find(p => p.pid === pid);
        if (!process) {
            return { success: false, message: `PID ${pid} is not in the external process list.` };
        }
        const killed = this.killProcess(pid);
        if (process.tmuxSession) {
            this.killTmuxSession(process.tmuxSession);
        }
        return {
            success: killed,
            message: killed
                ? `Killed PID ${pid} (${Math.round(process.rssKB / 1024)}MB, ${process.reason})`
                : `Failed to kill PID ${pid}`,
        };
    }
    /**
     * Kill all external processes. User-initiated only.
     */
    killAllExternal() {
        const report = this.lastReport;
        if (!report) {
            return { killed: 0, freedMB: 0, details: ['No scan report available.'] };
        }
        const details = [];
        let freedKB = 0;
        for (const proc of report.external) {
            const killed = this.killProcess(proc.pid);
            if (proc.tmuxSession) {
                this.killTmuxSession(proc.tmuxSession);
            }
            if (killed) {
                freedKB += proc.rssKB;
                details.push(`Killed PID ${proc.pid} (${Math.round(proc.rssKB / 1024)}MB)`);
            }
        }
        return {
            killed: details.length,
            freedMB: Math.round(freedKB / 1024),
            details,
        };
    }
    // ── Parsing Utilities ──────────────────────────────────────────────
    parseElapsed(elapsed) {
        // Format: [[DD-]HH:]MM:SS or just MM:SS
        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;
    }
    formatDuration(ms) {
        const hours = Math.floor(ms / 3_600_000);
        const minutes = Math.floor((ms % 3_600_000) / 60_000);
        if (hours > 24) {
            const days = Math.floor(hours / 24);
            return `${days}d ${hours % 24}h`;
        }
        if (hours > 0)
            return `${hours}h ${minutes}m`;
        return `${minutes}m`;
    }
}
//# sourceMappingURL=OrphanProcessReaper.js.map