psinetron-opencode-visualizer 1.0.2 → 1.0.4

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 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 ──────────────────────────────────────────────────
@@ -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.2",
3
+ "version": "1.0.4",
4
4
  "main": "index.ts",
5
5
  "description": "OpenCode session visualizer — real-time pixel-art office scene with animated agents",
6
6
  "files": [
@@ -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,14 +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
- agents.length = 0
757
- agentIdCounter = 0
758
- reservedCoffeeSpots.clear()
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
+ }
759
764
  for (const inst of data.instances || []) {
760
- createAgent(inst.instanceId, inst.cwd, inst.skin)
765
+ const existing = agents.find(a => a.instanceId === inst.instanceId)
766
+ if (!existing) {
767
+ createAgent(inst.instanceId, inst.cwd, inst.skin)
768
+ }
761
769
  }
762
770
  break
771
+ }
763
772
  case "instance.added":
764
773
  createAgent(data.instanceId, data.cwd, data.skin)
765
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}`)