@wonderwhy-er/desktop-commander
Version:
MCP server for terminal operations and file editing
365 lines (364 loc) • 13.6 kB
JavaScript
import * as os from 'os';
export class REPLSessionManager {
constructor(terminalManager) {
this.sessions = new Map();
this.terminalManager = terminalManager;
this.defaultPromptPatterns = {
python: /^(>>>|\.\.\.) /m,
node: /> $/m,
bash: /[\w\d\-_]+@[\w\d\-_]+:.*[$#] $/m,
ssh: /[\w\d\-_]+@[\w\d\-_]+:.*[$#] $/m
};
}
/**
* Create a new SSH session
* @param host - SSH host to connect to
* @param options - SSH connection options
* @returns PID of the created SSH session
*/
async createSSHSession(host, options = {}) {
if (!host) {
throw new Error('SSH host is required');
}
const username = options.username || os.userInfo().username;
const port = options.port || 22;
let sshCommand = `ssh ${username}@${host}`;
// Add optional parameters
if (port !== 22) {
sshCommand += ` -p ${port}`;
}
if (options.identity) {
sshCommand += ` -i "${options.identity}"`;
}
// Start the SSH process
const result = await this.terminalManager.executeCommand(sshCommand, { timeout: options.timeout || 10000 });
if (!result || result.pid <= 0) {
throw new Error(`Failed to start SSH session to ${host}`);
}
// Handle password prompt if needed
if (options.password) {
// Wait for password prompt
let output = "";
const startTime = Date.now();
const passwordPromptTimeout = 5000;
while (Date.now() - startTime < passwordPromptTimeout) {
const newOutput = this.terminalManager.getNewOutput(result.pid);
if (newOutput && newOutput.length > 0) {
output += newOutput;
if (output.toLowerCase().includes('password:')) {
// Send password
this.terminalManager.sendInputToProcess(result.pid, options.password + '\n');
break;
}
}
await new Promise(resolve => setTimeout(resolve, 100));
}
}
// Store session info
this.sessions.set(result.pid, {
type: 'ssh',
host,
username,
pid: result.pid,
startTime: Date.now(),
lastActivity: Date.now()
});
return result.pid;
}
/**
* Create a new REPL session for a specific language
* @param language - Language for the REPL (python, node, bash)
* @param options - Configuration options
* @returns PID of the created session
*/
async createSession(language, options = {}) {
// Handle SSH sessions separately
if (language.toLowerCase() === 'ssh') {
return this.createSSHSession(options.host, options);
}
let command;
let args = [];
switch (language.toLowerCase()) {
case 'python':
command = process.platform === 'win32' ? 'python' : 'python3';
args = ['-i'];
break;
case 'node':
command = 'node';
break;
case 'bash':
command = process.platform === 'win32' ? 'cmd' : 'bash';
break;
default:
throw new Error(`Unsupported language: ${language}`);
}
// Start the process
const result = await this.terminalManager.executeCommand(command, { args, timeout: options.timeout || 5000 });
if (!result || result.pid <= 0) {
throw new Error(`Failed to start ${language} REPL`);
}
// Store session info
this.sessions.set(result.pid, {
language,
pid: result.pid,
startTime: Date.now(),
lastActivity: Date.now()
});
return result.pid;
}
/**
* Execute code in an existing REPL session
* @param pid - Process ID of the REPL session
* @param code - Code to execute
* @param options - Execution options
* @returns Results including output and status
*/
async executeCode(pid, code, options = {}) {
const session = this.sessions.get(pid);
if (!session) {
throw new Error(`No active session with PID ${pid}`);
}
// Calculate timeout based on code complexity if not specified
const timeout = options.timeout || this.calculateTimeout(code);
// Handle multi-line code
if (code.includes('\n')) {
return this.handleMultilineCode(pid, code, session.language || 'bash', timeout);
}
else {
return this.sendAndReadREPL(pid, code, session.language || 'bash', timeout);
}
}
/**
* Send input to a REPL process and wait for output with timeout
* @param pid - Process ID
* @param input - Input to send
* @param language - REPL language
* @param timeoutMs - Timeout in milliseconds
* @returns Result object with output and status
*/
async sendAndReadREPL(pid, input, language, timeoutMs = 3000) {
// Send the input with newline if not already present
const inputToSend = input.endsWith('\n') ? input : input + '\n';
const success = this.terminalManager.sendInputToProcess(pid, inputToSend);
if (!success) {
return {
success: false,
output: null,
error: "Failed to send input to process"
};
}
// Wait for output with timeout
let output = "";
const startTime = Date.now();
// Keep checking for output until timeout
while (Date.now() - startTime < timeoutMs) {
const newOutput = this.terminalManager.getNewOutput(pid);
if (newOutput && newOutput.length > 0) {
output += newOutput;
// Check if output is complete (using prompt detection)
if (this.isOutputComplete(output, language)) {
break;
}
}
await new Promise(resolve => setTimeout(resolve, 100));
}
// Update last activity time
if (this.sessions.has(pid)) {
const session = this.sessions.get(pid);
if (session) {
session.lastActivity = Date.now();
}
}
// Check for errors
const error = this.detectErrors(output, language);
return {
success: true,
output: this.cleanOutput(output, input, language),
timeout: Date.now() - startTime >= timeoutMs,
error: error
};
}
/**
* Handle multi-line code input for different languages
* @param pid - Process ID
* @param code - Multi-line code
* @param language - REPL language
* @param timeout - Timeout in milliseconds
* @returns Result object
*/
async handleMultilineCode(pid, code, language, timeout) {
const lines = code.split('\n');
let isBlock = false;
let fullOutput = '';
// For Python, we need to handle indentation carefully
if (language.toLowerCase() === 'python') {
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const isLastLine = i === lines.length - 1;
// Send line and wait for prompt
const result = await this.sendAndReadREPL(pid, line, language,
// If it's the last line and potentially ending a block, wait longer
isLastLine && isBlock ? timeout : Math.min(1000, timeout));
fullOutput += result.output || '';
// Check if we're in a block (Python indentation)
if (line.trim() && (line.startsWith(' ') || line.startsWith('\t'))) {
isBlock = true;
}
else if (line.trim() === '' && isBlock) {
// Empty line ends a block
isBlock = false;
}
else if (line.trim() && !line.startsWith(' ') && !line.startsWith('\t')) {
// Non-indented, non-empty line
isBlock = false;
}
// Handle errors early
if (result.error) {
return {
success: false,
output: fullOutput,
error: result.error
};
}
}
// Ensure block is closed with empty line if needed
if (isBlock) {
const finalResult = await this.sendAndReadREPL(pid, '', language, timeout);
fullOutput += finalResult.output || '';
}
}
else {
// For other languages, send the entire block at once
return await this.sendAndReadREPL(pid, code, language, timeout);
}
return {
success: true,
output: fullOutput,
error: this.detectErrors(fullOutput, language)
};
}
/**
* Detect if the REPL output is complete and ready for next input
* @param output - Current output
* @param language - REPL language or session type
* @returns Whether output is complete
*/
isOutputComplete(output, language) {
const pattern = this.defaultPromptPatterns[language.toLowerCase()];
if (!pattern)
return true; // If no pattern, assume complete
return pattern.test(output);
}
/**
* Calculate appropriate timeout based on code complexity
* @param code - Code to analyze
* @returns Timeout in milliseconds
*/
calculateTimeout(code) {
// Base timeout
let timeout = 2000;
// Add time for loops
const loopCount = (code.match(/for|while/g) || []).length;
timeout += loopCount * 1000;
// Add time for imports or requires
const importCount = (code.match(/import|require/g) || []).length;
timeout += importCount * 2000;
// Add time based on code length
timeout += Math.min(code.length * 5, 5000);
// Cap at reasonable maximum
return Math.min(timeout, 30000);
}
/**
* Detect errors in REPL output
* @param output - REPL output
* @param language - REPL language
* @returns Detected error or null
*/
detectErrors(output, language) {
const errorPatterns = {
python: /\b(Error|Exception|SyntaxError|ValueError|TypeError)\b:.*$/m,
node: /\b(Error|SyntaxError|TypeError|ReferenceError)\b:.*$/m,
bash: /\b(command not found|No such file or directory)\b/m
};
const pattern = errorPatterns[language.toLowerCase()];
if (!pattern)
return null;
const match = output.match(pattern);
return match ? match[0] : null;
}
/**
* Clean and format REPL output
* @param output - Raw output
* @param input - Input that was sent
* @param language - REPL language
* @returns Cleaned output
*/
cleanOutput(output, input, language) {
// Remove echoed input if present
let cleaned = output;
// Remove the input echo that might appear in the output
const inputWithoutNewlines = input.replace(/\n/g, '');
if (inputWithoutNewlines.length > 0) {
cleaned = cleaned.replace(inputWithoutNewlines, '');
}
// Remove common prompt patterns
if (language.toLowerCase() === 'python') {
cleaned = cleaned.replace(/^(>>>|\.\.\.) /mg, '');
}
else if (language.toLowerCase() === 'node') {
cleaned = cleaned.replace(/^> /mg, '');
}
// Trim whitespace
cleaned = cleaned.trim();
return cleaned;
}
/**
* List all active REPL sessions
* @returns List of session objects
*/
listSessions() {
const result = [];
this.sessions.forEach((session, pid) => {
result.push({
pid,
language: session.language,
startTime: session.startTime,
lastActivity: session.lastActivity,
idleTime: Date.now() - session.lastActivity
});
});
return result;
}
/**
* Close a specific REPL session
* @param pid - Process ID to close
* @returns Success status
*/
async closeSession(pid) {
if (!this.sessions.has(pid)) {
return false;
}
const success = await this.terminalManager.terminateProcess(pid);
if (success) {
this.sessions.delete(pid);
}
return success;
}
/**
* Close all idle sessions older than specified time
* @param maxIdleMs - Maximum idle time in milliseconds
* @returns Number of closed sessions
*/
async closeIdleSessions(maxIdleMs = 30 * 60 * 1000) {
let closedCount = 0;
for (const [pid, session] of this.sessions.entries()) {
const idleTime = Date.now() - session.lastActivity;
if (idleTime > maxIdleMs) {
const success = await this.closeSession(pid);
if (success)
closedCount++;
}
}
return closedCount;
}
}