UNPKG

vibetunnel

Version:

Terminal sharing server with web interface - supports macOS, Linux, and headless environments

1,040 lines 139 kB
"use strict"; var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) { if (k2 === undefined) k2 = k; var desc = Object.getOwnPropertyDescriptor(m, k); if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) { desc = { enumerable: true, get: function() { return m[k]; } }; } Object.defineProperty(o, k2, desc); }) : (function(o, m, k, k2) { if (k2 === undefined) k2 = k; o[k2] = m[k]; })); var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) { Object.defineProperty(o, "default", { enumerable: true, value: v }); }) : function(o, v) { o["default"] = v; }); var __importStar = (this && this.__importStar) || (function () { var ownKeys = function(o) { ownKeys = Object.getOwnPropertyNames || function (o) { var ar = []; for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k; return ar; }; return ownKeys(o); }; return function (mod) { if (mod && mod.__esModule) return mod; var result = {}; if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]); __setModuleDefault(result, mod); return result; }; })(); var __importDefault = (this && this.__importDefault) || function (mod) { return (mod && mod.__esModule) ? mod : { "default": mod }; }; Object.defineProperty(exports, "__esModule", { value: true }); exports.TerminalManager = void 0; const headless_1 = require("@xterm/headless"); const chalk_1 = __importDefault(require("chalk")); const fs = __importStar(require("fs")); const path = __importStar(require("path")); const error_deduplicator_js_1 = require("../utils/error-deduplicator.js"); const logger_js_1 = require("../utils/logger.js"); const logger = (0, logger_js_1.createLogger)('terminal-manager'); // Helper function to truncate long strings for logging function truncateForLog(str, maxLength = 50) { if (str.length <= maxLength) return str; return `${str.substring(0, maxLength)}...(${str.length} chars total)`; } // Flow control configuration const FLOW_CONTROL_CONFIG = { // When buffer exceeds this percentage of max lines, pause reading // 80% gives a good buffer before hitting the scrollback limit highWatermark: 0.8, // Resume reading when buffer drops below this percentage // 50% ensures enough space is cleared before resuming lowWatermark: 0.5, // Check interval for resuming paused sessions // 100ms provides responsive resumption without excessive CPU usage checkInterval: 100, // ms // Maximum pending lines to accumulate while paused // 10K lines handles bursts without excessive memory (avg ~1MB at 100 chars/line) maxPendingLines: 10000, // Maximum time a session can be paused before timing out // 5 minutes handles temporary client issues without indefinite memory growth maxPauseTime: 5 * 60 * 1000, // 5 minutes // Lines to process between buffer pressure checks // Checking every 100 lines balances performance with responsiveness bufferCheckInterval: 100, }; /** * Manages terminal instances and their buffer operations for terminal sessions. * * Provides high-performance terminal emulation using xterm.js headless terminals, * with sophisticated flow control, buffer management, and real-time change * notifications. Handles asciinema stream parsing, terminal resizing, and * efficient binary encoding of terminal buffers. * * Key features: * - Headless xterm.js terminal instances with 10K line scrollback * - Asciinema v2 format stream parsing and playback * - Flow control with backpressure to prevent memory exhaustion * - Efficient binary buffer encoding for WebSocket transmission * - Real-time buffer change notifications with debouncing * - Error deduplication to prevent log spam * - Automatic cleanup of stale terminals * * Flow control strategy: * - Pauses reading when buffer reaches 80% capacity * - Resumes when buffer drops below 50% * - Queues up to 10K pending lines while paused * - Times out paused sessions after 5 minutes * * @example * ```typescript * const manager = new TerminalManager('/var/run/vibetunnel'); * * // Get terminal for session * const terminal = await manager.getTerminal(sessionId); * * // Subscribe to buffer changes * const unsubscribe = await manager.subscribeToBufferChanges( * sessionId, * (id, snapshot) => { * const encoded = manager.encodeSnapshot(snapshot); * ws.send(encoded); * } * ); * ``` * * @see XtermTerminal - Terminal emulation engine * @see web/src/server/services/buffer-aggregator.ts - Aggregates buffer updates * @see web/src/server/pty/asciinema-writer.ts - Writes asciinema streams */ class TerminalManager { constructor(controlDir) { this.terminals = new Map(); this.bufferListeners = new Map(); this.changeTimers = new Map(); this.writeQueues = new Map(); this.writeTimers = new Map(); this.errorDeduplicator = new error_deduplicator_js_1.ErrorDeduplicator({ keyExtractor: (error, context) => { // Use session ID and line prefix as context for xterm parsing errors const errorMessage = error instanceof Error ? error.message : String(error); return `${context}:${errorMessage}`; }, }); this.controlDir = controlDir; // Override console.warn to suppress xterm.js parsing warnings this.originalConsoleWarn = console.warn; console.warn = (...args) => { const message = args[0]; if (typeof message === 'string' && (message.includes('xterm.js parsing error') || message.includes('Unable to process character') || message.includes('Cannot read properties of undefined'))) { // Suppress xterm.js parsing warnings return; } this.originalConsoleWarn.apply(console, args); }; // Start flow control check timer this.startFlowControlTimer(); } /** * Get or create a terminal for a session */ async getTerminal(sessionId) { let sessionTerminal = this.terminals.get(sessionId); if (!sessionTerminal) { // Create new terminal const terminal = new headless_1.Terminal({ cols: 80, rows: 24, scrollback: 10000, allowProposedApi: true, convertEol: true, }); sessionTerminal = { terminal, lastUpdate: Date.now(), }; this.terminals.set(sessionId, sessionTerminal); logger.log(chalk_1.default.green(`Terminal created for session ${sessionId} (${terminal.cols}x${terminal.rows})`)); // Start watching the stream file await this.watchStreamFile(sessionId); } sessionTerminal.lastUpdate = Date.now(); return sessionTerminal.terminal; } /** * Watch stream file for changes */ async watchStreamFile(sessionId) { const sessionTerminal = this.terminals.get(sessionId); if (!sessionTerminal) return; const streamPath = path.join(this.controlDir, sessionId, 'stdout'); let lastOffset = sessionTerminal.lastFileOffset || 0; let lineBuffer = sessionTerminal.lineBuffer || ''; // Check if the file exists if (!fs.existsSync(streamPath)) { logger.error(`Stream file does not exist for session ${truncateForLog(sessionId)}: ${truncateForLog(streamPath, 100)}`); return; } try { // Read existing content first const content = fs.readFileSync(streamPath, 'utf8'); lastOffset = Buffer.byteLength(content, 'utf8'); // Process existing content const lines = content.split('\n'); for (const line of lines) { if (line.trim()) { this.handleStreamLine(sessionId, sessionTerminal, line); } } // Watch for changes sessionTerminal.watcher = fs.watch(streamPath, (eventType) => { if (eventType === 'change') { try { const stats = fs.statSync(streamPath); if (stats.size > lastOffset) { // Read only the new data const fd = fs.openSync(streamPath, 'r'); const buffer = Buffer.alloc(stats.size - lastOffset); fs.readSync(fd, buffer, 0, buffer.length, lastOffset); fs.closeSync(fd); // Update offset lastOffset = stats.size; sessionTerminal.lastFileOffset = lastOffset; // Process new data const newData = buffer.toString('utf8'); lineBuffer += newData; // Process complete lines const lines = lineBuffer.split('\n'); lineBuffer = lines.pop() || ''; // Keep incomplete line for next time sessionTerminal.lineBuffer = lineBuffer; for (const line of lines) { if (line.trim()) { this.handleStreamLine(sessionId, sessionTerminal, line); } } } } catch (error) { logger.error(`Error reading stream file for session ${truncateForLog(sessionId)}:`, error); } } }); logger.log(chalk_1.default.green(`Watching stream file for session ${truncateForLog(sessionId)}`)); } catch (error) { logger.error(`Failed to watch stream file for session ${truncateForLog(sessionId)}:`, error); throw error; } } /** * Start flow control timer to check paused sessions */ startFlowControlTimer() { let checkIndex = 0; const sessionIds = []; this.flowControlTimer = setInterval(() => { // Rebuild session list periodically if (checkIndex === 0) { sessionIds.length = 0; for (const [sessionId, sessionTerminal] of this.terminals) { if (sessionTerminal.isPaused) { sessionIds.push(sessionId); } } } // Process one session per tick to avoid thundering herd if (sessionIds.length > 0) { const sessionId = sessionIds[checkIndex % sessionIds.length]; const sessionTerminal = this.terminals.get(sessionId); if (sessionTerminal?.isPaused) { // Check for timeout if (sessionTerminal.pausedAt && Date.now() - sessionTerminal.pausedAt > FLOW_CONTROL_CONFIG.maxPauseTime) { logger.warn(chalk_1.default.red(`Session ${sessionId} has been paused for too long. ` + `Dropping ${sessionTerminal.pendingLines?.length || 0} pending lines.`)); sessionTerminal.isPaused = false; sessionTerminal.pendingLines = []; sessionTerminal.pausedAt = undefined; // Resume file watching after timeout this.resumeFileWatcher(sessionId).catch((error) => { logger.error(`Failed to resume file watcher for session ${sessionId} after timeout:`, error); }); } else { this.checkBufferPressure(sessionId); } } checkIndex = (checkIndex + 1) % Math.max(sessionIds.length, 1); } }, FLOW_CONTROL_CONFIG.checkInterval); } /** * Check buffer pressure and pause/resume as needed */ checkBufferPressure(sessionId) { const sessionTerminal = this.terminals.get(sessionId); if (!sessionTerminal) return false; const terminal = sessionTerminal.terminal; const buffer = terminal.buffer.active; const maxLines = terminal.options.scrollback || 10000; const currentLines = buffer.length; const bufferUtilization = currentLines / maxLines; const wasPaused = sessionTerminal.isPaused || false; // Check if we should pause if (!wasPaused && bufferUtilization > FLOW_CONTROL_CONFIG.highWatermark) { sessionTerminal.isPaused = true; sessionTerminal.pendingLines = []; sessionTerminal.pausedAt = Date.now(); // Apply backpressure by closing the file watcher if (sessionTerminal.watcher) { sessionTerminal.watcher.close(); sessionTerminal.watcher = undefined; } logger.warn(chalk_1.default.yellow(`Buffer pressure high for session ${sessionId}: ${Math.round(bufferUtilization * 100)}% ` + `(${currentLines}/${maxLines} lines). Pausing file watcher.`)); return true; } // Check if we should resume if (wasPaused && bufferUtilization < FLOW_CONTROL_CONFIG.lowWatermark) { // Avoid race condition: mark as processing pending before resuming if (sessionTerminal.pendingLines && sessionTerminal.pendingLines.length > 0 && !sessionTerminal.isProcessingPending) { sessionTerminal.isProcessingPending = true; const pendingCount = sessionTerminal.pendingLines.length; logger.log(chalk_1.default.green(`Buffer pressure normalized for session ${sessionId}: ${Math.round(bufferUtilization * 100)}% ` + `(${currentLines}/${maxLines} lines). Processing ${pendingCount} pending lines.`)); // Process pending lines asynchronously to avoid blocking setImmediate(() => { const lines = sessionTerminal.pendingLines || []; sessionTerminal.pendingLines = []; sessionTerminal.isPaused = false; sessionTerminal.pausedAt = undefined; sessionTerminal.isProcessingPending = false; for (const pendingLine of lines) { this.processStreamLine(sessionId, sessionTerminal, pendingLine); } // Resume file watching after processing pending lines this.resumeFileWatcher(sessionId).catch((error) => { logger.error(`Failed to resume file watcher for session ${truncateForLog(sessionId)}:`, error); }); }); } else if (!sessionTerminal.pendingLines || sessionTerminal.pendingLines.length === 0) { // No pending lines, just resume sessionTerminal.isPaused = false; sessionTerminal.pausedAt = undefined; // Resume file watching this.resumeFileWatcher(sessionId).catch((error) => { logger.error(`Failed to resume file watcher for session ${truncateForLog(sessionId)}:`, error); }); logger.log(chalk_1.default.green(`Buffer pressure normalized for session ${sessionId}: ${Math.round(bufferUtilization * 100)}% ` + `(${currentLines}/${maxLines} lines). Resuming file watcher.`)); } return false; } return wasPaused; } /** * Handle stream line */ handleStreamLine(sessionId, sessionTerminal, line) { // Initialize line counter if needed if (sessionTerminal.linesProcessedSinceCheck === undefined) { sessionTerminal.linesProcessedSinceCheck = 0; } // Check buffer pressure periodically or if already paused let isPaused = sessionTerminal.isPaused || false; if (!isPaused && sessionTerminal.linesProcessedSinceCheck >= FLOW_CONTROL_CONFIG.bufferCheckInterval) { isPaused = this.checkBufferPressure(sessionId); sessionTerminal.linesProcessedSinceCheck = 0; } if (isPaused) { // Queue the line for later processing if (!sessionTerminal.pendingLines) { sessionTerminal.pendingLines = []; } // Limit pending lines to prevent memory issues if (sessionTerminal.pendingLines.length < FLOW_CONTROL_CONFIG.maxPendingLines) { sessionTerminal.pendingLines.push(line); } else { logger.warn(chalk_1.default.red(`Pending lines limit reached for session ${sessionId}. Dropping new data to prevent memory overflow.`)); } return; } sessionTerminal.linesProcessedSinceCheck++; this.processStreamLine(sessionId, sessionTerminal, line); } /** * Process a stream line (separated from handleStreamLine for flow control) */ processStreamLine(sessionId, sessionTerminal, line) { try { const data = JSON.parse(line); // Handle asciinema header if (data.version && data.width && data.height) { sessionTerminal.terminal.resize(data.width, data.height); this.notifyBufferChange(sessionId); return; } // Handle asciinema events [timestamp, type, data] if (Array.isArray(data) && data.length >= 3) { const [timestamp, type, eventData] = data; if (timestamp === 'exit') { // Session exited logger.log(chalk_1.default.yellow(`Session ${truncateForLog(sessionId)} exited with code ${data[1]}`)); if (sessionTerminal.watcher) { sessionTerminal.watcher.close(); } return; } if (type === 'o') { // Output event - queue write to terminal with rate limiting this.queueTerminalWrite(sessionId, sessionTerminal, eventData); this.scheduleBufferChangeNotification(sessionId); } else if (type === 'r') { // Resize event const match = eventData.match(/^(\d+)x(\d+)$/); if (match) { const cols = Number.parseInt(match[1], 10); const rows = Number.parseInt(match[2], 10); sessionTerminal.terminal.resize(cols, rows); this.notifyBufferChange(sessionId); } } // Ignore 'i' (input) events } } catch (error) { // Use deduplicator to check if we should log this error // Use a more generic context key to group similar parsing errors together const contextKey = `${sessionId}:parse-stream-line`; if (this.errorDeduplicator.shouldLog(error, contextKey)) { const stats = this.errorDeduplicator.getErrorStats(error, contextKey); if (stats && stats.count > 1) { // Log summary for repeated errors logger.warn((0, error_deduplicator_js_1.formatErrorSummary)(error, stats, `session ${truncateForLog(sessionId)}`)); } else { // First occurrence - log the error with details const truncatedLine = line.length > 100 ? `${line.substring(0, 100)}...` : line; logger.error(`Failed to parse stream line for session ${truncateForLog(sessionId)}: ${truncatedLine}`); if (error instanceof Error && error.stack) { logger.debug(`Parse error details: ${error.message}`); } } } } } /** * Get buffer stats for a session */ async getBufferStats(sessionId) { const terminal = await this.getTerminal(sessionId); const buffer = terminal.buffer.active; const sessionTerminal = this.terminals.get(sessionId); logger.debug(`Getting buffer stats for session ${truncateForLog(sessionId)}: ${buffer.length} total rows`); const maxLines = terminal.options.scrollback || 10000; const bufferUtilization = buffer.length / maxLines; return { totalRows: buffer.length, cols: terminal.cols, rows: terminal.rows, viewportY: buffer.viewportY, cursorX: buffer.cursorX, cursorY: buffer.cursorY, scrollback: terminal.options.scrollback || 0, // Flow control metrics isPaused: sessionTerminal?.isPaused || false, pendingLines: sessionTerminal?.pendingLines?.length || 0, bufferUtilization: Math.round(bufferUtilization * 100), maxBufferLines: maxLines, }; } /** * Get buffer snapshot for a session - always returns full terminal buffer (cols x rows) */ async getBufferSnapshot(sessionId) { const startTime = Date.now(); const terminal = await this.getTerminal(sessionId); const buffer = terminal.buffer.active; // Always get the visible terminal area from bottom const startLine = Math.max(0, buffer.length - terminal.rows); const endLine = buffer.length; const actualLines = endLine - startLine; // Get cursor position relative to our viewport const cursorX = buffer.cursorX; const cursorY = buffer.cursorY + buffer.viewportY - startLine; // Extract cells const cells = []; const cell = buffer.getNullCell(); for (let row = 0; row < actualLines; row++) { const line = buffer.getLine(startLine + row); const rowCells = []; if (line) { for (let col = 0; col < terminal.cols; col++) { line.getCell(col, cell); const char = cell.getChars() || ' '; const width = cell.getWidth(); // Skip zero-width cells (part of wide characters) if (width === 0) continue; // Build attributes byte let attributes = 0; if (cell.isBold()) attributes |= 0x01; if (cell.isItalic()) attributes |= 0x02; if (cell.isUnderline()) attributes |= 0x04; if (cell.isDim()) attributes |= 0x08; if (cell.isInverse()) attributes |= 0x10; if (cell.isInvisible()) attributes |= 0x20; if (cell.isStrikethrough()) attributes |= 0x40; const bufferCell = { char, width, }; // Only include non-default values const fg = cell.getFgColor(); const bg = cell.getBgColor(); // Handle color values - -1 means default color if (fg !== undefined && fg !== -1) bufferCell.fg = fg; if (bg !== undefined && bg !== -1) bufferCell.bg = bg; if (attributes !== 0) bufferCell.attributes = attributes; rowCells.push(bufferCell); } // Trim blank cells from the end of the line let lastNonBlankCell = rowCells.length - 1; while (lastNonBlankCell >= 0) { const cell = rowCells[lastNonBlankCell]; if (cell.char !== ' ' || cell.fg !== undefined || cell.bg !== undefined || cell.attributes !== undefined) { break; } lastNonBlankCell--; } // Trim the array, but keep at least one cell if (lastNonBlankCell < rowCells.length - 1) { rowCells.splice(Math.max(1, lastNonBlankCell + 1)); } } else { // Empty line - just add a single space rowCells.push({ char: ' ', width: 1 }); } cells.push(rowCells); } // Trim blank lines from the bottom let lastNonBlankRow = cells.length - 1; while (lastNonBlankRow >= 0) { const row = cells[lastNonBlankRow]; const hasContent = row.some((cell) => cell.char !== ' ' || cell.fg !== undefined || cell.bg !== undefined || cell.attributes !== undefined); if (hasContent) break; lastNonBlankRow--; } // Keep at least one row const trimmedCells = cells.slice(0, Math.max(1, lastNonBlankRow + 1)); const duration = Date.now() - startTime; if (duration > 10) { logger.debug(`Buffer snapshot for session ${sessionId} took ${duration}ms (${trimmedCells.length} rows)`); } return { cols: terminal.cols, rows: trimmedCells.length, viewportY: startLine, cursorX, cursorY, cells: trimmedCells, }; } /** * Encode buffer snapshot to binary format * * Converts a buffer snapshot into an optimized binary format for * efficient transmission over WebSocket. The encoding uses various * compression techniques: * * - Empty rows are marked with 2-byte markers * - Spaces with default styling use 1 byte * - ASCII characters with colors use 2-8 bytes * - Unicode characters use variable length encoding * * The binary format is designed for fast decoding on the client * while minimizing bandwidth usage. * * @param snapshot - Terminal buffer snapshot to encode * @returns Binary buffer ready for transmission * * @example * ```typescript * const snapshot = await manager.getBufferSnapshot('session-123'); * const binary = manager.encodeSnapshot(snapshot); * * // Send over WebSocket with session ID * const packet = Buffer.concat([ * Buffer.from([0xBF]), // Magic byte * Buffer.from(sessionId.length.toString(16), 'hex'), * Buffer.from(sessionId), * binary * ]); * ws.send(packet); * ``` */ encodeSnapshot(snapshot) { const startTime = Date.now(); const { cols, rows, viewportY, cursorX, cursorY, cells } = snapshot; // Pre-calculate actual data size for efficiency let dataSize = 32; // Header size // First pass: calculate exact size needed for (let row = 0; row < cells.length; row++) { const rowCells = cells[row]; if (rowCells.length === 0 || (rowCells.length === 1 && rowCells[0].char === ' ' && !rowCells[0].fg && !rowCells[0].bg && !rowCells[0].attributes)) { // Empty row marker: 2 bytes dataSize += 2; } else { // Row header: 3 bytes (marker + length) dataSize += 3; for (const cell of rowCells) { dataSize += this.calculateCellSize(cell); } } } const buffer = Buffer.allocUnsafe(dataSize); let offset = 0; // Write header (32 bytes) buffer.writeUInt16LE(0x5654, offset); offset += 2; // Magic "VT" buffer.writeUInt8(0x01, offset); // Version 1 - our only format offset += 1; // Version buffer.writeUInt8(0x00, offset); offset += 1; // Flags buffer.writeUInt32LE(cols, offset); offset += 4; // Cols (32-bit) buffer.writeUInt32LE(rows, offset); offset += 4; // Rows (32-bit) buffer.writeInt32LE(viewportY, offset); // Signed for large buffers offset += 4; // ViewportY (32-bit signed) buffer.writeInt32LE(cursorX, offset); // Signed for consistency offset += 4; // CursorX (32-bit signed) buffer.writeInt32LE(cursorY, offset); // Signed for relative positions offset += 4; // CursorY (32-bit signed) buffer.writeUInt32LE(0, offset); offset += 4; // Reserved // Write cells with new optimized format for (let row = 0; row < cells.length; row++) { const rowCells = cells[row]; // Check if this is an empty row if (rowCells.length === 0 || (rowCells.length === 1 && rowCells[0].char === ' ' && !rowCells[0].fg && !rowCells[0].bg && !rowCells[0].attributes)) { // Empty row marker buffer.writeUInt8(0xfe, offset++); // Empty row marker buffer.writeUInt8(1, offset++); // Count of empty rows (for now just 1) } else { // Row with content buffer.writeUInt8(0xfd, offset++); // Row marker buffer.writeUInt16LE(rowCells.length, offset); // Number of cells in row offset += 2; // Write each cell for (const cell of rowCells) { offset = this.encodeCell(buffer, offset, cell); } } } // Return exact size buffer const result = buffer.subarray(0, offset); const duration = Date.now() - startTime; if (duration > 5) { logger.debug(`Encoded snapshot: ${result.length} bytes in ${duration}ms (${rows} rows)`); } return result; } /** * Calculate the size needed to encode a cell */ calculateCellSize(cell) { // Optimized encoding: // - Simple space with default colors: 1 byte // - ASCII char with default colors: 2 bytes // - ASCII char with colors/attrs: 2-8 bytes // - Unicode char: variable const isSpace = cell.char === ' '; const hasAttrs = cell.attributes && cell.attributes !== 0; const hasFg = cell.fg !== undefined; const hasBg = cell.bg !== undefined; const isAscii = cell.char.charCodeAt(0) <= 127; if (isSpace && !hasAttrs && !hasFg && !hasBg) { return 1; // Just a space marker } let size = 1; // Type byte if (isAscii) { size += 1; // ASCII character } else { const charBytes = Buffer.byteLength(cell.char, 'utf8'); size += 1 + charBytes; // Length byte + UTF-8 bytes } // Attributes/colors byte if (hasAttrs || hasFg || hasBg) { size += 1; // Flags byte if (hasFg && cell.fg !== undefined) { size += cell.fg > 255 ? 3 : 1; // RGB or palette } if (hasBg && cell.bg !== undefined) { size += cell.bg > 255 ? 3 : 1; // RGB or palette } } return size; } /** * Encode a single cell into the buffer */ encodeCell(buffer, offset, cell) { const isSpace = cell.char === ' '; const hasAttrs = cell.attributes && cell.attributes !== 0; const hasFg = cell.fg !== undefined; const hasBg = cell.bg !== undefined; const isAscii = cell.char.charCodeAt(0) <= 127; // Type byte format: // Bit 7: Has extended data (attrs/colors) // Bit 6: Is Unicode (vs ASCII) // Bit 5: Has foreground color // Bit 4: Has background color // Bit 3: Is RGB foreground (vs palette) // Bit 2: Is RGB background (vs palette) // Bits 1-0: Character type (00=space, 01=ASCII, 10=Unicode) if (isSpace && !hasAttrs && !hasFg && !hasBg) { // Simple space - 1 byte buffer.writeUInt8(0x00, offset++); // Type: space, no extended data return offset; } let typeByte = 0; if (hasAttrs || hasFg || hasBg) { typeByte |= 0x80; // Has extended data } if (!isAscii) { typeByte |= 0x40; // Is Unicode typeByte |= 0x02; // Character type: Unicode } else if (!isSpace) { typeByte |= 0x01; // Character type: ASCII } if (hasFg && cell.fg !== undefined) { typeByte |= 0x20; // Has foreground if (cell.fg > 255) typeByte |= 0x08; // Is RGB } if (hasBg && cell.bg !== undefined) { typeByte |= 0x10; // Has background if (cell.bg > 255) typeByte |= 0x04; // Is RGB } buffer.writeUInt8(typeByte, offset++); // Write character if (!isAscii) { const charBytes = Buffer.from(cell.char, 'utf8'); buffer.writeUInt8(charBytes.length, offset++); charBytes.copy(buffer, offset); offset += charBytes.length; } else if (!isSpace) { buffer.writeUInt8(cell.char.charCodeAt(0), offset++); } // Write extended data if present if (typeByte & 0x80) { // Attributes byte (if any) if (hasAttrs && cell.attributes !== undefined) { buffer.writeUInt8(cell.attributes, offset++); } else if (hasFg || hasBg) { buffer.writeUInt8(0, offset++); // No attributes but need the byte } // Foreground color if (hasFg && cell.fg !== undefined) { if (cell.fg > 255) { // RGB buffer.writeUInt8((cell.fg >> 16) & 0xff, offset++); buffer.writeUInt8((cell.fg >> 8) & 0xff, offset++); buffer.writeUInt8(cell.fg & 0xff, offset++); } else { // Palette buffer.writeUInt8(cell.fg, offset++); } } // Background color if (hasBg && cell.bg !== undefined) { if (cell.bg > 255) { // RGB buffer.writeUInt8((cell.bg >> 16) & 0xff, offset++); buffer.writeUInt8((cell.bg >> 8) & 0xff, offset++); buffer.writeUInt8(cell.bg & 0xff, offset++); } else { // Palette buffer.writeUInt8(cell.bg, offset++); } } } return offset; } /** * Close a terminal session */ closeTerminal(sessionId) { const sessionTerminal = this.terminals.get(sessionId); if (sessionTerminal) { if (sessionTerminal.watcher) { sessionTerminal.watcher.close(); } sessionTerminal.terminal.dispose(); this.terminals.delete(sessionId); // Clear write timer if exists const writeTimer = this.writeTimers.get(sessionId); if (writeTimer) { clearTimeout(writeTimer); this.writeTimers.delete(sessionId); } // Clear write queue this.writeQueues.delete(sessionId); logger.log(chalk_1.default.yellow(`Terminal closed for session ${truncateForLog(sessionId)}`)); } } /** * Clean up old terminals */ cleanup(maxAge = 30 * 60 * 1000) { const now = Date.now(); const toRemove = []; for (const [sessionId, sessionTerminal] of this.terminals) { if (now - sessionTerminal.lastUpdate > maxAge) { toRemove.push(sessionId); } } for (const sessionId of toRemove) { logger.log(chalk_1.default.yellow(`Cleaning up stale terminal for session ${truncateForLog(sessionId)}`)); this.closeTerminal(sessionId); } if (toRemove.length > 0) { logger.log(chalk_1.default.gray(`Cleaned up ${toRemove.length} stale terminals`)); } } /** * Queue terminal write with rate limiting to prevent flow control issues */ queueTerminalWrite(sessionId, sessionTerminal, data) { // Get or create write queue for this session let queue = this.writeQueues.get(sessionId); if (!queue) { queue = []; this.writeQueues.set(sessionId, queue); } // Add data to queue queue.push(data); // If no write timer is active, start processing the queue if (!this.writeTimers.has(sessionId)) { this.processWriteQueue(sessionId, sessionTerminal); } } /** * Process write queue with rate limiting */ processWriteQueue(sessionId, sessionTerminal) { const queue = this.writeQueues.get(sessionId); if (!queue || queue.length === 0) { this.writeTimers.delete(sessionId); return; } // Process a batch of writes (limit batch size to prevent overwhelming the terminal) const batchSize = 10; const batch = queue.splice(0, batchSize); const combinedData = batch.join(''); try { sessionTerminal.terminal.write(combinedData); } catch (error) { // Use error deduplicator to prevent log spam const contextKey = `${sessionId}:terminal-write`; if (this.errorDeduplicator.shouldLog(error, contextKey)) { const stats = this.errorDeduplicator.getErrorStats(error, contextKey); if (stats && stats.count > 1) { // Log summary for repeated errors logger.warn((0, error_deduplicator_js_1.formatErrorSummary)(error, stats, `terminal write for session ${truncateForLog(sessionId)}`)); } else { // First occurrence - log with more detail const errorMessage = error instanceof Error ? error.message : String(error); logger.warn(`Terminal write error for session ${truncateForLog(sessionId)}: ${errorMessage}`); if (error instanceof Error && error.stack) { logger.debug(`Write error stack: ${error.stack}`); } } } } // Schedule next batch processing if (queue.length > 0) { const timer = setTimeout(() => { this.processWriteQueue(sessionId, sessionTerminal); }, 10); // 10ms delay between batches this.writeTimers.set(sessionId, timer); } else { this.writeTimers.delete(sessionId); } } /** * Get all active terminals */ getActiveTerminals() { return Array.from(this.terminals.keys()); } /** * Subscribe to buffer changes for a session */ async subscribeToBufferChanges(sessionId, listener) { // Ensure terminal exists and is watching await this.getTerminal(sessionId); if (!this.bufferListeners.has(sessionId)) { this.bufferListeners.set(sessionId, new Set()); } const listeners = this.bufferListeners.get(sessionId); if (listeners) { listeners.add(listener); logger.log(chalk_1.default.blue(`Buffer listener subscribed for session ${sessionId} (${listeners.size} total)`)); } // Return unsubscribe function return () => { const listeners = this.bufferListeners.get(sessionId); if (listeners) { listeners.delete(listener); logger.log(chalk_1.default.yellow(`Buffer listener unsubscribed for session ${sessionId} (${listeners.size} remaining)`)); if (listeners.size === 0) { this.bufferListeners.delete(sessionId); } } }; } /** * Schedule buffer change notification (debounced) */ scheduleBufferChangeNotification(sessionId) { // Cancel existing timer const existingTimer = this.changeTimers.get(sessionId); if (existingTimer) { clearTimeout(existingTimer); } // Schedule new notification in 50ms const timer = setTimeout(() => { this.changeTimers.delete(sessionId); this.notifyBufferChange(sessionId); }, 50); this.changeTimers.set(sessionId, timer); } /** * Notify listeners of buffer change */ async notifyBufferChange(sessionId) { const listeners = this.bufferListeners.get(sessionId); if (!listeners || listeners.size === 0) return; // logger.debug( // `Notifying ${listeners.size} buffer change listeners for session ${truncateForLog(sessionId)}` // ); try { // Get full buffer snapshot const snapshot = await this.getBufferSnapshot(sessionId); // Notify all listeners listeners.forEach((listener) => { try { listener(sessionId, snapshot); } catch (error) { logger.error(`Error notifying buffer change listener for ${truncateForLog(sessionId)}:`, error); } }); } catch (error) { logger.error(`Error getting buffer snapshot for notification ${truncateForLog(sessionId)}:`, error); } } /** * Resume file watching for a paused session */ async resumeFileWatcher(sessionId) { const sessionTerminal = this.terminals.get(sessionId); if (!sessionTerminal || sessionTerminal.watcher) { return; // Already watching or session doesn't exist } await this.watchStreamFile(sessionId); } /** * Destroy the terminal manager and restore console overrides */ destroy() { // Close all terminals for (const sessionId of this.terminals.keys()) { this.closeTerminal(sessionId); } // Clear all timers for (const timer of this.changeTimers.values()) { clearTimeout(timer); } this.changeTimers.clear(); // Clear write timers for (const timer of this.writeTimers.values()) { clearTimeout(timer); } this.writeTimers.clear(); // Clear write queues this.writeQueues.clear(); // Clear flow control timer if (this.flowControlTimer) { clearInterval(this.flowControlTimer); this.flowControlTimer = undefined; } // Restore original console.warn console.warn = this.originalConsoleWarn; } } exports.TerminalManager = TerminalManager; //# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoidGVybWluYWwtbWFuYWdlci5qcyIsInNvdXJjZVJvb3QiOiIiLCJzb3VyY2VzIjpbIi4uLy4uLy4uL3NyYy9zZXJ2ZXIvc2VydmljZXMvdGVybWluYWwtbWFuYWdlci50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiOzs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7Ozs7QUFBQSw4Q0FBNEQ7QUFDNUQsa0RBQTBCO0FBQzFCLHVDQUF5QjtBQUN6QiwyQ0FBNkI7QUFDN0IsMEVBQXVGO0FBQ3ZGLGtEQUFrRDtBQUVsRCxNQUFNLE1BQU0sR0FBRyxJQUFBLHdCQUFZLEVBQUMsa0JBQWtCLENBQUMsQ0FBQztBQUVoRCx1REFBdUQ7QUFDdkQsU0FBUyxjQUFjLENBQUMsR0FBVyxFQUFFLFlBQW9CLEVBQUU7SUFDekQsSUFBSSxHQUFHLENBQUMsTUFBTSxJQUFJLFNBQVM7UUFBRSxPQUFPLEdBQUcsQ0FBQztJQUN4QyxPQUFPLEdBQUcsR0FBRyxDQUFDLFNBQVMsQ0FBQyxDQUFDLEVBQUUsU0FBUyxDQUFDLE9BQU8sR0FBRyxDQUFDLE1BQU0sZUFBZSxDQUFDO0FBQ3hFLENBQUM7QUFFRCw2QkFBNkI7QUFDN0IsTUFBTSxtQkFBbUIsR0FBRztJQUMxQixrRUFBa0U7SUFDbEUsOERBQThEO0lBQzlELGFBQWEsRUFBRSxHQUFHO0lBQ2xCLHlEQUF5RDtJQUN6RCxzREFBc0Q7SUFDdEQsWUFBWSxFQUFFLEdBQUc7SUFDakIsOENBQThDO0lBQzlDLG1FQUFtRTtJQUNuRSxhQUFhLEVBQUUsR0FBRyxFQUFFLEtBQUs7SUFDekIsbURBQW1EO0lBQ25ELGlGQUFpRjtJQUNqRixlQUFlLEVBQUUsS0FBSztJQUN0Qix5REFBeUQ7SUFDekQsNkVBQTZFO0lBQzdFLFlBQVksRUFBRSxDQUFDLEdBQUcsRUFBRSxHQUFHLElBQUksRUFBRSxZQUFZO0lBQ3pDLGtEQUFrRDtJQUNsRCxvRUFBb0U7SUFDcEUsbUJBQW1CLEVBQUUsR0FBRztDQUN6QixDQUFDO0FBa0NGOzs7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