/** * PTY Session Backend Module * * Implements the ISessionBackend interface using node-pty and @xterm/headless. * Provides a full terminal emulation backend with memory-bounded output buffers. * * @module pty-session-backend */ import type { ISessionBackend, ISession, SessionOptions } from '../session-backend.interface.js'; import { PtyTerminalBuffer } from './pty-terminal-buffer.js'; /** * Point-in-time PTY/FD health snapshot for observability. * * Surfaced so an operator (or a future health endpoint) can SEE PTY-FD * accumulation early instead of discovering it only when `start-agent` fails * with `posix_spawnp failed`. `liveSessions` ≫ `activeSessions` signals dead * sessions lingering in the registry; `openFileCount` climbing across the * backend lifetime signals the underlying FD leak directly. */ export interface PtyDiagnostics { /** Total entries in the sessionName→PtySession registry. */ registrySize: number; /** Registry entries whose PtySession is still alive (not killed/exited). */ activeSessions: number; /** Registry entries whose PtySession reports killed (reaper candidates). */ deadSessions: number; /** * Open file descriptors for THIS backend process via `lsof`, or null when * unavailable (lsof missing / non-POSIX). Cheap to read; bounded by timeout. */ openFileCount: number | null; } /** * PTY Session Backend implementation. * * Manages terminal sessions using node-pty for process spawning and * @xterm/headless for terminal emulation and output buffering. * * Features: * - In-memory session management via Map * - Terminal emulation with xterm.js headless * - Memory-bounded output buffers (max 10MB) * - Proper cleanup on session termination * * @example * ```typescript * const backend = new PtySessionBackend(); * * // Create a session * const session = await backend.createSession('dev', { * cwd: '/home/user/project', * command: '/bin/bash', * }); * * // Capture output * const output = backend.captureOutput('dev', 50); * * // Cleanup * await backend.destroy(); * ``` */ export declare class PtySessionBackend implements ISessionBackend { /** * Map of session names to PtySession instances */ private sessions; /** * Map of session names to PtyTerminalBuffer instances */ private terminalBuffers; /** * Map of session names to cumulative output bytes since last compact */ private cumulativeOutputBytes; /** * Map of session names to writable file streams for persistent session logs */ private sessionLogStreams; /** * Per session: the streaming redactor every byte passes through before it * is written to the session log. It masks secrets by name, by shape and by * exact value (backend env + the session's spawn env), and holds back the * tail of each chunk so a secret split across two chunks is still caught. */ private sessionLogRedactors; /** * Directory for persistent session log files */ private sessionLogsDir; /** * Logger for this backend */ private logger; /** * Interval handle for the orphan-PTY reaper sweep. Started in the constructor, * cleared on destroy()/forceDestroyAll(). The sweep tears down any registry * PTY whose owning session is no longer active (defensive FD-leak backstop). */ private orphanReaperTimer; /** * Regex patterns for stripping ANSI escape codes from terminal output */ private static readonly ANSI_STRIP_PATTERNS; /** * Create a new PTY session backend. */ constructor(); /** * Start the periodic orphan-PTY reaper sweep. * * The interval cadence comes from PTY_CONSTANTS.ORPHAN_REAPER_INTERVAL_MS * (never hardcoded). The timer is `unref()`'d so it never keeps the Node * event loop alive on its own. Safe to call repeatedly (no-op if running). */ private startOrphanReaper; /** * Stop the periodic orphan-PTY reaper sweep (idempotent). */ private stopOrphanReaper; /** * Defensive backstop sweep: tear down any registry PTY whose owning session * is no longer active so its `/dev/ptmx` master + `/dev/ttysNNN` slave FDs are * released even if some end path forgot to route through teardownSession. * * A session is considered orphaned when its PtySession reports `isKilled()` * (the shell exited / crashed / was killed) yet it is still present in the * registry. Such an entry only lingers if normal teardown was skipped — the * exact failure mode that accumulated 319 orphaned master FDs over 6 days. * Active, living sessions are left untouched. * * @returns Promise resolving to the number of orphaned PTYs reaped. * * @example * ```typescript * const reaped = await backend.reapOrphanPtys(); * ``` */ reapOrphanPtys(): Promise; /** * Create a new terminal session. * * @param name - Unique name for the session * @param options - Session configuration options * @returns Promise resolving to the created session * @throws Error if session name already exists * * @example * ```typescript * const session = await backend.createSession('my-session', { * cwd: '/home/user', * command: '/bin/bash', * args: ['--login'], * env: { NODE_ENV: 'development' }, * cols: 120, * rows: 40, * }); * ``` */ createSession(name: string, options: SessionOptions): Promise; /** * Get an existing session by name. * * @param name - Name of the session to retrieve * @returns The session if found, undefined otherwise * * @example * ```typescript * const session = backend.getSession('my-session'); * if (session) { * session.write('echo hello\n'); * } * ``` */ getSession(name: string): ISession | undefined; /** * Kill a session by name. * * @param name - Name of the session to kill * @returns Promise that resolves when the session is killed * * @example * ```typescript * await backend.killSession('my-session'); * ``` */ killSession(name: string): Promise; /** * Centralized PTY teardown — the SINGLE path every session-end route funnels * through (explicit stop/terminate, idle-exit, dropout, crash, natural exit, * and the orphan reaper). Given a session name it: * 1. looks the PtySession up in the registry (the always-present reference * that prevents the "orphan with no reference" leak), * 2. force-kills the child shell AND closes the node-pty master/slave FDs * (via PtySession.forceKill → closePty, or closePty directly for an * already-dead session), * 3. deletes it from every per-session Map (registry, buffers, byte * counters, log streams), * 4. drops it from the cross-boot orphan PID registry. * * Idempotent and safe to call twice: a missing session simply cleans up any * residual per-session resources and returns. This is the function whose * absence caused 319 orphaned /dev/ptmx FDs to accumulate. * * @param name - Name of the session to tear down. * @param options - `force: true` escalates SIGTERM→SIGKILL (default true); * `force: false` performs an FD-only close for a session already known dead. * @returns Promise that resolves when teardown + cleanup completes. * * @example * ```typescript * await backend.teardownSession('agent-leo'); // stop/terminate/idle-exit/crash * await backend.teardownSession('agent-leo'); // calling again is a safe no-op * ``` */ private teardownSession; /** * List all active session names. * * @returns Array of session names * * @example * ```typescript * const sessions = backend.listSessions(); * console.log('Active sessions:', sessions); * ``` */ listSessions(): string[]; /** * Check if a session exists by name. * * @param name - Name of the session to check * @returns true if the session exists, false otherwise * * @example * ```typescript * if (backend.sessionExists('my-session')) { * console.log('Session is running'); * } * ``` */ sessionExists(name: string): boolean; /** * Get the cumulative output bytes for a session since last reset. * Used by ContextWindowMonitorService for proactive compaction. * * @param name - Name of the session * @returns Cumulative output bytes, or 0 if session not tracked */ getCumulativeOutputBytes(name: string): number; /** * Reset the cumulative output byte counter for a session. * Called after a proactive compact is triggered. * * @param name - Name of the session */ resetCumulativeOutput(name: string): void; /** * Capture recent output from a session. * * @param name - Name of the session * @param lines - Number of lines to capture (default: 100) * @returns Captured output as a string * * @example * ```typescript * const output = backend.captureOutput('my-session', 50); * console.log('Recent output:', output); * ``` */ captureOutput(name: string, lines?: number): string; /** * The visible screen with faint (dim) ghost text blanked, and the cursor * row. See PtyTerminalBuffer.getInputView. * * @param name - Name of the session * @returns Screen rows and cursor row, or null for an unknown session */ captureInputView(name: string): { lines: string[]; cursorRow: number; } | null; /** * Wait until the session's output so far is parsed into the screen. * * @param name - Name of the session */ flushInputView(name: string): Promise; /** * The current terminal size of a session. * * @param name - Name of the session * @returns Columns and rows, or null when the session is unknown */ getTerminalDimensions(name: string): { cols: number; rows: number; } | null; /** * Make the session's program repaint its whole screen: one column * narrower, then back (no input is sent; the box text is kept). A * session smaller than the default size (started before the default * grew, or shrunk by a viewer) is enlarged to it instead — the resize is * itself the repaint, and a too-short window is what hides the box. * * @param name - Name of the session * @param settleMs - Pause between the two sizes * @returns True when the repaint was requested */ requestRepaint(name: string, settleMs?: number): Promise; /** * Get the latest OSC terminal title a session's program set. * * @param name - Name of the session * @returns The title, or '' when the session is unknown or set none * * @example * ```typescript * backend.getTerminalTitle('dev'); // '✳ Fix login bug' * ``` */ getTerminalTitle(name: string): string; /** * Get the full terminal buffer content for a session. * * @param name - Name of the session * @returns Full buffer content as a string * * @example * ```typescript * const buffer = backend.getTerminalBuffer('my-session'); * console.log('Full buffer:', buffer); * ``` */ getTerminalBuffer(name: string): string; /** * Get raw output history with ANSI escape codes preserved. * * @param name - Name of the session * @returns Raw output history as a string with ANSI codes * * @example * ```typescript * const rawHistory = backend.getRawHistory('my-session'); * terminal.write(rawHistory); * ``` */ getRawHistory(name: string): string; /** * Get the serialized visual state of the terminal as plain text. * * Unlike getRawHistory() which returns raw bytes including cursor movement * sequences that cause corruption when replayed at different terminal * dimensions, this returns the rendered visual state — the final text after * all cursor movements and erases have been processed by the headless terminal. * * Flushes pending terminal writes before reading to ensure completeness. * * @param name - Name of the session * @returns Promise resolving to serialized terminal state safe for any dimensions */ getSerializedState(name: string): Promise; /** * Get the PIDs of all active sessions. * * @returns Array of process IDs for all active sessions * * @example * ```typescript * const pids = backend.getAllSessionPids(); * console.log('Active PIDs:', pids); * ``` */ getAllSessionPids(): number[]; /** * Destroy the backend and clean up all resources. * * @returns Promise that resolves when cleanup is complete * * @example * ```typescript * await backend.destroy(); * ``` */ destroy(): Promise; /** * Forcefully destroy all sessions with SIGTERM → SIGKILL escalation. * * This method is designed for shutdown scenarios where child processes * (Claude Code, Gemini CLI) may catch or ignore SIGHUP/SIGTERM. * It collects all PIDs upfront, sends SIGTERM, waits, then SIGKILLs * each process and its process group. * * @returns Promise that resolves after the force-kill sequence completes * * @example * ```typescript * await backend.forceDestroyAll(); * ``` */ forceDestroyAll(): Promise; /** * Check if a child process is alive inside a session's PTY shell. * * Delegates to PtySession.isChildProcessAlive() to detect whether * the runtime (e.g. Claude Code) is still running inside the shell. * * @param name - Name of the session to check * @returns true if the session exists and has a living child process */ isChildProcessAlive(name: string): boolean; /** * Get the number of active sessions. * * @returns Number of active sessions */ getSessionCount(): number; /** * Read the open file descriptor count for THIS backend process. * * Uses `lsof -p | wc -l` on POSIX. Bounded by a short timeout and * exception-safe — returns null rather than throwing when lsof is missing or * the platform is unsupported, so callers can always include it in a * diagnostics payload without guarding. * * @returns The open-fd count, or null when it cannot be measured. */ private readOpenFileCount; /** * Produce a PTY/FD health snapshot for observability (registry occupancy + * process open-fd count). Intended for the diagnostics/health surface so PTY * file-descriptor accumulation is VISIBLE next time instead of only manifesting * as a `posix_spawnp failed` spawn error near the open-file ceiling. * * @returns A {@link PtyDiagnostics} snapshot. * * @example * ```typescript * const d = backend.getPtyDiagnostics(); * logger.info('PTY health', d); * // { registrySize: 1, activeSessions: 1, deadSessions: 0, openFileCount: 142 } * ``` */ getPtyDiagnostics(): PtyDiagnostics; /** * Resize a session's terminal. * * @param name - Name of the session * @param cols - Number of columns * @param rows - Number of rows * @throws Error if session does not exist */ resizeSession(name: string, cols: number, rows: number): void; /** * Get the raw output history for a session. * * This returns the raw bytes written to the terminal, including ANSI * sequences. Useful for replaying the session. * * @param name - Name of the session * @returns Raw output history as a string * * @example * ```typescript * const history = backend.getSessionHistory('my-session'); * ``` */ getSessionHistory(name: string): string; /** * Get the terminal buffer instance for a session. * * @param name - Name of the session * @returns The PtyTerminalBuffer instance if found, undefined otherwise */ getTerminalBufferInstance(name: string): PtyTerminalBuffer | undefined; /** * Get the file path for a session's persistent log file. * * @param sessionName - Name of the session * @returns Absolute path to the session log file */ getSessionLogPath(sessionName: string): string; /** * Open a writable file stream for a session log. * If a log file already exists (from a previous run), appends a restart separator. * * @param sessionName - Name of the session */ private openSessionLogStream; /** * Write ANSI-stripped, secret-redacted terminal data to a session's * persistent log file. * * Everything goes through the session's StreamingSecretRedactor * (utils/secret-redactor): `NAME=value` for secret names, raw token shapes * (Google `AIza…`, OpenAI/Anthropic `sk-…`, Slack `xox…`/`xapp-…`, GitHub * `ghp_…`/`github_pat_…`, …) and the exact value of every secret-named * variable in the backend env or the session's spawn env. The redactor * carries the unfinished last word into the next chunk, so a secret split * across two output chunks is still masked; the carry is written when the * log closes. This is a backstop: keys are no longer typed into sessions. * * @param sessionName - Name of the session * @param data - Raw terminal data (with ANSI codes) */ private writeToSessionLog; /** * Close and remove the file stream for a session log. * * @param sessionName - Name of the session */ private closeSessionLogStream; } //# sourceMappingURL=pty-session-backend.d.ts.map