vibetunnel
Version:
Terminal sharing server with web interface - supports macOS, Linux, and headless environments
1,040 lines • 139 kB
JavaScript
"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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