Phase 1 of systematic debugging: Gather evidence Added detailed logging to trace the complete command flow: - launchCommand: Shows when called, terminalId, command, WebSocket state - waitForTerminalReady: Shows waiting period and ready state - handleTerminalMessage: Shows all messages from backend with details - WebSocket message content logged before sending Also fixed duplicate 'ready' message (removed line 113-116). Now when user creates "Claude Code CLI" terminal, console will show: 1. launchCommand called with terminalId and command 2. Waiting for terminal ready 3. Ready message received (or timeout) 4. Command sent to WebSocket 5. All backend messages logged This will reveal exactly where the flow breaks. Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
385 lines
12 KiB
JavaScript
385 lines
12 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 {
|
|
// 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) {
|
|
ws.close(1008, 'Terminal not found');
|
|
return;
|
|
}
|
|
|
|
terminal.ws = ws;
|
|
terminal.lastActivity = new Date().toISOString();
|
|
|
|
console.log(`[TerminalService] WebSocket connected for terminal ${terminalId}`);
|
|
|
|
// Handle incoming messages from client (user input)
|
|
ws.on('message', (data) => {
|
|
try {
|
|
const message = JSON.parse(data);
|
|
|
|
if (message.type === 'input') {
|
|
// User typed something - send to PTY
|
|
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
|
|
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', () => {
|
|
console.log(`[TerminalService] WebSocket closed for terminal ${terminalId}`);
|
|
|
|
// 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
|
|
ws.send(JSON.stringify({
|
|
type: 'ready',
|
|
terminalId,
|
|
workingDir: terminal.workingDir,
|
|
mode: terminal.mode
|
|
}));
|
|
}
|
|
|
|
/**
|
|
* 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;
|