ccdeck 1.43.0 → 1.45.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,411 @@
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 { STOP, walkRolloutDays } from "./codex-dir.mjs";
8
+ import { PRODUCT } from "./brand.mjs";
9
+
10
+ // Cache results for 60s (lighter than Claude quota — reads more files)
11
+ let _cache = null;
12
+ let _cacheAt = 0;
13
+ const CACHE_MS = 60_000;
14
+
15
+ // ── what a forced read may cost ─────────────────────────────────────────────
16
+ // Until #600 the cache above was the whole of the admission control here, and
17
+ // `force` walked straight past it. That made one GET worth a full week of the
18
+ // rollout tree: listRolloutFiles(WINDOW_7D_MS) and then a read of every file it
19
+ // returns. Measured on this repo's machine against 280 rollouts of ~90KB — a
20
+ // week of ordinary use — one forced call is 685ms, 280 file opens and a peak of
21
+ // four descriptors. Nothing bounded how many of those calls ran at once, and
22
+ // the cost scaled exactly linearly: 16 concurrent forced reads were 4,480 opens
23
+ // and 64 descriptors, 128 were 35,840 opens, 512 descriptors and 54.7s.
24
+ //
25
+ // Reads on this server are deliberately open — `isTrustedRead` does not apply
26
+ // the `Sec-Fetch-Site` test that `isTrustedMutation` does, because a cross-site
27
+ // read of `http://127.0.0.1:4317` is an ordinary top-level navigation — so any
28
+ // page the user has open could run
29
+ //
30
+ // for (;;) fetch("http://127.0.0.1:4317/api/codex-usage?refresh=1",
31
+ // { mode: "no-cors" });
32
+ //
33
+ // and get one of those scans per request. MAX_PARALLEL_READS below bounds the
34
+ // fan-out WITHIN one call, and it exists because opening a week of rollouts at
35
+ // once risked EMFILE — which readTokenSeries swallows into `return null`, a
36
+ // silent undercount rather than an error. Unbounded calls let that EMFILE back
37
+ // in through the door the pool does not cover.
38
+ //
39
+ // The two things between a caller and a scan are the ones quota.mjs established
40
+ // and codex-quota.mjs adopted in #597, spelled the same way in all three:
41
+ //
42
+ // _inflight — callers that overlap wait on the one scan already running,
43
+ // `force` included. What ?refresh=1 asks for is a reading
44
+ // newer than the cache, and a scan in progress is one, so
45
+ // joining it costs nothing and is offered before the floor.
46
+ // FORCE_POLL_MS — the minimum interval between two scans WE pay for.
47
+ // `_inflight` deduplicates callers that overlap and nothing
48
+ // else, so a caller that waits for one scan to settle and then
49
+ // asks again was a fresh week of the disk every time.
50
+ //
51
+ // This module has neither of the extra parts its two siblings carry, and
52
+ // deliberately: there is no upstream backend to rate-limit us, so no cooldown,
53
+ // and nothing outside this file invalidates the cache, so no generation guard.
54
+ let _inflight = null;
55
+
56
+ // Stamped when a scan STARTS rather than when it lands: what the floor rations
57
+ // is the walk of the disk, and one that is still running has already been paid
58
+ // for.
59
+ let _lastScanAt = 0;
60
+
61
+ // quota.mjs's number, and codex-quota.mjs's, for the reason those two give it:
62
+ // "The refresh button may beat that floor, but not turn into a poll loop when
63
+ // held down." Three routes within a few lines of each other in the router have
64
+ // no business disagreeing about what `?refresh=1` costs.
65
+ const FORCE_POLL_MS = 60_000;
66
+
67
+ /**
68
+ * Whether we may walk a week of the rollout tree right now.
69
+ *
70
+ * Exported for tests, for the same reason quota.mjs exports `maySelfPoll` and
71
+ * codex-quota.mjs exports `mayFetchQuota`: this is the rule, it is pure, and it
72
+ * is worth pinning down away from the scan it guards.
73
+ */
74
+ export function mayScanUsage({ now, lastScanAt }) {
75
+ return now - lastScanAt >= FORCE_POLL_MS;
76
+ }
77
+
78
+ /**
79
+ * The answer to a read the floor refused.
80
+ *
81
+ * A reading, not an error. The panel draws this number from `codexUsage?.ok &&
82
+ * window7d.sessionCount > 0`, so an `{ ok: false }` refusal would make the token
83
+ * line vanish for a minute — the deck teaching itself a new failure mode in
84
+ * order to defend against a loop nobody ran. `stale` is the flag quota.mjs and
85
+ * codex-quota.mjs both use for exactly this, and `fetchedAt` keeps the moment
86
+ * the DATA was read rather than the moment of the read that was refused, so an
87
+ * age label drawn from it never vouches for a scan that did not happen.
88
+ */
89
+ function heldReading(now) {
90
+ if (_cache) return { ..._cache, stale: true };
91
+ // Only reachable before the first scan has ever landed — every outcome below
92
+ // is cached, failures included, and a scan that is still running is served by
93
+ // `_inflight` — and spelled the way codex-quota.mjs spells the same state.
94
+ return { ok: false, reason: "waiting", fetchedAt: now };
95
+ }
96
+
97
+ const WINDOW_5H_MS = 5 * 60 * 60 * 1000;
98
+ const WINDOW_7D_MS = 7 * 24 * 60 * 60 * 1000;
99
+
100
+ // A rollout file grows for as long as its session runs, and a week's worth of
101
+ // them is however much the user happened to type. Reading them all at once made
102
+ // the peak working set a function of that total; these two caps make it a
103
+ // function of the pool instead — at most MAX_PARALLEL_READS files in flight,
104
+ // each holding one chunk plus the line it is still assembling.
105
+ const READ_CHUNK_BYTES = 256 * 1024;
106
+ const MAX_PARALLEL_READS = 4;
107
+
108
+ // Run `worker` over `items` with at most `limit` of them in flight. Workers
109
+ // pull from a shared cursor, so a slow file delays only its own worker instead
110
+ // of stalling a fixed-size batch.
111
+ async function forEachLimited(items, limit, worker) {
112
+ let next = 0;
113
+ const workers = [];
114
+ for (let i = 0; i < Math.min(limit, items.length); i++) {
115
+ workers.push((async () => {
116
+ while (next < items.length) await worker(items[next++]);
117
+ })());
118
+ }
119
+ await Promise.all(workers);
120
+ }
121
+
122
+ // Append one rollout line's token_count event to the series, if it has one.
123
+ function foldTokenLine(series, raw) {
124
+ // Cheap pre-filter before the (relatively) expensive JSON.parse.
125
+ if (!raw.includes("total_token_usage")) return;
126
+ let obj;
127
+ try { obj = JSON.parse(raw); } catch { return; }
128
+ if (obj.type !== "event_msg" || obj.payload?.type !== "token_count") return;
129
+ const u = obj.payload.info?.total_token_usage;
130
+ if (!u) return;
131
+ const ts = obj.timestamp ? Date.parse(obj.timestamp) : NaN;
132
+ series.push({
133
+ ts: isNaN(ts) ? null : ts,
134
+ inp: u.input_tokens ?? 0,
135
+ out: u.output_tokens ?? 0,
136
+ cacheR: u.cached_input_tokens ?? 0,
137
+ // Read even though every value observed so far is zero: emptyWindow() has
138
+ // declared a cacheCreateTokens field since this file was written and nothing
139
+ // ever incremented it, so the window shape promised a number it could not
140
+ // produce. OpenAI populating the field is the only thing that has to change
141
+ // for the count to become real, and it should not also need a code change.
142
+ cacheW: u.cache_write_input_tokens ?? 0,
143
+ total: u.total_tokens ?? ((u.input_tokens ?? 0) + (u.output_tokens ?? 0)),
144
+ });
145
+ }
146
+
147
+ // Read the full series of cumulative token_count events from a rollout file.
148
+ // Each token_count event carries `info.total_token_usage` — the running total
149
+ // for the session at that point. We keep the whole series (with timestamps) so
150
+ // we can compute how many tokens were spent *within* a rolling window via a
151
+ // cumulative delta, rather than dumping a session's lifetime total into a bucket
152
+ // based on when it merely started.
153
+ //
154
+ // Returns an ascending-by-time array of { ts, inp, out, cacheR, total } where
155
+ // `inp` includes the cached portion (Codex reports input_tokens incl. cache),
156
+ // or null if the file has no usable token_count events.
157
+ async function readTokenSeries(filePath) {
158
+ let fd;
159
+ try {
160
+ fd = await open(filePath, "r");
161
+ const { size } = await fd.stat();
162
+ if (size === 0) return null;
163
+ const series = [];
164
+ // A chunk at a time rather than the whole file: only the token_count
165
+ // numbers survive the pass, so buffering megabytes of prompt text (once as
166
+ // a Buffer, again as a string, a third time as the split array) bought
167
+ // nothing. StringDecoder carries a UTF-8 sequence that straddles a chunk
168
+ // boundary over to the next chunk.
169
+ const buf = Buffer.allocUnsafe(READ_CHUNK_BYTES);
170
+ const decoder = new StringDecoder("utf8");
171
+ let pending = "";
172
+ let pos = 0;
173
+ while (pos < size) {
174
+ const { bytesRead } = await fd.read(buf, 0, READ_CHUNK_BYTES, pos);
175
+ if (bytesRead <= 0) break;
176
+ pos += bytesRead;
177
+ pending += decoder.write(buf.subarray(0, bytesRead));
178
+ let from = 0;
179
+ let nl;
180
+ while ((nl = pending.indexOf("\n", from)) >= 0) {
181
+ foldTokenLine(series, pending.slice(from, nl));
182
+ from = nl + 1;
183
+ }
184
+ if (from > 0) pending = pending.slice(from);
185
+ }
186
+ // A rollout still being written can end without its final newline.
187
+ pending += decoder.end();
188
+ if (pending) foldTokenLine(series, pending);
189
+ return series.length ? series : null;
190
+ } catch { return null; }
191
+ // AWAITED. A close scheduled and abandoned means this function resolves while
192
+ // the descriptor is still open, and on Windows a file with any handle on it
193
+ // cannot be deleted: the unlink marks it delete-pending, the name stays in
194
+ // the directory, and the next rmdir of the parent fails with ENOTEMPTY. The
195
+ // deck sweeps its own rollout copies, and the suite tears down a sandbox full
196
+ // of them — this is how "the read is finished" stops being a lie about the
197
+ // handle. Awaiting in a finally costs one microtask and cannot change what is
198
+ // returned. Errors stay swallowed: a close that fails is nothing a reader can
199
+ // act on.
200
+ finally { await fd?.close().catch(() => {}); }
201
+ }
202
+
203
+ // Tokens spent within [windowStartMs, now]: the last cumulative snapshot minus
204
+ // the last snapshot taken *before* the window opened. If the session began
205
+ // inside the window (no prior snapshot), the baseline is zero and the full
206
+ // cumulative end counts. Three of the fields are non-overlapping and sum to
207
+ // `total`: `input` is fresh (non-cached) input, `cacheRead` is the cached
208
+ // portion, `output` is output. (Codex's input_tokens includes cache, so we
209
+ // subtract it out to avoid double-counting.) `cacheCreate` is the exception and
210
+ // is documented as one where it is returned below — it is a slice of `input`
211
+ // rather than a fourth disjoint bucket.
212
+ function windowDelta(series, windowStartMs) {
213
+ if (!series || series.length === 0) return null;
214
+ const end = series[series.length - 1];
215
+ // Baseline = last event strictly before the window opened.
216
+ let base = null;
217
+ for (const e of series) {
218
+ if (e.ts != null && e.ts < windowStartMs) base = e;
219
+ else if (e.ts != null) break;
220
+ }
221
+ const dInp = Math.max(0, end.inp - (base?.inp ?? 0));
222
+ const dOut = Math.max(0, end.out - (base?.out ?? 0));
223
+ const dCacheR = Math.max(0, end.cacheR - (base?.cacheR ?? 0));
224
+ const dCacheW = Math.max(0, end.cacheW - (base?.cacheW ?? 0));
225
+ const dTotal = Math.max(0, end.total - (base?.total ?? 0));
226
+ return {
227
+ inputTokens: Math.max(0, dInp - dCacheR), // fresh (non-cached) input
228
+ outputTokens: dOut,
229
+ cacheReadTokens: dCacheR,
230
+ // Reported alongside the three above rather than carved out of `input`, and
231
+ // deliberately not part of the sum that reaches `total`. Codex's own
232
+ // arithmetic — total_tokens === input_tokens + output_tokens, in 171 of 171
233
+ // usage objects measured — puts the written tokens inside `input_tokens`,
234
+ // so subtracting them here would silently shrink the token line this
235
+ // window's only reader prints. Pricing is where the split has to happen and
236
+ // where it does happen (see billedInputTokens); this is a count, and the
237
+ // count is right as it stands.
238
+ cacheCreateTokens: dCacheW,
239
+ totalTokens: dTotal,
240
+ };
241
+ }
242
+
243
+ // Parse session start time from rollout filename.
244
+ // Format: rollout-YYYY-MM-DDTHH-MM-SS-<uuid>.jsonl
245
+ // The timestamp portion uses dashes instead of colons (Windows-safe).
246
+ //
247
+ // THAT WALL CLOCK IS LOCAL. This used to append a "Z" and hand the result to
248
+ // `Date.parse`, which declares it UTC, and every rollout the walk below
249
+ // considered was therefore mis-dated by the machine's offset — the whole
250
+ // membership test shifted by however far the machine sits from Greenwich
251
+ // (#609). Measured against the ten rollouts under `$CODEX_HOME` on this
252
+ // machine, TZ=Europe/Chisinau, offset +3: read as UTC the filename sits
253
+ // 179.3, 173.4, 178.5, 179.6, 179.7, 180.0, 179.8, 180.0 and 179.9 minutes
254
+ // ahead of the first event in its own file; read as local it lands 0.0 to 6.6
255
+ // minutes BEFORE it, which is the gap between naming a file and writing the
256
+ // first line into it. Ten out of ten, and the sign is the tell — a session
257
+ // cannot log an event before it starts.
258
+ //
259
+ // The tenth file is the one worth spelling out, because it is the reason this
260
+ // keys off the name and not the contents. In
261
+ // `rollout-2026-08-18T08-00-24-01a0133d-…` the envelope timestamp on line 1 is
262
+ // 06:33:07.513Z — 92 minutes AFTER the name, since the session sat idle before
263
+ // its first turn — while the `session_meta` payload nested inside that same
264
+ // line reads 05:00:24.355Z, which is 08:00:24 local, the filename to the
265
+ // second. So the outer timestamp is when the file was first APPENDED TO and
266
+ // the name is when the session STARTED; the two differ by as much as the user
267
+ // leaves the prompt sitting there.
268
+ //
269
+ // Reading that inner field would mean opening every rollout in the tree just
270
+ // to decide which rollouts to open, which is the one cost this function exists
271
+ // to avoid: the module's own measurements put a week at 280 files, and the
272
+ // files ruled out by the name are exactly the ones never touched again. It
273
+ // would also need an answer for a rollout whose first line is truncated,
274
+ // unparseable or simply not there yet — and the only two answers are to open
275
+ // it anyway (paying the cost the filter was for) or to drop it (a silent
276
+ // undercount, which is the bug being fixed here wearing a different hat). The
277
+ // name is on disk, free to read, and by the measurement above it is the more
278
+ // accurate of the two.
279
+ function parseRolloutTime(filename) {
280
+ // e.g. rollout-2026-06-17T12-39-01-019ed4f2-c821-...jsonl
281
+ const m = filename.match(/^rollout-(\d{4})-(\d{2})-(\d{2})T(\d{2})-(\d{2})-(\d{2})-/);
282
+ if (!m) return null;
283
+ const [y, mo, d, h, mi, s] = m.slice(1).map(Number);
284
+ // Built from parts rather than parsed from a string, so the conversion uses
285
+ // the zone rules in force ON THAT DATE rather than any single offset. An
286
+ // offset is not a constant: America/Los_Angeles is -8 in January and -7 in
287
+ // July, so a fix that subtracted `new Date().getTimezoneOffset()` would be
288
+ // wrong for half the window it filters, twice a year, and wrong by an hour
289
+ // for the whole of it on the days either side of a transition.
290
+ const dt = new Date(y, mo - 1, d, h, mi, s);
291
+ // `Date.parse` used to reject a nonsense date for free; the constructor
292
+ // instead rolls it over (month 13 becomes next January), which would turn a
293
+ // file that is not a rollout at all into one dated in the future — and a
294
+ // future date passes the window test below. Month and day are enough to
295
+ // catch that, and deliberately not the hour: a local time inside a
296
+ // spring-forward gap does not exist, and V8 normalises it to the hour after,
297
+ // which is the right answer and not a rollover. It subsumes the `isNaN` test
298
+ // that used to stand at the end of this function, since an invalid Date
299
+ // answers NaN to `getMonth()` and NaN matches nothing.
300
+ if (dt.getMonth() !== mo - 1 || dt.getDate() !== d) return null;
301
+ return dt.getTime();
302
+ }
303
+
304
+ // List rollout files whose start times fall within the given window.
305
+ //
306
+ // The walk over $CODEX_HOME/sessions is shared with the two entry points in
307
+ // index.mjs (codex-dir.mjs) rather than repeated here, so the files this counts
308
+ // usage from are exactly the files the watcher tails. They used to be two
309
+ // verbatim copies of the same four nested readdirs over two verbatim copies of
310
+ // the same CODEX_SESSIONS_DIR — which is how one of them came to resolve a
311
+ // relative CODEX_HOME differently from the other (#375).
312
+ async function listRolloutFiles(sinceMs) {
313
+ const out = [];
314
+ const nowMs = Date.now();
315
+ // Years arrive newest-first, so the first one that cannot hold a file in the
316
+ // window ends the walk: everything after it is older still. The extra day of
317
+ // slack covers a session that started just before the window.
318
+ //
319
+ // It used to also claim to cover "a filename timestamp that is UTC while the
320
+ // year directory is local time", which asserted the opposite of what the
321
+ // files say — see parseRolloutTime. Both are local now and `getFullYear()`
322
+ // here is local too, so the two sides of this comparison finally speak the
323
+ // same clock. What the day of slack still earns, beyond the session that
324
+ // started just before the window: an ambiguous local time on the day the
325
+ // clocks go back happens twice, V8 resolves it to the first of the two, and
326
+ // a session started during the second is dated an hour early. That is a
327
+ // one-hour error on one or two days a year against a seven-day window,
328
+ // where the old bug was an offset-wide error on every day of it.
329
+ const oldestYear = new Date(nowMs - sinceMs - 86400000).getFullYear();
330
+ await walkRolloutDays(
331
+ (dir, files) => {
332
+ for (const f of files) {
333
+ if (!f.endsWith(".jsonl")) continue;
334
+ const t = parseRolloutTime(f);
335
+ if (t != null && nowMs - t <= sinceMs) {
336
+ out.push({ path: join(dir, f), startMs: t });
337
+ }
338
+ }
339
+ },
340
+ { onYear: y => (parseInt(y, 10) < oldestYear ? STOP : undefined) },
341
+ );
342
+ return out;
343
+ }
344
+
345
+ function emptyWindow() {
346
+ return { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreateTokens: 0, totalTokens: 0, sessionCount: 0 };
347
+ }
348
+
349
+ export function fetchCodexUsage({ force = false } = {}) {
350
+ const now = Date.now();
351
+ if (!force && _cache && now - _cacheAt < CACHE_MS) return Promise.resolve(_cache);
352
+ // Offered before the floor: a scan that has not finished yet is a reading
353
+ // newer than the cache, which is what refresh asked for, and joining it costs
354
+ // nothing.
355
+ if (_inflight) return _inflight;
356
+ if (!mayScanUsage({ now, lastScanAt: _lastScanAt })) return Promise.resolve(heldReading(now));
357
+ _lastScanAt = now;
358
+ // A bare clear rather than quota.mjs's `_inflight === mine` check: that guard
359
+ // is there because invalidateQuotaCache drops the slot mid-flight, and this
360
+ // module has no invalidator to race with. If one is ever added, it needs the
361
+ // same check adding with it.
362
+ _inflight = scanCodexUsage(now).finally(() => { _inflight = null; });
363
+ return _inflight;
364
+ }
365
+
366
+ async function scanCodexUsage(now) {
367
+ const w5h = emptyWindow();
368
+ const w7d = emptyWindow();
369
+ const start5h = now - WINDOW_5H_MS;
370
+ const start7d = now - WINDOW_7D_MS;
371
+
372
+ const addTo = (win, d) => {
373
+ if (!d || d.totalTokens <= 0) return;
374
+ win.inputTokens += d.inputTokens;
375
+ win.outputTokens += d.outputTokens;
376
+ win.cacheReadTokens += d.cacheReadTokens;
377
+ win.cacheCreateTokens += d.cacheCreateTokens;
378
+ win.totalTokens += d.totalTokens;
379
+ win.sessionCount++;
380
+ };
381
+
382
+ try {
383
+ // Files whose session *started* within 7d. A long session that started up
384
+ // to 7d ago but is still active is captured here too, and its share of the
385
+ // 5h window is recovered via the cumulative delta below — so bucketing no
386
+ // longer drops active-but-old sessions or over-counts the pre-window tail.
387
+ const files = await listRolloutFiles(WINDOW_7D_MS);
388
+
389
+ // Bounded fan-out: a week of rollouts is an open-ended list, and opening
390
+ // every one of them at once also risked EMFILE, which readTokenSeries
391
+ // swallows into a silent undercount.
392
+ await forEachLimited(files, MAX_PARALLEL_READS, async ({ path }) => {
393
+ const series = await readTokenSeries(path);
394
+ if (!series) return;
395
+ // Same series feeds both windows; baseline differs per window start.
396
+ addTo(w5h, windowDelta(series, start5h));
397
+ addTo(w7d, windowDelta(series, start7d));
398
+ });
399
+ } catch (err) {
400
+ console.error(`${PRODUCT} codex-usage: scan failed:`, err?.message ?? err);
401
+ const result = { ok: false, fetchedAt: now };
402
+ _cache = result;
403
+ _cacheAt = now;
404
+ return result;
405
+ }
406
+
407
+ const result = { ok: true, window5h: w5h, window7d: w7d, fetchedAt: now };
408
+ _cache = result;
409
+ _cacheAt = now;
410
+ return result;
411
+ }