@tekyzinc/gsd-t 3.29.11 → 4.0.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/CHANGELOG.md +171 -0
  2. package/bin/gsd-t-parallel.cjs +64 -13
  3. package/bin/gsd-t.js +48 -376
  4. package/bin/journey-coverage.cjs +6 -3
  5. package/bin/parallel-cli.cjs +13 -1
  6. package/commands/gsd-t-complete-milestone.md +6 -12
  7. package/commands/gsd-t-debug.md +47 -533
  8. package/commands/gsd-t-design-decompose.md +24 -497
  9. package/commands/gsd-t-doc-ripple.md +23 -139
  10. package/commands/gsd-t-execute.md +41 -958
  11. package/commands/gsd-t-feature.md +2 -6
  12. package/commands/gsd-t-gap-analysis.md +3 -6
  13. package/commands/gsd-t-help.md +2 -4
  14. package/commands/gsd-t-impact.md +26 -295
  15. package/commands/gsd-t-init-scan-setup.md +7 -7
  16. package/commands/gsd-t-integrate.md +44 -369
  17. package/commands/gsd-t-milestone.md +25 -124
  18. package/commands/gsd-t-partition.md +28 -539
  19. package/commands/gsd-t-plan.md +26 -472
  20. package/commands/gsd-t-prd.md +25 -311
  21. package/commands/gsd-t-quick.md +1 -1
  22. package/commands/gsd-t-resume.md +0 -33
  23. package/commands/gsd-t-scan.md +45 -652
  24. package/commands/gsd-t-verify.md +52 -569
  25. package/commands/gsd-t-wave.md +41 -430
  26. package/package.json +1 -1
  27. package/scripts/gsd-t-calibration-hook.js +3 -1
  28. package/scripts/hooks/pre-commit-journey-coverage +0 -2
  29. package/scripts/hooks/pre-commit-playwright-gate +0 -2
  30. package/templates/CLAUDE-global.md +44 -173
  31. package/templates/CLAUDE-project.md +118 -104
  32. package/templates/prompts/design-verify-subagent.md +3 -0
  33. package/templates/prompts/qa-subagent.md +3 -0
  34. package/templates/prompts/red-team-subagent.md +5 -2
  35. package/templates/workflows/_lib.js +176 -0
  36. package/templates/workflows/gsd-t-debug.workflow.js +86 -0
  37. package/templates/workflows/gsd-t-execute.workflow.js +206 -0
  38. package/templates/workflows/gsd-t-integrate.workflow.js +71 -0
  39. package/templates/workflows/gsd-t-phase.workflow.js +94 -0
  40. package/templates/workflows/gsd-t-quick.workflow.js +72 -0
  41. package/templates/workflows/gsd-t-scan.workflow.js +711 -0
  42. package/templates/workflows/gsd-t-verify.workflow.js +348 -0
  43. package/templates/workflows/gsd-t-wave.workflow.js +43 -0
  44. package/bin/check-headless-sessions.js +0 -140
  45. package/bin/context-budget-audit.cjs +0 -447
  46. package/bin/context-meter-config.cjs +0 -101
  47. package/bin/context-meter-config.test.cjs +0 -101
  48. package/bin/event-stream.cjs +0 -205
  49. package/bin/gsd-t-benchmark-orchestrator.js +0 -437
  50. package/bin/gsd-t-capture-lint.cjs +0 -440
  51. package/bin/gsd-t-economics.cjs +0 -315
  52. package/bin/gsd-t-in-session-usage.cjs +0 -213
  53. package/bin/gsd-t-orchestrator-config.cjs +0 -161
  54. package/bin/gsd-t-orchestrator-queue.cjs +0 -180
  55. package/bin/gsd-t-orchestrator-recover.cjs +0 -231
  56. package/bin/gsd-t-orchestrator-worker.cjs +0 -219
  57. package/bin/gsd-t-orchestrator.js +0 -535
  58. package/bin/gsd-t-parallel-probe.cjs +0 -132
  59. package/bin/gsd-t-ratelimit-probe-worker.cjs +0 -237
  60. package/bin/gsd-t-ratelimit-probe.cjs +0 -648
  61. package/bin/gsd-t-report-tokens.cjs +0 -549
  62. package/bin/gsd-t-stream-feed-client.cjs +0 -151
  63. package/bin/gsd-t-token-backfill.cjs +0 -366
  64. package/bin/gsd-t-token-capture.cjs +0 -321
  65. package/bin/gsd-t-token-dashboard.cjs +0 -353
  66. package/bin/gsd-t-token-regenerate-log.cjs +0 -129
  67. package/bin/gsd-t-tool-attribution.cjs +0 -377
  68. package/bin/gsd-t-tool-cost.cjs +0 -195
  69. package/bin/gsd-t-transcript-tee.cjs +0 -246
  70. package/bin/gsd-t-unattended-heartbeat.cjs +0 -188
  71. package/bin/gsd-t-unattended-platform.cjs +0 -551
  72. package/bin/gsd-t-unattended-platform.js +0 -381
  73. package/bin/gsd-t-unattended-safety.cjs +0 -773
  74. package/bin/gsd-t-unattended-safety.js +0 -788
  75. package/bin/gsd-t-unattended.cjs +0 -2109
  76. package/bin/gsd-t-unattended.js +0 -1367
  77. package/bin/gsd-t-worker-dispatch.cjs +0 -211
  78. package/bin/handoff-lock.cjs +0 -249
  79. package/bin/handoff-lock.js +0 -249
  80. package/bin/headless-auto-spawn.cjs +0 -711
  81. package/bin/headless-auto-spawn.js +0 -373
  82. package/bin/headless-exit-codes.cjs +0 -67
  83. package/bin/live-activity-report.cjs +0 -615
  84. package/bin/log-tail.cjs +0 -81
  85. package/bin/m44-proof-measure.cjs +0 -285
  86. package/bin/m46-iter-proof.cjs +0 -149
  87. package/bin/m46-worker-proof.cjs +0 -201
  88. package/bin/m55-substrate-proof.cjs +0 -134
  89. package/bin/metrics-rollup.js +0 -200
  90. package/bin/model-windows.cjs +0 -99
  91. package/bin/model-windows.test.cjs +0 -75
  92. package/bin/parallelism-report.cjs +0 -535
  93. package/bin/runway-estimator.cjs +0 -242
  94. package/bin/spawn-plan-derive.cjs +0 -163
  95. package/bin/spawn-plan-status-updater.cjs +0 -292
  96. package/bin/spawn-plan-writer.cjs +0 -204
  97. package/bin/supervisor-pid-fingerprint.cjs +0 -126
  98. package/bin/token-budget.cjs +0 -265
  99. package/bin/unattended-watch-format.cjs +0 -178
  100. package/bin/watch-progress.js +0 -155
  101. package/commands/gsd-t-unattended-stop.md +0 -85
  102. package/commands/gsd-t-unattended-watch.md +0 -465
  103. package/commands/gsd-t-unattended.md +0 -476
  104. package/commands/gsd-t-visualize.md +0 -116
  105. package/scripts/gsd-t-context-meter.e2e.test.js +0 -364
  106. package/scripts/gsd-t-context-meter.js +0 -341
  107. package/scripts/gsd-t-context-meter.test.js +0 -471
  108. package/scripts/gsd-t-dashboard-server.js +0 -1203
  109. package/scripts/gsd-t-post-commit-spawn-plan.sh +0 -86
  110. package/scripts/gsd-t-transcript.html +0 -1821
  111. package/scripts/hooks/gsd-t-conversation-capture.js +0 -439
  112. package/scripts/hooks/gsd-t-in-session-usage-hook.js +0 -84
  113. package/scripts/spawn-plan-fmt-tokens.cjs +0 -80
  114. package/templates/hooks/post-commit-spawn-plan.sh +0 -85
@@ -1,1203 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * GSD-T Dashboard Server — Zero-dep SSE server for .gsd-t/events/*.jsonl
4
- * Serves gsd-t-dashboard.html and streams events to browser clients.
5
- */
6
- const http = require("http");
7
- const fs = require("fs");
8
- const path = require("path");
9
- const { spawn } = require("child_process");
10
-
11
- // Base port — the effective default is project-hashed (see
12
- // projectScopedDefaultPort). Explicit --port N always overrides.
13
- const DEFAULT_PORT = 7433;
14
- const MAX_EVENTS = 500;
15
-
16
- /**
17
- * Deterministic project-scoped default port so two projects running
18
- * `gsd-t visualize` simultaneously don't collide on 7433. Hashes the
19
- * resolved project directory (djb2) and maps into [7433, 7532].
20
- *
21
- * @param {string} projectDir — any path-like string; resolved internally.
22
- * @returns {number} port in [DEFAULT_PORT, DEFAULT_PORT + 99].
23
- */
24
- function projectScopedDefaultPort(projectDir) {
25
- const resolved = path.resolve(projectDir || ".");
26
- let hash = 5381;
27
- for (let i = 0; i < resolved.length; i++) {
28
- hash = ((hash * 33) ^ resolved.charCodeAt(i)) >>> 0;
29
- }
30
- return DEFAULT_PORT + (hash % 100);
31
- }
32
-
33
- /**
34
- * Pure helper so callers (and tests) can resolve the effective port from a
35
- * parsed --port argument + projectDir. Explicit argPort always wins.
36
- */
37
- function resolvePort({ argPort, projectDir }) {
38
- if (argPort != null && argPort !== "" && !Number.isNaN(parseInt(argPort, 10))) {
39
- return parseInt(argPort, 10);
40
- }
41
- return projectScopedDefaultPort(projectDir);
42
- }
43
- const KEEPALIVE_MS = 15000;
44
- const SSE_HEADERS = { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", "Access-Control-Allow-Origin": "*" };
45
-
46
- function parseEventLine(line) {
47
- if (!line || !line.trim()) return null;
48
- try { return JSON.parse(line.trim()); } catch { return null; }
49
- }
50
-
51
- function findEventsDir(projectDir) {
52
- return path.join(projectDir || process.cwd(), ".gsd-t", "events");
53
- }
54
-
55
- function safeReadJsonl(filePath) {
56
- try { if (fs.lstatSync(filePath).isSymbolicLink()) return []; } catch { /* safe */ }
57
- try { return fs.readFileSync(filePath, "utf8").split("\n").map(parseEventLine).filter(Boolean); } catch { return []; }
58
- }
59
-
60
- function readExistingEvents(eventsDir, maxEvents) {
61
- const limit = maxEvents || MAX_EVENTS;
62
- if (!eventsDir) return [];
63
- try { fs.accessSync(eventsDir); } catch { return []; }
64
- let files;
65
- try { files = fs.readdirSync(eventsDir).filter((f) => f.endsWith(".jsonl")).sort().reverse(); } catch { return []; }
66
- const results = [];
67
- for (const f of files) {
68
- if (results.length >= limit) break;
69
- safeReadJsonl(path.join(eventsDir, f)).forEach((e) => { if (results.length < limit) results.push(e); });
70
- }
71
- return results;
72
- }
73
-
74
- function tailEventsFile(filePath, callback) {
75
- let offset = 0;
76
- try { offset = fs.statSync(filePath).size; } catch { /* new file */ }
77
- function processNewData() {
78
- try { if (fs.lstatSync(filePath).isSymbolicLink()) return; } catch { return; }
79
- let stat;
80
- try { stat = fs.statSync(filePath); } catch { return; }
81
- if (stat.size <= offset) return;
82
- const fd = fs.openSync(filePath, "r");
83
- let chunk;
84
- try {
85
- const buf = Buffer.alloc(stat.size - offset);
86
- fs.readSync(fd, buf, 0, buf.length, offset);
87
- chunk = buf.toString("utf8");
88
- offset = stat.size;
89
- } finally { fs.closeSync(fd); }
90
- chunk.split("\n").forEach((line) => { const obj = parseEventLine(line); if (obj) callback(obj); });
91
- }
92
- fs.watchFile(filePath, { interval: 500, persistent: true }, processNewData);
93
- return () => fs.unwatchFile(filePath, processNewData);
94
- }
95
-
96
- function handleRoot(req, res, htmlPath) {
97
- fs.readFile(htmlPath, (err, data) => {
98
- if (err) { res.writeHead(404); res.end("Not found"); return; }
99
- res.writeHead(200, { "Content-Type": "text/html" });
100
- res.end(data);
101
- });
102
- }
103
-
104
- function handlePing(req, res, port) {
105
- res.writeHead(200, { "Content-Type": "application/json" });
106
- res.end(JSON.stringify({ status: "ok", port }));
107
- }
108
-
109
- function handleStop(req, res, server) {
110
- res.writeHead(200, { "Content-Type": "application/json" });
111
- res.end(JSON.stringify({ status: "stopping" }));
112
- setImmediate(() => server.close());
113
- }
114
-
115
- function getNewestJsonl(eventsDir) {
116
- try { const files = fs.readdirSync(eventsDir).filter((f) => f.endsWith(".jsonl")).sort(); return files.length ? path.join(eventsDir, files[files.length - 1]) : null; } catch { return null; }
117
- }
118
-
119
- function handleEvents(req, res, eventsDir) {
120
- res.writeHead(200, SSE_HEADERS);
121
- readExistingEvents(eventsDir, MAX_EVENTS).forEach((e) => { try { res.write("data: " + JSON.stringify(e) + "\n\n"); } catch { /* gone */ } });
122
- const sendEvent = (obj) => { try { res.write("data: " + JSON.stringify(obj) + "\n\n"); } catch { /* gone */ } };
123
- let watchedFile = getNewestJsonl(eventsDir);
124
- let unwatchFile = watchedFile ? tailEventsFile(watchedFile, sendEvent) : null;
125
- // Watch events directory for new JSONL files (e.g., after midnight date rollover)
126
- let dirWatcher = null;
127
- try {
128
- dirWatcher = fs.watch(eventsDir, (eventType, filename) => {
129
- if (!filename || !filename.endsWith(".jsonl")) return;
130
- const newFile = getNewestJsonl(eventsDir);
131
- if (newFile && newFile !== watchedFile) {
132
- if (unwatchFile) unwatchFile();
133
- watchedFile = newFile;
134
- unwatchFile = tailEventsFile(watchedFile, sendEvent);
135
- }
136
- });
137
- } catch { /* eventsDir may not exist yet */ }
138
- const timer = setInterval(() => { try { res.write(": keepalive\n\n"); } catch { clearInterval(timer); } }, KEEPALIVE_MS);
139
- req.on("close", () => { clearInterval(timer); if (unwatchFile) unwatchFile(); if (dirWatcher) dirWatcher.close(); });
140
- }
141
-
142
- function readMetricsData(metricsDir) {
143
- const taskFile = path.join(metricsDir, "task-metrics.jsonl");
144
- const rollupFile = path.join(metricsDir, "rollup.jsonl");
145
- const taskMetrics = fs.existsSync(taskFile) ? safeReadJsonl(taskFile) : [];
146
- const rollups = fs.existsSync(rollupFile) ? safeReadJsonl(rollupFile) : [];
147
- return { taskMetrics, rollups };
148
- }
149
-
150
- function handleMetrics(req, res, projectDir) {
151
- const metricsDir = path.join(projectDir, ".gsd-t", "metrics");
152
- const data = readMetricsData(metricsDir);
153
- res.writeHead(200, { "Content-Type": "application/json" });
154
- res.end(JSON.stringify(data));
155
- }
156
-
157
- // ── M42 D2 — per-spawn transcript routes ──────────────────────────────────────
158
-
159
- const TRANSCRIPTS_SUBDIR = path.join(".gsd-t", "transcripts");
160
-
161
- function transcriptsDir(projectDir) {
162
- return path.join(projectDir, TRANSCRIPTS_SUBDIR);
163
- }
164
-
165
- function readTranscriptsIndex(projectDir) {
166
- const p = path.join(transcriptsDir(projectDir), ".index.json");
167
- try {
168
- const raw = fs.readFileSync(p, "utf8");
169
- const parsed = JSON.parse(raw);
170
- if (parsed && Array.isArray(parsed.spawns)) return parsed;
171
- } catch { /* no index yet */ }
172
- return { spawns: [] };
173
- }
174
-
175
- function isValidSpawnId(id) {
176
- return typeof id === "string" && /^[a-zA-Z0-9._-]+$/.test(id) && id.length <= 200;
177
- }
178
-
179
- // M45 D2 follow-up: filesystem-walk fallback for in-session conversation NDJSONs.
180
- // The conversation-capture hook writes `in-session-{sessionId}.ndjson` directly
181
- // to `transcripts/`, but does NOT update `.index.json` (the index is owned by
182
- // the spawn lifecycle, not by the in-session hook). Without this scan, the
183
- // dashboard's left rail never shows in-session conversations even though the
184
- // NDJSONs are on disk. Synthesizes a spawn-shaped entry per in-session file
185
- // using filesystem timestamps; the viewer's `in-session-` prefix detection
186
- // then labels it as `💬 conversation`.
187
- //
188
- // M47 D2: `status` is derived per-entry from mtime. A file modified within the
189
- // last 30 seconds is `active` (the conversation hook is still appending);
190
- // otherwise `completed`. The viewer (M47 D1) buckets entries by this field
191
- // into Live vs Completed rail sections. The `success | failed | killed`
192
- // taxonomy is intentionally out of scope here — see contract v1.3.0.
193
- const IN_SESSION_ACTIVE_WINDOW_MS = 30_000;
194
- function listInSessionTranscripts(projectDir) {
195
- const dir = transcriptsDir(projectDir);
196
- let files;
197
- try { files = fs.readdirSync(dir); } catch { return []; }
198
- const now = Date.now();
199
- const out = [];
200
- for (const f of files) {
201
- if (!f.startsWith("in-session-") || !f.endsWith(".ndjson")) continue;
202
- const spawnId = f.slice(0, -".ndjson".length); // "in-session-{sessionId}"
203
- if (!isValidSpawnId(spawnId)) continue;
204
- let stat;
205
- try { stat = fs.statSync(path.join(dir, f)); } catch { continue; }
206
- const status = (now - stat.mtimeMs) < IN_SESSION_ACTIVE_WINDOW_MS ? "active" : "completed";
207
- out.push({
208
- spawnId,
209
- command: "in-session conversation",
210
- startedAt: stat.birthtime ? stat.birthtime.toISOString() : stat.mtime.toISOString(),
211
- lastUpdatedAt: stat.mtime.toISOString(),
212
- status,
213
- kind: "in-session",
214
- });
215
- }
216
- return out;
217
- }
218
-
219
- // M47 D2: Resolve the most-recently-modified `in-session-*.ndjson` file. Used
220
- // by the viewer's top-pane default load (zero-click main-conversation stream).
221
- // Reuses `isValidSpawnId` for path-traversal safety.
222
- function handleMainSession(req, res, projectDir) {
223
- const dir = transcriptsDir(projectDir);
224
- let files;
225
- try { files = fs.readdirSync(dir); } catch { files = []; }
226
- let best = null;
227
- for (const f of files) {
228
- if (!f.startsWith("in-session-") || !f.endsWith(".ndjson")) continue;
229
- const spawnId = f.slice(0, -".ndjson".length);
230
- if (!isValidSpawnId(spawnId)) continue;
231
- let stat;
232
- try { stat = fs.statSync(path.join(dir, f)); } catch { continue; }
233
- if (!best || stat.mtimeMs > best.mtimeMs) {
234
- best = { filename: f, sessionId: f.slice("in-session-".length, -".ndjson".length), mtimeMs: stat.mtimeMs };
235
- }
236
- }
237
- res.writeHead(200, {
238
- "Content-Type": "application/json",
239
- "Cache-Control": "no-store",
240
- });
241
- if (!best) {
242
- res.end(JSON.stringify({ filename: null, sessionId: null, mtimeMs: null }));
243
- return;
244
- }
245
- res.end(JSON.stringify(best));
246
- }
247
-
248
- function handleTranscriptsList(req, res, projectDir, transcriptHtmlPath) {
249
- const idx = readTranscriptsIndex(projectDir);
250
-
251
- // Merge index entries with in-session NDJSONs from the filesystem.
252
- // De-dupe by spawnId — index entries take precedence (richer metadata).
253
- const known = new Set(idx.spawns.map((s) => s.spawnId));
254
- const inSession = listInSessionTranscripts(projectDir).filter((s) => !known.has(s.spawnId));
255
- const merged = idx.spawns.concat(inSession);
256
-
257
- const sorted = merged
258
- .slice()
259
- .sort((a, b) => (Date.parse(b.startedAt) || 0) - (Date.parse(a.startedAt) || 0));
260
-
261
- // Content negotiation: browser navigations send Accept: text/html, fetch()
262
- // defaults to */*. For text/html we serve the viewer (same HTML as
263
- // /transcript/:id) with an empty spawn-id placeholder — the viewer's left
264
- // rail populates from /api/spawns-index and the main pane defers until the
265
- // user clicks a spawn. Programmatic clients (fetch's default */* or explicit
266
- // application/json) continue to get the JSON shape the dashboard JS already
267
- // consumes.
268
- const accept = String(req.headers["accept"] || "");
269
- if (accept.includes("text/html") && transcriptHtmlPath) {
270
- fs.readFile(transcriptHtmlPath, (err, data) => {
271
- if (err) { res.writeHead(404); res.end("Transcript UI not found"); return; }
272
- // Substitute the __SPAWN_ID__ placeholder with an empty string; the
273
- // viewer's initialId logic falls through to location.hash (also empty)
274
- // and connect('') is a no-op beyond a 404 SSE attempt — harmless, since
275
- // the left rail polls /api/spawns-index independently.
276
- const projectName = path.basename(path.resolve(projectDir || "."));
277
- // Function-form replacement: a string replacement would interpret
278
- // `$&`, `$1`, `$$`, etc. in the project basename as backreferences,
279
- // re-injecting the placeholder or fragments of it (Red Team BUG-1).
280
- const escapedName = _escapeHtml(projectName);
281
- const html = data.toString("utf8")
282
- .replace(/__SPAWN_ID__/g, () => "")
283
- .replace(/__PROJECT_NAME__/g, () => escapedName);
284
- res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
285
- res.end(html);
286
- });
287
- return;
288
- }
289
- res.writeHead(200, { "Content-Type": "application/json" });
290
- res.end(JSON.stringify({ spawns: sorted }));
291
- }
292
-
293
- function handleTranscriptPage(req, res, spawnId, transcriptHtmlPath, projectDir) {
294
- if (!isValidSpawnId(spawnId)) { res.writeHead(400); res.end("Invalid spawn id"); return; }
295
- fs.readFile(transcriptHtmlPath, (err, data) => {
296
- if (err) { res.writeHead(404); res.end("Transcript UI not found"); return; }
297
- // Inject the spawn-id as a data attribute on <body> by string replacement;
298
- // the HTML ships with a placeholder `data-spawn-id="__SPAWN_ID__"`.
299
- const projectName = path.basename(path.resolve(projectDir || "."));
300
- // Function-form replacement: see comment in handleTranscriptsList. Even
301
- // though isValidSpawnId guards spawnId against `$`, defence in depth.
302
- const escapedName = _escapeHtml(projectName);
303
- const html = data.toString("utf8")
304
- .replace(/__SPAWN_ID__/g, () => spawnId)
305
- .replace(/__PROJECT_NAME__/g, () => escapedName);
306
- res.writeHead(200, { "Content-Type": "text/html" });
307
- res.end(html);
308
- });
309
- }
310
-
311
- // HTML-escape just enough to make a directory basename safe in <title> and
312
- // <div class="title">. Project basenames effectively never contain quotes or
313
- // angle brackets, but we still escape to keep the surface tight.
314
- function _escapeHtml(s) {
315
- return String(s == null ? "" : s)
316
- .replace(/&/g, "&amp;")
317
- .replace(/</g, "&lt;")
318
- .replace(/>/g, "&gt;")
319
- .replace(/"/g, "&quot;")
320
- .replace(/'/g, "&#39;");
321
- }
322
-
323
- function tailTranscriptFile(filePath, callback) {
324
- let offset = 0;
325
- let buf = "";
326
- function processNewData() {
327
- let stat;
328
- try { stat = fs.statSync(filePath); } catch { return; }
329
- if (stat.size <= offset) return;
330
- const fd = fs.openSync(filePath, "r");
331
- try {
332
- const b = Buffer.alloc(stat.size - offset);
333
- fs.readSync(fd, b, 0, b.length, offset);
334
- buf += b.toString("utf8");
335
- offset = stat.size;
336
- } finally { fs.closeSync(fd); }
337
- let nl;
338
- while ((nl = buf.indexOf("\n")) >= 0) {
339
- const line = buf.slice(0, nl);
340
- buf = buf.slice(nl + 1);
341
- if (line.length > 0) callback(line);
342
- }
343
- }
344
- fs.watchFile(filePath, { interval: 500, persistent: true }, processNewData);
345
- return () => fs.unwatchFile(filePath, processNewData);
346
- }
347
-
348
- // ── M43 D6 — per-spawn tool-cost + usage routes ────────────────────────────
349
-
350
- /**
351
- * Load per-turn usage rows for a given spawn-id from
352
- * `.gsd-t/metrics/token-usage.jsonl`.
353
- *
354
- * Rows emitted by M41/M43 include either `spawn_id` OR `session_id`. We match
355
- * against both so the route works both for headless spawns (which tag rows
356
- * with `spawn_id` at emit time) and in-session D1 captures (which tag with
357
- * the session's `session_id`).
358
- *
359
- * @param {string} projectDir
360
- * @param {string} spawnId
361
- * @returns {Array<object>}
362
- */
363
- function readSpawnUsageRows(projectDir, spawnId) {
364
- const fp = path.join(projectDir, ".gsd-t", "metrics", "token-usage.jsonl");
365
- if (!fs.existsSync(fp)) return [];
366
- const lines = safeReadJsonl(fp);
367
- return lines.filter((row) => {
368
- if (!row || typeof row !== "object") return false;
369
- return row.spawn_id === spawnId || row.session_id === spawnId;
370
- });
371
- }
372
-
373
- function handleTranscriptUsage(req, res, spawnId, projectDir) {
374
- if (!isValidSpawnId(spawnId)) { res.writeHead(400); res.end("Invalid spawn id"); return; }
375
- const rows = readSpawnUsageRows(projectDir, spawnId);
376
- res.writeHead(200, { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" });
377
- res.end(JSON.stringify({ spawn_id: spawnId, rows, generated_at: new Date().toISOString() }));
378
- }
379
-
380
- /**
381
- * Proxy to D2's `aggregateByTool`. D2 is co-deployed in Wave 2 — if the
382
- * library isn't on disk yet, we return HTTP 503 with a machine-readable
383
- * body so the viewer panel can display "Tool attribution not yet available"
384
- * without breaking.
385
- */
386
- function handleTranscriptToolCost(req, res, spawnId, projectDir) {
387
- if (!isValidSpawnId(spawnId)) { res.writeHead(400); res.end("Invalid spawn id"); return; }
388
- let attribution;
389
- try {
390
- // TODO(D6→D2): remove the try/catch once D2 lands. Keep the call shape.
391
- // eslint-disable-next-line global-require
392
- attribution = require("../bin/gsd-t-tool-attribution.cjs");
393
- } catch (_) {
394
- res.writeHead(503, { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" });
395
- res.end(JSON.stringify({ error: "tool-attribution library not yet available" }));
396
- return;
397
- }
398
- const rows = readSpawnUsageRows(projectDir, spawnId);
399
- let tools = [];
400
- try {
401
- if (typeof attribution.aggregateByTool !== "function") {
402
- res.writeHead(503, { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" });
403
- res.end(JSON.stringify({ error: "aggregateByTool not exported by tool-attribution library" }));
404
- return;
405
- }
406
- tools = attribution.aggregateByTool(rows) || [];
407
- } catch (err) {
408
- res.writeHead(500, { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" });
409
- res.end(JSON.stringify({ error: String(err && err.message ? err.message : err) }));
410
- return;
411
- }
412
- res.writeHead(200, { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" });
413
- res.end(JSON.stringify({
414
- spawn_id: spawnId,
415
- tools,
416
- generated_at: new Date().toISOString(),
417
- }));
418
- }
419
-
420
- // ── M42 D3 — kill per-spawn ────────────────────────────────────────────────
421
-
422
- function writeTranscriptsIndex(projectDir, idx) {
423
- const p = path.join(transcriptsDir(projectDir), ".index.json");
424
- const tmp = p + ".tmp";
425
- try { fs.mkdirSync(path.dirname(p), { recursive: true }); } catch { /* exists */ }
426
- fs.writeFileSync(tmp, JSON.stringify(idx, null, 2));
427
- fs.renameSync(tmp, p);
428
- }
429
-
430
- function handleTranscriptKill(req, res, spawnId, projectDir) {
431
- if (!isValidSpawnId(spawnId)) { res.writeHead(400); res.end("Invalid spawn id"); return; }
432
- const idx = readTranscriptsIndex(projectDir);
433
- const i = idx.spawns.findIndex((s) => s.spawnId === spawnId);
434
- if (i < 0) { res.writeHead(404, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "unknown_spawn" })); return; }
435
- const entry = idx.spawns[i];
436
- const pid = entry.workerPid;
437
- if (!pid || typeof pid !== "number") {
438
- res.writeHead(409, { "Content-Type": "application/json" });
439
- res.end(JSON.stringify({ error: "no_pid_recorded" }));
440
- return;
441
- }
442
- try {
443
- process.kill(pid, "SIGTERM");
444
- idx.spawns[i].status = "stopped";
445
- idx.spawns[i].endedAt = idx.spawns[i].endedAt || new Date().toISOString();
446
- writeTranscriptsIndex(projectDir, idx);
447
- res.writeHead(200, { "Content-Type": "application/json" });
448
- res.end(JSON.stringify({ status: "stopped", pid }));
449
- } catch (err) {
450
- if (err.code === "ESRCH") {
451
- // Process already gone — treat as success and mark ended
452
- idx.spawns[i].status = idx.spawns[i].status === "running" ? "stopped" : idx.spawns[i].status;
453
- idx.spawns[i].endedAt = idx.spawns[i].endedAt || new Date().toISOString();
454
- writeTranscriptsIndex(projectDir, idx);
455
- res.writeHead(200, { "Content-Type": "application/json" });
456
- res.end(JSON.stringify({ status: "already_stopped", pid }));
457
- return;
458
- }
459
- if (err.code === "EPERM") {
460
- res.writeHead(403, { "Content-Type": "application/json" });
461
- res.end(JSON.stringify({ error: "permission_denied", pid }));
462
- return;
463
- }
464
- res.writeHead(500, { "Content-Type": "application/json" });
465
- res.end(JSON.stringify({ error: String(err.message || err) }));
466
- }
467
- }
468
-
469
- /**
470
- * Find the transcripts-index entry for a spawn-id, or null when the index
471
- * is missing / the id isn't recorded. Used by handleTranscriptStream to
472
- * detect already-finished spawns so the SSE stream can emit `event: end`
473
- * and close instead of tailing indefinitely.
474
- */
475
- function readIndexEntry(projectDir, spawnId) {
476
- const idx = readTranscriptsIndex(projectDir);
477
- if (!idx || !Array.isArray(idx.spawns)) return null;
478
- return idx.spawns.find((s) => s && s.spawnId === spawnId) || null;
479
- }
480
-
481
- function handleTranscriptStream(req, res, spawnId, projectDir) {
482
- if (!isValidSpawnId(spawnId)) { res.writeHead(400); res.end("Invalid spawn id"); return; }
483
- const filePath = path.join(transcriptsDir(projectDir), `${spawnId}.ndjson`);
484
- let exists = true;
485
- let fileSize = 0;
486
- try { fileSize = fs.statSync(filePath).size; } catch { exists = false; }
487
-
488
- res.writeHead(200, SSE_HEADERS);
489
-
490
- // Consult the transcripts index — if the spawn is already finished we
491
- // replay and close instead of attaching a live tail.
492
- const entry = readIndexEntry(projectDir, spawnId);
493
- const finishedStatuses = ["done", "failed", "stopped"];
494
- const isFinished = !!(entry && entry.status && finishedStatuses.includes(entry.status));
495
-
496
- // Replay: read the full file from byte 0 and send each line.
497
- let replayedBytes = 0;
498
- if (exists) {
499
- try {
500
- const full = fs.readFileSync(filePath, "utf8");
501
- replayedBytes = Buffer.byteLength(full, "utf8");
502
- full.split("\n").forEach((line) => {
503
- if (line.length > 0) {
504
- try { res.write("data: " + line + "\n\n"); } catch { /* gone */ }
505
- }
506
- });
507
- } catch { /* empty transcript */ }
508
- }
509
-
510
- // Finished spawn → emit end event and close the stream. No tail, no keepalive.
511
- if (isFinished) {
512
- const endPayload = JSON.stringify({
513
- status: entry.status,
514
- endedAt: entry.endedAt || null,
515
- });
516
- try { res.write("event: end\ndata: " + endPayload + "\n\n"); } catch { /* gone */ }
517
- try { res.end(); } catch { /* gone */ }
518
- return;
519
- }
520
-
521
- // Missing file — tell the viewer the stream is live but empty. fs.watchFile
522
- // needs the file to exist, so we skip the tail here. A running producer
523
- // will have created the file already (the finished-spawn branch above
524
- // catches the post-hoc teed case via index status).
525
- if (!exists) {
526
- const waitingPayload = JSON.stringify({
527
- status: "waiting",
528
- reason: "no transcript file yet",
529
- });
530
- try { res.write("event: status\ndata: " + waitingPayload + "\n\n"); } catch { /* gone */ }
531
- const timer = setInterval(() => { try { res.write(": keepalive\n\n"); } catch { clearInterval(timer); } }, KEEPALIVE_MS);
532
- req.on("close", () => { clearInterval(timer); });
533
- return;
534
- }
535
-
536
- // Empty file — producer hasn't written anything yet. Emit a status frame
537
- // so the viewer knows the stream is live but the file is 0 bytes, then
538
- // attach the live tail so we pick up writes as they arrive.
539
- if (fileSize === 0) {
540
- const emptyPayload = JSON.stringify({
541
- status: "empty",
542
- reason: "transcript file exists but is empty",
543
- });
544
- try { res.write("event: status\ndata: " + emptyPayload + "\n\n"); } catch { /* gone */ }
545
- }
546
-
547
- // Tail: from replayedBytes onward. We reuse the tailer, but seed offset
548
- // with what we already replayed so we don't double-send.
549
- let offset = replayedBytes;
550
- let buf = "";
551
- const processNewData = () => {
552
- let stat;
553
- try { stat = fs.statSync(filePath); } catch { return; }
554
- if (stat.size <= offset) return;
555
- const fd = fs.openSync(filePath, "r");
556
- try {
557
- const b = Buffer.alloc(stat.size - offset);
558
- fs.readSync(fd, b, 0, b.length, offset);
559
- buf += b.toString("utf8");
560
- offset = stat.size;
561
- } finally { fs.closeSync(fd); }
562
- let nl;
563
- while ((nl = buf.indexOf("\n")) >= 0) {
564
- const line = buf.slice(0, nl);
565
- buf = buf.slice(nl + 1);
566
- if (line.length > 0) {
567
- try { res.write("data: " + line + "\n\n"); } catch { /* gone */ }
568
- }
569
- }
570
- };
571
- fs.watchFile(filePath, { interval: 500, persistent: true }, processNewData);
572
- const unwatchFile = () => fs.unwatchFile(filePath, processNewData);
573
-
574
- const timer = setInterval(() => { try { res.write(": keepalive\n\n"); } catch { clearInterval(timer); } }, KEEPALIVE_MS);
575
- req.on("close", () => { clearInterval(timer); if (unwatchFile) unwatchFile(); });
576
- }
577
-
578
- // ── M44 D8 — spawn-plan endpoint + SSE channel ─────────────────────────────
579
- //
580
- // Additive; does not change existing endpoints or SSE streams. Reads plan
581
- // files atomically written by `bin/spawn-plan-writer.cjs` under
582
- // `.gsd-t/spawns/{spawnId}.json`. A plan is "active" when `endedAt === null`.
583
- // Contract: .gsd-t/contracts/spawn-plan-contract.md v1.0.0
584
-
585
- const SPAWNS_SUBDIR = path.join(".gsd-t", "spawns");
586
-
587
- function spawnsDir(projectDir) {
588
- return path.join(projectDir, SPAWNS_SUBDIR);
589
- }
590
-
591
- function readSpawnPlanFile(fp) {
592
- try {
593
- const raw = fs.readFileSync(fp, "utf8");
594
- const parsed = JSON.parse(raw);
595
- if (!parsed || typeof parsed !== "object") return null;
596
- return parsed;
597
- } catch { return null; }
598
- }
599
-
600
- function listAllSpawnPlans(projectDir) {
601
- const dir = spawnsDir(projectDir);
602
- if (!fs.existsSync(dir)) return [];
603
- let files;
604
- try { files = fs.readdirSync(dir).filter((f) => f.endsWith(".json")); } catch { return []; }
605
- const out = [];
606
- for (const f of files) {
607
- const plan = readSpawnPlanFile(path.join(dir, f));
608
- if (plan) out.push(plan);
609
- }
610
- return out;
611
- }
612
-
613
- function listActiveSpawnPlans(projectDir) {
614
- return listAllSpawnPlans(projectDir).filter((p) => p && p.endedAt == null);
615
- }
616
-
617
- // ── M44 D9 — Parallelism observability ──────────────────────────────────────
618
- // Additive endpoints powered by bin/parallelism-report.cjs (v1.0.0 contract).
619
- // Pure read-only observer; never writes, never spawns. 5-second per-response
620
- // cache so rapid panel polls don't hammer the filesystem.
621
-
622
- const PARALLELISM_CACHE_MS = 5000;
623
- const _parallelismCache = { metrics: { at: 0, body: null, wave: null }, report: new Map() };
624
-
625
- function _loadParallelismReporter() {
626
- try {
627
- // Resolve at call-time so tests that don't install the module don't break
628
- // unrelated endpoints. Require is cached by Node after first success.
629
- return require(path.join(__dirname, "..", "bin", "parallelism-report.cjs"));
630
- } catch (err) {
631
- return { _loadError: err && err.message || String(err) };
632
- }
633
- }
634
-
635
- function handleParallelism(req, res, projectDir) {
636
- const urlObj = req.url ? req.url.split("?") : ["", ""];
637
- const qs = urlObj[1] || "";
638
- const params = new URLSearchParams(qs);
639
- const wave = params.get("wave") || null;
640
- const now = Date.now();
641
- const cache = _parallelismCache.metrics;
642
- if (cache.body && cache.wave === wave && (now - cache.at) < PARALLELISM_CACHE_MS) {
643
- res.writeHead(200, { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*", "X-Cache": "hit" });
644
- res.end(cache.body);
645
- return;
646
- }
647
- const reporter = _loadParallelismReporter();
648
- if (reporter._loadError) {
649
- res.writeHead(500, { "Content-Type": "application/json" });
650
- res.end(JSON.stringify({ error: "parallelism-report module unavailable", detail: reporter._loadError }));
651
- return;
652
- }
653
- let metrics;
654
- try {
655
- metrics = reporter.computeParallelismMetrics({ projectDir, wave: wave || undefined });
656
- } catch (err) {
657
- // Contract says silent-fail; a thrown error here means contract regression.
658
- res.writeHead(500, { "Content-Type": "application/json" });
659
- res.end(JSON.stringify({ error: "computeParallelismMetrics threw", detail: err && err.message || String(err) }));
660
- return;
661
- }
662
- const body = JSON.stringify(metrics);
663
- cache.at = now;
664
- cache.wave = wave;
665
- cache.body = body;
666
- res.writeHead(200, { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*", "X-Cache": "miss" });
667
- res.end(body);
668
- }
669
-
670
- function handleParallelismReport(req, res, projectDir) {
671
- const urlObj = req.url ? req.url.split("?") : ["", ""];
672
- const qs = urlObj[1] || "";
673
- const params = new URLSearchParams(qs);
674
- const wave = params.get("wave") || null;
675
- const cacheKey = wave || "__all__";
676
- const now = Date.now();
677
- const cached = _parallelismCache.report.get(cacheKey);
678
- if (cached && (now - cached.at) < PARALLELISM_CACHE_MS) {
679
- res.writeHead(200, { "Content-Type": "text/markdown; charset=utf-8", "Access-Control-Allow-Origin": "*", "X-Cache": "hit" });
680
- res.end(cached.body);
681
- return;
682
- }
683
- const reporter = _loadParallelismReporter();
684
- if (reporter._loadError) {
685
- res.writeHead(500, { "Content-Type": "text/plain" });
686
- res.end("parallelism-report module unavailable: " + reporter._loadError);
687
- return;
688
- }
689
- let md;
690
- try {
691
- md = reporter.buildFullReport({ projectDir, wave: wave || undefined });
692
- } catch (err) {
693
- res.writeHead(500, { "Content-Type": "text/plain" });
694
- res.end("buildFullReport threw: " + (err && err.message || String(err)));
695
- return;
696
- }
697
- _parallelismCache.report.set(cacheKey, { at: now, body: md });
698
- res.writeHead(200, { "Content-Type": "text/markdown; charset=utf-8", "Access-Control-Allow-Origin": "*", "X-Cache": "miss" });
699
- res.end(md);
700
- }
701
-
702
- // M54 D1 T3 — Live Activity endpoints (additive, read-only)
703
- // Contract: .gsd-t/contracts/live-activity-contract.md v1.0.0
704
- // Three endpoints: GET /api/live-activity (5s cache),
705
- // GET /api/live-activity/<id>/tail (per-id 5s cache, path-traversal guard),
706
- // GET /api/live-activity/<id>/stream (SSE, uncached, 15s heartbeat).
707
-
708
- const LIVE_ACTIVITY_CACHE_MS = 5000;
709
- const _liveActivityCache = { list: { at: 0, body: null }, tail: new Map() };
710
-
711
- function _loadLiveActivityReporter() {
712
- try {
713
- return require(path.join(__dirname, "..", "bin", "live-activity-report.cjs"));
714
- } catch (err) {
715
- return { _loadError: err && err.message || String(err) };
716
- }
717
- }
718
-
719
- function isValidActivityId(id) {
720
- if (typeof id !== "string") return false;
721
- if (id.length === 0 || id.length > 256) return false;
722
- if (id.indexOf("..") !== -1) return false;
723
- if (id.indexOf("/") !== -1) return false;
724
- if (id.indexOf("\\") !== -1) return false;
725
- if (id.indexOf("\0") !== -1) return false;
726
- return true;
727
- }
728
-
729
- function handleLiveActivity(req, res, projectDir) {
730
- const now = Date.now();
731
- const cache = _liveActivityCache.list;
732
- if (cache.body && (now - cache.at) < LIVE_ACTIVITY_CACHE_MS) {
733
- res.writeHead(200, { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*", "Cache-Control": "no-store", "X-Cache": "hit" });
734
- res.end(cache.body);
735
- return;
736
- }
737
- const reporter = _loadLiveActivityReporter();
738
- if (reporter._loadError) {
739
- res.writeHead(500, { "Content-Type": "application/json" });
740
- res.end(JSON.stringify({ error: "live-activity-report module unavailable", detail: reporter._loadError }));
741
- return;
742
- }
743
- let result;
744
- try {
745
- result = reporter.computeLiveActivities({ projectDir });
746
- } catch (err) {
747
- // Contract regression — computeLiveActivities must never throw
748
- res.writeHead(500, { "Content-Type": "application/json" });
749
- res.end(JSON.stringify({ error: "computeLiveActivities threw", detail: err && err.message || String(err) }));
750
- return;
751
- }
752
- const body = JSON.stringify(result);
753
- cache.at = now;
754
- cache.body = body;
755
- res.writeHead(200, { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*", "Cache-Control": "no-store", "X-Cache": "miss" });
756
- res.end(body);
757
- }
758
-
759
- function handleLiveActivityTail(req, res, projectDir, id) {
760
- if (!isValidActivityId(id)) {
761
- res.writeHead(400, { "Content-Type": "application/json" });
762
- res.end(JSON.stringify({ error: "invalid_id" }));
763
- return;
764
- }
765
- const cacheKey = projectDir + "\0" + id;
766
- const now = Date.now();
767
- const cached = _liveActivityCache.tail.get(cacheKey);
768
- if (cached && (now - cached.at) < LIVE_ACTIVITY_CACHE_MS) {
769
- res.writeHead(cached.statusCode, cached.headers);
770
- res.end(cached.body);
771
- return;
772
- }
773
-
774
- // Find the activity to determine kind and tail path
775
- const reporter = _loadLiveActivityReporter();
776
- if (reporter._loadError) {
777
- res.writeHead(500, { "Content-Type": "application/json" });
778
- res.end(JSON.stringify({ error: "live-activity-report module unavailable" }));
779
- return;
780
- }
781
- let result;
782
- try {
783
- result = reporter.computeLiveActivities({ projectDir });
784
- } catch (err) {
785
- res.writeHead(500, { "Content-Type": "application/json" });
786
- res.end(JSON.stringify({ error: "computeLiveActivities threw", detail: err && err.message || String(err) }));
787
- return;
788
- }
789
-
790
- const activity = result.activities.find((a) => a.id === id);
791
- if (!activity) {
792
- const notFound = { statusCode: 404, headers: { "Content-Type": "application/json", "Cache-Control": "no-store" }, body: JSON.stringify({ error: "not_found" }) };
793
- _liveActivityCache.tail.set(cacheKey, Object.assign({ at: now }, notFound));
794
- res.writeHead(404, notFound.headers);
795
- res.end(notFound.body);
796
- return;
797
- }
798
-
799
- // Read tail content by kind
800
- let tailContent = "";
801
- const kind = activity.kind;
802
- if (kind === "bash" || kind === "spawn") {
803
- // Last ~64 KB of process stdout — use _readFileTail pattern
804
- // For bash: the log file path is not directly known without process inspection.
805
- // Return a JSON snapshot of the activity as a fallback.
806
- tailContent = JSON.stringify(activity, null, 2);
807
- } else if (kind === "monitor") {
808
- tailContent = JSON.stringify(activity, null, 2);
809
- } else if (kind === "tool") {
810
- tailContent = JSON.stringify(activity, null, 2);
811
- } else {
812
- tailContent = JSON.stringify(activity, null, 2);
813
- }
814
-
815
- const hit = { statusCode: 200, headers: { "Content-Type": "text/plain; charset=utf-8", "Cache-Control": "no-store", "Access-Control-Allow-Origin": "*" }, body: tailContent };
816
- _liveActivityCache.tail.set(cacheKey, Object.assign({ at: now }, hit));
817
- res.writeHead(200, hit.headers);
818
- res.end(hit.body);
819
- }
820
-
821
- function handleLiveActivityStream(req, res, projectDir, id) {
822
- if (!isValidActivityId(id)) {
823
- res.writeHead(400, { "Content-Type": "application/json" });
824
- res.end(JSON.stringify({ error: "invalid_id" }));
825
- return;
826
- }
827
- res.writeHead(200, Object.assign({}, SSE_HEADERS, { "Access-Control-Allow-Origin": "*", "Cache-Control": "no-store" }));
828
-
829
- // Send an initial data frame
830
- res.write("data: " + JSON.stringify({ id, status: "stream_opened" }) + "\n\n");
831
-
832
- // Heartbeat every 15s
833
- const heartbeat = setInterval(() => {
834
- if (!res.writableEnded) {
835
- res.write(": heartbeat\n\n");
836
- }
837
- }, KEEPALIVE_MS);
838
-
839
- // Poll for activity presence; close stream when activity disappears
840
- const reporter = _loadLiveActivityReporter();
841
- const poll = setInterval(() => {
842
- if (res.writableEnded) {
843
- clearInterval(poll);
844
- clearInterval(heartbeat);
845
- return;
846
- }
847
- if (reporter._loadError) {
848
- clearInterval(poll);
849
- clearInterval(heartbeat);
850
- res.write("data: " + JSON.stringify({ id, status: "reporter_unavailable" }) + "\n\n");
851
- res.end();
852
- return;
853
- }
854
- let result;
855
- try {
856
- result = reporter.computeLiveActivities({ projectDir });
857
- } catch (_) {
858
- return; // silent — will retry next tick
859
- }
860
- const still = result.activities.find((a) => a.id === id);
861
- if (!still) {
862
- clearInterval(poll);
863
- clearInterval(heartbeat);
864
- if (!res.writableEnded) {
865
- res.write("data: " + JSON.stringify({ id, status: "activity_ended" }) + "\n\n");
866
- res.end();
867
- }
868
- return;
869
- }
870
- // Emit a status frame
871
- res.write("data: " + JSON.stringify({ id, durationMs: still.durationMs, alive: still.alive }) + "\n\n");
872
- }, 1000);
873
-
874
- req.on("close", () => {
875
- clearInterval(poll);
876
- clearInterval(heartbeat);
877
- });
878
- }
879
-
880
- // POST /api/unattended-stop — proxies to the existing stop-sentinel flow so
881
- // the transcript panel's "Stop Supervisor" button reuses the canonical
882
- // kill path. Writes `.gsd-t/.unattended/stop` sentinel; supervisor polls
883
- // it and self-exits. Contract reminder: D9 does NOT implement its own
884
- // stop logic, does NOT PID-kill.
885
- function handleUnattendedStop(req, res, projectDir) {
886
- if (req.method !== "POST") {
887
- res.writeHead(405, { "Content-Type": "application/json", "Allow": "POST" });
888
- res.end(JSON.stringify({ error: "method not allowed" }));
889
- return;
890
- }
891
- const stopDir = path.join(projectDir, ".gsd-t", ".unattended");
892
- const stopFile = path.join(stopDir, "stop");
893
- try {
894
- fs.mkdirSync(stopDir, { recursive: true });
895
- fs.writeFileSync(stopFile, new Date().toISOString() + "\n");
896
- } catch (err) {
897
- res.writeHead(500, { "Content-Type": "application/json" });
898
- res.end(JSON.stringify({ error: "failed to write stop sentinel", detail: err && err.message || String(err) }));
899
- return;
900
- }
901
- res.writeHead(200, { "Content-Type": "application/json" });
902
- res.end(JSON.stringify({ ok: true, sentinel: stopFile }));
903
- }
904
-
905
- function handleSpawnPlans(req, res, projectDir) {
906
- const plans = listActiveSpawnPlans(projectDir).sort((a, b) => {
907
- const ta = Date.parse(a.startedAt) || 0;
908
- const tb = Date.parse(b.startedAt) || 0;
909
- return tb - ta;
910
- });
911
- res.writeHead(200, { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" });
912
- res.end(JSON.stringify({ plans, generated_at: new Date().toISOString() }));
913
- }
914
-
915
- function handleSpawnPlanUpdates(req, res, projectDir) {
916
- res.writeHead(200, SSE_HEADERS);
917
- // Initial snapshot: every current active plan.
918
- const initial = listActiveSpawnPlans(projectDir);
919
- for (const plan of initial) {
920
- try { res.write("data: " + JSON.stringify({ spawnId: plan.spawnId, plan }) + "\n\n"); } catch { /* gone */ }
921
- }
922
-
923
- const dir = spawnsDir(projectDir);
924
- let dirWatcher = null;
925
- const emittedCache = new Map(); // spawnId → last-mtime-ms, dedup rapid rename events
926
-
927
- function pushChange(spawnId) {
928
- if (!spawnId) return;
929
- const fp = path.join(dir, spawnId + ".json");
930
- let mtime = 0;
931
- try { mtime = fs.statSync(fp).mtimeMs || 0; } catch { /* missing */ }
932
- // Dedup rapid-fire rename events — only emit when mtime advances.
933
- if (emittedCache.get(spawnId) === mtime && mtime > 0) return;
934
- emittedCache.set(spawnId, mtime);
935
- const plan = readSpawnPlanFile(fp);
936
- if (!plan) return;
937
- try { res.write("data: " + JSON.stringify({ spawnId, plan }) + "\n\n"); } catch { /* gone */ }
938
- }
939
-
940
- try {
941
- if (fs.existsSync(dir)) {
942
- dirWatcher = fs.watch(dir, (eventType, filename) => {
943
- if (!filename || !filename.endsWith(".json")) return;
944
- const spawnId = filename.slice(0, -5);
945
- pushChange(spawnId);
946
- });
947
- }
948
- } catch { /* dir may not exist yet; skip watch */ }
949
-
950
- const timer = setInterval(() => { try { res.write(": keepalive\n\n"); } catch { clearInterval(timer); } }, KEEPALIVE_MS);
951
- req.on("close", () => { clearInterval(timer); if (dirWatcher) { try { dirWatcher.close(); } catch { /* ok */ } } });
952
- }
953
-
954
- // ── M49 — Idle-TTL self-shutdown ────────────────────────────────────────────
955
- //
956
- // A dashboard with zero HTTP requests AND zero active SSE connections for the
957
- // full TTL window self-exits cleanly. Safety net for any dashboard that
958
- // somehow gets started and then walks away — even if a future bug lets one
959
- // be auto-started, it dies on its own. Configurable via env
960
- // `GSD_T_DASHBOARD_IDLE_TTL_MS` or `--idle-ttl-ms` flag.
961
- //
962
- // "Idle" means: zero HTTP requests AND zero active SSE connections for the
963
- // full TTL window. `lastActivity` is bumped on every HTTP request handler
964
- // entry and on SSE connect/disconnect. SSE-active dashboards never exit.
965
- //
966
- // On shutdown, removes `.gsd-t/.dashboard.pid` so the lazy probe (M49 in
967
- // `bin/headless-auto-spawn.cjs`) sees a clean state.
968
-
969
- const DEFAULT_IDLE_TTL_MS = 4 * 60 * 60 * 1000; // 4 hours
970
- const IDLE_CHECK_INTERVAL_MS = 60 * 1000; // 60s
971
-
972
- function _activityTracker() {
973
- let lastActivity = Date.now();
974
- let activeSseConnections = 0;
975
- return {
976
- bump() { lastActivity = Date.now(); },
977
- sseConnect() { activeSseConnections++; lastActivity = Date.now(); },
978
- sseDisconnect() {
979
- if (activeSseConnections > 0) activeSseConnections--;
980
- lastActivity = Date.now();
981
- },
982
- snapshot() { return { lastActivity, activeSseConnections }; },
983
- };
984
- }
985
-
986
- /**
987
- * Wrap an SSE handler so it bumps the connect/disconnect counters.
988
- */
989
- function _wrapSseHandler(handler, tracker) {
990
- return function (req, res, ...rest) {
991
- tracker.sseConnect();
992
- let closed = false;
993
- const onClose = () => {
994
- if (closed) return;
995
- closed = true;
996
- tracker.sseDisconnect();
997
- };
998
- req.on("close", onClose);
999
- res.on("close", onClose);
1000
- res.on("finish", onClose);
1001
- return handler(req, res, ...rest);
1002
- };
1003
- }
1004
-
1005
- /**
1006
- * @param {object} opts { ttlMs, intervalMs, projectDir, server }
1007
- * @returns timer handle (so callers can clearInterval in tests).
1008
- */
1009
- function _startIdleTtlTimer({ ttlMs, intervalMs, projectDir, server, tracker, onShutdown }) {
1010
- const interval = setInterval(() => {
1011
- const { lastActivity, activeSseConnections } = tracker.snapshot();
1012
- const idle = Date.now() - lastActivity;
1013
- if (activeSseConnections === 0 && idle >= ttlMs) {
1014
- clearInterval(interval);
1015
- try {
1016
- // Remove pid file so the lazy probe in headless-auto-spawn sees clean state.
1017
- if (projectDir) {
1018
- const pidFile = path.join(projectDir, ".gsd-t", ".dashboard.pid");
1019
- try { fs.unlinkSync(pidFile); } catch { /* may not exist */ }
1020
- }
1021
- } catch { /* best-effort */ }
1022
- try { if (typeof onShutdown === "function") onShutdown(); } catch { /* best-effort */ }
1023
- try {
1024
- if (server) server.close(() => process.exit(0));
1025
- else process.exit(0);
1026
- } catch { process.exit(0); }
1027
- }
1028
- }, intervalMs);
1029
- if (typeof interval.unref === "function") interval.unref();
1030
- return interval;
1031
- }
1032
-
1033
- function startServer(port, eventsDir, htmlPath, projectDir, transcriptHtmlPath, opts) {
1034
- const projDir = projectDir || path.resolve(eventsDir, "..", "..");
1035
- const tHtmlPath = transcriptHtmlPath || path.join(path.dirname(htmlPath), "gsd-t-transcript.html");
1036
- const tracker = _activityTracker();
1037
- const ttlMs = (opts && Number.isFinite(opts.idleTtlMs))
1038
- ? opts.idleTtlMs
1039
- : (Number.parseInt(process.env.GSD_T_DASHBOARD_IDLE_TTL_MS || "", 10) || DEFAULT_IDLE_TTL_MS);
1040
- const intervalMs = (opts && Number.isFinite(opts.idleCheckIntervalMs))
1041
- ? opts.idleCheckIntervalMs
1042
- : IDLE_CHECK_INTERVAL_MS;
1043
-
1044
- // Wrap the three SSE handlers with the connect/disconnect tracker.
1045
- const handleEventsSse = _wrapSseHandler(handleEvents, tracker);
1046
- const handleTranscriptStreamSse = _wrapSseHandler(handleTranscriptStream, tracker);
1047
- const handleSpawnPlanUpdatesSse = _wrapSseHandler(handleSpawnPlanUpdates, tracker);
1048
-
1049
- const server = http.createServer((req, res) => {
1050
- tracker.bump(); // bump on every HTTP request handler entry
1051
- const url = req.url.split("?")[0];
1052
- if (url === "/" || url === "") return handleRoot(req, res, htmlPath);
1053
- if (url === "/events") return handleEventsSse(req, res, eventsDir);
1054
- if (url === "/metrics") return handleMetrics(req, res, projDir);
1055
- if (url === "/ping") return handlePing(req, res, port);
1056
- if (url === "/stop") return handleStop(req, res, server);
1057
- if (url === "/transcripts") return handleTranscriptsList(req, res, projDir, tHtmlPath);
1058
- // M47 D2 — most-recent in-session NDJSON for the viewer top-pane default load
1059
- if (url === "/api/main-session") return handleMainSession(req, res, projDir);
1060
- // M44 D8 — spawn plans: GET list + SSE change stream
1061
- if (url === "/api/spawn-plans") return handleSpawnPlans(req, res, projDir);
1062
- if (url === "/api/spawn-plans/stream") return handleSpawnPlanUpdatesSse(req, res, projDir);
1063
- // M44 D9 — parallelism observability (additive, read-only)
1064
- if (url === "/api/parallelism") return handleParallelism(req, res, projDir);
1065
- if (url === "/api/parallelism/report") return handleParallelismReport(req, res, projDir);
1066
- // M44 D9 — stop-supervisor proxy (POST only; reuses existing sentinel flow)
1067
- if (url === "/api/unattended-stop") return handleUnattendedStop(req, res, projDir);
1068
- // M54 D1 T3 — live-activity endpoints (additive, read-only)
1069
- if (url === "/api/live-activity") return handleLiveActivity(req, res, projDir);
1070
- const laTailMatch = url.match(/^\/api\/live-activity\/([^/]+)\/tail$/);
1071
- if (laTailMatch) return handleLiveActivityTail(req, res, projDir, decodeURIComponent(laTailMatch[1]));
1072
- const laStreamMatch = url.match(/^\/api\/live-activity\/([^/]+)\/stream$/);
1073
- if (laStreamMatch) return handleLiveActivityStream(req, res, projDir, decodeURIComponent(laStreamMatch[1]));
1074
- // POST /transcript/:spawnId/kill — SIGTERM the recorded workerPid
1075
- const killMatch = url.match(/^\/transcript\/([^/]+)\/kill$/);
1076
- if (killMatch && req.method === "POST") return handleTranscriptKill(req, res, decodeURIComponent(killMatch[1]), projDir);
1077
- // M43 D6 — /transcript/:spawnId/tool-cost — D2 attribution proxy
1078
- const toolCostMatch = url.match(/^\/transcript\/([^/]+)\/tool-cost$/);
1079
- if (toolCostMatch) return handleTranscriptToolCost(req, res, decodeURIComponent(toolCostMatch[1]), projDir);
1080
- // M43 D6 — /transcript/:spawnId/usage — per-turn rows for this spawn
1081
- const usageMatch = url.match(/^\/transcript\/([^/]+)\/usage$/);
1082
- if (usageMatch) return handleTranscriptUsage(req, res, decodeURIComponent(usageMatch[1]), projDir);
1083
- // /transcript/:spawnId/stream — SSE tail of per-spawn ndjson
1084
- const streamMatch = url.match(/^\/transcript\/([^/]+)\/stream$/);
1085
- if (streamMatch) return handleTranscriptStreamSse(req, res, decodeURIComponent(streamMatch[1]), projDir);
1086
- // /transcript/:spawnId — HTML viewer page
1087
- const pageMatch = url.match(/^\/transcript\/([^/]+)$/);
1088
- if (pageMatch) return handleTranscriptPage(req, res, decodeURIComponent(pageMatch[1]), tHtmlPath, projDir);
1089
- res.writeHead(404); res.end("Not found");
1090
- });
1091
- server.listen(port);
1092
-
1093
- // M49 — install idle-TTL self-shutdown timer. Skipped only when caller
1094
- // explicitly passes `idleTtlMs: 0` (used by tests that don't want the
1095
- // server to self-exit mid-test).
1096
- let idleTimer = null;
1097
- if (ttlMs > 0) {
1098
- idleTimer = _startIdleTtlTimer({
1099
- ttlMs,
1100
- intervalMs,
1101
- projectDir: projDir,
1102
- server,
1103
- tracker,
1104
- onShutdown: opts && opts.onShutdown,
1105
- });
1106
- }
1107
-
1108
- return { server, url: `http://localhost:${port}`, tracker, idleTimer };
1109
- }
1110
-
1111
- module.exports = {
1112
- startServer,
1113
- tailEventsFile,
1114
- readExistingEvents,
1115
- parseEventLine,
1116
- findEventsDir,
1117
- readMetricsData,
1118
- readTranscriptsIndex,
1119
- writeTranscriptsIndex,
1120
- readIndexEntry,
1121
- isValidSpawnId,
1122
- listInSessionTranscripts,
1123
- handleMainSession,
1124
- handleTranscriptsList,
1125
- handleTranscriptStream,
1126
- handleTranscriptPage,
1127
- handleTranscriptKill,
1128
- handleTranscriptToolCost,
1129
- handleTranscriptUsage,
1130
- readSpawnUsageRows,
1131
- transcriptsDir,
1132
- DEFAULT_PORT,
1133
- projectScopedDefaultPort,
1134
- resolvePort,
1135
- // M44 D8 — spawn-plan visibility
1136
- listAllSpawnPlans,
1137
- listActiveSpawnPlans,
1138
- handleSpawnPlans,
1139
- handleSpawnPlanUpdates,
1140
- readSpawnPlanFile,
1141
- spawnsDir,
1142
- // M44 D9 — parallelism observability
1143
- handleParallelism,
1144
- handleParallelismReport,
1145
- handleUnattendedStop,
1146
- // M54 D1 T3 — live-activity observability
1147
- handleLiveActivity,
1148
- handleLiveActivityTail,
1149
- handleLiveActivityStream,
1150
- isValidActivityId,
1151
- _liveActivityCache,
1152
- // M49 — idle-TTL exports for tests
1153
- _activityTracker,
1154
- _wrapSseHandler,
1155
- _startIdleTtlTimer,
1156
- DEFAULT_IDLE_TTL_MS,
1157
- };
1158
-
1159
- if (require.main === module) {
1160
- const argv = process.argv.slice(2);
1161
- const getArg = (flag) => { const i = argv.indexOf(flag); return i >= 0 ? argv[i + 1] : null; };
1162
- const hasFlag = (f) => argv.includes(f);
1163
- const projectDir = process.env.GSD_T_PROJECT_DIR || process.cwd();
1164
- const port = resolvePort({ argPort: getArg("--port"), projectDir });
1165
- const eventsDir = getArg("--events") || findEventsDir(projectDir);
1166
- const pidFile = path.join(projectDir, ".gsd-t", "dashboard.pid");
1167
- const htmlPath = path.join(__dirname, "gsd-t-dashboard.html");
1168
- const transcriptHtmlPath = path.join(__dirname, "gsd-t-transcript.html");
1169
-
1170
- if (hasFlag("--stop")) {
1171
- try { const pid = parseInt(fs.readFileSync(pidFile, "utf8").trim(), 10); process.kill(pid); fs.unlinkSync(pidFile); }
1172
- catch (e) { process.stderr.write("No running server: " + e.message + "\n"); }
1173
- process.exit(0);
1174
- }
1175
- if (hasFlag("--detach")) {
1176
- const child = spawn(process.execPath, [__filename, ...argv.filter((a) => a !== "--detach")], { detached: true, stdio: "ignore" });
1177
- child.unref();
1178
- try { fs.mkdirSync(path.dirname(pidFile), { recursive: true }); } catch { /* exists */ }
1179
- fs.writeFileSync(pidFile, String(child.pid));
1180
- process.exit(0);
1181
- }
1182
- // M49 — idle-TTL flag/env override. Falls through to startServer's default
1183
- // (env var GSD_T_DASHBOARD_IDLE_TTL_MS or 4h).
1184
- const ttlArg = getArg("--idle-ttl-ms");
1185
- const startOpts = {};
1186
- if (ttlArg != null && ttlArg !== "") {
1187
- const n = Number.parseInt(ttlArg, 10);
1188
- if (Number.isFinite(n)) startOpts.idleTtlMs = n;
1189
- }
1190
-
1191
- const { server, url } = startServer(port, eventsDir, htmlPath, projectDir, transcriptHtmlPath, startOpts);
1192
- process.stdout.write("GSD-T Dashboard: " + url + "\n");
1193
- function cleanup() {
1194
- try { fs.unlinkSync(pidFile); } catch { /* ok */ }
1195
- // M49 — also remove the lazy-probe pidfile so headless-auto-spawn sees clean state.
1196
- try {
1197
- fs.unlinkSync(path.join(projectDir, ".gsd-t", ".dashboard.pid"));
1198
- } catch { /* ok */ }
1199
- server.close(() => process.exit(0));
1200
- }
1201
- process.on("SIGTERM", cleanup);
1202
- process.on("SIGINT", cleanup);
1203
- }