/** * Terminal Service - Manages PTY processes with HTTP polling for output * This version uses HTTP polling instead of WebSocket to bypass nginx issues */ const { spawn } = require('node-pty'); const { Server } = require('ws'); const fs = require('fs').promises; const path = require('path'); class TerminalService { constructor() { this.terminals = new Map(); // terminalId -> { pty, sessionId, workingDir, mode, createdAt, outputBuffer, lastPollIndex } this.wsServer = null; this.logFile = path.join(process.env.HOME, 'obsidian-vault', '.claude-ide', 'terminal-logs.jsonl'); this.pingInterval = null; this.maxBufferSize = 10000; // Maximum buffer size per terminal this.maxOutputAge = 300000; // 5 minutes - max age for output in buffer } /** * Setup WebSocket server (kept for compatibility, but not used for terminal I/O) */ createServer(httpServer) { this.wsServer = new Server({ noServer: true }); // Handle WebSocket upgrade requests httpServer.on('upgrade', (request, socket, head) => { const pathname = new URL(request.url, `http://${request.headers.host}`).pathname; // Match /claude/api/terminals/:id/ws const terminalMatch = pathname.match(/^\/claude\/api\/terminals\/([^/]+)\/ws$/); if (terminalMatch) { const terminalId = terminalMatch[1]; console.log(`[TerminalService] Handling WebSocket upgrade for terminal ${terminalId}`); this.wsServer.handleUpgrade(request, socket, head, (ws) => { this.wsServer.emit('connection', ws, request, terminalId); }); } }); // Handle WebSocket connections this.wsServer.on('connection', (ws, request, terminalId) => { console.log(`[TerminalService] WebSocket connection event received for terminal ${terminalId}`); this.handleConnection(terminalId, ws); }); // Setup ping interval to keep connections alive this.setupPingInterval(); console.log('[TerminalService] WebSocket server initialized (legacy, polling preferred)'); } /** * Setup ping interval to keep WebSocket connections alive */ setupPingInterval() { // Send ping to all clients every 30 seconds this.pingInterval = setInterval(() => { if (this.wsServer) { this.wsServer.clients.forEach((ws) => { if (ws.readyState === ws.OPEN) { ws.ping(); } }); } }, 30000); console.log('[TerminalService] Ping interval configured (30s)'); } /** * Create a new terminal PTY */ createTerminal(options) { const { workingDir = process.env.HOME, sessionId = null, mode = 'mixed', shell = process.env.SHELL || '/bin/bash' } = options; // Generate unique terminal ID const terminalId = `term-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; try { console.log(`[TerminalService] Attempting to spawn PTY with shell: ${shell}, cwd: ${workingDir}`); // Spawn PTY process const pty = spawn(shell, [], { name: 'xterm-color', cols: 80, rows: 24, cwd: workingDir, env: process.env }); console.log(`[TerminalService] PTY spawned with PID: ${pty.pid}, shell: ${shell}`); // Store terminal info with output buffer for polling const terminal = { id: terminalId, pty, sessionId, workingDir, mode, // 'session', 'shell', or 'mixed' createdAt: new Date().toISOString(), lastActivity: new Date().toISOString(), outputBuffer: [], // Array of { index, timestamp, data, type } outputIndex: 0, // Monotonically increasing index lastPollTime: new Date().toISOString() }; this.terminals.set(terminalId, terminal); // Buffer PTY output for polling this.bufferOutput(terminalId, pty); // Log terminal creation this.logCommand(terminalId, null, `Terminal created in ${workingDir} (mode: ${mode})`); console.log(`[TerminalService] Created terminal ${terminalId} in ${workingDir} with HTTP polling enabled`); return { success: true, terminalId, terminal }; } catch (error) { console.error(`[TerminalService] Failed to create terminal:`, error); console.error(`[TerminalService] Error stack:`, error.stack); console.error(`[TerminalService] Error name:`, error.name); console.error(`[TerminalService] Error message:`, error.message); return { success: false, error: error.message }; } } /** * Buffer PTY output for HTTP polling */ bufferOutput(terminalId, pty) { const terminal = this.terminals.get(terminalId); if (!terminal) return; // Handle PTY output - buffer it for polling pty.onData((data) => { try { console.log(`[TerminalService] PTY data from ${terminalId}: ${data.replace(/\n/g, '\\n').replace(/\r/g, '\\r')}`); // Add to buffer with index terminal.outputBuffer.push({ index: terminal.outputIndex++, timestamp: Date.now(), type: 'data', data: data }); // Trim buffer if too large if (terminal.outputBuffer.length > this.maxBufferSize) { const removedCount = terminal.outputBuffer.length - this.maxBufferSize; terminal.outputBuffer = terminal.outputBuffer.slice(removedCount); console.log(`[TerminalService] Trimmed buffer for ${terminalId}, removed ${removedCount} old entries`); } terminal.lastActivity = new Date().toISOString(); } catch (error) { console.error(`[TerminalService] ERROR buffering PTY data:`, error); } }); // Handle PTY exit pty.onExit(({ exitCode, signal }) => { console.log(`[TerminalService] PTY EXIT for ${terminalId}: exitCode=${exitCode}, signal=${signal}`); this.logCommand(terminalId, null, `Terminal exited: ${exitCode || signal}`); // Add exit message to buffer terminal.outputBuffer.push({ index: terminal.outputIndex++, timestamp: Date.now(), type: 'exit', exitCode, signal }); terminal.lastActivity = new Date().toISOString(); }); } /** * Handle WebSocket connection for terminal I/O (legacy, kept for compatibility) */ handleConnection(terminalId, ws) { const terminal = this.terminals.get(terminalId); if (!terminal) { console.error(`[TerminalService] TERMINAL NOT FOUND: ${terminalId}`); ws.close(1008, 'Terminal not found'); return; } terminal.lastActivity = new Date().toISOString(); console.log(`[TerminalService] WebSocket connected for terminal ${terminalId} (legacy mode)`); console.log(`[TerminalService] Terminal info - workingDir: ${terminal.workingDir}, mode: ${terminal.mode}`); // Handle incoming messages from client (user input) ws.on('message', (data) => { console.log(`[TerminalService] Message received from ${terminalId}: ${data.toString()}`); try { const message = JSON.parse(data); console.log(`[TerminalService] Parsed message type: ${message.type}`); if (message.type === 'input') { // User typed something - send to PTY console.log(`[TerminalService] Writing to PTY: "${message.data.replace(/\n/g, '\\n')}"`); terminal.pty.write(message.data); terminal.lastActivity = new Date().toISOString(); } else if (message.type === 'resize') { // Handle terminal resize console.log(`[TerminalService] Resize to ${message.cols}x${message.rows}`); terminal.pty.resize(message.cols, message.rows); } } catch (error) { console.error('[TerminalService] Error handling message:', error); } }); // Handle WebSocket ping (respond with pong) ws.on('ping', () => { console.log(`[TerminalService] Ping received from ${terminalId}`); try { ws.pong(); } catch (error) { console.error(`[TerminalService] Error sending pong:`, error); } }); // Handle WebSocket pong (response to our ping) ws.on('pong', () => { console.log(`[TerminalService] Pong received from ${terminalId}`); terminal.lastActivity = new Date().toISOString(); }); // Handle WebSocket close ws.on('close', (code, reason) => { console.log(`[TerminalService] WebSocket closed for terminal ${terminalId} - code: ${code}, reason: ${reason || 'none'}`); // Don't kill PTY - it continues with HTTP polling }); // Handle WebSocket error ws.on('error', (error) => { console.error(`[TerminalService] ✖✖✖ WebSocket ERROR for terminal ${terminalId} ✖✖✖`); console.error(`[TerminalService] Error:`, error); }); // Send initial welcome message const readyMessage = JSON.stringify({ type: 'ready', terminalId, workingDir: terminal.workingDir, mode: terminal.mode, polling: true // Indicate polling is available }); console.log(`[TerminalService] Sending ready message to ${terminalId}: ${readyMessage}`); try { ws.send(readyMessage); console.log(`[TerminalService] Ready message sent successfully`); } catch (error) { console.error(`[TerminalService] Error sending ready message:`, error); } } /** * Get terminal output via HTTP polling (replaces WebSocket for output) */ getTerminalOutput(terminalId, sinceIndex = 0) { const terminal = this.terminals.get(terminalId); if (!terminal) { return { success: false, error: 'Terminal not found' }; } try { // Get output entries since the specified index const newOutput = terminal.outputBuffer.filter(entry => entry.index > sinceIndex); // Clean up old output based on age const now = Date.now(); terminal.outputBuffer = terminal.outputBuffer.filter(entry => { // Keep recent output and output that hasn't been polled yet return (now - entry.timestamp) < this.maxOutputAge || entry.index > sinceIndex; }); terminal.lastPollTime = new Date().toISOString(); return { success: true, output: newOutput, currentIndex: terminal.outputIndex - 1, hasMore: newOutput.length > 0 }; } catch (error) { console.error(`[TerminalService] Error getting terminal output:`, error); return { success: false, error: error.message }; } } /** * Resize terminal via HTTP */ resizeTerminal(terminalId, cols, rows) { const terminal = this.terminals.get(terminalId); if (!terminal) { return { success: false, error: 'Terminal not found' }; } if (!terminal.pty) { return { success: false, error: 'PTY not found' }; } try { terminal.pty.resize(cols, rows); terminal.lastActivity = new Date().toISOString(); console.log(`[TerminalService] Resized terminal ${terminalId} to ${cols}x${rows}`); return { success: true }; } catch (error) { console.error(`[TerminalService] Error resizing terminal ${terminalId}:`, error); return { success: false, error: error.message }; } } /** * Attach terminal to a Claude Code session * Pipes session stdout/stderr to PTY */ attachToSession(terminalId, session) { const terminal = this.terminals.get(terminalId); if (!terminal) { return { success: false, error: 'Terminal not found' }; } if (!session || !session.process) { return { success: false, error: 'Invalid session' }; } terminal.sessionId = session.id; terminal.mode = 'session'; // Pipe session output to PTY if (session.process.stdout) { session.process.stdout.on('data', (data) => { if (terminal.pty) { terminal.pty.write(data.toString()); } }); } if (session.process.stderr) { session.process.stderr.on('data', (data) => { if (terminal.pty) { // Write stderr in red terminal.pty.write(`\x1b[31m${data.toString()}\x1b[0m`); } }); } this.logCommand(terminalId, null, `Attached to session ${session.id}`); console.log(`[TerminalService] Terminal ${terminalId} attached to session ${session.id}`); return { success: true }; } /** * Set terminal mode (session/shell/mixed) */ setTerminalMode(terminalId, mode) { const terminal = this.terminals.get(terminalId); if (!terminal) { return { success: false, error: 'Terminal not found' }; } terminal.mode = mode; terminal.lastActivity = new Date().toISOString(); // Add mode change to buffer so polling clients see it terminal.outputBuffer.push({ index: terminal.outputIndex++, timestamp: Date.now(), type: 'modeChanged', mode }); return { success: true, mode }; } /** * Send input to terminal via HTTP (WebSocket workaround) */ sendTerminalInput(terminalId, data) { const terminal = this.terminals.get(terminalId); if (!terminal) { return { success: false, error: 'Terminal not found' }; } if (!terminal.pty) { return { success: false, error: 'PTY not found' }; } try { // Write directly to PTY terminal.pty.write(data); terminal.lastActivity = new Date().toISOString(); console.log(`[TerminalService] Wrote to PTY ${terminalId}: ${data.replace(/\n/g, '\\n')}`); return { success: true }; } catch (error) { console.error(`[TerminalService] Error writing to PTY ${terminalId}:`, error); return { success: false, error: error.message }; } } /** * Close terminal and kill PTY process */ closeTerminal(terminalId) { const terminal = this.terminals.get(terminalId); if (!terminal) { return { success: false, error: 'Terminal not found' }; } try { // Kill PTY process if (terminal.pty) { terminal.pty.kill(); } this.terminals.delete(terminalId); this.logCommand(terminalId, null, 'Terminal closed'); console.log(`[TerminalService] Terminal ${terminalId} closed`); return { success: true }; } catch (error) { console.error(`[TerminalService] Error closing terminal ${terminalId}:`, error); return { success: false, error: error.message }; } } /** * Get list of active terminals */ listTerminals() { const terminals = []; for (const [id, terminal] of this.terminals.entries()) { terminals.push({ id, workingDir: terminal.workingDir, sessionId: terminal.sessionId, mode: terminal.mode, createdAt: terminal.createdAt, lastActivity: terminal.lastActivity }); } return terminals; } /** * Get terminal by ID */ getTerminal(terminalId) { const terminal = this.terminals.get(terminalId); if (!terminal) { return { success: false, error: 'Terminal not found' }; } return { success: true, terminal: { id: terminal.id, workingDir: terminal.workingDir, sessionId: terminal.sessionId, mode: terminal.mode, createdAt: terminal.createdAt, lastActivity: terminal.lastActivity } }; } /** * Log command to file */ async logCommand(terminalId, command, action) { try { const logEntry = { timestamp: new Date().toISOString(), terminalId, command: command || action, user: process.env.USER || 'unknown' }; // Ensure directory exists const logDir = path.dirname(this.logFile); await fs.mkdir(logDir, { recursive: true }); // Append to log file await fs.appendFile(this.logFile, JSON.stringify(logEntry) + '\n'); } catch (error) { console.error('[TerminalService] Failed to log command:', error); } } /** * Cleanup all terminals (called on server shutdown) */ async cleanup() { console.log('[TerminalService] Cleaning up all terminals...'); // Clear ping interval if (this.pingInterval) { clearInterval(this.pingInterval); this.pingInterval = null; } for (const [id, terminal] of this.terminals.entries()) { try { if (terminal.pty) { terminal.pty.kill(); } } catch (error) { console.error(`[TerminalService] Error cleaning up terminal ${id}:`, error); } } this.terminals.clear(); // Close WebSocket server if (this.wsServer) { this.wsServer.close(); } console.log('[TerminalService] Cleanup complete'); } } // Export singleton instance const terminalService = new TerminalService(); module.exports = terminalService;