opencode-dashboard 0.1.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/LICENSE +21 -0
- package/README.md +329 -0
- package/agents/orchestrator.md +99 -0
- package/agents/pipeline-builder.md +53 -0
- package/agents/pipeline-committer.md +78 -0
- package/agents/pipeline-refactor.md +58 -0
- package/agents/pipeline-reviewer.md +68 -0
- package/bin/cli.ts +332 -0
- package/commands/dashboard-start.md +5 -0
- package/commands/dashboard-status.md +5 -0
- package/commands/dashboard-stop.md +5 -0
- package/dist/assets/index-W-qyIr7d.js +134 -0
- package/dist/assets/index-mMdK5PVd.css +1 -0
- package/dist/index.html +13 -0
- package/package.json +82 -0
- package/plugin/index.ts +1441 -0
- package/server/PLUGIN_EVENTS.md +410 -0
- package/server/index.ts +55 -0
- package/server/pid.ts +140 -0
- package/server/routes.ts +520 -0
- package/server/sse.ts +196 -0
- package/server/state.ts +936 -0
- package/shared/types.ts +402 -0
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
# Plugin Event Reference
|
|
2
|
+
|
|
3
|
+
This document describes every event that the `dashboard-bridge.ts` plugin pushes to the dashboard server via `POST /api/plugin/event`.
|
|
4
|
+
|
|
5
|
+
## Protocol
|
|
6
|
+
|
|
7
|
+
All events are sent as:
|
|
8
|
+
```json
|
|
9
|
+
POST /api/plugin/event
|
|
10
|
+
{
|
|
11
|
+
"pluginId": "<uuid>",
|
|
12
|
+
"event": "<event-type>",
|
|
13
|
+
"data": { ... }
|
|
14
|
+
}
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
The server enriches events with additional fields (e.g., `pipelineId`) before broadcasting to SSE clients. The plugin automatically includes `projectPath` and `timestamp` in every event payload via the `pushEvent()` enrichment layer.
|
|
18
|
+
|
|
19
|
+
---
|
|
20
|
+
|
|
21
|
+
## Event Types
|
|
22
|
+
|
|
23
|
+
### Required Events (from DASHBOARD_PLAN.md)
|
|
24
|
+
|
|
25
|
+
| # | Event | Status |
|
|
26
|
+
|---|-------|--------|
|
|
27
|
+
| 1 | `bead:discovered` | Implemented |
|
|
28
|
+
| 2 | `bead:claimed` | Implemented |
|
|
29
|
+
| 3 | `bead:stage` | Implemented |
|
|
30
|
+
| 4 | `bead:done` | Implemented |
|
|
31
|
+
| 5 | `bead:error` | Implemented |
|
|
32
|
+
| 6 | `agent:active` | Implemented |
|
|
33
|
+
| 7 | `agent:idle` | Implemented |
|
|
34
|
+
| 8 | `beads:refreshed` | Implemented |
|
|
35
|
+
|
|
36
|
+
### Additional Events (not in plan, but useful)
|
|
37
|
+
|
|
38
|
+
| Event | Purpose |
|
|
39
|
+
|-------|---------|
|
|
40
|
+
| `bead:changed` | Bead status changed (normal transition, not error/done) |
|
|
41
|
+
| `bead:removed` | Bead disappeared from `bd list --json` output |
|
|
42
|
+
|
|
43
|
+
---
|
|
44
|
+
|
|
45
|
+
## Detailed Event Reference
|
|
46
|
+
|
|
47
|
+
### `bead:discovered`
|
|
48
|
+
|
|
49
|
+
A new bead was found in `bd list --json` output that wasn't in the previous snapshot.
|
|
50
|
+
|
|
51
|
+
**When triggered:**
|
|
52
|
+
- On plugin startup (initial bead snapshot — all existing beads pushed as discovered)
|
|
53
|
+
- On any `tool.execute.after` hook (via `refreshAndDiff()`)
|
|
54
|
+
- On `session.idle` event (via `refreshAndDiff()`)
|
|
55
|
+
|
|
56
|
+
**Source function:** `refreshAndDiff()` (line ~659), `startupSequence()` (line ~751)
|
|
57
|
+
|
|
58
|
+
**Payload:**
|
|
59
|
+
```json
|
|
60
|
+
{
|
|
61
|
+
"bead": {
|
|
62
|
+
"id": "opencode-dashboard-abc",
|
|
63
|
+
"title": "Add auth middleware",
|
|
64
|
+
"description": "...",
|
|
65
|
+
"status": "open",
|
|
66
|
+
"priority": 1,
|
|
67
|
+
"issue_type": "task",
|
|
68
|
+
"created_at": "2026-02-20T09:00:00Z",
|
|
69
|
+
"updated_at": "2026-02-20T09:00:00Z"
|
|
70
|
+
},
|
|
71
|
+
"projectPath": "/Users/.../project-a",
|
|
72
|
+
"timestamp": 1740045600000
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
**Notes:**
|
|
77
|
+
- On startup, ALL beads are pushed as discovered (establishes baseline for server)
|
|
78
|
+
- Beads discovered in blocked/failed-closed state also emit a follow-up `bead:error`
|
|
79
|
+
|
|
80
|
+
---
|
|
81
|
+
|
|
82
|
+
### `bead:claimed`
|
|
83
|
+
|
|
84
|
+
A bead's status changed from any state to `in_progress`, indicating the orchestrator claimed it.
|
|
85
|
+
|
|
86
|
+
**When triggered:**
|
|
87
|
+
- In `tool.execute.after` hook, when `refreshAndDiff()` detects a bead transitioning to `in_progress`
|
|
88
|
+
|
|
89
|
+
**Source function:** `tool.execute.after` handler (line ~935)
|
|
90
|
+
|
|
91
|
+
**Payload:**
|
|
92
|
+
```json
|
|
93
|
+
{
|
|
94
|
+
"beadId": "opencode-dashboard-abc",
|
|
95
|
+
"bead": { "...full BeadRecord..." },
|
|
96
|
+
"stage": "orchestrator",
|
|
97
|
+
"projectPath": "/Users/.../project-a",
|
|
98
|
+
"timestamp": 1740045600000
|
|
99
|
+
}
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
**Notes:**
|
|
103
|
+
- Sets `currentBeadId` in the plugin for pipeline stage correlation
|
|
104
|
+
- If a previous bead was still `in_progress` when a new one is claimed, the previous bead gets a `bead:error` with "abandoned" message
|
|
105
|
+
|
|
106
|
+
---
|
|
107
|
+
|
|
108
|
+
### `bead:stage`
|
|
109
|
+
|
|
110
|
+
A bead is moving to a new pipeline stage (builder, refactor, reviewer, committer).
|
|
111
|
+
|
|
112
|
+
**When triggered:**
|
|
113
|
+
- In `tool.execute.before` hook, when the orchestrator invokes the Task tool with a recognized pipeline agent
|
|
114
|
+
|
|
115
|
+
**Source function:** `tool.execute.before` handler (line ~882)
|
|
116
|
+
|
|
117
|
+
**Detection logic:**
|
|
118
|
+
1. Hook fires for any tool execution
|
|
119
|
+
2. Checks if tool is `task`, `subtask`, or `developer`
|
|
120
|
+
3. Extracts agent name from `args.agent`, `args.subagent_type`, or `args.agentName`
|
|
121
|
+
4. Maps agent name to stage via `mapSubagentTypeToStage()`
|
|
122
|
+
5. Extracts bead ID from task description (e.g., `[bd-a1b2]`) or falls back to `currentBeadId`
|
|
123
|
+
|
|
124
|
+
**Payload:**
|
|
125
|
+
```json
|
|
126
|
+
{
|
|
127
|
+
"beadId": "opencode-dashboard-abc",
|
|
128
|
+
"stage": "builder",
|
|
129
|
+
"agentSessionId": "<orchestrator-session-id>",
|
|
130
|
+
"projectPath": "/Users/.../project-a",
|
|
131
|
+
"timestamp": 1740045600000
|
|
132
|
+
}
|
|
133
|
+
```
|
|
134
|
+
|
|
135
|
+
**Stage values:** `builder`, `refactor`, `reviewer`, `committer`, `designer`
|
|
136
|
+
|
|
137
|
+
**Notes:**
|
|
138
|
+
- The `agentSessionId` is the orchestrator's session ID at the time of invocation. The actual child agent session ID is reported later in `agent:active`.
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
### `bead:done`
|
|
143
|
+
|
|
144
|
+
A bead has been closed (status changed to `closed` with a non-failure reason).
|
|
145
|
+
|
|
146
|
+
**When triggered:**
|
|
147
|
+
- In `tool.execute.after` hook, when `refreshAndDiff()` detects a bead transitioning to `closed` status without a failure-indicating close reason
|
|
148
|
+
|
|
149
|
+
**Source function:** `tool.execute.after` handler (line ~951)
|
|
150
|
+
|
|
151
|
+
**Payload:**
|
|
152
|
+
```json
|
|
153
|
+
{
|
|
154
|
+
"beadId": "opencode-dashboard-abc",
|
|
155
|
+
"bead": { "...full BeadRecord..." },
|
|
156
|
+
"projectPath": "/Users/.../project-a",
|
|
157
|
+
"timestamp": 1740045600000
|
|
158
|
+
}
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
**Notes:**
|
|
162
|
+
- Clears `currentBeadId` if the completed bead was the current one
|
|
163
|
+
- If close reason contains failure indicators (fail, reject, abandon, error, abort), a `bead:error` is emitted instead (from the diff logic)
|
|
164
|
+
|
|
165
|
+
---
|
|
166
|
+
|
|
167
|
+
### `bead:error`
|
|
168
|
+
|
|
169
|
+
A bead has entered an error state.
|
|
170
|
+
|
|
171
|
+
**When triggered (4 scenarios):**
|
|
172
|
+
|
|
173
|
+
| Scenario | Detection | Source |
|
|
174
|
+
|----------|-----------|--------|
|
|
175
|
+
| Bead status → `blocked` | `diffBeadState()` detects status change | `refreshAndDiff()` |
|
|
176
|
+
| Bead closed with failure reason | `diffBeadState()` checks `close_reason` against failure patterns | `refreshAndDiff()` |
|
|
177
|
+
| Bead abandoned | New bead claimed while previous still `in_progress` | `tool.execute.after` handler |
|
|
178
|
+
| Discovered bead already in error state | Initial snapshot or new bead already blocked/failed-closed | `refreshAndDiff()` / `startupSequence()` |
|
|
179
|
+
|
|
180
|
+
**Failure reason patterns (case-insensitive regex):** `fail|reject|abandon|error|abort`
|
|
181
|
+
|
|
182
|
+
**Payload:**
|
|
183
|
+
```json
|
|
184
|
+
{
|
|
185
|
+
"beadId": "opencode-dashboard-abc",
|
|
186
|
+
"bead": { "...full BeadRecord..." },
|
|
187
|
+
"error": "Bead status changed to blocked (was: in_progress)",
|
|
188
|
+
"projectPath": "/Users/.../project-a",
|
|
189
|
+
"timestamp": 1740045600000
|
|
190
|
+
}
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
**Notes:**
|
|
194
|
+
- All `bead:error` payloads include both `beadId` (for easy routing) and the full `bead` object (for display)
|
|
195
|
+
|
|
196
|
+
---
|
|
197
|
+
|
|
198
|
+
### `agent:active`
|
|
199
|
+
|
|
200
|
+
A child agent session has been created and mapped to a pipeline stage.
|
|
201
|
+
|
|
202
|
+
**When triggered:**
|
|
203
|
+
- In the `event` handler for `session.created`, when a child session (has `parentID`) is created and can be mapped to a pipeline agent type
|
|
204
|
+
|
|
205
|
+
**Source function:** `event` handler, `session.created` branch (line ~1081)
|
|
206
|
+
|
|
207
|
+
**Agent detection logic:**
|
|
208
|
+
1. Check `pendingAgentType` (set by `tool.execute.before` when Task tool was invoked)
|
|
209
|
+
2. If not set, infer from session title (e.g., title containing "pipeline-builder")
|
|
210
|
+
|
|
211
|
+
**Payload:**
|
|
212
|
+
```json
|
|
213
|
+
{
|
|
214
|
+
"agent": "builder",
|
|
215
|
+
"sessionId": "<child-session-id>",
|
|
216
|
+
"parentSessionId": "<orchestrator-session-id>",
|
|
217
|
+
"beadId": "opencode-dashboard-abc",
|
|
218
|
+
"projectPath": "/Users/.../project-a",
|
|
219
|
+
"timestamp": 1740045600000
|
|
220
|
+
}
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
**Agent values:** `builder`, `refactor`, `reviewer`, `committer`, `designer`
|
|
224
|
+
|
|
225
|
+
---
|
|
226
|
+
|
|
227
|
+
### `agent:idle`
|
|
228
|
+
|
|
229
|
+
A child agent session has finished work.
|
|
230
|
+
|
|
231
|
+
**When triggered:**
|
|
232
|
+
- In the `event` handler for `session.idle`, when the idle session is a tracked child agent session
|
|
233
|
+
|
|
234
|
+
**Source function:** `event` handler, `session.idle` branch (line ~1114)
|
|
235
|
+
|
|
236
|
+
**Payload:**
|
|
237
|
+
```json
|
|
238
|
+
{
|
|
239
|
+
"agent": "builder",
|
|
240
|
+
"sessionId": "<child-session-id>",
|
|
241
|
+
"beadId": "opencode-dashboard-abc",
|
|
242
|
+
"projectPath": "/Users/.../project-a",
|
|
243
|
+
"timestamp": 1740045600000
|
|
244
|
+
}
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
**Notes:**
|
|
248
|
+
- After pushing `agent:idle`, the session-to-agent mapping is cleaned up
|
|
249
|
+
- A `refreshAndDiff()` is also triggered (may produce additional bead state events)
|
|
250
|
+
|
|
251
|
+
---
|
|
252
|
+
|
|
253
|
+
### `beads:refreshed`
|
|
254
|
+
|
|
255
|
+
Summary event sent after every bead state refresh that detected changes.
|
|
256
|
+
|
|
257
|
+
**When triggered:**
|
|
258
|
+
- After `refreshAndDiff()` completes with at least one diff
|
|
259
|
+
- After initial bead snapshot in `startupSequence()`
|
|
260
|
+
|
|
261
|
+
**Source function:** `refreshAndDiff()` (line ~680), `startupSequence()` (line ~769)
|
|
262
|
+
|
|
263
|
+
**Payload:**
|
|
264
|
+
```json
|
|
265
|
+
{
|
|
266
|
+
"beadCount": 5,
|
|
267
|
+
"changed": 2,
|
|
268
|
+
"projectPath": "/Users/.../project-a",
|
|
269
|
+
"timestamp": 1740045600000
|
|
270
|
+
}
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
---
|
|
274
|
+
|
|
275
|
+
### `bead:changed` (additional)
|
|
276
|
+
|
|
277
|
+
A bead's status changed normally (not to blocked, not closed with failure).
|
|
278
|
+
|
|
279
|
+
**When triggered:**
|
|
280
|
+
- In `refreshAndDiff()` when a bead's status changes (e.g., `open` → `in_progress`)
|
|
281
|
+
|
|
282
|
+
**Source function:** `refreshAndDiff()` (line ~663)
|
|
283
|
+
|
|
284
|
+
**Payload:**
|
|
285
|
+
```json
|
|
286
|
+
{
|
|
287
|
+
"bead": { "...full BeadRecord..." },
|
|
288
|
+
"prevStatus": "open",
|
|
289
|
+
"projectPath": "/Users/.../project-a",
|
|
290
|
+
"timestamp": 1740045600000
|
|
291
|
+
}
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
**Notes:**
|
|
295
|
+
- This is distinct from `bead:claimed` and `bead:done`. The `tool.execute.after` handler generates `bead:claimed`/`bead:done` after `refreshAndDiff()` returns diffs. Both the generic `bead:changed` AND the specific `bead:claimed`/`bead:done` will fire for the same transition.
|
|
296
|
+
|
|
297
|
+
---
|
|
298
|
+
|
|
299
|
+
### `bead:removed` (additional)
|
|
300
|
+
|
|
301
|
+
A bead that was in the previous snapshot is no longer in `bd list --json` output.
|
|
302
|
+
|
|
303
|
+
**When triggered:**
|
|
304
|
+
- In `refreshAndDiff()` when a bead ID exists in the previous snapshot but not the current one
|
|
305
|
+
|
|
306
|
+
**Source function:** `refreshAndDiff()` (line ~669)
|
|
307
|
+
|
|
308
|
+
**Payload:**
|
|
309
|
+
```json
|
|
310
|
+
{
|
|
311
|
+
"beadId": "opencode-dashboard-abc",
|
|
312
|
+
"projectPath": "/Users/.../project-a",
|
|
313
|
+
"timestamp": 1740045600000
|
|
314
|
+
}
|
|
315
|
+
```
|
|
316
|
+
|
|
317
|
+
---
|
|
318
|
+
|
|
319
|
+
## Event Flow Diagram
|
|
320
|
+
|
|
321
|
+
```
|
|
322
|
+
Plugin Startup
|
|
323
|
+
│
|
|
324
|
+
├─ checkServerHealth() → spawnServer() if needed
|
|
325
|
+
├─ registerWithServer() → pluginId assigned
|
|
326
|
+
├─ startHeartbeat() → periodic POST /api/plugin/heartbeat
|
|
327
|
+
└─ refreshBeadState() → for each bead:
|
|
328
|
+
├─ pushEvent("bead:discovered", ...)
|
|
329
|
+
├─ pushEvent("bead:error", ...) [if blocked/failed]
|
|
330
|
+
└─ pushEvent("beads:refreshed", ...)
|
|
331
|
+
|
|
332
|
+
OpenCode Events
|
|
333
|
+
│
|
|
334
|
+
├─ chat.message → context injection only (no dashboard event)
|
|
335
|
+
│
|
|
336
|
+
├─ tool.execute.before
|
|
337
|
+
│ └─ Task tool with pipeline agent detected?
|
|
338
|
+
│ └─ pushEvent("bead:stage", ...)
|
|
339
|
+
│
|
|
340
|
+
├─ tool.execute.after
|
|
341
|
+
│ └─ refreshAndDiff()
|
|
342
|
+
│ ├─ pushEvent("bead:discovered", ...) [new beads]
|
|
343
|
+
│ ├─ pushEvent("bead:changed", ...) [status changes]
|
|
344
|
+
│ ├─ pushEvent("bead:error", ...) [blocked/failed]
|
|
345
|
+
│ ├─ pushEvent("bead:removed", ...) [deleted beads]
|
|
346
|
+
│ └─ pushEvent("beads:refreshed", ...) [summary]
|
|
347
|
+
│ └─ Inspect diffs for claim/done:
|
|
348
|
+
│ ├─ pushEvent("bead:claimed", ...) [open → in_progress]
|
|
349
|
+
│ ├─ pushEvent("bead:done", ...) [→ closed, normal]
|
|
350
|
+
│ └─ pushEvent("bead:error", ...) [abandoned bead]
|
|
351
|
+
│
|
|
352
|
+
├─ session.created (child session)
|
|
353
|
+
│ └─ pushEvent("agent:active", ...)
|
|
354
|
+
│
|
|
355
|
+
├─ session.idle
|
|
356
|
+
│ ├─ pushEvent("agent:idle", ...)
|
|
357
|
+
│ └─ refreshAndDiff() → [same events as above]
|
|
358
|
+
│
|
|
359
|
+
└─ session.compacted → context re-injection only (no dashboard event)
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
## OpenCode Hook → Event Mapping
|
|
363
|
+
|
|
364
|
+
| OpenCode Hook | Plugin Handler | Dashboard Events Produced |
|
|
365
|
+
|---------------|---------------|--------------------------|
|
|
366
|
+
| Plugin startup | `startupSequence()` | `bead:discovered` (×N), `bead:error` (if any), `beads:refreshed` |
|
|
367
|
+
| `chat.message` | `chat.message` handler | (none — context injection only) |
|
|
368
|
+
| `tool.execute.before` | `tool.execute.before` handler | `bead:stage` (if Task tool with pipeline agent) |
|
|
369
|
+
| `tool.execute.after` | `tool.execute.after` handler | `bead:discovered`, `bead:changed`, `bead:error`, `bead:removed`, `beads:refreshed`, `bead:claimed`, `bead:done` |
|
|
370
|
+
| `session.created` | `event` handler | `agent:active` (if child session mapped to agent) |
|
|
371
|
+
| `session.idle` | `event` handler | `agent:idle` (if tracked agent session), then `refreshAndDiff()` events |
|
|
372
|
+
| `session.compacted` | `event` handler | (none — context re-injection only) |
|
|
373
|
+
|
|
374
|
+
## Payload Field Reference
|
|
375
|
+
|
|
376
|
+
### Common Fields
|
|
377
|
+
|
|
378
|
+
All events include `projectPath` and `timestamp` in their payload.
|
|
379
|
+
|
|
380
|
+
| Field | Type | Description |
|
|
381
|
+
|-------|------|-------------|
|
|
382
|
+
| `projectPath` | `string` | Absolute path to the project directory (e.g., `/Users/.../project-a`) |
|
|
383
|
+
| `timestamp` | `number` | Unix timestamp in milliseconds when the event was generated |
|
|
384
|
+
|
|
385
|
+
### BeadRecord Object
|
|
386
|
+
|
|
387
|
+
When events include a `bead` field, it contains the full `bd list --json` record:
|
|
388
|
+
|
|
389
|
+
| Field | Type | Description |
|
|
390
|
+
|-------|------|-------------|
|
|
391
|
+
| `id` | `string` | Bead identifier (e.g., `opencode-dashboard-abc`) |
|
|
392
|
+
| `title` | `string` | Bead title |
|
|
393
|
+
| `description` | `string` | Bead description |
|
|
394
|
+
| `status` | `string` | `open`, `in_progress`, `blocked`, or `closed` |
|
|
395
|
+
| `priority` | `number` | 0 (critical) to 4 (backlog) |
|
|
396
|
+
| `issue_type` | `string` | `bug`, `feature`, `task`, `epic`, `chore`, `decision` |
|
|
397
|
+
| `created_at` | `string` | ISO timestamp |
|
|
398
|
+
| `updated_at` | `string` | ISO timestamp |
|
|
399
|
+
| `closed_at` | `string?` | ISO timestamp (if closed) |
|
|
400
|
+
| `close_reason` | `string?` | Reason for closing |
|
|
401
|
+
| `dependencies` | `array?` | Dependency relationships |
|
|
402
|
+
|
|
403
|
+
## Server-Side Enrichment
|
|
404
|
+
|
|
405
|
+
The dashboard server (Phase 3) enriches events before broadcasting to SSE clients:
|
|
406
|
+
|
|
407
|
+
1. Looks up `projectPath` from `pluginId` (in case the plugin didn't include it)
|
|
408
|
+
2. Adds `_serverTimestamp` for server-side timing
|
|
409
|
+
3. Adds `pipelineId` from its internal pipeline tracking state
|
|
410
|
+
4. Broadcasts as named SSE events to all connected dashboard clients
|
package/server/index.ts
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Dashboard Server - Entry Point
|
|
3
|
+
*
|
|
4
|
+
* Standalone Bun server that aggregates state from OpenCode plugins
|
|
5
|
+
* and serves the dashboard API + SSE stream + pre-built frontend.
|
|
6
|
+
*
|
|
7
|
+
* Run: bun run server/index.ts
|
|
8
|
+
* Port: 3333 (configurable via DASHBOARD_PORT env var)
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import {
|
|
12
|
+
handleRequest,
|
|
13
|
+
closeAllSSEClients,
|
|
14
|
+
stateManager,
|
|
15
|
+
stopHealthMonitoring,
|
|
16
|
+
} from "./routes";
|
|
17
|
+
import { writePid, removePid } from "./pid";
|
|
18
|
+
|
|
19
|
+
const PORT = Number(process.env.DASHBOARD_PORT) || 3333;
|
|
20
|
+
|
|
21
|
+
const server = Bun.serve({
|
|
22
|
+
port: PORT,
|
|
23
|
+
fetch: handleRequest,
|
|
24
|
+
idleTimeout: 255, // seconds (max) — SSE connections are long-lived
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
// Write PID file so plugin tools and CLI can find this server
|
|
28
|
+
writePid(process.pid, server.port ?? PORT);
|
|
29
|
+
|
|
30
|
+
console.log(`[dashboard-server] Running on http://localhost:${server.port}`);
|
|
31
|
+
console.log(`[dashboard-server] PID: ${process.pid}`);
|
|
32
|
+
console.log(`[dashboard-server] Endpoints:`);
|
|
33
|
+
console.log(` POST /api/plugin/register`);
|
|
34
|
+
console.log(` POST /api/plugin/event`);
|
|
35
|
+
console.log(` POST /api/plugin/heartbeat`);
|
|
36
|
+
console.log(` DELETE /api/plugin/:id`);
|
|
37
|
+
console.log(` GET /api/state`);
|
|
38
|
+
console.log(` GET /api/events`);
|
|
39
|
+
console.log(` GET /api/health`);
|
|
40
|
+
|
|
41
|
+
// --- Graceful Shutdown ---
|
|
42
|
+
|
|
43
|
+
function shutdown() {
|
|
44
|
+
console.log(`\n[dashboard-server] Shutting down...`);
|
|
45
|
+
stateManager.persistNow(); // Flush state to disk before shutdown
|
|
46
|
+
stopHealthMonitoring();
|
|
47
|
+
closeAllSSEClients();
|
|
48
|
+
removePid(); // Clean up PID file
|
|
49
|
+
server.stop();
|
|
50
|
+
console.log(`[dashboard-server] Server stopped.`);
|
|
51
|
+
process.exit(0);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
process.on("SIGINT", shutdown);
|
|
55
|
+
process.on("SIGTERM", shutdown);
|
package/server/pid.ts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PID File Management for the Dashboard Server
|
|
3
|
+
*
|
|
4
|
+
* Enables both the plugin tools and CLI to discover, control,
|
|
5
|
+
* and check the status of a running dashboard server.
|
|
6
|
+
*
|
|
7
|
+
* PID file location: ~/.cache/opencode/opencode-dashboard.pid
|
|
8
|
+
* Contents: JSON { pid, port, startedAt }
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "fs";
|
|
12
|
+
import { dirname, join } from "path";
|
|
13
|
+
import { homedir } from "os";
|
|
14
|
+
|
|
15
|
+
// --- Types ---
|
|
16
|
+
|
|
17
|
+
export interface PidFileData {
|
|
18
|
+
pid: number;
|
|
19
|
+
port: number;
|
|
20
|
+
startedAt: string; // ISO timestamp
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// --- PID file path ---
|
|
24
|
+
|
|
25
|
+
const PID_DIR = join(homedir(), ".cache", "opencode");
|
|
26
|
+
const PID_FILE = join(PID_DIR, "opencode-dashboard.pid");
|
|
27
|
+
|
|
28
|
+
/** Get the PID file path (exposed for testing) */
|
|
29
|
+
export function getPidFilePath(): string {
|
|
30
|
+
return PID_FILE;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// --- Write ---
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Write PID file when the server starts.
|
|
37
|
+
* Creates the directory if it doesn't exist.
|
|
38
|
+
*/
|
|
39
|
+
export function writePid(pid: number, port: number): void {
|
|
40
|
+
if (!existsSync(PID_DIR)) {
|
|
41
|
+
mkdirSync(PID_DIR, { recursive: true });
|
|
42
|
+
}
|
|
43
|
+
const data: PidFileData = {
|
|
44
|
+
pid,
|
|
45
|
+
port,
|
|
46
|
+
startedAt: new Date().toISOString(),
|
|
47
|
+
};
|
|
48
|
+
writeFileSync(PID_FILE, JSON.stringify(data, null, 2), "utf-8");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// --- Read ---
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Read PID file. Returns null if the file doesn't exist or is malformed.
|
|
55
|
+
*/
|
|
56
|
+
export function readPid(): PidFileData | null {
|
|
57
|
+
try {
|
|
58
|
+
if (!existsSync(PID_FILE)) return null;
|
|
59
|
+
const raw = readFileSync(PID_FILE, "utf-8");
|
|
60
|
+
const parsed = JSON.parse(raw) as PidFileData;
|
|
61
|
+
if (typeof parsed.pid !== "number" || typeof parsed.port !== "number") {
|
|
62
|
+
return null;
|
|
63
|
+
}
|
|
64
|
+
return parsed;
|
|
65
|
+
} catch {
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// --- Remove ---
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Remove PID file on graceful shutdown or after stopping the server.
|
|
74
|
+
*/
|
|
75
|
+
export function removePid(): void {
|
|
76
|
+
try {
|
|
77
|
+
if (existsSync(PID_FILE)) {
|
|
78
|
+
unlinkSync(PID_FILE);
|
|
79
|
+
}
|
|
80
|
+
} catch {
|
|
81
|
+
// Ignore errors (file may already be gone)
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// --- Status check ---
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Check if a process with the given PID is still running.
|
|
89
|
+
* Uses `kill(pid, 0)` which checks for existence without sending a signal.
|
|
90
|
+
*/
|
|
91
|
+
function isProcessAlive(pid: number): boolean {
|
|
92
|
+
try {
|
|
93
|
+
process.kill(pid, 0);
|
|
94
|
+
return true;
|
|
95
|
+
} catch {
|
|
96
|
+
return false;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Check if the dashboard server is running.
|
|
102
|
+
*
|
|
103
|
+
* Performs a two-step check:
|
|
104
|
+
* 1. PID file exists and process is alive (fast, no network)
|
|
105
|
+
* 2. Optionally hits the health endpoint for confirmation
|
|
106
|
+
*
|
|
107
|
+
* Returns the PID file data if the server is running, null otherwise.
|
|
108
|
+
* Automatically cleans up stale PID files.
|
|
109
|
+
*/
|
|
110
|
+
export async function isServerRunning(
|
|
111
|
+
checkHealth = false,
|
|
112
|
+
): Promise<PidFileData | null> {
|
|
113
|
+
const pidData = readPid();
|
|
114
|
+
if (!pidData) return null;
|
|
115
|
+
|
|
116
|
+
// Check if the process is still alive
|
|
117
|
+
if (!isProcessAlive(pidData.pid)) {
|
|
118
|
+
// Stale PID file — process is dead, clean up
|
|
119
|
+
removePid();
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Optional: verify via health endpoint
|
|
124
|
+
if (checkHealth) {
|
|
125
|
+
try {
|
|
126
|
+
const res = await fetch(
|
|
127
|
+
`http://localhost:${pidData.port}/api/health`,
|
|
128
|
+
{ signal: AbortSignal.timeout(3000) },
|
|
129
|
+
);
|
|
130
|
+
if (!res.ok) {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
} catch {
|
|
134
|
+
// Health check failed but process is alive — might be starting up
|
|
135
|
+
// Return the PID data anyway; caller can retry
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return pidData;
|
|
140
|
+
}
|