slybeaver-opencode-visualizer 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.ts ADDED
@@ -0,0 +1,282 @@
1
+ import type { Plugin } from "@opencode-ai/plugin"
2
+ import { join } from "path"
3
+ import { existsSync } from "fs"
4
+
5
+ const SERVER_PORT = 5173
6
+
7
+ // ─── WebSocket server ───────────────────────────────────────────────
8
+
9
+ interface PluginClient {
10
+ instanceId: string
11
+ ws: WebSocket
12
+ cwd: string
13
+ connectedAt: number
14
+ }
15
+
16
+ interface FrontendClient {
17
+ ws: WebSocket
18
+ }
19
+
20
+ const pluginClients = new Map<string, PluginClient>()
21
+ const frontendClients = new Set<FrontendClient>()
22
+ let windowOpened = false
23
+ let htmlContent: string | null = null
24
+
25
+ function getHtmlPath(): string {
26
+ const paths = [
27
+ join(import.meta.dir, "visualizer", "index.html"),
28
+ join(process.cwd(), "visualizer", "index.html"),
29
+ ]
30
+ for (const p of paths) {
31
+ if (existsSync(p)) return p
32
+ }
33
+ throw new Error("Cannot find visualizer/index.html")
34
+ }
35
+
36
+ function broadcast(data: Record<string, unknown>) {
37
+ const msg = JSON.stringify(data)
38
+ for (const client of frontendClients) {
39
+ if (client.ws.readyState === WebSocket.OPEN) {
40
+ client.ws.send(msg)
41
+ }
42
+ }
43
+ }
44
+
45
+ function openChromeWindow() {
46
+ if (windowOpened) return
47
+ windowOpened = true
48
+ const url = `http://localhost:${SERVER_PORT}`
49
+
50
+ // Delay: if frontend reconnects from a still-open page, skip opening
51
+ setTimeout(() => {
52
+ if (frontendClients.size > 0) return
53
+
54
+ const chromePath = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"
55
+ if (existsSync(chromePath)) {
56
+ Bun.spawn([chromePath, `--app=${url}`, "--window-size=640,480"], {
57
+ stdio: ["ignore", "ignore", "ignore"],
58
+ })
59
+ } else {
60
+ console.warn(`[visualizer] Chrome not found, open manually: ${url}`)
61
+ }
62
+ }, 3000)
63
+ }
64
+
65
+ let shutdownTimer: ReturnType<typeof setTimeout> | null = null
66
+
67
+ function resetShutdownTimer() {
68
+ if (shutdownTimer) clearTimeout(shutdownTimer)
69
+ shutdownTimer = setTimeout(() => {
70
+ if (pluginClients.size === 0) {
71
+ // Server will shut down when process exits
72
+ }
73
+ }, 10_000)
74
+ }
75
+
76
+ try {
77
+ const server = Bun.serve({
78
+ port: SERVER_PORT,
79
+ hostname: "127.0.0.1",
80
+ async fetch(req) {
81
+ if (server.upgrade(req)) {
82
+ return
83
+ }
84
+ try {
85
+ if (!htmlContent) {
86
+ htmlContent = await Bun.file(getHtmlPath()).text()
87
+ }
88
+ return new Response(htmlContent, {
89
+ headers: {
90
+ "Content-Type": "text/html; charset=utf-8",
91
+ "Cache-Control": "no-cache",
92
+ },
93
+ })
94
+ } catch {
95
+ return new Response("<h1>Visualization server — frontend not found</h1>", {
96
+ headers: { "Content-Type": "text/html" },
97
+ status: 500,
98
+ })
99
+ }
100
+ },
101
+ websocket: {
102
+ open(_ws) {},
103
+ message(ws, message) {
104
+ let data: Record<string, unknown>
105
+ try {
106
+ data = JSON.parse(message as string)
107
+ } catch {
108
+ return
109
+ }
110
+
111
+ switch (data.type) {
112
+ case "register": {
113
+ const id = data.instanceId as string
114
+ pluginClients.set(id, {
115
+ instanceId: id,
116
+ ws,
117
+ cwd: (data.cwd as string) || "",
118
+ connectedAt: Date.now(),
119
+ })
120
+
121
+ broadcast({
122
+ type: "instance.added",
123
+ instanceId: id,
124
+ cwd: data.cwd,
125
+ })
126
+
127
+ openChromeWindow()
128
+ resetShutdownTimer()
129
+ break
130
+ }
131
+
132
+ case "frontend.register": {
133
+ frontendClients.add({ ws })
134
+ const instances = [...pluginClients.values()].map((c) => ({
135
+ instanceId: c.instanceId,
136
+ cwd: c.cwd,
137
+ }))
138
+ ws.send(JSON.stringify({ type: "state.sync", instances }))
139
+ break
140
+ }
141
+
142
+ case "event": {
143
+ broadcast({
144
+ type: "instance.event",
145
+ instanceId: data.instanceId,
146
+ event: data.event,
147
+ })
148
+ break
149
+ }
150
+ }
151
+ },
152
+ close(ws) {
153
+ for (const [id, client] of pluginClients) {
154
+ if (client.ws === ws) {
155
+ pluginClients.delete(id)
156
+ broadcast({ type: "instance.removed", instanceId: id })
157
+ break
158
+ }
159
+ }
160
+
161
+ for (const client of frontendClients) {
162
+ if (client.ws === ws) {
163
+ frontendClients.delete(client)
164
+ break
165
+ }
166
+ }
167
+
168
+ resetShutdownTimer()
169
+ },
170
+ },
171
+ })
172
+ } catch (err) {
173
+ console.error("[visualizer] Failed to start server:", err)
174
+ }
175
+
176
+ // ─── Plugin client ──────────────────────────────────────────────────
177
+
178
+ let ws: WebSocket | null = null
179
+ let reconnectTimer: ReturnType<typeof setTimeout> | null = null
180
+ const instanceId = `oc-${process.pid}-${Math.random().toString(36).slice(2, 8)}`
181
+
182
+ function connectToServer(): Promise<void> {
183
+ return new Promise((resolve, reject) => {
184
+ const socket = new WebSocket(`ws://localhost:${SERVER_PORT}`)
185
+
186
+ const timeout = setTimeout(() => {
187
+ socket.close()
188
+ reject(new Error("Connection timeout"))
189
+ }, 3000)
190
+
191
+ socket.onopen = () => {
192
+ clearTimeout(timeout)
193
+ if (reconnectTimer) {
194
+ clearTimeout(reconnectTimer)
195
+ reconnectTimer = null
196
+ }
197
+
198
+ ws = socket
199
+ socket.send(
200
+ JSON.stringify({
201
+ type: "register",
202
+ instanceId,
203
+ cwd: process.cwd(),
204
+ })
205
+ )
206
+ resolve()
207
+ }
208
+
209
+ socket.onerror = () => {
210
+ clearTimeout(timeout)
211
+ socket.close()
212
+ reject(new Error("Connection failed"))
213
+ }
214
+
215
+ socket.onclose = () => {
216
+ ws = null
217
+ if (!reconnectTimer) {
218
+ reconnectTimer = setTimeout(() => {
219
+ reconnectTimer = null
220
+ connectToServer().catch(() => {})
221
+ }, 2000)
222
+ }
223
+ }
224
+
225
+ socket.onmessage = () => {}
226
+ })
227
+ }
228
+
229
+ function sendEvent(eventType: string, payload: Record<string, unknown> = {}) {
230
+ if (!ws || ws.readyState !== WebSocket.OPEN) return
231
+ ws.send(
232
+ JSON.stringify({
233
+ type: "event",
234
+ instanceId,
235
+ event: { type: eventType, ...payload },
236
+ })
237
+ )
238
+ }
239
+
240
+ // ─── Plugin export ──────────────────────────────────────────────────
241
+
242
+ const VisualizerPlugin: Plugin = async ({ project, client, $, directory, worktree }) => {
243
+ // Wait up to 6 seconds for the server to be ready
244
+ for (let i = 0; i < 20; i++) {
245
+ try {
246
+ await connectToServer()
247
+ break
248
+ } catch {
249
+ await new Promise((r) => setTimeout(r, 300))
250
+ }
251
+ }
252
+
253
+ const interestingEvents = new Set([
254
+ "session.status",
255
+ "session.created",
256
+ "session.deleted",
257
+ "session.idle",
258
+ "session.error",
259
+ "session.diff",
260
+ "message.updated",
261
+ "todo.updated",
262
+ ])
263
+
264
+ return {
265
+ event: async ({ event }) => {
266
+ if (interestingEvents.has(event.type)) {
267
+ sendEvent(event.type, event.properties as Record<string, unknown>)
268
+ }
269
+ },
270
+
271
+ "tool.execute.after": async (input, _output) => {
272
+ sendEvent("tool.execute.after", { tool: input.tool })
273
+ },
274
+
275
+ "tool.execute.before": async (input, _output) => {
276
+ sendEvent("tool.execute.before", { tool: input.tool })
277
+ },
278
+ }
279
+ }
280
+
281
+ export { VisualizerPlugin }
282
+ export default VisualizerPlugin
package/package.json ADDED
@@ -0,0 +1,13 @@
1
+ {
2
+ "name": "slybeaver-opencode-visualizer",
3
+ "version": "1.0.0",
4
+ "description": "OpenCode session visualizer — real-time orb animation in Chrome app window",
5
+ "type": "module",
6
+ "main": "index.ts",
7
+ "files": [
8
+ "index.ts",
9
+ "visualizer/index.html"
10
+ ],
11
+ "keywords": ["opencode", "opencode-plugin", "visualizer", "monitor"],
12
+ "license": "MIT"
13
+ }
@@ -0,0 +1,588 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>OpenCode Visualizer</title>
7
+ <style>
8
+ * { margin: 0; padding: 0; box-sizing: border-box; }
9
+ body {
10
+ background: #08080f;
11
+ overflow: hidden;
12
+ font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
13
+ -webkit-app-region: drag;
14
+ user-select: none;
15
+ }
16
+ canvas { display: block; }
17
+ #overlay {
18
+ position: fixed;
19
+ bottom: 24px;
20
+ left: 50%;
21
+ transform: translateX(-50%);
22
+ display: flex;
23
+ gap: 32px;
24
+ padding: 8px 20px;
25
+ background: rgba(8, 8, 15, 0.7);
26
+ border-radius: 8px;
27
+ border: 1px solid rgba(255, 255, 255, 0.06);
28
+ backdrop-filter: blur(12px);
29
+ -webkit-backdrop-filter: blur(12px);
30
+ pointer-events: none;
31
+ z-index: 10;
32
+ }
33
+ .stat {
34
+ display: flex;
35
+ gap: 6px;
36
+ align-items: center;
37
+ color: rgba(255, 255, 255, 0.35);
38
+ font-size: 11px;
39
+ letter-spacing: 0.5px;
40
+ text-transform: uppercase;
41
+ }
42
+ .stat-value {
43
+ color: rgba(255, 255, 255, 0.8);
44
+ font-weight: 600;
45
+ font-variant-numeric: tabular-nums;
46
+ }
47
+ .stat-dot {
48
+ width: 6px;
49
+ height: 6px;
50
+ border-radius: 50%;
51
+ background: #40ff80;
52
+ box-shadow: 0 0 6px #40ff80;
53
+ }
54
+ </style>
55
+ </head>
56
+ <body>
57
+ <canvas id="canvas"></canvas>
58
+ <div id="overlay">
59
+ <div class="stat"><span class="stat-dot"></span> Instances: <span class="stat-value" id="instances">0</span></div>
60
+ <div class="stat">Events: <span class="stat-value" id="events">0</span></div>
61
+ <div class="stat">Uptime: <span class="stat-value" id="uptime">0s</span></div>
62
+ </div>
63
+
64
+ <script>
65
+ (() => {
66
+ const canvas = document.getElementById("canvas")
67
+ const ctx = canvas.getContext("2d")
68
+
69
+ const elInstances = document.getElementById("instances")
70
+ const elEvents = document.getElementById("events")
71
+ const elUptime = document.getElementById("uptime")
72
+
73
+ const PALETTE = [
74
+ { h: 200, s: 80, l: 62 },
75
+ { h: 275, s: 70, l: 62 },
76
+ { h: 340, s: 75, l: 62 },
77
+ { h: 160, s: 65, l: 52 },
78
+ { h: 40, s: 85, l: 58 },
79
+ { h: 30, s: 90, l: 55 },
80
+ { h: 190, s: 75, l: 55 },
81
+ { h: 310, s: 60, l: 60 },
82
+ ]
83
+
84
+ // ---- state ----
85
+ const nodes = new Map()
86
+ let eventCount = 0
87
+ let startTime = Date.now()
88
+ let nextColorIndex = 0
89
+
90
+ function resize() {
91
+ canvas.width = window.innerWidth * devicePixelRatio
92
+ canvas.height = window.innerHeight * devicePixelRatio
93
+ canvas.style.width = window.innerWidth + "px"
94
+ canvas.style.height = window.innerHeight + "px"
95
+ ctx.scale(devicePixelRatio, devicePixelRatio)
96
+ recalculateLayout()
97
+ }
98
+
99
+ // ---- layout ----
100
+ function recalculateLayout() {
101
+ const W = window.innerWidth
102
+ const H = window.innerHeight
103
+ const cx = W / 2
104
+ const cy = H / 2 - 20
105
+ const count = nodes.size
106
+
107
+ if (count === 0) return
108
+
109
+ const positions = layoutCircle(count, cx, cy, Math.min(W, H) * 0.28)
110
+
111
+ let i = 0
112
+ for (const [, node] of nodes) {
113
+ if (i < positions.length) {
114
+ node.tx = positions[i].x
115
+ node.ty = positions[i].y
116
+ }
117
+ i++
118
+ }
119
+ }
120
+
121
+ function layoutCircle(n, cx, cy, radius) {
122
+ if (n === 1) return [{ x: cx, y: cy }]
123
+ const positions = []
124
+ const startAngle = -Math.PI / 2
125
+ for (let i = 0; i < n; i++) {
126
+ const angle = startAngle + (2 * Math.PI * i) / n
127
+ positions.push({
128
+ x: cx + Math.cos(angle) * radius,
129
+ y: cy + Math.sin(angle) * radius,
130
+ })
131
+ }
132
+ return positions
133
+ }
134
+
135
+ // ---- nodes ----
136
+ function addNode(instanceId, cwd = "") {
137
+ if (nodes.has(instanceId)) return
138
+ const color = PALETTE[nextColorIndex % PALETTE.length]
139
+ nextColorIndex++
140
+
141
+ const cx = window.innerWidth / 2
142
+ const cy = window.innerHeight / 2 - 20
143
+
144
+ nodes.set(instanceId, {
145
+ id: instanceId,
146
+ cwd,
147
+ hue: color.h,
148
+ sat: color.s,
149
+ light: color.l,
150
+ x: cx,
151
+ y: cy,
152
+ tx: cx,
153
+ ty: cy,
154
+ radius: 0,
155
+ targetRadius: 18,
156
+ pulse: 0,
157
+ pulsePhase: Math.random() * Math.PI * 2,
158
+ status: "idle",
159
+ lastEventTime: 0,
160
+ particles: [],
161
+ shakeX: 0,
162
+ shakeY: 0,
163
+ shakeTimer: 0,
164
+ errorFlash: 0,
165
+ toolBurst: 0,
166
+ messageGlow: 0,
167
+ })
168
+ recalculateLayout()
169
+ updateOverlay()
170
+ }
171
+
172
+ function removeNode(instanceId) {
173
+ nodes.delete(instanceId)
174
+ recalculateLayout()
175
+ updateOverlay()
176
+ }
177
+
178
+ function clearAllNodes() {
179
+ nodes.clear()
180
+ recalculateLayout()
181
+ updateOverlay()
182
+ }
183
+
184
+ function getNode(instanceId) {
185
+ return nodes.get(instanceId)
186
+ }
187
+
188
+ // ---- particles ----
189
+ function emitParticles(node, count, speed, life, hue, sat, light) {
190
+ for (let i = 0; i < count; i++) {
191
+ const angle = Math.random() * Math.PI * 2
192
+ const spd = speed * (0.5 + Math.random())
193
+ node.particles.push({
194
+ x: 0,
195
+ y: 0,
196
+ vx: Math.cos(angle) * spd,
197
+ vy: Math.sin(angle) * spd,
198
+ life,
199
+ maxLife: life,
200
+ size: 1.5 + Math.random() * 3,
201
+ hue,
202
+ sat,
203
+ light: light + Math.random() * 20,
204
+ })
205
+ }
206
+ }
207
+
208
+ function handleEvent(instanceId, event) {
209
+ const node = getNode(instanceId)
210
+ if (!node) return
211
+
212
+ eventCount++
213
+ node.lastEventTime = performance.now()
214
+ updateOverlay()
215
+
216
+ switch (event.type) {
217
+ case "session.status":
218
+ {
219
+ const st = event.status?.type || event.status
220
+ if (st === "busy") {
221
+ node.status = "busy"
222
+ node.targetRadius = 50
223
+ node.label = "Думаю"
224
+ } else if (st === "retry") {
225
+ node.status = "retry"
226
+ node.targetRadius = 45
227
+ node.label = "Повтор"
228
+ } else {
229
+ node.status = "idle"
230
+ node.targetRadius = 18
231
+ node.label = "Жду"
232
+ }
233
+ }
234
+ break
235
+
236
+ case "session.idle":
237
+ node.status = "idle"
238
+ node.targetRadius = 18
239
+ node.label = "Жду"
240
+ break
241
+
242
+ case "session.error":
243
+ node.status = "error"
244
+ node.errorFlash = 1
245
+ node.shakeTimer = 600
246
+ node.targetRadius = 48
247
+ node.label = "Ошибка"
248
+ emitParticles(node, 12, 2, 80, 0, 100, 50)
249
+ break
250
+
251
+ case "session.created":
252
+ node.targetRadius = 35
253
+ node.label = "Старт"
254
+ emitParticles(node, 20, 3, 100, node.hue, node.sat, node.light)
255
+ break
256
+
257
+ case "session.deleted":
258
+ node.targetRadius = 0
259
+ emitParticles(node, 30, 4, 120, node.hue, node.sat, 80)
260
+ break
261
+
262
+ case "tool.execute.after":
263
+ case "tool.execute.before":
264
+ node.toolBurst = 1
265
+ emitParticles(node, 10, 1.5, 60, node.hue, node.sat, node.light)
266
+ break
267
+
268
+ case "message.updated":
269
+ node.messageGlow = 1
270
+ emitParticles(node, 5, 0.8, 50, node.hue, node.sat, 80)
271
+ break
272
+
273
+ case "todo.updated":
274
+ node.messageGlow = 0.5
275
+ emitParticles(node, 3, 0.5, 40, node.hue, node.sat, node.light)
276
+ break
277
+
278
+ case "session.diff":
279
+ node.toolBurst = 0.7
280
+ emitParticles(node, 6, 1, 55, 45, 90, 60)
281
+ break
282
+ }
283
+ }
284
+
285
+ // ---- update loop ----
286
+ function update(dt) {
287
+ for (const [, node] of nodes) {
288
+ // smooth position
289
+ node.x += (node.tx - node.x) * 0.08
290
+ node.y += (node.ty - node.y) * 0.08
291
+
292
+ // smooth radius
293
+ node.radius += (node.targetRadius - node.radius) * 0.1
294
+
295
+ // pulse (only when busy/retry)
296
+ if (node.status === "busy" || node.status === "retry") {
297
+ const pulseSpeed = node.status === "retry" ? 2 : 4
298
+ node.pulsePhase += dt * pulseSpeed
299
+ node.pulse = Math.sin(node.pulsePhase) * 0.5 + 0.5
300
+ } else {
301
+ node.pulse = 0
302
+ }
303
+
304
+ // error flash decay
305
+ if (node.errorFlash > 0) {
306
+ node.errorFlash = Math.max(0, node.errorFlash - dt * 2.5)
307
+ }
308
+
309
+ // tool burst decay
310
+ if (node.toolBurst > 0) {
311
+ node.toolBurst = Math.max(0, node.toolBurst - dt * 3)
312
+ }
313
+
314
+ // message glow decay
315
+ if (node.messageGlow > 0) {
316
+ node.messageGlow = Math.max(0, node.messageGlow - dt * 2)
317
+ }
318
+
319
+ // shake decay
320
+ if (node.shakeTimer > 0) {
321
+ node.shakeTimer -= dt * 1000
322
+ const intensity = node.shakeTimer / 600 * 4
323
+ node.shakeX = (Math.random() - 0.5) * intensity
324
+ node.shakeY = (Math.random() - 0.5) * intensity
325
+ } else {
326
+ node.shakeX = 0
327
+ node.shakeY = 0
328
+ }
329
+
330
+ // particles update
331
+ for (const p of node.particles) {
332
+ p.x += p.vx * dt * 60
333
+ p.y += p.vy * dt * 60
334
+ p.life -= dt * 60
335
+ }
336
+ node.particles = node.particles.filter(p => p.life > 0)
337
+
338
+ // continuous particle emission only while busy
339
+ if (node.status === "busy" && Math.random() < dt * 8) {
340
+ node.particles.push({
341
+ x: 0,
342
+ y: 0,
343
+ vx: (Math.random() - 0.5) * 0.8,
344
+ vy: -1 - Math.random(),
345
+ life: 40 + Math.random() * 20,
346
+ maxLife: 60,
347
+ size: 1 + Math.random() * 2,
348
+ hue: node.hue,
349
+ sat: node.sat,
350
+ light: node.light,
351
+ })
352
+ }
353
+ }
354
+ }
355
+
356
+ // ---- draw ----
357
+ function drawBackground(W, H) {
358
+ // subtle radial gradient
359
+ const grad = ctx.createRadialGradient(W / 2, H / 2, 0, W / 2, H / 2, Math.max(W, H) * 0.7)
360
+ grad.addColorStop(0, "rgba(20, 20, 40, 0.3)")
361
+ grad.addColorStop(1, "rgba(8, 8, 15, 0)")
362
+ ctx.fillStyle = "#08080f"
363
+ ctx.fillRect(0, 0, W, H)
364
+ ctx.fillStyle = grad
365
+ ctx.fillRect(0, 0, W, H)
366
+
367
+ // subtle dot grid
368
+ ctx.fillStyle = "rgba(255, 255, 255, 0.015)"
369
+ const spacing = 30
370
+ for (let x = spacing; x < W; x += spacing) {
371
+ for (let y = spacing; y < H; y += spacing) {
372
+ ctx.beginPath()
373
+ ctx.arc(x, y, 1, 0, Math.PI * 2)
374
+ ctx.fill()
375
+ }
376
+ }
377
+ }
378
+
379
+ function drawConnections(W, H) {
380
+ const cx = W / 2
381
+ const cy = H / 2 - 20
382
+
383
+ for (const [, node] of nodes) {
384
+ if (node.radius < 2) continue
385
+ const dx = cx - (node.x + node.shakeX)
386
+ const dy = cy - (node.y + node.shakeY)
387
+ const dist = Math.sqrt(dx * dx + dy * dy)
388
+
389
+ if (dist < 2) continue
390
+
391
+ // connection line to center
392
+ const alpha = 0.06 + node.pulse * 0.06
393
+ ctx.strokeStyle = `hsla(${node.hue}, ${node.sat}%, ${node.light}%, ${alpha})`
394
+ ctx.lineWidth = 1
395
+ ctx.beginPath()
396
+ ctx.moveTo(node.x + node.shakeX, node.y + node.shakeY)
397
+ ctx.lineTo(cx, cy)
398
+ ctx.stroke()
399
+ }
400
+
401
+ // draw center hub
402
+ if (nodes.size > 0) {
403
+ const active = [...nodes.values()].some(n => n.status === "busy")
404
+ const baseR = 4
405
+ const r = active ? baseR + 2 * Math.sin(performance.now() / 1000 * 3) : baseR
406
+ ctx.fillStyle = "rgba(255, 255, 255, 0.15)"
407
+ ctx.beginPath()
408
+ ctx.arc(cx, cy, r, 0, Math.PI * 2)
409
+ ctx.fill()
410
+ }
411
+ }
412
+
413
+ function drawNode(node) {
414
+ if (node.radius < 1) return
415
+
416
+ const x = node.x + node.shakeX
417
+ const y = node.y + node.shakeY
418
+ const r = node.radius
419
+ const pulseBoost = 1 + node.pulse * 0.15
420
+
421
+ // glow layers
422
+ const glowAlpha = 0.06 + node.pulse * 0.08 + node.toolBurst * 0.12 + node.messageGlow * 0.06
423
+ for (let i = 3; i >= 1; i--) {
424
+ const gr = r * (1.5 + i * 0.6) * pulseBoost
425
+ ctx.fillStyle = `hsla(${node.hue}, ${node.sat}%, ${node.light}%, ${glowAlpha / i})`
426
+ ctx.beginPath()
427
+ ctx.arc(x, y, gr, 0, Math.PI * 2)
428
+ ctx.fill()
429
+ }
430
+
431
+ // main orb
432
+ const orbGrad = ctx.createRadialGradient(x - r * 0.2, y - r * 0.2, r * 0.1, x, y, r)
433
+ const baseLight = node.errorFlash > 0
434
+ ? 30 + node.errorFlash * 30
435
+ : node.light + node.toolBurst * 15 + node.messageGlow * 10
436
+
437
+ const hueShift = node.errorFlash > 0 ? 0 : node.hue
438
+ orbGrad.addColorStop(0, `hsla(${hueShift}, ${node.sat}%, ${baseLight + 30}%, 0.9)`)
439
+ orbGrad.addColorStop(0.3, `hsla(${hueShift}, ${node.sat}%, ${baseLight}%, 0.7)`)
440
+ orbGrad.addColorStop(0.7, `hsla(${hueShift}, ${node.sat * 0.6}%, ${baseLight * 0.4}%, 0.4)`)
441
+ orbGrad.addColorStop(1, `hsla(${hueShift}, ${node.sat * 0.3}%, ${baseLight * 0.2}%, 0)`)
442
+
443
+ ctx.fillStyle = orbGrad
444
+ ctx.beginPath()
445
+ ctx.arc(x, y, r * pulseBoost, 0, Math.PI * 2)
446
+ ctx.fill()
447
+
448
+ // border ring
449
+ ctx.strokeStyle = `hsla(${node.hue}, ${node.sat}%, ${baseLight + 20}%, ${0.2 + node.pulse * 0.3})`
450
+ ctx.lineWidth = 1.5
451
+ ctx.beginPath()
452
+ ctx.arc(x, y, r * pulseBoost, 0, Math.PI * 2)
453
+ ctx.stroke()
454
+
455
+ // status indicator dot
456
+ if (node.status === "busy" || node.status === "retry" || node.status === "error") {
457
+ const dotColor = node.status === "error" ? "rgba(255, 60, 60, 0.8)"
458
+ : node.status === "retry" ? "rgba(255, 200, 40, 0.8)"
459
+ : "rgba(64, 255, 128, 0.8)"
460
+ ctx.fillStyle = dotColor
461
+ ctx.beginPath()
462
+ ctx.arc(x + r * 0.55, y - r * 0.55, 4, 0, Math.PI * 2)
463
+ ctx.fill()
464
+ }
465
+ }
466
+
467
+ function drawParticles(node) {
468
+ for (const p of node.particles) {
469
+ const x = node.x + node.shakeX + p.x
470
+ const y = node.y + node.shakeY + p.y
471
+ const alpha = Math.max(0, p.life / p.maxLife)
472
+ ctx.fillStyle = `hsla(${p.hue}, ${p.sat}%, ${p.light}%, ${alpha})`
473
+ ctx.beginPath()
474
+ ctx.arc(x, y, p.size * alpha, 0, Math.PI * 2)
475
+ ctx.fill()
476
+ }
477
+ }
478
+
479
+ function drawLabel(node) {
480
+ if (node.radius < 15) return
481
+
482
+ const x = node.x + node.shakeX
483
+ const y = node.y + node.shakeY
484
+ const label = node.label || ""
485
+
486
+ const alpha = node.status === "idle" ? 0.6 : 0.85
487
+ ctx.fillStyle = `rgba(255, 255, 255, ${alpha})`
488
+ ctx.font = "9px 'SF Mono', 'Fira Code', 'Consolas', monospace"
489
+ ctx.textAlign = "center"
490
+ ctx.textBaseline = "middle"
491
+ ctx.fillText(label, x, y)
492
+
493
+ // Folder name below the circle
494
+ const folder = node.cwd ? node.cwd.split("/").pop() || node.cwd : ""
495
+ if (folder) {
496
+ ctx.fillStyle = "rgba(255, 255, 255, 0.3)"
497
+ ctx.font = "8px 'SF Mono', 'Fira Code', 'Consolas', monospace"
498
+ ctx.fillText(folder, x, y + node.radius + 10)
499
+ }
500
+ }
501
+
502
+ function draw() {
503
+ const W = window.innerWidth
504
+ const H = window.innerHeight
505
+
506
+ ctx.clearRect(0, 0, W, H)
507
+ drawBackground(W, H)
508
+ drawConnections(W, H)
509
+
510
+ for (const [, node] of nodes) {
511
+ drawParticles(node)
512
+ drawNode(node)
513
+ drawLabel(node)
514
+ }
515
+ }
516
+
517
+ function updateOverlay() {
518
+ elInstances.textContent = nodes.size
519
+ elEvents.textContent = eventCount
520
+ const uptime = Math.floor((Date.now() - startTime) / 1000)
521
+ elUptime.textContent = uptime + "s"
522
+ }
523
+
524
+ // ---- main loop ----
525
+ let lastTime = performance.now()
526
+
527
+ function loop(time) {
528
+ const dt = Math.min((time - lastTime) / 1000, 0.1)
529
+ lastTime = time
530
+
531
+ update(dt)
532
+ draw()
533
+ requestAnimationFrame(loop)
534
+ }
535
+
536
+ // ---- websocket ----
537
+ function connect() {
538
+ const proto = location.protocol === "https:" ? "wss" : "ws"
539
+ const sock = new WebSocket(`${proto}://${location.host}`)
540
+
541
+ sock.onopen = () => {
542
+ clearAllNodes()
543
+ sock.send(JSON.stringify({ type: "frontend.register" }))
544
+ }
545
+
546
+ sock.onmessage = (e) => {
547
+ let data
548
+ try { data = JSON.parse(e.data) } catch { return }
549
+
550
+ switch (data.type) {
551
+ case "state.sync":
552
+ for (const inst of data.instances || []) {
553
+ addNode(inst.instanceId, inst.cwd)
554
+ }
555
+ break
556
+
557
+ case "instance.added":
558
+ addNode(data.instanceId, data.cwd)
559
+ break
560
+
561
+ case "instance.removed":
562
+ removeNode(data.instanceId)
563
+ break
564
+
565
+ case "instance.event":
566
+ handleEvent(data.instanceId, data.event)
567
+ break
568
+ }
569
+ }
570
+
571
+ sock.onclose = () => {
572
+ clearAllNodes()
573
+ setTimeout(connect, 2000)
574
+ }
575
+ }
576
+
577
+ // ---- init ----
578
+ window.addEventListener("resize", resize)
579
+ resize()
580
+ connect()
581
+ requestAnimationFrame(loop)
582
+
583
+ // uptime tick
584
+ setInterval(updateOverlay, 1000)
585
+ })()
586
+ </script>
587
+ </body>
588
+ </html>