UNPKG

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
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