agent-dag 3.22.0 → 3.22.3

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.
Files changed (70) hide show
  1. package/README.md +6 -477
  2. package/package.json +14 -48
  3. package/shim.js +107 -0
  4. package/LICENSE +0 -661
  5. package/LICENSING.md +0 -82
  6. package/THIRD_PARTY_NOTICES.md +0 -395
  7. package/bin/agent-dag.js +0 -626
  8. package/bin/deck.js +0 -1805
  9. package/dist/web/assets/index-3FWd7g_W.css +0 -1
  10. package/dist/web/assets/index-BOwtoP02.js +0 -266
  11. package/dist/web/index.html +0 -49
  12. package/hook/hook.js +0 -542
  13. package/release-notes.json +0 -392
  14. package/src/server/activity.mjs +0 -52
  15. package/src/server/agent-activity.mjs +0 -522
  16. package/src/server/args.mjs +0 -183
  17. package/src/server/auto-update.mjs +0 -79
  18. package/src/server/block-notify.mjs +0 -173
  19. package/src/server/boot-deadline.mjs +0 -127
  20. package/src/server/brand.mjs +0 -16
  21. package/src/server/browser-history.mjs +0 -497
  22. package/src/server/browser-presence.mjs +0 -211
  23. package/src/server/browser-profiles.mjs +0 -279
  24. package/src/server/browser-react.mjs +0 -284
  25. package/src/server/browser-watch-store.mjs +0 -350
  26. package/src/server/browser-watch.mjs +0 -905
  27. package/src/server/ccusage.mjs +0 -1168
  28. package/src/server/claude-accounts.mjs +0 -951
  29. package/src/server/claude-dir.mjs +0 -213
  30. package/src/server/codex-auth.mjs +0 -388
  31. package/src/server/codex-dir.mjs +0 -171
  32. package/src/server/codex-quota.mjs +0 -449
  33. package/src/server/codex-usage.mjs +0 -512
  34. package/src/server/cswap-admin.mjs +0 -1562
  35. package/src/server/cswap-auto.mjs +0 -658
  36. package/src/server/cswap-install.mjs +0 -641
  37. package/src/server/deck-home.mjs +0 -243
  38. package/src/server/deck-prefs.mjs +0 -301
  39. package/src/server/deck-probe.mjs +0 -111
  40. package/src/server/detach.mjs +0 -244
  41. package/src/server/exec.mjs +0 -996
  42. package/src/server/global-install.mjs +0 -67
  43. package/src/server/hwmonitor.mjs +0 -56
  44. package/src/server/index.mjs +0 -6043
  45. package/src/server/installer.mjs +0 -912
  46. package/src/server/invoked-as.mjs +0 -144
  47. package/src/server/lan-about.mjs +0 -119
  48. package/src/server/lan-engine.mjs +0 -952
  49. package/src/server/lan-reach.mjs +0 -256
  50. package/src/server/lan-socket.mjs +0 -682
  51. package/src/server/lan-sync.mjs +0 -941
  52. package/src/server/lhm-parse.mjs +0 -91
  53. package/src/server/log-tail.mjs +0 -139
  54. package/src/server/log-writer.mjs +0 -322
  55. package/src/server/login-service.mjs +0 -473
  56. package/src/server/macmon.mjs +0 -310
  57. package/src/server/npx.mjs +0 -264
  58. package/src/server/open-url.mjs +0 -242
  59. package/src/server/presence.mjs +0 -40
  60. package/src/server/quota.mjs +0 -792
  61. package/src/server/relay-guard.mjs +0 -507
  62. package/src/server/reset-label.mjs +0 -78
  63. package/src/server/retire-sound-hook.mjs +0 -349
  64. package/src/server/running-deck.mjs +0 -234
  65. package/src/server/self-update.mjs +0 -1380
  66. package/src/server/stop-deck.mjs +0 -171
  67. package/src/server/supervisor.mjs +0 -392
  68. package/src/server/system-metrics.mjs +0 -1825
  69. package/src/server/term.mjs +0 -686
  70. package/src/server/uv-bootstrap.mjs +0 -337
@@ -1,512 +0,0 @@
1
- // Aggregates Codex token usage from ~/.codex/sessions rollout JSONL files.
2
- // Unlike Claude, Codex has no CLI quota command — we derive usage from the
3
- // actual session logs for 5h and 7d rolling windows.
4
- import { open, stat } from "node:fs/promises";
5
- import { join } from "node:path";
6
- import { StringDecoder } from "node:string_decoder";
7
- import { createReadStream } from "node:fs";
8
- // The NAMESPACE, never a named import. `import { createZstdDecompress }` is
9
- // resolved at link time, and node:zlib has no such export before Node 22.15 —
10
- // so the named form does not degrade on an older runtime, it throws before a
11
- // single line of this module runs and takes the whole deck down with it. Caught
12
- // by the timezone probe, which spawns a child that imports this file.
13
- import zlib from "node:zlib";
14
- import { STOP, walkRolloutDays } from "./codex-dir.mjs";
15
- import { PRODUCT } from "./brand.mjs";
16
-
17
- // Cache results for 60s (lighter than Claude quota — reads more files)
18
- let _cache = null;
19
- let _cacheAt = 0;
20
- const CACHE_MS = 60_000;
21
-
22
- // ── what a forced read may cost ─────────────────────────────────────────────
23
- // Until #600 the cache above was the whole of the admission control here, and
24
- // `force` walked straight past it. That made one GET worth a full week of the
25
- // rollout tree: listRolloutFiles(WINDOW_7D_MS) and then a read of every file it
26
- // returns. Measured on this repo's machine against 280 rollouts of ~90KB — a
27
- // week of ordinary use — one forced call is 685ms, 280 file opens and a peak of
28
- // four descriptors. Nothing bounded how many of those calls ran at once, and
29
- // the cost scaled exactly linearly: 16 concurrent forced reads were 4,480 opens
30
- // and 64 descriptors, 128 were 35,840 opens, 512 descriptors and 54.7s.
31
- //
32
- // Reads on this server are deliberately open — `isTrustedRead` does not apply
33
- // the `Sec-Fetch-Site` test that `isTrustedMutation` does, because a cross-site
34
- // read of `http://127.0.0.1:4317` is an ordinary top-level navigation — so any
35
- // page the user has open could run
36
- //
37
- // for (;;) fetch("http://127.0.0.1:4317/api/codex-usage?refresh=1",
38
- // { mode: "no-cors" });
39
- //
40
- // and get one of those scans per request. MAX_PARALLEL_READS below bounds the
41
- // fan-out WITHIN one call, and it exists because opening a week of rollouts at
42
- // once risked EMFILE — which readTokenSeries swallows into `return null`, a
43
- // silent undercount rather than an error. Unbounded calls let that EMFILE back
44
- // in through the door the pool does not cover.
45
- //
46
- // The two things between a caller and a scan are the ones quota.mjs established
47
- // and codex-quota.mjs adopted in #597, spelled the same way in all three:
48
- //
49
- // _inflight — callers that overlap wait on the one scan already running,
50
- // `force` included. What ?refresh=1 asks for is a reading
51
- // newer than the cache, and a scan in progress is one, so
52
- // joining it costs nothing and is offered before the floor.
53
- // FORCE_POLL_MS — the minimum interval between two scans WE pay for.
54
- // `_inflight` deduplicates callers that overlap and nothing
55
- // else, so a caller that waits for one scan to settle and then
56
- // asks again was a fresh week of the disk every time.
57
- //
58
- // This module has neither of the extra parts its two siblings carry, and
59
- // deliberately: there is no upstream backend to rate-limit us, so no cooldown,
60
- // and nothing outside this file invalidates the cache, so no generation guard.
61
- let _inflight = null;
62
-
63
- // Stamped when a scan STARTS rather than when it lands: what the floor rations
64
- // is the walk of the disk, and one that is still running has already been paid
65
- // for.
66
- let _lastScanAt = 0;
67
-
68
- // quota.mjs's number, and codex-quota.mjs's, for the reason those two give it:
69
- // "The refresh button may beat that floor, but not turn into a poll loop when
70
- // held down." Three routes within a few lines of each other in the router have
71
- // no business disagreeing about what `?refresh=1` costs.
72
- const FORCE_POLL_MS = 60_000;
73
-
74
- /**
75
- * Whether we may walk a week of the rollout tree right now.
76
- *
77
- * Exported for tests, for the same reason quota.mjs exports `maySelfPoll` and
78
- * codex-quota.mjs exports `mayFetchQuota`: this is the rule, it is pure, and it
79
- * is worth pinning down away from the scan it guards.
80
- */
81
- export function mayScanUsage({ now, lastScanAt }) {
82
- return now - lastScanAt >= FORCE_POLL_MS;
83
- }
84
-
85
- /**
86
- * The answer to a read the floor refused.
87
- *
88
- * A reading, not an error. The panel draws this number from `codexUsage?.ok &&
89
- * window7d.sessionCount > 0`, so an `{ ok: false }` refusal would make the token
90
- * line vanish for a minute — the deck teaching itself a new failure mode in
91
- * order to defend against a loop nobody ran. `stale` is the flag quota.mjs and
92
- * codex-quota.mjs both use for exactly this, and `fetchedAt` keeps the moment
93
- * the DATA was read rather than the moment of the read that was refused, so an
94
- * age label drawn from it never vouches for a scan that did not happen.
95
- */
96
- function heldReading(now) {
97
- if (_cache) return { ..._cache, stale: true };
98
- // Only reachable before the first scan has ever landed — every outcome below
99
- // is cached, failures included, and a scan that is still running is served by
100
- // `_inflight` — and spelled the way codex-quota.mjs spells the same state.
101
- return { ok: false, reason: "waiting", fetchedAt: now };
102
- }
103
-
104
- const WINDOW_5H_MS = 5 * 60 * 60 * 1000;
105
- const WINDOW_7D_MS = 7 * 24 * 60 * 60 * 1000;
106
-
107
- // A rollout file grows for as long as its session runs, and a week's worth of
108
- // them is however much the user happened to type. Reading them all at once made
109
- // the peak working set a function of that total; these two caps make it a
110
- // function of the pool instead — at most MAX_PARALLEL_READS files in flight,
111
- // each holding one chunk plus the line it is still assembling.
112
- const READ_CHUNK_BYTES = 256 * 1024;
113
- const MAX_PARALLEL_READS = 4;
114
-
115
- // Run `worker` over `items` with at most `limit` of them in flight. Workers
116
- // pull from a shared cursor, so a slow file delays only its own worker instead
117
- // of stalling a fixed-size batch.
118
- async function forEachLimited(items, limit, worker) {
119
- let next = 0;
120
- const workers = [];
121
- for (let i = 0; i < Math.min(limit, items.length); i++) {
122
- workers.push((async () => {
123
- while (next < items.length) await worker(items[next++]);
124
- })());
125
- }
126
- await Promise.all(workers);
127
- }
128
-
129
- // Append one rollout line's token_count event to the series, if it has one.
130
- function foldTokenLine(series, raw) {
131
- // Cheap pre-filter before the (relatively) expensive JSON.parse.
132
- if (!raw.includes("total_token_usage")) return;
133
- let obj;
134
- try { obj = JSON.parse(raw); } catch { return; }
135
- if (obj.type !== "event_msg" || obj.payload?.type !== "token_count") return;
136
- const u = obj.payload.info?.total_token_usage;
137
- if (!u) return;
138
- const ts = obj.timestamp ? Date.parse(obj.timestamp) : NaN;
139
- series.push({
140
- ts: isNaN(ts) ? null : ts,
141
- inp: u.input_tokens ?? 0,
142
- out: u.output_tokens ?? 0,
143
- cacheR: u.cached_input_tokens ?? 0,
144
- // Read even though every value observed so far is zero: emptyWindow() has
145
- // declared a cacheCreateTokens field since this file was written and nothing
146
- // ever incremented it, so the window shape promised a number it could not
147
- // produce. OpenAI populating the field is the only thing that has to change
148
- // for the count to become real, and it should not also need a code change.
149
- cacheW: u.cache_write_input_tokens ?? 0,
150
- total: u.total_tokens ?? ((u.input_tokens ?? 0) + (u.output_tokens ?? 0)),
151
- });
152
- }
153
-
154
- // Read the full series of cumulative token_count events from a rollout file.
155
- // Each token_count event carries `info.total_token_usage` — the running total
156
- // for the session at that point. We keep the whole series (with timestamps) so
157
- // we can compute how many tokens were spent *within* a rolling window via a
158
- // cumulative delta, rather than dumping a session's lifetime total into a bucket
159
- // based on when it merely started.
160
- //
161
- // Returns an ascending-by-time array of { ts, inp, out, cacheR, total } where
162
- // `inp` includes the cached portion (Codex reports input_tokens incl. cache),
163
- // or null if the file has no usable token_count events.
164
- /**
165
- * Codex compresses cold rollouts, and this is how the deck keeps reading them.
166
- *
167
- * openai/codex 0.153.0 added a background worker that rewrites any rollout older
168
- * than seven days as `rollout-….jsonl.zst`. Its own source says the quiet part
169
- * out loud — "Requires every reader of the Codex home to support compressed
170
- * shared histories" — and this deck is one of those readers. The flag
171
- * (`local_thread_store_compression`) is still `default_enabled: false`, so
172
- * nothing on disk has changed yet; the failure it would cause is why this is
173
- * here before it does. A reader that matched only `.jsonl` would have skipped
174
- * every day past the seventh IN SILENCE, and the 30-day Codex window would have
175
- * quietly collapsed to the last seven with figures that still looked right.
176
- *
177
- * Streamed, not decompressed whole. The plain path reads a chunk at a time
178
- * precisely so a megabyte of prompt text is never buffered, and handing that
179
- * property back for a one-line `zstdDecompressSync` would trade a silent
180
- * undercount for a memory spike.
181
- *
182
- * `createZstdDecompress` arrived in Node 22.15 and this package declares
183
- * `>=18`, so a deck on an older runtime cannot read them at all. It says so
184
- * once, on the terminal it was started from, rather than counting zero and
185
- * looking healthy.
186
- */
187
- const COMPRESSED = ".jsonl.zst";
188
- const createZstdDecompress = typeof zlib.createZstdDecompress === "function"
189
- ? zlib.createZstdDecompress
190
- : null;
191
- let warnedNoZstd = false;
192
-
193
- async function readCompressedTokenSeries(filePath) {
194
- if (!createZstdDecompress) {
195
- if (!warnedNoZstd) {
196
- warnedNoZstd = true;
197
- console.error(
198
- `${PRODUCT} codex-usage: this Node (${process.version}) cannot read Codex's compressed `
199
- + "rollouts; sessions older than about a week are being left out. Node 22.15 or newer reads them.",
200
- );
201
- }
202
- return null;
203
- }
204
- // THE SOURCE IS HELD, and both halves of that matter.
205
- //
206
- // `Readable.pipe` attaches its error handling to the DESTINATION. The source
207
- // got neither an 'error' listener nor a destroy, so a read that failed — the
208
- // file removed between the listing and this call, EACCES, EMFILE — emitted
209
- // an unhandled 'error' and took the process down, in a reader whose
210
- // uncompressed twin deliberately survives those same errnos. A torn archive
211
- // errors on the destination instead, which the catch below swallows while the
212
- // source stays open: twenty reads of one corrupt file left twenty handles,
213
- // against a sixty-second poll. On Windows a held handle also blocks the
214
- // unlink, which is the case the plain path's `finally { await fd?.close() }`
215
- // names.
216
- let source = null;
217
- try {
218
- const series = [];
219
- const decoder = new StringDecoder("utf8");
220
- let pending = "";
221
- source = createReadStream(filePath);
222
- const stream = source.pipe(createZstdDecompress());
223
- // THE ERROR HAS TO REACH THE LOOP, not merely be caught. A bare
224
- // `on("error", () => {})` here stops the process dying and hangs the read
225
- // instead: `pipe` does not forward a source failure to the destination, so
226
- // the `for await` below waits forever for an 'end' that cannot come — which
227
- // is what a missing file did on the first Windows run of this code, at 30
228
- // seconds per poll. Destroying the destination WITH the error makes the
229
- // iteration reject, which is what the catch is for.
230
- source.on("error", err => { stream.destroy(err); });
231
- for await (const chunk of stream) {
232
- pending += decoder.write(chunk);
233
- let from = 0;
234
- let nl;
235
- while ((nl = pending.indexOf("\n", from)) >= 0) {
236
- foldTokenLine(series, pending.slice(from, nl));
237
- from = nl + 1;
238
- }
239
- if (from > 0) pending = pending.slice(from);
240
- }
241
- pending += decoder.end();
242
- if (pending) foldTokenLine(series, pending);
243
- return series.length ? series : null;
244
- } catch { return null; }
245
- finally { source?.destroy(); }
246
- }
247
-
248
- /** The reader, exported under a test-only name. The compressed path cannot be
249
- * reached through `fetchCodexUsage` without a Codex home full of week-old
250
- * sessions, and the thing worth checking is that both spellings produce the
251
- * same series. */
252
- export const readTokenSeriesForTest = filePath => readTokenSeries(filePath);
253
-
254
- async function readTokenSeries(filePath) {
255
- if (filePath.endsWith(COMPRESSED)) return readCompressedTokenSeries(filePath);
256
- let fd;
257
- try {
258
- fd = await open(filePath, "r");
259
- const { size } = await fd.stat();
260
- if (size === 0) return null;
261
- const series = [];
262
- // A chunk at a time rather than the whole file: only the token_count
263
- // numbers survive the pass, so buffering megabytes of prompt text (once as
264
- // a Buffer, again as a string, a third time as the split array) bought
265
- // nothing. StringDecoder carries a UTF-8 sequence that straddles a chunk
266
- // boundary over to the next chunk.
267
- const buf = Buffer.allocUnsafe(READ_CHUNK_BYTES);
268
- const decoder = new StringDecoder("utf8");
269
- let pending = "";
270
- let pos = 0;
271
- while (pos < size) {
272
- const { bytesRead } = await fd.read(buf, 0, READ_CHUNK_BYTES, pos);
273
- if (bytesRead <= 0) break;
274
- pos += bytesRead;
275
- pending += decoder.write(buf.subarray(0, bytesRead));
276
- let from = 0;
277
- let nl;
278
- while ((nl = pending.indexOf("\n", from)) >= 0) {
279
- foldTokenLine(series, pending.slice(from, nl));
280
- from = nl + 1;
281
- }
282
- if (from > 0) pending = pending.slice(from);
283
- }
284
- // A rollout still being written can end without its final newline.
285
- pending += decoder.end();
286
- if (pending) foldTokenLine(series, pending);
287
- return series.length ? series : null;
288
- } catch { return null; }
289
- // AWAITED. A close scheduled and abandoned means this function resolves while
290
- // the descriptor is still open, and on Windows a file with any handle on it
291
- // cannot be deleted: the unlink marks it delete-pending, the name stays in
292
- // the directory, and the next rmdir of the parent fails with ENOTEMPTY. The
293
- // deck sweeps its own rollout copies, and the suite tears down a sandbox full
294
- // of them — this is how "the read is finished" stops being a lie about the
295
- // handle. Awaiting in a finally costs one microtask and cannot change what is
296
- // returned. Errors stay swallowed: a close that fails is nothing a reader can
297
- // act on.
298
- finally { await fd?.close().catch(() => {}); }
299
- }
300
-
301
- // Tokens spent within [windowStartMs, now]: the last cumulative snapshot minus
302
- // the last snapshot taken *before* the window opened. If the session began
303
- // inside the window (no prior snapshot), the baseline is zero and the full
304
- // cumulative end counts. Three of the fields are non-overlapping and sum to
305
- // `total`: `input` is fresh (non-cached) input, `cacheRead` is the cached
306
- // portion, `output` is output. (Codex's input_tokens includes cache, so we
307
- // subtract it out to avoid double-counting.) `cacheCreate` is the exception and
308
- // is documented as one where it is returned below — it is a slice of `input`
309
- // rather than a fourth disjoint bucket.
310
- function windowDelta(series, windowStartMs) {
311
- if (!series || series.length === 0) return null;
312
- const end = series[series.length - 1];
313
- // Baseline = last event strictly before the window opened.
314
- let base = null;
315
- for (const e of series) {
316
- if (e.ts != null && e.ts < windowStartMs) base = e;
317
- else if (e.ts != null) break;
318
- }
319
- const dInp = Math.max(0, end.inp - (base?.inp ?? 0));
320
- const dOut = Math.max(0, end.out - (base?.out ?? 0));
321
- const dCacheR = Math.max(0, end.cacheR - (base?.cacheR ?? 0));
322
- const dCacheW = Math.max(0, end.cacheW - (base?.cacheW ?? 0));
323
- const dTotal = Math.max(0, end.total - (base?.total ?? 0));
324
- return {
325
- inputTokens: Math.max(0, dInp - dCacheR), // fresh (non-cached) input
326
- outputTokens: dOut,
327
- cacheReadTokens: dCacheR,
328
- // Reported alongside the three above rather than carved out of `input`, and
329
- // deliberately not part of the sum that reaches `total`. Codex's own
330
- // arithmetic — total_tokens === input_tokens + output_tokens, in 171 of 171
331
- // usage objects measured — puts the written tokens inside `input_tokens`,
332
- // so subtracting them here would silently shrink the token line this
333
- // window's only reader prints. Pricing is where the split has to happen and
334
- // where it does happen (see billedInputTokens); this is a count, and the
335
- // count is right as it stands.
336
- cacheCreateTokens: dCacheW,
337
- totalTokens: dTotal,
338
- };
339
- }
340
-
341
- // Parse session start time from rollout filename.
342
- // Format: rollout-YYYY-MM-DDTHH-MM-SS-<uuid>.jsonl
343
- // The timestamp portion uses dashes instead of colons (Windows-safe).
344
- //
345
- // THAT WALL CLOCK IS LOCAL. This used to append a "Z" and hand the result to
346
- // `Date.parse`, which declares it UTC, and every rollout the walk below
347
- // considered was therefore mis-dated by the machine's offset — the whole
348
- // membership test shifted by however far the machine sits from Greenwich
349
- // (#609). Measured against the ten rollouts under `$CODEX_HOME` on this
350
- // machine, TZ=Europe/Chisinau, offset +3: read as UTC the filename sits
351
- // 179.3, 173.4, 178.5, 179.6, 179.7, 180.0, 179.8, 180.0 and 179.9 minutes
352
- // ahead of the first event in its own file; read as local it lands 0.0 to 6.6
353
- // minutes BEFORE it, which is the gap between naming a file and writing the
354
- // first line into it. Ten out of ten, and the sign is the tell — a session
355
- // cannot log an event before it starts.
356
- //
357
- // The tenth file is the one worth spelling out, because it is the reason this
358
- // keys off the name and not the contents. In
359
- // `rollout-2026-08-18T08-00-24-01a0133d-…` the envelope timestamp on line 1 is
360
- // 06:33:07.513Z — 92 minutes AFTER the name, since the session sat idle before
361
- // its first turn — while the `session_meta` payload nested inside that same
362
- // line reads 05:00:24.355Z, which is 08:00:24 local, the filename to the
363
- // second. So the outer timestamp is when the file was first APPENDED TO and
364
- // the name is when the session STARTED; the two differ by as much as the user
365
- // leaves the prompt sitting there.
366
- //
367
- // Reading that inner field would mean opening every rollout in the tree just
368
- // to decide which rollouts to open, which is the one cost this function exists
369
- // to avoid: the module's own measurements put a week at 280 files, and the
370
- // files ruled out by the name are exactly the ones never touched again. It
371
- // would also need an answer for a rollout whose first line is truncated,
372
- // unparseable or simply not there yet — and the only two answers are to open
373
- // it anyway (paying the cost the filter was for) or to drop it (a silent
374
- // undercount, which is the bug being fixed here wearing a different hat). The
375
- // name is on disk, free to read, and by the measurement above it is the more
376
- // accurate of the two.
377
- function parseRolloutTime(filename) {
378
- // e.g. rollout-2026-06-17T12-39-01-019ed4f2-c821-...jsonl
379
- const m = filename.match(/^rollout-(\d{4})-(\d{2})-(\d{2})T(\d{2})-(\d{2})-(\d{2})-/);
380
- if (!m) return null;
381
- const [y, mo, d, h, mi, s] = m.slice(1).map(Number);
382
- // Built from parts rather than parsed from a string, so the conversion uses
383
- // the zone rules in force ON THAT DATE rather than any single offset. An
384
- // offset is not a constant: America/Los_Angeles is -8 in January and -7 in
385
- // July, so a fix that subtracted `new Date().getTimezoneOffset()` would be
386
- // wrong for half the window it filters, twice a year, and wrong by an hour
387
- // for the whole of it on the days either side of a transition.
388
- const dt = new Date(y, mo - 1, d, h, mi, s);
389
- // `Date.parse` used to reject a nonsense date for free; the constructor
390
- // instead rolls it over (month 13 becomes next January), which would turn a
391
- // file that is not a rollout at all into one dated in the future — and a
392
- // future date passes the window test below. Month and day are enough to
393
- // catch that, and deliberately not the hour: a local time inside a
394
- // spring-forward gap does not exist, and V8 normalises it to the hour after,
395
- // which is the right answer and not a rollover. It subsumes the `isNaN` test
396
- // that used to stand at the end of this function, since an invalid Date
397
- // answers NaN to `getMonth()` and NaN matches nothing.
398
- if (dt.getMonth() !== mo - 1 || dt.getDate() !== d) return null;
399
- return dt.getTime();
400
- }
401
-
402
- // List rollout files whose start times fall within the given window.
403
- //
404
- // The walk over $CODEX_HOME/sessions is shared with the two entry points in
405
- // index.mjs (codex-dir.mjs) rather than repeated here, so the files this counts
406
- // usage from are exactly the files the watcher tails. They used to be two
407
- // verbatim copies of the same four nested readdirs over two verbatim copies of
408
- // the same CODEX_SESSIONS_DIR — which is how one of them came to resolve a
409
- // relative CODEX_HOME differently from the other (#375).
410
- async function listRolloutFiles(sinceMs) {
411
- const out = [];
412
- const nowMs = Date.now();
413
- // Years arrive newest-first, so the first one that cannot hold a file in the
414
- // window ends the walk: everything after it is older still. The extra day of
415
- // slack covers a session that started just before the window.
416
- //
417
- // It used to also claim to cover "a filename timestamp that is UTC while the
418
- // year directory is local time", which asserted the opposite of what the
419
- // files say — see parseRolloutTime. Both are local now and `getFullYear()`
420
- // here is local too, so the two sides of this comparison finally speak the
421
- // same clock. What the day of slack still earns, beyond the session that
422
- // started just before the window: an ambiguous local time on the day the
423
- // clocks go back happens twice, V8 resolves it to the first of the two, and
424
- // a session started during the second is dated an hour early. That is a
425
- // one-hour error on one or two days a year against a seven-day window,
426
- // where the old bug was an offset-wide error on every day of it.
427
- const oldestYear = new Date(nowMs - sinceMs - 86400000).getFullYear();
428
- await walkRolloutDays(
429
- (dir, files) => {
430
- for (const f of files) {
431
- // Both spellings. A cold rollout is `rollout-….jsonl.zst` and its name
432
- // still carries the timestamp parseRolloutTime reads, so nothing else
433
- // in this function has to know.
434
- if (!f.endsWith(".jsonl") && !f.endsWith(COMPRESSED)) continue;
435
- const t = parseRolloutTime(f);
436
- if (t != null && nowMs - t <= sinceMs) {
437
- out.push({ path: join(dir, f), startMs: t });
438
- }
439
- }
440
- },
441
- { onYear: y => (parseInt(y, 10) < oldestYear ? STOP : undefined) },
442
- );
443
- return out;
444
- }
445
-
446
- function emptyWindow() {
447
- return { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreateTokens: 0, totalTokens: 0, sessionCount: 0 };
448
- }
449
-
450
- export function fetchCodexUsage({ force = false } = {}) {
451
- const now = Date.now();
452
- if (!force && _cache && now - _cacheAt < CACHE_MS) return Promise.resolve(_cache);
453
- // Offered before the floor: a scan that has not finished yet is a reading
454
- // newer than the cache, which is what refresh asked for, and joining it costs
455
- // nothing.
456
- if (_inflight) return _inflight;
457
- if (!mayScanUsage({ now, lastScanAt: _lastScanAt })) return Promise.resolve(heldReading(now));
458
- _lastScanAt = now;
459
- // A bare clear rather than quota.mjs's `_inflight === mine` check: that guard
460
- // is there because invalidateQuotaCache drops the slot mid-flight, and this
461
- // module has no invalidator to race with. If one is ever added, it needs the
462
- // same check adding with it.
463
- _inflight = scanCodexUsage(now).finally(() => { _inflight = null; });
464
- return _inflight;
465
- }
466
-
467
- async function scanCodexUsage(now) {
468
- const w5h = emptyWindow();
469
- const w7d = emptyWindow();
470
- const start5h = now - WINDOW_5H_MS;
471
- const start7d = now - WINDOW_7D_MS;
472
-
473
- const addTo = (win, d) => {
474
- if (!d || d.totalTokens <= 0) return;
475
- win.inputTokens += d.inputTokens;
476
- win.outputTokens += d.outputTokens;
477
- win.cacheReadTokens += d.cacheReadTokens;
478
- win.cacheCreateTokens += d.cacheCreateTokens;
479
- win.totalTokens += d.totalTokens;
480
- win.sessionCount++;
481
- };
482
-
483
- try {
484
- // Files whose session *started* within 7d. A long session that started up
485
- // to 7d ago but is still active is captured here too, and its share of the
486
- // 5h window is recovered via the cumulative delta below — so bucketing no
487
- // longer drops active-but-old sessions or over-counts the pre-window tail.
488
- const files = await listRolloutFiles(WINDOW_7D_MS);
489
-
490
- // Bounded fan-out: a week of rollouts is an open-ended list, and opening
491
- // every one of them at once also risked EMFILE, which readTokenSeries
492
- // swallows into a silent undercount.
493
- await forEachLimited(files, MAX_PARALLEL_READS, async ({ path }) => {
494
- const series = await readTokenSeries(path);
495
- if (!series) return;
496
- // Same series feeds both windows; baseline differs per window start.
497
- addTo(w5h, windowDelta(series, start5h));
498
- addTo(w7d, windowDelta(series, start7d));
499
- });
500
- } catch (err) {
501
- console.error(`${PRODUCT} codex-usage: scan failed:`, err?.message ?? err);
502
- const result = { ok: false, fetchedAt: now };
503
- _cache = result;
504
- _cacheAt = now;
505
- return result;
506
- }
507
-
508
- const result = { ok: true, window5h: w5h, window7d: w7d, fetchedAt: now };
509
- _cache = result;
510
- _cacheAt = now;
511
- return result;
512
- }