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.
- package/README.md +6 -477
- package/package.json +14 -48
- package/shim.js +107 -0
- package/LICENSE +0 -661
- package/LICENSING.md +0 -82
- package/THIRD_PARTY_NOTICES.md +0 -395
- package/bin/agent-dag.js +0 -626
- package/bin/deck.js +0 -1805
- package/dist/web/assets/index-3FWd7g_W.css +0 -1
- package/dist/web/assets/index-BOwtoP02.js +0 -266
- package/dist/web/index.html +0 -49
- package/hook/hook.js +0 -542
- package/release-notes.json +0 -392
- package/src/server/activity.mjs +0 -52
- package/src/server/agent-activity.mjs +0 -522
- package/src/server/args.mjs +0 -183
- package/src/server/auto-update.mjs +0 -79
- package/src/server/block-notify.mjs +0 -173
- package/src/server/boot-deadline.mjs +0 -127
- package/src/server/brand.mjs +0 -16
- package/src/server/browser-history.mjs +0 -497
- package/src/server/browser-presence.mjs +0 -211
- package/src/server/browser-profiles.mjs +0 -279
- package/src/server/browser-react.mjs +0 -284
- package/src/server/browser-watch-store.mjs +0 -350
- package/src/server/browser-watch.mjs +0 -905
- package/src/server/ccusage.mjs +0 -1168
- package/src/server/claude-accounts.mjs +0 -951
- package/src/server/claude-dir.mjs +0 -213
- package/src/server/codex-auth.mjs +0 -388
- package/src/server/codex-dir.mjs +0 -171
- package/src/server/codex-quota.mjs +0 -449
- package/src/server/codex-usage.mjs +0 -512
- package/src/server/cswap-admin.mjs +0 -1562
- package/src/server/cswap-auto.mjs +0 -658
- package/src/server/cswap-install.mjs +0 -641
- package/src/server/deck-home.mjs +0 -243
- package/src/server/deck-prefs.mjs +0 -301
- package/src/server/deck-probe.mjs +0 -111
- package/src/server/detach.mjs +0 -244
- package/src/server/exec.mjs +0 -996
- package/src/server/global-install.mjs +0 -67
- package/src/server/hwmonitor.mjs +0 -56
- package/src/server/index.mjs +0 -6043
- package/src/server/installer.mjs +0 -912
- package/src/server/invoked-as.mjs +0 -144
- package/src/server/lan-about.mjs +0 -119
- package/src/server/lan-engine.mjs +0 -952
- package/src/server/lan-reach.mjs +0 -256
- package/src/server/lan-socket.mjs +0 -682
- package/src/server/lan-sync.mjs +0 -941
- package/src/server/lhm-parse.mjs +0 -91
- package/src/server/log-tail.mjs +0 -139
- package/src/server/log-writer.mjs +0 -322
- package/src/server/login-service.mjs +0 -473
- package/src/server/macmon.mjs +0 -310
- package/src/server/npx.mjs +0 -264
- package/src/server/open-url.mjs +0 -242
- package/src/server/presence.mjs +0 -40
- package/src/server/quota.mjs +0 -792
- package/src/server/relay-guard.mjs +0 -507
- package/src/server/reset-label.mjs +0 -78
- package/src/server/retire-sound-hook.mjs +0 -349
- package/src/server/running-deck.mjs +0 -234
- package/src/server/self-update.mjs +0 -1380
- package/src/server/stop-deck.mjs +0 -171
- package/src/server/supervisor.mjs +0 -392
- package/src/server/system-metrics.mjs +0 -1825
- package/src/server/term.mjs +0 -686
- 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
|
-
}
|