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.
- package/LICENSE +21 -21
- package/README.md +52 -52
- package/bin/agent-dag.js +199 -199
- package/dist/web/assets/index-BPyjn8Kd.js +62 -0
- package/dist/web/index.html +13 -13
- package/hook/hook.js +96 -96
- package/package.json +68 -66
- package/src/server/index.mjs +643 -627
- package/src/server/installer.mjs +137 -137
- package/dist/web/assets/index-DZeh3MHD.js +0 -62
package/src/server/index.mjs
CHANGED
|
@@ -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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
if (
|
|
136
|
-
const
|
|
137
|
-
const
|
|
138
|
-
if (
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
}
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
const
|
|
195
|
-
const
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
};
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
if (
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
const
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
if (!cwd
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
if (
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
}
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
req.
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
});
|
|
486
|
-
req.on("
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
}
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
}
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
}
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
if (req.method === "GET") return
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
}
|
|
615
|
-
|
|
616
|
-
//
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
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
|
+
}
|