The WebSocket closes immediately with code 1006 due to nginx/proxy issues. This implementation completely bypasses WebSocket for terminal output by using HTTP polling instead. Architecture: - Server buffers PTY output in memory (outputBuffer array) - Frontend polls every 100ms via GET /terminals/:id/output?since=N - Output entries have monotonically increasing index - Old output is automatically cleaned up after 5 minutes - Commands sent via HTTP POST (already implemented) Changes: - terminal-service.js: Added bufferOutput(), getTerminalOutput() - terminal.js: Added startPolling(), stopPolling(), sendTerminalResize() - server.js: Added GET /terminals/:id/output and POST /terminals/:id/resize - No WebSocket needed for output display (keeps legacy compatibility) Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
560 lines
19 KiB
JavaScript
560 lines
19 KiB
JavaScript
/**
|
|
* 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;
|