taskplane 0.28.3 → 0.28.5

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.
Files changed (71) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +215 -215
  3. package/bin/gitignore-patterns.mjs +79 -79
  4. package/bin/rpc-wrapper.mjs +1086 -1086
  5. package/bin/taskplane.mjs +3254 -3254
  6. package/dashboard/public/app.js +2573 -2573
  7. package/dashboard/public/index.html +139 -139
  8. package/dashboard/public/style.css +1882 -1882
  9. package/dashboard/public/taskplane-word-color.svg +18 -18
  10. package/dashboard/public/taskplane-word-white.svg +18 -18
  11. package/dashboard/server.cjs +1666 -1666
  12. package/extensions/reviewer-extension.ts +119 -119
  13. package/extensions/task-orchestrator.ts +28 -28
  14. package/extensions/taskplane/abort.ts +502 -502
  15. package/extensions/taskplane/agent-bridge-extension.ts +765 -765
  16. package/extensions/taskplane/agent-host.ts +833 -745
  17. package/extensions/taskplane/cleanup.ts +747 -747
  18. package/extensions/taskplane/config-loader.ts +1328 -1322
  19. package/extensions/taskplane/config-schema.ts +692 -682
  20. package/extensions/taskplane/config.ts +73 -73
  21. package/extensions/taskplane/context-window.ts +66 -66
  22. package/extensions/taskplane/diagnostic-reports.ts +463 -463
  23. package/extensions/taskplane/diagnostics.ts +385 -385
  24. package/extensions/taskplane/engine-worker-entry.mjs +34 -34
  25. package/extensions/taskplane/engine-worker.ts +381 -381
  26. package/extensions/taskplane/engine.ts +4539 -4527
  27. package/extensions/taskplane/execution.ts +2733 -2708
  28. package/extensions/taskplane/extension.ts +30 -9
  29. package/extensions/taskplane/formatting.ts +773 -773
  30. package/extensions/taskplane/git.ts +90 -90
  31. package/extensions/taskplane/index.ts +28 -28
  32. package/extensions/taskplane/lane-runner.ts +1383 -1360
  33. package/extensions/taskplane/mailbox.ts +689 -689
  34. package/extensions/taskplane/merge.ts +3135 -3135
  35. package/extensions/taskplane/messages.ts +985 -985
  36. package/extensions/taskplane/migrations.ts +278 -278
  37. package/extensions/taskplane/naming.ts +117 -117
  38. package/extensions/taskplane/path-resolver.ts +237 -237
  39. package/extensions/taskplane/persistence.ts +2087 -2087
  40. package/extensions/taskplane/process-registry.ts +416 -416
  41. package/extensions/taskplane/quality-gate.ts +1033 -1033
  42. package/extensions/taskplane/resume.ts +2879 -2878
  43. package/extensions/taskplane/sessions.ts +57 -57
  44. package/extensions/taskplane/settings-loader.ts +136 -136
  45. package/extensions/taskplane/settings-tui.ts +1867 -1865
  46. package/extensions/taskplane/sidecar-telemetry.ts +252 -252
  47. package/extensions/taskplane/supervisor-primer.md +1694 -1694
  48. package/extensions/taskplane/supervisor.ts +4341 -4341
  49. package/extensions/taskplane/task-executor-core.ts +550 -550
  50. package/extensions/taskplane/tmux-compat.ts +37 -37
  51. package/extensions/taskplane/types.ts +4297 -4278
  52. package/extensions/taskplane/verification.ts +542 -542
  53. package/extensions/taskplane/waves.ts +1548 -1548
  54. package/extensions/taskplane/workspace.ts +705 -705
  55. package/extensions/taskplane/worktree.ts +2604 -2505
  56. package/package.json +57 -57
  57. package/skills/create-taskplane-task/SKILL.md +465 -465
  58. package/skills/create-taskplane-task/references/prompt-template.md +285 -285
  59. package/templates/agents/local/supervisor.md +33 -33
  60. package/templates/agents/local/task-merger.md +27 -27
  61. package/templates/agents/local/task-reviewer.md +30 -30
  62. package/templates/agents/local/task-worker.md +34 -34
  63. package/templates/agents/supervisor-routing.md +92 -92
  64. package/templates/agents/supervisor.md +168 -168
  65. package/templates/agents/task-merger.md +214 -214
  66. package/templates/agents/task-reviewer.md +192 -192
  67. package/templates/agents/task-worker.md +429 -429
  68. package/templates/tasks/EXAMPLE-001-hello-world/PROMPT.md +98 -98
  69. package/templates/tasks/EXAMPLE-001-hello-world/STATUS.md +73 -73
  70. package/templates/tasks/EXAMPLE-002-parallel-smoke/PROMPT.md +97 -97
  71. package/templates/tasks/EXAMPLE-002-parallel-smoke/STATUS.md +73 -73
@@ -1,1666 +1,1666 @@
1
- #!/usr/bin/env node
2
- /**
3
- * Orchestrator Web Dashboard — Local HTTP server with SSE live updates.
4
- *
5
- * Reads .pi/batch-state.json + STATUS.md files and streams state to the
6
- * browser via Server-Sent Events. Zero external dependencies.
7
- *
8
- * Usage:
9
- * node dashboard/server.cjs [--port 8099] [--root /path/to/project]
10
- */
11
-
12
- const http = require("http");
13
- const fs = require("fs");
14
- const path = require("path");
15
- const { exec } = require("child_process");
16
- // url module not needed — we parse with new URL() below
17
-
18
- // ─── Configuration ──────────────────────────────────────────────────────────
19
-
20
- const PUBLIC_DIR = path.join(__dirname, "public");
21
- const DEFAULT_PORT = 8100;
22
- const MAX_PORT_ATTEMPTS = 20;
23
- const POLL_INTERVAL = 2000; // ms between state checks
24
-
25
- // REPO_ROOT is resolved after parseArgs() — see initialization below.
26
- // In workspace mode, REPO_ROOT is the workspace root (passed via --root).
27
- // All dashboard state paths (batch-state, lane-state, conversation logs,
28
- // batch-history) live at <REPO_ROOT>/.pi/ — this is runtime/sidecar state
29
- // which does NOT follow the taskplane-pointer.json resolution chain.
30
- // The pointer directs config/agent lookups to a config repo, but the
31
- // dashboard only reads state files, so no pointer resolution is needed here.
32
- let REPO_ROOT;
33
- let BATCH_STATE_PATH;
34
- let BATCH_HISTORY_PATH;
35
-
36
- // ─── CLI Args ───────────────────────────────────────────────────────────────
37
-
38
- function parseArgs() {
39
- const args = process.argv.slice(2);
40
- const opts = { port: DEFAULT_PORT, open: true, root: "" };
41
- for (let i = 0; i < args.length; i++) {
42
- if (args[i] === "--port" && args[i + 1]) {
43
- opts.port = parseInt(args[i + 1]) || DEFAULT_PORT;
44
- i++;
45
- } else if (args[i] === "--root" && args[i + 1]) {
46
- opts.root = args[i + 1];
47
- i++;
48
- } else if (args[i] === "--no-open") {
49
- opts.open = false;
50
- } else if (args[i] === "--help" || args[i] === "-h") {
51
- console.log(`
52
- Orchestrator Web Dashboard
53
-
54
- Usage:
55
- node dashboard/server.cjs [options]
56
-
57
- Options:
58
- --port <number> Port to listen on (default: ${DEFAULT_PORT})
59
- --root <path> Project root directory (default: current directory)
60
- --no-open Don't auto-open browser
61
- -h, --help Show this help
62
- `);
63
- process.exit(0);
64
- }
65
- }
66
- return opts;
67
- }
68
-
69
- // ─── Data Loading (ported from orch-dashboard.cjs) ──────────────────────────
70
-
71
- function normalizeBatchStateIngress(state) {
72
- if (!state || typeof state !== "object" || !Array.isArray(state.lanes)) {
73
- return state;
74
- }
75
-
76
- for (const lane of state.lanes) {
77
- if (!lane || typeof lane !== "object") continue;
78
- // Legacy compatibility: older persisted states stored lane session IDs under
79
- // `tmuxSessionName`. Normalize to `laneSessionId` at ingress and drop legacy key.
80
- const laneSessionId = typeof lane.laneSessionId === "string"
81
- ? lane.laneSessionId
82
- : (typeof lane.tmuxSessionName === "string" ? lane.tmuxSessionName : undefined);
83
- if (laneSessionId) {
84
- lane.laneSessionId = laneSessionId;
85
- }
86
- if ("tmuxSessionName" in lane) {
87
- delete lane.tmuxSessionName;
88
- }
89
- }
90
-
91
- return state;
92
- }
93
-
94
- function loadBatchState() {
95
- try {
96
- const raw = fs.readFileSync(BATCH_STATE_PATH, "utf-8");
97
- return normalizeBatchStateIngress(JSON.parse(raw));
98
- } catch {
99
- return null;
100
- }
101
- }
102
-
103
- function resolveTaskFolder(task, state) {
104
- if (!task || !task.taskFolder) return null;
105
- const laneNum = task.laneNumber;
106
- const lane = (state?.lanes || []).find((l) => l.laneNumber === laneNum);
107
- if (!lane || !lane.worktreePath) return task.taskFolder;
108
-
109
- // In workspace mode, the worktree is inside a specific repo, not the workspace root.
110
- // The task folder path needs to be made relative to the repo root (parent of the worktree),
111
- // not the workspace root. Detect this by finding the repo root from the worktree path.
112
- const taskFolderAbs = path.resolve(task.taskFolder);
113
- const worktreeAbs = path.resolve(lane.worktreePath);
114
-
115
- // Try to find the repo root: walk up from the worktree path looking for which
116
- // ancestor is a prefix of the task folder. The worktree is at <repoRoot>/.worktrees/<name>
117
- // or a sibling, so the repo root is typically 2 levels up from a subdirectory worktree.
118
- // Heuristic: find the longest common ancestor between taskFolder and worktree's repo root.
119
- const repoRootAbs = path.resolve(REPO_ROOT);
120
-
121
- // First try: relative to workspace root (works in repo mode where workspace = repo)
122
- let rel = path.relative(repoRootAbs, taskFolderAbs);
123
- if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) return task.taskFolder;
124
-
125
- // Check if joining with worktree produces a valid path
126
- const candidate = path.join(worktreeAbs, rel);
127
- try {
128
- if (fs.existsSync(candidate)) return candidate;
129
- } catch { /* fall through */ }
130
-
131
- // Second try: the worktree is inside a repo subdirectory of the workspace root.
132
- // Strip the repo prefix from the task folder path to get the repo-relative path.
133
- // e.g., taskFolder = "workspace/platform-docs/task-mgmt/DOC-001/"
134
- // worktree = "workspace/platform-docs/.worktrees/wt-1/"
135
- // repo-relative = "task-mgmt/DOC-001/"
136
- // Find the repo by checking which workspace repo path is a prefix of the task folder.
137
- const repoRoots = [];
138
- try {
139
- const stateMode = state.mode;
140
- if (stateMode === "workspace" && state.repos) {
141
- for (const r of state.repos) repoRoots.push(path.resolve(r.path));
142
- }
143
- } catch { /* no repo info in state */ }
144
-
145
- // Also try inferring repo root from worktree path pattern:
146
- // .worktrees/<name> → parent is repo root; sibling worktrees → shared parent
147
- const worktreeParent = path.dirname(worktreeAbs);
148
- const worktreeGrandparent = path.dirname(worktreeParent);
149
- for (const possibleRepoRoot of [worktreeGrandparent, ...repoRoots]) {
150
- const repoRel = path.relative(possibleRepoRoot, taskFolderAbs);
151
- if (repoRel && !repoRel.startsWith("..") && !path.isAbsolute(repoRel)) {
152
- const repoCandidate = path.join(worktreeAbs, repoRel);
153
- try {
154
- if (fs.existsSync(repoCandidate)) return repoCandidate;
155
- } catch { continue; }
156
- }
157
- }
158
-
159
- // Fallback: return original task folder (might work if not in worktree)
160
- return task.taskFolder;
161
- }
162
-
163
- function parseStatusMd(taskFolder) {
164
- const candidates = [taskFolder];
165
- const taskId = path.basename(taskFolder);
166
- const archiveBase = taskFolder.replace(/[/\\]tasks[/\\][^/\\]+$/, "/tasks/archive/" + taskId);
167
- if (archiveBase !== taskFolder) candidates.push(archiveBase);
168
-
169
- for (const folder of candidates) {
170
- const statusPath = path.join(folder, "STATUS.md");
171
- try {
172
- const content = fs.readFileSync(statusPath, "utf-8");
173
- const stepMatch = content.match(/\*\*Current Step:\*\*\s*(.+)/);
174
- const statusMatch = content.match(/\*\*Status:\*\*\s*(.+)/);
175
- const iterMatch = content.match(/\*\*Iteration:\*\*\s*(\d+)/);
176
- const reviewMatch = content.match(/\*\*Review Counter:\*\*\s*(\d+)/);
177
- const checked = (content.match(/- \[x\]/gi) || []).length;
178
- const unchecked = (content.match(/- \[ \]/g) || []).length;
179
- const total = checked + unchecked;
180
- return {
181
- currentStep: stepMatch ? stepMatch[1].trim() : "Unknown",
182
- status: statusMatch ? statusMatch[1].trim() : "Unknown",
183
- iteration: iterMatch ? parseInt(iterMatch[1]) : 0,
184
- reviews: reviewMatch ? parseInt(reviewMatch[1]) : 0,
185
- checked,
186
- total,
187
- progress: total > 0 ? Math.round((checked / total) * 100) : 0,
188
- };
189
- } catch {
190
- continue;
191
- }
192
- }
193
- return null;
194
- }
195
-
196
- function getActiveSessions() {
197
- // Runtime V2: return active merger session names from the runtime registry
198
- // so the dashboard merge pane can display live telemetry for running agents.
199
- // Terminal statuses indicate the agent is no longer alive.
200
- const TERMINAL_STATUSES = new Set(["exited", "killed", "crashed", "timed_out"]);
201
- try {
202
- const state = loadBatchState();
203
- if (!state || !state.batchId) return [];
204
- const registry = loadRuntimeRegistry(state.batchId);
205
- if (!registry || !registry.agents) return [];
206
- return Object.values(registry.agents)
207
- .filter(a => a.role === "merger" && !TERMINAL_STATUSES.has(a.status))
208
- .map(a => a.agentId);
209
- } catch {
210
- return [];
211
- }
212
- }
213
-
214
- function checkDoneFile(taskFolder) {
215
- const candidates = [taskFolder];
216
- const taskId = path.basename(taskFolder);
217
- const archiveBase = taskFolder.replace(/[/\\]tasks[/\\][^/\\]+$/, "/tasks/archive/" + taskId);
218
- if (archiveBase !== taskFolder) candidates.push(archiveBase);
219
- for (const folder of candidates) {
220
- if (fs.existsSync(path.join(folder, ".DONE"))) return true;
221
- }
222
- return false;
223
- }
224
-
225
- /** Read lane state sidecar JSON files written by the task-runner. */
226
- function loadLaneStates() {
227
- const piDir = path.join(REPO_ROOT, ".pi");
228
- const states = {};
229
- try {
230
- const files = fs.readdirSync(piDir).filter(f => f.startsWith("lane-state-") && f.endsWith(".json"));
231
- for (const file of files) {
232
- try {
233
- const raw = fs.readFileSync(path.join(piDir, file), "utf-8").trim();
234
- if (!raw) continue;
235
- const data = JSON.parse(raw);
236
- if (data.prefix) states[data.prefix] = data;
237
- } catch { continue; }
238
- }
239
- } catch { /* .pi dir may not exist */ }
240
- return states;
241
- }
242
-
243
- // ─── Telemetry JSONL Tailing ────────────────────────────────────────────────
244
-
245
- /**
246
- * Module-level tail state for incremental JSONL reading.
247
- * Persists across poll ticks within this server process.
248
- * Key: absolute file path → { offset, partial }
249
- */
250
- const telemetryTailStates = new Map();
251
-
252
- /**
253
- * Module-level accumulated telemetry per session prefix.
254
- * Persists across poll ticks so incremental tail reads accumulate correctly.
255
- * Key: session prefix → { inputTokens, outputTokens, ... }
256
- */
257
- const telemetryAccumulators = new Map();
258
-
259
- /**
260
- * Tracks which files are currently contributing to each prefix.
261
- * Key: session prefix → Set of absolute file paths
262
- * Used to detect file rotation: when files change, accumulator is reset.
263
- */
264
- const telemetryPrefixFiles = new Map();
265
-
266
- /**
267
- * Parse a telemetry JSONL filename to extract lane number and role.
268
- * Pattern: {opId}-{batchId}-{repoId}[-{taskId}][-lane-{N}]-{role}.jsonl
269
- * Roles: worker, reviewer, merger
270
- * Returns { laneNumber: number|null, role: string, mergeNumber: number|null } or null if unparseable.
271
- */
272
- function parseTelemetryFilename(filename) {
273
- // Remove .jsonl extension
274
- const base = filename.replace(/\.jsonl$/, "");
275
- // Role is always the last segment
276
- const lastDash = base.lastIndexOf("-");
277
- if (lastDash < 0) return null;
278
- const role = base.slice(lastDash + 1);
279
- if (role !== "worker" && role !== "reviewer" && role !== "merger") return null;
280
-
281
- // Extract lane number from -lane-{N}- pattern
282
- const laneMatch = base.match(/-lane-(\d+)-/);
283
- const laneNumber = laneMatch ? parseInt(laneMatch[1], 10) : null;
284
-
285
- // Extract merge number from -merge-{N}- pattern (merge agents)
286
- const mergeMatch = base.match(/-merge-(\d+)-/);
287
- const mergeNumber = mergeMatch ? parseInt(mergeMatch[1], 10) : null;
288
-
289
- return { laneNumber, role, mergeNumber };
290
- }
291
-
292
- /**
293
- * Incrementally read new bytes from a JSONL file, parse events, and return them.
294
- * Handles: file not yet created, empty reads, partial trailing lines, malformed JSON.
295
- * @param {string} filePath - Absolute path to the JSONL file
296
- * @returns {object[]} Array of parsed event objects from new data
297
- */
298
- function tailJsonlFile(filePath) {
299
- // Get or create tail state for this file
300
- let tailState = telemetryTailStates.get(filePath);
301
- if (!tailState) {
302
- tailState = { offset: 0, partial: "" };
303
- telemetryTailStates.set(filePath, tailState);
304
- }
305
-
306
- // Check file size
307
- let fileSize;
308
- try {
309
- fileSize = fs.statSync(filePath).size;
310
- } catch {
311
- return []; // File doesn't exist yet
312
- }
313
-
314
- // Handle file truncation/recreation (offset beyond current size)
315
- if (fileSize < tailState.offset) {
316
- tailState.offset = 0;
317
- tailState.partial = "";
318
- tailState.wasReset = true; // Signal to caller that accumulator should be reset
319
- }
320
-
321
- if (fileSize <= tailState.offset) {
322
- return []; // No new data
323
- }
324
-
325
- // Cap read size per tick to avoid ERR_STRING_TOO_LONG on large files.
326
- // If there's more data remaining, the next SSE tick will pick up the rest.
327
- const MAX_TAIL_BYTES = 10 * 1024 * 1024; // 10 MB per tick
328
-
329
- // Skip-to-tail on fresh dashboard start with large files.
330
- // The partial-line handling below already discards the first partial line.
331
- if (tailState.offset === 0 && fileSize > MAX_TAIL_BYTES) {
332
- tailState.offset = fileSize - MAX_TAIL_BYTES;
333
- }
334
-
335
- // Read new bytes from offset, capped to MAX_TAIL_BYTES
336
- const bytesToRead = Math.min(fileSize - tailState.offset, MAX_TAIL_BYTES);
337
- const buf = Buffer.alloc(bytesToRead);
338
- let fd;
339
- try {
340
- fd = fs.openSync(filePath, "r");
341
- } catch {
342
- return []; // File became inaccessible
343
- }
344
- try {
345
- fs.readSync(fd, buf, 0, bytesToRead, tailState.offset);
346
- } catch {
347
- fs.closeSync(fd);
348
- return []; // Read error — try again next tick
349
- }
350
- fs.closeSync(fd);
351
- tailState.offset += bytesToRead;
352
-
353
- // Split into lines, preserving partial trailing line
354
- const chunk = tailState.partial + buf.toString("utf-8");
355
- const lines = chunk.split("\n");
356
- tailState.partial = lines.pop() || "";
357
-
358
- const events = [];
359
- for (const line of lines) {
360
- const trimmed = line.trim();
361
- if (!trimmed) continue;
362
- try {
363
- const event = JSON.parse(trimmed);
364
- if (event && event.type) events.push(event);
365
- } catch {
366
- // Malformed JSON — skip (concurrent write race, truncated line)
367
- }
368
- }
369
- return events;
370
- }
371
-
372
- /**
373
- * Load and accumulate telemetry from .pi/telemetry/*.jsonl files.
374
- * Returns telemetry keyed by session prefix (e.g., "orch-lane-1").
375
- *
376
- * Uses batch-state lanes to map lane numbers → session prefixes.
377
- * For standalone /task mode (no lane number in filename), data is keyed as "standalone".
378
- *
379
- * @param {object|null} batchState - The batch state from batch-state.json
380
- * @returns {object} Map of sessionPrefix → accumulated telemetry
381
- */
382
-
383
- // ── Runtime V2 Data Loaders (TP-107) ─────────────────────────────
384
-
385
- /**
386
- * Load the Runtime V2 process registry for the current batch.
387
- * Returns null if no registry exists (legacy batch).
388
- */
389
- function loadRuntimeRegistry(batchId) {
390
- if (!batchId) return null;
391
- const registryPath = path.join(REPO_ROOT, ".pi", "runtime", batchId, "registry.json");
392
- try {
393
- if (!fs.existsSync(registryPath)) return null;
394
- return JSON.parse(fs.readFileSync(registryPath, "utf-8"));
395
- } catch {
396
- return null;
397
- }
398
- }
399
-
400
- /**
401
- * Load Runtime V2 lane snapshots for the current batch.
402
- * Returns a map of laneNumber → snapshot data.
403
- */
404
- function loadRuntimeLaneSnapshots(batchId) {
405
- if (!batchId) return {};
406
- const lanesDir = path.join(REPO_ROOT, ".pi", "runtime", batchId, "lanes");
407
- const snapshots = {};
408
- try {
409
- if (!fs.existsSync(lanesDir)) return snapshots;
410
- const files = fs.readdirSync(lanesDir).filter(f => f.startsWith("lane-") && f.endsWith(".json"));
411
- for (const file of files) {
412
- try {
413
- const data = JSON.parse(fs.readFileSync(path.join(lanesDir, file), "utf-8"));
414
- if (data.laneNumber != null) snapshots[data.laneNumber] = data;
415
- } catch { continue; }
416
- }
417
- } catch { /* dir missing */ }
418
- return snapshots;
419
- }
420
-
421
- /**
422
- * Load Runtime V2 merge agent snapshots for the current batch.
423
- *
424
- * Reads all `merge-N.json` files from `.pi/runtime/{batchId}/lanes/`.
425
- * Returns a map of mergeNumber (string) → snapshot data.
426
- *
427
- * Follows the same pattern as {@link loadRuntimeLaneSnapshots}.
428
- *
429
- * @since TP-164
430
- */
431
- function loadRuntimeMergeSnapshots(batchId) {
432
- if (!batchId) return {};
433
- const lanesDir = path.join(REPO_ROOT, ".pi", "runtime", batchId, "lanes");
434
- const snapshots = {};
435
- try {
436
- if (!fs.existsSync(lanesDir)) return snapshots;
437
- const files = fs.readdirSync(lanesDir).filter(f => f.startsWith("merge-") && f.endsWith(".json"));
438
- for (const file of files) {
439
- try {
440
- const data = JSON.parse(fs.readFileSync(path.join(lanesDir, file), "utf-8"));
441
- if (data.mergeNumber != null) snapshots[data.mergeNumber] = data;
442
- } catch { continue; }
443
- }
444
- } catch { /* dir missing */ }
445
- return snapshots;
446
- }
447
-
448
- /**
449
- * Load Runtime V2 agent events for a specific agent.
450
- * Returns the last N events from the agent's events.jsonl.
451
- */
452
- function loadRuntimeAgentEvents(batchId, agentId, maxEvents) {
453
- if (!batchId || !agentId) return [];
454
- maxEvents = maxEvents || 200;
455
- const eventsPath = path.join(REPO_ROOT, ".pi", "runtime", batchId, "agents", agentId, "events.jsonl");
456
- try {
457
- if (!fs.existsSync(eventsPath)) return [];
458
- const raw = fs.readFileSync(eventsPath, "utf-8");
459
- const lines = raw.split("\n").filter(l => l.trim());
460
- const events = [];
461
- const start = Math.max(0, lines.length - maxEvents);
462
- for (let i = start; i < lines.length; i++) {
463
- try { events.push(JSON.parse(lines[i])); } catch { continue; }
464
- }
465
- return events;
466
- } catch {
467
- return [];
468
- }
469
- }
470
-
471
- /**
472
- * Load mailbox message activity for the current batch.
473
- *
474
- * TP-093 hardening: event-authoritative model.
475
- * Primary source: .pi/mailbox/{batchId}/events.jsonl (audit event stream).
476
- * Fallback: directory scans (inbox/ack/outbox/outbox/processed) for
477
- * compatibility when events.jsonl is absent.
478
- *
479
- * Includes:
480
- * - Consumed replies (outbox/processed/) so they don't disappear after ack
481
- * - Per-recipient broadcast delivery state from ack markers
482
- * - Rate-limited events in the timeline
483
- */
484
- function loadMailboxData(batchId) {
485
- if (!batchId) return { messages: [], agentIds: [], auditEvents: [] };
486
- const mbRoot = path.join(REPO_ROOT, ".pi", "mailbox", batchId);
487
- if (!fs.existsSync(mbRoot)) return { messages: [], agentIds: [], auditEvents: [] };
488
-
489
- // ── Primary: events.jsonl audit trail ──
490
- const auditEvents = loadMailboxAuditEvents(mbRoot);
491
-
492
- // ── Fallback: directory scan ──
493
- const messages = [];
494
- const agentIds = [];
495
-
496
- try {
497
- const dirs = fs.readdirSync(mbRoot, { withFileTypes: true })
498
- .filter(d => d.isDirectory())
499
- .map(d => d.name);
500
-
501
- for (const agentDir of dirs) {
502
- if (agentDir === "_broadcast") continue;
503
- agentIds.push(agentDir);
504
-
505
- // Scan inbox (pending), ack (delivered), outbox (active replies), outbox/processed (consumed replies)
506
- for (const subdir of ["inbox", "ack", "outbox", "outbox/processed"]) {
507
- const dir = path.join(mbRoot, agentDir, subdir);
508
- if (!fs.existsSync(dir)) continue;
509
- try {
510
- const files = fs.readdirSync(dir).filter(f => f.endsWith(".msg.json"));
511
- for (const file of files) {
512
- try {
513
- const msg = JSON.parse(fs.readFileSync(path.join(dir, file), "utf-8"));
514
- let status;
515
- if (subdir === "inbox") status = "pending";
516
- else if (subdir === "ack") status = "delivered";
517
- else if (subdir === "outbox") status = "reply";
518
- else status = "reply-acked"; // outbox/processed
519
- const isBroadcast = msg.to === "_broadcast";
520
- messages.push({ ...msg, _status: status, _agentDir: agentDir, _isBroadcast: isBroadcast });
521
- } catch { continue; }
522
- }
523
- } catch { continue; }
524
- }
525
- }
526
-
527
- // _broadcast: per-recipient delivery state
528
- const broadcastInbox = path.join(mbRoot, "_broadcast", "inbox");
529
- const broadcastAck = path.join(mbRoot, "_broadcast", "ack");
530
- for (const [dir, status] of [[broadcastInbox, "pending"], [broadcastAck, "delivered"]]) {
531
- if (!fs.existsSync(dir)) continue;
532
- try {
533
- const files = fs.readdirSync(dir).filter(f => f.endsWith(".msg.json"));
534
- for (const file of files) {
535
- try {
536
- const msg = JSON.parse(fs.readFileSync(path.join(dir, file), "utf-8"));
537
- messages.push({ ...msg, _status: status, _agentDir: "_broadcast", _isBroadcast: true });
538
- } catch { continue; }
539
- }
540
- } catch { continue; }
541
- }
542
- } catch { /* mailbox dir issues */ }
543
-
544
- messages.sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0));
545
- return { messages, agentIds, auditEvents };
546
- }
547
-
548
- /**
549
- * Load mailbox audit events from events.jsonl.
550
- * Returns events sorted by timestamp. Includes: message_sent, message_delivered,
551
- * message_replied, message_escalated, message_rate_limited.
552
- */
553
- function loadMailboxAuditEvents(mbRoot) {
554
- const eventsPath = path.join(mbRoot, "events.jsonl");
555
- if (!fs.existsSync(eventsPath)) return [];
556
- try {
557
- const raw = fs.readFileSync(eventsPath, "utf-8");
558
- const events = [];
559
- for (const line of raw.split("\n")) {
560
- if (!line.trim()) continue;
561
- try { events.push(JSON.parse(line)); } catch { continue; }
562
- }
563
- return events;
564
- } catch {
565
- return [];
566
- }
567
- }
568
-
569
- // ─────────────────────────────────────────────────────────────────────
570
- function loadTelemetryData(batchState) {
571
- const telemetryDir = path.join(REPO_ROOT, ".pi", "telemetry");
572
- const result = {};
573
-
574
- // Build lane number → session prefix mapping from batch state
575
- const laneToPrefix = {};
576
- if (batchState && batchState.lanes) {
577
- for (const lane of batchState.lanes) {
578
- const laneSessionId = lane.laneSessionId;
579
- if (lane.laneNumber != null && laneSessionId) {
580
- laneToPrefix[lane.laneNumber] = laneSessionId;
581
- }
582
- }
583
- }
584
-
585
- // Scan telemetry directory for JSONL files
586
- let files;
587
- try {
588
- files = fs.readdirSync(telemetryDir).filter(f => f.endsWith(".jsonl"));
589
- } catch {
590
- // .pi/telemetry/ may not exist (pre-RPC sessions) — degrade gracefully
591
- return result;
592
- }
593
-
594
- // Track which files still exist for tail-state cleanup
595
- const currentFiles = new Set();
596
- // Track current file→prefix mapping to detect file rotation
597
- const currentPrefixFiles = new Map(); // prefix → Set<filePath>
598
-
599
- for (const file of files) {
600
- const filePath = path.join(telemetryDir, file);
601
- currentFiles.add(filePath);
602
-
603
- // Parse filename to get lane number and role
604
- const parsed = parseTelemetryFilename(file);
605
- if (!parsed) continue;
606
-
607
- // Determine the key (session prefix)
608
- let prefix;
609
- if (parsed.role === "merger") {
610
- // Merge agent — derive prefix from lane naming so it matches the
611
- // session ID used by the client (e.g. "orch-henrylach-merge-1").
612
- // Lane sessions: "orch-{opId}-lane-{N}" → merge sessions: "orch-{opId}-merge-{N}".
613
- const firstLanePrefix = Object.values(laneToPrefix)[0]; // e.g. "orch-henrylach-lane-1"
614
- const opPrefix = firstLanePrefix?.replace(/-lane-\d+$/, ""); // "orch-henrylach"
615
- if (parsed.mergeNumber != null && opPrefix) {
616
- prefix = `${opPrefix}-merge-${parsed.mergeNumber}`;
617
- } else if (parsed.mergeNumber != null) {
618
- prefix = `orch-merge-${parsed.mergeNumber}`;
619
- } else {
620
- prefix = "orch-merge";
621
- }
622
- } else if (parsed.laneNumber != null && laneToPrefix[parsed.laneNumber]) {
623
- prefix = laneToPrefix[parsed.laneNumber];
624
- } else if (parsed.laneNumber != null) {
625
- // Lane number found but no batch-state mapping — use heuristic
626
- prefix = `orch-lane-${parsed.laneNumber}`;
627
- } else {
628
- // Standalone /task mode
629
- prefix = "standalone";
630
- }
631
-
632
- // Track file→prefix mapping
633
- if (!currentPrefixFiles.has(prefix)) currentPrefixFiles.set(prefix, new Set());
634
- currentPrefixFiles.get(prefix).add(filePath);
635
-
636
- // Check if file set for this prefix has changed (file rotation)
637
- const prevFiles = telemetryPrefixFiles.get(prefix);
638
- const isNewFile = !prevFiles || !prevFiles.has(filePath);
639
-
640
- // Initialize persistent accumulator for this prefix if needed,
641
- // or reset if files changed (new file appeared for same prefix)
642
- if (!telemetryAccumulators.has(prefix) || (isNewFile && !telemetryTailStates.has(filePath))) {
643
- const fresh = {
644
- inputTokens: 0, outputTokens: 0, cacheReadTokens: 0,
645
- cacheWriteTokens: 0, cost: 0, toolCalls: 0,
646
- lastTool: "", currentTool: "", retries: 0, retryActive: false,
647
- lastRetryError: "", compactions: 0, latestTotalTokens: 0,
648
- contextPct: 0, startedAt: 0,
649
- };
650
- telemetryAccumulators.set(prefix, fresh);
651
- // Also reset tail states for ALL files of this prefix to re-read from beginning
652
- if (prevFiles) {
653
- for (const pf of prevFiles) {
654
- telemetryTailStates.delete(pf);
655
- }
656
- }
657
- }
658
-
659
- const acc = telemetryAccumulators.get(prefix);
660
- result[prefix] = acc; // expose the persistent accumulator in the result
661
-
662
- // Tail the file for new events
663
- const events = tailJsonlFile(filePath);
664
-
665
- // Check if file was truncated — reset accumulator
666
- const ts = telemetryTailStates.get(filePath);
667
- if (ts && ts.wasReset) {
668
- acc.inputTokens = 0; acc.outputTokens = 0; acc.cacheReadTokens = 0;
669
- acc.cacheWriteTokens = 0; acc.cost = 0; acc.toolCalls = 0;
670
- acc.lastTool = ""; acc.currentTool = ""; acc.retries = 0; acc.retryActive = false;
671
- acc.lastRetryError = ""; acc.compactions = 0; acc.latestTotalTokens = 0;
672
- acc.contextPct = 0; acc.startedAt = 0;
673
- ts.wasReset = false;
674
- }
675
- for (const event of events) {
676
- switch (event.type) {
677
- case "message_end": {
678
- // A successful message_end means any prior retry resolved.
679
- // Clear retryActive to prevent stale retry badges from persisting
680
- // across batches or after transient API errors recover.
681
- acc.retryActive = false;
682
- const usage = event.message?.usage;
683
- if (usage) {
684
- acc.inputTokens += usage.input || 0;
685
- acc.outputTokens += usage.output || 0;
686
- acc.cacheReadTokens += usage.cacheRead || 0;
687
- acc.cacheWriteTokens += usage.cacheWrite || 0;
688
- if (usage.cost) {
689
- acc.cost += typeof usage.cost === "object"
690
- ? (usage.cost.total || 0)
691
- : (typeof usage.cost === "number" ? usage.cost : 0);
692
- }
693
- // Include cacheRead: totalTokens from pi excludes cache reads,
694
- // but cached tokens still consume context window capacity.
695
- const rawTotal = usage.totalTokens
696
- || ((usage.input || 0) + (usage.output || 0));
697
- const totalTokens = rawTotal + (usage.cacheRead || 0);
698
- if (totalTokens > acc.latestTotalTokens) {
699
- acc.latestTotalTokens = totalTokens;
700
- }
701
- }
702
- break;
703
- }
704
- case "tool_execution_start": {
705
- acc.toolCalls++;
706
- const toolDesc = event.toolName || "unknown";
707
- let argPreview = "";
708
- if (event.args) {
709
- if (typeof event.args === "string") {
710
- argPreview = event.args.slice(0, 80);
711
- } else if (typeof event.args === "object") {
712
- const firstVal = Object.values(event.args)[0];
713
- if (typeof firstVal === "string") {
714
- argPreview = firstVal.slice(0, 80);
715
- }
716
- }
717
- }
718
- const toolLabel = argPreview ? `${toolDesc} ${argPreview}` : toolDesc;
719
- acc.lastTool = toolLabel;
720
- acc.currentTool = toolLabel;
721
- break;
722
- }
723
- case "tool_execution_end": {
724
- acc.currentTool = "";
725
- break;
726
- }
727
- case "agent_start": {
728
- if (event.ts && !acc.startedAt) {
729
- acc.startedAt = event.ts;
730
- }
731
- break;
732
- }
733
- case "response": {
734
- // Extract context usage from get_session_stats responses
735
- const ctxUsage = event.data?.contextUsage;
736
- if (ctxUsage) {
737
- // Support both percent (current) and percentUsed (legacy pi versions)
738
- const pct = typeof ctxUsage.percent === "number" ? ctxUsage.percent
739
- : typeof ctxUsage.percentUsed === "number" ? ctxUsage.percentUsed
740
- : null;
741
- if (pct !== null) acc.contextPct = pct;
742
- }
743
- break;
744
- }
745
- case "auto_retry_start": {
746
- acc.retries++;
747
- acc.retryActive = true;
748
- acc.lastRetryError = event.errorMessage || event.error || "unknown";
749
- break;
750
- }
751
- case "auto_retry_end": {
752
- acc.retryActive = false;
753
- break;
754
- }
755
- case "auto_compaction_start": {
756
- acc.compactions++;
757
- break;
758
- }
759
- }
760
- }
761
- }
762
-
763
- // Clean up tail states for files that no longer exist
764
- for (const [filePath] of telemetryTailStates) {
765
- if (filePath.startsWith(telemetryDir) && !currentFiles.has(filePath)) {
766
- telemetryTailStates.delete(filePath);
767
- }
768
- }
769
-
770
- // Update prefix→files tracking for next call
771
- // Clean up accumulators and tracking for prefixes that have no remaining files
772
- const activePrefixes = new Set(Object.keys(result));
773
- for (const [prefix] of telemetryAccumulators) {
774
- if (!activePrefixes.has(prefix)) {
775
- telemetryAccumulators.delete(prefix);
776
- telemetryPrefixFiles.delete(prefix);
777
- }
778
- }
779
- // Store current file mappings for next call's rotation detection
780
- for (const [prefix, fileSet] of currentPrefixFiles) {
781
- telemetryPrefixFiles.set(prefix, fileSet);
782
- }
783
-
784
- return result;
785
- }
786
-
787
- // ─── Supervisor Data Loading ────────────────────────────────────────────────
788
-
789
- /**
790
- * Module-level tail state for supervisor JSONL files (actions.jsonl, events.jsonl).
791
- * Reuses the same incremental tailing pattern as telemetry.
792
- * Key: absolute file path → { offset, partial, entries }
793
- */
794
- const supervisorTailStates = {
795
- actions: { offset: 0, partial: "", entries: [] },
796
- events: { offset: 0, partial: "", entries: [] },
797
- conversation: { offset: 0, partial: "", entries: [] },
798
- };
799
-
800
- /**
801
- * The last known batchId — used to detect batch changes and reset accumulators.
802
- */
803
- let supervisorLastBatchId = "";
804
-
805
- /**
806
- * Incrementally tail a JSONL file, accumulating parsed entries.
807
- * Filters entries by batchId when provided.
808
- *
809
- * @param {string} filePath - Absolute path to the JSONL file
810
- * @param {object} tailState - Mutable tail state { offset, partial, entries }
811
- * @param {string} batchId - Batch ID to filter by (empty = no filter)
812
- * @returns {object[]} The accumulated entries array (same reference as tailState.entries)
813
- */
814
- function tailSupervisorJsonl(filePath, tailState, batchId) {
815
- // Check file size
816
- let fileSize;
817
- try {
818
- fileSize = fs.statSync(filePath).size;
819
- } catch {
820
- return tailState.entries; // File doesn't exist yet — return accumulated
821
- }
822
-
823
- // Handle file truncation/recreation
824
- if (fileSize < tailState.offset) {
825
- tailState.offset = 0;
826
- tailState.partial = "";
827
- tailState.entries = [];
828
- }
829
-
830
- if (fileSize <= tailState.offset) {
831
- return tailState.entries; // No new data
832
- }
833
-
834
- // Read new bytes from offset
835
- const bytesToRead = fileSize - tailState.offset;
836
- const buf = Buffer.alloc(bytesToRead);
837
- let fd;
838
- try {
839
- fd = fs.openSync(filePath, "r");
840
- } catch {
841
- return tailState.entries;
842
- }
843
- try {
844
- fs.readSync(fd, buf, 0, bytesToRead, tailState.offset);
845
- } catch {
846
- fs.closeSync(fd);
847
- return tailState.entries;
848
- }
849
- fs.closeSync(fd);
850
- tailState.offset = fileSize;
851
-
852
- // Split into lines, preserving partial trailing line
853
- const chunk = tailState.partial + buf.toString("utf-8");
854
- const lines = chunk.split("\n");
855
- tailState.partial = lines.pop() || "";
856
-
857
- for (const line of lines) {
858
- const trimmed = line.trim();
859
- if (!trimmed) continue;
860
- try {
861
- const entry = JSON.parse(trimmed);
862
- // Filter by batchId if provided
863
- if (batchId && entry.batchId && entry.batchId !== batchId) continue;
864
- tailState.entries.push(entry);
865
- } catch {
866
- // Malformed JSON — skip
867
- }
868
- }
869
-
870
- // Cap accumulated entries to prevent unbounded growth (keep last 500)
871
- if (tailState.entries.length > 500) {
872
- tailState.entries = tailState.entries.slice(-500);
873
- }
874
-
875
- return tailState.entries;
876
- }
877
-
878
- /**
879
- * Read supervisor autonomy level from project config.
880
- *
881
- * Checks `.pi/taskplane-config.json` for `orchestrator.supervisor.autonomy`.
882
- * Falls back to "supervised" (the default) if config is missing or malformed.
883
- * This is needed because the lockfile does not contain the autonomy level.
884
- *
885
- * @returns {string} Autonomy level: "interactive" | "supervised" | "autonomous"
886
- */
887
- function loadSupervisorAutonomy() {
888
- try {
889
- const configPath = path.join(REPO_ROOT, ".pi", "taskplane-config.json");
890
- const raw = fs.readFileSync(configPath, "utf-8");
891
- const config = JSON.parse(raw);
892
- const autonomy = config?.orchestrator?.supervisor?.autonomy;
893
- if (autonomy === "interactive" || autonomy === "supervised" || autonomy === "autonomous") {
894
- return autonomy;
895
- }
896
- } catch {
897
- // Config missing or malformed — use default
898
- }
899
- return "supervised"; // Default per DEFAULT_SUPERVISOR_CONFIG
900
- }
901
-
902
- /**
903
- * Load supervisor data for the dashboard.
904
- *
905
- * Reads (all from .pi/supervisor/):
906
- * - lock.json: supervisor active/stale status, heartbeat, autonomy (from config)
907
- * - actions.jsonl: recovery action audit trail (batch-scoped, incremental)
908
- * - events.jsonl: engine + tier 0 events (batch-scoped, incremental)
909
- * - conversation.jsonl: operator ↔ supervisor interaction log (spec §9.1)
910
- * - summary.md: human-readable batch summary (generated on completion)
911
- *
912
- * Returns null when no supervisor files exist (pre-supervisor batches).
913
- *
914
- * @param {object|null} batchState - The batch state from batch-state.json
915
- * @returns {object|null} Supervisor data object or null
916
- */
917
- function loadSupervisorData(batchState) {
918
- const supervisorDir = path.join(REPO_ROOT, ".pi", "supervisor");
919
- const batchId = batchState ? (batchState.batchId || "") : "";
920
-
921
- // Detect batch change — reset tail state accumulators
922
- if (batchId && batchId !== supervisorLastBatchId) {
923
- supervisorLastBatchId = batchId;
924
- supervisorTailStates.actions = { offset: 0, partial: "", entries: [] };
925
- supervisorTailStates.events = { offset: 0, partial: "", entries: [] };
926
- supervisorTailStates.conversation = { offset: 0, partial: "", entries: [] };
927
- }
928
-
929
- // ── Lockfile: supervisor status ──
930
- // The lockfile contains pid, sessionId, batchId, startedAt, heartbeat.
931
- // It does NOT contain autonomy — that comes from project config.
932
- let lock = null;
933
- try {
934
- const lockPath = path.join(supervisorDir, "lock.json");
935
- const raw = fs.readFileSync(lockPath, "utf-8");
936
- const parsed = JSON.parse(raw);
937
- if (parsed && parsed.pid && parsed.sessionId) {
938
- // Determine if lock is stale (heartbeat older than 90s)
939
- const heartbeatAge = parsed.heartbeat
940
- ? Date.now() - new Date(parsed.heartbeat).getTime()
941
- : Infinity;
942
- const isStale = heartbeatAge > 90_000;
943
-
944
- lock = {
945
- active: !isStale,
946
- pid: parsed.pid,
947
- sessionId: parsed.sessionId,
948
- batchId: parsed.batchId || "",
949
- startedAt: parsed.startedAt || "",
950
- heartbeat: parsed.heartbeat || "",
951
- // Autonomy is NOT in the lockfile — derive from project config
952
- autonomy: loadSupervisorAutonomy(),
953
- };
954
- }
955
- } catch {
956
- // No lockfile or malformed — supervisor is inactive
957
- }
958
-
959
- // ── Actions JSONL: recovery audit trail (batch-scoped, incremental) ──
960
- const actionsPath = path.join(supervisorDir, "actions.jsonl");
961
- const actions = tailSupervisorJsonl(actionsPath, supervisorTailStates.actions, batchId);
962
-
963
- // ── Events JSONL: engine events (batch-scoped, incremental) ──
964
- const eventsPath = path.join(supervisorDir, "events.jsonl");
965
- const events = tailSupervisorJsonl(eventsPath, supervisorTailStates.events, batchId);
966
-
967
- // ── Conversation JSONL: operator interaction log (spec §9.1) ──
968
- // The supervisor writes operator↔supervisor messages to conversation.jsonl.
969
- // Not yet implemented in all supervisor versions — degrade gracefully.
970
- const conversationPath = path.join(supervisorDir, "conversation.jsonl");
971
- const conversation = tailSupervisorJsonl(conversationPath, supervisorTailStates.conversation, batchId);
972
-
973
- // ── Summary: human-readable batch summary (generated on completion) ──
974
- // Per spec §9.1, the supervisor writes .pi/supervisor/summary.md when the
975
- // batch completes or is abandoned. Read the file if it exists.
976
- let summary = null;
977
- try {
978
- const summaryPath = path.join(supervisorDir, "summary.md");
979
- summary = fs.readFileSync(summaryPath, "utf-8");
980
- } catch {
981
- // No summary yet — batch may still be running, or pre-supervisor batch
982
- }
983
-
984
- // If nothing exists at all, return null (pre-supervisor batch)
985
- if (!lock && actions.length === 0 && events.length === 0 && conversation.length === 0 && !summary) {
986
- // Check if the supervisor directory even exists
987
- try {
988
- fs.statSync(supervisorDir);
989
- } catch {
990
- return null; // No supervisor dir → pre-supervisor batch
991
- }
992
- }
993
-
994
- return {
995
- lock,
996
- actions,
997
- events,
998
- conversation,
999
- summary,
1000
- };
1001
- }
1002
-
1003
- /**
1004
- * Compute batch total cost from lane states (primary) and telemetry (supplementary).
1005
- * Lane states are authoritative — telemetry provides additional data only for lanes
1006
- * that have no lane-state entry (e.g., very early in session startup).
1007
- */
1008
- function computeBatchTotalCost(laneStates, telemetry) {
1009
- let totalCost = 0;
1010
- const coveredPrefixes = new Set();
1011
-
1012
- // Primary: sum cost from lane states (worker + reviewer)
1013
- for (const [prefix, ls] of Object.entries(laneStates)) {
1014
- if (ls.workerCostUsd) {
1015
- totalCost += ls.workerCostUsd;
1016
- coveredPrefixes.add(prefix);
1017
- }
1018
- if (ls.reviewerCostUsd) {
1019
- totalCost += ls.reviewerCostUsd;
1020
- }
1021
- }
1022
-
1023
- // Supplementary: add cost from telemetry for uncovered lanes only
1024
- for (const [prefix, tel] of Object.entries(telemetry)) {
1025
- if (!coveredPrefixes.has(prefix) && tel.cost > 0) {
1026
- totalCost += tel.cost;
1027
- }
1028
- }
1029
-
1030
- return totalCost;
1031
- }
1032
-
1033
- function synthesizeLaneStateFromSnapshot(key, snap, fallbackBatchId) {
1034
- const w = snap.worker || {};
1035
- const r = snap.reviewer || null;
1036
- const statusMap = { running: "running", spawning: "running", exited: "done", crashed: "error", killed: "error", timed_out: "error", wrapping_up: "running" };
1037
- const reviewerStatusMap = { running: "running", spawning: "running", wrapping_up: "running", exited: "done", crashed: "done", killed: "done", timed_out: "done" };
1038
-
1039
- return {
1040
- prefix: key,
1041
- taskId: snap.taskId || null,
1042
- phase: snap.status === "running" ? "worker-active" : snap.status === "complete" ? "complete" : "idle",
1043
- workerStatus: statusMap[w.status] || w.status || "idle",
1044
- workerElapsed: w.elapsedMs || 0,
1045
- workerContextPct: w.contextPct || 0,
1046
- workerLastTool: w.lastTool || "",
1047
- workerToolCount: w.toolCalls || 0,
1048
- workerInputTokens: w.inputTokens || 0,
1049
- workerOutputTokens: w.outputTokens || 0,
1050
- workerCacheReadTokens: w.cacheReadTokens || 0,
1051
- workerCacheWriteTokens: w.cacheWriteTokens || 0,
1052
- workerCostUsd: w.costUsd || 0,
1053
- reviewerStatus: r ? (reviewerStatusMap[r.status] || r.status || "running") : "idle",
1054
- reviewerElapsed: r?.elapsedMs || 0,
1055
- reviewerContextPct: r?.contextPct || 0,
1056
- reviewerLastTool: r?.lastTool || "",
1057
- reviewerToolCount: r?.toolCalls || 0,
1058
- reviewerCostUsd: r?.costUsd || 0,
1059
- reviewerInputTokens: r?.inputTokens || 0,
1060
- reviewerOutputTokens: r?.outputTokens || 0,
1061
- reviewerCacheReadTokens: r?.cacheReadTokens || 0,
1062
- reviewerCacheWriteTokens: r?.cacheWriteTokens || 0,
1063
- reviewerType: r?.reviewType || "",
1064
- reviewerStep: r?.reviewStep || 0,
1065
- batchId: snap.batchId || fallbackBatchId,
1066
- timestamp: snap.updatedAt || Date.now(),
1067
- };
1068
- }
1069
-
1070
- /** Build full dashboard state object for the frontend. */
1071
- function buildDashboardState() {
1072
- const state = loadBatchState();
1073
- const sessions = getActiveSessions();
1074
- const rawLaneStates = loadLaneStates();
1075
- // Filter stale lane states from previous batches.
1076
- // Lane state files persist across batches (same filename), so without
1077
- // filtering the dashboard shows telemetry from old runs.
1078
- const currentBatchId = state?.batchId || null;
1079
- const laneStates = {};
1080
- for (const [key, ls] of Object.entries(rawLaneStates)) {
1081
- if (!currentBatchId || !ls.batchId || ls.batchId === currentBatchId) {
1082
- laneStates[key] = ls;
1083
- }
1084
- }
1085
- const telemetry = loadTelemetryData(state);
1086
- const batchTotalCost = computeBatchTotalCost(laneStates, telemetry);
1087
- const supervisor = loadSupervisorData(state);
1088
-
1089
- if (!state) {
1090
- return {
1091
- batch: null,
1092
- sessions,
1093
- tmuxSessions: sessions, // Legacy compatibility field for older dashboard clients
1094
- laneStates: {},
1095
- telemetry: {},
1096
- batchTotalCost: 0,
1097
- supervisor: null,
1098
- timestamp: Date.now(),
1099
- };
1100
- }
1101
-
1102
- const tasks = (state.tasks || []).map((task) => {
1103
- const effectiveFolder = resolveTaskFolder(task, state);
1104
- let statusData = null;
1105
- if (effectiveFolder) {
1106
- statusData = parseStatusMd(effectiveFolder);
1107
- }
1108
- if (!task.doneFileFound && effectiveFolder) {
1109
- task.doneFileFound = checkDoneFile(effectiveFolder);
1110
- }
1111
- return { ...task, statusData };
1112
- });
1113
-
1114
- // TP-107: Load Runtime V2 data when available
1115
- const runtimeRegistry = loadRuntimeRegistry(state.batchId);
1116
- const runtimeLaneSnapshots = loadRuntimeLaneSnapshots(state.batchId);
1117
- const mailboxData = loadMailboxData(state.batchId);
1118
-
1119
- // TP-164: Load merge agent snapshots for live dashboard telemetry.
1120
- const runtimeMergeSnapshots = loadRuntimeMergeSnapshots(state.batchId);
1121
-
1122
- // TP-164: Inject merge snapshot telemetry into the telemetry map so
1123
- // `telemetry[sessionName]` resolves for the merge pane.
1124
- // Snapshots are the PRIMARY source for merge telemetry — merge agents don't
1125
- // write to .pi/telemetry/*.jsonl (the JSONL path is for lane workers only).
1126
- // Inject snapshot data when no JSONL-backed entry exists for this session.
1127
- for (const snap of Object.values(runtimeMergeSnapshots)) {
1128
- const key = snap.sessionName;
1129
- if (!key) continue;
1130
- const agent = snap.agent;
1131
- if (!agent) continue;
1132
- // Only inject if there is no existing JSONL-backed telemetry entry.
1133
- // Merge agents don't emit to .pi/telemetry/, so existing will always be
1134
- // absent unless something else wrote to it — in that case defer to it.
1135
- const existing = telemetry[key];
1136
- if (!existing) {
1137
- telemetry[key] = {
1138
- inputTokens: agent.inputTokens || 0,
1139
- outputTokens: agent.outputTokens || 0,
1140
- cacheReadTokens: agent.cacheReadTokens || 0,
1141
- cacheWriteTokens: agent.cacheWriteTokens || 0,
1142
- cost: agent.costUsd || 0,
1143
- toolCalls: agent.toolCalls || 0,
1144
- lastTool: agent.lastTool || "",
1145
- currentTool: snap.status === "running" ? (agent.lastTool || "") : "",
1146
- contextPct: agent.contextPct || 0,
1147
- // startedAt is not in the snapshot; compute from elapsed if possible.
1148
- startedAt: agent.elapsedMs > 0 ? snap.updatedAt - agent.elapsedMs : snap.updatedAt,
1149
- retries: 0,
1150
- retryActive: false,
1151
- lastRetryError: "",
1152
- compactions: 0,
1153
- latestTotalTokens: (agent.inputTokens || 0) + (agent.outputTokens || 0),
1154
- _updatedAt: snap.updatedAt,
1155
- _source: "merge-snapshot",
1156
- // TP-178: Include waveIndex for precise wave-telemetry association (#498)
1157
- waveIndex: snap.waveIndex != null ? snap.waveIndex : undefined,
1158
- };
1159
- }
1160
- }
1161
-
1162
- // TP-115: Synthesize laneStates from V2 snapshots so the dashboard
1163
- // pipeline works without legacy lane-state-*.json sidecar files.
1164
- // V2 snapshots are authoritative when present.
1165
- if (Object.keys(runtimeLaneSnapshots).length > 0) {
1166
- for (const [laneNum, snap] of Object.entries(runtimeLaneSnapshots)) {
1167
- // Find the matching lane record to get the session name key
1168
- const laneRec = (state.lanes || []).find(l => l.laneNumber === Number(laneNum));
1169
- const key = laneRec ? (laneRec.laneSessionId) : `lane-${laneNum}`;
1170
- if (!laneStates[key] || (snap.updatedAt && snap.updatedAt > (laneStates[key].timestamp || 0))) {
1171
- laneStates[key] = synthesizeLaneStateFromSnapshot(key, snap, state.batchId);
1172
- }
1173
- }
1174
- }
1175
-
1176
- return {
1177
- laneStates,
1178
- telemetry,
1179
- batchTotalCost,
1180
- supervisor,
1181
- // Runtime V2 data (null/empty for legacy batches)
1182
- runtimeRegistry,
1183
- runtimeLaneSnapshots,
1184
- // TP-164: Merge agent snapshots for live dashboard telemetry.
1185
- runtimeMergeSnapshots,
1186
- mailbox: mailboxData,
1187
- batch: {
1188
- batchId: state.batchId,
1189
- phase: state.phase,
1190
- startedAt: state.startedAt,
1191
- updatedAt: state.updatedAt,
1192
- currentWaveIndex: state.currentWaveIndex || 0,
1193
- totalWaves: state.totalWaves || (state.wavePlan ? state.wavePlan.length : 0),
1194
- wavePlan: state.wavePlan || [],
1195
- // Lanes already include repoId (string|undefined) from PersistedLaneRecord (v2).
1196
- lanes: state.lanes || [],
1197
- // Tasks already include repoId, resolvedRepoId (string|undefined) from PersistedTaskRecord (v2).
1198
- tasks,
1199
- mergeResults: state.mergeResults || [],
1200
- errors: state.errors || [],
1201
- lastError: state.lastError || null,
1202
- // Workspace mode: "repo" (default/v1) or "workspace" (v2 multi-repo).
1203
- // Additive field — absent in v1 state files, frontend must default to "repo".
1204
- mode: state.mode || "repo",
1205
- // TP-148: Segment records for wave display context (v4+).
1206
- // Each record has taskId, segmentId, repoId, status.
1207
- segments: state.segments || [],
1208
- },
1209
- sessions,
1210
- tmuxSessions: sessions, // Legacy compatibility field for older dashboard clients
1211
- timestamp: Date.now(),
1212
- };
1213
- }
1214
-
1215
- // ─── Static File Serving ────────────────────────────────────────────────────
1216
-
1217
- const MIME_TYPES = {
1218
- ".html": "text/html; charset=utf-8",
1219
- ".css": "text/css; charset=utf-8",
1220
- ".js": "application/javascript; charset=utf-8",
1221
- ".json": "application/json; charset=utf-8",
1222
- ".svg": "image/svg+xml",
1223
- ".png": "image/png",
1224
- ".ico": "image/x-icon",
1225
- };
1226
-
1227
- function serveStatic(req, res) {
1228
- let filePath = new URL(req.url, "http://localhost").pathname;
1229
- if (filePath === "/") filePath = "/index.html";
1230
-
1231
- const fullPath = path.join(PUBLIC_DIR, filePath);
1232
- // Prevent directory traversal
1233
- if (!fullPath.startsWith(PUBLIC_DIR)) {
1234
- res.writeHead(403);
1235
- res.end("Forbidden");
1236
- return;
1237
- }
1238
-
1239
- const ext = path.extname(fullPath);
1240
- const contentType = MIME_TYPES[ext] || "application/octet-stream";
1241
-
1242
- try {
1243
- const content = fs.readFileSync(fullPath);
1244
- res.writeHead(200, {
1245
- "Content-Type": contentType,
1246
- "Cache-Control": "no-cache, no-store, must-revalidate",
1247
- });
1248
- res.end(content);
1249
- } catch {
1250
- res.writeHead(404, { "Content-Type": "text/plain" });
1251
- res.end("Not Found");
1252
- }
1253
- }
1254
-
1255
- // ─── SSE Stream ─────────────────────────────────────────────────────────────
1256
-
1257
- const sseClients = new Set();
1258
-
1259
- // ─── Conversation JSONL ─────────────────────────────────────────────────
1260
-
1261
- function serveConversation(req, res, prefix) {
1262
- if (!/^[\w-]+$/.test(prefix)) {
1263
- res.writeHead(400, { "Content-Type": "text/plain" });
1264
- res.end("Invalid prefix");
1265
- return;
1266
- }
1267
-
1268
- const filePath = path.join(REPO_ROOT, ".pi", `worker-conversation-${prefix}.jsonl`);
1269
- try {
1270
- const content = fs.readFileSync(filePath, "utf-8");
1271
- res.writeHead(200, {
1272
- "Content-Type": "application/x-ndjson",
1273
- "Access-Control-Allow-Origin": "*",
1274
- });
1275
- res.end(content);
1276
- } catch {
1277
- res.writeHead(200, {
1278
- "Content-Type": "application/x-ndjson",
1279
- "Access-Control-Allow-Origin": "*",
1280
- });
1281
- res.end(""); // empty — no conversation yet
1282
- }
1283
- }
1284
-
1285
- // ─── Dashboard SSE ──────────────────────────────────────────────────────
1286
-
1287
- function handleSSE(req, res) {
1288
- res.writeHead(200, {
1289
- "Content-Type": "text/event-stream",
1290
- "Cache-Control": "no-cache",
1291
- Connection: "keep-alive",
1292
- "Access-Control-Allow-Origin": "*",
1293
- });
1294
-
1295
- // Send initial state immediately
1296
- const state = buildDashboardState();
1297
- res.write(`data: ${JSON.stringify(state)}\n\n`);
1298
-
1299
- sseClients.add(res);
1300
- req.on("close", () => sseClients.delete(res));
1301
- }
1302
-
1303
- function broadcastState() {
1304
- if (sseClients.size === 0) return;
1305
- const state = buildDashboardState();
1306
- const payload = `data: ${JSON.stringify(state)}\n\n`;
1307
- for (const client of sseClients) {
1308
- try {
1309
- client.write(payload);
1310
- } catch {
1311
- sseClients.delete(client);
1312
- }
1313
- }
1314
- }
1315
-
1316
- // ─── Batch History API ──────────────────────────────────────────────────────
1317
-
1318
- // BATCH_HISTORY_PATH is initialized in main() alongside REPO_ROOT.
1319
-
1320
- function loadHistory() {
1321
- try {
1322
- if (!fs.existsSync(BATCH_HISTORY_PATH)) return [];
1323
- const raw = fs.readFileSync(BATCH_HISTORY_PATH, "utf-8");
1324
- return JSON.parse(raw);
1325
- } catch {
1326
- return [];
1327
- }
1328
- }
1329
-
1330
- /** GET /api/history — return list of batch summaries (compact: no per-task detail). */
1331
- function serveHistory(req, res) {
1332
- const history = loadHistory();
1333
- // Return compact list for the dropdown (no per-task details)
1334
- const compact = history.map(h => ({
1335
- batchId: h.batchId,
1336
- status: h.status,
1337
- startedAt: h.startedAt,
1338
- endedAt: h.endedAt,
1339
- durationMs: h.durationMs,
1340
- totalWaves: h.totalWaves,
1341
- totalTasks: h.totalTasks,
1342
- succeededTasks: h.succeededTasks,
1343
- failedTasks: h.failedTasks,
1344
- tokens: h.tokens,
1345
- }));
1346
- res.writeHead(200, {
1347
- "Content-Type": "application/json",
1348
- "Access-Control-Allow-Origin": "*",
1349
- });
1350
- res.end(JSON.stringify(compact));
1351
- }
1352
-
1353
- /** GET /api/history/:batchId — return full detail for one batch. */
1354
- function serveHistoryEntry(req, res, batchId) {
1355
- const history = loadHistory();
1356
- const entry = history.find(h => h.batchId === batchId);
1357
- if (!entry) {
1358
- res.writeHead(404, { "Content-Type": "application/json" });
1359
- res.end(JSON.stringify({ error: "Batch not found" }));
1360
- return;
1361
- }
1362
- res.writeHead(200, {
1363
- "Content-Type": "application/json",
1364
- "Access-Control-Allow-Origin": "*",
1365
- });
1366
- res.end(JSON.stringify(entry));
1367
- }
1368
-
1369
- /** GET /api/status-md/:taskId — return raw STATUS.md content for a task. */
1370
- function serveStatusMd(req, res, taskId) {
1371
- if (!/^[\w-]+$/.test(taskId)) {
1372
- res.writeHead(400, { "Content-Type": "text/plain" });
1373
- res.end("Invalid task ID");
1374
- return;
1375
- }
1376
-
1377
- const state = loadBatchState();
1378
- if (!state) {
1379
- res.writeHead(404, { "Content-Type": "application/json" });
1380
- res.end(JSON.stringify({ error: "No batch state" }));
1381
- return;
1382
- }
1383
-
1384
- const task = (state.tasks || []).find(t => t.taskId === taskId);
1385
- if (!task) {
1386
- res.writeHead(404, { "Content-Type": "application/json" });
1387
- res.end(JSON.stringify({ error: "Task not found" }));
1388
- return;
1389
- }
1390
-
1391
- const effectiveFolder = resolveTaskFolder(task, state);
1392
- if (!effectiveFolder) {
1393
- res.writeHead(404, { "Content-Type": "application/json" });
1394
- res.end(JSON.stringify({ error: "Cannot resolve task folder" }));
1395
- return;
1396
- }
1397
-
1398
- // Try effective folder, then archive
1399
- const candidates = [effectiveFolder];
1400
- const archiveBase = effectiveFolder.replace(/[/\\]tasks[/\\][^/\\]+$/, "/tasks/archive/" + taskId);
1401
- if (archiveBase !== effectiveFolder) candidates.push(archiveBase);
1402
-
1403
- for (const folder of candidates) {
1404
- const statusPath = path.join(folder, "STATUS.md");
1405
- try {
1406
- const content = fs.readFileSync(statusPath, "utf-8");
1407
- res.writeHead(200, {
1408
- "Content-Type": "text/plain; charset=utf-8",
1409
- "Access-Control-Allow-Origin": "*",
1410
- });
1411
- res.end(content);
1412
- return;
1413
- } catch { continue; }
1414
- }
1415
-
1416
- res.writeHead(404, { "Content-Type": "application/json" });
1417
- res.end(JSON.stringify({ error: "STATUS.md not found" }));
1418
- }
1419
-
1420
- // ─── Dashboard Preferences ──────────────────────────────────────────────────
1421
-
1422
- function getPreferencesPath() {
1423
- return path.join(REPO_ROOT, ".pi", "dashboard-preferences.json");
1424
- }
1425
-
1426
- function handleGetPreferences(req, res) {
1427
- const prefsPath = getPreferencesPath();
1428
- let prefs = { theme: "dark" };
1429
- try {
1430
- if (fs.existsSync(prefsPath)) {
1431
- prefs = JSON.parse(fs.readFileSync(prefsPath, "utf8"));
1432
- }
1433
- } catch { /* use defaults */ }
1434
- res.writeHead(200, {
1435
- "Content-Type": "application/json",
1436
- "Access-Control-Allow-Origin": "*",
1437
- });
1438
- res.end(JSON.stringify(prefs));
1439
- }
1440
-
1441
- function handlePostPreferences(req, res) {
1442
- let body = "";
1443
- req.on("data", (chunk) => { body += chunk; });
1444
- req.on("end", () => {
1445
- try {
1446
- const incoming = JSON.parse(body);
1447
- const prefsPath = getPreferencesPath();
1448
- let existing = {};
1449
- try {
1450
- if (fs.existsSync(prefsPath)) {
1451
- existing = JSON.parse(fs.readFileSync(prefsPath, "utf8"));
1452
- }
1453
- } catch { /* start fresh */ }
1454
- const merged = { ...existing, ...incoming };
1455
- const dir = path.dirname(prefsPath);
1456
- if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
1457
- fs.writeFileSync(prefsPath, JSON.stringify(merged, null, 2) + "\n");
1458
- res.writeHead(200, {
1459
- "Content-Type": "application/json",
1460
- "Access-Control-Allow-Origin": "*",
1461
- });
1462
- res.end(JSON.stringify(merged));
1463
- } catch (err) {
1464
- res.writeHead(400, {
1465
- "Content-Type": "application/json",
1466
- "Access-Control-Allow-Origin": "*",
1467
- });
1468
- res.end(JSON.stringify({ error: "Invalid JSON" }));
1469
- }
1470
- });
1471
- }
1472
-
1473
- // ─── HTTP Server ────────────────────────────────────────────────────────────
1474
-
1475
- function createServer() {
1476
- const server = http.createServer((req, res) => {
1477
- const pathname = new URL(req.url, "http://localhost").pathname;
1478
-
1479
- if (pathname === "/api/stream" && req.method === "GET") {
1480
- handleSSE(req, res);
1481
- } else if (pathname.startsWith("/api/conversation/") && req.method === "GET") {
1482
- const prefix = pathname.slice("/api/conversation/".length);
1483
- serveConversation(req, res, prefix);
1484
- } else if (pathname.startsWith("/api/agent-events/") && req.method === "GET") {
1485
- // TP-107: Serve Runtime V2 agent events (hardened)
1486
- const agentId = decodeURIComponent(pathname.slice("/api/agent-events/".length));
1487
- // Strict validation: same pattern as /api/conversation/:prefix
1488
- if (!/^[\w-]+$/.test(agentId)) {
1489
- res.writeHead(400, { "Content-Type": "text/plain" });
1490
- res.end("Invalid agent ID");
1491
- return;
1492
- }
1493
- const batchState = loadBatchState();
1494
- // Path containment: verify resolved path stays inside runtime dir
1495
- if (batchState?.batchId) {
1496
- const runtimeBase = path.join(REPO_ROOT, ".pi", "runtime", batchState.batchId, "agents");
1497
- const resolvedAgent = path.resolve(runtimeBase, agentId);
1498
- if (!resolvedAgent.startsWith(path.resolve(runtimeBase))) {
1499
- res.writeHead(403, { "Content-Type": "text/plain" });
1500
- res.end("Forbidden");
1501
- return;
1502
- }
1503
- }
1504
- // Optional: ?sinceTs= to return only events after a timestamp
1505
- const reqUrl = new URL(req.url, "http://localhost");
1506
- const sinceTs = parseInt(reqUrl.searchParams.get("sinceTs") || "0", 10);
1507
- let events = loadRuntimeAgentEvents(batchState?.batchId, agentId, 300);
1508
- if (sinceTs > 0) {
1509
- events = events.filter(e => (e.ts || 0) > sinceTs);
1510
- }
1511
- res.writeHead(200, { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" });
1512
- res.end(JSON.stringify(events));
1513
- } else if (pathname === "/api/state" && req.method === "GET") {
1514
- const state = buildDashboardState();
1515
- res.writeHead(200, {
1516
- "Content-Type": "application/json",
1517
- "Access-Control-Allow-Origin": "*",
1518
- });
1519
- res.end(JSON.stringify(state));
1520
- } else if (pathname === "/api/history" && req.method === "GET") {
1521
- serveHistory(req, res);
1522
- } else if (pathname.startsWith("/api/history/") && req.method === "GET") {
1523
- const batchId = decodeURIComponent(pathname.slice("/api/history/".length));
1524
- serveHistoryEntry(req, res, batchId);
1525
- } else if (pathname.startsWith("/api/status-md/") && req.method === "GET") {
1526
- const taskId = decodeURIComponent(pathname.slice("/api/status-md/".length));
1527
- serveStatusMd(req, res, taskId);
1528
- } else if (pathname === "/api/preferences" && req.method === "GET") {
1529
- handleGetPreferences(req, res);
1530
- } else if (pathname === "/api/preferences" && req.method === "POST") {
1531
- handlePostPreferences(req, res);
1532
- } else if (req.method === "OPTIONS") {
1533
- // CORS preflight for POST
1534
- res.writeHead(204, {
1535
- "Access-Control-Allow-Origin": "*",
1536
- "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
1537
- "Access-Control-Allow-Headers": "Content-Type",
1538
- });
1539
- res.end();
1540
- } else {
1541
- serveStatic(req, res);
1542
- }
1543
- });
1544
-
1545
- return server;
1546
- }
1547
-
1548
- // ─── Browser Auto-Open ─────────────────────────────────────────────────────
1549
-
1550
- function openBrowser(url) {
1551
- const cmd = process.platform === "win32" ? "start"
1552
- : process.platform === "darwin" ? "open" : "xdg-open";
1553
- exec(`${cmd} ${url}`, () => {}); // fire-and-forget
1554
- }
1555
-
1556
- // ─── Main ───────────────────────────────────────────────────────────────────
1557
-
1558
- /** Try to listen on a port. Resolves with the port on success, rejects on EADDRINUSE. */
1559
- function tryListen(server, port) {
1560
- return new Promise((resolve, reject) => {
1561
- const onError = (err) => {
1562
- server.removeListener("listening", onListening);
1563
- reject(err);
1564
- };
1565
- const onListening = () => {
1566
- server.removeListener("error", onError);
1567
- resolve(port);
1568
- };
1569
- server.once("error", onError);
1570
- server.once("listening", onListening);
1571
- server.listen(port);
1572
- });
1573
- }
1574
-
1575
- /** Find an available port starting from `start`, trying up to MAX_PORT_ATTEMPTS. */
1576
- async function findPort(server, start, explicit) {
1577
- // If the user explicitly passed --port, only try that one
1578
- if (explicit) {
1579
- try {
1580
- return await tryListen(server, start);
1581
- } catch (err) {
1582
- if (err.code === "EADDRINUSE") {
1583
- console.error(`\n Port ${start} is already in use.`);
1584
- console.error(` Try: taskplane dashboard --port ${start + 1}\n`);
1585
- process.exit(1);
1586
- }
1587
- throw err;
1588
- }
1589
- }
1590
- // Auto-scan for an available port
1591
- for (let port = start; port < start + MAX_PORT_ATTEMPTS; port++) {
1592
- try {
1593
- return await tryListen(server, port);
1594
- } catch (err) {
1595
- if (err.code === "EADDRINUSE") {
1596
- // Close the server so we can retry on the next port
1597
- server.close();
1598
- server = createServer();
1599
- continue;
1600
- }
1601
- throw err;
1602
- }
1603
- }
1604
- console.error(`\n No available port found in range ${start}-${start + MAX_PORT_ATTEMPTS - 1}.\n`);
1605
- process.exit(1);
1606
- }
1607
-
1608
- async function main() {
1609
- const opts = parseArgs();
1610
-
1611
- // Resolve project root: --root flag > cwd.
1612
- // In workspace mode this is the workspace root. All state/sidecar files
1613
- // (batch-state, lane-state, conversation logs, batch-history) live at
1614
- // <REPO_ROOT>/.pi/ and are NOT affected by taskplane-pointer.json.
1615
- // The pointer only redirects config/agent resolution in task-runner and
1616
- // orchestrator — the dashboard reads only runtime state, so no pointer
1617
- // resolution is performed here.
1618
- REPO_ROOT = path.resolve(opts.root || process.cwd());
1619
- BATCH_STATE_PATH = path.join(REPO_ROOT, ".pi", "batch-state.json");
1620
- BATCH_HISTORY_PATH = path.join(REPO_ROOT, ".pi", "batch-history.json");
1621
-
1622
- const server = createServer();
1623
- const explicitPort = process.argv.slice(2).includes("--port");
1624
- const port = await findPort(server, opts.port, explicitPort);
1625
-
1626
- console.log(`\n Orchestrator Dashboard → http://localhost:${port}\n`);
1627
-
1628
- // Broadcast state to all SSE clients on interval
1629
- const pollTimer = setInterval(broadcastState, POLL_INTERVAL);
1630
-
1631
- // Also watch batch-state.json for immediate push on change
1632
- try {
1633
- const batchDir = path.dirname(BATCH_STATE_PATH);
1634
- if (fs.existsSync(batchDir)) {
1635
- let debounce = null;
1636
- fs.watch(batchDir, (eventType, filename) => {
1637
- if (filename === "batch-state.json") {
1638
- clearTimeout(debounce);
1639
- debounce = setTimeout(broadcastState, 200);
1640
- }
1641
- });
1642
- }
1643
- } catch {
1644
- // fs.watch not supported — polling is sufficient
1645
- }
1646
-
1647
- // Auto-open browser
1648
- if (opts.open) {
1649
- setTimeout(() => openBrowser(`http://localhost:${port}`), 500);
1650
- }
1651
-
1652
- // Graceful shutdown
1653
- function cleanup() {
1654
- clearInterval(pollTimer);
1655
- for (const client of sseClients) {
1656
- try { client.end(); } catch {}
1657
- }
1658
- server.close();
1659
- process.exit(0);
1660
- }
1661
-
1662
- process.on("SIGINT", cleanup);
1663
- process.on("SIGTERM", cleanup);
1664
- }
1665
-
1666
- main();
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Orchestrator Web Dashboard — Local HTTP server with SSE live updates.
4
+ *
5
+ * Reads .pi/batch-state.json + STATUS.md files and streams state to the
6
+ * browser via Server-Sent Events. Zero external dependencies.
7
+ *
8
+ * Usage:
9
+ * node dashboard/server.cjs [--port 8099] [--root /path/to/project]
10
+ */
11
+
12
+ const http = require("http");
13
+ const fs = require("fs");
14
+ const path = require("path");
15
+ const { exec } = require("child_process");
16
+ // url module not needed — we parse with new URL() below
17
+
18
+ // ─── Configuration ──────────────────────────────────────────────────────────
19
+
20
+ const PUBLIC_DIR = path.join(__dirname, "public");
21
+ const DEFAULT_PORT = 8100;
22
+ const MAX_PORT_ATTEMPTS = 20;
23
+ const POLL_INTERVAL = 2000; // ms between state checks
24
+
25
+ // REPO_ROOT is resolved after parseArgs() — see initialization below.
26
+ // In workspace mode, REPO_ROOT is the workspace root (passed via --root).
27
+ // All dashboard state paths (batch-state, lane-state, conversation logs,
28
+ // batch-history) live at <REPO_ROOT>/.pi/ — this is runtime/sidecar state
29
+ // which does NOT follow the taskplane-pointer.json resolution chain.
30
+ // The pointer directs config/agent lookups to a config repo, but the
31
+ // dashboard only reads state files, so no pointer resolution is needed here.
32
+ let REPO_ROOT;
33
+ let BATCH_STATE_PATH;
34
+ let BATCH_HISTORY_PATH;
35
+
36
+ // ─── CLI Args ───────────────────────────────────────────────────────────────
37
+
38
+ function parseArgs() {
39
+ const args = process.argv.slice(2);
40
+ const opts = { port: DEFAULT_PORT, open: true, root: "" };
41
+ for (let i = 0; i < args.length; i++) {
42
+ if (args[i] === "--port" && args[i + 1]) {
43
+ opts.port = parseInt(args[i + 1]) || DEFAULT_PORT;
44
+ i++;
45
+ } else if (args[i] === "--root" && args[i + 1]) {
46
+ opts.root = args[i + 1];
47
+ i++;
48
+ } else if (args[i] === "--no-open") {
49
+ opts.open = false;
50
+ } else if (args[i] === "--help" || args[i] === "-h") {
51
+ console.log(`
52
+ Orchestrator Web Dashboard
53
+
54
+ Usage:
55
+ node dashboard/server.cjs [options]
56
+
57
+ Options:
58
+ --port <number> Port to listen on (default: ${DEFAULT_PORT})
59
+ --root <path> Project root directory (default: current directory)
60
+ --no-open Don't auto-open browser
61
+ -h, --help Show this help
62
+ `);
63
+ process.exit(0);
64
+ }
65
+ }
66
+ return opts;
67
+ }
68
+
69
+ // ─── Data Loading (ported from orch-dashboard.cjs) ──────────────────────────
70
+
71
+ function normalizeBatchStateIngress(state) {
72
+ if (!state || typeof state !== "object" || !Array.isArray(state.lanes)) {
73
+ return state;
74
+ }
75
+
76
+ for (const lane of state.lanes) {
77
+ if (!lane || typeof lane !== "object") continue;
78
+ // Legacy compatibility: older persisted states stored lane session IDs under
79
+ // `tmuxSessionName`. Normalize to `laneSessionId` at ingress and drop legacy key.
80
+ const laneSessionId = typeof lane.laneSessionId === "string"
81
+ ? lane.laneSessionId
82
+ : (typeof lane.tmuxSessionName === "string" ? lane.tmuxSessionName : undefined);
83
+ if (laneSessionId) {
84
+ lane.laneSessionId = laneSessionId;
85
+ }
86
+ if ("tmuxSessionName" in lane) {
87
+ delete lane.tmuxSessionName;
88
+ }
89
+ }
90
+
91
+ return state;
92
+ }
93
+
94
+ function loadBatchState() {
95
+ try {
96
+ const raw = fs.readFileSync(BATCH_STATE_PATH, "utf-8");
97
+ return normalizeBatchStateIngress(JSON.parse(raw));
98
+ } catch {
99
+ return null;
100
+ }
101
+ }
102
+
103
+ function resolveTaskFolder(task, state) {
104
+ if (!task || !task.taskFolder) return null;
105
+ const laneNum = task.laneNumber;
106
+ const lane = (state?.lanes || []).find((l) => l.laneNumber === laneNum);
107
+ if (!lane || !lane.worktreePath) return task.taskFolder;
108
+
109
+ // In workspace mode, the worktree is inside a specific repo, not the workspace root.
110
+ // The task folder path needs to be made relative to the repo root (parent of the worktree),
111
+ // not the workspace root. Detect this by finding the repo root from the worktree path.
112
+ const taskFolderAbs = path.resolve(task.taskFolder);
113
+ const worktreeAbs = path.resolve(lane.worktreePath);
114
+
115
+ // Try to find the repo root: walk up from the worktree path looking for which
116
+ // ancestor is a prefix of the task folder. The worktree is at <repoRoot>/.worktrees/<name>
117
+ // or a sibling, so the repo root is typically 2 levels up from a subdirectory worktree.
118
+ // Heuristic: find the longest common ancestor between taskFolder and worktree's repo root.
119
+ const repoRootAbs = path.resolve(REPO_ROOT);
120
+
121
+ // First try: relative to workspace root (works in repo mode where workspace = repo)
122
+ let rel = path.relative(repoRootAbs, taskFolderAbs);
123
+ if (!rel || rel.startsWith("..") || path.isAbsolute(rel)) return task.taskFolder;
124
+
125
+ // Check if joining with worktree produces a valid path
126
+ const candidate = path.join(worktreeAbs, rel);
127
+ try {
128
+ if (fs.existsSync(candidate)) return candidate;
129
+ } catch { /* fall through */ }
130
+
131
+ // Second try: the worktree is inside a repo subdirectory of the workspace root.
132
+ // Strip the repo prefix from the task folder path to get the repo-relative path.
133
+ // e.g., taskFolder = "workspace/platform-docs/task-mgmt/DOC-001/"
134
+ // worktree = "workspace/platform-docs/.worktrees/wt-1/"
135
+ // repo-relative = "task-mgmt/DOC-001/"
136
+ // Find the repo by checking which workspace repo path is a prefix of the task folder.
137
+ const repoRoots = [];
138
+ try {
139
+ const stateMode = state.mode;
140
+ if (stateMode === "workspace" && state.repos) {
141
+ for (const r of state.repos) repoRoots.push(path.resolve(r.path));
142
+ }
143
+ } catch { /* no repo info in state */ }
144
+
145
+ // Also try inferring repo root from worktree path pattern:
146
+ // .worktrees/<name> → parent is repo root; sibling worktrees → shared parent
147
+ const worktreeParent = path.dirname(worktreeAbs);
148
+ const worktreeGrandparent = path.dirname(worktreeParent);
149
+ for (const possibleRepoRoot of [worktreeGrandparent, ...repoRoots]) {
150
+ const repoRel = path.relative(possibleRepoRoot, taskFolderAbs);
151
+ if (repoRel && !repoRel.startsWith("..") && !path.isAbsolute(repoRel)) {
152
+ const repoCandidate = path.join(worktreeAbs, repoRel);
153
+ try {
154
+ if (fs.existsSync(repoCandidate)) return repoCandidate;
155
+ } catch { continue; }
156
+ }
157
+ }
158
+
159
+ // Fallback: return original task folder (might work if not in worktree)
160
+ return task.taskFolder;
161
+ }
162
+
163
+ function parseStatusMd(taskFolder) {
164
+ const candidates = [taskFolder];
165
+ const taskId = path.basename(taskFolder);
166
+ const archiveBase = taskFolder.replace(/[/\\]tasks[/\\][^/\\]+$/, "/tasks/archive/" + taskId);
167
+ if (archiveBase !== taskFolder) candidates.push(archiveBase);
168
+
169
+ for (const folder of candidates) {
170
+ const statusPath = path.join(folder, "STATUS.md");
171
+ try {
172
+ const content = fs.readFileSync(statusPath, "utf-8");
173
+ const stepMatch = content.match(/\*\*Current Step:\*\*\s*(.+)/);
174
+ const statusMatch = content.match(/\*\*Status:\*\*\s*(.+)/);
175
+ const iterMatch = content.match(/\*\*Iteration:\*\*\s*(\d+)/);
176
+ const reviewMatch = content.match(/\*\*Review Counter:\*\*\s*(\d+)/);
177
+ const checked = (content.match(/- \[x\]/gi) || []).length;
178
+ const unchecked = (content.match(/- \[ \]/g) || []).length;
179
+ const total = checked + unchecked;
180
+ return {
181
+ currentStep: stepMatch ? stepMatch[1].trim() : "Unknown",
182
+ status: statusMatch ? statusMatch[1].trim() : "Unknown",
183
+ iteration: iterMatch ? parseInt(iterMatch[1]) : 0,
184
+ reviews: reviewMatch ? parseInt(reviewMatch[1]) : 0,
185
+ checked,
186
+ total,
187
+ progress: total > 0 ? Math.round((checked / total) * 100) : 0,
188
+ };
189
+ } catch {
190
+ continue;
191
+ }
192
+ }
193
+ return null;
194
+ }
195
+
196
+ function getActiveSessions() {
197
+ // Runtime V2: return active merger session names from the runtime registry
198
+ // so the dashboard merge pane can display live telemetry for running agents.
199
+ // Terminal statuses indicate the agent is no longer alive.
200
+ const TERMINAL_STATUSES = new Set(["exited", "killed", "crashed", "timed_out"]);
201
+ try {
202
+ const state = loadBatchState();
203
+ if (!state || !state.batchId) return [];
204
+ const registry = loadRuntimeRegistry(state.batchId);
205
+ if (!registry || !registry.agents) return [];
206
+ return Object.values(registry.agents)
207
+ .filter(a => a.role === "merger" && !TERMINAL_STATUSES.has(a.status))
208
+ .map(a => a.agentId);
209
+ } catch {
210
+ return [];
211
+ }
212
+ }
213
+
214
+ function checkDoneFile(taskFolder) {
215
+ const candidates = [taskFolder];
216
+ const taskId = path.basename(taskFolder);
217
+ const archiveBase = taskFolder.replace(/[/\\]tasks[/\\][^/\\]+$/, "/tasks/archive/" + taskId);
218
+ if (archiveBase !== taskFolder) candidates.push(archiveBase);
219
+ for (const folder of candidates) {
220
+ if (fs.existsSync(path.join(folder, ".DONE"))) return true;
221
+ }
222
+ return false;
223
+ }
224
+
225
+ /** Read lane state sidecar JSON files written by the task-runner. */
226
+ function loadLaneStates() {
227
+ const piDir = path.join(REPO_ROOT, ".pi");
228
+ const states = {};
229
+ try {
230
+ const files = fs.readdirSync(piDir).filter(f => f.startsWith("lane-state-") && f.endsWith(".json"));
231
+ for (const file of files) {
232
+ try {
233
+ const raw = fs.readFileSync(path.join(piDir, file), "utf-8").trim();
234
+ if (!raw) continue;
235
+ const data = JSON.parse(raw);
236
+ if (data.prefix) states[data.prefix] = data;
237
+ } catch { continue; }
238
+ }
239
+ } catch { /* .pi dir may not exist */ }
240
+ return states;
241
+ }
242
+
243
+ // ─── Telemetry JSONL Tailing ────────────────────────────────────────────────
244
+
245
+ /**
246
+ * Module-level tail state for incremental JSONL reading.
247
+ * Persists across poll ticks within this server process.
248
+ * Key: absolute file path → { offset, partial }
249
+ */
250
+ const telemetryTailStates = new Map();
251
+
252
+ /**
253
+ * Module-level accumulated telemetry per session prefix.
254
+ * Persists across poll ticks so incremental tail reads accumulate correctly.
255
+ * Key: session prefix → { inputTokens, outputTokens, ... }
256
+ */
257
+ const telemetryAccumulators = new Map();
258
+
259
+ /**
260
+ * Tracks which files are currently contributing to each prefix.
261
+ * Key: session prefix → Set of absolute file paths
262
+ * Used to detect file rotation: when files change, accumulator is reset.
263
+ */
264
+ const telemetryPrefixFiles = new Map();
265
+
266
+ /**
267
+ * Parse a telemetry JSONL filename to extract lane number and role.
268
+ * Pattern: {opId}-{batchId}-{repoId}[-{taskId}][-lane-{N}]-{role}.jsonl
269
+ * Roles: worker, reviewer, merger
270
+ * Returns { laneNumber: number|null, role: string, mergeNumber: number|null } or null if unparseable.
271
+ */
272
+ function parseTelemetryFilename(filename) {
273
+ // Remove .jsonl extension
274
+ const base = filename.replace(/\.jsonl$/, "");
275
+ // Role is always the last segment
276
+ const lastDash = base.lastIndexOf("-");
277
+ if (lastDash < 0) return null;
278
+ const role = base.slice(lastDash + 1);
279
+ if (role !== "worker" && role !== "reviewer" && role !== "merger") return null;
280
+
281
+ // Extract lane number from -lane-{N}- pattern
282
+ const laneMatch = base.match(/-lane-(\d+)-/);
283
+ const laneNumber = laneMatch ? parseInt(laneMatch[1], 10) : null;
284
+
285
+ // Extract merge number from -merge-{N}- pattern (merge agents)
286
+ const mergeMatch = base.match(/-merge-(\d+)-/);
287
+ const mergeNumber = mergeMatch ? parseInt(mergeMatch[1], 10) : null;
288
+
289
+ return { laneNumber, role, mergeNumber };
290
+ }
291
+
292
+ /**
293
+ * Incrementally read new bytes from a JSONL file, parse events, and return them.
294
+ * Handles: file not yet created, empty reads, partial trailing lines, malformed JSON.
295
+ * @param {string} filePath - Absolute path to the JSONL file
296
+ * @returns {object[]} Array of parsed event objects from new data
297
+ */
298
+ function tailJsonlFile(filePath) {
299
+ // Get or create tail state for this file
300
+ let tailState = telemetryTailStates.get(filePath);
301
+ if (!tailState) {
302
+ tailState = { offset: 0, partial: "" };
303
+ telemetryTailStates.set(filePath, tailState);
304
+ }
305
+
306
+ // Check file size
307
+ let fileSize;
308
+ try {
309
+ fileSize = fs.statSync(filePath).size;
310
+ } catch {
311
+ return []; // File doesn't exist yet
312
+ }
313
+
314
+ // Handle file truncation/recreation (offset beyond current size)
315
+ if (fileSize < tailState.offset) {
316
+ tailState.offset = 0;
317
+ tailState.partial = "";
318
+ tailState.wasReset = true; // Signal to caller that accumulator should be reset
319
+ }
320
+
321
+ if (fileSize <= tailState.offset) {
322
+ return []; // No new data
323
+ }
324
+
325
+ // Cap read size per tick to avoid ERR_STRING_TOO_LONG on large files.
326
+ // If there's more data remaining, the next SSE tick will pick up the rest.
327
+ const MAX_TAIL_BYTES = 10 * 1024 * 1024; // 10 MB per tick
328
+
329
+ // Skip-to-tail on fresh dashboard start with large files.
330
+ // The partial-line handling below already discards the first partial line.
331
+ if (tailState.offset === 0 && fileSize > MAX_TAIL_BYTES) {
332
+ tailState.offset = fileSize - MAX_TAIL_BYTES;
333
+ }
334
+
335
+ // Read new bytes from offset, capped to MAX_TAIL_BYTES
336
+ const bytesToRead = Math.min(fileSize - tailState.offset, MAX_TAIL_BYTES);
337
+ const buf = Buffer.alloc(bytesToRead);
338
+ let fd;
339
+ try {
340
+ fd = fs.openSync(filePath, "r");
341
+ } catch {
342
+ return []; // File became inaccessible
343
+ }
344
+ try {
345
+ fs.readSync(fd, buf, 0, bytesToRead, tailState.offset);
346
+ } catch {
347
+ fs.closeSync(fd);
348
+ return []; // Read error — try again next tick
349
+ }
350
+ fs.closeSync(fd);
351
+ tailState.offset += bytesToRead;
352
+
353
+ // Split into lines, preserving partial trailing line
354
+ const chunk = tailState.partial + buf.toString("utf-8");
355
+ const lines = chunk.split("\n");
356
+ tailState.partial = lines.pop() || "";
357
+
358
+ const events = [];
359
+ for (const line of lines) {
360
+ const trimmed = line.trim();
361
+ if (!trimmed) continue;
362
+ try {
363
+ const event = JSON.parse(trimmed);
364
+ if (event && event.type) events.push(event);
365
+ } catch {
366
+ // Malformed JSON — skip (concurrent write race, truncated line)
367
+ }
368
+ }
369
+ return events;
370
+ }
371
+
372
+ /**
373
+ * Load and accumulate telemetry from .pi/telemetry/*.jsonl files.
374
+ * Returns telemetry keyed by session prefix (e.g., "orch-lane-1").
375
+ *
376
+ * Uses batch-state lanes to map lane numbers → session prefixes.
377
+ * For standalone /task mode (no lane number in filename), data is keyed as "standalone".
378
+ *
379
+ * @param {object|null} batchState - The batch state from batch-state.json
380
+ * @returns {object} Map of sessionPrefix → accumulated telemetry
381
+ */
382
+
383
+ // ── Runtime V2 Data Loaders (TP-107) ─────────────────────────────
384
+
385
+ /**
386
+ * Load the Runtime V2 process registry for the current batch.
387
+ * Returns null if no registry exists (legacy batch).
388
+ */
389
+ function loadRuntimeRegistry(batchId) {
390
+ if (!batchId) return null;
391
+ const registryPath = path.join(REPO_ROOT, ".pi", "runtime", batchId, "registry.json");
392
+ try {
393
+ if (!fs.existsSync(registryPath)) return null;
394
+ return JSON.parse(fs.readFileSync(registryPath, "utf-8"));
395
+ } catch {
396
+ return null;
397
+ }
398
+ }
399
+
400
+ /**
401
+ * Load Runtime V2 lane snapshots for the current batch.
402
+ * Returns a map of laneNumber → snapshot data.
403
+ */
404
+ function loadRuntimeLaneSnapshots(batchId) {
405
+ if (!batchId) return {};
406
+ const lanesDir = path.join(REPO_ROOT, ".pi", "runtime", batchId, "lanes");
407
+ const snapshots = {};
408
+ try {
409
+ if (!fs.existsSync(lanesDir)) return snapshots;
410
+ const files = fs.readdirSync(lanesDir).filter(f => f.startsWith("lane-") && f.endsWith(".json"));
411
+ for (const file of files) {
412
+ try {
413
+ const data = JSON.parse(fs.readFileSync(path.join(lanesDir, file), "utf-8"));
414
+ if (data.laneNumber != null) snapshots[data.laneNumber] = data;
415
+ } catch { continue; }
416
+ }
417
+ } catch { /* dir missing */ }
418
+ return snapshots;
419
+ }
420
+
421
+ /**
422
+ * Load Runtime V2 merge agent snapshots for the current batch.
423
+ *
424
+ * Reads all `merge-N.json` files from `.pi/runtime/{batchId}/lanes/`.
425
+ * Returns a map of mergeNumber (string) → snapshot data.
426
+ *
427
+ * Follows the same pattern as {@link loadRuntimeLaneSnapshots}.
428
+ *
429
+ * @since TP-164
430
+ */
431
+ function loadRuntimeMergeSnapshots(batchId) {
432
+ if (!batchId) return {};
433
+ const lanesDir = path.join(REPO_ROOT, ".pi", "runtime", batchId, "lanes");
434
+ const snapshots = {};
435
+ try {
436
+ if (!fs.existsSync(lanesDir)) return snapshots;
437
+ const files = fs.readdirSync(lanesDir).filter(f => f.startsWith("merge-") && f.endsWith(".json"));
438
+ for (const file of files) {
439
+ try {
440
+ const data = JSON.parse(fs.readFileSync(path.join(lanesDir, file), "utf-8"));
441
+ if (data.mergeNumber != null) snapshots[data.mergeNumber] = data;
442
+ } catch { continue; }
443
+ }
444
+ } catch { /* dir missing */ }
445
+ return snapshots;
446
+ }
447
+
448
+ /**
449
+ * Load Runtime V2 agent events for a specific agent.
450
+ * Returns the last N events from the agent's events.jsonl.
451
+ */
452
+ function loadRuntimeAgentEvents(batchId, agentId, maxEvents) {
453
+ if (!batchId || !agentId) return [];
454
+ maxEvents = maxEvents || 200;
455
+ const eventsPath = path.join(REPO_ROOT, ".pi", "runtime", batchId, "agents", agentId, "events.jsonl");
456
+ try {
457
+ if (!fs.existsSync(eventsPath)) return [];
458
+ const raw = fs.readFileSync(eventsPath, "utf-8");
459
+ const lines = raw.split("\n").filter(l => l.trim());
460
+ const events = [];
461
+ const start = Math.max(0, lines.length - maxEvents);
462
+ for (let i = start; i < lines.length; i++) {
463
+ try { events.push(JSON.parse(lines[i])); } catch { continue; }
464
+ }
465
+ return events;
466
+ } catch {
467
+ return [];
468
+ }
469
+ }
470
+
471
+ /**
472
+ * Load mailbox message activity for the current batch.
473
+ *
474
+ * TP-093 hardening: event-authoritative model.
475
+ * Primary source: .pi/mailbox/{batchId}/events.jsonl (audit event stream).
476
+ * Fallback: directory scans (inbox/ack/outbox/outbox/processed) for
477
+ * compatibility when events.jsonl is absent.
478
+ *
479
+ * Includes:
480
+ * - Consumed replies (outbox/processed/) so they don't disappear after ack
481
+ * - Per-recipient broadcast delivery state from ack markers
482
+ * - Rate-limited events in the timeline
483
+ */
484
+ function loadMailboxData(batchId) {
485
+ if (!batchId) return { messages: [], agentIds: [], auditEvents: [] };
486
+ const mbRoot = path.join(REPO_ROOT, ".pi", "mailbox", batchId);
487
+ if (!fs.existsSync(mbRoot)) return { messages: [], agentIds: [], auditEvents: [] };
488
+
489
+ // ── Primary: events.jsonl audit trail ──
490
+ const auditEvents = loadMailboxAuditEvents(mbRoot);
491
+
492
+ // ── Fallback: directory scan ──
493
+ const messages = [];
494
+ const agentIds = [];
495
+
496
+ try {
497
+ const dirs = fs.readdirSync(mbRoot, { withFileTypes: true })
498
+ .filter(d => d.isDirectory())
499
+ .map(d => d.name);
500
+
501
+ for (const agentDir of dirs) {
502
+ if (agentDir === "_broadcast") continue;
503
+ agentIds.push(agentDir);
504
+
505
+ // Scan inbox (pending), ack (delivered), outbox (active replies), outbox/processed (consumed replies)
506
+ for (const subdir of ["inbox", "ack", "outbox", "outbox/processed"]) {
507
+ const dir = path.join(mbRoot, agentDir, subdir);
508
+ if (!fs.existsSync(dir)) continue;
509
+ try {
510
+ const files = fs.readdirSync(dir).filter(f => f.endsWith(".msg.json"));
511
+ for (const file of files) {
512
+ try {
513
+ const msg = JSON.parse(fs.readFileSync(path.join(dir, file), "utf-8"));
514
+ let status;
515
+ if (subdir === "inbox") status = "pending";
516
+ else if (subdir === "ack") status = "delivered";
517
+ else if (subdir === "outbox") status = "reply";
518
+ else status = "reply-acked"; // outbox/processed
519
+ const isBroadcast = msg.to === "_broadcast";
520
+ messages.push({ ...msg, _status: status, _agentDir: agentDir, _isBroadcast: isBroadcast });
521
+ } catch { continue; }
522
+ }
523
+ } catch { continue; }
524
+ }
525
+ }
526
+
527
+ // _broadcast: per-recipient delivery state
528
+ const broadcastInbox = path.join(mbRoot, "_broadcast", "inbox");
529
+ const broadcastAck = path.join(mbRoot, "_broadcast", "ack");
530
+ for (const [dir, status] of [[broadcastInbox, "pending"], [broadcastAck, "delivered"]]) {
531
+ if (!fs.existsSync(dir)) continue;
532
+ try {
533
+ const files = fs.readdirSync(dir).filter(f => f.endsWith(".msg.json"));
534
+ for (const file of files) {
535
+ try {
536
+ const msg = JSON.parse(fs.readFileSync(path.join(dir, file), "utf-8"));
537
+ messages.push({ ...msg, _status: status, _agentDir: "_broadcast", _isBroadcast: true });
538
+ } catch { continue; }
539
+ }
540
+ } catch { continue; }
541
+ }
542
+ } catch { /* mailbox dir issues */ }
543
+
544
+ messages.sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0));
545
+ return { messages, agentIds, auditEvents };
546
+ }
547
+
548
+ /**
549
+ * Load mailbox audit events from events.jsonl.
550
+ * Returns events sorted by timestamp. Includes: message_sent, message_delivered,
551
+ * message_replied, message_escalated, message_rate_limited.
552
+ */
553
+ function loadMailboxAuditEvents(mbRoot) {
554
+ const eventsPath = path.join(mbRoot, "events.jsonl");
555
+ if (!fs.existsSync(eventsPath)) return [];
556
+ try {
557
+ const raw = fs.readFileSync(eventsPath, "utf-8");
558
+ const events = [];
559
+ for (const line of raw.split("\n")) {
560
+ if (!line.trim()) continue;
561
+ try { events.push(JSON.parse(line)); } catch { continue; }
562
+ }
563
+ return events;
564
+ } catch {
565
+ return [];
566
+ }
567
+ }
568
+
569
+ // ─────────────────────────────────────────────────────────────────────
570
+ function loadTelemetryData(batchState) {
571
+ const telemetryDir = path.join(REPO_ROOT, ".pi", "telemetry");
572
+ const result = {};
573
+
574
+ // Build lane number → session prefix mapping from batch state
575
+ const laneToPrefix = {};
576
+ if (batchState && batchState.lanes) {
577
+ for (const lane of batchState.lanes) {
578
+ const laneSessionId = lane.laneSessionId;
579
+ if (lane.laneNumber != null && laneSessionId) {
580
+ laneToPrefix[lane.laneNumber] = laneSessionId;
581
+ }
582
+ }
583
+ }
584
+
585
+ // Scan telemetry directory for JSONL files
586
+ let files;
587
+ try {
588
+ files = fs.readdirSync(telemetryDir).filter(f => f.endsWith(".jsonl"));
589
+ } catch {
590
+ // .pi/telemetry/ may not exist (pre-RPC sessions) — degrade gracefully
591
+ return result;
592
+ }
593
+
594
+ // Track which files still exist for tail-state cleanup
595
+ const currentFiles = new Set();
596
+ // Track current file→prefix mapping to detect file rotation
597
+ const currentPrefixFiles = new Map(); // prefix → Set<filePath>
598
+
599
+ for (const file of files) {
600
+ const filePath = path.join(telemetryDir, file);
601
+ currentFiles.add(filePath);
602
+
603
+ // Parse filename to get lane number and role
604
+ const parsed = parseTelemetryFilename(file);
605
+ if (!parsed) continue;
606
+
607
+ // Determine the key (session prefix)
608
+ let prefix;
609
+ if (parsed.role === "merger") {
610
+ // Merge agent — derive prefix from lane naming so it matches the
611
+ // session ID used by the client (e.g. "orch-henrylach-merge-1").
612
+ // Lane sessions: "orch-{opId}-lane-{N}" → merge sessions: "orch-{opId}-merge-{N}".
613
+ const firstLanePrefix = Object.values(laneToPrefix)[0]; // e.g. "orch-henrylach-lane-1"
614
+ const opPrefix = firstLanePrefix?.replace(/-lane-\d+$/, ""); // "orch-henrylach"
615
+ if (parsed.mergeNumber != null && opPrefix) {
616
+ prefix = `${opPrefix}-merge-${parsed.mergeNumber}`;
617
+ } else if (parsed.mergeNumber != null) {
618
+ prefix = `orch-merge-${parsed.mergeNumber}`;
619
+ } else {
620
+ prefix = "orch-merge";
621
+ }
622
+ } else if (parsed.laneNumber != null && laneToPrefix[parsed.laneNumber]) {
623
+ prefix = laneToPrefix[parsed.laneNumber];
624
+ } else if (parsed.laneNumber != null) {
625
+ // Lane number found but no batch-state mapping — use heuristic
626
+ prefix = `orch-lane-${parsed.laneNumber}`;
627
+ } else {
628
+ // Standalone /task mode
629
+ prefix = "standalone";
630
+ }
631
+
632
+ // Track file→prefix mapping
633
+ if (!currentPrefixFiles.has(prefix)) currentPrefixFiles.set(prefix, new Set());
634
+ currentPrefixFiles.get(prefix).add(filePath);
635
+
636
+ // Check if file set for this prefix has changed (file rotation)
637
+ const prevFiles = telemetryPrefixFiles.get(prefix);
638
+ const isNewFile = !prevFiles || !prevFiles.has(filePath);
639
+
640
+ // Initialize persistent accumulator for this prefix if needed,
641
+ // or reset if files changed (new file appeared for same prefix)
642
+ if (!telemetryAccumulators.has(prefix) || (isNewFile && !telemetryTailStates.has(filePath))) {
643
+ const fresh = {
644
+ inputTokens: 0, outputTokens: 0, cacheReadTokens: 0,
645
+ cacheWriteTokens: 0, cost: 0, toolCalls: 0,
646
+ lastTool: "", currentTool: "", retries: 0, retryActive: false,
647
+ lastRetryError: "", compactions: 0, latestTotalTokens: 0,
648
+ contextPct: 0, startedAt: 0,
649
+ };
650
+ telemetryAccumulators.set(prefix, fresh);
651
+ // Also reset tail states for ALL files of this prefix to re-read from beginning
652
+ if (prevFiles) {
653
+ for (const pf of prevFiles) {
654
+ telemetryTailStates.delete(pf);
655
+ }
656
+ }
657
+ }
658
+
659
+ const acc = telemetryAccumulators.get(prefix);
660
+ result[prefix] = acc; // expose the persistent accumulator in the result
661
+
662
+ // Tail the file for new events
663
+ const events = tailJsonlFile(filePath);
664
+
665
+ // Check if file was truncated — reset accumulator
666
+ const ts = telemetryTailStates.get(filePath);
667
+ if (ts && ts.wasReset) {
668
+ acc.inputTokens = 0; acc.outputTokens = 0; acc.cacheReadTokens = 0;
669
+ acc.cacheWriteTokens = 0; acc.cost = 0; acc.toolCalls = 0;
670
+ acc.lastTool = ""; acc.currentTool = ""; acc.retries = 0; acc.retryActive = false;
671
+ acc.lastRetryError = ""; acc.compactions = 0; acc.latestTotalTokens = 0;
672
+ acc.contextPct = 0; acc.startedAt = 0;
673
+ ts.wasReset = false;
674
+ }
675
+ for (const event of events) {
676
+ switch (event.type) {
677
+ case "message_end": {
678
+ // A successful message_end means any prior retry resolved.
679
+ // Clear retryActive to prevent stale retry badges from persisting
680
+ // across batches or after transient API errors recover.
681
+ acc.retryActive = false;
682
+ const usage = event.message?.usage;
683
+ if (usage) {
684
+ acc.inputTokens += usage.input || 0;
685
+ acc.outputTokens += usage.output || 0;
686
+ acc.cacheReadTokens += usage.cacheRead || 0;
687
+ acc.cacheWriteTokens += usage.cacheWrite || 0;
688
+ if (usage.cost) {
689
+ acc.cost += typeof usage.cost === "object"
690
+ ? (usage.cost.total || 0)
691
+ : (typeof usage.cost === "number" ? usage.cost : 0);
692
+ }
693
+ // Include cacheRead: totalTokens from pi excludes cache reads,
694
+ // but cached tokens still consume context window capacity.
695
+ const rawTotal = usage.totalTokens
696
+ || ((usage.input || 0) + (usage.output || 0));
697
+ const totalTokens = rawTotal + (usage.cacheRead || 0);
698
+ if (totalTokens > acc.latestTotalTokens) {
699
+ acc.latestTotalTokens = totalTokens;
700
+ }
701
+ }
702
+ break;
703
+ }
704
+ case "tool_execution_start": {
705
+ acc.toolCalls++;
706
+ const toolDesc = event.toolName || "unknown";
707
+ let argPreview = "";
708
+ if (event.args) {
709
+ if (typeof event.args === "string") {
710
+ argPreview = event.args.slice(0, 80);
711
+ } else if (typeof event.args === "object") {
712
+ const firstVal = Object.values(event.args)[0];
713
+ if (typeof firstVal === "string") {
714
+ argPreview = firstVal.slice(0, 80);
715
+ }
716
+ }
717
+ }
718
+ const toolLabel = argPreview ? `${toolDesc} ${argPreview}` : toolDesc;
719
+ acc.lastTool = toolLabel;
720
+ acc.currentTool = toolLabel;
721
+ break;
722
+ }
723
+ case "tool_execution_end": {
724
+ acc.currentTool = "";
725
+ break;
726
+ }
727
+ case "agent_start": {
728
+ if (event.ts && !acc.startedAt) {
729
+ acc.startedAt = event.ts;
730
+ }
731
+ break;
732
+ }
733
+ case "response": {
734
+ // Extract context usage from get_session_stats responses
735
+ const ctxUsage = event.data?.contextUsage;
736
+ if (ctxUsage) {
737
+ // Support both percent (current) and percentUsed (legacy pi versions)
738
+ const pct = typeof ctxUsage.percent === "number" ? ctxUsage.percent
739
+ : typeof ctxUsage.percentUsed === "number" ? ctxUsage.percentUsed
740
+ : null;
741
+ if (pct !== null) acc.contextPct = pct;
742
+ }
743
+ break;
744
+ }
745
+ case "auto_retry_start": {
746
+ acc.retries++;
747
+ acc.retryActive = true;
748
+ acc.lastRetryError = event.errorMessage || event.error || "unknown";
749
+ break;
750
+ }
751
+ case "auto_retry_end": {
752
+ acc.retryActive = false;
753
+ break;
754
+ }
755
+ case "auto_compaction_start": {
756
+ acc.compactions++;
757
+ break;
758
+ }
759
+ }
760
+ }
761
+ }
762
+
763
+ // Clean up tail states for files that no longer exist
764
+ for (const [filePath] of telemetryTailStates) {
765
+ if (filePath.startsWith(telemetryDir) && !currentFiles.has(filePath)) {
766
+ telemetryTailStates.delete(filePath);
767
+ }
768
+ }
769
+
770
+ // Update prefix→files tracking for next call
771
+ // Clean up accumulators and tracking for prefixes that have no remaining files
772
+ const activePrefixes = new Set(Object.keys(result));
773
+ for (const [prefix] of telemetryAccumulators) {
774
+ if (!activePrefixes.has(prefix)) {
775
+ telemetryAccumulators.delete(prefix);
776
+ telemetryPrefixFiles.delete(prefix);
777
+ }
778
+ }
779
+ // Store current file mappings for next call's rotation detection
780
+ for (const [prefix, fileSet] of currentPrefixFiles) {
781
+ telemetryPrefixFiles.set(prefix, fileSet);
782
+ }
783
+
784
+ return result;
785
+ }
786
+
787
+ // ─── Supervisor Data Loading ────────────────────────────────────────────────
788
+
789
+ /**
790
+ * Module-level tail state for supervisor JSONL files (actions.jsonl, events.jsonl).
791
+ * Reuses the same incremental tailing pattern as telemetry.
792
+ * Key: absolute file path → { offset, partial, entries }
793
+ */
794
+ const supervisorTailStates = {
795
+ actions: { offset: 0, partial: "", entries: [] },
796
+ events: { offset: 0, partial: "", entries: [] },
797
+ conversation: { offset: 0, partial: "", entries: [] },
798
+ };
799
+
800
+ /**
801
+ * The last known batchId — used to detect batch changes and reset accumulators.
802
+ */
803
+ let supervisorLastBatchId = "";
804
+
805
+ /**
806
+ * Incrementally tail a JSONL file, accumulating parsed entries.
807
+ * Filters entries by batchId when provided.
808
+ *
809
+ * @param {string} filePath - Absolute path to the JSONL file
810
+ * @param {object} tailState - Mutable tail state { offset, partial, entries }
811
+ * @param {string} batchId - Batch ID to filter by (empty = no filter)
812
+ * @returns {object[]} The accumulated entries array (same reference as tailState.entries)
813
+ */
814
+ function tailSupervisorJsonl(filePath, tailState, batchId) {
815
+ // Check file size
816
+ let fileSize;
817
+ try {
818
+ fileSize = fs.statSync(filePath).size;
819
+ } catch {
820
+ return tailState.entries; // File doesn't exist yet — return accumulated
821
+ }
822
+
823
+ // Handle file truncation/recreation
824
+ if (fileSize < tailState.offset) {
825
+ tailState.offset = 0;
826
+ tailState.partial = "";
827
+ tailState.entries = [];
828
+ }
829
+
830
+ if (fileSize <= tailState.offset) {
831
+ return tailState.entries; // No new data
832
+ }
833
+
834
+ // Read new bytes from offset
835
+ const bytesToRead = fileSize - tailState.offset;
836
+ const buf = Buffer.alloc(bytesToRead);
837
+ let fd;
838
+ try {
839
+ fd = fs.openSync(filePath, "r");
840
+ } catch {
841
+ return tailState.entries;
842
+ }
843
+ try {
844
+ fs.readSync(fd, buf, 0, bytesToRead, tailState.offset);
845
+ } catch {
846
+ fs.closeSync(fd);
847
+ return tailState.entries;
848
+ }
849
+ fs.closeSync(fd);
850
+ tailState.offset = fileSize;
851
+
852
+ // Split into lines, preserving partial trailing line
853
+ const chunk = tailState.partial + buf.toString("utf-8");
854
+ const lines = chunk.split("\n");
855
+ tailState.partial = lines.pop() || "";
856
+
857
+ for (const line of lines) {
858
+ const trimmed = line.trim();
859
+ if (!trimmed) continue;
860
+ try {
861
+ const entry = JSON.parse(trimmed);
862
+ // Filter by batchId if provided
863
+ if (batchId && entry.batchId && entry.batchId !== batchId) continue;
864
+ tailState.entries.push(entry);
865
+ } catch {
866
+ // Malformed JSON — skip
867
+ }
868
+ }
869
+
870
+ // Cap accumulated entries to prevent unbounded growth (keep last 500)
871
+ if (tailState.entries.length > 500) {
872
+ tailState.entries = tailState.entries.slice(-500);
873
+ }
874
+
875
+ return tailState.entries;
876
+ }
877
+
878
+ /**
879
+ * Read supervisor autonomy level from project config.
880
+ *
881
+ * Checks `.pi/taskplane-config.json` for `orchestrator.supervisor.autonomy`.
882
+ * Falls back to "supervised" (the default) if config is missing or malformed.
883
+ * This is needed because the lockfile does not contain the autonomy level.
884
+ *
885
+ * @returns {string} Autonomy level: "interactive" | "supervised" | "autonomous"
886
+ */
887
+ function loadSupervisorAutonomy() {
888
+ try {
889
+ const configPath = path.join(REPO_ROOT, ".pi", "taskplane-config.json");
890
+ const raw = fs.readFileSync(configPath, "utf-8");
891
+ const config = JSON.parse(raw);
892
+ const autonomy = config?.orchestrator?.supervisor?.autonomy;
893
+ if (autonomy === "interactive" || autonomy === "supervised" || autonomy === "autonomous") {
894
+ return autonomy;
895
+ }
896
+ } catch {
897
+ // Config missing or malformed — use default
898
+ }
899
+ return "supervised"; // Default per DEFAULT_SUPERVISOR_CONFIG
900
+ }
901
+
902
+ /**
903
+ * Load supervisor data for the dashboard.
904
+ *
905
+ * Reads (all from .pi/supervisor/):
906
+ * - lock.json: supervisor active/stale status, heartbeat, autonomy (from config)
907
+ * - actions.jsonl: recovery action audit trail (batch-scoped, incremental)
908
+ * - events.jsonl: engine + tier 0 events (batch-scoped, incremental)
909
+ * - conversation.jsonl: operator ↔ supervisor interaction log (spec §9.1)
910
+ * - summary.md: human-readable batch summary (generated on completion)
911
+ *
912
+ * Returns null when no supervisor files exist (pre-supervisor batches).
913
+ *
914
+ * @param {object|null} batchState - The batch state from batch-state.json
915
+ * @returns {object|null} Supervisor data object or null
916
+ */
917
+ function loadSupervisorData(batchState) {
918
+ const supervisorDir = path.join(REPO_ROOT, ".pi", "supervisor");
919
+ const batchId = batchState ? (batchState.batchId || "") : "";
920
+
921
+ // Detect batch change — reset tail state accumulators
922
+ if (batchId && batchId !== supervisorLastBatchId) {
923
+ supervisorLastBatchId = batchId;
924
+ supervisorTailStates.actions = { offset: 0, partial: "", entries: [] };
925
+ supervisorTailStates.events = { offset: 0, partial: "", entries: [] };
926
+ supervisorTailStates.conversation = { offset: 0, partial: "", entries: [] };
927
+ }
928
+
929
+ // ── Lockfile: supervisor status ──
930
+ // The lockfile contains pid, sessionId, batchId, startedAt, heartbeat.
931
+ // It does NOT contain autonomy — that comes from project config.
932
+ let lock = null;
933
+ try {
934
+ const lockPath = path.join(supervisorDir, "lock.json");
935
+ const raw = fs.readFileSync(lockPath, "utf-8");
936
+ const parsed = JSON.parse(raw);
937
+ if (parsed && parsed.pid && parsed.sessionId) {
938
+ // Determine if lock is stale (heartbeat older than 90s)
939
+ const heartbeatAge = parsed.heartbeat
940
+ ? Date.now() - new Date(parsed.heartbeat).getTime()
941
+ : Infinity;
942
+ const isStale = heartbeatAge > 90_000;
943
+
944
+ lock = {
945
+ active: !isStale,
946
+ pid: parsed.pid,
947
+ sessionId: parsed.sessionId,
948
+ batchId: parsed.batchId || "",
949
+ startedAt: parsed.startedAt || "",
950
+ heartbeat: parsed.heartbeat || "",
951
+ // Autonomy is NOT in the lockfile — derive from project config
952
+ autonomy: loadSupervisorAutonomy(),
953
+ };
954
+ }
955
+ } catch {
956
+ // No lockfile or malformed — supervisor is inactive
957
+ }
958
+
959
+ // ── Actions JSONL: recovery audit trail (batch-scoped, incremental) ──
960
+ const actionsPath = path.join(supervisorDir, "actions.jsonl");
961
+ const actions = tailSupervisorJsonl(actionsPath, supervisorTailStates.actions, batchId);
962
+
963
+ // ── Events JSONL: engine events (batch-scoped, incremental) ──
964
+ const eventsPath = path.join(supervisorDir, "events.jsonl");
965
+ const events = tailSupervisorJsonl(eventsPath, supervisorTailStates.events, batchId);
966
+
967
+ // ── Conversation JSONL: operator interaction log (spec §9.1) ──
968
+ // The supervisor writes operator↔supervisor messages to conversation.jsonl.
969
+ // Not yet implemented in all supervisor versions — degrade gracefully.
970
+ const conversationPath = path.join(supervisorDir, "conversation.jsonl");
971
+ const conversation = tailSupervisorJsonl(conversationPath, supervisorTailStates.conversation, batchId);
972
+
973
+ // ── Summary: human-readable batch summary (generated on completion) ──
974
+ // Per spec §9.1, the supervisor writes .pi/supervisor/summary.md when the
975
+ // batch completes or is abandoned. Read the file if it exists.
976
+ let summary = null;
977
+ try {
978
+ const summaryPath = path.join(supervisorDir, "summary.md");
979
+ summary = fs.readFileSync(summaryPath, "utf-8");
980
+ } catch {
981
+ // No summary yet — batch may still be running, or pre-supervisor batch
982
+ }
983
+
984
+ // If nothing exists at all, return null (pre-supervisor batch)
985
+ if (!lock && actions.length === 0 && events.length === 0 && conversation.length === 0 && !summary) {
986
+ // Check if the supervisor directory even exists
987
+ try {
988
+ fs.statSync(supervisorDir);
989
+ } catch {
990
+ return null; // No supervisor dir → pre-supervisor batch
991
+ }
992
+ }
993
+
994
+ return {
995
+ lock,
996
+ actions,
997
+ events,
998
+ conversation,
999
+ summary,
1000
+ };
1001
+ }
1002
+
1003
+ /**
1004
+ * Compute batch total cost from lane states (primary) and telemetry (supplementary).
1005
+ * Lane states are authoritative — telemetry provides additional data only for lanes
1006
+ * that have no lane-state entry (e.g., very early in session startup).
1007
+ */
1008
+ function computeBatchTotalCost(laneStates, telemetry) {
1009
+ let totalCost = 0;
1010
+ const coveredPrefixes = new Set();
1011
+
1012
+ // Primary: sum cost from lane states (worker + reviewer)
1013
+ for (const [prefix, ls] of Object.entries(laneStates)) {
1014
+ if (ls.workerCostUsd) {
1015
+ totalCost += ls.workerCostUsd;
1016
+ coveredPrefixes.add(prefix);
1017
+ }
1018
+ if (ls.reviewerCostUsd) {
1019
+ totalCost += ls.reviewerCostUsd;
1020
+ }
1021
+ }
1022
+
1023
+ // Supplementary: add cost from telemetry for uncovered lanes only
1024
+ for (const [prefix, tel] of Object.entries(telemetry)) {
1025
+ if (!coveredPrefixes.has(prefix) && tel.cost > 0) {
1026
+ totalCost += tel.cost;
1027
+ }
1028
+ }
1029
+
1030
+ return totalCost;
1031
+ }
1032
+
1033
+ function synthesizeLaneStateFromSnapshot(key, snap, fallbackBatchId) {
1034
+ const w = snap.worker || {};
1035
+ const r = snap.reviewer || null;
1036
+ const statusMap = { running: "running", spawning: "running", exited: "done", crashed: "error", killed: "error", timed_out: "error", wrapping_up: "running" };
1037
+ const reviewerStatusMap = { running: "running", spawning: "running", wrapping_up: "running", exited: "done", crashed: "done", killed: "done", timed_out: "done" };
1038
+
1039
+ return {
1040
+ prefix: key,
1041
+ taskId: snap.taskId || null,
1042
+ phase: snap.status === "running" ? "worker-active" : snap.status === "complete" ? "complete" : "idle",
1043
+ workerStatus: statusMap[w.status] || w.status || "idle",
1044
+ workerElapsed: w.elapsedMs || 0,
1045
+ workerContextPct: w.contextPct || 0,
1046
+ workerLastTool: w.lastTool || "",
1047
+ workerToolCount: w.toolCalls || 0,
1048
+ workerInputTokens: w.inputTokens || 0,
1049
+ workerOutputTokens: w.outputTokens || 0,
1050
+ workerCacheReadTokens: w.cacheReadTokens || 0,
1051
+ workerCacheWriteTokens: w.cacheWriteTokens || 0,
1052
+ workerCostUsd: w.costUsd || 0,
1053
+ reviewerStatus: r ? (reviewerStatusMap[r.status] || r.status || "running") : "idle",
1054
+ reviewerElapsed: r?.elapsedMs || 0,
1055
+ reviewerContextPct: r?.contextPct || 0,
1056
+ reviewerLastTool: r?.lastTool || "",
1057
+ reviewerToolCount: r?.toolCalls || 0,
1058
+ reviewerCostUsd: r?.costUsd || 0,
1059
+ reviewerInputTokens: r?.inputTokens || 0,
1060
+ reviewerOutputTokens: r?.outputTokens || 0,
1061
+ reviewerCacheReadTokens: r?.cacheReadTokens || 0,
1062
+ reviewerCacheWriteTokens: r?.cacheWriteTokens || 0,
1063
+ reviewerType: r?.reviewType || "",
1064
+ reviewerStep: r?.reviewStep || 0,
1065
+ batchId: snap.batchId || fallbackBatchId,
1066
+ timestamp: snap.updatedAt || Date.now(),
1067
+ };
1068
+ }
1069
+
1070
+ /** Build full dashboard state object for the frontend. */
1071
+ function buildDashboardState() {
1072
+ const state = loadBatchState();
1073
+ const sessions = getActiveSessions();
1074
+ const rawLaneStates = loadLaneStates();
1075
+ // Filter stale lane states from previous batches.
1076
+ // Lane state files persist across batches (same filename), so without
1077
+ // filtering the dashboard shows telemetry from old runs.
1078
+ const currentBatchId = state?.batchId || null;
1079
+ const laneStates = {};
1080
+ for (const [key, ls] of Object.entries(rawLaneStates)) {
1081
+ if (!currentBatchId || !ls.batchId || ls.batchId === currentBatchId) {
1082
+ laneStates[key] = ls;
1083
+ }
1084
+ }
1085
+ const telemetry = loadTelemetryData(state);
1086
+ const batchTotalCost = computeBatchTotalCost(laneStates, telemetry);
1087
+ const supervisor = loadSupervisorData(state);
1088
+
1089
+ if (!state) {
1090
+ return {
1091
+ batch: null,
1092
+ sessions,
1093
+ tmuxSessions: sessions, // Legacy compatibility field for older dashboard clients
1094
+ laneStates: {},
1095
+ telemetry: {},
1096
+ batchTotalCost: 0,
1097
+ supervisor: null,
1098
+ timestamp: Date.now(),
1099
+ };
1100
+ }
1101
+
1102
+ const tasks = (state.tasks || []).map((task) => {
1103
+ const effectiveFolder = resolveTaskFolder(task, state);
1104
+ let statusData = null;
1105
+ if (effectiveFolder) {
1106
+ statusData = parseStatusMd(effectiveFolder);
1107
+ }
1108
+ if (!task.doneFileFound && effectiveFolder) {
1109
+ task.doneFileFound = checkDoneFile(effectiveFolder);
1110
+ }
1111
+ return { ...task, statusData };
1112
+ });
1113
+
1114
+ // TP-107: Load Runtime V2 data when available
1115
+ const runtimeRegistry = loadRuntimeRegistry(state.batchId);
1116
+ const runtimeLaneSnapshots = loadRuntimeLaneSnapshots(state.batchId);
1117
+ const mailboxData = loadMailboxData(state.batchId);
1118
+
1119
+ // TP-164: Load merge agent snapshots for live dashboard telemetry.
1120
+ const runtimeMergeSnapshots = loadRuntimeMergeSnapshots(state.batchId);
1121
+
1122
+ // TP-164: Inject merge snapshot telemetry into the telemetry map so
1123
+ // `telemetry[sessionName]` resolves for the merge pane.
1124
+ // Snapshots are the PRIMARY source for merge telemetry — merge agents don't
1125
+ // write to .pi/telemetry/*.jsonl (the JSONL path is for lane workers only).
1126
+ // Inject snapshot data when no JSONL-backed entry exists for this session.
1127
+ for (const snap of Object.values(runtimeMergeSnapshots)) {
1128
+ const key = snap.sessionName;
1129
+ if (!key) continue;
1130
+ const agent = snap.agent;
1131
+ if (!agent) continue;
1132
+ // Only inject if there is no existing JSONL-backed telemetry entry.
1133
+ // Merge agents don't emit to .pi/telemetry/, so existing will always be
1134
+ // absent unless something else wrote to it — in that case defer to it.
1135
+ const existing = telemetry[key];
1136
+ if (!existing) {
1137
+ telemetry[key] = {
1138
+ inputTokens: agent.inputTokens || 0,
1139
+ outputTokens: agent.outputTokens || 0,
1140
+ cacheReadTokens: agent.cacheReadTokens || 0,
1141
+ cacheWriteTokens: agent.cacheWriteTokens || 0,
1142
+ cost: agent.costUsd || 0,
1143
+ toolCalls: agent.toolCalls || 0,
1144
+ lastTool: agent.lastTool || "",
1145
+ currentTool: snap.status === "running" ? (agent.lastTool || "") : "",
1146
+ contextPct: agent.contextPct || 0,
1147
+ // startedAt is not in the snapshot; compute from elapsed if possible.
1148
+ startedAt: agent.elapsedMs > 0 ? snap.updatedAt - agent.elapsedMs : snap.updatedAt,
1149
+ retries: 0,
1150
+ retryActive: false,
1151
+ lastRetryError: "",
1152
+ compactions: 0,
1153
+ latestTotalTokens: (agent.inputTokens || 0) + (agent.outputTokens || 0),
1154
+ _updatedAt: snap.updatedAt,
1155
+ _source: "merge-snapshot",
1156
+ // TP-178: Include waveIndex for precise wave-telemetry association (#498)
1157
+ waveIndex: snap.waveIndex != null ? snap.waveIndex : undefined,
1158
+ };
1159
+ }
1160
+ }
1161
+
1162
+ // TP-115: Synthesize laneStates from V2 snapshots so the dashboard
1163
+ // pipeline works without legacy lane-state-*.json sidecar files.
1164
+ // V2 snapshots are authoritative when present.
1165
+ if (Object.keys(runtimeLaneSnapshots).length > 0) {
1166
+ for (const [laneNum, snap] of Object.entries(runtimeLaneSnapshots)) {
1167
+ // Find the matching lane record to get the session name key
1168
+ const laneRec = (state.lanes || []).find(l => l.laneNumber === Number(laneNum));
1169
+ const key = laneRec ? (laneRec.laneSessionId) : `lane-${laneNum}`;
1170
+ if (!laneStates[key] || (snap.updatedAt && snap.updatedAt > (laneStates[key].timestamp || 0))) {
1171
+ laneStates[key] = synthesizeLaneStateFromSnapshot(key, snap, state.batchId);
1172
+ }
1173
+ }
1174
+ }
1175
+
1176
+ return {
1177
+ laneStates,
1178
+ telemetry,
1179
+ batchTotalCost,
1180
+ supervisor,
1181
+ // Runtime V2 data (null/empty for legacy batches)
1182
+ runtimeRegistry,
1183
+ runtimeLaneSnapshots,
1184
+ // TP-164: Merge agent snapshots for live dashboard telemetry.
1185
+ runtimeMergeSnapshots,
1186
+ mailbox: mailboxData,
1187
+ batch: {
1188
+ batchId: state.batchId,
1189
+ phase: state.phase,
1190
+ startedAt: state.startedAt,
1191
+ updatedAt: state.updatedAt,
1192
+ currentWaveIndex: state.currentWaveIndex || 0,
1193
+ totalWaves: state.totalWaves || (state.wavePlan ? state.wavePlan.length : 0),
1194
+ wavePlan: state.wavePlan || [],
1195
+ // Lanes already include repoId (string|undefined) from PersistedLaneRecord (v2).
1196
+ lanes: state.lanes || [],
1197
+ // Tasks already include repoId, resolvedRepoId (string|undefined) from PersistedTaskRecord (v2).
1198
+ tasks,
1199
+ mergeResults: state.mergeResults || [],
1200
+ errors: state.errors || [],
1201
+ lastError: state.lastError || null,
1202
+ // Workspace mode: "repo" (default/v1) or "workspace" (v2 multi-repo).
1203
+ // Additive field — absent in v1 state files, frontend must default to "repo".
1204
+ mode: state.mode || "repo",
1205
+ // TP-148: Segment records for wave display context (v4+).
1206
+ // Each record has taskId, segmentId, repoId, status.
1207
+ segments: state.segments || [],
1208
+ },
1209
+ sessions,
1210
+ tmuxSessions: sessions, // Legacy compatibility field for older dashboard clients
1211
+ timestamp: Date.now(),
1212
+ };
1213
+ }
1214
+
1215
+ // ─── Static File Serving ────────────────────────────────────────────────────
1216
+
1217
+ const MIME_TYPES = {
1218
+ ".html": "text/html; charset=utf-8",
1219
+ ".css": "text/css; charset=utf-8",
1220
+ ".js": "application/javascript; charset=utf-8",
1221
+ ".json": "application/json; charset=utf-8",
1222
+ ".svg": "image/svg+xml",
1223
+ ".png": "image/png",
1224
+ ".ico": "image/x-icon",
1225
+ };
1226
+
1227
+ function serveStatic(req, res) {
1228
+ let filePath = new URL(req.url, "http://localhost").pathname;
1229
+ if (filePath === "/") filePath = "/index.html";
1230
+
1231
+ const fullPath = path.join(PUBLIC_DIR, filePath);
1232
+ // Prevent directory traversal
1233
+ if (!fullPath.startsWith(PUBLIC_DIR)) {
1234
+ res.writeHead(403);
1235
+ res.end("Forbidden");
1236
+ return;
1237
+ }
1238
+
1239
+ const ext = path.extname(fullPath);
1240
+ const contentType = MIME_TYPES[ext] || "application/octet-stream";
1241
+
1242
+ try {
1243
+ const content = fs.readFileSync(fullPath);
1244
+ res.writeHead(200, {
1245
+ "Content-Type": contentType,
1246
+ "Cache-Control": "no-cache, no-store, must-revalidate",
1247
+ });
1248
+ res.end(content);
1249
+ } catch {
1250
+ res.writeHead(404, { "Content-Type": "text/plain" });
1251
+ res.end("Not Found");
1252
+ }
1253
+ }
1254
+
1255
+ // ─── SSE Stream ─────────────────────────────────────────────────────────────
1256
+
1257
+ const sseClients = new Set();
1258
+
1259
+ // ─── Conversation JSONL ─────────────────────────────────────────────────
1260
+
1261
+ function serveConversation(req, res, prefix) {
1262
+ if (!/^[\w-]+$/.test(prefix)) {
1263
+ res.writeHead(400, { "Content-Type": "text/plain" });
1264
+ res.end("Invalid prefix");
1265
+ return;
1266
+ }
1267
+
1268
+ const filePath = path.join(REPO_ROOT, ".pi", `worker-conversation-${prefix}.jsonl`);
1269
+ try {
1270
+ const content = fs.readFileSync(filePath, "utf-8");
1271
+ res.writeHead(200, {
1272
+ "Content-Type": "application/x-ndjson",
1273
+ "Access-Control-Allow-Origin": "*",
1274
+ });
1275
+ res.end(content);
1276
+ } catch {
1277
+ res.writeHead(200, {
1278
+ "Content-Type": "application/x-ndjson",
1279
+ "Access-Control-Allow-Origin": "*",
1280
+ });
1281
+ res.end(""); // empty — no conversation yet
1282
+ }
1283
+ }
1284
+
1285
+ // ─── Dashboard SSE ──────────────────────────────────────────────────────
1286
+
1287
+ function handleSSE(req, res) {
1288
+ res.writeHead(200, {
1289
+ "Content-Type": "text/event-stream",
1290
+ "Cache-Control": "no-cache",
1291
+ Connection: "keep-alive",
1292
+ "Access-Control-Allow-Origin": "*",
1293
+ });
1294
+
1295
+ // Send initial state immediately
1296
+ const state = buildDashboardState();
1297
+ res.write(`data: ${JSON.stringify(state)}\n\n`);
1298
+
1299
+ sseClients.add(res);
1300
+ req.on("close", () => sseClients.delete(res));
1301
+ }
1302
+
1303
+ function broadcastState() {
1304
+ if (sseClients.size === 0) return;
1305
+ const state = buildDashboardState();
1306
+ const payload = `data: ${JSON.stringify(state)}\n\n`;
1307
+ for (const client of sseClients) {
1308
+ try {
1309
+ client.write(payload);
1310
+ } catch {
1311
+ sseClients.delete(client);
1312
+ }
1313
+ }
1314
+ }
1315
+
1316
+ // ─── Batch History API ──────────────────────────────────────────────────────
1317
+
1318
+ // BATCH_HISTORY_PATH is initialized in main() alongside REPO_ROOT.
1319
+
1320
+ function loadHistory() {
1321
+ try {
1322
+ if (!fs.existsSync(BATCH_HISTORY_PATH)) return [];
1323
+ const raw = fs.readFileSync(BATCH_HISTORY_PATH, "utf-8");
1324
+ return JSON.parse(raw);
1325
+ } catch {
1326
+ return [];
1327
+ }
1328
+ }
1329
+
1330
+ /** GET /api/history — return list of batch summaries (compact: no per-task detail). */
1331
+ function serveHistory(req, res) {
1332
+ const history = loadHistory();
1333
+ // Return compact list for the dropdown (no per-task details)
1334
+ const compact = history.map(h => ({
1335
+ batchId: h.batchId,
1336
+ status: h.status,
1337
+ startedAt: h.startedAt,
1338
+ endedAt: h.endedAt,
1339
+ durationMs: h.durationMs,
1340
+ totalWaves: h.totalWaves,
1341
+ totalTasks: h.totalTasks,
1342
+ succeededTasks: h.succeededTasks,
1343
+ failedTasks: h.failedTasks,
1344
+ tokens: h.tokens,
1345
+ }));
1346
+ res.writeHead(200, {
1347
+ "Content-Type": "application/json",
1348
+ "Access-Control-Allow-Origin": "*",
1349
+ });
1350
+ res.end(JSON.stringify(compact));
1351
+ }
1352
+
1353
+ /** GET /api/history/:batchId — return full detail for one batch. */
1354
+ function serveHistoryEntry(req, res, batchId) {
1355
+ const history = loadHistory();
1356
+ const entry = history.find(h => h.batchId === batchId);
1357
+ if (!entry) {
1358
+ res.writeHead(404, { "Content-Type": "application/json" });
1359
+ res.end(JSON.stringify({ error: "Batch not found" }));
1360
+ return;
1361
+ }
1362
+ res.writeHead(200, {
1363
+ "Content-Type": "application/json",
1364
+ "Access-Control-Allow-Origin": "*",
1365
+ });
1366
+ res.end(JSON.stringify(entry));
1367
+ }
1368
+
1369
+ /** GET /api/status-md/:taskId — return raw STATUS.md content for a task. */
1370
+ function serveStatusMd(req, res, taskId) {
1371
+ if (!/^[\w-]+$/.test(taskId)) {
1372
+ res.writeHead(400, { "Content-Type": "text/plain" });
1373
+ res.end("Invalid task ID");
1374
+ return;
1375
+ }
1376
+
1377
+ const state = loadBatchState();
1378
+ if (!state) {
1379
+ res.writeHead(404, { "Content-Type": "application/json" });
1380
+ res.end(JSON.stringify({ error: "No batch state" }));
1381
+ return;
1382
+ }
1383
+
1384
+ const task = (state.tasks || []).find(t => t.taskId === taskId);
1385
+ if (!task) {
1386
+ res.writeHead(404, { "Content-Type": "application/json" });
1387
+ res.end(JSON.stringify({ error: "Task not found" }));
1388
+ return;
1389
+ }
1390
+
1391
+ const effectiveFolder = resolveTaskFolder(task, state);
1392
+ if (!effectiveFolder) {
1393
+ res.writeHead(404, { "Content-Type": "application/json" });
1394
+ res.end(JSON.stringify({ error: "Cannot resolve task folder" }));
1395
+ return;
1396
+ }
1397
+
1398
+ // Try effective folder, then archive
1399
+ const candidates = [effectiveFolder];
1400
+ const archiveBase = effectiveFolder.replace(/[/\\]tasks[/\\][^/\\]+$/, "/tasks/archive/" + taskId);
1401
+ if (archiveBase !== effectiveFolder) candidates.push(archiveBase);
1402
+
1403
+ for (const folder of candidates) {
1404
+ const statusPath = path.join(folder, "STATUS.md");
1405
+ try {
1406
+ const content = fs.readFileSync(statusPath, "utf-8");
1407
+ res.writeHead(200, {
1408
+ "Content-Type": "text/plain; charset=utf-8",
1409
+ "Access-Control-Allow-Origin": "*",
1410
+ });
1411
+ res.end(content);
1412
+ return;
1413
+ } catch { continue; }
1414
+ }
1415
+
1416
+ res.writeHead(404, { "Content-Type": "application/json" });
1417
+ res.end(JSON.stringify({ error: "STATUS.md not found" }));
1418
+ }
1419
+
1420
+ // ─── Dashboard Preferences ──────────────────────────────────────────────────
1421
+
1422
+ function getPreferencesPath() {
1423
+ return path.join(REPO_ROOT, ".pi", "dashboard-preferences.json");
1424
+ }
1425
+
1426
+ function handleGetPreferences(req, res) {
1427
+ const prefsPath = getPreferencesPath();
1428
+ let prefs = { theme: "dark" };
1429
+ try {
1430
+ if (fs.existsSync(prefsPath)) {
1431
+ prefs = JSON.parse(fs.readFileSync(prefsPath, "utf8"));
1432
+ }
1433
+ } catch { /* use defaults */ }
1434
+ res.writeHead(200, {
1435
+ "Content-Type": "application/json",
1436
+ "Access-Control-Allow-Origin": "*",
1437
+ });
1438
+ res.end(JSON.stringify(prefs));
1439
+ }
1440
+
1441
+ function handlePostPreferences(req, res) {
1442
+ let body = "";
1443
+ req.on("data", (chunk) => { body += chunk; });
1444
+ req.on("end", () => {
1445
+ try {
1446
+ const incoming = JSON.parse(body);
1447
+ const prefsPath = getPreferencesPath();
1448
+ let existing = {};
1449
+ try {
1450
+ if (fs.existsSync(prefsPath)) {
1451
+ existing = JSON.parse(fs.readFileSync(prefsPath, "utf8"));
1452
+ }
1453
+ } catch { /* start fresh */ }
1454
+ const merged = { ...existing, ...incoming };
1455
+ const dir = path.dirname(prefsPath);
1456
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
1457
+ fs.writeFileSync(prefsPath, JSON.stringify(merged, null, 2) + "\n");
1458
+ res.writeHead(200, {
1459
+ "Content-Type": "application/json",
1460
+ "Access-Control-Allow-Origin": "*",
1461
+ });
1462
+ res.end(JSON.stringify(merged));
1463
+ } catch (err) {
1464
+ res.writeHead(400, {
1465
+ "Content-Type": "application/json",
1466
+ "Access-Control-Allow-Origin": "*",
1467
+ });
1468
+ res.end(JSON.stringify({ error: "Invalid JSON" }));
1469
+ }
1470
+ });
1471
+ }
1472
+
1473
+ // ─── HTTP Server ────────────────────────────────────────────────────────────
1474
+
1475
+ function createServer() {
1476
+ const server = http.createServer((req, res) => {
1477
+ const pathname = new URL(req.url, "http://localhost").pathname;
1478
+
1479
+ if (pathname === "/api/stream" && req.method === "GET") {
1480
+ handleSSE(req, res);
1481
+ } else if (pathname.startsWith("/api/conversation/") && req.method === "GET") {
1482
+ const prefix = pathname.slice("/api/conversation/".length);
1483
+ serveConversation(req, res, prefix);
1484
+ } else if (pathname.startsWith("/api/agent-events/") && req.method === "GET") {
1485
+ // TP-107: Serve Runtime V2 agent events (hardened)
1486
+ const agentId = decodeURIComponent(pathname.slice("/api/agent-events/".length));
1487
+ // Strict validation: same pattern as /api/conversation/:prefix
1488
+ if (!/^[\w-]+$/.test(agentId)) {
1489
+ res.writeHead(400, { "Content-Type": "text/plain" });
1490
+ res.end("Invalid agent ID");
1491
+ return;
1492
+ }
1493
+ const batchState = loadBatchState();
1494
+ // Path containment: verify resolved path stays inside runtime dir
1495
+ if (batchState?.batchId) {
1496
+ const runtimeBase = path.join(REPO_ROOT, ".pi", "runtime", batchState.batchId, "agents");
1497
+ const resolvedAgent = path.resolve(runtimeBase, agentId);
1498
+ if (!resolvedAgent.startsWith(path.resolve(runtimeBase))) {
1499
+ res.writeHead(403, { "Content-Type": "text/plain" });
1500
+ res.end("Forbidden");
1501
+ return;
1502
+ }
1503
+ }
1504
+ // Optional: ?sinceTs= to return only events after a timestamp
1505
+ const reqUrl = new URL(req.url, "http://localhost");
1506
+ const sinceTs = parseInt(reqUrl.searchParams.get("sinceTs") || "0", 10);
1507
+ let events = loadRuntimeAgentEvents(batchState?.batchId, agentId, 300);
1508
+ if (sinceTs > 0) {
1509
+ events = events.filter(e => (e.ts || 0) > sinceTs);
1510
+ }
1511
+ res.writeHead(200, { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" });
1512
+ res.end(JSON.stringify(events));
1513
+ } else if (pathname === "/api/state" && req.method === "GET") {
1514
+ const state = buildDashboardState();
1515
+ res.writeHead(200, {
1516
+ "Content-Type": "application/json",
1517
+ "Access-Control-Allow-Origin": "*",
1518
+ });
1519
+ res.end(JSON.stringify(state));
1520
+ } else if (pathname === "/api/history" && req.method === "GET") {
1521
+ serveHistory(req, res);
1522
+ } else if (pathname.startsWith("/api/history/") && req.method === "GET") {
1523
+ const batchId = decodeURIComponent(pathname.slice("/api/history/".length));
1524
+ serveHistoryEntry(req, res, batchId);
1525
+ } else if (pathname.startsWith("/api/status-md/") && req.method === "GET") {
1526
+ const taskId = decodeURIComponent(pathname.slice("/api/status-md/".length));
1527
+ serveStatusMd(req, res, taskId);
1528
+ } else if (pathname === "/api/preferences" && req.method === "GET") {
1529
+ handleGetPreferences(req, res);
1530
+ } else if (pathname === "/api/preferences" && req.method === "POST") {
1531
+ handlePostPreferences(req, res);
1532
+ } else if (req.method === "OPTIONS") {
1533
+ // CORS preflight for POST
1534
+ res.writeHead(204, {
1535
+ "Access-Control-Allow-Origin": "*",
1536
+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
1537
+ "Access-Control-Allow-Headers": "Content-Type",
1538
+ });
1539
+ res.end();
1540
+ } else {
1541
+ serveStatic(req, res);
1542
+ }
1543
+ });
1544
+
1545
+ return server;
1546
+ }
1547
+
1548
+ // ─── Browser Auto-Open ─────────────────────────────────────────────────────
1549
+
1550
+ function openBrowser(url) {
1551
+ const cmd = process.platform === "win32" ? "start"
1552
+ : process.platform === "darwin" ? "open" : "xdg-open";
1553
+ exec(`${cmd} ${url}`, () => {}); // fire-and-forget
1554
+ }
1555
+
1556
+ // ─── Main ───────────────────────────────────────────────────────────────────
1557
+
1558
+ /** Try to listen on a port. Resolves with the port on success, rejects on EADDRINUSE. */
1559
+ function tryListen(server, port) {
1560
+ return new Promise((resolve, reject) => {
1561
+ const onError = (err) => {
1562
+ server.removeListener("listening", onListening);
1563
+ reject(err);
1564
+ };
1565
+ const onListening = () => {
1566
+ server.removeListener("error", onError);
1567
+ resolve(port);
1568
+ };
1569
+ server.once("error", onError);
1570
+ server.once("listening", onListening);
1571
+ server.listen(port);
1572
+ });
1573
+ }
1574
+
1575
+ /** Find an available port starting from `start`, trying up to MAX_PORT_ATTEMPTS. */
1576
+ async function findPort(server, start, explicit) {
1577
+ // If the user explicitly passed --port, only try that one
1578
+ if (explicit) {
1579
+ try {
1580
+ return await tryListen(server, start);
1581
+ } catch (err) {
1582
+ if (err.code === "EADDRINUSE") {
1583
+ console.error(`\n Port ${start} is already in use.`);
1584
+ console.error(` Try: taskplane dashboard --port ${start + 1}\n`);
1585
+ process.exit(1);
1586
+ }
1587
+ throw err;
1588
+ }
1589
+ }
1590
+ // Auto-scan for an available port
1591
+ for (let port = start; port < start + MAX_PORT_ATTEMPTS; port++) {
1592
+ try {
1593
+ return await tryListen(server, port);
1594
+ } catch (err) {
1595
+ if (err.code === "EADDRINUSE") {
1596
+ // Close the server so we can retry on the next port
1597
+ server.close();
1598
+ server = createServer();
1599
+ continue;
1600
+ }
1601
+ throw err;
1602
+ }
1603
+ }
1604
+ console.error(`\n No available port found in range ${start}-${start + MAX_PORT_ATTEMPTS - 1}.\n`);
1605
+ process.exit(1);
1606
+ }
1607
+
1608
+ async function main() {
1609
+ const opts = parseArgs();
1610
+
1611
+ // Resolve project root: --root flag > cwd.
1612
+ // In workspace mode this is the workspace root. All state/sidecar files
1613
+ // (batch-state, lane-state, conversation logs, batch-history) live at
1614
+ // <REPO_ROOT>/.pi/ and are NOT affected by taskplane-pointer.json.
1615
+ // The pointer only redirects config/agent resolution in task-runner and
1616
+ // orchestrator — the dashboard reads only runtime state, so no pointer
1617
+ // resolution is performed here.
1618
+ REPO_ROOT = path.resolve(opts.root || process.cwd());
1619
+ BATCH_STATE_PATH = path.join(REPO_ROOT, ".pi", "batch-state.json");
1620
+ BATCH_HISTORY_PATH = path.join(REPO_ROOT, ".pi", "batch-history.json");
1621
+
1622
+ const server = createServer();
1623
+ const explicitPort = process.argv.slice(2).includes("--port");
1624
+ const port = await findPort(server, opts.port, explicitPort);
1625
+
1626
+ console.log(`\n Orchestrator Dashboard → http://localhost:${port}\n`);
1627
+
1628
+ // Broadcast state to all SSE clients on interval
1629
+ const pollTimer = setInterval(broadcastState, POLL_INTERVAL);
1630
+
1631
+ // Also watch batch-state.json for immediate push on change
1632
+ try {
1633
+ const batchDir = path.dirname(BATCH_STATE_PATH);
1634
+ if (fs.existsSync(batchDir)) {
1635
+ let debounce = null;
1636
+ fs.watch(batchDir, (eventType, filename) => {
1637
+ if (filename === "batch-state.json") {
1638
+ clearTimeout(debounce);
1639
+ debounce = setTimeout(broadcastState, 200);
1640
+ }
1641
+ });
1642
+ }
1643
+ } catch {
1644
+ // fs.watch not supported — polling is sufficient
1645
+ }
1646
+
1647
+ // Auto-open browser
1648
+ if (opts.open) {
1649
+ setTimeout(() => openBrowser(`http://localhost:${port}`), 500);
1650
+ }
1651
+
1652
+ // Graceful shutdown
1653
+ function cleanup() {
1654
+ clearInterval(pollTimer);
1655
+ for (const client of sseClients) {
1656
+ try { client.end(); } catch {}
1657
+ }
1658
+ server.close();
1659
+ process.exit(0);
1660
+ }
1661
+
1662
+ process.on("SIGINT", cleanup);
1663
+ process.on("SIGTERM", cleanup);
1664
+ }
1665
+
1666
+ main();