@tekyzinc/gsd-t 3.10.16 → 3.11.10

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.
@@ -0,0 +1,424 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * GSD-T Agent Topology Dashboard Server
4
+ * Zero-dep SSE server that tails events, heartbeat, context-meter, and
5
+ * supervisor state files, then streams structured named events to browser clients.
6
+ *
7
+ * Port: 7434 (separate from the existing metrics dashboard on 7433)
8
+ */
9
+ const http = require("http");
10
+ const fs = require("fs");
11
+ const path = require("path");
12
+
13
+ const DEFAULT_PORT = 7434;
14
+ const KEEPALIVE_MS = 15000;
15
+ const SSE_HEADERS = {
16
+ "Content-Type": "text/event-stream",
17
+ "Cache-Control": "no-cache",
18
+ "Connection": "keep-alive",
19
+ "Access-Control-Allow-Origin": "*",
20
+ };
21
+
22
+ // ── State Model ────────────────────────────────────────────────────────────
23
+
24
+ const state = {
25
+ agents: new Map(),
26
+ sessions: new Map(),
27
+ context: { pct: 0, threshold: "normal", inputTokens: 0, windowSize: 200000, ts: null },
28
+ supervisor: null,
29
+ recentEvents: [],
30
+ contextHistory: [],
31
+ modelCounts: { opus: 0, sonnet: 0, haiku: 0 },
32
+ toolCount: 0,
33
+ };
34
+
35
+ const MAX_EVENTS = 200;
36
+ const MAX_HISTORY = 200;
37
+
38
+ // ── SSE Client Management ──────────────────────────────────────────────────
39
+
40
+ const clients = new Set();
41
+
42
+ function broadcast(eventName, data) {
43
+ const msg = `event: ${eventName}\ndata: ${JSON.stringify(data)}\n\n`;
44
+ for (const res of clients) {
45
+ try { res.write(msg); } catch { clients.delete(res); }
46
+ }
47
+ }
48
+
49
+ function sendSnapshot(res) {
50
+ const snapshot = {
51
+ agents: [...state.agents.values()],
52
+ sessions: [...state.sessions.values()].map(s => ({ ...s, childAgentIds: [...(s.childAgentIds || [])] })),
53
+ context: state.context,
54
+ supervisor: state.supervisor,
55
+ events: state.recentEvents.slice(-100),
56
+ contextHistory: state.contextHistory,
57
+ modelCounts: { ...state.modelCounts },
58
+ toolCount: state.toolCount,
59
+ };
60
+ res.write(`event: snapshot\ndata: ${JSON.stringify(snapshot)}\n\n`);
61
+ }
62
+
63
+ // ── File Tailing Helpers ───────────────────────────────────────────────────
64
+
65
+ function parseJsonLine(line) {
66
+ if (!line || !line.trim()) return null;
67
+ try { return JSON.parse(line.trim()); } catch { return null; }
68
+ }
69
+
70
+ function tailFile(filePath, callback, interval) {
71
+ let offset = 0;
72
+ try { offset = fs.statSync(filePath).size; } catch { /* new file */ }
73
+
74
+ function processNew() {
75
+ try { if (fs.lstatSync(filePath).isSymbolicLink()) return; } catch { return; }
76
+ let stat;
77
+ try { stat = fs.statSync(filePath); } catch { return; }
78
+ if (stat.size <= offset) return;
79
+ const fd = fs.openSync(filePath, "r");
80
+ try {
81
+ const buf = Buffer.alloc(stat.size - offset);
82
+ fs.readSync(fd, buf, 0, buf.length, offset);
83
+ offset = stat.size;
84
+ const chunk = buf.toString("utf8");
85
+ chunk.split("\n").forEach(line => {
86
+ const obj = parseJsonLine(line);
87
+ if (obj) callback(obj);
88
+ });
89
+ } finally { fs.closeSync(fd); }
90
+ }
91
+
92
+ fs.watchFile(filePath, { interval: interval || 500, persistent: true }, processNew);
93
+ return () => fs.unwatchFile(filePath, processNew);
94
+ }
95
+
96
+ function watchJson(filePath, callback, interval) {
97
+ let lastContent = "";
98
+ function check() {
99
+ try {
100
+ if (fs.lstatSync(filePath).isSymbolicLink()) return;
101
+ const content = fs.readFileSync(filePath, "utf8");
102
+ if (content === lastContent) return;
103
+ lastContent = content;
104
+ const obj = JSON.parse(content);
105
+ callback(obj);
106
+ } catch { /* file missing or malformed — normal */ }
107
+ }
108
+ fs.watchFile(filePath, { interval: interval || 2000, persistent: true }, check);
109
+ check();
110
+ return () => fs.unwatchFile(filePath, check);
111
+ }
112
+
113
+ // ── Event Processing ───────────────────────────────────────────────────────
114
+
115
+ function addRecentEvent(ev) {
116
+ state.recentEvents.push(ev);
117
+ if (state.recentEvents.length > MAX_EVENTS) state.recentEvents.shift();
118
+ }
119
+
120
+ function processEventLine(ev) {
121
+ const type = ev.event_type || ev.evt;
122
+
123
+ if (type === "session_start") {
124
+ const s = {
125
+ id: ev.agent_id || ev.sid || "",
126
+ model: (ev.data && ev.data.model) || ev.model || "",
127
+ source: (ev.data && ev.data.source) || "",
128
+ isActive: true,
129
+ startTs: ev.ts,
130
+ childAgentIds: new Set(),
131
+ };
132
+ state.sessions.set(s.id, s);
133
+ broadcast("session:start", { id: s.id, model: s.model, source: s.source, ts: ev.ts });
134
+ addRecentEvent({ type: "spawn", text: "session started", detail: s.id.slice(0, 12), ts: ev.ts });
135
+ }
136
+
137
+ else if (type === "session_end" || type === "session_stop") {
138
+ const sid = ev.agent_id || ev.sid || "";
139
+ const s = state.sessions.get(sid);
140
+ if (s) { s.isActive = false; s.endTs = ev.ts; }
141
+ broadcast("session:end", { id: sid, ts: ev.ts });
142
+ }
143
+
144
+ else if (type === "subagent_spawn" || type === "agent_spawn") {
145
+ const id = (ev.data && ev.data.agent_id) || ev.agent_id || ev.id || "";
146
+ const parentId = (ev.data && ev.data.parent_id) || ev.parent_agent_id || "";
147
+ const agentType = ev.reasoning || (ev.data && ev.data.agent_type) || "agent";
148
+ const model = (ev.data && ev.data.model) || "";
149
+ const a = {
150
+ id, parentId, type: agentType, model,
151
+ status: "running", spawnTs: ev.ts, completeTs: null,
152
+ duration: null, toolCount: 0, lastTool: null, lastToolTs: null,
153
+ sessionId: ev.sid || parentId,
154
+ };
155
+ state.agents.set(id, a);
156
+ if (model && state.modelCounts[model] !== undefined) state.modelCounts[model]++;
157
+
158
+ const session = state.sessions.get(parentId);
159
+ if (session && session.childAgentIds) session.childAgentIds.add(id);
160
+
161
+ broadcast("agent:spawn", { id, parentId, type: agentType, model, spawnTs: ev.ts, sessionId: a.sessionId });
162
+ addRecentEvent({ type: "spawn", text: `${agentType} spawned`, detail: id.slice(0, 12), ts: ev.ts });
163
+ }
164
+
165
+ else if (type === "subagent_complete" || type === "agent_stop") {
166
+ const id = (ev.data && ev.data.agent_id) || ev.agent_id || ev.id || "";
167
+ const a = state.agents.get(id);
168
+ const status = ev.outcome === "failure" ? "failed" : "done";
169
+ let duration = null;
170
+ if (a) {
171
+ a.status = status;
172
+ a.completeTs = ev.ts;
173
+ if (a.spawnTs) duration = (new Date(ev.ts) - new Date(a.spawnTs)) / 1000;
174
+ a.duration = duration;
175
+ }
176
+ broadcast("agent:complete", { id, status, completeTs: ev.ts, duration });
177
+ addRecentEvent({ type: status === "failed" ? "fail" : "complete",
178
+ text: `agent ${status}`, detail: `${id.slice(0, 12)}${duration ? " (" + fmtDur(duration) + ")" : ""}`, ts: ev.ts });
179
+ }
180
+
181
+ else if (type === "tool_call" || type === "tool") {
182
+ state.toolCount++;
183
+ const agentId = ev.agent_id || "";
184
+ const tool = ev.tool || ev.reasoning || "";
185
+ const file = ev.data && (ev.data.file || ev.data.pattern || ev.data.cmd || "");
186
+ const a = state.agents.get(agentId);
187
+ if (a) {
188
+ a.toolCount = (a.toolCount || 0) + 1;
189
+ a.lastTool = tool;
190
+ a.lastToolTs = ev.ts;
191
+ if (!a.toolCounts) a.toolCounts = {};
192
+ a.toolCounts[tool] = (a.toolCounts[tool] || 0) + 1;
193
+ }
194
+ broadcast("agent:tool", { agentId, tool, file, ts: ev.ts });
195
+ }
196
+
197
+ else if (type === "phase_transition") {
198
+ const phase = ev.phase || ev.reasoning || "";
199
+ const command = ev.command || "";
200
+ broadcast("phase:transition", { phase, command, outcome: ev.outcome, ts: ev.ts });
201
+ addRecentEvent({ type: "phase", text: `Phase: ${phase}`, detail: command, ts: ev.ts });
202
+ }
203
+
204
+ else if (type === "command_invoked") {
205
+ addRecentEvent({ type: "phase", text: ev.command || "command", detail: ev.reasoning || "", ts: ev.ts });
206
+ }
207
+ }
208
+
209
+ function processContextMeter(obj) {
210
+ state.context = {
211
+ pct: obj.pct || 0,
212
+ threshold: obj.threshold || "normal",
213
+ inputTokens: obj.inputTokens || 0,
214
+ windowSize: obj.modelWindowSize || 200000,
215
+ ts: obj.timestamp || new Date().toISOString(),
216
+ };
217
+ state.contextHistory.push({ ts: state.context.ts, pct: state.context.pct });
218
+ if (state.contextHistory.length > MAX_HISTORY) state.contextHistory.shift();
219
+ broadcast("context:update", state.context);
220
+ }
221
+
222
+ function processSupervisorState(obj) {
223
+ state.supervisor = {
224
+ status: obj.status || "unknown",
225
+ iter: obj.iter || 0,
226
+ milestone: obj.milestone || "",
227
+ workerPid: obj.workerPid || null,
228
+ elapsed: obj.wallClockElapsedMs || 0,
229
+ };
230
+ broadcast("supervisor:update", state.supervisor);
231
+ }
232
+
233
+ function fmtDur(s) {
234
+ s = Math.round(s);
235
+ if (s < 60) return s + "s";
236
+ if (s < 3600) return Math.floor(s / 60) + "m " + (s % 60) + "s";
237
+ return Math.floor(s / 3600) + "h " + Math.floor((s % 3600) / 60) + "m";
238
+ }
239
+
240
+ // ── Bootstrap: Read Existing Data ──────────────────────────────────────────
241
+
242
+ function bootstrap(projectDir) {
243
+ const gsdtDir = path.join(projectDir, ".gsd-t");
244
+
245
+ // Read existing events
246
+ const eventsDir = path.join(gsdtDir, "events");
247
+ try {
248
+ const files = fs.readdirSync(eventsDir).filter(f => f.endsWith(".jsonl")).sort();
249
+ for (const f of files) {
250
+ const lines = fs.readFileSync(path.join(eventsDir, f), "utf8").split("\n");
251
+ lines.forEach(line => { const obj = parseJsonLine(line); if (obj) processEventLine(obj); });
252
+ }
253
+ } catch { /* no events dir */ }
254
+
255
+ // Read recent heartbeat files (modified within 2 hours)
256
+ const cutoff = Date.now() - 2 * 3600 * 1000;
257
+ try {
258
+ const hbFiles = fs.readdirSync(gsdtDir).filter(f => f.startsWith("heartbeat-") && f.endsWith(".jsonl"));
259
+ for (const f of hbFiles) {
260
+ const fp = path.join(gsdtDir, f);
261
+ try {
262
+ const stat = fs.statSync(fp);
263
+ if (stat.mtimeMs < cutoff) continue;
264
+ const lines = fs.readFileSync(fp, "utf8").split("\n");
265
+ lines.forEach(line => { const obj = parseJsonLine(line); if (obj) processEventLine(obj); });
266
+ } catch { /* skip */ }
267
+ }
268
+ } catch { /* no heartbeat files */ }
269
+
270
+ // Read context meter
271
+ const cmPath = path.join(gsdtDir, ".context-meter-state.json");
272
+ try {
273
+ const obj = JSON.parse(fs.readFileSync(cmPath, "utf8"));
274
+ processContextMeter(obj);
275
+ } catch { /* not present */ }
276
+
277
+ // Read supervisor state
278
+ const supPath = path.join(gsdtDir, ".unattended", "state.json");
279
+ try {
280
+ const obj = JSON.parse(fs.readFileSync(supPath, "utf8"));
281
+ processSupervisorState(obj);
282
+ } catch { /* not running */ }
283
+ }
284
+
285
+ // ── Start Tailing ──────────────────────────────────────────────────────────
286
+
287
+ const unwatchers = [];
288
+
289
+ function startTailing(projectDir) {
290
+ const gsdtDir = path.join(projectDir, ".gsd-t");
291
+ const eventsDir = path.join(gsdtDir, "events");
292
+
293
+ // Tail today's event file
294
+ const today = new Date().toISOString().slice(0, 10);
295
+ const evFile = path.join(eventsDir, today + ".jsonl");
296
+ try { fs.accessSync(eventsDir); } catch { try { fs.mkdirSync(eventsDir, { recursive: true }); } catch {} }
297
+ unwatchers.push(tailFile(evFile, processEventLine));
298
+
299
+ // Watch for new event files (date rollover)
300
+ try {
301
+ const watcher = fs.watch(eventsDir, (eventType, filename) => {
302
+ if (filename && filename.endsWith(".jsonl") && filename > today + ".jsonl") {
303
+ unwatchers.push(tailFile(path.join(eventsDir, filename), processEventLine));
304
+ }
305
+ });
306
+ unwatchers.push(() => watcher.close());
307
+ } catch { /* events dir may not exist */ }
308
+
309
+ // Tail active heartbeat files
310
+ try {
311
+ const cutoff = Date.now() - 2 * 3600 * 1000;
312
+ const hbFiles = fs.readdirSync(gsdtDir).filter(f => f.startsWith("heartbeat-") && f.endsWith(".jsonl"));
313
+ for (const f of hbFiles) {
314
+ const fp = path.join(gsdtDir, f);
315
+ try { if (fs.statSync(fp).mtimeMs >= cutoff) unwatchers.push(tailFile(fp, processEventLine, 500)); } catch {}
316
+ }
317
+ } catch {}
318
+
319
+ // Watch for new heartbeat files
320
+ try {
321
+ const watcher = fs.watch(gsdtDir, (eventType, filename) => {
322
+ if (filename && filename.startsWith("heartbeat-") && filename.endsWith(".jsonl")) {
323
+ unwatchers.push(tailFile(path.join(gsdtDir, filename), processEventLine, 500));
324
+ }
325
+ });
326
+ unwatchers.push(() => watcher.close());
327
+ } catch {}
328
+
329
+ // Watch context meter
330
+ unwatchers.push(watchJson(path.join(gsdtDir, ".context-meter-state.json"), processContextMeter, 2000));
331
+
332
+ // Watch supervisor state
333
+ unwatchers.push(watchJson(path.join(gsdtDir, ".unattended", "state.json"), processSupervisorState, 5000));
334
+ }
335
+
336
+ // ── HTTP Server ────────────────────────────────────────────────────────────
337
+
338
+ function createServer(projectDir, port) {
339
+ const htmlPath = path.join(__dirname, "gsd-t-agent-dashboard.html");
340
+
341
+ const server = http.createServer((req, res) => {
342
+ const url = req.url.split("?")[0];
343
+
344
+ if (url === "/" || url === "/index.html") {
345
+ fs.readFile(htmlPath, (err, data) => {
346
+ if (err) { res.writeHead(404); res.end("Not found"); return; }
347
+ res.writeHead(200, { "Content-Type": "text/html" });
348
+ res.end(data);
349
+ });
350
+ }
351
+
352
+ else if (url === "/stream") {
353
+ res.writeHead(200, SSE_HEADERS);
354
+ clients.add(res);
355
+ sendSnapshot(res);
356
+
357
+ const keepalive = setInterval(() => {
358
+ try { res.write(": keepalive\n\n"); } catch { clearInterval(keepalive); clients.delete(res); }
359
+ }, KEEPALIVE_MS);
360
+
361
+ req.on("close", () => { clearInterval(keepalive); clients.delete(res); });
362
+ }
363
+
364
+ else if (url === "/snapshot") {
365
+ const snapshot = {
366
+ agents: [...state.agents.values()],
367
+ sessions: [...state.sessions.values()].map(s => ({ ...s, childAgentIds: [...(s.childAgentIds || [])] })),
368
+ context: state.context,
369
+ supervisor: state.supervisor,
370
+ events: state.recentEvents.slice(-100),
371
+ contextHistory: state.contextHistory,
372
+ modelCounts: { ...state.modelCounts },
373
+ toolCount: state.toolCount,
374
+ };
375
+ res.writeHead(200, { "Content-Type": "application/json", "Access-Control-Allow-Origin": "*" });
376
+ res.end(JSON.stringify(snapshot));
377
+ }
378
+
379
+ else if (url === "/ping") {
380
+ res.writeHead(200, { "Content-Type": "application/json" });
381
+ res.end(JSON.stringify({ status: "ok", port, agents: state.agents.size, sessions: state.sessions.size }));
382
+ }
383
+
384
+ else if (url === "/stop") {
385
+ res.writeHead(200, { "Content-Type": "application/json" });
386
+ res.end(JSON.stringify({ status: "stopping" }));
387
+ cleanup();
388
+ server.close();
389
+ process.exit(0);
390
+ }
391
+
392
+ else {
393
+ res.writeHead(404);
394
+ res.end("Not found");
395
+ }
396
+ });
397
+
398
+ server.listen(port, () => {
399
+ console.log(`[agent-dashboard] listening on http://localhost:${port}`);
400
+ console.log(`[agent-dashboard] project: ${projectDir}`);
401
+ console.log(`[agent-dashboard] agents: ${state.agents.size}, sessions: ${state.sessions.size}`);
402
+ });
403
+
404
+ return server;
405
+ }
406
+
407
+ function cleanup() {
408
+ for (const fn of unwatchers) {
409
+ try { if (typeof fn === "function") fn(); } catch {}
410
+ }
411
+ unwatchers.length = 0;
412
+ }
413
+
414
+ // ── Main ───────────────────────────────────────────────────────────────────
415
+
416
+ const projectDir = process.argv[2] || process.cwd();
417
+ const port = parseInt(process.argv[3] || process.env.GSD_T_AGENT_DASHBOARD_PORT || DEFAULT_PORT, 10);
418
+
419
+ bootstrap(projectDir);
420
+ startTailing(projectDir);
421
+ createServer(projectDir, port);
422
+
423
+ process.on("SIGINT", () => { cleanup(); process.exit(0); });
424
+ process.on("SIGTERM", () => { cleanup(); process.exit(0); });