Monorepo for Aesthetic.Computer aesthetic.computer
at main 1146 lines 40 kB view raw
1#!/usr/bin/env node 2/** 3 * 🔍 Devcontainer Status - Provides process tree and system info 4 * 5 * Usage: 6 * node devcontainer-status.mjs # JSON output for VS Code extension 7 * node devcontainer-status.mjs --watch # Continuous updates (SSE style) 8 * node devcontainer-status.mjs --server # HTTP server on port 7890 with WebSocket 9 * 10 * Endpoints: 11 * GET / - Interactive D3.js dashboard (for Simple Browser debugging) 12 * GET /status - JSON status snapshot 13 * GET /stream - SSE live updates 14 * WS /ws - WebSocket for real-time updates 15 */ 16 17import { exec, execSync, spawn } from 'child_process'; 18import { createServer } from 'http'; 19import { promisify } from 'util'; 20import { WebSocketServer } from 'ws'; 21import os from 'os'; 22import path from 'path'; 23import { fileURLToPath } from 'url'; 24 25const __dirname = path.dirname(fileURLToPath(import.meta.url)); 26 27const execAsync = promisify(exec); 28 29// Key processes we care about for Aesthetic Computer 30// IMPORTANT: Order matters! First match wins, so specific patterns must come before broad ones. 31const INTERESTING_PROCESSES = [ 32 // Emacs & terminals 33 { pattern: /emacs.*daemon|emacs.*--daemon/, name: 'Emacs Daemon', icon: '🔮', category: 'editor' }, 34 { pattern: /emacsclient/, name: 'Emacs Client', icon: '📝', category: 'editor' }, 35 { pattern: /artery-tui|artery\.mjs/, name: 'Artery TUI', icon: '🩸', category: 'tui' }, 36 { pattern: /emacs-mcp/, name: 'Emacs MCP', icon: '🧠', category: 'bridge' }, 37 38 // Eat terminals (emacs tabs) 39 { pattern: /eat.*fishy|🐟.*fishy/, name: '🐟 Fishy', icon: '🐟', category: 'shell' }, 40 { pattern: /eat.*kidlisp|🧪.*kidlisp/, name: '🧪 KidLisp', icon: '🧪', category: 'shell' }, 41 { pattern: /eat.*tunnel|🚇.*tunnel/, name: '🚇 Tunnel', icon: '🚇', category: 'shell' }, 42 { pattern: /eat.*url|⚡.*url/, name: '⚡ URL', icon: '⚡', category: 'shell' }, 43 { pattern: /eat.*bookmarks|🔖.*bookmarks/, name: '🔖 Bookmarks', icon: '🔖', category: 'shell' }, 44 45 // AI - Claude Code (BEFORE vscode-server since their paths contain it) 46 { pattern: /native-binary\/claude/, name: 'Claude Code', icon: '🧠', category: 'ai' }, 47 { pattern: /(?:^|\/)claude(?:\s|$)/, name: 'Claude CLI', icon: '🤖', category: 'ai' }, 48 { pattern: /ollama/, name: 'Ollama', icon: '🤖', category: 'ai' }, 49 50 // VS Code main servers (BEFORE LSP - their cmds contain extension names like mongodb-vscode) 51 { pattern: /server-main\.js/, name: 'VS Code Server', icon: '💻', category: 'ide' }, 52 { pattern: /extensionHost/, name: 'VS Code', icon: '💻', category: 'ide' }, 53 { pattern: /bootstrap-fork.*fileWatcher/, name: 'VS Code Files', icon: '💻', category: 'ide' }, 54 { pattern: /ptyHost/, name: 'VS Code PTY', icon: '💻', category: 'ide' }, 55 56 // LSP servers (child processes of extensionHost with specific server scripts) 57 { pattern: /tsserver/, name: 'TypeScript LSP', icon: '📘', category: 'lsp' }, 58 { pattern: /vscode-pylance|server\.bundle\.js/, name: 'Pylance', icon: '🐍', category: 'lsp' }, 59 { pattern: /mongodb.*languageServer/, name: 'MongoDB LSP', icon: '🍃', category: 'lsp' }, 60 { pattern: /eslint.*server|eslintServer/, name: 'ESLint', icon: '📏', category: 'lsp' }, 61 { pattern: /prettier/, name: 'Prettier', icon: '✨', category: 'lsp' }, 62 { pattern: /cssServerMain/, name: 'CSS LSP', icon: '🎨', category: 'lsp' }, 63 { pattern: /jsonServerMain/, name: 'JSON LSP', icon: '📋', category: 'lsp' }, 64 { pattern: /copilot-agent|github\.copilot/, name: 'Copilot', icon: '✨', category: 'ai' }, 65 66 // Node.js processes (specific before generic) 67 { pattern: /node.*session\.mjs/, name: 'Session Server', icon: '🔗', category: 'dev' }, 68 { pattern: /node.*server\.mjs/, name: 'Main Server', icon: '🖥️', category: 'dev' }, 69 { pattern: /node.*chat\.mjs/, name: 'Chat Service', icon: '💬', category: 'dev' }, 70 { pattern: /node.*devcontainer-status/, name: 'Status Server', icon: '📊', category: 'dev' }, 71 { pattern: /node.*stripe/, name: 'Stripe', icon: '💳', category: 'dev' }, 72 { pattern: /node.*netlify/, name: 'Netlify CLI', icon: '🌐', category: 'dev' }, 73 { pattern: /netlify-log-filter/, name: 'Log Filter', icon: '📝', category: 'dev' }, 74 { pattern: /nodemon/, name: 'Nodemon', icon: '👀', category: 'dev' }, 75 { pattern: /esbuild/, name: 'esbuild', icon: '⚡', category: 'dev' }, 76 77 // Infrastructure 78 { pattern: /redis-server/, name: 'Redis', icon: '📦', category: 'db' }, 79 { pattern: /caddy/, name: 'Caddy', icon: '🌐', category: 'proxy' }, 80 { pattern: /ngrok|cloudflared/, name: 'Tunnel', icon: '🚇', category: 'proxy' }, 81 { pattern: /deno/, name: 'Deno', icon: '🦕', category: 'dev' }, 82 83 // Generic Node.js (last resort for node processes) 84 { pattern: /node(?!.*vscode)(?!.*copilot)/, name: 'Node.js', icon: '🟢', category: 'dev' }, 85 86 // Shells 87 { pattern: /fish(?!.*vscode)/, name: 'Fish Shell', icon: '🐚', category: 'shell' }, 88 { pattern: /bash/, name: 'Bash', icon: '🐚', category: 'shell' }, 89]; 90 91// Parse ps output into structured data (with PPID for tree structure) 92function parseProcessLine(line) { 93 const parts = line.trim().split(/\s+/); 94 if (parts.length < 12) return null; 95 96 const [user, pid, ppid, cpu, mem, vsz, rss, tty, stat, start, time, ...cmdParts] = parts; 97 const cmd = cmdParts.join(' '); 98 99 return { 100 pid: parseInt(pid), 101 ppid: parseInt(ppid), 102 user, 103 cpu: parseFloat(cpu), 104 mem: parseFloat(mem), 105 rss: parseInt(rss), // Resident memory in KB 106 stat, 107 start, 108 time, 109 cmd, 110 cmdShort: cmd.slice(0, 80), 111 }; 112} 113 114// Identify interesting processes - extract meaningful names 115function categorizeProcess(proc) { 116 const cmd = proc.cmd; 117 118 for (const { pattern, name, icon, category } of INTERESTING_PROCESSES) { 119 if (pattern.test(cmd)) { 120 // Try to extract a more specific name for node processes 121 let displayName = name; 122 123 // For generic Node.js, extract the script name 124 if (name === 'Node.js') { 125 const scriptMatch = cmd.match(/node\s+(?:--[^\s]+\s+)*([^\s]+\.m?js)/); 126 if (scriptMatch) { 127 const script = scriptMatch[1].split('/').pop().replace(/\.m?js$/, ''); 128 displayName = script.charAt(0).toUpperCase() + script.slice(1); 129 } 130 } 131 132 // For chat services, extract which chat 133 if (cmd.includes('chat.mjs')) { 134 const chatMatch = cmd.match(/chat\.mjs\s+(\S+)/); 135 if (chatMatch) { 136 displayName = 'Chat: ' + chatMatch[1].replace('chat-', ''); 137 } 138 } 139 140 // Make unique by PID for shells 141 if (category === 'shell' && !displayName.includes('🐟') && !displayName.includes('🧪')) { 142 displayName = displayName + ' #' + proc.pid; 143 } 144 145 return { ...proc, name: displayName, icon, category, interesting: true }; 146 } 147 } 148 return { ...proc, interesting: false }; 149} 150 151// Get process tree with hierarchy 152async function getProcessTree() { 153 try { 154 // Include PPID in output for tree structure 155 const { stdout } = await execAsync('ps -eo user,pid,ppid,%cpu,%mem,vsz,rss,tty,stat,start,time,args --sort=-rss 2>/dev/null || ps aux'); 156 const lines = stdout.trim().split('\n').slice(1); // Skip header 157 158 const allProcesses = lines 159 .map(parseProcessLine) 160 .filter(Boolean); 161 162 const processes = allProcesses.map(p => categorizeProcess(p)); 163 164 // Build a map of all PIDs for parent lookup 165 const pidMap = new Map(); 166 allProcesses.forEach(p => pidMap.set(p.pid, p)); 167 168 // Separate interesting processes from others 169 const interesting = processes.filter(p => p.interesting); 170 171 // For each interesting process, find its parent chain up to another interesting process 172 interesting.forEach(p => { 173 // Find nearest interesting ancestor 174 let parentPid = p.ppid; 175 let depth = 0; 176 while (parentPid && parentPid > 1 && depth < 20) { 177 const parent = pidMap.get(parentPid); 178 if (!parent) break; 179 180 // Check if parent is also interesting 181 const interestingParent = interesting.find(ip => ip.pid === parentPid); 182 if (interestingParent) { 183 p.parentInteresting = parentPid; 184 break; 185 } 186 parentPid = parent.ppid; 187 depth++; 188 } 189 }); 190 191 const topMemory = processes 192 .filter(p => !p.interesting && p.rss > 10000) // > 10MB 193 .slice(0, 10); 194 195 return { 196 interesting, 197 topMemory, 198 total: processes.length, 199 }; 200 } catch (err) { 201 return { error: err.message, interesting: [], topMemory: [], total: 0 }; 202 } 203} 204 205// Get emacs buffer status via emacsclient 206async function getEmacsStatus() { 207 try { 208 const { stdout } = await execAsync( 209 `/usr/sbin/emacsclient --eval '(ac-mcp-format-state)' 2>/dev/null`, 210 { timeout: 2000 } 211 ); 212 // Parse the elisp string result 213 const state = stdout.trim().replace(/^"|"$/g, ''); 214 return { online: true, state }; 215 } catch { 216 return { online: false, state: null }; 217 } 218} 219 220// Get system overview 221function getSystemInfo() { 222 const totalMem = os.totalmem(); 223 const freeMem = os.freemem(); 224 const usedMem = totalMem - freeMem; 225 const loadAvg = os.loadavg(); 226 const uptime = os.uptime(); 227 228 return { 229 hostname: os.hostname(), 230 platform: os.platform(), 231 arch: os.arch(), 232 cpus: os.cpus().length, 233 memory: { 234 total: Math.round(totalMem / 1024 / 1024), // MB 235 used: Math.round(usedMem / 1024 / 1024), 236 free: Math.round(freeMem / 1024 / 1024), 237 percent: Math.round((usedMem / totalMem) * 100), 238 }, 239 load: { 240 '1m': loadAvg[0].toFixed(2), 241 '5m': loadAvg[1].toFixed(2), 242 '15m': loadAvg[2].toFixed(2), 243 }, 244 uptime: { 245 seconds: uptime, 246 formatted: formatUptime(uptime), 247 }, 248 }; 249} 250 251function formatUptime(seconds) { 252 const days = Math.floor(seconds / 86400); 253 const hours = Math.floor((seconds % 86400) / 3600); 254 const mins = Math.floor((seconds % 3600) / 60); 255 256 if (days > 0) return `${days}d ${hours}h`; 257 if (hours > 0) return `${hours}h ${mins}m`; 258 return `${mins}m`; 259} 260 261// Get full status 262async function getFullStatus() { 263 const [processes, emacs, system] = await Promise.all([ 264 getProcessTree(), 265 getEmacsStatus(), 266 Promise.resolve(getSystemInfo()), 267 ]); 268 269 return { 270 timestamp: Date.now(), 271 system, 272 emacs, 273 processes, 274 }; 275} 276 277// Main entry point 278async function main() { 279 const args = process.argv.slice(2); 280 281 if (args.includes('--server')) { 282 // HTTP + WebSocket server mode 283 const PORT = parseInt(process.env.STATUS_PORT) || 7890; 284 285 const server = createServer(async (req, res) => { 286 // CORS headers for VS Code webview 287 res.setHeader('Access-Control-Allow-Origin', '*'); 288 res.setHeader('Access-Control-Allow-Methods', 'GET'); 289 290 // Parse URL to handle query strings 291 const url = new URL(req.url, `http://${req.headers.host}`); 292 const pathname = url.pathname; 293 294 if (pathname === '/status') { 295 res.setHeader('Content-Type', 'application/json'); 296 const status = await getFullStatus(); 297 res.writeHead(200); 298 res.end(JSON.stringify(status, null, 2)); 299 300 } else if (pathname === '/stream') { 301 // SSE stream for live updates 302 res.writeHead(200, { 303 'Content-Type': 'text/event-stream', 304 'Cache-Control': 'no-cache', 305 'Connection': 'keep-alive', 306 }); 307 308 const sendUpdate = async () => { 309 const status = await getFullStatus(); 310 res.write(`data: ${JSON.stringify(status)}\n\n`); 311 }; 312 313 await sendUpdate(); 314 const interval = setInterval(sendUpdate, 2000); 315 316 req.on('close', () => { 317 clearInterval(interval); 318 }); 319 320 } else if (pathname === '/' || pathname === '/dashboard' || pathname === '/index.html') { 321 // Serve the interactive D3.js dashboard HTML 322 res.setHeader('Content-Type', 'text/html'); 323 res.writeHead(200); 324 res.end(getDashboardHTML()); 325 326 } else { 327 res.setHeader('Content-Type', 'application/json'); 328 res.writeHead(404); 329 res.end(JSON.stringify({ error: 'Not found' })); 330 } 331 }); 332 333 // WebSocket server for real-time updates 334 const wss = new WebSocketServer({ server, path: '/ws' }); 335 336 wss.on('connection', (ws) => { 337 console.log('🔌 WebSocket client connected'); 338 339 // Send initial status immediately 340 getFullStatus().then(status => { 341 ws.send(JSON.stringify(status)); 342 }); 343 344 // Send updates every 1.5 seconds 345 const interval = setInterval(async () => { 346 if (ws.readyState === ws.OPEN) { 347 const status = await getFullStatus(); 348 ws.send(JSON.stringify(status)); 349 } 350 }, 1500); 351 352 ws.on('close', () => { 353 console.log('🔌 WebSocket client disconnected'); 354 clearInterval(interval); 355 }); 356 357 ws.on('error', (err) => { 358 console.error('WebSocket error:', err); 359 clearInterval(interval); 360 }); 361 }); 362 363 server.listen(PORT, '0.0.0.0', () => { 364 console.log(`🔍 Devcontainer Status Server running on http://localhost:${PORT}`); 365 console.log(` GET / - Interactive D3.js dashboard`); 366 console.log(` GET /status - JSON status snapshot`); 367 console.log(` GET /stream - SSE live updates`); 368 console.log(` WS /ws - WebSocket real-time updates`); 369 }); 370 371 } else if (args.includes('--watch')) { 372 // Continuous output mode 373 const update = async () => { 374 const status = await getFullStatus(); 375 console.clear(); 376 console.log(JSON.stringify(status, null, 2)); 377 }; 378 379 await update(); 380 setInterval(update, 2000); 381 382 } else { 383 // Single JSON output (default) 384 const status = await getFullStatus(); 385 console.log(JSON.stringify(status, null, 2)); 386 } 387} 388 389// Generate the interactive dashboard HTML with D3.js 390function getDashboardHTML() { 391 return `<!DOCTYPE html> 392<html lang="en"> 393<head> 394 <meta charset="UTF-8"> 395 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 396 <title>Aesthetic Computer</title> 397 <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script> 398 <script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"></script> 399 <style> 400 * { margin: 0; padding: 0; box-sizing: border-box; } 401 body { 402 background: #000; 403 color: #fff; 404 font-family: monospace; 405 overflow: hidden; 406 height: 100vh; 407 } 408 #canvas { position: absolute; top: 0; left: 0; } 409 .hud { 410 position: absolute; 411 z-index: 100; 412 pointer-events: none; 413 } 414 .title { 415 top: 16px; 416 left: 16px; 417 font-size: 14px; 418 display: flex; 419 align-items: center; 420 gap: 8px; 421 } 422 .title .dot { color: #ff69b4; } 423 .status-dot { 424 width: 6px; 425 height: 6px; 426 border-radius: 50%; 427 background: #ff69b4; 428 } 429 .status-dot.online { background: #0f0; } 430 .stats { 431 top: 16px; 432 right: 16px; 433 text-align: right; 434 font-size: 12px; 435 color: #555; 436 } 437 .stats .val { color: #fff; } 438 .mem { 439 bottom: 16px; 440 right: 16px; 441 font-size: 12px; 442 color: #555; 443 } 444 .center { 445 top: 50%; 446 left: 50%; 447 transform: translate(-50%, -50%); 448 text-align: center; 449 font-size: 12px; 450 color: #555; 451 } 452 .center .count { 453 font-size: 28px; 454 color: #fff; 455 margin-bottom: 4px; 456 } 457 .label-container { 458 position: absolute; 459 top: 0; 460 left: 0; 461 width: 100%; 462 height: 100%; 463 pointer-events: none; 464 z-index: 50; 465 overflow: hidden; 466 } 467 .proc-label { 468 position: absolute; 469 text-align: center; 470 transform: translate(-50%, -100%); 471 white-space: nowrap; 472 text-shadow: 0 0 3px #000, 0 0 6px #000; 473 pointer-events: none; 474 } 475 .proc-label .icon { font-size: 18px; display: block; line-height: 1; } 476 .proc-label .name { font-size: 10px; margin-top: 2px; font-weight: bold; letter-spacing: 0.3px; } 477 .proc-label .info { font-size: 8px; color: #aaa; margin-top: 1px; } 478 .proc-label .bar { 479 width: 80px; 480 height: 5px; 481 background: #333; 482 margin: 5px auto 0; 483 border-radius: 3px; 484 overflow: hidden; 485 } 486 .proc-label .bar-fill { 487 height: 100%; 488 border-radius: 3px; 489 transition: width 0.3s ease; 490 } 491 </style> 492</head> 493<body> 494 <canvas id="canvas"></canvas> 495 496 <div class="hud title"> 497 <div class="status-dot" id="status-dot"></div> 498 <span>Aesthetic<span class="dot">.</span>Computer Architecture</span> 499 </div> 500 501 <div class="hud stats"> 502 <div><span class="val" id="uptime">—</span></div> 503 <div><span class="val" id="cpus">—</span> cpus</div> 504 </div> 505 506 <div class="hud center"> 507 <div class="count" id="process-count">0</div> 508 <div>processes</div> 509 </div> 510 511 <div class="hud mem"> 512 <span id="mem-text">— / —</span> MB 513 </div> 514 515 <div id="labels" class="label-container"></div> 516 517 <script> 518 const colors = { 519 'editor': 0xb06bff, 'tui': 0xff69b4, 'bridge': 0x6bff9f, 520 'db': 0xffeb6b, 'proxy': 0x6b9fff, 'ai': 0xff9f6b, 521 'shell': 0x6bffff, 'dev': 0x6bff9f, 'ide': 0x6b9fff, 'lsp': 0x888888 522 }; 523 524 let width = window.innerWidth, height = window.innerHeight; 525 let meshes = new Map(), connections = new Map(), ws; 526 let graveyard = []; // Dead processes 527 const MAX_GRAVEYARD = 30; // Keep last 30 dead processes 528 const GRAVEYARD_Y = -200; // Y position for graveyard 529 530 // Three.js setup 531 const scene = new THREE.Scene(); 532 const camera = new THREE.PerspectiveCamera(50, width / height, 0.1, 5000); 533 camera.position.set(0, 150, 400); 534 camera.lookAt(0, 0, 0); 535 536 const renderer = new THREE.WebGLRenderer({ canvas: document.getElementById('canvas'), antialias: true }); 537 renderer.setSize(width, height); 538 renderer.setPixelRatio(window.devicePixelRatio); 539 540 // OrbitControls for mouse interaction 541 const controls = new THREE.OrbitControls(camera, renderer.domElement); 542 controls.enableDamping = true; 543 controls.dampingFactor = 0.05; 544 controls.minDistance = 20; 545 controls.maxDistance = 3000; 546 controls.enablePan = true; 547 controls.autoRotate = true; 548 controls.autoRotateSpeed = 0.3; 549 controls.target.set(0, 0, 0); 550 551 // Focus mode - click on node to orbit around it 552 let focusedPid = null; 553 let focusTarget = new THREE.Vector3(0, 0, 0); 554 let focusDistance = null; // null = free zoom 555 let transitioning = false; 556 557 // Raycaster for click detection 558 const raycaster = new THREE.Raycaster(); 559 const mouse = new THREE.Vector2(); 560 561 renderer.domElement.addEventListener('click', (e) => { 562 mouse.x = (e.clientX / width) * 2 - 1; 563 mouse.y = -(e.clientY / height) * 2 + 1; 564 565 raycaster.setFromCamera(mouse, camera); 566 const meshArray = Array.from(meshes.values()); 567 const intersects = raycaster.intersectObjects(meshArray); 568 569 if (intersects.length > 0) { 570 const clicked = intersects[0].object; 571 const pid = clicked.userData.pid; 572 573 if (focusedPid === String(pid)) { 574 // Click same node = unfocus 575 focusedPid = null; 576 focusTarget.set(0, 0, 0); 577 focusDistance = null; // free zoom 578 } else { 579 // Focus on this node 580 focusedPid = String(pid); 581 focusTarget.copy(clicked.position); 582 focusDistance = 80 + (clicked.userData.size || 6) * 3; 583 } 584 transitioning = true; 585 controls.autoRotate = true; 586 } else if (!e.shiftKey) { 587 // Click empty space = unfocus (unless shift held) 588 focusedPid = null; 589 focusTarget.set(0, 0, 0); 590 focusDistance = null; // free zoom 591 transitioning = true; 592 } 593 }); 594 595 // Double-click to hard reset view 596 renderer.domElement.addEventListener('dblclick', () => { 597 focusedPid = null; 598 focusTarget.set(0, 0, 0); 599 focusDistance = null; 600 transitioning = true; 601 camera.position.set(0, 150, 400); 602 }); 603 604 // Store tree structure 605 let processTree = { roots: [], byPid: new Map() }; 606 607 // Create central kernel/system node - the centroid 608 let kernelMesh = null; 609 let kernelGlow = null; 610 let kernelCore = null; 611 function createKernelNode() { 612 const group = new THREE.Group(); 613 614 // Outer wireframe sphere (large) 615 const outerGeo = new THREE.SphereGeometry(35, 32, 32); 616 const outerMat = new THREE.MeshBasicMaterial({ 617 color: 0x4488ff, 618 transparent: true, 619 opacity: 0.15, 620 wireframe: true 621 }); 622 const outer = new THREE.Mesh(outerGeo, outerMat); 623 group.add(outer); 624 625 // Middle ring 626 const ringGeo = new THREE.TorusGeometry(25, 1.5, 8, 48); 627 const ringMat = new THREE.MeshBasicMaterial({ 628 color: 0x66aaff, 629 transparent: true, 630 opacity: 0.4 631 }); 632 const ring = new THREE.Mesh(ringGeo, ringMat); 633 ring.rotation.x = Math.PI / 2; 634 group.add(ring); 635 kernelGlow = ring; 636 637 // Inner solid core 638 const coreGeo = new THREE.SphereGeometry(12, 24, 24); 639 const coreMat = new THREE.MeshBasicMaterial({ 640 color: 0x88ccff, 641 transparent: true, 642 opacity: 0.7 643 }); 644 const core = new THREE.Mesh(coreGeo, coreMat); 645 group.add(core); 646 kernelCore = core; 647 648 group.userData = { 649 pid: 'kernel', 650 name: 'Fedora Linux 43', 651 icon: '🐧', 652 category: 'kernel', 653 cpu: 0, 654 rss: 0, 655 size: 35, 656 targetPos: new THREE.Vector3(0, 0, 0), 657 pulsePhase: 0 658 }; 659 group.position.set(0, 0, 0); 660 return group; 661 } 662 663 kernelMesh = createKernelNode(); 664 scene.add(kernelMesh); 665 meshes.set('kernel', kernelMesh); 666 667 function createNodeMesh(node, depth = 0, index = 0, siblingCount = 1) { 668 const cpu = node.cpu || 0; 669 const memMB = (node.rss || 10000) / 1024; 670 const baseColor = colors[node.category] || 0x666666; 671 672 // Create a small sphere for the node 673 const size = Math.max(4, Math.min(12, 3 + memMB * 0.05 + cpu * 0.1)); 674 const geo = new THREE.SphereGeometry(size, 12, 12); 675 const mat = new THREE.MeshBasicMaterial({ 676 color: baseColor, 677 transparent: true, 678 opacity: 0.7 + cpu * 0.003 679 }); 680 681 const mesh = new THREE.Mesh(geo, mat); 682 mesh.userData = { 683 ...node, 684 size, 685 depth, 686 index, 687 siblingCount, 688 baseColor, 689 targetPos: new THREE.Vector3(), 690 pulsePhase: Math.random() * Math.PI * 2 691 }; 692 693 return mesh; 694 } 695 696 function createConnectionLine(parentMesh, childMesh) { 697 // Use a cylinder for thick lines 698 const geo = new THREE.CylinderGeometry(1.5, 1.5, 1, 8); 699 const mat = new THREE.MeshBasicMaterial({ 700 color: 0x444444, 701 transparent: true, 702 opacity: 0.5 703 }); 704 const mesh = new THREE.Mesh(geo, mat); 705 mesh.userData.isCylinder = true; 706 return mesh; 707 } 708 709 function updateConnectionMesh(conn, childPos, parentPos) { 710 const mesh = conn.line; 711 // Calculate midpoint 712 const mid = new THREE.Vector3().addVectors(childPos, parentPos).multiplyScalar(0.5); 713 mesh.position.copy(mid); 714 715 // Calculate length and direction 716 const dir = new THREE.Vector3().subVectors(parentPos, childPos); 717 const length = dir.length(); 718 mesh.scale.set(1, length, 1); 719 720 // Orient cylinder to point from child to parent 721 mesh.quaternion.setFromUnitVectors( 722 new THREE.Vector3(0, 1, 0), 723 dir.normalize() 724 ); 725 } 726 727 function layoutTree(processes) { 728 // Build tree structure 729 const byPid = new Map(); 730 const children = new Map(); 731 732 processes.forEach(p => { 733 byPid.set(String(p.pid), p); 734 children.set(String(p.pid), []); 735 }); 736 737 // Find roots (no interesting parent) and build child lists 738 const roots = []; 739 processes.forEach(p => { 740 const parentPid = String(p.parentInteresting || 0); 741 if (parentPid && byPid.has(parentPid)) { 742 children.get(parentPid).push(p); 743 } else { 744 roots.push(p); 745 } 746 }); 747 748 // Group roots by category for better organization 749 const categoryOrder = ['ide', 'editor', 'tui', 'dev', 'db', 'shell', 'ai', 'lsp', 'proxy', 'bridge']; 750 roots.sort((a, b) => { 751 const ai = categoryOrder.indexOf(a.category); 752 const bi = categoryOrder.indexOf(b.category); 753 return (ai === -1 ? 99 : ai) - (bi === -1 ? 99 : bi); 754 }); 755 756 // Tighter layout 757 const levelHeight = 50; // Vertical spacing between levels 758 const baseRadius = 100; // Starting radius for roots 759 760 // Count total descendants for sizing 761 function countDescendants(pid) { 762 const nodeChildren = children.get(pid) || []; 763 let count = nodeChildren.length; 764 nodeChildren.forEach(c => count += countDescendants(String(c.pid))); 765 return count; 766 } 767 768 function positionNode(node, depth, angle, radius, parentX, parentZ) { 769 const pid = String(node.pid); 770 const nodeChildren = children.get(pid) || []; 771 const childCount = nodeChildren.length; 772 773 // Position based on angle from parent 774 const x = parentX + Math.cos(angle) * radius; 775 const z = parentZ + Math.sin(angle) * radius; 776 777 node.targetX = x; 778 node.targetY = -depth * levelHeight; 779 node.targetZ = z; 780 781 // Position children in an arc spreading outward 782 if (childCount > 0) { 783 // Spread children over an arc (not full circle) 784 const arcSpread = Math.min(Math.PI * 0.9, Math.PI * 0.3 * childCount); 785 const startAngle = angle - arcSpread / 2; 786 const childRadius = 35 + childCount * 10; 787 788 nodeChildren.forEach((child, i) => { 789 const childAngle = childCount === 1 790 ? angle 791 : startAngle + (arcSpread / (childCount - 1)) * i; 792 positionNode(child, depth + 1, childAngle, childRadius, x, z); 793 }); 794 } 795 } 796 797 // Position root nodes in a large circle, spaced by their subtree size 798 const totalRoots = roots.length; 799 if (totalRoots > 0) { 800 // Calculate weights based on descendant count 801 const weights = roots.map(r => 1 + countDescendants(String(r.pid)) * 0.5); 802 const totalWeight = weights.reduce((a, b) => a + b, 0); 803 804 let currentAngle = -Math.PI / 2; // Start at top 805 roots.forEach((root, i) => { 806 const angleSpan = (weights[i] / totalWeight) * Math.PI * 2; 807 const angle = currentAngle + angleSpan / 2; 808 currentAngle += angleSpan; 809 810 positionNode(root, 0, angle, baseRadius, 0, 0); 811 }); 812 } 813 814 return { roots, byPid, children }; 815 } 816 817 function updateLabels() { 818 const container = document.getElementById('labels'); 819 container.innerHTML = ''; 820 821 scene.updateMatrixWorld(); 822 823 meshes.forEach((mesh, pid) => { 824 const pos = new THREE.Vector3(); 825 mesh.getWorldPosition(pos); 826 827 // Offset label just above the node (closer) 828 const labelPos = pos.clone(); 829 labelPos.y += (mesh.userData.size || 8) + 5; 830 831 // Project to screen 832 labelPos.project(camera); 833 834 const x = (labelPos.x * 0.5 + 0.5) * width; 835 const y = (-labelPos.y * 0.5 + 0.5) * height; 836 837 // Only show if in front of camera and on screen 838 if (labelPos.z < 1 && x > -100 && x < width + 100 && y > -100 && y < height + 100) { 839 const d = mesh.userData; 840 const color = '#' + (colors[d.category] || 0x666666).toString(16).padStart(6, '0'); 841 842 // Calculate distance from camera to this node 843 const distToCamera = camera.position.distanceTo(pos); 844 // Scale up when close (inverse relationship) - closer = bigger 845 const proximityScale = Math.max(0.4, Math.min(3, 150 / distToCamera)); 846 847 // Fade labels that are far away 848 const opacity = focusedPid 849 ? (pid === focusedPid ? 1 : (d.parentInteresting === parseInt(focusedPid) ? 0.9 : 0.3)) 850 : Math.max(0.5, Math.min(1, 300 / distToCamera)); 851 852 const cpuPct = Math.min(100, d.cpu || 0); 853 const memMB = ((d.rss || 0) / 1024).toFixed(0); 854 855 const label = document.createElement('div'); 856 label.className = 'proc-label'; 857 label.style.left = x + 'px'; 858 label.style.top = y + 'px'; 859 label.style.opacity = opacity; 860 label.style.transform = \`translate(-50%, -100%) scale(\${proximityScale})\`; 861 label.innerHTML = \` 862 <div class="icon">\${d.icon || '●'}</div> 863 <div class="name" style="color:\${color}">\${d.name || pid}</div> 864 <div class="info">\${memMB}MB · \${cpuPct.toFixed(0)}%</div> 865 \`; 866 container.appendChild(label); 867 } 868 }); 869 } 870 871 function updateViz(processData) { 872 if (!processData?.interesting) return; 873 874 const processes = processData.interesting; 875 document.getElementById('process-count').textContent = processes.length; 876 877 // Layout the tree 878 processTree = layoutTree(processes); 879 880 // Create a set of current PIDs 881 const currentPids = new Set(processes.map(p => String(p.pid))); 882 883 // Add/update nodes 884 processes.forEach(p => { 885 const pid = String(p.pid); 886 887 if (!meshes.has(pid)) { 888 const mesh = createNodeMesh(p); 889 // Initialize at target position 890 mesh.position.set(p.targetX || 0, p.targetY || 0, p.targetZ || 0); 891 mesh.userData.targetPos.set(p.targetX || 0, p.targetY || 0, p.targetZ || 0); 892 scene.add(mesh); 893 meshes.set(pid, mesh); 894 } else { 895 const mesh = meshes.get(pid); 896 const d = mesh.userData; 897 d.cpu = p.cpu; 898 d.mem = p.mem; 899 d.rss = p.rss; 900 d.name = p.name; 901 902 // Update target position from tree layout 903 d.targetPos.set(p.targetX || d.targetPos.x, p.targetY || d.targetPos.y, p.targetZ || d.targetPos.z); 904 905 // Update size based on activity 906 const memMB = (p.rss || 10000) / 1024; 907 d.size = Math.max(4, Math.min(12, 3 + memMB * 0.05 + p.cpu * 0.1)); 908 mesh.scale.setScalar(d.size / 6); 909 910 // Update color 911 const baseColor = colors[p.category] || 0x666666; 912 const brighten = Math.min(1.8, 1 + p.cpu * 0.02); 913 const r = ((baseColor >> 16) & 255) * brighten; 914 const g = ((baseColor >> 8) & 255) * brighten; 915 const b = (baseColor & 255) * brighten; 916 mesh.material.color.setRGB(Math.min(255, r) / 255, Math.min(255, g) / 255, Math.min(255, b) / 255); 917 mesh.material.opacity = 0.7 + p.cpu * 0.003; 918 } 919 920 // Create/update connection to parent (or kernel for roots) 921 const parentPid = String(p.parentInteresting || 0); 922 if (parentPid && meshes.has(parentPid)) { 923 const connKey = pid + '->' + parentPid; 924 if (!connections.has(connKey)) { 925 const line = createConnectionLine(); 926 scene.add(line); 927 connections.set(connKey, { line, childPid: pid, parentPid }); 928 } 929 } else { 930 // Root process - connect to kernel 931 const connKey = pid + '->kernel'; 932 if (!connections.has(connKey)) { 933 const line = createConnectionLine(); 934 scene.add(line); 935 connections.set(connKey, { line, childPid: pid, parentPid: 'kernel' }); 936 } 937 } 938 }); 939 940 // Move dead processes to graveyard instead of removing 941 meshes.forEach((mesh, pid) => { 942 if (pid === 'kernel') return; // Never kill kernel 943 if (!currentPids.has(pid) && !mesh.userData.isDead) { 944 // Mark as dead and send to graveyard 945 mesh.userData.isDead = true; 946 mesh.userData.deathTime = Date.now(); 947 948 // Calculate graveyard position 949 const graveyardIndex = graveyard.length; 950 const col = graveyardIndex % 10; 951 const row = Math.floor(graveyardIndex / 10); 952 mesh.userData.targetPos.set( 953 (col - 4.5) * 25, 954 GRAVEYARD_Y - row * 20, 955 0 956 ); 957 958 // Dim the mesh 959 mesh.material.opacity = 0.25; 960 mesh.material.color.setHex(0x444444); 961 962 graveyard.push({ pid, mesh, name: mesh.userData.name, deathTime: Date.now() }); 963 964 // Remove from active meshes (but keep in scene) 965 meshes.delete(pid); 966 967 // Limit graveyard size 968 while (graveyard.length > MAX_GRAVEYARD) { 969 const oldest = graveyard.shift(); 970 scene.remove(oldest.mesh); 971 if (oldest.mesh.geometry) oldest.mesh.geometry.dispose(); 972 if (oldest.mesh.material) oldest.mesh.material.dispose(); 973 } 974 } 975 }); 976 977 // Remove orphan connections (but not to graveyard) 978 const graveyardPids = new Set(graveyard.map(g => g.pid)); 979 connections.forEach((conn, key) => { 980 const childExists = meshes.has(conn.childPid) || graveyardPids.has(conn.childPid); 981 const parentExists = meshes.has(conn.parentPid) || graveyardPids.has(conn.parentPid); 982 if (!childExists || !parentExists) { 983 scene.remove(conn.line); 984 conn.line.geometry.dispose(); 985 conn.line.material.dispose(); 986 connections.delete(key); 987 } 988 }); 989 } 990 991 let time = 0; 992 function animate() { 993 requestAnimationFrame(animate); 994 time += 0.016; 995 996 // Smooth transition to focus target 997 if (focusedPid && meshes.has(focusedPid)) { 998 const focusMesh = meshes.get(focusedPid); 999 focusTarget.lerp(focusMesh.position, 0.08); 1000 } 1001 1002 // Transition camera orbit target 1003 controls.target.lerp(focusTarget, transitioning ? 0.06 : 0.02); 1004 1005 // Adjust zoom only when focused on a node (focusDistance is set) 1006 if (focusDistance !== null) { 1007 const currentDist = camera.position.distanceTo(controls.target); 1008 if (Math.abs(currentDist - focusDistance) > 5) { 1009 const dir = camera.position.clone().sub(controls.target).normalize(); 1010 const targetPos = controls.target.clone().add(dir.multiplyScalar(focusDistance)); 1011 camera.position.lerp(targetPos, 0.04); 1012 } else { 1013 transitioning = false; 1014 } 1015 } else { 1016 transitioning = false; 1017 } 1018 1019 controls.update(); 1020 1021 // Animate kernel centroid 1022 if (kernelGlow) { 1023 kernelGlow.rotation.z = time * 0.3; 1024 kernelGlow.rotation.x = Math.PI / 2 + Math.sin(time * 0.5) * 0.1; 1025 } 1026 if (kernelCore) { 1027 const pulse = 1 + Math.sin(time * 0.8) * 0.1; 1028 kernelCore.scale.setScalar(pulse); 1029 } 1030 if (kernelMesh) { 1031 kernelMesh.rotation.y = time * 0.1; 1032 } 1033 1034 // Animate graveyard processes (falling, fading) 1035 graveyard.forEach((grave, i) => { 1036 const mesh = grave.mesh; 1037 if (mesh && mesh.userData) { 1038 const d = mesh.userData; 1039 // Slowly drift toward graveyard position 1040 mesh.position.x += (d.targetPos.x - mesh.position.x) * 0.02; 1041 mesh.position.y += (d.targetPos.y - mesh.position.y) * 0.015; 1042 mesh.position.z += (d.targetPos.z - mesh.position.z) * 0.02; 1043 1044 // Gentle sway 1045 mesh.position.x += Math.sin(time * 0.3 + i) * 0.05; 1046 1047 // Fade older ones more 1048 const age = (Date.now() - grave.deathTime) / 1000; 1049 mesh.material.opacity = Math.max(0.1, 0.3 - age * 0.005); 1050 } 1051 }); 1052 1053 // Animate nodes toward their target positions 1054 meshes.forEach((mesh, pid) => { 1055 const d = mesh.userData; 1056 const cpu = d.cpu || 0; 1057 const isFocused = focusedPid === pid; 1058 const isRelated = focusedPid && (d.parentInteresting === parseInt(focusedPid) || String(d.parentInteresting) === focusedPid); 1059 1060 // Smoothly move toward target 1061 mesh.position.x += (d.targetPos.x - mesh.position.x) * 0.03; 1062 mesh.position.y += (d.targetPos.y - mesh.position.y) * 0.03; 1063 mesh.position.z += (d.targetPos.z - mesh.position.z) * 0.03; 1064 1065 // Add gentle floating 1066 const float = Math.sin(time * 0.5 + d.pulsePhase) * 2; 1067 mesh.position.y += float * 0.02; 1068 1069 // Pulse based on CPU (stronger when focused) 1070 const pulseAmp = isFocused ? 0.2 : (0.1 + cpu * 0.005); 1071 const pulse = 1 + Math.sin(time * (1 + cpu * 0.05) + d.pulsePhase) * pulseAmp; 1072 const sizeMultiplier = isFocused ? 1.5 : (isRelated ? 1.2 : 1); 1073 mesh.scale.setScalar((d.size / 6) * pulse * sizeMultiplier); 1074 1075 // Adjust opacity based on focus state 1076 if (focusedPid) { 1077 mesh.material.opacity = isFocused ? 1 : (isRelated ? 0.8 : 0.3); 1078 } else { 1079 mesh.material.opacity = 0.7 + cpu * 0.003; 1080 } 1081 }); 1082 1083 // Update connection lines 1084 connections.forEach(conn => { 1085 const childMesh = meshes.get(conn.childPid); 1086 const parentMesh = meshes.get(conn.parentPid); 1087 if (childMesh && parentMesh) { 1088 updateConnectionMesh(conn, childMesh.position, parentMesh.position); 1089 1090 // Highlight connections to focused node 1091 const involvesFocus = focusedPid && (conn.childPid === focusedPid || conn.parentPid === focusedPid); 1092 conn.line.material.opacity = focusedPid ? (involvesFocus ? 0.9 : 0.15) : 0.5; 1093 conn.line.material.color.setHex(involvesFocus ? 0xff69b4 : 0x444444); 1094 // Make focused connections thicker 1095 const thickness = involvesFocus ? 2.5 : 1.5; 1096 conn.line.scale.x = thickness / 1.5; 1097 conn.line.scale.z = thickness / 1.5; 1098 } 1099 }); 1100 1101 renderer.render(scene, camera); 1102 updateLabels(); 1103 } 1104 1105 function connectWS() { 1106 ws = new WebSocket('ws://' + location.host + '/ws'); 1107 ws.onopen = () => document.getElementById('status-dot').classList.add('online'); 1108 ws.onclose = () => { 1109 document.getElementById('status-dot').classList.remove('online'); 1110 setTimeout(connectWS, 2000); 1111 }; 1112 ws.onerror = () => ws.close(); 1113 ws.onmessage = (e) => { 1114 try { 1115 const data = JSON.parse(e.data); 1116 if (data.system) { 1117 document.getElementById('uptime').textContent = data.system.uptime.formatted; 1118 document.getElementById('cpus').textContent = data.system.cpus; 1119 const m = data.system.memory; 1120 document.getElementById('mem-text').textContent = m.used + ' / ' + m.total; 1121 } 1122 updateViz(data.processes); 1123 } catch {} 1124 }; 1125 } 1126 1127 window.addEventListener('resize', () => { 1128 width = window.innerWidth; 1129 height = window.innerHeight; 1130 camera.aspect = width / height; 1131 camera.updateProjectionMatrix(); 1132 renderer.setSize(width, height); 1133 }); 1134 1135 animate(); 1136 connectWS(); 1137 </script> 1138</body> 1139</html>`; 1140} 1141 1142main().catch(err => { 1143 console.error('Error:', err); 1144 process.exit(1); 1145 process.exit(1); 1146});