interm-mcp
Version:
MCP server for terminal applications and TUI automation with 127 tools
303 lines (302 loc) • 12.1 kB
JavaScript
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();
}
}