agents-deck 1.18.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1109 @@
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, sep } 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(`agents-deck: rotated ${persistPath} (${(s.size / 1024 / 1024).toFixed(0)}MB → ${oldPath})`);
61
+ } catch (err) {
62
+ console.error("agents-deck: 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
+ /** Read the main session JSONL. Returns the root model and any
82
+ * legacy-schema subagent models (older CC versions kept subagent blocks
83
+ * inline with `isSidechain:true` + `parentToolUseID`). Current CC versions
84
+ * store subagents in `<sessionDir>/subagents/agent-<id>.jsonl` — those are
85
+ * handled by `readSubagentModelsFromDir` below. */
86
+ async function readModelFromTranscript(path) {
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;
102
+ for (const line of text.split("\n")) {
103
+ if (!line) continue;
104
+ let obj;
105
+ try { obj = JSON.parse(line); } catch { continue; }
106
+ const msg = obj && obj.message;
107
+ const model = (msg && typeof msg.model === "string" && /^claude[-_]/i.test(msg.model)) ? msg.model
108
+ : (typeof obj.model === "string" && /^claude[-_]/i.test(obj.model)) ? obj.model
109
+ : null;
110
+ if (!model) continue;
111
+ anyModelSeen = model;
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) rootModel = anyModelSeen;
121
+ if (!rootModel && Object.keys(subagentModels).length === 0) return null;
122
+ return { rootModel, subagentModels };
123
+ } catch {
124
+ return null;
125
+ }
126
+ }
127
+
128
+ /** Newer CC schema (~2026-06): each subagent turn writes its OWN file at
129
+ * `<projects>/<slug>/<sessionId>/subagents/agent-<agentId>.jsonl` with a
130
+ * sidecar `.meta.json` carrying `{agentType, description}`. The hook
131
+ * payload's `agent_id` matches the file's <agentId>, so the reducer can
132
+ * attribute via the existing `subagentModels` map (it keys by parentToolUseId
133
+ * but the reducer looks up `${sessionId}::${key}` and the subagent node id
134
+ * is built from `agent_id` — identical lookup either way).
135
+ *
136
+ * Returns { [agentId]: model } scanning every agent-*.jsonl file in dir. */
137
+ async function readSubagentModelsFromDir(transcriptPath) {
138
+ // Subagent dir sits next to the main jsonl: <dir>/<sessionId>/subagents/
139
+ // Derive from transcript_path by stripping the .jsonl suffix.
140
+ if (!transcriptPath || typeof transcriptPath !== "string") return null;
141
+ const sessionDir = transcriptPath.replace(/\.jsonl$/i, "");
142
+ const subDir = join(sessionDir, "subagents");
143
+ let entries;
144
+ try { entries = await readdir(subDir); } catch { return null; }
145
+ const models = {};
146
+ for (const f of entries) {
147
+ if (!/^agent-([0-9a-f]+)\.jsonl$/i.test(f)) continue;
148
+ const agentId = f.replace(/^agent-/, "").replace(/\.jsonl$/i, "");
149
+ const full = join(subDir, f);
150
+ try {
151
+ const s = await stat(full);
152
+ if (s.size === 0) continue;
153
+ const fh = await open(full, "r");
154
+ let text;
155
+ try {
156
+ const buf = Buffer.alloc(s.size);
157
+ await fh.read(buf, 0, s.size, 0);
158
+ text = buf.toString("utf8");
159
+ } finally {
160
+ await fh.close();
161
+ }
162
+ // Last-seen claude-* model wins — subagents may switch model mid-turn
163
+ // (Sonnet → Haiku for tool-call fallback etc.).
164
+ let last = null;
165
+ for (const line of text.split("\n")) {
166
+ if (!line) continue;
167
+ let obj;
168
+ try { obj = JSON.parse(line); } catch { continue; }
169
+ const msg = obj && obj.message;
170
+ const m = (msg && typeof msg.model === "string" && /^claude[-_]/i.test(msg.model)) ? msg.model
171
+ : (typeof obj.model === "string" && /^claude[-_]/i.test(obj.model)) ? obj.model
172
+ : null;
173
+ if (m) last = m;
174
+ }
175
+ if (last) models[agentId] = last;
176
+ } catch { /* skip unreadable file */ }
177
+ }
178
+ return Object.keys(models).length ? models : null;
179
+ }
180
+
181
+ function maybeResolveModel(payload) {
182
+ if (!payload || typeof payload !== "object") return;
183
+ const sid = payload.session_id;
184
+ const tp = payload.transcript_path;
185
+ if (!sid || !tp) return;
186
+ // Re-read on every event for this session — the cache was preventing us
187
+ // from picking up subagent models that arrive after the root is known.
188
+ // Throttle so we don't thrash the filesystem.
189
+ if (pendingTranscriptReads.has(sid)) return;
190
+ const now = Date.now();
191
+ const last = modelLastReadAt.get(sid) ?? 0;
192
+ if (now - last < MODEL_READ_THROTTLE_MS) return;
193
+ modelLastReadAt.set(sid, now);
194
+ pendingTranscriptReads.add(sid);
195
+ Promise.all([readModelFromTranscript(tp), readSubagentModelsFromDir(tp)])
196
+ .then(([result, dirSubs]) => {
197
+ const rootModel = result?.rootModel ?? null;
198
+ // Merge legacy (inline isSidechain) + new (subagents/ dir) maps. Dir
199
+ // wins on conflict since current CC only writes to the dir.
200
+ const subagentModels = { ...(result?.subagentModels ?? {}), ...(dirSubs ?? {}) };
201
+ if (!rootModel && Object.keys(subagentModels).length === 0) return;
202
+ const prev = modelBySession.get(sid);
203
+ const subsSig = JSON.stringify(subagentModels);
204
+ if (prev && prev.rootModel === rootModel && prev.subsSig === subsSig) return;
205
+ modelBySession.set(sid, { rootModel, subsSig });
206
+ pushEvent({
207
+ hook_event_name: "ModelObserved",
208
+ session_id: sid,
209
+ model: rootModel,
210
+ subagentModels,
211
+ }, "internal");
212
+ })
213
+ .catch(() => {})
214
+ .finally(() => pendingTranscriptReads.delete(sid));
215
+ }
216
+
217
+ // ─── Usage enrichment ────────────────────────────────────────────────────
218
+ // Same story as the model: token counts (input/output/cache) are missing
219
+ // from every CC hook payload but present on every assistant message in
220
+ // the transcript JSONL as a `"usage":{…}` block. We sum them across the
221
+ // whole transcript and ship a synthetic UsageObserved event so the
222
+ // session's root agent gets accurate cumulative usage (and therefore the
223
+ // cost columns actually have something to multiply by).
224
+ const lastUsageReadAt = new Map(); // sid -> ms timestamp
225
+ const pendingUsageReads = new Set(); // sid currently being read
226
+ const USAGE_READ_THROTTLE_MS = 2500;
227
+
228
+ async function readUsageFromTranscript(path) {
229
+ try {
230
+ const s = await stat(path);
231
+ if (s.size === 0) return null;
232
+ // Transcripts can grow large (thinking blocks, tool inputs) — read the
233
+ // whole file. Each entry has its own usage object and we sum every
234
+ // occurrence, so missing earlier bytes would undercount. Files are
235
+ // usually < 1MB; tens-of-MB sessions cost a few ms to scan.
236
+ const fh = await open(path, "r");
237
+ let buf;
238
+ try {
239
+ buf = Buffer.alloc(s.size);
240
+ await fh.read(buf, 0, s.size, 0);
241
+ } finally {
242
+ await fh.close();
243
+ }
244
+ const text = buf.toString("utf8");
245
+ const totals = {
246
+ input_tokens: 0, output_tokens: 0,
247
+ cache_read_input_tokens: 0, cache_creation_input_tokens: 0,
248
+ };
249
+ // Match each `"usage":{...}` block and sum the four numeric fields.
250
+ // Regex is good enough — these blocks are flat single-level JSON.
251
+ const re = /"usage"\s*:\s*\{([^}]+)\}/g;
252
+ const grab = (blob, key) => {
253
+ const km = blob.match(new RegExp(`"${key}"\\s*:\\s*(\\d+)`));
254
+ return km ? Number(km[1]) : 0;
255
+ };
256
+ for (const m of text.matchAll(re)) {
257
+ const blob = m[1];
258
+ totals.input_tokens += grab(blob, "input_tokens");
259
+ totals.output_tokens += grab(blob, "output_tokens");
260
+ totals.cache_read_input_tokens += grab(blob, "cache_read_input_tokens");
261
+ totals.cache_creation_input_tokens += grab(blob, "cache_creation_input_tokens");
262
+ }
263
+ if (totals.input_tokens === 0 && totals.output_tokens === 0
264
+ && totals.cache_read_input_tokens === 0 && totals.cache_creation_input_tokens === 0) return null;
265
+ return totals;
266
+ } catch {
267
+ return null;
268
+ }
269
+ }
270
+
271
+ function maybeResolveUsage(payload) {
272
+ if (!payload || typeof payload !== "object") return;
273
+ const sid = payload.session_id;
274
+ const tp = payload.transcript_path;
275
+ if (!sid || !tp) return;
276
+ if (pendingUsageReads.has(sid)) return;
277
+ const now = Date.now();
278
+ const last = lastUsageReadAt.get(sid) ?? 0;
279
+ if (now - last < USAGE_READ_THROTTLE_MS) return;
280
+ lastUsageReadAt.set(sid, now);
281
+ pendingUsageReads.add(sid);
282
+ readUsageFromTranscript(tp)
283
+ .then(usage => {
284
+ if (!usage) return;
285
+ pushEvent({ hook_event_name: "UsageObserved", session_id: sid, usage }, "internal");
286
+ })
287
+ .catch(() => {})
288
+ .finally(() => pendingUsageReads.delete(sid));
289
+ }
290
+
291
+ // ─── Context enrichment ──────────────────────────────────────────────────
292
+ // Approximation of `/context` since CC doesn't expose its breakdown via
293
+ // hooks. We scan the transcript JSONL for message counts (user / assistant
294
+ // / tool_use / tool_result / system-reminders) and walk up from cwd for
295
+ // any CLAUDE.md files in scope. Token totals come from UsageObserved; this
296
+ // scan is purely structural ("what does the context contain").
297
+ const lastContextReadAt = new Map();
298
+ const pendingContextReads = new Set();
299
+ const CONTEXT_READ_THROTTLE_MS = 4000;
300
+
301
+ async function readContextFromTranscript(path) {
302
+ try {
303
+ const s = await stat(path);
304
+ if (s.size === 0) return null;
305
+ const fh = await open(path, "r");
306
+ let buf;
307
+ try { buf = Buffer.alloc(s.size); await fh.read(buf, 0, s.size, 0); }
308
+ finally { await fh.close(); }
309
+ const fullText = buf.toString("utf8");
310
+ // CC `/clear` writes a user message `<command-name>/clear</command-name>`
311
+ // into the same transcript file and resets its in-memory context window
312
+ // to ~0, but the JSONL keeps growing — every usage block before the
313
+ // clear marker is stale (pre-reset) and reading the LAST one made the
314
+ // donut report ~100% even though CC's actual context was empty. Same
315
+ // applies to `/compact`: it writes a summary and starts a fresh context.
316
+ // Slice the transcript to the segment AFTER the most recent reset so
317
+ // counts/usage reflect what CC is actually carrying forward.
318
+ const resetRe = /<command-name>\s*\/(?:clear|compact)\s*<\/command-name>/g;
319
+ let lastResetIdx = -1;
320
+ for (const m of fullText.matchAll(resetRe)) {
321
+ lastResetIdx = (m.index ?? -1) + m[0].length;
322
+ }
323
+ const text = lastResetIdx >= 0 ? fullText.slice(lastResetIdx) : fullText;
324
+ const breakdown = {
325
+ msgsUser: 0,
326
+ msgsAssistant: 0,
327
+ toolUses: 0,
328
+ toolResults: 0,
329
+ systemReminders: 0,
330
+ currentContextTokens: 0,
331
+ };
332
+ breakdown.msgsUser = (text.match(/"type"\s*:\s*"user"/g) ?? []).length;
333
+ breakdown.msgsAssistant = (text.match(/"type"\s*:\s*"assistant"/g) ?? []).length;
334
+ breakdown.toolUses = (text.match(/"type"\s*:\s*"tool_use"/g) ?? []).length;
335
+ breakdown.toolResults = (text.match(/"type"\s*:\s*"tool_result"/g) ?? []).length;
336
+ breakdown.systemReminders = (text.match(/<system-reminder>/g) ?? []).length;
337
+ // Current context size = input + cache_read + cache_create on the LAST
338
+ // usage block in the post-reset slice. If the user just ran /clear and
339
+ // hasn't sent a new prompt yet, this stays 0 (no usage blocks yet) —
340
+ // matches what CC's own `/context` would report.
341
+ const re = /"usage"\s*:\s*\{([^}]+)\}/g;
342
+ const grab = (blob, key) => {
343
+ const km = blob.match(new RegExp(`"${key}"\\s*:\\s*(\\d+)`));
344
+ return km ? Number(km[1]) : 0;
345
+ };
346
+ let lastBlob = null;
347
+ for (const m of text.matchAll(re)) lastBlob = m[1];
348
+ if (lastBlob) {
349
+ breakdown.currentContextTokens =
350
+ grab(lastBlob, "input_tokens") +
351
+ grab(lastBlob, "cache_read_input_tokens") +
352
+ grab(lastBlob, "cache_creation_input_tokens");
353
+ }
354
+ return breakdown;
355
+ } catch { return null; }
356
+ }
357
+
358
+ /** Encode an absolute path the way CC stores it under
359
+ * ~/.claude/projects/<slug>/. Drive letters, colons, and path separators
360
+ * are flattened to "-" so the slug survives as a single directory name. */
361
+ function ccProjectSlug(cwd) {
362
+ if (!cwd) return "";
363
+ // Replace path separators and the Windows drive colon. Match CC's own
364
+ // encoding: every \\ / : → - (no collapsing of adjacent dashes).
365
+ return resolve(cwd).replace(/[\\/:]/g, "-");
366
+ }
367
+
368
+ async function scanClaudeMdFiles(cwd) {
369
+ if (!cwd || typeof cwd !== "string") return [];
370
+ const found = [];
371
+ const seen = new Set();
372
+ const home = homedir();
373
+ const push = async (p) => {
374
+ if (seen.has(p)) return;
375
+ seen.add(p);
376
+ try {
377
+ const s = await stat(p);
378
+ if (s.isFile() && s.size > 0) found.push({ path: p, bytes: s.size });
379
+ } catch {}
380
+ };
381
+ // Walk up from cwd to filesystem root. At each dir, check for the
382
+ // canonical CC memory filenames plus CLAUDE.local.md (user-private).
383
+ let dir = resolve(cwd);
384
+ for (let depth = 0; depth < 16; depth++) {
385
+ for (const rel of [
386
+ "CLAUDE.md",
387
+ "CLAUDE.local.md",
388
+ join(".claude", "CLAUDE.md"),
389
+ join(".claude", "CLAUDE.local.md"),
390
+ ]) {
391
+ await push(join(dir, rel));
392
+ }
393
+ const parent = pdirname(dir);
394
+ if (parent === dir) break;
395
+ dir = parent;
396
+ }
397
+ // User-global memory.
398
+ await push(join(home, ".claude", "CLAUDE.md"));
399
+ await push(join(home, ".claude", "CLAUDE.local.md"));
400
+ // Per-project auto-memory: ~/.claude/projects/<slug>/memory/*.md
401
+ // (plus MEMORY.md index). CC injects these into context for sessions
402
+ // whose cwd matches the slug.
403
+ const slug = ccProjectSlug(cwd);
404
+ if (slug) {
405
+ const memDir = join(home, ".claude", "projects", slug, "memory");
406
+ try {
407
+ const entries = await readdir(memDir);
408
+ for (const f of entries) {
409
+ if (f.toLowerCase().endsWith(".md")) await push(join(memDir, f));
410
+ }
411
+ } catch {}
412
+ }
413
+ return found;
414
+ }
415
+
416
+ function maybeResolveContext(payload) {
417
+ if (!payload || typeof payload !== "object") return;
418
+ const sid = payload.session_id;
419
+ const tp = payload.transcript_path;
420
+ const cwd = payload.cwd;
421
+ if (!sid || !tp) return;
422
+ if (pendingContextReads.has(sid)) return;
423
+ const now = Date.now();
424
+ const last = lastContextReadAt.get(sid) ?? 0;
425
+ if (now - last < CONTEXT_READ_THROTTLE_MS) return;
426
+ lastContextReadAt.set(sid, now);
427
+ pendingContextReads.add(sid);
428
+ Promise.all([readContextFromTranscript(tp), scanClaudeMdFiles(cwd)])
429
+ .then(([breakdown, claudeMdFiles]) => {
430
+ if (!breakdown && (!claudeMdFiles || claudeMdFiles.length === 0)) return;
431
+ pushEvent({
432
+ hook_event_name: "ContextObserved",
433
+ session_id: sid,
434
+ context: {
435
+ ...(breakdown ?? {}),
436
+ claudeMdFiles: claudeMdFiles ?? [],
437
+ },
438
+ }, "internal");
439
+ })
440
+ .catch(() => {})
441
+ .finally(() => pendingContextReads.delete(sid));
442
+ }
443
+
444
+ // ─── Codex transcript enrichment ──────────────────────────────────────────
445
+ // Codex CLI hook payloads carry `session_id` but no transcript path. Sessions
446
+ // are persisted to ~/.codex/sessions/YYYY/MM/DD/rollout-<sid>.jsonl with one
447
+ // JSON object per line: {type, payload}. Token usage shows up in
448
+ // {type:"event_msg", payload:{type:"token_count",
449
+ // info:{total_token_usage:{input_tokens, cached_input_tokens,
450
+ // output_tokens, reasoning_output_tokens,
451
+ // total_tokens}}}}
452
+ // We resolve the rollout path lazily (cache sid→path), then read the tail
453
+ // for usage + model. CODEX_HOME overrides ~/.codex.
454
+ const CODEX_HOME = process.env.CODEX_HOME
455
+ ? resolve(process.env.CODEX_HOME)
456
+ : join(homedir(), ".codex");
457
+ const CODEX_SESSIONS_DIR = join(CODEX_HOME, "sessions");
458
+ const codexRolloutPathBySid = new Map();
459
+ const lastCodexUsageReadAt = new Map();
460
+ const pendingCodexUsageReads = new Set();
461
+ const CODEX_READ_THROTTLE_MS = 2500;
462
+
463
+ async function findCodexRolloutPath(sid) {
464
+ const cached = codexRolloutPathBySid.get(sid);
465
+ if (cached) return cached;
466
+ // Walk year → month → day → files. Codex includes the sid in the filename
467
+ // (rollout-...-<sid>.jsonl) so a directory-scoped match is enough.
468
+ const tryYears = async () => {
469
+ try { return await readdir(CODEX_SESSIONS_DIR); } catch { return []; }
470
+ };
471
+ const years = (await tryYears()).sort().reverse(); // newest first
472
+ for (const y of years) {
473
+ let months;
474
+ try { months = (await readdir(join(CODEX_SESSIONS_DIR, y))).sort().reverse(); }
475
+ catch { continue; }
476
+ for (const m of months) {
477
+ let days;
478
+ try { days = (await readdir(join(CODEX_SESSIONS_DIR, y, m))).sort().reverse(); }
479
+ catch { continue; }
480
+ for (const d of days) {
481
+ const dayDir = join(CODEX_SESSIONS_DIR, y, m, d);
482
+ let files;
483
+ try { files = await readdir(dayDir); } catch { continue; }
484
+ const hit = files.find(f => f.includes(sid) && f.endsWith(".jsonl"));
485
+ if (hit) {
486
+ const full = join(dayDir, hit);
487
+ codexRolloutPathBySid.set(sid, full);
488
+ return full;
489
+ }
490
+ }
491
+ }
492
+ }
493
+ return null;
494
+ }
495
+
496
+ /** Tail-read a Codex rollout JSONL. Returns the last token_count info block
497
+ * plus the most recent observed model + the session's model_context_window
498
+ * (set once from task_started). */
499
+ async function readCodexRollout(path) {
500
+ try {
501
+ const s = await stat(path);
502
+ if (s.size === 0) return null;
503
+ const fh = await open(path, "r");
504
+ let text;
505
+ try {
506
+ const buf = Buffer.alloc(s.size);
507
+ await fh.read(buf, 0, s.size, 0);
508
+ text = buf.toString("utf8");
509
+ } finally {
510
+ await fh.close();
511
+ }
512
+ let lastUsage = null;
513
+ let model = null;
514
+ let contextWindow = null;
515
+ let cwd = null;
516
+ for (const line of text.split("\n")) {
517
+ if (!line) continue;
518
+ let obj;
519
+ try { obj = JSON.parse(line); } catch { continue; }
520
+ const type = obj && obj.type;
521
+ const pl = obj && obj.payload;
522
+ if (type === "session_meta" && pl) {
523
+ if (typeof pl.cwd === "string") cwd = pl.cwd;
524
+ // session_meta sometimes carries the model in newer Codex versions.
525
+ if (typeof pl.model === "string") model = pl.model;
526
+ } else if (type === "event_msg" && pl) {
527
+ if (pl.type === "token_count" && pl.info && pl.info.total_token_usage) {
528
+ lastUsage = pl.info.total_token_usage;
529
+ } else if (pl.type === "task_started" && typeof pl.model_context_window === "number") {
530
+ contextWindow = pl.model_context_window;
531
+ }
532
+ } else if (type === "response_item" && pl && typeof pl.model === "string") {
533
+ // Fallback model source — response items carry the model id.
534
+ model = pl.model;
535
+ }
536
+ }
537
+ if (!lastUsage && !model && !contextWindow) return null;
538
+ return { usage: lastUsage, model, contextWindow, cwd };
539
+ } catch {
540
+ return null;
541
+ }
542
+ }
543
+
544
+ function maybeResolveCodex(payload) {
545
+ if (!payload || typeof payload !== "object") return;
546
+ if (payload.provider !== "codex") return;
547
+ const sid = payload.session_id;
548
+ if (!sid) return;
549
+ if (pendingCodexUsageReads.has(sid)) return;
550
+ const now = Date.now();
551
+ const last = lastCodexUsageReadAt.get(sid) ?? 0;
552
+ if (now - last < CODEX_READ_THROTTLE_MS) return;
553
+ lastCodexUsageReadAt.set(sid, now);
554
+ pendingCodexUsageReads.add(sid);
555
+ (async () => {
556
+ const path = await findCodexRolloutPath(sid);
557
+ if (!path) return;
558
+ const r = await readCodexRollout(path);
559
+ if (!r) return;
560
+ if (r.usage) {
561
+ pushEvent({
562
+ hook_event_name: "UsageObserved",
563
+ session_id: sid,
564
+ usage: r.usage,
565
+ }, "internal");
566
+ }
567
+ if (r.model) {
568
+ pushEvent({
569
+ hook_event_name: "ModelObserved",
570
+ session_id: sid,
571
+ model: r.model,
572
+ }, "internal");
573
+ }
574
+ if (r.contextWindow) {
575
+ // Piggy-back on the model event with the window — reducer reads
576
+ // model_context_window directly off any payload.
577
+ pushEvent({
578
+ hook_event_name: "ModelObserved",
579
+ session_id: sid,
580
+ model: r.model ?? undefined,
581
+ model_context_window: r.contextWindow,
582
+ }, "internal");
583
+ }
584
+ })()
585
+ .catch(() => {})
586
+ .finally(() => pendingCodexUsageReads.delete(sid));
587
+ }
588
+
589
+ // ─── Codex rollout watcher ────────────────────────────────────────────────
590
+ // Codex CLI hooks never fire on Windows — the elevated/unelevated sandbox
591
+ // refuses to spawn the hook command (exit 1, child never runs). So instead of
592
+ // relying on hooks, we tail the rollout JSONL files Codex writes to
593
+ // ~/.codex/sessions/YYYY/MM/DD/rollout-<ts>-<sid>.jsonl and reconstruct the
594
+ // agent-dag event stream from them. Each rollout line is one append-only JSON
595
+ // object {timestamp, type, payload}; we map the relevant ones to the same
596
+ // synthetic hook payloads the reducer already understands:
597
+ // session_meta → SessionStart
598
+ // event_msg/user_message → UserPromptSubmit
599
+ // response_item/function_call → PreToolUse
600
+ // response_item/function_call_output → PostToolUse
601
+ // event_msg/token_count → UsageObserved
602
+ // event_msg/task_started (+window) → ModelObserved (context window)
603
+ // turn_context / response_item.model → model snapshot (ModelObserved on change)
604
+ // Events are emitted with source "codex" so pushEvent skips the Claude-only
605
+ // transcript enrichment (which needs transcript_path / hook events) but still
606
+ // persists + broadcasts them exactly like a hook event. This path is entirely
607
+ // additive — the Claude hook flow is untouched.
608
+ const codexFileState = new Map(); // path -> { offset, sid, cwd, skip }
609
+ const codexSessionModel = new Map(); // sid -> last model string
610
+ let codexScanRunning = false;
611
+ let codexWatchTimer = null;
612
+ let codexWorkspace = "";
613
+
614
+ function codexCwdInWorkspace(cwd) {
615
+ if (!codexWorkspace) return true;
616
+ if (!cwd || typeof cwd !== "string") return false;
617
+ const a = resolve(cwd).toLowerCase();
618
+ const b = resolve(codexWorkspace).toLowerCase();
619
+ return a === b || a.startsWith(b + sep.toLowerCase());
620
+ }
621
+
622
+ // List rollout files from the newest 2 day-directories. New sessions always
623
+ // land in today's dir, so this captures live activity without scanning years
624
+ // of history every tick.
625
+ async function listRecentCodexRollouts() {
626
+ const out = [];
627
+ let years;
628
+ try { years = (await readdir(CODEX_SESSIONS_DIR)).filter(d => /^\d{4}$/.test(d)).sort().reverse(); }
629
+ catch { return out; }
630
+ let dayDirs = 0;
631
+ for (const y of years) {
632
+ let months;
633
+ try { months = (await readdir(join(CODEX_SESSIONS_DIR, y))).sort().reverse(); } catch { continue; }
634
+ for (const m of months) {
635
+ let days;
636
+ try { days = (await readdir(join(CODEX_SESSIONS_DIR, y, m))).sort().reverse(); } catch { continue; }
637
+ for (const d of days) {
638
+ const dir = join(CODEX_SESSIONS_DIR, y, m, d);
639
+ let files;
640
+ try { files = await readdir(dir); } catch { continue; }
641
+ for (const f of files) if (f.endsWith(".jsonl")) out.push(join(dir, f));
642
+ if (++dayDirs >= 2) return out;
643
+ }
644
+ }
645
+ }
646
+ return out;
647
+ }
648
+
649
+ async function readByteRange(path, from, to) {
650
+ const fh = await open(path, "r");
651
+ try {
652
+ const len = to - from;
653
+ if (len <= 0) return "";
654
+ const buf = Buffer.alloc(len);
655
+ await fh.read(buf, 0, len, from);
656
+ return buf.toString("utf8");
657
+ } finally {
658
+ await fh.close();
659
+ }
660
+ }
661
+
662
+ // Read the first complete JSON line of a rollout (the session_meta header)
663
+ // to learn sid + cwd before we start streaming. The header line can be large
664
+ // (base_instructions text runs tens of KB), so we read in growing chunks until
665
+ // we hit the first newline rather than guessing a fixed window.
666
+ async function readCodexHeader(path) {
667
+ try {
668
+ const size = (await stat(path)).size;
669
+ if (size === 0) return null;
670
+ const CHUNK = 65536;
671
+ let upto = Math.min(CHUNK, size);
672
+ let text = "";
673
+ for (;;) {
674
+ text = await readByteRange(path, 0, upto);
675
+ const nl = text.indexOf("\n");
676
+ if (nl >= 0) {
677
+ const obj = JSON.parse(text.slice(0, nl));
678
+ if (obj && obj.type === "session_meta" && obj.payload) {
679
+ return { sid: obj.payload.id, cwd: typeof obj.payload.cwd === "string" ? obj.payload.cwd : null };
680
+ }
681
+ return null;
682
+ }
683
+ if (upto >= size) return null; // no newline in the whole file yet
684
+ upto = Math.min(upto + CHUNK, size); // grow and retry
685
+ if (upto > 4 * 1024 * 1024) return null; // 4MB sanity cap on a single line
686
+ }
687
+ } catch {}
688
+ return null;
689
+ }
690
+
691
+ // Map one parsed rollout object to a synthetic hook payload (or null to skip).
692
+ // Mutates codexSessionModel and returns { payload, modelEvent } where
693
+ // modelEvent is an optional ModelObserved to emit first when the model changed.
694
+ function codexObjToPayload(obj, sid, cwd) {
695
+ const type = obj && obj.type;
696
+ const pl = (obj && obj.payload) || {};
697
+ const base = { session_id: sid, cwd, provider: "codex" };
698
+ const model = codexSessionModel.get(sid);
699
+
700
+ // Track model from turn_context / response_item before mapping events.
701
+ if (type === "turn_context" && typeof pl.model === "string") {
702
+ codexSessionModel.set(sid, pl.model);
703
+ return null;
704
+ }
705
+ if (type === "response_item" && typeof pl.model === "string") {
706
+ codexSessionModel.set(sid, pl.model);
707
+ }
708
+
709
+ if (type === "event_msg") {
710
+ if (pl.type === "user_message") {
711
+ const prompt = typeof pl.message === "string" ? pl.message : "";
712
+ return { ...base, hook_event_name: "UserPromptSubmit", prompt, model };
713
+ }
714
+ if (pl.type === "token_count" && pl.info && pl.info.total_token_usage) {
715
+ return { ...base, hook_event_name: "UsageObserved", usage: pl.info.total_token_usage, model };
716
+ }
717
+ if (pl.type === "task_started" && typeof pl.model_context_window === "number") {
718
+ return { ...base, hook_event_name: "ModelObserved", model, model_context_window: pl.model_context_window };
719
+ }
720
+ return null;
721
+ }
722
+ if (type === "response_item") {
723
+ if (pl.type === "function_call") {
724
+ let input = pl.arguments;
725
+ try { input = JSON.parse(pl.arguments); } catch {}
726
+ return { ...base, hook_event_name: "PreToolUse", tool_name: pl.name ?? "tool", tool_input: input, tool_use_id: pl.call_id, model };
727
+ }
728
+ if (pl.type === "custom_tool_call") {
729
+ return { ...base, hook_event_name: "PreToolUse", tool_name: pl.name ?? "tool", tool_input: { patch: pl.input }, tool_use_id: pl.call_id, model };
730
+ }
731
+ if (pl.type === "function_call_output" || pl.type === "custom_tool_call_output") {
732
+ const tool_response = pl.output != null ? parseCodexOutput(pl.output) : undefined;
733
+ return { ...base, hook_event_name: "PostToolUse", tool_use_id: pl.call_id, tool_response, model };
734
+ }
735
+ }
736
+ return null;
737
+ }
738
+
739
+ function parseCodexOutput(raw) {
740
+ if (typeof raw !== "string") return raw;
741
+ try {
742
+ const o = JSON.parse(raw);
743
+ return (o && typeof o.output === "string") ? o.output : raw;
744
+ } catch {
745
+ return raw;
746
+ }
747
+ }
748
+
749
+ function emitCodexEvent(payload) {
750
+ pushEvent(payload, "codex");
751
+ }
752
+
753
+ // Emit the SessionStart root exactly once per file, lazily — only when the
754
+ // session actually produces an event. This keeps long-dead sessions that were
755
+ // merely on disk at startup from cluttering the canvas with empty roots.
756
+ function ensureCodexRoot(state) {
757
+ if (state.rootEmitted) return;
758
+ state.rootEmitted = true;
759
+ emitCodexEvent({ session_id: state.sid, cwd: state.cwd, provider: "codex", hook_event_name: "SessionStart" });
760
+ }
761
+
762
+ async function codexScanOnce(firstRun) {
763
+ if (codexScanRunning) return;
764
+ codexScanRunning = true;
765
+ try {
766
+ const files = await listRecentCodexRollouts();
767
+ for (const path of files) {
768
+ let st;
769
+ try { st = await stat(path); } catch { continue; }
770
+ let state = codexFileState.get(path);
771
+
772
+ if (!state) {
773
+ // New file — read the header for sid + cwd, then decide whether to
774
+ // capture it. Skip files outside our workspace.
775
+ const header = await readCodexHeader(path);
776
+ if (!header || !header.sid) continue; // not ready yet — retry next tick
777
+ if (!codexCwdInWorkspace(header.cwd)) {
778
+ codexFileState.set(path, { offset: st.size, sid: header.sid, cwd: header.cwd, skip: true, rootEmitted: false });
779
+ continue;
780
+ }
781
+ state = { offset: 0, sid: header.sid, cwd: header.cwd, skip: false, rootEmitted: false };
782
+ codexFileState.set(path, state);
783
+ if (firstRun) {
784
+ // On startup, skip a pre-existing session's history entirely — no
785
+ // root, no replay. Only future appends (a live session that keeps
786
+ // going) will lazily create the root via ensureCodexRoot.
787
+ state.offset = st.size;
788
+ continue;
789
+ }
790
+ }
791
+
792
+ if (state.skip) { state.offset = st.size; continue; }
793
+ if (st.size <= state.offset) continue;
794
+
795
+ const text = await readByteRange(path, state.offset, st.size);
796
+ const lastNl = text.lastIndexOf("\n");
797
+ if (lastNl < 0) continue; // no complete line yet
798
+ const consume = text.slice(0, lastNl);
799
+ state.offset += Buffer.byteLength(consume, "utf8") + 1; // +1 for the \n
800
+
801
+ for (const line of consume.split("\n")) {
802
+ if (!line) continue;
803
+ let obj;
804
+ try { obj = JSON.parse(line); } catch { continue; }
805
+ const prevModel = codexSessionModel.get(state.sid);
806
+ const payload = codexObjToPayload(obj, state.sid, state.cwd);
807
+ // If the model changed (turn_context/response_item), surface it.
808
+ const nowModel = codexSessionModel.get(state.sid);
809
+ if (nowModel && nowModel !== prevModel) {
810
+ ensureCodexRoot(state);
811
+ emitCodexEvent({ session_id: state.sid, cwd: state.cwd, provider: "codex", hook_event_name: "ModelObserved", model: nowModel });
812
+ }
813
+ if (payload) {
814
+ ensureCodexRoot(state);
815
+ emitCodexEvent(payload);
816
+ }
817
+ }
818
+ }
819
+ } catch {
820
+ /* swallow — watcher must never crash the server */
821
+ } finally {
822
+ codexScanRunning = false;
823
+ }
824
+ }
825
+
826
+ function startCodexWatcher(workspace) {
827
+ codexWorkspace = workspace ?? "";
828
+ if (!existsSync(CODEX_SESSIONS_DIR)) return null;
829
+ // Initial catalog: create roots for in-progress sessions, skip their
830
+ // history, then poll for new lines.
831
+ codexScanOnce(true).catch(() => {});
832
+ codexWatchTimer = setInterval(() => { codexScanOnce(false).catch(() => {}); }, 1500);
833
+ if (codexWatchTimer.unref) codexWatchTimer.unref();
834
+ return codexWatchTimer;
835
+ }
836
+
837
+ function pushEvent(raw, source, opts = {}) {
838
+ // Synchronous enrichment: if we already know this session's model, stamp
839
+ // it on the payload so the client's recursive scanner picks it up.
840
+ if (raw && typeof raw === "object" && raw.session_id && !raw.model) {
841
+ const cached = modelBySession.get(raw.session_id);
842
+ if (cached) raw.model = cached;
843
+ }
844
+
845
+ const seq = nextSeq++;
846
+ const evt = {
847
+ seq,
848
+ receivedAt: opts.receivedAt ?? Date.now(),
849
+ source,
850
+ payload: raw,
851
+ };
852
+ events.push(evt);
853
+ if (events.length > MAX_BUFFER) events.splice(0, events.length - MAX_BUFFER);
854
+
855
+ const line = `id: ${seq}\nevent: hook\ndata: ${JSON.stringify(evt)}\n\n`;
856
+ for (const res of sseClients) {
857
+ try { res.write(line); } catch {}
858
+ }
859
+
860
+ if (persistPath && !opts.replay) {
861
+ // Fire-and-forget append. JSONL = newline-delimited JSON.
862
+ appendFile(persistPath, JSON.stringify(evt) + "\n", "utf8").catch(() => {});
863
+ // Cheap throttled check (every 30s) — only rotates if file > 50MB.
864
+ maybeRotatePersistFile();
865
+ }
866
+
867
+ // Kick off async transcript scans. Model arrives as a one-shot
868
+ // ModelObserved; usage is re-read periodically (throttled to 2.5s per
869
+ // session) so the cost columns track running totals as the session
870
+ // progresses. Both result in synthetic events.
871
+ // Provider gates the path: Claude reads transcript_path; Codex reads its
872
+ // rollout JSONL under ~/.codex/sessions/. The Claude scanners short-circuit
873
+ // when transcript_path is absent (always the case for Codex hooks).
874
+ if (source === "hook" && !opts.replay) {
875
+ if (raw && raw.provider === "codex") {
876
+ maybeResolveCodex(raw);
877
+ } else {
878
+ maybeResolveModel(raw);
879
+ maybeResolveUsage(raw);
880
+ maybeResolveContext(raw);
881
+ }
882
+ }
883
+
884
+ return evt;
885
+ }
886
+
887
+ async function replayLog(filePath) {
888
+ if (!existsSync(filePath)) return 0;
889
+ let count = 0;
890
+ const rl = createInterface({ input: createReadStream(filePath, { encoding: "utf8" }) });
891
+ for await (const line of rl) {
892
+ if (!line) continue;
893
+ try {
894
+ const evt = JSON.parse(line);
895
+ if (evt && typeof evt === "object" && evt.payload) {
896
+ pushEvent(evt.payload, evt.source ?? "replay", { receivedAt: evt.receivedAt, replay: true });
897
+ count++;
898
+ }
899
+ } catch { /* skip corrupt line */ }
900
+ }
901
+ return count;
902
+ }
903
+
904
+ function send(res, status, body, headers = {}) {
905
+ res.writeHead(status, {
906
+ "Content-Type": "application/json; charset=utf-8",
907
+ "Cache-Control": "no-store",
908
+ ...headers,
909
+ });
910
+ res.end(typeof body === "string" ? body : JSON.stringify(body));
911
+ }
912
+
913
+ async function serveStatic(req, res, url) {
914
+ // Strip leading slash, default to index.html
915
+ let rel = url.pathname.replace(/^\/+/, "");
916
+ if (rel === "" || rel.endsWith("/")) rel = `${rel}index.html`;
917
+ const filePath = join(WEB_DIST, rel);
918
+ if (!filePath.startsWith(WEB_DIST)) return send(res, 403, { error: "forbidden" });
919
+
920
+ try {
921
+ const s = await stat(filePath);
922
+ if (s.isDirectory()) return send(res, 404, { error: "not found" });
923
+ const buf = await readFile(filePath);
924
+ res.writeHead(200, {
925
+ "Content-Type": MIME[extname(filePath).toLowerCase()] ?? "application/octet-stream",
926
+ "Cache-Control": "no-cache",
927
+ });
928
+ res.end(buf);
929
+ } catch {
930
+ // SPA fallback to index.html for client-side routes
931
+ try {
932
+ const idx = await readFile(join(WEB_DIST, "index.html"));
933
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
934
+ res.end(idx);
935
+ } catch {
936
+ send(res, 404, { error: "ui not built. run `pnpm build` or `npm run build`." });
937
+ }
938
+ }
939
+ }
940
+
941
+ function handleEventIngest(req, res) {
942
+ let body = "";
943
+ req.setEncoding("utf8");
944
+ req.on("data", c => {
945
+ body += c;
946
+ if (body.length > 5_000_000) {
947
+ req.destroy();
948
+ }
949
+ });
950
+ req.on("end", () => {
951
+ let parsed;
952
+ try { parsed = JSON.parse(body); }
953
+ catch { return send(res, 400, { error: "invalid json" }); }
954
+ const evt = pushEvent(parsed, "hook");
955
+ send(res, 200, { ok: true, seq: evt.seq });
956
+ });
957
+ req.on("error", () => send(res, 400, { error: "bad request" }));
958
+ }
959
+
960
+ function handleSse(req, res) {
961
+ res.writeHead(200, {
962
+ "Content-Type": "text/event-stream",
963
+ "Cache-Control": "no-cache, no-transform",
964
+ "Connection": "keep-alive",
965
+ "X-Accel-Buffering": "no",
966
+ });
967
+ res.write(`retry: 1500\n\n`);
968
+
969
+ // Resume: replay events after Last-Event-ID. Marked with `replay:true`
970
+ // on the envelope so the client can suppress turn-cleanup side effects
971
+ // (exitAt stamping, autofit churn) until the live stream takes over.
972
+ // Without this the reducer's UserPromptSubmit handler treats replayed
973
+ // events as a real new turn — hiding prior-turn subagents using the
974
+ // event's stale receivedAt, which collides with wall-clock visibility
975
+ // gates and yields the "nodes appear then vanish" symptom on refresh.
976
+ const lastId = Number(req.headers["last-event-id"] ?? 0);
977
+ for (const e of events) {
978
+ if (e.seq <= lastId) continue;
979
+ const tagged = { ...e, replay: true };
980
+ res.write(`id: ${e.seq}\nevent: hook\ndata: ${JSON.stringify(tagged)}\n\n`);
981
+ }
982
+ // Sentinel: tells client "ring buffer drained, live stream starts now".
983
+ res.write(`event: replay-end\ndata: {}\n\n`);
984
+
985
+ sseClients.add(res);
986
+ const ping = setInterval(() => {
987
+ try { res.write(`: ping\n\n`); } catch {}
988
+ }, 15000);
989
+
990
+ req.on("close", () => {
991
+ clearInterval(ping);
992
+ sseClients.delete(res);
993
+ });
994
+ }
995
+
996
+ function handleHealth(_req, res) {
997
+ send(res, 200, {
998
+ ok: true,
999
+ name: "agent-dag",
1000
+ seq: nextSeq - 1,
1001
+ clients: sseClients.size,
1002
+ uptimeMs: Math.round(process.uptime() * 1000),
1003
+ });
1004
+ }
1005
+
1006
+ function isProcessAlive(pid) {
1007
+ try { process.kill(pid, 0); return true; }
1008
+ catch (e) { return e && e.code === "EPERM"; }
1009
+ }
1010
+
1011
+ async function sweepStaleDiscovery() {
1012
+ const dir = join(homedir(), ".claude", "agent-dag");
1013
+ let files;
1014
+ try { files = await readdir(dir); } catch { return 0; }
1015
+ let removed = 0;
1016
+ for (const f of files) {
1017
+ if (!f.endsWith(".json")) continue;
1018
+ const p = join(dir, f);
1019
+ try {
1020
+ const d = JSON.parse(await readFile(p, "utf8"));
1021
+ if (d && typeof d.pid === "number" && !isProcessAlive(d.pid)) {
1022
+ await unlink(p).catch(() => {});
1023
+ removed++;
1024
+ }
1025
+ } catch { /* corrupt — leave it */ }
1026
+ }
1027
+ return removed;
1028
+ }
1029
+
1030
+ function randomPort(min, max) {
1031
+ return Math.floor(Math.random() * (max - min + 1)) + min;
1032
+ }
1033
+
1034
+ async function tryListen(server, port, host) {
1035
+ return new Promise((res, rej) => {
1036
+ server.once("error", rej);
1037
+ server.listen(port, host, () => {
1038
+ server.removeListener("error", rej);
1039
+ res();
1040
+ });
1041
+ });
1042
+ }
1043
+
1044
+ export async function startServer({ port = 4317, host = "127.0.0.1", persist = null, portRange = [4318, 4400], workspace = "", codex = true } = {}) {
1045
+ const removed = await sweepStaleDiscovery();
1046
+ if (removed > 0) console.log(` swept ${removed} stale discovery file(s)`);
1047
+ if (persist) {
1048
+ persistPath = resolve(persist);
1049
+ try { await mkdir(pdirname(persistPath), { recursive: true }); } catch {}
1050
+ const replayed = await replayLog(persistPath);
1051
+ if (replayed > 0) {
1052
+ // Don't broadcast replays as live; SSE clients catch up via Last-Event-ID
1053
+ // already. Just keep the buffer + seq counter primed.
1054
+ }
1055
+ }
1056
+ const server = createServer((req, res) => {
1057
+ const url = new URL(req.url ?? "/", `http://${req.headers.host ?? host}`);
1058
+
1059
+ if (req.method === "POST" && url.pathname === "/api/event") return handleEventIngest(req, res);
1060
+ if (req.method === "GET" && url.pathname === "/api/health") return handleHealth(req, res);
1061
+ if (req.method === "GET" && url.pathname === "/events") return handleSse(req, res);
1062
+
1063
+ if (req.method === "GET" && url.pathname === "/api/events") {
1064
+ const since = Number(url.searchParams.get("since") ?? 0);
1065
+ return send(res, 200, events.filter(e => e.seq > since));
1066
+ }
1067
+
1068
+ // POST /api/clear — wipe in-memory buffer + persistence file (UI reset)
1069
+ if (req.method === "POST" && url.pathname === "/api/clear") {
1070
+ events.length = 0;
1071
+ if (persistPath) truncate(persistPath, 0).catch(() => {});
1072
+ pushEvent({ hook_event_name: "__clear", cwd: "" }, "internal");
1073
+ return send(res, 200, { ok: true });
1074
+ }
1075
+
1076
+ if (req.method === "GET") return serveStatic(req, res, url);
1077
+ send(res, 405, { error: "method not allowed" });
1078
+ });
1079
+
1080
+ // Try requested port first, then up to 10 random ports from portRange.
1081
+ const candidates = [port];
1082
+ for (let i = 0; i < 10; i++) candidates.push(randomPort(portRange[0], portRange[1]));
1083
+
1084
+ for (const candidate of candidates) {
1085
+ try {
1086
+ await tryListen(server, candidate, host);
1087
+ // Codex has no working hooks on Windows — tail its rollout files instead.
1088
+ if (codex) startCodexWatcher(workspace);
1089
+ return server;
1090
+ } catch (err) {
1091
+ if (err && err.code === "EADDRINUSE") continue;
1092
+ throw err;
1093
+ }
1094
+ }
1095
+ throw Object.assign(new Error(`all ports tried — none available`), { code: "EADDRINUSE" });
1096
+ }
1097
+
1098
+ // Allow running this file directly for dev.
1099
+ if (import.meta.url === pathToFileURL(process.argv[1]).href) {
1100
+ const port = Number(process.env.CCGRAPH_PORT ?? 4317);
1101
+ startServer({ port }).then(s => {
1102
+ const addr = s.address();
1103
+ const p = typeof addr === "object" && addr ? addr.port : port;
1104
+ console.log(`agents-deck server: http://127.0.0.1:${p}`);
1105
+ }).catch(e => {
1106
+ console.error("agents-deck server failed:", e.message);
1107
+ process.exit(1);
1108
+ });
1109
+ }