UNPKG

interm-mcp

Version:

MCP server for terminal applications and TUI automation with 127 tools

298 lines (297 loc) 12.2 kB
import * as pty from 'node-pty'; import { v4 as uuidv4 } from 'uuid'; import { createTerminalError, handleError, isValidShell } from './utils/error-utils.js'; export class TerminalManager { static instance; sessions = new Map(); constructor() { // Set up cleanup on process exit process.on('exit', () => this.cleanup()); process.on('SIGINT', () => this.cleanup()); process.on('SIGTERM', () => this.cleanup()); } static getInstance() { if (!TerminalManager.instance) { TerminalManager.instance = new TerminalManager(); } return TerminalManager.instance; } async createSession(cols = 80, rows = 24, shell = process.platform === 'win32' ? 'powershell.exe' : process.env.SHELL || '/bin/bash', workingDirectory = process.cwd()) { try { if (!isValidShell(shell)) { throw createTerminalError('INVALID_SHELL', `Invalid shell: ${shell}`); } const id = uuidv4(); const ptyProcess = pty.spawn(shell, [], { name: 'xterm-color', cols, rows, cwd: workingDirectory, env: process.env }); const session = { id, pid: ptyProcess.pid, cols, rows, shell, workingDirectory, createdAt: new Date(), lastActivity: new Date() }; const sessionData = { session, ptyProcess, outputBuffer: '', lastOutput: '', dataListener: undefined, exitListener: undefined }; // Set up data handler const dataListener = (data) => { sessionData.outputBuffer += data; sessionData.lastOutput = data; sessionData.session.lastActivity = new Date(); }; sessionData.dataListener = dataListener; ptyProcess.onData(dataListener); // Set up exit handler const exitListener = ({ exitCode }) => { console.log(`Terminal session ${id} exited with code ${exitCode}`); this.cleanupSessionListeners(id); this.sessions.delete(id); }; sessionData.exitListener = exitListener; ptyProcess.onExit(exitListener); this.sessions.set(id, sessionData); // Wait a moment for shell to initialize await new Promise(resolve => setTimeout(resolve, 100)); return session; } catch (error) { throw handleError(error, 'Failed to create terminal session'); } } async executeCommand(sessionId, command, timeout = 60000, expectOutput = true) { const sessionData = this.sessions.get(sessionId); if (!sessionData) { throw createTerminalError('SESSION_NOT_FOUND', `Session ${sessionId} not found`); } const startTime = Date.now(); const startBuffer = sessionData.outputBuffer; try { // Clear previous output sessionData.outputBuffer = ''; // Send command sessionData.ptyProcess.write(command + '\r'); if (!expectOutput) { return { output: '', exitCode: null, duration: Date.now() - startTime, command, timestamp: new Date() }; } // For interactive commands (like boxmux), don't wait for completion if (this.isInteractiveCommand(command)) { // Wait a short time to capture initial output, then return await new Promise(resolve => setTimeout(resolve, 2000)); return { output: sessionData.outputBuffer.trim(), exitCode: null, duration: Date.now() - startTime, command, timestamp: new Date(), isInteractive: true }; } // Wait for command to complete return new Promise((resolve, reject) => { let timeoutId = null; // Only set timeout if specified (0 means no timeout) if (timeout > 0) { timeoutId = setTimeout(() => { reject(createTerminalError('TIMEOUT_ERROR', `Command timed out after ${timeout}ms`)); }, timeout); } const checkOutput = () => { const output = sessionData.outputBuffer.substring(startBuffer.length); // Simple heuristic: command is done if we see a new prompt // This is basic and might need refinement based on shell type if (output.includes('$ ') || output.includes('# ') || output.includes('> ') || output.includes('% ') || output.includes('❯ ')) { if (timeoutId) clearTimeout(timeoutId); resolve({ output: output.trim(), exitCode: null, // PTY doesn't easily provide exit codes for individual commands duration: Date.now() - startTime, command, timestamp: new Date() }); } else { setTimeout(checkOutput, 100); } }; setTimeout(checkOutput, 100); }); } catch (error) { throw handleError(error, `Failed to execute command: ${command}`); } } isInteractiveCommand(command) { // List of commands that are typically interactive/long-running const interactiveCommands = [ 'boxmux', 'tmux', 'screen', 'vim', 'emacs', 'nano', 'htop', 'top', 'less', 'more', 'man', 'ssh', 'telnet', 'docker run', 'kubectl' ]; return interactiveCommands.some(cmd => command.trim().startsWith(cmd)); } async sendInput(sessionId, input) { const sessionData = this.sessions.get(sessionId); if (!sessionData) { throw createTerminalError('SESSION_NOT_FOUND', `Session ${sessionId} not found`); } try { sessionData.ptyProcess.write(input); sessionData.session.lastActivity = new Date(); } catch (error) { throw handleError(error, `Failed to send input to session ${sessionId}`); } } async getTerminalContent(sessionId, options = {}) { const sessionData = this.sessions.get(sessionId); if (!sessionData) { throw createTerminalError('SESSION_NOT_FOUND', `Session ${sessionId} not found`); } let content = sessionData.outputBuffer; const lines = content.split('\n'); const totalLines = lines.length; let truncated = false; // Apply line limit if specified if (options.lastNLines && options.lastNLines < lines.length) { content = lines.slice(-options.lastNLines).join('\n'); truncated = true; } // Apply token limit if specified (rough estimate: 4 chars per token) const maxTokens = options.maxTokens || 20000; const estimatedTokens = content.length / 4; if (estimatedTokens > maxTokens) { const maxChars = maxTokens * 4; content = content.slice(-maxChars); truncated = true; } // Strip ANSI sequences if formatting not requested if (!options.includeFormatting) { content = this.stripAnsiSequences(content); } return { content, truncated, totalLines }; } stripAnsiSequences(text) { // Remove ANSI escape sequences return text .replace(/\x1b\[[0-9;]*[A-Za-z]/g, '') // Standard ANSI escape sequences .replace(/\x1b\][0-9]*;[^\x07]*\x07/g, '') // OSC sequences .replace(/\x1b[PX^_][^\x1b]*\x1b\\/g, '') // String terminators .replace(/\x1b./g, ''); // Any remaining escape sequences } async getTerminalState(sessionId) { const sessionData = this.sessions.get(sessionId); if (!sessionData) { throw createTerminalError('SESSION_NOT_FOUND', `Session ${sessionId} not found`); } // This is a simplified version - in a real implementation, // you might want to parse ANSI sequences for cursor position and formatting return { content: sessionData.outputBuffer, cursor: { x: 0, y: 0, visible: true }, dimensions: { cols: sessionData.session.cols, rows: sessionData.session.rows }, attributes: [] // Would parse ANSI formatting in real implementation }; } async resizeSession(sessionId, cols, rows) { const sessionData = this.sessions.get(sessionId); if (!sessionData) { throw createTerminalError('SESSION_NOT_FOUND', `Session ${sessionId} not found`); } try { sessionData.ptyProcess.resize(cols, rows); sessionData.session.cols = cols; sessionData.session.rows = rows; sessionData.session.lastActivity = new Date(); } catch (error) { throw handleError(error, `Failed to resize session ${sessionId}`); } } async closeSession(sessionId) { const sessionData = this.sessions.get(sessionId); if (!sessionData) { throw createTerminalError('SESSION_NOT_FOUND', `Session ${sessionId} not found`); } try { this.cleanupSessionListeners(sessionId); sessionData.ptyProcess.kill(); this.sessions.delete(sessionId); } catch (error) { throw handleError(error, `Failed to close session ${sessionId}`); } } async recoverSession(sessionId) { const sessionData = this.sessions.get(sessionId); if (!sessionData) { throw createTerminalError('SESSION_NOT_FOUND', `Session ${sessionId} not found`); } try { // Send interrupt to stop any stuck processes sessionData.ptyProcess.write('\x03'); // Ctrl+C await new Promise(resolve => setTimeout(resolve, 1000)); // Clear the buffer to start fresh sessionData.outputBuffer = ''; sessionData.lastOutput = ''; // Send a newline to get a fresh prompt sessionData.ptyProcess.write('\r'); console.log(`Session ${sessionId} recovered successfully`); } catch (error) { throw handleError(error, `Failed to recover session ${sessionId}`); } } cleanupSessionListeners(sessionId) { const sessionData = this.sessions.get(sessionId); if (sessionData) { // Clear listener references to prevent memory leaks // Note: node-pty doesn't expose removeListener, so we just clear references sessionData.dataListener = undefined; sessionData.exitListener = undefined; } } getSession(sessionId) { return this.sessions.get(sessionId)?.session; } getAllSessions() { return Array.from(this.sessions.values()).map(data => data.session); } async cleanup() { console.log(`Cleaning up ${this.sessions.size} terminal sessions...`); const promises = Array.from(this.sessions.keys()).map(sessionId => this.closeSession(sessionId).catch(console.error)); await Promise.all(promises); console.log('Terminal cleanup complete'); } }