UNPKG

vibetunnel

Version:

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

993 lines 332 kB
"use strict"; /** * PtyManager - Core PTY management using node-pty * * This class handles PTY creation, process management, and I/O operations * using the node-pty library while maintaining compatibility with tty-fwd. */ 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.PtyManager = void 0; const chalk_1 = __importDefault(require("chalk")); const child_process_1 = require("child_process"); const events_1 = require("events"); const fs = __importStar(require("fs")); const net = __importStar(require("net")); const path = __importStar(require("path")); // Import node-pty with fallback support let pty; // Dynamic import will be done in initialization const util_1 = require("util"); const uuid_1 = require("uuid"); const types_js_1 = require("../../shared/types.js"); const process_tree_analyzer_js_1 = require("../services/process-tree-analyzer.js"); const activity_detector_js_1 = require("../utils/activity-detector.js"); const ansi_title_filter_js_1 = require("../utils/ansi-title-filter.js"); const logger_js_1 = require("../utils/logger.js"); const terminal_title_js_1 = require("../utils/terminal-title.js"); const write_queue_js_1 = require("../utils/write-queue.js"); const version_js_1 = require("../version.js"); const control_unix_handler_js_1 = require("../websocket/control-unix-handler.js"); const asciinema_writer_js_1 = require("./asciinema-writer.js"); const fish_handler_js_1 = require("./fish-handler.js"); const process_utils_js_1 = require("./process-utils.js"); const session_manager_js_1 = require("./session-manager.js"); const socket_protocol_js_1 = require("./socket-protocol.js"); const types_js_2 = require("./types.js"); const logger = (0, logger_js_1.createLogger)('pty-manager'); // Title injection timing constants const TITLE_UPDATE_INTERVAL_MS = 1000; // How often to check if title needs updating const TITLE_INJECTION_QUIET_PERIOD_MS = 50; // Minimum quiet period before injecting title const TITLE_INJECTION_CHECK_INTERVAL_MS = 10; // How often to check for quiet period // Foreground process tracking constants const PROCESS_POLL_INTERVAL_MS = 500; // How often to check foreground process const MIN_COMMAND_DURATION_MS = 3000; // Minimum duration for command completion notifications (3 seconds) const SHELL_COMMANDS = new Set(['cd', 'ls', 'pwd', 'echo', 'export', 'alias', 'unset']); // Built-in commands to ignore /** * PtyManager handles the lifecycle and I/O operations of pseudo-terminal (PTY) sessions. * * This class provides comprehensive terminal session management including: * - Creating and managing PTY processes using node-pty * - Handling terminal input/output with proper buffering and queuing * - Managing terminal resizing from both browser and host terminal * - Recording sessions in asciinema format for playback * - Communicating with external sessions via Unix domain sockets * - Dynamic terminal title management with activity detection * - Session persistence and recovery across server restarts * * The PtyManager supports both in-memory sessions (where the PTY is managed directly) * and external sessions (where communication happens via IPC sockets). * * @extends EventEmitter * * @fires PtyManager#sessionExited - When a session terminates * @fires PtyManager#sessionNameChanged - When a session name is updated * @fires PtyManager#bell - When a bell character is detected in terminal output * * @example * ```typescript * // Create a PTY manager instance * const ptyManager = new PtyManager('/path/to/control/dir'); * * // Create a new session * const { sessionId, sessionInfo } = await ptyManager.createSession( * ['bash', '-l'], * { * name: 'My Terminal', * workingDir: '/home/user', * cols: 80, * rows: 24, * titleMode: TitleMode.DYNAMIC * } * ); * * // Send input to the session * ptyManager.sendInput(sessionId, { text: 'ls -la\n' }); * * // Resize the terminal * ptyManager.resizeSession(sessionId, 100, 30); * * // Kill the session gracefully * await ptyManager.killSession(sessionId); * ``` */ class PtyManager extends events_1.EventEmitter { constructor(controlPath) { super(); this.sessions = new Map(); this.defaultTerm = 'xterm-256color'; this.inputSocketClients = new Map(); // Cache socket connections this.lastTerminalSize = null; this.resizeEventListeners = []; this.sessionResizeSources = new Map(); this.sessionEventListeners = new Map(); this.sessionExitTimes = new Map(); // Track session exit times to avoid false bells this.processTreeAnalyzer = new process_tree_analyzer_js_1.ProcessTreeAnalyzer(); // Process tree analysis for bell source identification this.activityFileWarningsLogged = new Set(); // Track which sessions we've logged warnings for this.lastWrittenActivityState = new Map(); // Track last written activity state to avoid unnecessary writes this.sessionMonitor = null; // Reference to SessionMonitor for notification tracking // Command tracking for notifications this.commandTracking = new Map(); /** * Import necessary exec function */ this.execAsync = (0, util_1.promisify)(child_process_1.exec); this.sessionManager = new session_manager_js_1.SessionManager(controlPath); this.processTreeAnalyzer = new process_tree_analyzer_js_1.ProcessTreeAnalyzer(); this.setupTerminalResizeDetection(); // Initialize node-pty if not already done if (!PtyManager.initialized) { throw new Error('PtyManager not initialized. Call PtyManager.initialize() first.'); } } /** * Initialize PtyManager with fallback support for node-pty */ static async initialize() { if (PtyManager.initialized) { return; } try { logger.log('Initializing PtyManager...'); pty = await Promise.resolve().then(() => __importStar(require('node-pty'))); PtyManager.initialized = true; logger.log('✅ PtyManager initialized successfully'); } catch (error) { logger.error('Failed to initialize PtyManager:', error); throw new Error(`Cannot load node-pty: ${error instanceof Error ? error.message : String(error)}`); } } /** * Set the SessionMonitor instance for activity tracking */ setSessionMonitor(monitor) { this.sessionMonitor = monitor; } /** * Setup terminal resize detection for when the hosting terminal is resized */ setupTerminalResizeDetection() { // Only setup resize detection if we're running in a TTY if (!process.stdout.isTTY) { logger.debug('Not a TTY, skipping terminal resize detection'); return; } // Store initial terminal size this.lastTerminalSize = { cols: process.stdout.columns || 80, rows: process.stdout.rows || 24, }; // Method 1: Listen for Node.js TTY resize events (most reliable) const handleStdoutResize = () => { const newCols = process.stdout.columns || 80; const newRows = process.stdout.rows || 24; this.handleTerminalResize(newCols, newRows); }; process.stdout.on('resize', handleStdoutResize); this.resizeEventListeners.push(() => { process.stdout.removeListener('resize', handleStdoutResize); }); // Method 2: Listen for SIGWINCH signals (backup for Unix systems) const handleSigwinch = () => { const newCols = process.stdout.columns || 80; const newRows = process.stdout.rows || 24; this.handleTerminalResize(newCols, newRows); }; process.on('SIGWINCH', handleSigwinch); this.resizeEventListeners.push(() => { process.removeListener('SIGWINCH', handleSigwinch); }); } /** * Handle terminal resize events from the hosting terminal */ handleTerminalResize(newCols, newRows) { // Skip if size hasn't actually changed if (this.lastTerminalSize && this.lastTerminalSize.cols === newCols && this.lastTerminalSize.rows === newRows) { return; } logger.log(chalk_1.default.blue(`Terminal resized to ${newCols}x${newRows}`)); // Update stored size this.lastTerminalSize = { cols: newCols, rows: newRows }; // Forward resize to all active sessions using "last resize wins" logic const currentTime = Date.now(); for (const [sessionId, session] of this.sessions) { if (session.ptyProcess && session.sessionInfo.status === 'running') { // Check if we should apply this resize based on "last resize wins" logic const lastResize = this.sessionResizeSources.get(sessionId); const shouldResize = !lastResize || lastResize.source === 'terminal' || currentTime - lastResize.timestamp > 1000; // 1 second grace period for browser resizes if (shouldResize) { try { // Resize the PTY process session.ptyProcess.resize(newCols, newRows); // Record the resize event in the asciinema file session.asciinemaWriter?.writeResize(newCols, newRows); // Track this resize this.sessionResizeSources.set(sessionId, { cols: newCols, rows: newRows, source: 'terminal', timestamp: currentTime, }); logger.debug(`Resized session ${sessionId} to ${newCols}x${newRows} from terminal`); } catch (error) { logger.error(`Failed to resize session ${sessionId}:`, error); } } else { logger.debug(`Skipping terminal resize for session ${sessionId} (browser has precedence)`); } } } } /** * Create a new PTY session */ async createSession(command, options) { const sessionId = options.sessionId || (0, uuid_1.v4)(); const sessionName = options.name || path.basename(command[0]); // Correctly determine the web directory path const webDir = path.resolve(__dirname, '..', '..'); const workingDir = options.workingDir || webDir; const term = this.defaultTerm; // For external spawns without dimensions, let node-pty use the terminal's natural size // For other cases, use reasonable defaults const cols = options.cols; const rows = options.rows; // Verify working directory exists logger.debug('Session creation parameters:', { sessionId, sessionName, workingDir, term, cols: cols !== undefined ? cols : 'terminal default', rows: rows !== undefined ? rows : 'terminal default', }); try { // Create session directory structure const paths = this.sessionManager.createSessionDirectory(sessionId); // Resolve the command using unified resolution logic const resolved = process_utils_js_1.ProcessUtils.resolveCommand(command); const { command: finalCommand, args: finalArgs } = resolved; const resolvedCommand = [finalCommand, ...finalArgs]; // Log resolution details if (resolved.resolvedFrom === 'alias') { logger.log(chalk_1.default.cyan(`Using alias: '${resolved.originalCommand}' → '${resolvedCommand.join(' ')}'`)); } else if (resolved.resolvedFrom === 'path' && resolved.originalCommand) { logger.log(chalk_1.default.gray(`Resolved '${resolved.originalCommand}' → '${finalCommand}'`)); } else if (resolved.useShell) { logger.debug(`Using shell to execute ${resolved.resolvedFrom}: ${command.join(' ')}`); } // Log the final command logger.debug(chalk_1.default.blue(`Creating PTY session with command: ${resolvedCommand.join(' ')}`)); logger.debug(`Working directory: ${workingDir}`); // Check if this session is being spawned from within VibeTunnel const attachedViaVT = !!process.env.VIBETUNNEL_SESSION_ID; // Create initial session info with resolved command const sessionInfo = { id: sessionId, command: resolvedCommand, name: sessionName, workingDir: workingDir, status: 'starting', startedAt: new Date().toISOString(), initialCols: cols, initialRows: rows, lastClearOffset: 0, version: version_js_1.VERSION, gitRepoPath: options.gitRepoPath, gitBranch: options.gitBranch, gitAheadCount: options.gitAheadCount, gitBehindCount: options.gitBehindCount, gitHasChanges: options.gitHasChanges, gitIsWorktree: options.gitIsWorktree, gitMainRepoPath: options.gitMainRepoPath, attachedViaVT, }; // Save initial session info this.sessionManager.saveSessionInfo(sessionId, sessionInfo); // Create asciinema writer // Use actual dimensions if provided, otherwise AsciinemaWriter will use defaults (80x24) const asciinemaWriter = asciinema_writer_js_1.AsciinemaWriter.create(paths.stdoutPath, cols || undefined, rows || undefined, command.join(' '), sessionName, this.createEnvVars(term)); // Set up pruning detection callback for precise offset tracking asciinemaWriter.onPruningSequence(async ({ sequence, position }) => { const sessionInfo = this.sessionManager.loadSessionInfo(sessionId); if (sessionInfo) { sessionInfo.lastClearOffset = position; await this.sessionManager.saveSessionInfo(sessionId, sessionInfo); logger.debug(`Updated lastClearOffset for session ${sessionId} to exact position ${position} ` + `after detecting pruning sequence '${sequence.split('\x1b').join('\\x1b')}'`); } }); // Create PTY process let ptyProcess; try { // Set up environment like Linux implementation const ptyEnv = { ...process.env, TERM: term, // Set session ID to prevent recursive vt calls and for debugging VIBETUNNEL_SESSION_ID: sessionId, }; // Debug log the spawn parameters logger.debug('PTY spawn parameters:', { command: finalCommand, args: finalArgs, options: { name: term, cols: cols !== undefined ? cols : 'terminal default', rows: rows !== undefined ? rows : 'terminal default', cwd: workingDir, hasEnv: !!ptyEnv, envKeys: Object.keys(ptyEnv).length, }, }); // Build spawn options - only include dimensions if provided const spawnOptions = { name: term, cwd: workingDir, env: ptyEnv, }; // Only add dimensions if they're explicitly provided // This allows node-pty to use the terminal's natural size for external spawns if (cols !== undefined) { spawnOptions.cols = cols; } if (rows !== undefined) { spawnOptions.rows = rows; } ptyProcess = pty.spawn(finalCommand, finalArgs, spawnOptions); // Add immediate exit handler to catch CI issues const exitHandler = (event) => { const timeSinceStart = Date.now() - Date.parse(sessionInfo.startedAt); if (timeSinceStart < 1000) { logger.error(`PTY process exited quickly after spawn! Exit code: ${event.exitCode}, signal: ${event.signal}, time: ${timeSinceStart}ms`); logger.error('This often happens in CI when PTY allocation fails or shell is misconfigured'); logger.error('Debug info:', { SHELL: process.env.SHELL, TERM: process.env.TERM, CI: process.env.CI, NODE_ENV: process.env.NODE_ENV, command: finalCommand, args: finalArgs, cwd: workingDir, cwdExists: fs.existsSync(workingDir), commandExists: fs.existsSync(finalCommand), }); } }; ptyProcess.onExit(exitHandler); } catch (spawnError) { // Debug log the raw error first logger.debug('Raw spawn error:', { type: typeof spawnError, isError: spawnError instanceof Error, errorString: String(spawnError), errorKeys: spawnError && typeof spawnError === 'object' ? Object.keys(spawnError) : [], }); // Provide better error messages for common issues let errorMessage = spawnError instanceof Error ? spawnError.message : String(spawnError); const errorCode = spawnError instanceof Error && 'code' in spawnError ? spawnError.code : undefined; if (errorCode === 'ENOENT' || errorMessage.includes('ENOENT')) { errorMessage = `Command not found: '${command[0]}'. Please ensure the command exists and is in your PATH.`; } else if (errorCode === 'EACCES' || errorMessage.includes('EACCES')) { errorMessage = `Permission denied: '${command[0]}'. The command exists but is not executable.`; } else if (errorCode === 'ENXIO' || errorMessage.includes('ENXIO')) { errorMessage = `Failed to allocate terminal for '${command[0]}'. This may occur if the command doesn't exist or the system cannot create a pseudo-terminal.`; } else if (errorMessage.includes('cwd') || errorMessage.includes('working directory')) { errorMessage = `Working directory does not exist: '${workingDir}'`; } // Log the error with better serialization const errorDetails = spawnError instanceof Error ? { ...spawnError, message: spawnError.message, stack: spawnError.stack, code: spawnError.code, } : spawnError; logger.error(`Failed to spawn PTY for command '${command.join(' ')}':`, errorDetails); throw new types_js_2.PtyError(errorMessage, 'SPAWN_FAILED'); } // Create session object // Auto-detect Claude commands and set dynamic mode if no title mode specified let titleMode = options.titleMode; if (!titleMode) { // Check all command arguments for Claude const isClaudeCommand = command.some((arg) => arg.toLowerCase().includes('claude')); if (isClaudeCommand) { titleMode = types_js_1.TitleMode.DYNAMIC; logger.log(chalk_1.default.cyan('✓ Auto-selected dynamic title mode for Claude')); logger.debug(`Detected Claude in command: ${command.join(' ')}`); } } // Detect if this is a tmux attachment session const isTmuxAttachment = (resolvedCommand.includes('tmux') && (resolvedCommand.includes('attach-session') || resolvedCommand.includes('attach') || resolvedCommand.includes('a'))) || sessionName.startsWith('tmux:'); const session = { id: sessionId, sessionInfo, ptyProcess, asciinemaWriter, controlDir: paths.controlDir, stdoutPath: paths.stdoutPath, stdinPath: paths.stdinPath, sessionJsonPath: paths.sessionJsonPath, startTime: new Date(), titleMode: titleMode || types_js_1.TitleMode.NONE, isExternalTerminal: !!options.forwardToStdout, currentWorkingDir: workingDir, titleFilter: new ansi_title_filter_js_1.TitleSequenceFilter(), isTmuxAttachment, }; this.sessions.set(sessionId, session); // Update session info with PID and running status sessionInfo.pid = ptyProcess.pid; sessionInfo.status = 'running'; this.sessionManager.saveSessionInfo(sessionId, sessionInfo); // Setup session.json watcher for external sessions if (options.forwardToStdout) { this.setupSessionWatcher(session); } logger.debug(chalk_1.default.green(`Session ${sessionId} created successfully (PID: ${ptyProcess.pid})`)); logger.log(chalk_1.default.gray(`Running: ${resolvedCommand.join(' ')} in ${workingDir}`)); // Setup PTY event handlers this.setupPtyHandlers(session, options.forwardToStdout || false, options.onExit); // Start foreground process tracking this.startForegroundProcessTracking(session); // Note: stdin forwarding is now handled via IPC socket // Initial title will be set when the first output is received // Do not write title sequence to PTY input as it would be sent to the shell // Emit session started event this.emit('sessionStarted', sessionId, sessionInfo.name || sessionInfo.command.join(' ')); // Send notification to Mac app if (control_unix_handler_js_1.controlUnixHandler.isMacAppConnected()) { control_unix_handler_js_1.controlUnixHandler.sendNotification('Session Started', sessionInfo.name || sessionInfo.command.join(' '), { type: 'session-start', sessionId: sessionId, sessionName: sessionInfo.name || sessionInfo.command.join(' '), }); } return { sessionId, sessionInfo, }; } catch (error) { // Cleanup on failure try { this.sessionManager.cleanupSession(sessionId); } catch (cleanupError) { logger.warn(`Failed to cleanup session ${sessionId} after creation failure:`, cleanupError); } throw new types_js_2.PtyError(`Failed to create session: ${error instanceof Error ? error.message : String(error)}`, 'SESSION_CREATE_FAILED'); } } getPtyForSession(sessionId) { const session = this.sessions.get(sessionId); return session?.ptyProcess || null; } getInternalSession(sessionId) { return this.sessions.get(sessionId); } /** * Setup event handlers for a PTY process */ setupPtyHandlers(session, forwardToStdout, onExit) { const { ptyProcess, asciinemaWriter } = session; if (!ptyProcess) { logger.error(`No PTY process found for session ${session.id}`); return; } // Create write queue for stdout if forwarding const stdoutQueue = forwardToStdout ? new write_queue_js_1.WriteQueue() : null; if (stdoutQueue) { session.stdoutQueue = stdoutQueue; } // Create write queue for input to prevent race conditions const inputQueue = new write_queue_js_1.WriteQueue(); session.inputQueue = inputQueue; // Setup activity detector for dynamic mode if (session.titleMode === types_js_1.TitleMode.DYNAMIC) { session.activityDetector = new activity_detector_js_1.ActivityDetector(session.sessionInfo.command, session.id); // Set up Claude turn notification callback session.activityDetector.setOnClaudeTurn((sessionId) => { logger.info(`🔔 NOTIFICATION DEBUG: Claude turn detected for session ${sessionId}`); this.emit('claudeTurn', sessionId, session.sessionInfo.name || session.sessionInfo.command.join(' ')); }); } // Setup periodic title updates for both static and dynamic modes if (session.titleMode !== types_js_1.TitleMode.NONE && session.titleMode !== types_js_1.TitleMode.FILTER && forwardToStdout) { // Track last known activity state for change detection let lastKnownActivityState = null; session.titleUpdateInterval = setInterval(() => { // For dynamic mode, check for activity state changes if (session.titleMode === types_js_1.TitleMode.DYNAMIC && session.activityDetector) { const activityState = session.activityDetector.getActivityState(); // Check if activity state has changed const activityChanged = lastKnownActivityState === null || activityState.isActive !== lastKnownActivityState.isActive || activityState.specificStatus?.status !== lastKnownActivityState.specificStatus; if (activityChanged) { // Update last known state lastKnownActivityState = { isActive: activityState.isActive, specificStatus: activityState.specificStatus?.status, }; // Mark title for update this.markTitleUpdateNeeded(session); logger.debug(`Activity state changed for session ${session.id}: ` + `active=${activityState.isActive}, ` + `status=${activityState.specificStatus?.status || 'none'}`); // Send notification when activity becomes inactive (Claude's turn) if (!activityState.isActive && activityState.specificStatus?.status === 'waiting') { logger.info(`🔔 NOTIFICATION DEBUG: Claude turn detected for session ${session.id}`); this.emit('claudeTurn', session.id, session.sessionInfo.name || session.sessionInfo.command.join(' ')); // Send notification to Mac app directly if (control_unix_handler_js_1.controlUnixHandler.isMacAppConnected()) { control_unix_handler_js_1.controlUnixHandler.sendNotification('Your Turn', 'Claude has finished responding', { type: 'your-turn', sessionId: session.id, sessionName: session.sessionInfo.name || session.sessionInfo.command.join(' '), }); } } } // Always write activity state for external tools this.writeActivityState(session, activityState); } // Check and update title if needed this.checkAndUpdateTitle(session); }, TITLE_UPDATE_INTERVAL_MS); } // Handle PTY data output ptyProcess.onData((data) => { let processedData = data; // Track PTY output in SessionMonitor for activity and bell detection if (this.sessionMonitor) { this.sessionMonitor.trackPtyOutput(session.id, data); } // If title mode is not NONE, filter out any title sequences the process might // have written to the stream. if (session.titleMode !== undefined && session.titleMode !== types_js_1.TitleMode.NONE) { processedData = session.titleFilter ? session.titleFilter.filter(data) : data; } // Handle activity detection for dynamic mode if (session.titleMode === types_js_1.TitleMode.DYNAMIC && session.activityDetector) { const { filteredData, activity } = session.activityDetector.processOutput(processedData); processedData = filteredData; // Check if activity status changed if (activity.specificStatus?.status !== session.lastActivityStatus) { session.lastActivityStatus = activity.specificStatus?.status; this.markTitleUpdateNeeded(session); // Update SessionMonitor with activity change if (this.sessionMonitor) { const isActive = activity.specificStatus?.status === 'working'; this.sessionMonitor.updateSessionActivity(session.id, isActive, activity.specificStatus?.app); } } } // Check for title update triggers if (session.titleMode === types_js_1.TitleMode.STATIC && forwardToStdout) { // Check if we should update title based on data content if (!session.initialTitleSent || (0, terminal_title_js_1.shouldInjectTitle)(processedData)) { this.markTitleUpdateNeeded(session); if (!session.initialTitleSent) { session.initialTitleSent = true; } } } // Write to asciinema file (it has its own internal queue) // The AsciinemaWriter now handles pruning detection internally with precise byte tracking asciinemaWriter?.writeOutput(Buffer.from(processedData, 'utf8')); // Forward to stdout if requested (using queue for ordering) if (forwardToStdout && stdoutQueue) { stdoutQueue.enqueue(async () => { const canWrite = process.stdout.write(processedData); // Track write activity for safe title injection session.lastWriteTimestamp = Date.now(); if (!canWrite) { await (0, events_1.once)(process.stdout, 'drain'); } }); } }); // Handle PTY exit ptyProcess.onExit(async ({ exitCode, signal }) => { try { // Mark session as exiting to prevent false bell notifications this.sessionExitTimes.set(session.id, Date.now()); // Write exit event to asciinema if (asciinemaWriter?.isOpen()) { asciinemaWriter.writeRawJson(['exit', exitCode || 0, session.id]); asciinemaWriter .close() .catch((error) => logger.error(`Failed to close asciinema writer for session ${session.id}:`, error)); } // Update session status this.sessionManager.updateSessionStatus(session.id, 'exited', undefined, exitCode || (signal ? 128 + (typeof signal === 'number' ? signal : 1) : 1)); // Wait for stdout queue to drain if it exists if (session.stdoutQueue) { try { await session.stdoutQueue.drain(); } catch (error) { logger.error(`Failed to drain stdout queue for session ${session.id}:`, error); } } // Clean up session resources this.cleanupSessionResources(session); // Remove from active sessions this.sessions.delete(session.id); // Clean up command tracking this.commandTracking.delete(session.id); // Emit session exited event this.emit('sessionExited', session.id, session.sessionInfo.name || session.sessionInfo.command.join(' '), exitCode); // Send notification to Mac app if (control_unix_handler_js_1.controlUnixHandler.isMacAppConnected()) { control_unix_handler_js_1.controlUnixHandler.sendNotification('Session Ended', session.sessionInfo.name || session.sessionInfo.command.join(' '), { type: 'session-exit', sessionId: session.id, sessionName: session.sessionInfo.name || session.sessionInfo.command.join(' '), }); } // Call exit callback if provided (for fwd.ts) if (onExit) { onExit(exitCode || 0, signal); } } catch (error) { logger.error(`Failed to handle exit for session ${session.id}:`, error); } }); // Mark for initial title update if (forwardToStdout && (session.titleMode === types_js_1.TitleMode.STATIC || session.titleMode === types_js_1.TitleMode.DYNAMIC)) { this.markTitleUpdateNeeded(session); session.initialTitleSent = true; logger.debug(`Marked initial title update for session ${session.id}`); } // Setup IPC socket for all communication this.setupIPCSocket(session); } /** * Setup Unix socket for all IPC communication */ setupIPCSocket(session) { const ptyProcess = session.ptyProcess; if (!ptyProcess) { logger.error(`No PTY process found for session ${session.id}`); return; } // Create Unix domain socket for all IPC // IMPORTANT: macOS has a 104 character limit for Unix socket paths, including null terminator. // This means the actual usable path length is 103 characters. To avoid EINVAL errors: // - Use short socket names (e.g., 'ipc.sock' instead of 'vibetunnel-ipc.sock') // - Keep session directories as short as possible // - Avoid deeply nested directory structures const socketPath = path.join(session.controlDir, 'ipc.sock'); // Verify the socket path isn't too long if (socketPath.length > 103) { const error = new Error(`Socket path too long: ${socketPath.length} characters`); logger.error(`Socket path too long (${socketPath.length} chars): ${socketPath}`); logger.error(`macOS limit is 103 characters. Consider using shorter session IDs or control paths.`); throw error; // Fail fast instead of returning silently } try { // Remove existing socket if it exists try { fs.unlinkSync(socketPath); } catch (_e) { // Socket doesn't exist, this is expected } // Initialize connected clients set if not already present if (!session.connectedClients) { session.connectedClients = new Set(); } // Create Unix domain socket server with framed message protocol const inputServer = net.createServer((client) => { const parser = new socket_protocol_js_1.MessageParser(); client.setNoDelay(true); // Add client to connected clients set session.connectedClients?.add(client); logger.debug(`Client connected to session ${session.id}, total clients: ${session.connectedClients?.size}`); client.on('data', (chunk) => { parser.addData(chunk); for (const { type, payload } of parser.parseMessages()) { this.handleSocketMessage(session, type, payload); } }); client.on('error', (err) => { logger.debug(`Client socket error for session ${session.id}:`, err); }); client.on('close', () => { // Remove client from connected clients set session.connectedClients?.delete(client); logger.debug(`Client disconnected from session ${session.id}, remaining clients: ${session.connectedClients?.size}`); }); }); inputServer.listen(socketPath, () => { // Make socket writable by all try { fs.chmodSync(socketPath, 0o666); } catch (e) { logger.debug(`Failed to chmod input socket for session ${session.id}:`, e); } logger.debug(`Input socket created for session ${session.id}`); }); // Store server reference for cleanup session.inputSocketServer = inputServer; } catch (error) { logger.error(`Failed to create input socket for session ${session.id}:`, error); } // All IPC goes through this socket } /** * Setup file watcher for session.json changes */ setupSessionWatcher(session) { const _sessionJsonPath = path.join(session.controlDir, 'session.json'); try { // Use polling approach for better reliability on macOS // Check for changes every 100ms const checkInterval = setInterval(() => { try { // Read the current session info from disk const updatedInfo = this.sessionManager.loadSessionInfo(session.id); if (updatedInfo && updatedInfo.name !== session.sessionInfo.name) { // Name has changed, update our internal state const oldName = session.sessionInfo.name; session.sessionInfo.name = updatedInfo.name; logger.debug(`Session ${session.id} name changed from "${oldName}" to "${updatedInfo.name}"`); // Emit event for name change this.trackAndEmit('sessionNameChanged', session.id, updatedInfo.name); // Update title if needed for external terminals if (session.isExternalTerminal && (session.titleMode === types_js_1.TitleMode.STATIC || session.titleMode === types_js_1.TitleMode.DYNAMIC)) { this.markTitleUpdateNeeded(session); } } } catch (error) { // Session file might be deleted, ignore logger.debug(`Failed to read session file for ${session.id}:`, error); } }, 100); // Store interval for cleanup session.sessionJsonInterval = checkInterval; logger.debug(`Session watcher setup for ${session.id}`); } catch (error) { logger.error(`Failed to setup session watcher for ${session.id}:`, error); } } /** * Handle incoming socket messages */ handleSocketMessage(session, type, payload) { try { const data = (0, socket_protocol_js_1.parsePayload)(type, payload); switch (type) { case socket_protocol_js_1.MessageType.STDIN_DATA: { const text = data; if (session.ptyProcess && session.inputQueue) { // Queue input write to prevent race conditions session.inputQueue.enqueue(() => { if (session.ptyProcess) { session.ptyProcess.write(text); } // Record it (non-blocking) session.asciinemaWriter?.writeInput(text); }); } break; } case socket_protocol_js_1.MessageType.CONTROL_CMD: { const cmd = data; this.handleControlMessage(session, cmd); break; } case socket_protocol_js_1.MessageType.STATUS_UPDATE: { const status = data; // Update activity status for the session if (!session.activityStatus) { session.activityStatus = {}; } session.activityStatus.specificStatus = { app: status.app, status: status.status, }; logger.debug(`Updated status for session ${session.id}:`, status); // Broadcast status update to all connected clients if (session.connectedClients && session.connectedClients.size > 0) { const message = (0, socket_protocol_js_1.frameMessage)(socket_protocol_js_1.MessageType.STATUS_UPDATE, status); for (const client of session.connectedClients) { try { client.write(message); } catch (err) { logger.debug(`Failed to broadcast status to client:`, err); } } logger.debug(`Broadcasted status update to ${session.connectedClients.size} clients`); } break; } case socket_protocol_js_1.MessageType.HEARTBEAT: // Heartbeat received - no action needed for now break; default: logger.debug(`Unknown message type ${type} for session ${session.id}`); } } catch (error) { // Don't log the full error object as it might contain buffers or circular references const errorMessage = error instanceof Error ? error.message : String(error); logger.error(`Failed to handle socket message for session ${session.id}: ${errorMessage}`); } } /** * Handle control messages from control pipe */ handleControlMessage(session, message) { if (message.cmd === 'resize' && typeof message.cols === 'number' && typeof message.rows === 'number') { try { if (session.ptyProcess) { session.ptyProcess.resize(message.cols, message.rows); session.asciinemaWriter?.writeResize(message.cols, message.rows); } } catch (error) { logger.warn(`Failed to resize session ${session.id} to ${message.cols}x${message.rows}:`, error); } } else if (message.cmd === 'kill') { const signal = typeof message.signal === 'string' || typeof message.signal === 'number' ? message.signal : 'SIGTERM'; try { if (session.ptyProcess) { session.ptyProcess.kill(signal); } } catch (error) { logger.warn(`Failed to kill session ${session.id} with signal ${signal}:`, error); } } else if (message.cmd === 'reset-size') { try { if (session.ptyProcess) { // Get current terminal size from process.stdout const cols = process.stdout.columns || 80; const rows = process.stdout.rows || 24; session.ptyProcess.resize(cols, rows); session.asciinemaWriter?.writeResize(cols, rows); logger.debug(`Reset session ${session.id} size to terminal size: ${cols}x${rows}`); } } catch (error) { logger.warn(`Failed to reset session ${session.id} size to terminal size:`, error); } } else if (message.cmd === 'update-title' && typeof message.title === 'string') { // Handle title update via IPC (used by vt title command) logger.debug(`[IPC] Received title update for session ${session.id}: "${message.title}"`); logger.debug(`[IPC] Current session name before update: "${session.sessionInfo.name}"`); this.updateSessionName(session.id, message.title); } } /** * Get fish shell completions for a partial command */ async getFishCompletions(sessionId, partial) { try { const session = this.sessions.get(sessionId); if (!session) { return []; } const userShell = process_utils_js_1.ProcessUtils.getUserShell(); if (!fish_handler_js_1.FishHandler.isFishShell(userShell)) { return []; } const { fishHandler } = await Promise.resolve().then(() => __importStar(require('./fish-handler.js'))); const cwd = session.currentWorkingDir || process.cwd(); return await fishHandler.getCompletions(partial, cwd); } catch (error) { logger.warn(`Fish completions failed: ${error}`); return []; } } /** * Send text input to a session */ sendInput(sessionId, input) { try { let dataToSend = ''; if (input.text !== undefined) { dataToSend = input.text; logger.debug(`Received text input: ${JSON.stringify(input.text)} -> sending: ${JSON.stringify(dataToSend)}`); } else if (input.key !== undefined) { dataToSend = this.convertSpecialKey(input.key); logger.debug(`Received special key: "${input.key}" -> converted to: ${JSON.stringify(dataToSend)}`); } else { throw new types_js_2.PtyError('No text or key specified in input', 'INVALID_INPUT'); } // If we have an in-memory session with active PTY, use it const memorySession = this.sessions.get(sessionId); if (memorySession?.ptyProcess && memorySession.inputQueue) { // Queue input write to prevent race conditions memorySession.inputQueue.enqueue(() => { if (memorySession.ptyProcess) { memorySession.ptyProcess.write(dataToSend); } memorySession.asciinemaWriter?.writeInput(dataToSend); // Track directory changes for title modes that need it if ((memorySession.titleMode === types_js_1.TitleMode.STATIC || memorySession.titleMode === types_js_1.TitleMode.DYNAMIC) && input.text) { const newDir = (0, terminal_title_js_1.extractCdDirectory)(input.text, memorySession.currentWorkingDir || memorySession.sessionInfo.workingDir); if (newDir) { memorySess