- Log PTY PID and shell when spawned - Log all PTY output data - Log PTY exit events with exit code and signal - Log when WebSocket cannot send PTY data This will help identify if the PTY is exiting immediately or if there's an issue with the PTY process.
399 lines
13 KiB
JavaScript
399 lines
13 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
|
|
});
|
|
|
|
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);
|
|
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;
|