UNPKG

@wonderwhy-er/desktop-commander

Version:

MCP server for terminal operations and file editing

365 lines (364 loc) 13.6 kB
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; } }