/**
 * @fileoverview tmux session manager for persistent Claude sessions.
 *
 * This module provides the TmuxManager class which creates and manages
 * tmux sessions that wrap Claude CLI processes. tmux provides:
 *
 * - **Persistence**: Sessions survive server restarts and disconnects
 * - **Ghost recovery**: Orphaned sessions are discovered and reattached on startup
 * - **Resource tracking**: Memory, CPU, and child process stats per session
 * - **Reliable input**: `send-keys -l` sends literal text in a single command
 * - **Teammate support**: Immutable pane IDs enable targeting individual teammates
 *
 * tmux sessions are named `codeman-{sessionId}` and stored in ~/.codeman/mux-sessions.json.
 *
 * Key features:
 * - `send-keys 'text' Enter` sends literal text in a single command
 * - `list-sessions -F` provides structured queries
 * - `display-message -p '#{pane_pid}'` for reliable PID discovery
 * - Single server architecture
 *
 * @module tmux-manager
 */
import { EventEmitter } from 'node:events';
import { execSync, exec } from 'node:child_process';
import { promisify } from 'node:util';
const execAsync = promisify(exec);
import { existsSync, readFileSync, mkdirSync } from 'node:fs';
import { writeFile, rename } from 'node:fs/promises';
import { dirname } from 'node:path';
import { dataPath, DEFAULT_TMUX_SOCKET } from './config/instance.js';
import { getErrorMessage, DEFAULT_NICE_CONFIG, } from './types.js';
import { buildEffortCliArgs } from './session-cli-builder.js';
import { wrapWithNice, SAFE_PATH_PATTERN, findClaudeDir, resolveOpenCodeDir, resolveCodexDir } from './utils/index.js';
// ============================================================================
// Timing Constants
// ============================================================================
import { EXEC_TIMEOUT_MS } from './config/exec-timeout.js';
/** Delay after tmux session creation — enough for detached tmux to be queryable */
const TMUX_CREATION_WAIT_MS = 100;
/** Max retries for getPanePid — tmux server cold-start (e.g. macOS) may need extra time */
const GET_PID_MAX_RETRIES = 5;
const GET_PID_RETRY_MS = 200;
/** Delay after tmux kill command (200ms) */
const TMUX_KILL_WAIT_MS = 200;
/** Delay for graceful shutdown (100ms) */
const GRACEFUL_SHUTDOWN_WAIT_MS = 100;
/** Default stats collection interval (2 seconds) */
const DEFAULT_STATS_INTERVAL_MS = 2000;
/** Stable cwd for tmux server/pane launch; actual session cwd is reached inside the pane. */
const TMUX_LAUNCH_CWD = '/tmp';
/** Claude Code native macOS recommendation for avoiding low nofile startup failures. */
export const CLAUDE_CODE_NOFILE_LIMIT = 2147483646;
/**
 * SAFETY: Test mode detection.
 * When running under vitest (VITEST env var is set automatically),
 * ALL tmux shell commands are disabled. TmuxManager becomes a pure
 * in-memory mock that cannot interact with real tmux sessions.
 *
 * This makes it PHYSICALLY IMPOSSIBLE for any test to:
 * - Kill a tmux session
 * - Create a tmux session
 * - Send input to a tmux session
 * - Discover/reconcile real tmux sessions
 * - Read/write ~/.codeman/mux-sessions.json
 */
const IS_TEST_MODE = !!process.env.VITEST;
/** Path to persisted mux session metadata */
const MUX_SESSIONS_FILE = dataPath('mux-sessions.json');
/** Regex to validate tmux session names (only allow safe characters) */
const SAFE_MUX_NAME_PATTERN = /^codeman-[a-f0-9-]+$/;
/** Legacy pattern for pre-rename sessions (claudeman- prefix) */
const LEGACY_MUX_NAME_PATTERN = /^claudeman-[a-f0-9-]+$/;
/** Regex to validate tmux pane targets (e.g., "%0", "%1", "0", "1") */
const SAFE_PANE_TARGET_PATTERN = /^(%\d+|\d+)$/;
/** Dedicated tmux socket for new Codeman-owned sessions (instance-scoped:
 *  `codeman` for prod, `codeman-beta` on the beta branch). */
const DEFAULT_CODEMAN_TMUX_SOCKET = DEFAULT_TMUX_SOCKET;
/** Regex to validate tmux socket names passed to `tmux -L`. */
const SAFE_TMUX_SOCKET_PATTERN = /^[a-zA-Z0-9_.-]+$/;
/**
 * Separator used in `tmux list-panes -F` output between session name and pid.
 *
 * Must NOT be a backslash-escape (e.g. `\t`, `\n`): under non-tty execution
 * contexts (launchd on macOS, systemd without TTYPath) tmux can emit such
 * escapes as the literal two characters `\` + letter rather than the control
 * byte, breaking the parser and causing every tracked session to be classified
 * as dead — which wipes state.json on restart. '|' is passed through verbatim
 * in every environment and is rejected by tmux's own session-name validation,
 * so it cannot appear inside `#{session_name}` and cause a false split.
 */
const PANE_LIST_SEP = '|';
/** Format string for `tmux list-panes -F`. Keep in sync with {@link parsePaneList}. */
const PANE_LIST_FORMAT = `#{session_name}${PANE_LIST_SEP}#{pane_pid}`;
/**
 * 构建 pane 启动前的 nofile 修复命令。
 *
 * macOS launchd/tmux 组合有时会让 pane 继承 256 的 soft nofile；
 * 新版 Claude Code 会在这种环境下直接退出。这里避免使用 $变量
 * 或命令替换，因为 fullCmd 目前经由双引号 bash -c 传递，外层
 * shell 会提前展开它们。
 */
export function buildNofileLimitCommand(targetLimit = CLAUDE_CODE_NOFILE_LIMIT) {
    const safeLimit = Number.isSafeInteger(targetLimit) && targetLimit > 0 ? targetLimit : CLAUDE_CODE_NOFILE_LIMIT;
    return `ulimit -Sn ${safeLimit} 2>/dev/null || ulimit -n ${safeLimit} 2>/dev/null || true`;
}
/**
 * Parse the output of `tmux list-panes -a -F '#{session_name}|#{pane_pid}'`
 * into a Map of session-name → pane pid. Exported for unit testing.
 *
 * - Skips empty lines and lines without the separator.
 * - Skips entries with a non-numeric pid or empty name.
 */
export function parsePaneList(output) {
    const result = new Map();
    for (const line of output.split('\n')) {
        if (!line)
            continue;
        const sep = line.indexOf(PANE_LIST_SEP);
        if (sep === -1)
            continue;
        const name = line.slice(0, sep);
        const pid = parseInt(line.slice(sep + 1), 10);
        if (name && !Number.isNaN(pid)) {
            result.set(name, pid);
        }
    }
    return result;
}
/**
 * Resolve a target pane id from `tmux list-panes -F '#{pane_id}:#{pane_active}'`.
 * Prefers the active pane and falls back to the first valid pane.
 */
export function resolveTmuxPaneTarget(muxName, paneTarget) {
    if (!isValidMuxName(muxName)) {
        return null;
    }
    if (paneTarget === undefined || paneTarget === 'active') {
        return muxName;
    }
    if (!SAFE_PANE_TARGET_PATTERN.test(paneTarget)) {
        return null;
    }
    return `${muxName}.${paneTarget}`;
}
/**
 * Pick the active pane id from `tmux list-panes -F '#{pane_id}:#{pane_active}'`
 * output (lines like `%0:1`). Returns the pane id whose active flag is 1.
 */
export function resolveActivePaneTarget(output) {
    for (const line of output.split('\n')) {
        const sep = line.indexOf(':');
        if (sep === -1)
            continue;
        const paneId = line.slice(0, sep).trim();
        const active = line.slice(sep + 1).trim();
        if (paneId && active === '1')
            return paneId;
    }
    return null;
}
const GRAPHEME_SEGMENTER = (() => {
    try {
        const Segmenter = Intl.Segmenter;
        return Segmenter ? new Segmenter(undefined, { granularity: 'grapheme' }) : null;
    }
    catch {
        return null;
    }
})();
function findEscapeEnd(text, start) {
    const type = text[start + 1];
    if (type === '[') {
        for (let i = start + 2; i < text.length; i++) {
            const code = text.charCodeAt(i);
            if (code >= 0x40 && code <= 0x7e)
                return i;
        }
        return text.length - 1;
    }
    if (type === ']') {
        for (let i = start + 2; i < text.length; i++) {
            if (text.charCodeAt(i) === 0x07)
                return i;
            if (text[i] === '\x1b' && text[i + 1] === '\\')
                return i + 1;
        }
        return text.length - 1;
    }
    if (type === 'P' || type === '^' || type === '_' || type === 'X') {
        for (let i = start + 2; i < text.length; i++) {
            if (text.charCodeAt(i) === 0x07)
                return i;
            if (text[i] === '\x1b' && text[i + 1] === '\\')
                return i + 1;
        }
        return text.length - 1;
    }
    return Math.min(start + 1, text.length - 1);
}
function sanitizePaneLineStyles(line) {
    let result = '';
    for (let i = 0; i < line.length; i++) {
        if (line[i] !== '\x1b') {
            result += line[i];
            continue;
        }
        const end = findEscapeEnd(line, i);
        const sequence = line.slice(i, end + 1);
        if (isSgrSequence(sequence)) {
            result += sequence;
        }
        i = end;
    }
    return result;
}
function isSgrSequence(sequence) {
    return (sequence.length >= 3 &&
        sequence.charCodeAt(0) === 27 &&
        sequence[1] === '[' &&
        sequence.endsWith('m') &&
        /^[0-9;:]*$/.test(sequence.slice(2, -1)));
}
function isZeroWidthCodePoint(codePoint) {
    return (codePoint === 0x00ad ||
        codePoint === 0x034f ||
        codePoint === 0x061c ||
        codePoint === 0x115f ||
        codePoint === 0x1160 ||
        codePoint === 0x17b4 ||
        codePoint === 0x17b5 ||
        codePoint === 0x180e ||
        codePoint === 0x200b ||
        codePoint === 0x200c ||
        codePoint === 0x200d ||
        codePoint === 0x2060 ||
        codePoint === 0xfeff ||
        (codePoint >= 0x0300 && codePoint <= 0x036f) ||
        (codePoint >= 0x0483 && codePoint <= 0x0489) ||
        (codePoint >= 0x0591 && codePoint <= 0x05bd) ||
        codePoint === 0x05bf ||
        (codePoint >= 0x05c1 && codePoint <= 0x05c2) ||
        (codePoint >= 0x05c4 && codePoint <= 0x05c5) ||
        codePoint === 0x05c7 ||
        (codePoint >= 0x0610 && codePoint <= 0x061a) ||
        (codePoint >= 0x064b && codePoint <= 0x065f) ||
        codePoint === 0x0670 ||
        (codePoint >= 0x06d6 && codePoint <= 0x06dc) ||
        (codePoint >= 0x06df && codePoint <= 0x06e4) ||
        (codePoint >= 0x06e7 && codePoint <= 0x06e8) ||
        (codePoint >= 0x06ea && codePoint <= 0x06ed) ||
        codePoint === 0x0711 ||
        (codePoint >= 0x0730 && codePoint <= 0x074a) ||
        (codePoint >= 0x07a6 && codePoint <= 0x07b0) ||
        (codePoint >= 0x07eb && codePoint <= 0x07f3) ||
        (codePoint >= 0x0816 && codePoint <= 0x0819) ||
        (codePoint >= 0x081b && codePoint <= 0x0823) ||
        (codePoint >= 0x0825 && codePoint <= 0x0827) ||
        (codePoint >= 0x0829 && codePoint <= 0x082d) ||
        (codePoint >= 0x0859 && codePoint <= 0x085b) ||
        (codePoint >= 0x08d3 && codePoint <= 0x08e1) ||
        (codePoint >= 0x08e3 && codePoint <= 0x0902) ||
        (codePoint >= 0x093a && codePoint <= 0x093c) ||
        codePoint === 0x094d ||
        (codePoint >= 0x0951 && codePoint <= 0x0957) ||
        (codePoint >= 0x0962 && codePoint <= 0x0963) ||
        (codePoint >= 0x1ab0 && codePoint <= 0x1aff) ||
        (codePoint >= 0x1dc0 && codePoint <= 0x1dff) ||
        (codePoint >= 0x20d0 && codePoint <= 0x20ff) ||
        (codePoint >= 0xfe00 && codePoint <= 0xfe0f) ||
        (codePoint >= 0xfe20 && codePoint <= 0xfe2f) ||
        (codePoint >= 0xe0100 && codePoint <= 0xe01ef));
}
function isWideCodePoint(codePoint) {
    return (codePoint >= 0x1100 &&
        (codePoint <= 0x115f ||
            codePoint === 0x2329 ||
            codePoint === 0x232a ||
            (codePoint >= 0x2e80 && codePoint <= 0xa4cf && codePoint !== 0x303f) ||
            (codePoint >= 0xac00 && codePoint <= 0xd7a3) ||
            (codePoint >= 0xf900 && codePoint <= 0xfaff) ||
            (codePoint >= 0xfe10 && codePoint <= 0xfe19) ||
            (codePoint >= 0xfe30 && codePoint <= 0xfe6f) ||
            (codePoint >= 0xff00 && codePoint <= 0xff60) ||
            (codePoint >= 0xffe0 && codePoint <= 0xffe6) ||
            (codePoint >= 0x1f300 && codePoint <= 0x1faff) ||
            (codePoint >= 0x20000 && codePoint <= 0x3fffd)));
}
function nextGrapheme(text, start) {
    if (GRAPHEME_SEGMENTER) {
        const iterator = GRAPHEME_SEGMENTER.segment(text.slice(start))[Symbol.iterator]();
        const next = iterator.next();
        if (!next.done && next.value.segment) {
            return { value: next.value.segment, nextIndex: start + next.value.segment.length };
        }
    }
    const first = text.codePointAt(start);
    if (first === undefined)
        return { value: '', nextIndex: start + 1 };
    let value = String.fromCodePoint(first);
    let nextIndex = start + value.length;
    while (nextIndex < text.length) {
        const codePoint = text.codePointAt(nextIndex);
        if (codePoint === undefined || !isZeroWidthCodePoint(codePoint))
            break;
        const mark = String.fromCodePoint(codePoint);
        value += mark;
        nextIndex += mark.length;
    }
    return { value, nextIndex };
}
function terminalCellWidth(grapheme) {
    let hasVisible = false;
    let hasWide = false;
    for (let i = 0; i < grapheme.length; i++) {
        const codePoint = grapheme.codePointAt(i);
        if (codePoint === undefined)
            continue;
        if (codePoint > 0xffff)
            i++;
        if (isZeroWidthCodePoint(codePoint) || codePoint < 0x20 || (codePoint >= 0x7f && codePoint < 0xa0)) {
            continue;
        }
        hasVisible = true;
        if (isWideCodePoint(codePoint))
            hasWide = true;
    }
    if (!hasVisible)
        return 0;
    return hasWide ? 2 : 1;
}
function truncatePaneLineByVisibleColumns(line, maxColumns) {
    let result = '';
    let visibleColumns = 0;
    let sawSgr = false;
    for (let i = 0; i < line.length; i++) {
        if (line[i] === '\x1b') {
            const end = findEscapeEnd(line, i);
            const sequence = line.slice(i, end + 1);
            if (isSgrSequence(sequence)) {
                result += sequence;
                sawSgr = true;
            }
            i = end;
            continue;
        }
        const grapheme = nextGrapheme(line, i);
        const width = terminalCellWidth(grapheme.value);
        if (width === 0) {
            result += grapheme.value;
        }
        else if (visibleColumns + width <= maxColumns) {
            result += grapheme.value;
            visibleColumns += width;
        }
        else {
            break;
        }
        i = grapheme.nextIndex - 1;
        if (visibleColumns >= maxColumns) {
            continue;
        }
    }
    if (sawSgr) {
        result += '\x1b[0m';
    }
    return result;
}
export function formatPaneSnapshot(lines, geometry) {
    const cols = Math.max(1, geometry.cols);
    // Paint the full pane width. Earlier this dropped the rightmost column
    // (cols - 1) out of caution about last-column autowrap, but every painted
    // row is immediately followed by an absolute cursor-position CSI (the next
    // row's `\x1b[r;1H`, or the final cursor move), which cancels xterm's
    // pending-wrap state before any further glyph — so the last column is safe.
    const paintCols = cols;
    const rows = Math.max(1, geometry.rows);
    const parts = [];
    for (let row = 0; row < Math.min(lines.length, rows); row++) {
        const safeLine = truncatePaneLineByVisibleColumns(sanitizePaneLineStyles(lines[row]), paintCols);
        parts.push(`\x1b[${row + 1};1H${safeLine}`);
    }
    const cursorX = Math.max(0, Math.min(cols - 1, geometry.cursorX));
    const cursorY = Math.max(0, Math.min(rows - 1, geometry.cursorY));
    parts.push(`\x1b[${cursorY + 1};${cursorX + 1}H`);
    return parts.join('');
}
/** Characters unsafe in paths — shell metacharacters, quotes, and control chars */
const UNSAFE_PATH_CHARS = /[;&|$`(){}<>'"\n\r]/;
/**
 * Validates that a session name contains only safe characters.
 * Prevents command injection via malformed session IDs.
 */
function isValidMuxName(name) {
    return SAFE_MUX_NAME_PATTERN.test(name) || LEGACY_MUX_NAME_PATTERN.test(name);
}
function isValidTerminalDimension(value) {
    return Number.isSafeInteger(value) && value > 0 && value <= 1000;
}
/**
 * Validates that a path contains only safe characters.
 * Prevents command injection via malformed paths.
 */
function isValidPath(path) {
    if (UNSAFE_PATH_CHARS.test(path)) {
        return false;
    }
    if (path.includes('..')) {
        return false;
    }
    return SAFE_PATH_PATTERN.test(path);
}
// ===========================================================================
// Single-socket architecture: ALL Codeman sessions live on one dedicated tmux
// socket (`tmux -L codeman`), isolated from the user's default tmux server.
// The socket name is a process-wide constant (env-overridable for test/multi-
// instance isolation) — it is never stored per-session, so it cannot drift.
// ===========================================================================
/**
 * Resolve the process-wide Codeman tmux socket name. Always returns a valid
 * name: `CODEMAN_TMUX_SOCKET` env override if safe, else the built-in default.
 */
function resolveConfiguredTmuxSocket() {
    const raw = process.env.CODEMAN_TMUX_SOCKET ?? DEFAULT_CODEMAN_TMUX_SOCKET;
    if (!SAFE_TMUX_SOCKET_PATTERN.test(raw)) {
        console.warn(`[TmuxManager] Ignoring invalid CODEMAN_TMUX_SOCKET: ${JSON.stringify(raw)}`);
        return DEFAULT_CODEMAN_TMUX_SOCKET;
    }
    return raw;
}
/** Build the `tmux -L <socket>` command prefix. Socket name is shell-escaped. */
function tmuxCommand(socket) {
    return `tmux -L ${shellescape(socket)}`;
}
/**
 * Build Claude CLI permission flags for the tmux command string.
 * Validates allowedTools to prevent command injection.
 */
function buildClaudePermissionFlags(claudeMode, allowedTools) {
    const mode = claudeMode || 'dangerously-skip-permissions';
    switch (mode) {
        case 'dangerously-skip-permissions':
            return ' --dangerously-skip-permissions';
        case 'allowedTools':
            if (allowedTools) {
                // Sanitize: allow tool names with patterns like Bash(git:*), space/comma-separated
                // Block shell metacharacters: ; & | $ ` \ { } < > ' " newlines
                const hasDangerousChars = /[;&|$`\\{}<>'"[\]\n\r]/.test(allowedTools);
                if (!hasDangerousChars) {
                    return ` --allowedTools "${allowedTools}"`;
                }
            }
            // Fall back to normal mode if tools are invalid or missing
            return '';
        case 'normal':
            return '';
    }
}
/**
 * Build the opencode CLI command with appropriate flags.
 */
function buildOpenCodeCommand(config) {
    const parts = ['opencode'];
    // Model selection — allow provider/model format (alphanumeric, dots, hyphens, slashes)
    if (config?.model) {
        const safeModel = /^[a-zA-Z0-9._\-/]+$/.test(config.model) ? config.model : undefined;
        if (safeModel)
            parts.push('--model', safeModel);
    }
    // Continue existing session
    if (config?.continueSession) {
        const safeId = /^[a-zA-Z0-9_-]+$/.test(config.continueSession) ? config.continueSession : undefined;
        if (safeId)
            parts.push('--session', safeId);
        if (safeId && config.forkSession)
            parts.push('--fork');
    }
    return parts.join(' ');
}
/**
 * Build the codex CLI command with appropriate flags.
 *
 * Codeman launches Codex's native TUI and handles replay/scrollback by
 * stripping destructive terminal sequences before xterm.js sees them.
 */
export function buildCodexCommand(config) {
    const parts = ['codex'];
    if (config?.dangerouslyBypassApprovals) {
        parts.push('--dangerously-bypass-approvals-and-sandbox');
    }
    if (config?.model) {
        const safeModel = /^[a-zA-Z0-9._\-/]+$/.test(config.model) ? config.model : undefined;
        if (safeModel)
            parts.push('--model', safeModel);
    }
    if (config?.resumeSessionId) {
        const safeId = /^[a-zA-Z0-9_-]+$/.test(config.resumeSessionId) ? config.resumeSessionId : undefined;
        if (safeId)
            parts.push('resume', safeId);
    }
    return parts.join(' ');
}
/**
 * Build the spawn command for any session mode.
 * Shared by createSession() and respawnPane() to avoid duplication.
 */
/**
 * Build the shell fragment carrying the effort level as a SOFT default
 * (see buildEffortCliArgs — `--effort <level>` for regular levels incl. max,
 * `--settings '{"ultracode":true}'` for ultracode; deliberately not the
 * CLAUDE_CODE_EFFORT_LEVEL env var, which hard-locks /effort switching).
 *
 * Injection-safe: effort is validated against the EFFORT_LEVELS allowlist inside
 * buildEffortCliArgs, so the single-quoted values contain no user-controlled characters.
 */
function buildEffortSettingsFlag(effort) {
    const [flag, value] = buildEffortCliArgs(effort);
    return flag && value ? ` ${flag} '${value}'` : '';
}
function buildSpawnCommand(options) {
    if (options.mode === 'claude') {
        // Validate model to prevent command injection
        const safeModel = options.model && /^[a-zA-Z0-9._\-[\]]+$/.test(options.model) ? options.model : undefined;
        const modelFlag = safeModel ? ` --model "${safeModel}"` : '';
        const effortFlag = buildEffortSettingsFlag(options.effort);
        // Use --resume to restore a previous conversation, otherwise --session-id for new sessions.
        // Wrap --resume in a fallback: if it exits non-zero (session not found, corrupt, etc.),
        // fall back to a new session with --session-id so the pane doesn't die.
        const safeResumeId = options.resumeSessionId && /^[a-f0-9-]+$/.test(options.resumeSessionId) ? options.resumeSessionId : undefined;
        const permFlags = buildClaudePermissionFlags(options.claudeMode, options.allowedTools);
        if (safeResumeId) {
            const resumeCmd = `claude${permFlags} --resume "${safeResumeId}"${modelFlag}${effortFlag}`;
            const fallbackCmd = `claude${permFlags} --session-id "${options.sessionId}"${modelFlag}${effortFlag}`;
            return `${resumeCmd} || ${fallbackCmd}`;
        }
        return `claude${permFlags} --session-id "${options.sessionId}"${modelFlag}${effortFlag}`;
    }
    if (options.mode === 'opencode') {
        return buildOpenCodeCommand(options.openCodeConfig);
    }
    if (options.mode === 'codex') {
        return buildCodexCommand(options.codexConfig);
    }
    return '$SHELL';
}
/**
 * Set sensitive environment variables on a tmux session via setenv.
 * These are inherited by panes but not visible in ps output or tmux history.
 */
function setOpenCodeEnvVars(tmuxCmd, muxName) {
    const sensitiveVars = ['ANTHROPIC_API_KEY', 'OPENAI_API_KEY', 'GOOGLE_API_KEY'];
    for (const key of sensitiveVars) {
        const val = process.env[key];
        if (val) {
            // Shell-escape: wrap in single quotes, escape any inner single quotes
            const escaped = val.replace(/'/g, "'\\''");
            try {
                execSync(`${tmuxCmd} setenv -t '${muxName}' ${key} '${escaped}'`, {
                    encoding: 'utf8',
                    timeout: EXEC_TIMEOUT_MS,
                    stdio: ['pipe', 'pipe', 'pipe'],
                });
            }
            catch {
                /* Non-critical — key may not be needed */
            }
        }
    }
}
/**
 * Set sensitive environment variables for Codex on a tmux session via setenv.
 * Codex (OpenAI CLI) needs OPENAI_API_KEY; we also forward CODEX_* keys.
 */
function setCodexEnvVars(tmuxCmd, muxName) {
    const sensitiveVars = ['OPENAI_API_KEY', 'CODEX_API_KEY', 'CODEX_HOME'];
    for (const key of sensitiveVars) {
        const val = process.env[key];
        if (val) {
            const escaped = val.replace(/'/g, "'\\''");
            try {
                execSync(`${tmuxCmd} setenv -t '${muxName}' ${key} '${escaped}'`, {
                    encoding: 'utf8',
                    timeout: EXEC_TIMEOUT_MS,
                    stdio: ['pipe', 'pipe', 'pipe'],
                });
            }
            catch {
                /* Non-critical — key may not be needed */
            }
        }
    }
}
/**
 * Set OPENCODE_CONFIG_CONTENT on a tmux session via setenv.
 * Uses tmux setenv to avoid shell metacharacter injection from user-supplied JSON.
 */
function setOpenCodeConfigContent(tmuxCmd, muxName, config) {
    if (!config)
        return;
    let jsonContent;
    if (config.autoAllowTools) {
        const permConfig = { permission: { '*': 'allow' } };
        if (config.configContent) {
            try {
                const existing = JSON.parse(config.configContent);
                Object.assign(permConfig, existing);
                permConfig.permission = { '*': 'allow' };
            }
            catch {
                /* invalid JSON, use default permConfig */
            }
        }
        jsonContent = JSON.stringify(permConfig);
    }
    else if (config.configContent) {
        // Validate JSON to prevent garbage config
        try {
            JSON.parse(config.configContent);
            jsonContent = config.configContent;
        }
        catch {
            console.error('[TmuxManager] Invalid JSON in openCodeConfig.configContent, skipping');
            return;
        }
    }
    if (jsonContent) {
        const escaped = jsonContent.replace(/'/g, "'\\''");
        try {
            execSync(`${tmuxCmd} setenv -t '${muxName}' OPENCODE_CONFIG_CONTENT '${escaped}'`, {
                encoding: 'utf8',
                timeout: EXEC_TIMEOUT_MS,
                stdio: ['pipe', 'pipe', 'pipe'],
            });
        }
        catch {
            /* Non-critical */
        }
    }
}
/**
 * Manages tmux sessions that wrap Claude CLI or shell processes.
 *
 * Implements the TerminalMultiplexer interface.
 *
 * @example
 * ```typescript
 * const manager = new TmuxManager();
 *
 * // Create a tmux session for Claude
 * const session = await manager.createSession({ sessionId, workingDir: '/project', mode: 'claude' });
 *
 * // Send input (single command, no delay!)
 * manager.sendInput(sessionId, '/clear\r');
 *
 * // Kill when done
 * await manager.killSession(sessionId);
 * ```
 */
export class TmuxManager extends EventEmitter {
    backend = 'tmux';
    sessions = new Map();
    tmuxSocket = resolveConfiguredTmuxSocket();
    statsInterval = null;
    mouseSyncInterval = null;
    /** Track last-known pane count per session to avoid unnecessary tmux set-option calls */
    lastPaneCount = new Map();
    trueColorConfigured = false;
    constructor() {
        super();
        this.setMaxListeners(50);
        if (!IS_TEST_MODE) {
            this.loadSessions();
        }
    }
    /** The dedicated tmux socket all Codeman sessions live on (see {@link TerminalMultiplexer.muxSocket}). */
    get muxSocket() {
        return this.tmuxSocket;
    }
    tmux() {
        return tmuxCommand(this.tmuxSocket);
    }
    // Load saved sessions from disk (NEVER called in test mode)
    loadSessions() {
        if (IS_TEST_MODE)
            return;
        try {
            if (existsSync(MUX_SESSIONS_FILE)) {
                const content = readFileSync(MUX_SESSIONS_FILE, 'utf-8');
                const data = JSON.parse(content);
                if (Array.isArray(data)) {
                    // Dedup by muxName: one live tmux session must map to exactly one
                    // tracked entry. A per-session socket-tag mismatch could historically
                    // let the same session be tracked twice — once under its real UUID and
                    // once under a "restored-<id>" placeholder — surfacing as duplicate tabs.
                    // Single-socket unification removed that failure mode; this pass stays
                    // to clean any stale duplicates already on disk. Keep the real (UUID)
                    // entry and drop placeholder twins.
                    let dropped = 0;
                    const keptByMuxName = new Map(); // muxName -> kept sessionId
                    for (const session of data) {
                        // Strip the obsolete per-session tmuxSocket tag (now a process-wide
                        // constant). Left in place it would be written back by saveSessions()
                        // and linger on disk as a zombie field forever.
                        delete session.tmuxSocket;
                        const muxName = session.muxName;
                        const priorId = muxName ? keptByMuxName.get(muxName) : undefined;
                        if (priorId) {
                            const incomingIsPlaceholder = String(session.sessionId).startsWith('restored-');
                            const priorIsPlaceholder = priorId.startsWith('restored-');
                            // Drop the incoming unless it's the real twin of a placeholder we kept.
                            if (incomingIsPlaceholder || !priorIsPlaceholder) {
                                dropped++;
                                continue;
                            }
                            this.sessions.delete(priorId);
                            dropped++;
                        }
                        this.sessions.set(session.sessionId, session);
                        if (muxName)
                            keptByMuxName.set(muxName, session.sessionId);
                    }
                    // Persist the cleaned list so the stale duplicates don't reload.
                    if (dropped > 0) {
                        console.log(`[TmuxManager] Dropped ${dropped} duplicate mux session record(s) on load`);
                        this.saveSessions();
                    }
                }
            }
        }
        catch (err) {
            console.error('[TmuxManager] Failed to load sessions:', err);
        }
    }
    /**
     * Save sessions to disk asynchronously. (NEVER writes in test mode)
     * Uses atomic temp+rename to prevent corruption on crash.
     */
    saveSessions() {
        if (IS_TEST_MODE)
            return;
        try {
            const dir = dirname(MUX_SESSIONS_FILE);
            if (!existsSync(dir)) {
                mkdirSync(dir, { recursive: true });
            }
            const data = Array.from(this.sessions.values());
            const json = JSON.stringify(data, null, 2);
            const tempPath = MUX_SESSIONS_FILE + '.tmp';
            writeFile(tempPath, json, 'utf-8')
                .then(() => rename(tempPath, MUX_SESSIONS_FILE))
                .catch((err) => {
                console.error('[TmuxManager] Failed to save sessions:', err);
            });
        }
        catch (err) {
            console.error('[TmuxManager] Failed to save sessions:', err);
        }
    }
    /**
     * Build the array of environment export commands shared by createSession() and respawnPane().
     * Includes locale, mux markers, session identity, and API URL.
     *
     * User-supplied envOverrides are NOT inlined here — they go through applyEnvOverrides()
     * via `tmux setenv` so secret values (e.g., OPENCODE_API_KEY) never appear in the bash
     * command line (visible in `ps`). This also sidesteps shell-metachar injection via keys.
     */
    buildEnvExports(sessionId, muxName, mode) {
        const exports = [
            'export LANG=en_US.UTF-8',
            'export LC_ALL=en_US.UTF-8',
            mode === 'codex' ? 'export COLORTERM=truecolor' : 'unset COLORTERM',
            ...(mode === 'codex' ? ['unset NO_COLOR'] : []),
            'export CODEMAN_MUX=1',
            `export CODEMAN_SESSION_ID=${sessionId}`,
            `export CODEMAN_MUX_NAME=${muxName}`,
            `export CODEMAN_API_URL=${process.env.CODEMAN_API_URL || 'http://localhost:3000'}`,
            // Path only (not the secret value): hook curl commands cat the file at
            // execution time, so the COD-54 hook secret stays off the command line.
            `export CODEMAN_HOOK_SECRET_FILE="${dataPath('hook-secret')}"`,
        ];
        // Only unset CLAUDECODE for Claude sessions
        if (mode === 'claude')
            exports.splice(2, 0, 'unset CLAUDECODE');
        return exports;
    }
    /**
     * Apply user-supplied env overrides to a tmux session via `tmux setenv`.
     * Values stay off the bash command line (not visible in `ps`), and are inherited
     * by new panes — including `respawn-pane`. Persists at tmux-session level, so
     * Codeman server restarts don't lose the setting as long as the tmux session lives.
     *
     * Key validation is strict (`/^[A-Z_][A-Z0-9_]*$/`) as defense-in-depth against
     * shell-metachar injection even if upstream schema check is bypassed.
     */
    applyEnvOverrides(muxName, envOverrides) {
        // Legacy cleanup: pre-0.7.2 set CLAUDE_CODE_EFFORT_LEVEL via setenv, which persists
        // on the tmux session and hard-locks /effort switching in every respawned pane.
        // Effort now flows as a `--settings` soft default (see buildEffortSettingsFlag),
        // so unconditionally unset the stale var before applying current overrides.
        try {
            execSync(`${this.tmux()} setenv -t ${shellescape(muxName)} -u CLAUDE_CODE_EFFORT_LEVEL`, {
                timeout: EXEC_TIMEOUT_MS,
                stdio: ['pipe', 'pipe', 'pipe'],
            });
        }
        catch {
            /* Non-critical — var may not exist */
        }
        if (!envOverrides)
            return;
        const VALID_KEY = /^[A-Z_][A-Z0-9_]*$/;
        for (const [key, value] of Object.entries(envOverrides)) {
            if (!value)
                continue; // Skip empty — nothing to set
            if (!VALID_KEY.test(key)) {
                console.warn(`[TmuxManager] Skipping invalid env override key: ${JSON.stringify(key)}`);
                continue;
            }
            try {
                execSync(`${this.tmux()} setenv -t ${shellescape(muxName)} ${key} ${shellescape(value)}`, {
                    timeout: EXEC_TIMEOUT_MS,
                    stdio: ['pipe', 'pipe', 'pipe'],
                });
            }
            catch (err) {
                console.warn(`[TmuxManager] Failed to set env override ${key}:`, err);
            }
        }
    }
    /**
     * Resolve the CLI binary directory and return the PATH export prefix string.
     * Returns '' if no override is needed (shell mode) or the binary dir is not found.
     * In createSession(), a missing binary dir throws — the caller handles that separately.
     */
    buildPathExport(mode) {
        if (mode === 'claude') {
            const dir = findClaudeDir();
            return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir };
        }
        if (mode === 'opencode') {
            const dir = resolveOpenCodeDir();
            return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir };
        }
        if (mode === 'codex') {
            const dir = resolveCodexDir();
            return { pathExport: dir ? `export PATH="${dir}:$PATH" && ` : '', dir };
        }
        return { pathExport: '', dir: null };
    }
    /**
     * Configure OpenCode-specific environment on a tmux session.
     * Sets sensitive API keys and config content via tmux setenv
     * (not visible in ps output or tmux history, inherited by panes).
     */
    _configureOpenCode(muxName, openCodeConfig) {
        const tmuxCmd = this.tmux();
        setOpenCodeEnvVars(tmuxCmd, muxName);
        setOpenCodeConfigContent(tmuxCmd, muxName, openCodeConfig);
    }
    /**
     * Configure Codex-specific environment on a tmux session.
     * Sets OPENAI_API_KEY (and related keys) via tmux setenv so secrets don't
     * appear in the bash command line.
     */
    _configureCodex(muxName) {
        setCodexEnvVars(this.tmux(), muxName);
    }
    /**
     * Creates a new tmux session wrapping Claude CLI or a shell.
     * In test mode: creates an in-memory session only (no real tmux session).
     */
    async createSession(options) {
        const { sessionId, workingDir, mode, name, niceConfig, model, claudeMode, allowedTools, openCodeConfig, codexConfig, resumeSessionId, envOverrides, effort, } = options;
        const muxName = `codeman-${sessionId.slice(0, 8)}`;
        if (!isValidMuxName(muxName)) {
            throw new Error('Invalid session name: contains unsafe characters');
        }
        if (!isValidPath(workingDir)) {
            throw new Error('Invalid working directory path: contains unsafe characters');
        }
        // TEST MODE: Create in-memory session only — no real tmux session
        if (IS_TEST_MODE) {
            const session = {
                sessionId,
                muxName,
                pid: 99999,
                createdAt: Date.now(),
                workingDir,
                mode,
                attached: false,
                name,
            };
            this.sessions.set(sessionId, session);
            this.emit('sessionCreated', session);
            return session;
        }
        // Resolve CLI binary directory based on mode
        const { pathExport, dir: cliDir } = this.buildPathExport(mode);
        if (mode === 'claude' && !cliDir) {
            throw new Error('Claude CLI not found. Install it with: curl -fsSL https://claude.ai/install.sh | bash');
        }
        if (mode === 'opencode' && !cliDir) {
            throw new Error('OpenCode CLI not found. Install with: curl -fsSL https://opencode.ai/install | bash');
        }
        const envExportsStr = this.buildEnvExports(sessionId, muxName, mode).join(' && ');
        const baseCmd = buildSpawnCommand({
            mode,
            sessionId,
            model,
            claudeMode,
            allowedTools,
            openCodeConfig,
            codexConfig,
            resumeSessionId,
            effort,
        });
        const config = niceConfig || DEFAULT_NICE_CONFIG;
        const cmd = wrapWithNice(baseCmd, config);
        try {
            // Build the full command to run inside tmux
            const fullCmd = `${buildNofileLimitCommand()} && ${pathExport}${envExportsStr} && ${cmd}`;
            // Create tmux session in three steps to handle cold-start (no server running)
            // and avoid the race where the command exits before remain-on-exit is set:
            // 1. Create session with default shell (starts tmux server, stays alive)
            // 2. Set remain-on-exit (server now exists, session won't vanish on exit)
            // 3. Replace shell with actual command via respawn-pane (no terminal echo)
            // Unset $TMUX so nested sessions work when the dev server itself runs inside tmux.
            // (Production uses systemd which has a clean env, but dev/test may be nested.)
            const cleanEnv = { ...process.env };
            delete cleanEnv.TMUX;
            // Create the session on the dedicated socket (${this.tmux()} = `tmux -L <socket>`),
            // launched in TMUX_LAUNCH_CWD (/tmp) rather than the real workingDir: a FUSE/rclone
            // mount that isn't ready yet makes `getcwd` fail and breaks the spawn (see #110). The
            // pane cd's into workingDir below via respawn-pane.
            execSync(`${this.tmux()} new-session -ds "${muxName}" -c ${TMUX_LAUNCH_CWD}`, {
                cwd: TMUX_LAUNCH_CWD,
                timeout: EXEC_TIMEOUT_MS,
                stdio: 'ignore',
                env: cleanEnv,
            });
            this.resizeWindow(muxName, 120, 40);
            // Set remain-on-exit now that the server is running — must be before respawn-pane
            try {
                execSync(`${this.tmux()} set-option -t "${muxName}" remain-on-exit on`, {
                    timeout: EXEC_TIMEOUT_MS,
                    stdio: 'ignore',
                });
            }
            catch {
                /* Non-critical */
            }
            // For OpenCode: set sensitive env vars and config via tmux setenv
            // (not visible in ps output or tmux history, inherited by panes)
            if (mode === 'opencode') {
                this._configureOpenCode(muxName, openCodeConfig);
            }
            else if (mode === 'codex') {
                this._configureCodex(muxName);
            }
            // Apply user-supplied env overrides (e.g., CLAUDE_CODE_EFFORT_LEVEL) via tmux setenv
            // so secret values stay off the bash command line. Must run before respawn-pane.
            this.applyEnvOverrides(muxName, envOverrides);
            // Replace the shell with the actual command (no echo in terminal). Keep
            // pane launch in /tmp, then cd inside bash against the current mount table.
            const launchCmd = `cd ${JSON.stringify(workingDir)} && ${fullCmd}`;
            execSync(`${this.tmux()} respawn-pane -k -c ${TMUX_LAUNCH_CWD} -t "${muxName}" bash -c ${JSON.stringify(launchCmd)}`, {
                timeout: EXEC_TIMEOUT_MS,
                stdio: 'ignore',
            });
            // Wait for tmux session to be queryable
            await new Promise((resolve) => setTimeout(resolve, TMUX_CREATION_WAIT_MS));
            // Non-critical tmux config — run in parallel to avoid blocking event loop.
            // These configure UX niceties (no status bar, true color).
            // Mouse mode is OFF by default so xterm.js handles text selection natively.
            // It gets enabled dynamically when panes are split (agent teams).
            const configPromises = [
                // Disable tmux status bar — Codeman's web UI provides session info
                execAsync(`${this.tmux()} set-option -t "${muxName}" status off`, { timeout: EXEC_TIMEOUT_MS })
                    .then(() => { })
                    .catch(() => {
                    /* Non-critical — session still works with status bar */
                }),
                // Override global remain-on-exit with session-level setting
                execAsync(`${this.tmux()} set-option -t "${muxName}" remain-on-exit on`, { timeout: EXEC_TIMEOUT_MS })
                    .then(() => { })
                    .catch(() => {
                    /* Already set globally as fallback */
                }),
                // Raise tmux scrollback from its 2000-line default so re-attach preserves
                // more context. Matches the xterm-side default in constants.js.
                execAsync(`${this.tmux()} set-option -t "${muxName}" history-limit 50000`, { timeout: EXEC_TIMEOUT_MS })
                    .then(() => { })
                    .catch(() => {
                    /* Non-critical — falls back to tmux default */
                }),
            ];
            // Enable 24-bit true color passthrough — server-wide, set once per lifetime
            if (!this.trueColorConfigured) {
                configPromises.push(execAsync(`${this.tmux()} set-option -sa terminal-overrides ",*:Tc"`, { timeout: EXEC_TIMEOUT_MS })
                    .then(() => {
                    this.trueColorConfigured = true;
                })
                    .catch(() => {
                    /* Non-critical — colors limited to 256 */
                }));
            }
            // Fire-and-forget — these are non-critical UX niceties that don't need
            // to complete before the session is usable. Errors are already swallowed.
            void Promise.all(configPromises);
            // Get the PID of the pane process (retry for tmux server cold-start)
            let pid = this.getPanePid(muxName);
            for (let i = 0; !pid && i < GET_PID_MAX_RETRIES; i++) {
                await new Promise((resolve) => setTimeout(resolve, GET_PID_RETRY_MS));
                pid = this.getPanePid(muxName);
            }
            if (!pid) {
                throw new Error('Failed to get tmux pane PID');
            }
            const session = {
                sessionId,
                muxName,
                pid,
                createdAt: Date.now(),
                workingDir,
                mode,
                attached: false,
                name,
            };
            this.sessions.set(sessionId, session);
            this.saveSessions();
            this.emit('sessionCreated', session);
            return session;
        }
        catch (err) {
            throw new Error(`Failed to create tmux session: ${getErrorMessage(err)}`);
        }
    }
    /**
     * Get the PID of the process running in the tmux pane.
     */
    getPanePid(muxName) {
        if (IS_TEST_MODE)
            return 99999;
        if (!isValidMuxName(muxName)) {
            console.error('[TmuxManager] Invalid session name in getPanePid:', muxName);
            return null;
        }
        try {
            const output = execSync(`${this.tmux()} display-message -t "${muxName}" -p '#{pane_pid}'`, {
                encoding: 'utf-8',
                timeout: EXEC_TIMEOUT_MS,
            }).trim();
            const pid = parseInt(output, 10);
            return Number.isNaN(pid) ? null : pid;
        }
        catch {
            return null;
        }
    }
    /**
     * Check if a tmux session exists.
     */
    muxSessionExists(muxName) {
        return this.sessionExists(muxName);
    }
    /**
     * Check if the pane in a tmux session is dead (command exited but remain-on-exit keeps it).
     * Returns true if the session exists but the pane's command has exited.
     */
    isPaneDead(muxName) {
        if (IS_TEST_MODE)
            return false;
        if (!isValidMuxName(muxName))
            return false;
        try {
            const output = execSync(`${this.tmux()} display-message -t "${muxName}" -p '#{pane_dead}'`, {
                encoding: 'utf-8',
                timeout: EXEC_TIMEOUT_MS,
            }).trim();
            return output === '1';
        }
        catch {
            return false;
        }
    }
    /**
     * Respawn a dead pane in an existing tmux session.
     * Uses `tmux respawn-pane -k` to restart the command in the same pane,
     * preserving the session and its scrollback buffer.
     */
    async respawnPane(options) {
        const { sessionId, workingDir, mode, niceConfig, model, claudeMode, allowedTools, openCodeConfig, codexConfig, resumeSessionId, envOverrides, effort, } = options;
        const session = this.sessions.get(sessionId);
        if (!session)
            return null;
        const muxName = session.muxName;
        if (!isValidMuxName(muxName) || !isValidPath(workingDir))
            return null;
        // Resolve CLI binary directory based on mode
        const { pathExport } = this.buildPathExport(mode);
        const envExportsStr = this.buildEnvExports(sessionId, muxName, mode).join(' && ');
        const baseCmd = buildSpawnCommand({
            mode,
            sessionId,
            model,
            claudeMode,
            allowedTools,
            openCodeConfig,
            codexConfig,
            resumeSessionId,
            effort,
        });
        const config = niceConfig || DEFAULT_NICE_CONFIG;
        const cmd = wrapWithNice(baseCmd, config);
        const fullCmd = `${buildNofileLimitCommand()} && ${pathExport}${envExportsStr} && ${cmd}`;
        try {
            // For OpenCode: set sensitive env vars via tmux setenv before respawn
            if (mode === 'opencode') {
                this._configureOpenCode(muxName, openCodeConfig);
            }
            else if (mode === 'codex') {
                this._configureCodex(muxName);
            }
            // Re-apply user env overrides before respawn so the new shell inherits them.
            this.applyEnvOverrides(muxName, envOverrides);
            const launchCmd = `cd ${JSON.stringify(workingDir)} && ${fullCmd}`;
            await execAsync(`${this.tmux()} respawn-pane -k -c ${TMUX_LAUNCH_CWD} -t "${muxName}" bash -c ${JSON.stringify(launchCmd)}`, {
                timeout: EXEC_TIMEOUT_MS,
            });
            // Wait for the respawned process to start
            await new Promise((resolve) => setTimeout(resolve, TMUX_CREATION_WAIT_MS));
            const pid = this.getPanePid(muxName);
            if (pid)
                session.pid = pid;
            return pid;
        }
        catch (err) {
            console.error('[TmuxManager] Failed to respawn pane:', err);
            return null;
        }
    }
    sessionExists(muxName) {
        if (IS_TEST_MODE)
            return false;
        if (!isValidMuxName(muxName))
            return false;
        try {
            execSync(`${this.tmux()} has-session -t "${muxName}" 2>/dev/null`, {
                encoding: 'utf-8',
                timeout: EXEC_TIMEOUT_MS,
            });
            return true;
        }
        catch {
            return false;
        }
    }
    // Get all child process PIDs recursively
    getChildPids(pid) {
        const pids = [];
        try {
            const output = execSync(`pgrep -P ${pid}`, {
                encoding: 'utf-8',
                timeout: EXEC_TIMEOUT_MS,
            }).trim();
            if (output) {
                for (const childPid of output
                    .split('\n')
                    .map((p) => parseInt(p, 10))
                    .filter((p) => !Number.isNaN(p))) {
                    pids.push(childPid);
                    pids.push(...this.getChildPids(childPid));
                }
            }
        }
        catch {
            // No children or command failed
        }
        return pids;
    }
    // Check if a process is still alive
    isProcessAlive(pid) {
        try {
            process.kill(pid, 0);
            return true;
        }
        catch {
            return false;
        }
    }
    // Verify all PIDs are dead, with retry
    async verifyProcessesDead(pids, maxWaitMs = 1000) {
        const startTime = Date.now();
        const checkInterval = 100;
        while (Date.now() - startTime < maxWaitMs) {
            const aliveCount = pids.filter((pid) => this.isProcessAlive(pid)).length;
            if (aliveCount === 0) {
                return true;
            }
            await new Promise((resolve) => setTimeout(resolve, checkInterval));
        }
        const stillAlive = pids.filter((pid) => this.isProcessAlive(pid));
        if (stillAlive.length > 0) {
            console.warn(`[TmuxManager] ${stillAlive.length} processes still alive after kill: ${stillAlive.join(', ')}`);
        }
        return stillAlive.length === 0;
    }
    /**
     * Kill a tmux session and all its child processes.
     * Uses a 4-strategy approach (children → process group → tmux kill → SIGKILL).
     * In test mode: removes from memory only (no real kill).
     */
    async killSession(sessionId) {
        const session = this.sessions.get(sessionId);
        if (!session) {
            return false;
        }
        // TEST MODE: Remove from memory only — NEVER touch real tmux sessions
        if (IS_TEST_MODE) {
            this.sessions.delete(sessionId);
            this.emit('sessionKilled', { sessionId });
            return true;
        }
        // SAFETY: Never kill the tmux session we're running inside of
        const currentMuxName = process.env.CODEMAN_MUX_NAME;
        if (currentMuxName && session.muxName === currentMuxName) {
            console.error(`[TmuxManager] BLOCKED: Refusing to kill own tmux session: ${session.muxName}`);
            return false;
        }
        // Get current PID (may have changed)
        const currentPid = this.getPanePid(session.muxName) || session.pid;
        console.log(`[TmuxManager] Killing session ${session.muxName} (PID ${currentPid})`);
        const allPids = [currentPid];
        // Strategy 1: Kill all child processes recursively
        let childPids = this.getChildPids(currentPid);
        if (childPids.length > 0) {
            console.log(`[TmuxManager] Found ${childPids.length} child processes to kill`);
            allPids.push(...childPids);
            for (const childPid of [...childPids].reverse()) {
                if (this.isProcessAlive(childPid)) {
                    try {
                        process.kill(childPid, 'SIGTERM');
                    }
                    catch {
                        // Process may already be dead
                    }
                }
            }
            await new Promise((resolve) => setTimeout(resolve, TMUX_KILL_WAIT_MS));
            childPids = this.getChildPids(currentPid);
            for (const childPid of childPids) {
                if (this.isProcessAlive(childPid)) {
                    try {
                        process.kill(childPid, 'SIGKILL');
                    }
                    catch {
                        // Process already terminated
                    }
                }
            }
        }
        // Strategy 2: Kill the entire process group
        if (this.isProcessAlive(currentPid)) {
            try {
                process.kill(-currentPid, 'SIGTERM');
                await new Promise((resolve) => setTimeout(resolve, GRACEFUL_SHUTDOWN_WAIT_MS));
                if (this.isProcessAlive(currentPid)) {
                    process.kill(-currentPid, 'SIGKILL');
                }
            }
            catch {
                // Process group may not exist or already terminated
            }
        }
        // Strategy 3: Kill tmux session by name (guard the name before it reaches the shell)
        if (isValidMuxName(session.muxName)) {
            try {
                execSync(`${this.tmux()} kill-session -t "${session.muxName}" 2>/dev/null`, {
                    timeout: EXEC_TIMEOUT_MS,
                });
            }
            catch {
                // Session may already be dead
            }
        }
        // Strategy 4: Direct kill by PID as final fallback
        if (this.isProcessAlive(currentPid)) {
            try {
                process.kill(currentPid, 'SIGKILL');
            }
            catch {
                // Already dead
            }
        }
        // Verify all processes are dead
        const allDead = await this.verifyProcessesDead(allPids, 2000);
        if (!allDead) {
            console.error(`[TmuxManager] Warning: Some processes may still be alive for session ${session.muxName}`);
        }
        this.lastPaneCount.delete(session.muxName);
        this.sessions.delete(sessionId);
        this.saveSessions();
        this.emit('sessionKilled', { sessionId });
        return true;
    }
    getSessions() {
        return Array.from(this.sessions.values());
    }
    getSession(sessionId) {
        return this.sessions.get(sessionId);
    }
    updateSessionName(sessionId, name) {
        const session = this.sessions.get(sessionId);
        if (!session) {
            return false;
        }
        session.name = name;
        this.saveSessions();
        return true;
    }
    /**
     * Reconcile tracked sessions with actual running tmux sessions.
     */
    async reconcileSessions() {
        // TEST MODE: Return all registered sessions as alive, never discover real ones
        if (IS_TEST_MODE) {
            return {
                alive: Array.from(this.sessions.keys()),
                dead: [],
                discovered: [],
            };
        }
        const alive = [];
        const dead = [];
        const discovered = [];
        // Single batched query against the one socket Codeman owns. With a single
        // socket a session's location is a constant, so there is no per-session
        // socket tag to reconcile and no cross-socket ambiguity that could mark a
        // live session dead (the root cause of vanished/duplicate tabs).
        let active;
        try {
            const output = execSync(`${this.tmux()} list-panes -a -F '${PANE_LIST_FORMAT}' 2>/dev/null || true`, {
                encoding: 'utf-8',
                timeout: EXEC_TIMEOUT_MS,
            }).trim();
            active = parsePaneList(output);
        }
        catch (err) {
            console.error('[TmuxManager] Failed to list tmux panes:', err);
            active = new Map();
        }
        // Check tracked sessions against the live pane list.
        for (const [sessionId, session] of this.sessions) {
            const pid = active.get(session.muxName);
            if (pid !== undefined) {
                alive.push(sessionId);
                if (pid !== session.pid)
                    session.pid = pid;
            }
            else {
                dead.push(sessionId);
                this.sessions.delete(sessionId);
                this.emit('sessionDied', { sessionId });
            }
        }
        // Discover untracked codeman/claudeman sessions on our socket. Dedup by
        // muxName (globally unique) so a name we already track never spawns a
        // second "Restored:" entry.
        const knownMuxNames = new Set();
        for (const session of this.sessions.values()) {
            knownMuxNames.add(session.muxName);
        }
        for (const [sessionName, pid] of active) {
            if (!sessionName.startsWith('codeman-') && !sessionName.startsWith('claudeman-'))
                continue;
            // Only admit names that pass the safe-name pattern. A foreign process on the
            // shared `tmux -L codeman` socket could create a `codeman-…` session whose name
            // contains shell metacharacters; rejecting it here keeps it out of this.sessions
            // and away from the name-interpolating tmux call sites (M1).
            if (!isValidMuxName(sessionName)) {
                console.warn(`[TmuxManager] Skipping discovered tmux session with unsafe name: ${sessionName}`);
                continue;
            }
            if (knownMuxNames.has(sessionName))
                continue;
            const fragment = sessionName.replace(/^(?:codeman|claudeman)-/, '');
            const sessionId = `restored-${fragment}`;
            const session = {
                sessionId,
                muxName: sessionName,
                pid,
                createdAt: Date.now(),
                workingDir: process.cwd(),
                mode: 'claude',
                attached: false,
                name: `Restored: ${sessionName}`,
            };
            this.sessions.set(sessionId, session);
            knownMuxNames.add(sessionName);
            discovered.push(sessionId);
            console.log(`[TmuxManager] Discovered unknown tmux session: ${sessionName} (PID ${pid})`);
        }
        if (dead.length > 0 || discovered.length > 0) {
            this.saveSessions();
        }
        return { alive, dead, discovered };
    }
    async getProcessStats(sessionId) {
        if (IS_TEST_MODE)
            return { memoryMB: 0, cpuPercent: 0, childCount: 0, updatedAt: Date.now() };
        const session = this.sessions.get(sessionId);
        if (!session) {
            return null;
        }
        try {
            const psOutput = (await execAsync(`ps -o rss=,pcpu= -p ${session.pid} 2>/dev/null || echo "0 0"`, {
                encoding: 'utf-8',
                timeout: EXEC_TIMEOUT_MS,
            })).stdout.trim();
            const [rss, cpu] = psOutput.split(/\s+/).map((x) => parseFloat(x) || 0);
            let childCount = 0;
            try {
                const childOutput = (await execAsync(`pgrep -P ${session.pid} | wc -l`, {
                    encoding: 'utf-8',
                    timeout: EXEC_TIMEOUT_MS,
                })).stdout.trim();
                childCount = parseInt(childOutput, 10) || 0;
            }
            catch {
                // No children or command failed
            }
            return {
                memoryMB: Math.round((rss / 1024) * 10) / 10,
                cpuPercent: Math.round(cpu * 10) / 10,
                childCount,
                updatedAt: Date.now(),
            };
        }
        catch {
            return null;
        }
    }
    async getSessionsWithStats() {
        if (IS_TEST_MODE) {
            return Array.from(this.sessions.values()).map((s) => ({
                ...s,
                stats: { memoryMB: 0, cpuPercent: 0, childCount: 0, updatedAt: Date.now() },
            }));
        }
        const sessions = Array.from(this.sessions.values());
        if (sessions.length === 0) {
            return [];
        }
        const sessionPids = sessions.map((s) => s.pid);
        const statsMap = new Map();
        try {
            // Step 1: Get descendant PIDs
            const descendantMap = new Map();
            const pgrepOutput = (await execAsync(`for p in ${sessionPids.join(' ')}; do children=$(pgrep -P $p 2>/dev/null | tr '\\n' ','); echo "$p:$children"; done`, {
                encoding: 'utf-8',
                timeout: EXEC_TIMEOUT_MS,
            })).stdout.trim();
            for (const line of pgrepOutput.split('\n')) {
                const [pidStr, childrenStr] = line.split(':');
                const sessionPid = parseInt(pidStr, 10);
                if (!Number.isNaN(sessionPid)) {
                    const children = (childrenStr || '')
                        .split(',')
                        .map((s) => parseInt(s.trim(), 10))
                        .filter((n) => !Number.isNaN(n) && n > 0);
                    descendantMap.set(sessionPid, children);
                }
            }
            // Step 2: Collect all PIDs
            const allPids = new Set(sessionPids);
            for (const children of descendantMap.values()) {
                for (const child of children) {
                    allPids.add(child);
                }
            }
            // Step 3: Single ps call
            const pidArray = Array.from(allPids);
            if (pidArray.length > 0) {
                const psOutput = (await execAsync(`ps -o pid=,rss=,pcpu= -p ${pidArray.join(',')} 2>/dev/null || true`, {
                    encoding: 'utf-8',
                    timeout: EXEC_TIMEOUT_MS,
                })).stdout.trim();
                const processStats = new Map();
                for (const line of psOutput.split('\n')) {
                    const parts = line.trim().split(/\s+/);
                    if (parts.length >= 3) {
                        const pid = parseInt(parts[0], 10);
                        const rss = parseFloat(parts[1]) || 0;
                        const cpu = parseFloat(parts[2]) || 0;
                        if (!Number.isNaN(pid)) {
                            processStats.set(pid, { rss, cpu });
                        }
                    }
                }
                // Step 4: Aggregate stats
                for (const sessionPid of sessionPids) {
                    const children = descendantMap.get(sessionPid) || [];
                    const sessionStats = processStats.get(sessionPid) || { rss: 0, cpu: 0 };
                    let totalRss = sessionStats.rss;
                    let totalCpu = sessionStats.cpu;
                    for (const childPid of children) {
                        const childStats = processStats.get(childPid);
                        if (childStats) {
                            totalRss += childStats.rss;
                            totalCpu += childStats.cpu;
                        }
                    }
                    statsMap.set(sessionPid, {
                        memoryMB: Math.round((totalRss / 1024) * 10) / 10,
                        cpuPercent: Math.round(totalCpu * 10) / 10,
                        childCount: children.length,
                        updatedAt: Date.now(),
                    });
                }
            }
        }
        catch {
            // Fall back to individual queries
            const statsPromises = sessions.map((session) => this.getProcessStats(session.sessionId));
            const results = await Promise.allSettled(statsPromises);
            return sessions.map((session, i) => ({
                ...session,
                stats: results[i].status === 'fulfilled' ? (results[i].value ?? undefined) : undefined,
            }));
        }
        return sessions.map((session) => ({
            ...session,
            stats: statsMap.get(session.pid) || undefined,
        }));
    }
    startStatsCollection(intervalMs = DEFAULT_STATS_INTERVAL_MS) {
        if (this.statsInterval) {
            clearInterval(this.statsInterval);
        }
        this.statsInterval = setInterval(async () => {
            try {
                const sessionsWithStats = await this.getSessionsWithStats();
                this.emit('statsUpdated', sessionsWithStats);
            }
            catch (err) {
                console.error('[TmuxManager] Stats collection error:', err);
            }
        }, intervalMs);
    }
    stopStatsCollection() {
        if (this.statsInterval) {
            clearInterval(this.statsInterval);
            this.statsInterval = null;
        }
    }
    /**
     * Start periodic mouse mode sync for all tracked sessions.
     * Polls pane counts every 5s and toggles mouse on/off as needed.
     * Polls every 5s. On pane count change, toggles mouse on (>1 pane) or off (1 pane).
     * If enableMouseMode/disableMouseMode fails, lastPaneCount is NOT updated so it retries next poll.
     */
    startMouseModeSync(intervalMs = 5000) {
        if (this.mouseSyncInterval) {
            clearInterval(this.mouseSyncInterval);
        }
        this.mouseSyncInterval = setInterval(async () => {
            if (IS_TEST_MODE)
                return;
            for (const session of this.sessions.values()) {
                const panes = await this.listPanes(session.muxName);
                const count = panes.length;
                if (count === 0)
                    continue;
                const prev = this.lastPaneCount.get(session.muxName);
                if (prev === count)
                    continue;
                // Pane count changed — toggle mouse mode
                if (count > 1) {
                    if (await this.enableMouseMode(session.muxName)) {
                        this.lastPaneCount.set(session.muxName, count);
                    }
                    // If enableMouseMode fails, DON'T update lastPaneCount — retry next poll
                }
                else {
                    if (await this.disableMouseMode(session.muxName)) {
                        this.lastPaneCount.set(session.muxName, count);
                    }
                }
            }
        }, intervalMs);
    }
    stopMouseModeSync() {
        if (this.mouseSyncInterval) {
            clearInterval(this.mouseSyncInterval);
            this.mouseSyncInterval = null;
        }
        this.lastPaneCount.clear();
    }
    destroy() {
        this.stopStatsCollection();
        this.stopMouseModeSync();
    }
    registerSession(session) {
        this.sessions.set(session.sessionId, session);
        this.saveSessions();
    }
    setAttached(sessionId, attached) {
        const session = this.sessions.get(sessionId);
        if (session) {
            session.attached = attached;
            this.saveSessions();
        }
    }
    updateRespawnConfig(sessionId, config) {
        const session = this.sessions.get(sessionId);
        if (session) {
            session.respawnConfig = config;
            this.saveSessions();
        }
    }
    clearRespawnConfig(sessionId) {
        const session = this.sessions.get(sessionId);
        if (session && session.respawnConfig) {
            delete session.respawnConfig;
            this.saveSessions();
        }
    }
    updateRalphEnabled(sessionId, enabled) {
        const session = this.sessions.get(sessionId);
        if (session) {
            session.ralphEnabled = enabled;
            this.saveSessions();
        }
    }
    /**
     * Send input directly to a tmux session using `send-keys`.
     *
     * Uses tmux send-keys for reliable input delivery:
     * - `-l` flag sends literal text (no key interpretation)
     * - `Enter` key is sent as a SEPARATE tmux invocation after a small delay
     * - Ink (Claude CLI) needs text and Enter split to avoid treating Enter as a newline
     */
    async sendInput(sessionId, input) {
        const session = this.sessions.get(sessionId);
        if (!session) {
            console.error(`[TmuxManager] sendInput failed: no session found for ${sessionId}. Known: ${Array.from(this.sessions.keys()).join(', ')}`);
            return false;
        }
        // TEST MODE: No-op — don't send input to real tmux sessions
        if (IS_TEST_MODE) {
            return true;
        }
        console.log(`[TmuxManager] sendInput to ${session.muxName}, input length: ${input.length}, hasCarriageReturn: ${input.includes('\r')}`);
        if (!isValidMuxName(session.muxName)) {
            console.error('[TmuxManager] Invalid session name in sendInput:', session.muxName);
            return false;
        }
        try {
            const hasCarriageReturn = input.includes('\r');
            const textPart = input.replace(/\r/g, '').replace(/\n/g, '').trimEnd();
            if (textPart && hasCarriageReturn) {
                // Send text first, then Enter as a SEPARATE tmux command after a short delay.
                // Ink (Claude CLI's terminal framework) needs them split — sending both in a
                // single tmux invocation (via \;) causes Ink to interpret Enter as a newline
                // character in the input buffer rather than as form submission.
                await execAsync(`${this.tmux()} send-keys -t "${session.muxName}" -l ${shellescape(textPart)}`, {
                    timeout: EXEC_TIMEOUT_MS,
                });
                await new Promise((resolve) => setTimeout(resolve, 50));
                await execAsync(`${this.tmux()} send-keys -t "${session.muxName}" Enter`, {
                    timeout: EXEC_TIMEOUT_MS,
                });
            }
            else if (textPart) {
                // Text only, no Enter
                await execAsync(`${this.tmux()} send-keys -t "${session.muxName}" -l ${shellescape(textPart)}`, {
                    timeout: EXEC_TIMEOUT_MS,
                });
            }
            else if (hasCarriageReturn) {
                // Enter only
                await execAsync(`${this.tmux()} send-keys -t "${session.muxName}" Enter`, {
                    timeout: EXEC_TIMEOUT_MS,
                });
            }
            return true;
        }
        catch (err) {
            console.error('[TmuxManager] Failed to send input:', err);
            return false;
        }
    }
    // ========== Pane Methods (for Agent Team teammate panes) ==========
    /**
     * Enable mouse mode for an existing tmux session.
     * Allows clicking to select panes in agent team split-pane layouts.
     * When mouse mode is on, tmux intercepts mouse events (slow selection, no browser copy).
     */
    async enableMouseMode(muxName) {
        if (IS_TEST_MODE)
            return true;
        if (!isValidMuxName(muxName)) {
            console.error('[TmuxManager] Invalid session name in enableMouseMode:', muxName);
            return false;
        }
        try {
            await execAsync(`${this.tmux()} set-option -t "${muxName}" mouse on`, {
                encoding: 'utf-8',
                timeout: EXEC_TIMEOUT_MS,
            });
            console.log(`[TmuxManager] Mouse mode ON for ${muxName}`);
            return true;
        }
        catch (err) {
            console.error(`[TmuxManager] Failed to enable mouse mode for ${muxName}:`, err);
            return false;
        }
    }
    /**
     * Disable mouse mode for an existing tmux session.
     * Restores native xterm.js text selection and browser clipboard copy.
     */
    async disableMouseMode(muxName) {
        if (IS_TEST_MODE)
            return true;
        if (!isValidMuxName(muxName)) {
            console.error('[TmuxManager] Invalid session name in disableMouseMode:', muxName);
            return false;
        }
        try {
            await execAsync(`${this.tmux()} set-option -t "${muxName}" mouse off`, {
                encoding: 'utf-8',
                timeout: EXEC_TIMEOUT_MS,
            });
            console.log(`[TmuxManager] Mouse mode OFF for ${muxName}`);
            return true;
        }
        catch (err) {
            console.error(`[TmuxManager] Failed to disable mouse mode for ${muxName}:`, err);
            return false;
        }
    }
    /**
     * Sync mouse mode based on pane count: enable if split (>1 pane), disable if single.
     * Called by TeamWatcher when teammates spawn/despawn panes.
     * Uses `tmux list-panes` for bulletproof detection — counts actual panes, not config.
     */
    async syncMouseMode(muxName) {
        if (IS_TEST_MODE)
            return true;
        const panes = await this.listPanes(muxName);
        if (panes.length > 1) {
            return this.enableMouseMode(muxName);
        }
        else {
            return this.disableMouseMode(muxName);
        }
    }
    /**
     * List all panes in a tmux session.
     * Returns structured info for each pane.
     */
    async listPanes(muxName) {
        if (IS_TEST_MODE)
            return [];
        if (!isValidMuxName(muxName)) {
            console.error('[TmuxManager] Invalid session name in listPanes:', muxName);
            return [];
        }
        try {
            const output = (await execAsync(`${this.tmux()} list-panes -t "${muxName}" -F '#{pane_id}:#{pane_index}:#{pane_pid}:#{pane_width}:#{pane_height}'`, { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS })).stdout.trim();
            return output
                .split('\n')
                .map((line) => {
                const [paneId, indexStr, pidStr, widthStr, heightStr] = line.split(':');
                return {
                    paneId,
                    paneIndex: parseInt(indexStr, 10),
                    panePid: parseInt(pidStr, 10),
                    width: parseInt(widthStr, 10),
                    height: parseInt(heightStr, 10),
                };
            })
                .filter((p) => !Number.isNaN(p.paneIndex));
        }
        catch {
            return [];
        }
    }
    /**
     * Send input to a specific pane within a tmux session.
     * Uses the same literal text approach as sendInput() but targets a specific pane.
     */
    sendInputToPane(muxName, paneTarget, input) {
        if (IS_TEST_MODE)
            return true;
        if (!isValidMuxName(muxName)) {
            console.error('[TmuxManager] Invalid session name in sendInputToPane:', muxName);
            return false;
        }
        if (!SAFE_PANE_TARGET_PATTERN.test(paneTarget)) {
            console.error('[TmuxManager] Invalid pane target:', paneTarget);
            return false;
        }
        // Build target: sessionName.paneId (e.g., "codeman-abc12345.%1")
        const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`;
        const tmux = this.tmux();
        try {
            const hasCarriageReturn = input.includes('\r');
            const textPart = input.replace(/\r/g, '').replace(/\n/g, '').trimEnd();
            if (textPart && hasCarriageReturn) {
                execSync(`${tmux} send-keys -t ${shellescape(target)} -l ${shellescape(textPart)}`, {
                    encoding: 'utf-8',
                    timeout: EXEC_TIMEOUT_MS,
                });
                execSync(`${tmux} send-keys -t ${shellescape(target)} Enter`, {
                    encoding: 'utf-8',
                    timeout: EXEC_TIMEOUT_MS,
                });
            }
            else if (textPart) {
                execSync(`${tmux} send-keys -t ${shellescape(target)} -l ${shellescape(textPart)}`, {
                    encoding: 'utf-8',
                    timeout: EXEC_TIMEOUT_MS,
                });
            }
            else if (hasCarriageReturn) {
                execSync(`${tmux} send-keys -t ${shellescape(target)} Enter`, {
                    encoding: 'utf-8',
                    timeout: EXEC_TIMEOUT_MS,
                });
            }
            return true;
        }
        catch (err) {
            console.error('[TmuxManager] Failed to send input to pane:', err);
            return false;
        }
    }
    /**
     * Capture the current visible text and SGR styles of a specific pane.
     *
     * `capture-pane -e` is sanitized by `formatPaneSnapshot`: SGR color/style
     * codes are preserved, while cursor/erase/scroll-region controls are stripped
     * before rows are repainted at absolute positions in browser xterm.
     */
    capturePaneBuffer(muxName, paneTarget) {
        if (IS_TEST_MODE)
            return '';
        if (!isValidMuxName(muxName)) {
            console.error('[TmuxManager] Invalid session name in capturePaneBuffer:', muxName);
            return null;
        }
        if (!SAFE_PANE_TARGET_PATTERN.test(paneTarget)) {
            console.error('[TmuxManager] Invalid pane target:', paneTarget);
            return null;
        }
        const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`;
        try {
            const buffer = execSync(`${this.tmux()} capture-pane -p -e -t ${shellescape(target)}`, {
                encoding: 'utf-8',
                timeout: EXEC_TIMEOUT_MS,
            }).replace(/\n+$/g, '');
            try {
                const cursor = execSync(`${this.tmux()} display-message -p -t ${shellescape(target)} '#{cursor_x} #{cursor_y} #{pane_width} #{pane_height}'`, {
                    encoding: 'utf-8',
                    timeout: EXEC_TIMEOUT_MS,
                }).trim();
                const [cursorX, cursorY, cols, rows] = cursor.split(/\s+/).map((value) => parseInt(value, 10));
                if (Number.isFinite(cursorX) &&
                    Number.isFinite(cursorY) &&
                    Number.isFinite(cols) &&
                    Number.isFinite(rows) &&
                    cursorX >= 0 &&
                    cursorY >= 0 &&
                    cols > 0 &&
                    rows > 0) {
                    return formatPaneSnapshot(buffer.split('\n'), { cols, rows, cursorX, cursorY });
                }
            }
            catch (cursorErr) {
                console.error('[TmuxManager] Failed to query pane cursor after capture:', cursorErr);
            }
            return buffer;
        }
        catch (err) {
            console.error('[TmuxManager] Failed to capture pane buffer:', err);
            return null;
        }
    }
    /**
     * Capture the active pane for a tmux session.
     *
     * Pane ids are not stable across respawns or restores, so callers should not
     * assume the first pane remains `%0`.
     */
    captureActivePaneBuffer(muxName) {
        if (IS_TEST_MODE)
            return '';
        if (!isValidMuxName(muxName)) {
            console.error('[TmuxManager] Invalid session name in captureActivePaneBuffer:', muxName);
            return null;
        }
        try {
            const output = execSync(`${this.tmux()} list-panes -t ${shellescape(muxName)} -F '#{pane_id}:#{pane_active}'`, {
                encoding: 'utf-8',
                timeout: EXEC_TIMEOUT_MS,
            }).trim();
            const target = resolveActivePaneTarget(output);
            return target ? this.capturePaneBuffer(muxName, target) : null;
        }
        catch (err) {
            console.error('[TmuxManager] Failed to resolve active pane for capture:', err);
            return null;
        }
    }
    /**
     * Start piping pane output to a file using tmux pipe-pane.
     * Only pipes output direction (-O) to avoid echoing input.
     */
    startPipePane(muxName, paneTarget, outputFile) {
        if (IS_TEST_MODE)
            return true;
        if (!isValidMuxName(muxName)) {
            console.error('[TmuxManager] Invalid session name in startPipePane:', muxName);
            return false;
        }
        if (!SAFE_PANE_TARGET_PATTERN.test(paneTarget)) {
            console.error('[TmuxManager] Invalid pane target:', paneTarget);
            return false;
        }
        if (!isValidPath(outputFile)) {
            console.error('[TmuxManager] Invalid output file path:', outputFile);
            return false;
        }
        const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`;
        try {
            execSync(`${this.tmux()} pipe-pane -O -t ${shellescape(target)} ${shellescape('cat >> ' + outputFile)}`, {
                encoding: 'utf-8',
                timeout: EXEC_TIMEOUT_MS,
            });
            return true;
        }
        catch (err) {
            console.error('[TmuxManager] Failed to start pipe-pane:', err);
            return false;
        }
    }
    /**
     * Stop piping pane output (calling pipe-pane with no command stops piping).
     */
    stopPipePane(muxName, paneTarget) {
        if (IS_TEST_MODE)
            return true;
        if (!isValidMuxName(muxName)) {
            console.error('[TmuxManager] Invalid session name in stopPipePane:', muxName);
            return false;
        }
        if (!SAFE_PANE_TARGET_PATTERN.test(paneTarget)) {
            console.error('[TmuxManager] Invalid pane target:', paneTarget);
            return false;
        }
        const target = paneTarget.startsWith('%') ? `${muxName}.${paneTarget}` : `${muxName}.%${paneTarget}`;
        try {
            execSync(`${this.tmux()} pipe-pane -t ${shellescape(target)}`, {
                encoding: 'utf-8',
                timeout: EXEC_TIMEOUT_MS,
            });
            return true;
        }
        catch (err) {
            console.error('[TmuxManager] Failed to stop pipe-pane:', err);
            return false;
        }
    }
    getAttachCommand() {
        return 'tmux';
    }
    getAttachArgs(muxName) {
        return ['-L', this.tmuxSocket, 'attach-session', '-t', muxName];
    }
    setManualWindowSize(muxName) {
        if (!isValidMuxName(muxName)) {
            console.error('[TmuxManager] Invalid session name in setManualWindowSize:', muxName);
            return false;
        }
        try {
            execSync(`${this.tmux()} set-window-option -t ${shellescape(muxName)} window-size manual`, {
                timeout: EXEC_TIMEOUT_MS,
                stdio: 'ignore',
            });
            return true;
        }
        catch (err) {
            console.error('[TmuxManager] Failed to set manual window size:', err);
            return false;
        }
    }
    resizeWindow(muxName, cols, rows) {
        if (!isValidMuxName(muxName)) {
            console.error('[TmuxManager] Invalid session name in resizeWindow:', muxName);
            return false;
        }
        if (!isValidTerminalDimension(cols) || !isValidTerminalDimension(rows)) {
            console.error('[TmuxManager] Invalid resize dimensions:', { cols, rows });
            return false;
        }
        // Fire-and-forget: this runs on the interactive resize path (WS {t:'z'} and
        // HTTP /resize), so use a non-blocking exec — a slow/hung tmux must not stall
        // the Fastify event loop while other sessions' input/SSE are served. The sole
        // caller (Session.resize) ignores the result, and under `window-size manual`
        // the subsequent ptyProcess.resize is subordinate to this authoritative size.
        exec(`${this.tmux()} resize-window -t ${shellescape(muxName)} -x ${cols} -y ${rows}`, { timeout: EXEC_TIMEOUT_MS }, (err) => {
            if (err)
                console.error('[TmuxManager] Failed to resize tmux window:', err);
        });
        return true;
    }
    isAvailable() {
        return TmuxManager.isTmuxAvailable();
    }
    /**
     * Check if tmux is available on the system.
     */
    static isTmuxAvailable() {
        try {
            execSync('which tmux', { encoding: 'utf-8', timeout: EXEC_TIMEOUT_MS });
            return true;
        }
        catch {
            return false;
        }
    }
}
/**
 * Shell-escape a string for use as a single argument.
 * Wraps in single quotes, escaping any embedded single quotes.
 */
function shellescape(str) {
    // Replace single quotes with '\'' (end quote, escaped quote, restart quote)
    return "'" + str.replace(/'/g, "'\\''") + "'";
}
//# sourceMappingURL=tmux-manager.js.map