agent-dag 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.
@@ -48,6 +48,85 @@ let _cacheAt = 0;
48
48
  // tracks what claude-swap is doing. No network cost to amortise.
49
49
  const CACHE_MS = 5_000;
50
50
 
51
+ // ── what a forced read may cost ──────────────────────────────────────────────
52
+ //
53
+ // Until #604 the cache above was the whole of the admission control here, and
54
+ // `force` walked straight past it. `handleClaudeAccounts` reads `refresh=1` off
55
+ // the query string and passes it through, and reads on this server are
56
+ // deliberately open — `isTrustedRead` does not apply the `Sec-Fetch-Site` test
57
+ // that `isTrustedMutation` does, because a cross-site read of
58
+ // `http://127.0.0.1:4317` is an ordinary top-level navigation — so any page the
59
+ // user had open could run a `?refresh=1` loop and get one roster read per
60
+ // request, concurrently.
61
+ //
62
+ // What that buys is the cheapest of the six forcible routes and it is worth
63
+ // saying so plainly rather than dressing it up: two small local JSON reads,
64
+ // sequence.json and cache/usage.json, plus a call into nudgeCollector that is
65
+ // throttled on its own terms and spawns nothing when nothing is due. There is no
66
+ // network here by design — see the note at the top of this file — and no
67
+ // subprocess per request. This is the SHAPE the four routes before it were fixed
68
+ // for, not a cost anyone would have noticed.
69
+ //
70
+ // The reason to fix it anyway is the second half. This is the only one of the
71
+ // six whose cache is invalidated from elsewhere — nine call sites in four
72
+ // modules: six mutations in cswap-admin.mjs, an auto-switch in cswap-auto.mjs, a
73
+ // manual one in index.mjs, and the first `cswap add` below — and #582 has
74
+ // already shown what a read that started before an invalidation does when it
75
+ // lands after one. So the in-flight slot this route was missing arrives with the
76
+ // generation guard that makes it safe, rather than after the next bug report.
77
+ const FORCE_POLL_MS = 60_000;
78
+
79
+ // A read in progress, offered to callers that arrive while it is running.
80
+ let _inflight = null;
81
+ // Stamped when a read STARTS rather than when it lands: what the floor rations
82
+ // is the trip to disk, and one that is still running has already been paid for.
83
+ let _lastReadAt = 0;
84
+ // Which roster the reading below is about — as a counter, because the answer is
85
+ // about whichever account claude-swap's store says is active, and that can move
86
+ // under a read that is already running. invalidateClaudeAccountsCache bumps it;
87
+ // every write is stamped with the value that was current when the read STARTED.
88
+ // quota.mjs's `_generation`, for quota.mjs's reason (#582).
89
+ let _generation = 0;
90
+
91
+ /**
92
+ * Whether we may go to disk for the roster again.
93
+ *
94
+ * The same shape and the same minute as quota.mjs's `maySelfPoll`, and exported
95
+ * for the same reason it is: this is the rule, it is pure, and it belongs
96
+ * somewhere a test can point at it.
97
+ *
98
+ * Two intervals, like `maySelfPoll`'s. A forced read may beat the cache, but not
99
+ * turn into a poll loop when the button is held down, so it takes the minute the
100
+ * other four forcible routes use. An unforced read takes the cache's own
101
+ * interval — it is the panel's ordinary poll, the cache above has already
102
+ * answered it, and measuring from the START of the last read rather than from
103
+ * its end is the only difference between the two rules.
104
+ */
105
+ export function mayReadAccounts({ now, force, lastReadAt }) {
106
+ return now - lastReadAt >= (force ? FORCE_POLL_MS : CACHE_MS);
107
+ }
108
+
109
+ /**
110
+ * The answer to a read the floor refused.
111
+ *
112
+ * A reading, not an error. AccountsPanel renders `data.accounts`, and an
113
+ * `{ ok: false }` refusal would empty the roster for a minute — the deck
114
+ * teaching itself a new failure mode in order to defend against a loop nobody
115
+ * ran. `stale` is the flag quota.mjs, codex-quota.mjs and codex-usage.mjs all
116
+ * use for exactly this, and every account row already carries its own
117
+ * `fetchedAt` from claude-swap's store, which is the age the panel draws and
118
+ * which nothing here touches.
119
+ */
120
+ function heldReading(now) {
121
+ if (_cache) return { ..._cache, stale: true };
122
+ // Unreachable in practice, and spelled the way codex-quota.mjs and
123
+ // codex-usage.mjs spell the same state. Every outcome below is cached, a read
124
+ // still running is served by `_inflight`, and the one moment `_cache` is empty
125
+ // with a recent stamp — just after an invalidation — is exactly the moment
126
+ // invalidateClaudeAccountsCache clears the stamp as well.
127
+ return { ok: false, reason: "waiting", fetchedAt: now };
128
+ }
129
+
51
130
  // Past this, claude-swap's own numbers are old enough that showing them
52
131
  // without a marker would misrepresent them (its own trust ceiling is 3600s).
53
132
  const STALE_AFTER_MS = 15 * 60_000;
@@ -255,8 +334,51 @@ function lane(id, label, win) {
255
334
  export async function fetchClaudeAccounts({ force = false } = {}) {
256
335
  const now = Date.now();
257
336
  if (!force && _cache && now - _cacheAt < CACHE_MS) return _cache;
337
+ // Offered before the floor: a read that has not finished yet is a reading
338
+ // newer than the cache, which is what refresh asked for, and joining it costs
339
+ // nothing.
340
+ if (_inflight) return _inflight;
341
+ if (!mayReadAccounts({ now, force, lastReadAt: _lastReadAt })) return heldReading(now);
342
+ _lastReadAt = now;
343
+
344
+ // `_inflight === mine` rather than a bare clear, which is quota.mjs's guard
345
+ // and is here for quota.mjs's reason: invalidateClaudeAccountsCache drops
346
+ // `_inflight` so the next caller starts a read that knows the roster moved,
347
+ // and that read installs its own promise here. A read from before the switch
348
+ // finishing afterwards would otherwise clear the NEW one on its way out.
349
+ const mine = readRoster(now, _generation)
350
+ .finally(() => { if (_inflight === mine) _inflight = null; });
351
+ _inflight = mine;
352
+ return mine;
353
+ }
258
354
 
259
- const finish = (r) => { _cache = r; _cacheAt = Date.now(); return r; };
355
+ /**
356
+ * The read itself, split out from the admission control above it so the guard is
357
+ * readable as the four lines it is.
358
+ *
359
+ * `gen` is the generation that was current when this read STARTED, and finish()
360
+ * refuses to write anything under it once that has moved.
361
+ *
362
+ * Every write here happens after at least one await, and
363
+ * invalidateClaudeAccountsCache clears variables — which does nothing to a
364
+ * function that is already running and still holds the old roster in a local. So
365
+ * a switch landing mid-read was followed, milliseconds later, by the pre-switch
366
+ * roster being written straight back over the cleared cache: the invalidation
367
+ * looked like it worked and was undone before the next poll could observe it,
368
+ * and the panel went on showing the account the user had just switched away
369
+ * from. That is #582's defect, in the module #582 did not touch.
370
+ *
371
+ * The read is deliberately not cancelled. Whoever asked for it is still owed an
372
+ * answer, and the answer is not wrong — it is about a roster that has since
373
+ * moved, which makes it a fine return value and a bad cached one.
374
+ */
375
+ async function readRoster(now, gen) {
376
+ const finish = (r) => {
377
+ if (gen !== _generation) return r;
378
+ _cache = r;
379
+ _cacheAt = Date.now();
380
+ return r;
381
+ };
260
382
 
261
383
  const root = backupRoot();
262
384
  const seq = await readJson(join(root, "sequence.json"));
@@ -395,9 +517,31 @@ export async function requestCollection() {
395
517
  return _lastNudge !== before;
396
518
  }
397
519
 
520
+ /**
521
+ * Forget the roster, because something just made it wrong.
522
+ *
523
+ * Called from nine places in four modules — every `cswap` mutation the deck
524
+ * performs — and every one of them is behind a POST that `isTrustedMutation`
525
+ * guards, so nothing a page can send in a loop reaches this.
526
+ *
527
+ * Three things go besides the reading itself:
528
+ *
529
+ * `_generation` so a read that STARTED before this call cannot write its
530
+ * answer into the cache afterwards. See finish() in readRoster.
531
+ * `_inflight` so a caller arriving after the switch is not handed the read
532
+ * that began before it — joining a run is only free when the
533
+ * run is still about the right thing.
534
+ * `_lastReadAt` so the very next read is real work rather than a refusal.
535
+ * Every one of these call sites is followed by the panel
536
+ * reloading with ?refresh=1, and a floor that answered THAT
537
+ * with the pre-switch roster would make the guard the bug.
538
+ */
398
539
  export function invalidateClaudeAccountsCache() {
399
540
  _cache = null;
400
541
  _cacheAt = 0;
542
+ _generation++;
543
+ _inflight = null;
544
+ _lastReadAt = 0;
401
545
  }
402
546
 
403
547
  /**
@@ -12,10 +12,10 @@
12
12
  // immediately before it is spent, a response that does not clearly carry a
13
13
  // new access token is never treated as success, and nothing here throws —
14
14
  // a rejected promise from a background poll would take the server down.
15
- import { readFile, chmod, unlink, realpath } from "node:fs/promises";
15
+ import { readFile, chmod, unlink } from "node:fs/promises";
16
16
  import { join } from "node:path";
17
17
  import { CODEX_HOME } from "./codex-dir.mjs";
18
- import { createTemp, renameWithRetry } from "./installer.mjs";
18
+ import { createTemp, renameWithRetry, resolveWriteTarget } from "./installer.mjs";
19
19
  import { PRODUCT } from "./brand.mjs";
20
20
 
21
21
  // This file used to resolve CODEX_HOME itself, as `process.env.CODEX_HOME ??
@@ -149,7 +149,12 @@ async function readAuthFile() {
149
149
  *
150
150
  * Resolves symlinks first — `~/.codex/auth.json` is often a link into a
151
151
  * dotfiles repo or an encrypted volume, and renaming onto the link would
152
- * replace it with a regular file, quietly detaching the user's setup.
152
+ * replace it with a regular file, quietly detaching the user's setup. That
153
+ * resolution is the installer's resolveWriteTarget rather than a bare realpath
154
+ * here, because settings.json needed the identical rule (#673) and a rule
155
+ * written twice is a rule that drifts: the shared one also follows a DANGLING
156
+ * link to the file it names, which a realpath cannot answer at all and which is
157
+ * exactly the state a dotfiles repo is in before its first apply.
153
158
  *
154
159
  * The temp file comes from the installer's createTemp, which numbers every
155
160
  * write and creates it with O_EXCL, rather than from a name built out of the
@@ -177,7 +182,7 @@ async function readAuthFile() {
177
182
  * happen" rather than swallowing it.
178
183
  */
179
184
  async function persistAuth(auth) {
180
- const target = await realpath(AUTH_PATH).catch(() => AUTH_PATH);
185
+ const target = await resolveWriteTarget(AUTH_PATH);
181
186
  const { tmp, handle } = await createTemp(target, { mode: 0o600 });
182
187
 
183
188
  let ok = false;
@@ -243,9 +243,84 @@ async function requestUsage(base, auth) {
243
243
  // to race over the single-use refresh token.
244
244
  let _inflight = null;
245
245
 
246
+ // ── what a forced read may cost ────────────────────────────────────────────
247
+ // The floor between two reads WE pay for, and it is the same number and the
248
+ // same rule quota.mjs gives the Claude half — see FORCE_POLL_MS and maySelfPoll
249
+ // there. The two routes are four lines apart in the router and had no business
250
+ // disagreeing about what `?refresh=1` costs.
251
+ //
252
+ // `force` used to mean "skip the cache", and the cache was the ONLY thing
253
+ // between a caller and chatgpt.com. `_inflight` deduplicates callers that
254
+ // overlap and nothing else, so a caller that waits for one fetch to settle and
255
+ // then asks again got a fresh round trip every time — two authenticated HTTPS
256
+ // GETs carrying the user's live ChatGPT session, as fast as the round trip
257
+ // allows, from any page the user happens to have open (#580). Reads on this
258
+ // server are deliberately open (isTrustedRead), so "any page" is the real
259
+ // threat model rather than a hypothetical one.
260
+ //
261
+ // The sharper half is the credential rather than the traffic. On a 401
262
+ // doFetchCodexQuota spends the SINGLE-USE refresh token via forceCodexRefresh,
263
+ // and `staleAccessToken` only stops that happening twice for the same rejected
264
+ // token — every turn re-reads auth.json and sees the token the previous turn
265
+ // rotated to, so a backend that keeps answering 401 rotated a fresh credential
266
+ // once per request, racing the Codex CLI for each one. codex-auth.mjs's own
267
+ // EXPIRY_SKEW_MS comment says what losing that race costs the user: a
268
+ // `refresh_token_reused` that reads as "your login is broken", recoverable only
269
+ // with `codex login`.
270
+ const FORCE_POLL_MS = 60_000;
271
+
272
+ // Set from a 429 or a rejected refresh: a backend that is refusing us must not
273
+ // be asked once per request, whoever is asking. Same shape as quota.mjs's
274
+ // _rateLimitedUntil, which is likewise never beaten by force.
275
+ let _rateLimitedUntil = 0;
276
+ const COOLDOWN_MS = 5 * 60_000;
277
+
278
+ // Stamped when a fetch STARTS rather than when it lands: what the floor is
279
+ // rationing is the round trip, and one that is still in flight has already been
280
+ // paid for.
281
+ let _lastFetchAt = 0;
282
+
283
+ /**
284
+ * Whether we may spend a request of the user's ChatGPT session right now.
285
+ *
286
+ * Exported for tests, for the same reason quota.mjs exports maySelfPoll: this
287
+ * is the rule, it is pure, and it is worth pinning down away from the fetch it
288
+ * guards.
289
+ */
290
+ export function mayFetchQuota({ now, lastFetchAt, rateLimitedUntil }) {
291
+ if (now < rateLimitedUntil) return false;
292
+ return now - lastFetchAt >= FORCE_POLL_MS;
293
+ }
294
+
295
+ /**
296
+ * The answer to a read the floor refused.
297
+ *
298
+ * A reading, not an error — a user who clicks ↻ twice in a second must get the
299
+ * numbers they already have rather than a red hint, which is exactly what
300
+ * quota.mjs does with `{ ...held, stale: true }`. The timestamp stays the one
301
+ * the data was fetched at, so the panel's age label keeps telling the truth
302
+ * instead of vouching for a reading it did not take.
303
+ */
304
+ function heldReading(now) {
305
+ if (_cache) return { ..._cache, stale: true };
306
+ // Only reachable before the first fetch has ever landed — `finish` caches
307
+ // every outcome, failures included — and spelled the way the Claude side
308
+ // spells the same two states.
309
+ return { ok: false, reason: now < _rateLimitedUntil ? "rate_limited" : "waiting", fetchedAt: now };
310
+ }
311
+
246
312
  export function fetchCodexQuota({ force = false } = {}) {
247
- if (!force && _cache && Date.now() - _cacheAt < CACHE_MS) return Promise.resolve(_cache);
248
- _inflight ??= doFetchCodexQuota().finally(() => { _inflight = null; });
313
+ const now = Date.now();
314
+ if (!force && _cache && now - _cacheAt < CACHE_MS) return Promise.resolve(_cache);
315
+ // Joining a run already in flight costs nothing, so it is offered before the
316
+ // floor: what refresh asks for is a reading newer than the cache, and a fetch
317
+ // that has not landed yet is one.
318
+ if (_inflight) return _inflight;
319
+ if (!mayFetchQuota({ now, lastFetchAt: _lastFetchAt, rateLimitedUntil: _rateLimitedUntil })) {
320
+ return Promise.resolve(heldReading(now));
321
+ }
322
+ _lastFetchAt = now;
323
+ _inflight = doFetchCodexQuota().finally(() => { _inflight = null; });
249
324
  return _inflight;
250
325
  }
251
326
 
@@ -256,6 +331,13 @@ async function doFetchCodexQuota() {
256
331
  // shortens the effective TTL for no reason.
257
332
  const finish = (r) => { _cache = r; _cacheAt = Date.now(); return r; };
258
333
  const fail = (reason) => finish({ ok: false, reason, fetchedAt: started });
334
+ // A refusal we were told about, rather than one we inferred: back off further
335
+ // than the ordinary floor before asking again. `retry-after` is honoured when
336
+ // the backend sends one, because it knows better than the constant does.
337
+ const cooldown = (res) => {
338
+ const after = parseInt(res?.headers?.get?.("retry-after") ?? "", 10);
339
+ _rateLimitedUntil = Date.now() + (Number.isFinite(after) ? after * 1000 : COOLDOWN_MS);
340
+ };
259
341
 
260
342
  let auth, base, res;
261
343
  try {
@@ -294,12 +376,22 @@ async function doFetchCodexQuota() {
294
376
  // destroyed.
295
377
  if (res.status === 401) {
296
378
  const refreshed = await forceCodexRefresh(auth.accessToken);
297
- if (!refreshed.ok) return fail(refreshed.reason);
379
+ if (!refreshed.ok) {
380
+ // The credential is gone and only `codex login` brings it back, so
381
+ // rotating another single-use token at the next request would burn the
382
+ // one the CLI is still holding. Wait.
383
+ if (refreshed.reason === "refresh_rejected") cooldown(null);
384
+ return fail(refreshed.reason);
385
+ }
298
386
  auth = refreshed;
299
387
  res = await requestUsage(base, auth);
300
388
  }
301
389
 
302
390
  if (!res.ok) {
391
+ // A second 401 means the token we just rotated to was rejected as well —
392
+ // the case that turned into one rotation per request. 429 is the backend
393
+ // saying the same thing in the ordinary way.
394
+ if (res.status === 401 || res.status === 429) cooldown(res);
303
395
  return fail(res.status === 401 ? "refresh_rejected" : `http_${res.status}`);
304
396
  }
305
397
  } catch (err) {
@@ -12,6 +12,88 @@ let _cache = null;
12
12
  let _cacheAt = 0;
13
13
  const CACHE_MS = 60_000;
14
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
+
15
97
  const WINDOW_5H_MS = 5 * 60 * 60 * 1000;
16
98
  const WINDOW_7D_MS = 7 * 24 * 60 * 60 * 1000;
17
99
 
@@ -161,14 +243,62 @@ function windowDelta(series, windowStartMs) {
161
243
  // Parse session start time from rollout filename.
162
244
  // Format: rollout-YYYY-MM-DDTHH-MM-SS-<uuid>.jsonl
163
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.
164
279
  function parseRolloutTime(filename) {
165
280
  // e.g. rollout-2026-06-17T12-39-01-019ed4f2-c821-...jsonl
166
- const m = filename.match(/^rollout-(\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2})-/);
281
+ const m = filename.match(/^rollout-(\d{4})-(\d{2})-(\d{2})T(\d{2})-(\d{2})-(\d{2})-/);
167
282
  if (!m) return null;
168
- // Replace the last two dashes in time part with colons
169
- const iso = m[1].replace(/T(\d{2})-(\d{2})-(\d{2})$/, "T$1:$2:$3") + "Z";
170
- const t = Date.parse(iso);
171
- return isNaN(t) ? null : t;
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();
172
302
  }
173
303
 
174
304
  // List rollout files whose start times fall within the given window.
@@ -184,8 +314,18 @@ async function listRolloutFiles(sinceMs) {
184
314
  const nowMs = Date.now();
185
315
  // Years arrive newest-first, so the first one that cannot hold a file in the
186
316
  // window ends the walk: everything after it is older still. The extra day of
187
- // slack covers a session that started just before the window and a filename
188
- // timestamp that is UTC while the year directory is local time.
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.
189
329
  const oldestYear = new Date(nowMs - sinceMs - 86400000).getFullYear();
190
330
  await walkRolloutDays(
191
331
  (dir, files) => {
@@ -206,10 +346,24 @@ function emptyWindow() {
206
346
  return { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheCreateTokens: 0, totalTokens: 0, sessionCount: 0 };
207
347
  }
208
348
 
209
- export async function fetchCodexUsage({ force = false } = {}) {
349
+ export function fetchCodexUsage({ force = false } = {}) {
210
350
  const now = Date.now();
211
- if (!force && _cache && now - _cacheAt < CACHE_MS) return _cache;
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
+ }
212
365
 
366
+ async function scanCodexUsage(now) {
213
367
  const w5h = emptyWindow();
214
368
  const w7d = emptyWindow();
215
369
  const start5h = now - WINDOW_5H_MS;