import type { WebSocket } from 'ws';
import type { IncomingMessage } from 'http';
import type { AgentManager } from './agent-manager';
import type { StateManager } from './state-manager';
import type { PersistentSessionManager } from './persistent-session-manager';
import { validateWebSocketAuth, getAuthConfig } from './auth';
import { sessionStore } from './session-store';
import crypto from 'crypto';
interface WebSocketMessage {
type: string;
payload?: any;
}
interface WebSocketContext {
agentManager: AgentManager;
stateManager: StateManager;
sessionManager?: PersistentSessionManager;
wss?: any; // WebSocketServer instance for broadcasting
}
// Context tracking state
let lastContextData: { used: number; max: number } | null = null;
function parseContextData(text: string): { used?: number; max?: number } | null {
const result: { used?: number; max?: number } = {};
// Parse 200000
const budgetMatch = text.match(/(\d+)<\/budget:token_budget>/);
if (budgetMatch) {
result.max = parseInt(budgetMatch[1], 10);
}
// Parse token usage like "Token usage: 56234/200000; 143766 remaining"
const usageMatch = text.match(/Token usage:\s*(\d+)\/(\d+)/i);
if (usageMatch) {
result.used = parseInt(usageMatch[1], 10);
result.max = parseInt(usageMatch[2], 10);
}
// Parse "X/Y remaining" format
const remainingMatch = text.match(/(\d+)\/(\d+)[;\s]+(\d+)\s+remaining/i);
if (remainingMatch) {
result.used = parseInt(remainingMatch[1], 10);
result.max = parseInt(remainingMatch[2], 10);
}
return Object.keys(result).length > 0 ? result : null;
}
export function handleWebSocketConnection(
ws: WebSocket,
request: IncomingMessage,
context: WebSocketContext
) {
const { agentManager, stateManager, wss } = context;
let currentSessionId: string | null = null;
let currentAgentId: string | null = null;
let terminalSessionId: string | null = null;
// Handle session timeouts for this connection
const handleSessionTimeout = async (timedOutSessionId: string) => {
const session = sessionStore.getSession(timedOutSessionId);
if (session?.agentId) {
console.log(`Session ${timedOutSessionId} timed out, cleaning up agent ${session.agentId}`);
try {
await agentManager.stopAgent(session.agentId);
} catch (error) {
console.error('Error cleaning up timed-out agent:', error);
}
}
};
sessionStore.on('session-timeout', handleSessionTimeout);
// Extract terminal session ID, autoLaunchClaude, and sandboxed from query params if provided
const url = new URL(request.url || '', `http://${request.headers.host}`);
const providedSessionId = url.searchParams.get('sessionId');
const autoLaunchClaude = url.searchParams.get('autoLaunchClaude') === 'true';
const sandboxed = url.searchParams.get('sandboxed') !== 'false'; // Default to true (sandboxed)
// Generate or use provided session ID
const sessionId = providedSessionId || crypto.randomBytes(12).toString('hex');
let isReconnection = false;
// Check authentication
const authConfig = getAuthConfig();
if (authConfig.enabled) {
const headers = Object.fromEntries(
Object.entries(request.headers).map(([k, v]) => [k.toLowerCase(), v as string])
);
if (!validateWebSocketAuth(url, headers)) {
console.log('WebSocket connection rejected: Authentication failed');
ws.close(1008, 'Authentication required');
return;
}
}
console.log('New WebSocket connection established');
console.log('WebSocket readyState:', ws.readyState);
console.log('Session ID:', sessionId);
console.log('Is reconnection:', isReconnection);
// Check if this is a reconnection to an existing session
const existingSession = sessionStore.getSession(sessionId);
if (existingSession) {
isReconnection = true;
currentAgentId = existingSession.agentId;
console.log('Reconnecting to existing session:', sessionId, 'with agent:', currentAgentId);
// Try to reattach to the agent
const reattached = agentManager.reattachAgent(currentAgentId);
if (!reattached) {
console.log('Agent no longer exists, will create new one');
currentAgentId = null;
isReconnection = false;
}
}
// Setup ping/pong to keep connection alive
const pingInterval = setInterval(() => {
if (ws.readyState === 1) { // OPEN state
ws.ping();
}
}, 30000); // Ping every 30 seconds
ws.on('pong', () => {
// Connection is alive
});
ws.on('error', (error) => {
console.error('WebSocket error:', error);
});
// Send welcome message with session ID
send('CONNECTED', {
message: 'Welcome to MorphBox Terminal',
sessionId: sessionId,
isReconnection: isReconnection
});
// Launch agent automatically based on type
if (autoLaunchClaude) {
setTimeout(async () => {
console.log('Auto-launching Claude for WebSocket connection');
try {
// If reconnecting and agent exists, reattach and send buffered output
if (isReconnection && currentAgentId) {
console.log(`Reattaching to existing agent: ${currentAgentId}`);
// Try to reattach to the agent
const reattached = agentManager.reattachAgent(currentAgentId);
if (!reattached) {
console.log('Failed to reattach to agent, creating new one');
// Agent no longer exists, create a new one
isReconnection = false;
currentAgentId = null;
} else {
send('RECONNECTED', { agentId: currentAgentId });
// Also send AGENT_LAUNCHED to hide the loading overlay
send('AGENT_LAUNCHED', { agentId: currentAgentId });
// Send any buffered output
const bufferedOutput = sessionStore.getAndClearBuffer(sessionId);
if (bufferedOutput.length > 0) {
console.log(`Sending ${bufferedOutput.length} buffered outputs`);
for (const output of bufferedOutput) {
// Include agentId so the OUTPUT is properly displayed
send('OUTPUT', { data: output, agentId: currentAgentId });
}
}
// Send current state to ensure terminal is ready
await sendCurrentState();
// NOTE: Removed wake-up CR logic that was causing Claude UI to redraw
// The agent is already active and buffered output has been sent
console.log('Reconnection complete, agent is active');
}
}
if (!isReconnection || !currentAgentId) {
// Create new session
currentSessionId = await stateManager.createSession(process.cwd(), 'claude');
send('SESSION_CREATED', { sessionId: currentSessionId });
// Launch bash agent with Claude command
// Use morphbox user instead of root so --dangerously-skip-permissions works
console.log('Launching bash agent with Claude, sandboxed=', sandboxed);
currentAgentId = await agentManager.launchAgent('bash', {
sessionId: currentSessionId,
workspacePath: process.cwd(),
vmUser: 'morphbox',
sandboxed: sandboxed
});
// Store session info
sessionStore.createSession(sessionId, currentAgentId, {
terminalSize: { cols: 80, rows: 24 },
workingDirectory: process.cwd()
});
// After the bash agent is ready, launch Claude
setTimeout(() => {
const agent = agentManager.getAgent(currentAgentId);
if (agent && agent.status === 'running') {
// Send the Claude command with --dangerously-skip-permissions
console.log('Sending Claude command to bash agent');
agent.sendInput('claude --dangerously-skip-permissions\n');
}
}, 1000);
}
// Set up agent event listeners
const handleOutput = (data: { agentId: string; data: string }) => {
if (data.agentId === currentAgentId) {
// Parse for context data (OUTPUT is broadcast globally from websocket-server.ts)
parseAndBroadcastContextData(data.data);
// Buffer output if disconnected
if (ws.readyState !== 1) {
sessionStore.addOutput(sessionId, data.data);
}
}
};
const handleError = (data: { agentId: string; error: string }) => {
if (data.agentId === currentAgentId) {
send('ERROR', { message: data.error });
}
};
const handleExit = (data: { agentId: string; code: number }) => {
if (data.agentId === currentAgentId) {
send('AGENT_EXIT', { code: data.code });
currentAgentId = null;
// Clean up listeners
agentManager.off('agent_output', handleOutput);
agentManager.off('agent_error', handleError);
agentManager.off('agent_exit', handleExit);
agentManager.off('agent_sessionId', handleSessionId);
// Auto-restart SSH connection if it exits
setTimeout(async () => {
if (!currentAgentId && ws.readyState === 1) {
console.log('Auto-restarting SSH agent after exit');
try {
currentAgentId = await agentManager.launchAgent('ssh', {
sessionId: currentSessionId || '',
terminalSessionId: terminalSessionId || currentSessionId || '',
vmHost,
vmPort,
vmUser
});
console.log('SSH agent restarted with ID:', currentAgentId);
// Re-attach the same event listeners
agentManager.on('agent_output', handleOutput);
agentManager.on('agent_error', handleError);
agentManager.on('agent_exit', handleExit);
agentManager.on('agent_sessionId', handleSessionId);
send('AGENT_LAUNCHED', { agentId: currentAgentId });
} catch (error) {
console.error('Failed to restart SSH agent:', error);
sendError('Failed to restart SSH connection');
}
}
}, 1000);
}
};
const handleSessionId = (data: { agentId: string; sessionId: string }) => {
if (data.agentId === currentAgentId) {
terminalSessionId = data.sessionId;
send('TERMINAL_SESSION_ID', { sessionId: data.sessionId });
}
};
agentManager.on('agent_output', handleOutput);
agentManager.on('agent_error', handleError);
agentManager.on('agent_exit', handleExit);
agentManager.on('agent_sessionId', handleSessionId);
// Handle context usage updates
const handleContextUsage = ({ agentId, usage }: { agentId: string; usage: any }) => {
if (agentId === currentAgentId) {
send('CONTEXT_USAGE', usage);
}
};
agentManager.on('context_usage', handleContextUsage);
if (!isReconnection) {
send('AGENT_LAUNCHED', { agentId: currentAgentId });
await sendCurrentState();
}
} catch (error) {
console.error('Failed to launch Claude:', error);
let errorMessage = 'Unknown error';
if (error instanceof Error) {
errorMessage = error.message;
// Clean up any Node.js-specific error details that might contain 'require'
errorMessage = errorMessage.replace(/require\s+is\s+not\s+defined/gi, 'Module loading error');
}
sendError('Failed to launch Claude: ' + errorMessage);
}
}, 100);
} else {
// Launch bash automatically for regular terminals
setTimeout(async () => {
console.log('Auto-launching bash for terminal connection');
try {
// Check if this is a reconnection to an existing session
if (isReconnection && currentAgentId) {
console.log(`Reattaching to existing bash agent: ${currentAgentId}`);
// Try to reattach to the agent
const reattached = agentManager.reattachAgent(currentAgentId);
if (!reattached) {
console.log('Failed to reattach to bash agent, creating new one');
// Agent no longer exists, create a new one
isReconnection = false;
currentAgentId = null;
} else {
send('RECONNECTED', { agentId: currentAgentId });
// Also send AGENT_LAUNCHED to hide the loading overlay
send('AGENT_LAUNCHED', { agentId: currentAgentId });
// Send any buffered output
const bufferedOutput = sessionStore.getAndClearBuffer(sessionId);
if (bufferedOutput.length > 0) {
console.log(`Sending ${bufferedOutput.length} buffered outputs for bash terminal`);
for (const output of bufferedOutput) {
// Include agentId so the OUTPUT is properly displayed
send('OUTPUT', { data: output, agentId: currentAgentId });
}
}
// Send current state to ensure terminal is ready
await sendCurrentState();
console.log('Bash terminal reconnection complete');
}
}
if (!isReconnection || !currentAgentId) {
// Create new session
currentSessionId = await stateManager.createSession(process.cwd(), 'bash');
send('SESSION_CREATED', { sessionId: currentSessionId });
// Launch bash agent with sandboxed option
console.log('Launching bash agent for terminal connection, sandboxed=', sandboxed);
currentAgentId = await agentManager.launchAgent('bash', {
sessionId: currentSessionId,
workspacePath: process.cwd(),
sandboxed: sandboxed
});
// Store session info
sessionStore.createSession(sessionId, currentAgentId, {
terminalSize: { cols: 80, rows: 24 },
workingDirectory: process.cwd()
});
}
// Set up agent event listeners
const handleOutput = (data: { agentId: string; data: string }) => {
if (data.agentId === currentAgentId) {
// Parse for context budget information from Claude Code output
// (OUTPUT is broadcast globally from websocket-server.ts)
parseAndBroadcastContextData(data.data);
// Buffer output if disconnected
if (ws.readyState !== 1) {
sessionStore.addOutput(sessionId, data.data);
}
}
};
const handleError = (data: { agentId: string; error: string }) => {
if (data.agentId === currentAgentId) {
send('ERROR', { message: data.error });
}
};
const handleExit = (data: { agentId: string; code: number }) => {
if (data.agentId === currentAgentId) {
send('AGENT_EXIT', { code: data.code });
currentAgentId = null;
// Clean up listeners
agentManager.off('agent_output', handleOutput);
agentManager.off('agent_error', handleError);
agentManager.off('agent_exit', handleExit);
}
};
agentManager.on('agent_output', handleOutput);
agentManager.on('agent_error', handleError);
agentManager.on('agent_exit', handleExit);
if (!isReconnection) {
send('AGENT_LAUNCHED', { agentId: currentAgentId });
await sendCurrentState();
}
} catch (error) {
console.error('Failed to launch bash:', error);
sendError('Failed to launch terminal');
}
}, 100);
}
// Handle incoming messages
ws.on('message', async (data) => {
try {
const message = JSON.parse(data.toString()) as WebSocketMessage;
switch (message.type) {
case 'CREATE_SESSION':
await handleCreateSession(message.payload);
break;
case 'LAUNCH_AGENT':
await handleLaunchAgent(message.payload);
break;
case 'SEND_INPUT':
await handleSendInput(message.payload);
break;
case 'STOP_AGENT':
await handleStopAgent();
break;
case 'GET_STATE':
await sendCurrentState();
break;
case 'CREATE_SNAPSHOT':
await handleCreateSnapshot(message.payload);
break;
case 'RESIZE':
await handleResize(message.payload);
break;
case 'GET_ALL_AGENTS':
// Return all active agents across all sessions for monitoring panels
const allAgents = agentManager.getAllAgents();
const agentInfo: Record = {};
for (const [id, agent] of Object.entries(allAgents)) {
agentInfo[id] = {
type: agent.type,
status: agent.status,
sessionId: agent.sessionId || 'unknown'
};
}
send('ALL_AGENTS_STATE', { agents: agentInfo });
break;
default:
sendError(`Unknown message type: ${message.type}`);
}
} catch (error) {
console.error('Error handling WebSocket message:', error);
sendError(error instanceof Error ? error.message : 'Unknown error');
}
});
// Handle connection close
ws.on('close', async (code, reason) => {
console.log('WebSocket connection closed:', {
code,
reason: reason?.toString(),
currentAgentId,
currentSessionId,
sessionId,
timestamp: new Date().toISOString()
});
// Clear ping interval
clearInterval(pingInterval);
// Clean up session timeout handler
sessionStore.off('session-timeout', handleSessionTimeout);
// For persistence: detach agent instead of stopping it
if (currentAgentId && sessionId) {
console.log(`Detaching agent ${currentAgentId} for session ${sessionId}`);
agentManager.detachAgent(currentAgentId);
// Session will be cleaned up after timeout if not reconnected
} else if (currentAgentId) {
// No session ID, stop agent normally
try {
await agentManager.stopAgent(currentAgentId);
} catch (error) {
console.error('Error stopping agent on disconnect:', error);
}
}
});
// Helper functions
function send(type: string, payload?: any) {
// WebSocket.OPEN is 1
if (ws.readyState === 1) {
const message = JSON.stringify({ type, payload });
console.log('Sending message:', type);
// Log OUTPUT data length for debugging
if (type === 'OUTPUT' && payload?.data) {
console.log(` OUTPUT data length: ${payload.data.length}, preview: ${JSON.stringify(payload.data.substring(0, 50))}`);
}
ws.send(message);
} else {
console.log('Cannot send message, WebSocket not open. State:', ws.readyState);
}
}
function broadcast(type: string, payload?: any, includeAgentId?: string) {
if (!wss) {
console.log('[broadcast] wss is undefined, cannot broadcast');
return;
}
const message = JSON.stringify({
type,
payload: {
...payload,
agentId: includeAgentId // Include agent ID so panels can filter
}
});
// Broadcast to all connected clients EXCEPT the current one (it gets direct messages)
let broadcastCount = 0;
wss.clients.forEach((client: any) => {
if (client !== ws && client.readyState === 1) { // Skip current WebSocket, only broadcast to others
client.send(message);
broadcastCount++;
}
});
console.log(`[broadcast] Broadcast ${type} to ${broadcastCount} other clients (excluding current), agentId: ${includeAgentId}`);
}
function sendError(message: string) {
send('ERROR', { message });
}
function parseAndBroadcastContextData(text: string) {
const contextData = parseContextData(text);
if (contextData) {
// Update last known values
if (contextData.max !== undefined) {
if (!lastContextData) {
lastContextData = { used: 0, max: contextData.max };
} else {
lastContextData.max = contextData.max;
}
}
if (contextData.used !== undefined) {
if (!lastContextData) {
lastContextData = { used: contextData.used, max: 200000 }; // Default max
} else {
lastContextData.used = contextData.used;
}
}
// Broadcast context update to this connection
if (lastContextData) {
console.log('[Context Tracking] Broadcasting context data:', lastContextData);
send('context_update', {
tokens: {
used: lastContextData.used,
max: lastContextData.max
}
});
}
}
}
async function sendCurrentState() {
try {
const state = await stateManager.getCurrentState();
const activeAgents = agentManager.getActiveAgents();
send('STATE_UPDATE', {
...state,
activeAgents,
currentSessionId,
currentAgentId
});
} catch (error) {
sendError('Failed to get current state');
}
}
async function handleCreateSession(payload: any) {
const { workspacePath, agentType } = payload;
if (!workspacePath || !agentType) {
sendError('Missing required fields: workspacePath, agentType');
return;
}
try {
currentSessionId = await stateManager.createSession(workspacePath, agentType);
send('SESSION_CREATED', { sessionId: currentSessionId });
await sendCurrentState();
} catch (error) {
sendError('Failed to create session');
}
}
async function handleLaunchAgent(payload: any) {
const { type = 'claude' } = payload;
if (!currentSessionId) {
sendError('No active session. Create a session first.');
return;
}
try {
currentAgentId = await agentManager.launchAgent(type, {
sessionId: currentSessionId,
workspacePath: payload.workspacePath
});
// Set up agent event listeners
const handleOutput = (data: { agentId: string; data: string }) => {
if (data.agentId === currentAgentId) {
// Parse for context data (OUTPUT is broadcast globally from websocket-server.ts)
parseAndBroadcastContextData(data.data);
// Buffer output if disconnected
if (ws.readyState !== 1) {
sessionStore.addOutput(sessionId, data.data);
}
}
};
const handleError = (data: { agentId: string; error: string }) => {
if (data.agentId === currentAgentId) {
send('ERROR', { message: data.error });
}
};
const handleExit = (data: { agentId: string; code: number }) => {
if (data.agentId === currentAgentId) {
send('AGENT_EXIT', { code: data.code });
currentAgentId = null;
// Clean up listeners
agentManager.off('agent_output', handleOutput);
agentManager.off('agent_error', handleError);
agentManager.off('agent_exit', handleExit);
}
};
agentManager.on('agent_output', handleOutput);
agentManager.on('agent_error', handleError);
agentManager.on('agent_exit', handleExit);
// Note: handleSessionId is not needed here since it's only used in the initial launch
send('AGENT_LAUNCHED', { agentId: currentAgentId });
await sendCurrentState();
} catch (error) {
sendError('Failed to launch agent');
}
}
async function handleSendInput(payload: any) {
const { input } = payload;
if (!input) {
sendError('Missing input');
return;
}
// If no agent is active, launch appropriate agent based on connection type
if (!currentAgentId) {
// For non-Claude terminals, launch a bash shell
if (!autoLaunchClaude) {
try {
// Create session if needed
if (!currentSessionId) {
currentSessionId = await stateManager.createSession(process.cwd(), 'bash');
send('SESSION_CREATED', { sessionId: currentSessionId });
}
// Launch bash agent with sandboxed option
console.log('Launching bash agent on message, sandboxed=', sandboxed);
currentAgentId = await agentManager.launchAgent('bash', {
sessionId: currentSessionId,
workspacePath: process.cwd(),
sandboxed: sandboxed
});
// Set up agent event listeners
const handleOutput = (data: { agentId: string; data: string }) => {
if (data.agentId === currentAgentId) {
// Parse for context data (OUTPUT is broadcast globally from websocket-server.ts)
parseAndBroadcastContextData(data.data);
}
};
const handleError = (data: { agentId: string; error: string }) => {
if (data.agentId === currentAgentId) {
send('ERROR', { message: data.error });
}
};
const handleExit = (data: { agentId: string; code: number }) => {
if (data.agentId === currentAgentId) {
send('AGENT_EXIT', { code: data.code });
currentAgentId = null;
// Clean up listeners
agentManager.off('agent_output', handleOutput);
agentManager.off('agent_error', handleError);
agentManager.off('agent_exit', handleExit);
}
};
agentManager.on('agent_output', handleOutput);
agentManager.on('agent_error', handleError);
agentManager.on('agent_exit', handleExit);
send('AGENT_LAUNCHED', { agentId: currentAgentId });
// Now send the input
await agentManager.sendToAgent(currentAgentId, input);
// Log command
if (currentSessionId) {
await stateManager.logCommand(currentSessionId, input, '', null);
}
return;
} catch (error) {
console.error('Failed to launch bash agent:', error);
sendError('Failed to launch terminal');
return;
}
} else {
send('OUTPUT', { data: '\r\nClaude is starting up...\r\n' });
return;
}
}
// Send to active agent
try {
await agentManager.sendToAgent(currentAgentId, input);
// Log command
if (currentSessionId) {
await stateManager.logCommand(currentSessionId, input, '', null);
}
} catch (error) {
sendError('Failed to send input to agent');
}
}
async function handleStopAgent() {
if (!currentAgentId) {
sendError('No active agent');
return;
}
try {
await agentManager.stopAgent(currentAgentId);
currentAgentId = null;
send('AGENT_STOPPED');
await sendCurrentState();
} catch (error) {
sendError('Failed to stop agent');
}
}
async function handleCreateSnapshot(payload: any) {
const { name, description } = payload;
if (!currentSessionId) {
sendError('No active session');
return;
}
if (!name || !description) {
sendError('Missing required fields: name, description');
return;
}
try {
const snapshotId = await stateManager.createSnapshot(currentSessionId, name, description);
send('SNAPSHOT_CREATED', { snapshotId });
} catch (error) {
sendError('Failed to create snapshot');
}
}
async function handleResize(payload: any) {
const { cols, rows } = payload;
if (!currentAgentId) {
// No active agent, nothing to resize
return;
}
if (!cols || !rows) {
sendError('Missing required fields: cols, rows');
return;
}
try {
// Get the active agent and resize its PTY
const agent = agentManager.getAgent(currentAgentId);
if (agent && agent.resize) {
await agent.resize(cols, rows);
}
} catch (error) {
console.error('Failed to resize terminal:', error);
// Don't send error to user as resize is a background operation
}
}
}