Skip to main content
Glama
terminal-manager.ts19.8 kB
import { spawn } from 'child_process'; import path from 'path'; import { TerminalSession, CommandExecutionResult, ActiveSession, TimingInfo, OutputEvent } from './types.js'; import { DEFAULT_COMMAND_TIMEOUT } from './config.js'; import { configManager } from './config-manager.js'; import {capture} from "./utils/capture.js"; import { analyzeProcessState } from './utils/process-detection.js'; interface CompletedSession { pid: number; outputLines: string[]; // Line-based buffer (consistent with active sessions) exitCode: number | null; startTime: Date; endTime: Date; } // Result type for paginated output reading export interface PaginatedOutputResult { lines: string[]; totalLines: number; readFrom: number; // Starting line of this read readCount: number; // Number of lines returned remaining: number; // Lines remaining after this read isComplete: boolean; // Whether process has finished exitCode?: number | null; // Exit code if completed runtimeMs?: number; // Runtime in milliseconds (for completed processes) } /** * Configuration for spawning a shell with appropriate flags */ interface ShellSpawnConfig { executable: string; args: string[]; useShellOption: string | boolean; } /** * Get the appropriate spawn configuration for a given shell * This handles login shell flags for different shell types */ function getShellSpawnArgs(shellPath: string, command: string): ShellSpawnConfig { const shellName = path.basename(shellPath).toLowerCase(); // Unix shells with login flag support if (shellName.includes('bash') || shellName.includes('zsh')) { return { executable: shellPath, args: ['-l', '-c', command], useShellOption: false }; } // PowerShell Core (cross-platform, supports -Login) if (shellName === 'pwsh' || shellName === 'pwsh.exe') { return { executable: shellPath, args: ['-Login', '-Command', command], useShellOption: false }; } // Windows PowerShell 5.1 (no login flag support) if (shellName === 'powershell' || shellName === 'powershell.exe') { return { executable: shellPath, args: ['-Command', command], useShellOption: false }; } // CMD if (shellName === 'cmd' || shellName === 'cmd.exe') { return { executable: shellPath, args: ['/c', command], useShellOption: false }; } // Fish shell (uses -l for login, -c for command) if (shellName.includes('fish')) { return { executable: shellPath, args: ['-l', '-c', command], useShellOption: false }; } // Unknown/other shells - use shell option for safety // This provides a fallback for shells we don't explicitly handle return { executable: command, args: [], useShellOption: shellPath }; } export class TerminalManager { private sessions: Map<number, TerminalSession> = new Map(); private completedSessions: Map<number, CompletedSession> = new Map(); /** * Send input to a running process * @param pid Process ID * @param input Text to send to the process * @returns Whether input was successfully sent */ sendInputToProcess(pid: number, input: string): boolean { const session = this.sessions.get(pid); if (!session) { return false; } try { if (session.process.stdin && !session.process.stdin.destroyed) { // Ensure input ends with a newline for most REPLs const inputWithNewline = input.endsWith('\n') ? input : input + '\n'; session.process.stdin.write(inputWithNewline); return true; } return false; } catch (error) { console.error(`Error sending input to process ${pid}:`, error); return false; } } async executeCommand(command: string, timeoutMs: number = DEFAULT_COMMAND_TIMEOUT, shell?: string, collectTiming: boolean = false): Promise<CommandExecutionResult> { // Get the shell from config if not specified let shellToUse: string | boolean | undefined = shell; if (!shellToUse) { try { const config = await configManager.getConfig(); shellToUse = config.defaultShell || true; } catch (error) { // If there's an error getting the config, fall back to default shellToUse = true; } } // For REPL interactions, we need to ensure stdin, stdout, and stderr are properly configured // Note: No special stdio options needed here, Node.js handles pipes by default // Enhance SSH commands automatically let enhancedCommand = command; if (command.trim().startsWith('ssh ') && !command.includes(' -t')) { enhancedCommand = command.replace(/^ssh /, 'ssh -t '); console.log(`Enhanced SSH command: ${enhancedCommand}`); } // Get the appropriate spawn configuration for the shell let spawnConfig: ShellSpawnConfig; let spawnOptions: any; if (typeof shellToUse === 'string') { // Use shell-specific configuration with login flags where appropriate spawnConfig = getShellSpawnArgs(shellToUse, enhancedCommand); spawnOptions = { env: { ...process.env, TERM: 'xterm-256color' // Better terminal compatibility } }; // Add shell option if needed (for unknown shells) if (spawnConfig.useShellOption) { spawnOptions.shell = spawnConfig.useShellOption; } } else { // Boolean or undefined shell - use default shell option behavior spawnConfig = { executable: enhancedCommand, args: [], useShellOption: shellToUse }; spawnOptions = { shell: shellToUse, env: { ...process.env, TERM: 'xterm-256color' } }; } // Spawn the process with appropriate arguments const childProcess = spawn(spawnConfig.executable, spawnConfig.args, spawnOptions); let output = ''; // Ensure childProcess.pid is defined before proceeding if (!childProcess.pid) { // Return a consistent error object instead of throwing return { pid: -1, // Use -1 to indicate an error state output: 'Error: Failed to get process ID. The command could not be executed.', isBlocked: false }; } const session: TerminalSession = { pid: childProcess.pid, process: childProcess, outputLines: [], // Line-based buffer lastReadIndex: 0, // Track where "new" output starts isBlocked: false, startTime: new Date() }; this.sessions.set(childProcess.pid, session); // Timing telemetry const startTime = Date.now(); let firstOutputTime: number | undefined; let lastOutputTime: number | undefined; const outputEvents: OutputEvent[] = []; let exitReason: TimingInfo['exitReason'] = 'timeout'; return new Promise((resolve) => { let resolved = false; let periodicCheck: NodeJS.Timeout | null = null; // Quick prompt patterns for immediate detection const quickPromptPatterns = />>>\s*$|>\s*$|\$\s*$|#\s*$/; const resolveOnce = (result: CommandExecutionResult) => { if (resolved) return; resolved = true; if (periodicCheck) clearInterval(periodicCheck); // Add timing info if requested if (collectTiming) { const endTime = Date.now(); result.timingInfo = { startTime, endTime, totalDurationMs: endTime - startTime, exitReason, firstOutputTime, lastOutputTime, timeToFirstOutputMs: firstOutputTime ? firstOutputTime - startTime : undefined, outputEvents: outputEvents.length > 0 ? outputEvents : undefined }; } resolve(result); }; childProcess.stdout.on('data', (data: any) => { const text = data.toString(); const now = Date.now(); if (!firstOutputTime) firstOutputTime = now; lastOutputTime = now; output += text; // Append to line-based buffer this.appendToLineBuffer(session, text); // Record output event if collecting timing if (collectTiming) { outputEvents.push({ timestamp: now, deltaMs: now - startTime, source: 'stdout', length: text.length, snippet: text.slice(0, 50).replace(/\n/g, '\\n') }); } // Immediate check for obvious prompts if (quickPromptPatterns.test(text)) { session.isBlocked = true; exitReason = 'early_exit_quick_pattern'; if (collectTiming && outputEvents.length > 0) { outputEvents[outputEvents.length - 1].matchedPattern = 'quick_pattern'; } resolveOnce({ pid: childProcess.pid!, output, isBlocked: true }); } }); childProcess.stderr.on('data', (data: any) => { const text = data.toString(); const now = Date.now(); if (!firstOutputTime) firstOutputTime = now; lastOutputTime = now; output += text; // Append to line-based buffer this.appendToLineBuffer(session, text); // Record output event if collecting timing if (collectTiming) { outputEvents.push({ timestamp: now, deltaMs: now - startTime, source: 'stderr', length: text.length, snippet: text.slice(0, 50).replace(/\n/g, '\\n') }); } }); // Periodic comprehensive check every 100ms periodicCheck = setInterval(() => { if (output.trim()) { const processState = analyzeProcessState(output, childProcess.pid); if (processState.isWaitingForInput) { session.isBlocked = true; exitReason = 'early_exit_periodic_check'; resolveOnce({ pid: childProcess.pid!, output, isBlocked: true }); } } }, 100); // Timeout fallback setTimeout(() => { session.isBlocked = true; exitReason = 'timeout'; resolveOnce({ pid: childProcess.pid!, output, isBlocked: true }); }, timeoutMs); childProcess.on('exit', (code: any) => { if (childProcess.pid) { // Store completed session before removing active session this.completedSessions.set(childProcess.pid, { pid: childProcess.pid, outputLines: [...session.outputLines], // Copy line buffer exitCode: code, startTime: session.startTime, endTime: new Date() }); // Keep only last 100 completed sessions if (this.completedSessions.size > 100) { const oldestKey = Array.from(this.completedSessions.keys())[0]; this.completedSessions.delete(oldestKey); } this.sessions.delete(childProcess.pid); } exitReason = 'process_exit'; resolveOnce({ pid: childProcess.pid!, output, isBlocked: false }); }); }); } /** * Append text to a session's line buffer * Handles partial lines and newline splitting */ private appendToLineBuffer(session: TerminalSession, text: string): void { if (!text) return; // Split text into lines, keeping track of whether text ends with newline const lines = text.split('\n'); for (let i = 0; i < lines.length; i++) { const line = lines[i]; const isLastFragment = i === lines.length - 1; const endsWithNewline = text.endsWith('\n'); if (session.outputLines.length === 0) { // First line ever session.outputLines.push(line); } else if (i === 0) { // First fragment - append to last line (might be partial) session.outputLines[session.outputLines.length - 1] += line; } else { // Subsequent lines - add as new lines session.outputLines.push(line); } } } /** * Read process output with pagination (like file reading) * @param pid Process ID * @param offset Line offset: 0=from lastReadIndex, positive=absolute, negative=tail * @param length Max lines to return * @param updateReadIndex Whether to update lastReadIndex (default: true for offset=0) */ readOutputPaginated(pid: number, offset: number = 0, length: number = 1000): PaginatedOutputResult | null { // First check active sessions const session = this.sessions.get(pid); if (session) { return this.readFromLineBuffer( session.outputLines, offset, length, session.lastReadIndex, (newIndex) => { session.lastReadIndex = newIndex; }, false, undefined ); } // Then check completed sessions const completedSession = this.completedSessions.get(pid); if (completedSession) { const runtimeMs = completedSession.endTime.getTime() - completedSession.startTime.getTime(); return this.readFromLineBuffer( completedSession.outputLines, offset, length, 0, // Completed sessions don't track read position () => {}, // No-op for completed sessions true, completedSession.exitCode, runtimeMs ); } return null; } /** * Internal helper to read from a line buffer with offset/length */ private readFromLineBuffer( lines: string[], offset: number, length: number, lastReadIndex: number, updateLastRead: (index: number) => void, isComplete: boolean, exitCode?: number | null, runtimeMs?: number ): PaginatedOutputResult { const totalLines = lines.length; let startIndex: number; let linesToRead: string[]; if (offset < 0) { // Negative offset = start position from end, then read 'length' lines forward // e.g., offset=-50, length=10 means: start 50 lines from end, read 10 lines const fromEnd = Math.abs(offset); startIndex = Math.max(0, totalLines - fromEnd); linesToRead = lines.slice(startIndex, startIndex + length); // Don't update lastReadIndex for tail reads } else if (offset === 0) { // offset=0 means "from where I last read" (like getNewOutput) startIndex = lastReadIndex; linesToRead = lines.slice(startIndex, startIndex + length); // Update lastReadIndex for "new output" behavior updateLastRead(Math.min(startIndex + linesToRead.length, totalLines)); } else { // Positive offset = absolute position startIndex = offset; linesToRead = lines.slice(startIndex, startIndex + length); // Don't update lastReadIndex for absolute position reads } const readCount = linesToRead.length; const endIndex = startIndex + readCount; const remaining = Math.max(0, totalLines - endIndex); return { lines: linesToRead, totalLines, readFrom: startIndex, readCount, remaining, isComplete, exitCode, runtimeMs }; } /** * Get total line count for a process */ getOutputLineCount(pid: number): number | null { const session = this.sessions.get(pid); if (session) { return session.outputLines.length; } const completedSession = this.completedSessions.get(pid); if (completedSession) { return completedSession.outputLines.length; } return null; } /** * Legacy method for backward compatibility * Returns all new output since last read * @param maxLines Maximum lines to return (default: 1000 for context protection) * @deprecated Use readOutputPaginated instead */ getNewOutput(pid: number, maxLines: number = 1000): string | null { const result = this.readOutputPaginated(pid, 0, maxLines); if (!result) return null; const output = result.lines.join('\n').trim(); // For completed sessions, append completion info with runtime if (result.isComplete) { const runtimeStr = result.runtimeMs !== undefined ? `\nRuntime: ${(result.runtimeMs / 1000).toFixed(2)}s` : ''; if (output) { return `${output}\n\nProcess completed with exit code ${result.exitCode}${runtimeStr}`; } else { return `Process completed with exit code ${result.exitCode}${runtimeStr}\n(No output produced)`; } } // Add truncation warning if there's more output if (result.remaining > 0) { return `${output}\n\n[Output truncated: ${result.remaining} more lines available. Use read_process_output with offset/length for full output.]`; } return output || null; } /** * Capture a snapshot of current output state for interaction tracking. * Used by interactWithProcess to know what output existed before sending input. */ captureOutputSnapshot(pid: number): { totalChars: number; lineCount: number } | null { const session = this.sessions.get(pid); if (session) { const fullOutput = session.outputLines.join('\n'); return { totalChars: fullOutput.length, lineCount: session.outputLines.length }; } return null; } /** * Get output that appeared since a snapshot was taken. * This handles the case where output is appended to the last line (REPL prompts). * Also checks completed sessions in case process finished between snapshot and poll. */ getOutputSinceSnapshot(pid: number, snapshot: { totalChars: number; lineCount: number }): string | null { // Check active session first const session = this.sessions.get(pid); if (session) { const fullOutput = session.outputLines.join('\n'); if (fullOutput.length <= snapshot.totalChars) { return ''; // No new output } return fullOutput.substring(snapshot.totalChars); } // Fallback to completed sessions - process may have finished between snapshot and poll const completedSession = this.completedSessions.get(pid); if (completedSession) { const fullOutput = completedSession.outputLines.join('\n'); if (fullOutput.length <= snapshot.totalChars) { return ''; // No new output } return fullOutput.substring(snapshot.totalChars); } return null; } /** * Get a session by PID * @param pid Process ID * @returns The session or undefined if not found */ getSession(pid: number): TerminalSession | undefined { return this.sessions.get(pid); } forceTerminate(pid: number): boolean { const session = this.sessions.get(pid); if (!session) { return false; } try { session.process.kill('SIGINT'); setTimeout(() => { if (this.sessions.has(pid)) { session.process.kill('SIGKILL'); } }, 1000); return true; } catch (error) { // Convert error to string, handling both Error objects and other types const errorMessage = error instanceof Error ? error.message : String(error); capture('server_request_error', {error: errorMessage, message: `Failed to terminate process ${pid}:`}); return false; } } listActiveSessions(): ActiveSession[] { const now = new Date(); return Array.from(this.sessions.values()).map(session => ({ pid: session.pid, isBlocked: session.isBlocked, runtime: now.getTime() - session.startTime.getTime() })); } listCompletedSessions(): CompletedSession[] { return Array.from(this.completedSessions.values()); } } export const terminalManager = new TerminalManager();

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/wonderwhy-er/ClaudeComputerCommander'

If you have feedback or need assistance with the MCP directory API, please join our Discord server