vibetunnel/web/src/server/services/terminal-manager.ts
Mario Zechner e912b65c9e Fix Node.js detection in build-bun-executable.sh for Xcode builds
Add common Node.js installation paths to PATH including Homebrew, NVM, n, and MacPorts locations. This matches the approach used in build-web-frontend.sh and ensures the script can find Node.js when running in Xcode's restricted environment.

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-06-22 04:04:52 +02:00

670 lines
19 KiB
TypeScript

import { Terminal as XtermTerminal } from '@xterm/headless';
import * as fs from 'fs';
import * as path from 'path';
interface SessionTerminal {
terminal: XtermTerminal;
watcher?: fs.FSWatcher;
lastUpdate: number;
}
type BufferChangeListener = (sessionId: string, snapshot: BufferSnapshot) => void;
interface BufferCell {
char: string;
width: number;
fg?: number;
bg?: number;
attributes?: number;
}
interface BufferSnapshot {
cols: number;
rows: number;
viewportY: number;
cursorX: number;
cursorY: number;
cells: BufferCell[][];
}
export class TerminalManager {
private terminals: Map<string, SessionTerminal> = new Map();
private controlDir: string;
private bufferListeners: Map<string, Set<BufferChangeListener>> = new Map();
private changeTimers: Map<string, NodeJS.Timeout> = new Map();
constructor(controlDir: string) {
this.controlDir = controlDir;
}
/**
* Get or create a terminal for a session
*/
async getTerminal(sessionId: string): Promise<XtermTerminal> {
let sessionTerminal = this.terminals.get(sessionId);
if (!sessionTerminal) {
// Create new terminal
const terminal = new XtermTerminal({
cols: 80,
rows: 24,
scrollback: 10000,
allowProposedApi: true,
convertEol: true,
});
sessionTerminal = {
terminal,
lastUpdate: Date.now(),
};
this.terminals.set(sessionId, sessionTerminal);
// Start watching the stream file
await this.watchStreamFile(sessionId);
}
sessionTerminal.lastUpdate = Date.now();
return sessionTerminal.terminal;
}
/**
* Watch stream file for changes
*/
private async watchStreamFile(sessionId: string): Promise<void> {
const sessionTerminal = this.terminals.get(sessionId);
if (!sessionTerminal) return;
const streamPath = path.join(this.controlDir, sessionId, 'stdout');
let lastOffset = 0;
let lineBuffer = '';
// Check if the file exists
if (!fs.existsSync(streamPath)) {
console.warn(`Stream file does not exist for session ${sessionId}: ${streamPath}`);
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;
// 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
for (const line of lines) {
if (line.trim()) {
this.handleStreamLine(sessionId, sessionTerminal, line);
}
}
}
} catch (error) {
console.error(`Error reading stream file for session ${sessionId}:`, error);
}
}
});
console.log(`Watching stream file for session ${sessionId}`);
} catch (error) {
console.error(`Failed to watch stream file for session ${sessionId}:`, error);
throw error;
}
}
/**
* Handle stream line
*/
private handleStreamLine(sessionId: string, sessionTerminal: SessionTerminal, line: string) {
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
console.log(`Session ${sessionId} exited with code ${data[1]}`);
if (sessionTerminal.watcher) {
sessionTerminal.watcher.close();
}
return;
}
if (type === 'o') {
// Output event - write to terminal
sessionTerminal.terminal.write(eventData);
this.scheduleBufferChangeNotification(sessionId);
} else if (type === 'r') {
// Resize event
const match = eventData.match(/^(\d+)x(\d+)$/);
if (match) {
const cols = parseInt(match[1], 10);
const rows = parseInt(match[2], 10);
sessionTerminal.terminal.resize(cols, rows);
this.notifyBufferChange(sessionId);
}
}
// Ignore 'i' (input) events
}
} catch (error) {
console.error(`Failed to parse stream line for session ${sessionId}:`, error);
}
}
/**
* Get buffer stats for a session
*/
async getBufferStats(sessionId: string) {
const terminal = await this.getTerminal(sessionId);
const buffer = terminal.buffer.active;
return {
totalRows: buffer.length,
cols: terminal.cols,
rows: terminal.rows,
viewportY: buffer.viewportY,
cursorX: buffer.cursorX,
cursorY: buffer.cursorY,
scrollback: terminal.options.scrollback || 0,
};
}
/**
* Get buffer snapshot for a session - always returns full terminal buffer (cols x rows)
*/
async getBufferSnapshot(sessionId: string): Promise<BufferSnapshot> {
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: BufferCell[][] = [];
const cell = buffer.getNullCell();
for (let row = 0; row < actualLines; row++) {
const line = buffer.getLine(startLine + row);
const rowCells: BufferCell[] = [];
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: 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));
return {
cols: terminal.cols,
rows: trimmedCells.length,
viewportY: startLine,
cursorX,
cursorY,
cells: trimmedCells,
};
}
/**
* Encode buffer snapshot to binary format - optimized for minimal data transmission
*/
encodeSnapshot(snapshot: BufferSnapshot): Buffer {
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
return buffer.subarray(0, offset);
}
/**
* Calculate the size needed to encode a cell
*/
private calculateCellSize(cell: BufferCell): number {
// 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
*/
private encodeCell(buffer: Buffer, offset: number, cell: BufferCell): number {
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: string): void {
const sessionTerminal = this.terminals.get(sessionId);
if (sessionTerminal) {
if (sessionTerminal.watcher) {
sessionTerminal.watcher.close();
}
sessionTerminal.terminal.dispose();
this.terminals.delete(sessionId);
}
}
/**
* Clean up old terminals
*/
cleanup(maxAge: number = 30 * 60 * 1000): void {
const now = Date.now();
const toRemove: string[] = [];
for (const [sessionId, sessionTerminal] of this.terminals) {
if (now - sessionTerminal.lastUpdate > maxAge) {
toRemove.push(sessionId);
}
}
for (const sessionId of toRemove) {
console.log(`Cleaning up stale terminal for session ${sessionId}`);
this.closeTerminal(sessionId);
}
}
/**
* Get all active terminals
*/
getActiveTerminals(): string[] {
return Array.from(this.terminals.keys());
}
/**
* Subscribe to buffer changes for a session
*/
async subscribeToBufferChanges(
sessionId: string,
listener: BufferChangeListener
): Promise<() => void> {
// 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);
}
// Return unsubscribe function
return () => {
const listeners = this.bufferListeners.get(sessionId);
if (listeners) {
listeners.delete(listener);
if (listeners.size === 0) {
this.bufferListeners.delete(sessionId);
}
}
};
}
/**
* Schedule buffer change notification (debounced)
*/
private scheduleBufferChangeNotification(sessionId: string) {
// 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
*/
private async notifyBufferChange(sessionId: string) {
const listeners = this.bufferListeners.get(sessionId);
if (!listeners || listeners.size === 0) return;
try {
// Get full buffer snapshot
const snapshot = await this.getBufferSnapshot(sessionId);
// Notify all listeners
listeners.forEach((listener) => {
try {
listener(sessionId, snapshot);
} catch (error) {
console.error(`Error notifying buffer change listener for ${sessionId}:`, error);
}
});
} catch (error) {
console.error(`Error getting buffer snapshot for notification ${sessionId}:`, error);
}
}
}