@ipv9/tokentracker-cli 0.39.38 → 0.39.39

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 (79) hide show
  1. package/README.md +41 -9
  2. package/dashboard/dist/assets/{Card-DD4s0V6T.js → Card-BbRYRVXf.js} +1 -1
  3. package/dashboard/dist/assets/DashboardPage-DYKHv0m3.js +60 -0
  4. package/dashboard/dist/assets/{FadeIn-CUSBK0jm.js → FadeIn-CjAacJ5t.js} +1 -1
  5. package/dashboard/dist/assets/{IpCheckPage-PHF4lPax.js → IpCheckPage-BGALK8Vy.js} +1 -1
  6. package/dashboard/dist/assets/{LimitsPage-dS9C9Vdt.js → LimitsPage-LkZGPYvg.js} +2 -2
  7. package/dashboard/dist/assets/{LocalOnlyNotice-DCmtS_j5.js → LocalOnlyNotice-Dxo2vFMQ.js} +1 -1
  8. package/dashboard/dist/assets/PopoverPopup-DP9fwdtl.js +1 -0
  9. package/dashboard/dist/assets/Select-DPE53NmM.js +1 -0
  10. package/dashboard/dist/assets/SelectItemText-D69v77is.js +12 -0
  11. package/dashboard/dist/assets/SettingsPage-Dxzqpi02.js +1 -0
  12. package/dashboard/dist/assets/SkillsPage-Bo7e39AB.js +1 -0
  13. package/dashboard/dist/assets/WidgetsPage-HHcIr_Eq.js +1 -0
  14. package/dashboard/dist/assets/WrappedPage-C-_jLXXU.js +1 -0
  15. package/dashboard/dist/assets/{arrow-up-right-CjaBQnwI.js → arrow-up-right-BJvJHUGj.js} +1 -1
  16. package/dashboard/dist/assets/{download-DLBqkSwp.js → download-CnzY_EgN.js} +1 -1
  17. package/dashboard/dist/assets/format-1w-q5n2f.js +1 -0
  18. package/dashboard/dist/assets/main-1kxAVM3m.css +1 -0
  19. package/dashboard/dist/assets/main-3gBEI6Jl.js +694 -0
  20. package/dashboard/dist/assets/mock-data-CJOMGcL9.js +1 -0
  21. package/dashboard/dist/assets/use-limits-display-prefs-D1nT9-mr.js +1 -0
  22. package/dashboard/dist/assets/use-native-settings-Dh6NzqjY.js +1 -0
  23. package/dashboard/dist/assets/use-usage-limits-BXR6lzGf.js +1 -0
  24. package/dashboard/dist/assets/useCurrency-Hhs5jbWw.js +1 -0
  25. package/dashboard/dist/index.html +15 -27
  26. package/dashboard/dist/llms.txt +0 -2
  27. package/dashboard/dist/sitemap.xml +0 -12
  28. package/package.json +7 -4
  29. package/src/cli.js +4 -13
  30. package/src/commands/doctor.js +1 -3
  31. package/src/commands/init.js +63 -53
  32. package/src/commands/serve.js +2 -50
  33. package/src/commands/status.js +1 -55
  34. package/src/commands/sync.js +134 -227
  35. package/src/lib/{browser-auth.js → browser-open.js} +3 -149
  36. package/src/lib/claude-categorizer.js +204 -32
  37. package/src/lib/codex-context-breakdown.js +83 -7
  38. package/src/lib/codex-rollout-parser.js +52 -10
  39. package/src/lib/diagnostics.js +1 -49
  40. package/src/lib/doctor.js +12 -87
  41. package/src/lib/file-identity.js +69 -0
  42. package/src/lib/init-flow.js +6 -3
  43. package/src/lib/local-api.js +6 -364
  44. package/src/lib/pricing/curated-overrides.json +1 -1
  45. package/src/lib/pricing/seed-snapshot.json +1 -1
  46. package/src/lib/rollout.js +392 -144
  47. package/src/lib/runtime-config.js +0 -32
  48. package/src/lib/sqlite-reader.js +83 -8
  49. package/dashboard/dist/assets/ActivityHeatmap-BrehqUIb.js +0 -42
  50. package/dashboard/dist/assets/DashboardPage-CyUShBRJ.js +0 -19
  51. package/dashboard/dist/assets/DevicePage-BA74Su1l.js +0 -1
  52. package/dashboard/dist/assets/DialogTitle-DoKsz5wi.js +0 -1
  53. package/dashboard/dist/assets/InsforgeUserHeaderControls-Bk_U2dHr.js +0 -1
  54. package/dashboard/dist/assets/LandingPage-Bbeu84Iu.js +0 -4356
  55. package/dashboard/dist/assets/LeaderboardAvatar-ByqeB6bM.js +0 -1
  56. package/dashboard/dist/assets/LeaderboardPage-BeWXF00S.js +0 -6
  57. package/dashboard/dist/assets/LeaderboardProfileModal-C4yvbZd_.js +0 -72
  58. package/dashboard/dist/assets/LeaderboardProfilePage-D3cbSh5o.js +0 -1
  59. package/dashboard/dist/assets/LoginPage-Ch3UQoWj.js +0 -1
  60. package/dashboard/dist/assets/PopoverPopup-DjG0gjlA.js +0 -1
  61. package/dashboard/dist/assets/Select-HzIjWJpE.js +0 -1
  62. package/dashboard/dist/assets/SelectItemText-f0tCwtvZ.js +0 -1
  63. package/dashboard/dist/assets/SettingsPage-DJOZAlZy.js +0 -1
  64. package/dashboard/dist/assets/SkillsPage-BQcyJIdx.js +0 -1
  65. package/dashboard/dist/assets/WidgetsPage-B_vxuV09.js +0 -1
  66. package/dashboard/dist/assets/WrappedPage-CZN9Y3Vz.js +0 -1
  67. package/dashboard/dist/assets/index-BeoRn2gJ.js +0 -2
  68. package/dashboard/dist/assets/info-BAIxlHIB.js +0 -1
  69. package/dashboard/dist/assets/main-DAcSUW2t.css +0 -1
  70. package/dashboard/dist/assets/main-pi07CfeC.js +0 -1004
  71. package/dashboard/dist/assets/use-limits-display-prefs-CMfASZsh.js +0 -1
  72. package/dashboard/dist/assets/use-native-settings-CfPik-Ao.js +0 -1
  73. package/dashboard/dist/assets/use-spring-wsmJQRqb.js +0 -1
  74. package/dashboard/dist/assets/use-usage-limits-Bt0Dp6kx.js +0 -1
  75. package/dashboard/dist/assets/useCurrency-CsaAMc3j.js +0 -1
  76. package/dashboard/dist/assets/useOpenInteractionType-CPzi5Z8M.js +0 -12
  77. package/dashboard/dist/share.html +0 -140
  78. package/src/commands/device-login.js +0 -161
  79. package/src/lib/upload-throttle.js +0 -148
@@ -1,138 +1,8 @@
1
- const http = require("node:http");
2
- const crypto = require("node:crypto");
1
+ // Opens a URL in the user's browser. This module used to also run the cloud
2
+ // sign-in flow (a local callback server plus a redirect dance); that went away
3
+ // with the cloud, and the file was renamed so its name stops implying auth.
3
4
  const cp = require("node:child_process");
4
5
 
5
- const { DEFAULT_BASE_URL } = require("./runtime-config");
6
-
7
- async function beginBrowserAuth({ baseUrl, dashboardUrl, timeoutMs, open }) {
8
- const nonce = crypto.randomBytes(16).toString("hex");
9
- const callbackPath = `/tokentracker/callback/${nonce}`;
10
- const authUrl = dashboardUrl ? new URL("/", dashboardUrl) : new URL("/auth/sign-up", baseUrl);
11
- const postAuthRedirect = resolvePostAuthRedirect({ dashboardUrl, authUrl });
12
- const { callbackUrl, waitForCallback } = await startLocalCallbackServer({
13
- callbackPath,
14
- timeoutMs,
15
- redirectUrl: postAuthRedirect,
16
- });
17
- authUrl.searchParams.set("redirect", callbackUrl);
18
- if (dashboardUrl && baseUrl && baseUrl !== DEFAULT_BASE_URL) {
19
- authUrl.searchParams.set("base_url", baseUrl);
20
- }
21
-
22
- if (open !== false) openInBrowser(authUrl.toString());
23
-
24
- return { authUrl: authUrl.toString(), waitForCallback };
25
- }
26
-
27
- async function startLocalCallbackServer({ callbackPath, timeoutMs, redirectUrl }) {
28
- let resolved = false;
29
- let resolveResult;
30
- let rejectResult;
31
-
32
- const resultPromise = new Promise((resolve, reject) => {
33
- resolveResult = resolve;
34
- rejectResult = reject;
35
- });
36
-
37
- const server = http.createServer((req, res) => {
38
- if (resolved) {
39
- res.writeHead(409, { "Content-Type": "text/plain; charset=utf-8" });
40
- res.end("Already authenticated.\n");
41
- return;
42
- }
43
-
44
- const method = req.method || "GET";
45
- if (method !== "GET") {
46
- res.writeHead(405, { "Content-Type": "text/plain; charset=utf-8" });
47
- res.end("Method not allowed.\n");
48
- return;
49
- }
50
-
51
- const url = new URL(req.url || "/", "http://127.0.0.1");
52
- if (url.pathname !== callbackPath) {
53
- res.writeHead(404, { "Content-Type": "text/plain; charset=utf-8" });
54
- res.end("Not found.\n");
55
- return;
56
- }
57
-
58
- const accessToken = url.searchParams.get("access_token") || "";
59
- if (!accessToken) {
60
- res.writeHead(400, { "Content-Type": "text/plain; charset=utf-8" });
61
- res.end("Missing access_token.\n");
62
- return;
63
- }
64
-
65
- resolved = true;
66
- if (redirectUrl) {
67
- res.writeHead(302, {
68
- Location: redirectUrl,
69
- "Content-Type": "text/html; charset=utf-8",
70
- });
71
- res.end(
72
- [
73
- "<!doctype html>",
74
- '<html><head><meta charset="utf-8"><title>TokenTracker</title></head>',
75
- "<body>",
76
- "<h2>Login succeeded</h2>",
77
- `<p>Redirecting to <a href="${redirectUrl}">dashboard</a>...</p>`,
78
- "</body></html>",
79
- ].join(""),
80
- );
81
- } else {
82
- res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
83
- res.end(
84
- [
85
- "<!doctype html>",
86
- '<html><head><meta charset="utf-8"><title>TokenTracker</title></head>',
87
- "<body>",
88
- "<h2>Login succeeded</h2>",
89
- "<p>You can close this tab and return to the CLI.</p>",
90
- "</body></html>",
91
- ].join(""),
92
- );
93
- }
94
-
95
- resolveResult({
96
- accessToken,
97
- userId: url.searchParams.get("user_id") || null,
98
- email: url.searchParams.get("email") || null,
99
- name: url.searchParams.get("name") || null,
100
- });
101
- });
102
-
103
- await new Promise((resolve, reject) => {
104
- server.once("error", reject);
105
- server.listen(0, "127.0.0.1", resolve);
106
- });
107
-
108
- const addr = server.address();
109
- const port = typeof addr === "object" && addr ? addr.port : null;
110
- if (!port) {
111
- server.close();
112
- throw new Error("Failed to bind local callback server");
113
- }
114
-
115
- const callbackUrl = `http://127.0.0.1:${port}${callbackPath}`;
116
-
117
- const timer = setTimeout(() => {
118
- if (resolved) return;
119
- resolved = true;
120
- rejectResult(new Error("Authentication timed out"));
121
- server.close();
122
- }, timeoutMs);
123
-
124
- async function waitForCallback() {
125
- try {
126
- return await resultPromise;
127
- } finally {
128
- clearTimeout(timer);
129
- server.close();
130
- }
131
- }
132
-
133
- return { callbackUrl, waitForCallback };
134
- }
135
-
136
6
  function detectDefaultBrowser() {
137
7
  try {
138
8
  const raw = cp.execFileSync("defaults", [
@@ -285,23 +155,7 @@ end if
285
155
  spawnBrowserCommand(cmd, args, { url, platform, stderr });
286
156
  }
287
157
 
288
- function resolvePostAuthRedirect({ dashboardUrl, authUrl }) {
289
- try {
290
- if (dashboardUrl) {
291
- const target = new URL("/", dashboardUrl);
292
- if (target.protocol === "http:" || target.protocol === "https:") {
293
- return target.toString();
294
- }
295
- return null;
296
- }
297
- } catch (_e) {
298
- return null;
299
- }
300
- return null;
301
- }
302
-
303
158
  module.exports = {
304
- beginBrowserAuth,
305
159
  openInBrowser,
306
160
  buildBrowserOpenErrorMessage,
307
161
  spawnBrowserCommand,
@@ -28,6 +28,7 @@ const {
28
28
  allocateByLargestRemainder,
29
29
  } = require("./categorizer-utils");
30
30
  const { claudeMessageDedupKey } = require("./rollout");
31
+ const { fileIdentity, isUnchanged } = require("./file-identity");
31
32
 
32
33
  const CATEGORY_KEYS = [
33
34
  "system_prefix",
@@ -494,12 +495,40 @@ function classifyOneMessage(obj, sessionState, breakdown, toolLedger = null, ski
494
495
  }
495
496
 
496
497
  // Read one session jsonl streaming, in timestamp range, dedup by msgId+reqId.
497
- async function categorizeSessionFile(filePath, { fromIso, toIso, seenHashes }, breakdown, toolLedger = null, skillLedger = null, execLedger = null) {
498
+ // Parses one file into its OWN accumulators and returns them, rather than
499
+ // mutating shared ones. That is what makes a per-file cache possible: the
500
+ // result depends only on (file contents, fromIso, toIso), so it can be reused
501
+ // whenever the file has not changed.
502
+ //
503
+ // The dedup set is now file-local. Cross-file duplicates are handled by the
504
+ // caller, which detects them and falls back to a full scan — see
505
+ // mergeFileResult. Intra-file dedup, including the deferred-add behavior
506
+ // below, is unchanged and must stay that way.
507
+ // `sharedSeen` is used only by the collision fallback, which needs the old
508
+ // cross-file dedup semantics. When it is passed, the returned `hashes` list is
509
+ // empty because dedup has already been applied globally.
510
+ async function categorizeSessionFile(filePath, { fromIso, toIso, sharedSeen = null }) {
511
+ const breakdown = emptyCategoryMap();
512
+ const toolLedger = {
513
+ tool_calls: { total_calls: 0, by_name: new Map() },
514
+ subagents: { total_calls: 0, by_name: new Map() },
515
+ };
516
+ const skillLedger = { total_calls: 0, by_name: new Map() };
517
+ const execLedger = {
518
+ total_calls: 0,
519
+ by_type: new Map(),
520
+ by_executable: new Map(),
521
+ by_command: new Map(),
522
+ by_exit: new Map(),
523
+ };
524
+ const seenHashes = sharedSeen || new Set();
525
+ const empty = () => ({ breakdown, toolLedger, skillLedger, execLedger, counted: 0, hashes: [] });
526
+
498
527
  let stream;
499
528
  try {
500
529
  stream = fssync.createReadStream(filePath, { encoding: "utf8" });
501
530
  } catch (_e) {
502
- return 0;
531
+ return empty();
503
532
  }
504
533
  const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
505
534
  const sessionState = { systemPrefixSeen: false };
@@ -538,7 +567,14 @@ async function categorizeSessionFile(filePath, { fromIso, toIso, seenHashes }, b
538
567
  }
539
568
  rl.close();
540
569
  stream.close?.();
541
- return counted;
570
+ return {
571
+ breakdown,
572
+ toolLedger,
573
+ skillLedger,
574
+ execLedger,
575
+ counted,
576
+ hashes: sharedSeen ? [] : [...seenHashes],
577
+ };
542
578
  }
543
579
 
544
580
  // Convert a YYYY-MM-DD day key (already in the user's tz from the API call)
@@ -624,6 +660,99 @@ function maxMtimeMs(files) {
624
660
  return max;
625
661
  }
626
662
 
663
+ // Per-file parse cache. See the equivalent in codex-context-breakdown.js —
664
+ // same problem (#62), same two-layer shape: the aggregate cache above stays
665
+ // the fast path, and this only decides how expensive a MISS is.
666
+ const FILE_CACHE = new Map();
667
+ const FILE_CACHE_SCHEMA_VERSION = "claude-category-file-v1";
668
+ const FILE_CACHE_MAX_ENTRIES = 8_000;
669
+
670
+ // Instrumentation is opt-in: without it a long-running server would grow one
671
+ // Map entry per session file forever, purely to serve tests.
672
+ let trackParses = false;
673
+ const parseCounts = new Map();
674
+
675
+ function fileCacheKey(fromIso, toIso, filePath) {
676
+ return `${FILE_CACHE_SCHEMA_VERSION}|${fromIso || ""}|${toIso || ""}|${filePath}`;
677
+ }
678
+
679
+ function pruneFileCache() {
680
+ if (FILE_CACHE.size <= FILE_CACHE_MAX_ENTRIES) return;
681
+ let excess = FILE_CACHE.size - FILE_CACHE_MAX_ENTRIES;
682
+ for (const key of FILE_CACHE.keys()) {
683
+ FILE_CACHE.delete(key);
684
+ if (--excess <= 0) break;
685
+ }
686
+ }
687
+
688
+ // These fields are high-water marks, not running sums. Adding them would
689
+ // silently inflate the reported worst case.
690
+ const MAX_MERGE_FIELDS = new Set(["max_duration_ms"]);
691
+
692
+ // Folds one cached row into an accumulator map. Always builds a fresh target
693
+ // object on first insert: putting a cached row in by reference would let a
694
+ // later merge mutate the cache in place, which is the aliasing hazard that
695
+ // per-file caching introduces.
696
+ function mergeRowInto(targetMap, key, row) {
697
+ const existing = targetMap.get(key);
698
+ if (!existing) {
699
+ targetMap.set(key, { ...row, totals: { ...row.totals } });
700
+ return;
701
+ }
702
+ for (const [field, value] of Object.entries(row)) {
703
+ if (field === "totals") {
704
+ addInto(existing.totals, value);
705
+ } else if (typeof value === "number") {
706
+ existing[field] = MAX_MERGE_FIELDS.has(field)
707
+ ? Math.max(existing[field] || 0, value)
708
+ : (existing[field] || 0) + value;
709
+ } else if (existing[field] === undefined) {
710
+ existing[field] = value;
711
+ }
712
+ }
713
+ }
714
+
715
+ function mergeNamedLedger(target, source) {
716
+ target.total_calls += source.total_calls || 0;
717
+ for (const [name, row] of source.by_name) mergeRowInto(target.by_name, name, row);
718
+ }
719
+
720
+ // Returns false when this file shares a dedup hash with one already merged.
721
+ // Such a collision cannot be repaired from aggregates: the deduped message's
722
+ // tokens are missing, and worse, systemPrefixSeen may have latched onto a
723
+ // different message, invalidating the whole per-file result rather than one
724
+ // row. The caller re-runs a full sequential scan instead of patching.
725
+ function mergeFileResult(acc, result, globalHashes) {
726
+ for (const hash of result.hashes) {
727
+ if (globalHashes.has(hash)) return false;
728
+ globalHashes.add(hash);
729
+ }
730
+ for (const key of CATEGORY_KEYS) addInto(acc.breakdown[key], result.breakdown[key]);
731
+ mergeNamedLedger(acc.toolLedger.tool_calls, result.toolLedger.tool_calls);
732
+ mergeNamedLedger(acc.toolLedger.subagents, result.toolLedger.subagents);
733
+ mergeNamedLedger(acc.skillLedger, result.skillLedger);
734
+ acc.execLedger.total_calls += result.execLedger.total_calls || 0;
735
+ for (const bucket of ["by_type", "by_executable", "by_command", "by_exit"]) {
736
+ for (const [name, row] of result.execLedger[bucket]) {
737
+ mergeRowInto(acc.execLedger[bucket], name, row);
738
+ }
739
+ }
740
+ acc.messageCount += result.counted;
741
+ if (result.counted > 0) acc.sessionCount += 1;
742
+ return true;
743
+ }
744
+
745
+ function __resetCategoryCachesForTests() {
746
+ trackParses = true;
747
+ CACHE.clear();
748
+ FILE_CACHE.clear();
749
+ parseCounts.clear();
750
+ }
751
+
752
+ function __getCategoryParseCountsForTests() {
753
+ return new Map(parseCounts);
754
+ }
755
+
627
756
  async function computeClaudeCategoryBreakdown({ from = null, to = null, rootDir = null, projectDir = null } = {}) {
628
757
  const root = rootDir || defaultClaudeProjectsDir();
629
758
  let files = [];
@@ -656,49 +785,90 @@ async function computeClaudeCategoryBreakdown({ from = null, to = null, rootDir
656
785
  }
657
786
 
658
787
  const { fromIso, toIso } = dayKeyToIsoBounds(from, to);
659
- const breakdown = emptyCategoryMap();
660
- const seenHashes = new Set();
661
- let messageCount = 0;
662
- let sessionCount = 0;
663
- const toolLedger = {
664
- tool_calls: { total_calls: 0, by_name: new Map() },
665
- subagents: { total_calls: 0, by_name: new Map() },
666
- };
667
- const skillLedger = { total_calls: 0, by_name: new Map() };
668
- const execLedger = {
669
- total_calls: 0,
670
- by_type: new Map(),
671
- by_executable: new Map(),
672
- by_command: new Map(),
673
- by_exit: new Map(),
674
- };
788
+ function newAccumulator() {
789
+ return {
790
+ breakdown: emptyCategoryMap(),
791
+ toolLedger: {
792
+ tool_calls: { total_calls: 0, by_name: new Map() },
793
+ subagents: { total_calls: 0, by_name: new Map() },
794
+ },
795
+ skillLedger: { total_calls: 0, by_name: new Map() },
796
+ execLedger: {
797
+ total_calls: 0,
798
+ by_type: new Map(),
799
+ by_executable: new Map(),
800
+ by_command: new Map(),
801
+ by_exit: new Map(),
802
+ },
803
+ messageCount: 0,
804
+ sessionCount: 0,
805
+ };
806
+ }
675
807
 
676
808
  // Process files with bounded parallelism. CPU-bound (JSON.parse per line)
677
809
  // limits the win, but overlapping the per-file fs.open + first-block read
678
810
  // I/O behind the previous file's parsing still shaves ~15% off cold scans.
679
- // `seenHashes`/`breakdown`/ledgers are mutated only inside synchronous
680
- // sections of `classifyOneMessage`; `for await` in `categorizeSessionFile`
681
- // only yields between lines, so workers can't tear shared state.
811
+ // Each parse now owns its accumulators, so there is no shared state to tear
812
+ // and an unchanged file skips the parse entirely (#62).
682
813
  const SCAN_CONCURRENCY = 4;
814
+ const results = new Array(files.length);
683
815
  let cursor = 0;
684
816
  async function worker() {
685
817
  while (cursor < files.length) {
686
818
  const idx = cursor++;
687
819
  if (idx >= files.length) return;
688
- const counted = await categorizeSessionFile(
689
- files[idx],
690
- { fromIso, toIso, seenHashes },
691
- breakdown,
692
- toolLedger,
693
- skillLedger,
694
- execLedger,
695
- );
696
- if (counted > 0) sessionCount += 1;
697
- messageCount += counted;
820
+ const filePath = files[idx];
821
+ const key = fileCacheKey(fromIso, toIso, filePath);
822
+ const identity = fileIdentity(filePath);
823
+ const hit = FILE_CACHE.get(key);
824
+ if (hit && isUnchanged(hit.identity, identity)) {
825
+ results[idx] = hit.result;
826
+ continue;
827
+ }
828
+ if (trackParses) parseCounts.set(filePath, (parseCounts.get(filePath) || 0) + 1);
829
+ const result = await categorizeSessionFile(filePath, { fromIso, toIso });
830
+ results[idx] = result;
831
+ if (identity) {
832
+ FILE_CACHE.set(key, { identity, result });
833
+ pruneFileCache();
834
+ }
698
835
  }
699
836
  }
700
837
  await Promise.all(Array.from({ length: SCAN_CONCURRENCY }, () => worker()));
701
838
 
839
+ // Merge in `files` order rather than completion order, so the result no
840
+ // longer depends on how the four workers happened to interleave.
841
+ let acc = newAccumulator();
842
+ const globalHashes = new Set();
843
+ let composable = true;
844
+ for (const result of results) {
845
+ if (!result) continue;
846
+ if (!mergeFileResult(acc, result, globalHashes)) {
847
+ composable = false;
848
+ break;
849
+ }
850
+ }
851
+
852
+ if (!composable) {
853
+ // Two files claimed the same dedup hash, so per-file results overlap and
854
+ // cannot be summed. Redo the scan sequentially with one shared dedup set,
855
+ // which is exactly the pre-cache behavior. Expected never to happen: a
856
+ // scan of 6,529 real session files found 0 cross-file duplicates. Kept so
857
+ // correctness does not rest on that measurement.
858
+ acc = newAccumulator();
859
+ const sharedSeen = new Set();
860
+ const fallbackHashes = new Set();
861
+ for (const filePath of files) {
862
+ const result = await categorizeSessionFile(filePath, { fromIso, toIso, sharedSeen });
863
+ if (trackParses) parseCounts.set(filePath, (parseCounts.get(filePath) || 0) + 1);
864
+ mergeFileResult(acc, result, fallbackHashes);
865
+ }
866
+ }
867
+
868
+ const { breakdown, toolLedger, skillLedger, execLedger } = acc;
869
+ const messageCount = acc.messageCount;
870
+ const sessionCount = acc.sessionCount;
871
+
702
872
  const totals = emptyTotals();
703
873
  for (const key of CATEGORY_KEYS) addInto(totals, breakdown[key]);
704
874
 
@@ -1306,6 +1476,8 @@ async function computeClaudeGroundTruthBuckets({ rootDir = null } = {}) {
1306
1476
  }
1307
1477
 
1308
1478
  module.exports = {
1479
+ __resetCategoryCachesForTests,
1480
+ __getCategoryParseCountsForTests,
1309
1481
  CATEGORY_KEYS,
1310
1482
  computeClaudeCategoryBreakdown,
1311
1483
  computeClaudeGroundTruthBuckets,
@@ -14,6 +14,8 @@ const fs = require("node:fs");
14
14
  const os = require("node:os");
15
15
  const path = require("node:path");
16
16
 
17
+ const { fileIdentity, isUnchanged } = require("./file-identity");
18
+
17
19
  const {
18
20
  emptyTotals,
19
21
  addInto,
@@ -139,6 +141,59 @@ function cacheTimeZoneKey(timeZoneContext) {
139
141
  return `${timeZoneContext.timeZone || ""}|${Number.isFinite(timeZoneContext.offsetMinutes) ? timeZoneContext.offsetMinutes : ""}`;
140
142
  }
141
143
 
144
+ // Per-file parse cache.
145
+ //
146
+ // The aggregate cache above keys on the GLOBAL maximum mtime, so appending to
147
+ // the one session that is currently active invalidates the aggregate for every
148
+ // unchanged historical file and forces a full rescan (issue #62). This layer
149
+ // makes that miss cost one file instead of all of them.
150
+ //
151
+ // `top` is deliberately absent from the key: truncation to the top N happens
152
+ // at merge time, so a single cached per-file parse serves every `top` value.
153
+ const FILE_CACHE = new Map();
154
+ const FILE_CACHE_SCHEMA_VERSION = "codex-context-file-v1";
155
+ // Roughly one entry per session file per (range, timezone) combination the
156
+ // dashboard asks for. Entries are small — one parsed rollup, no line data.
157
+ const FILE_CACHE_MAX_ENTRIES = 8_000;
158
+
159
+ // Counts parses per file so tests can assert an unchanged file was NOT
160
+ // reparsed. Observing the actual claim beats spying on the filesystem, which
161
+ // would also catch the identity check's own 256-byte read.
162
+ let trackParses = false;
163
+ const parseCounts = new Map();
164
+
165
+ function fileCacheKey({ fromKey, toKey, timeZoneContext, filePath }) {
166
+ return [
167
+ FILE_CACHE_SCHEMA_VERSION,
168
+ fromKey || "",
169
+ toKey || "",
170
+ cacheTimeZoneKey(timeZoneContext),
171
+ filePath,
172
+ ].join("|");
173
+ }
174
+
175
+ function pruneFileCache() {
176
+ if (FILE_CACHE.size <= FILE_CACHE_MAX_ENTRIES) return;
177
+ const excess = FILE_CACHE.size - FILE_CACHE_MAX_ENTRIES;
178
+ let removed = 0;
179
+ // Map iterates in insertion order, so this drops the least recently stored.
180
+ for (const key of FILE_CACHE.keys()) {
181
+ FILE_CACHE.delete(key);
182
+ if (++removed >= excess) break;
183
+ }
184
+ }
185
+
186
+ function __resetContextCachesForTests() {
187
+ trackParses = true;
188
+ CACHE.clear();
189
+ FILE_CACHE.clear();
190
+ parseCounts.clear();
191
+ }
192
+
193
+ function __getParseCountsForTests() {
194
+ return new Map(parseCounts);
195
+ }
196
+
142
197
  // ---------------------------------------------------------------------------
143
198
  // Main entry
144
199
  // ---------------------------------------------------------------------------
@@ -182,13 +237,32 @@ async function computeCodexContextBreakdown({
182
237
  const sessions = [];
183
238
 
184
239
  for (const filePath of files) {
185
- const parsed = await parseCodexRolloutFile(filePath, {
186
- fromIso,
187
- toIso,
188
- from: fromKey,
189
- to: toKey,
190
- timeZoneContext,
191
- });
240
+ const key = fileCacheKey({ fromKey, toKey, timeZoneContext, filePath });
241
+ const identity = fileIdentity(filePath);
242
+ const hit = FILE_CACHE.get(key);
243
+
244
+ let parsed;
245
+ if (hit && isUnchanged(hit.identity, identity)) {
246
+ parsed = hit.parsed;
247
+ } else {
248
+ if (trackParses) parseCounts.set(filePath, (parseCounts.get(filePath) || 0) + 1);
249
+ parsed = await parseCodexRolloutFile(filePath, {
250
+ fromIso,
251
+ toIso,
252
+ from: fromKey,
253
+ to: toKey,
254
+ timeZoneContext,
255
+ });
256
+ // A null identity means the path is not a readable regular file right
257
+ // now; caching against it could never be invalidated correctly.
258
+ if (identity) {
259
+ FILE_CACHE.set(key, { identity, parsed });
260
+ pruneFileCache();
261
+ }
262
+ }
263
+
264
+ // Re-applied on every pass rather than baked into the cached entry, so a
265
+ // zero-token file has one representation instead of two.
192
266
  if (!parsed || !parsed.totals || !parsed.totals.total_tokens) continue;
193
267
  sessions.push(parsed);
194
268
  }
@@ -397,4 +471,6 @@ async function computeCodexContextBreakdown({
397
471
 
398
472
  module.exports = {
399
473
  computeCodexContextBreakdown,
474
+ __resetContextCachesForTests,
475
+ __getParseCountsForTests,
400
476
  };
@@ -349,6 +349,15 @@ async function parseCodexRolloutFile(filePath, { fromIso, toIso, from = null, to
349
349
  let pendingSkills = [];
350
350
  let pendingExecEnds = []; // exec_command_end payloads since last token_count
351
351
 
352
+ // Codex subagent-fork replay handling (issue #75). A rollout spawned via
353
+ // session_meta.source.subagent.thread_spawn replays the parent thread's
354
+ // history before an "inter_agent_communication_metadata" boundary. While in
355
+ // replay we advance the delta baseline but do not attribute anything, so the
356
+ // context breakdown reflects only the genuine child turns. Whole-file read,
357
+ // so no cross-call cursor is needed — a boundary-less (partial) fork simply
358
+ // stays in replay and contributes nothing.
359
+ let accounting = true;
360
+
352
361
  const totals = emptyTotals();
353
362
  const byTool = new Map(); // tool_name -> {name,calls,totals}
354
363
  const bySkill = new Map(); // skill_name -> {name,calls,totals}
@@ -525,20 +534,36 @@ async function parseCodexRolloutFile(filePath, { fromIso, toIso, from = null, to
525
534
  } catch {
526
535
  continue;
527
536
  }
528
- const ts = typeof obj?.timestamp === "string" ? obj.timestamp : null;
529
- if (!ts) continue;
530
- if (fromIso && ts < fromIso) continue;
531
- if (toIso && ts > toIso) continue;
532
- if (!isTimestampInRequestedDayRange(ts, { from, to, timeZoneContext })) continue;
533
-
537
+ // Stateful fork-control records are handled BEFORE any timestamp/date
538
+ // filtering. They must never be dropped by a query window: if a date filter
539
+ // removed the boundary while keeping replay records on one side of it, the
540
+ // replayed parent history would leak back into the child's totals (the exact
541
+ // #75 overcount). This mirrors the pre-gate ordering in rollout.js.
534
542
  if (obj.type === "session_meta") {
535
543
  const p = obj.payload || {};
536
544
  sessionId = p.id || sessionId;
537
545
  cwd = p.cwd || cwd;
538
546
  cliVersion = p.cli_version || cliVersion;
539
547
  provider = p.model_provider || provider;
548
+ if (p.source?.subagent?.thread_spawn) accounting = false;
549
+ continue;
550
+ }
551
+
552
+ // Boundary between replayed parent history and genuine child turns.
553
+ if (obj.type === "inter_agent_communication_metadata") {
554
+ accounting = true;
555
+ pendingCalls = [];
556
+ pendingSkills = [];
557
+ pendingExecEnds = [];
558
+ continue;
540
559
  }
541
560
 
561
+ const ts = typeof obj?.timestamp === "string" ? obj.timestamp : null;
562
+ if (!ts) continue;
563
+ if (fromIso && ts < fromIso) continue;
564
+ if (toIso && ts > toIso) continue;
565
+ if (!isTimestampInRequestedDayRange(ts, { from, to, timeZoneContext })) continue;
566
+
542
567
  if (obj.type === "turn_context") {
543
568
  const p = obj.payload || {};
544
569
  if (typeof p.cwd === "string") cwd = p.cwd;
@@ -547,14 +572,17 @@ async function parseCodexRolloutFile(filePath, { fromIso, toIso, from = null, to
547
572
  }
548
573
 
549
574
  if (obj.type === "response_item" && obj.payload?.type === "function_call") {
550
- pendingCalls.push(obj.payload);
551
- const skill = extractSkillNameFromFunctionCall(obj.payload);
552
- if (skill) pendingSkills.push(skill);
575
+ // Skip replayed parent tool calls; only child turns are attributed.
576
+ if (accounting) {
577
+ pendingCalls.push(obj.payload);
578
+ const skill = extractSkillNameFromFunctionCall(obj.payload);
579
+ if (skill) pendingSkills.push(skill);
580
+ }
553
581
  continue;
554
582
  }
555
583
 
556
584
  if (obj.type === "event_msg" && obj.payload?.type === "exec_command_end") {
557
- pendingExecEnds.push(obj.payload);
585
+ if (accounting) pendingExecEnds.push(obj.payload);
558
586
  continue;
559
587
  }
560
588
 
@@ -564,7 +592,11 @@ async function parseCodexRolloutFile(filePath, { fromIso, toIso, from = null, to
564
592
  const lastUsage = info?.last_token_usage;
565
593
  const totalUsage = info?.total_token_usage;
566
594
  const delta = pickDelta(lastUsage, totalUsage, prevTotals);
595
+ // Advance the delta baseline through replay so the first child delta is
596
+ // measured from the last replayed cumulative, but do not attribute the
597
+ // replayed parent history to this child.
567
598
  if (totalUsage && typeof totalUsage === "object") prevTotals = totalUsage;
599
+ if (!accounting) continue;
568
600
  if (delta) attributeTurn(delta);
569
601
  continue;
570
602
  }
@@ -573,6 +605,16 @@ async function parseCodexRolloutFile(filePath, { fromIso, toIso, from = null, to
573
605
  rl.close();
574
606
  stream.close?.();
575
607
 
608
+ // Saw a subagent fork but never its boundary: the whole file was treated as
609
+ // replay and contributes nothing. Safe (never over-count) but silent —
610
+ // surface it under debug so a malformed/renamed boundary is diagnosable.
611
+ if (!accounting && process.env.TOKENTRACKER_DEBUG) {
612
+ process.stderr.write(
613
+ `[codex-fork] ${filePath}: subagent fork with no ` +
614
+ `inter_agent_communication_metadata boundary; context breakdown empty\n`,
615
+ );
616
+ }
617
+
576
618
  return {
577
619
  sessionId,
578
620
  cwd,