Skip to main content
Glama
session-manager.jsโ€ข9.25 kB
/** * SSH Session Manager * Manages persistent SSH sessions with state and context */ import { v4 as uuidv4 } from 'uuid'; import { logger } from './logger.js'; // Map to store active sessions const sessions = new Map(); // Session states export const SESSION_STATES = { INITIALIZING: 'initializing', READY: 'ready', BUSY: 'busy', ERROR: 'error', CLOSED: 'closed' }; class SSHSession { constructor(id, serverName, ssh) { this.id = id; this.serverName = serverName; this.ssh = ssh; this.state = SESSION_STATES.INITIALIZING; this.context = { cwd: null, env: {}, history: [], variables: {} }; this.createdAt = new Date(); this.lastActivity = new Date(); this.shell = null; this.outputBuffer = ''; this.errorBuffer = ''; } /** * Initialize the session with a shell */ async initialize() { try { logger.info(`Initializing SSH session ${this.id}`, { server: this.serverName }); // Start an interactive shell this.shell = await this.ssh.requestShell({ term: 'xterm-256color', cols: 80, rows: 24 }); // Setup event handlers this.shell.on('data', (data) => { this.outputBuffer += data.toString(); this.lastActivity = new Date(); // Log output in verbose mode if (logger.verbose) { logger.debug(`Session ${this.id} output`, { data: data.toString().substring(0, 200) }); } }); this.shell.on('close', () => { logger.info(`Session ${this.id} shell closed`); this.state = SESSION_STATES.CLOSED; this.cleanup(); }); this.shell.stderr.on('data', (data) => { this.errorBuffer += data.toString(); logger.warn(`Session ${this.id} stderr`, { error: data.toString() }); }); // Wait for shell prompt await this.waitForPrompt(); // Allow context queries through standard execute flow this.state = SESSION_STATES.READY; // Get initial working directory await this.updateContext(); logger.info(`Session ${this.id} initialized`, { server: this.serverName, cwd: this.context.cwd }); } catch (error) { this.state = SESSION_STATES.ERROR; logger.error(`Failed to initialize session ${this.id}`, { error: error.message }); throw error; } } /** * Wait for shell prompt */ async waitForPrompt(timeout = 5000) { const startTime = Date.now(); while (Date.now() - startTime < timeout) { // Check if we have a prompt (ends with $ or # typically) if (this.outputBuffer.match(/[$#>]\s*$/)) { return true; } // Wait a bit await new Promise(resolve => setTimeout(resolve, 100)); } throw new Error('Timeout waiting for shell prompt'); } /** * Update session context (pwd, env) */ async updateContext() { try { // Get current directory const pwdResult = await this.execute('pwd', { silent: true }); if (pwdResult.success) { this.context.cwd = pwdResult.output.trim(); } // Get environment variables (selective) const envResult = await this.execute('echo $PATH:$USER:$HOME', { silent: true }); if (envResult.success) { const [path, user, home] = envResult.output.trim().split(':'); this.context.env = { PATH: path, USER: user, HOME: home }; } } catch (error) { logger.warn(`Failed to update context for session ${this.id}`, { error: error.message }); } } /** * Execute a command in the session */ async execute(command, options = {}) { if (this.state !== SESSION_STATES.READY) { throw new Error(`Session ${this.id} is not ready (state: ${this.state})`); } this.state = SESSION_STATES.BUSY; this.lastActivity = new Date(); try { // Clear buffers this.outputBuffer = ''; this.errorBuffer = ''; // Add to history unless silent if (!options.silent) { this.context.history.push({ command, timestamp: new Date(), cwd: this.context.cwd }); logger.info(`Session ${this.id} executing`, { command: command.substring(0, 100), server: this.serverName }); } // Send command this.shell.write(command + '\n'); // Wait for command to complete await this.waitForPrompt(options.timeout || 30000); // Parse output (remove command echo and prompt) let output = this.outputBuffer; // Remove the command echo (first line) const lines = output.split('\n'); if (lines[0].includes(command)) { lines.shift(); } // Remove the prompt (last line) const lastLine = lines[lines.length - 1]; if (lastLine.match(/[$#>]\s*$/)) { lines.pop(); } output = lines.join('\n').trim(); // Check for command success (basic heuristic) const success = !this.errorBuffer && !output.includes('command not found'); // Update context if command might have changed it if (command.startsWith('cd ') || command.startsWith('export ')) { await this.updateContext(); } this.state = SESSION_STATES.READY; return { success, output, error: this.errorBuffer, session: this.id }; } catch (error) { this.state = SESSION_STATES.ERROR; logger.error(`Session ${this.id} execution failed`, { command, error: error.message }); throw error; } } /** * Set session variable */ setVariable(name, value) { this.context.variables[name] = value; this.lastActivity = new Date(); } /** * Get session variable */ getVariable(name) { return this.context.variables[name]; } /** * Get session info */ getInfo() { return { id: this.id, server: this.serverName, state: this.state, cwd: this.context.cwd, env: this.context.env, created: this.createdAt, lastActivity: this.lastActivity, historyCount: this.context.history.length, variables: Object.keys(this.context.variables) }; } /** * Close the session */ close() { logger.info(`Closing session ${this.id}`); if (this.shell) { this.shell.write('exit\n'); this.shell.end(); this.shell = null; } this.state = SESSION_STATES.CLOSED; this.cleanup(); } /** * Cleanup resources */ cleanup() { sessions.delete(this.id); this.outputBuffer = ''; this.errorBuffer = ''; this.context.history = []; } } /** * Create a new SSH session */ export async function createSession(serverName, ssh) { const sessionId = `ssh_${Date.now()}_${uuidv4().substring(0, 8)}`; const session = new SSHSession(sessionId, serverName, ssh); sessions.set(sessionId, session); try { await session.initialize(); logger.info('SSH session created', { id: sessionId, server: serverName }); return session; } catch (error) { sessions.delete(sessionId); throw error; } } /** * Get an existing session */ export function getSession(sessionId) { const session = sessions.get(sessionId); if (!session) { throw new Error(`Session ${sessionId} not found`); } if (session.state === SESSION_STATES.CLOSED) { throw new Error(`Session ${sessionId} is closed`); } return session; } /** * List all active sessions */ export function listSessions() { const activeSessions = []; for (const [id, session] of sessions.entries()) { if (session.state !== SESSION_STATES.CLOSED) { activeSessions.push(session.getInfo()); } } return activeSessions; } /** * Close a session */ export function closeSession(sessionId) { const session = sessions.get(sessionId); if (!session) { throw new Error(`Session ${sessionId} not found`); } session.close(); return true; } /** * Close all sessions for a server */ export function closeServerSessions(serverName) { let closedCount = 0; for (const [id, session] of sessions.entries()) { if (session.serverName === serverName) { session.close(); closedCount++; } } return closedCount; } /** * Cleanup old sessions */ export function cleanupSessions(maxAge = 30 * 60 * 1000) { // 30 minutes default const now = Date.now(); let cleanedCount = 0; for (const [id, session] of sessions.entries()) { const age = now - session.lastActivity.getTime(); if (age > maxAge) { logger.info(`Cleaning up inactive session ${id}`, { age: Math.floor(age / 1000) + 's' }); session.close(); cleanedCount++; } } return cleanedCount; } // Periodic cleanup of inactive sessions setInterval(() => { const cleaned = cleanupSessions(); if (cleaned > 0) { logger.info(`Cleaned up ${cleaned} inactive sessions`); } }, 5 * 60 * 1000); // Every 5 minutes export default { createSession, getSession, listSessions, closeSession, closeServerSessions, cleanupSessions, SESSION_STATES };

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/bvisible/mcp-ssh-manager'

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