/** * Terminal Service - Manages PTY processes and WebSocket connections */ 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, ws, sessionId, workingDir, mode, createdAt } this.wsServer = null; this.logFile = path.join(process.env.HOME, 'obsidian-vault', '.claude-ide', 'terminal-logs.jsonl'); } /** * Setup WebSocket server 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]; this.wsServer.handleUpgrade(request, socket, head, (ws) => { this.wsServer.emit('connection', ws, request, terminalId); }); } }); // Handle WebSocket connections this.wsServer.on('connection', (ws, request, terminalId) => { this.handleConnection(terminalId, ws); }); console.log('[TerminalService] WebSocket server initialized'); } /** * 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 { // Spawn PTY process const pty = spawn(shell, [], { name: 'xterm-color', cols: 80, rows: 24, cwd: workingDir, env: process.env }); // Store terminal info const terminal = { id: terminalId, pty, ws: null, sessionId, workingDir, mode, // 'session', 'shell', or 'mixed' createdAt: new Date().toISOString(), lastActivity: new Date().toISOString() }; this.terminals.set(terminalId, terminal); // Log terminal creation this.logCommand(terminalId, null, `Terminal created in ${workingDir} (mode: ${mode})`); console.log(`[TerminalService] Created terminal ${terminalId} in ${workingDir}`); return { success: true, terminalId, terminal }; } catch (error) { console.error(`[TerminalService] Failed to create terminal:`, error); return { success: false, error: error.message }; } } /** * Handle WebSocket connection for terminal I/O */ 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.ws = ws; terminal.lastActivity = new Date().toISOString(); console.log(`[TerminalService] WebSocket connected for terminal ${terminalId}`); 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(); // Log commands (basic detection of Enter key) if (message.data === '\r') { // Could extract and log command here } } 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 PTY output - send to client terminal.pty.onData((data) => { if (ws.readyState === ws.OPEN) { ws.send(JSON.stringify({ type: 'data', data: data })); } }); // Handle PTY exit terminal.pty.onExit(({ exitCode, signal }) => { console.log(`[TerminalService] Terminal ${terminalId} exited: ${exitCode || signal}`); this.logCommand(terminalId, null, `Terminal exited: ${exitCode || signal}`); if (ws.readyState === ws.OPEN) { ws.send(JSON.stringify({ type: 'exit', exitCode, signal })); ws.close(); } this.terminals.delete(terminalId); }); // 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 immediately - allow reconnection // PTY will be killed after timeout or explicit close }); // Handle WebSocket error ws.on('error', (error) => { console.error(`[TerminalService] WebSocket error for terminal ${terminalId}:`, error); }); // Send initial welcome message const readyMessage = JSON.stringify({ type: 'ready', terminalId, workingDir: terminal.workingDir, mode: terminal.mode }); console.log(`[TerminalService] Sending ready message to ${terminalId}: ${readyMessage}`); ws.send(readyMessage); console.log(`[TerminalService] Ready message sent successfully`); } /** * 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(); // Notify client of mode change if (terminal.ws && terminal.ws.readyState === terminal.ws.OPEN) { terminal.ws.send(JSON.stringify({ type: 'modeChanged', mode })); } return { success: true, mode }; } /** * 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(); } // Close WebSocket if (terminal.ws && terminal.ws.readyState === terminal.ws.OPEN) { terminal.ws.close(1000, 'Terminal closed'); } 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...'); for (const [id, terminal] of this.terminals.entries()) { try { if (terminal.pty) { terminal.pty.kill(); } if (terminal.ws && terminal.ws.readyState === terminal.ws.OPEN) { terminal.ws.close(1000, 'Server shutting down'); } } 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;