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
package/plugin/index.ts
ADDED
|
@@ -0,0 +1,1441 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenCode Dashboard Plugin — Distributable Entry Point
|
|
3
|
+
*
|
|
4
|
+
* This is the main plugin file loaded by OpenCode when installed from npm:
|
|
5
|
+
* opencode.json → "plugin": ["opencode-dashboard"]
|
|
6
|
+
*
|
|
7
|
+
* Provides:
|
|
8
|
+
* - Custom tools: dashboard_start, dashboard_stop, dashboard_status, dashboard_open
|
|
9
|
+
* - Context injection: bd prime + pipeline guidance (conditional on agent detection)
|
|
10
|
+
* - Agent discovery: reads agent configs from opencode.json, .opencode/agents/, ~/.config/opencode/agents/
|
|
11
|
+
* - Dynamic columns: generates column config from discovered agents
|
|
12
|
+
* - Bead tracking: bead state diffs, stage detection, agent lifecycle
|
|
13
|
+
* - Server bridge: auto-start, registration, heartbeat, event pushing
|
|
14
|
+
*
|
|
15
|
+
* The plugin loads dormant. The server only starts when the LLM calls
|
|
16
|
+
* dashboard_start (or the user runs `npx opencode-dashboard start`).
|
|
17
|
+
* Context injection works independently of the server.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { Plugin } from "@opencode-ai/plugin";
|
|
21
|
+
import { tool } from "@opencode-ai/plugin";
|
|
22
|
+
import type { BeadRecord, BeadDiff, ColumnConfig } from "../shared/types";
|
|
23
|
+
import { isServerRunning, readPid, writePid, removePid } from "../server/pid";
|
|
24
|
+
import { join, resolve } from "path";
|
|
25
|
+
import { readdir, readFile, copyFile, mkdir, stat } from "fs/promises";
|
|
26
|
+
|
|
27
|
+
// ─── Constants ─────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
const LOG_PREFIX = "[dashboard]";
|
|
30
|
+
const DEFAULT_PORT = 3333;
|
|
31
|
+
|
|
32
|
+
// Debug-gated logging — all output goes to stderr to avoid TUI corruption
|
|
33
|
+
const DEBUG = process.env.DASHBOARD_DEBUG === "1";
|
|
34
|
+
const log = (...args: unknown[]) => {
|
|
35
|
+
if (DEBUG) console.error(LOG_PREFIX, ...args);
|
|
36
|
+
};
|
|
37
|
+
const warn = (...args: unknown[]) => {
|
|
38
|
+
if (DEBUG) console.error(LOG_PREFIX, "[warn]", ...args);
|
|
39
|
+
};
|
|
40
|
+
const logError = (...args: unknown[]) => {
|
|
41
|
+
if (DEBUG) console.error(LOG_PREFIX, "[error]", ...args);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
// Server paths (resolved relative to this file)
|
|
45
|
+
const SERVER_ENTRY = join(import.meta.dir, "../server/index.ts");
|
|
46
|
+
|
|
47
|
+
// Network
|
|
48
|
+
const HEARTBEAT_INTERVAL_MS = 30_000;
|
|
49
|
+
const SPAWN_POLL_INTERVAL_MS = 500;
|
|
50
|
+
const SPAWN_TIMEOUT_MS = 5_000;
|
|
51
|
+
const FETCH_TIMEOUT_MS = 5_000;
|
|
52
|
+
|
|
53
|
+
// ─── Module-level state ────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
let pluginId: string | null = null;
|
|
56
|
+
let serverReady = false;
|
|
57
|
+
let serverPort = DEFAULT_PORT;
|
|
58
|
+
let heartbeatTimer: ReturnType<typeof setInterval> | null = null;
|
|
59
|
+
let cleanupRegistered = false;
|
|
60
|
+
let lastBeadSnapshot: BeadRecord[] = [];
|
|
61
|
+
let shellRef: any = null;
|
|
62
|
+
let projectPath = "";
|
|
63
|
+
|
|
64
|
+
// Activation state
|
|
65
|
+
let activated = false;
|
|
66
|
+
let activating = false;
|
|
67
|
+
|
|
68
|
+
// Bead state refresh guards
|
|
69
|
+
let isRefreshing = false;
|
|
70
|
+
let refreshTimer: ReturnType<typeof setTimeout> | null = null;
|
|
71
|
+
|
|
72
|
+
// Pipeline tracking
|
|
73
|
+
let currentBeadId: string | null = null;
|
|
74
|
+
let sessionToAgent = new Map<string, string>();
|
|
75
|
+
let pendingAgentType: string | null = null;
|
|
76
|
+
let currentAgentName: string | null = null; // tracks the agent handling the current chat.message
|
|
77
|
+
|
|
78
|
+
// Agent discovery results
|
|
79
|
+
let discoveredAgents: DiscoveredAgent[] = [];
|
|
80
|
+
let hasPipelineAgents = false;
|
|
81
|
+
|
|
82
|
+
// ─── Agent Discovery Types ────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
interface DiscoveredAgent {
|
|
85
|
+
name: string; // filename without .md extension
|
|
86
|
+
description: string;
|
|
87
|
+
mode: string; // "primary", "subagent", "all"
|
|
88
|
+
color: string | null; // hex color from frontmatter
|
|
89
|
+
hidden: boolean;
|
|
90
|
+
source: "project" | "global" | "json";
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ─── Pipeline Guidance (conditionally injected when pipeline agents detected) ───
|
|
94
|
+
|
|
95
|
+
function buildPipelineGuidance(): string | null {
|
|
96
|
+
if (!hasPipelineAgents) return null;
|
|
97
|
+
return `<pipeline-guidance>
|
|
98
|
+
## Beads CLI Usage
|
|
99
|
+
|
|
100
|
+
Use the \`bash\` tool for all beads operations. Always use \`--json\` flag:
|
|
101
|
+
- \`bd ready --json\` - List ready tasks
|
|
102
|
+
- \`bd show <id> --json\` - Show task details
|
|
103
|
+
- \`bd update <id> --status in_progress --json\` - Claim a bead
|
|
104
|
+
- \`bd close <id> --reason "message" --json\` - Complete a bead
|
|
105
|
+
- \`bd list --status open --json\` - List all open issues
|
|
106
|
+
|
|
107
|
+
## Pipeline Workflow
|
|
108
|
+
|
|
109
|
+
When working on beads:
|
|
110
|
+
1. Run \`bd ready --json\` to find work
|
|
111
|
+
2. Claim with \`bd update <id> --status in_progress --json\`
|
|
112
|
+
3. Run pipeline stages in order:
|
|
113
|
+
- pipeline-builder (implement the feature)
|
|
114
|
+
- pipeline-refactor (improve code quality - optional)
|
|
115
|
+
- pipeline-reviewer (review and fix issues)
|
|
116
|
+
- pipeline-committer (create git commit)
|
|
117
|
+
4. Close with \`bd close <id> --reason "message" --json\`
|
|
118
|
+
|
|
119
|
+
Include the bead ID in Task descriptions (e.g., "Build: [bd-a1b2] Add auth middleware").
|
|
120
|
+
</pipeline-guidance>`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const injectedSessions = new Set<string>();
|
|
124
|
+
|
|
125
|
+
// ─── URL helpers ───────────────────────────────────────────────
|
|
126
|
+
|
|
127
|
+
function serverUrl(path: string): string {
|
|
128
|
+
return `http://localhost:${serverPort}${path}`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ─── Context Injection ─────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
async function getBdPrimeOutput($: any): Promise<string | null> {
|
|
134
|
+
try {
|
|
135
|
+
const result = await $`bd prime`.text();
|
|
136
|
+
const trimmed = result.trim();
|
|
137
|
+
if (!trimmed) return null;
|
|
138
|
+
return trimmed;
|
|
139
|
+
} catch {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function buildContextMessage(bdPrime: string | null): string {
|
|
145
|
+
const parts: string[] = [];
|
|
146
|
+
if (bdPrime) {
|
|
147
|
+
parts.push(`<beads-context>\n${bdPrime}\n</beads-context>`);
|
|
148
|
+
}
|
|
149
|
+
const guidance = buildPipelineGuidance();
|
|
150
|
+
if (guidance) {
|
|
151
|
+
parts.push(guidance);
|
|
152
|
+
}
|
|
153
|
+
return parts.join("\n\n");
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
async function injectContext(
|
|
157
|
+
client: any,
|
|
158
|
+
sessionID: string,
|
|
159
|
+
model: { providerID: string; modelID: string } | undefined,
|
|
160
|
+
agent: string | undefined,
|
|
161
|
+
$: any,
|
|
162
|
+
): Promise<void> {
|
|
163
|
+
if (injectedSessions.has(sessionID)) {
|
|
164
|
+
log(`Session ${sessionID} already injected, skipping`);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Mark BEFORE the await to prevent re-entrancy via chat.message
|
|
169
|
+
injectedSessions.add(sessionID);
|
|
170
|
+
|
|
171
|
+
const bdPrime = await getBdPrimeOutput($);
|
|
172
|
+
const contextMessage = buildContextMessage(bdPrime);
|
|
173
|
+
|
|
174
|
+
try {
|
|
175
|
+
await client.session.prompt({
|
|
176
|
+
path: { id: sessionID },
|
|
177
|
+
body: {
|
|
178
|
+
noReply: true,
|
|
179
|
+
...(model ? { model } : {}),
|
|
180
|
+
...(agent ? { agent } : {}),
|
|
181
|
+
parts: [
|
|
182
|
+
{
|
|
183
|
+
type: "text" as const,
|
|
184
|
+
text: contextMessage,
|
|
185
|
+
synthetic: true,
|
|
186
|
+
},
|
|
187
|
+
],
|
|
188
|
+
},
|
|
189
|
+
});
|
|
190
|
+
log(
|
|
191
|
+
`Injected context into session ${sessionID}` +
|
|
192
|
+
(bdPrime ? " (with bd prime)" : " (pipeline guidance only)"),
|
|
193
|
+
);
|
|
194
|
+
} catch (err) {
|
|
195
|
+
injectedSessions.delete(sessionID);
|
|
196
|
+
logError(`Failed to inject context into session ${sessionID}:`, err);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
async function isChildSession(client: any, sessionID: string): Promise<boolean> {
|
|
201
|
+
try {
|
|
202
|
+
const result = await client.session.get({ path: { id: sessionID } });
|
|
203
|
+
return !!result?.data?.parentID;
|
|
204
|
+
} catch {
|
|
205
|
+
return false;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
async function getSessionContext(
|
|
210
|
+
client: any,
|
|
211
|
+
sessionID: string,
|
|
212
|
+
): Promise<{
|
|
213
|
+
model?: { providerID: string; modelID: string };
|
|
214
|
+
agent?: string;
|
|
215
|
+
}> {
|
|
216
|
+
try {
|
|
217
|
+
const result = await client.session.messages({
|
|
218
|
+
path: { id: sessionID },
|
|
219
|
+
query: { limit: 50 },
|
|
220
|
+
});
|
|
221
|
+
const messages = result?.data;
|
|
222
|
+
if (!messages || !Array.isArray(messages)) return {};
|
|
223
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
224
|
+
const msg = messages[i]?.info;
|
|
225
|
+
if (msg?.role === "user" && msg?.model) {
|
|
226
|
+
return { model: msg.model, agent: msg.agent };
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return {};
|
|
230
|
+
} catch {
|
|
231
|
+
return {};
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
async function hasExistingContext(client: any, sessionID: string): Promise<boolean> {
|
|
236
|
+
try {
|
|
237
|
+
const result = await client.session.messages({
|
|
238
|
+
path: { id: sessionID },
|
|
239
|
+
query: { limit: 50 },
|
|
240
|
+
});
|
|
241
|
+
const messages = result?.data;
|
|
242
|
+
if (!messages || !Array.isArray(messages)) return false;
|
|
243
|
+
for (const msg of messages) {
|
|
244
|
+
const parts = msg?.parts;
|
|
245
|
+
if (!parts || !Array.isArray(parts)) continue;
|
|
246
|
+
for (const part of parts) {
|
|
247
|
+
if (
|
|
248
|
+
part?.type === "text" &&
|
|
249
|
+
typeof part.text === "string" &&
|
|
250
|
+
part.text.includes("<pipeline-guidance>")
|
|
251
|
+
) {
|
|
252
|
+
return true;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
return false;
|
|
257
|
+
} catch {
|
|
258
|
+
return false;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ─── Agent Discovery ───────────────────────────────────────────
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Parse YAML-like frontmatter from a markdown agent file.
|
|
266
|
+
* Extracts top-level scalar keys only (description, mode, color, hidden).
|
|
267
|
+
*/
|
|
268
|
+
function parseFrontmatter(content: string): Record<string, string> {
|
|
269
|
+
const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
|
270
|
+
if (!match) return {};
|
|
271
|
+
const frontmatter: Record<string, string> = {};
|
|
272
|
+
for (const line of match[1].split("\n")) {
|
|
273
|
+
const kv = line.match(/^(\w[\w-]*)\s*:\s*(.+)$/);
|
|
274
|
+
if (kv) {
|
|
275
|
+
frontmatter[kv[1]] = kv[2].trim().replace(/^["']|["']$/g, "");
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
return frontmatter;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* Discover agents from markdown files in a directory.
|
|
283
|
+
*/
|
|
284
|
+
async function discoverAgentsFromDir(
|
|
285
|
+
dir: string,
|
|
286
|
+
source: "project" | "global",
|
|
287
|
+
): Promise<DiscoveredAgent[]> {
|
|
288
|
+
const agents: DiscoveredAgent[] = [];
|
|
289
|
+
try {
|
|
290
|
+
const entries = await readdir(dir);
|
|
291
|
+
for (const entry of entries) {
|
|
292
|
+
if (!entry.endsWith(".md")) continue;
|
|
293
|
+
const name = entry.replace(/\.md$/, "");
|
|
294
|
+
try {
|
|
295
|
+
const content = await readFile(join(dir, entry), "utf-8");
|
|
296
|
+
const fm = parseFrontmatter(content);
|
|
297
|
+
agents.push({
|
|
298
|
+
name,
|
|
299
|
+
description: fm.description || "",
|
|
300
|
+
mode: fm.mode || "subagent",
|
|
301
|
+
color: fm.color || null,
|
|
302
|
+
hidden: fm.hidden === "true",
|
|
303
|
+
source,
|
|
304
|
+
});
|
|
305
|
+
} catch {
|
|
306
|
+
// Skip files that can't be read
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
} catch {
|
|
310
|
+
// Directory doesn't exist — that's fine
|
|
311
|
+
}
|
|
312
|
+
return agents;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Discover agents from opencode.json "agent" key.
|
|
317
|
+
*/
|
|
318
|
+
async function discoverAgentsFromJson(projectDir: string): Promise<DiscoveredAgent[]> {
|
|
319
|
+
const agents: DiscoveredAgent[] = [];
|
|
320
|
+
try {
|
|
321
|
+
const content = await readFile(join(projectDir, "opencode.json"), "utf-8");
|
|
322
|
+
const config = JSON.parse(content);
|
|
323
|
+
const agentConfig = config?.agent;
|
|
324
|
+
if (agentConfig && typeof agentConfig === "object") {
|
|
325
|
+
for (const [name, cfg] of Object.entries(agentConfig)) {
|
|
326
|
+
if (!cfg || typeof cfg !== "object") continue;
|
|
327
|
+
const c = cfg as Record<string, unknown>;
|
|
328
|
+
agents.push({
|
|
329
|
+
name,
|
|
330
|
+
description: (c.description as string) || "",
|
|
331
|
+
mode: (c.mode as string) || "subagent",
|
|
332
|
+
color: (c.color as string) || null,
|
|
333
|
+
hidden: c.hidden === true,
|
|
334
|
+
source: "json",
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
} catch {
|
|
339
|
+
// opencode.json doesn't exist or invalid — that's fine
|
|
340
|
+
}
|
|
341
|
+
return agents;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Discover all agents from all sources. Later sources override earlier ones (by name).
|
|
346
|
+
* Order: opencode.json < .opencode/agents/ < ~/.config/opencode/agents/
|
|
347
|
+
*/
|
|
348
|
+
async function discoverAllAgents(projectDir: string): Promise<DiscoveredAgent[]> {
|
|
349
|
+
const [jsonAgents, projectAgents, globalAgents] = await Promise.all([
|
|
350
|
+
discoverAgentsFromJson(projectDir),
|
|
351
|
+
discoverAgentsFromDir(join(projectDir, ".opencode", "agents"), "project"),
|
|
352
|
+
discoverAgentsFromDir(
|
|
353
|
+
join(process.env.HOME || "~", ".config", "opencode", "agents"),
|
|
354
|
+
"global",
|
|
355
|
+
),
|
|
356
|
+
]);
|
|
357
|
+
|
|
358
|
+
// Merge: later sources win
|
|
359
|
+
const byName = new Map<string, DiscoveredAgent>();
|
|
360
|
+
for (const agent of [...jsonAgents, ...projectAgents, ...globalAgents]) {
|
|
361
|
+
byName.set(agent.name, agent);
|
|
362
|
+
}
|
|
363
|
+
return Array.from(byName.values());
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/** Known pipeline agent names for ordering */
|
|
367
|
+
const PIPELINE_AGENT_ORDER: Record<string, number> = {
|
|
368
|
+
"pipeline-builder": 1,
|
|
369
|
+
"pipeline-refactor": 2,
|
|
370
|
+
"pipeline-reviewer": 3,
|
|
371
|
+
"pipeline-committer": 4,
|
|
372
|
+
};
|
|
373
|
+
|
|
374
|
+
/** Default color palette for agent columns */
|
|
375
|
+
const DEFAULT_AGENT_COLORS = [
|
|
376
|
+
"#8b5cf6", // violet
|
|
377
|
+
"#3b82f6", // blue
|
|
378
|
+
"#06b6d4", // cyan
|
|
379
|
+
"#f59e0b", // amber
|
|
380
|
+
"#10b981", // emerald
|
|
381
|
+
"#ec4899", // pink
|
|
382
|
+
"#f97316", // orange
|
|
383
|
+
"#6366f1", // indigo
|
|
384
|
+
];
|
|
385
|
+
|
|
386
|
+
/**
|
|
387
|
+
* Generate column config from discovered agents.
|
|
388
|
+
*
|
|
389
|
+
* Layout:
|
|
390
|
+
* - "ready" always first
|
|
391
|
+
* - orchestrator before pipeline agents
|
|
392
|
+
* - pipeline agents in known order
|
|
393
|
+
* - other agents alphabetically
|
|
394
|
+
* - "done" and "error" always last
|
|
395
|
+
*/
|
|
396
|
+
function generateColumnConfig(agents: DiscoveredAgent[]): ColumnConfig[] {
|
|
397
|
+
const columns: ColumnConfig[] = [];
|
|
398
|
+
let order = 0;
|
|
399
|
+
|
|
400
|
+
// Fixed bookend: ready
|
|
401
|
+
columns.push({
|
|
402
|
+
id: "ready",
|
|
403
|
+
label: "Ready",
|
|
404
|
+
type: "status",
|
|
405
|
+
color: "#64748b",
|
|
406
|
+
order: order++,
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
// Separate agents into categories
|
|
410
|
+
const orchestratorAgent = agents.find((a) => a.name === "orchestrator");
|
|
411
|
+
const pipelineAgents = agents
|
|
412
|
+
.filter((a) => a.name in PIPELINE_AGENT_ORDER)
|
|
413
|
+
.sort((a, b) => (PIPELINE_AGENT_ORDER[a.name] ?? 99) - (PIPELINE_AGENT_ORDER[b.name] ?? 99));
|
|
414
|
+
const otherAgents = agents
|
|
415
|
+
.filter(
|
|
416
|
+
(a) =>
|
|
417
|
+
a.name !== "orchestrator" && !(a.name in PIPELINE_AGENT_ORDER),
|
|
418
|
+
)
|
|
419
|
+
.sort((a, b) => a.name.localeCompare(b.name));
|
|
420
|
+
|
|
421
|
+
let colorIndex = 0;
|
|
422
|
+
const nextColor = (agent: DiscoveredAgent): string => {
|
|
423
|
+
if (agent.color) return agent.color;
|
|
424
|
+
return DEFAULT_AGENT_COLORS[colorIndex++ % DEFAULT_AGENT_COLORS.length];
|
|
425
|
+
};
|
|
426
|
+
|
|
427
|
+
// Orchestrator first (if present)
|
|
428
|
+
if (orchestratorAgent) {
|
|
429
|
+
columns.push({
|
|
430
|
+
id: orchestratorAgent.name,
|
|
431
|
+
label: formatAgentLabel(orchestratorAgent.name),
|
|
432
|
+
type: "agent",
|
|
433
|
+
color: nextColor(orchestratorAgent),
|
|
434
|
+
order: order++,
|
|
435
|
+
});
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
// Pipeline agents in sequence
|
|
439
|
+
for (const agent of pipelineAgents) {
|
|
440
|
+
columns.push({
|
|
441
|
+
id: agent.name,
|
|
442
|
+
label: formatAgentLabel(agent.name),
|
|
443
|
+
type: "agent",
|
|
444
|
+
color: nextColor(agent),
|
|
445
|
+
order: order++,
|
|
446
|
+
});
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
// Other agents alphabetically
|
|
450
|
+
for (const agent of otherAgents) {
|
|
451
|
+
columns.push({
|
|
452
|
+
id: agent.name,
|
|
453
|
+
label: formatAgentLabel(agent.name),
|
|
454
|
+
type: "agent",
|
|
455
|
+
color: nextColor(agent),
|
|
456
|
+
order: order++,
|
|
457
|
+
});
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
// Fixed bookends: done and error
|
|
461
|
+
columns.push({
|
|
462
|
+
id: "done",
|
|
463
|
+
label: "Done",
|
|
464
|
+
type: "status",
|
|
465
|
+
color: "#22c55e",
|
|
466
|
+
order: order++,
|
|
467
|
+
});
|
|
468
|
+
columns.push({
|
|
469
|
+
id: "error",
|
|
470
|
+
label: "Error",
|
|
471
|
+
type: "status",
|
|
472
|
+
color: "#ef4444",
|
|
473
|
+
order: order++,
|
|
474
|
+
});
|
|
475
|
+
|
|
476
|
+
return columns;
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
/**
|
|
480
|
+
* Format agent name into a human-readable label.
|
|
481
|
+
* "pipeline-builder" → "Builder", "orchestrator" → "Orchestrator"
|
|
482
|
+
*/
|
|
483
|
+
function formatAgentLabel(name: string): string {
|
|
484
|
+
// Strip "pipeline-" prefix for cleaner labels
|
|
485
|
+
const display = name.startsWith("pipeline-") ? name.slice("pipeline-".length) : name;
|
|
486
|
+
return display.charAt(0).toUpperCase() + display.slice(1);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
/**
|
|
490
|
+
* Check if specific files are installed in a target directory.
|
|
491
|
+
*/
|
|
492
|
+
async function checkAgentsInstalled(
|
|
493
|
+
targetDir: string,
|
|
494
|
+
requiredFiles: string[] = ["orchestrator.md", "pipeline-builder.md"],
|
|
495
|
+
): Promise<boolean> {
|
|
496
|
+
try {
|
|
497
|
+
const entries = await readdir(targetDir);
|
|
498
|
+
return requiredFiles.every((f) => entries.includes(f));
|
|
499
|
+
} catch {
|
|
500
|
+
return false;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
/**
|
|
505
|
+
* Copy shipped agent files to a target directory. Skips files that already exist.
|
|
506
|
+
*/
|
|
507
|
+
async function installAgents(targetDir: string): Promise<string[]> {
|
|
508
|
+
const shippedDir = join(import.meta.dir, "../agents");
|
|
509
|
+
const installed: string[] = [];
|
|
510
|
+
try {
|
|
511
|
+
await mkdir(targetDir, { recursive: true });
|
|
512
|
+
const entries = await readdir(shippedDir);
|
|
513
|
+
for (const entry of entries) {
|
|
514
|
+
if (!entry.endsWith(".md")) continue;
|
|
515
|
+
const destPath = join(targetDir, entry);
|
|
516
|
+
// Skip if file already exists (don't overwrite user customizations)
|
|
517
|
+
try {
|
|
518
|
+
await stat(destPath);
|
|
519
|
+
log(`Agent file already exists, skipping: ${destPath}`);
|
|
520
|
+
continue;
|
|
521
|
+
} catch {
|
|
522
|
+
// File doesn't exist — proceed with copy
|
|
523
|
+
}
|
|
524
|
+
await copyFile(join(shippedDir, entry), destPath);
|
|
525
|
+
installed.push(entry);
|
|
526
|
+
}
|
|
527
|
+
} catch (err) {
|
|
528
|
+
logError(`Failed to install agents to ${targetDir}:`, err);
|
|
529
|
+
}
|
|
530
|
+
return installed;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
// ─── Server Lifecycle ──────────────────────────────────────────
|
|
534
|
+
|
|
535
|
+
async function checkServerHealth(): Promise<boolean> {
|
|
536
|
+
try {
|
|
537
|
+
const res = await fetch(serverUrl("/api/health"), {
|
|
538
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
539
|
+
});
|
|
540
|
+
return res.ok;
|
|
541
|
+
} catch {
|
|
542
|
+
return false;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
async function spawnServer(port: number): Promise<boolean> {
|
|
547
|
+
// Resolve the bun executable path. We cannot use process.execPath because
|
|
548
|
+
// when running inside OpenCode, that points to the OpenCode Go binary,
|
|
549
|
+
// not bun. Bun.which("bun") finds it on $PATH reliably.
|
|
550
|
+
const bunPath = Bun.which("bun");
|
|
551
|
+
if (!bunPath) {
|
|
552
|
+
logError("Could not find 'bun' on PATH — cannot start dashboard server");
|
|
553
|
+
return false;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
log(`Server not running, starting on port ${port}...`);
|
|
557
|
+
log(`Spawning: ${bunPath} run ${SERVER_ENTRY}`);
|
|
558
|
+
|
|
559
|
+
try {
|
|
560
|
+
const proc = Bun.spawn([bunPath, "run", SERVER_ENTRY], {
|
|
561
|
+
detached: true,
|
|
562
|
+
stdio: ["ignore", "ignore", "ignore"],
|
|
563
|
+
env: { ...process.env, DASHBOARD_PORT: String(port) },
|
|
564
|
+
});
|
|
565
|
+
proc.unref();
|
|
566
|
+
log(`Spawned server process (PID: ${proc.pid})`);
|
|
567
|
+
} catch (err) {
|
|
568
|
+
logError(`Failed to spawn server process:`, err);
|
|
569
|
+
return false;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// Poll for readiness
|
|
573
|
+
const maxAttempts = Math.ceil(SPAWN_TIMEOUT_MS / SPAWN_POLL_INTERVAL_MS);
|
|
574
|
+
for (let i = 0; i < maxAttempts; i++) {
|
|
575
|
+
await Bun.sleep(SPAWN_POLL_INTERVAL_MS);
|
|
576
|
+
if (await checkServerHealth()) {
|
|
577
|
+
log(`Server started successfully (attempt ${i + 1}/${maxAttempts})`);
|
|
578
|
+
return true;
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
warn(`Server failed to start within ${SPAWN_TIMEOUT_MS}ms`);
|
|
583
|
+
return false;
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
async function registerWithServer(
|
|
587
|
+
pPath: string,
|
|
588
|
+
projectName: string,
|
|
589
|
+
): Promise<string | null> {
|
|
590
|
+
try {
|
|
591
|
+
const res = await fetch(serverUrl("/api/plugin/register"), {
|
|
592
|
+
method: "POST",
|
|
593
|
+
headers: { "Content-Type": "application/json" },
|
|
594
|
+
body: JSON.stringify({ projectPath: pPath, projectName }),
|
|
595
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
596
|
+
});
|
|
597
|
+
if (!res.ok) {
|
|
598
|
+
logError(`Registration failed: HTTP ${res.status}`);
|
|
599
|
+
return null;
|
|
600
|
+
}
|
|
601
|
+
const data = (await res.json()) as { pluginId?: string };
|
|
602
|
+
if (!data.pluginId) {
|
|
603
|
+
logError(`Registration response missing pluginId`);
|
|
604
|
+
return null;
|
|
605
|
+
}
|
|
606
|
+
log(`Registered with server, pluginId: ${data.pluginId}`);
|
|
607
|
+
return data.pluginId;
|
|
608
|
+
} catch (err) {
|
|
609
|
+
logError(`Registration failed:`, err);
|
|
610
|
+
return null;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
async function sendHeartbeat(): Promise<void> {
|
|
615
|
+
if (!pluginId || !serverReady) return;
|
|
616
|
+
try {
|
|
617
|
+
const res = await fetch(serverUrl("/api/plugin/heartbeat"), {
|
|
618
|
+
method: "POST",
|
|
619
|
+
headers: { "Content-Type": "application/json" },
|
|
620
|
+
body: JSON.stringify({ pluginId }),
|
|
621
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
622
|
+
});
|
|
623
|
+
if (!res.ok) {
|
|
624
|
+
warn(`Heartbeat failed: HTTP ${res.status}`);
|
|
625
|
+
}
|
|
626
|
+
} catch (err) {
|
|
627
|
+
warn(`Heartbeat failed:`, err);
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function startHeartbeat(): void {
|
|
632
|
+
if (heartbeatTimer) clearInterval(heartbeatTimer);
|
|
633
|
+
heartbeatTimer = setInterval(sendHeartbeat, HEARTBEAT_INTERVAL_MS);
|
|
634
|
+
log(`Heartbeat started (every ${HEARTBEAT_INTERVAL_MS / 1000}s)`);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function stopHeartbeat(): void {
|
|
638
|
+
if (heartbeatTimer) {
|
|
639
|
+
clearInterval(heartbeatTimer);
|
|
640
|
+
heartbeatTimer = null;
|
|
641
|
+
log(`Heartbeat stopped`);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
async function deregisterFromServer(): Promise<void> {
|
|
646
|
+
if (!pluginId || !serverReady) return;
|
|
647
|
+
const id = pluginId;
|
|
648
|
+
pluginId = null;
|
|
649
|
+
try {
|
|
650
|
+
await fetch(`${serverUrl("/api/plugin")}/${id}`, {
|
|
651
|
+
method: "DELETE",
|
|
652
|
+
keepalive: true,
|
|
653
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
654
|
+
});
|
|
655
|
+
log(`Deregistered from server (pluginId: ${id})`);
|
|
656
|
+
} catch (err) {
|
|
657
|
+
warn(`Deregistration failed:`, err);
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
// ─── Event Pushing ─────────────────────────────────────────────
|
|
662
|
+
|
|
663
|
+
async function pushEvent(event: string, data: unknown): Promise<void> {
|
|
664
|
+
if (!pluginId || !serverReady) return;
|
|
665
|
+
|
|
666
|
+
const enrichedData =
|
|
667
|
+
data != null && typeof data === "object"
|
|
668
|
+
? { ...(data as Record<string, unknown>), projectPath, timestamp: Date.now() }
|
|
669
|
+
: { data, projectPath, timestamp: Date.now() };
|
|
670
|
+
|
|
671
|
+
try {
|
|
672
|
+
const res = await fetch(serverUrl("/api/plugin/event"), {
|
|
673
|
+
method: "POST",
|
|
674
|
+
headers: { "Content-Type": "application/json" },
|
|
675
|
+
body: JSON.stringify({ pluginId, event, data: enrichedData }),
|
|
676
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
677
|
+
});
|
|
678
|
+
if (!res.ok) {
|
|
679
|
+
warn(`pushEvent(${event}) failed: HTTP ${res.status}`);
|
|
680
|
+
}
|
|
681
|
+
} catch (err) {
|
|
682
|
+
warn(`pushEvent(${event}) failed:`, err);
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// ─── Bead State Management ─────────────────────────────────────
|
|
687
|
+
|
|
688
|
+
async function refreshBeadState($: any): Promise<BeadRecord[]> {
|
|
689
|
+
try {
|
|
690
|
+
const output = await $`bd list --json`.text();
|
|
691
|
+
const trimmed = output.trim();
|
|
692
|
+
if (!trimmed) return [];
|
|
693
|
+
const parsed = JSON.parse(trimmed);
|
|
694
|
+
if (!Array.isArray(parsed)) {
|
|
695
|
+
warn(`bd list --json returned non-array:`, typeof parsed);
|
|
696
|
+
return [];
|
|
697
|
+
}
|
|
698
|
+
return parsed as BeadRecord[];
|
|
699
|
+
} catch (err) {
|
|
700
|
+
warn(`Failed to refresh bead state:`, err);
|
|
701
|
+
return [];
|
|
702
|
+
}
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
function diffBeadState(prev: BeadRecord[], next: BeadRecord[]): BeadDiff[] {
|
|
706
|
+
const diffs: BeadDiff[] = [];
|
|
707
|
+
const prevMap = new Map<string, BeadRecord>();
|
|
708
|
+
for (const bead of prev) prevMap.set(bead.id, bead);
|
|
709
|
+
const nextMap = new Map<string, BeadRecord>();
|
|
710
|
+
for (const bead of next) nextMap.set(bead.id, bead);
|
|
711
|
+
|
|
712
|
+
for (const [id, bead] of nextMap) {
|
|
713
|
+
const prevBead = prevMap.get(id);
|
|
714
|
+
if (!prevBead) {
|
|
715
|
+
diffs.push({ type: "discovered", bead });
|
|
716
|
+
if (bead.status === "blocked") {
|
|
717
|
+
diffs.push({ type: "error", bead, error: "Discovered bead already blocked" });
|
|
718
|
+
} else if (
|
|
719
|
+
bead.status === "closed" &&
|
|
720
|
+
bead.close_reason &&
|
|
721
|
+
/fail|reject|abandon|error|abort/i.test(bead.close_reason)
|
|
722
|
+
) {
|
|
723
|
+
diffs.push({
|
|
724
|
+
type: "error",
|
|
725
|
+
bead,
|
|
726
|
+
error: `Discovered bead closed with failure: ${bead.close_reason}`,
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
continue;
|
|
730
|
+
}
|
|
731
|
+
if (prevBead.status !== bead.status) {
|
|
732
|
+
if (bead.status === "blocked") {
|
|
733
|
+
diffs.push({
|
|
734
|
+
type: "error",
|
|
735
|
+
bead,
|
|
736
|
+
error: `Bead status changed to blocked (was: ${prevBead.status})`,
|
|
737
|
+
});
|
|
738
|
+
} else if (
|
|
739
|
+
bead.status === "closed" &&
|
|
740
|
+
bead.close_reason &&
|
|
741
|
+
/fail|reject|abandon|error|abort/i.test(bead.close_reason)
|
|
742
|
+
) {
|
|
743
|
+
diffs.push({
|
|
744
|
+
type: "error",
|
|
745
|
+
bead,
|
|
746
|
+
error: `Bead closed with failure: ${bead.close_reason}`,
|
|
747
|
+
});
|
|
748
|
+
} else {
|
|
749
|
+
diffs.push({ type: "changed", bead, prevStatus: prevBead.status });
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
for (const [id] of prevMap) {
|
|
755
|
+
if (!nextMap.has(id)) {
|
|
756
|
+
diffs.push({ type: "removed", beadId: id });
|
|
757
|
+
}
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
return diffs;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
async function refreshAndDiff($: any): Promise<BeadDiff[]> {
|
|
764
|
+
if (isRefreshing) {
|
|
765
|
+
log(`refreshAndDiff skipped (already in progress)`);
|
|
766
|
+
return [];
|
|
767
|
+
}
|
|
768
|
+
isRefreshing = true;
|
|
769
|
+
|
|
770
|
+
try {
|
|
771
|
+
const next = await refreshBeadState($);
|
|
772
|
+
const diffs = diffBeadState(lastBeadSnapshot, next);
|
|
773
|
+
lastBeadSnapshot = next;
|
|
774
|
+
if (diffs.length === 0) return diffs;
|
|
775
|
+
|
|
776
|
+
log(`Bead state changed: ${diffs.length} diff(s)`);
|
|
777
|
+
|
|
778
|
+
for (const diff of diffs) {
|
|
779
|
+
switch (diff.type) {
|
|
780
|
+
case "discovered":
|
|
781
|
+
await pushEvent("bead:discovered", { bead: diff.bead });
|
|
782
|
+
break;
|
|
783
|
+
case "changed":
|
|
784
|
+
await pushEvent("bead:changed", { bead: diff.bead, prevStatus: diff.prevStatus });
|
|
785
|
+
break;
|
|
786
|
+
case "removed":
|
|
787
|
+
await pushEvent("bead:removed", { beadId: diff.beadId });
|
|
788
|
+
break;
|
|
789
|
+
case "error":
|
|
790
|
+
await pushEvent("bead:error", {
|
|
791
|
+
beadId: diff.bead.id,
|
|
792
|
+
bead: diff.bead,
|
|
793
|
+
error: diff.error,
|
|
794
|
+
});
|
|
795
|
+
break;
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
await pushEvent("beads:refreshed", {
|
|
800
|
+
beadCount: next.length,
|
|
801
|
+
changed: diffs.length,
|
|
802
|
+
beadIds: next.map((b) => b.id),
|
|
803
|
+
});
|
|
804
|
+
|
|
805
|
+
return diffs;
|
|
806
|
+
} finally {
|
|
807
|
+
isRefreshing = false;
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
function scheduleRefresh($: any, delayMs: number = 500): void {
|
|
812
|
+
if (refreshTimer) clearTimeout(refreshTimer);
|
|
813
|
+
refreshTimer = setTimeout(async () => {
|
|
814
|
+
refreshTimer = null;
|
|
815
|
+
try {
|
|
816
|
+
const diffs = await refreshAndDiff($);
|
|
817
|
+
await processDiffs(diffs);
|
|
818
|
+
} catch (err) {
|
|
819
|
+
warn(`Scheduled refresh failed:`, err);
|
|
820
|
+
}
|
|
821
|
+
}, delayMs);
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
async function processDiffs(diffs: BeadDiff[]): Promise<void> {
|
|
825
|
+
for (const diff of diffs) {
|
|
826
|
+
if (diff.type === "changed") {
|
|
827
|
+
if (diff.bead.status === "in_progress" && diff.prevStatus !== "in_progress") {
|
|
828
|
+
const prevBeadId = currentBeadId;
|
|
829
|
+
currentBeadId = diff.bead.id;
|
|
830
|
+
log(`Bead claimed: ${diff.bead.id} (was: ${prevBeadId ?? "none"})`);
|
|
831
|
+
|
|
832
|
+
if (prevBeadId && prevBeadId !== diff.bead.id) {
|
|
833
|
+
const prevBead = lastBeadSnapshot.find(
|
|
834
|
+
(b) => b.id === prevBeadId && b.status === "in_progress",
|
|
835
|
+
);
|
|
836
|
+
if (prevBead) {
|
|
837
|
+
warn(`Previous bead ${prevBeadId} may have been abandoned`);
|
|
838
|
+
await pushEvent("bead:error", {
|
|
839
|
+
beadId: prevBead.id,
|
|
840
|
+
bead: prevBead,
|
|
841
|
+
error: "Bead abandoned — new bead claimed before this one was closed",
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
await pushEvent("bead:claimed", {
|
|
847
|
+
beadId: diff.bead.id,
|
|
848
|
+
bead: diff.bead,
|
|
849
|
+
stage: currentAgentName || "ready",
|
|
850
|
+
});
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
if (diff.bead.status === "closed" && diff.prevStatus !== "closed") {
|
|
854
|
+
if (currentBeadId === diff.bead.id) {
|
|
855
|
+
currentBeadId = null;
|
|
856
|
+
log(`Current bead closed: ${diff.bead.id}`);
|
|
857
|
+
}
|
|
858
|
+
await pushEvent("bead:done", { beadId: diff.bead.id, bead: diff.bead });
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
if (diff.type === "discovered" && diff.bead.status === "in_progress" && !currentBeadId) {
|
|
863
|
+
currentBeadId = diff.bead.id;
|
|
864
|
+
log(`Discovered in-progress bead, setting as current: ${diff.bead.id}`);
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
if (
|
|
868
|
+
diff.type === "error" &&
|
|
869
|
+
currentBeadId === diff.bead.id &&
|
|
870
|
+
(diff.bead.status === "blocked" || diff.bead.status === "closed")
|
|
871
|
+
) {
|
|
872
|
+
currentBeadId = null;
|
|
873
|
+
log(`Current bead entered error state: ${diff.bead.id} (${diff.bead.status})`);
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// ─── Agent Stage Helpers ───────────────────────────────────────
|
|
879
|
+
|
|
880
|
+
/**
|
|
881
|
+
* Check if an agent name corresponds to a known agent column.
|
|
882
|
+
* Returns the agent name as the column/stage ID directly.
|
|
883
|
+
*/
|
|
884
|
+
function agentNameToStage(agentName: string): string | null {
|
|
885
|
+
// Check if this agent is one we discovered
|
|
886
|
+
const known = discoveredAgents.find((a) => a.name === agentName);
|
|
887
|
+
if (known) return agentName;
|
|
888
|
+
// Also accept short names that match pipeline- prefix
|
|
889
|
+
const withPrefix = `pipeline-${agentName}`;
|
|
890
|
+
const prefixed = discoveredAgents.find((a) => a.name === withPrefix);
|
|
891
|
+
if (prefixed) return prefixed.name;
|
|
892
|
+
// Accept any agent name — the dashboard will handle unknown columns gracefully
|
|
893
|
+
return agentName;
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
function extractBeadId(text: string): string | null {
|
|
897
|
+
const match = text.match(/\[([a-zA-Z0-9][\w-]*)\]/);
|
|
898
|
+
return match ? match[1] : null;
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
// ─── Startup Sequence ──────────────────────────────────────────
|
|
902
|
+
|
|
903
|
+
async function startupSequence(
|
|
904
|
+
pPath: string,
|
|
905
|
+
projectName: string,
|
|
906
|
+
$: any,
|
|
907
|
+
port: number,
|
|
908
|
+
): Promise<string> {
|
|
909
|
+
serverPort = port;
|
|
910
|
+
|
|
911
|
+
// Discover agents (needed for column config and pipeline guidance decision)
|
|
912
|
+
discoveredAgents = await discoverAllAgents(pPath);
|
|
913
|
+
hasPipelineAgents = discoveredAgents.some((a) => a.name in PIPELINE_AGENT_ORDER);
|
|
914
|
+
log(
|
|
915
|
+
`Discovered ${discoveredAgents.length} agent(s)` +
|
|
916
|
+
(hasPipelineAgents ? " (pipeline agents present)" : " (no pipeline agents)"),
|
|
917
|
+
);
|
|
918
|
+
|
|
919
|
+
// Check if already running (via PID file)
|
|
920
|
+
const existing = await isServerRunning(true);
|
|
921
|
+
if (existing) {
|
|
922
|
+
serverPort = existing.port;
|
|
923
|
+
log(`Server already running (PID: ${existing.pid}, port: ${existing.port})`);
|
|
924
|
+
} else {
|
|
925
|
+
// Check health on requested port (server may be running without PID file)
|
|
926
|
+
let isHealthy = await checkServerHealth();
|
|
927
|
+
if (!isHealthy) {
|
|
928
|
+
isHealthy = await spawnServer(port);
|
|
929
|
+
}
|
|
930
|
+
if (!isHealthy) {
|
|
931
|
+
serverReady = false;
|
|
932
|
+
return `Dashboard server failed to start on port ${port}. Context injection still works.`;
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
log(`Dashboard server is reachable on port ${serverPort}`);
|
|
937
|
+
|
|
938
|
+
// Register
|
|
939
|
+
const id = await registerWithServer(pPath, projectName);
|
|
940
|
+
if (!id) {
|
|
941
|
+
serverReady = false;
|
|
942
|
+
return `Dashboard server is running but registration failed. Context injection still works.`;
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
pluginId = id;
|
|
946
|
+
serverReady = true;
|
|
947
|
+
|
|
948
|
+
// Generate and send column config
|
|
949
|
+
const columns = generateColumnConfig(discoveredAgents);
|
|
950
|
+
await pushEvent("columns:update", { columns });
|
|
951
|
+
log(`Sent column config: ${columns.length} columns`);
|
|
952
|
+
|
|
953
|
+
// Start heartbeat
|
|
954
|
+
startHeartbeat();
|
|
955
|
+
|
|
956
|
+
// Initial bead state fetch
|
|
957
|
+
const initialBeads = await refreshBeadState($);
|
|
958
|
+
lastBeadSnapshot = initialBeads;
|
|
959
|
+
|
|
960
|
+
if (initialBeads.length > 0) {
|
|
961
|
+
log(`Initial bead snapshot: ${initialBeads.length} bead(s)`);
|
|
962
|
+
for (const bead of initialBeads) {
|
|
963
|
+
await pushEvent("bead:discovered", { bead });
|
|
964
|
+
if (bead.status === "blocked") {
|
|
965
|
+
await pushEvent("bead:error", {
|
|
966
|
+
beadId: bead.id,
|
|
967
|
+
bead,
|
|
968
|
+
error: "Discovered bead already blocked",
|
|
969
|
+
});
|
|
970
|
+
} else if (
|
|
971
|
+
bead.status === "closed" &&
|
|
972
|
+
bead.close_reason &&
|
|
973
|
+
/fail|reject|abandon|error|abort/i.test(bead.close_reason)
|
|
974
|
+
) {
|
|
975
|
+
await pushEvent("bead:error", {
|
|
976
|
+
beadId: bead.id,
|
|
977
|
+
bead,
|
|
978
|
+
error: `Discovered bead closed with failure: ${bead.close_reason}`,
|
|
979
|
+
});
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
|
|
984
|
+
await pushEvent("beads:refreshed", {
|
|
985
|
+
beadCount: initialBeads.length,
|
|
986
|
+
changed: initialBeads.length,
|
|
987
|
+
beadIds: initialBeads.map((b) => b.id),
|
|
988
|
+
});
|
|
989
|
+
|
|
990
|
+
// Register process exit cleanup (once per lifetime)
|
|
991
|
+
if (!cleanupRegistered) {
|
|
992
|
+
cleanupRegistered = true;
|
|
993
|
+
process.on("beforeExit", async () => {
|
|
994
|
+
stopHeartbeat();
|
|
995
|
+
await deregisterFromServer();
|
|
996
|
+
});
|
|
997
|
+
const signalCleanup = () => {
|
|
998
|
+
stopHeartbeat();
|
|
999
|
+
deregisterFromServer().catch(() => {});
|
|
1000
|
+
};
|
|
1001
|
+
process.on("SIGINT", signalCleanup);
|
|
1002
|
+
process.on("SIGTERM", signalCleanup);
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
return `Dashboard running at http://localhost:${serverPort}`;
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
// ─── Stop Server ───────────────────────────────────────────────
|
|
1009
|
+
|
|
1010
|
+
async function stopServer(): Promise<string> {
|
|
1011
|
+
// Deregister first
|
|
1012
|
+
stopHeartbeat();
|
|
1013
|
+
await deregisterFromServer();
|
|
1014
|
+
serverReady = false;
|
|
1015
|
+
activated = false;
|
|
1016
|
+
|
|
1017
|
+
// Find and kill the server process via PID file
|
|
1018
|
+
const pidData = readPid();
|
|
1019
|
+
if (!pidData) {
|
|
1020
|
+
return "No dashboard server found (no PID file).";
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
try {
|
|
1024
|
+
process.kill(pidData.pid, "SIGTERM");
|
|
1025
|
+
removePid();
|
|
1026
|
+
return `Dashboard server stopped (PID: ${pidData.pid}).`;
|
|
1027
|
+
} catch (err: any) {
|
|
1028
|
+
if (err?.code === "ESRCH") {
|
|
1029
|
+
removePid();
|
|
1030
|
+
return `Dashboard server was not running (stale PID file cleaned up).`;
|
|
1031
|
+
}
|
|
1032
|
+
return `Failed to stop dashboard server: ${err?.message ?? err}`;
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
|
|
1036
|
+
// ─── Status Check ──────────────────────────────────────────────
|
|
1037
|
+
|
|
1038
|
+
async function getServerStatus(): Promise<string> {
|
|
1039
|
+
const pidData = await isServerRunning(true);
|
|
1040
|
+
if (!pidData) {
|
|
1041
|
+
return "Dashboard server is not running.";
|
|
1042
|
+
}
|
|
1043
|
+
|
|
1044
|
+
try {
|
|
1045
|
+
const res = await fetch(`http://localhost:${pidData.port}/api/health`, {
|
|
1046
|
+
signal: AbortSignal.timeout(3000),
|
|
1047
|
+
});
|
|
1048
|
+
if (res.ok) {
|
|
1049
|
+
const health = (await res.json()) as {
|
|
1050
|
+
uptime?: number;
|
|
1051
|
+
plugins?: number;
|
|
1052
|
+
sseClients?: number;
|
|
1053
|
+
};
|
|
1054
|
+
return (
|
|
1055
|
+
`Dashboard server is running.\n` +
|
|
1056
|
+
` URL: http://localhost:${pidData.port}\n` +
|
|
1057
|
+
` PID: ${pidData.pid}\n` +
|
|
1058
|
+
` Uptime: ${health.uptime ?? "?"}s\n` +
|
|
1059
|
+
` Connected plugins: ${health.plugins ?? "?"}\n` +
|
|
1060
|
+
` SSE clients: ${health.sseClients ?? "?"}\n` +
|
|
1061
|
+
` Started: ${pidData.startedAt}`
|
|
1062
|
+
);
|
|
1063
|
+
}
|
|
1064
|
+
return `Dashboard server process exists (PID: ${pidData.pid}) but health check failed.`;
|
|
1065
|
+
} catch {
|
|
1066
|
+
return `Dashboard server process exists (PID: ${pidData.pid}) but is not responding.`;
|
|
1067
|
+
}
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
// ─── Plugin Export ─────────────────────────────────────────────
|
|
1071
|
+
|
|
1072
|
+
export const DashboardPlugin: Plugin = async ({ client, directory, $ }) => {
|
|
1073
|
+
const projectName = directory.split("/").pop() || "unknown";
|
|
1074
|
+
log(`Plugin loaded for ${projectName} (dormant — waiting for /dashboard-start)`);
|
|
1075
|
+
log(`Directory: ${directory}`);
|
|
1076
|
+
|
|
1077
|
+
projectPath = directory;
|
|
1078
|
+
shellRef = $;
|
|
1079
|
+
|
|
1080
|
+
// Helper to show toast notifications (fire-and-forget)
|
|
1081
|
+
const toast = (
|
|
1082
|
+
message: string,
|
|
1083
|
+
variant: "info" | "success" | "warning" | "error" = "info",
|
|
1084
|
+
title?: string,
|
|
1085
|
+
) => {
|
|
1086
|
+
client.tui
|
|
1087
|
+
.showToast({
|
|
1088
|
+
body: { message, variant, ...(title ? { title } : {}), duration: 4000 },
|
|
1089
|
+
})
|
|
1090
|
+
.catch(() => {});
|
|
1091
|
+
};
|
|
1092
|
+
|
|
1093
|
+
// Check if setup has been run (look for commands in either location)
|
|
1094
|
+
const projectCommandsDir = join(directory, ".opencode", "commands");
|
|
1095
|
+
const globalCommandsDir = join(
|
|
1096
|
+
process.env.HOME ?? process.env.USERPROFILE ?? "",
|
|
1097
|
+
".config",
|
|
1098
|
+
"opencode",
|
|
1099
|
+
"commands",
|
|
1100
|
+
);
|
|
1101
|
+
const hasProjectCommands = await checkAgentsInstalled(projectCommandsDir, [
|
|
1102
|
+
"dashboard-start.md",
|
|
1103
|
+
]);
|
|
1104
|
+
const hasGlobalCommands = await checkAgentsInstalled(globalCommandsDir, [
|
|
1105
|
+
"dashboard-start.md",
|
|
1106
|
+
]);
|
|
1107
|
+
if (!hasProjectCommands && !hasGlobalCommands) {
|
|
1108
|
+
toast(
|
|
1109
|
+
"Run: npx opencode-dashboard setup",
|
|
1110
|
+
"warning",
|
|
1111
|
+
"Dashboard setup required",
|
|
1112
|
+
);
|
|
1113
|
+
log("Setup not detected — commands not installed in either location");
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
return {
|
|
1117
|
+
// ─── Custom Tools (LLM-callable) ──────────────────────────
|
|
1118
|
+
|
|
1119
|
+
tool: {
|
|
1120
|
+
dashboard_start: tool({
|
|
1121
|
+
description:
|
|
1122
|
+
"Start the OpenCode dashboard server. Opens a Kanban board at http://localhost:{port} " +
|
|
1123
|
+
"that shows real-time pipeline progress. Call this when the user wants to see the dashboard.",
|
|
1124
|
+
args: {
|
|
1125
|
+
port: tool.schema
|
|
1126
|
+
.number()
|
|
1127
|
+
.optional()
|
|
1128
|
+
.describe("Server port (default: 3333, env: DASHBOARD_PORT)"),
|
|
1129
|
+
},
|
|
1130
|
+
async execute(args) {
|
|
1131
|
+
const port =
|
|
1132
|
+
args.port ?? (Number(process.env.DASHBOARD_PORT) || DEFAULT_PORT);
|
|
1133
|
+
|
|
1134
|
+
// Check if already running
|
|
1135
|
+
const existing = await isServerRunning(true);
|
|
1136
|
+
if (existing) {
|
|
1137
|
+
serverPort = existing.port;
|
|
1138
|
+
if (!activated) {
|
|
1139
|
+
// Server is running but plugin isn't connected — connect now
|
|
1140
|
+
const result = await startupSequence(directory, projectName, $, existing.port);
|
|
1141
|
+
activated = true;
|
|
1142
|
+
toast(result, "success", "Dashboard");
|
|
1143
|
+
return result;
|
|
1144
|
+
}
|
|
1145
|
+
const msg = `Dashboard already running at http://localhost:${existing.port}`;
|
|
1146
|
+
toast(msg, "info", "Dashboard");
|
|
1147
|
+
return msg;
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
// Start fresh
|
|
1151
|
+
activating = true;
|
|
1152
|
+
try {
|
|
1153
|
+
const result = await startupSequence(directory, projectName, $, port);
|
|
1154
|
+
activated = true;
|
|
1155
|
+
const isSuccess = serverReady;
|
|
1156
|
+
toast(result, isSuccess ? "success" : "warning", "Dashboard");
|
|
1157
|
+
return result;
|
|
1158
|
+
} finally {
|
|
1159
|
+
activating = false;
|
|
1160
|
+
}
|
|
1161
|
+
},
|
|
1162
|
+
}),
|
|
1163
|
+
|
|
1164
|
+
dashboard_stop: tool({
|
|
1165
|
+
description:
|
|
1166
|
+
"Stop the running OpenCode dashboard server. " +
|
|
1167
|
+
"Call this when the user wants to shut down the dashboard.",
|
|
1168
|
+
args: {},
|
|
1169
|
+
async execute() {
|
|
1170
|
+
const result = await stopServer();
|
|
1171
|
+
toast(result, "info", "Dashboard");
|
|
1172
|
+
return result;
|
|
1173
|
+
},
|
|
1174
|
+
}),
|
|
1175
|
+
|
|
1176
|
+
dashboard_status: tool({
|
|
1177
|
+
description:
|
|
1178
|
+
"Check if the OpenCode dashboard server is running and show its status. " +
|
|
1179
|
+
"Returns URL, PID, uptime, and connection info.",
|
|
1180
|
+
args: {},
|
|
1181
|
+
async execute() {
|
|
1182
|
+
return getServerStatus();
|
|
1183
|
+
},
|
|
1184
|
+
}),
|
|
1185
|
+
|
|
1186
|
+
dashboard_open: tool({
|
|
1187
|
+
description:
|
|
1188
|
+
"Open the OpenCode dashboard in the default browser. " +
|
|
1189
|
+
"Starts the server first if it's not already running.",
|
|
1190
|
+
args: {
|
|
1191
|
+
port: tool.schema
|
|
1192
|
+
.number()
|
|
1193
|
+
.optional()
|
|
1194
|
+
.describe("Server port (default: 3333)"),
|
|
1195
|
+
},
|
|
1196
|
+
async execute(args) {
|
|
1197
|
+
const port =
|
|
1198
|
+
args.port ?? (Number(process.env.DASHBOARD_PORT) || DEFAULT_PORT);
|
|
1199
|
+
|
|
1200
|
+
// Ensure server is running
|
|
1201
|
+
let running = await isServerRunning(true);
|
|
1202
|
+
if (!running) {
|
|
1203
|
+
activating = true;
|
|
1204
|
+
try {
|
|
1205
|
+
await startupSequence(directory, projectName, $, port);
|
|
1206
|
+
activated = true;
|
|
1207
|
+
} finally {
|
|
1208
|
+
activating = false;
|
|
1209
|
+
}
|
|
1210
|
+
running = await isServerRunning(false);
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
const url = `http://localhost:${running?.port ?? port}`;
|
|
1214
|
+
|
|
1215
|
+
// Open in default browser
|
|
1216
|
+
try {
|
|
1217
|
+
const openCmd =
|
|
1218
|
+
process.platform === "darwin"
|
|
1219
|
+
? "open"
|
|
1220
|
+
: process.platform === "win32"
|
|
1221
|
+
? "start"
|
|
1222
|
+
: "xdg-open";
|
|
1223
|
+
Bun.spawn([openCmd, url], {
|
|
1224
|
+
detached: true,
|
|
1225
|
+
stdio: ["ignore", "ignore", "ignore"],
|
|
1226
|
+
}).unref();
|
|
1227
|
+
toast(`Opened ${url} in browser`, "success", "Dashboard");
|
|
1228
|
+
return `Opened dashboard at ${url} in your default browser.`;
|
|
1229
|
+
} catch (err: any) {
|
|
1230
|
+
return `Dashboard running at ${url} but failed to open browser: ${err?.message ?? err}`;
|
|
1231
|
+
}
|
|
1232
|
+
},
|
|
1233
|
+
}),
|
|
1234
|
+
},
|
|
1235
|
+
|
|
1236
|
+
// ─── Context Injection on First Message ─────────────────────
|
|
1237
|
+
|
|
1238
|
+
"chat.message": async (input: any, _output: any) => {
|
|
1239
|
+
const sessionID = input.sessionID;
|
|
1240
|
+
const model = input.model;
|
|
1241
|
+
const agent = input.agent;
|
|
1242
|
+
|
|
1243
|
+
// Track current agent for bead:claimed stage tracking
|
|
1244
|
+
if (agent) {
|
|
1245
|
+
currentAgentName = agent;
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
if (injectedSessions.has(sessionID)) return;
|
|
1249
|
+
|
|
1250
|
+
if (await isChildSession(client, sessionID)) {
|
|
1251
|
+
log(`Skipping child session ${sessionID}`);
|
|
1252
|
+
injectedSessions.add(sessionID);
|
|
1253
|
+
return;
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
if (await hasExistingContext(client, sessionID)) {
|
|
1257
|
+
log(`Context already exists in session ${sessionID}, marking as injected`);
|
|
1258
|
+
injectedSessions.add(sessionID);
|
|
1259
|
+
return;
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
await injectContext(client, sessionID, model, agent, $);
|
|
1263
|
+
},
|
|
1264
|
+
|
|
1265
|
+
// ─── Pipeline Stage Detection ───────────────────────────────
|
|
1266
|
+
|
|
1267
|
+
"tool.execute.before": async (input: any, output: any) => {
|
|
1268
|
+
if (!activated) return;
|
|
1269
|
+
|
|
1270
|
+
try {
|
|
1271
|
+
const toolName = input.tool?.toLowerCase() ?? "";
|
|
1272
|
+
const isTaskTool =
|
|
1273
|
+
toolName === "task" || toolName === "subtask" || toolName === "developer";
|
|
1274
|
+
if (!isTaskTool) return;
|
|
1275
|
+
|
|
1276
|
+
const args = output.args;
|
|
1277
|
+
if (!args) return;
|
|
1278
|
+
|
|
1279
|
+
const agentName: string | undefined =
|
|
1280
|
+
args.agent ?? args.subagent_type ?? args.agentName;
|
|
1281
|
+
if (!agentName || typeof agentName !== "string") return;
|
|
1282
|
+
|
|
1283
|
+
const stage = agentNameToStage(agentName);
|
|
1284
|
+
if (!stage) {
|
|
1285
|
+
log(`Task tool invoked with unknown agent type: ${agentName}`);
|
|
1286
|
+
return;
|
|
1287
|
+
}
|
|
1288
|
+
|
|
1289
|
+
log(`Task tool detected: agent=${agentName} → stage=${stage}`);
|
|
1290
|
+
pendingAgentType = stage;
|
|
1291
|
+
|
|
1292
|
+
const description: string = args.description ?? args.prompt ?? args.message ?? "";
|
|
1293
|
+
const beadIdFromDesc = extractBeadId(description);
|
|
1294
|
+
const beadId = beadIdFromDesc ?? currentBeadId;
|
|
1295
|
+
|
|
1296
|
+
if (serverReady && beadId) {
|
|
1297
|
+
await pushEvent("bead:stage", {
|
|
1298
|
+
beadId,
|
|
1299
|
+
stage,
|
|
1300
|
+
agentSessionId: input.sessionID,
|
|
1301
|
+
});
|
|
1302
|
+
log(`Posted bead:stage event: bead=${beadId} stage=${stage}`);
|
|
1303
|
+
}
|
|
1304
|
+
} catch (err) {
|
|
1305
|
+
warn(`Error in tool.execute.before hook:`, err);
|
|
1306
|
+
}
|
|
1307
|
+
},
|
|
1308
|
+
|
|
1309
|
+
// ─── Refresh Bead State After Tool Execution ────────────────
|
|
1310
|
+
|
|
1311
|
+
"tool.execute.after": async (input: any, _output: any) => {
|
|
1312
|
+
if (!activated) return;
|
|
1313
|
+
|
|
1314
|
+
try {
|
|
1315
|
+
if (!serverReady) return;
|
|
1316
|
+
|
|
1317
|
+
scheduleRefresh($);
|
|
1318
|
+
|
|
1319
|
+
if (input.tool === "bash" || input.tool === "shell") {
|
|
1320
|
+
const args = input.args;
|
|
1321
|
+
const command: string =
|
|
1322
|
+
typeof args === "string" ? args : args?.command ?? args?.cmd ?? "";
|
|
1323
|
+
if (command.includes("bd update") || command.includes("bd close")) {
|
|
1324
|
+
log(`Detected bd command in bash tool: ${command.substring(0, 100)}`);
|
|
1325
|
+
if (refreshTimer) {
|
|
1326
|
+
clearTimeout(refreshTimer);
|
|
1327
|
+
refreshTimer = null;
|
|
1328
|
+
}
|
|
1329
|
+
const diffs = await refreshAndDiff($);
|
|
1330
|
+
await processDiffs(diffs);
|
|
1331
|
+
return;
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
} catch (err) {
|
|
1335
|
+
warn(`Error in tool.execute.after hook:`, err);
|
|
1336
|
+
}
|
|
1337
|
+
},
|
|
1338
|
+
|
|
1339
|
+
// ─── Session Lifecycle Events ───────────────────────────────
|
|
1340
|
+
|
|
1341
|
+
event: async ({ event }: any) => {
|
|
1342
|
+
// session.compacted: re-inject context (works even when dormant)
|
|
1343
|
+
if (event.type === "session.compacted") {
|
|
1344
|
+
const props = event.properties as { sessionID?: string };
|
|
1345
|
+
const sessionID = props?.sessionID;
|
|
1346
|
+
if (typeof sessionID !== "string" || !sessionID) {
|
|
1347
|
+
warn(`session.compacted event missing sessionID`);
|
|
1348
|
+
return;
|
|
1349
|
+
}
|
|
1350
|
+
log(`Session ${sessionID} compacted, re-injecting context`);
|
|
1351
|
+
injectedSessions.delete(sessionID);
|
|
1352
|
+
|
|
1353
|
+
if (await isChildSession(client, sessionID)) {
|
|
1354
|
+
log(`Skipping re-injection for child session ${sessionID}`);
|
|
1355
|
+
return;
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
const ctx = await getSessionContext(client, sessionID);
|
|
1359
|
+
await injectContext(client, sessionID, ctx.model, ctx.agent, $);
|
|
1360
|
+
return;
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
if (!activated) return;
|
|
1364
|
+
|
|
1365
|
+
// session.created: map child sessions to agent types
|
|
1366
|
+
if (event.type === "session.created") {
|
|
1367
|
+
try {
|
|
1368
|
+
const props = event.properties as {
|
|
1369
|
+
info?: { id?: string; parentID?: string; title?: string };
|
|
1370
|
+
};
|
|
1371
|
+
const session = props?.info;
|
|
1372
|
+
if (!session?.id || !session.parentID) return;
|
|
1373
|
+
|
|
1374
|
+
log(`Child session created: ${session.id} (parent: ${session.parentID})`);
|
|
1375
|
+
|
|
1376
|
+
let agentType: string | null = null;
|
|
1377
|
+
|
|
1378
|
+
if (pendingAgentType) {
|
|
1379
|
+
agentType = pendingAgentType;
|
|
1380
|
+
pendingAgentType = null;
|
|
1381
|
+
log(`Mapped child session ${session.id} → ${agentType} (from pending Task invocation)`);
|
|
1382
|
+
} else if (session.title) {
|
|
1383
|
+
const titleLower = session.title.toLowerCase();
|
|
1384
|
+
// Match against all discovered agent names
|
|
1385
|
+
for (const agent of discoveredAgents) {
|
|
1386
|
+
if (titleLower.includes(agent.name)) {
|
|
1387
|
+
agentType = agent.name;
|
|
1388
|
+
log(`Mapped child session ${session.id} → ${agentType} (from title)`);
|
|
1389
|
+
break;
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
if (agentType) {
|
|
1395
|
+
sessionToAgent.set(session.id, agentType);
|
|
1396
|
+
if (serverReady) {
|
|
1397
|
+
await pushEvent("agent:active", {
|
|
1398
|
+
agent: agentType,
|
|
1399
|
+
sessionId: session.id,
|
|
1400
|
+
parentSessionId: session.parentID,
|
|
1401
|
+
beadId: currentBeadId,
|
|
1402
|
+
});
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
} catch (err) {
|
|
1406
|
+
warn(`Error in session.created handler:`, err);
|
|
1407
|
+
}
|
|
1408
|
+
return;
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
// session.idle: agent finished work
|
|
1412
|
+
if (event.type === "session.idle") {
|
|
1413
|
+
try {
|
|
1414
|
+
const props = event.properties as { sessionID?: string };
|
|
1415
|
+
const sessionID = props?.sessionID;
|
|
1416
|
+
if (typeof sessionID !== "string" || !sessionID) return;
|
|
1417
|
+
|
|
1418
|
+
const agentType = sessionToAgent.get(sessionID);
|
|
1419
|
+
if (agentType) {
|
|
1420
|
+
log(`Agent idle: ${agentType} (session: ${sessionID})`);
|
|
1421
|
+
if (serverReady) {
|
|
1422
|
+
await pushEvent("agent:idle", {
|
|
1423
|
+
agent: agentType,
|
|
1424
|
+
sessionId: sessionID,
|
|
1425
|
+
beadId: currentBeadId,
|
|
1426
|
+
});
|
|
1427
|
+
}
|
|
1428
|
+
sessionToAgent.delete(sessionID);
|
|
1429
|
+
}
|
|
1430
|
+
|
|
1431
|
+
if (serverReady) {
|
|
1432
|
+
scheduleRefresh($);
|
|
1433
|
+
}
|
|
1434
|
+
} catch (err) {
|
|
1435
|
+
warn(`Error in session.idle handler:`, err);
|
|
1436
|
+
}
|
|
1437
|
+
return;
|
|
1438
|
+
}
|
|
1439
|
+
},
|
|
1440
|
+
};
|
|
1441
|
+
};
|