/**
 * ActiveWorkSilenceSentinel — detects sessions in the registry that were
 * actively producing output and then went silent for an extended period,
 * independent of topic binding.
 *
 * Closes the watchdog gap surfaced 2026-05-22: a gsd-style sub-spawned
 * worktree session went silent for 1h16m and three existing watchdogs all
 * missed it:
 *   - SessionWatchdog requires a long-running child (this had none).
 *   - SessionMonitor only inspects topic-bound sessions (this wasn't).
 *   - PresenceProxy wakes on inbound user messages (none arrived).
 *
 * This sentinel sits one layer below those: it walks the SessionRegistry
 * directly, looking for "had output recently, now hasn't for N minutes."
 * On match it tries one gentle nudge; if that doesn't unstick the session
 * within the verify window, it escalates via the tone-gated /attention
 * path.
 *
 * Spec: docs/specs/silently-stopped-trio.md
 *
 * Signal-vs-authority: the threshold check is a detector. The nudge is a
 * bounded recovery primitive. The escalation goes through MessagingToneGate
 * via the notify path. No new blocking authority.
 */
import { EventEmitter } from 'node:events';
const DEFAULT_CONFIG = {
    enabled: true,
    tickIntervalMs: 60_000,
    silenceThresholdMs: 30 * 60_000,
    verifyWindowMs: 30_000,
    activeWorkMaxFrozenIndicatorMs: 90 * 60_000,
    autoRecover: false,
    maxAutoRecoveries: 1,
};
export class ActiveWorkSilenceSentinel extends EventEmitter {
    deps;
    cfg;
    states = new Map();
    verifyTimers = new Map();
    tickHandle = null;
    /** Liveness of the tick loop (GUARD-POSTURE-ENDPOINT-SPEC §2.2): 0 = never ticked. */
    lastTickAt = 0;
    constructor(deps, cfg = {}) {
        super();
        this.deps = deps;
        this.cfg = { ...DEFAULT_CONFIG, ...cfg };
    }
    start() {
        if (!this.cfg.enabled || this.tickHandle)
            return;
        this.tickHandle = setInterval(() => this.tick(), this.cfg.tickIntervalMs);
        // Unref so this doesn't keep the process alive on shutdown.
        if (typeof this.tickHandle.unref === 'function')
            this.tickHandle.unref();
    }
    stop() {
        if (this.tickHandle) {
            clearInterval(this.tickHandle);
            this.tickHandle = null;
        }
        for (const t of this.verifyTimers.values())
            this.clearTimer(t);
        this.verifyTimers.clear();
        this.states.clear();
    }
    /** Public for tests. Walk the registry and act on silence findings. */
    tick() {
        this.lastTickAt = Date.now();
        const now = (this.deps.now ?? Date.now)();
        const sessions = this.deps.listSessions();
        const pastThreshold = new Set();
        for (const s of sessions) {
            if (s.paused || s.recoveryInFlight)
                continue;
            if (!s.lastOutputAt || s.lastOutputAt <= 0)
                continue;
            // Session is in the registry but never produced output → not "actively working then stopped"; skip.
            const idleMs = now - s.lastOutputAt;
            if (idleMs < this.cfg.silenceThresholdMs)
                continue;
            pastThreshold.add(s.sessionName);
            const existing = this.states.get(s.sessionName);
            if (existing) {
                // A1/A5 (HONEST-PROGRESS-MESSAGING): a session whose escalation was
                // suppressed because it still looked actively working is re-evaluated
                // every tick — so A5's frozen-indicator timeout can eventually fire and
                // a turn that finishes mid-suppression gets re-judged as a real wedge.
                if (existing.status === 'suppressed-active')
                    void this.escalate(s.sessionName);
                continue; // any other status: already handling
            }
            this.report(s.sessionName, s.lastOutputAt);
        }
        // A suppressed-active session that is no longer past the silence threshold
        // (its frame changed → genuine progress, or it ended) is cleared so the map
        // can't leak and the session is monitored fresh.
        for (const [name, st] of this.states) {
            if (st.status === 'suppressed-active' && !pastThreshold.has(name)) {
                this.clear(name);
                this.emit('recovered', name);
            }
        }
    }
    /** Public entry: report a silence finding. Idempotent. */
    report(sessionName, lastOutputAt) {
        if (!this.cfg.enabled)
            return;
        if (this.states.has(sessionName))
            return;
        const now = (this.deps.now ?? Date.now)();
        const state = {
            sessionName,
            detectedAt: now,
            lastOutputAtAtDetection: lastOutputAt,
            nudgedAt: 0,
            status: 'detected',
            recoveryAttempts: 0,
        };
        this.states.set(sessionName, state);
        this.emit('silence', { sessionName, idleMs: now - lastOutputAt });
        void this.runNudge(sessionName);
    }
    isRecoveryActive(sessionName) {
        const s = this.states.get(sessionName);
        return !!s && s.status !== 'recovered' && s.status !== 'escalated';
    }
    listActive() {
        return Array.from(this.states.values());
    }
    clear(sessionName) {
        const t = this.verifyTimers.get(sessionName);
        if (t)
            this.clearTimer(t);
        this.verifyTimers.delete(sessionName);
        this.states.delete(sessionName);
    }
    async runNudge(sessionName) {
        const state = this.states.get(sessionName);
        if (!state)
            return;
        state.status = 'nudged';
        state.nudgedAt = (this.deps.now ?? Date.now)();
        let accepted = false;
        try {
            accepted = await this.deps.nudgeFn(sessionName);
        }
        catch (err) {
            this.emit('nudge-error', { sessionName, err });
            accepted = false;
        }
        if (!accepted) {
            // Couldn't even nudge — escalate immediately (corroborated inside escalate).
            void this.escalate(sessionName);
            return;
        }
        const handle = this.setTimer(() => this.verifyNudge(sessionName), this.cfg.verifyWindowMs);
        this.verifyTimers.set(sessionName, handle);
    }
    verifyNudge(sessionName) {
        const state = this.states.get(sessionName);
        if (!state)
            return;
        // Re-poll the registry — has lastOutputAt advanced past detection point?
        const fresh = this.deps.listSessions().find(s => s.sessionName === sessionName);
        if (!fresh) {
            // Session vanished from registry — treat as recovered (probably ended cleanly).
            state.status = 'recovered';
            this.clear(sessionName);
            return;
        }
        if (fresh.lastOutputAt > state.lastOutputAtAtDetection) {
            state.status = 'recovered';
            this.emit('recovered', sessionName);
            this.clear(sessionName);
            return;
        }
        void this.escalate(sessionName);
    }
    /**
     * Corroboration gate (HONEST-PROGRESS-MESSAGING A1/A2/A5). Before claiming a
     * session is stuck, re-capture its LIVE frame and prove it really is wedged —
     * a static scrollback on a session that is mid-tool, mid-sub-agent, or just
     * finished is NOT a freeze. The whole path FAILS CLOSED (FD-6): any error
     * suppresses the escalation rather than risk a false "it's stuck" claim.
     */
    async escalate(sessionName) {
        const state = this.states.get(sessionName);
        if (!state)
            return;
        // Legacy path: no live-frame capture wired → keep the original behavior but
        // with the honest A3 wording. Corroboration is unavailable, so escalate on
        // threshold + failed-nudge as before.
        if (!this.deps.captureFrame) {
            this.deps.recordEvent?.('escalated_legacy', sessionName);
            this.proceedEscalation(sessionName, this.honestAskMessage(sessionName));
            return;
        }
        let frame;
        try {
            frame = this.deps.captureFrame(sessionName);
        }
        catch (err) {
            // FD-6 — capture threw: unreliable evidence, suppress + re-arm.
            this.deps.recordEvent?.('suppressed_corroborate_error', sessionName, String(err));
            this.markSuppressedActive(state, undefined);
            return;
        }
        if (!frame) {
            // No frame to judge → fail closed (suppress + re-arm).
            this.deps.recordEvent?.('suppressed_corroborate_error', sessionName, 'empty-frame');
            this.markSuppressedActive(state, undefined);
            return;
        }
        let activelyWorking = false;
        try {
            activelyWorking = this.deps.looksActivelyWorking?.(frame, sessionName) ?? false;
        }
        catch (err) {
            this.deps.recordEvent?.('suppressed_corroborate_error', sessionName, String(err));
            this.markSuppressedActive(state, undefined);
            return;
        }
        // A1 + A5: the live frame still shows an active-work indicator → in an active
        // turn (a long task), NOT a freeze. Suppress — unless the SAME frame has been
        // byte-identical past the frozen-indicator timeout, the rare genuine hang.
        if (activelyWorking) {
            const hash = cheapHash(frame);
            const now = (this.deps.now ?? Date.now)();
            const frozenLongEnough = state.activeFrozenHash === hash &&
                state.activeFrozenSince != null &&
                now - state.activeFrozenSince >= this.cfg.activeWorkMaxFrozenIndicatorMs;
            if (frozenLongEnough) {
                this.deps.recordEvent?.('escalated_frozen_indicator', sessionName);
                state.status = 'escalated';
                const minutes = Math.max(1, Math.round(this.cfg.activeWorkMaxFrozenIndicatorMs / 60_000));
                void this.notify(sessionName, `${friendlyName(sessionName)} has shown the same "working" frame for ${minutes} min with zero change — could be a long task, or a hang that froze mid-step. Worth a look?`);
                this.emit('escalated', sessionName);
                return;
            }
            // Still working (or the frozen-frame timer hasn't elapsed) → suppress.
            this.markSuppressedActive(state, hash);
            this.deps.recordEvent?.('suppressed_active_indicator', sessionName);
            return;
        }
        // A2(c): a live sub-agent means the session is mid-work, not wedged. Suppress.
        let subagentLive = false;
        try {
            subagentLive = this.deps.hasActiveSubagents?.(sessionName) ?? false;
        }
        catch {
            // A failing tracker read is unreliable evidence → fail closed (suppress).
            this.deps.recordEvent?.('suppressed_corroborate_error', sessionName, 'subagent-check-threw');
            this.markSuppressedActive(state, undefined);
            return;
        }
        if (subagentLive) {
            this.markSuppressedActive(state, undefined);
            this.deps.recordEvent?.('suppressed_subagent_live', sessionName);
            return;
        }
        // A2(b): a clean idle prompt is a finished/idle turn, not a wedge. Drop the
        // episode silently (the tracker's paused flag normally catches this; the live
        // frame is the authority).
        let cleanIdle = false;
        try {
            cleanIdle = this.deps.isCleanIdlePrompt?.(frame, sessionName) ?? false;
        }
        catch {
            cleanIdle = false;
        }
        if (cleanIdle) {
            this.clear(sessionName);
            this.emit('recovered', sessionName);
            return;
        }
        // Corroborated wedge: no active-work indicator, no live sub-agent, and the
        // frame is in an indeterminate non-prompt state. This is a genuine freeze.
        this.deps.recordEvent?.('escalated_indeterminate', sessionName);
        this.proceedEscalation(sessionName, this.honestAskMessage(sessionName));
    }
    /** Mark a session as suppressed-active and (re)stamp the frozen-frame timer.
     *  A changed frame hash resets the A5 timer (genuine progress within the turn). */
    markSuppressedActive(state, frameHash) {
        const now = (this.deps.now ?? Date.now)();
        if (frameHash != null && state.activeFrozenHash !== frameHash) {
            state.activeFrozenHash = frameHash;
            state.activeFrozenSince = now;
        }
        else if (frameHash == null) {
            // Couldn't hash the frame (error path) — don't arm A5 on bad evidence.
            state.activeFrozenHash = undefined;
            state.activeFrozenSince = undefined;
        } // else: same hash → keep the existing activeFrozenSince so A5 can elapse.
        state.status = 'suppressed-active';
        this.emit('suppressed-active', state.sessionName);
    }
    /** The autoRecover-or-ask escalation, reached only after corroboration (or in
     *  the legacy no-capture path). */
    proceedEscalation(sessionName, askText) {
        const state = this.states.get(sessionName);
        if (!state)
            return;
        // Auto-heal path (dark by default): after the nudge failed, recover the
        // session (respawn) instead of only asking the user — bounded by
        // maxAutoRecoveries so a session that stays stuck can't trigger a
        // respawn-loop. Falls through to the ask-path when off / cap reached / no
        // recoverFn wired.
        if (this.cfg.autoRecover && this.deps.recoverFn && state.recoveryAttempts < this.cfg.maxAutoRecoveries) {
            void this.runRecovery(sessionName);
            return;
        }
        state.status = 'escalated';
        void this.notify(sessionName, askText);
        this.emit('escalated', sessionName);
    }
    /** A3 honest wording — evidence + uncertainty, never an asserted conclusion. */
    honestAskMessage(sessionName) {
        const state = this.states.get(sessionName);
        const base = state?.lastOutputAtAtDetection ?? (this.deps.now ?? Date.now)();
        const minutes = Math.max(1, Math.round(((this.deps.now ?? Date.now)() - base) / 60_000));
        return `${friendlyName(sessionName)}'s screen hasn't changed in ${minutes} min and a nudge didn't wake it — it may be stuck, or on a long task I can't see into. Want me to check?`;
    }
    /**
     * Auto-recovery ladder (dark by default): notify in the stalled session's own
     * topic, respawn it, then notify the outcome. Bounded by maxAutoRecoveries
     * (the recoveryAttempts increment + the escalate() guard) so a session that
     * stays stuck after a respawn falls back to asking the user — never a loop.
     */
    async runRecovery(sessionName) {
        const state = this.states.get(sessionName);
        if (!state || !this.deps.recoverFn)
            return;
        state.status = 'recovering';
        state.recoveryAttempts += 1;
        const minutes = Math.max(1, Math.round(((this.deps.now ?? Date.now)() - state.lastOutputAtAtDetection) / 60_000));
        await this.notify(sessionName, `${friendlyName(sessionName)} went quiet about ${minutes} minutes ago and a nudge didn't wake it — auto-recovering it now.`);
        this.emit('recovering', sessionName);
        let ok = false;
        try {
            ok = await this.deps.recoverFn(sessionName);
        }
        catch (err) {
            this.emit('recover-error', { sessionName, err });
            ok = false;
        }
        if (ok) {
            state.status = 'recovered';
            await this.notify(sessionName, `${friendlyName(sessionName)} was stuck — I recovered it (fresh restart, conversation preserved). It should pick back up now.`);
            this.emit('recovered', sessionName);
            // Clear so the freshly-respawned session is monitored anew. The respawn
            // resets its output clock, so it won't immediately re-trigger.
            this.clear(sessionName);
            return;
        }
        // Respawn failed — fall back to asking the user, and DO NOT clear (the
        // persisted state stops tick() re-detecting → no auto-recovery loop).
        state.status = 'recovery-failed';
        await this.notify(sessionName, `${friendlyName(sessionName)} went quiet and I couldn't auto-recover it. Want me to dig in?`);
        this.emit('recovery-failed', sessionName);
    }
    async notify(sessionName, text) {
        try {
            await this.deps.notifyFn(sessionName, text);
        }
        catch (err) {
            this.emit('notify-error', { sessionName, err });
        }
    }
    setTimer(fn, ms) {
        return (this.deps.setTimer ?? setTimeout)(fn, ms);
    }
    clearTimer(handle) {
        (this.deps.clearTimer ?? clearTimeout)(handle);
    }
    /** Sync in-memory runtime read for the GuardRegistry (GET /guards).
     *  MUST stay a cheap property read — no I/O, no session listing. */
    guardStatus() {
        return { enabled: this.cfg.enabled, lastTickAt: this.lastTickAt };
    }
}
function friendlyName(sessionName) {
    const stripped = sessionName
        .replace(/^ai\.instar\./, '')
        .replace(/-server$/, '')
        .replace(/-lifeline$/, '');
    // FD-7 (HONEST-PROGRESS-MESSAGING): the name is embedded in a user-facing
    // message — clamp to a safe charset so a crafted tmux session name can't inject
    // markdown/control characters into the escalation text. Session names are
    // already alphanumeric/dash/dot by construction; this is defense-in-depth.
    const safe = stripped.replace(/[^A-Za-z0-9._-]/g, '');
    return safe.length > 0 ? safe : 'a session';
}
/** FNV-1a — enough to detect that a captured live frame changed byte-for-byte
 *  (A5 frozen-indicator timer). Not security-sensitive. */
function cheapHash(s) {
    let h = 0x811c9dc5;
    for (let i = 0; i < s.length; i++) {
        h ^= s.charCodeAt(i);
        h = Math.imul(h, 0x01000193);
    }
    return (h >>> 0).toString(16);
}
//# sourceMappingURL=ActiveWorkSilenceSentinel.js.map