shell-mirror
Version:
Access your Mac shell from any device securely. Perfect for mobile coding with Claude Code CLI, Gemini CLI, and any shell tool.
1,254 lines (1,069 loc) โข 54.3 kB
JavaScript
const term = new Terminal({
cursorBlink: true,
macOptionIsMeta: true,
scrollback: 1000,
// Mac Terminal.app appearance settings
theme: {
background: '#000000', // Pure black like Mac Terminal
foreground: '#ffffff', // White text
cursor: '#ffffff', // White cursor
cursorAccent: '#000000', // Black cursor accent
selection: '#5c5c5c', // Mac selection color
// Mac Terminal color palette
black: '#000000',
red: '#c23621',
green: '#25bc24',
yellow: '#adad27',
blue: '#492ee1',
magenta: '#d338d3',
cyan: '#33bbc8',
white: '#cbcccd',
brightBlack: '#818383',
brightRed: '#fc391f',
brightGreen: '#31e722',
brightYellow: '#eaec23',
brightBlue: '#5833ff',
brightMagenta: '#f935f8',
brightCyan: '#14f0f0',
brightWhite: '#e9ebeb'
},
fontFamily: '"SF Mono", Monaco, Menlo, "Ubuntu Mono", monospace', // Mac system fonts
fontSize: 11, // Mac Terminal default size
lineHeight: 1.2, // Mac Terminal line spacing
letterSpacing: 0, // Tight character spacing like Mac
allowTransparency: false, // Solid background
convertEol: true, // Convert line endings properly
cols: 120, // Match agent terminal width
rows: 30 // Match agent terminal height
});
const fitAddon = new FitAddon.FitAddon();
term.loadAddon(fitAddon);
const connectContainer = document.getElementById('connect-container');
const terminalContainer = document.getElementById('terminal-container');
let ws;
let peerConnection;
let dataChannel;
let user;
let AGENT_ID;
let CLIENT_ID;
let SELECTED_AGENT; // Store full agent data including WebSocket URL
let usingDirectConnection = false; // Flag to prevent handler overwrite
// Session management
let currentSession = null;
let availableSessions = [];
let requestedSessionId = null; // For connecting to specific session from URL
// Chunk reassembly for large messages
const chunkAssembler = {
activeChunks: new Map(),
handleChunkedMessage(message) {
const { type, chunkId } = message;
switch (type) {
case 'chunk_start':
console.log(`[CLIENT] ๐ฆ Starting chunk reassembly: ${chunkId} (${message.totalChunks} chunks, ${message.totalSize} bytes)`);
this.activeChunks.set(chunkId, {
originalType: message.originalType,
totalChunks: message.totalChunks,
totalSize: message.totalSize,
receivedChunks: new Map(),
startTime: Date.now()
});
return true;
case 'chunk_data':
const chunkInfo = this.activeChunks.get(chunkId);
if (!chunkInfo) {
console.error(`[CLIENT] โ Received chunk data for unknown chunk ID: ${chunkId}`);
return true;
}
chunkInfo.receivedChunks.set(message.chunkIndex, message.data);
console.log(`[CLIENT] ๐ฆ Received chunk ${message.chunkIndex + 1}/${chunkInfo.totalChunks}`);
return true;
case 'chunk_end':
return this.reassembleChunks(chunkId);
default:
return false; // Not a chunk message
}
},
reassembleChunks(chunkId) {
const chunkInfo = this.activeChunks.get(chunkId);
if (!chunkInfo) {
console.error(`[CLIENT] โ Cannot reassemble unknown chunk: ${chunkId}`);
return true;
}
try {
// Check if we have all chunks
if (chunkInfo.receivedChunks.size !== chunkInfo.totalChunks) {
console.error(`[CLIENT] โ Missing chunks: expected ${chunkInfo.totalChunks}, got ${chunkInfo.receivedChunks.size}`);
return true;
}
// Reassemble chunks in order
let reassembledData = '';
for (let i = 0; i < chunkInfo.totalChunks; i++) {
if (!chunkInfo.receivedChunks.has(i)) {
console.error(`[CLIENT] โ Missing chunk ${i}`);
return true;
}
reassembledData += chunkInfo.receivedChunks.get(i);
}
const elapsed = Date.now() - chunkInfo.startTime;
console.log(`[CLIENT] โ
Reassembled ${chunkInfo.totalChunks} chunks in ${elapsed}ms (${reassembledData.length} chars)`);
// Parse and process the reassembled message
const originalMessage = JSON.parse(reassembledData);
this.activeChunks.delete(chunkId);
// Process the original message
if (originalMessage.type === 'output') {
term.write(originalMessage.data);
} else {
handleSessionMessage(originalMessage);
}
return true;
} catch (err) {
console.error(`[CLIENT] โ Error reassembling chunks for ${chunkId}:`, err);
this.activeChunks.delete(chunkId);
return true;
}
},
cleanup() {
// Clean up old incomplete chunks (older than 30 seconds)
const now = Date.now();
for (const [chunkId, chunkInfo] of this.activeChunks.entries()) {
if (now - chunkInfo.startTime > 30000) {
console.log(`[CLIENT] ๐งน Cleaning up stale chunk: ${chunkId}`);
this.activeChunks.delete(chunkId);
}
}
}
};
// Connection status management
function updateConnectionStatus(status) {
const statusElement = document.getElementById('connection-status');
if (!statusElement) return;
statusElement.className = 'connection-status';
if (status === 'connecting') {
statusElement.classList.add('connecting');
} else if (status === 'connected') {
statusElement.classList.add('connected');
}
// else: disconnected (default red)
}
// Cleanup timer for chunk assembler
setInterval(() => {
chunkAssembler.cleanup();
}, 30000); // Clean up every 30 seconds
// Check for agent parameter and connect directly
window.addEventListener('load', () => {
loadVersionInfo();
// Wait for GA script to load and send page view
setTimeout(() => {
console.log('๐ [TERMINAL DEBUG] Checking Google Analytics setup...');
console.log('๐ [TERMINAL DEBUG] gtag function type:', typeof gtag);
console.log('๐ [TERMINAL DEBUG] gtagLoaded flag:', window.gtagLoaded);
// Send terminal page view event
if (typeof sendGAEvent === 'function') {
sendGAEvent('page_view', {
page_title: 'Shell Mirror Terminal',
page_location: window.location.href
});
}
}, 1000);
// Get agent ID and session ID from URL parameters
const urlParams = new URLSearchParams(window.location.search);
const agentId = urlParams.get('agent');
const sessionId = urlParams.get('session');
console.log('[CLIENT] ๐ DEBUG: URL params - agent:', agentId, 'session:', sessionId);
console.log('[CLIENT] ๐ DEBUG: Full URL:', window.location.href);
if (agentId) {
AGENT_ID = agentId;
SELECTED_AGENT = { id: agentId, agentId: agentId };
requestedSessionId = sessionId; // Store for session request
console.log('[CLIENT] ๐ DEBUG: Set requestedSessionId to:', requestedSessionId);
console.log('[CLIENT] ๐ Connecting to agent:', agentId, sessionId ? `session: ${sessionId}` : '(new session)');
startConnection();
} else {
// No agent specified, redirect to dashboard
console.log('[CLIENT] โ No agent specified, redirecting to dashboard');
window.location.href = '/app/dashboard.html';
}
});
// Load version info into dropdown
async function loadVersionInfo() {
try {
const response = await fetch('/build-info.json');
const buildInfo = await response.json();
if (buildInfo) {
const buildDateTime = new Date(buildInfo.buildTime).toLocaleString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
const versionElement = document.getElementById('version-info-dropdown');
if (versionElement) {
versionElement.textContent = `v${buildInfo.version} โข Built ${buildDateTime}`;
}
}
} catch (error) {
console.log('Could not load build info for terminal:', error);
}
}
// Update connection detail in dropdown
function startConnection() {
updateConnectionStatus('connecting');
connectContainer.style.display = 'none';
terminalContainer.classList.add('show');
term.open(document.getElementById('terminal'));
// Initialize session display (shows header with connection status even before session exists)
updateSessionDisplay();
// Track terminal session start in Google Analytics
if (typeof sendGAEvent === 'function') {
sendGAEvent('terminal_session_start', {
event_category: 'terminal',
event_label: requestedSessionId ? 'existing_session' : 'new_session',
agent_id: AGENT_ID,
session_id: requestedSessionId || 'new'
});
}
// Delay fit to ensure proper dimensions after CSS transitions
setTimeout(() => {
fitAddon.fit();
term.focus(); // Ensure cursor is visible even before connection
}, 100);
initialize();
}
async function initialize() {
console.log('[CLIENT] ๐ Initializing connection to agent:', AGENT_ID);
console.log('[CLIENT] ๐ Selected agent data:', SELECTED_AGENT);
// First try direct connection to agent
const directConnectionSuccess = await tryDirectConnection();
if (directConnectionSuccess) {
console.log('[CLIENT] โ
Direct connection established - no server needed!');
return;
}
console.log('[CLIENT] โ ๏ธ Direct connection failed, falling back to WebRTC signaling...');
await initializeWebRTCSignaling();
}
async function tryDirectConnection() {
console.log('[CLIENT] ๐ Attempting direct connection to agent...');
updateConnectionStatus('connecting');
// Get agent data from API to find local connection details
try {
const response = await fetch('/php-backend/api/agents-list.php', {
credentials: 'include'
});
const data = await response.json();
if (!data.success || !data.data.agents) {
console.log('[CLIENT] โ Could not get agent list for direct connection');
return false;
}
const agent = data.data.agents.find(a => a.agentId === AGENT_ID);
if (!agent || !agent.localPort) {
console.log('[CLIENT] โ Agent not found or no local port information');
return false;
}
// Try common local IPs for the agent
// Start with localhost/loopback, then try common private network ranges
const possibleIPs = [
'localhost',
'127.0.0.1',
// Common private network ranges
...generatePrivateIPCandidates()
];
for (const ip of possibleIPs) {
const success = await tryDirectConnectionToIP(ip, agent.localPort);
if (success) {
return true;
}
}
console.log('[CLIENT] โ Direct connection failed to all IP candidates');
updateConnectionStatus('disconnected');
return false;
} catch (error) {
console.log('[CLIENT] โ Error during direct connection attempt:', error);
return false;
}
}
async function tryDirectConnectionToIP(ip, port) {
return new Promise((resolve) => {
console.log(`[CLIENT] ๐ Trying direct connection to ${ip}:${port}`);
const directWs = new WebSocket(`ws://${ip}:${port}`);
const timeout = setTimeout(() => {
console.log(`[CLIENT] โฐ Connection timeout to ${ip}:${port}`);
directWs.close();
resolve(false);
}, 3000); // 3 second timeout
directWs.onopen = () => {
clearTimeout(timeout);
console.log(`[CLIENT] โ
Direct connection established to ${ip}:${port}`);
// Set up the direct connection handlers
setupDirectConnection(directWs);
resolve(true);
};
directWs.onerror = () => {
clearTimeout(timeout);
console.log(`[CLIENT] โ Connection failed to ${ip}:${port}`);
resolve(false);
};
directWs.onclose = () => {
clearTimeout(timeout);
resolve(false);
};
});
}
function setupDirectConnection(directWs) {
console.log('[CLIENT] ๐ง Setting up direct connection handlers');
// Store the WebSocket for global access
ws = directWs;
usingDirectConnection = true; // Prevent signaling handler from overwriting
// Set up message handlers
directWs.onmessage = (event) => {
const data = JSON.parse(event.data);
console.log(`[CLIENT] ๐จ Direct message: ${data.type}`);
switch (data.type) {
case 'pong':
console.log('[CLIENT] ๐ Received pong from direct connection');
break;
case 'authenticated':
console.log('[CLIENT] โ
Direct authentication successful');
// Request session creation
directWs.send(JSON.stringify({
type: 'create_session',
sessionId: requestedSessionId,
cols: term.cols,
rows: term.rows
}));
break;
case 'session_created':
console.log('[CLIENT] โ
Direct session created:', data.sessionId);
// Update current session
currentSession = {
id: data.sessionId,
name: data.sessionName || 'Terminal Session'
};
// Update available sessions
if (data.availableSessions) {
availableSessions = data.availableSessions;
} else {
// If agent doesn't provide session list, add this session manually
if (!availableSessions.find(s => s.id === currentSession.id)) {
availableSessions.push(currentSession);
}
}
// Clear terminal and show success message with session color
term.clear();
const sessionColor = getSessionColor(currentSession.id);
term.write(`\r\n\x1b[38;2;${sessionColor.ansi}mโจ New session created: ${currentSession.name}\x1b[0m\r\n\r\n`);
// Update URL with session ID so refresh reconnects to same session
updateUrlWithSession(data.sessionId);
// Update UI
updateSessionDisplay();
// Save to localStorage
saveSessionToLocalStorage(AGENT_ID, currentSession);
break;
case 'output':
// Handle terminal output
if (data.sessionId === currentSession?.id) {
term.write(data.data);
}
break;
default:
console.log('[CLIENT] โ Unknown direct message type:', data.type);
}
};
directWs.onclose = () => {
console.log('[CLIENT] โ Direct connection closed');
updateConnectionStatus('disconnected');
};
directWs.onerror = (error) => {
console.error('[CLIENT] โ Direct connection error:', error);
};
// Set up terminal input handler for direct connection
term.onData((data) => {
if (currentSession && directWs.readyState === WebSocket.OPEN) {
directWs.send(JSON.stringify({
type: 'input',
sessionId: currentSession.id,
data: data
}));
}
});
// Send authentication
directWs.send(JSON.stringify({
type: 'authenticate',
agentId: AGENT_ID
}));
updateConnectionStatus('connected');
}
function generatePrivateIPCandidates() {
// Generate most common private network IP candidates
const candidates = [];
// Most common home router ranges (limit to most popular subnets)
const commonSubnets = [0, 1, 2, 10, 100];
for (const subnet of commonSubnets) {
// Common host IPs: router (1), common DHCP assignments
const hosts = [1, 2, 10, 100, 101, 150];
for (const host of hosts) {
candidates.push(`192.168.${subnet}.${host}`);
}
}
// Common corporate/enterprise ranges (just the most common ones)
candidates.push(
'10.0.0.1', '10.0.0.2', '10.0.0.100',
'10.0.1.1', '10.0.1.100',
'172.16.0.1', '172.16.0.100'
);
return candidates;
}
async function initializeWebRTCSignaling() {
console.log('[CLIENT] ๐ Initializing WebRTC signaling connection to agent:', AGENT_ID);
// Use Heroku WebSocket server for WebRTC signaling only
const signalingUrl = 'wss://shell-mirror-30aa5479ceaf.herokuapp.com';
console.log('[CLIENT] ๐ Using Heroku WebSocket server for signaling:', signalingUrl);
ws = new WebSocket(`${signalingUrl}?role=client`);
ws.onopen = () => {
console.log('[CLIENT] โ
WebSocket connection to signaling server opened.');
};
ws.onmessage = async (message) => {
const data = JSON.parse(message.data);
console.log(`[CLIENT] Received message of type: ${data.type}`);
switch (data.type) {
case 'server-hello':
CLIENT_ID = data.id;
console.log(`[CLIENT] Assigned Client ID: ${CLIENT_ID}`);
// First send a test message to verify communication
console.log(`[CLIENT] ๐งช Sending test ping message first...`);
const testSent = sendMessage({ type: 'ping', from: CLIENT_ID, to: AGENT_ID, timestamp: Date.now() });
if (!testSent) {
console.error(`[CLIENT] โ Failed to send test message - WebSocket connection broken`);
return;
}
// Start polling to connect to the agent
const intervalId = setInterval(() => {
console.log(`[CLIENT] ๐ Sending client-hello to Agent: ${AGENT_ID}`);
// Build session request
let sessionRequest = null;
console.log('[CLIENT] ๐ DEBUG: Building session request, requestedSessionId:', requestedSessionId);
if (requestedSessionId) {
sessionRequest = { sessionId: requestedSessionId };
console.log(`[CLIENT] ๐ฏ Requesting existing session: ${requestedSessionId}`);
} else {
sessionRequest = { newSession: true };
console.log(`[CLIENT] ๐ Requesting new session`);
}
const sent = sendMessage({
type: 'client-hello',
from: CLIENT_ID,
to: AGENT_ID,
sessionRequest: sessionRequest
});
if (!sent) {
console.error(`[CLIENT] โ Failed to send client-hello - stopping attempts`);
clearInterval(intervalId);
}
}, 1000);
// This is a bit of a hack for the message handler.
// We redefine it to handle the next phase of messages.
// Skip if using direct connection - don't overwrite its handler!
if (!usingDirectConnection) {
ws.onmessage = async (nextMessage) => {
let messageData = nextMessage.data;
// Handle Blob messages by converting to text first
if (messageData instanceof Blob) {
console.log(`[CLIENT] ๐ Received Blob message, converting to text...`);
messageData = await messageData.text();
}
try {
const nextData = JSON.parse(messageData);
console.log(`[CLIENT] ๐จ Received message of type: ${nextData.type}`);
if (nextData.type === 'offer') {
console.log('[CLIENT] Received offer from agent. Stopping client-hello retries.');
clearInterval(intervalId);
// Handle session assignment from agent
if (nextData.sessionId) {
currentSession = {
id: nextData.sessionId,
name: nextData.sessionName || 'Terminal Session',
isNewSession: nextData.isNewSession || false
};
console.log('[CLIENT] ๐ Session assigned:', currentSession);
console.log('[CLIENT] ๐ Agent ID for storage:', AGENT_ID);
// Update UI to show session info
updateSessionDisplay();
// Save session info to localStorage for dashboard
saveSessionToLocalStorage(AGENT_ID, currentSession);
}
if (nextData.availableSessions) {
availableSessions = nextData.availableSessions;
console.log('[CLIENT] ๐ Available sessions:', availableSessions);
// Re-render tabs with updated session list
if (currentSession) {
renderTabs();
}
}
console.log('[CLIENT] Received WebRTC offer from agent.');
await createPeerConnection();
await peerConnection.setRemoteDescription(new RTCSessionDescription(nextData));
const answer = await peerConnection.createAnswer();
await peerConnection.setLocalDescription(answer);
console.log('[CLIENT] Sending WebRTC answer to agent.');
sendMessage({ type: 'answer', sdp: answer.sdp, to: AGENT_ID, from: CLIENT_ID });
// Force ICE gathering if it hasn't started within 2 seconds
console.log('[CLIENT] ๐ง Setting up ICE gathering fallback timer...');
setTimeout(() => {
if (peerConnection.iceGatheringState === 'new') {
console.log('[CLIENT] โ ๏ธ ICE gathering hasn\'t started - triggering restart');
try {
peerConnection.restartIce();
} catch (error) {
console.error('[CLIENT] โ Failed to restart ICE:', error);
}
} else {
console.log('[CLIENT] โ
ICE gathering is active:', peerConnection.iceGatheringState);
}
}, 2000);
} else if (nextData.type === 'candidate') {
console.log('[CLIENT] ๐ง Received ICE candidate from agent:', {
candidate: nextData.candidate.candidate,
sdpMid: nextData.candidate.sdpMid,
sdpMLineIndex: nextData.candidate.sdpMLineIndex
});
if (peerConnection) {
try {
await peerConnection.addIceCandidate(new RTCIceCandidate(nextData.candidate));
console.log('[CLIENT] โ
ICE candidate added successfully');
} catch (error) {
console.error('[CLIENT] โ Error adding ICE candidate:', error);
}
} else {
console.error('[CLIENT] โ Cannot add ICE candidate - no peer connection');
}
}
} catch (error) {
console.error(`[CLIENT] โ Error processing WebRTC message:`, error);
}
};
} // End if (!usingDirectConnection)
break;
}
};
ws.onclose = (event) => {
console.log(`[CLIENT] ๐ Disconnected from signaling server. Code: ${event.code}, Reason: ${event.reason}`);
term.write('\r\n\r\nConnection to server lost. Please refresh.\r\n');
};
ws.onerror = (error) => {
console.error('[CLIENT] โ WebSocket error:', error);
};
}
async function testSTUNConnectivity() {
console.log('[CLIENT] ๐งช Testing STUN server connectivity...');
try {
// Create a test peer connection to check STUN server access
const testPC = new RTCPeerConnection({
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }]
});
let candidateReceived = false;
return new Promise((resolve) => {
const timeout = setTimeout(() => {
console.log('[CLIENT] โ ๏ธ STUN connectivity test timed out - may indicate network restrictions');
testPC.close();
resolve(false);
}, 5000);
testPC.onicecandidate = (event) => {
if (event.candidate && !candidateReceived) {
candidateReceived = true;
console.log('[CLIENT] โ
STUN server connectivity confirmed');
clearTimeout(timeout);
testPC.close();
resolve(true);
}
};
// Create a dummy data channel to trigger ICE gathering
testPC.createDataChannel('test');
testPC.createOffer().then(offer => testPC.setLocalDescription(offer));
});
} catch (error) {
console.error('[CLIENT] โ STUN connectivity test failed:', error);
return false;
}
}
async function createPeerConnection() {
console.log('[CLIENT] Creating PeerConnection.');
// Test STUN connectivity first
const stunWorking = await testSTUNConnectivity();
if (!stunWorking) {
console.log('[CLIENT] โ ๏ธ STUN servers may be blocked - using TURN servers for connectivity');
}
// Test STUN server connectivity with multiple backup servers
console.log('[CLIENT] ๐ Configuring ICE servers with multiple STUN/TURN options...');
const iceServers = [
// Google STUN servers (primary)
{ urls: 'stun:stun.l.google.com:19302' },
{ urls: 'stun:stun1.l.google.com:19302' },
// Cloudflare STUN servers (backup)
{ urls: 'stun:stun.cloudflare.com:3478' },
// Mozilla STUN servers (backup)
{ urls: 'stun:stun.services.mozilla.com:3478' },
// OpenRelay free TURN server (for NAT traversal)
{
urls: 'turn:openrelay.metered.ca:80',
username: 'openrelayproject',
credential: 'openrelayproject'
},
// Alternative TURN server
{
urls: 'turn:openrelay.metered.ca:443',
username: 'openrelayproject',
credential: 'openrelayproject'
}
];
console.log('[CLIENT] ๐ Configured ICE servers:', iceServers.map(server => server.urls));
// Enhanced WebRTC configuration for better ICE candidate generation
const rtcConfig = {
iceServers: iceServers,
iceCandidatePoolSize: 10, // Generate more ICE candidates
iceTransportPolicy: 'all', // Use both STUN and TURN
bundlePolicy: 'balanced' // Optimize for connection establishment
};
console.log('[CLIENT] โ๏ธ WebRTC config:', rtcConfig);
peerConnection = new RTCPeerConnection(rtcConfig);
// Debug: Verify event handler is being attached
console.log('[CLIENT] ๐ง Attaching ICE candidate event handler...');
peerConnection.onicecandidate = (event) => {
console.log('[CLIENT] ๐ง ICE candidate event fired:', event.candidate ? 'candidate found' : 'gathering complete');
if (event.candidate) {
console.log('[CLIENT] ๐ค ICE candidate details:', {
candidate: event.candidate.candidate,
sdpMid: event.candidate.sdpMid,
sdpMLineIndex: event.candidate.sdpMLineIndex
});
console.log('[CLIENT] ๐ค Sending ICE candidate to agent...');
const sent = sendMessage({ type: 'candidate', candidate: event.candidate, to: AGENT_ID, from: CLIENT_ID });
if (sent) {
console.log('[CLIENT] โ
ICE candidate sent successfully');
} else {
console.log('[CLIENT] โ Failed to send ICE candidate');
}
} else {
console.log('[CLIENT] ๐ ICE candidate gathering complete.');
}
};
peerConnection.oniceconnectionstatechange = () => {
console.log(`[CLIENT] ๐ ICE connection state changed: ${peerConnection.iceConnectionState}`);
console.log(`[CLIENT] ๐ ICE gathering state: ${peerConnection.iceGatheringState}`);
switch (peerConnection.iceConnectionState) {
case 'new':
console.log('[CLIENT] ๐ ICE connection starting...');
break;
case 'checking':
console.log('[CLIENT] ๐ ICE connection checking candidates...');
break;
case 'connected':
console.log('[CLIENT] โ
WebRTC connection established!');
updateConnectionStatus('connected');
// Track successful connection in Google Analytics
if (typeof sendGAEvent === 'function') {
sendGAEvent('terminal_connection_success', {
event_category: 'terminal',
event_label: 'webrtc_established',
agent_id: AGENT_ID
});
}
break;
case 'completed':
console.log('[CLIENT] โ
ICE connection completed successfully!');
updateConnectionStatus('connected');
break;
case 'failed':
console.log('[CLIENT] โ ICE connection failed - no viable candidates');
console.log('[CLIENT] ๐ก Troubleshooting: This may be due to firewall/NAT issues or blocked STUN servers');
updateConnectionStatus('disconnected');
term.write('\r\n\r\nโ Connection failed: Network connectivity issues\r\n');
term.write('๐ก This may be due to:\r\n');
term.write(' โข Firewall blocking WebRTC traffic\r\n');
term.write(' โข Corporate network restrictions\r\n');
term.write(' โข STUN/TURN servers unreachable\r\n');
term.write(' โข Agent may have crashed or disconnected\r\n');
term.write('\r\n๐ Click Dashboard to return and try another agent\r\n');
break;
case 'disconnected':
console.log('[CLIENT] โ ๏ธ ICE connection disconnected');
updateConnectionStatus('disconnected');
break;
case 'closed':
console.log('[CLIENT] ๐ ICE connection closed');
updateConnectionStatus('disconnected');
break;
}
};
peerConnection.onconnectionstatechange = () => {
console.log(`[CLIENT] ๐ก Connection state changed: ${peerConnection.connectionState}`);
switch (peerConnection.connectionState) {
case 'new':
console.log('[CLIENT] ๐ Connection starting...');
break;
case 'connecting':
console.log('[CLIENT] ๐ Connection in progress...');
break;
case 'connected':
console.log('[CLIENT] โ
Peer connection fully established!');
break;
case 'disconnected':
console.log('[CLIENT] โ ๏ธ Peer connection disconnected');
break;
case 'failed':
console.log('[CLIENT] โ Peer connection failed completely');
break;
case 'closed':
console.log('[CLIENT] ๐ Peer connection closed');
break;
}
};
peerConnection.onicegatheringstatechange = () => {
console.log(`[CLIENT] ๐ ICE gathering state changed: ${peerConnection.iceGatheringState}`);
switch (peerConnection.iceGatheringState) {
case 'new':
console.log('[CLIENT] ๐ ICE gathering not started');
break;
case 'gathering':
console.log('[CLIENT] ๐ ICE gathering in progress...');
break;
case 'complete':
console.log('[CLIENT] โ
ICE gathering completed');
break;
}
};
// Client waits for data channel from agent
peerConnection.ondatachannel = (event) => {
console.log('[CLIENT] ๐จ Data channel received from agent!');
dataChannel = event.channel;
setupDataChannel();
};
}
function setupDataChannel() {
dataChannel.onopen = () => {
console.log('[CLIENT] โ
Data channel is open!');
term.focus();
fitAddon.fit();
// Mac-style connection message with proper colors
term.write('\r\n\x1b[32mConnected to Mac Terminal\x1b[0m\r\n');
};
dataChannel.onmessage = (event) => {
try {
const message = JSON.parse(event.data);
// Check if this is a chunked message
if (chunkAssembler.handleChunkedMessage(message)) {
// Message was handled by chunk assembler
return;
}
// Handle normal messages
if (message.type === 'output') {
term.write(message.data);
} else {
// Handle session-related messages
handleSessionMessage(message);
}
} catch (err) {
console.error('[CLIENT] Error parsing data channel message:', err);
}
};
dataChannel.onclose = () => {
console.log('[CLIENT] Data channel closed.');
updateConnectionStatus('disconnected');
term.write('\r\n\r\n\x1b[31mโ Terminal session ended.\x1b[0m\r\n');
term.write('๐ Click Dashboard to return and start a new session\r\n');
};
dataChannel.onerror = (error) => {
console.error('[CLIENT] Data channel error:', error);
updateConnectionStatus('disconnected');
term.write('\r\n\r\n\x1b[31mโ Data channel error occurred.\x1b[0m\r\n');
term.write('๐ Click Dashboard to return and try again\r\n');
};
term.onData((data) => {
if (dataChannel && dataChannel.readyState === 'open') {
dataChannel.send(JSON.stringify({ type: 'input', data }));
}
});
window.addEventListener('resize', () => {
fitAddon.fit();
});
term.onResize(({ cols, rows }) => {
if (dataChannel && dataChannel.readyState === 'open') {
dataChannel.send(JSON.stringify({ type: 'resize', cols, rows }));
}
});
}
function sendMessage(message) {
console.log(`[CLIENT] ๐ค Attempting to send message:`, message);
console.log(`[CLIENT] ๐ WebSocket state: ${ws ? ws.readyState : 'null'} (OPEN=1)`);
if (!ws) {
console.error('[CLIENT] โ WebSocket is null - cannot send message');
return false;
}
if (ws.readyState !== 1) { // WebSocket.OPEN = 1
const states = ['CONNECTING', 'OPEN', 'CLOSING', 'CLOSED'];
console.error(`[CLIENT] โ WebSocket not open (state: ${ws.readyState} = ${states[ws.readyState] || 'UNKNOWN'}) - cannot send message`);
return false;
}
try {
const messageStr = JSON.stringify(message);
console.log(`[CLIENT] ๐จ Sending message: ${messageStr}`);
ws.send(messageStr);
console.log(`[CLIENT] โ
Message sent successfully`);
return true;
} catch (error) {
console.error(`[CLIENT] โ Error sending message:`, error);
return false;
}
}
// Session Management Functions
function updateSessionDisplay() {
const sessionHeader = document.getElementById('session-header');
if (!sessionHeader) {
console.warn('[CLIENT] โ ๏ธ session-header element not found');
return;
}
// Always render tabs (they will show appropriate state even if no sessions)
console.log('[CLIENT] ๐ Rendering tabs, availableSessions:', availableSessions, 'currentSession:', currentSession);
renderTabs();
if (currentSession) {
console.log('[CLIENT] ๐ Session display updated:', currentSession);
} else {
console.log('[CLIENT] ๐ No current session - showing connection state only');
}
}
// Session tab color palette (fixed colors by creation order)
const SESSION_TAB_COLORS = [
{ bg: '#e3f2fd', border: '#2196f3', text: '#1565c0', muted: '#5a9fd4', ansi: '33;150;243' }, // Blue
{ bg: '#e8f5e9', border: '#4caf50', text: '#2e7d32', muted: '#6fbf73', ansi: '76;175;80' }, // Green
{ bg: '#fff3e0', border: '#ff9800', text: '#e65100', muted: '#ffb74d', ansi: '255;152;0' }, // Orange
{ bg: '#f3e5f5', border: '#9c27b0', text: '#6a1b9a', muted: '#ba68c8', ansi: '156;39;176' }, // Purple
{ bg: '#e0f7fa', border: '#00bcd4', text: '#00838f', muted: '#4dd0e1', ansi: '0;188;212' }, // Teal
{ bg: '#fce4ec', border: '#e91e63', text: '#ad1457', muted: '#f06292', ansi: '233;30;99' }, // Pink
];
// Track color assignments by session ID (persists across renders)
const sessionColorMap = {};
let nextColorIndex = 0;
function getSessionColor(sessionId) {
if (!sessionColorMap[sessionId]) {
sessionColorMap[sessionId] = nextColorIndex;
nextColorIndex = (nextColorIndex + 1) % SESSION_TAB_COLORS.length;
}
return SESSION_TAB_COLORS[sessionColorMap[sessionId]];
}
function renderTabs() {
const tabBar = document.getElementById('session-tab-bar');
if (!tabBar) {
console.warn('[CLIENT] โ ๏ธ session-tab-bar element not found');
return;
}
// Ensure we have sessions to display
let sessionsToRender = [];
if (availableSessions && availableSessions.length > 0) {
// Use availableSessions from agent
sessionsToRender = availableSessions;
} else if (currentSession) {
// Fallback: show at least the current session
sessionsToRender = [currentSession];
}
console.log('[CLIENT] ๐จ Rendering tabs:', {
sessionCount: sessionsToRender.length,
currentSession: currentSession?.id,
source: availableSessions?.length > 0 ? 'agent' : 'fallback'
});
// Build tabs HTML
let tabsHTML = '';
if (sessionsToRender.length > 0) {
tabsHTML = sessionsToRender.map(session => {
const isActive = currentSession && session.id === currentSession.id;
const displayName = session.name || 'Terminal Session';
const color = getSessionColor(session.id);
// Active tabs get full color, inactive tabs get muted version of their color
const tabStyle = isActive
? `background: ${color.bg}; border-color: ${color.border}; border-bottom: 3px solid ${color.border};`
: `background: rgba(255,255,255,0.05); border-color: transparent; border-bottom: 3px solid ${color.muted}40;`;
const textStyle = isActive
? `color: ${color.text}; font-weight: 600;`
: `color: ${color.muted};`;
return `
<div class="session-tab ${isActive ? 'active' : ''}" style="${tabStyle}; cursor: pointer;" title="${displayName}" data-color-index="${sessionColorMap[session.id]}" onclick="switchToSession('${session.id}')">
<span class="session-tab-name" style="${textStyle}">${displayName}</span>
<button class="session-tab-close" onclick="event.stopPropagation(); closeSession('${session.id}', event)" title="Close session" style="color: ${isActive ? color.text : color.muted}">ร</button>
</div>
`;
}).join('');
// Add new session button only when we have sessions
tabsHTML += '<button class="session-tab-new" onclick="createNewSession()" title="New Session">+</button>';
} else {
// No sessions available - show status message
tabsHTML = '<div style="color: #888; font-size: 0.85rem; padding: 6px 12px;">No sessions available</div>';
}
tabBar.innerHTML = tabsHTML;
console.log('[CLIENT] โ
Tabs rendered:', sessionsToRender.length, 'tabs');
}
// Update URL with current session ID so refresh reconnects
function updateUrlWithSession(sessionId) {
const url = new URL(window.location.href);
url.searchParams.set('session', sessionId);
window.history.replaceState({}, '', url.toString());
console.log('[CLIENT] ๐ URL updated with session:', sessionId);
}
// Close a session with confirmation - shows custom modal
function closeSession(sessionId, event) {
event.stopPropagation(); // Don't trigger tab switch
const session = availableSessions.find(s => s.id === sessionId);
const sessionName = session?.name || 'this session';
const createdAt = session?.createdAt || null;
// Get session color for modal
const sessionColor = getSessionColor(sessionId);
// Show custom modal instead of browser confirm()
if (typeof showCloseSessionModal === 'function') {
showCloseSessionModal(sessionId, sessionName, createdAt, sessionColor);
} else {
// Fallback to native confirm if modal not available
if (confirm(`Close "${sessionName}"?\n\nThis will terminate the terminal session.`)) {
doCloseSession(sessionId);
}
}
}
// Actually close the session (called from modal confirmation)
function doCloseSession(sessionId) {
console.log('[CLIENT] ๐๏ธ Closing session:', sessionId);
// Remove from available sessions
const remainingSessions = availableSessions.filter(s => s.id !== sessionId);
const isClosingCurrentSession = currentSession && currentSession.id === sessionId;
const nextSession = isClosingCurrentSession && remainingSessions.length > 0 ? remainingSessions[0] : null;
// Send close request to agent (with auto-switch if closing active session)
const closeMessage = {
type: 'close_session',
sessionId: sessionId,
switchToSessionId: nextSession ? nextSession.id : null // Atomic close + switch
};
if (dataChannel && dataChannel.readyState === 'open') {
dataChannel.send(JSON.stringify(closeMessage));
} else if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(closeMessage));
}
// Update local state
availableSessions = remainingSessions;
// If closing current session, update UI immediately
if (isClosingCurrentSession) {
// Clear terminal IMMEDIATELY to prevent garbage from closed session
term.clear();
if (nextSession) {
// Update currentSession IMMEDIATELY so renderTabs shows correct active tab
currentSession = {
id: nextSession.id,
name: nextSession.name || 'Terminal Session'
};
// Update URL
updateUrlWithSession(nextSession.id);
// Note: Agent will send session-switched with buffered output
} else {
currentSession = null;
term.write('\r\n\x1b[33mSession closed. Click + to create a new session.\x1b[0m\r\n');
}
}
renderTabs();
}
function getConnectionStatus() {
// Check direct WebSocket connection
if (ws && ws.readyState === WebSocket.OPEN) {
return 'connected';
}
// Check WebRTC data channel connection
if (dataChannel && dataChannel.readyState === 'open') {
return 'connected';
}
// Check connecting states
if (ws && ws.readyState === WebSocket.CONNECTING) {
return 'connecting';
}
if (dataChannel && dataChannel.readyState === 'connecting') {
return 'connecting';
}
// Not connected
return 'disconnected';
}
function formatLastActivity(timestamp) {
const now = Date.now();
const diff = now - timestamp;
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (minutes < 1) return 'now';
if (minutes < 60) return `${minutes}m`;
if (hours < 24) return `${hours}h`;
return `${days}d`;
}
function switchToSession(sessionId) {
if (!dataChannel || dataChannel.readyState !== 'open') {
console.error('[CLIENT] โ Cannot switch session - data channel not open');
return;
}
console.log('[CLIENT] ๐ Switching to session:', sessionId);
dataChannel.send(JSON.stringify({
type: 'session-switch',
sessionId: sessionId
}));
}
function createNewSession() {
console.log('[CLIENT] ๐ Creating new session...');
console.log('[CLIENT] Debug - ws:', ws ? ws.readyState : 'null',
'dataChannel:', dataChannel ? dataChannel.readyState : 'null');
// PRIORITIZE WebRTC data channel - it has proper SessionManager!
if (dataChannel && dataChannel.readyState === 'open') {
console.log('[CLIENT] ๐ค Sending session create via WebRTC data channel');
const message = {
type: 'session-create',
cols: term.cols,
rows: term.rows
};
console.log('[CLIENT] Message:', JSON.stringify(message));
dataChannel.send(JSON.stringify(message));
} else if (ws && ws.readyState === WebSocket.OPEN) {
console.log('[CLIENT] ๐ค Sending session create via Direct WebSocket');
const message = {
type: 'create_session',
cols: term.cols,
rows: term.rows
};
console.log('[CLIENT] Message:', JSON.stringify(message));
ws.send(JSON.stringify(message));
} else {
console.error('[CLIENT] โ Cannot create session - no active connection');
console.error('[CLIENT] ws:', ws ? ws.readyState : 'null',
'dataChannel:', dataChannel ? dataChannel.readyState : 'null');
term.write('\r\n\x1b[31mโ Cannot create session - not connected\x1b[0m\r\n');
}
}
// Handle session-related data channel messages
function handleSessionMessage(message) {
switch (message.type) {
case 'session-created':
console.log('[CLIENT] โ
New session created:', message.sessionId);
// Update current session
currentSession = {
id: message.sessionId,
name: message.sessionName || 'Terminal Session'
};
// Update available sessions list
if (message.availableSessions) {
availableSessions = message.availableSessions;
} else {
// Add to local list if not provided
availableSessions.push(currentSession);
}
// Clear terminal for new session with session color
term.clear();
const sessionColor = getSessionColor(currentSession.id);
term.write(`\r\n\x1b[38;2;${sessionColor.ansi}mโจ New session created: ${currentSession.name}\x1b[0m\r\n\r\n`);
// Update URL with session ID so refresh reconnects to same session
updateUrlWithSession(message.sessionId);
// Update UI
updateSessionDisplay();
// Save to localStorage
saveSessionToLocalStorage(AGENT_ID, currentSession);
// Focus terminal for keyboard input
term.focus();
break;
case 'session-switched':
current