repl-mcp
Version:
Universal REPL session manager MCP server
681 lines (674 loc) • 29.2 kB
JavaScript
import * as nodePty from 'node-pty';
import { PromptDetector } from './prompt-detector.js';
import * as os from 'os';
import * as fs from 'fs';
import pkg from '@xterm/xterm';
import serializePkg from '@xterm/addon-serialize';
const Terminal = pkg.Terminal;
const SerializeAddon = serializePkg.SerializeAddon;
export class SessionManager {
sessions = new Map();
outputBuffers = new Map(); // Complete output buffers (cleared before each command)
sessionLogs = new Map(); // Session-specific logs
globalLogs = []; // Global logs (server events)
serverTerminals = new Map(); // Server-side xterm.js instances
serializeAddons = new Map(); // Serialize addons for each terminal
MAX_LOGS_PER_SESSION = 50; // Limit logs per session
MAX_GLOBAL_LOGS = 100; // Limit global logs
MAX_OUTPUT_SIZE = 50 * 1024; // 50KB limit for MCP responses
MAX_HISTORY_SIZE = 10; // Limit history to last 10 commands
truncateForMCPResponse(output) {
if (output.length <= this.MAX_OUTPUT_SIZE) {
return output;
}
const keepSize = Math.floor(this.MAX_OUTPUT_SIZE * 0.8); // Keep 80% of max size
return '[...output truncated. Total length: ' + output.length + ' chars, showing last ' + keepSize + ' chars...]\n' +
output.slice(-keepSize);
}
log(message, sessionId) {
const timestamp = new Date().toISOString();
const logEntry = `${timestamp}: ${message}`;
if (sessionId) {
// Session-specific log
if (!this.sessionLogs.has(sessionId)) {
this.sessionLogs.set(sessionId, []);
}
const sessionLogArray = this.sessionLogs.get(sessionId);
sessionLogArray.push(logEntry);
// Rotate session logs if too many
if (sessionLogArray.length > this.MAX_LOGS_PER_SESSION) {
sessionLogArray.splice(0, sessionLogArray.length - this.MAX_LOGS_PER_SESSION);
}
}
else {
// Global log
this.globalLogs.push(logEntry);
// Rotate global logs if too many
if (this.globalLogs.length > this.MAX_GLOBAL_LOGS) {
this.globalLogs.splice(0, this.globalLogs.length - this.MAX_GLOBAL_LOGS);
}
}
console.error(logEntry); // Also log to console.error for immediate visibility
}
getDebugLogs(sessionId) {
if (sessionId) {
// Return session-specific logs only
const sessionLogArray = this.sessionLogs.get(sessionId) || [];
return sessionLogArray;
}
else {
// Return only recent global logs to avoid token overflow
return this.globalLogs.slice(-20); // Last 20 global logs only
}
}
clearDebugLogs(sessionId) {
if (sessionId) {
this.sessionLogs.delete(sessionId);
}
else {
this.sessionLogs.clear();
this.globalLogs = [];
}
}
getFullOutput(sessionId, offset = 0, limit = 40000) {
const fullOutput = this.outputBuffers.get(sessionId);
if (!fullOutput) {
return {
success: false,
error: `No output buffer found for session ${sessionId}`
};
}
const totalLength = fullOutput.length;
const endPos = Math.min(offset + limit, totalLength);
const outputChunk = fullOutput.slice(offset, endPos);
const actualLength = outputChunk.length;
const hasMore = endPos < totalLength;
const nextOffset = hasMore ? endPos : undefined;
return {
success: true,
output: outputChunk,
totalLength,
offset,
length: actualLength,
hasMore,
nextOffset
};
}
getLogStats() {
const totalLogs = Array.from(this.sessionLogs.values()).reduce((sum, logs) => sum + logs.length, 0);
return {
totalSessions: this.sessionLogs.size,
totalLogs: totalLogs,
globalLogs: this.globalLogs.length
};
}
async createSession(config, displayName) {
const sessionId = this.generateSessionId();
const startingDir = config.startingDirectory || process.cwd();
// Create session state first, so sessionId always exists
const sessionState = {
id: sessionId,
config,
displayName,
status: 'initializing',
currentDirectory: startingDir,
history: [],
lastOutput: '',
createdAt: new Date(),
lastActivity: new Date(),
learnedPromptPatterns: []
};
this.sessions.set(sessionId, sessionState);
this.outputBuffers.set(sessionId, '');
// Validate starting directory exists
try {
const stats = fs.statSync(startingDir);
if (!stats.isDirectory()) {
sessionState.status = 'error';
sessionState.lastError = `Starting directory is not a directory: ${startingDir}`;
return {
success: false,
sessionId,
error: `Starting directory is not a directory: ${startingDir}`
};
}
}
catch (error) {
sessionState.status = 'error';
sessionState.lastError = `Starting directory does not exist or is not accessible: ${startingDir}`;
return {
success: false,
sessionId,
error: `Starting directory does not exist or is not accessible: ${startingDir}`
};
}
this.log(`[DEBUG ${sessionId}] Starting session creation for ${config.type}`, sessionId);
try {
// Create shell process
this.log(`[DEBUG ${sessionId}] Creating shell process`, sessionId);
const shellProcess = this.createShellProcess(config, startingDir);
sessionState.process = shellProcess;
// Send zsh-specific initialization immediately after process creation
if (config.shell === 'zsh') {
this.log(`[DEBUG ${sessionId}] Sending histchars initialization for zsh`, sessionId);
shellProcess.write('histchars=\r\n');
}
// Create server-side terminal
this.log(`[DEBUG ${sessionId}] Creating server-side terminal`, sessionId);
this.createServerSideTerminal(sessionId);
// Setup output handlers
this.log(`[DEBUG ${sessionId}] Setting up output handlers`, sessionId);
this.setupOutputHandlers(sessionId, shellProcess);
// Wait for shell to be ready (with LLM fallback)
this.log(`[DEBUG ${sessionId}] Waiting for shell to be ready`, sessionId);
try {
await this.waitForShellReady(sessionId);
}
catch (error) {
// Try LLM-assisted prompt detection
this.log(`[DEBUG ${sessionId}] Standard prompt detection failed, trying LLM fallback`, sessionId);
const result = await this.waitForPromptWithLLMFallback(sessionId, 10000);
if (!result.success) {
// If LLM fallback returns a question, this is a different kind of error
if (result.question) {
throw new Error(`Session needs LLM assistance: ${result.question}`);
}
throw new Error(result.error || 'Shell initialization failed');
}
// LLM fallback succeeded, continue with session creation
}
// Execute commands in order
this.log(`[DEBUG ${sessionId}] Executing commands: ${config.commands.length}`, sessionId);
for (let i = 0; i < config.commands.length; i++) {
const command = config.commands[i];
const isLastCommand = i === config.commands.length - 1;
if (isLastCommand) {
this.log(`[DEBUG ${sessionId}] Starting REPL: ${command}`, sessionId);
await this.startREPL(sessionId, command);
}
else {
this.log(`[DEBUG ${sessionId}] Executing setup command: ${command}`, sessionId);
await this.executeSetupCommand(sessionId, command);
}
}
sessionState.status = 'ready';
sessionState.lastActivity = new Date();
this.log(`[DEBUG ${sessionId}] Session ready`, sessionId);
return {
success: true,
sessionId
};
}
catch (error) {
this.log(`[DEBUG ${sessionId}] Session creation failed: ${error}`, sessionId);
sessionState.status = 'error';
sessionState.lastError = error instanceof Error ? error.message : String(error);
// Check if this is a timeout error that could benefit from LLM assistance
const errorMessage = error instanceof Error ? error.message : String(error);
if (errorMessage.includes('timeout')) {
const rawOutput = this.outputBuffers.get(sessionId) || '';
this.log(`[DEBUG ${sessionId}] Timeout detected, offering LLM assistance`, sessionId);
return {
success: false,
sessionId,
question: `Session creation timed out. Here's the raw output - please analyze and respond:
Raw output:
"""
${rawOutput}
"""
Timeout error: ${errorMessage}
Please respond with one of:
- READY:{pattern} - if you see a working prompt, specify the pattern
- SEND:{command} - if a command should be sent (e.g., \\n for Enter, \\x03 for Ctrl+C)
- WAIT:{seconds} - if we should wait longer (specify number of seconds)
- FAILED:{reason} - if this should be considered a failure`,
questionType: 'session_timeout',
context: {
sessionId,
rawOutput,
timeoutError: errorMessage
},
canContinue: true
};
}
// For non-timeout errors, just return failure
return {
success: false,
sessionId,
error: errorMessage
};
}
}
async sendInput(sessionId, input, options = {}) {
const { wait_for_prompt = false, timeout = 30000, add_newline = true } = options;
const session = this.sessions.get(sessionId);
if (!session) {
throw new Error(`Session ${sessionId} not found`);
}
if (!session.process) {
throw new Error(`Session ${sessionId} has no active process`);
}
// 実行中でも入力を受け付ける(より柔軟に)
if (wait_for_prompt) {
session.status = 'executing';
}
session.lastActivity = new Date();
const startTime = Date.now();
try {
if (wait_for_prompt) {
// Clear output buffer before sending command
this.outputBuffers.set(sessionId, '');
}
// Send input with proper line ending
const text = add_newline ? input + '\r' : input;
this.log(`[sendInput] Writing to PTY: ${JSON.stringify(text)} (length: ${text.length})`, sessionId);
session.process.write(text);
if (!wait_for_prompt) {
// Immediate return for interactive input
return {
success: true,
rawOutput: '',
executionTime: Date.now() - startTime
};
}
// Wait for prompt to return
const result = await this.waitForPromptWithLLMFallback(sessionId, timeout);
if (result.question) {
// Return LLM question without updating session state
return result;
}
const output = result.rawOutput;
const executionTime = Date.now() - startTime;
const isError = PromptDetector.isErrorOutput(output, session.config.type);
session.status = 'ready';
session.history.push(input);
// Limit history to last MAX_HISTORY_SIZE commands
if (session.history.length > this.MAX_HISTORY_SIZE) {
session.history = session.history.slice(-this.MAX_HISTORY_SIZE);
}
session.lastOutput = output;
session.lastActivity = new Date();
return {
success: !isError,
rawOutput: output,
executionTime,
error: isError ? 'Command execution failed' : undefined
};
}
catch (error) {
if (wait_for_prompt) {
session.status = 'error';
session.lastError = error instanceof Error ? error.message : String(error);
}
throw error;
}
}
async sendSignal(sessionId, signal) {
const session = this.sessions.get(sessionId);
if (!session) {
return { success: false, error: `Session ${sessionId} not found` };
}
if (!session.process) {
return { success: false, error: `Session ${sessionId} has no active process` };
}
try {
// Send control characters directly to PTY for better compatibility
let controlChar = null;
let signalName = signal;
switch (signal) {
case 'SIGINT':
controlChar = '\x03'; // Ctrl+C
signalName = 'Ctrl+C';
break;
case 'SIGTSTP':
controlChar = '\x1A'; // Ctrl+Z
signalName = 'Ctrl+Z';
break;
case 'SIGQUIT':
controlChar = '\x1C'; // Ctrl+\
signalName = 'Ctrl+\\';
break;
}
if (controlChar) {
this.log(`[sendSignal] Writing to PTY: ${JSON.stringify(controlChar)} (length: ${controlChar.length})`, sessionId);
session.process.write(controlChar);
session.lastActivity = new Date();
this.log(`Sent ${signalName} character (${JSON.stringify(controlChar)}) to PTY process`, sessionId);
return { success: true, message: `${signal} sent as ${signalName} character to session ${sessionId}` };
}
// Should never reach here with current supported signals
return { success: false, error: `Unsupported signal: ${signal}` };
}
catch (error) {
return { success: false, error: `Failed to send signal ${signal}: ${error}` };
}
}
async setSessionReady(sessionId, pattern) {
const session = this.sessions.get(sessionId);
if (!session) {
return { success: false, error: `Session ${sessionId} not found` };
}
session.status = 'ready';
session.lastActivity = new Date();
// Store the pattern for future use if needed
return { success: true, message: `Session ${sessionId} marked as ready with pattern: ${pattern}` };
}
async waitForSession(sessionId, seconds) {
const session = this.sessions.get(sessionId);
if (!session) {
return { success: false, error: `Session ${sessionId} not found` };
}
// Wait for specified seconds
await new Promise(resolve => setTimeout(resolve, seconds * 1000));
session.lastActivity = new Date();
return { success: true, message: `Waited ${seconds} seconds for session ${sessionId}` };
}
async markSessionFailed(sessionId, reason) {
const session = this.sessions.get(sessionId);
if (!session) {
return { success: false, error: `Session ${sessionId} not found` };
}
session.status = 'error';
session.lastError = reason;
session.lastActivity = new Date();
return { success: true, message: `Session ${sessionId} marked as failed: ${reason}` };
}
getSession(sessionId) {
return this.sessions.get(sessionId);
}
listSessions() {
return Array.from(this.sessions.values());
}
async destroySession(sessionId) {
const session = this.sessions.get(sessionId);
if (!session) {
return false;
}
if (session.process) {
session.process.kill();
}
this.sessions.delete(sessionId);
this.outputBuffers.delete(sessionId);
this.sessionLogs.delete(sessionId); // Clean up session-specific logs
this.serverTerminals.delete(sessionId); // Clean up server-side terminal
this.serializeAddons.delete(sessionId); // Clean up serialize addon
return true;
}
createShellProcess(config, startingDir) {
const env = { ...process.env, ...config.environment };
let shellCommand;
let shellArgs = [];
switch (config.shell) {
case 'bash':
shellCommand = 'bash';
shellArgs = ['-i']; // interactive mode
break;
case 'zsh':
shellCommand = 'zsh';
shellArgs = ['-i'];
break;
case 'cmd':
shellCommand = 'cmd.exe';
shellArgs = ['/K']; // /K keeps cmd.exe running after commands
break;
case 'powershell':
shellCommand = 'powershell.exe';
shellArgs = []; // powershell.exe doesn't need -NoExit with node-pty
break;
default:
shellCommand = process.platform === 'win32' ? 'powershell.exe' : 'bash';
shellArgs = [];
}
// node-pty's spawn returns a PtyProcess
return nodePty.spawn(shellCommand, shellArgs, {
name: 'xterm-color',
cols: 132,
rows: 43,
cwd: startingDir,
env: { ...env, TERM: 'xterm' }, // Cast to string dictionary
encoding: 'utf8',
// Enable ConPTY on Windows if build number is high enough
useConpty: process.platform === 'win32' && getWindowsBuildNumber() >= 18309,
// useConptyDll: false // Keep this false for now unless explicitly needed
});
}
createServerSideTerminal(sessionId) {
// Create server-side xterm.js instance
const terminal = new Terminal({
cols: 132,
rows: 43,
allowProposedApi: true // Required for some addons
});
// Create and load SerializeAddon
const serializeAddon = new SerializeAddon();
terminal.loadAddon(serializeAddon);
// Store references
this.serverTerminals.set(sessionId, terminal);
this.serializeAddons.set(sessionId, serializeAddon);
this.log(`[DEBUG ${sessionId}] Server-side terminal created`, sessionId);
}
getSerializedTerminalState(sessionId) {
const serializeAddon = this.serializeAddons.get(sessionId);
if (!serializeAddon) {
return null;
}
try {
return serializeAddon.serialize();
}
catch (error) {
this.log(`[ERROR ${sessionId}] Failed to serialize terminal state: ${error}`, sessionId);
return null;
}
}
getCurrentLineCleanText(sessionId) {
const terminal = this.serverTerminals.get(sessionId);
if (!terminal) {
return null;
}
try {
// Get the current line (where cursor is) as clean text (without ANSI codes)
return terminal.buffer.active.getLine(terminal.buffer.active.baseY + terminal.buffer.active.cursorY)?.translateToString() || '';
}
catch (error) {
this.log(`[ERROR ${sessionId}] Failed to get current line clean text: ${error}`, sessionId);
return null;
}
}
getFullCleanText(sessionId) {
const terminal = this.serverTerminals.get(sessionId);
if (!terminal) {
return null;
}
try {
// Get all visible lines as clean text
const lines = [];
for (let i = 0; i < terminal.buffer.active.length; i++) {
const line = terminal.buffer.active.getLine(i);
if (line) {
const text = line.translateToString();
if (text.trim()) {
lines.push(text);
}
}
}
return lines.join('\n');
}
catch (error) {
this.log(`[ERROR ${sessionId}] Failed to get full clean text: ${error}`, sessionId);
return null;
}
}
setupOutputHandlers(sessionId, process) {
const serverTerminal = this.serverTerminals.get(sessionId);
const appendOutput = (data) => {
// Always append to output buffer (complete output, no truncation during collection)
const currentBuffer = this.outputBuffers.get(sessionId) || '';
const newBuffer = currentBuffer + data;
this.outputBuffers.set(sessionId, newBuffer);
// Also send to server-side terminal for proper ANSI processing
if (serverTerminal) {
serverTerminal.write(data);
}
};
// node-pty uses onData method instead of 'data' event
process.onData((data) => {
this.log(`[DEBUG ${sessionId}] Raw data received: ${JSON.stringify(data)}`, sessionId);
appendOutput(data);
});
process.onExit((exitCode) => {
const session = this.sessions.get(sessionId);
if (session) {
session.status = 'terminated';
session.lastError = `Process exited with code ${exitCode.exitCode}`;
}
});
}
async waitForShellReady(sessionId, timeout = 5000) {
return new Promise((resolve, reject) => {
const startTime = Date.now();
const checkReady = () => {
if (Date.now() - startTime > timeout) {
reject(new Error('Shell initialization timeout'));
return;
}
const output = this.outputBuffers.get(sessionId) || '';
// Simple check for shell prompt (can be improved)
if (output.includes('$') || output.includes('>') || output.includes('#')) {
resolve();
}
else {
setTimeout(checkReady, 100);
}
};
setTimeout(checkReady, 500); // Give shell time to initialize
});
}
async executeSetupCommand(sessionId, command) {
const session = this.sessions.get(sessionId);
if (!session?.process) {
throw new Error('Session process not found');
}
this.log(`[DEBUG ${sessionId}] Executing setup command: ${command}`, sessionId);
// Clear buffer
this.outputBuffers.set(sessionId, '');
// Send command
session.process.write(command + '\r\n');
// Wait for command to complete with proper prompt detection
try {
await this.waitForPrompt(sessionId, 5000);
this.log(`[DEBUG ${sessionId}] Setup command completed: ${command}`, sessionId);
}
catch (error) {
this.log(`[DEBUG ${sessionId}] Setup command timeout, continuing: ${command}`, sessionId);
// Don't fail the entire session for setup command timeouts
await new Promise(resolve => setTimeout(resolve, 1000));
}
}
async startREPL(sessionId, replCommand) {
const session = this.sessions.get(sessionId);
if (!session?.process) {
throw new Error('Session process not found');
}
// Clear buffer
this.outputBuffers.set(sessionId, '');
// Start REPL
session.process.write(replCommand + '\r\n');
// Wait for REPL prompt or specific signal
// Wait for REPL prompt
await this.waitForPrompt(sessionId, session.config.timeout || 10000);
}
async waitForPrompt(sessionId, timeout) {
const session = this.sessions.get(sessionId);
if (!session) {
throw new Error('Session not found');
}
return new Promise((resolve, reject) => {
const startTime = Date.now();
const initialOutputLength = (this.outputBuffers.get(sessionId) || '').length;
let hasSeenOutput = false;
const checkPrompt = () => {
if (Date.now() - startTime > timeout) {
const output = this.outputBuffers.get(sessionId) || '';
reject(new Error(`Command execution timeout after ${timeout}ms. Output: "${output}"`));
return;
}
const currentOutput = this.outputBuffers.get(sessionId) || '';
// Check if we've seen new output since command was sent
if (currentOutput.length > initialOutputLength) {
hasSeenOutput = true;
}
// Use xterm.js clean text for prompt detection
const cleanText = this.getCurrentLineCleanText(sessionId);
if (cleanText) {
this.log(`[DEBUG ${sessionId}] Testing clean text prompt detection: "${cleanText}"`, sessionId);
const promptInfo = PromptDetector.detectPrompt(cleanText, session.config.type, session.learnedPromptPatterns, true); // isCleanText = true
if (promptInfo.detected && promptInfo.ready && hasSeenOutput) {
this.log(`[DEBUG ${sessionId}] Clean text prompt detected: ${promptInfo.type}`, sessionId);
resolve(currentOutput);
return;
}
}
// Fallback to raw output if clean text is not available
this.log(`[DEBUG ${sessionId}] Testing raw output prompt detection with expectedType: ${session.config.type}`, sessionId);
const promptInfo = PromptDetector.detectPrompt(currentOutput, session.config.type, session.learnedPromptPatterns, false); // isCleanText = false
if (promptInfo.detected && promptInfo.ready && hasSeenOutput) {
resolve(currentOutput);
}
else {
setTimeout(checkPrompt, 100);
}
};
checkPrompt();
});
}
generateSessionId() {
return Math.random().toString(36).substring(2, 8);
}
async waitForPromptWithLLMFallback(sessionId, timeout) {
try {
const output = await this.waitForPrompt(sessionId, timeout);
return {
success: true,
rawOutput: output,
executionTime: 0
};
}
catch (error) {
// Timeout occurred - ask LLM for guidance
const rawOutput = this.outputBuffers.get(sessionId) || '';
return this.createLLMTimeoutQuestion(sessionId, rawOutput, error);
}
}
createLLMTimeoutQuestion(sessionId, rawOutput, error) {
const question = `Session timed out. Here's the raw output - please analyze and respond:
Raw output:
"""
${rawOutput}
"""
Timeout error: ${error.message}
Available tools to resolve this:
- send_signal_to_session("${sessionId}", "SIGINT") - Send Ctrl+C to interrupt stuck processes
- send_input_to_session("${sessionId}", "\\n", {wait_for_prompt: true}) - Send Enter key and wait for prompt
- send_input_to_session("${sessionId}", "q", {wait_for_prompt: true}) - Send 'q' to quit interactive programs
- set_session_ready("${sessionId}", "prompt_pattern") - If you see a working prompt, specify the pattern (regex like '[\\\\d]+] pry\\\\(main\\\\)> $' or literal like '$ ')
- wait_for_session("${sessionId}", seconds) - Wait longer for response (specify number of seconds)
- mark_session_failed("${sessionId}", "reason") - Give up and mark as failed with explanation
Analyze the output and choose the most appropriate tool to resolve the timeout.`;
return {
success: false,
rawOutput: '',
error: 'Timeout - LLM guidance needed',
executionTime: 0,
question,
questionType: 'timeout_analysis',
context: { sessionId, rawOutput },
canContinue: true
};
}
}
function getWindowsBuildNumber() {
const osVersion = os.release();
const buildNumber = parseInt(osVersion.split('.')[2]);
return buildNumber;
}
//# sourceMappingURL=session-manager.js.map