Skip to main content
Glama
tmuxManager.ts18.7 kB
/** * TmuxManager - Responsible for managing the tmux server and interactions with it */ import { spawn } from 'child_process'; import * as path from 'path'; import * as os from 'os'; import { v4 as uuidv4 } from 'uuid'; import { setTimeout as sleep } from 'timers/promises'; import { KeyMutex } from '../utils/mutex.js'; // Interface for window information export interface TmuxWindow { id: string; // Window ID (e.g., @0, @1, etc.) name: string; // Window name (user defined or auto-generated) active: boolean; // Whether this window is currently active } export class TmuxManager { private socketPath: string; private sessionName: string; private tmuxProcess: ReturnType<typeof spawn> | null = null; // reserved for future long-lived server mgmt private initialized: boolean = false; // Per-pane serialization to prevent interleaving send-keys/capture operations private paneMutex = new KeyMutex(); constructor() { // Create unique socket path to avoid conflicts with user's tmux const tempDir = os.tmpdir(); this.socketPath = path.join(tempDir, `tmux-mcp-${uuidv4()}.sock`); // Session name used for the managed tmux session this.sessionName = 'mcp-terminally'; } /** * Run a process and capture its stdout/stderr. Reject on non-zero exit. */ private spawnAndCapture(cmd: string, args: string[]): Promise<{ stdout: string; stderr: string; code: number }> { return new Promise((resolve, reject) => { const child = spawn(cmd, args, { stdio: ['ignore', 'pipe', 'pipe'] }); let stdout = ''; let stderr = ''; child.stdout.on('data', (d) => (stdout += d.toString())); child.stderr.on('data', (d) => (stderr += d.toString())); child.on('error', (err) => reject(err)); child.on('close', (code) => { const c = code ?? 0; if (c === 0) { resolve({ stdout, stderr, code: c }); } else { reject(new Error(`${cmd} exited with code ${c}: ${stderr || stdout}`)); } }); }); } /** * Convenience wrapper to call tmux with our socket path using argv. */ private tmux(args: string[]) { return this.spawnAndCapture('tmux', ['-S', this.socketPath, ...args]); } /** * Check if tmux is installed on the system */ async checkTmuxInstalled(): Promise<boolean> { try { await this.spawnAndCapture('tmux', ['-V']); return true; } catch (error) { throw new Error('tmux is not installed. Please install tmux to use this MCP server.'); } } /** * Start tmux server and create the main session. * Also configure default shell, login shell behavior, and history limit. */ async startServer(): Promise<void> { if (this.initialized) { return; } try { // Create a new session; this will start the server on the custom socket if needed. try { await this.tmux(['new-session', '-d', '-s', this.sessionName, '-n', 'default']); } catch (e: any) { const msg = String(e?.message || e); // Ignore duplicate session errors (server already running with our session) if (!/duplicate session|session already exists/i.test(msg)) { throw e; } } // Configure defaults for subsequent windows/panes const bash = '/bin/bash'; await this.tmux(['set-option', '-g', 'default-shell', bash]); await this.tmux(['set-option', '-g', 'default-command', `${bash} -l`]); await this.tmux(['set-option', '-g', 'history-limit', '50000']); // Help shells treat large pastes atomically (reduce line-edit artifacts) await this.tmux(['set-option', '-g', 'assume-paste-time', '1']); this.initialized = true; // eslint-disable-next-line no-console console.log(`tmux server started with socket: ${this.socketPath}`); } catch (error) { // eslint-disable-next-line no-console console.error('Failed to start tmux server:', error); throw error; } } /** * Stop tmux server */ async stopServer(): Promise<void> { if (!this.initialized) { return; } try { await this.tmux(['kill-server']); this.initialized = false; // eslint-disable-next-line no-console console.log('tmux server stopped'); } catch (error) { // eslint-disable-next-line no-console console.error('Failed to stop tmux server:', error); // Don't throw here, as we're typically calling this during shutdown } } /** * Create a new tab (window in tmux terminology) */ async createTab(options?: { name?: string; cwd?: string; env?: Record<string, string>; login?: boolean }): Promise<{ window_id: string; name: string }> { if (!this.initialized) { await this.startServer(); } const windowName = options?.name || `tab-${Date.now()}`; const safeName = windowName.replace(/#/g, '##'); // escape '#' to prevent tmux format expansion try { // Build command array for new-window const args = [ 'new-window', '-d', '-t', this.sessionName, '-n', safeName, '-P', '-F', '#{window_id}', ]; // Add working directory if specified if (options?.cwd) { args.push('-c', options.cwd); } // Create a new window in the session and print its window_id atomically const { stdout } = await this.tmux(args); const windowId = stdout.trim().split('\n').slice(-1)[0].trim(); // Set environment variables if specified if (options?.env) { for (const [key, value] of Object.entries(options.env)) { await this.tmux(['send-keys', '-t', windowId, `export ${key}="${value}"`, 'Enter']); } } return { window_id: windowId, name: safeName }; } catch (error) { // eslint-disable-next-line no-console console.error('Failed to create tab:', error); throw error; } } /** * Close a specific tab (window) */ async closeTab(windowId: string): Promise<void> { if (!this.initialized) { throw new Error('tmux server not initialized'); } try { await this.tmux(['kill-window', '-t', windowId]); } catch (error) { // eslint-disable-next-line no-console console.error(`Failed to close tab ${windowId}:`, error); throw error; } } /** * List all tabs (windows) */ async listTabs(): Promise<TmuxWindow[]> { if (!this.initialized) { await this.startServer(); } try { const { stdout } = await this.tmux([ 'list-windows', '-t', this.sessionName, '-F', '#{window_id}\t#{window_name}\t#{window_active}', ]); const windows: TmuxWindow[] = []; const raw = stdout.trim(); if (!raw) return windows; const lines = raw.split('\n'); for (const line of lines) { const parts = line.split('\t'); const id = parts[0]; const name = parts.slice(1, parts.length - 1).join('\t'); // preserve any tabs inside name const activeStr = parts[parts.length - 1]; windows.push({ id, name, active: activeStr === '1', }); } return windows; } catch (error) { // eslint-disable-next-line no-console console.error('Failed to list tabs:', error); throw error; } } /** * Execute a command in a specific tab (window) and capture its output using markers. * Commands are executed directly in the shell session, preserving working directory and environment. */ async executeCommand(windowId: string, command: string, timeout: number = 10000, stripAnsi: boolean = false): Promise<{ output: string; exit_code: number; timed_out: boolean }> { if (!this.initialized) { throw new Error('tmux server not initialized'); } const startMarker = `MCP_START_MARKER_${uuidv4()}`; const endMarker = `MCP_END_MARKER_${uuidv4()}`; const exitCodeMarker = `_EXIT_CODE:`; return this.paneMutex.runExclusive(windowId, async () => { try { // Execute command directly in the shell session to preserve state await this.tmux(['send-keys', '-t', windowId, `echo '${startMarker}'`, 'Enter']); await sleep(100); // Give time for the command to execute await this.tmux(['send-keys', '-t', windowId, command, 'Enter']); await sleep(100); // Give time for the command to execute await this.tmux(['send-keys', '-t', windowId, `echo '${endMarker}${exitCodeMarker}'$?`, 'Enter']); await sleep(100); // Give time for the command to execute // Poll pane until END marker or timeout let paneDump = ''; let timedOut = false; const startTime = Date.now(); while (Date.now() - startTime < timeout) { const { stdout } = await this.tmux(['capture-pane', '-J', '-p', '-S', '-', '-E', '-', '-t', windowId]); if (stdout.includes(endMarker)) { paneDump = stdout; break; } await sleep(50); } // On timeout: interrupt the foreground process and capture what we have. if (!paneDump) { timedOut = true; await this.tmux(['send-keys', '-t', windowId, 'C-c']); await sleep(50); const { stdout } = await this.tmux(['capture-pane', '-J', '-p', '-S', '-', '-E', '-', '-t', windowId]); paneDump = stdout; } // Normalize and parse const normalized = paneDump.replace(/\r/g, '').replace(/\u000c/g, ''); const lines = normalized.split('\n'); const startIdx = lines.findIndex((l) => l.trim() === startMarker); let endIdx = -1; let exitCode = 0; for (let i = startIdx + 1; i < lines.length; i++) { if (lines[i].includes(endMarker)) { endIdx = i; // Extract exit code from the end marker line const exitCodeMatch = lines[i].match(new RegExp(`${exitCodeMarker}(\\d+)`)); if (exitCodeMatch) { exitCode = parseInt(exitCodeMatch[1], 10); } break; } } if (startIdx === -1) { // Never saw start marker; nothing reliable to return return { output: '', exit_code: timedOut ? 130 : 0, timed_out: timedOut }; } const payloadLines = endIdx !== -1 ? lines.slice(startIdx + 1, endIdx) : lines.slice(startIdx + 1); // Filter out shell prompts and command echoes but keep actual output const filteredLines = payloadLines.filter((line) => { const trimmed = line.trim(); if (!trimmed) return false; // Remove shell prompts (various formats) if (trimmed.includes('$') && (trimmed.includes('danielsteigman') || trimmed.includes('MacBook'))) return false; if (trimmed.startsWith('(base)') && trimmed.includes('$')) return false; // Remove command echoes (lines that exactly match our command) if (trimmed === command) return false; // Remove printf/echo marker commands if (trimmed.includes('printf') && trimmed.includes('MCP_')) return false; if (trimmed.includes('echo') && trimmed.includes('MCP_')) return false; return true; }); let payload = filteredLines.join('\n').trim(); // Strip ANSI sequences if requested if (stripAnsi) { payload = this.stripAnsiSequences(payload); } return { output: payload, exit_code: exitCode, timed_out: timedOut }; } catch (error) { // eslint-disable-next-line no-console console.error(`Failed to execute command in tab ${windowId}:`, error); if (error instanceof Error) { throw new Error(`Error executing command '${command}': ${error.message}`); } else { throw new Error(`An unknown error occurred while executing command '${command}'.`); } } }); } /** * Read output from a specific tab (window), attempting to clean it. * Phase 1: Use argv-based tmux and serialize with mutex. */ async readOutput(windowId: string, historyLimit?: number): Promise<string> { if (!this.initialized) { throw new Error('tmux server not initialized'); } return this.paneMutex.runExclusive(windowId, async () => { try { const args: string[] = ['capture-pane', '-p']; if (historyLimit && historyLimit > 0) { // -S -N gets the last N lines from history. args.push('-S', `-${historyLimit}`); } else { // Capture entire visible pane + history by default if no limit args.push('-S', '-', '-E', '-'); } args.push('-t', windowId); // Execute and get the pane content const { stdout } = await this.tmux(args); // Clean the output: remove prompt lines, MCP markers, and empty lines const lines = stdout.split('\n'); const cleanedLines = lines .map((line) => line.trimEnd()) // Trim trailing whitespace .filter((line) => { if (!line) return false; // Remove empty lines // Remove common shell prompt artifacts if (line.includes('➜') || line.startsWith('$') || line.startsWith('#')) return false; // Remove our bounded-exec marker lines if (line.includes('MCP_START_MARKER') || line.includes('MCP_END_MARKER')) return false; // Remove heredoc injection scaffolding from executeCommand // e.g., "cat <<'__MCP_...'" line, the heredoc terminator "__MCP_...", and any lines containing the token if (/^cat <<'.*__MCP_[A-Za-z0-9]+' \| bash -lc/.test(line)) return false; if (/^__MCP_[A-Za-z0-9]+$/.test(line.trim())) return false; if (line.includes('__MCP_')) return false; // Remove printf lines that print our markers and script control lines if (line.includes("printf '%s\\n' 'MCP_START_MARKER") || line.includes("printf '%s\\n' 'MCP_END_MARKER")) return false; // Remove execution control artifacts from the injected script if (line.includes('__EC=$?') || line.includes('exit $__EC')) return false; return true; }); return cleanedLines.join('\n').trim(); // Trim final result } catch (error) { // eslint-disable-next-line no-console console.error(`Failed to read output from tab ${windowId}:`, error); throw new Error(`Failed to read output: ${(error as Error).message}`); } }); } /** * Read logs from a tab's pipe-pane file (line-count oriented) */ async readLogsFromTab(windowId: string, lines: number = 500, stripAnsi: boolean = false): Promise<{ content: string; returned_lines: number; truncated: boolean }> { if (!this.initialized) { throw new Error('tmux server not initialized'); } return this.paneMutex.runExclusive(windowId, async () => { try { // For now, use capture-pane as a fallback until pipe-pane is implemented const args: string[] = ['capture-pane', '-p']; if (lines > 0) { args.push('-S', `-${lines}`); } else { args.push('-S', '-', '-E', '-'); } args.push('-t', windowId); const { stdout } = await this.tmux(args); let content = stdout; if (stripAnsi) { content = this.stripAnsiSequences(content); } const contentLines = content.split('\n'); const returnedLines = contentLines.length; const truncated = false; // TODO: implement proper truncation detection return { content: content.trim(), returned_lines: returnedLines, truncated }; } catch (error) { console.error(`Failed to read logs from tab ${windowId}:`, error); throw new Error(`Failed to read logs: ${(error as Error).message}`); } }); } /** * Start a long-running process in a tab */ async startProcess(windowId: string, command: string, appendNewline: boolean = true): Promise<{ started: boolean }> { if (!this.initialized) { throw new Error('tmux server not initialized'); } return this.paneMutex.runExclusive(windowId, async () => { try { await this.tmux(['send-keys', '-t', windowId, command]); if (appendNewline) { await this.tmux(['send-keys', '-t', windowId, 'Enter']); } return { started: true }; } catch (error) { console.error(`Failed to start process in tab ${windowId}:`, error); throw new Error(`Failed to start process: ${(error as Error).message}`); } }); } /** * Stop a process in a tab by sending a signal. Can also close the tab by killing the main process. */ async stopProcess(windowId: string, signal: string = 'SIGINT'): Promise<{ success: boolean }> { if (!this.initialized) { throw new Error('tmux server not initialized'); } return this.paneMutex.runExclusive(windowId, async () => { try { if (signal === 'SIGINT') { // Send Ctrl+C to interrupt the current process await this.tmux(['send-keys', '-t', windowId, 'C-c']); // Then send exit to close the shell (which closes the tab) await sleep(100); await this.tmux(['send-keys', '-t', windowId, 'exit', 'Enter']); } else if (signal === 'SIGTERM') { // Send Ctrl+\ for SIGTERM-like behavior, then exit await this.tmux(['send-keys', '-t', windowId, 'C-\\']); await sleep(100); await this.tmux(['send-keys', '-t', windowId, 'exit', 'Enter']); } return { success: true }; } catch (error) { console.error(`Failed to stop process in tab ${windowId}:`, error); throw new Error(`Failed to stop process: ${(error as Error).message}`); } }); } /** * Strip ANSI escape sequences from text */ private stripAnsiSequences(text: string): string { // Remove ANSI escape sequences return text.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, ''); } /** * Get the base tmux command with socket (for debugging/logging only) * Useful for constructing complex commands */ getTmuxBaseCommand(): string { return `tmux -S "${this.socketPath}"`; } /** * Get the session name */ getSessionName(): string { return this.sessionName; } /** * Check if the tmux server is initialized */ isInitialized(): boolean { return this.initialized; } }

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/NightTrek/Terminally-mcp'

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