@geoqiao/pi-usage 0.1.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.
Files changed (62) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +222 -0
  3. package/bin/pi-usage.js +69 -0
  4. package/data/models.dev-LICENSE +21 -0
  5. package/data/prices.json +2678 -0
  6. package/extensions/usage-report.js +36 -0
  7. package/package.json +51 -0
  8. package/src/analytics.js +189 -0
  9. package/src/collect.js +34 -0
  10. package/src/network.js +25 -0
  11. package/src/report.js +43 -0
  12. package/vendor/vibe-usage/NOTICE.md +58 -0
  13. package/vendor/vibe-usage/src/cindy-roots.js +85 -0
  14. package/vendor/vibe-usage/src/claude-roots.js +165 -0
  15. package/vendor/vibe-usage/src/cline-roots.js +40 -0
  16. package/vendor/vibe-usage/src/codex-roots.js +46 -0
  17. package/vendor/vibe-usage/src/craft-roots.js +15 -0
  18. package/vendor/vibe-usage/src/extra-roots.js +312 -0
  19. package/vendor/vibe-usage/src/parsers/aggregate.js +196 -0
  20. package/vendor/vibe-usage/src/parsers/alma.js +94 -0
  21. package/vendor/vibe-usage/src/parsers/amp.js +156 -0
  22. package/vendor/vibe-usage/src/parsers/antigravity-db.js +359 -0
  23. package/vendor/vibe-usage/src/parsers/antigravity.js +530 -0
  24. package/vendor/vibe-usage/src/parsers/cindy-ledger.js +157 -0
  25. package/vendor/vibe-usage/src/parsers/claude-code.js +372 -0
  26. package/vendor/vibe-usage/src/parsers/cline.js +92 -0
  27. package/vendor/vibe-usage/src/parsers/codex-cache.js +138 -0
  28. package/vendor/vibe-usage/src/parsers/codex.js +1198 -0
  29. package/vendor/vibe-usage/src/parsers/contract.js +55 -0
  30. package/vendor/vibe-usage/src/parsers/copilot-cli.js +128 -0
  31. package/vendor/vibe-usage/src/parsers/craft-agent.js +21 -0
  32. package/vendor/vibe-usage/src/parsers/cursor.js +262 -0
  33. package/vendor/vibe-usage/src/parsers/dimagent.js +127 -0
  34. package/vendor/vibe-usage/src/parsers/droid.js +113 -0
  35. package/vendor/vibe-usage/src/parsers/dsh.js +563 -0
  36. package/vendor/vibe-usage/src/parsers/fs-utils.js +36 -0
  37. package/vendor/vibe-usage/src/parsers/gemini-cli.js +190 -0
  38. package/vendor/vibe-usage/src/parsers/grok.js +395 -0
  39. package/vendor/vibe-usage/src/parsers/hermes.js +123 -0
  40. package/vendor/vibe-usage/src/parsers/index.js +61 -0
  41. package/vendor/vibe-usage/src/parsers/kimi-code.js +467 -0
  42. package/vendor/vibe-usage/src/parsers/kiro.js +788 -0
  43. package/vendor/vibe-usage/src/parsers/mcode.js +182 -0
  44. package/vendor/vibe-usage/src/parsers/mimocode.js +88 -0
  45. package/vendor/vibe-usage/src/parsers/omp.js +10 -0
  46. package/vendor/vibe-usage/src/parsers/openclaw.js +142 -0
  47. package/vendor/vibe-usage/src/parsers/opencode.js +151 -0
  48. package/vendor/vibe-usage/src/parsers/pi-coding-agent.js +27 -0
  49. package/vendor/vibe-usage/src/parsers/pi-session-jsonl.js +166 -0
  50. package/vendor/vibe-usage/src/parsers/qwen-code.js +122 -0
  51. package/vendor/vibe-usage/src/parsers/roo-code.js +123 -0
  52. package/vendor/vibe-usage/src/parsers/sqlite.js +148 -0
  53. package/vendor/vibe-usage/src/parsers/trae-cli.js +171 -0
  54. package/vendor/vibe-usage/src/parsers/workbuddy.js +322 -0
  55. package/vendor/vibe-usage/src/parsers/zcode.js +115 -0
  56. package/vendor/vibe-usage/src/pi-roots.js +125 -0
  57. package/vendor/vibe-usage/src/tools.js +422 -0
  58. package/vendor/vibe-usage/src/workbuddy-roots.js +22 -0
  59. package/vendor/vibe-usage/upstream-files.json +48 -0
  60. package/web/report.css +10 -0
  61. package/web/report.html +81 -0
  62. package/web/report.js +310 -0
@@ -0,0 +1,1198 @@
1
+ import {
2
+ closeSync,
3
+ createReadStream,
4
+ existsSync,
5
+ openSync,
6
+ readSync,
7
+ readdirSync,
8
+ statSync,
9
+ } from 'node:fs';
10
+ import { join } from 'node:path';
11
+ import { createInterface } from 'node:readline';
12
+ import { createHash } from 'node:crypto';
13
+ import { aggregateToBuckets } from './aggregate.js';
14
+ import { mergeCindyHarnessUsage, readCindyHarnessUsage } from './cindy-ledger.js';
15
+ import {
16
+ codexSessionDirs,
17
+ resolveCodexHomes,
18
+ validateExtraCodexHome,
19
+ } from '../codex-roots.js';
20
+ import { discoverCodexHomes } from '../extra-roots.js';
21
+ import {
22
+ codexCacheEnabled,
23
+ fileSignature,
24
+ loadCodexFileCache,
25
+ loadCodexFileTail,
26
+ saveCodexFileCache,
27
+ saveCodexFileTail,
28
+ } from './codex-cache.js';
29
+
30
+ // Changing a model id changes its server-side bucket key. Keep pre-release
31
+ // history byte-for-byte stable so upgrading cannot re-upload the same tokens
32
+ // under tier-decorated keys and double-count them.
33
+ const CODEX_SERVICE_TIER_ATTRIBUTION_START_MS = Date.parse('2026-08-31T00:00:00.000Z');
34
+
35
+ function normalizeCodexServiceTier(value) {
36
+ if (typeof value !== 'string') return null;
37
+ const tier = value.trim().toLowerCase();
38
+ if (tier === 'fast' || tier === 'priority') return tier;
39
+ if (tier === 'flex' || tier === 'batch') return tier;
40
+ return null;
41
+ }
42
+
43
+ function decorateCodexModel(model, serviceTier, timestampMs) {
44
+ const rawModel = model || 'unknown';
45
+ if (
46
+ rawModel === 'unknown'
47
+ || !serviceTier
48
+ || timestampMs < CODEX_SERVICE_TIER_ATTRIBUTION_START_MS
49
+ ) {
50
+ return rawModel;
51
+ }
52
+ return `${rawModel}-${serviceTier}`;
53
+ }
54
+
55
+ // Codex stores live sessions in $CODEX_HOME/sessions (default ~/.codex) and,
56
+ // once a session is "completed", moves its rollout file verbatim into
57
+ // $CODEX_HOME/archived_sessions. A session can be archived between two syncs,
58
+ // so scanning only the live dir loses that session's usage forever. We scan
59
+ // both, index them together so fork replay-skip works across directories, and
60
+ // select the most complete physical file when the same session briefly exists
61
+ // in both locations during an archive move.
62
+ /**
63
+ * Recursively find all .jsonl files under a directory.
64
+ * Codex CLI stores sessions as: ~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl
65
+ */
66
+ function findJsonlFiles(dir, strict = false) {
67
+ const results = [];
68
+ if (!existsSync(dir)) return results;
69
+ try {
70
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
71
+ const fullPath = join(dir, entry.name);
72
+ if (entry.isDirectory()) {
73
+ for (const nested of findJsonlFiles(fullPath, strict)) results.push(nested);
74
+ } else if (entry.name.endsWith('.jsonl')) {
75
+ results.push(fullPath);
76
+ }
77
+ }
78
+ } catch (err) {
79
+ if (strict && err?.code !== 'ENOENT') throw err;
80
+ // Default roots are best-effort; configured roots must never look empty
81
+ // merely because a directory became unreadable between syncs.
82
+ }
83
+ return results;
84
+ }
85
+
86
+ function readLines(filePath, snapshotSize, start = 0) {
87
+ return createInterface({
88
+ input: createReadStream(filePath, {
89
+ encoding: 'utf-8',
90
+ // Rollouts are append-only while Codex is working. Bound both parser
91
+ // passes to the size captured before pass 1 so they see the same prefix
92
+ // even when the live file grows between reads.
93
+ ...(start > 0 ? { start } : {}),
94
+ ...(snapshotSize == null ? {} : { end: snapshotSize - 1 }),
95
+ }),
96
+ crlfDelay: Infinity,
97
+ });
98
+ }
99
+
100
+ function extractProject(meta) {
101
+ if (meta.git?.repository_url) {
102
+ // e.g. https://github.com/org/repo.git → org/repo
103
+ const match = meta.git.repository_url.match(/([^/]+\/[^/]+?)(?:\.git)?$/);
104
+ if (match) return match[1];
105
+ }
106
+ if (meta.cwd) return meta.cwd.split('/').pop() || 'unknown';
107
+ return 'unknown';
108
+ }
109
+
110
+ /**
111
+ * A sub-agent rollout (spawned thread / guardian / collab agent). Depending
112
+ * on the Codex version the marker is `thread_source: "subagent"`, a
113
+ * `source: { subagent: {...} }` object, or just a `parent_thread_id` — check
114
+ * all three so no version's sub-agents slip through as normal sessions.
115
+ */
116
+ function isSubagentMeta(meta) {
117
+ if (meta.thread_source === 'subagent') return true;
118
+ const src = meta.source;
119
+ if (src === 'subagent') return true;
120
+ if (src && typeof src === 'object' && 'subagent' in src) return true;
121
+ return meta.parent_thread_id != null;
122
+ }
123
+
124
+ function extractParentThreadId(meta) {
125
+ return meta.parent_thread_id
126
+ || meta.source?.subagent?.thread_spawn?.parent_thread_id
127
+ || null;
128
+ }
129
+
130
+ /**
131
+ * Read only far enough to find the canonical (first) session_meta. This cheap
132
+ * discovery pass lets ordinary sessions skip the old full-file index pass;
133
+ * only forks, sub-agents, and parents referenced by them need replay indexes.
134
+ */
135
+ async function readSessionHeader(filePath, snapshotSize) {
136
+ for await (const line of readLines(filePath, snapshotSize)) {
137
+ if (!line.trim()) continue;
138
+ try {
139
+ const obj = JSON.parse(line);
140
+ if (obj.type !== 'session_meta' || !obj.payload) continue;
141
+ const meta = obj.payload;
142
+ return {
143
+ sessionId: meta.id || null,
144
+ forkedFromId: meta.forked_from_id || null,
145
+ parentThreadId: extractParentThreadId(meta),
146
+ sessionProject: extractProject(meta),
147
+ sessionStartedAtMs: timestampMs(meta.timestamp) ?? timestampMs(obj.timestamp),
148
+ isSubagent: isSubagentMeta(meta),
149
+ };
150
+ } catch {
151
+ continue;
152
+ }
153
+ }
154
+ return {
155
+ sessionId: null,
156
+ forkedFromId: null,
157
+ parentThreadId: null,
158
+ sessionProject: 'unknown',
159
+ sessionStartedAtMs: null,
160
+ isSubagent: false,
161
+ };
162
+ }
163
+
164
+ function cacheData(cache, changes = {}) {
165
+ return {
166
+ header: changes.header ?? cache?.header ?? null,
167
+ index: changes.index ?? cache?.index ?? null,
168
+ result: changes.result ?? cache?.result ?? null,
169
+ lastAuditedAt: changes.lastAuditedAt ?? cache?.lastAuditedAt ?? null,
170
+ };
171
+ }
172
+
173
+ const TAIL_GUARD_BYTES = 4096;
174
+
175
+ function snapshotGuard(filePath, size) {
176
+ if (size <= 0) return { hash: null, endsWithNewline: false };
177
+ const length = Math.min(size, TAIL_GUARD_BYTES);
178
+ const buffer = Buffer.allocUnsafe(length);
179
+ const fd = openSync(filePath, 'r');
180
+ try {
181
+ const read = readSync(fd, buffer, 0, length, size - length);
182
+ const slice = buffer.subarray(0, read);
183
+ return {
184
+ hash: createHash('sha256').update(slice).digest('base64url').slice(0, 20),
185
+ endsWithNewline: slice.at(-1) === 0x0a,
186
+ };
187
+ } finally {
188
+ closeSync(fd);
189
+ }
190
+ }
191
+
192
+ function tailStateFor(file) {
193
+ const prior = file.priorTail;
194
+ const tail = prior?.tail;
195
+ if (!tail || !prior.signature) return null;
196
+ if (prior.signature.size <= 0 || prior.signature.size >= file.signature.size) return null;
197
+ if (prior.signature.dev !== file.signature.dev || prior.signature.ino !== file.signature.ino) return null;
198
+ if (prior.signature.mtimeMs > file.signature.mtimeMs) return null;
199
+ if (tail.parsedBytes !== prior.signature.size || !tail.endsWithNewline || !tail.guardHash) return null;
200
+ try {
201
+ const guard = snapshotGuard(file.filePath, prior.signature.size);
202
+ return guard.hash === tail.guardHash ? tail : null;
203
+ } catch {
204
+ return null;
205
+ }
206
+ }
207
+
208
+ function timestampMs(value) {
209
+ if (value == null || value === '') return null;
210
+ const n = new Date(value).getTime();
211
+ return Number.isFinite(n) ? n : null;
212
+ }
213
+
214
+ function epochMs(value) {
215
+ if (typeof value === 'string' && value.trim() !== '') value = Number(value);
216
+ if (typeof value !== 'number' || !Number.isFinite(value)) return null;
217
+ return value < 1e12 ? value * 1000 : value;
218
+ }
219
+
220
+ function isTaskStarted(payload) {
221
+ return payload?.type === 'task_started' || payload?.type === 'turn_started';
222
+ }
223
+
224
+ function upperBound(sorted, target) {
225
+ let lo = 0;
226
+ let hi = sorted.length;
227
+ while (lo < hi) {
228
+ const mid = lo + ((hi - lo) >> 1);
229
+ if (sorted[mid] <= target) lo = mid + 1;
230
+ else hi = mid;
231
+ }
232
+ return lo;
233
+ }
234
+
235
+ function tokenFingerprint(payload) {
236
+ // Copied rollout items are re-serialized with a fresh outer timestamp, but
237
+ // their token_count payload is unchanged. A compact payload hash therefore
238
+ // identifies replayed records without retaining raw usage objects in memory.
239
+ return createHash('sha256')
240
+ .update(JSON.stringify(payload))
241
+ .digest('base64url')
242
+ .slice(0, 16);
243
+ }
244
+
245
+ /**
246
+ * Return the longest prefix of `child` that is also a suffix of `parent`.
247
+ * Codex can fork full history or the last N turns, but the copied block always
248
+ * reaches the source snapshot's end. Requiring the suffix prevents a child's
249
+ * coincidentally repeated payload from matching an unrelated interior turn.
250
+ * KMP keeps this linear even when many token payloads are identical.
251
+ */
252
+ function longestReplayPrefix(child, parent) {
253
+ if (child.length === 0 || parent.length === 0) return 0;
254
+
255
+ const prefix = new Array(child.length).fill(0);
256
+ for (let i = 1, matched = 0; i < child.length; i++) {
257
+ while (matched > 0 && child[i] !== child[matched]) matched = prefix[matched - 1];
258
+ if (child[i] === child[matched]) matched++;
259
+ prefix[i] = matched;
260
+ }
261
+
262
+ let matched = 0;
263
+ for (let i = 0; i < parent.length; i++) {
264
+ const fingerprint = parent[i];
265
+ while (matched > 0 && fingerprint !== child[matched]) matched = prefix[matched - 1];
266
+ if (fingerprint === child[matched]) matched++;
267
+ if (matched === child.length && i < parent.length - 1) matched = prefix[matched - 1];
268
+ }
269
+ return matched;
270
+ }
271
+
272
+ /**
273
+ * Return the longest prefix of `child` found contiguously anywhere in
274
+ * `parent`. A live sub-agent rollout can be observed while Codex is still
275
+ * copying the parent block, before that copy reaches the parent snapshot's
276
+ * end. In that state the exact records are inherited history even though the
277
+ * stricter completed-replay suffix match above deliberately rejects them.
278
+ */
279
+ function longestPartialReplayPrefix(child, parent) {
280
+ if (child.length === 0 || parent.length === 0) return 0;
281
+
282
+ const prefix = new Array(child.length).fill(0);
283
+ for (let i = 1, matched = 0; i < child.length; i++) {
284
+ while (matched > 0 && child[i] !== child[matched]) matched = prefix[matched - 1];
285
+ if (child[i] === child[matched]) matched++;
286
+ prefix[i] = matched;
287
+ }
288
+
289
+ let matched = 0;
290
+ let longest = 0;
291
+ for (const fingerprint of parent) {
292
+ while (matched > 0 && fingerprint !== child[matched]) matched = prefix[matched - 1];
293
+ if (fingerprint === child[matched]) matched++;
294
+ longest = Math.max(longest, matched);
295
+ if (matched === child.length) matched = prefix[matched - 1];
296
+ }
297
+ return longest;
298
+ }
299
+
300
+ // `task_started.started_at` is stored at one-second precision while the
301
+ // canonical session timestamp has milliseconds. Real Codex Desktop rollouts
302
+ // start the child task within a few seconds of creating the child session.
303
+ const OWN_TASK_START_WINDOW_MS = 5_000;
304
+
305
+ /**
306
+ * Stream a rollout once and build a compact replay index. A fork/sub-agent
307
+ * file starts with its own session_meta and can then contain the source
308
+ * session's complete metadata and history. Only the first session_meta is
309
+ * canonical; later ones are replayed records and must never overwrite it.
310
+ *
311
+ * tokenTimes preserves raw token_count ordinals (including malformed usage
312
+ * records) on a monotonic timeline. tokenFingerprints identifies an exact
313
+ * copied sequence even when Codex forks only the last N turns instead of a
314
+ * full prefix. Together they bound matching to source records that existed at
315
+ * spawn without over-skipping child work when the parent later grows.
316
+ */
317
+ async function indexSessionFile(filePath, snapshotSize) {
318
+ let sessionId = null;
319
+ let forkedFromId = null;
320
+ let parentThreadId = null;
321
+ let sessionProject = 'unknown';
322
+ let sessionStartedAtMs = null;
323
+ let isSubagent = false;
324
+ let sessionMetaCount = 0;
325
+ let parsedRecordCount = 0;
326
+ let rawTokenCount = 0;
327
+ let logicalTimestamp = Number.NEGATIVE_INFINITY;
328
+ const tokenTimes = [];
329
+ const tokenFingerprints = [];
330
+ let pendingTokenTimeIndexes = [];
331
+ const taskBoundaries = [];
332
+ let firstTaskBoundary = null;
333
+ let ownTaskBoundary = null;
334
+
335
+ for await (const line of readLines(filePath, snapshotSize)) {
336
+ if (!line.trim()) continue;
337
+ try {
338
+ const obj = JSON.parse(line);
339
+ parsedRecordCount++;
340
+
341
+ const recordTimestamp = timestampMs(obj.timestamp);
342
+ if (recordTimestamp != null) {
343
+ logicalTimestamp = Math.max(logicalTimestamp, recordTimestamp);
344
+ // An invalid token_count timestamp is placed at the next valid record
345
+ // time. If there is no next valid record it remains +Infinity, which
346
+ // deliberately biases a parent-at-spawn boundary toward under-skip.
347
+ for (const idx of pendingTokenTimeIndexes) tokenTimes[idx] = logicalTimestamp;
348
+ pendingTokenTimeIndexes = [];
349
+ }
350
+
351
+ if (obj.type === 'session_meta' && obj.payload) {
352
+ sessionMetaCount++;
353
+ if (sessionMetaCount === 1) {
354
+ const meta = obj.payload;
355
+ sessionId = meta.id || null;
356
+ forkedFromId = meta.forked_from_id || null;
357
+ parentThreadId = extractParentThreadId(meta);
358
+ isSubagent = isSubagentMeta(meta);
359
+ sessionProject = extractProject(meta);
360
+ sessionStartedAtMs = timestampMs(meta.timestamp) ?? recordTimestamp;
361
+ }
362
+ } else if (obj.type === 'event_msg' && obj.payload?.type === 'token_count') {
363
+ rawTokenCount++;
364
+ tokenFingerprints.push(tokenFingerprint(obj.payload));
365
+ if (recordTimestamp == null) {
366
+ tokenTimes.push(Number.POSITIVE_INFINITY);
367
+ pendingTokenTimeIndexes.push(tokenTimes.length - 1);
368
+ } else {
369
+ tokenTimes.push(logicalTimestamp);
370
+ }
371
+ } else if (obj.type === 'event_msg' && isTaskStarted(obj.payload)) {
372
+ const boundary = {
373
+ recordIndex: parsedRecordCount,
374
+ rawTokenCount,
375
+ startedAtMs: epochMs(obj.payload.started_at),
376
+ };
377
+ taskBoundaries.push(boundary);
378
+ firstTaskBoundary ??= boundary;
379
+
380
+ const startedAtMs = boundary.startedAtMs;
381
+ if (sessionStartedAtMs != null && startedAtMs != null
382
+ && Math.abs(startedAtMs - sessionStartedAtMs) <= OWN_TASK_START_WINDOW_MS) {
383
+ // Keep the last match so a copied parent task that happened to start
384
+ // in the same second cannot win over the child's later own boundary.
385
+ ownTaskBoundary = boundary;
386
+ }
387
+ }
388
+ } catch {
389
+ continue;
390
+ }
391
+ }
392
+
393
+ return {
394
+ filePath,
395
+ sessionId,
396
+ forkedFromId,
397
+ parentThreadId,
398
+ sessionProject,
399
+ sessionStartedAtMs,
400
+ isSubagent,
401
+ sessionMetaCount,
402
+ parsedRecordCount,
403
+ rawTokenCount,
404
+ tokenTimes,
405
+ tokenFingerprints,
406
+ taskBoundaries,
407
+ firstTaskBoundary,
408
+ ownTaskBoundary,
409
+ };
410
+ }
411
+
412
+ function replayBoundary(meta, sessionById) {
413
+ const parentId = meta.forkedFromId || (meta.isSubagent ? meta.parentThreadId : null);
414
+ const parent = parentId ? sessionById.get(parentId) : null;
415
+ const parentAtSpawn = parent && meta.sessionStartedAtMs != null
416
+ ? upperBound(parent.tokenTimes, meta.sessionStartedAtMs)
417
+ : null;
418
+ const parentSnapshot = parentAtSpawn == null
419
+ ? []
420
+ : parent.tokenFingerprints.slice(0, parentAtSpawn);
421
+ const replayTokenCount = longestReplayPrefix(meta.tokenFingerprints, parentSnapshot);
422
+ const partialReplayTokenCount = meta.isSubagent
423
+ ? longestPartialReplayPrefix(meta.tokenFingerprints, parentSnapshot)
424
+ : 0;
425
+
426
+ if (meta.isSubagent) {
427
+ // Direct evidence inside the child wins. Legacy single-meta rollouts did
428
+ // not replay task_started records, so their first task remains a safe
429
+ // fallback. Double-meta files must not use their copied parent's first
430
+ // task_started as the boundary.
431
+ // Exact token matching also handles LastNTurns forks. When it identifies
432
+ // the copied token suffix, the last task_started at that same raw ordinal
433
+ // is the child's own task boundary (copied history is written first).
434
+ const matchedTaskBoundaries = replayTokenCount > 0
435
+ ? meta.taskBoundaries.filter(boundary => (
436
+ boundary.rawTokenCount === replayTokenCount
437
+ && boundary.startedAtMs != null
438
+ && meta.sessionStartedAtMs != null
439
+ && boundary.startedAtMs >= Math.floor(meta.sessionStartedAtMs / 1000) * 1000
440
+ ))
441
+ : [];
442
+ const matchedTaskBoundary = matchedTaskBoundaries.at(-1) || null;
443
+ const direct = matchedTaskBoundary
444
+ || meta.ownTaskBoundary
445
+ || (meta.sessionMetaCount === 1 && !meta.forkedFromId
446
+ ? meta.firstTaskBoundary
447
+ : null);
448
+ if (direct) {
449
+ return {
450
+ rawTokenCount: Math.max(
451
+ replayTokenCount,
452
+ partialReplayTokenCount,
453
+ direct.rawTokenCount
454
+ ),
455
+ recordIndex: direct.recordIndex,
456
+ };
457
+ }
458
+
459
+ // A recognized sub-agent can be synced while Codex is only partway
460
+ // through appending the copied parent block. The completed-replay matcher
461
+ // correctly rejects that interior slice, but counting it would create a
462
+ // temporary spike that disappears on the next sync. Exact payload overlap
463
+ // with the known parent is sufficient evidence to defer those leading
464
+ // records until the rollout reaches a stable suffix or task boundary.
465
+ return {
466
+ rawTokenCount: Math.max(replayTokenCount, partialReplayTokenCount),
467
+ recordIndex: null,
468
+ };
469
+ }
470
+
471
+ if (meta.forkedFromId) {
472
+ return { rawTokenCount: replayTokenCount, recordIndex: null };
473
+ }
474
+ return { rawTokenCount: 0, recordIndex: null };
475
+ }
476
+
477
+ function boundaryKey(boundary) {
478
+ return `${boundary.rawTokenCount}:${boundary.recordIndex ?? ''}`;
479
+ }
480
+
481
+ function updateFileCache(file, changes) {
482
+ const data = cacheData(file.cache, changes);
483
+ try {
484
+ saveCodexFileCache(file.codexHome, file.filePath, file.signature, data);
485
+ } catch {
486
+ // A read-only home, full disk, or antivirus race must only disable the
487
+ // optimization for this run. Raw-log parsing remains the source of truth.
488
+ }
489
+ file.cache = { ...(file.cache || {}), ...data };
490
+ }
491
+
492
+ function workBudgetMs() {
493
+ const configured = Number(process.env.VIBE_USAGE_CODEX_WORK_BUDGET_MS);
494
+ if (Number.isFinite(configured) && configured > 0) return configured;
495
+ // The macOS app terminates its child after 120 seconds. Cache-building work
496
+ // in a non-interactive child therefore checkpoints before that wall so the
497
+ // next invocation resumes instead of starting from zero. Interactive users
498
+ // can let a cold build finish in one run (and can interrupt it safely).
499
+ if (codexCacheEnabled() && !process.stdout.isTTY) return 105_000;
500
+ return Number.POSITIVE_INFINITY;
501
+ }
502
+
503
+ function auditIntervalMs() {
504
+ const configured = Number(process.env.VIBE_USAGE_CODEX_AUDIT_INTERVAL_MS);
505
+ if (Number.isFinite(configured) && configured >= 0) return configured;
506
+ return 30 * 24 * 60 * 60 * 1000;
507
+ }
508
+
509
+ function auditMaxBytes() {
510
+ const configured = Number(process.env.VIBE_USAGE_CODEX_AUDIT_MAX_BYTES);
511
+ if (Number.isFinite(configured) && configured > 0) return configured;
512
+ // Keep the background audit bounded below the app's wall timeout. Larger
513
+ // active files are still invalidated immediately by their stat signature,
514
+ // and every cache generation is rebuilt after parser-algorithm changes.
515
+ return 64 * 1024 * 1024;
516
+ }
517
+
518
+ function applySessionEvent(acc, event) {
519
+ const timestampMsValue = event.timestamp.getTime();
520
+ if (acc.lastTimestampMs != null && timestampMsValue < acc.lastTimestampMs) return false;
521
+ acc.firstTimestampMs ??= timestampMsValue;
522
+ acc.lastTimestampMs = timestampMsValue;
523
+ acc.messageCount++;
524
+
525
+ if (event.role === 'user') {
526
+ if (acc.turnStartMs != null && acc.turnEndMs != null && acc.turnEndMs > acc.turnStartMs) {
527
+ acc.completedActiveSeconds += Math.round((acc.turnEndMs - acc.turnStartMs) / 1000);
528
+ }
529
+ acc.turnStartMs = null;
530
+ acc.turnEndMs = null;
531
+ acc.waitingForFirstResponse = true;
532
+ acc.userMessageCount++;
533
+ acc.userPromptHours[event.timestamp.getUTCHours()]++;
534
+ } else if (acc.waitingForFirstResponse) {
535
+ acc.turnStartMs = timestampMsValue;
536
+ acc.turnEndMs = timestampMsValue;
537
+ acc.waitingForFirstResponse = false;
538
+ } else if (acc.turnStartMs != null) {
539
+ acc.turnEndMs = timestampMsValue;
540
+ }
541
+ return true;
542
+ }
543
+
544
+ function buildSessionAccumulator(events, previous = null) {
545
+ const sorted = [...events].sort((a, b) => a.timestamp - b.timestamp);
546
+ const first = sorted[0];
547
+ const acc = previous
548
+ ? {
549
+ ...previous,
550
+ userPromptHours: [...previous.userPromptHours],
551
+ }
552
+ : {
553
+ sessionId: first?.sessionId || null,
554
+ source: first?.source || null,
555
+ project: first?.project || 'unknown',
556
+ firstTimestampMs: null,
557
+ lastTimestampMs: null,
558
+ completedActiveSeconds: 0,
559
+ turnStartMs: null,
560
+ turnEndMs: null,
561
+ waitingForFirstResponse: false,
562
+ messageCount: 0,
563
+ userMessageCount: 0,
564
+ userPromptHours: new Array(24).fill(0),
565
+ };
566
+ for (const event of sorted) {
567
+ if (!applySessionEvent(acc, event)) return null;
568
+ }
569
+ return acc.sessionId ? acc : null;
570
+ }
571
+
572
+ function sessionFromAccumulator(acc) {
573
+ if (!acc?.sessionId || acc.firstTimestampMs == null || acc.lastTimestampMs == null) return null;
574
+ let activeSeconds = acc.completedActiveSeconds;
575
+ if (acc.turnStartMs != null && acc.turnEndMs != null && acc.turnEndMs > acc.turnStartMs) {
576
+ activeSeconds += Math.round((acc.turnEndMs - acc.turnStartMs) / 1000);
577
+ }
578
+ return {
579
+ source: acc.source,
580
+ project: acc.project || 'unknown',
581
+ sessionHash: createHash('sha256').update(acc.sessionId).digest('hex').slice(0, 16),
582
+ firstMessageAt: new Date(acc.firstTimestampMs).toISOString(),
583
+ lastMessageAt: new Date(acc.lastTimestampMs).toISOString(),
584
+ durationSeconds: Math.round((acc.lastTimestampMs - acc.firstTimestampMs) / 1000),
585
+ activeSeconds,
586
+ messageCount: acc.messageCount,
587
+ userMessageCount: acc.userMessageCount,
588
+ userPromptHours: acc.userPromptHours,
589
+ };
590
+ }
591
+
592
+ // Rollout order is a protocol boundary, not a nearest-tool heuristic:
593
+ // OutputItemDone items -> response completion -> drained tools -> token_count.
594
+ // A turn contains MANY requests. Only the interval since a known boundary is
595
+ // eligible; cumulative-only usage, broken intervals and mixed IDs stay unknown.
596
+ function responseEvidence(open = false, turnId = null) {
597
+ return { open, turnId, tool: false, message: false, invalid: false, responseId: null, completed: null };
598
+ }
599
+
600
+ const CODEX_TOOL_ITEMS = new Set(['function_call', 'custom_tool_call', 'local_shell_call', 'web_search_call', 'image_generation_call', 'tool_search_call']);
601
+ const CODEX_USAGE_FIELDS = ['input_tokens', 'cached_input_tokens', 'output_tokens', 'reasoning_output_tokens', 'total_tokens'];
602
+ function sameResponseUsage(a, b) {
603
+ return a && b && CODEX_USAGE_FIELDS.every(key => (a[key] ?? 0) === (b[key] ?? 0));
604
+ }
605
+ function evidenceType(evidence) {
606
+ if (!evidence.open || evidence.invalid) return 'other';
607
+ return evidence.tool ? 'tool' : evidence.message ? 'non_tool' : 'other';
608
+ }
609
+ function observeResponse(evidence, obj, sessionId) {
610
+ const p = obj.payload;
611
+ if (obj.type === 'session_meta' || obj.type === 'compacted'
612
+ || (obj.type === 'event_msg' && ['task_complete', 'turn_complete', 'turn_aborted', 'context_compacted', 'error'].includes(p?.type))) {
613
+ return responseEvidence();
614
+ }
615
+ if (obj.type === 'turn_context' || (obj.type === 'event_msg' && isTaskStarted(p))) {
616
+ return { ...responseEvidence(true, p?.turn_id ?? null), invalid: evidence.tool || evidence.message || !!evidence.completed };
617
+ }
618
+ if (obj.type === 'event_msg' && p?.type === 'user_message') {
619
+ return { ...responseEvidence(true, evidence.turnId), invalid: evidence.tool || evidence.message || !!evidence.completed };
620
+ }
621
+ if (obj.type === 'token_usage_record') {
622
+ if (p?.thread_id && sessionId && p.thread_id !== sessionId) evidence.invalid = true;
623
+ if (p?.turn_id && evidence.turnId && p.turn_id !== evidence.turnId) evidence.invalid = true;
624
+ if (evidence.responseId && p?.response_id !== evidence.responseId) evidence.invalid = true;
625
+ if (evidence.completed) {
626
+ if (p?.response_id !== evidence.completed.id || !sameResponseUsage(p?.usage, evidence.completed.usage)) evidence.invalid = true;
627
+ } else {
628
+ evidence.completed = { id: p?.response_id, usage: p?.usage, requestType: evidenceType(evidence) };
629
+ }
630
+ return evidence;
631
+ }
632
+ // item_completed/tool-result events describe UI/execution, not another model
633
+ // response. In particular, parallel tool results must not multiply usage.
634
+ if (obj.type !== 'response_item') return evidence;
635
+ if (!p || typeof p !== 'object') { evidence.invalid = true; return evidence; }
636
+ const turnId = p.internal_chat_message_metadata_passthrough?.turn_id;
637
+ if (turnId && evidence.turnId && turnId !== evidence.turnId) evidence.invalid = true;
638
+ evidence.turnId ??= turnId ?? null;
639
+ if (p.response_id) {
640
+ if (evidence.responseId && evidence.responseId !== p.response_id) evidence.invalid = true;
641
+ evidence.responseId = p.response_id;
642
+ }
643
+ if (p.type?.endsWith('_output')) return evidence;
644
+ if (p.type === 'message' && p.role !== 'assistant') {
645
+ if (evidence.tool || evidence.message) evidence.invalid = true;
646
+ return evidence;
647
+ }
648
+ if (evidence.completed) evidence.invalid = true;
649
+ if (CODEX_TOOL_ITEMS.has(p.type)) evidence.tool = true;
650
+ else if (p.type === 'message' && Array.isArray(p.content)) evidence.message = true;
651
+ else if (p.type !== 'reasoning') evidence.invalid = true;
652
+ return evidence;
653
+ }
654
+
655
+ function mergeBucketLists(lists) {
656
+ const entries = [];
657
+ for (const buckets of lists) {
658
+ for (const bucket of buckets || []) {
659
+ entries.push({
660
+ requestType: bucket.requestType,
661
+ source: bucket.source,
662
+ model: bucket.model,
663
+ project: bucket.project,
664
+ ...(bucket.hostname ? { hostname: bucket.hostname } : {}),
665
+ timestamp: new Date(bucket.bucketStart),
666
+ inputTokens: bucket.inputTokens,
667
+ outputTokens: bucket.outputTokens,
668
+ cachedInputTokens: bucket.cachedInputTokens,
669
+ reasoningOutputTokens: bucket.reasoningOutputTokens,
670
+ });
671
+ }
672
+ }
673
+ return aggregateToBuckets(entries);
674
+ }
675
+
676
+ async function parseSessionFile(filePath, snapshotSize, fm, boundary, {
677
+ previousTail = null,
678
+ captureTail = false,
679
+ } = {}) {
680
+ const entries = [];
681
+ const sessionEvents = [];
682
+ let rawTokenSeen = previousTail?.rawTokenSeen || 0;
683
+ let parsedRecordIndex = previousTail?.parsedRecordIndex || 0;
684
+ let firstSessionMetaSeen = previousTail?.firstSessionMetaSeen || false;
685
+
686
+ const sessionProject = fm.sessionProject;
687
+ // Group timing events by the real Codex session id, not the file path: the
688
+ // same session can briefly exist in both sessions/ and archived_sessions/
689
+ // (mid-archive, or a re-synced archive). Path-keyed grouping would emit it
690
+ // as two different sessionHashes and double-count its session stats. Fall
691
+ // back to the path only when the id is unknown (corrupt/missing meta).
692
+ const sessionKey = fm.sessionId || filePath;
693
+
694
+ let turnContextModel = previousTail?.turnContextModel || 'unknown';
695
+ let serviceTier = previousTail?.serviceTier || null;
696
+ let prevTotal = previousTail?.prevTotal || null;
697
+ let prevCumulativeTotal = previousTail?.prevCumulativeTotal ?? null;
698
+ let evidence = previousTail?.responseEvidence || responseEvidence();
699
+ const start = previousTail?.parsedBytes || 0;
700
+ for await (const line of readLines(filePath, snapshotSize, start)) {
701
+ if (!line.trim()) continue;
702
+ try {
703
+ const obj = JSON.parse(line);
704
+ parsedRecordIndex++;
705
+
706
+ // A direct child task boundary covers every copied record, including
707
+ // timing/meta events. The raw-token ordinal covers full-history and
708
+ // last-N-turn forks whose exact payload sequence was matched in pass 1.
709
+ const beforeOwnTask = boundary.recordIndex != null
710
+ && parsedRecordIndex < boundary.recordIndex;
711
+ const inReplayBlock = beforeOwnTask || rawTokenSeen < boundary.rawTokenCount;
712
+
713
+ const isSessionMeta = obj.type === 'session_meta';
714
+ const isCanonicalSessionMeta = isSessionMeta && !firstSessionMetaSeen;
715
+ const isOwnSessionMeta = isSessionMeta
716
+ && obj.payload?.id != null
717
+ && obj.payload.id === fm.sessionId;
718
+ if (isSessionMeta) firstSessionMetaSeen = true;
719
+
720
+ if (obj.timestamp) {
721
+ const evTs = new Date(obj.timestamp);
722
+ if (!isNaN(evTs.getTime())) {
723
+ // Repeated same-id metadata can be appended on resume/config
724
+ // updates and belongs to this logical session. A different-id meta
725
+ // is copied parent history and must not inflate timing stats.
726
+ const keepSessionMeta = isCanonicalSessionMeta
727
+ || (isOwnSessionMeta && !inReplayBlock);
728
+ if (keepSessionMeta || (!isSessionMeta && !inReplayBlock)) {
729
+ const isUserTurn = obj.type === 'turn_context' || obj.type === 'session_meta';
730
+ sessionEvents.push({
731
+ sessionId: sessionKey,
732
+ source: 'codex',
733
+ project: sessionProject,
734
+ timestamp: evTs,
735
+ role: isUserTurn ? 'user' : 'assistant',
736
+ });
737
+ }
738
+ }
739
+ }
740
+
741
+ evidence = inReplayBlock ? responseEvidence() : observeResponse(evidence, obj, fm.sessionId);
742
+
743
+ if (obj.type === 'turn_context') {
744
+ if (obj.payload?.model) turnContextModel = obj.payload.model;
745
+ if (Object.hasOwn(obj.payload || {}, 'service_tier')) {
746
+ serviceTier = normalizeCodexServiceTier(obj.payload.service_tier);
747
+ }
748
+ continue;
749
+ }
750
+
751
+ if (obj.type !== 'event_msg') continue;
752
+
753
+ const payload = obj.payload;
754
+ if (!payload) continue;
755
+
756
+ if (payload.type === 'thread_settings_applied') {
757
+ const settings = payload.thread_settings;
758
+ if (settings?.model) turnContextModel = settings.model;
759
+ if (Object.hasOwn(settings || {}, 'service_tier')) {
760
+ serviceTier = normalizeCodexServiceTier(settings.service_tier);
761
+ }
762
+ continue;
763
+ }
764
+
765
+ if (payload.type !== 'token_count') continue;
766
+
767
+ // Raw ordinals advance before validating usage/timestamp so pass 1 and
768
+ // pass 2 cannot drift on a malformed copied token_count record.
769
+ const isReplayedHistory = inReplayBlock;
770
+ rawTokenSeen++;
771
+
772
+ const info = payload.info;
773
+ if (!info) continue;
774
+
775
+ // Codex sometimes writes the same token_count twice back-to-back:
776
+ // identical last_token_usage with an unchanged cumulative total. A
777
+ // real API call always advances the cumulative counter (its input
778
+ // tokens alone are non-zero), so an unchanged positive total marks a
779
+ // duplicate emission — or a zero-usage bookkeeping event such as
780
+ // compaction — and must count as zero, not a second copy of
781
+ // last_token_usage. Guarded to positive totals so builds that leave
782
+ // total_token_usage all-zero can't suppress real usage.
783
+ const cumulativeTotal = info.total_token_usage?.total_tokens;
784
+ const previousCumulativeTotal = prevCumulativeTotal;
785
+ const isDuplicateEmission = typeof cumulativeTotal === 'number'
786
+ && cumulativeTotal > 0
787
+ && cumulativeTotal === prevCumulativeTotal;
788
+ if (typeof cumulativeTotal === 'number') prevCumulativeTotal = cumulativeTotal;
789
+
790
+ // Prefer incremental per-request usage; compute delta from cumulative
791
+ // totals as fallback. Always advance the cumulative baseline, even
792
+ // when last_token_usage exists or the record belongs to a replay.
793
+ const curr = info.total_token_usage;
794
+ let usage = info.last_token_usage;
795
+ if (!usage && curr) {
796
+ if (prevTotal) {
797
+ const delta = {
798
+ input_tokens: (curr.input_tokens || 0) - (prevTotal.input_tokens || 0),
799
+ output_tokens: (curr.output_tokens || 0) - (prevTotal.output_tokens || 0),
800
+ cached_input_tokens: (curr.cached_input_tokens || 0) - (prevTotal.cached_input_tokens || 0),
801
+ reasoning_output_tokens: (curr.reasoning_output_tokens || 0) - (prevTotal.reasoning_output_tokens || 0),
802
+ };
803
+ // Cumulative counters can reset after compaction or a new usage
804
+ // window. Treat the first post-reset total as a fresh baseline;
805
+ // allowing a negative delta would cancel legitimate bucket usage.
806
+ usage = Object.values(delta).some(value => value < 0) ? curr : delta;
807
+ } else {
808
+ // First cumulative entry — use as-is (it's the first event's total)
809
+ usage = curr;
810
+ }
811
+ }
812
+ // total_token_usage is session-wide, not per model. A global baseline
813
+ // avoids counting the full cumulative total again after a model switch.
814
+ if (curr) prevTotal = { ...curr };
815
+ if (isDuplicateEmission) continue;
816
+ const completedRequest = evidence.completed || (cumulativeTotal > 0
817
+ && cumulativeTotal - (previousCumulativeTotal ?? 0) === info.last_token_usage?.total_tokens);
818
+ const requestType = info.last_token_usage && completedRequest && !evidence.invalid
819
+ ? evidence.completed
820
+ ? sameResponseUsage(evidence.completed.usage, usage) ? evidence.completed.requestType : 'other'
821
+ : evidenceType(evidence)
822
+ : 'other';
823
+ evidence = responseEvidence(!isReplayedHistory, evidence.turnId);
824
+ if (!usage || isReplayedHistory) continue;
825
+
826
+ const timestamp = obj.timestamp ? new Date(obj.timestamp) : null;
827
+ if (!timestamp || isNaN(timestamp.getTime())) continue;
828
+
829
+ const rawModel = info.model || payload.model || turnContextModel || 'unknown';
830
+ const model = decorateCodexModel(rawModel, serviceTier, timestamp.getTime());
831
+
832
+ // OpenAI API: input_tokens INCLUDES cached, output_tokens INCLUDES reasoning.
833
+ // Normalize to Anthropic-style semantics where each field is non-overlapping.
834
+ const cachedInput = usage.cached_input_tokens || usage.cache_read_input_tokens || 0;
835
+ const reasoningOutput = usage.reasoning_output_tokens || 0;
836
+ entries.push({
837
+ source: 'codex',
838
+ requestType,
839
+ model,
840
+ project: sessionProject,
841
+ timestamp,
842
+ inputTokens: (usage.input_tokens || 0) - cachedInput,
843
+ outputTokens: (usage.output_tokens || 0) - reasoningOutput,
844
+ cachedInputTokens: cachedInput,
845
+ reasoningOutputTokens: reasoningOutput,
846
+ });
847
+ } catch {
848
+ evidence.invalid = true;
849
+ continue;
850
+ }
851
+ }
852
+
853
+ // Indexed files must match both passes exactly. Ordinary sessions take the
854
+ // single-pass fast path and have no expected counts; their byte-bounded
855
+ // snapshot is still stable, and any append invalidates the stat signature on
856
+ // the next sync.
857
+ if (fm.parsedRecordCount != null && fm.rawTokenCount != null) {
858
+ if (parsedRecordIndex !== fm.parsedRecordCount || rawTokenSeen !== fm.rawTokenCount) {
859
+ throw new Error('Codex rollout changed while syncing; retry on the next sync');
860
+ }
861
+ }
862
+
863
+ const buckets = mergeBucketLists([
864
+ previousTail?.buckets || [],
865
+ aggregateToBuckets(entries),
866
+ ]);
867
+ const sessionAccumulator = buildSessionAccumulator(
868
+ sessionEvents,
869
+ previousTail?.sessionAccumulator || null
870
+ );
871
+ // Appended records should be chronological. If an app version inserts an
872
+ // older event into the tail, the compact accumulator cannot reproduce the
873
+ // global sort exactly, so discard the optimization and rebuild this file.
874
+ if (previousTail && sessionEvents.length > 0 && !sessionAccumulator) {
875
+ return parseSessionFile(filePath, snapshotSize, fm, boundary, { captureTail });
876
+ }
877
+ const session = sessionFromAccumulator(sessionAccumulator);
878
+ const result = { buckets, sessions: session ? [session] : [] };
879
+ if (captureTail) {
880
+ const guard = snapshotGuard(filePath, snapshotSize);
881
+ result.tail = {
882
+ parsedBytes: snapshotSize,
883
+ parsedRecordIndex,
884
+ rawTokenSeen,
885
+ firstSessionMetaSeen,
886
+ turnContextModel,
887
+ serviceTier,
888
+ prevTotal,
889
+ prevCumulativeTotal,
890
+ responseEvidence: evidence,
891
+ buckets,
892
+ sessionAccumulator,
893
+ guardHash: guard.hash,
894
+ endsWithNewline: guard.endsWithNewline,
895
+ };
896
+ }
897
+ return result;
898
+ }
899
+
900
+ function mergeFileResults(results) {
901
+ const entries = [];
902
+ const sessions = [];
903
+ for (const result of results) {
904
+ for (const bucket of result.buckets || []) {
905
+ entries.push({
906
+ requestType: bucket.requestType,
907
+ source: bucket.source,
908
+ model: bucket.model,
909
+ project: bucket.project,
910
+ ...(bucket.hostname ? { hostname: bucket.hostname } : {}),
911
+ timestamp: new Date(bucket.bucketStart),
912
+ inputTokens: bucket.inputTokens,
913
+ outputTokens: bucket.outputTokens,
914
+ cachedInputTokens: bucket.cachedInputTokens,
915
+ reasoningOutputTokens: bucket.reasoningOutputTokens,
916
+ });
917
+ }
918
+ for (const session of result.sessions || []) sessions.push(session);
919
+ }
920
+ return { buckets: aggregateToBuckets(entries), sessions };
921
+ }
922
+
923
+ async function parseNativeCodex({ codexExtraHome, extraRoots = [] } = {}) {
924
+ let extraCodexHomePath = null;
925
+ if (codexExtraHome?.trim()) {
926
+ const validation = validateExtraCodexHome(codexExtraHome);
927
+ if (!validation.ok) {
928
+ return {
929
+ buckets: [],
930
+ sessions: [],
931
+ skipped: true,
932
+ warnings: [`codex: 额外 Codex Home 不可用,已跳过本次 Codex 同步: ${validation.path}`],
933
+ };
934
+ }
935
+ extraCodexHomePath = validation.path;
936
+ }
937
+
938
+ const configuredHomes = [];
939
+ for (const root of extraRoots) {
940
+ const discovered = discoverCodexHomes(root);
941
+ if (!discovered.readable || discovered.homes.length === 0) {
942
+ return {
943
+ buckets: [],
944
+ sessions: [],
945
+ skipped: true,
946
+ warnings: [`codex: 额外根目录不可用,已跳过本次 Codex 同步: ${discovered.root}`],
947
+ };
948
+ }
949
+ configuredHomes.push(...discovered.homes);
950
+ }
951
+
952
+ const strictHomes = new Set(configuredHomes);
953
+ if (extraCodexHomePath) strictHomes.add(extraCodexHomePath);
954
+ const codexHomes = [...new Set([
955
+ ...resolveCodexHomes(codexExtraHome),
956
+ ...configuredHomes,
957
+ ])];
958
+ const dirs = codexHomes.flatMap(codexHome => (
959
+ codexSessionDirs(codexHome).map(dir => ({ codexHome, dir, strict: strictHomes.has(codexHome) }))
960
+ ));
961
+ if (!dirs.some(({ dir }) => existsSync(dir))) return { buckets: [], sessions: [] };
962
+
963
+ const startedAt = Date.now();
964
+ const budget = workBudgetMs();
965
+ const overBudget = () => Date.now() - startedAt >= budget;
966
+ const cacheStats = {
967
+ headerHits: 0,
968
+ indexHits: 0,
969
+ resultHits: 0,
970
+ tailHits: 0,
971
+ filesRead: 0,
972
+ audited: 0,
973
+ };
974
+ const files = [];
975
+ for (const { codexHome, dir, strict } of dirs) {
976
+ let filePaths;
977
+ try {
978
+ filePaths = findJsonlFiles(dir, strict);
979
+ } catch {
980
+ return {
981
+ buckets: [], sessions: [], skipped: true,
982
+ warnings: [`codex: 额外根目录读取失败,已保留上次同步数据: ${codexHome}`],
983
+ };
984
+ }
985
+ for (const filePath of filePaths) {
986
+ try {
987
+ const stat = statSync(filePath);
988
+ if (stat.size <= 0) continue;
989
+ const signature = fileSignature(stat);
990
+ const cache = loadCodexFileCache(codexHome, filePath, signature);
991
+ const priorCache = cache || loadCodexFileCache(codexHome, filePath);
992
+ const priorTail = cache ? null : loadCodexFileTail(codexHome, filePath);
993
+ const file = {
994
+ codexHome,
995
+ filePath,
996
+ strict,
997
+ snapshotSize: stat.size,
998
+ signature,
999
+ cache,
1000
+ priorCache,
1001
+ priorTail,
1002
+ header: null,
1003
+ appendTail: null,
1004
+ };
1005
+ if (!cache && priorCache) file.appendTail = tailStateFor(file);
1006
+ files.push(file);
1007
+ } catch (err) {
1008
+ // The file may move to archived_sessions between discovery and stat.
1009
+ if (strict && err?.code !== 'ENOENT') {
1010
+ return {
1011
+ buckets: [], sessions: [], skipped: true,
1012
+ warnings: [`codex: 额外根目录读取失败,已保留上次同步数据: ${codexHome}`],
1013
+ };
1014
+ }
1015
+ }
1016
+ }
1017
+ }
1018
+ if (files.length === 0) return { buckets: [], sessions: [] };
1019
+
1020
+ // A warm cache gets a tiny rolling correctness audit. Never mix this into a
1021
+ // cold/resumed build: all files must already have complete results, and at
1022
+ // most one bounded file is re-read per invocation.
1023
+ const auditPaths = new Set();
1024
+ if (files.every(file => file.cache?.header && file.cache?.result)) {
1025
+ const cutoff = Date.now() - auditIntervalMs();
1026
+ const candidate = files
1027
+ .filter(file => file.snapshotSize <= auditMaxBytes())
1028
+ .filter(file => (file.cache.lastAuditedAt || 0) <= cutoff)
1029
+ .sort((a, b) => (a.cache.lastAuditedAt || 0) - (b.cache.lastAuditedAt || 0))[0];
1030
+ if (candidate) auditPaths.add(candidate.filePath);
1031
+ }
1032
+
1033
+ // Cheap discovery: cached headers require no rollout read. On a cold build,
1034
+ // read only through the first session_meta so ordinary sessions can avoid
1035
+ // the former all-files replay-index pass.
1036
+ const candidatesById = new Map();
1037
+ for (let i = 0; i < files.length; i++) {
1038
+ const file = files[i];
1039
+ const reusableHeader = file.cache?.header || (file.appendTail ? file.priorCache?.header : null);
1040
+ if (reusableHeader && !auditPaths.has(file.filePath)) {
1041
+ file.header = reusableHeader;
1042
+ if (!file.cache) file.cache = { header: reusableHeader };
1043
+ cacheStats.headerHits++;
1044
+ } else {
1045
+ try {
1046
+ file.header = await readSessionHeader(file.filePath, file.snapshotSize);
1047
+ cacheStats.filesRead++;
1048
+ updateFileCache(file, { header: file.header });
1049
+ } catch {
1050
+ if (file.strict) {
1051
+ return {
1052
+ buckets: [], sessions: [], skipped: true,
1053
+ warnings: [`codex: 额外根目录读取失败,已保留上次同步数据: ${file.codexHome}`],
1054
+ };
1055
+ }
1056
+ continue;
1057
+ }
1058
+ }
1059
+ if (file.header.sessionId) {
1060
+ if (!candidatesById.has(file.header.sessionId)) candidatesById.set(file.header.sessionId, []);
1061
+ candidatesById.get(file.header.sessionId).push(file);
1062
+ }
1063
+ if (overBudget() && i < files.length - 1) {
1064
+ return {
1065
+ buckets: [], sessions: [], skipped: true,
1066
+ indexing: { phase: 'discovery', completed: i + 1, total: files.length },
1067
+ cache: cacheStats,
1068
+ };
1069
+ }
1070
+ }
1071
+
1072
+ const duplicateIds = new Set(
1073
+ [...candidatesById].filter(([, candidates]) => candidates.length > 1).map(([id]) => id)
1074
+ );
1075
+ const referencedParentIds = new Set();
1076
+ for (const file of files) {
1077
+ const parentId = file.header?.forkedFromId
1078
+ || (file.header?.isSubagent ? file.header.parentThreadId : null);
1079
+ if (parentId) referencedParentIds.add(parentId);
1080
+ }
1081
+
1082
+ // Only replay participants, their parents, corrupt-header files, and
1083
+ // duplicate physical copies need the full compact token index.
1084
+ const fileMeta = new Map();
1085
+ const needsIndex = new Set();
1086
+ for (const file of files) {
1087
+ const header = file.header;
1088
+ const required = !header
1089
+ || !header.sessionId
1090
+ || header.isSubagent
1091
+ || header.forkedFromId != null
1092
+ || header.parentThreadId != null
1093
+ || referencedParentIds.has(header.sessionId)
1094
+ || duplicateIds.has(header.sessionId);
1095
+ if (!required) {
1096
+ fileMeta.set(file.filePath, { ...header, filePath: file.filePath });
1097
+ continue;
1098
+ }
1099
+ needsIndex.add(file.filePath);
1100
+ let meta = auditPaths.has(file.filePath) ? null : file.cache?.index;
1101
+ if (meta) {
1102
+ cacheStats.indexHits++;
1103
+ } else {
1104
+ try {
1105
+ meta = await indexSessionFile(file.filePath, file.snapshotSize);
1106
+ cacheStats.filesRead++;
1107
+ updateFileCache(file, { index: meta });
1108
+ } catch {
1109
+ if (file.strict) {
1110
+ return {
1111
+ buckets: [], sessions: [], skipped: true,
1112
+ warnings: [`codex: 额外根目录读取失败,已保留上次同步数据: ${file.codexHome}`],
1113
+ };
1114
+ }
1115
+ continue;
1116
+ }
1117
+ }
1118
+ fileMeta.set(file.filePath, meta);
1119
+ if (overBudget()) {
1120
+ return {
1121
+ buckets: [], sessions: [], skipped: true,
1122
+ indexing: { phase: 'replay-index', completed: fileMeta.size, total: files.length },
1123
+ cache: cacheStats,
1124
+ };
1125
+ }
1126
+ }
1127
+
1128
+ // Select the most complete physical copy exactly as before. Unique ordinary
1129
+ // sessions have no full record count, but cannot compete with another copy.
1130
+ const sessionById = new Map();
1131
+ for (const file of files) {
1132
+ const meta = fileMeta.get(file.filePath);
1133
+ if (!meta?.sessionId) continue;
1134
+ const existing = sessionById.get(meta.sessionId);
1135
+ const count = meta.parsedRecordCount ?? 0;
1136
+ const existingCount = existing?.parsedRecordCount ?? 0;
1137
+ if (!existing || count > existingCount) sessionById.set(meta.sessionId, meta);
1138
+ }
1139
+
1140
+ const results = [];
1141
+ for (let i = 0; i < files.length; i++) {
1142
+ const file = files[i];
1143
+ const fm = fileMeta.get(file.filePath);
1144
+ if (!fm) continue;
1145
+ if (fm.sessionId && sessionById.get(fm.sessionId)?.filePath !== file.filePath) continue;
1146
+
1147
+ const boundary = needsIndex.has(file.filePath)
1148
+ ? replayBoundary(fm, sessionById)
1149
+ : { rawTokenCount: 0, recordIndex: null };
1150
+ const key = boundaryKey(boundary);
1151
+ let result = !auditPaths.has(file.filePath) && file.cache?.result?.boundaryKey === key
1152
+ ? file.cache.result
1153
+ : null;
1154
+ if (result) {
1155
+ cacheStats.resultHits++;
1156
+ } else {
1157
+ const previousTail = !needsIndex.has(file.filePath) && !auditPaths.has(file.filePath)
1158
+ ? file.appendTail
1159
+ : null;
1160
+ const parsed = await parseSessionFile(file.filePath, file.snapshotSize, fm, boundary, {
1161
+ previousTail,
1162
+ captureTail: !needsIndex.has(file.filePath),
1163
+ });
1164
+ cacheStats.filesRead++;
1165
+ if (previousTail) cacheStats.tailHits++;
1166
+ const { tail, ...summary } = parsed;
1167
+ if (tail) {
1168
+ try {
1169
+ saveCodexFileTail(file.codexHome, file.filePath, file.signature, tail);
1170
+ } catch {
1171
+ // Same fail-open rule as the summary cache: tail acceleration is
1172
+ // optional and a write failure must not fail the parser.
1173
+ }
1174
+ }
1175
+ result = { boundaryKey: key, ...summary };
1176
+ updateFileCache(file, { result, lastAuditedAt: Date.now() });
1177
+ file.appendTail = null;
1178
+ file.priorTail = null;
1179
+ if (auditPaths.has(file.filePath)) cacheStats.audited++;
1180
+ }
1181
+ results.push(result);
1182
+
1183
+ if (overBudget() && i < files.length - 1) {
1184
+ return {
1185
+ buckets: [], sessions: [], skipped: true,
1186
+ indexing: { phase: 'usage', completed: i + 1, total: files.length },
1187
+ cache: cacheStats,
1188
+ };
1189
+ }
1190
+ }
1191
+
1192
+ return { ...mergeFileResults(results), cache: cacheStats };
1193
+ }
1194
+
1195
+ export async function parse(options = {}) {
1196
+ const nativeResult = await parseNativeCodex(options);
1197
+ return mergeCindyHarnessUsage(nativeResult, readCindyHarnessUsage('codex'));
1198
+ }