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.
@@ -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
@@ -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
+ }