UNPKG

interm-mcp

Version:

MCP server for terminal applications and TUI automation with 127 tools

303 lines (302 loc) 12.1 kB
import * as pty from 'node-pty'; import { v4 as uuidv4 } from 'uuid'; import { createTerminalError, handleError, isValidShell } from './utils/error-utils.js'; import { EnvironmentManager } from './environment-manager.js'; export class SessionManager { static instance; sessions = new Map(); environmentManager; constructor() { this.environmentManager = EnvironmentManager.getInstance(); } static getInstance() { if (!SessionManager.instance) { SessionManager.instance = new SessionManager(); } return SessionManager.instance; } async createSession(cols = 80, rows = 24, shell = process.platform === 'win32' ? 'powershell.exe' : process.env.SHELL || '/bin/bash', workingDirectory = process.cwd(), environment, title) { try { if (!isValidShell(shell)) { throw createTerminalError('INVALID_SHELL', `Invalid shell: ${shell}`); } const id = uuidv4(); const sessionEnv = { ...process.env, ...(environment || {}) }; const ptyProcess = pty.spawn(shell, [], { name: 'xterm-color', cols, rows, cwd: workingDirectory, env: sessionEnv }); const session = { id, pid: ptyProcess.pid, cols, rows, shell, workingDirectory, createdAt: new Date(), lastActivity: new Date(), environment, title }; const sessionData = { session, ptyProcess, outputBuffer: '', lastOutput: '', history: [], bookmarks: new Map() }; // Set up data handler ptyProcess.onData((data) => { sessionData.outputBuffer += data; sessionData.lastOutput = data; sessionData.session.lastActivity = new Date(); // Add to history if it looks like a command completion if (data.includes('\n') && (data.includes('$ ') || data.includes('> ') || data.includes('% '))) { sessionData.history.push(data); // Keep history manageable if (sessionData.history.length > 1000) { sessionData.history = sessionData.history.slice(-500); } } }); // Set up exit handler ptyProcess.onExit(({ exitCode }) => { console.log(`Terminal session ${id} exited with code ${exitCode}`); this.environmentManager.clearSessionEnvironment(id); this.sessions.delete(id); }); this.sessions.set(id, sessionData); // Set initial environment variables if provided if (environment) { await this.environmentManager.mergeEnvironments(id, environment); } // Set terminal title if provided if (title) { await this.setTerminalTitle(id, title); } // 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 saveBookmark(sessionId, name) { const sessionData = this.sessions.get(sessionId); if (!sessionData) { throw createTerminalError('SESSION_NOT_FOUND', `Session ${sessionId} not found`); } try { const terminalState = await this.getTerminalState(sessionId); sessionData.bookmarks.set(name, { content: terminalState.content, cursor: terminalState.cursor, timestamp: new Date() }); } catch (error) { throw handleError(error, `Failed to save bookmark ${name}`); } } async restoreBookmark(sessionId, name) { const sessionData = this.sessions.get(sessionId); if (!sessionData) { throw createTerminalError('SESSION_NOT_FOUND', `Session ${sessionId} not found`); } const bookmark = sessionData.bookmarks.get(name); if (!bookmark) { throw createTerminalError('RESOURCE_ERROR', `Bookmark ${name} not found`); } try { // Clear current content and restore bookmark content await this.sendInput(sessionId, '\u001b[2J\u001b[H'); // Clear screen and move to home await this.sendInput(sessionId, bookmark.content); } catch (error) { throw handleError(error, `Failed to restore bookmark ${name}`); } } getSessionHistory(sessionId, limit) { const sessionData = this.sessions.get(sessionId); if (!sessionData) { throw createTerminalError('SESSION_NOT_FOUND', `Session ${sessionId} not found`); } const history = sessionData.history; return limit ? history.slice(-limit) : history; } searchHistory(sessionId, pattern, isRegex = false) { const sessionData = this.sessions.get(sessionId); if (!sessionData) { throw createTerminalError('SESSION_NOT_FOUND', `Session ${sessionId} not found`); } try { const searchRegex = isRegex ? new RegExp(pattern, 'gi') : new RegExp(pattern.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'), 'gi'); return sessionData.history.filter(entry => searchRegex.test(entry)); } catch (error) { throw handleError(error, 'Invalid search pattern'); } } async setTerminalTitle(sessionId, title) { const sessionData = this.sessions.get(sessionId); if (!sessionData) { throw createTerminalError('SESSION_NOT_FOUND', `Session ${sessionId} not found`); } try { // Send ANSI escape sequence to set terminal title const titleSequence = `\u001b]0;${title}\u0007`; sessionData.ptyProcess.write(titleSequence); sessionData.session.title = title; sessionData.session.lastActivity = new Date(); } catch (error) { throw handleError(error, `Failed to set terminal title to ${title}`); } } async executeCommand(sessionId, command, timeout = 30000, 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() }; } // Wait for command to complete with improved timeout handling return new Promise((resolve, reject) => { let resolved = false; const timeoutId = setTimeout(() => { if (!resolved) { resolved = true; reject(createTerminalError('TIMEOUT_ERROR', `Command timed out after ${timeout}ms`)); } }, timeout); const checkOutput = () => { if (resolved) return; const output = sessionData.outputBuffer.substring(startBuffer.length); // Improved prompt detection if (output.includes('$ ') || output.includes('# ') || output.includes('> ') || output.includes('% ') || output.includes('❯ ') || output.match(/\n.*[@$#%>]\s*$/)) { resolved = true; clearTimeout(timeoutId); resolve({ output: output.trim(), exitCode: null, 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}`); } } 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 getTerminalState(sessionId) { const sessionData = this.sessions.get(sessionId); if (!sessionData) { throw createTerminalError('SESSION_NOT_FOUND', `Session ${sessionId} not found`); } return { content: sessionData.outputBuffer, cursor: { x: 0, y: 0, visible: true }, dimensions: { cols: sessionData.session.cols, rows: sessionData.session.rows }, attributes: [] }; } 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 { await this.environmentManager.clearSessionEnvironment(sessionId); sessionData.ptyProcess.kill(); this.sessions.delete(sessionId); } catch (error) { throw handleError(error, `Failed to close session ${sessionId}`); } } getSession(sessionId) { return this.sessions.get(sessionId)?.session; } getAllSessions() { return Array.from(this.sessions.values()).map(data => data.session); } getBookmarks(sessionId) { const sessionData = this.sessions.get(sessionId); if (!sessionData) { throw createTerminalError('SESSION_NOT_FOUND', `Session ${sessionId} not found`); } return Array.from(sessionData.bookmarks.entries()).map(([name, bookmark]) => ({ name, timestamp: bookmark.timestamp })); } async cleanup() { const promises = Array.from(this.sessions.keys()).map(sessionId => this.closeSession(sessionId).catch(console.error)); await Promise.all(promises); await this.environmentManager.cleanup(); } }