agent-dag 1.15.0 → 1.15.4

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.
@@ -1,627 +1,643 @@
1
- // agent-dag server: HTTP ingest + SSE broadcast + static file serving.
2
- // Single-file pure Node HTTP server, zero deps.
3
- import { createServer } from "node:http";
4
- import { readFile, stat, mkdir, appendFile, open, truncate, readdir, unlink } from "node:fs/promises";
5
- import { createReadStream, existsSync } from "node:fs";
6
- import { homedir } from "node:os";
7
- import { extname, join, resolve, dirname as pdirname } from "node:path";
8
- import { fileURLToPath, pathToFileURL } from "node:url";
9
- import { dirname } from "node:path";
10
- import { createInterface } from "node:readline";
11
-
12
- const __dirname = dirname(fileURLToPath(import.meta.url));
13
- const PKG_ROOT = resolve(__dirname, "..", "..");
14
- const WEB_DIST = resolve(PKG_ROOT, "dist", "web");
15
-
16
- const MIME = {
17
- ".html": "text/html; charset=utf-8",
18
- ".js": "application/javascript; charset=utf-8",
19
- ".mjs": "application/javascript; charset=utf-8",
20
- ".css": "text/css; charset=utf-8",
21
- ".json": "application/json; charset=utf-8",
22
- ".svg": "image/svg+xml",
23
- ".png": "image/png",
24
- ".jpg": "image/jpeg",
25
- ".woff2": "font/woff2",
26
- ".map": "application/json",
27
- };
28
-
29
- const MAX_BUFFER = 2000; // recent events kept for late SSE subscribers
30
- const events = []; // ring buffer
31
- let nextSeq = 1;
32
- const sseClients = new Set(); // res handles
33
-
34
- let persistPath = null; // absolute path to events.jsonl, or null
35
-
36
- // ─── Persistence rotation ─────────────────────────────────────────────────
37
- // 24/7 dev servers used to grow events.jsonl unbounded — saw it hit GBs
38
- // across weeks. We rotate when the file passes ROTATE_AT_BYTES, archiving
39
- // the previous file to .1 and starting fresh. Last-event-id replay still
40
- // covers the in-memory ring buffer of MAX_BUFFER events.
41
- const ROTATE_AT_BYTES = 50 * 1024 * 1024;
42
- let lastRotateCheckAt = 0;
43
- let rotateInProgress = false;
44
- async function maybeRotatePersistFile() {
45
- if (!persistPath) return;
46
- const now = Date.now();
47
- // Throttle disk-stat checks to once per 30s.
48
- if (now - lastRotateCheckAt < 30_000) return;
49
- lastRotateCheckAt = now;
50
- if (rotateInProgress) return;
51
- rotateInProgress = true;
52
- try {
53
- const s = await stat(persistPath).catch(() => null);
54
- if (!s || s.size < ROTATE_AT_BYTES) return;
55
- // Roll events.jsonl → events.jsonl.1 (replacing any previous .1).
56
- const oldPath = persistPath + ".1";
57
- try { await unlink(oldPath); } catch {}
58
- const { rename } = await import("node:fs/promises");
59
- await rename(persistPath, oldPath);
60
- console.log(`agent-dag: rotated ${persistPath} (${(s.size / 1024 / 1024).toFixed(0)}MB → ${oldPath})`);
61
- } catch (err) {
62
- console.error("agent-dag: persist rotation failed:", err && err.message ? err.message : err);
63
- } finally {
64
- rotateInProgress = false;
65
- }
66
- }
67
-
68
- // ─── Model enrichment ────────────────────────────────────────────────────
69
- // CC's hook payloads never carry the `model` field — but every hook
70
- // references a `transcript_path` JSONL that contains lines like
71
- // `"model":"claude-opus-4-7"`. We read the tail of that file once per
72
- // session, cache the result, and (a) inject `model` into subsequent
73
- // payloads for that session before broadcasting, (b) emit a synthetic
74
- // `ModelObserved` event so the client backfills agents created before
75
- // the model was resolved.
76
- const modelBySession = new Map(); // sessionId -> { rootModel, subsSig }
77
- const pendingTranscriptReads = new Set(); // sessionId currently being read
78
- const modelLastReadAt = new Map(); // sessionId -> ms timestamp (re-read throttle)
79
- const MODEL_READ_THROTTLE_MS = 2500;
80
-
81
- async function readModelFromTranscript(path) {
82
- // Returns { rootModel, subagentModels } — root is the most recent
83
- // non-sidechain assistant model, subagents are attributed by the Task
84
- // tool_use id that owns them. CC transcripts mark subagent messages
85
- // with `isSidechain:true` and (for current schemas) a `parentToolUseID`
86
- // or `parent_tool_use_id` referencing the Task invocation.
87
- try {
88
- const s = await stat(path);
89
- if (s.size === 0) return null;
90
- const fh = await open(path, "r");
91
- let text;
92
- try {
93
- const buf = Buffer.alloc(s.size);
94
- await fh.read(buf, 0, s.size, 0);
95
- text = buf.toString("utf8");
96
- } finally {
97
- await fh.close();
98
- }
99
- let rootModel = null;
100
- const subagentModels = {};
101
- for (const line of text.split("\n")) {
102
- if (!line) continue;
103
- let obj;
104
- try { obj = JSON.parse(line); } catch { continue; }
105
- const msg = obj && obj.message;
106
- // Assistant model entries live on either obj.model or obj.message.model
107
- // depending on CC schema version. Accept either.
108
- const model = (msg && typeof msg.model === "string" && /^claude[-_]/i.test(msg.model)) ? msg.model
109
- : (typeof obj.model === "string" && /^claude[-_]/i.test(obj.model)) ? obj.model
110
- : null;
111
- if (!model) continue;
112
- const isSide = obj.isSidechain === true || obj.is_sidechain === true;
113
- const ptid = obj.parentToolUseID || obj.parent_tool_use_id || obj.parentToolUseId || null;
114
- if (isSide && ptid) {
115
- subagentModels[ptid] = model;
116
- } else if (!isSide) {
117
- rootModel = model;
118
- }
119
- }
120
- if (!rootModel && Object.keys(subagentModels).length === 0) return null;
121
- return { rootModel, subagentModels };
122
- } catch {
123
- return null;
124
- }
125
- }
126
-
127
- function maybeResolveModel(payload) {
128
- if (!payload || typeof payload !== "object") return;
129
- const sid = payload.session_id;
130
- const tp = payload.transcript_path;
131
- if (!sid || !tp) return;
132
- // Re-read on every event for this session — the cache was preventing us
133
- // from picking up subagent models that arrive after the root is known.
134
- // Throttle so we don't thrash the filesystem.
135
- if (pendingTranscriptReads.has(sid)) return;
136
- const now = Date.now();
137
- const last = modelLastReadAt.get(sid) ?? 0;
138
- if (now - last < MODEL_READ_THROTTLE_MS) return;
139
- modelLastReadAt.set(sid, now);
140
- pendingTranscriptReads.add(sid);
141
- readModelFromTranscript(tp)
142
- .then(result => {
143
- if (!result) return;
144
- const { rootModel, subagentModels } = result;
145
- const prev = modelBySession.get(sid);
146
- const subsSig = JSON.stringify(subagentModels);
147
- if (prev && prev.rootModel === rootModel && prev.subsSig === subsSig) return;
148
- modelBySession.set(sid, { rootModel, subsSig });
149
- pushEvent({
150
- hook_event_name: "ModelObserved",
151
- session_id: sid,
152
- model: rootModel,
153
- subagentModels,
154
- }, "internal");
155
- })
156
- .catch(() => {})
157
- .finally(() => pendingTranscriptReads.delete(sid));
158
- }
159
-
160
- // ─── Usage enrichment ────────────────────────────────────────────────────
161
- // Same story as the model: token counts (input/output/cache) are missing
162
- // from every CC hook payload but present on every assistant message in
163
- // the transcript JSONL as a `"usage":{…}` block. We sum them across the
164
- // whole transcript and ship a synthetic UsageObserved event so the
165
- // session's root agent gets accurate cumulative usage (and therefore the
166
- // cost columns actually have something to multiply by).
167
- const lastUsageReadAt = new Map(); // sid -> ms timestamp
168
- const pendingUsageReads = new Set(); // sid currently being read
169
- const USAGE_READ_THROTTLE_MS = 2500;
170
-
171
- async function readUsageFromTranscript(path) {
172
- try {
173
- const s = await stat(path);
174
- if (s.size === 0) return null;
175
- // Transcripts can grow large (thinking blocks, tool inputs) — read the
176
- // whole file. Each entry has its own usage object and we sum every
177
- // occurrence, so missing earlier bytes would undercount. Files are
178
- // usually < 1MB; tens-of-MB sessions cost a few ms to scan.
179
- const fh = await open(path, "r");
180
- let buf;
181
- try {
182
- buf = Buffer.alloc(s.size);
183
- await fh.read(buf, 0, s.size, 0);
184
- } finally {
185
- await fh.close();
186
- }
187
- const text = buf.toString("utf8");
188
- const totals = {
189
- input_tokens: 0, output_tokens: 0,
190
- cache_read_input_tokens: 0, cache_creation_input_tokens: 0,
191
- };
192
- // Match each `"usage":{...}` block and sum the four numeric fields.
193
- // Regex is good enough — these blocks are flat single-level JSON.
194
- const re = /"usage"\s*:\s*\{([^}]+)\}/g;
195
- const grab = (blob, key) => {
196
- const km = blob.match(new RegExp(`"${key}"\\s*:\\s*(\\d+)`));
197
- return km ? Number(km[1]) : 0;
198
- };
199
- for (const m of text.matchAll(re)) {
200
- const blob = m[1];
201
- totals.input_tokens += grab(blob, "input_tokens");
202
- totals.output_tokens += grab(blob, "output_tokens");
203
- totals.cache_read_input_tokens += grab(blob, "cache_read_input_tokens");
204
- totals.cache_creation_input_tokens += grab(blob, "cache_creation_input_tokens");
205
- }
206
- if (totals.input_tokens === 0 && totals.output_tokens === 0
207
- && totals.cache_read_input_tokens === 0 && totals.cache_creation_input_tokens === 0) return null;
208
- return totals;
209
- } catch {
210
- return null;
211
- }
212
- }
213
-
214
- function maybeResolveUsage(payload) {
215
- if (!payload || typeof payload !== "object") return;
216
- const sid = payload.session_id;
217
- const tp = payload.transcript_path;
218
- if (!sid || !tp) return;
219
- if (pendingUsageReads.has(sid)) return;
220
- const now = Date.now();
221
- const last = lastUsageReadAt.get(sid) ?? 0;
222
- if (now - last < USAGE_READ_THROTTLE_MS) return;
223
- lastUsageReadAt.set(sid, now);
224
- pendingUsageReads.add(sid);
225
- readUsageFromTranscript(tp)
226
- .then(usage => {
227
- if (!usage) return;
228
- pushEvent({ hook_event_name: "UsageObserved", session_id: sid, usage }, "internal");
229
- })
230
- .catch(() => {})
231
- .finally(() => pendingUsageReads.delete(sid));
232
- }
233
-
234
- // ─── Context enrichment ──────────────────────────────────────────────────
235
- // Approximation of `/context` since CC doesn't expose its breakdown via
236
- // hooks. We scan the transcript JSONL for message counts (user / assistant
237
- // / tool_use / tool_result / system-reminders) and walk up from cwd for
238
- // any CLAUDE.md files in scope. Token totals come from UsageObserved; this
239
- // scan is purely structural ("what does the context contain").
240
- const lastContextReadAt = new Map();
241
- const pendingContextReads = new Set();
242
- const CONTEXT_READ_THROTTLE_MS = 4000;
243
-
244
- async function readContextFromTranscript(path) {
245
- try {
246
- const s = await stat(path);
247
- if (s.size === 0) return null;
248
- const fh = await open(path, "r");
249
- let buf;
250
- try { buf = Buffer.alloc(s.size); await fh.read(buf, 0, s.size, 0); }
251
- finally { await fh.close(); }
252
- const text = buf.toString("utf8");
253
- const breakdown = {
254
- msgsUser: 0,
255
- msgsAssistant: 0,
256
- toolUses: 0,
257
- toolResults: 0,
258
- systemReminders: 0,
259
- currentContextTokens: 0,
260
- };
261
- breakdown.msgsUser = (text.match(/"type"\s*:\s*"user"/g) ?? []).length;
262
- breakdown.msgsAssistant = (text.match(/"type"\s*:\s*"assistant"/g) ?? []).length;
263
- breakdown.toolUses = (text.match(/"type"\s*:\s*"tool_use"/g) ?? []).length;
264
- breakdown.toolResults = (text.match(/"type"\s*:\s*"tool_result"/g) ?? []).length;
265
- breakdown.systemReminders = (text.match(/<system-reminder>/g) ?? []).length;
266
- // Current context size = input + cache_read + cache_create on the LAST
267
- // usage block. Each assistant message's usage describes the context
268
- // window for THAT call; summing across calls double-counts cached
269
- // prefixes and explodes past the real ceiling. Take the most recent.
270
- const re = /"usage"\s*:\s*\{([^}]+)\}/g;
271
- const grab = (blob, key) => {
272
- const km = blob.match(new RegExp(`"${key}"\\s*:\\s*(\\d+)`));
273
- return km ? Number(km[1]) : 0;
274
- };
275
- let lastBlob = null;
276
- for (const m of text.matchAll(re)) lastBlob = m[1];
277
- if (lastBlob) {
278
- breakdown.currentContextTokens =
279
- grab(lastBlob, "input_tokens") +
280
- grab(lastBlob, "cache_read_input_tokens") +
281
- grab(lastBlob, "cache_creation_input_tokens");
282
- }
283
- return breakdown;
284
- } catch { return null; }
285
- }
286
-
287
- /** Encode an absolute path the way CC stores it under
288
- * ~/.claude/projects/<slug>/. Drive letters, colons, and path separators
289
- * are flattened to "-" so the slug survives as a single directory name. */
290
- function ccProjectSlug(cwd) {
291
- if (!cwd) return "";
292
- // Replace path separators and the Windows drive colon. Match CC's own
293
- // encoding: every \\ / : → - (no collapsing of adjacent dashes).
294
- return resolve(cwd).replace(/[\\/:]/g, "-");
295
- }
296
-
297
- async function scanClaudeMdFiles(cwd) {
298
- if (!cwd || typeof cwd !== "string") return [];
299
- const found = [];
300
- const seen = new Set();
301
- const home = homedir();
302
- const push = async (p) => {
303
- if (seen.has(p)) return;
304
- seen.add(p);
305
- try {
306
- const s = await stat(p);
307
- if (s.isFile() && s.size > 0) found.push({ path: p, bytes: s.size });
308
- } catch {}
309
- };
310
- // Walk up from cwd to filesystem root. At each dir, check for the
311
- // canonical CC memory filenames plus CLAUDE.local.md (user-private).
312
- let dir = resolve(cwd);
313
- for (let depth = 0; depth < 16; depth++) {
314
- for (const rel of [
315
- "CLAUDE.md",
316
- "CLAUDE.local.md",
317
- join(".claude", "CLAUDE.md"),
318
- join(".claude", "CLAUDE.local.md"),
319
- ]) {
320
- await push(join(dir, rel));
321
- }
322
- const parent = pdirname(dir);
323
- if (parent === dir) break;
324
- dir = parent;
325
- }
326
- // User-global memory.
327
- await push(join(home, ".claude", "CLAUDE.md"));
328
- await push(join(home, ".claude", "CLAUDE.local.md"));
329
- // Per-project auto-memory: ~/.claude/projects/<slug>/memory/*.md
330
- // (plus MEMORY.md index). CC injects these into context for sessions
331
- // whose cwd matches the slug.
332
- const slug = ccProjectSlug(cwd);
333
- if (slug) {
334
- const memDir = join(home, ".claude", "projects", slug, "memory");
335
- try {
336
- const entries = await readdir(memDir);
337
- for (const f of entries) {
338
- if (f.toLowerCase().endsWith(".md")) await push(join(memDir, f));
339
- }
340
- } catch {}
341
- }
342
- return found;
343
- }
344
-
345
- function maybeResolveContext(payload) {
346
- if (!payload || typeof payload !== "object") return;
347
- const sid = payload.session_id;
348
- const tp = payload.transcript_path;
349
- const cwd = payload.cwd;
350
- if (!sid || !tp) return;
351
- if (pendingContextReads.has(sid)) return;
352
- const now = Date.now();
353
- const last = lastContextReadAt.get(sid) ?? 0;
354
- if (now - last < CONTEXT_READ_THROTTLE_MS) return;
355
- lastContextReadAt.set(sid, now);
356
- pendingContextReads.add(sid);
357
- Promise.all([readContextFromTranscript(tp), scanClaudeMdFiles(cwd)])
358
- .then(([breakdown, claudeMdFiles]) => {
359
- if (!breakdown && (!claudeMdFiles || claudeMdFiles.length === 0)) return;
360
- pushEvent({
361
- hook_event_name: "ContextObserved",
362
- session_id: sid,
363
- context: {
364
- ...(breakdown ?? {}),
365
- claudeMdFiles: claudeMdFiles ?? [],
366
- },
367
- }, "internal");
368
- })
369
- .catch(() => {})
370
- .finally(() => pendingContextReads.delete(sid));
371
- }
372
-
373
- function pushEvent(raw, source, opts = {}) {
374
- // Synchronous enrichment: if we already know this session's model, stamp
375
- // it on the payload so the client's recursive scanner picks it up.
376
- if (raw && typeof raw === "object" && raw.session_id && !raw.model) {
377
- const cached = modelBySession.get(raw.session_id);
378
- if (cached) raw.model = cached;
379
- }
380
-
381
- const seq = nextSeq++;
382
- const evt = {
383
- seq,
384
- receivedAt: opts.receivedAt ?? Date.now(),
385
- source,
386
- payload: raw,
387
- };
388
- events.push(evt);
389
- if (events.length > MAX_BUFFER) events.splice(0, events.length - MAX_BUFFER);
390
-
391
- const line = `id: ${seq}\nevent: hook\ndata: ${JSON.stringify(evt)}\n\n`;
392
- for (const res of sseClients) {
393
- try { res.write(line); } catch {}
394
- }
395
-
396
- if (persistPath && !opts.replay) {
397
- // Fire-and-forget append. JSONL = newline-delimited JSON.
398
- appendFile(persistPath, JSON.stringify(evt) + "\n", "utf8").catch(() => {});
399
- // Cheap throttled check (every 30s) — only rotates if file > 50MB.
400
- maybeRotatePersistFile();
401
- }
402
-
403
- // Kick off async transcript scans. Model arrives as a one-shot
404
- // ModelObserved; usage is re-read periodically (throttled to 2.5s per
405
- // session) so the cost columns track running totals as the session
406
- // progresses. Both result in synthetic events.
407
- if (source === "hook" && !opts.replay) {
408
- maybeResolveModel(raw);
409
- maybeResolveUsage(raw);
410
- maybeResolveContext(raw);
411
- }
412
-
413
- return evt;
414
- }
415
-
416
- async function replayLog(filePath) {
417
- if (!existsSync(filePath)) return 0;
418
- let count = 0;
419
- const rl = createInterface({ input: createReadStream(filePath, { encoding: "utf8" }) });
420
- for await (const line of rl) {
421
- if (!line) continue;
422
- try {
423
- const evt = JSON.parse(line);
424
- if (evt && typeof evt === "object" && evt.payload) {
425
- pushEvent(evt.payload, evt.source ?? "replay", { receivedAt: evt.receivedAt, replay: true });
426
- count++;
427
- }
428
- } catch { /* skip corrupt line */ }
429
- }
430
- return count;
431
- }
432
-
433
- function send(res, status, body, headers = {}) {
434
- res.writeHead(status, {
435
- "Content-Type": "application/json; charset=utf-8",
436
- "Cache-Control": "no-store",
437
- ...headers,
438
- });
439
- res.end(typeof body === "string" ? body : JSON.stringify(body));
440
- }
441
-
442
- async function serveStatic(req, res, url) {
443
- // Strip leading slash, default to index.html
444
- let rel = url.pathname.replace(/^\/+/, "");
445
- if (rel === "" || rel.endsWith("/")) rel = `${rel}index.html`;
446
- const filePath = join(WEB_DIST, rel);
447
- if (!filePath.startsWith(WEB_DIST)) return send(res, 403, { error: "forbidden" });
448
-
449
- try {
450
- const s = await stat(filePath);
451
- if (s.isDirectory()) return send(res, 404, { error: "not found" });
452
- const buf = await readFile(filePath);
453
- res.writeHead(200, {
454
- "Content-Type": MIME[extname(filePath).toLowerCase()] ?? "application/octet-stream",
455
- "Cache-Control": "no-cache",
456
- });
457
- res.end(buf);
458
- } catch {
459
- // SPA fallback to index.html for client-side routes
460
- try {
461
- const idx = await readFile(join(WEB_DIST, "index.html"));
462
- res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
463
- res.end(idx);
464
- } catch {
465
- send(res, 404, { error: "ui not built. run `pnpm build` or `npm run build`." });
466
- }
467
- }
468
- }
469
-
470
- function handleEventIngest(req, res) {
471
- let body = "";
472
- req.setEncoding("utf8");
473
- req.on("data", c => {
474
- body += c;
475
- if (body.length > 5_000_000) {
476
- req.destroy();
477
- }
478
- });
479
- req.on("end", () => {
480
- let parsed;
481
- try { parsed = JSON.parse(body); }
482
- catch { return send(res, 400, { error: "invalid json" }); }
483
- const evt = pushEvent(parsed, "hook");
484
- send(res, 200, { ok: true, seq: evt.seq });
485
- });
486
- req.on("error", () => send(res, 400, { error: "bad request" }));
487
- }
488
-
489
- function handleSse(req, res) {
490
- res.writeHead(200, {
491
- "Content-Type": "text/event-stream",
492
- "Cache-Control": "no-cache, no-transform",
493
- "Connection": "keep-alive",
494
- "X-Accel-Buffering": "no",
495
- });
496
- res.write(`retry: 1500\n\n`);
497
-
498
- // Resume: replay events after Last-Event-ID
499
- const lastId = Number(req.headers["last-event-id"] ?? 0);
500
- for (const e of events) {
501
- if (e.seq <= lastId) continue;
502
- res.write(`id: ${e.seq}\nevent: hook\ndata: ${JSON.stringify(e)}\n\n`);
503
- }
504
-
505
- sseClients.add(res);
506
- const ping = setInterval(() => {
507
- try { res.write(`: ping\n\n`); } catch {}
508
- }, 15000);
509
-
510
- req.on("close", () => {
511
- clearInterval(ping);
512
- sseClients.delete(res);
513
- });
514
- }
515
-
516
- function handleHealth(_req, res) {
517
- send(res, 200, {
518
- ok: true,
519
- name: "agent-dag",
520
- seq: nextSeq - 1,
521
- clients: sseClients.size,
522
- uptimeMs: Math.round(process.uptime() * 1000),
523
- });
524
- }
525
-
526
- function isProcessAlive(pid) {
527
- try { process.kill(pid, 0); return true; }
528
- catch (e) { return e && e.code === "EPERM"; }
529
- }
530
-
531
- async function sweepStaleDiscovery() {
532
- const dir = join(homedir(), ".claude", "agent-dag");
533
- let files;
534
- try { files = await readdir(dir); } catch { return 0; }
535
- let removed = 0;
536
- for (const f of files) {
537
- if (!f.endsWith(".json")) continue;
538
- const p = join(dir, f);
539
- try {
540
- const d = JSON.parse(await readFile(p, "utf8"));
541
- if (d && typeof d.pid === "number" && !isProcessAlive(d.pid)) {
542
- await unlink(p).catch(() => {});
543
- removed++;
544
- }
545
- } catch { /* corrupt — leave it */ }
546
- }
547
- return removed;
548
- }
549
-
550
- function randomPort(min, max) {
551
- return Math.floor(Math.random() * (max - min + 1)) + min;
552
- }
553
-
554
- async function tryListen(server, port, host) {
555
- return new Promise((res, rej) => {
556
- server.once("error", rej);
557
- server.listen(port, host, () => {
558
- server.removeListener("error", rej);
559
- res();
560
- });
561
- });
562
- }
563
-
564
- export async function startServer({ port = 4317, host = "127.0.0.1", persist = null, portRange = [4318, 4400] } = {}) {
565
- const removed = await sweepStaleDiscovery();
566
- if (removed > 0) console.log(` swept ${removed} stale discovery file(s)`);
567
- if (persist) {
568
- persistPath = resolve(persist);
569
- try { await mkdir(pdirname(persistPath), { recursive: true }); } catch {}
570
- const replayed = await replayLog(persistPath);
571
- if (replayed > 0) {
572
- // Don't broadcast replays as live; SSE clients catch up via Last-Event-ID
573
- // already. Just keep the buffer + seq counter primed.
574
- }
575
- }
576
- const server = createServer((req, res) => {
577
- const url = new URL(req.url ?? "/", `http://${req.headers.host ?? host}`);
578
-
579
- if (req.method === "POST" && url.pathname === "/api/event") return handleEventIngest(req, res);
580
- if (req.method === "GET" && url.pathname === "/api/health") return handleHealth(req, res);
581
- if (req.method === "GET" && url.pathname === "/events") return handleSse(req, res);
582
-
583
- if (req.method === "GET" && url.pathname === "/api/events") {
584
- const since = Number(url.searchParams.get("since") ?? 0);
585
- return send(res, 200, events.filter(e => e.seq > since));
586
- }
587
-
588
- // POST /api/clear — wipe in-memory buffer + persistence file (UI reset)
589
- if (req.method === "POST" && url.pathname === "/api/clear") {
590
- events.length = 0;
591
- if (persistPath) truncate(persistPath, 0).catch(() => {});
592
- pushEvent({ hook_event_name: "__clear", cwd: "" }, "internal");
593
- return send(res, 200, { ok: true });
594
- }
595
-
596
- if (req.method === "GET") return serveStatic(req, res, url);
597
- send(res, 405, { error: "method not allowed" });
598
- });
599
-
600
- // Try requested port first, then up to 10 random ports from portRange.
601
- const candidates = [port];
602
- for (let i = 0; i < 10; i++) candidates.push(randomPort(portRange[0], portRange[1]));
603
-
604
- for (const candidate of candidates) {
605
- try {
606
- await tryListen(server, candidate, host);
607
- return server;
608
- } catch (err) {
609
- if (err && err.code === "EADDRINUSE") continue;
610
- throw err;
611
- }
612
- }
613
- throw Object.assign(new Error(`all ports tried — none available`), { code: "EADDRINUSE" });
614
- }
615
-
616
- // Allow running this file directly for dev.
617
- if (import.meta.url === pathToFileURL(process.argv[1]).href) {
618
- const port = Number(process.env.CCGRAPH_PORT ?? 4317);
619
- startServer({ port }).then(s => {
620
- const addr = s.address();
621
- const p = typeof addr === "object" && addr ? addr.port : port;
622
- console.log(`agent-dag server: http://127.0.0.1:${p}`);
623
- }).catch(e => {
624
- console.error("agent-dag server failed:", e.message);
625
- process.exit(1);
626
- });
627
- }
1
+ // agent-dag server: HTTP ingest + SSE broadcast + static file serving.
2
+ // Single-file pure Node HTTP server, zero deps.
3
+ import { createServer } from "node:http";
4
+ import { readFile, stat, mkdir, appendFile, open, truncate, readdir, unlink } from "node:fs/promises";
5
+ import { createReadStream, existsSync } from "node:fs";
6
+ import { homedir } from "node:os";
7
+ import { extname, join, resolve, dirname as pdirname } from "node:path";
8
+ import { fileURLToPath, pathToFileURL } from "node:url";
9
+ import { dirname } from "node:path";
10
+ import { createInterface } from "node:readline";
11
+
12
+ const __dirname = dirname(fileURLToPath(import.meta.url));
13
+ const PKG_ROOT = resolve(__dirname, "..", "..");
14
+ const WEB_DIST = resolve(PKG_ROOT, "dist", "web");
15
+
16
+ const MIME = {
17
+ ".html": "text/html; charset=utf-8",
18
+ ".js": "application/javascript; charset=utf-8",
19
+ ".mjs": "application/javascript; charset=utf-8",
20
+ ".css": "text/css; charset=utf-8",
21
+ ".json": "application/json; charset=utf-8",
22
+ ".svg": "image/svg+xml",
23
+ ".png": "image/png",
24
+ ".jpg": "image/jpeg",
25
+ ".woff2": "font/woff2",
26
+ ".map": "application/json",
27
+ };
28
+
29
+ const MAX_BUFFER = 2000; // recent events kept for late SSE subscribers
30
+ const events = []; // ring buffer
31
+ let nextSeq = 1;
32
+ const sseClients = new Set(); // res handles
33
+
34
+ let persistPath = null; // absolute path to events.jsonl, or null
35
+
36
+ // ─── Persistence rotation ─────────────────────────────────────────────────
37
+ // 24/7 dev servers used to grow events.jsonl unbounded — saw it hit GBs
38
+ // across weeks. We rotate when the file passes ROTATE_AT_BYTES, archiving
39
+ // the previous file to .1 and starting fresh. Last-event-id replay still
40
+ // covers the in-memory ring buffer of MAX_BUFFER events.
41
+ const ROTATE_AT_BYTES = 50 * 1024 * 1024;
42
+ let lastRotateCheckAt = 0;
43
+ let rotateInProgress = false;
44
+ async function maybeRotatePersistFile() {
45
+ if (!persistPath) return;
46
+ const now = Date.now();
47
+ // Throttle disk-stat checks to once per 30s.
48
+ if (now - lastRotateCheckAt < 30_000) return;
49
+ lastRotateCheckAt = now;
50
+ if (rotateInProgress) return;
51
+ rotateInProgress = true;
52
+ try {
53
+ const s = await stat(persistPath).catch(() => null);
54
+ if (!s || s.size < ROTATE_AT_BYTES) return;
55
+ // Roll events.jsonl → events.jsonl.1 (replacing any previous .1).
56
+ const oldPath = persistPath + ".1";
57
+ try { await unlink(oldPath); } catch {}
58
+ const { rename } = await import("node:fs/promises");
59
+ await rename(persistPath, oldPath);
60
+ console.log(`agent-dag: rotated ${persistPath} (${(s.size / 1024 / 1024).toFixed(0)}MB → ${oldPath})`);
61
+ } catch (err) {
62
+ console.error("agent-dag: persist rotation failed:", err && err.message ? err.message : err);
63
+ } finally {
64
+ rotateInProgress = false;
65
+ }
66
+ }
67
+
68
+ // ─── Model enrichment ────────────────────────────────────────────────────
69
+ // CC's hook payloads never carry the `model` field — but every hook
70
+ // references a `transcript_path` JSONL that contains lines like
71
+ // `"model":"claude-opus-4-7"`. We read the tail of that file once per
72
+ // session, cache the result, and (a) inject `model` into subsequent
73
+ // payloads for that session before broadcasting, (b) emit a synthetic
74
+ // `ModelObserved` event so the client backfills agents created before
75
+ // the model was resolved.
76
+ const modelBySession = new Map(); // sessionId -> { rootModel, subsSig }
77
+ const pendingTranscriptReads = new Set(); // sessionId currently being read
78
+ const modelLastReadAt = new Map(); // sessionId -> ms timestamp (re-read throttle)
79
+ const MODEL_READ_THROTTLE_MS = 2500;
80
+
81
+ async function readModelFromTranscript(path) {
82
+ // Returns { rootModel, subagentModels } — root is the most recent
83
+ // non-sidechain assistant model, subagents are attributed by the Task
84
+ // tool_use id that owns them. CC transcripts mark subagent messages
85
+ // with `isSidechain:true` and (for current schemas) a `parentToolUseID`
86
+ // or `parent_tool_use_id` referencing the Task invocation.
87
+ try {
88
+ const s = await stat(path);
89
+ if (s.size === 0) return null;
90
+ const fh = await open(path, "r");
91
+ let text;
92
+ try {
93
+ const buf = Buffer.alloc(s.size);
94
+ await fh.read(buf, 0, s.size, 0);
95
+ text = buf.toString("utf8");
96
+ } finally {
97
+ await fh.close();
98
+ }
99
+ let rootModel = null;
100
+ const subagentModels = {};
101
+ let anyModelSeen = null; // regex-style fallback — the last claude-* model
102
+ // we encountered, regardless of sidechain flag.
103
+ // Keeps the old behavior alive when the JSONL
104
+ // schema doesn't expose `isSidechain`.
105
+ for (const line of text.split("\n")) {
106
+ if (!line) continue;
107
+ let obj;
108
+ try { obj = JSON.parse(line); } catch { continue; }
109
+ const msg = obj && obj.message;
110
+ const model = (msg && typeof msg.model === "string" && /^claude[-_]/i.test(msg.model)) ? msg.model
111
+ : (typeof obj.model === "string" && /^claude[-_]/i.test(obj.model)) ? obj.model
112
+ : null;
113
+ if (!model) continue;
114
+ anyModelSeen = model;
115
+ const isSide = obj.isSidechain === true || obj.is_sidechain === true;
116
+ const ptid = obj.parentToolUseID || obj.parent_tool_use_id || obj.parentToolUseId || null;
117
+ if (isSide && ptid) {
118
+ subagentModels[ptid] = model;
119
+ } else if (!isSide) {
120
+ rootModel = model;
121
+ }
122
+ }
123
+ // Fallback: if no `isSidechain:false` entry was found (older schema, or
124
+ // a transcript that only contains sidechain blocks), use the last-seen
125
+ // model so the root card still shows SOMETHING instead of going blank.
126
+ if (!rootModel) rootModel = anyModelSeen;
127
+ if (!rootModel && Object.keys(subagentModels).length === 0) return null;
128
+ return { rootModel, subagentModels };
129
+ } catch {
130
+ return null;
131
+ }
132
+ }
133
+
134
+ function maybeResolveModel(payload) {
135
+ if (!payload || typeof payload !== "object") return;
136
+ const sid = payload.session_id;
137
+ const tp = payload.transcript_path;
138
+ if (!sid || !tp) return;
139
+ // Re-read on every event for this session — the cache was preventing us
140
+ // from picking up subagent models that arrive after the root is known.
141
+ // Throttle so we don't thrash the filesystem.
142
+ if (pendingTranscriptReads.has(sid)) return;
143
+ const now = Date.now();
144
+ const last = modelLastReadAt.get(sid) ?? 0;
145
+ if (now - last < MODEL_READ_THROTTLE_MS) return;
146
+ modelLastReadAt.set(sid, now);
147
+ pendingTranscriptReads.add(sid);
148
+ readModelFromTranscript(tp)
149
+ .then(result => {
150
+ if (!result) return;
151
+ const { rootModel, subagentModels } = result;
152
+ const prev = modelBySession.get(sid);
153
+ const subsSig = JSON.stringify(subagentModels);
154
+ if (prev && prev.rootModel === rootModel && prev.subsSig === subsSig) return;
155
+ modelBySession.set(sid, { rootModel, subsSig });
156
+ pushEvent({
157
+ hook_event_name: "ModelObserved",
158
+ session_id: sid,
159
+ model: rootModel,
160
+ subagentModels,
161
+ }, "internal");
162
+ })
163
+ .catch(() => {})
164
+ .finally(() => pendingTranscriptReads.delete(sid));
165
+ }
166
+
167
+ // ─── Usage enrichment ────────────────────────────────────────────────────
168
+ // Same story as the model: token counts (input/output/cache) are missing
169
+ // from every CC hook payload but present on every assistant message in
170
+ // the transcript JSONL as a `"usage":{…}` block. We sum them across the
171
+ // whole transcript and ship a synthetic UsageObserved event so the
172
+ // session's root agent gets accurate cumulative usage (and therefore the
173
+ // cost columns actually have something to multiply by).
174
+ const lastUsageReadAt = new Map(); // sid -> ms timestamp
175
+ const pendingUsageReads = new Set(); // sid currently being read
176
+ const USAGE_READ_THROTTLE_MS = 2500;
177
+
178
+ async function readUsageFromTranscript(path) {
179
+ try {
180
+ const s = await stat(path);
181
+ if (s.size === 0) return null;
182
+ // Transcripts can grow large (thinking blocks, tool inputs) — read the
183
+ // whole file. Each entry has its own usage object and we sum every
184
+ // occurrence, so missing earlier bytes would undercount. Files are
185
+ // usually < 1MB; tens-of-MB sessions cost a few ms to scan.
186
+ const fh = await open(path, "r");
187
+ let buf;
188
+ try {
189
+ buf = Buffer.alloc(s.size);
190
+ await fh.read(buf, 0, s.size, 0);
191
+ } finally {
192
+ await fh.close();
193
+ }
194
+ const text = buf.toString("utf8");
195
+ const totals = {
196
+ input_tokens: 0, output_tokens: 0,
197
+ cache_read_input_tokens: 0, cache_creation_input_tokens: 0,
198
+ };
199
+ // Match each `"usage":{...}` block and sum the four numeric fields.
200
+ // Regex is good enough — these blocks are flat single-level JSON.
201
+ const re = /"usage"\s*:\s*\{([^}]+)\}/g;
202
+ const grab = (blob, key) => {
203
+ const km = blob.match(new RegExp(`"${key}"\\s*:\\s*(\\d+)`));
204
+ return km ? Number(km[1]) : 0;
205
+ };
206
+ for (const m of text.matchAll(re)) {
207
+ const blob = m[1];
208
+ totals.input_tokens += grab(blob, "input_tokens");
209
+ totals.output_tokens += grab(blob, "output_tokens");
210
+ totals.cache_read_input_tokens += grab(blob, "cache_read_input_tokens");
211
+ totals.cache_creation_input_tokens += grab(blob, "cache_creation_input_tokens");
212
+ }
213
+ if (totals.input_tokens === 0 && totals.output_tokens === 0
214
+ && totals.cache_read_input_tokens === 0 && totals.cache_creation_input_tokens === 0) return null;
215
+ return totals;
216
+ } catch {
217
+ return null;
218
+ }
219
+ }
220
+
221
+ function maybeResolveUsage(payload) {
222
+ if (!payload || typeof payload !== "object") return;
223
+ const sid = payload.session_id;
224
+ const tp = payload.transcript_path;
225
+ if (!sid || !tp) return;
226
+ if (pendingUsageReads.has(sid)) return;
227
+ const now = Date.now();
228
+ const last = lastUsageReadAt.get(sid) ?? 0;
229
+ if (now - last < USAGE_READ_THROTTLE_MS) return;
230
+ lastUsageReadAt.set(sid, now);
231
+ pendingUsageReads.add(sid);
232
+ readUsageFromTranscript(tp)
233
+ .then(usage => {
234
+ if (!usage) return;
235
+ pushEvent({ hook_event_name: "UsageObserved", session_id: sid, usage }, "internal");
236
+ })
237
+ .catch(() => {})
238
+ .finally(() => pendingUsageReads.delete(sid));
239
+ }
240
+
241
+ // ─── Context enrichment ──────────────────────────────────────────────────
242
+ // Approximation of `/context` since CC doesn't expose its breakdown via
243
+ // hooks. We scan the transcript JSONL for message counts (user / assistant
244
+ // / tool_use / tool_result / system-reminders) and walk up from cwd for
245
+ // any CLAUDE.md files in scope. Token totals come from UsageObserved; this
246
+ // scan is purely structural ("what does the context contain").
247
+ const lastContextReadAt = new Map();
248
+ const pendingContextReads = new Set();
249
+ const CONTEXT_READ_THROTTLE_MS = 4000;
250
+
251
+ async function readContextFromTranscript(path) {
252
+ try {
253
+ const s = await stat(path);
254
+ if (s.size === 0) return null;
255
+ const fh = await open(path, "r");
256
+ let buf;
257
+ try { buf = Buffer.alloc(s.size); await fh.read(buf, 0, s.size, 0); }
258
+ finally { await fh.close(); }
259
+ const text = buf.toString("utf8");
260
+ const breakdown = {
261
+ msgsUser: 0,
262
+ msgsAssistant: 0,
263
+ toolUses: 0,
264
+ toolResults: 0,
265
+ systemReminders: 0,
266
+ currentContextTokens: 0,
267
+ };
268
+ breakdown.msgsUser = (text.match(/"type"\s*:\s*"user"/g) ?? []).length;
269
+ breakdown.msgsAssistant = (text.match(/"type"\s*:\s*"assistant"/g) ?? []).length;
270
+ breakdown.toolUses = (text.match(/"type"\s*:\s*"tool_use"/g) ?? []).length;
271
+ breakdown.toolResults = (text.match(/"type"\s*:\s*"tool_result"/g) ?? []).length;
272
+ breakdown.systemReminders = (text.match(/<system-reminder>/g) ?? []).length;
273
+ // Current context size = input + cache_read + cache_create on the LAST
274
+ // usage block. Each assistant message's usage describes the context
275
+ // window for THAT call; summing across calls double-counts cached
276
+ // prefixes and explodes past the real ceiling. Take the most recent.
277
+ const re = /"usage"\s*:\s*\{([^}]+)\}/g;
278
+ const grab = (blob, key) => {
279
+ const km = blob.match(new RegExp(`"${key}"\\s*:\\s*(\\d+)`));
280
+ return km ? Number(km[1]) : 0;
281
+ };
282
+ let lastBlob = null;
283
+ for (const m of text.matchAll(re)) lastBlob = m[1];
284
+ if (lastBlob) {
285
+ breakdown.currentContextTokens =
286
+ grab(lastBlob, "input_tokens") +
287
+ grab(lastBlob, "cache_read_input_tokens") +
288
+ grab(lastBlob, "cache_creation_input_tokens");
289
+ }
290
+ return breakdown;
291
+ } catch { return null; }
292
+ }
293
+
294
+ /** Encode an absolute path the way CC stores it under
295
+ * ~/.claude/projects/<slug>/. Drive letters, colons, and path separators
296
+ * are flattened to "-" so the slug survives as a single directory name. */
297
+ function ccProjectSlug(cwd) {
298
+ if (!cwd) return "";
299
+ // Replace path separators and the Windows drive colon. Match CC's own
300
+ // encoding: every \\ / : → - (no collapsing of adjacent dashes).
301
+ return resolve(cwd).replace(/[\\/:]/g, "-");
302
+ }
303
+
304
+ async function scanClaudeMdFiles(cwd) {
305
+ if (!cwd || typeof cwd !== "string") return [];
306
+ const found = [];
307
+ const seen = new Set();
308
+ const home = homedir();
309
+ const push = async (p) => {
310
+ if (seen.has(p)) return;
311
+ seen.add(p);
312
+ try {
313
+ const s = await stat(p);
314
+ if (s.isFile() && s.size > 0) found.push({ path: p, bytes: s.size });
315
+ } catch {}
316
+ };
317
+ // Walk up from cwd to filesystem root. At each dir, check for the
318
+ // canonical CC memory filenames plus CLAUDE.local.md (user-private).
319
+ let dir = resolve(cwd);
320
+ for (let depth = 0; depth < 16; depth++) {
321
+ for (const rel of [
322
+ "CLAUDE.md",
323
+ "CLAUDE.local.md",
324
+ join(".claude", "CLAUDE.md"),
325
+ join(".claude", "CLAUDE.local.md"),
326
+ ]) {
327
+ await push(join(dir, rel));
328
+ }
329
+ const parent = pdirname(dir);
330
+ if (parent === dir) break;
331
+ dir = parent;
332
+ }
333
+ // User-global memory.
334
+ await push(join(home, ".claude", "CLAUDE.md"));
335
+ await push(join(home, ".claude", "CLAUDE.local.md"));
336
+ // Per-project auto-memory: ~/.claude/projects/<slug>/memory/*.md
337
+ // (plus MEMORY.md index). CC injects these into context for sessions
338
+ // whose cwd matches the slug.
339
+ const slug = ccProjectSlug(cwd);
340
+ if (slug) {
341
+ const memDir = join(home, ".claude", "projects", slug, "memory");
342
+ try {
343
+ const entries = await readdir(memDir);
344
+ for (const f of entries) {
345
+ if (f.toLowerCase().endsWith(".md")) await push(join(memDir, f));
346
+ }
347
+ } catch {}
348
+ }
349
+ return found;
350
+ }
351
+
352
+ function maybeResolveContext(payload) {
353
+ if (!payload || typeof payload !== "object") return;
354
+ const sid = payload.session_id;
355
+ const tp = payload.transcript_path;
356
+ const cwd = payload.cwd;
357
+ if (!sid || !tp) return;
358
+ if (pendingContextReads.has(sid)) return;
359
+ const now = Date.now();
360
+ const last = lastContextReadAt.get(sid) ?? 0;
361
+ if (now - last < CONTEXT_READ_THROTTLE_MS) return;
362
+ lastContextReadAt.set(sid, now);
363
+ pendingContextReads.add(sid);
364
+ Promise.all([readContextFromTranscript(tp), scanClaudeMdFiles(cwd)])
365
+ .then(([breakdown, claudeMdFiles]) => {
366
+ if (!breakdown && (!claudeMdFiles || claudeMdFiles.length === 0)) return;
367
+ pushEvent({
368
+ hook_event_name: "ContextObserved",
369
+ session_id: sid,
370
+ context: {
371
+ ...(breakdown ?? {}),
372
+ claudeMdFiles: claudeMdFiles ?? [],
373
+ },
374
+ }, "internal");
375
+ })
376
+ .catch(() => {})
377
+ .finally(() => pendingContextReads.delete(sid));
378
+ }
379
+
380
+ function pushEvent(raw, source, opts = {}) {
381
+ // Synchronous enrichment: if we already know this session's model, stamp
382
+ // it on the payload so the client's recursive scanner picks it up.
383
+ if (raw && typeof raw === "object" && raw.session_id && !raw.model) {
384
+ const cached = modelBySession.get(raw.session_id);
385
+ if (cached) raw.model = cached;
386
+ }
387
+
388
+ const seq = nextSeq++;
389
+ const evt = {
390
+ seq,
391
+ receivedAt: opts.receivedAt ?? Date.now(),
392
+ source,
393
+ payload: raw,
394
+ };
395
+ events.push(evt);
396
+ if (events.length > MAX_BUFFER) events.splice(0, events.length - MAX_BUFFER);
397
+
398
+ const line = `id: ${seq}\nevent: hook\ndata: ${JSON.stringify(evt)}\n\n`;
399
+ for (const res of sseClients) {
400
+ try { res.write(line); } catch {}
401
+ }
402
+
403
+ if (persistPath && !opts.replay) {
404
+ // Fire-and-forget append. JSONL = newline-delimited JSON.
405
+ appendFile(persistPath, JSON.stringify(evt) + "\n", "utf8").catch(() => {});
406
+ // Cheap throttled check (every 30s) — only rotates if file > 50MB.
407
+ maybeRotatePersistFile();
408
+ }
409
+
410
+ // Kick off async transcript scans. Model arrives as a one-shot
411
+ // ModelObserved; usage is re-read periodically (throttled to 2.5s per
412
+ // session) so the cost columns track running totals as the session
413
+ // progresses. Both result in synthetic events.
414
+ if (source === "hook" && !opts.replay) {
415
+ maybeResolveModel(raw);
416
+ maybeResolveUsage(raw);
417
+ maybeResolveContext(raw);
418
+ }
419
+
420
+ return evt;
421
+ }
422
+
423
+ async function replayLog(filePath) {
424
+ if (!existsSync(filePath)) return 0;
425
+ let count = 0;
426
+ const rl = createInterface({ input: createReadStream(filePath, { encoding: "utf8" }) });
427
+ for await (const line of rl) {
428
+ if (!line) continue;
429
+ try {
430
+ const evt = JSON.parse(line);
431
+ if (evt && typeof evt === "object" && evt.payload) {
432
+ pushEvent(evt.payload, evt.source ?? "replay", { receivedAt: evt.receivedAt, replay: true });
433
+ count++;
434
+ }
435
+ } catch { /* skip corrupt line */ }
436
+ }
437
+ return count;
438
+ }
439
+
440
+ function send(res, status, body, headers = {}) {
441
+ res.writeHead(status, {
442
+ "Content-Type": "application/json; charset=utf-8",
443
+ "Cache-Control": "no-store",
444
+ ...headers,
445
+ });
446
+ res.end(typeof body === "string" ? body : JSON.stringify(body));
447
+ }
448
+
449
+ async function serveStatic(req, res, url) {
450
+ // Strip leading slash, default to index.html
451
+ let rel = url.pathname.replace(/^\/+/, "");
452
+ if (rel === "" || rel.endsWith("/")) rel = `${rel}index.html`;
453
+ const filePath = join(WEB_DIST, rel);
454
+ if (!filePath.startsWith(WEB_DIST)) return send(res, 403, { error: "forbidden" });
455
+
456
+ try {
457
+ const s = await stat(filePath);
458
+ if (s.isDirectory()) return send(res, 404, { error: "not found" });
459
+ const buf = await readFile(filePath);
460
+ res.writeHead(200, {
461
+ "Content-Type": MIME[extname(filePath).toLowerCase()] ?? "application/octet-stream",
462
+ "Cache-Control": "no-cache",
463
+ });
464
+ res.end(buf);
465
+ } catch {
466
+ // SPA fallback to index.html for client-side routes
467
+ try {
468
+ const idx = await readFile(join(WEB_DIST, "index.html"));
469
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
470
+ res.end(idx);
471
+ } catch {
472
+ send(res, 404, { error: "ui not built. run `pnpm build` or `npm run build`." });
473
+ }
474
+ }
475
+ }
476
+
477
+ function handleEventIngest(req, res) {
478
+ let body = "";
479
+ req.setEncoding("utf8");
480
+ req.on("data", c => {
481
+ body += c;
482
+ if (body.length > 5_000_000) {
483
+ req.destroy();
484
+ }
485
+ });
486
+ req.on("end", () => {
487
+ let parsed;
488
+ try { parsed = JSON.parse(body); }
489
+ catch { return send(res, 400, { error: "invalid json" }); }
490
+ const evt = pushEvent(parsed, "hook");
491
+ send(res, 200, { ok: true, seq: evt.seq });
492
+ });
493
+ req.on("error", () => send(res, 400, { error: "bad request" }));
494
+ }
495
+
496
+ function handleSse(req, res) {
497
+ res.writeHead(200, {
498
+ "Content-Type": "text/event-stream",
499
+ "Cache-Control": "no-cache, no-transform",
500
+ "Connection": "keep-alive",
501
+ "X-Accel-Buffering": "no",
502
+ });
503
+ res.write(`retry: 1500\n\n`);
504
+
505
+ // Resume: replay events after Last-Event-ID. Marked with `replay:true`
506
+ // on the envelope so the client can suppress turn-cleanup side effects
507
+ // (exitAt stamping, autofit churn) until the live stream takes over.
508
+ // Without this the reducer's UserPromptSubmit handler treats replayed
509
+ // events as a real new turn — hiding prior-turn subagents using the
510
+ // event's stale receivedAt, which collides with wall-clock visibility
511
+ // gates and yields the "nodes appear then vanish" symptom on refresh.
512
+ const lastId = Number(req.headers["last-event-id"] ?? 0);
513
+ for (const e of events) {
514
+ if (e.seq <= lastId) continue;
515
+ const tagged = { ...e, replay: true };
516
+ res.write(`id: ${e.seq}\nevent: hook\ndata: ${JSON.stringify(tagged)}\n\n`);
517
+ }
518
+ // Sentinel: tells client "ring buffer drained, live stream starts now".
519
+ res.write(`event: replay-end\ndata: {}\n\n`);
520
+
521
+ sseClients.add(res);
522
+ const ping = setInterval(() => {
523
+ try { res.write(`: ping\n\n`); } catch {}
524
+ }, 15000);
525
+
526
+ req.on("close", () => {
527
+ clearInterval(ping);
528
+ sseClients.delete(res);
529
+ });
530
+ }
531
+
532
+ function handleHealth(_req, res) {
533
+ send(res, 200, {
534
+ ok: true,
535
+ name: "agent-dag",
536
+ seq: nextSeq - 1,
537
+ clients: sseClients.size,
538
+ uptimeMs: Math.round(process.uptime() * 1000),
539
+ });
540
+ }
541
+
542
+ function isProcessAlive(pid) {
543
+ try { process.kill(pid, 0); return true; }
544
+ catch (e) { return e && e.code === "EPERM"; }
545
+ }
546
+
547
+ async function sweepStaleDiscovery() {
548
+ const dir = join(homedir(), ".claude", "agent-dag");
549
+ let files;
550
+ try { files = await readdir(dir); } catch { return 0; }
551
+ let removed = 0;
552
+ for (const f of files) {
553
+ if (!f.endsWith(".json")) continue;
554
+ const p = join(dir, f);
555
+ try {
556
+ const d = JSON.parse(await readFile(p, "utf8"));
557
+ if (d && typeof d.pid === "number" && !isProcessAlive(d.pid)) {
558
+ await unlink(p).catch(() => {});
559
+ removed++;
560
+ }
561
+ } catch { /* corrupt — leave it */ }
562
+ }
563
+ return removed;
564
+ }
565
+
566
+ function randomPort(min, max) {
567
+ return Math.floor(Math.random() * (max - min + 1)) + min;
568
+ }
569
+
570
+ async function tryListen(server, port, host) {
571
+ return new Promise((res, rej) => {
572
+ server.once("error", rej);
573
+ server.listen(port, host, () => {
574
+ server.removeListener("error", rej);
575
+ res();
576
+ });
577
+ });
578
+ }
579
+
580
+ export async function startServer({ port = 4317, host = "127.0.0.1", persist = null, portRange = [4318, 4400] } = {}) {
581
+ const removed = await sweepStaleDiscovery();
582
+ if (removed > 0) console.log(` swept ${removed} stale discovery file(s)`);
583
+ if (persist) {
584
+ persistPath = resolve(persist);
585
+ try { await mkdir(pdirname(persistPath), { recursive: true }); } catch {}
586
+ const replayed = await replayLog(persistPath);
587
+ if (replayed > 0) {
588
+ // Don't broadcast replays as live; SSE clients catch up via Last-Event-ID
589
+ // already. Just keep the buffer + seq counter primed.
590
+ }
591
+ }
592
+ const server = createServer((req, res) => {
593
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? host}`);
594
+
595
+ if (req.method === "POST" && url.pathname === "/api/event") return handleEventIngest(req, res);
596
+ if (req.method === "GET" && url.pathname === "/api/health") return handleHealth(req, res);
597
+ if (req.method === "GET" && url.pathname === "/events") return handleSse(req, res);
598
+
599
+ if (req.method === "GET" && url.pathname === "/api/events") {
600
+ const since = Number(url.searchParams.get("since") ?? 0);
601
+ return send(res, 200, events.filter(e => e.seq > since));
602
+ }
603
+
604
+ // POST /api/clear — wipe in-memory buffer + persistence file (UI reset)
605
+ if (req.method === "POST" && url.pathname === "/api/clear") {
606
+ events.length = 0;
607
+ if (persistPath) truncate(persistPath, 0).catch(() => {});
608
+ pushEvent({ hook_event_name: "__clear", cwd: "" }, "internal");
609
+ return send(res, 200, { ok: true });
610
+ }
611
+
612
+ if (req.method === "GET") return serveStatic(req, res, url);
613
+ send(res, 405, { error: "method not allowed" });
614
+ });
615
+
616
+ // Try requested port first, then up to 10 random ports from portRange.
617
+ const candidates = [port];
618
+ for (let i = 0; i < 10; i++) candidates.push(randomPort(portRange[0], portRange[1]));
619
+
620
+ for (const candidate of candidates) {
621
+ try {
622
+ await tryListen(server, candidate, host);
623
+ return server;
624
+ } catch (err) {
625
+ if (err && err.code === "EADDRINUSE") continue;
626
+ throw err;
627
+ }
628
+ }
629
+ throw Object.assign(new Error(`all ports tried — none available`), { code: "EADDRINUSE" });
630
+ }
631
+
632
+ // Allow running this file directly for dev.
633
+ if (import.meta.url === pathToFileURL(process.argv[1]).href) {
634
+ const port = Number(process.env.CCGRAPH_PORT ?? 4317);
635
+ startServer({ port }).then(s => {
636
+ const addr = s.address();
637
+ const p = typeof addr === "object" && addr ? addr.port : port;
638
+ console.log(`agent-dag server: http://127.0.0.1:${p}`);
639
+ }).catch(e => {
640
+ console.error("agent-dag server failed:", e.message);
641
+ process.exit(1);
642
+ });
643
+ }