"use strict";
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
    if (k2 === undefined) k2 = k;
    var desc = Object.getOwnPropertyDescriptor(m, k);
    if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
      desc = { enumerable: true, get: function() { return m[k]; } };
    }
    Object.defineProperty(o, k2, desc);
}) : (function(o, m, k, k2) {
    if (k2 === undefined) k2 = k;
    o[k2] = m[k];
}));
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
    Object.defineProperty(o, "default", { enumerable: true, value: v });
}) : function(o, v) {
    o["default"] = v;
});
var __importStar = (this && this.__importStar) || (function () {
    var ownKeys = function(o) {
        ownKeys = Object.getOwnPropertyNames || function (o) {
            var ar = [];
            for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
            return ar;
        };
        return ownKeys(o);
    };
    return function (mod) {
        if (mod && mod.__esModule) return mod;
        var result = {};
        if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
        __setModuleDefault(result, mod);
        return result;
    };
})();
Object.defineProperty(exports, "__esModule", { value: true });
exports.terminalManager = void 0;
const pty = __importStar(require("node-pty"));
const fs = __importStar(require("fs"));
const events_1 = require("events");
const config_1 = require("./config");
const session_manager_1 = require("./session-manager");
const adapters_1 = require("./adapters");
const logger_1 = require("./logger");
const memory_watcher_1 = require("./memory-watcher");
const cli_prompt_detector_1 = require("./cli-prompt-detector");
// RED LINE (logger.ts rule #1): PTY byte streams — both input (writeRaw) and
// output (onData) — NEVER enter log events, not even truncated or as preview.
// Users paste API keys / tokens / session secrets into Claude TUI; the PTY
// stream is secret-contaminated. Log meta only (projectId, bytes, exitCode).
const log = (0, logger_1.modLogger)('terminal');
// Maximum scrollback buffer size per terminal (~5M characters)
const SCROLLBACK_MAX_CHARS = 5 * 1024 * 1024;
const MAX_RESTART_RETRIES = 5;
const RESTART_BASE_DELAY_MS = 3000;
class TerminalManager extends events_1.EventEmitter {
    constructor() {
        super();
        this.terminals = new Map();
        /** Pending auto-restart timers, keyed by projectId. Tracked separately so stop() can cancel them. */
        this.restartTimers = new Map();
        /** Consecutive crash count per project for exponential backoff */
        this.crashCounts = new Map();
        /** Set of projects where the "no resumable session → drop --continue" one-shot
         *  fallback has already fired. Without this guard, the auto-restart timer
         *  unconditionally re-spawns with --continue + startTerminal resets
         *  crashCounts, so handleExit would see (crashes0=0, continueSession=true)
         *  again and fall back forever, never exhausting MAX_RESTART_RETRIES. Cleared
         *  on stop() / killForUpdate() / switchCliTool() — i.e. only when the project's
         *  PTY lifecycle restarts cleanly. */
        this.continueFallbackDone = new Set();
        /** Throttle activity emissions to max once per 500ms per project */
        this.activityThrottles = new Map();
    }
    getOrCreate(project, rawBroadcast = () => { }, continueSession = false) {
        const existing = this.terminals.get(project.id);
        if (existing) {
            existing.rawBroadcast = rawBroadcast;
            return;
        }
        this.startTerminal(project, rawBroadcast, continueSession);
    }
    updateBroadcast(projectId, rawBroadcast) {
        const instance = this.terminals.get(projectId);
        if (instance)
            instance.rawBroadcast = rawBroadcast;
    }
    /**
     * Write raw keystrokes directly to the PTY.
     * RED LINE: do NOT log `data` — see top-of-file comment.
     */
    writeRaw(projectId, data) {
        this.terminals.get(projectId)?.pty.write(data);
    }
    /** Resize the PTY to match the browser terminal. */
    resize(projectId, cols, rows) {
        const instance = this.terminals.get(projectId);
        if (!instance)
            return;
        try {
            instance.pty.resize(Math.max(cols, 10), Math.max(rows, 5));
        }
        catch (err) {
            log.warn({ err, projectId, cols, rows }, 'pty resize failed');
        }
    }
    stop(projectId) {
        // Cancel any pending auto-restart timer first
        const timer = this.restartTimers.get(projectId);
        if (timer !== undefined) {
            clearTimeout(timer);
            this.restartTimers.delete(projectId);
        }
        // Clear lifecycle-bound state BEFORE the early-return so that a project
        // sitting in restartTimer-only state (no live PTY) still has its fallback
        // gate reset on stop. Otherwise a subsequent /start would inherit a spent
        // gate and skip the one-shot --continue fallback for the new lifecycle.
        this.continueFallbackDone.delete(projectId);
        const instance = this.terminals.get(projectId);
        if (!instance)
            return;
        instance.intentionalStop = true;
        try {
            instance.pty.kill();
        }
        catch { /* ignore */ }
        this.terminals.delete(projectId);
        this.activityThrottles.delete(projectId);
        // Clean up session watcher to prevent orphaned polling intervals
        session_manager_1.sessionManager.stopWatcherForProject(projectId);
        (0, memory_watcher_1.stopMemoryWatcher)(projectId);
        instance.project.status = 'stopped';
        (0, config_1.saveProject)(instance.project);
    }
    /**
     * Tear down the existing PTY (if any) and spawn a new one with the project's
     * current cliTool. Preserves the registered rawBroadcast so live WS clients
     * keep receiving output without reconnecting. Caller is responsible for
     * persisting `updatedProject.cliTool` to disk before calling.
     *
     * Emits a `cli-tool-switched` event so server bookkeeping (e.g. broadcasting
     * a `terminal_reset` WS message that asks browsers to clear their xterm
     * scrollback) can fire without reaching into projectClients from this file.
     */
    switchCliTool(updatedProject, continueSession) {
        const projectId = updatedProject.id;
        const existing = this.terminals.get(projectId);
        const broadcast = existing?.rawBroadcast ?? (() => { });
        const timer = this.restartTimers.get(projectId);
        if (timer !== undefined) {
            clearTimeout(timer);
            this.restartTimers.delete(projectId);
        }
        if (existing) {
            existing.intentionalStop = true;
            try {
                existing.pty.kill();
            }
            catch { /* ignore */ }
            this.terminals.delete(projectId);
            this.activityThrottles.delete(projectId);
            session_manager_1.sessionManager.stopWatcherForProject(projectId);
            (0, memory_watcher_1.stopMemoryWatcher)(projectId);
        }
        this.crashCounts.delete(projectId);
        this.continueFallbackDone.delete(projectId);
        this.emit('cli-tool-switched', { projectId, cliTool: updatedProject.cliTool });
        this.startTerminal(updatedProject, broadcast, continueSession);
    }
    /** Kill PTY without changing project status — used during update so resumeAll can restart with --continue. */
    killForUpdate(projectId) {
        const timer = this.restartTimers.get(projectId);
        if (timer !== undefined) {
            clearTimeout(timer);
            this.restartTimers.delete(projectId);
        }
        const instance = this.terminals.get(projectId);
        if (!instance)
            return;
        instance.intentionalStop = true;
        try {
            instance.pty.kill();
        }
        catch { /* ignore */ }
        this.terminals.delete(projectId);
        this.activityThrottles.delete(projectId);
        this.continueFallbackDone.delete(projectId);
        session_manager_1.sessionManager.stopWatcherForProject(projectId);
        (0, memory_watcher_1.stopMemoryWatcher)(projectId);
        // Deliberately do NOT change project.status — keep it as 'running'
    }
    hasTerminal(projectId) {
        return this.terminals.has(projectId);
    }
    /** Return an opaque identity for the current PTY instance, or null if
     *  none. Callers can cache this across an `await` and compare later to
     *  detect `stop()` + `start()` cycles that happened during the wait. */
    getTerminalRef(projectId) {
        return this.terminals.get(projectId)?.pty ?? null;
    }
    /** Return the accumulated raw scrollback for a project (for replay on reconnect). */
    getScrollback(projectId) {
        return this.terminals.get(projectId)?.scrollback ?? '';
    }
    /** Return epoch ms of last PTY data, or null if no terminal / never had data. */
    getLastActivityAt(projectId) {
        const instance = this.terminals.get(projectId);
        return instance ? instance.lastActivityAt : null;
    }
    /** Derive project status from in-memory state (not disk). Source of truth for running/restarting. */
    getProjectStatus(projectId) {
        if (this.terminals.has(projectId))
            return 'running';
        if (this.restartTimers.has(projectId))
            return 'restarting';
        return 'stopped';
    }
    /** Return activity map for all running terminals: projectId → lastActivityAt ms. */
    getAllActivity() {
        const result = {};
        for (const [id, instance] of this.terminals) {
            if (instance.lastActivityAt !== null) {
                result[id] = instance.lastActivityAt;
            }
        }
        return result;
    }
    /** Return IDs of all projects that have a terminal (including those with no output yet). */
    getAllRunningIds() {
        return [...this.terminals.keys(), ...this.restartTimers.keys()];
    }
    resumeAll() {
        for (const project of (0, config_1.getProjects)()) {
            if (project.status === 'running' || project.status === 'restarting') {
                // Terminal-only projects: mark stopped instead of resuming (no session to continue)
                if (project.cliTool === 'terminal') {
                    project.status = 'stopped';
                    (0, config_1.saveProject)(project);
                    continue;
                }
                log.info({ projectId: project.id, projectName: project.name }, 'resuming terminal with --continue');
                this.startTerminal(project, () => { }, true);
            }
        }
    }
    startTerminal(project, rawBroadcast, continueSession = false) {
        const adapter = (0, adapters_1.getAdapter)(project.cliTool ?? 'claude');
        const effectiveContinue = continueSession && adapter.supportsContinue();
        const command = adapter.buildCommand(project.permissionMode, effectiveContinue);
        log.info({
            projectId: project.id,
            projectName: project.name,
            cliTool: project.cliTool,
            command: command || '(bare shell)',
            continueSession: effectiveContinue,
        }, 'starting terminal');
        let ptyProcess;
        try {
            const userShell = process.env.SHELL || (fs.existsSync('/bin/zsh') ? '/bin/zsh' : '/bin/bash');
            const env = { ...process.env };
            // Set COLORFGBG so Ink-based CLIs (Gemini, Codex) detect dark/light theme.
            // Default to dark; frontend sends /theme command on subscribe if different.
            if (!env.COLORFGBG) {
                env.COLORFGBG = '15;0'; // light fg on dark bg = dark theme
            }
            // Terminal-only projects: spawn a bare interactive shell (no CLI command)
            const args = command ? ['-ilc', command] : ['-il'];
            ptyProcess = pty.spawn(userShell, args, {
                name: 'xterm-256color',
                cols: 80, // conservative default; resized to browser width on first subscribe
                rows: 24,
                cwd: project.folderPath,
                env,
            });
        }
        catch (err) {
            log.error({ err, projectId: project.id, cliTool: project.cliTool }, 'pty spawn failed');
            project.status = 'stopped';
            (0, config_1.saveProject)(project);
            // Clear the gate so the next /start can fall back again. Without this,
            // a fallback-triggered startTerminal that fails at spawn() would leave
            // continueFallbackDone set forever for this project.
            this.continueFallbackDone.delete(project.id);
            return;
        }
        const instance = {
            pty: ptyProcess,
            project,
            intentionalStop: false,
            rawBroadcast,
            scrollback: '',
            lastActivityAt: null,
            continueSession: effectiveContinue,
        };
        this.terminals.set(project.id, instance);
        this.crashCounts.delete(project.id);
        project.status = 'running';
        (0, config_1.saveProject)(project);
        // Terminal-only projects have no session files to watch
        if (project.cliTool !== 'terminal') {
            session_manager_1.sessionManager.startSession(project.id, project.folderPath, project.cliTool ?? 'claude');
        }
        // Auto-sync .ccweb/memory/*.md into CLAUDE.md / AGENTS.md. Idempotent —
        // safe across auto-restart cycles where startTerminal re-enters.
        (0, memory_watcher_1.startMemoryWatcher)(project.id, project.folderPath, project.cliTool);
        ptyProcess.onData((data) => {
            // RED LINE: `data` is PTY output — NEVER log. In-memory scrollback and
            // WS broadcast are the only legitimate consumers. See top-of-file.
            // Identity gate: after switchCliTool / stop replaces or removes this
            // instance, late chunks from the dying old PTY would otherwise leak into
            // the new session's scrollback + WS feed. Drop them.
            if (this.terminals.get(project.id) !== instance)
                return;
            const now = Date.now();
            instance.lastActivityAt = now;
            // Append to scrollback, trimming from front if over cap
            instance.scrollback += data;
            if (instance.scrollback.length > SCROLLBACK_MAX_CHARS) {
                instance.scrollback = instance.scrollback.slice(instance.scrollback.length - SCROLLBACK_MAX_CHARS);
            }
            // Forward to all live terminal clients
            instance.rawBroadcast(data);
            // Sniff for Ink-TUI interactive menus (e.g. claude --continue resume
            // session selector). Detector keeps a small ring buffer and only fires
            // on state transitions, so this is cheap per chunk. NEVER logs `data`.
            cli_prompt_detector_1.cliPromptDetector.feed(project.id, data);
            // Emit activity event (throttled to 500ms per project)
            const lastEmit = this.activityThrottles.get(project.id) ?? 0;
            if (now - lastEmit >= 500) {
                this.activityThrottles.set(project.id, now);
                this.emit('activity', { projectId: project.id, lastActivityAt: now });
            }
        });
        ptyProcess.onExit(({ exitCode }) => {
            log.info({ projectId: project.id, exitCode }, 'terminal exited');
            // Terminal-only projects: all exits are intentional (no auto-restart)
            if (project.cliTool === 'terminal') {
                instance.intentionalStop = true;
            }
            this.handleExit(project.id, instance, exitCode);
        });
    }
    handleExit(projectId, exitingInstance, exitCode) {
        const instance = this.terminals.get(projectId);
        // If switchCliTool / stop already swapped the live instance, this onExit
        // belongs to the OLD pty — `instance` here is the new active one and must
        // not be torn down or auto-restarted. Drop the stale callback.
        if (exitingInstance && instance && instance !== exitingInstance)
            return;
        // Clear any sticky interactive-prompt detection state for this PTY lifecycle.
        // A new PTY (auto-restart / fresh start) will start with empty buffer and
        // re-detect from scratch as data arrives.
        cli_prompt_detector_1.cliPromptDetector.reset(projectId);
        if (!instance || instance.intentionalStop) {
            this.terminals.delete(projectId);
            this.crashCounts.delete(projectId);
            return;
        }
        const { project, rawBroadcast } = instance;
        this.terminals.delete(projectId);
        // First crash that came from a --continue / resume launch: re-spawn fresh
        // without --continue, without burning a retry slot. Covers the common case
        // where the project has no resumable session yet (claude prints
        // "No conversation found to continue" and exits 1, leaving ccweb to spin
        // forever in the auto-restart loop). If the fresh start also fails the
        // normal backoff/give-up path takes over from there.
        //
        // continueFallbackDone gates this to one-shot per PTY lifecycle: the
        // auto-restart timer always passes continueSession=true and startTerminal
        // resets crashCounts, so without the gate (crashes0=0, continueSession=true)
        // would re-trigger fallback forever and MAX_RESTART_RETRIES never bites.
        const crashes0 = this.crashCounts.get(projectId) ?? 0;
        if (crashes0 === 0 &&
            exitingInstance?.continueSession &&
            exitCode != null && exitCode !== 0 &&
            !instance.intentionalStop &&
            !this.continueFallbackDone.has(projectId)) {
            this.continueFallbackDone.add(projectId);
            log.info({ projectId, exitCode }, 'first crash on --continue spawn; falling back to fresh start');
            this.startTerminal(project, rawBroadcast, false);
            return;
        }
        // Exponential backoff: 3s, 6s, 12s, 24s, 48s — then give up
        const crashes = crashes0 + 1;
        this.crashCounts.set(projectId, crashes);
        if (crashes > MAX_RESTART_RETRIES) {
            log.error({ projectId, crashes, maxRetries: MAX_RESTART_RETRIES }, 'giving up auto-restart');
            project.status = 'stopped';
            (0, config_1.saveProject)(project);
            rawBroadcast(`\r\n\x1b[31m[Terminal crashed ${crashes} times — auto-restart disabled. Please restart manually.]\x1b[0m\r\n`);
            this.crashCounts.delete(projectId);
            (0, memory_watcher_1.stopMemoryWatcher)(projectId);
            return;
        }
        const adapter = (0, adapters_1.getAdapter)(project.cliTool ?? 'claude');
        const continueHint = adapter.supportsContinue() ? ' with --continue' : '';
        const delay = RESTART_BASE_DELAY_MS * Math.pow(2, crashes - 1);
        const delaySec = Math.round(delay / 1000);
        project.status = 'restarting';
        (0, config_1.saveProject)(project);
        rawBroadcast(`\r\n\x1b[33m[Terminal exited — restarting${continueHint} in ${delaySec}s… (attempt ${crashes}/${MAX_RESTART_RETRIES})]\x1b[0m\r\n`);
        log.info({
            projectId,
            delaySec,
            crashes,
            maxRetries: MAX_RESTART_RETRIES,
            useContinue: adapter.supportsContinue(),
        }, 'auto-restarting terminal');
        // Clear any existing restart timer to avoid double-restart on rapid crash loop
        const existing = this.restartTimers.get(projectId);
        if (existing)
            clearTimeout(existing);
        const timer = setTimeout(() => {
            this.restartTimers.delete(projectId);
            if (!this.terminals.has(projectId)) {
                this.startTerminal(project, rawBroadcast, true);
            }
        }, delay);
        this.restartTimers.set(projectId, timer);
    }
}
exports.terminalManager = new TerminalManager();
//# sourceMappingURL=terminal-manager.js.map