/**
 * SessionMonitor — Proactive session health monitoring.
 *
 * Unlike StallTriageNurse (reactive — triggers on unanswered messages) or
 * SessionWatchdog (reactive — triggers on stuck bash commands), the
 * SessionMonitor is PROACTIVE: it periodically checks all active sessions
 * and ensures users experience the responsiveness they'd expect.
 *
 * Responsibilities:
 * 1. Detect idle sessions (no tmux output changes for extended periods)
 * 2. Track session responsiveness (time between user message and agent reply)
 * 3. Send proactive health updates to users (not overbearing)
 * 4. Coordinate with StallTriageNurse for recovery when issues are found
 *
 * Design principle: Responsive and informative but not overbearing.
 * One update per issue per session — not a stream of "still checking" messages.
 */
import { EventEmitter } from 'events';
import fs from 'node:fs';
import path from 'node:path';
import { detectContextExhaustion } from './QuotaExhaustionDetector.js';
const DEFAULT_CONFIG = {
    enabled: true,
    pollIntervalSec: 60,
    idleThresholdMinutes: 15,
    notificationCooldownMinutes: 30,
};
export class SessionMonitor extends EventEmitter {
    config;
    deps;
    snapshots = new Map();
    interval = null;
    running = false;
    /** Topic-level cooldown for context exhaustion — survives snapshot cleanup */
    contextExhaustionCooldowns = new Map();
    /** Topic-level notification cooldown — survives snapshot cleanup.
     *  When snapshots are deleted (session dies between polls) and recreated,
     *  snap.notifiedAt resets to null. Without this, the dead-session notification
     *  fires every poll (60s) creating spam. */
    notificationCooldowns = new Map();
    /** topicId -> {sessionName, at} already notified for context exhaustion.
     *  Makes the user notification once-per-failure-EPISODE (per session
     *  instance), not once-per-cooldown-window — the prior behavior re-posted
     *  the same death every cycle while the condition persisted (the 2026-06-06
     *  flood). A successful recovery clears the flag so a future genuine death
     *  notifies. Persisted via deps.statePath so a server restart does not
     *  forget the episode and re-announce the same death once per boot. */
    ctxNotifiedSessions = new Map();
    ctxPersistWarned = false;
    /** Entries older than this are pruned at load — a week-old dead-session
     *  episode is over by any reasonable measure, and pruning bounds the file. */
    static CTX_NOTIFIED_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
    constructor(deps, config) {
        super();
        this.deps = deps;
        this.config = { ...DEFAULT_CONFIG, ...config };
        this.loadCtxNotified();
    }
    /** Load the persisted notify ledger (tolerant: missing/corrupt → empty). */
    loadCtxNotified() {
        if (!this.deps.statePath)
            return;
        try {
            if (!fs.existsSync(this.deps.statePath))
                return;
            const raw = JSON.parse(fs.readFileSync(this.deps.statePath, 'utf-8'));
            const cutoff = Date.now() - SessionMonitor.CTX_NOTIFIED_MAX_AGE_MS;
            for (const [topic, entry] of Object.entries(raw)) {
                const topicId = Number(topic);
                if (!Number.isFinite(topicId) || !entry || typeof entry.sessionName !== 'string')
                    continue;
                if (typeof entry.at !== 'number' || entry.at < cutoff)
                    continue; // pruned
                this.ctxNotifiedSessions.set(topicId, { sessionName: entry.sessionName, at: entry.at });
            }
        }
        catch (err) {
            // A corrupt ledger must never break monitoring — start empty (worst
            // case: one repeat notice per dead session, the pre-persistence status quo).
            console.warn('[SessionMonitor] Could not load ctx-notified ledger — starting empty:', err);
        }
    }
    /** Persist the notify ledger (atomic tmp+rename; failure-tolerant). */
    persistCtxNotified() {
        if (!this.deps.statePath)
            return;
        try {
            const obj = {};
            for (const [topicId, entry] of this.ctxNotifiedSessions)
                obj[String(topicId)] = entry;
            const tmp = `${this.deps.statePath}.tmp`;
            fs.mkdirSync(path.dirname(this.deps.statePath), { recursive: true });
            fs.writeFileSync(tmp, JSON.stringify(obj, null, 2));
            fs.renameSync(tmp, this.deps.statePath);
        }
        catch (err) {
            // Persistence is best-effort: losing it degrades to the pre-persistence
            // behavior (one repeat notice per boot), never to broken monitoring.
            if (!this.ctxPersistWarned) {
                this.ctxPersistWarned = true;
                console.warn('[SessionMonitor] Could not persist ctx-notified ledger (will not warn again):', err);
            }
        }
    }
    // ── Typed Events ────────────────────────────────────────
    emit(event, data) {
        return super.emit(event, data);
    }
    on(event, listener) {
        return super.on(event, listener);
    }
    // ── Public API ──────────────────────────────────────────
    start() {
        if (!this.config.enabled || this.interval)
            return;
        console.log(`[SessionMonitor] Starting (poll: ${this.config.pollIntervalSec}s, idle: ${this.config.idleThresholdMinutes}m)`);
        this.interval = setInterval(() => this.poll(), this.config.pollIntervalSec * 1000);
        // Initial poll after 10 seconds (let other systems initialize)
        setTimeout(() => this.poll(), 10_000);
    }
    stop() {
        if (this.interval) {
            clearInterval(this.interval);
            this.interval = null;
        }
    }
    getStatus() {
        const health = [];
        for (const [topicId, snap] of this.snapshots) {
            health.push({
                topicId,
                sessionName: snap.sessionName,
                status: snap.status,
                idleMinutes: Math.round((Date.now() - snap.lastOutputAt) / 60000),
            });
        }
        return {
            enabled: this.config.enabled,
            trackedSessions: this.snapshots.size,
            sessionHealth: health,
        };
    }
    // ── Core Polling ────────────────────────────────────────
    async poll() {
        if (!this.config.enabled || this.running)
            return;
        this.running = true;
        try {
            const activeTopics = this.deps.getActiveTopicSessions();
            // Clean up snapshots for topics that no longer have sessions
            for (const topicId of this.snapshots.keys()) {
                if (!activeTopics.has(topicId)) {
                    this.snapshots.delete(topicId);
                }
            }
            // Check each active session
            for (const [topicId, sessionName] of activeTopics) {
                try {
                    await this.checkSession(topicId, sessionName);
                }
                catch (err) {
                    console.error(`[SessionMonitor] Error checking topic ${topicId}:`, err);
                }
            }
        }
        finally {
            this.running = false;
        }
    }
    async checkSession(topicId, sessionName) {
        const now = Date.now();
        const alive = this.deps.isSessionAlive(sessionName);
        // Get or create snapshot
        let snap = this.snapshots.get(topicId);
        if (!snap) {
            snap = {
                sessionName,
                topicId,
                lastOutput: '',
                lastOutputAt: now,
                lastUserMessageAt: 0,
                lastAgentMessageAt: 0,
                notifiedAt: null,
                status: 'healthy',
            };
            this.snapshots.set(topicId, snap);
        }
        // Update session name if it changed (respawn)
        snap.sessionName = sessionName;
        // Capture current output
        const currentOutput = alive ? (this.deps.captureSessionOutput(sessionName, 30) || '') : '';
        // Check if output changed
        if (currentOutput !== snap.lastOutput && currentOutput.length > 0) {
            snap.lastOutput = currentOutput;
            snap.lastOutputAt = now;
        }
        // Get recent message timestamps
        const history = this.deps.getTopicHistory(topicId, 5);
        for (const msg of history) {
            const ts = new Date(msg.timestamp).getTime();
            if (msg.fromUser && ts > snap.lastUserMessageAt) {
                snap.lastUserMessageAt = ts;
            }
            else if (!msg.fromUser && ts > snap.lastAgentMessageAt) {
                snap.lastAgentMessageAt = ts;
            }
        }
        // ── Proactive context exhaustion check ────────────────────
        // Run BEFORE health classification because a context-exhausted session
        // appears "alive" with recent output (the error message itself), so it
        // would be classified as 'healthy' and skip all recovery logic.
        if (alive && currentOutput && this.deps.sessionRecovery) {
            // Topic-level cooldown survives snapshot cleanup (which happens when sessions die between polls)
            const lastCtxAction = this.contextExhaustionCooldowns.get(topicId);
            const ctxCooldownMs = this.config.notificationCooldownMinutes * 60 * 1000;
            const inCtxCooldown = lastCtxAction && (now - lastCtxAction) < ctxCooldownMs;
            if (!inCtxCooldown) {
                const ctxCheck = detectContextExhaustion(currentOutput);
                if (ctxCheck.matched) {
                    console.log(`[SessionMonitor] Context exhaustion detected in topic ${topicId} (pattern: ${ctxCheck.pattern})`);
                    this.contextExhaustionCooldowns.set(topicId, now);
                    let recoveryResult = null;
                    try {
                        recoveryResult = await this.deps.sessionRecovery.checkAndRecover(topicId, sessionName);
                        this.emit('monitor:mechanical-recovery', { topicId, sessionName, result: recoveryResult });
                        if (recoveryResult.recovered) {
                            console.log(`[SessionMonitor] Context exhaustion recovery succeeded for topic ${topicId}: ${recoveryResult.message}`);
                            snap.status = 'healthy';
                            snap.notifiedAt = now;
                            if (this.ctxNotifiedSessions.delete(topicId))
                                this.persistCtxNotified(); // episode over — a future genuine death may notify again
                            return;
                        }
                    }
                    catch (err) {
                        console.error(`[SessionMonitor] Context exhaustion recovery error for topic ${topicId}:`, err);
                    }
                    // DEFERRED ≠ dead. The work-check found the session alive and actively
                    // producing work, so the kill was deferred — the session is FINE.
                    // Telling the user it "hit conversation too long and can't continue"
                    // here is a FALSE death report on a live session (2026-06-06 flood:
                    // 68 detections / 29+ deferrals / 0 real recoveries in one day).
                    // Housekeeping → log + event only, NEVER the user.
                    if (recoveryResult?.deferred) {
                        console.log(`[SessionMonitor] Context-exhaustion recovery deferred for topic ${topicId} (session alive + producing work) — suppressing user notification`);
                        this.emit('monitor:recovery-deferred', { topicId, sessionName });
                        return;
                    }
                    // Genuine failure: notify once per session INSTANCE (failure episode),
                    // not once per cooldown window — the same dead session must not
                    // re-announce its death every cycle.
                    if (this.ctxNotifiedSessions.get(topicId)?.sessionName === sessionName) {
                        console.log(`[SessionMonitor] Context-exhaustion already notified for topic ${topicId} session "${sessionName}" — suppressing duplicate`);
                        return;
                    }
                    snap.status = 'dead';
                    await this.deps.sendToTopic(topicId, `Session hit "conversation too long" and can't continue. Send a new message to start a fresh session with your recent history.`).catch(() => { });
                    this.emit('monitor:user-notified', { topicId, message: 'context_exhausted' });
                    this.ctxNotifiedSessions.set(topicId, { sessionName, at: now });
                    this.persistCtxNotified();
                    snap.notifiedAt = now;
                    this.notificationCooldowns.set(topicId, now);
                    return;
                }
            }
        }
        // Determine session health
        const idleMinutes = Math.round((now - snap.lastOutputAt) / 60000);
        const prevStatus = snap.status;
        if (!alive) {
            snap.status = 'dead';
        }
        else if (snap.lastUserMessageAt > snap.lastAgentMessageAt &&
            now - snap.lastUserMessageAt > 10 * 60 * 1000) {
            // User sent a message > 10 min ago and agent hasn't replied
            snap.status = 'unresponsive';
        }
        else if (idleMinutes >= this.config.idleThresholdMinutes) {
            snap.status = 'idle';
        }
        else {
            snap.status = 'healthy';
        }
        // Take action based on health status
        if (snap.status === 'healthy')
            return;
        if (snap.status === prevStatus && snap.notifiedAt)
            return; // Already handled
        // Check notification cooldown — use BOTH snapshot-level and persistent topic-level cooldown.
        // Snapshots get deleted when sessions die between polls, resetting snap.notifiedAt to null.
        // The persistent cooldown survives snapshot cleanup and prevents notification spam.
        const cooldownMs = this.config.notificationCooldownMinutes * 60 * 1000;
        if (snap.notifiedAt && now - snap.notifiedAt < cooldownMs) {
            return;
        }
        const lastNotification = this.notificationCooldowns.get(topicId);
        if (lastNotification && now - lastNotification < cooldownMs) {
            return;
        }
        // Only act if the user has sent a message that's gone unanswered.
        // A session sitting idle after the agent responded is NORMAL — the user
        // just hasn't sent a new message yet. "Recently active" is not a reason
        // to escalate; only an unanswered user message is.
        const userExpectsResponse = snap.lastUserMessageAt > snap.lastAgentMessageAt;
        if (!userExpectsResponse)
            return; // Session idle but no unanswered message — normal state
        // Try mechanical recovery first (fast, no LLM) before escalating to triage.
        // Run for ALL unhealthy statuses — context exhaustion can occur in alive sessions
        // that appear "unresponsive" or even "idle" (the session is alive at a prompt
        // but can't process input because the conversation is too long).
        if (this.deps.sessionRecovery) {
            try {
                const recoveryResult = await this.deps.sessionRecovery.checkAndRecover(topicId, sessionName);
                this.emit('monitor:mechanical-recovery', { topicId, sessionName, result: recoveryResult });
                if (recoveryResult.recovered) {
                    console.log(`[SessionMonitor] Mechanical recovery succeeded for topic ${topicId}: ${recoveryResult.message}`);
                    snap.status = 'healthy';
                    snap.notifiedAt = now;
                    return;
                }
            }
            catch (err) {
                console.error(`[SessionMonitor] Mechanical recovery error for topic ${topicId}:`, err);
            }
        }
        switch (snap.status) {
            case 'dead': {
                this.emit('monitor:recovery-triggered', { topicId, sessionName, reason: 'session_dead' });
                if (this.deps.triggerTriage) {
                    const result = await this.deps.triggerTriage(topicId, sessionName, 'Session died while user was waiting');
                    if (result.resolved) {
                        snap.status = 'healthy';
                        snap.notifiedAt = now;
                        return;
                    }
                }
                // If triage didn't resolve or isn't available, notify user
                if (userExpectsResponse) {
                    await this.deps.sendToTopic(topicId, `The session has stopped. Send a new message to start a fresh session with full conversation context.`).catch(() => { });
                    this.emit('monitor:user-notified', { topicId, message: 'session_dead' });
                }
                snap.notifiedAt = now;
                this.notificationCooldowns.set(topicId, now);
                break;
            }
            case 'unresponsive': {
                const waitMinutes = Math.round((now - snap.lastUserMessageAt) / 60000);
                this.emit('monitor:unresponsive', { topicId, sessionName, waitMinutes });
                if (this.deps.triggerTriage) {
                    const result = await this.deps.triggerTriage(topicId, sessionName, `Session unresponsive for ${waitMinutes} minutes after user message`);
                    if (result.resolved) {
                        snap.status = 'healthy';
                        snap.notifiedAt = now;
                        return;
                    }
                }
                snap.notifiedAt = now;
                this.notificationCooldowns.set(topicId, now);
                break;
            }
            case 'idle': {
                this.emit('monitor:idle-detected', { topicId, sessionName, idleMinutes });
                // For idle sessions, only notify if user is actively waiting
                if (userExpectsResponse) {
                    const msg = `Session has been idle for ${idleMinutes} minutes. Checking if it needs attention...`;
                    await this.deps.sendToTopic(topicId, msg).catch(() => { });
                    this.emit('monitor:user-notified', { topicId, message: 'session_idle' });
                    if (this.deps.triggerTriage) {
                        await this.deps.triggerTriage(topicId, sessionName, `Session idle for ${idleMinutes} minutes, user waiting`);
                    }
                }
                snap.notifiedAt = now;
                this.notificationCooldowns.set(topicId, now);
                break;
            }
        }
    }
}
//# sourceMappingURL=SessionMonitor.js.map