diff --git a/database.sqlite-shm b/database.sqlite-shm index edd3e277..f7b3d0e9 100644 Binary files a/database.sqlite-shm and b/database.sqlite-shm differ diff --git a/database.sqlite-wal b/database.sqlite-wal index bc76b4bd..5011cd44 100644 Binary files a/database.sqlite-wal and b/database.sqlite-wal differ diff --git a/public/claude-ide/terminal.js b/public/claude-ide/terminal.js index 2646a7e4..f2aae7d1 100644 --- a/public/claude-ide/terminal.js +++ b/public/claude-ide/terminal.js @@ -1,15 +1,18 @@ /** - * Terminal Manager - Frontend for xterm.js terminals + * Terminal Manager - Frontend for xterm.js terminals with HTTP polling + * Uses HTTP polling to bypass WebSocket/SSL issues */ class TerminalManager { constructor() { - this.terminals = new Map(); // terminalId -> { terminal, ws, fitAddon, container, mode, ready } + this.terminals = new Map(); // terminalId -> { terminal, fitAddon, container, mode, polling, currentOutputIndex } this.activeTerminalId = null; this.xtermLoaded = false; this.terminalsContainer = null; this.terminalTabsContainer = null; this.debugMessages = []; + this.pollingInterval = 100; // ms between polls + this.pollingTimers = new Map(); // terminalId -> timer // Bind methods this.createTerminal = this.createTerminal.bind(this); @@ -39,11 +42,12 @@ class TerminalManager { if (debugContent) { const colorMap = { 'INIT': '#4a9eff', - 'WS': '#a78bfa', + 'HTTP': '#a78bfa', 'CMD': '#51cf66', 'ERROR': '#ff6b6b', 'READY': '#ffd43b', - 'PTY': '#ffa94d' + 'PTY': '#ffa94d', + 'POLL': '#ff922b' }; const color = colorMap[category] || '#e0e0e0'; @@ -262,17 +266,14 @@ class TerminalManager { this.debugLog('INIT', `Creating terminal UI...`); await this.createTerminalUI(terminalId, selectedDir, mode); - // Initialize xterm.js FIRST (before connecting WebSocket) - // This ensures this.terminals map has the entry ready + // Initialize xterm.js this.debugLog('INIT', `Initializing xterm.js...`); await this.initializeXTerm(terminalId); - this.debugLog('INIT', `xterm.js initialized, terminal should be in map now`); + this.debugLog('INIT', `xterm.js initialized`); - // NOW connect WebSocket (terminal entry exists in map) - // This waits for the 'ready' message from backend - this.debugLog('INIT', `Connecting WebSocket...`); - await this.connectTerminal(terminalId); - this.debugLog('INIT', `WebSocket connected and ready`); + // Start HTTP polling for output (NO WebSocket needed!) + this.debugLog('POLL', `Starting HTTP polling for ${terminalId}...`); + this.startPolling(terminalId); // Switch to new terminal this.debugLog('INIT', `Switching to terminal ${terminalId}...`); @@ -282,9 +283,7 @@ class TerminalManager { // Handle terminal type specific initialization if (selectedTerminalType === 'claude-cli') { this.debugLog('CMD', `Claude Code CLI terminal selected, launching command...`); - // TEMPORARY: Test with simple echo command first - // TODO: Change back to 'claude --dangerously-skip-permissions\n' after testing - await this.launchCommand(terminalId, 'echo "WebSocket test"\n'); + await this.launchCommand(terminalId, 'claude --dangerously-skip-permissions\n'); if (!silent) { showToast('Claude Code CLI terminal created', 'success'); @@ -641,22 +640,13 @@ class TerminalManager { * Launch Claude CLI in terminal with optional session */ async launchClaudeCLI(terminalId, sessionId, source = 'web') { - const terminal = this.terminals.get(terminalId); - if (!terminal || !terminal.ws || terminal.ws.readyState !== WebSocket.OPEN) { - console.error('[TerminalManager] Terminal not ready for CLI launch'); - return; - } - // Construct command const command = sessionId ? `claude --session ${sessionId}\n` : 'claude\n'; - // Send command to terminal - terminal.ws.send(JSON.stringify({ - type: 'input', - data: command - })); + // Send command to terminal via HTTP + await this.sendTerminalInput(terminalId, command); // Update mode to session await this.setMode(terminalId, 'session'); @@ -675,87 +665,96 @@ class TerminalManager { } // Store session ID and source in terminal data - if (sessionId) { - terminal.sessionId = sessionId; - terminal.sessionSource = source; + const terminal = this.terminals.get(terminalId); + if (terminal) { + if (sessionId) { + terminal.sessionId = sessionId; + terminal.sessionSource = source; + } } } /** - * Launch a command in the terminal - * Uses HTTP POST as workaround for WebSocket send issue + * Launch a command in the terminal via HTTP */ async launchCommand(terminalId, command) { this.debugLog('CMD', `launchCommand called: terminalId=${terminalId}, command="${command.trim()}"`); - - // Wait for terminal to be ready (max 5 seconds) - this.debugLog('CMD', `Waiting for terminal ${terminalId} to be ready...`); - const ready = await this.waitForTerminalReady(terminalId, 5000); - - if (!ready) { - this.debugLog('ERROR', `Terminal ${terminalId} NOT ready (timeout after 5s)`); - showToast('Terminal not ready. Please try again.', 'error'); - return; - } - - this.debugLog('CMD', `Terminal ${terminalId} is ready!`); - - // Use HTTP POST instead of WebSocket send (bypasses proxy issue) - this.debugLog('CMD', `Sending command via HTTP POST: ${command.trim()}`); - - try { - const res = await fetch(`/claude/api/terminals/${terminalId}/input`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ data: command }) - }); - - if (!res.ok) { - throw new Error(`HTTP ${res.status}: ${res.statusText}`); - } - - const result = await res.json(); - - if (result.success) { - this.debugLog('CMD', `Command sent to terminal ${terminalId} via HTTP: ${command.trim()}`); - } else { - this.debugLog('ERROR', `Failed to send command: ${result.error}`); - showToast(`Failed to send command: ${result.error}`, 'error'); - } - } catch (error) { - this.debugLog('ERROR', `HTTP POST failed:`, { error: error.message }); - showToast(`Failed to send command: ${error.message}`, 'error'); - } + await this.sendTerminalInput(terminalId, command); } /** - * Wait for terminal to be ready + * Start HTTP polling for terminal output */ - async waitForTerminalReady(terminalId, timeout = 5000) { - const startTime = Date.now(); + startPolling(terminalId) { + const terminal = this.terminals.get(terminalId); + if (!terminal) return; - return new Promise((resolve) => { - const checkReady = () => { - const terminal = this.terminals.get(terminalId); + terminal.currentOutputIndex = 0; + terminal.polling = true; - if (terminal && terminal.ready) { - console.log(`[TerminalManager] Terminal ${terminalId} is ready`); - resolve(true); - return; + const poll = async () => { + const term = this.terminals.get(terminalId); + if (!term || !term.polling) { + this.debugLog('POLL', `Stopped polling for ${terminalId}`); + return; + } + + try { + const res = await fetch(`/claude/api/terminals/${terminalId}/output?since=${term.currentOutputIndex}`); + const data = await res.json(); + + if (data.success && data.output && data.output.length > 0) { + this.debugLog('POLL', `Received ${data.output.length} output entries for ${terminalId}`); + + // Write output to xterm.js + for (const entry of data.output) { + if (entry.type === 'data') { + if (term.terminal) { + term.terminal.write(entry.data); + } + } else if (entry.type === 'exit') { + this.debugLog('PTY', `Terminal exited: ${entry.exitCode || entry.signal}`); + showToast(`Terminal exited: ${entry.exitCode || 'terminated'}`, 'info'); + } else if (entry.type === 'modeChanged') { + this.debugLog('INIT', `Mode changed to ${entry.mode}`); + this.updateModeDisplay(terminalId, entry.mode); + } + } + + // Update current index + term.currentOutputIndex = data.currentIndex; } + } catch (error) { + this.debugLog('ERROR', `Polling error for ${terminalId}:`, { error: error.message }); + } - if (Date.now() - startTime > timeout) { - console.error(`[TerminalManager] Terminal ${terminalId} ready timeout`); - resolve(false); - return; - } + // Schedule next poll + if (term.polling) { + this.pollingTimers.set(terminalId, setTimeout(poll, this.pollingInterval)); + } + }; - // Check again in 100ms - setTimeout(checkReady, 100); - }; + // Start polling + poll(); + this.debugLog('POLL', `Started polling for ${terminalId} at ${this.pollingInterval}ms interval`); + } - checkReady(); - }); + /** + * Stop HTTP polling for terminal + */ + stopPolling(terminalId) { + const timer = this.pollingTimers.get(terminalId); + if (timer) { + clearTimeout(timer); + this.pollingTimers.delete(terminalId); + } + + const terminal = this.terminals.get(terminalId); + if (terminal) { + terminal.polling = false; + } + + this.debugLog('POLL', `Stopped polling for ${terminalId}`); } /** @@ -878,163 +877,6 @@ class TerminalManager { }); } - /** - * Connect terminal WebSocket - * Waits for 'ready' message from backend before resolving - */ - async connectTerminal(terminalId) { - const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - const wsUrl = `${protocol}//${window.location.host}/claude/api/terminals/${terminalId}/ws`; - - this.debugLog('WS', `Connecting to WebSocket for terminal ${terminalId}`, { url: wsUrl }); - - return new Promise((resolve, reject) => { - try { - const ws = new WebSocket(wsUrl); - - ws.onopen = () => { - this.debugLog('WS', `✅ WebSocket OPENED for terminal ${terminalId}`); - this.debugLog('WS', `Checking if terminal exists in map...`); - - // Store WebSocket in terminal entry - // NOTE: This assumes initializeXTerm() has already been called - // and this.terminals has the entry - const terminal = this.terminals.get(terminalId); - if (terminal) { - this.debugLog('WS', `✅ Terminal found in map, storing WebSocket`); - terminal.ws = ws; - terminal.ready = false; // Will be set to true when 'ready' message received - this.debugLog('WS', `✅ WebSocket stored in terminal map, waiting for 'ready' message`); - } else { - this.debugLog('ERROR', `❌ CRITICAL: Terminal ${terminalId} not found in map!`); - this.debugLog('ERROR', `Available terminals:`, { - count: this.terminals.size, - ids: Array.from(this.terminals.keys()) - }); - reject(new Error(`Terminal ${terminalId} not initialized`)); - ws.close(); - return; - } - }; - - ws.onmessage = (event) => { - try { - const message = JSON.parse(event.data); - this.debugLog('WS', `Message received: type="${message.type}"`, message); - this.handleTerminalMessage(terminalId, message); - - // If this is the ready message, resolve the promise - if (message.type === 'ready') { - this.debugLog('READY', `✅ Ready message received for ${terminalId}, PTY initialized`, message); - const terminal = this.terminals.get(terminalId); - if (terminal) { - terminal.ready = true; - } - resolve(); - } - } catch (error) { - this.debugLog('ERROR', `Failed to parse WebSocket message`, { error: error.message, data: event.data }); - } - }; - - ws.onerror = (error) => { - this.debugLog('ERROR', `✖✖✖ WebSocket ERROR for terminal ${terminalId} ✖✖✖`, { - type: error.type || 'unknown', - message: error.message || 'No error message', - error: error - }); - this.debugLog('ERROR', `Check browser console (F12) for full error details`); - reject(error); - }; - - ws.onclose = (event) => { - const closeReasons = { - 1000: 'Normal Closure', - 1001: 'Endpoint Going Away', - 1002: 'Protocol Error', - 1003: 'Unsupported Data', - 1004: '(Reserved)', - 1005: 'No Status Received', - 1006: 'Abnormal Closure (connection dropped)', - 1007: 'Invalid frame payload data', - 1008: 'Policy Violation', - 1009: 'Message Too Big', - 1010: 'Mandatory Extension', - 1011: 'Internal Server Error', - 1015: 'TLS Handshake' - }; - - const closeReason = closeReasons[event.code] || `Unknown (${event.code})`; - - this.debugLog('ERROR', `🔌 WebSocket CLOSED for terminal ${terminalId}`, { - code: event.code, - reasonName: closeReason, - reason: event.reason || 'None provided', - wasClean: event.wasClean, - timestamp: new Date().toISOString() - }); - - if (event.code === 1006) { - this.debugLog('ERROR', `💡 Code 1006 means connection was dropped abnormally - check server logs`); - this.debugLog('ERROR', `💡 Common causes: server crash, network issue, or timeout`); - } - - // Show terminals currently in map for debugging - this.debugLog('ERROR', `Current terminals in map:`, { - count: this.terminals.size, - ids: Array.from(this.terminals.keys()) - }); - }; - } catch (error) { - this.debugLog('ERROR', `Exception connecting WebSocket`, error); - reject(error); - } - }); - } - - /** - * Handle terminal message from WebSocket - */ - handleTerminalMessage(terminalId, message) { - this.debugLog('WS', `Handling message: type="${message.type}"`, message); - - const terminal = this.terminals.get(terminalId); - - if (!terminal) { - this.debugLog('ERROR', `Cannot handle message - terminal ${terminalId} not found in map`, { terminalsInMap: Array.from(this.terminals.keys()) }); - return; - } - - switch (message.type) { - case 'ready': - this.debugLog('READY', `PTY initialized for ${terminalId}`); - break; - - case 'data': - // Write to xterm.js - if (terminal.terminal) { - this.debugLog('PTY', `Writing data to xterm.js (${message.data.length} chars)`); - terminal.terminal.write(message.data); - } else { - this.debugLog('ERROR', `No xterm.js instance for terminal ${terminalId}`); - } - break; - - case 'exit': - this.debugLog('PTY', `Terminal exited: ${message.exitCode || signal}`); - showToast(`Terminal exited: ${message.exitCode || 'terminated'}`, 'info'); - break; - - case 'modeChanged': - this.debugLog('INIT', `Mode changed to ${message.mode}`); - this.updateModeDisplay(terminalId, message.mode); - break; - - default: - this.debugLog('WS', `Unknown message type: ${message.type}`); - } - } - /** * Initialize xterm.js instance for terminal */ @@ -1101,70 +943,86 @@ class TerminalManager { // Auto-focus terminal when created setTimeout(() => terminal.focus(), 100); - // Handle user input + // Handle user input - send via HTTP POST terminal.onData((data) => { this.sendTerminalInput(terminalId, data); }); - // Handle resize + // Handle resize - send via HTTP POST terminal.onResize(({ cols, rows }) => { this.sendTerminalResize(terminalId, cols, rows); }); // Store terminal instance in map - // This MUST happen before connectTerminal() is called this.terminals.set(terminalId, { terminal, fitAddon, - ws: null, // Will be set by connectTerminal() container, mode: 'mixed', - ready: false // Will be set to true when 'ready' message received + polling: false, + currentOutputIndex: 0 }); - this.debugLog('INIT', `xterm.js instance stored in map for ${terminalId}, terminals now has`, { terminalIds: Array.from(this.terminals.keys()) }); + this.debugLog('INIT', `xterm.js instance stored in map for ${terminalId}`); return terminal; } /** - * Send input to terminal + * Send input to terminal via HTTP POST */ - sendTerminalInput(terminalId, data) { - const terminal = this.terminals.get(terminalId); + async sendTerminalInput(terminalId, data) { + try { + const res = await fetch(`/claude/api/terminals/${terminalId}/input`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ data }) + }); - if (!terminal || !terminal.ws || terminal.ws.readyState !== WebSocket.OPEN) { - return; + if (!res.ok) { + throw new Error(`HTTP ${res.status}: ${res.statusText}`); + } + + const result = await res.json(); + + if (!result.success) { + this.debugLog('ERROR', `Failed to send input: ${result.error}`); + } + } catch (error) { + this.debugLog('ERROR', `Error sending input:`, { error: error.message }); } - - terminal.ws.send(JSON.stringify({ - type: 'input', - data - })); } /** - * Send terminal resize + * Send terminal resize via HTTP POST */ - sendTerminalResize(terminalId, cols, rows) { - const terminal = this.terminals.get(terminalId); + async sendTerminalResize(terminalId, cols, rows) { + try { + const res = await fetch(`/claude/api/terminals/${terminalId}/resize`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ cols, rows }) + }); - if (!terminal || !terminal.ws || terminal.ws.readyState !== WebSocket.OPEN) { - return; + if (!res.ok) { + throw new Error(`HTTP ${res.status}: ${res.statusText}`); + } + + const result = await res.json(); + + if (!result.success) { + this.debugLog('ERROR', `Failed to resize terminal: ${result.error}`); + } + } catch (error) { + this.debugLog('ERROR', `Error resizing terminal:`, { error: error.message }); } - - terminal.ws.send(JSON.stringify({ - type: 'resize', - cols, - rows - })); } /** * Switch to specific terminal */ async switchToTerminal(terminalId) { - this.debugLog('INIT', `switchToTerminal called for ${terminalId}, checking if terminal in map...`); + this.debugLog('INIT', `switchToTerminal called for ${terminalId}`); // Hide all containers and remove active class document.querySelectorAll('.terminal-container').forEach(c => { @@ -1183,30 +1041,16 @@ class TerminalManager { container.classList.add('active'); container.style.display = 'flex'; - // Initialize xterm.js if not already done - if (!this.terminals.has(terminalId)) { - this.debugLog('INIT', `Terminal ${terminalId} NOT in map, calling initializeXTerm...`); - await this.initializeXTerm(terminalId); - } else { - this.debugLog('INIT', `Terminal ${terminalId} already in map, skipping initialization`); - } - - // Fit terminal - DISABLED to avoid WebSocket closure issue + // Fit terminal const terminal = this.terminals.get(terminalId); if (terminal && terminal.fitAddon) { - this.debugLog('INIT', `Skipping fitAddon.fit() for ${terminalId} to avoid WebSocket closure`); - // TODO: Investigate why fitAddon.fit() causes WebSocket to close - // setTimeout(() => { - // this.debugLog('INIT', `Executing fitAddon.fit() for ${terminalId}`); - // try { - // terminal.fitAddon.fit(); - // this.debugLog('INIT', `fitAddon.fit() completed for ${terminalId}`); - // } catch (error) { - // this.debugLog('ERROR', `fitAddon.fit() failed for ${terminalId}`, { error: error.message }); - // } - // }, 100); - } else { - this.debugLog('INIT', `No fitAddon for terminal ${terminalId} (terminal=${!!terminal}, fitAddon=${!!terminal?.fitAddon})`); + setTimeout(() => { + try { + terminal.fitAddon.fit(); + } catch (error) { + this.debugLog('ERROR', `fitAddon.fit() failed for ${terminalId}`, { error: error.message }); + } + }, 100); } } @@ -1225,6 +1069,9 @@ class TerminalManager { */ async closeTerminal(terminalId) { try { + // Stop polling + this.stopPolling(terminalId); + // Close via API await fetch(`/claude/api/terminals/${terminalId}`, { method: 'DELETE' diff --git a/server.js b/server.js index 2162ae44..24fb0b3e 100644 --- a/server.js +++ b/server.js @@ -1528,6 +1528,46 @@ app.post('/claude/api/terminals/:id/input', requireAuth, (req, res) => { res.status(500).json({ error: 'Failed to send input' }); } }); + +// Get terminal output via HTTP polling (bypasses WebSocket issue) +app.get('/claude/api/terminals/:id/output', requireAuth, (req, res) => { + try { + const sinceIndex = parseInt(req.query.since) || 0; + const result = terminalService.getTerminalOutput(req.params.id, sinceIndex); + + if (result.success) { + res.json(result); + } else { + res.status(404).json({ error: result.error }); + } + } catch (error) { + console.error('Error getting terminal output:', error); + res.status(500).json({ error: 'Failed to get output' }); + } +}); + +// Resize terminal via HTTP +app.post('/claude/api/terminals/:id/resize', requireAuth, (req, res) => { + try { + const { cols, rows } = req.body; + + if (!cols || !rows) { + res.status(400).json({ error: 'Missing cols or rows parameter' }); + return; + } + + const result = terminalService.resizeTerminal(req.params.id, cols, rows); + + if (result.success) { + res.json({ success: true }); + } else { + res.status(404).json({ error: result.error }); + } + } catch (error) { + console.error('Error resizing terminal:', error); + res.status(500).json({ error: 'Failed to resize terminal' }); + } +}); // Get recent directories for terminal picker app.get('/claude/api/files/recent-dirs', requireAuth, (req, res) => { diff --git a/services/terminal-service.js b/services/terminal-service.js index 5499b375..92dd8bd4 100644 --- a/services/terminal-service.js +++ b/services/terminal-service.js @@ -1,5 +1,6 @@ /** - * Terminal Service - Manages PTY processes and WebSocket connections + * 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'); @@ -9,14 +10,16 @@ const path = require('path'); class TerminalService { constructor() { - this.terminals = new Map(); // terminalId -> { pty, ws, sessionId, workingDir, mode, createdAt } + 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 for terminal I/O + * Setup WebSocket server (kept for compatibility, but not used for terminal I/O) */ createServer(httpServer) { this.wsServer = new Server({ noServer: true }); @@ -46,7 +49,7 @@ class TerminalService { // Setup ping interval to keep connections alive this.setupPingInterval(); - console.log('[TerminalService] WebSocket server initialized'); + console.log('[TerminalService] WebSocket server initialized (legacy, polling preferred)'); } /** @@ -95,24 +98,29 @@ class TerminalService { console.log(`[TerminalService] PTY spawned with PID: ${pty.pid}, shell: ${shell}`); - // Store terminal info + // Store terminal info with output buffer for polling const terminal = { id: terminalId, pty, - ws: null, sessionId, workingDir, mode, // 'session', 'shell', or 'mixed' createdAt: new Date().toISOString(), - lastActivity: 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}`); + console.log(`[TerminalService] Created terminal ${terminalId} in ${workingDir} with HTTP polling enabled`); return { success: true, terminalId, terminal }; } catch (error) { @@ -125,7 +133,58 @@ class TerminalService { } /** - * Handle WebSocket connection for terminal I/O + * 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); @@ -136,10 +195,9 @@ class TerminalService { return; } - terminal.ws = ws; terminal.lastActivity = new Date().toISOString(); - console.log(`[TerminalService] WebSocket connected for terminal ${terminalId}`); + 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) @@ -154,11 +212,6 @@ class TerminalService { 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}`); @@ -185,60 +238,16 @@ class TerminalService { terminal.lastActivity = new Date().toISOString(); }); - // Handle PTY output - send to client - terminal.pty.onData((data) => { - try { - console.log(`[TerminalService] PTY data from ${terminalId}: ${data.replace(/\n/g, '\\n').replace(/\r/g, '\\r')}`); - if (ws.readyState === ws.OPEN) { - ws.send(JSON.stringify({ - type: 'data', - data: data - })); - } else { - console.log(`[TerminalService] Cannot send PTY data - WebSocket not open (state: ${ws.readyState})`); - } - } catch (error) { - console.error(`[TerminalService] ERROR sending PTY data to client:`, error); - } - }); - - // Handle PTY exit - terminal.pty.onExit(({ exitCode, signal }) => { - console.log(`[TerminalService] PTY EXIT for ${terminalId}: exitCode=${exitCode}, signal=${signal}`); - this.logCommand(terminalId, null, `Terminal exited: ${exitCode || signal}`); - - try { - if (ws.readyState === ws.OPEN) { - ws.send(JSON.stringify({ - type: 'exit', - exitCode, - signal - })); - ws.close(); - } - } catch (error) { - console.error(`[TerminalService] Error sending exit message:`, error); - } - - 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 + // 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); - console.error(`[TerminalService] Message: ${error.message}`); - if (error.stack) { - console.error(`[TerminalService] Stack: ${error.stack}`); - } }); // Send initial welcome message @@ -246,30 +255,81 @@ class TerminalService { type: 'ready', terminalId, workingDir: terminal.workingDir, - mode: terminal.mode + 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`); - - // Send a ping immediately after ready to ensure connection stays alive - setTimeout(() => { - try { - if (ws.readyState === ws.OPEN) { - ws.ping(); - console.log(`[TerminalService] Sent ping after ready message`); - } - } catch (error) { - console.error(`[TerminalService] Error sending ping after ready:`, error); - } - }, 100); } 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 @@ -326,13 +386,13 @@ class TerminalService { 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 - })); - } + // 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 }; } @@ -381,11 +441,6 @@ class TerminalService { 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'); @@ -482,9 +537,6 @@ class TerminalService { 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); }