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