Skip to main content
Glama
web-server-manager.ts44.5 kB
import { SSHConnectionManager, TerminalOutputEntry, } from "./ssh-connection-manager.js"; import { ErrorResponse } from "./types.js"; import { PortManager } from "./port-discovery.js"; import { TerminalSessionStateManager, SessionBusyError } from "./terminal-session-state-manager.js"; import { Logger, log } from "./logger.js"; import * as http from "http"; import express from "express"; import { WebSocketServer } from "ws"; export interface WebServerManagerConfig { port?: number; } interface SessionState { connectedClients: Set<import("ws").WebSocket>; lastActivity: number; commandHistory: Array<{ commandId: string; command: string; source: 'user' | 'claude'; timestamp: number; completed: boolean; }>; } // REMOVED: RegexPatterns interface (no longer used after echo suppression removal) /** * Pure Web Server Manager - Handles only HTTP/WebSocket functionality * This server provides web interface and terminal monitoring without MCP */ export class WebServerManager { private httpServer?: http.Server; private app: express.Express; private wss?: WebSocketServer; private sshManager: SSHConnectionManager; private portManager: PortManager; private terminalStateManager: TerminalSessionStateManager; private config: WebServerManagerConfig; private webPort?: number; private running = false; // Terminal state synchronization private sessionStates: Map<string, SessionState> = new Map(); // REMOVED: Pattern cache (no longer used after echo suppression removal) // Echo suppression handled in browser terminal handler constructor( sshManager: SSHConnectionManager, config: WebServerManagerConfig = {}, terminalStateManager?: TerminalSessionStateManager ) { this.validateConfig(config); this.config = { port: config.port, ...config, }; this.sshManager = sshManager; this.portManager = new PortManager(); this.terminalStateManager = terminalStateManager || new TerminalSessionStateManager(); // Initialize logger with 'file' transport for safe console output in web server context Logger.initialize('file', 'WebServer', 'logs/web-server.log'); this.app = express(); this.setupExpressRoutes(); // Set up monitoring for Claude Code commands to provide visual indicators this.setupClaudeCommandMonitoring(); } private validateConfig(config: WebServerManagerConfig): void { if (config.port !== undefined && (config.port < 1 || config.port > 65535)) { throw new Error("Invalid port: must be between 1 and 65535"); } } /** * Start the web server on discovered or specified port */ async start(): Promise<void> { try { await this.discoverPort(); await this.startHttpServer(); this.setupWebSocketServer(); this.running = true; } catch (error) { await this.cleanup(); throw new Error( `Failed to start web server: ${error instanceof Error ? error.message : String(error)}`, ); } } /** * Stop the web server gracefully */ async stop(): Promise<void> { await this.cleanup(); } private async discoverPort(): Promise<void> { if (this.config.port) { // Use specified port this.webPort = await this.portManager.reservePort(this.config.port); } else { // Auto-discover port starting from 8080 this.webPort = await this.portManager.getUnifiedPort(8080); } } private async startHttpServer(): Promise<void> { return new Promise((resolve, reject) => { this.httpServer = this.app.listen( this.webPort, (error?: Error | undefined) => { if (error) { if (error.message.includes("EADDRINUSE")) { reject(new Error(`Port ${this.webPort} is already in use`)); } else { reject(error); } return; } resolve(); }, ); this.httpServer.on( "error", (error: { code?: string; message: string }) => { if (error.code === "EADDRINUSE") { reject(new Error(`Port ${this.webPort} is already in use`)); } else { reject(error); } }, ); }); } private setupExpressRoutes(): void { // Serve static files for web interface const staticPath = "./static"; this.app.use(express.static(staticPath)); // Handle root route this.app.get("/", (_, res) => { res.send(` <html> <head><title>SSH MCP Server</title></head> <body> <h1>SSH MCP Server</h1> <p>Server running on port ${this.webPort}</p> <p>WebSocket endpoint: ws://localhost:${this.webPort}/ws/monitoring</p> </body> </html> `); }); // Handle session-specific routes this.app.get("/session/:sessionName", (req, res) => { const sessionName = req.params.sessionName; // Validate session exists if (!this.sshManager.hasSession(sessionName)) { res.status(404).send("Session not found"); return; } res.send(` <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>SSH MCP Terminal Viewer</title> <link rel="icon" type="image/x-icon" href="/favicon.ico"> <link rel="stylesheet" href="/xterm.css" /> <link rel="stylesheet" href="/styles.css"> <style> #terminal-container { position: relative; } </style> </head> <body> <div id="session-header"> <h1 id="session-title">SSH Session: ${sessionName}</h1> <div id="connection-status">🟢 Connected</div> </div> <div id="terminal-container"> <div id="terminal"></div> </div> <script src="/xterm.js"></script> <script src="/xterm-addon-fit.js"></script> <script src="/terminal-input-handler.js"></script> <script> const sessionName = '${sessionName}'; const wsUrl = \`ws://localhost:${this.webPort}/ws/session/\${sessionName}\`; // Initialize terminal const term = new Terminal({ theme: { background: '#000000', foreground: '#ffffff', cursor: '#ffffff', selection: '#ffffff' }, fontSize: 16, fontFamily: 'Monaco, Menlo, "Ubuntu Mono", monospace', cursorBlink: true }); const fitAddon = new FitAddon.FitAddon(); term.loadAddon(fitAddon); term.open(document.getElementById('terminal')); fitAddon.fit(); // WebSocket connection const ws = new WebSocket(wsUrl); // CRITICAL FIX: Initialize terminal handler BEFORE WebSocket connection // This prevents race condition where messages arrive before handler is ready console.debug('About to create TerminalInputHandler...'); console.debug('TerminalInputHandler available: ' + typeof TerminalInputHandler); let terminalHandler; try { terminalHandler = new TerminalInputHandler(term, ws, sessionName); console.debug('TerminalInputHandler created successfully'); } catch (error) { console.error('CRITICAL ERROR: Failed to create TerminalInputHandler', error instanceof Error ? error : new Error(String(error))); terminalHandler = null; } ws.onopen = function() { // WebSocket connection established document.getElementById('connection-status').innerHTML = '🟢 Connected'; }; ws.onmessage = function(event) { try { const data = JSON.parse(event.data); if (terminalHandler && terminalHandler.handleTerminalOutput) { terminalHandler.handleTerminalOutput(data); } else { console.error('CRITICAL: terminalHandler not available for message: ' + JSON.stringify(data)); } } catch (error) { console.error('Error processing WebSocket message', error instanceof Error ? error : new Error(String(error))); document.getElementById('connection-status').innerHTML = '⚠️ Message Error'; } }; ws.onclose = function() { document.getElementById('connection-status').innerHTML = '🔴 Disconnected'; }; ws.onerror = function(error) { console.error('WebSocket error', error instanceof Error ? error : new Error(String(error))); document.getElementById('connection-status').innerHTML = '⚠️ Connection Error'; }; // Auto-resize terminal window.addEventListener('resize', () => { fitAddon.fit(); }); </script> </body> </html> `); }); // Admin endpoint to reset terminal lock state this.app.post("/admin/unlock-terminal/:sessionName", express.json(), (req, res) => { const sessionName = req.params.sessionName; // Validate session exists if (!this.sshManager.hasSession(sessionName)) { res.status(404).json({ error: "Session not found" }); return; } res.json({ success: true, message: `Terminal lock state reset for session: ${sessionName}`, timestamp: new Date().toISOString() }); }); } private setupWebSocketServer(): void { if (!this.httpServer) { throw new Error("HTTP server must be started before WebSocket server"); } this.wss = new WebSocketServer({ server: this.httpServer, verifyClient: (info: { origin: string; secure: boolean; req: http.IncomingMessage; }): boolean => { const url = new URL(info.req.url!, `http://${info.req.headers.host}`); if (url.pathname === "/ws/monitoring") { return true; } if (url.pathname.startsWith("/ws/session/")) { const sessionMatch = url.pathname.match(/^\/ws\/session\/(.+)$/); if (sessionMatch) { const sessionName = decodeURIComponent(sessionMatch[1]); return this.sshManager.hasSession(sessionName); } } return false; }, }); this.wss.on("connection", (ws, req) => { const url = new URL(req.url!, `http://${req.headers.host}`); if (url.pathname === "/ws/monitoring") { // General monitoring connection ws.send( JSON.stringify({ type: "connected", message: "Monitoring connection established", }), ); } else if (url.pathname.startsWith("/ws/session/")) { // Session-specific connection const sessionMatch = url.pathname.match(/^\/ws\/session\/(.+)$/); if (sessionMatch) { const sessionName = decodeURIComponent(sessionMatch[1]); console.debug(`[WebServerManager] Setting up WebSocket for session: ${sessionName}`); this.setupSessionWebSocket(ws, sessionName); } } }); } private setupSessionWebSocket( ws: import("ws").WebSocket, sessionName: string, ): void { // Add client to session for state synchronization (AC5.3, AC5.4) this.addClientToSession(sessionName, ws); // Auto-subscribe to session terminal output console.log(`🔧 [CONCATENATION DEBUG] Checking if session exists: ${sessionName}`); const sessionExists = this.sshManager.hasSession(sessionName); console.log(`🔧 [CONCATENATION DEBUG] Session ${sessionName} exists: ${sessionExists}`); if (sessionExists) { const outputCallback = (entry: TerminalOutputEntry): void => { if (ws.readyState === ws.OPEN) { // Use the new structure with backward compatibility const outputData = entry.content || entry.output || ''; // LIVE LISTENER FORMATTING FIX: Forward all entries from live listeners // The SSH manager's broadcastToLiveListenersRaw already handles command/result formatting // CRLF BUG FIX: Don't add CRLF to final prompts that should remain on same line // Check if this is a final prompt (ends with ]$ followed by optional space) const isFinalPrompt = entry.source === 'system' && /\[[^\]]+\]\$\s*$/.test(outputData); // Only add CRLF if: // 1. Data doesn't already end with CRLF, AND // 2. It's not a final prompt that should remain on the same line const formattedData = outputData.endsWith('\r\n') || isFinalPrompt ? outputData : `${outputData}\r\n`; // Forward all live terminal entries to WebSocket clients with proper formatting // This ensures real-time display matches history replay formatting (Rules 1a/1b) ws.send( JSON.stringify({ type: "terminal_output", sessionName, timestamp: new Date(entry.timestamp).toISOString(), data: formattedData, source: entry.source, }), ); // Commands are skipped - they're sent by SSH manager's broadcastToLiveListenersRaw with prompts } }; try { this.sshManager.addTerminalOutputListener(sessionName, outputCallback); // Send historical terminal session with proper format: prompt + command + output + prompt console.log(`🔧 [CONCATENATION DEBUG] About to send terminal history for ${sessionName}`); this.sendFormattedTerminalHistory(ws, sessionName); console.log(`🔧 [CONCATENATION DEBUG] Finished sending terminal history for ${sessionName}`); ws.on("close", () => { this.sshManager.removeTerminalOutputListener( sessionName, outputCallback, ); // Remove client from session state tracking this.removeClientFromSession(sessionName, ws); }); ws.on("error", () => { this.sshManager.removeTerminalOutputListener( sessionName, outputCallback, ); // Remove client from session state tracking this.removeClientFromSession(sessionName, ws); }); // Handle incoming WebSocket messages (terminal input and state requests) ws.on("message", (message: Buffer) => { let data: unknown; try { data = JSON.parse(message.toString()); log.debug('SERVER received WebSocket message: ' + JSON.stringify(data)); if (typeof data === 'object' && data !== null && 'type' in data && 'sessionName' in data) { const messageData = data as Record<string, unknown>; if (messageData.type === "terminal_input" && messageData.sessionName === sessionName) { void this.handleTerminalInputMessage(ws, data, sessionName); } else if (messageData.type === "terminal_input_raw" && messageData.sessionName === sessionName) { void this.handleTerminalInputRawMessage(ws, data, sessionName); } else if (messageData.type === "request_state_recovery" && messageData.sessionName === sessionName) { this.handleStateRecoveryRequest(ws, sessionName); } else if (messageData.type === "terminal_signal" && messageData.sessionName === sessionName) { void this.handleTerminalSignalMessage(ws, data, sessionName); } else if (messageData.type === "malformed_test") { // Handle malformed message test gracefully (AC5.5) this.handleMalformedMessage(ws, sessionName); } } } catch (error) { log.error('Error processing WebSocket message', error instanceof Error ? error : new Error(String(error))); this.handleMalformedMessage(ws, sessionName); } }); } catch (error) { // Handle listener setup errors gracefully - log but don't crash log.error('Error setting up terminal output listener', error instanceof Error ? error : new Error(String(error))); } } else { console.log(`🔧 [CONCATENATION DEBUG] Session ${sessionName} does not exist - skipping history replay`); // Send session not found message to browser if (ws.readyState === ws.OPEN) { ws.send(JSON.stringify({ type: "error", message: `Session ${sessionName} not found`, timestamp: new Date().toISOString() })); } } } private async cleanup(): Promise<void> { const cleanupPromises: Promise<void>[] = []; // Close WebSocket server if (this.wss) { cleanupPromises.push( new Promise<void>((resolve) => { this.wss!.close(() => { this.wss = undefined; resolve(); }); }), ); } // Close HTTP server if (this.httpServer) { cleanupPromises.push( new Promise<void>((resolve, reject) => { this.httpServer!.close((error) => { if (error) reject(error); else { this.httpServer = undefined; resolve(); } }); }), ); } // Release port reservation if (this.webPort) { this.portManager.releasePort(this.webPort); } await Promise.all(cleanupPromises).catch((error) => { // Log cleanup errors but don't throw - cleanup should be graceful log.error('Error during web server cleanup', error instanceof Error ? error : new Error(String(error))); }); this.running = false; } private async handleTerminalInputMessage( ws: import("ws").WebSocket, data: unknown, sessionName: string ): Promise<void> { console.debug(`[WebServerManager] handleTerminalInputMessage called for session: ${sessionName}`); try { // Type guard for data if (typeof data !== 'object' || data === null) { this.sendErrorResponse(ws, "Invalid data format", undefined); return; } const messageData = data as Record<string, unknown>; // Validate session exists const sessionExists = this.sshManager.hasSession(sessionName); console.debug(`[WebServerManager] Session ${sessionName} exists: ${sessionExists}`); if (!sessionExists) { console.debug(`[WebServerManager] Available sessions: ${JSON.stringify(this.sshManager.listSessions())}`); this.sendErrorResponse(ws, `Session '${sessionName}' not found`, typeof messageData.commandId === 'string' ? messageData.commandId : undefined); return; } // Validate required fields if (!messageData.command || typeof messageData.command !== 'string') { this.sendErrorResponse(ws, "Command is required and must be a string", typeof messageData.commandId === 'string' ? messageData.commandId : undefined); return; } if (!messageData.commandId || typeof messageData.commandId !== 'string') { this.sendErrorResponse(ws, "CommandId is required and must be a string", typeof messageData.commandId === 'string' ? messageData.commandId : undefined); return; } const commandId = messageData.commandId as string; const command = messageData.command as string; console.debug(`[WebServerManager] Executing command: "${command}" (commandId: ${commandId}) for session: ${sessionName}`); // STATE MACHINE: Check if session can accept new commands if (!this.terminalStateManager.canAcceptCommand(sessionName)) { const currentCommand = this.terminalStateManager.getCurrentCommand(sessionName); const errorMessage = `Session is busy executing command: ${currentCommand?.command || 'unknown'} (initiated by ${currentCommand?.initiator || 'unknown'})`; this.sendErrorResponse(ws, errorMessage, commandId); return; } // CRITICAL FIX: Proper resource management pattern try { // STATE MACHINE: Start command execution this.terminalStateManager.startCommandExecution(sessionName, command, commandId, 'browser'); try { // COMMAND CAPTURE: Add command to browser command buffer before execution this.sshManager.addBrowserCommand(sessionName, command, commandId, 'user'); // Store command in session history const sessionState = this.getOrCreateSessionState(sessionName); // Add command to history when starting execution sessionState.commandHistory.push({ commandId, command, source: 'user', timestamp: Date.now(), completed: false }); // Send visual indicator for user execution (AC5.2) this.broadcastVisualIndicator(sessionName, 'user_command_executing', 'user', { commandId }); // Send processing state (AC5.2) this.broadcastProcessingState(sessionName, 'executing', commandId); // Execute command with user source and capture result const commandResult = await this.sshManager.executeCommand( sessionName, command, { source: 'user' } ); // Update browser command buffer with execution result this.sshManager.updateBrowserCommandResult(sessionName, commandId, commandResult); // Mark command as completed in session history const historyEntry = sessionState.commandHistory.find(h => h.commandId === commandId); if (historyEntry) { historyEntry.completed = true; } // Send completion processing state (AC5.2) this.broadcastProcessingState(sessionName, 'completed', commandId); // Send ready state for immediate recovery (AC5.5) if (ws.readyState === ws.OPEN) { ws.send(JSON.stringify({ type: 'terminal_ready', sessionName, timestamp: new Date().toISOString() })); } } catch (commandError) { // Handle command errors gracefully (AC5.5) const errorMessage = commandError instanceof Error ? commandError.message : String(commandError); // Update browser command buffer with error result this.sshManager.updateBrowserCommandResult(sessionName, commandId, { stdout: '', stderr: errorMessage, exitCode: 1 // Non-zero exit code indicates error }); // Mark command as completed even on error const sessionStateError = this.getOrCreateSessionState(sessionName); const historyEntryError = sessionStateError.commandHistory.find(h => h.commandId === commandId); if (historyEntryError) { historyEntryError.completed = true; } // Send error response with source identification (AC5.5) const errorResponse = { type: 'command_error', sessionName, source: 'user', commandId: commandId, errorMessage: errorMessage, timestamp: new Date().toISOString() }; if (ws.readyState === ws.OPEN) { ws.send(JSON.stringify(errorResponse)); } // Send error processing state this.broadcastProcessingState(sessionName, 'error', commandId); // Send ready state for immediate recovery (AC5.5) if (ws.readyState === ws.OPEN) { ws.send(JSON.stringify({ type: 'terminal_ready', sessionName, timestamp: new Date().toISOString() })); } } finally { // CRITICAL: Always cleanup state, even if executeCommand throws this.terminalStateManager.completeCommandExecution(sessionName, commandId); } } catch (stateError) { // Handle state management errors (SessionBusyError, etc.) if (stateError instanceof SessionBusyError) { this.sendErrorResponse(ws, stateError.message, commandId); } else { const errorMessage = stateError instanceof Error ? stateError.message : String(stateError); this.sendErrorResponse(ws, `State management error: ${errorMessage}`, commandId); } } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); const messageData = typeof data === 'object' && data !== null ? data as Record<string, unknown> : {}; this.sendErrorResponse(ws, `Command execution failed: ${errorMessage}`, typeof messageData.commandId === 'string' ? messageData.commandId : undefined); } } private async handleTerminalInputRawMessage( ws: import("ws").WebSocket, data: unknown, sessionName: string ): Promise<void> { try { // Type guard for data if (typeof data !== 'object' || data === null) { this.sendErrorResponse(ws, "Invalid data format", undefined); return; } const messageData = data as Record<string, unknown>; // Validate session exists if (!this.sshManager.hasSession(sessionName)) { this.sendErrorResponse(ws, `Session '${sessionName}' not found`, undefined); return; } // Validate required field if (!messageData.input || typeof messageData.input !== 'string') { this.sendErrorResponse(ws, "Input is required and must be a string", undefined); return; } const input = messageData.input as string; // Send raw input directly to SSH session for real-time processing // CRITICAL FIX: The permanent data handler is NOT broadcasting - send terminal output directly // Execute the raw input this.sshManager.sendTerminalInputRaw(sessionName, input); // Send terminal output echo back to browser immediately if (ws.readyState === ws.OPEN) { ws.send(JSON.stringify({ type: 'terminal_output', sessionName, timestamp: new Date().toISOString(), data: input, source: 'user' })); // NOTE: SSH output will be handled by the permanent data handler // If that's not working, we need to fix the SSH manager broadcasting } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); this.sendErrorResponse(ws, `Terminal input failed: ${errorMessage}`, undefined); } } private async handleTerminalSignalMessage( ws: import("ws").WebSocket, data: unknown, sessionName: string ): Promise<void> { try { // Type guard for data if (typeof data !== 'object' || data === null) { this.sendErrorResponse(ws, "Invalid data format", undefined); return; } const messageData = data as Record<string, unknown>; // Validate session exists if (!this.sshManager.hasSession(sessionName)) { this.sendErrorResponse(ws, `Session '${sessionName}' not found`, undefined); return; } // Validate required field if (!messageData.signal || typeof messageData.signal !== 'string') { this.sendErrorResponse(ws, "Signal is required and must be a string", undefined); return; } const signal = messageData.signal as string; // Send signal to SSH session for command interruption // AC 5.4: WebSocket SIGINT signal format: {type: 'terminal_signal', sessionName: 'session', signal: 'SIGINT'} this.sshManager.sendTerminalSignal(sessionName, signal); // If SIGINT, clear terminal state manager to allow new commands if (signal === 'SIGINT') { const currentCommand = this.terminalStateManager.getCurrentCommand(sessionName); if (currentCommand) { this.terminalStateManager.completeCommandExecution(sessionName, currentCommand.commandId); console.debug(`[WebServerManager] SIGINT: Cleared terminal state for session ${sessionName}, command ${currentCommand.commandId}`); } } // Send confirmation response if (ws.readyState === ws.OPEN) { ws.send(JSON.stringify({ type: 'terminal_signal_sent', sessionName, signal, timestamp: new Date().toISOString() })); } } catch (error) { const errorMessage = error instanceof Error ? error.message : String(error); this.sendErrorResponse(ws, `Terminal signal failed: ${errorMessage}`, undefined); } } private sendErrorResponse( ws: import("ws").WebSocket, message: string, commandId?: string ): void { if (ws.readyState === ws.OPEN) { const errorResponse = { type: 'error', message, commandId, timestamp: new Date().toISOString() }; ws.send(JSON.stringify(errorResponse)); } } private setupClaudeCommandMonitoring(): void { // This would typically be implemented by hooking into the SSHConnectionManager's // command execution pipeline to detect Claude Code commands and provide visual indicators // For now, we'll monitor via the output listener mechanism // Note: In a full implementation, we would extend the SSHConnectionManager // to emit command events that we can listen to here } // Terminal state synchronization methods private getOrCreateSessionState(sessionName: string): SessionState { if (!this.sessionStates.has(sessionName)) { this.sessionStates.set(sessionName, { connectedClients: new Set(), lastActivity: Date.now(), commandHistory: [] }); } return this.sessionStates.get(sessionName)!; } private broadcastVisualIndicator(sessionName: string, indicatorType: string, source: 'user' | 'claude', data?: Record<string, unknown>): void { const sessionState = this.sessionStates.get(sessionName); if (!sessionState) return; const message = JSON.stringify({ type: 'visual_state_indicator', sessionName, indicatorType, source, timestamp: new Date().toISOString(), ...data }); sessionState.connectedClients.forEach(client => { if (client.readyState === client.OPEN) { client.send(message); } }); } private broadcastProcessingState(sessionName: string, state: 'executing' | 'completed' | 'error', commandId?: string): void { const sessionState = this.sessionStates.get(sessionName); if (!sessionState) return; const message = JSON.stringify({ type: 'processing_state', sessionName, state, commandId, timestamp: new Date().toISOString() }); sessionState.connectedClients.forEach(client => { if (client.readyState === client.OPEN) { client.send(message); } }); } private addClientToSession(sessionName: string, client: import("ws").WebSocket): void { const sessionState = this.getOrCreateSessionState(sessionName); sessionState.connectedClients.add(client); } private removeClientFromSession(sessionName: string, client: import("ws").WebSocket): void { const sessionState = this.sessionStates.get(sessionName); if (sessionState) { sessionState.connectedClients.delete(client); } } /** * Check if a session has active browser connections * @param sessionName - Name of the session to check * @returns True if there are active WebSocket connections for the session */ public hasActiveBrowserConnections(sessionName: string): boolean { const sessionState = this.sessionStates.get(sessionName); if (!sessionState) { return false; } // Filter out closed connections and check if any remain const activeConnections = Array.from(sessionState.connectedClients).filter( client => client.readyState === client.OPEN ); // Clean up any closed connections sessionState.connectedClients.clear(); activeConnections.forEach(client => sessionState.connectedClients.add(client)); return activeConnections.length > 0; } private handleStateRecoveryRequest(ws: import("ws").WebSocket, sessionName: string): void { if (ws.readyState === ws.OPEN) { // Send graceful recovery indication ws.send(JSON.stringify({ type: 'graceful_recovery', sessionName, timestamp: new Date().toISOString() })); } } private handleMalformedMessage(ws: import("ws").WebSocket, sessionName: string): void { if (ws.readyState === ws.OPEN) { // Handle malformed messages gracefully (AC5.5) const response = JSON.stringify({ type: 'malformed_message_handled', sessionName, message: 'Invalid message format handled gracefully', timestamp: new Date().toISOString() }); ws.send(response); } } /** * Creates a standardized error response with consistent structure * Used throughout the application for uniform error handling */ public createStandardizedErrorResponse(error: Error, commandId?: string): ErrorResponse { return { error: error.name || 'Error', message: error.message, timestamp: Date.now(), code: error.name?.toUpperCase().replace(/ERROR$/, '') || 'UNKNOWN', ...(commandId && { commandId }) }; } // Public API methods for testing and monitoring async getPort(): Promise<number> { if (!this.webPort) { throw new Error("Web server not started - port not yet discovered"); } return this.webPort; } isRunning(): boolean { return this.running; } getConfig(): WebServerManagerConfig { return { ...this.config }; } getTerminalStateManager(): TerminalSessionStateManager { return this.terminalStateManager; } // Methods that should NOT be available in pure web server isMCPRunning(): never { throw new Error("MCP functionality not available in pure web server"); } getMCPPort(): never { throw new Error("MCP functionality not available in pure web server"); } // Public method to handle Claude Code command execution for integration public async handleClaudeCodeCommand(sessionName: string, command: string): Promise<void> { // This method allows external code (like tests) to simulate Claude Code commands // that should provide visual indicators but not affect terminal lock state if (!this.sshManager.hasSession(sessionName)) { return; } // COMMAND CAPTURE: Add Claude command to browser command buffer const claudeCommandId = `claude-cmd-${Date.now()}`; this.sshManager.addBrowserCommand(sessionName, command, claudeCommandId, 'claude'); // Send visual indicator for Claude Code execution (AC5.2) this.broadcastVisualIndicator(sessionName, 'claude_command_executing', 'claude', { command }); try { // Execute the command as Claude Code (does not lock terminal) and capture result const commandResult = await this.sshManager.executeCommand(sessionName, command, { source: 'claude' }); // Update browser command buffer with execution result this.sshManager.updateBrowserCommandResult(sessionName, claudeCommandId, commandResult); // Send visual indicator for completion this.broadcastVisualIndicator(sessionName, 'claude_command_completed', 'claude', { command }); } catch (error) { // Update browser command buffer with error result const errorMessage = error instanceof Error ? error.message : String(error); this.sshManager.updateBrowserCommandResult(sessionName, claudeCommandId, { stdout: '', stderr: errorMessage, exitCode: 1 // Non-zero exit code indicates error }); // Send visual indicator for error this.broadcastVisualIndicator(sessionName, 'claude_command_error', 'claude', { command, error: errorMessage }); } } /** * Send formatted terminal history with proper terminal session format: * Always starts with initial prompt, then history, then final prompt if needed */ private sendFormattedTerminalHistory(ws: import("ws").WebSocket, sessionName: string): void { if (!this.sshManager.hasSession(sessionName) || ws.readyState !== ws.OPEN) { return; } try { // Get terminal output history and session connection info const terminalHistory = this.sshManager.getTerminalHistory(sessionName); const connectionInfo = this.sshManager.getSessionConnectionInfo(sessionName); if (!connectionInfo) { log.warn(`Cannot get connection info for ${sessionName} - using fallback prompt format`); // Fall back to simple history replay with generic initial prompt this.sendInitialPrompt(ws, sessionName, 'user@localhost', '~'); terminalHistory.forEach((entry) => { if (ws.readyState === ws.OPEN) { ws.send( JSON.stringify({ type: "terminal_output", sessionName, timestamp: new Date(entry.timestamp).toISOString(), data: entry.output, source: entry.source, }), ); } }); // Send final prompt if no history if (terminalHistory.length === 0) { this.sendFinalPrompt(ws, sessionName, 'user@localhost', '~'); } return; } // Rule 1a/1b: History replay with prompt injection const { username, host } = connectionInfo; // Helper function to format prompts consistently with ssh-connection-manager const formatPrompt = (username: string, host: string, currentDir = '~'): string => { return `[${username}@${host} ${currentDir}]$`; }; // Rule 1a: Iterate through history entries and inject prompts log.debug(`[CONCATENATION DEBUG] Sending ${terminalHistory.length} terminal history entries for ${sessionName}`); terminalHistory.forEach((entry, index) => { log.debug(`[CONCATENATION DEBUG] Entry ${index}: type=${entry.type}, content="${entry.content?.substring(0, 50)}..."`); }); for (const entry of terminalHistory) { if (ws.readyState !== ws.OPEN) break; // Helper function to check if content already has a prompt const hasPrompt = (content: string): boolean => { const promptPattern = `[${username}@${host}`; return content.includes(promptPattern); }; // Handle new structure with type field if (entry.type === 'command') { // CRITICAL FIX: Always inject prompt for commands in history replay // Raw commands should always get prompts when replayed to browser const prompt = formatPrompt(username, host); const output = `${prompt} ${entry.content}\r\n`; log.debug(`[CONCATENATION FIX] Sending command with prompt: "${output.trim()}"`); ws.send( JSON.stringify({ type: "terminal_output", sessionName, timestamp: new Date(entry.timestamp).toISOString(), data: output, source: entry.source, }), ); } else if (entry.type === 'result') { // Send result without prompt (no extra line break) const output = entry.content.endsWith('\r\n') ? entry.content : `${entry.content}\r\n`; ws.send( JSON.stringify({ type: "terminal_output", sessionName, timestamp: new Date(entry.timestamp).toISOString(), data: output, source: entry.source, }), ); } else { // Backward compatibility: handle old format without type field // Check if it looks like a command or result based on content const content = entry.output || entry.content || ''; if (hasPrompt(content)) { // Looks like a command with prompt - send as-is const output = content.endsWith('\r\n') ? content : `${content}\r\n`; ws.send( JSON.stringify({ type: "terminal_output", sessionName, timestamp: new Date(entry.timestamp).toISOString(), data: output, source: entry.source, }), ); } else { // Looks like a result - send without adding prompt const output = content.endsWith('\r\n') ? content : `${content}\r\n`; ws.send( JSON.stringify({ type: "terminal_output", sessionName, timestamp: new Date(entry.timestamp).toISOString(), data: output, source: entry.source, }), ); } } } // Rule 1b: End with ready prompt if history exists - ENSURE CRLF if (terminalHistory.length > 0) { const prompt = `${formatPrompt(username, host)} `; ws.send( JSON.stringify({ type: "terminal_output", sessionName, timestamp: new Date().toISOString(), data: prompt, // Note: No trailing \r\n for final prompt - cursor should remain on same line source: 'system', }), ); } else { // Send initial prompt if no history this.sendInitialPrompt(ws, sessionName, username + '@' + host, '~'); } } catch (error) { log.error(`Error sending formatted terminal history for session ${sessionName}`, error instanceof Error ? error : new Error(String(error))); // Graceful degradation - fall back to simple history replay with minimal prompts const terminalHistory = this.sshManager.getTerminalHistory(sessionName); // Always send initial prompt even in error case this.sendInitialPrompt(ws, sessionName, 'user@localhost', '~'); terminalHistory.forEach((entry) => { if (ws.readyState === ws.OPEN) { ws.send( JSON.stringify({ type: "terminal_output", sessionName, timestamp: new Date(entry.timestamp).toISOString(), data: entry.output, source: entry.source, }), ); } }); // Send final prompt if no history in error case if (terminalHistory.length === 0) { this.sendFinalPrompt(ws, sessionName, 'user@localhost', '~'); } } } /** * Send initial prompt when browser first connects * Format: [username@host directory]$ (ready for first command) */ private sendInitialPrompt(ws: import("ws").WebSocket, sessionName: string, userHost: string, directory: string): void { if (ws.readyState !== ws.OPEN) return; const initialPrompt = `[${userHost} ${directory}]$ `; ws.send( JSON.stringify({ type: "terminal_output", sessionName, timestamp: new Date().toISOString(), data: initialPrompt, source: "system", }), ); } /** * Send final prompt when no history exists * Format: [username@host directory]$ (cursor ready for input) */ private sendFinalPrompt(ws: import("ws").WebSocket, sessionName: string, userHost: string, directory: string): void { if (ws.readyState !== ws.OPEN) return; const finalPrompt = `[${userHost} ${directory}]$ `; ws.send( JSON.stringify({ type: "terminal_output", sessionName, timestamp: new Date().toISOString(), data: finalPrompt, source: "system", }), ); } // REMOVED: getCachedPatterns method // No longer needed since WebSocket echo suppression was removed // REMOVED: suppressWebSocketCommandEcho method // The SSH manager's broadcastCommandWithPrompt already handles command echo formatting correctly // No additional echo suppression is needed in the WebSocket layer // REMOVED: escapeRegex method (no longer used after echo suppression removal) }

Latest Blog Posts

MCP directory API

We provide all the information about MCP servers via our MCP API.

curl -X GET 'https://glama.ai/api/mcp/v1/servers/LightspeedDMS/ssh-mcp'

If you have feedback or need assistance with the MCP directory API, please join our Discord server