ccdeck 1.43.0 → 1.44.1

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,3479 @@
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, open, truncate, readdir, unlink } from "node:fs/promises";
5
+ import { createReadStream, existsSync, readFileSync, realpathSync } 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
+ import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
12
+ import { claudeConfigDir } from "./claude-dir.mjs";
13
+ import { CODEX_HOME, CODEX_SESSIONS_DIR, STOP, walkRolloutDays } from "./codex-dir.mjs";
14
+ import { PRODUCT } from "./brand.mjs";
15
+ import { invokedName, renameNotice } from "./invoked-as.mjs";
16
+ import { appendLogLine, codexCwdInWorkspace, writesCodexLog } from "./log-writer.mjs";
17
+ import { readProcesses, startSystemMetrics, systemSnapshot } from "./system-metrics.mjs";
18
+
19
+ const __dirname = dirname(fileURLToPath(import.meta.url));
20
+ const PKG_ROOT = resolve(__dirname, "..", "..");
21
+ const WEB_DIST = resolve(PKG_ROOT, "dist", "web");
22
+
23
+ // Read at import — i.e. at boot, before an upgrade can overwrite these files.
24
+ // Reading it later would report whatever npm has since installed and hide the
25
+ // exact drift /api/version exists to expose. See src/server/self-update.mjs.
26
+ const RUNNING_VERSION = (() => {
27
+ try { return JSON.parse(readFileSync(join(PKG_ROOT, "package.json"), "utf8"))?.version ?? null; }
28
+ catch { return null; }
29
+ })();
30
+
31
+ const MIME = {
32
+ ".html": "text/html; charset=utf-8",
33
+ ".js": "application/javascript; charset=utf-8",
34
+ ".mjs": "application/javascript; charset=utf-8",
35
+ ".css": "text/css; charset=utf-8",
36
+ ".json": "application/json; charset=utf-8",
37
+ ".svg": "image/svg+xml",
38
+ ".png": "image/png",
39
+ ".jpg": "image/jpeg",
40
+ ".woff2": "font/woff2",
41
+ ".map": "application/json",
42
+ };
43
+
44
+ const MAX_BUFFER = 2000; // recent events kept for late SSE subscribers
45
+ const events = []; // ring buffer
46
+ let nextSeq = 1;
47
+ // Identity of *this* process's seq numbering. nextSeq restarts at 1 on every
48
+ // boot and is re-derived by replaying events.jsonl, so it is monotonic only
49
+ // within one process: rotation (50MB) or /api/clear shortens that log and the
50
+ // next boot starts numbering well below what an already-open tab saw. Stamped
51
+ // on every envelope so a client can tell "counter restarted" apart from "old
52
+ // duplicate" instead of silently dropping the live stream.
53
+ const SEQ_EPOCH = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
54
+ const sseClients = new Set(); // res handles
55
+
56
+ let persistPath = null; // absolute path to events.jsonl, or null
57
+
58
+ // ─── Which deck records which session ─────────────────────────────────────
59
+ // The hook posts an event to every deck whose workspace matches, and by
60
+ // default all of them append to one events.jsonl — so each event landed in
61
+ // that file once per running deck, and every later replay of it ingested each
62
+ // tool call that many times. The hook now elects one writer per log file and
63
+ // marks the request to the rest `?persist=0`. That flag covers the hook event
64
+ // itself; this map carries it to the ModelObserved/UsageObserved/
65
+ // ContextObserved events a deck derives from the same session's transcript,
66
+ // which every deck reads for itself and would otherwise duplicate exactly the
67
+ // same way. A session is recorded unless we have been told someone else owns
68
+ // it, so a lone deck — and a deck the hook is too old to flag — still writes
69
+ // everything.
70
+ //
71
+ // THIS COVERS THE CLAUDE SIDE ONLY, and that is worth stating because reading it
72
+ // as the general answer is what produced #447. The only thing that ever fills
73
+ // the set is noteLogWriter, called from handleEventIngest — a hook POST. Codex
74
+ // events are read off the rollout files inside this process and never reach an
75
+ // HTTP handler, and Codex hooks are not installed any more, so a Codex session
76
+ // id cannot get in here and writesLogFor answers "mine" for every one of them on
77
+ // every deck. The Codex half is decided by writesCodexLog in the watcher's scan
78
+ // loop instead, and every event derived from a rollout — including the memory
79
+ // scan's ContextObserved — has to carry that verdict explicitly.
80
+ const foreignSessions = new Set(); // session ids another deck is logging
81
+ const MAX_FOREIGN_SESSIONS = 512; // bound: insertion order = oldest first
82
+
83
+ function noteLogWriter(payload, mine) {
84
+ const sid = payload && typeof payload === "object" ? payload.session_id : null;
85
+ if (typeof sid !== "string" || sid === "") return;
86
+ // Delete either way: on re-add it moves the id back to the young end, so the
87
+ // eviction below drops sessions that stopped being posted, not busy ones.
88
+ foreignSessions.delete(sid);
89
+ if (mine) return;
90
+ foreignSessions.add(sid);
91
+ if (foreignSessions.size > MAX_FOREIGN_SESSIONS) {
92
+ foreignSessions.delete(foreignSessions.values().next().value);
93
+ }
94
+ }
95
+
96
+ /**
97
+ * Is this deck the one that writes this payload's session to the log? Every
98
+ * event goes through here on its way to disk, including the ones this deck
99
+ * derives from a transcript on its own — that is the point of remembering the
100
+ * session rather than only honouring the flag on the event that carried it.
101
+ */
102
+ export function writesLogFor(payload) {
103
+ const sid = payload && typeof payload === "object" ? payload.session_id : null;
104
+ return typeof sid !== "string" || sid === "" || !foreignSessions.has(sid);
105
+ }
106
+
107
+ // ─── Persistence rotation ─────────────────────────────────────────────────
108
+ // 24/7 dev servers used to grow events.jsonl unbounded — saw it hit GBs
109
+ // across weeks. We rotate when the file passes ROTATE_AT_BYTES, archiving
110
+ // the previous file to .1 and starting fresh. Last-event-id replay still
111
+ // covers the in-memory ring buffer of MAX_BUFFER events.
112
+ const ROTATE_AT_BYTES = 50 * 1024 * 1024;
113
+ let lastRotateCheckAt = 0;
114
+ let rotateInProgress = false;
115
+ async function maybeRotatePersistFile() {
116
+ if (!persistPath) return;
117
+ const now = Date.now();
118
+ // Throttle disk-stat checks to once per 30s.
119
+ if (now - lastRotateCheckAt < 30_000) return;
120
+ lastRotateCheckAt = now;
121
+ if (rotateInProgress) return;
122
+ rotateInProgress = true;
123
+ try {
124
+ const s = await stat(persistPath).catch(() => null);
125
+ if (!s || s.size < ROTATE_AT_BYTES) return;
126
+ // Roll events.jsonl → events.jsonl.1 (replacing any previous .1).
127
+ const oldPath = persistPath + ".1";
128
+ try { await unlink(oldPath); } catch {}
129
+ const { rename } = await import("node:fs/promises");
130
+ await rename(persistPath, oldPath);
131
+ console.log(`${PRODUCT}: rotated ${persistPath} (${(s.size / 1024 / 1024).toFixed(0)}MB → ${oldPath})`);
132
+ } catch (err) {
133
+ console.error(`${PRODUCT}: persist rotation failed:`, err && err.message ? err.message : err);
134
+ } finally {
135
+ rotateInProgress = false;
136
+ }
137
+ }
138
+
139
+ // ─── Incremental transcript scanning ─────────────────────────────────────
140
+ // Model, usage and context enrichment all derive from the same append-only
141
+ // transcript JSONL, and each one used to re-read and re-parse the whole file
142
+ // on every throttled pass. A session's transcript grows to tens of MB, so
143
+ // that cost O(n) per pass, O(n²) over the session, and — because the parse
144
+ // loop is one synchronous block — it stalled SSE broadcasts and /api/event
145
+ // ingest for as long as it ran.
146
+ //
147
+ // Instead we keep one cursor per file plus the running totals derived so
148
+ // far, read only the bytes appended since the last pass, and fold them into
149
+ // that state. The three scanners share it, so the first one to run in a
150
+ // cycle pays for the read and the other two reuse the result. This mirrors
151
+ // the offset tailing the Codex rollout watcher already does further down.
152
+ const transcriptScans = new Map(); // path -> scan state
153
+ const transcriptScanInFlight = new Map(); // path -> in-progress scan promise
154
+ const MAX_TRANSCRIPT_SCANS = 256; // bound the per-path state
155
+
156
+ // The transcript's `message.model`, and the only filter standing between it and
157
+ // every model the deck shows. Bedrock and Mantle put a provider namespace in
158
+ // front of the id — `us.anthropic.claude-opus-5`, `anthropic.claude-opus-5` —
159
+ // so a bare `^claude` dropped every line a Bedrock session writes and the deck
160
+ // showed those users no model, no context window and no cost at all (#475).
161
+ //
162
+ // The prefix list is `VENDOR_PREFIX_RE` in src/web/model-id.ts, written out a
163
+ // second time here rather than imported: this file is plain .mjs that node runs
164
+ // straight off disk with no build step, so it cannot reach a `.ts` module.
165
+ // bedrock-model-ids.test.ts sweeps both against one list of ids so the copies
166
+ // cannot drift apart.
167
+ const MODEL_ID_RE = /^(?:(?:us-gov|global|apac|us|eu|jp|au)\.anthropic\.|anthropic\.)?claude[-_]/i;
168
+ const USAGE_BLOCK_RE = /"usage"\s*:\s*\{([^}]+)\}/g;
169
+ // CC `/clear` and `/compact` write a marker into the transcript and reset the
170
+ // context window to ~0 while the JSONL keeps growing — everything before the
171
+ // last marker is stale.
172
+ const CONTEXT_RESET_RE = /<command-name>\s*\/(?:clear|compact)\s*<\/command-name>/g;
173
+ const TYPE_USER_RE = /"type"\s*:\s*"user"/g;
174
+ const TYPE_ASSISTANT_RE = /"type"\s*:\s*"assistant"/g;
175
+ const TYPE_TOOL_USE_RE = /"type"\s*:\s*"tool_use"/g;
176
+ const TYPE_TOOL_RESULT_RE = /"type"\s*:\s*"tool_result"/g;
177
+ const SYSTEM_REMINDER_RE = /<system-reminder>/g;
178
+ const USAGE_FIELD_RE = {
179
+ input_tokens: /"input_tokens"\s*:\s*(\d+)/,
180
+ output_tokens: /"output_tokens"\s*:\s*(\d+)/,
181
+ cache_read_input_tokens: /"cache_read_input_tokens"\s*:\s*(\d+)/,
182
+ cache_creation_input_tokens: /"cache_creation_input_tokens"\s*:\s*(\d+)/,
183
+ ephemeral_1h_input_tokens: /"ephemeral_1h_input_tokens"\s*:\s*(\d+)/,
184
+ ephemeral_5m_input_tokens: /"ephemeral_5m_input_tokens"\s*:\s*(\d+)/,
185
+ };
186
+ // The flat `cache_creation_input_tokens` says how many tokens were written to
187
+ // cache but not at which TTL, and Anthropic bills a 1-hour write at 2x input
188
+ // against the 5-minute 1.25x — CC writes 1-hour caches for most of its prefix,
189
+ // so pricing the whole lot at the cheaper rate under-reports the bill. The
190
+ // split lives in a `cache_creation` sub-object that USAGE_BLOCK_RE cannot
191
+ // reach: its `[^}]+` stops at the first `}`, which in a real transcript closes
192
+ // `server_tool_use`, several fields earlier. Match the sub-object on the raw
193
+ // line instead of on the extracted blob.
194
+ const CACHE_CREATION_BLOCK_RE = /"cache_creation"\s*:\s*\{([^}]*)\}/g;
195
+
196
+ function grabUsageField(blob, key) {
197
+ const m = blob.match(USAGE_FIELD_RE[key]);
198
+ return m ? Number(m[1]) : 0;
199
+ }
200
+
201
+ function newContextBreakdown() {
202
+ return {
203
+ msgsUser: 0,
204
+ msgsAssistant: 0,
205
+ toolUses: 0,
206
+ toolResults: 0,
207
+ systemReminders: 0,
208
+ currentContextTokens: 0,
209
+ };
210
+ }
211
+
212
+ function newTranscriptState() {
213
+ return {
214
+ offset: 0, // bytes already folded in
215
+ rootModel: null,
216
+ lastModel: null, // last claude-* model on any line, sidechain included
217
+ subagentModels: {},
218
+ aiTitle: null, // newest "ai-title" entry, the session's sentence title
219
+ agentName: null, // newest "agent-name" entry, the session's short name
220
+ usage: {
221
+ input_tokens: 0, output_tokens: 0,
222
+ cache_read_input_tokens: 0, cache_creation_input_tokens: 0,
223
+ ephemeral_1h_input_tokens: 0, ephemeral_5m_input_tokens: 0,
224
+ },
225
+ ctx: newContextBreakdown(),
226
+ };
227
+ }
228
+
229
+ async function readByteRange(path, from, to) {
230
+ const fh = await open(path, "r");
231
+ try {
232
+ const len = to - from;
233
+ if (len <= 0) return "";
234
+ const buf = Buffer.alloc(len);
235
+ await fh.read(buf, 0, len, from);
236
+ return buf.toString("utf8");
237
+ } finally {
238
+ await fh.close();
239
+ }
240
+ }
241
+
242
+ // ─── Session naming ──────────────────────────────────────────────────────
243
+ // CC writes two whole-line records that name the session, and nothing else in
244
+ // the transcript carries either fact:
245
+ //
246
+ // {"type":"ai-title","aiTitle":"Inspect repository to understand current state","sessionId":"…"}
247
+ // {"type":"agent-name","agentName":"account-management-oauth-flow","sessionId":"…"}
248
+ //
249
+ // Both are re-emitted on roughly every turn rather than only when they change.
250
+ // Measured on the two largest transcripts on this machine: 685 `ai-title`
251
+ // entries carrying 2 DISTINCT values (46.4 MB session) and 406 carrying 1
252
+ // (19.6 MB session). So "the last one wins" is right, but the value is nearly
253
+ // always the value we already had — which is why the emit below is gated on a
254
+ // change rather than fired per pass.
255
+ //
256
+ // `aiTitle` is NOT reliably the sentence it looks like. In the 46.4 MB session
257
+ // the first 332 entries read "Inspect repository to understand current state"
258
+ // and every one of the last 353 reads "account-management-oauth-flow" —
259
+ // byte-identical to `agentName`, and 353 is exactly the `agent-name` count. CC
260
+ // overwrites the title with the slug once a session has a name. The client
261
+ // therefore has to treat "title equals name" as "no title", or the tooltip
262
+ // would repeat the card.
263
+ const AI_TITLE_MARK = '"ai-title"';
264
+ const AGENT_NAME_MARK = '"agent-name"';
265
+
266
+ /** Fold one line's naming records into `out`. Last value wins. Pure: `out` is
267
+ * the only thing written, and a line that is not one of the two records — or
268
+ * is a truncated fragment of one — leaves it untouched. */
269
+ function foldSessionNamingLine(out, line) {
270
+ if (!line) return;
271
+ const hasTitle = line.includes(AI_TITLE_MARK);
272
+ const hasName = line.includes(AGENT_NAME_MARK);
273
+ if (!hasTitle && !hasName) return;
274
+ let obj = null;
275
+ try { obj = JSON.parse(line); } catch { return; }
276
+ if (!obj || typeof obj !== "object") return;
277
+ if (obj.type === "ai-title" && typeof obj.aiTitle === "string" && obj.aiTitle) {
278
+ out.aiTitle = obj.aiTitle;
279
+ } else if (obj.type === "agent-name" && typeof obj.agentName === "string" && obj.agentName) {
280
+ out.agentName = obj.agentName;
281
+ }
282
+ }
283
+
284
+ /**
285
+ * The session naming carried by a chunk of transcript text, newest wins.
286
+ *
287
+ * Pure and text-in, so the suite can pin the parsing against a handful of lines
288
+ * instead of a 46 MB fixture. Returns `{aiTitle: null, agentName: null}` for a
289
+ * chunk holding neither — a young session, or a stretch of the file that is all
290
+ * tool output — and the caller keeps whatever it already knew rather than
291
+ * clearing a name it has already shown.
292
+ */
293
+ export function readSessionNaming(text) {
294
+ const out = { aiTitle: null, agentName: null };
295
+ if (!text || typeof text !== "string") return out;
296
+ for (const line of text.split("\n")) foldSessionNamingLine(out, line);
297
+ return out;
298
+ }
299
+
300
+ /** Fold one transcript line into the running state. Every fact the three
301
+ * scanners need lives on a single line, so line-at-a-time folding sees
302
+ * exactly what a whole-file pass would. */
303
+ function foldTranscriptLine(state, line) {
304
+ if (!line) return;
305
+
306
+ // Rides the scan that is already reading these bytes for the model, the usage
307
+ // totals and the context counts, so naming costs no read of its own — see the
308
+ // block above `maybeResolveSessionName` for why that beat a tail read.
309
+ foldSessionNamingLine(state, line);
310
+
311
+ // Model. Only a line that mentions a model can change it, and parsing the
312
+ // rest is what made the full rescan expensive.
313
+ if (line.includes('"model"')) {
314
+ let obj = null;
315
+ try { obj = JSON.parse(line); } catch {}
316
+ const msg = obj && obj.message;
317
+ const model = (msg && typeof msg.model === "string" && MODEL_ID_RE.test(msg.model)) ? msg.model
318
+ : (obj && typeof obj.model === "string" && MODEL_ID_RE.test(obj.model)) ? obj.model
319
+ : null;
320
+ if (model) {
321
+ state.lastModel = model;
322
+ const isSide = obj.isSidechain === true || obj.is_sidechain === true;
323
+ const ptid = obj.parentToolUseID || obj.parent_tool_use_id || obj.parentToolUseId || null;
324
+ if (isSide && ptid) state.subagentModels[ptid] = model;
325
+ else if (!isSide) state.rootModel = model;
326
+ }
327
+ }
328
+
329
+ // Usage totals sum every block in the file, resets included.
330
+ for (const m of line.matchAll(USAGE_BLOCK_RE)) {
331
+ const blob = m[1];
332
+ state.usage.input_tokens += grabUsageField(blob, "input_tokens");
333
+ state.usage.output_tokens += grabUsageField(blob, "output_tokens");
334
+ state.usage.cache_read_input_tokens += grabUsageField(blob, "cache_read_input_tokens");
335
+ state.usage.cache_creation_input_tokens += grabUsageField(blob, "cache_creation_input_tokens");
336
+ }
337
+ for (const m of line.matchAll(CACHE_CREATION_BLOCK_RE)) {
338
+ state.usage.ephemeral_1h_input_tokens += grabUsageField(m[1], "ephemeral_1h_input_tokens");
339
+ state.usage.ephemeral_5m_input_tokens += grabUsageField(m[1], "ephemeral_5m_input_tokens");
340
+ }
341
+
342
+ // Context counts only what follows the most recent /clear or /compact.
343
+ let ctxText = line;
344
+ let resetEnd = -1;
345
+ for (const m of line.matchAll(CONTEXT_RESET_RE)) resetEnd = (m.index ?? -1) + m[0].length;
346
+ if (resetEnd >= 0) {
347
+ state.ctx = newContextBreakdown();
348
+ ctxText = line.slice(resetEnd);
349
+ }
350
+ const ctx = state.ctx;
351
+ ctx.msgsUser += (ctxText.match(TYPE_USER_RE) ?? []).length;
352
+ ctx.msgsAssistant += (ctxText.match(TYPE_ASSISTANT_RE) ?? []).length;
353
+ ctx.toolUses += (ctxText.match(TYPE_TOOL_USE_RE) ?? []).length;
354
+ ctx.toolResults += (ctxText.match(TYPE_TOOL_RESULT_RE) ?? []).length;
355
+ ctx.systemReminders += (ctxText.match(SYSTEM_REMINDER_RE) ?? []).length;
356
+ // Current context size = the LAST usage block after the reset. Stays 0
357
+ // right after a /clear, which is what CC's own /context reports.
358
+ let lastBlob = null;
359
+ for (const m of ctxText.matchAll(USAGE_BLOCK_RE)) lastBlob = m[1];
360
+ if (lastBlob) {
361
+ ctx.currentContextTokens =
362
+ grabUsageField(lastBlob, "input_tokens") +
363
+ grabUsageField(lastBlob, "cache_read_input_tokens") +
364
+ grabUsageField(lastBlob, "cache_creation_input_tokens");
365
+ }
366
+ }
367
+
368
+ /** Record a use of `path` and keep the map under the cap. Re-inserting on
369
+ * every touch makes the Map's own insertion order the LRU order, so eviction
370
+ * reads one key instead of scanning for the smallest timestamp. Scanning a
371
+ * timestamp is also what broke the cache: a state is created with no stamp
372
+ * yet, so the entry the scan had just inserted was the smallest of all and
373
+ * the one deleted, every time. Past 256 distinct transcripts the map froze on
374
+ * the first 256 paths and every later transcript re-read its whole JSONL from
375
+ * byte 0 on every throttled pass — the O(n)-per-pass stall the cursor exists
376
+ * to remove. */
377
+ function touchTranscriptScan(path, state) {
378
+ transcriptScans.delete(path);
379
+ transcriptScans.set(path, state);
380
+ pruneTranscriptScans();
381
+ }
382
+
383
+ function pruneTranscriptScans() {
384
+ while (transcriptScans.size > MAX_TRANSCRIPT_SCANS) {
385
+ transcriptScans.delete(transcriptScans.keys().next().value);
386
+ }
387
+ }
388
+
389
+ /** Bring a transcript's scan state up to date and return it. Concurrent
390
+ * callers share one read — folding the same appended bytes twice would
391
+ * double the usage totals. Never throws; an unreadable file just leaves
392
+ * the state where it was. */
393
+ function scanTranscript(path) {
394
+ if (!path || typeof path !== "string") return Promise.resolve(null);
395
+ const inFlight = transcriptScanInFlight.get(path);
396
+ if (inFlight) return inFlight;
397
+ const run = (async () => {
398
+ let state = transcriptScans.get(path);
399
+ if (!state) state = newTranscriptState();
400
+ touchTranscriptScan(path, state);
401
+ try {
402
+ const s = await stat(path);
403
+ // Shorter than the cursor means the file was truncated, rotated or
404
+ // replaced — the offset now points at unrelated bytes, so start over.
405
+ if (s.size < state.offset) {
406
+ state = newTranscriptState();
407
+ // Touch again rather than set: the await above yielded, and another
408
+ // path's scan may have re-ordered or evicted this entry meanwhile.
409
+ touchTranscriptScan(path, state);
410
+ }
411
+ if (s.size <= state.offset) return state;
412
+ const text = await readByteRange(path, state.offset, s.size);
413
+ const lastNl = text.lastIndexOf("\n");
414
+ if (lastNl < 0) return state; // no complete line appended yet
415
+ const consumed = text.slice(0, lastNl);
416
+ // Advance before folding: a fold that throws half-way must not leave
417
+ // the cursor where the next pass would count those lines again.
418
+ state.offset += Buffer.byteLength(consumed, "utf8") + 1; // +1 for the \n
419
+ for (const line of consumed.split("\n")) foldTranscriptLine(state, line);
420
+ } catch { /* keep whatever we already folded */ }
421
+ return state;
422
+ })();
423
+ transcriptScanInFlight.set(path, run);
424
+ return run.finally(() => {
425
+ if (transcriptScanInFlight.get(path) === run) transcriptScanInFlight.delete(path);
426
+ });
427
+ }
428
+
429
+ // ─── Model enrichment ────────────────────────────────────────────────────
430
+ // CC's hook payloads never carry the `model` field — but every hook
431
+ // references a `transcript_path` JSONL that contains lines like
432
+ // `"model":"claude-opus-4-7"`. We re-read the tail of that file on every
433
+ // event for the session (throttled to MODEL_READ_THROTTLE_MS), cache the
434
+ // resolved root + subagent models, and (a) inject `model` into subsequent
435
+ // payloads for that session before broadcasting, (b) emit a synthetic
436
+ // `ModelObserved` event whenever that set CHANGES, so the client backfills
437
+ // agents created before the model was resolved. The cache is the emit
438
+ // filter, not a read filter: reading once per session meant a subagent
439
+ // model that only appears after the root is known was never picked up.
440
+ const modelBySession = new Map(); // sessionId -> { rootModel, subsSig }
441
+ const pendingTranscriptReads = new Set(); // sessionId currently being read
442
+ const modelLastReadAt = new Map(); // sessionId -> ms timestamp (re-read throttle)
443
+ const MODEL_READ_THROTTLE_MS = 2500;
444
+
445
+ /** The cache entry is `{ rootModel, subsSig }`, but `payload.model` is a
446
+ * model *string* — that is the only shape the client's recursive scanner
447
+ * reads. Returns the root model id, or null when the session resolved
448
+ * only subagent models and has no root model yet. */
449
+ export function cachedModelId(cached) {
450
+ const rootModel = cached?.rootModel;
451
+ return typeof rootModel === "string" && rootModel ? rootModel : null;
452
+ }
453
+
454
+ /** Read the main session JSONL. Returns the root model and any
455
+ * legacy-schema subagent models (older CC versions kept subagent blocks
456
+ * inline with `isSidechain:true` + `parentToolUseID`). Current CC versions
457
+ * store subagents in `<sessionDir>/subagents/agent-<id>.jsonl` — those are
458
+ * handled by `readSubagentModelsFromDir` below. */
459
+ export async function readModelFromTranscript(path) {
460
+ const state = await scanTranscript(path);
461
+ if (!state) return null;
462
+ const rootModel = state.rootModel ?? state.lastModel;
463
+ const subagentModels = { ...state.subagentModels };
464
+ if (!rootModel && Object.keys(subagentModels).length === 0) return null;
465
+ return { rootModel, subagentModels };
466
+ }
467
+
468
+ /** Newer CC schema (~2026-06): each subagent turn writes its OWN file at
469
+ * `<projects>/<slug>/<sessionId>/subagents/agent-<agentId>.jsonl` with a
470
+ * sidecar `.meta.json` carrying `{agentType, description}`. The hook
471
+ * payload's `agent_id` matches the file's <agentId>, so the reducer can
472
+ * attribute via the existing `subagentModels` map (it keys by parentToolUseId
473
+ * but the reducer looks up `${sessionId}::${key}` and the subagent node id
474
+ * is built from `agent_id` — identical lookup either way).
475
+ *
476
+ * Returns { [agentId]: model } scanning every agent-*.jsonl file in dir. */
477
+ async function readSubagentModelsFromDir(transcriptPath) {
478
+ // Subagent dir sits next to the main jsonl: <dir>/<sessionId>/subagents/
479
+ // Derive from transcript_path by stripping the .jsonl suffix.
480
+ if (!transcriptPath || typeof transcriptPath !== "string") return null;
481
+ const sessionDir = transcriptPath.replace(/\.jsonl$/i, "");
482
+ const subDir = join(sessionDir, "subagents");
483
+ let entries;
484
+ try { entries = await readdir(subDir); } catch { return null; }
485
+ const models = {};
486
+ for (const f of entries) {
487
+ if (!/^agent-([0-9a-f]+)\.jsonl$/i.test(f)) continue;
488
+ const agentId = f.replace(/^agent-/, "").replace(/\.jsonl$/i, "");
489
+ const full = join(subDir, f);
490
+ try {
491
+ // Same incremental cursor as the main transcript. Last-seen claude-*
492
+ // model wins — subagents may switch model mid-turn (Sonnet → Haiku for
493
+ // tool-call fallback etc.).
494
+ const state = await scanTranscript(full);
495
+ const last = state ? state.lastModel : null;
496
+ if (last) models[agentId] = last;
497
+ } catch { /* skip unreadable file */ }
498
+ }
499
+ return Object.keys(models).length ? models : null;
500
+ }
501
+
502
+ function maybeResolveModel(payload) {
503
+ if (!payload || typeof payload !== "object") return;
504
+ const sid = payload.session_id;
505
+ const tp = payload.transcript_path;
506
+ if (!sid || !tp) return;
507
+ // Re-read on every event for this session — the cache was preventing us
508
+ // from picking up subagent models that arrive after the root is known.
509
+ // Throttle so we don't thrash the filesystem.
510
+ if (pendingTranscriptReads.has(sid)) return;
511
+ const now = Date.now();
512
+ const last = modelLastReadAt.get(sid) ?? 0;
513
+ if (now - last < MODEL_READ_THROTTLE_MS) return;
514
+ modelLastReadAt.set(sid, now);
515
+ pendingTranscriptReads.add(sid);
516
+ Promise.all([readModelFromTranscript(tp), readSubagentModelsFromDir(tp)])
517
+ .then(([result, dirSubs]) => {
518
+ const rootModel = result?.rootModel ?? null;
519
+ // Merge legacy (inline isSidechain) + new (subagents/ dir) maps. Dir
520
+ // wins on conflict since current CC only writes to the dir.
521
+ const subagentModels = { ...(result?.subagentModels ?? {}), ...(dirSubs ?? {}) };
522
+ if (!rootModel && Object.keys(subagentModels).length === 0) return;
523
+ const prev = modelBySession.get(sid);
524
+ const subsSig = JSON.stringify(subagentModels);
525
+ if (prev && prev.rootModel === rootModel && prev.subsSig === subsSig) return;
526
+ modelBySession.set(sid, { rootModel, subsSig });
527
+ pushEvent({
528
+ hook_event_name: "ModelObserved",
529
+ session_id: sid,
530
+ model: rootModel,
531
+ subagentModels,
532
+ }, "internal");
533
+ })
534
+ .catch(() => {})
535
+ .finally(() => pendingTranscriptReads.delete(sid));
536
+ }
537
+
538
+ // ─── Usage enrichment ────────────────────────────────────────────────────
539
+ // Same story as the model: token counts (input/output/cache) are missing
540
+ // from every CC hook payload but present on every assistant message in
541
+ // the transcript JSONL as a `"usage":{…}` block. We sum them across the
542
+ // whole transcript and ship a synthetic UsageObserved event so the
543
+ // session's root agent gets accurate cumulative usage (and therefore the
544
+ // cost columns actually have something to multiply by).
545
+ const lastUsageReadAt = new Map(); // sid -> ms timestamp
546
+ const pendingUsageReads = new Set(); // sid currently being read
547
+ const USAGE_READ_THROTTLE_MS = 2500;
548
+
549
+ // Every entry carries its own usage object and we sum every occurrence, so
550
+ // the totals are cumulative over the whole transcript — the running state
551
+ // keeps them across passes and each pass only adds the newly appended blocks.
552
+ export async function readUsageFromTranscript(path) {
553
+ const state = await scanTranscript(path);
554
+ if (!state) return null;
555
+ const totals = { ...state.usage };
556
+ if (totals.input_tokens === 0 && totals.output_tokens === 0
557
+ && totals.cache_read_input_tokens === 0 && totals.cache_creation_input_tokens === 0) return null;
558
+ return totals;
559
+ }
560
+
561
+ function maybeResolveUsage(payload) {
562
+ if (!payload || typeof payload !== "object") return;
563
+ const sid = payload.session_id;
564
+ const tp = payload.transcript_path;
565
+ if (!sid || !tp) return;
566
+ if (pendingUsageReads.has(sid)) return;
567
+ const now = Date.now();
568
+ const last = lastUsageReadAt.get(sid) ?? 0;
569
+ if (now - last < USAGE_READ_THROTTLE_MS) return;
570
+ lastUsageReadAt.set(sid, now);
571
+ pendingUsageReads.add(sid);
572
+ readUsageFromTranscript(tp)
573
+ .then(usage => {
574
+ if (!usage) return;
575
+ pushEvent({ hook_event_name: "UsageObserved", session_id: sid, usage }, "internal");
576
+ })
577
+ .catch(() => {})
578
+ .finally(() => pendingUsageReads.delete(sid));
579
+ }
580
+
581
+ // ─── Session-name enrichment ─────────────────────────────────────────────
582
+ // WHY THIS IS NOT A TAIL READ. The obvious shape for "get the newest naming
583
+ // record out of a 46 MB file" is to read the last N KB and parse backwards, and
584
+ // it is the wrong shape here for two reasons, one of them measured.
585
+ //
586
+ // The measured one: the density is not uniform, so no N is safe. Back-scanning
587
+ // from an arbitrary point in the 46.4 MB transcript to the nearest `ai-title`
588
+ // costs p50 38 KB but p95 227 KB, p99 511 KB and 723 KB worst case, because a
589
+ // single line in that file reaches 710 KB — one big tool result evicts every
590
+ // naming record from any window you picked. A 256 KB tail covers 95.9% of
591
+ // positions, 512 KB covers 99.1%, and the last percent still needs a megabyte.
592
+ //
593
+ // The structural one: it would be a second reader of bytes this process is
594
+ // already reading. `scanTranscript` keeps a per-path CURSOR and folds each
595
+ // appended line exactly once, for the model, the usage totals and the context
596
+ // counts alike. Folding two more fields into that pass (see
597
+ // `foldSessionNamingLine`) costs no read at all, sees every record rather than
598
+ // a window of them, and cannot be defeated by a 710 KB line.
599
+ //
600
+ // That also settles the trigger. With a tail read the trigger IS the cost
601
+ // control, so you have to pick a boundary and `Stop` is the honest one. With a
602
+ // cursor the bytes are read once whoever asks, so the trigger only decides how
603
+ // LATE the name appears — and gating on `Stop` would hold a name the scan
604
+ // already has until the turn ends, for a saving of zero. So this runs off any
605
+ // hook event like its three neighbours, throttled per session, and the throttle
606
+ // matches MODEL_READ_THROTTLE_MS so the two passes coincide and share one
607
+ // in-flight scan.
608
+ //
609
+ // The emit is what is actually kept rare, and it is gated on a CHANGE: with 685
610
+ // records carrying 2 distinct values, a per-pass emit would be ~683 events
611
+ // saying nothing. Sessions that never get named emit nothing at all.
612
+ const nameBySession = new Map(); // sid -> `${agentName}\0${aiTitle}`
613
+ const lastNameReadAt = new Map(); // sid -> ms timestamp
614
+ const pendingNameReads = new Set(); // sid currently being read
615
+
616
+ /** The naming the cursor has folded so far, or null when the scan has nothing.
617
+ * Exported beside readContextFromTranscript for the same reason: the rule is
618
+ * worth pinning directly rather than through a live server. */
619
+ export async function readSessionNamingFromTranscript(path) {
620
+ const state = await scanTranscript(path);
621
+ if (!state) return null;
622
+ if (!state.agentName && !state.aiTitle) return null;
623
+ return { agentName: state.agentName, aiTitle: state.aiTitle };
624
+ }
625
+
626
+ function maybeResolveSessionName(payload) {
627
+ if (!payload || typeof payload !== "object") return;
628
+ const sid = payload.session_id;
629
+ const tp = payload.transcript_path;
630
+ if (!sid || !tp) return;
631
+ if (pendingNameReads.has(sid)) return;
632
+ const now = Date.now();
633
+ const last = lastNameReadAt.get(sid) ?? 0;
634
+ if (now - last < MODEL_READ_THROTTLE_MS) return;
635
+ lastNameReadAt.set(sid, now);
636
+ pendingNameReads.add(sid);
637
+ readSessionNamingFromTranscript(tp)
638
+ .then(naming => {
639
+ if (!naming) return;
640
+ const sig = `${naming.agentName ?? ""}\u0000${naming.aiTitle ?? ""}`;
641
+ if (nameBySession.get(sid) === sig) return;
642
+ nameBySession.set(sid, sig);
643
+ pushEvent({
644
+ hook_event_name: "SessionNamed",
645
+ session_id: sid,
646
+ sessionName: naming.agentName ?? null,
647
+ sessionTitle: naming.aiTitle ?? null,
648
+ }, "internal");
649
+ })
650
+ .catch(() => {})
651
+ .finally(() => pendingNameReads.delete(sid));
652
+ }
653
+
654
+ // ─── Context enrichment ──────────────────────────────────────────────────
655
+ // Approximation of `/context` since CC doesn't expose its breakdown via
656
+ // hooks. We scan the transcript JSONL for message counts (user / assistant
657
+ // / tool_use / tool_result / system-reminders) and walk up from cwd for
658
+ // any CLAUDE.md files in scope. Cumulative token totals come from
659
+ // UsageObserved; this scan produces the structural counts ("what does the
660
+ // context contain") plus the current window size — currentContextTokens, the
661
+ // last usage block after the most recent /clear or /compact, which is what
662
+ // the context donut and the modal's percentage are drawn from.
663
+ const lastContextReadAt = new Map();
664
+ const pendingContextReads = new Set();
665
+ const CONTEXT_READ_THROTTLE_MS = 4000;
666
+
667
+ // The counts reset at every `/clear` or `/compact` marker (see
668
+ // foldTranscriptLine): CC resets its in-memory window there while the JSONL
669
+ // keeps growing, and reading the pre-reset blocks made the donut report ~100%
670
+ // on an empty context.
671
+ export async function readContextFromTranscript(path) {
672
+ const state = await scanTranscript(path);
673
+ // Nothing folded yet — the file is empty, unreadable, or has no complete
674
+ // line. Callers treat that as "no breakdown", same as before.
675
+ if (!state || state.offset === 0) return null;
676
+ return { ...state.ctx };
677
+ }
678
+
679
+ /** Longest slug CC will store verbatim. Anything longer is truncated here and
680
+ * given a hash suffix, so two deep paths sharing a 200-character prefix still
681
+ * get separate directories. */
682
+ const CC_SLUG_MAX = 200;
683
+
684
+ /** CC's hash of the *unencoded* path, used only for the truncation suffix:
685
+ * the classic h*31 + c string hash, kept in a signed 32-bit int. */
686
+ function ccPathHash(s) {
687
+ let h = 0;
688
+ for (let i = 0; i < s.length; i++) h = ((h << 5) - h + s.charCodeAt(i)) | 0;
689
+ return h;
690
+ }
691
+
692
+ /** Encode an absolute path the way CC stores it under
693
+ * ~/.claude/projects/<slug>/, so the auto-memory scan below looks in the
694
+ * directory CC actually wrote.
695
+ *
696
+ * CC flattens *every* non-alphanumeric character to "-", not just the path
697
+ * separators and the Windows drive colon this used to replace. Dots are the
698
+ * ones that bite: a worktree under .claude/worktrees/, or any folder named
699
+ * like my.app, produced a slug with a literal dot in it, the readdir below
700
+ * missed, and the project's auto-memory files were quietly left out of the
701
+ * context panel. Underscores and spaces were wrong the same way. */
702
+ export function ccProjectSlug(cwd) {
703
+ if (!cwd) return "";
704
+ // resolve() gives the platform's own absolute form — "/Users/…" on
705
+ // macOS/Linux, "C:\Users\…" on Windows — and this class covers both, so the
706
+ // drive colon and the backslashes fall out of the general rule.
707
+ const abs = resolve(cwd);
708
+ const slug = abs.replace(/[^a-zA-Z0-9]/g, "-");
709
+ if (slug.length <= CC_SLUG_MAX) return slug;
710
+ return `${slug.slice(0, CC_SLUG_MAX)}-${Math.abs(ccPathHash(abs)).toString(36)}`;
711
+ }
712
+
713
+ /**
714
+ * Candidate memory-file paths on the walk from `cwd` up to the filesystem root.
715
+ *
716
+ * Both CLIs load their memory file the same way — nearest-first from the
717
+ * working directory outwards — and differ only in what the file is CALLED and
718
+ * in what else they add on top, so the walk is written once here and the two
719
+ * scanners below supply their own names. `rels` is a list of paths RELATIVE to
720
+ * each directory on the walk rather than bare filenames, because CC also honours
721
+ * `.claude/CLAUDE.md` at every level and Codex does not.
722
+ *
723
+ * Sixteen levels is the same depth this has always used: deep enough for any
724
+ * real checkout, shallow enough that a cwd on a network mount cannot turn one
725
+ * context read into an unbounded number of stat() calls.
726
+ *
727
+ * Returns paths without touching the disk. Statting them is collectMemoryFiles'
728
+ * job, so a caller that wants to add its own paths — a user-global file, a
729
+ * per-project memory directory — can splice them into one ordered list and get
730
+ * a single de-duplicated, existence-checked answer back.
731
+ */
732
+ function memoryWalkPaths(cwd, rels) {
733
+ const out = [];
734
+ let dir = resolve(cwd);
735
+ for (let depth = 0; depth < 16; depth++) {
736
+ for (const rel of rels) out.push(join(dir, rel));
737
+ const parent = pdirname(dir);
738
+ if (parent === dir) break;
739
+ dir = parent;
740
+ }
741
+ return out;
742
+ }
743
+
744
+ /**
745
+ * Which of `paths` are real, non-empty files, in the order given and with
746
+ * duplicates dropped.
747
+ *
748
+ * A zero-byte file is skipped on purpose: it contributes nothing to the model's
749
+ * context, and listing it in the modal would have the reader looking for the
750
+ * bytes it claims to cost. A path that cannot be stat()ed is simply absent —
751
+ * this runs against a tree another process is editing, and a permissions error
752
+ * on one candidate is no reason to lose the other fifteen.
753
+ */
754
+ async function collectMemoryFiles(paths) {
755
+ const found = [];
756
+ const seen = new Set();
757
+ for (const p of paths) {
758
+ if (seen.has(p)) continue;
759
+ seen.add(p);
760
+ try {
761
+ const s = await stat(p);
762
+ if (s.isFile() && s.size > 0) found.push({ path: p, bytes: s.size });
763
+ } catch {}
764
+ }
765
+ return found;
766
+ }
767
+
768
+ /** The memory files a CLAUDE session has in scope. Exported alongside its Codex
769
+ * counterpart below so a test can check the pair together — the two must agree
770
+ * on the walk and disagree on the filename, and only one of them showing up in
771
+ * a test is how they would drift. */
772
+ export async function scanClaudeMdFiles(cwd) {
773
+ if (!cwd || typeof cwd !== "string") return [];
774
+ const home = homedir();
775
+ // Walk up from cwd to filesystem root, checking the canonical CC memory
776
+ // filenames plus CLAUDE.local.md (user-private) at each level.
777
+ const paths = memoryWalkPaths(cwd, [
778
+ "CLAUDE.md",
779
+ "CLAUDE.local.md",
780
+ join(".claude", "CLAUDE.md"),
781
+ join(".claude", "CLAUDE.local.md"),
782
+ ]);
783
+ // User-global memory.
784
+ paths.push(join(home, ".claude", "CLAUDE.md"));
785
+ paths.push(join(home, ".claude", "CLAUDE.local.md"));
786
+ // Per-project auto-memory: ~/.claude/projects/<slug>/memory/*.md
787
+ // (plus MEMORY.md index). CC injects these into context for sessions
788
+ // whose cwd matches the slug.
789
+ const slug = ccProjectSlug(cwd);
790
+ if (slug) {
791
+ const memDir = join(home, ".claude", "projects", slug, "memory");
792
+ try {
793
+ const entries = await readdir(memDir);
794
+ for (const f of entries) {
795
+ if (f.toLowerCase().endsWith(".md")) paths.push(join(memDir, f));
796
+ }
797
+ } catch {}
798
+ }
799
+ return collectMemoryFiles(paths);
800
+ }
801
+
802
+ /**
803
+ * The memory files a CODEX session has in scope: AGENTS.md, not CLAUDE.md.
804
+ *
805
+ * WHY THIS FUNCTION EXISTS AT ALL (#399). The context modal's third section was
806
+ * fed by scanClaudeMdFiles for every session regardless of provider, so the
807
+ * moment the donut became reachable for Codex the modal would have told a Codex
808
+ * user "No CLAUDE.md files found on the path from cwd to ~/.claude" — naming a
809
+ * file and a directory Codex does not read. Before this, `AGENTS.md` did not
810
+ * appear anywhere in this repository.
811
+ *
812
+ * That Codex reads it is not an assumption. Sampled every rollout under this
813
+ * machine's CODEX_HOME (structural search, no record content printed): the
814
+ * literal string `AGENTS.md` appears on 9 lines across 5 of the 8 files — in the
815
+ * `response_item/message` role=user preamble Codex prepends to a turn, in a
816
+ * role=developer message, and in a `world_state` record — while `CLAUDE.md`
817
+ * appears on zero lines in any of them.
818
+ *
819
+ * WHY THE FILESYSTEM AND NOT THE ROLLOUT. The rollout does name the files, but
820
+ * only inside message TEXT, and reading the text of a user's conversation to
821
+ * find a filename is not a trade this deck makes anywhere else — the Claude side
822
+ * has always answered the same question by walking the filesystem, and the two
823
+ * halves of one modal section should be derived the same way or the reader
824
+ * cannot compare them.
825
+ *
826
+ * CODEX_HOME comes from codex-dir.mjs like every other Codex path in the
827
+ * process (#375), so a relocated Codex home is honoured here without this
828
+ * module growing a sixth spelling of the rule.
829
+ *
830
+ * Exported for the tests, like readContextFromTranscript beside it: the rule for
831
+ * which files a session has in scope is worth pinning directly, rather than
832
+ * through a watcher, a temp home and a 1.5s poll.
833
+ */
834
+ export async function scanAgentsMdFiles(cwd) {
835
+ if (!cwd || typeof cwd !== "string") return [];
836
+ // Codex has no `.codex/AGENTS.md` per-directory convention to mirror CC's
837
+ // `.claude/CLAUDE.md`, so the per-level list is the single filename.
838
+ const paths = memoryWalkPaths(cwd, ["AGENTS.md"]);
839
+ // The user-global instructions file, which Codex loads for every session
840
+ // whatever the cwd — the counterpart of ~/.claude/CLAUDE.md.
841
+ paths.push(join(CODEX_HOME, "AGENTS.md"));
842
+ return collectMemoryFiles(paths);
843
+ }
844
+
845
+ function maybeResolveContext(payload) {
846
+ if (!payload || typeof payload !== "object") return;
847
+ const sid = payload.session_id;
848
+ const tp = payload.transcript_path;
849
+ const cwd = payload.cwd;
850
+ if (!sid || !tp) return;
851
+ if (pendingContextReads.has(sid)) return;
852
+ const now = Date.now();
853
+ const last = lastContextReadAt.get(sid) ?? 0;
854
+ if (now - last < CONTEXT_READ_THROTTLE_MS) return;
855
+ lastContextReadAt.set(sid, now);
856
+ pendingContextReads.add(sid);
857
+ Promise.all([readContextFromTranscript(tp), scanClaudeMdFiles(cwd)])
858
+ .then(([breakdown, memoryFiles]) => {
859
+ if (!breakdown && (!memoryFiles || memoryFiles.length === 0)) return;
860
+ pushEvent({
861
+ hook_event_name: "ContextObserved",
862
+ session_id: sid,
863
+ context: {
864
+ ...(breakdown ?? {}),
865
+ memoryFiles: memoryFiles ?? [],
866
+ },
867
+ }, "internal");
868
+ })
869
+ .catch(() => {})
870
+ .finally(() => pendingContextReads.delete(sid));
871
+ }
872
+
873
+ // Throttle state for the Codex half of the same question. Separate maps rather
874
+ // than sharing maybeResolveContext's, because the two run on different triggers
875
+ // — a hook payload there, a batch of appended rollout lines here — and one
876
+ // session cannot be both.
877
+ const lastCodexMemoryReadAt = new Map();
878
+ const pendingCodexMemoryReads = new Set();
879
+
880
+ /**
881
+ * Emit the memory files a Codex session has in scope, throttled per session.
882
+ *
883
+ * WHY THIS IS NOT maybeResolveContext (#399). That function early-returns
884
+ * without `payload.transcript_path`, a field only Claude Code sends, and it
885
+ * would scan for CLAUDE.md if it got that far. It is also unreachable from here
886
+ * on a second count: pushEvent gates all of its enrichment on `source ===
887
+ * "hook"`, and the Codex rollout watcher emits with source "codex" because it is
888
+ * not a hook stream at all. So this is called from the watcher's own scan loop,
889
+ * next to the lazy root, rather than off the back of an event.
890
+ *
891
+ * There is no structural breakdown alongside the file list, and that is the
892
+ * honest answer rather than a gap: the counts the Claude side reports —
893
+ * user/assistant messages, tool uses, tool results, system-reminders — come from
894
+ * a regex scan of a transcript this deck has read from byte zero, and the
895
+ * watcher deliberately SKIPS a pre-existing session's history at startup
896
+ * (`state.offset = st.size`). Counting from the moment the deck attached would
897
+ * produce five confident numbers that are all short by however much of the
898
+ * session happened first, on a panel whose whole purpose is to say what is in
899
+ * the window. ContextModal says so in the slot those counts would have used;
900
+ * see codex-approval.ts for the same move on a different unanswerable question.
901
+ *
902
+ * WHY `persist` IS A PARAMETER (#447). Every other event the rollout watcher
903
+ * produces goes out through emitCodexEvent, which carries the per-batch verdict
904
+ * of writesCodexLog: several decks tailing one rollout all DRAW it, and exactly
905
+ * one of them appends it to the events.jsonl they share. This function pushed
906
+ * with no opts at all, so pushEvent fell back to writesLogFor — and that answers
907
+ * from `foreignSessions`, a set only ever filled by noteLogWriter off an
908
+ * incoming hook POST marked `?persist=0`. A Codex rollout never touches an HTTP
909
+ * handler and Codex hooks are not installed any more (installer.mjs keeps the
910
+ * provider for uninstall only), so a Codex session id can never appear in that
911
+ * set and every deck answered "yes, mine" for this one event. The caller already
912
+ * holds the verdict for the whole batch, so it is threaded in rather than
913
+ * re-derived: recomputing it here would cost a second directory listing and
914
+ * could disagree with the roots and tool calls the same batch just emitted.
915
+ *
916
+ * Omitting the argument keeps the old behaviour — write. That is the same
917
+ * fail-safe writesCodexLog itself takes when a deck cannot find its own
918
+ * discovery record: a line written twice is recoverable, a deck that quietly
919
+ * records nothing is not.
920
+ */
921
+ function maybeResolveCodexMemory(sid, cwd, persist) {
922
+ if (!sid || !cwd) return;
923
+ if (pendingCodexMemoryReads.has(sid)) return;
924
+ const now = Date.now();
925
+ const last = lastCodexMemoryReadAt.get(sid) ?? 0;
926
+ if (now - last < CONTEXT_READ_THROTTLE_MS) return;
927
+ lastCodexMemoryReadAt.set(sid, now);
928
+ pendingCodexMemoryReads.add(sid);
929
+ scanAgentsMdFiles(cwd)
930
+ .then(memoryFiles => {
931
+ // Nothing found is not a fact worth an event: the reducer merges a
932
+ // ContextObserved into whatever the session already had, and an empty list
933
+ // would only ever overwrite a real one with nothing. A repo that grows its
934
+ // first AGENTS.md mid-session is picked up by the next throttled pass.
935
+ if (!memoryFiles.length) return;
936
+ // `persist` false means another deck tailing this same rollout was elected
937
+ // to record it. The event is still buffered and broadcast from here, so
938
+ // every deck's context modal lists the AGENTS.md files — it is only the
939
+ // second copy on disk that is dropped, exactly as emitCodexEvent does it.
940
+ pushEvent({
941
+ hook_event_name: "ContextObserved",
942
+ session_id: sid,
943
+ provider: "codex",
944
+ context: { memoryFiles },
945
+ }, "internal", { persist });
946
+ })
947
+ .catch(() => {})
948
+ .finally(() => pendingCodexMemoryReads.delete(sid));
949
+ }
950
+
951
+ // ─── Codex transcript enrichment ──────────────────────────────────────────
952
+ // Codex CLI hook payloads carry `session_id` but no transcript path. Sessions
953
+ // are persisted to ~/.codex/sessions/YYYY/MM/DD/rollout-<sid>.jsonl with one
954
+ // JSON object per line: {type, payload}. Token usage shows up in
955
+ // {type:"event_msg", payload:{type:"token_count",
956
+ // info:{total_token_usage:{input_tokens, cached_input_tokens,
957
+ // output_tokens, reasoning_output_tokens,
958
+ // total_tokens}}}}
959
+ // We resolve the rollout path lazily (cache sid→path), then read the tail
960
+ // for usage + model. CODEX_HOME overrides ~/.codex, and codex-dir.mjs owns that
961
+ // rule for every module on the Codex side — this one used to spell it inline,
962
+ // which is how five modules ended up with three spellings of it (#375).
963
+ //
964
+ // Re-exported because bin/deck.js prints this path in the boot banner, and it
965
+ // used to build its own `join(homedir(), ".codex", "sessions")` for the purpose
966
+ // — which ignored CODEX_HOME and so named a directory that does not exist on any
967
+ // machine that sets it. Handing out the binding the watcher itself reads, rather
968
+ // than a second computation of the same rule, is what makes the printed path and
969
+ // the tailed path unable to disagree.
970
+ export { CODEX_SESSIONS_DIR };
971
+ const codexRolloutPathBySid = new Map();
972
+ const lastCodexUsageReadAt = new Map();
973
+ const pendingCodexUsageReads = new Set();
974
+ const CODEX_READ_THROTTLE_MS = 2500;
975
+
976
+ async function findCodexRolloutPath(sid) {
977
+ const cached = codexRolloutPathBySid.get(sid);
978
+ if (cached) return cached;
979
+ // Walk year → month → day → files, newest first. Codex includes the sid in the
980
+ // filename (rollout-...-<sid>.jsonl) so a directory-scoped match is enough.
981
+ // The walk itself lives in codex-dir.mjs, shared with the watcher's listing
982
+ // below and with codex-usage.mjs, so all three read one tree the same way.
983
+ let found = null;
984
+ await walkRolloutDays((dayDir, files) => {
985
+ const hit = files.find(f => f.includes(sid) && f.endsWith(".jsonl"));
986
+ if (!hit) return;
987
+ found = join(dayDir, hit);
988
+ return STOP;
989
+ });
990
+ if (found) codexRolloutPathBySid.set(sid, found);
991
+ return found;
992
+ }
993
+
994
+ /** Tail-read a Codex rollout JSONL. Returns the last token_count info block
995
+ * plus the most recent observed model + the session's model_context_window
996
+ * (set once from task_started). */
997
+ async function readCodexRollout(path) {
998
+ try {
999
+ const s = await stat(path);
1000
+ if (s.size === 0) return null;
1001
+ const fh = await open(path, "r");
1002
+ let text;
1003
+ try {
1004
+ const buf = Buffer.alloc(s.size);
1005
+ await fh.read(buf, 0, s.size, 0);
1006
+ text = buf.toString("utf8");
1007
+ } finally {
1008
+ await fh.close();
1009
+ }
1010
+ let lastUsage = null;
1011
+ let model = null;
1012
+ let contextWindow = null;
1013
+ let cwd = null;
1014
+ for (const line of text.split("\n")) {
1015
+ if (!line) continue;
1016
+ let obj;
1017
+ try { obj = JSON.parse(line); } catch { continue; }
1018
+ const type = obj && obj.type;
1019
+ const pl = obj && obj.payload;
1020
+ if (type === "session_meta" && pl) {
1021
+ if (typeof pl.cwd === "string") cwd = pl.cwd;
1022
+ // session_meta sometimes carries the model in newer Codex versions.
1023
+ if (typeof pl.model === "string") model = pl.model;
1024
+ } else if (type === "event_msg" && pl) {
1025
+ if (pl.type === "token_count" && pl.info && pl.info.total_token_usage) {
1026
+ lastUsage = pl.info.total_token_usage;
1027
+ } else if (pl.type === "task_started" && typeof pl.model_context_window === "number") {
1028
+ contextWindow = pl.model_context_window;
1029
+ }
1030
+ } else if (type === "response_item" && pl && typeof pl.model === "string") {
1031
+ // Fallback model source — response items carry the model id.
1032
+ model = pl.model;
1033
+ }
1034
+ }
1035
+ if (!lastUsage && !model && !contextWindow) return null;
1036
+ return { usage: lastUsage, model, contextWindow, cwd };
1037
+ } catch {
1038
+ return null;
1039
+ }
1040
+ }
1041
+
1042
+ function maybeResolveCodex(payload) {
1043
+ if (!payload || typeof payload !== "object") return;
1044
+ if (payload.provider !== "codex") return;
1045
+ const sid = payload.session_id;
1046
+ if (!sid) return;
1047
+ if (pendingCodexUsageReads.has(sid)) return;
1048
+ const now = Date.now();
1049
+ const last = lastCodexUsageReadAt.get(sid) ?? 0;
1050
+ if (now - last < CODEX_READ_THROTTLE_MS) return;
1051
+ lastCodexUsageReadAt.set(sid, now);
1052
+ pendingCodexUsageReads.add(sid);
1053
+ (async () => {
1054
+ const path = await findCodexRolloutPath(sid);
1055
+ if (!path) return;
1056
+ const r = await readCodexRollout(path);
1057
+ if (!r) return;
1058
+ if (r.usage) {
1059
+ pushEvent({
1060
+ hook_event_name: "UsageObserved",
1061
+ session_id: sid,
1062
+ usage: r.usage,
1063
+ }, "internal");
1064
+ }
1065
+ if (r.model) {
1066
+ pushEvent({
1067
+ hook_event_name: "ModelObserved",
1068
+ session_id: sid,
1069
+ model: r.model,
1070
+ }, "internal");
1071
+ }
1072
+ if (r.contextWindow) {
1073
+ // Piggy-back on the model event with the window — reducer reads
1074
+ // model_context_window directly off any payload.
1075
+ pushEvent({
1076
+ hook_event_name: "ModelObserved",
1077
+ session_id: sid,
1078
+ model: r.model ?? undefined,
1079
+ model_context_window: r.contextWindow,
1080
+ }, "internal");
1081
+ }
1082
+ })()
1083
+ .catch(() => {})
1084
+ .finally(() => pendingCodexUsageReads.delete(sid));
1085
+ }
1086
+
1087
+ // ─── Codex rollout watcher ────────────────────────────────────────────────
1088
+ // Codex CLI hooks never fire on Windows — the elevated/unelevated sandbox
1089
+ // refuses to spawn the hook command (exit 1, child never runs). So instead of
1090
+ // relying on hooks, we tail the rollout JSONL files Codex writes to
1091
+ // ~/.codex/sessions/YYYY/MM/DD/rollout-<ts>-<sid>.jsonl and reconstruct the
1092
+ // agent-dag event stream from them. Each rollout line is one append-only JSON
1093
+ // object {timestamp, type, payload}; we map the relevant ones to the same
1094
+ // synthetic hook payloads the reducer already understands:
1095
+ // session_meta → SessionStart
1096
+ // event_msg/user_message → UserPromptSubmit (Codex ≤ 0.144)
1097
+ // event_msg/item_completed/UserMessage → UserPromptSubmit (Codex ≥ 0.147)
1098
+ // response_item/function_call → PreToolUse
1099
+ // response_item/function_call_output → PostToolUse / PostToolUseFailure,
1100
+ // decided by the outcome line Codex
1101
+ // prepends to the output (codexCallFailed)
1102
+ // event_msg/token_count → UsageObserved, and riding on it the
1103
+ // session's live context occupancy and
1104
+ // the CLI's own window (#399)
1105
+ // event_msg/task_started (+window) → ModelObserved (context window)
1106
+ // event_msg/task_complete → Stop (the turn finished)
1107
+ // event_msg/turn_aborted → Stop (the turn was interrupted)
1108
+ // turn_context / response_item.model → model snapshot (ModelObserved on change)
1109
+ // turn_context.approval_policy → session snapshot, spread onto every
1110
+ // payload below (#398); no event of its own
1111
+ // There is deliberately no SessionEnd here: Codex writes no session-close
1112
+ // record, so the end of a SESSION is still inferred by sweepStaleSessions.
1113
+ // Nor is there anything that maps to `Notification`, and no synthetic one is
1114
+ // invented: Codex writes no approval record to a rollout at all, so the deck
1115
+ // cannot see a Codex session blocked on a human and does not pretend to — see
1116
+ // codex-approval.ts for the evidence and for what is said instead of guessing.
1117
+ // Events are emitted with source "codex" so pushEvent skips the Claude-only
1118
+ // transcript enrichment (which needs transcript_path / hook events) but still
1119
+ // broadcasts them exactly like a hook event, and persists them when this deck is
1120
+ // the one elected to log this rollout — see writesCodexLog. This path is
1121
+ // entirely additive — the Claude hook flow is untouched.
1122
+ const codexFileState = new Map(); // path -> { offset, sid, cwd, skip, seenAt }
1123
+ const codexSessionModel = new Map(); // sid -> last model string
1124
+ // sid -> the `approval_policy` of the newest `turn_context` seen on this
1125
+ // session. See codexObjToPayload for why this is read and what it is NOT used
1126
+ // for; #398 for why the deck holds it at all.
1127
+ const codexSessionApproval = new Map(); // sid -> last approval_policy string
1128
+ // How long a rollout's tail cursor is kept after it stops showing up in the
1129
+ // listing. The listing covers two day-directories, so anything missing from it
1130
+ // is at least a day old and will never be appended to again.
1131
+ const CODEX_STATE_TTL_MS = 10 * 60 * 1000;
1132
+ let codexScanRunning = false;
1133
+ let codexWatchTimer = null;
1134
+ let codexWorkspace = "";
1135
+
1136
+ /**
1137
+ * Every deck registered right now, as its own discovery record spells it.
1138
+ *
1139
+ * These are the files hook.js enumerates; this is the server reading them for
1140
+ * itself, because the rollout watcher has no hook to do it and still has to know
1141
+ * which other decks are tailing the same file into the same log. Dead pids are
1142
+ * ignored rather than unlinked — sweepStaleDiscovery owns that, and a poll
1143
+ * running every 1.5s is no place to be deleting other decks' registrations.
1144
+ */
1145
+ async function readLiveDecks() {
1146
+ const dir = join(claudeConfigDir(), "agent-dag");
1147
+ let files;
1148
+ try { files = await readdir(dir); } catch { return []; }
1149
+ const decks = [];
1150
+ for (const f of files) {
1151
+ if (!f.endsWith(".json")) continue;
1152
+ try {
1153
+ const d = JSON.parse(await readFile(join(dir, f), "utf8"));
1154
+ // Without both numbers there is nothing to group or tie-break on, so the
1155
+ // record cannot take part in an election either way.
1156
+ if (!d || typeof d.pid !== "number" || typeof d.port !== "number") continue;
1157
+ if (!isProcessAlive(d.pid)) continue;
1158
+ decks.push(d);
1159
+ } catch { /* corrupt, or gone between listing and read */ }
1160
+ }
1161
+ return decks;
1162
+ }
1163
+
1164
+ // List rollout files from the newest 2 day-directories. New sessions always
1165
+ // land in today's dir, so this captures live activity without scanning years
1166
+ // of history every tick.
1167
+ async function listRecentCodexRollouts() {
1168
+ const out = [];
1169
+ let dayDirs = 0;
1170
+ await walkRolloutDays((dir, files) => {
1171
+ for (const f of files) if (f.endsWith(".jsonl")) out.push(join(dir, f));
1172
+ // Two day-directories deep is the whole point of this listing: it runs every
1173
+ // tick, and anything older than that is a session no process will append to.
1174
+ if (++dayDirs >= 2) return STOP;
1175
+ });
1176
+ return out;
1177
+ }
1178
+
1179
+ // Read the first complete JSON line of a rollout (the session_meta header)
1180
+ // to learn sid + cwd before we start streaming. The header line can be large
1181
+ // (base_instructions text runs tens of KB), so we read in growing chunks until
1182
+ // we hit the first newline rather than guessing a fixed window.
1183
+ async function readCodexHeader(path) {
1184
+ try {
1185
+ const size = (await stat(path)).size;
1186
+ if (size === 0) return null;
1187
+ const CHUNK = 65536;
1188
+ let upto = Math.min(CHUNK, size);
1189
+ let text = "";
1190
+ for (;;) {
1191
+ text = await readByteRange(path, 0, upto);
1192
+ const nl = text.indexOf("\n");
1193
+ if (nl >= 0) {
1194
+ const obj = JSON.parse(text.slice(0, nl));
1195
+ if (obj && obj.type === "session_meta" && obj.payload) {
1196
+ return { sid: obj.payload.id, cwd: typeof obj.payload.cwd === "string" ? obj.payload.cwd : null };
1197
+ }
1198
+ return null;
1199
+ }
1200
+ if (upto >= size) return null; // no newline in the whole file yet
1201
+ upto = Math.min(upto + CHUNK, size); // grow and retry
1202
+ if (upto > 4 * 1024 * 1024) return null; // 4MB sanity cap on a single line
1203
+ }
1204
+ } catch {}
1205
+ return null;
1206
+ }
1207
+
1208
+ /**
1209
+ * The human's prompt out of a 0.147-era `item_completed` item.
1210
+ *
1211
+ * `item.content` is an array of parts — every UserMessage observed carries a
1212
+ * single `{ type: "text", text, text_elements }` — so the parts are joined
1213
+ * rather than indexed, and a part with no string `text` contributes nothing
1214
+ * instead of printing "undefined" into the prompt the card shows.
1215
+ */
1216
+ function codexItemText(item) {
1217
+ const parts = Array.isArray(item && item.content) ? item.content : [];
1218
+ return parts.map(p => (p && typeof p.text === "string" ? p.text : "")).join("");
1219
+ }
1220
+
1221
+ // Map one parsed rollout object to a synthetic hook payload (or null to skip).
1222
+ // Mutates codexSessionModel and returns { payload, modelEvent } where
1223
+ // modelEvent is an optional ModelObserved to emit first when the model changed.
1224
+ //
1225
+ // Exported for the tests: this is the whole of the Codex translation, and the
1226
+ // lifecycle it produces is worth pinning against the real reducer without
1227
+ // standing up a watcher, a temp home and a 1.5s poll to get at it.
1228
+ export function codexObjToPayload(obj, sid, cwd) {
1229
+ const type = obj && obj.type;
1230
+ const pl = (obj && obj.payload) || {};
1231
+ const model = codexSessionModel.get(sid);
1232
+ // Rides on every payload this function returns, exactly as `model` does and
1233
+ // for the same reason: it is a property of the SESSION rather than of any one
1234
+ // event, the rollout restates it on every turn, and a field spread onto each
1235
+ // payload needs no event of its own and self-heals on the next line the
1236
+ // watcher reads. The reducer stamps it on the root beside `contextWindow`.
1237
+ const approval_policy = codexSessionApproval.get(sid);
1238
+ const base = { session_id: sid, cwd, provider: "codex", approval_policy };
1239
+
1240
+ // Track model from turn_context / response_item before mapping events.
1241
+ if (type === "turn_context") {
1242
+ if (typeof pl.model === "string") codexSessionModel.set(sid, pl.model);
1243
+ // #398: the deck used to read `model` out of this record and drop
1244
+ // everything else, and `approval_policy` was the field that mattered most
1245
+ // among the discarded ones. It is the ONLY recorded fact anywhere in a
1246
+ // rollout that says whether this session is even capable of stopping to ask
1247
+ // a human: at "never" Codex denies an escalation outright rather than
1248
+ // prompting, so a quiet session there is genuinely working, while at
1249
+ // "on-request" / "on-failure" / "untrusted" a quiet one may be parked on a
1250
+ // prompt the deck will never see.
1251
+ //
1252
+ // It is read to SAY that, and deliberately not to infer a block from it —
1253
+ // see codex-approval.ts. Codex persists no approval record of any kind (58
1254
+ // turn_contexts and ~1,100 records across the rollouts on this machine
1255
+ // carry no approval-shaped type; the persist filter keeps every *_end and
1256
+ // drops every request/begin — patch_apply_end with no patch_apply_begin,
1257
+ // web_search_end with no begin, item_completed with no item_started), so a
1258
+ // "waiting" state derived from this plus a pending call would be a guess
1259
+ // wearing the clothes of a measurement.
1260
+ //
1261
+ // Written on the record even when the value is missing or not a string, so
1262
+ // a future Codex that renames or drops the field clears the stale answer
1263
+ // rather than pinning the session to whatever it last said.
1264
+ if (typeof pl.approval_policy === "string" && pl.approval_policy) {
1265
+ codexSessionApproval.set(sid, pl.approval_policy);
1266
+ } else {
1267
+ codexSessionApproval.delete(sid);
1268
+ }
1269
+ return null;
1270
+ }
1271
+ if (type === "response_item" && typeof pl.model === "string") {
1272
+ codexSessionModel.set(sid, pl.model);
1273
+ }
1274
+
1275
+ if (type === "event_msg") {
1276
+ if (pl.type === "user_message") {
1277
+ const prompt = typeof pl.message === "string" ? pl.message : "";
1278
+ return { ...base, hook_event_name: "UserPromptSubmit", prompt, model };
1279
+ }
1280
+ // Codex 0.147 stopped writing `user_message` and writes the same submission
1281
+ // as an `item_completed` carrying a `UserMessage` item instead. The two are
1282
+ // mutually exclusive per CLI version — across the rollouts sampled here the
1283
+ // 0.144 files have `user_message` and no `item_completed` at all, and the
1284
+ // 0.147 files have exactly one `UserMessage` item per turn and no
1285
+ // `user_message` — so handling both names emits one prompt per turn either
1286
+ // way rather than two on either version. The item is the human's typed text
1287
+ // only: the AGENTS.md preamble Codex prepends is written as a bare
1288
+ // `response_item` with role "user" and never gets an item of its own.
1289
+ //
1290
+ // This matters far beyond the prompt text. `UserPromptSubmit` is what puts
1291
+ // a settled root back to `active` (reducer.ts), so on 0.147 it is the ONLY
1292
+ // thing that reopens a session for its second turn — without it the `Stop`
1293
+ // below would trade "live forever" for "done forever", which is not better.
1294
+ if (pl.type === "item_completed" && pl.item && pl.item.type === "UserMessage") {
1295
+ return { ...base, hook_event_name: "UserPromptSubmit", prompt: codexItemText(pl.item), model };
1296
+ }
1297
+ // Codex states THREE numbers on every `token_count` and this branch used to
1298
+ // take one of them (#399). The other two are what the context donut is drawn
1299
+ // from, so the deck rendered no context readout at all for the only provider
1300
+ // that reports its window exactly.
1301
+ //
1302
+ // Measured across every rollout under this machine's CODEX_HOME — 178
1303
+ // `token_count` records, 164 on Codex 0.144.5 and 14 on 0.147.0 — all 178
1304
+ // carry `info.last_token_usage`, `info.total_token_usage` and
1305
+ // `info.model_context_window`. Nothing here is a new read: the line is
1306
+ // already parsed and the object already destructured.
1307
+ //
1308
+ // total_token_usage cumulative SPEND for the session. Every request's
1309
+ // prompt summed, so it counts the cached prefix again
1310
+ // on every turn and passes the context window many
1311
+ // times over inside one session (5,238,700 against a
1312
+ // 258,400 window in the longest file here). Correct
1313
+ // for cost, meaningless as an occupancy figure.
1314
+ //
1315
+ // last_token_usage the MOST RECENT request: `input_tokens` is the whole
1316
+ // conversation Codex sent (it already contains the
1317
+ // cached prefix — see billedInputTokens), plus that
1318
+ // request's completion. `total_tokens` is exactly
1319
+ // input + output on 177 of the 178 records.
1320
+ //
1321
+ // model_context_window the CLI's own ceiling, 258,400 for gpt-5.6 against
1322
+ // the 1,050,000 the static table guesses.
1323
+ //
1324
+ // WHY `last_token_usage.total_tokens` AND NOT `input_tokens`. The prompt-only
1325
+ // figure is the closer analogue of the Claude side, which sums the last usage
1326
+ // block's input + cache_read + cache_creation and leaves the completion out.
1327
+ // The difference is one response — 20 to 2,288 tokens in this sample, under
1328
+ // 1% of the window — and `total_tokens` wins on the case where they diverge
1329
+ // for real: on `thread_rolled_back` (the user rewinding the conversation)
1330
+ // Codex writes a `token_count` whose per-request components are all zero and
1331
+ // whose `last_token_usage.total_tokens` is the RECOMPUTED context size —
1332
+ // 47,355, down from 58,516 — while `total_token_usage` does not move at all,
1333
+ // because no request was made. Codex is using that field as "tokens in the
1334
+ // window", and reading `input_tokens` there would collapse the donut to 0%
1335
+ // at precisely the moment the number changed most.
1336
+ //
1337
+ // The window rides along too. `task_started` below is the only other carrier
1338
+ // and it fires once per turn, so a deck that attached mid-turn — the ordinary
1339
+ // case, since the watcher skips a pre-existing session's history at startup —
1340
+ // had to wait for the next turn before the donut could be scaled against
1341
+ // anything but the wrong static default.
1342
+ if (pl.type === "token_count" && pl.info) {
1343
+ const info = pl.info;
1344
+ const last = info.last_token_usage;
1345
+ const contextTokens = last && typeof last.total_tokens === "number" ? last.total_tokens : undefined;
1346
+ const window = typeof info.model_context_window === "number" ? info.model_context_window : undefined;
1347
+ // A record that states none of the three says nothing, and emitting an
1348
+ // event for it would put an empty envelope in the ring buffer and in the
1349
+ // persisted log for every reader to skip forever.
1350
+ if (!info.total_token_usage && contextTokens === undefined && window === undefined) return null;
1351
+ return {
1352
+ ...base,
1353
+ hook_event_name: "UsageObserved",
1354
+ usage: info.total_token_usage,
1355
+ model,
1356
+ model_context_window: window,
1357
+ context_tokens: contextTokens,
1358
+ };
1359
+ }
1360
+ if (pl.type === "task_started" && typeof pl.model_context_window === "number") {
1361
+ return { ...base, hook_event_name: "ModelObserved", model, model_context_window: pl.model_context_window };
1362
+ }
1363
+ // The end of a turn, which is the only end Codex ever announces. Both names
1364
+ // are one outcome as far as the deck is concerned — the turn is over and
1365
+ // nothing is running — so both settle the root the way Claude's own Stop
1366
+ // hook does. They are also exhaustive: across the rollouts sampled here
1367
+ // every `task_started` is answered by exactly one of the two (54 completes
1368
+ // + 1 abort for 55 starts), so no turn is left open by this mapping and
1369
+ // none is closed twice. `turn_aborted` is the Esc key, and it is the case
1370
+ // that matters most on the deck: pressing Esc is precisely when the user is
1371
+ // watching to confirm the thing stopped.
1372
+ //
1373
+ // Per TURN, not per session, and that is correct: Codex writes no
1374
+ // session-close record at all — a rollout simply stops growing when the
1375
+ // terminal goes away — so `sweepStaleSessions` remains the only thing that
1376
+ // ends a Codex *session*, and it must. What changes is that it now only
1377
+ // ever sees the sessions it was written for: the ones that died without
1378
+ // finishing. A turn that really ended is settled here, at the moment it
1379
+ // ended, with no `reaped` flag, because it was a finish and not a guess.
1380
+ if (pl.type === "task_complete" || pl.type === "turn_aborted") {
1381
+ return { ...base, hook_event_name: "Stop", model };
1382
+ }
1383
+ return null;
1384
+ }
1385
+ if (type === "response_item") {
1386
+ if (pl.type === "function_call") {
1387
+ let input = pl.arguments;
1388
+ try { input = JSON.parse(pl.arguments); } catch {}
1389
+ return { ...base, hook_event_name: "PreToolUse", tool_name: pl.name ?? "tool", tool_input: input, tool_use_id: pl.call_id, model };
1390
+ }
1391
+ if (pl.type === "custom_tool_call") {
1392
+ return { ...base, hook_event_name: "PreToolUse", tool_name: pl.name ?? "tool", tool_input: codexCustomToolInput(pl.name, pl.input), tool_use_id: pl.call_id, model };
1393
+ }
1394
+ // #397: the outcome, not just the fact that an outcome arrived. This used
1395
+ // to hardcode "PostToolUse" for both output types, and the reducer derives
1396
+ // `ok` from the event NAME (`tc.ok = name === "PostToolUse"`) — so `ok` was
1397
+ // structurally incapable of being false on the Codex path and a command
1398
+ // that exited non-zero drew exactly like one that succeeded. Every surface
1399
+ // that reads the flag inherited the lie: the burst dot, the tool row, the
1400
+ // ToolModal styling, the detail-panel error count, and the session
1401
+ // summary's "Errors" stat, which was therefore pinned at 0 for the life of
1402
+ // a Codex session. `PostToolUseFailure` is the name the reducer already
1403
+ // understands (it sets `ok = false` and writes an `errorPreview` from the
1404
+ // response); nothing but this mapper was missing.
1405
+ if (pl.type === "function_call_output" || pl.type === "custom_tool_call_output") {
1406
+ const tool_response = pl.output != null ? parseCodexOutput(pl.output) : undefined;
1407
+ const name = codexCallFailed(pl.output) ? "PostToolUseFailure" : "PostToolUse";
1408
+ return { ...base, hook_event_name: name, tool_use_id: pl.call_id, tool_response, model };
1409
+ }
1410
+ }
1411
+ return null;
1412
+ }
1413
+
1414
+ /**
1415
+ * Wrap a `custom_tool_call`'s raw input under the key that describes what it
1416
+ * actually is.
1417
+ *
1418
+ * WHY THIS IS NOT JUST `{ patch: input }` ANY MORE (#417). Codex's
1419
+ * `custom_tool_call` container carries a bare string and nothing that says what
1420
+ * kind of string it is — the tool's NAME is the only discriminator. When this
1421
+ * branch was written the container had exactly one inhabitant, `apply_patch`,
1422
+ * so it hardcoded `patch` and was right. It is no longer the only inhabitant,
1423
+ * and on 0.147 it is not even the common one:
1424
+ *
1425
+ * CLI custom_tool_call name count
1426
+ * 0.144.5 exec 77
1427
+ * 0.144.5 apply_patch 2
1428
+ * 0.147.0 exec 6
1429
+ *
1430
+ * Eighty-three shell scripts were therefore filed on the deck as patches. The
1431
+ * client's `commandStringOf` reads `cmd` / `command` / `script` and never
1432
+ * `patch`, so no Codex call could show what it ran; `extractFilePath` mean-
1433
+ * while reads `patch` and tried to find a `*** Update File:` header in a
1434
+ * JavaScript program. Keying off the name fixes both directions at once.
1435
+ *
1436
+ * WHY `exec` GETS `script` AND NOT `command`. Because it is not a command. The
1437
+ * `exec` tool takes a small JavaScript program that calls into a `tools.*` API
1438
+ * — 83 of 83 inputs on this machine start with `const `, none parses as JSON,
1439
+ * all are multi-line — and Codex's own result wrapper calls it one, prefixing
1440
+ * the output with "Script completed" / "Script failed" rather than an exit code
1441
+ * (the line #397 reads). Filing a program under `command` would make the deck
1442
+ * draw the first token of the program as the command that ran, which is the
1443
+ * word `const` on every Codex call in the session. `script` is both true and
1444
+ * already understood by the client, which digs the real command out of the
1445
+ * program from there.
1446
+ *
1447
+ * WHY THE FALLBACK IS `input` AND NOT A GUESS. A name this function has never
1448
+ * heard of is a string whose meaning is unknown, and the one thing worse than
1449
+ * showing it under a neutral key is showing it under a confident wrong one —
1450
+ * that is the whole of this bug, repeated. `input` claims nothing; the tool's
1451
+ * own name still reaches the bubble, so an unrecognised Codex tool degrades to
1452
+ * "a tool I cannot read the arguments of" instead of "a patch".
1453
+ */
1454
+ const CODEX_CUSTOM_TOOL_INPUT_KEY = {
1455
+ // A `*** Begin Patch … *** End Patch` document (2/2 observed, both 0.144.5).
1456
+ // Unchanged, and it has to stay unchanged: the client's extractFilePath()
1457
+ // pulls the edited file's path out of `input.patch` for the sub-bubble.
1458
+ apply_patch: "patch",
1459
+ // A JavaScript program. See above.
1460
+ exec: "script",
1461
+ };
1462
+
1463
+ function codexCustomToolInput(name, input) {
1464
+ const key = CODEX_CUSTOM_TOOL_INPUT_KEY[name] ?? "input";
1465
+ return { [key]: input };
1466
+ }
1467
+
1468
+ /**
1469
+ * The text parts of a Codex tool result, in the order Codex wrote them.
1470
+ *
1471
+ * Codex writes the result in two different containers and the deck sees both,
1472
+ * so this is where the difference stops. Across the rollouts sampled here:
1473
+ * `custom_tool_call_output.output` is an ARRAY of `{ type: "input_text", text }`
1474
+ * parts (85/85, on 0.144 and 0.147 alike), and `function_call_output.output` is
1475
+ * a bare string (32/32). The `{ output, metadata }` envelope `parseCodexOutput`
1476
+ * unwraps was written by neither, but it is cheap to keep tolerating and the
1477
+ * unwrapping already lives there, so the string case is routed through it
1478
+ * rather than duplicating the guess.
1479
+ */
1480
+ function codexOutputParts(output) {
1481
+ if (output == null) return [];
1482
+ if (Array.isArray(output)) {
1483
+ return output.map(p => (p && typeof p.text === "string" ? p.text : ""));
1484
+ }
1485
+ const unwrapped = parseCodexOutput(output);
1486
+ return typeof unwrapped === "string" ? [unwrapped] : [];
1487
+ }
1488
+
1489
+ /**
1490
+ * Did this Codex tool call actually fail?
1491
+ *
1492
+ * Codex prepends its own wrapper line to the tool's output and that line — not
1493
+ * any structured field — is where the outcome lives. Verified against every
1494
+ * tool result in this machine's CODEX_HOME:
1495
+ *
1496
+ * 0.144.5 exec "Script completed" 75 "Script failed" 2
1497
+ * 0.147.0 exec "Script completed" 6 (no failure observed)
1498
+ * 0.144.5 apply_patch "Exit code: 0" 2
1499
+ * 0.144.5 exec_command / run — bare string, no wrapper line at all 32
1500
+ *
1501
+ * The two CLI versions spell it IDENTICALLY, which is why one rule covers both
1502
+ * and why this needs no version sniffing: 0.147 renamed the prompt event (see
1503
+ * the `item_completed` branch above) but left the exec wrapper alone.
1504
+ *
1505
+ * Only the FIRST part's FIRST line is read, and that precision is load-bearing
1506
+ * rather than tidiness. The wrapper line is at part index 0 in 85 of 85 results
1507
+ * that have one; the later parts are the command's own stdout, and the command
1508
+ * prints whatever it likes there. On this machine two exec results contain a
1509
+ * line reading "Script error:" in part 1 — output from a script that ran fine
1510
+ * under a wrapper that says "Script completed" — so a rule that scanned every
1511
+ * part would paint two successful calls red. `\r` is stripped because the same
1512
+ * wrapper is written by Codex on Windows.
1513
+ *
1514
+ * Silence means success, deliberately. A result with no wrapper line — every
1515
+ * `function_call_output` on 0.144, and whatever container a future Codex
1516
+ * invents — keeps today's behaviour of mapping to `PostToolUse`. Reporting an
1517
+ * unknown outcome as a failure would trade one wrong colour for another, and
1518
+ * this direction is the recoverable one: a missed failure is a call that draws
1519
+ * as it always has, while a false failure puts a red dot and an "Errors" count
1520
+ * on a session that did nothing wrong.
1521
+ */
1522
+ function codexCallFailed(output) {
1523
+ const first = codexOutputParts(output)[0];
1524
+ if (typeof first !== "string") return false;
1525
+ const head = first.split("\n")[0].replace(/\r$/, "");
1526
+ if (/^Script failed\b/.test(head)) return true;
1527
+ // apply_patch reports itself with an exit code instead of a word. Anything
1528
+ // non-zero is a patch that did not apply.
1529
+ const exit = /^Exit code:\s*(\d+)/.exec(head);
1530
+ if (exit) return Number(exit[1]) !== 0;
1531
+ return false;
1532
+ }
1533
+
1534
+ function parseCodexOutput(raw) {
1535
+ if (typeof raw !== "string") return raw;
1536
+ try {
1537
+ const o = JSON.parse(raw);
1538
+ return (o && typeof o.output === "string") ? o.output : raw;
1539
+ } catch {
1540
+ return raw;
1541
+ }
1542
+ }
1543
+
1544
+ // `persist` is false when another deck tailing this same rollout was elected to
1545
+ // write it to the log they share. The event is still buffered and broadcast —
1546
+ // every deck watching the session draws it — it is only the second copy on disk
1547
+ // that is dropped. See writesCodexLog in log-writer.mjs.
1548
+ function emitCodexEvent(payload, persist) {
1549
+ pushEvent(payload, "codex", { persist });
1550
+ }
1551
+
1552
+ // Emit the SessionStart root exactly once per file, lazily — only when the
1553
+ // session actually produces an event. This keeps long-dead sessions that were
1554
+ // merely on disk at startup from cluttering the canvas with empty roots.
1555
+ function ensureCodexRoot(state, persist) {
1556
+ if (state.rootEmitted) return;
1557
+ state.rootEmitted = true;
1558
+ emitCodexEvent({ session_id: state.sid, cwd: state.cwd, provider: "codex", hook_event_name: "SessionStart" }, persist);
1559
+ }
1560
+
1561
+ async function codexScanOnce(firstRun) {
1562
+ if (codexScanRunning) return;
1563
+ codexScanRunning = true;
1564
+ try {
1565
+ const now = Date.now();
1566
+ // Read at most once per scan, and only from the first rollout that actually
1567
+ // has new bytes: most ticks read nothing and must not pay a directory
1568
+ // listing for the answer to a question nothing is asking.
1569
+ let decksRead = null;
1570
+ const liveDecks = () => (decksRead ??= readLiveDecks());
1571
+ const files = await listRecentCodexRollouts();
1572
+ for (const path of files) {
1573
+ let st;
1574
+ try { st = await stat(path); } catch { continue; }
1575
+ let state = codexFileState.get(path);
1576
+ if (state) state.seenAt = now;
1577
+
1578
+ if (!state) {
1579
+ // New file — read the header for sid + cwd, then decide whether to
1580
+ // capture it. Skip files outside our workspace.
1581
+ const header = await readCodexHeader(path);
1582
+ if (!header || !header.sid) continue; // not ready yet — retry next tick
1583
+ if (!codexCwdInWorkspace(header.cwd, codexWorkspace)) {
1584
+ codexFileState.set(path, { offset: st.size, sid: header.sid, cwd: header.cwd, skip: true, rootEmitted: false, seenAt: now });
1585
+ continue;
1586
+ }
1587
+ state = { offset: 0, sid: header.sid, cwd: header.cwd, skip: false, rootEmitted: false, seenAt: now };
1588
+ codexFileState.set(path, state);
1589
+ if (firstRun) {
1590
+ // On startup, skip a pre-existing session's history entirely — no
1591
+ // root, no replay. Only future appends (a live session that keeps
1592
+ // going) will lazily create the root via ensureCodexRoot.
1593
+ state.offset = st.size;
1594
+ continue;
1595
+ }
1596
+ }
1597
+
1598
+ if (state.skip) { state.offset = st.size; continue; }
1599
+ if (st.size <= state.offset) continue;
1600
+
1601
+ const text = await readByteRange(path, state.offset, st.size);
1602
+ const lastNl = text.lastIndexOf("\n");
1603
+ if (lastNl < 0) continue; // no complete line yet
1604
+ const consume = text.slice(0, lastNl);
1605
+ state.offset += Buffer.byteLength(consume, "utf8") + 1; // +1 for the \n
1606
+
1607
+ // Decided per file rather than per line: which decks are up, and which of
1608
+ // them tail this rollout, cannot change inside one batch of appended
1609
+ // lines, and the answer must be the same for every event in it — a root
1610
+ // written by one deck and its tool calls by another is worse than either.
1611
+ const persist = !persistPath
1612
+ || writesCodexLog({ decks: await liveDecks(), pid: process.pid, cwd: state.cwd });
1613
+
1614
+ for (const line of consume.split("\n")) {
1615
+ if (!line) continue;
1616
+ let obj;
1617
+ try { obj = JSON.parse(line); } catch { continue; }
1618
+ const prevModel = codexSessionModel.get(state.sid);
1619
+ const payload = codexObjToPayload(obj, state.sid, state.cwd);
1620
+ // If the model changed (turn_context/response_item), surface it.
1621
+ const nowModel = codexSessionModel.get(state.sid);
1622
+ if (nowModel && nowModel !== prevModel) {
1623
+ ensureCodexRoot(state, persist);
1624
+ emitCodexEvent({ session_id: state.sid, cwd: state.cwd, provider: "codex", hook_event_name: "ModelObserved", model: nowModel }, persist);
1625
+ }
1626
+ if (payload) {
1627
+ ensureCodexRoot(state, persist);
1628
+ emitCodexEvent(payload, persist);
1629
+ }
1630
+ }
1631
+
1632
+ // Once per batch of appended lines rather than once per line — the scan
1633
+ // throttles itself per session, but the cheapest call is the one that is
1634
+ // never made, and a batch can be hundreds of lines. Gated on the root
1635
+ // existing so a session the deck has decided not to draw does not cost a
1636
+ // directory walk, and repeated rather than done once at root creation so
1637
+ // an AGENTS.md written after the session started is still found (#399).
1638
+ //
1639
+ // Carries the same `persist` verdict as every emit in the loop above, and
1640
+ // for the same reason: this is one more event about this batch of appended
1641
+ // lines, and a batch whose roots and tool calls went to one deck's log
1642
+ // while its memory list went to every deck's is the split the election
1643
+ // exists to prevent (#447).
1644
+ if (state.rootEmitted) maybeResolveCodexMemory(state.sid, state.cwd, persist);
1645
+ }
1646
+
1647
+ // Rollout files fall out of the newest-2-days listing and never come back,
1648
+ // but their tail cursors used to live as long as the process did. Expire by
1649
+ // "not seen for a while" rather than "absent from this listing": a single
1650
+ // unreadable directory mid-scan would otherwise drop a live file's cursor,
1651
+ // and re-adding it at offset 0 replays that entire rollout as fresh events.
1652
+ for (const [p, s] of codexFileState) {
1653
+ if (now - (s.seenAt ?? 0) > CODEX_STATE_TTL_MS) codexFileState.delete(p);
1654
+ }
1655
+ } catch {
1656
+ /* swallow — watcher must never crash the server */
1657
+ } finally {
1658
+ codexScanRunning = false;
1659
+ }
1660
+ }
1661
+
1662
+ export function startCodexWatcher(workspace) {
1663
+ codexWorkspace = workspace ?? "";
1664
+ // Deliberately not gated on CODEX_SESSIONS_DIR existing. `codex login`
1665
+ // creates ~/.codex, but sessions/ only appears when the first session
1666
+ // starts — and rollout tailing is the only Codex capture path there is, so
1667
+ // bailing out here meant a fresh install had to restart the deck before any
1668
+ // Codex agent ever showed up, while the banner claimed to be watching.
1669
+ // listRecentCodexRollouts returns [] while the directory is missing, so the
1670
+ // poll below is a cheap no-op that doubles as the existence re-check. A
1671
+ // filesystem watch is no help: fs.watch on a missing path throws, and
1672
+ // watching the parent recursively is macOS/Windows-only.
1673
+ //
1674
+ // Initial catalog: create roots for in-progress sessions, skip their
1675
+ // history, then poll for new lines.
1676
+ codexScanOnce(true).catch(() => {});
1677
+ codexWatchTimer = setInterval(() => { codexScanOnce(false).catch(() => {}); }, 1500);
1678
+ if (codexWatchTimer.unref) codexWatchTimer.unref();
1679
+ return codexWatchTimer;
1680
+ }
1681
+
1682
+ /** Envelopes newer than `seq` from the ring buffer, oldest first. */
1683
+ export function eventsSince(seq) {
1684
+ const after = Number(seq) || 0;
1685
+ return events.filter(e => e.seq > after);
1686
+ }
1687
+
1688
+ // ─── Per-session cache expiry ────────────────────────────────────────────
1689
+ // Every enrichment cache above is keyed by session id and nothing ever
1690
+ // removed an entry: a deck left up for weeks — the 24/7 use this thing is
1691
+ // built for — kept a model string, a subagent signature and four read-throttle
1692
+ // stamps for every session it had ever seen, plus the Codex rollout path and
1693
+ // model of each. SessionEnd is not a usable eviction signal (a killed CLI
1694
+ // never sends one, and Codex has no such hook at all), so entries expire by
1695
+ // least-recent use against a cap instead — the same shape pruneTranscriptScans
1696
+ // already uses for its per-path state. The cap sits far above any plausible
1697
+ // number of concurrent sessions, so a live session is never evicted; and an
1698
+ // evicted one that speaks again simply re-reads its transcript.
1699
+ const sessionTouchedAt = new Map(); // sid -> ms of the last event seen
1700
+ const MAX_TRACKED_SESSIONS = 256;
1701
+
1702
+ function forgetSession(sid) {
1703
+ modelBySession.delete(sid);
1704
+ // The two the session-naming work added (#520/#522) and did not list here.
1705
+ // Both are keyed by session id and nothing else ever removed an entry, which
1706
+ // is the exact leak the comment above says this mechanism exists to end —
1707
+ // every sibling cache is capped at MAX_TRACKED_SESSIONS and these two were
1708
+ // not. The functional half is worse than the leak: nameBySession gates the
1709
+ // SessionNamed emit on "has this changed", so a live session evicted past the
1710
+ // cap and then heard from again re-emits its model (modelBySession was
1711
+ // cleared) and never re-emits its name. A tab that connects after the event
1712
+ // ring has rolled past the original SessionNamed shows that session unnamed
1713
+ // for the rest of its life.
1714
+ nameBySession.delete(sid);
1715
+ lastNameReadAt.delete(sid);
1716
+ modelLastReadAt.delete(sid);
1717
+ lastUsageReadAt.delete(sid);
1718
+ lastContextReadAt.delete(sid);
1719
+ codexRolloutPathBySid.delete(sid);
1720
+ lastCodexUsageReadAt.delete(sid);
1721
+ lastCodexMemoryReadAt.delete(sid);
1722
+ codexSessionModel.delete(sid);
1723
+ codexSessionApproval.delete(sid);
1724
+ }
1725
+
1726
+ function touchSession(sid) {
1727
+ if (!sid || typeof sid !== "string") return;
1728
+ // Re-insert so the Map's own insertion order *is* the LRU order and eviction
1729
+ // below is one key read rather than a scan of every session ever seen.
1730
+ sessionTouchedAt.delete(sid);
1731
+ sessionTouchedAt.set(sid, Date.now());
1732
+ while (sessionTouchedAt.size > MAX_TRACKED_SESSIONS) {
1733
+ const oldest = sessionTouchedAt.keys().next().value;
1734
+ sessionTouchedAt.delete(oldest);
1735
+ forgetSession(oldest);
1736
+ }
1737
+ }
1738
+
1739
+ // ─── SSE backpressure ────────────────────────────────────────────────────
1740
+ // A client that stops reading without closing its socket — a frozen tab, a
1741
+ // suspended machine, a stalled `ssh -L` tunnel — never fires 'close' and never
1742
+ // makes write() throw, so the old `try { res.write(line) } catch {}` had no
1743
+ // way to notice it. On loopback nothing times the connection out either, so
1744
+ // every event (tool responses run to megabytes) queued in that socket's write
1745
+ // buffer for as long as the process lived.
1746
+ //
1747
+ // We drop the client rather than the events. EventSource reconnects after the
1748
+ // `retry: 1500` we send on connect and resumes from Last-Event-ID, so the ring
1749
+ // buffer replays whatever it missed. Dropping individual events instead would
1750
+ // leave a hole the resume path cannot even see, the client's last id having
1751
+ // moved past it.
1752
+ //
1753
+ // The ceiling has to clear the largest SINGLE frame the deck can emit, because
1754
+ // one write() of such a frame puts the whole of it in the queue with nothing
1755
+ // having had the chance to drain any of it — a client reading at full speed
1756
+ // looks, for that instant, exactly like a frozen tab. #588 was exactly that
1757
+ // failure: queuedBytes below doubled every reading, so the real ceiling was
1758
+ // 4 MiB and one 4 MiB tool response hung up on every subscribed tab at once.
1759
+ //
1760
+ // So the number is checked against what `POST /api/event` admits rather than
1761
+ // left to feel. handleEventIngest caps a body at 5,000,000 CHARACTERS. The
1762
+ // event is re-serialized before it goes out, and re-serializing a value that
1763
+ // came from JSON.parse of an N-character document cannot exceed N characters —
1764
+ // every escape the output needs was already paid for in the input, and \uXXXX
1765
+ // input comes back shorter — so one frame is at most 5,000,000 characters, plus
1766
+ // this deck's envelope, measured at 127, plus the id/event/data framing. 8 MiB
1767
+ // clears that by a little over 1.6x, and is the number this constant has always
1768
+ // named; what #588 changed is that it now means it.
1769
+ //
1770
+ // Characters, not bytes, which is the one misleading thing left in the name.
1771
+ // writeSse and writeResume write STRINGS, and a Writable with decodeStrings
1772
+ // false — which both an OutgoingMessage and the net.Socket under it are — adds
1773
+ // `chunk.length`, i.e. UTF-16 units, to its queue. Measured on Node 22.14: a
1774
+ // 4,800,000-character Read of CJK text is 14,400,184 bytes on the wire and
1775
+ // `res.writableLength` reports 4,800,311. That is why comparing this against a
1776
+ // character-denominated ingest limit is the right comparison and comparing it
1777
+ // against a byte count would not be — and it is worth stating plainly, because
1778
+ // assuming a unit for writableLength instead of measuring it is the whole
1779
+ // shape of the bug this comment exists to explain. The memory behind a full
1780
+ // buffer is larger than the number says, up to two bytes per unit while it is
1781
+ // held as a string; that is not what the cap is for, which is noticing a client
1782
+ // that has stopped reading at all.
1783
+ //
1784
+ // Exported, with queuedBytes, so the arithmetic can be asserted directly. #588
1785
+ // survived because it could only be observed through a live socket, where the
1786
+ // existing tests' tolerances were wider than the error.
1787
+ export const MAX_CLIENT_BUFFER_BYTES = 8 * 1024 * 1024;
1788
+
1789
+ /**
1790
+ * What this response has accepted and not yet handed to the kernel, in the
1791
+ * units writableLength reports it in — see MAX_CLIENT_BUFFER_BYTES above, which
1792
+ * is the number this is compared against.
1793
+ *
1794
+ * NOT the sum of the two writableLengths, which is what #588 was: Node's
1795
+ * `OutgoingMessage.writableLength` getter is `outputSize + this[kChunkedLength]
1796
+ * + (socket ? socket.writableLength : 0)`, so the socket's queue is already
1797
+ * inside it. For an SSE response, whose outputSize is zero from the moment the
1798
+ * headers flush, the two readings are the same number exactly — measured on
1799
+ * Node 22.14 against a paused reader, `res.writableLength=4194615
1800
+ * socket.writableLength=4194615` — so adding them reported exactly twice the
1801
+ * real backlog and made an 8 MiB constant behave as a 4 MiB one.
1802
+ *
1803
+ * Why max and not simply `res.writableLength`, which is today's whole answer.
1804
+ * That composition is a Node implementation detail and it has moved before, so
1805
+ * the expression is chosen to survive it moving again. Read the two as an
1806
+ * overlapping pair and take the larger:
1807
+ * - composed as it is today, `own` already contains `sock`, so `own >= sock`
1808
+ * and max is `own` — the exact total;
1809
+ * - were the getter to stop including the socket term, `own` for a flushed
1810
+ * SSE response is zero and max is `sock` — again the exact total;
1811
+ * - with the socket detached (`res.socket` null, which happens between the
1812
+ * response ending and the handle being released) max is `own`, the only
1813
+ * reading there is.
1814
+ * Every case is right, and the failure mode if some future composition makes
1815
+ * both terms non-zero and disjoint is under-counting by at most 2x — a client
1816
+ * held a little longer than intended, which is the harmless direction. Summing
1817
+ * fails the other way, and dropping readers that are not behind is the bug.
1818
+ */
1819
+ export function queuedBytes(res) {
1820
+ const own = typeof res.writableLength === "number" ? res.writableLength : 0;
1821
+ const sock = res.socket && typeof res.socket.writableLength === "number" ? res.socket.writableLength : 0;
1822
+ return Math.max(own, sock);
1823
+ }
1824
+
1825
+ /** Hang up on a client we have decided not to keep. `delete` on a response
1826
+ * that never made it into the set — the resume path below hangs up on clients
1827
+ * before they are subscribed — is a harmless no-op. */
1828
+ function dropSse(res) {
1829
+ sseClients.delete(res);
1830
+ // Destroying the socket is what makes the request emit 'close', which is
1831
+ // where the ping interval is cleared.
1832
+ try { res.destroy(); } catch {}
1833
+ try { res.socket?.destroy(); } catch {}
1834
+ }
1835
+
1836
+ /** Write one SSE frame, hanging up on a client too far behind to keep. */
1837
+ function writeSse(res, frame) {
1838
+ try {
1839
+ res.write(frame);
1840
+ if (queuedBytes(res) <= MAX_CLIENT_BUFFER_BYTES) return;
1841
+ } catch { /* already dead — drop it below */ }
1842
+ dropSse(res);
1843
+ }
1844
+
1845
+ // How long a resuming client is given to accept the bytes already queued for
1846
+ // it before the deck concludes it is not reading at all. Generous on purpose:
1847
+ // what it has to work through is a full MAX_CLIENT_BUFFER_BYTES, the link may
1848
+ // be an `ssh -L` tunnel rather than loopback, and dropping a client that is
1849
+ // merely slow costs it the whole replay. A tab that is genuinely frozen will
1850
+ // not accept a byte in any budget, so the only thing a long one buys it is a
1851
+ // few more seconds of holding its own buffer. The environment override exists
1852
+ // so the tests can pin the drop without sitting through the real budget.
1853
+ //
1854
+ // It is a budget per awaited frame, and what that frame waits on is everything
1855
+ // queued ahead of it draining — a full MAX_CLIENT_BUFFER_BYTES, by
1856
+ // construction, since that is what the loop fills to before it stops. So this
1857
+ // states a minimum rate a resuming client has to manage, and #588 doubled that
1858
+ // flush in practice without touching this line: the cap it is sized against was
1859
+ // really 4 MiB and is now the 8 MiB it always said. Still generous — measured
1860
+ // on loopback a full cap flushes in about a quarter of a second.
1861
+ const REPLAY_DRAIN_MS = Number(process.env.AGENTS_DECK_REPLAY_DRAIN_MS) > 0
1862
+ ? Number(process.env.AGENTS_DECK_REPLAY_DRAIN_MS)
1863
+ : 30_000;
1864
+
1865
+ /**
1866
+ * Write one frame of the resume stream under the same ceiling the live path
1867
+ * obeys, waiting rather than dropping when the client is at it. Resolves true
1868
+ * while the client is worth keeping, false once it is not.
1869
+ *
1870
+ * Waiting is the whole difference from writeSse, and the replay is why. The
1871
+ * live path writes one frame per event, so a full buffer there means the
1872
+ * client stopped reading and the only answer is to hang up. Here the burst is
1873
+ * ours: the loop below hands the socket the entire ring buffer in one turn of
1874
+ * the event loop, so even a client reading at full speed sees its buffer fill
1875
+ * — nothing has drained it yet, because nothing could. Dropping on that would
1876
+ * hang up on healthy clients, and hang up on them again every time they came
1877
+ * back: EventSource reconnects with the same Last-Event-ID, meets the same
1878
+ * oversized replay, and is dropped again 1.5 seconds later, forever. So we
1879
+ * stop writing until the socket has taken what it already has, and only give
1880
+ * up on a client that takes nothing at all for REPLAY_DRAIN_MS.
1881
+ *
1882
+ * The wait is on write()'s completion callback rather than on a 'drain' event:
1883
+ * 'drain' fires only after a write that was answered false, so waiting on it
1884
+ * means depending on an answer this call may never have seen. The cap sits far
1885
+ * above the stream's own 16 KiB high-water mark, so by the time queuedBytes is
1886
+ * at the cap the `false` that a 'drain' would eventually answer belongs to some
1887
+ * frame long since written, and its drain may already have come and gone. The
1888
+ * callback fires once this chunk —
1889
+ * and therefore everything queued ahead of it — has reached the OS, which is
1890
+ * exactly the condition being waited for. It also fires, with an error we do
1891
+ * not need to read, if the response is destroyed underneath us, so this cannot
1892
+ * hang on a client that goes away.
1893
+ */
1894
+ async function writeResume(res, frame) {
1895
+ // Below the cap this is the plain write it has always been. The `await` in
1896
+ // the caller costs a microtask and nothing else: the checkpoint drains
1897
+ // before the loop can accept I/O, so no live event can slip between two
1898
+ // replay frames the way it could across a real wait.
1899
+ if (queuedBytes(res) <= MAX_CLIENT_BUFFER_BYTES) {
1900
+ try { res.write(frame); return true; } catch { return false; }
1901
+ }
1902
+ return new Promise(resolve => {
1903
+ let settled = false;
1904
+ const done = ok => {
1905
+ if (settled) return;
1906
+ settled = true;
1907
+ clearTimeout(timer);
1908
+ resolve(ok);
1909
+ };
1910
+ // Unref'd so a client that stopped reading can never be the reason the
1911
+ // process stays alive, and cleared on every path out so a completed replay
1912
+ // leaves no timer behind.
1913
+ const timer = setTimeout(() => done(false), REPLAY_DRAIN_MS);
1914
+ timer.unref?.();
1915
+ try { res.write(frame, () => done(!res.destroyed)); }
1916
+ catch { done(false); }
1917
+ });
1918
+ }
1919
+
1920
+ // What a redacted occurrence of the deck's token is replaced with. A visible
1921
+ // marker rather than an empty string, because the reader of a mangled Bash
1922
+ // output deserves to know the deck did it and why, and because the UI renders
1923
+ // payload strings verbatim — a silent hole would look like a truncated tool
1924
+ // response and get debugged as one.
1925
+ const TOKEN_REDACTED = "[redacted: ccdeck token]";
1926
+
1927
+ /**
1928
+ * Take the deck's own token back out of an event, before anything stores it.
1929
+ *
1930
+ * #366 made HOOK_TOKEN the one credential separating "may read" from "may
1931
+ * mutate", and there is a path that copies it into a store needing no
1932
+ * credential to read. `POST /api/event` is deliberately open — hook/hook.js is
1933
+ * installed outside this package, into the user's ~/.claude, so requiring a
1934
+ * credential there would silence every session whose hook predates that change
1935
+ * — while `GET /api/events`, `GET /events` and events.jsonl are all readable
1936
+ * without one. So an agent session that merely LOOKS at the discovery file puts
1937
+ * the token into the ring buffer, and that is not an exotic act: `cat
1938
+ * ~/.claude/agent-dag/<pid>.json`, a `Read` of it, or a plain `grep -r
1939
+ * ~/.claude` all do it, and CC records both the tool input and the tool
1940
+ * response. From the buffer it is served to exactly the two callers #366 was
1941
+ * written for — the other local UID and the sandboxed subprocess — neither of
1942
+ * which can open the discovery file itself but both of which reach the API. For
1943
+ * them this turned "cannot mutate" back into "can mutate", the token read out
1944
+ * of the event log being the whole of the new gate. What keeps those two out of
1945
+ * the discovery file itself — a mode bit on Unix, the profile directory's ACL
1946
+ * on Windows, where the chmod is a no-op — is spelled out at presentsDeckToken.
1947
+ *
1948
+ * Why this sits in pushEvent and not in handleEventIngest, which is where the
1949
+ * report first put it. Events reach the buffer through more than one door:
1950
+ * handleEventIngest is the hook's, emitCodexEvent is the Codex rollout
1951
+ * watcher's — those events never pass through an HTTP handler at all —
1952
+ * replayLog is the boot replay's, `/api/clear` pushes its own marker, and six
1953
+ * enrichment sites push synthetic events off the back of a transcript read.
1954
+ * Redacting at the ingest handler covers one of those and leaves the next route
1955
+ * somebody adds uncovered. pushEvent is the one point every event passes
1956
+ * through exactly once, so the check is written once and cannot be forgotten.
1957
+ *
1958
+ * Why a walk over the string values rather than
1959
+ * `JSON.stringify(payload).includes(token)`, which is the obvious one-liner.
1960
+ * Measured rather than assumed, on 4.7k real payloads out of a 21MB
1961
+ * events.jsonl (mean 5KB serialized): the walk costs 1.3–3.6 µs per event and
1962
+ * serialize-then-search costs 14–48 µs, the same order as the JSON.parse the
1963
+ * ingest path already pays for the body. Serializing here would also defeat the
1964
+ * deliberate optimization below, which skips serializing ENTIRELY when nothing
1965
+ * is subscribed and nothing is being logged — a headless deck, and the boot
1966
+ * replay of a log that only rotates at 50MB. The walk allocates nothing at all
1967
+ * until it finds something.
1968
+ *
1969
+ * Iterative rather than recursive on purpose. The payload arrives from
1970
+ * JSON.parse of a body capped at 5MB, which is free to nest as deeply as it
1971
+ * likes, and a recursive scan would be a new way to overflow the stack inside
1972
+ * the request listener — where nothing catches it.
1973
+ *
1974
+ * Scope is the token and nothing else in the discovery file. pid, port,
1975
+ * workspace, the log path and startedAt are not credentials — /api/health
1976
+ * already answers with the workspace path — and redacting the workspace would
1977
+ * mangle the `cwd` of every honest event the deck draws.
1978
+ *
1979
+ * What this does NOT catch, stated plainly so nobody mistakes it for more. A
1980
+ * token split across two string fields, or across two events, survives — but
1981
+ * every substring search has that shape, and the halves are individually
1982
+ * worthless: secretEquals is byte-exact, so a partial token authenticates
1983
+ * nothing. Case is not folded for the same reason; HOOK_TOKEN is lowercase hex
1984
+ * and an uppercased copy would not authenticate either.
1985
+ *
1986
+ * And this is not a new oracle. A caller who can POST here and read
1987
+ * /api/events can post a guess and watch whether it comes back redacted, which
1988
+ * tells them precisely what `x-ccdeck-token: <guess>` on any protected route
1989
+ * already tells them — against 256 bits with no structure to search.
1990
+ *
1991
+ * Returns the payload, because a top-level JSON string is a legal body for
1992
+ * `POST /api/event` and a primitive cannot be redacted in place.
1993
+ */
1994
+ function redactDeckToken(raw) {
1995
+ // Read at call time, not at definition time: HOOK_TOKEN is declared far
1996
+ // below this line and nothing calls pushEvent during module evaluation.
1997
+ const token = HOOK_TOKEN;
1998
+ if (typeof raw === "string") return raw.includes(token) ? raw.replaceAll(token, TOKEN_REDACTED) : raw;
1999
+ if (raw === null || typeof raw !== "object") return raw;
2000
+
2001
+ const stack = [raw];
2002
+ while (stack.length > 0) {
2003
+ const node = stack.pop();
2004
+ // Arrays and objects are walked separately because an indexed loop over an
2005
+ // array is markedly cheaper than `for…in` over one, and this runs on every
2006
+ // event: a single PostToolUse response can be an array of thousands.
2007
+ if (Array.isArray(node)) {
2008
+ for (let i = 0; i < node.length; i++) {
2009
+ const v = node[i];
2010
+ if (typeof v === "string") {
2011
+ if (v.includes(token)) node[i] = v.replaceAll(token, TOKEN_REDACTED);
2012
+ } else if (v !== null && typeof v === "object") stack.push(v);
2013
+ }
2014
+ } else {
2015
+ // `for…in` is safe on these: they come from JSON.parse or from an object
2016
+ // literal in this file, so the prototype chain is Object.prototype, which
2017
+ // has nothing enumerable. A `"__proto__"` key in the posted JSON becomes
2018
+ // an OWN data property — JSON.parse does not run the setter — so it both
2019
+ // enumerates here and takes the assignment below as an ordinary write,
2020
+ // leaving the prototype alone.
2021
+ for (const k in node) {
2022
+ const v = node[k];
2023
+ if (typeof v === "string") {
2024
+ if (v.includes(token)) node[k] = v.replaceAll(token, TOKEN_REDACTED);
2025
+ } else if (v !== null && typeof v === "object") stack.push(v);
2026
+ }
2027
+ }
2028
+ }
2029
+ return raw;
2030
+ }
2031
+
2032
+ function pushEvent(raw, source, opts = {}) {
2033
+ // First, before anything below can see it: the deck's own credential does not
2034
+ // belong in a store that is served without one. Every entry point to the
2035
+ // buffer, the SSE fan-out and events.jsonl passes through here, so this is
2036
+ // the single line that keeps it out of all three. See redactDeckToken.
2037
+ raw = redactDeckToken(raw);
2038
+
2039
+ // Synchronous enrichment: if we already know this session's model, stamp
2040
+ // it on the payload so the client's recursive scanner picks it up.
2041
+ if (raw && typeof raw === "object" && raw.session_id && !raw.model) {
2042
+ const modelId = cachedModelId(modelBySession.get(raw.session_id));
2043
+ if (modelId) raw.model = modelId;
2044
+ }
2045
+
2046
+ const seq = nextSeq++;
2047
+ const evt = {
2048
+ seq,
2049
+ epoch: SEQ_EPOCH,
2050
+ receivedAt: opts.receivedAt ?? Date.now(),
2051
+ source,
2052
+ payload: raw,
2053
+ };
2054
+ events.push(evt);
2055
+ if (events.length > MAX_BUFFER) events.splice(0, events.length - MAX_BUFFER);
2056
+
2057
+ // Does this event reach the log at all? Not on a replay (it came from
2058
+ // there), not when the hook told us another deck owns this session's log,
2059
+ // and not when we have no log. Decided before serializing because it is half
2060
+ // of the answer to whether serializing is worth doing.
2061
+ const persisting = persistPath && !opts.replay && opts.persist !== false && writesLogFor(raw);
2062
+
2063
+ // One serialization, shared by both consumers — and skipped entirely when
2064
+ // neither wants it. This used to stringify the whole envelope twice on the
2065
+ // hottest path in the process (once for the SSE frame, once for the persist
2066
+ // line), and built the frame even with nobody subscribed: a headless deck
2067
+ // paid a full stringify per event for a string no one read, and boot replay
2068
+ // — which runs before the listener exists and never broadcasts — paid one
2069
+ // for every line of a log that rotates at 50MB. An event this deck is not
2070
+ // logging is still broadcast, so a subscriber alone is reason enough.
2071
+ const json = (sseClients.size > 0 || persisting) ? JSON.stringify(evt) : null;
2072
+
2073
+ if (sseClients.size > 0) {
2074
+ const line = `id: ${seq}\nevent: hook\ndata: ${json}\n\n`;
2075
+ // writeSse may drop a client mid-loop; deleting from a Set while iterating
2076
+ // it is well defined and skips only the entry removed.
2077
+ for (const res of sseClients) writeSse(res, line);
2078
+ }
2079
+
2080
+ if (persisting) {
2081
+ // Fire-and-forget append. JSONL = newline-delimited JSON, so the whole line
2082
+ // has to reach the file as one write — this used to be `appendFile`, which
2083
+ // splits anything over 512 KiB into separate appends and let another
2084
+ // event land in the middle of a large tool response. See appendLogLine.
2085
+ //
2086
+ // Note this runs AFTER redactDeckToken above, as every path to the log
2087
+ // does: the string being written is the one serialization of the event the
2088
+ // SSE frame also used, and the token was taken out of the payload before
2089
+ // either existed.
2090
+ appendLogLine(persistPath, json + "\n");
2091
+ // Cheap throttled check (every 30s) — only rotates if file > 50MB.
2092
+ maybeRotatePersistFile();
2093
+ }
2094
+
2095
+ // Note the session so the caches the scanners below fill can expire by
2096
+ // least-recent use. Replays are excluded: they fill nothing, and a boot
2097
+ // replay of a log spanning weeks would otherwise churn the whole LRU through
2098
+ // dead session ids before the first live event even arrives.
2099
+ if (!opts.replay && raw && typeof raw === "object") touchSession(raw.session_id);
2100
+
2101
+ // Kick off async transcript scans. Model and usage are both re-read
2102
+ // periodically (throttled to 2.5s per session) so the cost columns track
2103
+ // running totals as the session progresses and late subagent models still
2104
+ // land; ModelObserved is only emitted when the resolved set changes. Both
2105
+ // result in synthetic events.
2106
+ // Provider gates the path: Claude reads transcript_path; Codex reads its
2107
+ // rollout JSONL under ~/.codex/sessions/. The Claude scanners short-circuit
2108
+ // when transcript_path is absent (always the case for Codex hooks).
2109
+ if (source === "hook" && !opts.replay) {
2110
+ if (raw && raw.provider === "codex") {
2111
+ maybeResolveCodex(raw);
2112
+ } else {
2113
+ maybeResolveModel(raw);
2114
+ maybeResolveUsage(raw);
2115
+ maybeResolveContext(raw);
2116
+ maybeResolveSessionName(raw);
2117
+ }
2118
+ }
2119
+
2120
+ return evt;
2121
+ }
2122
+
2123
+ /**
2124
+ * Read the log back into the ring buffer at boot.
2125
+ *
2126
+ * A line that will not parse is skipped rather than thrown on, and that is
2127
+ * deliberate — it is what lets a log damaged by the pre-#446 writer still
2128
+ * replay. Every line before the damage and every line after it is whole, so
2129
+ * stopping at the first bad one would throw away the rest of the session for a
2130
+ * fault that costs one event. Newline framing is what makes the recovery
2131
+ * possible: a torn write leaves the reader resynchronised at the very next
2132
+ * `\n`, with no length prefix to have been lost along with the bytes.
2133
+ *
2134
+ * What changed is that the skip is no longer silent. Before, a deck whose log
2135
+ * had been shredded replayed "mostly" and said nothing anywhere: the largest
2136
+ * tool responses and whatever ordinary event was spliced into them were gone,
2137
+ * with no counter and no line on the terminal. The count below is the only
2138
+ * signal that a log carries damage from a writer that has since been fixed, and
2139
+ * the byte total is what tells the user whether it was one truncated tail from
2140
+ * a kill -9 or a megabyte of shredded tool output.
2141
+ *
2142
+ * Deliberately one line, and deliberately without the path in it: this prints
2143
+ * onto a terminal the deck is about to paint over (see oneLine in term.mjs for
2144
+ * what a multi-line message does there), and the path was already printed at
2145
+ * boot by the caller.
2146
+ */
2147
+ async function replayLog(filePath) {
2148
+ if (!existsSync(filePath)) return 0;
2149
+ let count = 0;
2150
+ let skipped = 0;
2151
+ let skippedBytes = 0;
2152
+ const rl = createInterface({ input: createReadStream(filePath, { encoding: "utf8" }) });
2153
+ for await (const line of rl) {
2154
+ if (!line) continue;
2155
+ try {
2156
+ const evt = JSON.parse(line);
2157
+ if (evt && typeof evt === "object" && evt.payload) {
2158
+ pushEvent(evt.payload, evt.source ?? "replay", { receivedAt: evt.receivedAt, replay: true });
2159
+ count++;
2160
+ }
2161
+ } catch {
2162
+ skipped++;
2163
+ skippedBytes += Buffer.byteLength(line, "utf8");
2164
+ }
2165
+ }
2166
+ if (skipped > 0) {
2167
+ const kb = (skippedBytes / 1024).toFixed(0);
2168
+ console.warn(`${PRODUCT}: skipped ${skipped} unreadable line(s) (${kb}KB) while replaying the event log`);
2169
+ }
2170
+ return count;
2171
+ }
2172
+
2173
+ function send(res, status, body, headers = {}) {
2174
+ res.writeHead(status, {
2175
+ "Content-Type": "application/json; charset=utf-8",
2176
+ "Cache-Control": "no-store",
2177
+ ...headers,
2178
+ });
2179
+ res.end(typeof body === "string" ? body : JSON.stringify(body));
2180
+ }
2181
+
2182
+ async function serveStatic(req, res, url) {
2183
+ // Strip leading slash, default to index.html
2184
+ let rel = url.pathname.replace(/^\/+/, "");
2185
+ if (rel === "" || rel.endsWith("/")) rel = `${rel}index.html`;
2186
+ const filePath = join(WEB_DIST, rel);
2187
+ if (!filePath.startsWith(WEB_DIST)) return send(res, 403, { error: "forbidden" });
2188
+
2189
+ try {
2190
+ const s = await stat(filePath);
2191
+ if (s.isDirectory()) return send(res, 404, { error: "not found" });
2192
+ const buf = await readFile(filePath);
2193
+ res.writeHead(200, {
2194
+ "Content-Type": MIME[extname(filePath).toLowerCase()] ?? "application/octet-stream",
2195
+ "Cache-Control": "no-cache",
2196
+ });
2197
+ res.end(buf);
2198
+ } catch {
2199
+ // SPA fallback to index.html for client-side routes
2200
+ try {
2201
+ const idx = await readFile(join(WEB_DIST, "index.html"));
2202
+ // Same no-cache as the normal path above, which this used to omit. It
2203
+ // matters more since the deck upgrades itself: index.html is the file
2204
+ // naming the hashed bundle, so a heuristically-cached copy sends the tab
2205
+ // back to the OLD assets after an update and the reload achieves nothing.
2206
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-cache" });
2207
+ res.end(idx);
2208
+ } catch {
2209
+ send(res, 404, { error: "ui not built. run `pnpm build` or `npm run build`." });
2210
+ }
2211
+ }
2212
+ }
2213
+
2214
+ /** Collect a request body as a string, capped so a bad client can't fill memory. */
2215
+ function readBody(req, limit = 64_000) {
2216
+ return new Promise((resolve, reject) => {
2217
+ let body = "";
2218
+ req.setEncoding("utf8");
2219
+ req.on("data", c => {
2220
+ body += c;
2221
+ if (body.length > limit) { req.destroy(); reject(new Error("body too large")); }
2222
+ });
2223
+ req.on("end", () => resolve(body));
2224
+ req.on("error", reject);
2225
+ });
2226
+ }
2227
+
2228
+ // How long an oversized POST is drained after it has been refused, so the 413
2229
+ // reaches a poster that is still uploading. Generous next to hook.js's own
2230
+ // one-second budget, and finite so nothing can sit on the socket indefinitely.
2231
+ const OVERSIZE_DRAIN_MS = 10_000;
2232
+
2233
+ // `persist` is false when the hook posted this event to another deck as well
2234
+ // and elected that one to write it to the log they share. The event is still
2235
+ // buffered and broadcast here — every matching deck draws it — it is only the
2236
+ // second copy on disk that is dropped.
2237
+ function handleEventIngest(req, res, persist = true) {
2238
+ let body = "";
2239
+ // Set once the cap is hit, because everything after that point is about an
2240
+ // exchange that is already over: more `data` may still be in flight, and
2241
+ // `end` must not go on to parse the truncated half.
2242
+ let refused = false;
2243
+ req.setEncoding("utf8");
2244
+ req.on("data", c => {
2245
+ if (refused) return;
2246
+ body += c;
2247
+ if (body.length > 5_000_000) {
2248
+ refused = true;
2249
+ body = ""; // nothing will read it now; let it go
2250
+ // ANSWER, rather than vanish. `req.destroy()` on its own tore the socket
2251
+ // down with no status line on it at all, so the poster learned only that
2252
+ // the connection had gone — indistinguishable from a deck that died or
2253
+ // was never there, and nothing in the exchange to tell those apart. `end`
2254
+ // never fires on a destroyed request either, so the handler below got no
2255
+ // second chance to speak. readBody above gets this right already, by
2256
+ // rejecting into a caller that replies.
2257
+ send(res, 413, { error: "event too large" });
2258
+ // Then keep reading, and throw it away. Answering is not enough on its
2259
+ // own, because the poster is still mid-upload when the answer goes out:
2260
+ // hang up now and its next write lands on a dead socket, it aborts with
2261
+ // EPIPE, and the 413 that was already sitting in its receive buffer is
2262
+ // discarded unread — the same disappearance in a different costume.
2263
+ // `Connection: close` is the tempting version of hanging up and has
2264
+ // exactly that effect: Node destroys the socket the moment such a
2265
+ // response flushes. Draining lets the poster finish and then read the
2266
+ // answer, which is the whole point of answering. `body` no longer grows,
2267
+ // so it costs no memory.
2268
+ req.resume();
2269
+ // Bounded, because draining forever is its own denial of service: a
2270
+ // poster that stops without ending would otherwise hold the socket for as
2271
+ // long as it liked.
2272
+ const grace = setTimeout(() => req.destroy(), OVERSIZE_DRAIN_MS);
2273
+ grace.unref?.();
2274
+ req.on("close", () => clearTimeout(grace));
2275
+ }
2276
+ });
2277
+ req.on("end", () => {
2278
+ if (refused) return;
2279
+ let parsed;
2280
+ try { parsed = JSON.parse(body); }
2281
+ catch { return send(res, 400, { error: "invalid json" }); }
2282
+ noteLogWriter(parsed, persist);
2283
+ const evt = pushEvent(parsed, "hook", { persist });
2284
+ send(res, 200, { ok: true, seq: evt.seq });
2285
+ });
2286
+ // Guarded for the same reason `end` is, and more sharply: destroying the
2287
+ // request above is itself what raises this, and answering a second time on a
2288
+ // response already sent throws ERR_HTTP_HEADERS_SENT out of an error handler,
2289
+ // where nothing is waiting to catch it.
2290
+ req.on("error", () => { if (!refused) send(res, 400, { error: "bad request" }); });
2291
+ }
2292
+
2293
+ function handleSse(req, res) {
2294
+ res.writeHead(200, {
2295
+ "Content-Type": "text/event-stream",
2296
+ "Cache-Control": "no-cache, no-transform",
2297
+ "Connection": "keep-alive",
2298
+ "X-Accel-Buffering": "no",
2299
+ });
2300
+ res.write(`retry: 1500\n\n`);
2301
+
2302
+ // A stale or absent id replays the whole ring, and so does a malformed one:
2303
+ // Number("nonsense") is NaN, every `seq <= NaN` is false, and the catch-up
2304
+ // loop below would rather compare against a number.
2305
+ const asked = Number(req.headers["last-event-id"] ?? 0);
2306
+ const lastId = Number.isFinite(asked) ? asked : 0;
2307
+
2308
+ // The replay waits on the socket now, so it can no longer be part of this
2309
+ // synchronous handler. Nothing is waiting on the result here — the response
2310
+ // is already committed to a 200 and its own failure path is to hang up — so
2311
+ // start it, keep the router's contract of returning nothing, and make sure a
2312
+ // rejection ends the stream rather than the process.
2313
+ resumeSse(req, res, lastId).catch(() => dropSse(res));
2314
+ }
2315
+
2316
+ /**
2317
+ * Drain the ring buffer into a newly connected client, then subscribe it.
2318
+ *
2319
+ * Two things had to change when this stopped being one synchronous burst.
2320
+ * `close` is registered before the first frame, because a tab closed mid-replay
2321
+ * has to stop it. And the replay repeats until it reaches the live tail: an
2322
+ * actual wait lets pushEvent run, and an event that lands after we have walked
2323
+ * past its place but before the client is in `sseClients` would otherwise be in
2324
+ * neither stream — a hole the client cannot even ask for again, its last id
2325
+ * having moved past it.
2326
+ */
2327
+ async function resumeSse(req, res, lastId) {
2328
+ let sentThrough = lastId;
2329
+ let ping = null;
2330
+ let closed = false;
2331
+ req.on("close", () => {
2332
+ closed = true;
2333
+ if (ping) clearInterval(ping);
2334
+ sseClients.delete(res);
2335
+ });
2336
+
2337
+ for (;;) {
2338
+ // A snapshot per pass, because a wait lets pushEvent splice the head of
2339
+ // `events` off, and iterating an array being spliced from the front skips
2340
+ // entries. Events evicted that way are gone for this client, which is the
2341
+ // same bargain every resume against a rotated ring already makes.
2342
+ const batch = events.slice();
2343
+ for (const e of batch) {
2344
+ if (e.seq <= sentThrough) continue;
2345
+ if (closed || res.destroyed) return;
2346
+ // Marked with `replay:true` on the envelope so the client can suppress
2347
+ // turn-cleanup side effects (exitAt stamping, autofit churn) until the
2348
+ // live stream takes over. Without this the reducer's UserPromptSubmit
2349
+ // handler treats replayed events as a real new turn — hiding prior-turn
2350
+ // subagents using the event's stale receivedAt, which collides with
2351
+ // wall-clock visibility gates and yields the "nodes appear then vanish"
2352
+ // symptom on refresh.
2353
+ const tagged = { ...e, replay: true };
2354
+ if (!await writeResume(res, `id: ${e.seq}\nevent: hook\ndata: ${JSON.stringify(tagged)}\n\n`)) {
2355
+ return dropSse(res);
2356
+ }
2357
+ sentThrough = e.seq;
2358
+ }
2359
+ // Caught up with the tail as it stands right now. Reached in one pass
2360
+ // unless a wait let new events in, and it terminates for the same reason
2361
+ // the client is still here at all: either it is taking bytes, in which case
2362
+ // loopback outruns any hook, or it is not, in which case writeResume gives
2363
+ // up on it.
2364
+ if (events.length === 0 || events[events.length - 1].seq <= sentThrough) break;
2365
+ }
2366
+
2367
+ if (closed || res.destroyed) return;
2368
+
2369
+ // Sentinel: tells client "ring buffer drained, live stream starts now". It
2370
+ // goes out under the same rule as the frames before it, so a client that has
2371
+ // just been handed a large replay is not hung up on over the last 30 bytes of
2372
+ // it before it has had the chance to read any.
2373
+ //
2374
+ // Subscribing before waiting on that write, rather than after, is what closes
2375
+ // the last hole: writeResume puts the bytes on the socket before it returns,
2376
+ // so the sentinel still precedes every live frame, and an event pushed while
2377
+ // we wait reaches this client through the live fan-out instead of falling
2378
+ // into the gap between the two. If the wait then ends in a drop, dropSse
2379
+ // takes it back out of the set — the same exit writeSse uses.
2380
+ const flushed = writeResume(res, `event: replay-end\ndata: {}\n\n`);
2381
+ sseClients.add(res);
2382
+ // Through writeSse like every other frame: on a client that has stopped
2383
+ // reading, the ping is the one thing still being written between events, and
2384
+ // it is what eventually reveals the socket as unrecoverable.
2385
+ ping = setInterval(() => writeSse(res, `: ping\n\n`), 15000);
2386
+ if (!await flushed) dropSse(res);
2387
+ }
2388
+
2389
+ // True only when a supervisor is listening AND the event log is being written.
2390
+ // Without persistence a restart wipes the canvas irrecoverably — replayLog has
2391
+ // no file to read — so the deck must not offer to do it.
2392
+ let _canRestart = false;
2393
+
2394
+ async function handleVersion(req, res) {
2395
+ const { versionReport } = await import(
2396
+ pathToFileURL(join(PKG_ROOT, "src/server/self-update.mjs")).href
2397
+ );
2398
+ // ?refresh=1 asks npm now rather than reusing the cached answer — what the
2399
+ // version chip does when clicked, for the user who has just published or is
2400
+ // wondering whether the check is working at all.
2401
+ const force = new URL(req.url, "http://localhost").searchParams.get("refresh") === "1";
2402
+ const report = await versionReport({ running: RUNNING_VERSION, pkgRoot: PKG_ROOT, force });
2403
+ // Attached here rather than inside versionReport for the same reason
2404
+ // canRestart is: the report is what this package root says about itself,
2405
+ // while both of these are facts about the process serving it — the command
2406
+ // the user typed lives in this process's environment and argv, not on disk.
2407
+ //
2408
+ // It is deliberately not folded into `name`, which is documented as the
2409
+ // package an upgrade would install and is load-bearing for the update flow.
2410
+ // The two answer different questions and give different answers: `name` is
2411
+ // read off the install on disk and is always known, while this one is the
2412
+ // command that was typed and is silent wherever it cannot be proven — every
2413
+ // Windows global install, and every `npm i -g ccdeck`, whose stub spawns the
2414
+ // deck by absolute path and leaves nothing in argv to read.
2415
+ const invoked = invokedName({ pkgRoot: PKG_ROOT });
2416
+ // The second half of that notice, computed here rather than in the browser.
2417
+ //
2418
+ // The banner needs one more fact than the name: whether typing `ccdeck` is
2419
+ // enough (a global install ships all three commands, so it already is) or
2420
+ // whether the whole answer is `npx ccdeck`. The browser used to derive that
2421
+ // from `upgradeMode`, which reads like the same question and is not — it
2422
+ // answers "may this copy install over itself", and `AGENTS_DECK_NO_INSTALL=1`
2423
+ // makes it null for EVERY shape including npx, because upgradeBlockedReason
2424
+ // tests the opt-out before it tests npx. So an npx user who opted out of
2425
+ // installs was told the global-install line and sent to a command that does
2426
+ // not exist on their machine, while the terminal row two feet away said
2427
+ // `npx ccdeck` — one fact, two surfaces, opposite answers (#363).
2428
+ //
2429
+ // renameNotice is where that fact is decided for the terminal, so it decides
2430
+ // it here too and the browser renders the string rather than a branch. No
2431
+ // `dash` argument on purpose: the glyph tier is a terminal concern, and the
2432
+ // default em dash is what a browser should get.
2433
+ const rename = renameNotice({ invoked, pkgRoot: PKG_ROOT });
2434
+ send(res, 200, {
2435
+ ...report, canRestart: _canRestart, invokedAs: invoked, renameFix: rename?.fix ?? null,
2436
+ });
2437
+ }
2438
+
2439
+ // Runs `npm i -g <this deck>@latest`, and only that: the argument vector is
2440
+ // fixed inside self-update.mjs — including which of the three published names
2441
+ // it installs, which comes from the layout npm built rather than from the
2442
+ // request. Answers immediately — progress is read back from /api/version.
2443
+ async function handleUpgrade(_req, res) {
2444
+ const { startUpgrade } = await import(
2445
+ pathToFileURL(join(PKG_ROOT, "src/server/self-update.mjs")).href
2446
+ );
2447
+ const out = startUpgrade({ pkgRoot: PKG_ROOT });
2448
+ send(res, out.ok ? 200 : 409, out);
2449
+ }
2450
+
2451
+ // Restart is a two-party act: this half answers before it stops listening, so
2452
+ // the caller learns it was accepted rather than losing the socket mid-reply.
2453
+ //
2454
+ // `{ upgrade: true }` asks for the npx variant: come back through
2455
+ // `npx -y <spec>@latest` instead of re-running the files already here. Granted
2456
+ // only where that is genuinely how this copy updates — the mode is decided from
2457
+ // the install on disk, never from the request.
2458
+ async function handleRestart(req, res) {
2459
+ if (!_onRestart) return send(res, 501, { ok: false, reason: "unsupervised" });
2460
+ // Enforced here and not only in the UI. Under --no-persist a restart destroys
2461
+ // the whole canvas — replayLog has no file to read — and a destructive act
2462
+ // must not be prevented by a hidden button alone.
2463
+ if (!_canRestart) return send(res, 409, { ok: false, reason: "no_persist" });
2464
+
2465
+ let wantUpgrade = false;
2466
+ if (req.method === "POST") {
2467
+ const body = await readBody(req).catch(() => null);
2468
+ try { wantUpgrade = JSON.parse(body ?? "")?.upgrade === true; } catch { /* a plain restart */ }
2469
+ }
2470
+ let mode = null;
2471
+ if (wantUpgrade) {
2472
+ const { upgradeBlock, upgradeMode, npxRestartSpec } = await import(
2473
+ pathToFileURL(join(PKG_ROOT, "src/server/self-update.mjs")).href
2474
+ );
2475
+ if (upgradeMode(upgradeBlock(PKG_ROOT)) !== "npx" || !npxRestartSpec(PKG_ROOT)) {
2476
+ return send(res, 409, { ok: false, reason: "not_npx" });
2477
+ }
2478
+ mode = "npx";
2479
+ }
2480
+
2481
+ if (_restarting) return send(res, 202, { ok: true, already: true });
2482
+ _restarting = true;
2483
+ // Accepted either way — the ask is good and the launcher holds on to it — but
2484
+ // the two are not the same event and answering 200 to both would be this
2485
+ // window's second untruth rather than its first. Between this listener
2486
+ // accepting its first connection and bin/deck.js finishing the rest of its
2487
+ // startup there is a real stretch during which a restart cannot be run yet,
2488
+ // and a caller that reads the body should be able to tell that it is waiting
2489
+ // on a boot rather than on a supervisor. See markDeckReady, and requestRestart
2490
+ // in bin/deck.js for the half that does the waiting.
2491
+ if (_deckReady) send(res, 200, { ok: true, mode });
2492
+ else send(res, 202, { ok: true, mode, booting: true, detail: "the deck is still starting up; the restart runs as soon as it has finished booting" });
2493
+ // Let the response flush before the listener goes away.
2494
+ setTimeout(() => {
2495
+ try { _onRestart(mode); }
2496
+ catch (err) {
2497
+ // This catch is where #448 lived: it released the server's half of the
2498
+ // latch and said nothing, while the launcher's half stayed set with
2499
+ // nothing left to clear it, and every later restart answered "ok" and did
2500
+ // nothing for the rest of the process's life. The launcher now owns its
2501
+ // own failures; this stays as the outer net, and it says so — one line,
2502
+ // message only, because the terminal underneath is repainted every 800ms
2503
+ // by the pulse and a stack dumped into it is a stack nobody can read.
2504
+ _restarting = false;
2505
+ console.error(`${PRODUCT}: restart request failed: ${err?.message ?? err}`);
2506
+ }
2507
+ }, 120).unref();
2508
+ }
2509
+
2510
+ /** The other end of the latch above, for the upgrade that never happened.
2511
+ *
2512
+ * An npx upgrade is now fetched before anything is torn down, so a fetch that
2513
+ * fails leaves this process serving — with `_restarting` still set from a
2514
+ * restart that is not coming. Nothing would ever clear it: the only path that
2515
+ * did was the process ending. Every later click, from any tab, answered 202
2516
+ * "already restarting" for the rest of the deck's life. */
2517
+ export function releaseRestart() {
2518
+ _restarting = false;
2519
+ }
2520
+
2521
+ /** The launcher saying its boot is over: everything a shutdown would have to
2522
+ * tear down now exists.
2523
+ *
2524
+ * This server starts accepting from inside startServer, before that call has
2525
+ * even returned to bin/deck.js — so /api/restart is answerable for the whole
2526
+ * of the startup that follows it, which on a cold boot includes the discovery
2527
+ * file's first fsynced write and spawning the browser. The listener cannot see
2528
+ * any of that from here; it has to be told. Called once, from bin/deck.js.
2529
+ *
2530
+ * Only /api/restart reads it, and only to answer honestly. Nothing is refused
2531
+ * on the strength of it: the restart is still handed to the launcher, which
2532
+ * holds it until it can run it (#448). */
2533
+ export function markDeckReady() {
2534
+ _deckReady = true;
2535
+ }
2536
+
2537
+ async function handleQuota(req, res) {
2538
+ const { fetchClaudeQuota } = await import(
2539
+ pathToFileURL(join(PKG_ROOT, "src/server/quota.mjs")).href
2540
+ );
2541
+ const url = new URL(req.url, "http://localhost");
2542
+ const force = url.searchParams.get("refresh") === "1";
2543
+ const quota = await fetchClaudeQuota({ force });
2544
+ send(res, 200, quota);
2545
+ }
2546
+
2547
+ async function handleCodexUsage(req, res) {
2548
+ const { fetchCodexUsage } = await import(
2549
+ pathToFileURL(join(PKG_ROOT, "src/server/codex-usage.mjs")).href
2550
+ );
2551
+ const url = new URL(req.url, "http://localhost");
2552
+ const force = url.searchParams.get("refresh") === "1";
2553
+ const usage = await fetchCodexUsage({ force });
2554
+ send(res, 200, usage);
2555
+ }
2556
+
2557
+ async function handleCodexQuota(req, res) {
2558
+ const { fetchCodexQuota } = await import(
2559
+ pathToFileURL(join(PKG_ROOT, "src/server/codex-quota.mjs")).href
2560
+ );
2561
+ const url = new URL(req.url, "http://localhost");
2562
+ const force = url.searchParams.get("refresh") === "1";
2563
+ const quota = await fetchCodexQuota({ force });
2564
+ send(res, 200, quota);
2565
+ }
2566
+
2567
+ /**
2568
+ * ccusage's `--since`/`--until` grammar: a `YYYYMMDD` calendar date, or nothing
2569
+ * at all — an absent parameter lets ccusage.mjs pick the default 30-day range.
2570
+ *
2571
+ * Eight digits is a shape, not a calendar, and deliberately so. What this gate
2572
+ * is for is keeping anything that is not a date out of a child process's
2573
+ * argument vector; `99999999` is exactly as inert an argument as `20260101`,
2574
+ * and whether a date outside the logs means an empty chart is ccusage's
2575
+ * question to answer rather than the deck's to guess at.
2576
+ */
2577
+ export const isCliDate = (v) => v === undefined || /^\d{8}$/.test(v);
2578
+
2579
+ async function handleCcusage(req, res) {
2580
+ const url = new URL(req.url, "http://localhost");
2581
+ const force = url.searchParams.get("refresh") === "1";
2582
+ const since = url.searchParams.get("since") || undefined;
2583
+ const until = url.searchParams.get("until") || undefined;
2584
+ // Refused here, at the boundary, because this is where a string chosen by
2585
+ // someone else enters: /api/ccusage is a GET, so it needs no CORS, no
2586
+ // preflight and no ability to read the response — any page the user has open
2587
+ // can fire it at the loopback port — and both values end up in the argv of a
2588
+ // spawned CLI. The spawn no longer goes through a shell (see ccusage.mjs), so
2589
+ // this is the second lock rather than the only one, and it is the one that
2590
+ // keeps the pair honest if a shell is ever reintroduced downstream.
2591
+ if (!isCliDate(since) || !isCliDate(until)) {
2592
+ return send(res, 400, {
2593
+ ok: false,
2594
+ reason: "bad_range",
2595
+ error: "since and until must be YYYYMMDD dates, e.g. 20260101",
2596
+ });
2597
+ }
2598
+ const { fetchCcusageDaily } = await import(
2599
+ pathToFileURL(join(PKG_ROOT, "src/server/ccusage.mjs")).href
2600
+ );
2601
+ const data = await fetchCcusageDaily({ since, until, force });
2602
+ send(res, 200, data);
2603
+ }
2604
+
2605
+ async function handleClaudeAccounts(req, res) {
2606
+ const { fetchClaudeAccounts } = await import(
2607
+ pathToFileURL(join(PKG_ROOT, "src/server/claude-accounts.mjs")).href
2608
+ );
2609
+ const url = new URL(req.url, "http://localhost");
2610
+ const force = url.searchParams.get("refresh") === "1";
2611
+ send(res, 200, await fetchClaudeAccounts({ force }));
2612
+ }
2613
+
2614
+ async function handleClaudeAccountSwitch(req, res) {
2615
+ const { switchClaudeAccount, invalidateClaudeAccountsCache } = await import(
2616
+ pathToFileURL(join(PKG_ROOT, "src/server/claude-accounts.mjs")).href
2617
+ );
2618
+ const body = await readBody(req).catch(() => null);
2619
+ let parsed = null;
2620
+ try { parsed = JSON.parse(body ?? ""); } catch { /* handled below */ }
2621
+ if (!parsed || typeof parsed !== "object") return send(res, 400, { ok: false, reason: "bad_request" });
2622
+
2623
+ const result = await switchClaudeAccount(parsed.account);
2624
+ // The active account just moved; the next poll should see it immediately
2625
+ // rather than serving the pre-switch roster for another few seconds.
2626
+ invalidateClaudeAccountsCache();
2627
+ // The 5h/7d percentages moved with it — they are read for whichever account is
2628
+ // active — and only this side knows: /api/quota is polled by the usage panel,
2629
+ // which has no idea the accounts panel just switched and so never sends
2630
+ // ?refresh=1. Without this the two panels sit on one screen disagreeing about
2631
+ // the same account for a full poll cycle, and the stale one is the one the
2632
+ // user just acted on. Only on a switch that took: a refused one left the
2633
+ // account where it was, and throwing away a reading we paid for costs a real
2634
+ // answer to fix nothing.
2635
+ if (result.ok) {
2636
+ const { invalidateQuotaCache } = await import(
2637
+ pathToFileURL(join(PKG_ROOT, "src/server/quota.mjs")).href
2638
+ );
2639
+ invalidateQuotaCache();
2640
+ }
2641
+ send(res, result.ok ? 200 : 400, result);
2642
+ }
2643
+
2644
+ function cswapAdminModule() {
2645
+ return import(pathToFileURL(join(PKG_ROOT, "src/server/cswap-admin.mjs")).href);
2646
+ }
2647
+
2648
+ // Reading the login's progress. The browser polls this while its dialog is
2649
+ // open, the same way the upgrade notice polls /api/version.
2650
+ async function handleAccountLoginState(_req, res) {
2651
+ const { loginState } = await cswapAdminModule();
2652
+ send(res, 200, { ok: true, ...loginState() });
2653
+ }
2654
+
2655
+ /**
2656
+ * Everything that changes the account store, behind one verb switch — the shape
2657
+ * handleCswapAutoAction already uses.
2658
+ *
2659
+ * The sign-in code is the one field here that is a credential. It is read out
2660
+ * of the body, handed straight to the child's stdin, and never logged, echoed
2661
+ * back, or written anywhere — which is also why this is a POST body and not a
2662
+ * query parameter.
2663
+ */
2664
+ async function handleClaudeAccountAdmin(req, res) {
2665
+ const admin = await cswapAdminModule();
2666
+ const body = await readBody(req).catch(() => null);
2667
+ let parsed = null;
2668
+ try { parsed = JSON.parse(body ?? ""); } catch { /* handled below */ }
2669
+ if (!parsed || typeof parsed !== "object") return send(res, 400, { ok: false, reason: "bad_request" });
2670
+
2671
+ let result;
2672
+ switch (parsed.action) {
2673
+ case "login": result = await admin.startLogin({ email: parsed.email }); break;
2674
+ case "login-code": result = await admin.submitLoginCode(parsed.code); break;
2675
+ case "login-cancel": result = await admin.cancelLogin(); break;
2676
+ case "share": result = await admin.shareAccount(parsed.account); break;
2677
+ case "import": result = await admin.importAccount(parsed.blob); break;
2678
+ case "remove": result = await admin.removeAccount(parsed.account); break;
2679
+ case "alias": result = await admin.setAlias(parsed.account, parsed.alias); break;
2680
+ case "move": result = await admin.moveAccount(parsed.account, parsed.slot); break;
2681
+ default: return send(res, 400, { ok: false, reason: "unknown_action" });
2682
+ }
2683
+ send(res, result.ok ? 200 : 400, result);
2684
+ }
2685
+
2686
+ function cswapAutoModule() {
2687
+ return import(pathToFileURL(join(PKG_ROOT, "src/server/cswap-auto.mjs")).href);
2688
+ }
2689
+
2690
+ async function handleCswapAuto(req, res) {
2691
+ const { autoStatus } = await cswapAutoModule();
2692
+ send(res, 200, await autoStatus());
2693
+ }
2694
+
2695
+ /**
2696
+ * One POST for every auto-switch control, keyed by `action`. Each one can move
2697
+ * the user's live Claude account or change when it moves, so nothing here is
2698
+ * reachable by GET.
2699
+ */
2700
+ async function handleCswapAutoAction(req, res) {
2701
+ const mod = await cswapAutoModule();
2702
+ const body = await readBody(req).catch(() => null);
2703
+ let parsed = null;
2704
+ try { parsed = JSON.parse(body ?? ""); } catch { /* handled below */ }
2705
+ if (!parsed || typeof parsed !== "object") return send(res, 400, { ok: false, reason: "bad_request" });
2706
+
2707
+ let result;
2708
+ switch (parsed.action) {
2709
+ case "enable":
2710
+ result = await mod.setAutoEnabled(parsed.enabled === true);
2711
+ break;
2712
+ case "setting":
2713
+ result = await mod.setCswapConfig(String(parsed.key ?? ""), parsed.value);
2714
+ break;
2715
+ case "account":
2716
+ result = await mod.setAccountEnabled(parsed.account, parsed.enabled === true);
2717
+ break;
2718
+ default:
2719
+ return send(res, 400, { ok: false, reason: "unknown_action" });
2720
+ }
2721
+ send(res, result.ok ? 200 : 400, result);
2722
+ }
2723
+
2724
+ async function handleSoundHook(req, res) {
2725
+ const { soundHookStatus } = await import(
2726
+ pathToFileURL(join(PKG_ROOT, "src/server/sound-hook.mjs")).href
2727
+ );
2728
+ send(res, 200, await soundHookStatus());
2729
+ }
2730
+
2731
+ async function handleSoundHookSet(req, res) {
2732
+ const { setSoundHook, restoreParkedSoundHooks } = await import(
2733
+ pathToFileURL(join(PKG_ROOT, "src/server/sound-hook.mjs")).href
2734
+ );
2735
+ const body = await readBody(req).catch(() => null);
2736
+ let parsed = null;
2737
+ try { parsed = JSON.parse(body ?? ""); } catch { /* handled below */ }
2738
+ if (!parsed || typeof parsed !== "object") return send(res, 400, { ok: false, reason: "bad_request" });
2739
+ if (parsed.action === "restore") return send(res, 200, await restoreParkedSoundHooks());
2740
+ send(res, 200, await setSoundHook(parsed.enabled === true));
2741
+ }
2742
+
2743
+ // The tree this deck was told to capture, "" when it captures the whole
2744
+ // machine. Set by startServer and never changed afterwards.
2745
+ //
2746
+ // The browser had no way to learn it: the launcher prints the scope on stdout
2747
+ // and the hook reads it out of the discovery file, but nothing put it in an
2748
+ // HTTP response — so the deck's own empty state guessed, and guessed wrong for
2749
+ // everyone who passed --workspace or --scope. Health is where it belongs: it
2750
+ // already answers "which deck am I talking to", and it is the one route the UI
2751
+ // can read before a single event has arrived.
2752
+ let _workspace = "";
2753
+
2754
+ // Which CLIs this deck is actually watching. Decided in bin/deck.js — from
2755
+ // whether each one is on the machine, and from --claude/--no-claude and
2756
+ // --codex/--no-codex — and passed in here, because the browser had no way to
2757
+ // learn it and so drew both sides of the UI on every machine.
2758
+ //
2759
+ // That is the whole of #402 and its mirror. A Codex-only machine got the
2760
+ // accounts panel open on first run, telling it to sign into a CLI it does not
2761
+ // have; a Claude-only machine permanently carried "Quota unavailable. / Run
2762
+ // codex login to authenticate." Same missing fact, two directions.
2763
+ //
2764
+ // Defaults are both true, which is what an older deck effectively reported by
2765
+ // saying nothing — and the browser reads a missing field as "could not say" and
2766
+ // shows both, so the two agree.
2767
+ let _providers = { claude: true, codex: true };
2768
+
2769
+ /**
2770
+ * The one spelling of `--workspace` everything downstream compares against.
2771
+ * Empty — including a value that is nothing but spaces — stays empty, which is
2772
+ * how every reader of it says "machine-wide".
2773
+ *
2774
+ * Called once, in bin/deck.js, where the flag arrives: that process's cwd is the
2775
+ * shell the user typed the command in, and it is the only process on either
2776
+ * capture path whose cwd is the one a relative `--workspace ./sub` was written
2777
+ * against. The raw string used to go straight into the discovery file, and
2778
+ * hook.js resolved it inside its own process — which the host CLI runs with the
2779
+ * AGENT's cwd — so `--workspace ./sub` scoped Claude sessions to a `sub`
2780
+ * directory under whatever the agent happened to be working in, a different
2781
+ * directory per agent and none of them the one asked for. The Codex path
2782
+ * resolved the same string in the server process and was right. Resolving here
2783
+ * makes both of them right, and for the same reason bin/deck.js already resolves
2784
+ * the events log before publishing it: what goes in the discovery file is read
2785
+ * by other processes that cannot reconstruct the context it was written in.
2786
+ *
2787
+ * Symlinks are resolved too, because a process's cwd — which is what both
2788
+ * providers report — comes from getcwd() and has none left in it. Without this,
2789
+ * `--workspace /tmp/proj` on a Mac is scoped to /tmp/proj while every session
2790
+ * inside it reports /private/tmp/proj, and the deck stays empty. A path that
2791
+ * does not exist yet keeps its resolved form rather than failing: scoping a deck
2792
+ * to a directory you are about to create is not an error.
2793
+ */
2794
+ export function canonicalWorkspace(raw) {
2795
+ if (typeof raw !== "string" || raw.trim() === "") return "";
2796
+ const abs = resolve(raw);
2797
+ try { return realpathSync(abs); } catch { return abs; }
2798
+ }
2799
+
2800
+ function handleHealth(_req, res) {
2801
+ send(res, 200, {
2802
+ ok: true,
2803
+ name: "agent-dag",
2804
+ seq: nextSeq - 1,
2805
+ clients: sseClients.size,
2806
+ uptimeMs: Math.round(process.uptime() * 1000),
2807
+ workspace: _workspace,
2808
+ providers: _providers,
2809
+ });
2810
+ }
2811
+
2812
+ // A secret this process, and only this process, knows. It goes into the
2813
+ // discovery file bin/deck.js writes (mode 0600) and is never sent anywhere:
2814
+ // hook/hook.js reads it from that file and asks us to hash it against a nonce
2815
+ // before it will send us a single session payload.
2816
+ //
2817
+ // Fresh per start, deliberately. A discovery file that outlives its deck —
2818
+ // SIGKILL and power cuts both leave one behind — names a pid the OS is free to
2819
+ // hand to something else and a port that by then may belong to anything. The
2820
+ // pid probe below cannot tell that apart from a running deck, so the token is
2821
+ // what actually distinguishes us: the replacement process does not have it, and
2822
+ // neither does the next deck, so a stale file authenticates nothing.
2823
+ const HOOK_TOKEN = randomBytes(32).toString("hex");
2824
+
2825
+ /** The token this deck expects to be challenged on. Written by writeDiscovery. */
2826
+ export function hookToken() { return HOOK_TOKEN; }
2827
+
2828
+ /**
2829
+ * The proof of knowing `token`, for a nonce the challenger chose.
2830
+ *
2831
+ * hook/hook.js spells this out a second time — it is installed outside the
2832
+ * package and cannot import from here — and a test pins the two against each
2833
+ * other. Changing one without the other silently blinds the deck.
2834
+ */
2835
+ export function challengeProof(token, nonce) {
2836
+ return createHash("sha256").update(`${token}:${nonce}`).digest("hex");
2837
+ }
2838
+
2839
+ // GET /api/hook-challenge?nonce=… — answer a hook's challenge.
2840
+ //
2841
+ // The nonce is the caller's, so the answer proves knowledge of the token
2842
+ // without disclosing it, and proves it for this exchange only. Answering
2843
+ // freely is what the exchange requires: the hook is asking whether the process
2844
+ // on this port is the deck that wrote the discovery file, and it asks precisely
2845
+ // because it does not yet know — a deck that demanded credentials before
2846
+ // answering could not be told apart from a stranger that refuses.
2847
+ //
2848
+ // So this route is an oracle, and it must stay one. That makes its answer
2849
+ // useless as a credential FOR this server, and nothing here may ever accept it
2850
+ // as one: a caller who can GET this can obtain a valid proof for any nonce, so
2851
+ // a gate honouring proofs is a gate honouring anybody. See presentsDeckToken,
2852
+ // which takes the token itself and refuses the hashed form for this reason.
2853
+ //
2854
+ // What the free answer does NOT give away is the token: the response is a
2855
+ // one-way hash of it, and after the read gate above a rebound page cannot see
2856
+ // even that.
2857
+ function handleHookChallenge(_req, res, url) {
2858
+ const nonce = url.searchParams.get("nonce") ?? "";
2859
+ if (!nonce || nonce.length > 256) return send(res, 400, { error: "bad nonce" });
2860
+ send(res, 200, { proof: challengeProof(HOOK_TOKEN, nonce) });
2861
+ }
2862
+
2863
+ function isProcessAlive(pid) {
2864
+ try { process.kill(pid, 0); return true; }
2865
+ catch (e) { return e && e.code === "EPERM"; }
2866
+ }
2867
+
2868
+ async function sweepStaleDiscovery() {
2869
+ // Same directory the installer writes and the hooks read — see claude-dir.mjs.
2870
+ const dir = join(claudeConfigDir(), "agent-dag");
2871
+ let files;
2872
+ try { files = await readdir(dir); } catch { return 0; }
2873
+ let removed = 0;
2874
+ for (const f of files) {
2875
+ if (!f.endsWith(".json")) continue;
2876
+ const p = join(dir, f);
2877
+ try {
2878
+ const d = JSON.parse(await readFile(p, "utf8"));
2879
+ if (d && typeof d.pid === "number" && !isProcessAlive(d.pid)) {
2880
+ await unlink(p).catch(() => {});
2881
+ removed++;
2882
+ }
2883
+ } catch { /* corrupt — leave it */ }
2884
+ }
2885
+ return removed;
2886
+ }
2887
+
2888
+ function randomPort(min, max) {
2889
+ return Math.floor(Math.random() * (max - min + 1)) + min;
2890
+ }
2891
+
2892
+ // Parse a request target into a URL, or null when it cannot be parsed.
2893
+ //
2894
+ // The base is the constant "http://localhost", never the Host header. Node's
2895
+ // HTTP parser hands the header through verbatim, so a client sending
2896
+ // `Host: bad host` used to produce `new URL("/", "http://bad host")`, which
2897
+ // throws ERR_INVALID_URL synchronously inside the request listener. Nothing
2898
+ // catches that — it becomes an uncaughtException, the worker exits 1 and the
2899
+ // supervisor tears the whole deck down for a single malformed request. Only
2900
+ // the path and the query are ever read from this URL, so the authority half
2901
+ // is free to be a constant — which is what every other new URL call site in
2902
+ // this file already does. Anything else unparseable answers 400 instead.
2903
+ export function requestUrl(rawUrl) {
2904
+ try { return new URL(rawUrl ?? "/", "http://localhost"); }
2905
+ catch { return null; }
2906
+ }
2907
+
2908
+ // Is this request allowed to be answered at all, whatever it asks for?
2909
+ //
2910
+ // The rebinding gate below was reached only by mutations, on the reasoning that
2911
+ // a cross-site page cannot read a loopback reply. That is true of a genuinely
2912
+ // cross-origin page and false of a rebound one: the browser resolved
2913
+ // attacker.example to 127.0.0.1 itself, so it calls the reply same-origin and
2914
+ // hands the body to the page. Every read was therefore open to exactly the
2915
+ // attack the mutation gate was written to stop — and the reads are where the
2916
+ // secrets are. GET /api/events is the whole ring buffer: prompt text, the Bash
2917
+ // command lines the agent ran, the paths and contents it wrote, the contents of
2918
+ // every file it read back. /api/claude-accounts names the accounts,
2919
+ // /api/claude-accounts/login carries a live OAuth authorize URL, /api/sound-hook
2920
+ // the user's own hook command lines, /api/health the absolute workspace path.
2921
+ //
2922
+ // So the Host check runs for every method now. What it asks is only the
2923
+ // rebinding question — did this request arrive addressed to a name that can
2924
+ // only ever be this machine — and it asks it of browser-shaped requests alone,
2925
+ // meaning anything carrying an Origin or fetch metadata. A client sending
2926
+ // neither is not a page and has no ambient authority to borrow: that is
2927
+ // hook/hook.js, a plain Node http.request from the user's own machine, and it
2928
+ // keeps reaching the deck under whatever name it used before.
2929
+ //
2930
+ // Deliberately not part of this: the Sec-Fetch-Site test that isTrustedMutation
2931
+ // applies. `cross-site` on a read is an ordinary top-level navigation — a link
2932
+ // to http://localhost:4317 clicked on any page — and the document it loads is
2933
+ // the deck's own UI on the deck's own origin, which is not an attack and used
2934
+ // to work. Rebinding does not need that test either: a rebound page's requests
2935
+ // report `same-origin`, and it is the Host that gives it away.
2936
+ export function isTrustedRead({ origin, host, secFetchSite } = {}) {
2937
+ const browserShaped = (typeof origin === "string" && origin !== "")
2938
+ || (typeof secFetchSite === "string" && secFetchSite.trim() !== "");
2939
+ if (!browserShaped) return true;
2940
+ return isLoopbackHost(host);
2941
+ }
2942
+
2943
+ // Is this mutating request allowed to be acted on?
2944
+ //
2945
+ // The deck binds 127.0.0.1, which sounds private but is reachable from every
2946
+ // page the user's browser has open: a cross-site POST with a CORS-safelisted
2947
+ // `Content-Type: text/plain` fires no preflight, so any visited page could
2948
+ // remove a Claude account, import an attacker-crafted one, switch the live
2949
+ // account, flip auto-switching, start a global npm upgrade, restart the deck
2950
+ // or truncate the event log. None of those need to read the response, so the
2951
+ // same-origin policy alone never stopped them.
2952
+ //
2953
+ // Two headers decide it, and both are set by the browser itself — page script
2954
+ // cannot forge either, they are forbidden header names:
2955
+ //
2956
+ // Sec-Fetch-Site present on every modern-browser request. `same-origin`
2957
+ // (our own UI) and `none` (the user typed the URL) pass;
2958
+ // `cross-site` and `same-site` are exactly the attack and
2959
+ // are refused. Absent on older Safari, hence the second half.
2960
+ // Origin present on every browser POST, including same-origin ones.
2961
+ // It must name this very server: comparing it to the Host
2962
+ // header the browser filled in from the target URL. A page
2963
+ // on http://evil.com — or on http://localhost:8000, which is
2964
+ // just as cross-origin — cannot make the two agree. The
2965
+ // opaque `null` origin (sandboxed iframe, data: URL) fails
2966
+ // to parse and is refused with it.
2967
+ //
2968
+ // Agreement between the two is necessary but not sufficient, because both are
2969
+ // derived from the URL the page was served from and neither says a word about
2970
+ // the address the socket actually landed on — so the Host must also name a
2971
+ // loopback identity. See isLoopbackHost for the attack that gets through
2972
+ // without it.
2973
+ //
2974
+ // A request carrying neither header is not a browser request and is allowed
2975
+ // whatever Host it names: that is hook/hook.js, a plain Node http.request from
2976
+ // the user's own machine that sends no Origin at all, plus curl and the deck's
2977
+ // own tooling. Ambient browser authority is the whole threat here, and those
2978
+ // clients have none — a process that can POST here can already run anything as
2979
+ // the user, and nothing it sends is chosen by a page.
2980
+ export function isTrustedMutation({ origin, host, secFetchSite } = {}) {
2981
+ const site = typeof secFetchSite === "string" ? secFetchSite.trim().toLowerCase() : "";
2982
+ if (site && site !== "same-origin" && site !== "none") return false;
2983
+ const hasOrigin = typeof origin === "string" && origin !== "";
2984
+ if (!hasOrigin && !site) return true;
2985
+ // Either header present means a browser sent this, so the rebinding gate
2986
+ // applies even to the shape that carries fetch metadata but no Origin. Every
2987
+ // request reaching this line is browser-shaped by the test just above, so
2988
+ // isTrustedRead here is exactly its isLoopbackHost half.
2989
+ if (!isTrustedRead({ origin, host, secFetchSite })) return false;
2990
+ return hasOrigin ? originMatchesHost(origin, host) : true;
2991
+ }
2992
+
2993
+ // Was this request addressed to a name that can only ever be this machine?
2994
+ //
2995
+ // Origin === Host alone is not a defence against DNS rebinding: the page is
2996
+ // served from http://attacker.example:4317, the attacker re-points that record
2997
+ // at 127.0.0.1, and the browser then sends Host: attacker.example:4317 with a
2998
+ // matching Origin and Sec-Fetch-Site: same-origin — every header self-consistent
2999
+ // and every one attacker-chosen, because fetch metadata comes from the origin
3000
+ // tuple (scheme, host, port), not from the resolved IP. The browser also treats
3001
+ // the reply as same-origin, so the page reads the body: that is the account
3002
+ // share envelope, with the OAuth token in it. A loopback literal has no DNS
3003
+ // record to re-point, and `localhost` is reserved to loopback, so requiring one
3004
+ // of them is what separates the deck's own UI from the rebound page.
3005
+ function isLoopbackHost(host) {
3006
+ if (typeof host !== "string") return false;
3007
+ const authority = host.trim().toLowerCase();
3008
+ // A real Host header is bare authority. Userinfo or a path would let
3009
+ // `evil.example@127.0.0.1` and `127.0.0.1/…` parse to a loopback hostname
3010
+ // while naming something else, so refuse them rather than reason about them.
3011
+ if (authority === "" || /[/\\?#@\s]/.test(authority)) return false;
3012
+ let name;
3013
+ try { name = new URL(`http://${authority}`).hostname; } catch { return false; }
3014
+ if (name === "localhost" || name === "[::1]") return true;
3015
+ // The whole 127.0.0.0/8 is this machine, not just .0.1 — a second deck parked
3016
+ // on 127.0.0.2 is as local as the first. URL only produces a dotted quad for
3017
+ // something it already validated as an IPv4 address, so shape is enough here.
3018
+ return /^127\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(name);
3019
+ }
3020
+
3021
+ // Does `origin` name the same host:port the request was addressed to? Ports are
3022
+ // part of an origin, so http://127.0.0.1:8000 is not http://127.0.0.1:4317 —
3023
+ // and the default port is spelled both ways depending on the client, so it is
3024
+ // normalised off both sides before comparing.
3025
+ function originMatchesHost(origin, host) {
3026
+ if (typeof host !== "string" || host === "") return false;
3027
+ let parsed;
3028
+ try { parsed = new URL(origin); } catch { return false; }
3029
+ const dropDefaultPort = (h, scheme) =>
3030
+ h.replace(scheme === "https:" ? /:443$/ : /:80$/, "");
3031
+ const fromOrigin = dropDefaultPort(parsed.host.toLowerCase(), parsed.protocol);
3032
+ const fromHost = dropDefaultPort(host.trim().toLowerCase(), parsed.protocol);
3033
+ return fromOrigin !== "" && fromOrigin === fromHost;
3034
+ }
3035
+
3036
+ // Mutating routes that ask nothing of the caller beyond the gates above.
3037
+ //
3038
+ // Only the hook's ingest, and only because hook/hook.js is installed OUTSIDE
3039
+ // this package — it lives in the user's ~/.claude and is loaded by whatever
3040
+ // Claude Code session is already running. Requiring a credential here would
3041
+ // stop every event from every session whose hook predates this change, on a
3042
+ // machine where nothing is obviously broken and nothing says why, until each
3043
+ // one is reinstalled. It also carries no credential and destroys nothing: the
3044
+ // worst a caller does with it is draw a session on the canvas that is not
3045
+ // there. See the handshake in hook/hook.js for the authentication that does
3046
+ // run on this path, which is the deck proving itself to the hook.
3047
+ const OPEN_MUTATIONS = new Set(["/api/event"]);
3048
+
3049
+ // Constant-time comparison of two secrets, and a length test that is not.
3050
+ // Lengths differ freely in public — a mismatched one only says "not this
3051
+ // token" — but timingSafeEqual throws rather than answering false when they do.
3052
+ function secretEquals(given, expected) {
3053
+ const a = Buffer.from(String(given ?? ""), "utf8");
3054
+ const b = Buffer.from(String(expected ?? ""), "utf8");
3055
+ if (a.length === 0 || a.length !== b.length) return false;
3056
+ return timingSafeEqual(a, b);
3057
+ }
3058
+
3059
+ // Does this request carry the deck's own token?
3060
+ //
3061
+ // One spelling, `x-ccdeck-token`, carrying the token itself. That is all a
3062
+ // local process needs, because the only way it can hold the token is to have
3063
+ // read it out of a file only it can open.
3064
+ //
3065
+ // The token is HOOK_TOKEN, fresh per deck and written to the discovery file at
3066
+ // mode 0600. That mode is the whole of the access control: the answer to "who
3067
+ // may do this" is "whoever can read that file", which is the user this deck
3068
+ // runs as and nobody else. On Windows the chmod is a best-effort no-op, so
3069
+ // there the file's protection is the per-user ACL on the profile directory it
3070
+ // sits in rather than a mode bit; the token still works identically.
3071
+ //
3072
+ // The hashed challengeProof form is deliberately NOT accepted here, and this
3073
+ // is the paragraph that must be read before anyone adds it. GET
3074
+ // /api/hook-challenge?nonce=… answers challengeProof(HOOK_TOKEN, nonce) to any
3075
+ // caller for any nonce, by design — so accepting a proof as a credential means
3076
+ // the server hands out its own credentials on request, and two unauthenticated
3077
+ // GETs are a complete bypass of everything below. This gate shipped that way
3078
+ // for one commit; the regression test in csrf-origin.test.ts pins it shut.
3079
+ //
3080
+ // The premise that makes the oracle safe is the one this gate rejects, which is
3081
+ // exactly why the two cannot share a secret: the challenge is the deck proving
3082
+ // itself TO the hook, not the hook proving itself to the deck. hook/hook.js
3083
+ // already holds the token — it read the same 0600 file — and challenges the
3084
+ // port to find out whether the process listening there is really the deck that
3085
+ // wrote it, or something else that inherited the port number from a stale
3086
+ // discovery file. Nothing travels in the other direction, so no caller ever
3087
+ // needed the hashed form as a way IN, and a client entitled to mutate holds the
3088
+ // token itself anyway.
3089
+ function presentsDeckToken(headers = {}) {
3090
+ const raw = headers["x-ccdeck-token"];
3091
+ return typeof raw === "string" && secretEquals(raw.trim(), HOOK_TOKEN);
3092
+ }
3093
+
3094
+ // Is this the deck's own page in the user's browser?
3095
+ //
3096
+ // A strict subset of isTrustedMutation, which every request here has already
3097
+ // passed: an Origin must actually be present — a browser sends one on every
3098
+ // POST, including a same-origin one — it must name this very server, the Host
3099
+ // must be a loopback identity, and any fetch metadata must say `same-origin`.
3100
+ // `none` is excluded on purpose: that is a top-level navigation the user typed
3101
+ // or a form submitted from one, never the UI's own fetch.
3102
+ function isDeckUiRequest({ origin, host, secFetchSite } = {}) {
3103
+ const site = typeof secFetchSite === "string" ? secFetchSite.trim().toLowerCase() : "";
3104
+ if (site !== "" && site !== "same-origin") return false;
3105
+ if (typeof origin !== "string" || origin === "") return false;
3106
+ if (!isLoopbackHost(host)) return false;
3107
+ return originMatchesHost(origin, host);
3108
+ }
3109
+
3110
+ // May this request change something?
3111
+ //
3112
+ // Everything above this line is about the browser: whether a page chose the
3113
+ // request, and whether it was addressed to this machine. None of it asks who
3114
+ // the caller is, and for a client that is not a browser at all nothing did —
3115
+ // a request carrying no Origin and no fetch metadata was waved through on the
3116
+ // reasoning that a process able to POST here can already run anything as the
3117
+ // user. That holds on a single-user laptop and fails twice elsewhere. Loopback
3118
+ // is not scoped to a UID, so on a shared box or a multi-tenant container every
3119
+ // other account on the machine can reach this port; and a sandboxed subprocess
3120
+ // denied the credential store but allowed loopback egress — the ordinary shape
3121
+ // of an agent's Bash sandbox — reaches the same credentials through the API.
3122
+ // `curl -XPOST localhost:4317/api/claude-accounts/admin -d '{"action":"share"}'`
3123
+ // answered with the account's live OAuth refresh token in the clear, and the
3124
+ // same request reached account remove and import, the live account switch, a
3125
+ // global `npm i -g`, the restart and the event log.
3126
+ //
3127
+ // So a mutation must now be either of two things:
3128
+ //
3129
+ // - the user's own browser tab, recognised by the headers a page cannot
3130
+ // forge and the deck's own loopback address (isDeckUiRequest), or
3131
+ // - a client holding the deck's token, which it can only have read from the
3132
+ // 0600 discovery file (presentsDeckToken).
3133
+ //
3134
+ // What this does NOT do, stated plainly so nobody mistakes it for more: the
3135
+ // browser clause rests on headers that page script cannot set but any local
3136
+ // program can. A local attacker who adds `Origin` and `Sec-Fetch-Site` to the
3137
+ // request is back through. That is not a gap left by laziness — there is no
3138
+ // fix for it here. Authenticating the tab would mean giving the tab a secret,
3139
+ // and every channel from this process to a browser on the same machine is
3140
+ // readable by any other process on that machine: a token in the served
3141
+ // index.html is read by the same `curl http://localhost:4317/` that the gate
3142
+ // is meant to stop, and a token in the URL the deck opens is read out of the
3143
+ // browser's argv by `ps`. What is closed is the free pass — a request that
3144
+ // presents nothing at all no longer changes anything — and what is opened is
3145
+ // the honest door, so a script of the user's own authenticates by reading the
3146
+ // token instead of impersonating a page.
3147
+ function isAuthorizedMutation(req) {
3148
+ const headers = req?.headers ?? {};
3149
+ if (presentsDeckToken(headers)) return true;
3150
+ return isDeckUiRequest({
3151
+ origin: headers.origin,
3152
+ host: headers.host,
3153
+ secFetchSite: headers["sec-fetch-site"],
3154
+ });
3155
+ }
3156
+
3157
+ // A request handler rejected. Two audiences, two different amounts of detail:
3158
+ // the operator, who needs the whole error — stack included — to find the bug,
3159
+ // and the HTTP client, which needs to know only that the request failed.
3160
+ //
3161
+ // They used to get the same string, on the theory that this server binds
3162
+ // 127.0.0.1 and its only client is the user's own tab. A DNS-rebound page
3163
+ // reaches a loopback server as same-origin and can read the body, and the
3164
+ // errors that land here carry absolute paths out of the user's home directory
3165
+ // — a failed rename of ~/.claude/settings.json, an ENOENT from an import. So
3166
+ // stderr keeps every byte and the response body carries none of it; nothing is
3167
+ // swallowed, it just stops travelling over the wire.
3168
+ export function sendInternalError(res, err, log = console.error) {
3169
+ log(`${PRODUCT}: request handler failed:`, err);
3170
+ if (!res.headersSent) send(res, 500, { error: "internal error" });
3171
+ else res.end();
3172
+ }
3173
+
3174
+ // One attempt, leaving the server with no more listeners on it than it started
3175
+ // with. `server.listen(port, host, cb)` registers `cb` for a 'listening' event
3176
+ // that a failed bind never emits, so the previous shape left one behind per
3177
+ // attempt — eleven candidates printed Node's "MaxListenersExceededWarning:
3178
+ // 11 listening listeners added to [Server]" into the middle of a boot that was
3179
+ // already going wrong. Both sides are removed by whichever fires first.
3180
+ async function tryListen(server, port, host) {
3181
+ return new Promise((res, rej) => {
3182
+ const onListening = () => { server.removeListener("error", onError); res(); };
3183
+ const onError = (err) => { server.removeListener("listening", onListening); rej(err); };
3184
+ server.once("error", onError);
3185
+ server.once("listening", onListening);
3186
+ server.listen(port, host);
3187
+ });
3188
+ }
3189
+
3190
+ /**
3191
+ * Whether another PORT could fix this failed `listen`.
3192
+ *
3193
+ * startServer builds ten random fallback candidates and they exist for exactly
3194
+ * one situation: "this port is unavailable". Until #552 only EADDRINUSE reached
3195
+ * them, which is the POSIX spelling of that answer and not the only one.
3196
+ *
3197
+ * WINDOWS. `winnat` hands out contiguous TCP blocks to Hyper-V, WSL2 and Docker
3198
+ * Desktop, and a bind INSIDE one of those reserved exclusion ranges — the ones
3199
+ * `netsh interface ipv4 show excludedportrange protocol=tcp` prints — is refused
3200
+ * with WSAEACCES, which libuv reports as EACCES. Any machine with containers or
3201
+ * WSL on it can therefore have 4317 blocked without a single socket being open
3202
+ * on it. The loop rethrew on the first candidate, bin/deck.js printed
3203
+ * `server failed: listen EACCES: permission denied 127.0.0.1:4317`, and the deck
3204
+ * exited 1 with the fallback range untouched.
3205
+ *
3206
+ * On POSIX the same code is what a bind below 1024 gets without privilege.
3207
+ * Retrying is right there too: 4317 and the whole fallback range are above 1024,
3208
+ * so a random candidate is a port the user can actually have — and the deck
3209
+ * coming up on 4322 beats it refusing to come up at all, which is already how
3210
+ * EADDRINUSE on a privileged port behaves today.
3211
+ *
3212
+ * The other direction is the half that keeps this honest. EADDRNOTAVAIL,
3213
+ * ENOTFOUND, EAI_AGAIN and EAFNOSUPPORT are about the HOST, not the port: the
3214
+ * address does not exist on this machine or does not resolve, and no candidate
3215
+ * can help. Walking eleven of them only delays the one sentence that would have
3216
+ * explained it, so anything not named here stops the loop.
3217
+ */
3218
+ export const portRetryable = (err) =>
3219
+ Boolean(err) && (err.code === "EADDRINUSE" || err.code === "EACCES");
3220
+
3221
+ /**
3222
+ * The sentence that goes beside a listen errno, or "" when the errno says it
3223
+ * all.
3224
+ *
3225
+ * Pure, and the platform is a parameter, for the reason every Windows answer in
3226
+ * this repo is written that way: the branch that matters most is the one the
3227
+ * author cannot run. A raw `listen EACCES: permission denied 127.0.0.1:4317` is
3228
+ * true and useless — the thing the user needs is the name of the command that
3229
+ * lists the ranges their machine has reserved.
3230
+ */
3231
+ export function listenHint(code, { host = "", port = 0, platform = process.platform } = {}) {
3232
+ if (code === "EACCES") {
3233
+ if (platform === "win32") {
3234
+ return "a reserved port range is the usual cause on Windows: Hyper-V, WSL2 and Docker Desktop have winnat hold contiguous TCP blocks. Run `netsh interface ipv4 show excludedportrange protocol=tcp` to see them, then pass --port with a number outside every range";
3235
+ }
3236
+ if (port > 0 && port < 1024) return "ports below 1024 need root — pass --port with a number above 1024";
3237
+ return "the OS refused the bind; a sandbox or a security policy is the usual cause";
3238
+ }
3239
+ if (code === "EADDRNOTAVAIL") {
3240
+ return `no interface on this machine holds ${host || "that address"}, so no other port can help — pass --host with an address it does hold, or 127.0.0.1 for local only`;
3241
+ }
3242
+ if (code === "ENOTFOUND" || code === "EAI_AGAIN") {
3243
+ return `${host || "that host"} does not resolve, so no other port can help — pass --host with an address rather than a name`;
3244
+ }
3245
+ return "";
3246
+ }
3247
+
3248
+ /**
3249
+ * The error startServer throws, with the hint already in it.
3250
+ *
3251
+ * `exhausted` is the difference between "every candidate was refused" and "this
3252
+ * one was refused for a reason more candidates cannot fix". The exhausted
3253
+ * message keeps its opening words — bin/deck.js prints them and
3254
+ * boot-listen-before-report.test.ts reads them — and now names the LAST errno
3255
+ * rather than asserting EADDRINUSE, which was a lie the moment EACCES could
3256
+ * reach the end of the loop.
3257
+ */
3258
+ export function listenFailure(err, { host = "", port = 0, platform = process.platform, exhausted = false } = {}) {
3259
+ const code = err?.code;
3260
+ const head = exhausted
3261
+ ? `all ports tried — none available (last: ${code ?? "unknown"} on ${host}:${port})`
3262
+ : String(err?.message ?? err ?? "listen failed");
3263
+ const hint = listenHint(code, { host, port, platform });
3264
+ const out = new Error(hint ? `${head} — ${hint}` : head);
3265
+ if (code !== undefined) out.code = code;
3266
+ if (err) out.cause = err;
3267
+ return out;
3268
+ }
3269
+
3270
+ // Set from startServer's options. The server cannot restart itself — the
3271
+ // process lifecycle belongs to the supervisor in bin/agent-dag.js, which is the
3272
+ // only thing that can bring a replacement up on the same port without racing
3273
+ // the dying listener. Absent (running the module directly, or under an older
3274
+ // launcher), /api/restart answers 501 and the UI hides the control rather than
3275
+ // offering a button that does nothing.
3276
+ let _onRestart = null;
3277
+ // A restart is in flight. Several browser tabs watching the same deck will each
3278
+ // ask; the second ask must not re-enter the shutdown.
3279
+ let _restarting = false;
3280
+ // Whether the launcher has finished booting — see markDeckReady. False for the
3281
+ // whole window between this listener accepting its first connection and
3282
+ // bin/deck.js reaching the end of its startup, which is a window /api/restart
3283
+ // is reachable in and cannot answer for on its own.
3284
+ let _deckReady = false;
3285
+
3286
+ export async function startServer({ port = 4317, host = "127.0.0.1", persist = null, portRange = [4318, 4400], workspace = "", codex = true, claude = true, onRestart = null } = {}) {
3287
+ _onRestart = typeof onRestart === "function" ? onRestart : null;
3288
+ _canRestart = _onRestart != null && persist != null;
3289
+ // A new listener is a new boot, whatever a previous one had got as far as
3290
+ // reporting. Nothing but bin/deck.js ever sets this, and it does so once, at
3291
+ // the end of the startup that begins with this call.
3292
+ _deckReady = false;
3293
+ _workspace = typeof workspace === "string" ? workspace : "";
3294
+ // `!== false` rather than a cast: a caller that omits the field means "yes",
3295
+ // which is how every embedder that predates this option keeps working.
3296
+ _providers = { claude: claude !== false, codex: codex !== false };
3297
+ const removed = await sweepStaleDiscovery();
3298
+ if (removed > 0) console.log(` swept ${removed} stale discovery file(s)`);
3299
+ if (persist) {
3300
+ persistPath = resolve(persist);
3301
+ try { await mkdir(pdirname(persistPath), { recursive: true }); } catch {}
3302
+ const replayed = await replayLog(persistPath);
3303
+ if (replayed > 0) {
3304
+ // Don't broadcast replays as live; SSE clients catch up via Last-Event-ID
3305
+ // already. Just keep the buffer + seq counter primed.
3306
+ }
3307
+ }
3308
+ // The async handlers below are dispatched as floating promises. Node's
3309
+ // default for an unhandled rejection is to kill the process, which would
3310
+ // take the whole deck down — SSE stream, hook ingest and all — because one
3311
+ // background quota poll hit a network error. Answer the request instead.
3312
+ const guard = (p, res) => Promise.resolve(p).catch(err => sendInternalError(res, err));
3313
+
3314
+ const server = createServer((req, res) => {
3315
+ const url = requestUrl(req.url);
3316
+ // Unparseable request target. Nothing below can route it, and throwing here
3317
+ // would be an uncaughtException inside the listener — i.e. the whole deck.
3318
+ if (!url) return send(res, 400, { error: "bad request target" });
3319
+
3320
+ // Three gates in front of the whole table, so every route is covered and
3321
+ // any route added later is covered too, without the author having to
3322
+ // remember. They ask three different questions and they run in the order
3323
+ // that answers the cheapest first.
3324
+
3325
+ // Was this addressed to this machine? Every method, because a rebound page
3326
+ // reads a reply as readily as it writes a request. See isTrustedRead.
3327
+ if (!isTrustedRead({
3328
+ origin: req.headers.origin,
3329
+ host: req.headers.host,
3330
+ secFetchSite: req.headers["sec-fetch-site"],
3331
+ })) {
3332
+ return send(res, 403, { error: "cross-site request blocked" });
3333
+ }
3334
+
3335
+ // Did a page choose this? Mutations only — the same-site read this refuses
3336
+ // is an ordinary navigation. See isTrustedMutation.
3337
+ if (req.method !== "GET" && req.method !== "HEAD" && !isTrustedMutation({
3338
+ origin: req.headers.origin,
3339
+ host: req.headers.host,
3340
+ secFetchSite: req.headers["sec-fetch-site"],
3341
+ })) {
3342
+ return send(res, 403, { error: "cross-site request blocked" });
3343
+ }
3344
+
3345
+ // And who is asking? Refusing by default is the point: a mutating route
3346
+ // added later is protected until someone deliberately lists it as open,
3347
+ // which is the direction this has to fail in. See isAuthorizedMutation.
3348
+ if (req.method !== "GET" && req.method !== "HEAD"
3349
+ && !OPEN_MUTATIONS.has(url.pathname) && !isAuthorizedMutation(req)) {
3350
+ return send(res, 401, { error: "unauthenticated" });
3351
+ }
3352
+
3353
+ // `?persist=0` — another deck was elected to write this event to the log
3354
+ // the two of them share. Absent, this deck writes it.
3355
+ if (req.method === "POST" && url.pathname === "/api/event") return guard(handleEventIngest(req, res, url.searchParams.get("persist") !== "0"), res);
3356
+ if (req.method === "GET" && url.pathname === "/api/health") return handleHealth(req, res);
3357
+ if (req.method === "GET" && url.pathname === "/api/hook-challenge") return handleHookChallenge(req, res, url);
3358
+ if (req.method === "GET" && url.pathname === "/events") return handleSse(req, res);
3359
+ if (req.method === "GET" && url.pathname === "/api/version") return guard(handleVersion(req, res), res);
3360
+ if (req.method === "POST" && url.pathname === "/api/upgrade") return guard(handleUpgrade(req, res), res);
3361
+ if (req.method === "POST" && url.pathname === "/api/restart") return guard(handleRestart(req, res), res);
3362
+ if (req.method === "GET" && url.pathname === "/api/quota") return guard(handleQuota(req, res), res);
3363
+ if (req.method === "GET" && url.pathname === "/api/codex-usage") return guard(handleCodexUsage(req, res), res);
3364
+ // Machine state, not session state: sampled on the server's own timer and
3365
+ // deliberately kept out of the event stream. See src/server/system-metrics.mjs.
3366
+ if (req.method === "GET" && url.pathname === "/api/system") return send(res, 200, systemSnapshot());
3367
+ // On demand only — the process list costs a subprocess on every platform,
3368
+ // so it is fetched while the detail panel is open and never on the timer.
3369
+ if (req.method === "GET" && url.pathname === "/api/system/processes") {
3370
+ return guard(readProcesses().then(procs => send(res, 200, { ok: true, procs })), res);
3371
+ }
3372
+ if (req.method === "GET" && url.pathname === "/api/codex-quota") return guard(handleCodexQuota(req, res), res);
3373
+ if (req.method === "GET" && url.pathname === "/api/ccusage") return guard(handleCcusage(req, res), res);
3374
+ if (req.method === "GET" && url.pathname === "/api/claude-accounts") return guard(handleClaudeAccounts(req, res), res);
3375
+ if (req.method === "POST" && url.pathname === "/api/claude-accounts/switch") return guard(handleClaudeAccountSwitch(req, res), res);
3376
+ if (req.method === "GET" && url.pathname === "/api/claude-accounts/login") return guard(handleAccountLoginState(req, res), res);
3377
+ if (req.method === "POST" && url.pathname === "/api/claude-accounts/admin") return guard(handleClaudeAccountAdmin(req, res), res);
3378
+ if (req.method === "GET" && url.pathname === "/api/sound-hook") return guard(handleSoundHook(req, res), res);
3379
+ if (req.method === "POST" && url.pathname === "/api/sound-hook") return guard(handleSoundHookSet(req, res), res);
3380
+ if (req.method === "GET" && url.pathname === "/api/cswap-auto") return guard(handleCswapAuto(req, res), res);
3381
+ if (req.method === "POST" && url.pathname === "/api/cswap-auto") return guard(handleCswapAutoAction(req, res), res);
3382
+
3383
+ if (req.method === "GET" && url.pathname === "/api/events") {
3384
+ return send(res, 200, eventsSince(url.searchParams.get("since") ?? 0));
3385
+ }
3386
+
3387
+ // POST /api/clear — wipe in-memory buffer + persistence file (UI reset)
3388
+ if (req.method === "POST" && url.pathname === "/api/clear") {
3389
+ events.length = 0;
3390
+ if (persistPath) truncate(persistPath, 0).catch(() => {});
3391
+ // Drop the caches that gate an emit on "has this changed", because the
3392
+ // client is about to forget what they are comparing against: __clear makes
3393
+ // the reducer return a fresh state, so every session's name and every
3394
+ // subagent's model label go with it. maybeResolveSessionName then computes
3395
+ // the same signature, takes its early return, and emits nothing — so the
3396
+ // card falls back to cwd/prompt for the rest of that session while the
3397
+ // server is sitting on the name.
3398
+ //
3399
+ // The root model survives without help because pushEvent stamps
3400
+ // `raw.model` on every payload; there is no equivalent stamp for the name
3401
+ // or for a subagent's model, which is why those two are listed and the
3402
+ // rest of the per-session state is not.
3403
+ //
3404
+ // The rule, for the next cache that gates an emit: anything answering
3405
+ // "has this changed" has to appear in BOTH places that mean the client no
3406
+ // longer has it — here, and in forgetSession.
3407
+ nameBySession.clear();
3408
+ modelBySession.clear();
3409
+ // The read stamps go with them. Clearing only the signatures would leave
3410
+ // the next hook event inside MODEL_READ_THROTTLE_MS, so the transcript
3411
+ // would not be re-read at all and the name would stay missing until the
3412
+ // throttle expired — a clear followed by a keystroke is exactly when a
3413
+ // user is watching.
3414
+ lastNameReadAt.clear();
3415
+ modelLastReadAt.clear();
3416
+ pushEvent({ hook_event_name: "__clear", cwd: "" }, "internal");
3417
+ return send(res, 200, { ok: true });
3418
+ }
3419
+
3420
+ if (req.method === "GET") return serveStatic(req, res, url);
3421
+ send(res, 405, { error: "method not allowed" });
3422
+ });
3423
+
3424
+ // Try requested port first, then up to 10 random ports from portRange.
3425
+ const candidates = [port];
3426
+ for (let i = 0; i < 10; i++) candidates.push(randomPort(portRange[0], portRange[1]));
3427
+
3428
+ // The errno the exhaustion message ends up naming, and the port it happened
3429
+ // on. Kept because the last candidate's reason is the only one still worth
3430
+ // saying by then — the nine before it were random ports nobody asked for.
3431
+ let lastErr = null;
3432
+ let lastPort = port;
3433
+ for (const candidate of candidates) {
3434
+ try {
3435
+ await tryListen(server, candidate, host);
3436
+ // Codex has no working hooks on Windows — tail its rollout files instead.
3437
+ if (codex) startCodexWatcher(workspace);
3438
+ // Both timers are unref'd, so this never holds the process open.
3439
+ startSystemMetrics();
3440
+ // Auto-switch resumes only if the user previously turned it on; the
3441
+ // module reads its own persisted flag and does nothing otherwise.
3442
+ cswapAutoModule().then(m => m.initCswapAuto()).catch(() => {});
3443
+ return server;
3444
+ } catch (err) {
3445
+ lastErr = err;
3446
+ lastPort = candidate;
3447
+ // "This port is unavailable" — which Windows spells EACCES for a port
3448
+ // inside a reserved exclusion range. See portRetryable.
3449
+ if (portRetryable(err)) continue;
3450
+ // Anything else is about the host or the socket, and the next candidate
3451
+ // would fail identically. Say why instead of trying ten more times.
3452
+ throw listenFailure(err, { host, port: candidate });
3453
+ }
3454
+ }
3455
+ throw listenFailure(lastErr, { host, port: lastPort, exhausted: true });
3456
+ }
3457
+
3458
+ // Allow running this file directly for dev (`npm run dev:server`). The port
3459
+ // variable is the CLI's, deliberately: this block spent two renames reading a
3460
+ // name from the project's first identity that no README ever documented, so
3461
+ // the one variable people know worked everywhere except here.
3462
+ //
3463
+ // argv[1] is a string only when node was handed a script path. `node -e`,
3464
+ // `--input-type=module` on stdin and a worker started from eval source all
3465
+ // leave it undefined, and pathToFileURL(undefined) throws instead of answering
3466
+ // false — which fails the whole import, in the one file whose job is to export
3467
+ // startServer. Guard the argument, not the comparison (#481).
3468
+ const entryPath = process.argv[1];
3469
+ if (entryPath && import.meta.url === pathToFileURL(entryPath).href) {
3470
+ const port = Number(process.env.AGENT_DAG_PORT ?? 4317);
3471
+ startServer({ port }).then(s => {
3472
+ const addr = s.address();
3473
+ const p = typeof addr === "object" && addr ? addr.port : port;
3474
+ console.log(`${PRODUCT} server: http://127.0.0.1:${p}`);
3475
+ }).catch(e => {
3476
+ console.error(`${PRODUCT} server failed:`, e.message);
3477
+ process.exit(1);
3478
+ });
3479
+ }