feat: implement HTTP polling to bypass WebSocket issue
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>
This commit is contained in:
Binary file not shown.
Binary file not shown.
@@ -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 {
|
class TerminalManager {
|
||||||
constructor() {
|
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.activeTerminalId = null;
|
||||||
this.xtermLoaded = false;
|
this.xtermLoaded = false;
|
||||||
this.terminalsContainer = null;
|
this.terminalsContainer = null;
|
||||||
this.terminalTabsContainer = null;
|
this.terminalTabsContainer = null;
|
||||||
this.debugMessages = [];
|
this.debugMessages = [];
|
||||||
|
this.pollingInterval = 100; // ms between polls
|
||||||
|
this.pollingTimers = new Map(); // terminalId -> timer
|
||||||
|
|
||||||
// Bind methods
|
// Bind methods
|
||||||
this.createTerminal = this.createTerminal.bind(this);
|
this.createTerminal = this.createTerminal.bind(this);
|
||||||
@@ -39,11 +42,12 @@ class TerminalManager {
|
|||||||
if (debugContent) {
|
if (debugContent) {
|
||||||
const colorMap = {
|
const colorMap = {
|
||||||
'INIT': '#4a9eff',
|
'INIT': '#4a9eff',
|
||||||
'WS': '#a78bfa',
|
'HTTP': '#a78bfa',
|
||||||
'CMD': '#51cf66',
|
'CMD': '#51cf66',
|
||||||
'ERROR': '#ff6b6b',
|
'ERROR': '#ff6b6b',
|
||||||
'READY': '#ffd43b',
|
'READY': '#ffd43b',
|
||||||
'PTY': '#ffa94d'
|
'PTY': '#ffa94d',
|
||||||
|
'POLL': '#ff922b'
|
||||||
};
|
};
|
||||||
const color = colorMap[category] || '#e0e0e0';
|
const color = colorMap[category] || '#e0e0e0';
|
||||||
|
|
||||||
@@ -262,17 +266,14 @@ class TerminalManager {
|
|||||||
this.debugLog('INIT', `Creating terminal UI...`);
|
this.debugLog('INIT', `Creating terminal UI...`);
|
||||||
await this.createTerminalUI(terminalId, selectedDir, mode);
|
await this.createTerminalUI(terminalId, selectedDir, mode);
|
||||||
|
|
||||||
// Initialize xterm.js FIRST (before connecting WebSocket)
|
// Initialize xterm.js
|
||||||
// This ensures this.terminals map has the entry ready
|
|
||||||
this.debugLog('INIT', `Initializing xterm.js...`);
|
this.debugLog('INIT', `Initializing xterm.js...`);
|
||||||
await this.initializeXTerm(terminalId);
|
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)
|
// Start HTTP polling for output (NO WebSocket needed!)
|
||||||
// This waits for the 'ready' message from backend
|
this.debugLog('POLL', `Starting HTTP polling for ${terminalId}...`);
|
||||||
this.debugLog('INIT', `Connecting WebSocket...`);
|
this.startPolling(terminalId);
|
||||||
await this.connectTerminal(terminalId);
|
|
||||||
this.debugLog('INIT', `WebSocket connected and ready`);
|
|
||||||
|
|
||||||
// Switch to new terminal
|
// Switch to new terminal
|
||||||
this.debugLog('INIT', `Switching to terminal ${terminalId}...`);
|
this.debugLog('INIT', `Switching to terminal ${terminalId}...`);
|
||||||
@@ -282,9 +283,7 @@ class TerminalManager {
|
|||||||
// Handle terminal type specific initialization
|
// Handle terminal type specific initialization
|
||||||
if (selectedTerminalType === 'claude-cli') {
|
if (selectedTerminalType === 'claude-cli') {
|
||||||
this.debugLog('CMD', `Claude Code CLI terminal selected, launching command...`);
|
this.debugLog('CMD', `Claude Code CLI terminal selected, launching command...`);
|
||||||
// TEMPORARY: Test with simple echo command first
|
await this.launchCommand(terminalId, 'claude --dangerously-skip-permissions\n');
|
||||||
// TODO: Change back to 'claude --dangerously-skip-permissions\n' after testing
|
|
||||||
await this.launchCommand(terminalId, 'echo "WebSocket test"\n');
|
|
||||||
|
|
||||||
if (!silent) {
|
if (!silent) {
|
||||||
showToast('Claude Code CLI terminal created', 'success');
|
showToast('Claude Code CLI terminal created', 'success');
|
||||||
@@ -641,22 +640,13 @@ class TerminalManager {
|
|||||||
* Launch Claude CLI in terminal with optional session
|
* Launch Claude CLI in terminal with optional session
|
||||||
*/
|
*/
|
||||||
async launchClaudeCLI(terminalId, sessionId, source = 'web') {
|
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
|
// Construct command
|
||||||
const command = sessionId
|
const command = sessionId
|
||||||
? `claude --session ${sessionId}\n`
|
? `claude --session ${sessionId}\n`
|
||||||
: 'claude\n';
|
: 'claude\n';
|
||||||
|
|
||||||
// Send command to terminal
|
// Send command to terminal via HTTP
|
||||||
terminal.ws.send(JSON.stringify({
|
await this.sendTerminalInput(terminalId, command);
|
||||||
type: 'input',
|
|
||||||
data: command
|
|
||||||
}));
|
|
||||||
|
|
||||||
// Update mode to session
|
// Update mode to session
|
||||||
await this.setMode(terminalId, 'session');
|
await this.setMode(terminalId, 'session');
|
||||||
@@ -675,87 +665,96 @@ class TerminalManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Store session ID and source in terminal data
|
// Store session ID and source in terminal data
|
||||||
if (sessionId) {
|
const terminal = this.terminals.get(terminalId);
|
||||||
terminal.sessionId = sessionId;
|
if (terminal) {
|
||||||
terminal.sessionSource = source;
|
if (sessionId) {
|
||||||
|
terminal.sessionId = sessionId;
|
||||||
|
terminal.sessionSource = source;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Launch a command in the terminal
|
* Launch a command in the terminal via HTTP
|
||||||
* Uses HTTP POST as workaround for WebSocket send issue
|
|
||||||
*/
|
*/
|
||||||
async launchCommand(terminalId, command) {
|
async launchCommand(terminalId, command) {
|
||||||
this.debugLog('CMD', `launchCommand called: terminalId=${terminalId}, command="${command.trim()}"`);
|
this.debugLog('CMD', `launchCommand called: terminalId=${terminalId}, command="${command.trim()}"`);
|
||||||
|
await this.sendTerminalInput(terminalId, command);
|
||||||
// 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');
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Wait for terminal to be ready
|
* Start HTTP polling for terminal output
|
||||||
*/
|
*/
|
||||||
async waitForTerminalReady(terminalId, timeout = 5000) {
|
startPolling(terminalId) {
|
||||||
const startTime = Date.now();
|
const terminal = this.terminals.get(terminalId);
|
||||||
|
if (!terminal) return;
|
||||||
|
|
||||||
return new Promise((resolve) => {
|
terminal.currentOutputIndex = 0;
|
||||||
const checkReady = () => {
|
terminal.polling = true;
|
||||||
const terminal = this.terminals.get(terminalId);
|
|
||||||
|
|
||||||
if (terminal && terminal.ready) {
|
const poll = async () => {
|
||||||
console.log(`[TerminalManager] Terminal ${terminalId} is ready`);
|
const term = this.terminals.get(terminalId);
|
||||||
resolve(true);
|
if (!term || !term.polling) {
|
||||||
return;
|
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) {
|
// Schedule next poll
|
||||||
console.error(`[TerminalManager] Terminal ${terminalId} ready timeout`);
|
if (term.polling) {
|
||||||
resolve(false);
|
this.pollingTimers.set(terminalId, setTimeout(poll, this.pollingInterval));
|
||||||
return;
|
}
|
||||||
}
|
};
|
||||||
|
|
||||||
// Check again in 100ms
|
// Start polling
|
||||||
setTimeout(checkReady, 100);
|
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
|
* Initialize xterm.js instance for terminal
|
||||||
*/
|
*/
|
||||||
@@ -1101,70 +943,86 @@ class TerminalManager {
|
|||||||
// Auto-focus terminal when created
|
// Auto-focus terminal when created
|
||||||
setTimeout(() => terminal.focus(), 100);
|
setTimeout(() => terminal.focus(), 100);
|
||||||
|
|
||||||
// Handle user input
|
// Handle user input - send via HTTP POST
|
||||||
terminal.onData((data) => {
|
terminal.onData((data) => {
|
||||||
this.sendTerminalInput(terminalId, data);
|
this.sendTerminalInput(terminalId, data);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle resize
|
// Handle resize - send via HTTP POST
|
||||||
terminal.onResize(({ cols, rows }) => {
|
terminal.onResize(({ cols, rows }) => {
|
||||||
this.sendTerminalResize(terminalId, cols, rows);
|
this.sendTerminalResize(terminalId, cols, rows);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Store terminal instance in map
|
// Store terminal instance in map
|
||||||
// This MUST happen before connectTerminal() is called
|
|
||||||
this.terminals.set(terminalId, {
|
this.terminals.set(terminalId, {
|
||||||
terminal,
|
terminal,
|
||||||
fitAddon,
|
fitAddon,
|
||||||
ws: null, // Will be set by connectTerminal()
|
|
||||||
container,
|
container,
|
||||||
mode: 'mixed',
|
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;
|
return terminal;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Send input to terminal
|
* Send input to terminal via HTTP POST
|
||||||
*/
|
*/
|
||||||
sendTerminalInput(terminalId, data) {
|
async sendTerminalInput(terminalId, data) {
|
||||||
const terminal = this.terminals.get(terminalId);
|
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) {
|
if (!res.ok) {
|
||||||
return;
|
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) {
|
async sendTerminalResize(terminalId, cols, rows) {
|
||||||
const terminal = this.terminals.get(terminalId);
|
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) {
|
if (!res.ok) {
|
||||||
return;
|
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
|
* Switch to specific terminal
|
||||||
*/
|
*/
|
||||||
async switchToTerminal(terminalId) {
|
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
|
// Hide all containers and remove active class
|
||||||
document.querySelectorAll('.terminal-container').forEach(c => {
|
document.querySelectorAll('.terminal-container').forEach(c => {
|
||||||
@@ -1183,30 +1041,16 @@ class TerminalManager {
|
|||||||
container.classList.add('active');
|
container.classList.add('active');
|
||||||
container.style.display = 'flex';
|
container.style.display = 'flex';
|
||||||
|
|
||||||
// Initialize xterm.js if not already done
|
// Fit terminal
|
||||||
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
|
|
||||||
const terminal = this.terminals.get(terminalId);
|
const terminal = this.terminals.get(terminalId);
|
||||||
if (terminal && terminal.fitAddon) {
|
if (terminal && terminal.fitAddon) {
|
||||||
this.debugLog('INIT', `Skipping fitAddon.fit() for ${terminalId} to avoid WebSocket closure`);
|
setTimeout(() => {
|
||||||
// TODO: Investigate why fitAddon.fit() causes WebSocket to close
|
try {
|
||||||
// setTimeout(() => {
|
terminal.fitAddon.fit();
|
||||||
// this.debugLog('INIT', `Executing fitAddon.fit() for ${terminalId}`);
|
} catch (error) {
|
||||||
// try {
|
this.debugLog('ERROR', `fitAddon.fit() failed for ${terminalId}`, { error: error.message });
|
||||||
// terminal.fitAddon.fit();
|
}
|
||||||
// this.debugLog('INIT', `fitAddon.fit() completed for ${terminalId}`);
|
}, 100);
|
||||||
// } 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})`);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1225,6 +1069,9 @@ class TerminalManager {
|
|||||||
*/
|
*/
|
||||||
async closeTerminal(terminalId) {
|
async closeTerminal(terminalId) {
|
||||||
try {
|
try {
|
||||||
|
// Stop polling
|
||||||
|
this.stopPolling(terminalId);
|
||||||
|
|
||||||
// Close via API
|
// Close via API
|
||||||
await fetch(`/claude/api/terminals/${terminalId}`, {
|
await fetch(`/claude/api/terminals/${terminalId}`, {
|
||||||
method: 'DELETE'
|
method: 'DELETE'
|
||||||
|
|||||||
40
server.js
40
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' });
|
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
|
// Get recent directories for terminal picker
|
||||||
app.get('/claude/api/files/recent-dirs', requireAuth, (req, res) => {
|
app.get('/claude/api/files/recent-dirs', requireAuth, (req, res) => {
|
||||||
|
|||||||
@@ -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');
|
const { spawn } = require('node-pty');
|
||||||
@@ -9,14 +10,16 @@ const path = require('path');
|
|||||||
|
|
||||||
class TerminalService {
|
class TerminalService {
|
||||||
constructor() {
|
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.wsServer = null;
|
||||||
this.logFile = path.join(process.env.HOME, 'obsidian-vault', '.claude-ide', 'terminal-logs.jsonl');
|
this.logFile = path.join(process.env.HOME, 'obsidian-vault', '.claude-ide', 'terminal-logs.jsonl');
|
||||||
this.pingInterval = null;
|
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) {
|
createServer(httpServer) {
|
||||||
this.wsServer = new Server({ noServer: true });
|
this.wsServer = new Server({ noServer: true });
|
||||||
@@ -46,7 +49,7 @@ class TerminalService {
|
|||||||
// Setup ping interval to keep connections alive
|
// Setup ping interval to keep connections alive
|
||||||
this.setupPingInterval();
|
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}`);
|
console.log(`[TerminalService] PTY spawned with PID: ${pty.pid}, shell: ${shell}`);
|
||||||
|
|
||||||
// Store terminal info
|
// Store terminal info with output buffer for polling
|
||||||
const terminal = {
|
const terminal = {
|
||||||
id: terminalId,
|
id: terminalId,
|
||||||
pty,
|
pty,
|
||||||
ws: null,
|
|
||||||
sessionId,
|
sessionId,
|
||||||
workingDir,
|
workingDir,
|
||||||
mode, // 'session', 'shell', or 'mixed'
|
mode, // 'session', 'shell', or 'mixed'
|
||||||
createdAt: new Date().toISOString(),
|
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);
|
this.terminals.set(terminalId, terminal);
|
||||||
|
|
||||||
|
// Buffer PTY output for polling
|
||||||
|
this.bufferOutput(terminalId, pty);
|
||||||
|
|
||||||
// Log terminal creation
|
// Log terminal creation
|
||||||
this.logCommand(terminalId, null, `Terminal created in ${workingDir} (mode: ${mode})`);
|
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 };
|
return { success: true, terminalId, terminal };
|
||||||
} catch (error) {
|
} 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) {
|
handleConnection(terminalId, ws) {
|
||||||
const terminal = this.terminals.get(terminalId);
|
const terminal = this.terminals.get(terminalId);
|
||||||
@@ -136,10 +195,9 @@ class TerminalService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
terminal.ws = ws;
|
|
||||||
terminal.lastActivity = new Date().toISOString();
|
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}`);
|
console.log(`[TerminalService] Terminal info - workingDir: ${terminal.workingDir}, mode: ${terminal.mode}`);
|
||||||
|
|
||||||
// Handle incoming messages from client (user input)
|
// 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')}"`);
|
console.log(`[TerminalService] Writing to PTY: "${message.data.replace(/\n/g, '\\n')}"`);
|
||||||
terminal.pty.write(message.data);
|
terminal.pty.write(message.data);
|
||||||
terminal.lastActivity = new Date().toISOString();
|
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') {
|
} else if (message.type === 'resize') {
|
||||||
// Handle terminal resize
|
// Handle terminal resize
|
||||||
console.log(`[TerminalService] Resize to ${message.cols}x${message.rows}`);
|
console.log(`[TerminalService] Resize to ${message.cols}x${message.rows}`);
|
||||||
@@ -185,60 +238,16 @@ class TerminalService {
|
|||||||
terminal.lastActivity = new Date().toISOString();
|
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
|
// Handle WebSocket close
|
||||||
ws.on('close', (code, reason) => {
|
ws.on('close', (code, reason) => {
|
||||||
console.log(`[TerminalService] WebSocket closed for terminal ${terminalId} - code: ${code}, reason: ${reason || 'none'}`);
|
console.log(`[TerminalService] WebSocket closed for terminal ${terminalId} - code: ${code}, reason: ${reason || 'none'}`);
|
||||||
|
// Don't kill PTY - it continues with HTTP polling
|
||||||
// Don't kill PTY immediately - allow reconnection
|
|
||||||
// PTY will be killed after timeout or explicit close
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle WebSocket error
|
// Handle WebSocket error
|
||||||
ws.on('error', (error) => {
|
ws.on('error', (error) => {
|
||||||
console.error(`[TerminalService] ✖✖✖ WebSocket ERROR for terminal ${terminalId} ✖✖✖`);
|
console.error(`[TerminalService] ✖✖✖ WebSocket ERROR for terminal ${terminalId} ✖✖✖`);
|
||||||
console.error(`[TerminalService] Error:`, error);
|
console.error(`[TerminalService] Error:`, error);
|
||||||
console.error(`[TerminalService] Message: ${error.message}`);
|
|
||||||
if (error.stack) {
|
|
||||||
console.error(`[TerminalService] Stack: ${error.stack}`);
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Send initial welcome message
|
// Send initial welcome message
|
||||||
@@ -246,30 +255,81 @@ class TerminalService {
|
|||||||
type: 'ready',
|
type: 'ready',
|
||||||
terminalId,
|
terminalId,
|
||||||
workingDir: terminal.workingDir,
|
workingDir: terminal.workingDir,
|
||||||
mode: terminal.mode
|
mode: terminal.mode,
|
||||||
|
polling: true // Indicate polling is available
|
||||||
});
|
});
|
||||||
console.log(`[TerminalService] Sending ready message to ${terminalId}: ${readyMessage}`);
|
console.log(`[TerminalService] Sending ready message to ${terminalId}: ${readyMessage}`);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
ws.send(readyMessage);
|
ws.send(readyMessage);
|
||||||
console.log(`[TerminalService] Ready message sent successfully`);
|
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) {
|
} catch (error) {
|
||||||
console.error(`[TerminalService] Error sending ready message:`, 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
|
* Attach terminal to a Claude Code session
|
||||||
* Pipes session stdout/stderr to PTY
|
* Pipes session stdout/stderr to PTY
|
||||||
@@ -326,13 +386,13 @@ class TerminalService {
|
|||||||
terminal.mode = mode;
|
terminal.mode = mode;
|
||||||
terminal.lastActivity = new Date().toISOString();
|
terminal.lastActivity = new Date().toISOString();
|
||||||
|
|
||||||
// Notify client of mode change
|
// Add mode change to buffer so polling clients see it
|
||||||
if (terminal.ws && terminal.ws.readyState === terminal.ws.OPEN) {
|
terminal.outputBuffer.push({
|
||||||
terminal.ws.send(JSON.stringify({
|
index: terminal.outputIndex++,
|
||||||
type: 'modeChanged',
|
timestamp: Date.now(),
|
||||||
mode
|
type: 'modeChanged',
|
||||||
}));
|
mode
|
||||||
}
|
});
|
||||||
|
|
||||||
return { success: true, mode };
|
return { success: true, mode };
|
||||||
}
|
}
|
||||||
@@ -381,11 +441,6 @@ class TerminalService {
|
|||||||
terminal.pty.kill();
|
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.terminals.delete(terminalId);
|
||||||
|
|
||||||
this.logCommand(terminalId, null, 'Terminal closed');
|
this.logCommand(terminalId, null, 'Terminal closed');
|
||||||
@@ -482,9 +537,6 @@ class TerminalService {
|
|||||||
if (terminal.pty) {
|
if (terminal.pty) {
|
||||||
terminal.pty.kill();
|
terminal.pty.kill();
|
||||||
}
|
}
|
||||||
if (terminal.ws && terminal.ws.readyState === terminal.ws.OPEN) {
|
|
||||||
terminal.ws.close(1000, 'Server shutting down');
|
|
||||||
}
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(`[TerminalService] Error cleaning up terminal ${id}:`, error);
|
console.error(`[TerminalService] Error cleaning up terminal ${id}:`, error);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user