psinetron-opencode-visualizer 1.0.3 → 1.0.5

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.
Files changed (46) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +45 -75
  3. package/index.ts +15 -174
  4. package/package.json +5 -2
  5. package/visualizer/index.html +14 -15
  6. package/visualizer/ocv-server.ts +153 -0
  7. package/visualizer/person1/person1/rotations/east.png +0 -0
  8. package/visualizer/person1/person1/rotations/north-east.png +0 -0
  9. package/visualizer/person1/person1/rotations/north-west.png +0 -0
  10. package/visualizer/person1/person1/rotations/north.png +0 -0
  11. package/visualizer/person1/person1/rotations/south-east.png +0 -0
  12. package/visualizer/person1/person1/rotations/south-west.png +0 -0
  13. package/visualizer/person1/person1/rotations/south.png +0 -0
  14. package/visualizer/person1/person1/rotations/west.png +0 -0
  15. package/visualizer/person2/person2/rotations/east.png +0 -0
  16. package/visualizer/person2/person2/rotations/north-east.png +0 -0
  17. package/visualizer/person2/person2/rotations/north-west.png +0 -0
  18. package/visualizer/person2/person2/rotations/north.png +0 -0
  19. package/visualizer/person2/person2/rotations/south-east.png +0 -0
  20. package/visualizer/person2/person2/rotations/south-west.png +0 -0
  21. package/visualizer/person2/person2/rotations/south.png +0 -0
  22. package/visualizer/person2/person2/rotations/west.png +0 -0
  23. package/visualizer/person3/person3/rotations/east.png +0 -0
  24. package/visualizer/person3/person3/rotations/north-east.png +0 -0
  25. package/visualizer/person3/person3/rotations/north-west.png +0 -0
  26. package/visualizer/person3/person3/rotations/north.png +0 -0
  27. package/visualizer/person3/person3/rotations/south-east.png +0 -0
  28. package/visualizer/person3/person3/rotations/south-west.png +0 -0
  29. package/visualizer/person3/person3/rotations/south.png +0 -0
  30. package/visualizer/person3/person3/rotations/west.png +0 -0
  31. package/visualizer/person4/person4/rotations/east.png +0 -0
  32. package/visualizer/person4/person4/rotations/north-east.png +0 -0
  33. package/visualizer/person4/person4/rotations/north-west.png +0 -0
  34. package/visualizer/person4/person4/rotations/north.png +0 -0
  35. package/visualizer/person4/person4/rotations/south-east.png +0 -0
  36. package/visualizer/person4/person4/rotations/south-west.png +0 -0
  37. package/visualizer/person4/person4/rotations/south.png +0 -0
  38. package/visualizer/person4/person4/rotations/west.png +0 -0
  39. package/visualizer/person5/person5/rotations/east.png +0 -0
  40. package/visualizer/person5/person5/rotations/north-east.png +0 -0
  41. package/visualizer/person5/person5/rotations/north-west.png +0 -0
  42. package/visualizer/person5/person5/rotations/north.png +0 -0
  43. package/visualizer/person5/person5/rotations/south-east.png +0 -0
  44. package/visualizer/person5/person5/rotations/south-west.png +0 -0
  45. package/visualizer/person5/person5/rotations/south.png +0 -0
  46. package/visualizer/person5/person5/rotations/west.png +0 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Fail
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,40 +1,60 @@
1
- # OpenCode Visualizer
1
+ # 👾 psinetron-opencode-visualizer
2
2
 
3
- Real-time pixel-art office scene that visualizes OpenCode AI agent sessions. Watch your agents walk, sit at desks, type on laptops, drink coffee, and react to events — all rendered in a charming retro pixel-art style.
3
+ [![MIT License](https://img.shields.io/github/license/psinetron/opencode-visualiser)](https://github.com/psinetron/opencode-visualiser/blob/main/LICENSE)
4
+ [![GitHub stars](https://img.shields.io/github/stars/psinetron/opencode-visualiser.svg)](https://github.com/psinetron/opencode-visualiser/stargazers)
5
+ [![NPM Version](https://img.shields.io/npm/v/psinetron-opencode-visualizer.svg)](https://www.npmjs.com/package/psinetron-opencode-visualizer)
4
6
 
5
- ![OpenCode Visualizer](visualizer/textures/background.png)
7
+ **Bringing the custom cruiser ethos to AI orchestration.** Turning raw OpenCode terminal logs into cozy 2D pixel office chaos. Watch your agents work, idle, and celebrate success in a bustling virtual office.
6
8
 
7
- ## Features
9
+ ---
8
10
 
9
- - **5 unique pixel-art characters** — each with distinct idle, working, and reaction animations
10
- - **12 animation states** — idle, walking, sitting, typing, drinking coffee, scratching head, yawning, error reactions, and more
11
- - **Persistent office layout** — 5 desks, a coffee zone, and a rest area with Y-sorted rendering
12
- - **Real-time WebSocket sync** — events stream from OpenCode sessions to the visualizer instantly
13
- - **Cross-platform standalone window** — automatically opens in Chrome app mode on macOS, Windows, and Linux; falls back to default browser
11
+ ## 🔥 Witness the Chaos
14
12
 
15
- ## Requirements
13
+ <img src="images/cover.png" width="100%" alt="OpenCode Visualizer" />
16
14
 
17
- - [OpenCode](https://opencode.ai)
18
- - [Bun](https://bun.sh) (runtime)
19
- - Chrome, Chromium, or Edge (for app-mode window; optional any browser works)
15
+ <!--
16
+ THIS IS THE MOST IMPORTANT PART FOR HYPE.
17
+ When you create your video-demo for X.com (MP4), convert it to a small GIF (e.g., using CapCut or online converters like ezgif) and place it here.
20
18
 
21
- ## Installation
19
+ For example, uncomment the line below when you have a GIF in your repo:
20
+
21
+ <img src="visualizer-demo.gif" width="100%" alt="OpenCode Visualizer Demo" />
22
+
23
+ *Placeholder for the awesome video demo you are going to record. It should show the JSON logs in the terminal transforming into the pixel art office.*
24
+ -->
25
+
26
+ ---
27
+
28
+ ## ✨ Features
29
+
30
+ - **Real-time Visualization:** Watch your OpenCode agents move around their office as they execute tools, search files, and write code.
31
+ - **Cozy Pixel Art Aesthetic:** A calming, gamified view of complex AI orchestration.
32
+ - **Mult-Agent Support:** Visualizes multiple agents (Explore, Scout, etc.) simultaneously in the same shared office space.
33
+ - **Customization (Skins):** Each agent has a custom skin saved in `.opencode/viz-skin.json`.
34
+ - **Unique Agent Animations:** Each of the 5 characters has its own set of unique idle, walk, work, and reaction animations.
35
+ - **Zero Friction Launch:** Pure Bun & TypeScript. Launches a cross-platform, isolated Chrome "app" window using native OS calls. No heavy Electron needed.
36
+
37
+ ---
38
+
39
+ ## 🚀 Installation
40
+
41
+ *Note: Requires [Bun](https://bun.sh) to be installed.*
22
42
 
23
43
  ### Via OpenCode UI (recommended)
24
44
 
25
- 1. Press `Cmd+P` (`Ctrl+P` on Windows/Linux) to open the command palette
45
+ 1. Press `Control+P` (`Ctrl+P` on Windows/Linux) to open the command palette
26
46
  2. Select **Install plugin**
27
- 3. Enter the package name: `slybeaver-opencode-visualizer`
47
+ 3. Enter the package name: `psinetron-opencode-visualizer`
28
48
 
29
49
  OpenCode will install the plugin and automatically add it to your project's `opencode.json`.
30
50
 
31
51
  ### Manual configuration
32
52
 
33
- Add the plugin to your OpenCode config (`opencode.json` or `opencode.jsonc`):
53
+ Add the plugin to your OpenCode config (`opencode.json`):
34
54
 
35
55
  ```json
36
56
  {
37
- "plugin": ["slybeaver-opencode-visualizer"]
57
+ "plugin": ["psinetron-opencode-visualizer"]
38
58
  }
39
59
  ```
40
60
 
@@ -46,68 +66,18 @@ Or install from a local path:
46
66
  }
47
67
  ```
48
68
 
49
- ## How It Works
50
69
 
51
- ```
52
- OpenCode session
53
- │ event hook
54
-
55
- Plugin (index.ts) ─── WebSocket ───► Bun Server (port 5173)
56
- │ broadcast
57
-
58
- Frontend (Canvas, 1024×768)
59
- Pixel-art office with animated agents
60
- ```
61
70
 
62
- 1. The plugin starts a Bun HTTP + WebSocket server on `localhost:5173`
63
- 2. It opens the visualizer in a Chrome app window (or your default browser)
64
- 3. OpenCode session events stream through WebSocket to the visualizer
65
- 4. The canvas renders agents navigating the office, sitting at desks, and animating in real time
71
+ ---
66
72
 
67
- ## Project Structure
73
+ ## 🎨 Customization (Skins)
68
74
 
69
- ```
70
- ├── index.ts # Plugin entry point (server + WebSocket + Chrome launcher)
71
- ├── opencode.json # OpenCode plugin manifest
72
- ├── package.json # npm metadata
73
- ├── visualizer/
74
- │ ├── index.html # SPA canvas app (all rendering logic in <script>)
75
- │ ├── textures/ # Background, desk, and coffee table sprites
76
- │ └── person1-5/ # Character sprites (8 rotation + 6-8 animation types each)
77
- │ └── personN/
78
- │ ├── rotations/ # Direction sprites (N, S, E, W, NE, NW, SE, SW)
79
- │ └── animations/ # Frame-by-frame animation sprites (idle, walk, sit, work, coffee, scratch, yawn)
80
- ```
75
+ The plugin automatically assigns a random pixel art skin to each agent and saves it to `.opencode/viz-skin.json` in your project folder. You can manually edit this file to change skins.
81
76
 
82
- ## Agent States
83
-
84
- | State | Description |
85
- |-------|-------------|
86
- | `idle` | Standing still, breathing animation |
87
- | `walking` | Walking to a destination |
88
- | `walking_to_desk` | Heading to an assigned desk |
89
- | `sitting` | Sitting down transition |
90
- | `working` | Typing on a laptop |
91
- | `drinking` | At the coffee zone |
92
- | `scratching` | Idle desk animation — scratching head |
93
- | `yawning` | Idle desk animation — yawning |
94
- | `error` | Reacting to an error |
95
- | `flash` | White flash on session diff |
96
- | `idle_rest` | Standing in the rest area |
97
- | `leaving` | Walking to the exit door |
98
-
99
- ## Development
100
-
101
- ```bash
102
- # Run the plugin (requires OpenCode)
103
- opencode
104
-
105
- # The visualizer runs at http://localhost:5173
106
- # Open it manually in any browser
107
- ```
77
+ Available skins: `person1`, `person2`, `person3`, `person4`, `person5`.
108
78
 
109
- No build step required — Bun runs TypeScript directly.
79
+ ---
110
80
 
111
- ## License
81
+ ## 📜 License
112
82
 
113
- MIT
83
+ MIT License. See LICENSE for more information. Built, not bought, by psinetron.
package/index.ts CHANGED
@@ -1,182 +1,21 @@
1
1
  import type { Plugin } from "@opencode-ai/plugin"
2
2
  import { join } from "path"
3
3
  import { existsSync, readFileSync, writeFileSync, mkdirSync } from "fs"
4
- import { platform } from "os"
5
4
 
6
5
  const SERVER_PORT = 5173
6
+ const DAEMON_PATH = join(import.meta.dir, "visualizer", "ocv-server.ts")
7
7
 
8
- // ─── WebSocket server ───────────────────────────────────────────────
8
+ let daemonStarted = false
9
9
 
10
- interface PluginClient {
11
- instanceId: string
12
- ws: WebSocket
13
- cwd: string
14
- skin: string
15
- connectedAt: number
16
- }
17
-
18
- interface FrontendClient {
19
- ws: WebSocket
20
- }
21
-
22
- const pluginClients = new Map<string, PluginClient>()
23
- const frontendClients = new Set<FrontendClient>()
24
- let windowOpened = false
25
- function broadcast(data: Record<string, unknown>) {
26
- const msg = JSON.stringify(data)
27
- for (const client of frontendClients) {
28
- if (client.ws.readyState === WebSocket.OPEN) {
29
- try { client.ws.send(msg) } catch {}
30
- }
31
- }
32
- }
33
-
34
- function openBrowserWindow() {
35
- if (windowOpened) return
36
- windowOpened = true
37
- const url = `http://localhost:${SERVER_PORT}`
38
-
39
- setTimeout(() => {
40
- if (frontendClients.size > 0) return
41
-
42
- const os = platform()
43
-
44
- try {
45
- if (os === "darwin") {
46
- Bun.spawn(["open", "-n", "-a", "Google Chrome", "--args", `--app=${url}`, "--window-size=1024,768"], {
47
- stdio: ["ignore", "ignore", "ignore"],
48
- })
49
- } else if (os === "win32") {
50
- Bun.spawn(["cmd.exe", "/c", "start", "chrome", `--app=${url}`, "--window-size=1024,768"], {
51
- stdio: ["ignore", "ignore", "ignore"],
52
- })
53
- } else {
54
- Bun.spawn(["google-chrome", `--app=${url}`, "--window-size=1024,768"], {
55
- stdio: ["ignore", "ignore", "ignore"],
56
- })
57
- }
58
- } catch {
59
- console.warn(`[visualizer] Failed to open Chrome app window, open manually: ${url}`)
60
- }
61
- }, 3000)
62
- }
63
-
64
- let shutdownTimer: ReturnType<typeof setTimeout> | null = null
65
- let server: ReturnType<typeof Bun.serve> | null = null
66
-
67
- function resetShutdownTimer() {
68
- if (shutdownTimer) clearTimeout(shutdownTimer)
69
- shutdownTimer = setTimeout(() => {
70
- if (pluginClients.size === 0) {
71
- server?.stop()
72
- process.exit(0)
73
- }
74
- }, 10_000)
75
- }
76
-
77
- try {
78
- const VISUALIZER_DIR = join(import.meta.dir, "visualizer")
79
-
80
- server = Bun.serve({
81
- port: SERVER_PORT,
82
- hostname: "127.0.0.1",
83
- async fetch(req) {
84
- if (server.upgrade(req)) {
85
- return
86
- }
87
- try {
88
- const url = new URL(req.url)
89
- const pathname = url.pathname === "/" ? "/index.html" : url.pathname
90
- const filePath = join(VISUALIZER_DIR, pathname)
91
- if (existsSync(filePath)) {
92
- return new Response(Bun.file(filePath), {
93
- headers: { "Cache-Control": "no-store, no-cache, must-revalidate" },
94
- })
95
- }
96
- return new Response("Not found", { status: 404 })
97
- } catch {
98
- return new Response("<h1>Visualization server — error</h1>", {
99
- headers: { "Content-Type": "text/html" },
100
- status: 500,
101
- })
102
- }
103
- },
104
- websocket: {
105
- open(_ws) {},
106
- message(ws, message) {
107
- let data: Record<string, unknown>
108
- try {
109
- data = JSON.parse(message as string)
110
- } catch {
111
- return
112
- }
113
-
114
- switch (data.type) {
115
- case "register": {
116
- const id = data.instanceId as string
117
- pluginClients.set(id, {
118
- instanceId: id,
119
- ws,
120
- cwd: (data.cwd as string) || "",
121
- skin: (data.skin as string) || "",
122
- connectedAt: Date.now(),
123
- })
124
-
125
- broadcast({
126
- type: "instance.added",
127
- instanceId: id,
128
- cwd: data.cwd,
129
- skin: data.skin,
130
- })
131
-
132
- openBrowserWindow()
133
- resetShutdownTimer()
134
- break
135
- }
136
-
137
- case "frontend.register": {
138
- frontendClients.add({ ws })
139
- const instances = [...pluginClients.values()].map((c) => ({
140
- instanceId: c.instanceId,
141
- cwd: c.cwd,
142
- skin: c.skin,
143
- }))
144
- ws.send(JSON.stringify({ type: "state.sync", instances }))
145
- break
146
- }
147
-
148
- case "event": {
149
- broadcast({
150
- type: "instance.event",
151
- instanceId: data.instanceId,
152
- event: data.event,
153
- })
154
- break
155
- }
156
- }
157
- },
158
- close(ws) {
159
- for (const [id, client] of pluginClients) {
160
- if (client.ws === ws) {
161
- pluginClients.delete(id)
162
- broadcast({ type: "instance.removed", instanceId: id })
163
- break
164
- }
165
- }
166
-
167
- for (const client of frontendClients) {
168
- if (client.ws === ws) {
169
- frontendClients.delete(client)
170
- break
171
- }
172
- }
173
-
174
- resetShutdownTimer()
175
- },
176
- },
177
- })
178
- } catch (err) {
179
- console.error("[visualizer] Failed to start server:", err)
10
+ function ensureDaemon() {
11
+ if (daemonStarted) return
12
+ daemonStarted = true
13
+ try {
14
+ const proc = Bun.spawn(["bun", DAEMON_PATH], {
15
+ stdio: ["ignore", "ignore", "ignore"],
16
+ })
17
+ proc.unref()
18
+ } catch {}
180
19
  }
181
20
 
182
21
  // ─── Plugin client ──────────────────────────────────────────────────
@@ -200,7 +39,7 @@ function resolveSkin(cwd: string): string {
200
39
 
201
40
  let ws: WebSocket | null = null
202
41
  let reconnectTimer: ReturnType<typeof setTimeout> | null = null
203
- const instanceId = `oc-${process.pid}-${Math.random().toString(36).slice(2, 8)}`
42
+ const instanceId = `oc-${process.pid}-${Math.random().toString(36).substring(2, 10)}`
204
43
 
205
44
  function connectToServer(skin: string): Promise<void> {
206
45
  return new Promise((resolve, reject) => {
@@ -265,7 +104,9 @@ function sendEvent(eventType: string, payload: Record<string, unknown> = {}) {
265
104
 
266
105
  const VisualizerPlugin: Plugin = async ({ project, client, $, directory, worktree }) => {
267
106
  const skin = resolveSkin(directory)
268
- // Wait up to 6 seconds for the server to be ready
107
+
108
+ ensureDaemon()
109
+
269
110
  for (let i = 0; i < 20; i++) {
270
111
  try {
271
112
  await connectToServer(skin)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "psinetron-opencode-visualizer",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
4
4
  "main": "index.ts",
5
5
  "description": "OpenCode session visualizer — real-time pixel-art office scene with animated agents",
6
6
  "files": [
@@ -9,5 +9,8 @@
9
9
  ],
10
10
  "keywords": ["opencode", "opencode-plugin", "visualizer", "monitor"],
11
11
  "license": "MIT",
12
- "type": "module"
12
+ "type": "module",
13
+ "devDependencies": {
14
+ "@opencode-ai/plugin": "^0.1.0"
15
+ }
13
16
  }
@@ -248,6 +248,8 @@ function getFreeCoffeeSpot() {
248
248
  }
249
249
 
250
250
  function createAgent(instanceId, cwd, skin) {
251
+ const existing = agents.find(a => a.instanceId === instanceId)
252
+ if (existing) return existing
251
253
  if (agents.length >= 5) return null
252
254
  const id = agentIdCounter++
253
255
  const skinIds = Object.keys(SKINS)
@@ -752,24 +754,21 @@ function connect() {
752
754
  try { data = JSON.parse(e.data) } catch { return }
753
755
 
754
756
  switch (data.type) {
755
- case "state.sync":
756
- {
757
- const syncIds = new Set((data.instances || []).map(inst => inst.instanceId))
758
- if (syncIds.size > 0) {
759
- for (const a of [...agents]) {
760
- if (!syncIds.has(a.instanceId)) {
761
- removeAgent(a.instanceId)
762
- }
763
- }
764
- for (const inst of data.instances || []) {
765
- const existing = agents.find(a => a.instanceId === inst.instanceId)
766
- if (!existing) {
767
- createAgent(inst.instanceId, inst.cwd, inst.skin)
768
- }
769
- }
757
+ case "state.sync": {
758
+ const syncIds = new Set((data.instances || []).map(i => i.instanceId))
759
+ for (const a of [...agents]) {
760
+ if (!syncIds.has(a.instanceId)) {
761
+ removeAgent(a.instanceId)
762
+ }
763
+ }
764
+ for (const inst of data.instances || []) {
765
+ const existing = agents.find(a => a.instanceId === inst.instanceId)
766
+ if (!existing) {
767
+ createAgent(inst.instanceId, inst.cwd, inst.skin)
770
768
  }
771
769
  }
772
770
  break
771
+ }
773
772
  case "instance.added":
774
773
  createAgent(data.instanceId, data.cwd, data.skin)
775
774
  break
@@ -0,0 +1,153 @@
1
+ // ocv-server.ts — standalone WebSocket server daemon
2
+ // Lives independently of any opencode instance — survives plugin process kills
3
+ import { join } from "path"
4
+ import { existsSync } from "fs"
5
+ import { platform } from "os"
6
+
7
+ const SERVER_PORT = 5173
8
+
9
+ interface PluginClient {
10
+ instanceId: string
11
+ ws: WebSocket
12
+ cwd: string
13
+ skin: string
14
+ connectedAt: number
15
+ }
16
+
17
+ interface FrontendClient {
18
+ ws: WebSocket
19
+ }
20
+
21
+ const pluginClients = new Map<string, PluginClient>()
22
+ const frontendClients = new Set<FrontendClient>()
23
+
24
+ function broadcast(data: Record<string, unknown>) {
25
+ const msg = JSON.stringify(data)
26
+ for (const client of frontendClients) {
27
+ if (client.ws.readyState === WebSocket.OPEN) {
28
+ try { client.ws.send(msg) } catch {}
29
+ }
30
+ }
31
+ }
32
+
33
+ function openBrowser() {
34
+ const url = `http://localhost:${SERVER_PORT}`
35
+ const os = platform()
36
+
37
+ setTimeout(() => {
38
+ if (frontendClients.size > 0) return
39
+ try {
40
+ if (os === "darwin") {
41
+ Bun.spawn(["open", "-n", "-a", "Google Chrome", "--args", `--app=${url}`, "--window-size=1024,768"], {
42
+ stdio: ["ignore", "ignore", "ignore"],
43
+ })
44
+ } else if (os === "win32") {
45
+ Bun.spawn(["cmd.exe", "/c", "start", "chrome", `--app=${url}`, "--window-size=1024,768"], {
46
+ stdio: ["ignore", "ignore", "ignore"],
47
+ })
48
+ } else {
49
+ Bun.spawn(["google-chrome", `--app=${url}`, "--window-size=1024,768"], {
50
+ stdio: ["ignore", "ignore", "ignore"],
51
+ })
52
+ }
53
+ } catch {
54
+ // Nothing to do — user can open manually
55
+ }
56
+ }, 3000)
57
+ }
58
+
59
+ let shutdownTimer: ReturnType<typeof setTimeout> | null = null
60
+ let server: ReturnType<typeof Bun.serve> | null = null
61
+
62
+ function resetShutdownTimer() {
63
+ if (shutdownTimer) clearTimeout(shutdownTimer)
64
+ shutdownTimer = setTimeout(() => {
65
+ if (pluginClients.size === 0 && frontendClients.size === 0) {
66
+ server?.stop()
67
+ process.exit(0)
68
+ }
69
+ }, 300_000)
70
+ }
71
+
72
+ const VISUALIZER_DIR = import.meta.dir
73
+
74
+ server = Bun.serve({
75
+ port: SERVER_PORT,
76
+ hostname: "127.0.0.1",
77
+ async fetch(req) {
78
+ if (server.upgrade(req)) return
79
+ try {
80
+ const url = new URL(req.url)
81
+ const pathname = url.pathname === "/" ? "/index.html" : url.pathname
82
+ const filePath = join(VISUALIZER_DIR, pathname)
83
+ if (existsSync(filePath)) {
84
+ return new Response(Bun.file(filePath), {
85
+ headers: { "Cache-Control": "no-store, no-cache, must-revalidate" },
86
+ })
87
+ }
88
+ return new Response("Not found", { status: 404 })
89
+ } catch {
90
+ return new Response("<h1>Server error</h1>", {
91
+ headers: { "Content-Type": "text/html" },
92
+ status: 500,
93
+ })
94
+ }
95
+ },
96
+ websocket: {
97
+ open(_ws) {},
98
+ message(ws, message) {
99
+ let data: Record<string, unknown>
100
+ try { data = JSON.parse(message as string) } catch { return }
101
+
102
+ switch (data.type) {
103
+ case "register": {
104
+ const id = data.instanceId as string
105
+ pluginClients.set(id, {
106
+ instanceId: id,
107
+ ws,
108
+ cwd: (data.cwd as string) || "",
109
+ skin: (data.skin as string) || "",
110
+ connectedAt: Date.now(),
111
+ })
112
+ broadcast({ type: "instance.added", instanceId: id, cwd: data.cwd, skin: data.skin })
113
+ ws.send(JSON.stringify({ type: "registered" }))
114
+ openBrowser()
115
+ resetShutdownTimer()
116
+ break
117
+ }
118
+ case "frontend.register": {
119
+ frontendClients.add({ ws })
120
+ const instances = [...pluginClients.values()].map((c) => ({
121
+ instanceId: c.instanceId,
122
+ cwd: c.cwd,
123
+ skin: c.skin,
124
+ }))
125
+ ws.send(JSON.stringify({ type: "state.sync", instances }))
126
+ break
127
+ }
128
+ case "event": {
129
+ broadcast({ type: "instance.event", instanceId: data.instanceId, event: data.event })
130
+ break
131
+ }
132
+ }
133
+ },
134
+ close(ws) {
135
+ for (const [id, client] of pluginClients) {
136
+ if (client.ws === ws) {
137
+ pluginClients.delete(id)
138
+ broadcast({ type: "instance.removed", instanceId: id })
139
+ break
140
+ }
141
+ }
142
+ for (const client of frontendClients) {
143
+ if (client.ws === ws) {
144
+ frontendClients.delete(client)
145
+ break
146
+ }
147
+ }
148
+ resetShutdownTimer()
149
+ },
150
+ },
151
+ })
152
+
153
+ console.log(`[ocv-server] Listening on http://localhost:${SERVER_PORT}`)