Monorepo for Aesthetic.Computer
aesthetic.computer
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});