psinetron-opencode-visualizer 1.0.3 → 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 +14 -173
- package/package.json +1 -1
- package/visualizer/index.html +14 -15
- package/visualizer/ocv-server.ts +153 -0
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 ──────────────────────────────────────────────────
|
|
@@ -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
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}`)
|