Skip to main content
Glama
terminal-session-state-manager.ts8.03 kB
/** * Terminal Session State Manager * * CRITICAL PURPOSE: Prevent command execution duplication between MCP and browser paths * * FUNDAMENTAL PROBLEM SOLVED: * - MCP path: handleSSHExec() → sshManager.executeCommand() * - Browser path: handleTerminalInputMessage() → sshManager.executeCommand() * - BOTH paths execute on same SSH session causing duplication * * SOLUTION: Simple state machine with SINGLE execution path control */ import { Logger, log } from './logger.js'; /** * Current command execution context */ interface CurrentCommand { command: string; commandId: string; initiator: 'mcp' | 'browser'; startTime: number; } /** * Terminal session state representation */ interface TerminalSessionState { sessionName: string; state: 'WAITING_FOR_COMMAND' | 'EXECUTING_COMMAND'; currentCommand?: CurrentCommand; } /** * State transition validation errors */ export class SessionBusyError extends Error { constructor(sessionName: string, currentCommand: CurrentCommand) { super(`Session '${sessionName}' is busy executing command: ${currentCommand.command} (initiated by ${currentCommand.initiator})`); this.name = 'SessionBusyError'; } } export class SessionNotFoundError extends Error { constructor(sessionName: string) { super(`Session '${sessionName}' not found in state manager`); this.name = 'SessionNotFoundError'; } } /** * Terminal Session State Manager * * Implements simple state machine to prevent command execution duplication: * - WAITING_FOR_COMMAND: Can accept new commands * - EXECUTING_COMMAND: Rejects new commands until completion * * GOLDEN RULE: Only ONE command executes at a time per session */ export class TerminalSessionStateManager { private sessionStates = new Map<string, TerminalSessionState>(); constructor() { // Initialize logger for state management - use null for testing environments if (!Logger.getInstance()) { Logger.initialize('null', 'TerminalStateManager'); } } /** * Start command execution transition * * @param sessionName - SSH session identifier * @param command - Command to execute * @param commandId - Unique command identifier * @param initiator - Source of command ('mcp' | 'browser') * @returns true if command can start, false if session busy * @throws SessionBusyError if session is already executing command */ startCommandExecution( sessionName: string, command: string, commandId: string, initiator: 'mcp' | 'browser' ): boolean { const sessionState = this.getOrCreateSessionState(sessionName); // Check if session can accept new commands if (sessionState.state === 'EXECUTING_COMMAND') { if (sessionState.currentCommand) { throw new SessionBusyError(sessionName, sessionState.currentCommand); } else { // CRITICAL: Fail fast on corrupted state instead of silent recovery throw new Error(`CRITICAL: Session ${sessionName} in corrupted state - EXECUTING_COMMAND with no currentCommand`); } } // Transition to executing state sessionState.state = 'EXECUTING_COMMAND'; sessionState.currentCommand = { command, commandId, initiator, startTime: Date.now() }; log.debug(`Session ${sessionName}: Started executing command "${command}" (${commandId}) from ${initiator}`); return true; } /** * Complete command execution transition * * @param sessionName - SSH session identifier * @param commandId - Command identifier that completed */ completeCommandExecution(sessionName: string, commandId: string): void { const sessionState = this.sessionStates.get(sessionName); if (!sessionState) { log.warn(`Attempted to complete command ${commandId} for unknown session: ${sessionName}`); return; } // Verify command ID matches current command if (sessionState.currentCommand?.commandId !== commandId) { // CRITICAL: Fail fast on command ID mismatch instead of continuing throw new Error(`Command ID mismatch: expected ${sessionState.currentCommand?.commandId}, got ${commandId}`); } // Transition to waiting state const completedCommand = sessionState.currentCommand; sessionState.state = 'WAITING_FOR_COMMAND'; sessionState.currentCommand = undefined; if (completedCommand) { const executionTime = Date.now() - completedCommand.startTime; log.debug(`Session ${sessionName}: Completed command "${completedCommand.command}" (${commandId}) in ${executionTime}ms`); } } /** * Check if session can accept new commands * * @param sessionName - SSH session identifier * @returns true if session can accept commands */ canAcceptCommand(sessionName: string): boolean { const sessionState = this.sessionStates.get(sessionName); // Unknown sessions can accept commands (will be created) if (!sessionState) { return true; } return sessionState.state === 'WAITING_FOR_COMMAND'; } /** * Get current executing command for session * * @param sessionName - SSH session identifier * @returns Current command or null if none executing */ getCurrentCommand(sessionName: string): CurrentCommand | null { const sessionState = this.sessionStates.get(sessionName); return sessionState?.currentCommand || null; } /** * Get current session state * * @param sessionName - SSH session identifier * @returns Current state */ getSessionState(sessionName: string): 'WAITING_FOR_COMMAND' | 'EXECUTING_COMMAND' { const sessionState = this.sessionStates.get(sessionName); return sessionState?.state || 'WAITING_FOR_COMMAND'; } /** * Get or create session state */ private getOrCreateSessionState(sessionName: string): TerminalSessionState { let sessionState = this.sessionStates.get(sessionName); if (!sessionState) { sessionState = { sessionName, state: 'WAITING_FOR_COMMAND', currentCommand: undefined }; this.sessionStates.set(sessionName, sessionState); log.debug(`Created new session state for: ${sessionName}`); } return sessionState; } /** * Remove session from state tracking * Used when SSH session is disconnected * * @param sessionName - SSH session identifier */ removeSession(sessionName: string): void { const removed = this.sessionStates.delete(sessionName); if (removed) { log.debug(`Removed session state for: ${sessionName}`); } } /** * Force reset session to waiting state * Used for emergency recovery from stuck states * * @param sessionName - SSH session identifier */ forceResetSession(sessionName: string): void { const sessionState = this.sessionStates.get(sessionName); if (sessionState) { sessionState.state = 'WAITING_FOR_COMMAND'; sessionState.currentCommand = undefined; log.warn(`Force reset session state for: ${sessionName}`); } } /** * Get diagnostic information for all sessions * Used for debugging and monitoring */ getDiagnosticInfo(): Array<{ sessionName: string; state: string; currentCommand?: CurrentCommand; executionDuration?: number; }> { const diagnostics: Array<{ sessionName: string; state: string; currentCommand?: CurrentCommand; executionDuration?: number; }> = []; this.sessionStates.forEach((sessionState) => { const diagnostic = { sessionName: sessionState.sessionName, state: sessionState.state, currentCommand: sessionState.currentCommand, // Calculate execution duration for running commands executionDuration: sessionState.currentCommand ? Date.now() - sessionState.currentCommand.startTime : undefined }; diagnostics.push(diagnostic); }); return diagnostics; } }

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