/**
 * SessionReaper — pressure-aware, positive-evidence reaper for idle-but-alive
 * sessions. SESSION-REAPER-SPEC.md (v2 CONVERGED).
 *
 * THE hard requirement: NEVER reap a working session. The classifier does NOT
 * infer idleness from the ABSENCE of activity (a session mid-LLM-generation or
 * mid-network-call looks identical to an idle one). It requires POSITIVE proof
 * the turn is complete and the session is parked at a ready prompt, PLUS render
 * stasis across ticks, PLUS quiet process+transcript — and every signal carries
 * a confidence. Any ambiguity, any low-confidence/unresolvable signal → KEEP.
 *
 * Reaping is gated again by: hysteresis (continuous candidacy across N ticks),
 * a pressure-adaptive idle threshold (does almost nothing at Normal), a bounded
 * per-tick/per-hour budget, and a two-phase reap (mark reap-pending, then on a
 * later tick re-confirm the full classifier fresh before terminating). Ships
 * dry-run/off by default; auto-disables to dry-run on any ambiguous/failed reap.
 */
import { EventEmitter } from 'node:events';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { ReapGuard } from '../core/ReapGuard.js';
import { getActivitySignal } from './frameworkActivitySignals.js';
import { probeTranscript, transcriptDelta } from './transcriptProber.js';
export const DEFAULT_SESSION_REAPER_CONFIG = {
    enabled: false,
    dryRun: true,
    tickIntervalSec: 120,
    minAgeMinutes: 30,
    confirmObservations: 3,
    confirmWindowMinutes: 10,
    paneCaptureLines: 200,
    recentUserWindowMinutes: 30,
    idleThresholdModerateMinutes: 45,
    idleThresholdCriticalMinutes: 15,
    normalTierReaps: false,
    maxReapsPerTick: 3,
    maxReapsPerHour: 12,
    finalGraceSec: 60,
    protectOpenCommitments: true,
    staleCommitmentWindowMinutes: 480, // 8h
    reapStaleIdleWithActiveChildren: true,
    cpuModerateLoadPerCore: 1.0,
    cpuCriticalLoadPerCore: 1.5,
    cpuAwareActiveProcessKeep: false,
    cpuActiveMinRatePerSec: 0.02,
    busyOrphanDetection: false,
    busyOrphanConfirmTicks: 5,
    topicMovedCloseout: true,
    topicMovedConfirmTicks: 2,
    topicMovedVetoBreakerAttempts: 5,
};
/** Memory-pressure thresholds (freePct). Kept as constants — the existing
 *  behavior surface; CPU thresholds are the configurable addition. */
const MEM_MODERATE_FREE_PCT = 12;
const MEM_CRITICAL_FREE_PCT = 5;
const TIER_ORDER = { normal: 0, moderate: 1, critical: 2 };
/**
 * Pure pressure classifier — the single source of truth for the reaper's tier.
 * tier = WORST of the memory tier (free %) and the CPU tier (1-min load ÷ cores).
 * `loadPerCore: null` (cores unknown) drops CPU out of the calc (memory-only),
 * preserving the pre-CPU behavior. Fully unit-testable (no `os` dependency).
 */
export function computePressure(inputs, thresholds) {
    const memTier = inputs.freePct < MEM_CRITICAL_FREE_PCT ? 'critical'
        : inputs.freePct < MEM_MODERATE_FREE_PCT ? 'moderate'
            : 'normal';
    let cpuTier = 'normal';
    if (inputs.loadPerCore != null && Number.isFinite(inputs.loadPerCore)) {
        cpuTier =
            inputs.loadPerCore >= thresholds.cpuCriticalLoadPerCore ? 'critical'
                : inputs.loadPerCore >= thresholds.cpuModerateLoadPerCore ? 'moderate'
                    : 'normal';
    }
    const tier = TIER_ORDER[cpuTier] >= TIER_ORDER[memTier] ? cpuTier : memTier;
    const round = (n) => Math.round(n * 100) / 100;
    return {
        tier,
        inputs: {
            freePct: Math.round(inputs.freePct * 10) / 10,
            loadPerCore: inputs.loadPerCore == null ? null : round(inputs.loadPerCore),
            memTier,
            cpuTier,
        },
    };
}
export class SessionReaper extends EventEmitter {
    cfg;
    deps;
    now;
    timer;
    running = false;
    obs = new Map();
    /** Prior descendant-CPU sample per session, for the cross-tick CPU-progress
     *  delta that backs `cpuAwareActiveProcessKeep`. GC'd alongside `obs`. */
    cpuSamples = new Map();
    /** Consecutive busy-orphan-suspect ticks per session (observe-only dwell for
     *  `busyOrphanDetection`). Resets to 0 on any non-suspect tick. GC'd with obs. */
    busyOrphanStreak = new Map();
    /** Consecutive VETOED closeout terminate attempts per session (the WS1.2
     *  P19 breaker counter). Distinct from the dwell streak: this counts only
     *  real terminate() calls that came back vetoed. At
     *  `topicMovedVetoBreakerAttempts` the closeout stops retrying for the
     *  episode. Reset on success / topic-home / pin-conflict hold. GC'd with obs. */
    topicMovedVetoes = new Map();
    /** Consecutive owned-elsewhere ticks per session (the topicMovedCloseout
     *  dwell). Resets when the topic returns to this machine / unowned. GC'd with obs. */
    topicMovedStreak = new Map();
    /** Last audited `verdict:keptBy` per session — so the decision audit logs only
     *  on a CHANGE, not every tick (auditability without per-tick log spam). */
    lastAuditedDecision = new Map();
    reapTimestamps = []; // for per-hour budget
    /** Flips to true (forcing dry-run) after any ambiguous/failed reap. */
    autoDisabled = false;
    lastTickAt = 0;
    /** Shared stateless KEEP-guards (UNIFIED-SESSION-LIFECYCLE §P2). The reaper
     *  consults this first, then layers its stateful transcript-growth + positive-idle
     *  checks — so the same guard backs both the reaper and terminateSession(). */
    guard;
    constructor(deps, cfg) {
        super();
        this.deps = deps;
        this.cfg = { ...DEFAULT_SESSION_REAPER_CONFIG, ...(cfg ?? {}) };
        this.now = deps.now ?? (() => Date.now());
        this.guard = new ReapGuard(deps, {
            minAgeMs: this.cfg.minAgeMinutes * 60_000,
            recentUserWindowMs: this.cfg.recentUserWindowMinutes * 60_000,
            protectOpenCommitments: this.cfg.protectOpenCommitments,
            staleCommitmentWindowMs: this.cfg.staleCommitmentWindowMinutes * 60_000,
        });
        // Durable candidacy (A): restore the idle-candidacy clock across restarts.
        // reapPendingSince is DROPPED on load so a stale "about to kill" state can never
        // insta-reap on boot — the two-phase reap must re-confirm fresh. candidateSince
        // (the long idle clock) + lastFrame/lastTranscript (render-stasis continuity)
        // survive; every tick still re-checks all-clear + frame-stasis before reaping.
        try {
            const restored = this.deps.loadCandidacy?.();
            if (restored) {
                for (const [id, o] of Object.entries(restored)) {
                    if (!o || typeof o.candidateSince !== 'number')
                        continue;
                    this.obs.set(id, { ...o, reapPendingSince: undefined });
                }
            }
        }
        catch { /* @silent-fallback-ok — bad/absent state file ⇒ start in-memory (prior behavior) */ }
    }
    /** Serialize the in-memory candidacy map for durable persistence (A). */
    persistCandidacy() {
        if (!this.deps.saveCandidacy)
            return;
        try {
            const out = {};
            for (const [id, o] of this.obs.entries())
                out[id] = o;
            this.deps.saveCandidacy(out);
        }
        catch { /* @silent-fallback-ok — a failed persist just resets the clock next restart */ }
    }
    start() {
        if (this.timer)
            return;
        this.timer = setInterval(() => { void this.tick(); }, this.cfg.tickIntervalSec * 1000);
        if (typeof this.timer.unref === 'function')
            this.timer.unref();
    }
    stop() {
        if (this.timer) {
            clearInterval(this.timer);
            this.timer = undefined;
        }
    }
    /** Whether kills are actually performed (vs dry-run logged). */
    get killsEnabled() {
        return this.cfg.enabled && !this.cfg.dryRun && !this.autoDisabled;
    }
    probe(session) {
        if (this.deps.probeTranscript)
            return this.deps.probeTranscript(session);
        const framework = session.framework ?? this.deps.frameworkForSession(session.tmuxSession) ?? 'claude-code';
        // Claude uses claudeSessionId; Codex's transcript is globbed by its session
        // id which we do not separately track → unresolved → KEEP (safe).
        const sessionId = framework === 'claude-code' ? (session.claudeSessionId ?? '') : '';
        // projectDir is the agent's session-launch cwd, which Claude Code encodes into the
        // transcript path (~/.claude/projects/<encoded-cwd>/<sessionId>.jsonl). Passing ''
        // resolved to an empty-encoded dir that never exists → EVERY session read as
        // transcript-unresolved → the reaper could never PROVE a session idle and kept
        // everything (2026-06-06 grounding). Inject it via `transcriptProjectDir`; an
        // absent/wrong value still resolves to unresolved → KEEP (safe).
        const projectDir = this.deps.transcriptProjectDir?.() ?? '';
        return probeTranscript({ framework, sessionId, projectDir });
    }
    /**
     * Positive idle detection: returns true ONLY if the frame affirmatively shows
     * a ready-for-input prompt AND contains no active-work marker. Conservative —
     * an undetected ready prompt returns false (→ KEEP), never a false idle.
     */
    static isPositivelyIdle(framework, frame) {
        if (!framework)
            return false; // unknown framework → cannot positively assert idle
        const sig = getActivitySignal(framework);
        // Any LIVE-generation marker anywhere in the captured buffer ⇒ not idle. Uses
        // `liveActivity` (spinner / "Working (Ns" / "generating"), NOT toolCallOrSpinner:
        // the latter matches tool-call names + the bare framework word that PERSIST in an
        // idle session's scrollback, which made every idle session read as "working" so
        // the reaper never reaped (2026-06-07 root cause). The transcript-growth +
        // confirmObservations gates downstream backstop any momentary live-marker miss.
        if (sig.liveActivity.test(frame) || sig.escapeToInterrupt.test(frame) || sig.runningIndicator.test(frame)) {
            return false;
        }
        // Positive ready-prompt signatures (conservative; tunable via dry-run).
        if (framework === 'claude-code') {
            return /bypass permissions|\? for shortcuts|auto-accept edits|shift\+tab/i.test(frame)
                || /\n\s*>\s*$/.test(frame.trimEnd() + '\n');
        }
        if (framework === 'gemini-cli') {
            // Apprenticeship Step 2: the Gemini interactive-TUI ready-prompt signature
            // is not yet live-characterized (the minimal body runs one-shot, not a
            // long-lived TUI). Conservatively return false → KEEP, never a false idle.
            // Refined when the loop-driver/TUI path lands (§6 build-time discovery).
            return false;
        }
        // codex-cli: a ready prompt with no working status line. The model-name idle
        // line is NOT a reliable positive, so require the explicit input affordance.
        return /\? for shortcuts|send a message|type a message|\bEsc\b.*interrupt/i.test(frame) === false
            ? /\n\s*›\s*$|\n\s*▌|send your message/i.test(frame)
            : false;
    }
    /**
     * Stateless per-tick evaluation. Returns KEEP unless EVERY gate clears with
     * high confidence. Order: cheap protect-gates first (short-circuit), then the
     * positive-idle + activeness checks.
     */
    evaluate(session, opts) {
        const framework = session.framework ?? this.deps.frameworkForSession(session.tmuxSession);
        const frame = safeCapture(this.deps, session.tmuxSession, this.cfg.paneCaptureLines);
        const transcript = this.probe(session);
        let cpuTightened = false;
        let busyOrphanSuspect = false;
        let staleIdleRelaxed = false;
        const keep = (reason, confidence = 'high') => ({ verdict: 'keep', keptBy: reason, confidence, frame, transcript, cpuTightened, busyOrphanSuspect, staleIdleRelaxed });
        // Stale-idle: no user message within the staleness window on the bound topic.
        // An 8h-silent session is treated as abandoned (Justin's "no message today"
        // rule) — see the active-process relax below. Unbindable topic ⇒ NOT stale
        // (conservative: never relax a veto on a session we can't time-bound).
        const staleTopicId = this.deps.topicBinding(session.tmuxSession);
        const staleIdle = this.cfg.reapStaleIdleWithActiveChildren
            && staleTopicId != null
            && !this.deps.recentUserMessage(staleTopicId, this.cfg.staleCommitmentWindowMinutes * 60_000);
        // ── Stateless KEEP-guards (§P2): protected, spawn-grace, recovery,
        //    pending-injection, relay-lease, recent-user, open-commitment,
        //    active-subagent, structural-long-work, active-process, main-process.
        //    Extracted to the shared ReapGuard so terminateSession() enforces the
        //    identical chain. Order + reasons preserved exactly. ──
        const blocked = this.guard.blockedReason(session);
        if (blocked) {
            // Host-load-gated tightening of the `active-process` existence-veto.
            // `opts.cpuFlat===true` (computed by tick() from the descendant CPU-seconds
            // delta) means the ONLY thing keeping this session is a child that EXISTS
            // but burns ~no CPU under pressure (a wedged/idle MCP child). In that one
            // case, don't honor the veto — fall through to the stateful transcript-
            // growth + positive-idle checks below, which STILL must all clear before
            // the session is reap-eligible. Every other keep-reason — and the
            // off-pressure / can't-measure cases (cpuFlat !== true) — is unchanged.
            if (blocked.reason === 'active-process' && (opts?.cpuFlat === true || staleIdle)) {
                // Relax the active-process veto and fall through (no return) to the stateful
                // transcript-growth + positive-idle checks, which STILL must all clear before
                // the session is reap-eligible. Two independent reasons to relax:
                //   • cpuFlat — the child exists but burns ~no CPU under pressure (wedged/idle).
                //   • staleIdle — no user message in 8h (abandoned); its idle children (e.g. the
                //     session's own idle MCP servers) must not shield a dead session forever.
                //     This is the active-process analogue of the #955 stale-commitment override.
                if (opts?.cpuFlat === true)
                    cpuTightened = true;
                if (staleIdle)
                    staleIdleRelaxed = true;
            }
            else {
                // OBSERVE-ONLY busy-orphan detection — the inverse of the relax above.
                // A child is keeping this session, but if that child is provably BURNING
                // CPU (opts.cpuFlat===false) while the session ITSELF looks fully idle
                // (positive idle prompt + flat transcript), it's a candidate useless-but-
                // busy orphan — the gap cpuAwareActiveProcessKeep can't catch. Flag it for
                // the dwell tracker; the keep verdict is UNCHANGED (never reaps on this).
                if (this.cfg.busyOrphanDetection
                    && blocked.reason === 'active-process'
                    && opts?.cpuFlat === false
                    && this.looksIdleApartFromBusyChild(framework, frame, transcript, session)) {
                    busyOrphanSuspect = true;
                }
                return keep(blocked.reason, blocked.confidence);
            }
        }
        // ── Stateful checks (stay in the reaper; need per-tick obs / captured frame) ──
        // E. Transcript growth this tick (vs last tick). 'grew' ⇒ working;
        //    'unknown' (unresolved/rotated) ⇒ KEEP.
        const prev = this.obs.get(session.id)?.lastTranscript;
        if (prev) {
            const delta = transcriptDelta(prev, transcript);
            if (delta === 'grew')
                return keep('transcript-grew');
            if (delta === 'unknown')
                return keep('transcript-unresolved', 'low');
        }
        else if (!transcript.resolved) {
            // First sighting with an unresolvable transcript: cannot prove idle. KEEP.
            return keep('transcript-unresolved', 'low');
        }
        // (1) Positive idle proof — REQUIRED. No positive ready-prompt ⇒ KEEP.
        if (!SessionReaper.isPositivelyIdle(framework, frame))
            return keep('no-positive-idle');
        // All gates clear: this tick the session is a reap candidate.
        return { verdict: 'reap-eligible', keptBy: 'all-clear', confidence: 'high', frame, transcript, cpuTightened, busyOrphanSuspect, staleIdleRelaxed };
    }
    /**
     * CPU-progress probe backing `cpuAwareActiveProcessKeep`. Returns:
     *  - `true`  → the session's descendants are CPU-flat (progress below the idle
     *    floor) over the sample window — i.e. existing-but-not-working;
     *  - `false` → descendants used CPU (genuinely working);
     *  - `undefined` → DO NOT tighten (the conservative default = KEEP): the
     *    feature is off, the box is at `normal` pressure, CPU can't be sampled, or
     *    there's no prior sample yet to delta against.
     * Records the current reading for the next tick's delta. Stateful — call once
     * per session per tick (from tick(), never from the observational report()).
     */
    cpuProgressFlat(session, tier) {
        // Sample when EITHER consumer needs the signal: cpuAwareActiveProcessKeep
        // (uses cpuFlat===true to relax) or busyOrphanDetection (uses cpuFlat===false
        // to flag). Off-pressure / no dep ⇒ undefined (no tighten, no flag).
        if ((!this.cfg.cpuAwareActiveProcessKeep && !this.cfg.busyOrphanDetection)
            || tier === 'normal' || !this.deps.descendantCpuSeconds) {
            return undefined;
        }
        let sec;
        try {
            sec = this.deps.descendantCpuSeconds(session.tmuxSession);
        }
        catch {
            return undefined;
        }
        if (!Number.isFinite(sec))
            return undefined;
        const at = this.now();
        const prior = this.cpuSamples.get(session.id);
        this.cpuSamples.set(session.id, { sec, at });
        if (!prior)
            return undefined; // first sighting — no delta yet, can't tell
        const elapsedSec = (at - prior.at) / 1000;
        if (elapsedSec <= 0)
            return undefined;
        const ratePerSec = (sec - prior.sec) / elapsedSec; // CPU-seconds per wall-second
        // Accumulated CPU went backwards (pid reuse / process restart) ⇒ not provably
        // flat ⇒ can't-tell ⇒ KEEP.
        if (ratePerSec < 0)
            return undefined;
        return ratePerSec < this.cfg.cpuActiveMinRatePerSec;
    }
    /**
     * Does this session look fully idle EXCEPT for the busy child keeping it alive?
     * True only when its transcript is provably STATIC (resolved + not grown vs the
     * previous tick) AND its pane is positively idle (a ready prompt, no working
     * footer). Conservative: a first sighting (no prior transcript), an unresolved/
     * rotated transcript, or any growth → false. Reuses the already-captured frame
     * and transcript (no extra tmux/fs work). Pure observation — never reaps.
     */
    looksIdleApartFromBusyChild(framework, frame, transcript, session) {
        const prev = this.obs.get(session.id)?.lastTranscript;
        if (!prev)
            return false; // first sighting — no growth comparison yet
        if (transcriptDelta(prev, transcript) !== 'static')
            return false; // grew/unknown ⇒ not idle
        return SessionReaper.isPositivelyIdle(framework, frame);
    }
    /** Active idle threshold (ms) for the current pressure tier. */
    thresholdMs(tier) {
        if (tier === 'normal')
            return this.cfg.normalTierReaps ? this.cfg.idleThresholdModerateMinutes * 60_000 : null;
        if (tier === 'moderate')
            return this.cfg.idleThresholdModerateMinutes * 60_000;
        return this.cfg.idleThresholdCriticalMinutes * 60_000;
    }
    hourlyBudgetRemaining() {
        const cutoff = this.now() - 3_600_000;
        this.reapTimestamps = this.reapTimestamps.filter(t => t >= cutoff);
        return this.cfg.maxReapsPerHour - this.reapTimestamps.length;
    }
    async tick() {
        if (!this.cfg.enabled || this.running)
            return;
        this.running = true;
        this.lastTickAt = this.now();
        try {
            const pressure = this.deps.pressure();
            const threshold = this.thresholdMs(pressure.tier);
            const sessions = this.deps.listRunningSessions();
            const live = new Set(sessions.map(s => s.id));
            // GC obs for vanished sessions.
            for (const id of [...this.obs.keys()])
                if (!live.has(id)) {
                    this.obs.delete(id);
                    this.deps.clearReaping(id);
                }
            for (const id of [...this.lastAuditedDecision.keys()])
                if (!live.has(id))
                    this.lastAuditedDecision.delete(id);
            for (const id of [...this.cpuSamples.keys()])
                if (!live.has(id))
                    this.cpuSamples.delete(id);
            for (const id of [...this.busyOrphanStreak.keys()])
                if (!live.has(id))
                    this.busyOrphanStreak.delete(id);
            for (const id of [...this.topicMovedStreak.keys()])
                if (!live.has(id))
                    this.topicMovedStreak.delete(id);
            for (const id of [...this.topicMovedVetoes.keys()])
                if (!live.has(id))
                    this.topicMovedVetoes.delete(id);
            let reapedThisTick = 0;
            for (const session of sessions) {
                // ── Post-transfer closeout (operator-named issue, 2026-06-05) ──────
                // A topic-bound session whose topic is now OWNED BY ANOTHER MACHINE is
                // a leftover from a move/failover: the conversation continues on the
                // owning machine, and this one only does duplicate work. Independent of
                // the idle pipeline (a duplicate is wrong even when busy), but the kill
                // still goes through the guarded `terminate` authority — a KEEP-guard
                // veto is audited and retried next tick (eventual closeout, never a
                // forced kill). Dwell of `topicMovedConfirmTicks` absorbs ownership
                // churn mid-transfer.
                if (this.cfg.topicMovedCloseout && this.deps.topicOwnerElsewhere) {
                    let otherOwner = null;
                    let pinnedHere = false;
                    try {
                        const topicId = this.deps.topicBinding(session.tmuxSession);
                        otherOwner = topicId != null ? this.deps.topicOwnerElsewhere(topicId) : null;
                        // WS1.3 (MULTI-MACHINE-SEAMLESSNESS-SPEC): pin-conflict = do-not-act.
                        // When the topic's PIN names THIS machine while ownership still says
                        // another, the divergence is mid-reconcile TOWARD us — the
                        // OwnershipReconciler is bringing the record back, and closing the
                        // local session now would kill the exact session the pin wants here
                        // (the 2026-06-12 incident: the closeout attacked the working laptop
                        // session every 2 minutes for hours during a stuck transfer-back).
                        pinnedHere = topicId != null && (this.deps.topicPinnedHere?.(topicId) ?? false);
                    }
                    catch {
                        otherOwner = null; /* signal failed → cannot reason → skip rule */
                    }
                    if (otherOwner && pinnedHere) {
                        // -1 is the held-and-audited sentinel: audit ONCE per conflict
                        // episode, hold (never act) for as long as the pin names us.
                        const prior = this.topicMovedStreak.get(session.id) ?? 0;
                        if (prior !== -1) {
                            this.audit('reap-skipped-topic-moved', session, { rule: 'topic-moved-away', otherOwner, skipped: 'pin-conflict-pending-reconcile' });
                            this.topicMovedStreak.set(session.id, -1);
                        }
                        // The closeout intent is withdrawn while the pin holds — a fresh
                        // episode after reconcile starts with a clean breaker.
                        this.topicMovedVetoes.delete(session.id);
                    }
                    else if (otherOwner) {
                        const streak = (this.topicMovedStreak.get(session.id) ?? 0) + 1;
                        this.topicMovedStreak.set(session.id, streak);
                        if (streak >= this.cfg.topicMovedConfirmTicks) {
                            const reason = `topic moved to ${otherOwner} — closing the leftover session on this machine (post-transfer closeout)`;
                            const vetoes = this.topicMovedVetoes.get(session.id) ?? 0;
                            if (vetoes >= this.cfg.topicMovedVetoBreakerAttempts) {
                                // P19 breaker OPEN (WS1.2): this session vetoed the closeout
                                // `topicMovedVetoBreakerAttempts` times in a row — stop
                                // retrying for the episode. NOT a stranded session: it falls
                                // through to the idle pipeline below every tick, so it is
                                // still reaped normally when its work actually finishes. The
                                // open transition was audited + escalated exactly once.
                            }
                            else if (!this.killsEnabled) {
                                if (streak === this.cfg.topicMovedConfirmTicks) {
                                    this.audit('would-reap', session, { rule: 'topic-moved-away', otherOwner, dryRun: true });
                                }
                            }
                            else if (reapedThisTick < this.cfg.maxReapsPerTick && this.hourlyBudgetRemaining() > 0) {
                                const res = await this.deps.terminate(session.id, reason);
                                if (res.terminated) {
                                    reapedThisTick++;
                                    this.reapTimestamps.push(this.now());
                                    this.audit('reaped', session, { rule: 'topic-moved-away', otherOwner });
                                    this.topicMovedStreak.delete(session.id);
                                    this.topicMovedVetoes.delete(session.id);
                                    continue; // session is gone — skip the idle pipeline
                                }
                                // Guard veto / already-terminal — audit once per streak crossing,
                                // keep the streak so next tick retries (bounded by the breaker).
                                const v = vetoes + 1;
                                this.topicMovedVetoes.set(session.id, v);
                                if (streak === this.cfg.topicMovedConfirmTicks) {
                                    this.audit('reap-skipped-topic-moved', session, { rule: 'topic-moved-away', otherOwner, skipped: res.skipped });
                                }
                                if (v === this.cfg.topicMovedVetoBreakerAttempts) {
                                    // Breaker opens NOW: one audit row + one deduped escalation.
                                    this.audit('closeout-breaker-open', session, {
                                        rule: 'topic-moved-away', otherOwner, vetoedAttempts: v, lastSkipped: res.skipped ?? 'keep-guard',
                                    });
                                    const topicId = this.deps.topicBinding(session.tmuxSession);
                                    this.deps.raiseAttention?.({
                                        id: `closeout-breaker:${session.id}`,
                                        title: `Topic ${topicId ?? '?'} moved to ${otherOwner}, but the old session won't close`,
                                        summary: `Post-transfer closeout gave up after ${v} vetoed attempts (held by: ${res.skipped ?? 'keep-guard'}).`,
                                        description: `The conversation for topic ${topicId ?? '?'} now lives on ${otherOwner}, but the leftover session on this machine (${session.tmuxSession}) refused to close ${v} times in a row — a KEEP-guard reports it is still working (${res.skipped ?? 'keep-guard'}). Closeout retries have stopped (P19 breaker); the session will close via the normal idle path when it finishes, or you can close it from the dashboard.`,
                                    });
                                }
                            }
                        }
                    }
                    else if ((this.topicMovedStreak.get(session.id) ?? 0) !== 0) {
                        // Clears both a counting streak AND the -1 pin-conflict sentinel,
                        // so a FUTURE genuine move starts its dwell from a clean slate.
                        this.topicMovedStreak.set(session.id, 0);
                        this.topicMovedVetoes.delete(session.id);
                    }
                }
                // CPU-progress probe for the active-process keep-tightening. Sampled here
                // (once per session per tick) so the cross-tick delta lives in one place;
                // undefined off-pressure / when the feature is off ⇒ evaluate() unchanged.
                const cpuFlat = this.cpuProgressFlat(session, pressure.tier);
                let evaln;
                try {
                    evaln = this.evaluate(session, { cpuFlat });
                }
                catch {
                    // A protect-signal threw — we cannot reason about this session, so
                    // KEEP it (abort any reap-pending) and reset candidacy. Never reap on
                    // a failed evaluation.
                    this.deps.clearReaping(session.id);
                    this.obs.set(session.id, { candidateSince: 0, consecutive: 0, lastFrame: '', lastTranscript: { resolved: false, path: '', size: 0, mtime: 0 } });
                    continue;
                }
                // Decision audit (transition-only): record what we decided + WHY, stamped
                // with the pressure context, the first time we see it and on every change.
                this.auditDecisionIfChanged(session, evaln, pressure);
                // Kill-path observability: whenever the new behavior actually relaxed the
                // active-process existence-veto this tick, leave a durable breadcrumb
                // (every tick it applies — this is a behavior change to a reap decision).
                if (evaln.cpuTightened) {
                    this.audit('cpu-keep-tightened', session, {
                        tier: pressure.tier, verdict: evaln.verdict, keptBy: evaln.keptBy,
                        cpuActiveMinRatePerSec: this.cfg.cpuActiveMinRatePerSec,
                    });
                }
                // Observe-only busy-orphan dwell tracker: count consecutive suspect ticks;
                // emit ONE `busy-orphan-suspected` audit row the tick the streak crosses
                // busyOrphanConfirmTicks (not every tick after — avoids a per-tick flood),
                // and a `busy-orphan-cleared` row when a confirmed suspect recovers. Never
                // changes the verdict — purely makes the gap measurable.
                if (this.cfg.busyOrphanDetection) {
                    const prevStreak = this.busyOrphanStreak.get(session.id) ?? 0;
                    if (evaln.busyOrphanSuspect) {
                        const streak = prevStreak + 1;
                        this.busyOrphanStreak.set(session.id, streak);
                        if (streak === this.cfg.busyOrphanConfirmTicks) {
                            this.audit('busy-orphan-suspected', session, {
                                tier: pressure.tier, streakTicks: streak, keptBy: evaln.keptBy,
                                dwellMs: streak * this.cfg.tickIntervalSec * 1000,
                            });
                        }
                    }
                    else if (prevStreak > 0) {
                        if (prevStreak >= this.cfg.busyOrphanConfirmTicks) {
                            this.audit('busy-orphan-cleared', session, { tier: pressure.tier, afterTicks: prevStreak });
                        }
                        this.busyOrphanStreak.set(session.id, 0);
                    }
                }
                const prior = this.obs.get(session.id);
                const now = this.now();
                if (evaln.verdict === 'keep') {
                    // Any non-candidate observation resets candidacy + aborts reap-pending.
                    if (prior?.reapPendingSince != null) {
                        this.deps.clearReaping(session.id);
                        this.audit('reap-aborted', session, { keptBy: evaln.keptBy });
                    }
                    this.obs.set(session.id, { candidateSince: 0, consecutive: 0, lastFrame: evaln.frame, lastTranscript: evaln.transcript });
                    continue;
                }
                // Candidate this tick. Render-stasis: the frame must be byte-identical
                // to last tick. A changed frame ⇒ activity ⇒ reset candidacy.
                const frameStatic = prior != null && prior.consecutive > 0 && prior.lastFrame === evaln.frame;
                // If we were reap-pending and the frame is no longer static, the session
                // rendered something during the grace window — abort the reap (§3.5).
                if (prior?.reapPendingSince != null && !frameStatic) {
                    this.deps.clearReaping(session.id);
                    this.audit('reap-aborted', session, { reason: 'frame-changed-during-grace' });
                    this.obs.set(session.id, { candidateSince: now, consecutive: 1, lastFrame: evaln.frame, lastTranscript: evaln.transcript });
                    continue;
                }
                const consecutive = frameStatic ? prior.consecutive + 1 : 1;
                const candidateSince = frameStatic && prior.candidateSince ? prior.candidateSince : now;
                const next = { candidateSince, consecutive, lastFrame: evaln.frame, lastTranscript: evaln.transcript, reapPendingSince: prior?.reapPendingSince };
                // Two-phase: if already reap-pending, see if the grace window elapsed.
                if (next.reapPendingSince != null) {
                    if (now - next.reapPendingSince >= this.cfg.finalGraceSec * 1000) {
                        // Final confirmation already passed THIS tick's full classifier
                        // (we're here ⇒ still reap-eligible + render-static). Terminate.
                        const canReap = threshold != null && reapedThisTick < this.cfg.maxReapsPerTick && this.hourlyBudgetRemaining() > 0;
                        if (canReap) {
                            // Carry THIS reap's active-process relaxation through to the terminate
                            // authority. evaln reached reap-eligible; if it got there by relaxing the
                            // active-process veto (cpuFlat under pressure, or 8h-stale-idle children),
                            // the authority's un-relaxed re-check would otherwise skip:active-process
                            // and the reap would never land. False ⇒ no active process was the blocker,
                            // so the bypass is a harmless no-op.
                            const relaxedActiveProcess = evaln.cpuTightened || evaln.staleIdleRelaxed;
                            await this.performReap(session, pressure, next, relaxedActiveProcess); // clears the lease on every path
                            reapedThisTick++;
                        }
                        else {
                            // Grace elapsed but the reap is gated (tier dropped / budget spent).
                            // Release the reaping lease so the idle-kill safety net is not
                            // permanently disabled for this session.
                            this.deps.clearReaping(session.id);
                        }
                        this.obs.set(session.id, { ...next, reapPendingSince: undefined });
                    }
                    else {
                        this.obs.set(session.id, next); // keep waiting out the grace window
                    }
                    continue;
                }
                // Not yet reap-pending. Need: hysteresis satisfied + idle past threshold
                // + pressure tier permits reaping + budget available.
                const hysteresisOk = consecutive >= this.cfg.confirmObservations
                    && (now - candidateSince) >= this.cfg.confirmWindowMinutes * 60_000;
                const idleMs = now - candidateSince;
                if (threshold != null && hysteresisOk && idleMs >= threshold
                    && reapedThisTick < this.cfg.maxReapsPerTick && this.hourlyBudgetRemaining() > 0) {
                    // Enter reap-pending (two-phase). Lease the session so idle-kill won't
                    // race us; terminate on a later tick after the grace window.
                    next.reapPendingSince = now;
                    this.deps.markReaping(session.id);
                    this.audit('reap-pending', session, { tier: pressure.tier, idleMs, thresholdMs: threshold });
                }
                this.obs.set(session.id, next);
            }
            this.persistCandidacy(); // durable candidacy (A): the idle clock survives restarts
        }
        finally {
            this.running = false;
        }
    }
    async performReap(session, pressure, obs, relaxedActiveProcess = false) {
        const detail = { tier: pressure.tier, idleMs: this.now() - obs.candidateSince, dryRun: !this.killsEnabled };
        if (!this.killsEnabled) {
            this.audit('would-reap', session, detail); // dry-run: log, do not kill
            this.deps.clearReaping(session.id);
            return;
        }
        try {
            // bypassActiveProcessKeep: carry the reaper's already-applied active-process
            // relaxation to the authority so it doesn't re-veto on the un-relaxed shared
            // guard (the 1,532× skipped:active-process stalemate). Scoped to active-process
            // only; every other KEEP-guard is still enforced by terminateSession.
            // Build-Session Yield Safety (ACT-839): an idle session can still have a
            // DIRTY worktree (uncommitted edits sitting there). Collect that here,
            // PRE-kill, in the reaper's own loop — never a synchronous git call on the
            // terminate chokepoint. `dirtyCheck` is present only when the dev-gated
            // feature is live; it is bounded + fail-open internally.
            const workEvidence = [];
            if (this.deps.dirtyCheck && session.cwd) {
                try {
                    if (this.deps.dirtyCheck(session.cwd))
                        workEvidence.push('uncommitted-worktree-work');
                }
                catch { /* @silent-fallback-ok: SPEC-MANDATED fail-open — evidence collection NEVER endangers the kill path; a dirty-check failure just omits the signal (the kill proceeds with the evidence gathered so far). */ }
            }
            const r = await this.deps.terminate(session.id, 'reaped-idle', {
                bypassActiveProcessKeep: relaxedActiveProcess,
                // Pre-relaxation verdict as killer-supplied evidence (reap-notify R2.1):
                // an idle-reap means this reaper PROVED no active work — assert that set
                // authoritatively (now possibly carrying uncommitted-worktree-work) so
                // the chokepoint fallback can't re-stamp the active-process signal the
                // relaxation just disproved.
                workEvidence,
            });
            if (r.terminated) {
                this.reapTimestamps.push(this.now());
                this.audit('reaped', session, detail);
                this.emit('reaped', session);
            }
            else if (r.skipped) {
                // A refusal WITH a known reason (session is busy/protected/already gone) is a
                // deliberate, safe decline by the terminate dep — a normal skip. Move on to the
                // next candidate; do NOT disable the whole reaper. Disabling here was a bug: one
                // perpetually-busy session (e.g. skipped:'active-process') auto-disabled the
                // reaper every boot, so it never reaped any of the OTHER genuinely-idle sessions
                // (observed 2026-06-07: 8 self-shutoffs on a 37-session fleet, 0 real reaps).
                this.audit('reap-skipped', session, { ...detail, skipped: r.skipped });
            }
            else {
                // terminated:false with NO reason given = genuinely unexpected — fail safe.
                this.autoDisabled = true;
                this.audit('reap-skipped-auto-disable', session, { ...detail, skipped: r.skipped });
                this.emit('auto-disabled', { session, reason: 'unexpected-no-skip-reason' });
            }
        }
        catch (err) {
            this.autoDisabled = true;
            this.audit('reap-failed-auto-disable', session, { ...detail, error: err instanceof Error ? err.message : String(err) });
            this.emit('auto-disabled', { session, reason: 'error' });
        }
        finally {
            this.deps.clearReaping(session.id);
        }
    }
    /** Emit a `decision` audit row only when a session's (verdict, keptBy) differs
     *  from the last audited value — so a multi-day kept session logs once, not
     *  every tick. Each row carries the pressure context that drove the call. */
    auditDecisionIfChanged(session, evaln, pressure) {
        const key = `${evaln.verdict}:${evaln.keptBy}`;
        if (this.lastAuditedDecision.get(session.id) === key)
            return;
        this.lastAuditedDecision.set(session.id, key);
        this.audit('decision', session, {
            verdict: evaln.verdict,
            keptBy: evaln.keptBy,
            confidence: evaln.confidence,
            tier: pressure.tier,
            inputs: pressure.inputs,
        });
    }
    audit(event, session, detail) {
        const entry = { ts: new Date(this.now()).toISOString(), kind: 'session-reaper', event, session: session.name, sessionId: session.id, ...detail };
        if (this.deps.audit)
            this.deps.audit(entry);
    }
    /** Observability snapshot for GET /sessions/reaper. */
    snapshot() {
        const pressure = this.deps.pressure();
        const threshold = this.thresholdMs(pressure.tier);
        const now = this.now();
        const sessions = this.deps.listRunningSessions().map(s => {
            const o = this.obs.get(s.id);
            let verdict = 'keep';
            let keptBy = 'eval-error';
            let confidence = 'low';
            try {
                const e = this.evaluate(s);
                verdict = e.verdict;
                keptBy = e.keptBy;
                confidence = e.confidence;
            }
            catch { /* a protect-signal threw — report as kept, never crash the route */ }
            return {
                name: s.name, sessionId: s.id, verdict, keptBy, confidence,
                consecutive: o?.consecutive ?? 0, idleMs: o?.candidateSince ? now - o.candidateSince : 0,
                reapPending: o?.reapPendingSince != null,
            };
        });
        return {
            enabled: this.cfg.enabled, dryRun: this.cfg.dryRun || this.autoDisabled, autoDisabled: this.autoDisabled,
            lastTickAt: this.lastTickAt, pressure,
            activeThresholdMinutes: threshold == null ? null : Math.round(threshold / 60_000),
            reapsLastHour: this.cfg.maxReapsPerHour - this.hourlyBudgetRemaining(),
            sessions,
        };
    }
    /** Sync in-memory runtime read for the GuardRegistry (GET /guards).
     *  Cheap property read ONLY — snapshot() is the heavy surface. */
    guardStatus() {
        return {
            enabled: this.cfg.enabled,
            dryRun: this.cfg.dryRun || this.autoDisabled,
            lastTickAt: this.lastTickAt,
        };
    }
}
function safeCapture(deps, tmuxSession, lines) {
    try {
        return deps.captureOutput(tmuxSession, lines) ?? '';
    }
    catch {
        return '';
    }
}
/** Default audit sink: append one JSON line to logs/sentinel-events.jsonl. */
export function fileAuditSink(stateDir) {
    const logPath = path.join(stateDir, '..', 'logs', 'sentinel-events.jsonl');
    return (event) => {
        try {
            fs.mkdirSync(path.dirname(logPath), { recursive: true });
            fs.appendFileSync(logPath, JSON.stringify(event) + '\n');
        }
        catch { /* never throw from the audit sink */ }
    };
}
/** Path of the dedicated, reviewable reaper-decision audit trail. */
export function reaperAuditPath(stateDir) {
    return path.join(stateDir, '..', 'logs', 'reaper-audit.jsonl');
}
/**
 * Dedicated reaper audit sink → logs/reaper-audit.jsonl (separate from the
 * shared sentinel log so the reaper's decisions are reviewable on their own).
 * Silent: never throws, never notifies — purely an inspectable record.
 */
export function reaperAuditSink(stateDir) {
    const logPath = reaperAuditPath(stateDir);
    return (event) => {
        try {
            fs.mkdirSync(path.dirname(logPath), { recursive: true });
            fs.appendFileSync(logPath, JSON.stringify(event) + '\n');
        }
        catch { /* never throw from the audit sink */ }
    };
}
/**
 * Read the tail of the reaper audit trail (newest last), bounded to `limit`
 * rows. Returns [] when the file is absent or unreadable — never throws.
 */
export function readReaperAudit(stateDir, limit) {
    const logPath = reaperAuditPath(stateDir);
    let raw;
    try {
        raw = fs.readFileSync(logPath, 'utf-8');
    }
    catch {
        return []; // @silent-fallback-ok — absent audit file ⇒ no rows yet.
    }
    const lines = raw.split('\n').filter(l => l.trim().length > 0);
    const tail = lines.slice(Math.max(0, lines.length - limit));
    const out = [];
    for (const line of tail) {
        try {
            out.push(JSON.parse(line));
        }
        catch { /* skip a torn line */ }
    }
    return out;
}
//# sourceMappingURL=SessionReaper.js.map