@tekyzinc/gsd-t 3.15.10 → 3.16.11

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.
@@ -8,8 +8,38 @@ const fs = require("fs");
8
8
  const path = require("path");
9
9
  const { spawn } = require("child_process");
10
10
 
11
+ // Base port — the effective default is project-hashed (see
12
+ // projectScopedDefaultPort). Explicit --port N always overrides.
11
13
  const DEFAULT_PORT = 7433;
12
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
+ }
13
43
  const KEEPALIVE_MS = 15000;
14
44
  const SSE_HEADERS = { "Content-Type": "text/event-stream", "Cache-Control": "no-cache", "Connection": "keep-alive", "Access-Control-Allow-Origin": "*" };
15
45
 
@@ -124,8 +154,235 @@ function handleMetrics(req, res, projectDir) {
124
154
  res.end(JSON.stringify(data));
125
155
  }
126
156
 
127
- function startServer(port, eventsDir, htmlPath, projectDir) {
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
+ function handleTranscriptsList(req, res, projectDir) {
180
+ const idx = readTranscriptsIndex(projectDir);
181
+ const sorted = idx.spawns
182
+ .slice()
183
+ .sort((a, b) => (Date.parse(b.startedAt) || 0) - (Date.parse(a.startedAt) || 0));
184
+ res.writeHead(200, { "Content-Type": "application/json" });
185
+ res.end(JSON.stringify({ spawns: sorted }));
186
+ }
187
+
188
+ function handleTranscriptPage(req, res, spawnId, transcriptHtmlPath) {
189
+ if (!isValidSpawnId(spawnId)) { res.writeHead(400); res.end("Invalid spawn id"); return; }
190
+ fs.readFile(transcriptHtmlPath, (err, data) => {
191
+ if (err) { res.writeHead(404); res.end("Transcript UI not found"); return; }
192
+ // Inject the spawn-id as a data attribute on <body> by string replacement;
193
+ // the HTML ships with a placeholder `data-spawn-id="__SPAWN_ID__"`.
194
+ const html = data.toString("utf8").replace(/__SPAWN_ID__/g, spawnId);
195
+ res.writeHead(200, { "Content-Type": "text/html" });
196
+ res.end(html);
197
+ });
198
+ }
199
+
200
+ function tailTranscriptFile(filePath, callback) {
201
+ let offset = 0;
202
+ let buf = "";
203
+ function processNewData() {
204
+ let stat;
205
+ try { stat = fs.statSync(filePath); } catch { return; }
206
+ if (stat.size <= offset) return;
207
+ const fd = fs.openSync(filePath, "r");
208
+ try {
209
+ const b = Buffer.alloc(stat.size - offset);
210
+ fs.readSync(fd, b, 0, b.length, offset);
211
+ buf += b.toString("utf8");
212
+ offset = stat.size;
213
+ } finally { fs.closeSync(fd); }
214
+ let nl;
215
+ while ((nl = buf.indexOf("\n")) >= 0) {
216
+ const line = buf.slice(0, nl);
217
+ buf = buf.slice(nl + 1);
218
+ if (line.length > 0) callback(line);
219
+ }
220
+ }
221
+ fs.watchFile(filePath, { interval: 500, persistent: true }, processNewData);
222
+ return () => fs.unwatchFile(filePath, processNewData);
223
+ }
224
+
225
+ // ── M42 D3 — kill per-spawn ────────────────────────────────────────────────
226
+
227
+ function writeTranscriptsIndex(projectDir, idx) {
228
+ const p = path.join(transcriptsDir(projectDir), ".index.json");
229
+ const tmp = p + ".tmp";
230
+ try { fs.mkdirSync(path.dirname(p), { recursive: true }); } catch { /* exists */ }
231
+ fs.writeFileSync(tmp, JSON.stringify(idx, null, 2));
232
+ fs.renameSync(tmp, p);
233
+ }
234
+
235
+ function handleTranscriptKill(req, res, spawnId, projectDir) {
236
+ if (!isValidSpawnId(spawnId)) { res.writeHead(400); res.end("Invalid spawn id"); return; }
237
+ const idx = readTranscriptsIndex(projectDir);
238
+ const i = idx.spawns.findIndex((s) => s.spawnId === spawnId);
239
+ if (i < 0) { res.writeHead(404, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "unknown_spawn" })); return; }
240
+ const entry = idx.spawns[i];
241
+ const pid = entry.workerPid;
242
+ if (!pid || typeof pid !== "number") {
243
+ res.writeHead(409, { "Content-Type": "application/json" });
244
+ res.end(JSON.stringify({ error: "no_pid_recorded" }));
245
+ return;
246
+ }
247
+ try {
248
+ process.kill(pid, "SIGTERM");
249
+ idx.spawns[i].status = "stopped";
250
+ idx.spawns[i].endedAt = idx.spawns[i].endedAt || new Date().toISOString();
251
+ writeTranscriptsIndex(projectDir, idx);
252
+ res.writeHead(200, { "Content-Type": "application/json" });
253
+ res.end(JSON.stringify({ status: "stopped", pid }));
254
+ } catch (err) {
255
+ if (err.code === "ESRCH") {
256
+ // Process already gone — treat as success and mark ended
257
+ idx.spawns[i].status = idx.spawns[i].status === "running" ? "stopped" : idx.spawns[i].status;
258
+ idx.spawns[i].endedAt = idx.spawns[i].endedAt || new Date().toISOString();
259
+ writeTranscriptsIndex(projectDir, idx);
260
+ res.writeHead(200, { "Content-Type": "application/json" });
261
+ res.end(JSON.stringify({ status: "already_stopped", pid }));
262
+ return;
263
+ }
264
+ if (err.code === "EPERM") {
265
+ res.writeHead(403, { "Content-Type": "application/json" });
266
+ res.end(JSON.stringify({ error: "permission_denied", pid }));
267
+ return;
268
+ }
269
+ res.writeHead(500, { "Content-Type": "application/json" });
270
+ res.end(JSON.stringify({ error: String(err.message || err) }));
271
+ }
272
+ }
273
+
274
+ /**
275
+ * Find the transcripts-index entry for a spawn-id, or null when the index
276
+ * is missing / the id isn't recorded. Used by handleTranscriptStream to
277
+ * detect already-finished spawns so the SSE stream can emit `event: end`
278
+ * and close instead of tailing indefinitely.
279
+ */
280
+ function readIndexEntry(projectDir, spawnId) {
281
+ const idx = readTranscriptsIndex(projectDir);
282
+ if (!idx || !Array.isArray(idx.spawns)) return null;
283
+ return idx.spawns.find((s) => s && s.spawnId === spawnId) || null;
284
+ }
285
+
286
+ function handleTranscriptStream(req, res, spawnId, projectDir) {
287
+ if (!isValidSpawnId(spawnId)) { res.writeHead(400); res.end("Invalid spawn id"); return; }
288
+ const filePath = path.join(transcriptsDir(projectDir), `${spawnId}.ndjson`);
289
+ let exists = true;
290
+ let fileSize = 0;
291
+ try { fileSize = fs.statSync(filePath).size; } catch { exists = false; }
292
+
293
+ res.writeHead(200, SSE_HEADERS);
294
+
295
+ // Consult the transcripts index — if the spawn is already finished we
296
+ // replay and close instead of attaching a live tail.
297
+ const entry = readIndexEntry(projectDir, spawnId);
298
+ const finishedStatuses = ["done", "failed", "stopped"];
299
+ const isFinished = !!(entry && entry.status && finishedStatuses.includes(entry.status));
300
+
301
+ // Replay: read the full file from byte 0 and send each line.
302
+ let replayedBytes = 0;
303
+ if (exists) {
304
+ try {
305
+ const full = fs.readFileSync(filePath, "utf8");
306
+ replayedBytes = Buffer.byteLength(full, "utf8");
307
+ full.split("\n").forEach((line) => {
308
+ if (line.length > 0) {
309
+ try { res.write("data: " + line + "\n\n"); } catch { /* gone */ }
310
+ }
311
+ });
312
+ } catch { /* empty transcript */ }
313
+ }
314
+
315
+ // Finished spawn → emit end event and close the stream. No tail, no keepalive.
316
+ if (isFinished) {
317
+ const endPayload = JSON.stringify({
318
+ status: entry.status,
319
+ endedAt: entry.endedAt || null,
320
+ });
321
+ try { res.write("event: end\ndata: " + endPayload + "\n\n"); } catch { /* gone */ }
322
+ try { res.end(); } catch { /* gone */ }
323
+ return;
324
+ }
325
+
326
+ // Missing file — tell the viewer the stream is live but empty. fs.watchFile
327
+ // needs the file to exist, so we skip the tail here. A running producer
328
+ // will have created the file already (the finished-spawn branch above
329
+ // catches the post-hoc teed case via index status).
330
+ if (!exists) {
331
+ const waitingPayload = JSON.stringify({
332
+ status: "waiting",
333
+ reason: "no transcript file yet",
334
+ });
335
+ try { res.write("event: status\ndata: " + waitingPayload + "\n\n"); } catch { /* gone */ }
336
+ const timer = setInterval(() => { try { res.write(": keepalive\n\n"); } catch { clearInterval(timer); } }, KEEPALIVE_MS);
337
+ req.on("close", () => { clearInterval(timer); });
338
+ return;
339
+ }
340
+
341
+ // Empty file — producer hasn't written anything yet. Emit a status frame
342
+ // so the viewer knows the stream is live but the file is 0 bytes, then
343
+ // attach the live tail so we pick up writes as they arrive.
344
+ if (fileSize === 0) {
345
+ const emptyPayload = JSON.stringify({
346
+ status: "empty",
347
+ reason: "transcript file exists but is empty",
348
+ });
349
+ try { res.write("event: status\ndata: " + emptyPayload + "\n\n"); } catch { /* gone */ }
350
+ }
351
+
352
+ // Tail: from replayedBytes onward. We reuse the tailer, but seed offset
353
+ // with what we already replayed so we don't double-send.
354
+ let offset = replayedBytes;
355
+ let buf = "";
356
+ const processNewData = () => {
357
+ let stat;
358
+ try { stat = fs.statSync(filePath); } catch { return; }
359
+ if (stat.size <= offset) return;
360
+ const fd = fs.openSync(filePath, "r");
361
+ try {
362
+ const b = Buffer.alloc(stat.size - offset);
363
+ fs.readSync(fd, b, 0, b.length, offset);
364
+ buf += b.toString("utf8");
365
+ offset = stat.size;
366
+ } finally { fs.closeSync(fd); }
367
+ let nl;
368
+ while ((nl = buf.indexOf("\n")) >= 0) {
369
+ const line = buf.slice(0, nl);
370
+ buf = buf.slice(nl + 1);
371
+ if (line.length > 0) {
372
+ try { res.write("data: " + line + "\n\n"); } catch { /* gone */ }
373
+ }
374
+ }
375
+ };
376
+ fs.watchFile(filePath, { interval: 500, persistent: true }, processNewData);
377
+ const unwatchFile = () => fs.unwatchFile(filePath, processNewData);
378
+
379
+ const timer = setInterval(() => { try { res.write(": keepalive\n\n"); } catch { clearInterval(timer); } }, KEEPALIVE_MS);
380
+ req.on("close", () => { clearInterval(timer); if (unwatchFile) unwatchFile(); });
381
+ }
382
+
383
+ function startServer(port, eventsDir, htmlPath, projectDir, transcriptHtmlPath) {
128
384
  const projDir = projectDir || path.resolve(eventsDir, "..", "..");
385
+ const tHtmlPath = transcriptHtmlPath || path.join(path.dirname(htmlPath), "gsd-t-transcript.html");
129
386
  const server = http.createServer((req, res) => {
130
387
  const url = req.url.split("?")[0];
131
388
  if (url === "/" || url === "") return handleRoot(req, res, htmlPath);
@@ -133,23 +390,53 @@ function startServer(port, eventsDir, htmlPath, projectDir) {
133
390
  if (url === "/metrics") return handleMetrics(req, res, projDir);
134
391
  if (url === "/ping") return handlePing(req, res, port);
135
392
  if (url === "/stop") return handleStop(req, res, server);
393
+ if (url === "/transcripts") return handleTranscriptsList(req, res, projDir);
394
+ // POST /transcript/:spawnId/kill — SIGTERM the recorded workerPid
395
+ const killMatch = url.match(/^\/transcript\/([^/]+)\/kill$/);
396
+ if (killMatch && req.method === "POST") return handleTranscriptKill(req, res, decodeURIComponent(killMatch[1]), projDir);
397
+ // /transcript/:spawnId/stream — SSE tail of per-spawn ndjson
398
+ const streamMatch = url.match(/^\/transcript\/([^/]+)\/stream$/);
399
+ if (streamMatch) return handleTranscriptStream(req, res, decodeURIComponent(streamMatch[1]), projDir);
400
+ // /transcript/:spawnId — HTML viewer page
401
+ const pageMatch = url.match(/^\/transcript\/([^/]+)$/);
402
+ if (pageMatch) return handleTranscriptPage(req, res, decodeURIComponent(pageMatch[1]), tHtmlPath);
136
403
  res.writeHead(404); res.end("Not found");
137
404
  });
138
405
  server.listen(port);
139
406
  return { server, url: `http://localhost:${port}` };
140
407
  }
141
408
 
142
- module.exports = { startServer, tailEventsFile, readExistingEvents, parseEventLine, findEventsDir, readMetricsData };
409
+ module.exports = {
410
+ startServer,
411
+ tailEventsFile,
412
+ readExistingEvents,
413
+ parseEventLine,
414
+ findEventsDir,
415
+ readMetricsData,
416
+ readTranscriptsIndex,
417
+ writeTranscriptsIndex,
418
+ readIndexEntry,
419
+ isValidSpawnId,
420
+ handleTranscriptsList,
421
+ handleTranscriptStream,
422
+ handleTranscriptPage,
423
+ handleTranscriptKill,
424
+ transcriptsDir,
425
+ DEFAULT_PORT,
426
+ projectScopedDefaultPort,
427
+ resolvePort,
428
+ };
143
429
 
144
430
  if (require.main === module) {
145
431
  const argv = process.argv.slice(2);
146
432
  const getArg = (flag) => { const i = argv.indexOf(flag); return i >= 0 ? argv[i + 1] : null; };
147
433
  const hasFlag = (f) => argv.includes(f);
148
- const port = parseInt(getArg("--port") || DEFAULT_PORT, 10);
149
434
  const projectDir = process.env.GSD_T_PROJECT_DIR || process.cwd();
435
+ const port = resolvePort({ argPort: getArg("--port"), projectDir });
150
436
  const eventsDir = getArg("--events") || findEventsDir(projectDir);
151
437
  const pidFile = path.join(projectDir, ".gsd-t", "dashboard.pid");
152
438
  const htmlPath = path.join(__dirname, "gsd-t-dashboard.html");
439
+ const transcriptHtmlPath = path.join(__dirname, "gsd-t-transcript.html");
153
440
 
154
441
  if (hasFlag("--stop")) {
155
442
  try { const pid = parseInt(fs.readFileSync(pidFile, "utf8").trim(), 10); process.kill(pid); fs.unlinkSync(pidFile); }
@@ -163,7 +450,7 @@ if (require.main === module) {
163
450
  fs.writeFileSync(pidFile, String(child.pid));
164
451
  process.exit(0);
165
452
  }
166
- const { server, url } = startServer(port, eventsDir, htmlPath);
453
+ const { server, url } = startServer(port, eventsDir, htmlPath, projectDir, transcriptHtmlPath);
167
454
  process.stdout.write("GSD-T Dashboard: " + url + "\n");
168
455
  function cleanup() { try { fs.unlinkSync(pidFile); } catch { /* ok */ } server.close(() => process.exit(0)); }
169
456
  process.on("SIGTERM", cleanup);
@@ -26,6 +26,11 @@ body{background:var(--bg);color:var(--text);font-family:var(--font);font-size:12
26
26
  .status.wait{background:var(--yellow-bg);border-color:var(--yellow);color:var(--yellow);}
27
27
  .dot{width:6px;height:6px;border-radius:50%;background:currentColor;animation:pdot 1.5s ease-in-out infinite;}
28
28
  @keyframes pdot{0%,100%{opacity:1}50%{opacity:.3}}
29
+ .livestream-btn{display:inline-flex;align-items:center;gap:5px;padding:3px 10px;border-radius:4px;
30
+ background:var(--blue-bg);border:1px solid var(--blue);color:var(--blue);text-decoration:none;
31
+ font-size:10px;font-weight:500;font-family:var(--font);cursor:pointer;transition:opacity .15s;}
32
+ .livestream-btn:hover{opacity:.85;}
33
+ .livestream-btn.disabled{background:transparent;border-color:var(--border);color:var(--muted);cursor:not-allowed;pointer-events:none;}
29
34
  .hright{margin-left:auto;color:var(--muted);font-size:11px;}.main{display:flex;flex:1;overflow:hidden;}
30
35
  .garea{flex:1;position:relative;background:var(--bg);}.rfwrap{width:100%;height:100%;}
31
36
  .react-flow__background{background:var(--bg);}.react-flow__node{font-family:var(--font);font-size:11px;}
@@ -66,6 +71,7 @@ body{background:var(--bg);color:var(--text);font-family:var(--font);font-size:12
66
71
  <div class="hdr">
67
72
  <span class="logo">GSD-T Agent Dashboard</span>
68
73
  <div id="status" class="status wait"><span class="dot"></span><span id="status-txt">Connecting...</span></div>
74
+ <a id="livestream-btn" class="livestream-btn disabled" href="#" title="Open the latest live spawn transcript">▶ Live Stream</a>
69
75
  <div class="hright"><span id="ev-count">0 events</span></div>
70
76
  </div>
71
77
  <div class="main">
@@ -91,7 +97,7 @@ const {useState,useEffect,useCallback,useRef}=React;
91
97
  const {ReactFlow,useNodesState,useEdgesState,Background,Controls,MarkerType}=window.ReactFlow||{};
92
98
  const dagre=window.dagre;
93
99
  const params=new URLSearchParams(location.search);
94
- const PORT=params.get('port')||'7433';
100
+ const PORT=params.get('port')||location.port||'7433';
95
101
  const MAX_EVENTS=200;
96
102
  const GRAPH_W=160,GRAPH_H=60;
97
103
 
@@ -257,6 +263,30 @@ ReactDOM.render(React.createElement(Dashboard),document.getElementById('rf-root'
257
263
  }
258
264
  fetchMetrics();setInterval(fetchMetrics,30000);
259
265
  })();
266
+ (function(){
267
+ const btn=document.getElementById('livestream-btn');
268
+ if(!btn)return;
269
+ function pickLatest(spawns){
270
+ if(!Array.isArray(spawns)||spawns.length===0)return null;
271
+ const live=spawns.filter(s=>s.status&&!['done','stopped','failed','crashed'].includes(s.status));
272
+ const pool=live.length?live:spawns;
273
+ return pool.slice().sort((a,b)=>(Date.parse(b.startedAt)||0)-(Date.parse(a.startedAt)||0))[0];
274
+ }
275
+ function refresh(){
276
+ fetch(`http://localhost:${PORT}/transcripts`,{cache:'no-store'}).then(r=>r.ok?r.json():null).then(d=>{
277
+ const latest=d&&pickLatest(d.spawns);
278
+ if(latest){
279
+ btn.href=`/transcript/${encodeURIComponent(latest.spawnId)}`;
280
+ btn.classList.remove('disabled');
281
+ const live=latest.status&&!['done','stopped','failed','crashed'].includes(latest.status);
282
+ btn.textContent=(live?'▶ Live Stream':'▶ Latest Transcript')+` · ${latest.spawnId.slice(0,10)}`;
283
+ }else{
284
+ btn.href='#';btn.classList.add('disabled');btn.textContent='▶ Live Stream (no spawns yet)';
285
+ }
286
+ }).catch(()=>{});
287
+ }
288
+ refresh();setInterval(refresh,10000);
289
+ })();
260
290
  </script>
261
291
  </body>
262
292
  </html>