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.
- package/LICENSE +21 -21
- package/README.md +215 -215
- package/bin/gitignore-patterns.mjs +79 -79
- package/bin/rpc-wrapper.mjs +1086 -1086
- package/bin/taskplane.mjs +3254 -3254
- package/dashboard/public/app.js +2573 -2573
- package/dashboard/public/index.html +139 -139
- package/dashboard/public/style.css +1882 -1882
- package/dashboard/public/taskplane-word-color.svg +18 -18
- package/dashboard/public/taskplane-word-white.svg +18 -18
- package/dashboard/server.cjs +1666 -1666
- package/extensions/reviewer-extension.ts +119 -119
- package/extensions/task-orchestrator.ts +28 -28
- package/extensions/taskplane/abort.ts +502 -502
- package/extensions/taskplane/agent-bridge-extension.ts +765 -765
- package/extensions/taskplane/agent-host.ts +833 -745
- package/extensions/taskplane/cleanup.ts +747 -747
- package/extensions/taskplane/config-loader.ts +1328 -1322
- package/extensions/taskplane/config-schema.ts +692 -682
- package/extensions/taskplane/config.ts +73 -73
- package/extensions/taskplane/context-window.ts +66 -66
- package/extensions/taskplane/diagnostic-reports.ts +463 -463
- package/extensions/taskplane/diagnostics.ts +385 -385
- package/extensions/taskplane/engine-worker-entry.mjs +34 -34
- package/extensions/taskplane/engine-worker.ts +381 -381
- package/extensions/taskplane/engine.ts +4539 -4527
- package/extensions/taskplane/execution.ts +2733 -2708
- package/extensions/taskplane/extension.ts +30 -9
- package/extensions/taskplane/formatting.ts +773 -773
- package/extensions/taskplane/git.ts +90 -90
- package/extensions/taskplane/index.ts +28 -28
- package/extensions/taskplane/lane-runner.ts +1383 -1360
- package/extensions/taskplane/mailbox.ts +689 -689
- package/extensions/taskplane/merge.ts +3135 -3135
- package/extensions/taskplane/messages.ts +985 -985
- package/extensions/taskplane/migrations.ts +278 -278
- package/extensions/taskplane/naming.ts +117 -117
- package/extensions/taskplane/path-resolver.ts +237 -237
- package/extensions/taskplane/persistence.ts +2087 -2087
- package/extensions/taskplane/process-registry.ts +416 -416
- package/extensions/taskplane/quality-gate.ts +1033 -1033
- package/extensions/taskplane/resume.ts +2879 -2878
- package/extensions/taskplane/sessions.ts +57 -57
- package/extensions/taskplane/settings-loader.ts +136 -136
- package/extensions/taskplane/settings-tui.ts +1867 -1865
- package/extensions/taskplane/sidecar-telemetry.ts +252 -252
- package/extensions/taskplane/supervisor-primer.md +1694 -1694
- package/extensions/taskplane/supervisor.ts +4341 -4341
- package/extensions/taskplane/task-executor-core.ts +550 -550
- package/extensions/taskplane/tmux-compat.ts +37 -37
- package/extensions/taskplane/types.ts +4297 -4278
- package/extensions/taskplane/verification.ts +542 -542
- package/extensions/taskplane/waves.ts +1548 -1548
- package/extensions/taskplane/workspace.ts +705 -705
- package/extensions/taskplane/worktree.ts +2604 -2505
- package/package.json +57 -57
- package/skills/create-taskplane-task/SKILL.md +465 -465
- package/skills/create-taskplane-task/references/prompt-template.md +285 -285
- package/templates/agents/local/supervisor.md +33 -33
- package/templates/agents/local/task-merger.md +27 -27
- package/templates/agents/local/task-reviewer.md +30 -30
- package/templates/agents/local/task-worker.md +34 -34
- package/templates/agents/supervisor-routing.md +92 -92
- package/templates/agents/supervisor.md +168 -168
- package/templates/agents/task-merger.md +214 -214
- package/templates/agents/task-reviewer.md +192 -192
- package/templates/agents/task-worker.md +429 -429
- package/templates/tasks/EXAMPLE-001-hello-world/PROMPT.md +98 -98
- package/templates/tasks/EXAMPLE-001-hello-world/STATUS.md +73 -73
- package/templates/tasks/EXAMPLE-002-parallel-smoke/PROMPT.md +97 -97
- package/templates/tasks/EXAMPLE-002-parallel-smoke/STATUS.md +73 -73
package/dashboard/server.cjs
CHANGED
|
@@ -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();
|