- Log spawn attempt before calling spawn() - Add detailed error logging with stack trace - This will help identify if spawn is failing silently
404 lines
14 KiB
JavaScript
404 lines
14 KiB
JavaScript
/**
|
|
* 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 {
|
|
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
|
|
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);
|
|
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 };
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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) => {
|
|
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})`);
|
|
}
|
|
});
|
|
|
|
// 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}`);
|
|
|
|
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;
|