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.
- package/LICENSE +21 -0
- package/README.md +45 -75
- package/index.ts +15 -174
- package/package.json +5 -2
- package/visualizer/index.html +14 -15
- package/visualizer/ocv-server.ts +153 -0
- package/visualizer/person1/person1/rotations/east.png +0 -0
- package/visualizer/person1/person1/rotations/north-east.png +0 -0
- package/visualizer/person1/person1/rotations/north-west.png +0 -0
- package/visualizer/person1/person1/rotations/north.png +0 -0
- package/visualizer/person1/person1/rotations/south-east.png +0 -0
- package/visualizer/person1/person1/rotations/south-west.png +0 -0
- package/visualizer/person1/person1/rotations/south.png +0 -0
- package/visualizer/person1/person1/rotations/west.png +0 -0
- package/visualizer/person2/person2/rotations/east.png +0 -0
- package/visualizer/person2/person2/rotations/north-east.png +0 -0
- package/visualizer/person2/person2/rotations/north-west.png +0 -0
- package/visualizer/person2/person2/rotations/north.png +0 -0
- package/visualizer/person2/person2/rotations/south-east.png +0 -0
- package/visualizer/person2/person2/rotations/south-west.png +0 -0
- package/visualizer/person2/person2/rotations/south.png +0 -0
- package/visualizer/person2/person2/rotations/west.png +0 -0
- package/visualizer/person3/person3/rotations/east.png +0 -0
- package/visualizer/person3/person3/rotations/north-east.png +0 -0
- package/visualizer/person3/person3/rotations/north-west.png +0 -0
- package/visualizer/person3/person3/rotations/north.png +0 -0
- package/visualizer/person3/person3/rotations/south-east.png +0 -0
- package/visualizer/person3/person3/rotations/south-west.png +0 -0
- package/visualizer/person3/person3/rotations/south.png +0 -0
- package/visualizer/person3/person3/rotations/west.png +0 -0
- package/visualizer/person4/person4/rotations/east.png +0 -0
- package/visualizer/person4/person4/rotations/north-east.png +0 -0
- package/visualizer/person4/person4/rotations/north-west.png +0 -0
- package/visualizer/person4/person4/rotations/north.png +0 -0
- package/visualizer/person4/person4/rotations/south-east.png +0 -0
- package/visualizer/person4/person4/rotations/south-west.png +0 -0
- package/visualizer/person4/person4/rotations/south.png +0 -0
- package/visualizer/person4/person4/rotations/west.png +0 -0
- package/visualizer/person5/person5/rotations/east.png +0 -0
- package/visualizer/person5/person5/rotations/north-east.png +0 -0
- package/visualizer/person5/person5/rotations/north-west.png +0 -0
- package/visualizer/person5/person5/rotations/north.png +0 -0
- package/visualizer/person5/person5/rotations/south-east.png +0 -0
- package/visualizer/person5/person5/rotations/south-west.png +0 -0
- package/visualizer/person5/person5/rotations/south.png +0 -0
- 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
|
-
#
|
|
1
|
+
# 👾 psinetron-opencode-visualizer
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
[](https://github.com/psinetron/opencode-visualiser/blob/main/LICENSE)
|
|
4
|
+
[](https://github.com/psinetron/opencode-visualiser/stargazers)
|
|
5
|
+
[](https://www.npmjs.com/package/psinetron-opencode-visualizer)
|
|
4
6
|
|
|
5
|
-
|
|
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
|
-
|
|
9
|
+
---
|
|
8
10
|
|
|
9
|
-
|
|
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
|
-
|
|
13
|
+
<img src="images/cover.png" width="100%" alt="OpenCode Visualizer" />
|
|
16
14
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
-
|
|
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
|
-
|
|
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 `
|
|
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: `
|
|
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`
|
|
53
|
+
Add the plugin to your OpenCode config (`opencode.json`):
|
|
34
54
|
|
|
35
55
|
```json
|
|
36
56
|
{
|
|
37
|
-
"plugin": ["
|
|
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
|
-
|
|
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
|
-
##
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
8
|
+
let daemonStarted = false
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
}
|
|
17
|
-
|
|
18
|
-
|
|
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).
|
|
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
|
-
|
|
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
|
+
"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
|
}
|
package/visualizer/index.html
CHANGED
|
@@ -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
|
-
|
|
758
|
-
if (syncIds.
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
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}`)
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|