tokentracker-cli 1.3.11 → 1.3.13

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 (110) hide show
  1. package/dashboard/dist/assets/{AchievementBadge-CjXQPC-W.js → AchievementBadge-DkNsd5tj.js} +1 -1
  2. package/dashboard/dist/assets/{AchievementsPage-xb-g6Jws.js → AchievementsPage-Dr72Hcog.js} +2 -2
  3. package/dashboard/dist/assets/{AchievementsSection-DI4NOt9D.js → AchievementsSection-CkOdGlnD.js} +1 -1
  4. package/dashboard/dist/assets/{BadgeDetailModal-Cn2rV5Bk.js → BadgeDetailModal-DADLfBsK.js} +1 -1
  5. package/dashboard/dist/assets/{BotAnimated-CFb-gvO7.js → BotAnimated-k6u1GRzw.js} +1 -1
  6. package/dashboard/dist/assets/{Card-CB2-QAqP.js → Card-Dk6s3h-P.js} +1 -1
  7. package/dashboard/dist/assets/{CloudCheckoutPage-beBzOiJA.js → CloudCheckoutPage-DL0pvok0.js} +1 -1
  8. package/dashboard/dist/assets/{CloudPage-COYm4wdR.js → CloudPage-BizN1MZL.js} +2 -2
  9. package/dashboard/dist/assets/{CommandPalette-kCa9Mfpj.js → CommandPalette-B2crDNlV.js} +1 -1
  10. package/dashboard/dist/assets/{CommunityStatsModal-BeUZhxxn.js → CommunityStatsModal-C6Rc5NME.js} +1 -1
  11. package/dashboard/dist/assets/{ConfirmModal-DiCm2JXX.js → ConfirmModal-BrxBQn4n.js} +1 -1
  12. package/dashboard/dist/assets/{Controls-BkRVScXl.js → Controls-C2mEPI72.js} +1 -1
  13. package/dashboard/dist/assets/{DashboardPage-DiV0ynQc.js → DashboardPage-BomPd7nt.js} +1 -1
  14. package/dashboard/dist/assets/{DateRangePopover-DdmjfdFj.js → DateRangePopover-Gz-N_D7O.js} +1 -1
  15. package/dashboard/dist/assets/{DevicePage-D7E9uy4l.js → DevicePage-wmnNx9bd.js} +1 -1
  16. package/dashboard/dist/assets/{DialogClose-VDWLqY0o.js → DialogClose-D1dzj_on.js} +1 -1
  17. package/dashboard/dist/assets/DialogDescription-ChbOGLc_.js +1 -0
  18. package/dashboard/dist/assets/{DialogTitle-DfBUUBYN.js → DialogTitle-BBxNf3O8.js} +1 -1
  19. package/dashboard/dist/assets/{ElectricLogo-Cr-WDplA.js → ElectricLogo-DCopeDc8.js} +1 -1
  20. package/dashboard/dist/assets/{FadeIn-DPdMW40K.js → FadeIn-DSndojVd.js} +1 -1
  21. package/dashboard/dist/assets/{HeaderGithubStar-Ulqd6uC1.js → HeaderGithubStar-BBAGbevf.js} +1 -1
  22. package/dashboard/dist/assets/{HoverTooltip-By6YzN89.js → HoverTooltip-COD3xE9E.js} +1 -1
  23. package/dashboard/dist/assets/{IpCheckPage-BLvWuVmL.js → IpCheckPage-BtMWefop.js} +1 -1
  24. package/dashboard/dist/assets/{LandingPage-BfgBmSGV.js → LandingPage-DN2IVBw7.js} +2 -2
  25. package/dashboard/dist/assets/{LeaderboardPage-D7C91BEb.js → LeaderboardPage-DkJowsRI.js} +2 -2
  26. package/dashboard/dist/assets/{LeaderboardProBadge-Mj_-HH8L.js → LeaderboardProBadge-CRRphJNs.js} +1 -1
  27. package/dashboard/dist/assets/{LeaderboardProfileModal-BfHTrqHf.js → LeaderboardProfileModal-WmZAsZx5.js} +3 -3
  28. package/dashboard/dist/assets/{LeaderboardProfilePage-CggAvx-w.js → LeaderboardProfilePage-DWHu-eOp.js} +1 -1
  29. package/dashboard/dist/assets/{LimitsPage-BR8hlK7t.js → LimitsPage-BRFAWm-1.js} +1 -1
  30. package/dashboard/dist/assets/{LocalOnlyNotice-yW1orlU8.js → LocalOnlyNotice-BjJ8N4Zc.js} +1 -1
  31. package/dashboard/dist/assets/{LoginCard-B_7mpZ6Y.js → LoginCard-CYmjHoYK.js} +1 -1
  32. package/dashboard/dist/assets/{LoginModal-D1ZtIkRD.js → LoginModal-DX1D9F8b.js} +1 -1
  33. package/dashboard/dist/assets/{LoginPage-DRThDeqD.js → LoginPage-D7Ok7HiZ.js} +1 -1
  34. package/dashboard/dist/assets/{PetPage-CsIkqyiR.js → PetPage-BYISPev2.js} +2 -2
  35. package/dashboard/dist/assets/{PopoverPopup-dySzR9xR.js → PopoverPopup-BlzqlKqF.js} +1 -1
  36. package/dashboard/dist/assets/{ProviderIcon-oBKAZ-WS.js → ProviderIcon-BuLwtYjj.js} +1 -1
  37. package/dashboard/dist/assets/{ResetPasswordPage-Bf0-zIl2.js → ResetPasswordPage-BJRKclq9.js} +1 -1
  38. package/dashboard/dist/assets/{Select-xyNGq71G.js → Select-0YyBw95W.js} +1 -1
  39. package/dashboard/dist/assets/{SelectItemText-C8ZB19C7.js → SelectItemText-CZKyxUjE.js} +1 -1
  40. package/dashboard/dist/assets/{SelfHostPage-Cf7pLoVz.js → SelfHostPage-C_Afofs-.js} +1 -1
  41. package/dashboard/dist/assets/{ServiceStatusPage-BH3W_Wxq.js → ServiceStatusPage-0KpLdpon.js} +1 -1
  42. package/dashboard/dist/assets/{SessionsPage-u8RIKjx9.js → SessionsPage-YtSyWMd0.js} +1 -1
  43. package/dashboard/dist/assets/{SettingsPage-Bnh-AxM9.js → SettingsPage-Bm641_AH.js} +3 -3
  44. package/dashboard/dist/assets/{SkillsPage-CIP3Av7z.js → SkillsPage-DNg-I2g_.js} +1 -1
  45. package/dashboard/dist/assets/{TokenGalaxy-DnNhsfa-.js → TokenGalaxy-BMN7WiRH.js} +1 -1
  46. package/dashboard/dist/assets/{TooltipPopup-BstAYPIH.js → TooltipPopup-BF56BHfc.js} +1 -1
  47. package/dashboard/dist/assets/{TrendMonitor-D4CVzczb.js → TrendMonitor-D0ea5xMA.js} +1 -1
  48. package/dashboard/dist/assets/{WidgetsPage-A_gMzap_.js → WidgetsPage-CIAoHmJG.js} +1 -1
  49. package/dashboard/dist/assets/{WrappedPage-Bmm_WjA5.js → WrappedPage-CC_57Zr2.js} +1 -1
  50. package/dashboard/dist/assets/{__vite-browser-external-DS-dnBDP.js → __vite-browser-external-CPskAZuH.js} +2 -2
  51. package/dashboard/dist/assets/{agent-logos-BdU0GjQq.js → agent-logos-CcJUaB75.js} +1 -1
  52. package/dashboard/dist/assets/{arrow-left-Cl8AYaOh.js → arrow-left-CUiT31ZF.js} +1 -1
  53. package/dashboard/dist/assets/{arrow-right-Cfi2pR1p.js → arrow-right-Tds6Mert.js} +1 -1
  54. package/dashboard/dist/assets/{arrow-up-right-DH7vnAf4.js → arrow-up-right-CGSxdyzM.js} +1 -1
  55. package/dashboard/dist/assets/{calendar-days-BJjjx5Vu.js → calendar-days-Cc8AtMa3.js} +1 -1
  56. package/dashboard/dist/assets/{check-DGTejNYt.js → check-CzJylQ9j.js} +1 -1
  57. package/dashboard/dist/assets/{chevron-down-Brz4a22U.js → chevron-down-DyG9JVDO.js} +1 -1
  58. package/dashboard/dist/assets/{cloud-checkout-DZbiSnVr.js → cloud-checkout-DP5zddOq.js} +1 -1
  59. package/dashboard/dist/assets/{composite-rfjkpEna.js → composite-I_1t6PMI.js} +1 -1
  60. package/dashboard/dist/assets/{download-axaJjbTW.js → download-B_LvkErh.js} +1 -1
  61. package/dashboard/dist/assets/{external-link-D9dvv4DN.js → external-link-DDA3ObBn.js} +1 -1
  62. package/dashboard/dist/assets/{flame-DuivUwnP.js → flame-BqpVWH_1.js} +1 -1
  63. package/dashboard/dist/assets/{format-tokens-Dg_-K3cD.js → format-tokens-D6QEOnGq.js} +1 -1
  64. package/dashboard/dist/assets/{gift-CgE1uDg4.js → gift-BxbKo3W6.js} +1 -1
  65. package/dashboard/dist/assets/{host-mode-C2QCLKjw.js → host-mode-o3qQY7tz.js} +1 -1
  66. package/dashboard/dist/assets/{icons-PNlyYW8C.js → icons-oGPZPj2n.js} +1 -1
  67. package/dashboard/dist/assets/{index-DFWE1vbu.js → index-BxtvQ1J7.js} +1 -1
  68. package/dashboard/dist/assets/{index-PiRGMAPd.js → index-Byukv2Nu.js} +1 -1
  69. package/dashboard/dist/assets/{index-BbroxaKr.js → index-DX--5z1g.js} +1 -1
  70. package/dashboard/dist/assets/{info-BjRBvuNu.js → info-BoTz1AGZ.js} +1 -1
  71. package/dashboard/dist/assets/{link-2-CCqYoK3V.js → link-2-C09a122s.js} +1 -1
  72. package/dashboard/dist/assets/{loader-circle-IiB0vjDJ.js → loader-circle--9U0axVe.js} +1 -1
  73. package/dashboard/dist/assets/{modifiers.esm-DbjjBgLM.js → modifiers.esm-Cra0I6RP.js} +1 -1
  74. package/dashboard/dist/assets/{monitor-smartphone-BUSQ3-U3.js → monitor-smartphone-C1ENjmOu.js} +1 -1
  75. package/dashboard/dist/assets/{play-CDU4bWoE.js → play-NqPqbW59.js} +1 -1
  76. package/dashboard/dist/assets/{plus-BY7jwS0R.js → plus-DZP82JeP.js} +1 -1
  77. package/dashboard/dist/assets/{provider-display-endIONDM.js → provider-display-BLo4k0bi.js} +1 -1
  78. package/dashboard/dist/assets/{react-C0JpiKb9.js → react-CTLavVze.js} +1 -1
  79. package/dashboard/dist/assets/{safePolygon-B5pqmyki.js → safePolygon-Yw5w5Nw8.js} +1 -1
  80. package/dashboard/dist/assets/{search-BWQCqCrK.js → search-DgV6mpMO.js} +1 -1
  81. package/dashboard/dist/assets/{server-DkDn4DQU.js → server-BybwAQjW.js} +1 -1
  82. package/dashboard/dist/assets/{shield-check-BPOMBy-3.js → shield-check-BiTYwWdT.js} +1 -1
  83. package/dashboard/dist/assets/{skills-api-Btor2iNW.js → skills-api-Dx_Z8_SO.js} +1 -1
  84. package/dashboard/dist/assets/{sortable.esm-qA98E6si.js → sortable.esm-CmJnBLcr.js} +1 -1
  85. package/dashboard/dist/assets/{store-DRcycxJw.js → store-DzNIKGzf.js} +1 -1
  86. package/dashboard/dist/assets/{terminal-BWOI_yxH.js → terminal-DPly-UPW.js} +1 -1
  87. package/dashboard/dist/assets/{trash-DgWVzyKs.js → trash-DsL2ZAN5.js} +1 -1
  88. package/dashboard/dist/assets/{use-community-stats-odyPsCDi.js → use-community-stats-CSTkXFed.js} +1 -1
  89. package/dashboard/dist/assets/{use-latest-request-guard-CBocPZjh.js → use-latest-request-guard-CbAXXNlU.js} +1 -1
  90. package/dashboard/dist/assets/{use-limits-display-prefs-uKB639iI.js → use-limits-display-prefs-mDl6Yv_-.js} +1 -1
  91. package/dashboard/dist/assets/{use-native-settings-EoDW6Dgk.js → use-native-settings-Bu2C3b20.js} +1 -1
  92. package/dashboard/dist/assets/{use-ordered-list-B4Gychu4.js → use-ordered-list-DkGSfMTv.js} +1 -1
  93. package/dashboard/dist/assets/use-reduced-motion-6nBw3cR5.js +1 -0
  94. package/dashboard/dist/assets/{use-session-efficiency-pref-C0goPBNl.js → use-session-efficiency-pref-B2GDf83h.js} +1 -1
  95. package/dashboard/dist/assets/{use-transform-hwE19i1Y.js → use-transform-BVVduBPJ.js} +1 -1
  96. package/dashboard/dist/assets/{use-usage-limits-DnsGayPX.js → use-usage-limits-fuzpbbVh.js} +1 -1
  97. package/dashboard/dist/assets/{useCurrency-DiN1Jj_t.js → useCurrency-CdDQzroJ.js} +1 -1
  98. package/dashboard/dist/assets/{usePositioner-CM1Isf5e.js → usePositioner-b6XXJtxE.js} +1 -1
  99. package/dashboard/dist/assets/{useTokenFormat-CGhMrRps.js → useTokenFormat-90RoMQgI.js} +1 -1
  100. package/dashboard/dist/assets/{zap-Bc2wd9eQ.js → zap-xEHzTW-T.js} +1 -1
  101. package/dashboard/dist/index.html +1 -1
  102. package/dashboard/dist/ip-check.html +1 -1
  103. package/dashboard/dist/leaderboard.html +1 -1
  104. package/dashboard/dist/share.html +1 -1
  105. package/package.json +1 -1
  106. package/src/lib/async-queue-reader.js +117 -0
  107. package/src/lib/local-api.js +88 -226
  108. package/src/lib/pricing/seed-snapshot.json +1 -1
  109. package/dashboard/dist/assets/DialogDescription-BQ5VSHQi.js +0 -1
  110. package/dashboard/dist/assets/use-reduced-motion-B-JhzSwL.js +0 -1
@@ -0,0 +1,117 @@
1
+ const fs = require("node:fs/promises");
2
+ const { StringDecoder } = require("node:string_decoder");
3
+ const { setImmediate: yieldToRequests } = require("node:timers/promises");
4
+
5
+ const CHUNK_BYTES = 64 * 1024;
6
+ const PROBE_BYTES = 256;
7
+
8
+ function signature(stat) {
9
+ return `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeNs}:${stat.ctimeNs}`;
10
+ }
11
+
12
+ // One in-flight read per path shares the dashboard's request fan-out. Parsing
13
+ // yields between bounded chunks so a cold/replaced queue cannot monopolize the
14
+ // HTTP event loop. Keep committed newline-terminated rows separate from a tail
15
+ // that a concurrent writer may still be completing.
16
+ function createAsyncQueueReader({ parseRow, label, maxEntries = 4 }) {
17
+ const cache = new Map();
18
+ const pending = new Map();
19
+
20
+ async function probe(file, position, length) {
21
+ const buffer = Buffer.alloc(length);
22
+ const { bytesRead } = await file.read(buffer, 0, length, position);
23
+ return buffer.subarray(0, bytesRead);
24
+ }
25
+
26
+ async function load(queuePath) {
27
+ const cached = cache.get(queuePath);
28
+ let file;
29
+ try {
30
+ file = await fs.open(queuePath, "r");
31
+ // Stat the opened file, rather than a path which can be atomically replaced
32
+ // between stat and open. Its signature always describes the bytes we read.
33
+ const stat = await file.stat({ bigint: true });
34
+ const nextSignature = signature(stat);
35
+ if (cached?.signature === nextSignature) return cached.rows;
36
+ const identity = `${stat.dev}:${stat.ino}`;
37
+ const size = Number(stat.size);
38
+ let offset = 0;
39
+ let seen = new Map();
40
+ if (cached && cached.identity === identity && size > cached.size && cached.consumedBytes > 0) {
41
+ const head = await probe(file, 0, cached.head.length);
42
+ const anchor = await probe(file, cached.consumedBytes - cached.anchor.length, cached.anchor.length);
43
+ if (head.equals(cached.head) && anchor.equals(cached.anchor)) {
44
+ offset = cached.consumedBytes;
45
+ seen = cached.seen;
46
+ }
47
+ }
48
+
49
+ const changes = new Map();
50
+ let malformed = 0;
51
+ function parse(line, target) {
52
+ if (!line.trim()) return;
53
+ try {
54
+ const entry = parseRow(JSON.parse(line));
55
+ if (entry) target.set(entry.key, entry.row);
56
+ } catch { malformed++; }
57
+ }
58
+
59
+ const decoder = new StringDecoder("utf8");
60
+ const buffer = Buffer.allocUnsafe(CHUNK_BYTES);
61
+ let remainder = "";
62
+ let position = offset;
63
+ let consumedBytes = offset;
64
+ while (position < size) {
65
+ const length = Math.min(CHUNK_BYTES, size - position);
66
+ const { bytesRead } = await file.read(buffer, 0, length, position);
67
+ if (!bytesRead) throw new Error("Queue truncated during read");
68
+ const newline = buffer.lastIndexOf(0x0a, bytesRead - 1);
69
+ if (newline >= 0) consumedBytes = position + newline + 1;
70
+ position += bytesRead;
71
+ remainder += decoder.write(buffer.subarray(0, bytesRead));
72
+ let start = 0;
73
+ let end;
74
+ while ((end = remainder.indexOf("\n", start)) !== -1) {
75
+ parse(remainder.slice(start, end), changes);
76
+ start = end + 1;
77
+ }
78
+ remainder = remainder.slice(start);
79
+ await yieldToRequests();
80
+ }
81
+ remainder += decoder.end();
82
+ // Capture probes before committing any state: a failed read must not mutate
83
+ // a snapshot still being used by another endpoint.
84
+ const head = await probe(file, 0, Math.min(PROBE_BYTES, consumedBytes));
85
+ const anchor = await probe(file, Math.max(0, consumedBytes - PROBE_BYTES), Math.min(PROBE_BYTES, consumedBytes));
86
+ for (const [key, row] of changes) seen.set(key, row);
87
+ const tail = new Map();
88
+ parse(remainder, tail);
89
+ // Legacy files without a final newline are readable, but their provisional
90
+ // key never enters the committed map (completion may change that key).
91
+ const rows = tail.size ? Array.from(new Map([...seen, ...tail]).values()) : Array.from(seen.values());
92
+ cache.delete(queuePath);
93
+ cache.set(queuePath, { signature: nextSignature, identity, size, consumedBytes, head, anchor, seen, rows });
94
+ while (cache.size > maxEntries) cache.delete(cache.keys().next().value);
95
+ if (malformed) console.error(`[LocalAPI] ${label}: skipped ${malformed} malformed line(s)`);
96
+ return rows;
97
+ } catch (error) {
98
+ if (error.code === "ENOENT") {
99
+ cache.delete(queuePath);
100
+ return [];
101
+ }
102
+ console.error(`[LocalAPI] ${label}: failed to read queue:`, error.message);
103
+ return cached?.rows || [];
104
+ } finally {
105
+ if (file) await file.close();
106
+ }
107
+ }
108
+
109
+ return function read(queuePath) {
110
+ if (pending.has(queuePath)) return pending.get(queuePath);
111
+ const task = load(queuePath).finally(() => pending.delete(queuePath));
112
+ pending.set(queuePath, task);
113
+ return task;
114
+ };
115
+ }
116
+
117
+ module.exports = { createAsyncQueueReader };
@@ -3,6 +3,8 @@ const os = require("node:os");
3
3
  const path = require("node:path");
4
4
  const { spawn } = require("node:child_process");
5
5
  const crypto = require("node:crypto");
6
+ const { setImmediate: yieldToRequests } = require("node:timers/promises");
7
+ const { createAsyncQueueReader } = require("./async-queue-reader");
6
8
  const { DEFAULT_BASE_URL, resolveRuntimeConfig, assertInsforgeRuntime, normalizeInstanceBaseUrl, clearDeviceIdentity, resetInstanceState } = require("./runtime-config");
7
9
  const {
8
10
  filterRowsByUsageScope,
@@ -92,13 +94,13 @@ const CLAUDE_MEM_OBSERVER_PROJECT_KEY = deriveProjectKeyFromRef(
92
94
  );
93
95
  const PROJECT_USAGE_MAX_ENTRIES = 10;
94
96
  const MAX_TIME_ZONE_CACHE_ENTRIES = 16;
97
+ const AGGREGATION_BATCH_ROWS = 256;
95
98
 
96
99
  // The native dashboard fans one refresh out across several local endpoints.
97
100
  // Keep one immutable, deduped view of the current queue so those endpoints do
98
101
  // not all read and JSON.parse the same append-only file. A file identity +
99
102
  // nanosecond timestamp signature invalidates the cache immediately on append,
100
103
  // in-place rewrite, or atomic replacement.
101
- let queueDataCache = null;
102
104
  const dailyAggregationCache = new WeakMap();
103
105
  const zonedPartsFormatters = new Map();
104
106
 
@@ -110,11 +112,6 @@ function boundedCacheSet(cache, key, value, maxEntries) {
110
112
  }
111
113
  }
112
114
 
113
- function queueFileSignature(queuePath) {
114
- const stat = fs.statSync(queuePath, { bigint: true });
115
- return `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeNs}:${stat.ctimeNs}`;
116
- }
117
-
118
115
  function timeZoneContextKey({ timeZone, offsetMinutes } = {}) {
119
116
  return `${timeZone || ""}|${Number.isFinite(offsetMinutes) ? offsetMinutes : ""}|p${getPricingRevision()}`;
120
117
  }
@@ -125,7 +122,7 @@ function timeZoneContextKey({ timeZone, offsetMinutes } = {}) {
125
122
  // key ONCE (rowDayKey constructs an Intl.DateTimeFormat per call — the
126
123
  // dominant per-row cost) and reuse it for both the range check and any
127
124
  // day bucketing.
128
- function readProjectUsageContext(qp, url) {
125
+ async function readProjectUsageContext(qp, url) {
129
126
  const from = url.searchParams.get("from") || "";
130
127
  const to = url.searchParams.get("to") || "";
131
128
  const timeZoneContext = getTimeZoneContext(url);
@@ -136,7 +133,7 @@ function readProjectUsageContext(qp, url) {
136
133
  return (!from || day >= from) && (!to || day <= to);
137
134
  };
138
135
  const projectQueuePath = path.join(path.dirname(qp), "project.queue.jsonl");
139
- const projectRows = readProjectQueueData(projectQueuePath).filter(
136
+ const projectRows = (await readProjectQueueData(projectQueuePath)).filter(
140
137
  (row) => row.project_key !== CLAUDE_MEM_OBSERVER_PROJECT_KEY,
141
138
  );
142
139
  return { from, to, timeZoneContext, hasRange, dayInRange, projectRows };
@@ -145,57 +142,15 @@ function readProjectUsageContext(qp, url) {
145
142
  // Same signature-based caching as readQueueData: the project-usage endpoints
146
143
  // all fan out from one dashboard refresh, so without this every request
147
144
  // re-read and re-parsed the whole append-only project queue.
148
- let projectQueueDataCache = null;
149
-
150
- function readProjectQueueData(projectQueuePath) {
151
- let signature;
152
- try {
153
- signature = queueFileSignature(projectQueuePath);
154
- } catch (e) {
155
- if (projectQueueDataCache?.queuePath === projectQueuePath) {
156
- projectQueueDataCache = null;
157
- }
158
- if (e?.code !== "ENOENT") {
159
- console.error("[LocalAPI] readProjectQueueData: failed to stat:", e?.message || e);
160
- }
161
- return [];
162
- }
163
-
164
- if (
165
- projectQueueDataCache?.queuePath === projectQueuePath &&
166
- projectQueueDataCache.signature === signature
167
- ) {
168
- return projectQueueDataCache.rows;
169
- }
170
-
171
- let raw;
172
- try {
173
- raw = fs.readFileSync(projectQueuePath, "utf8");
174
- } catch (e) {
175
- if (e?.code !== "ENOENT") {
176
- console.error("[LocalAPI] readProjectQueueData: failed to read:", e?.message || e);
177
- }
178
- return [];
179
- }
180
- const lines = raw.split("\n").filter((l) => l.trim());
181
- const seen = new Map();
182
- for (const line of lines) {
183
- try {
184
- const row = JSON.parse(line);
185
- const key = `${row.project_key || ""}|${row.source || ""}|${row.hour_start || ""}`;
186
- // Same legacy-row corrections as the main queue (codex inclusive-input,
187
- // cursor billable=0) so both read paths report identical numbers.
188
- seen.set(key, normalizeQueueRow(row));
189
- } catch {
190
- // skip malformed
191
- }
192
- }
193
- // Callers treat the result as read-only (sortByHour copies before sorting),
194
- // so the cached array can be shared across requests.
195
- const rows = Array.from(seen.values());
196
- projectQueueDataCache = { queuePath: projectQueuePath, signature, rows };
197
- return rows;
198
- }
145
+ const readProjectQueueData = createAsyncQueueReader({
146
+ label: "readProjectQueueData",
147
+ parseRow(row) {
148
+ return {
149
+ key: `${row.project_key || ""}|${row.source || ""}|${row.hour_start || ""}`,
150
+ row: normalizeQueueRow(row),
151
+ };
152
+ },
153
+ });
199
154
 
200
155
  function isLegacyInclusiveCodexRow(row) {
201
156
  if (!row || (row.source !== "codex" && row.source !== "every-code")) return false;
@@ -244,136 +199,16 @@ function normalizeQueueRow(row) {
244
199
  return normalized;
245
200
  }
246
201
 
247
- function readQueueData(queuePath) {
248
- let stat;
249
- try {
250
- stat = fs.statSync(queuePath, { bigint: true });
251
- } catch (e) {
252
- if (queueDataCache?.queuePath === queuePath) queueDataCache = null;
253
- if (e?.code !== "ENOENT") {
254
- console.error("[LocalAPI] readQueueData: failed to stat queue:", e?.message || e);
255
- }
256
- return [];
257
- }
258
- const signature = `${stat.dev}:${stat.ino}:${stat.size}:${stat.mtimeNs}:${stat.ctimeNs}`;
259
-
260
- const cached = queueDataCache;
261
- if (cached?.queuePath === queuePath && cached.signature === signature) {
262
- return cached.rows;
263
- }
264
-
265
- // The queue is append-only, so on a same-file append only the new tail is
266
- // read and parsed; the deduped row set carries over. A replaced, truncated,
267
- // or first-seen file falls back to a full read.
268
- const devIno = `${stat.dev}:${stat.ino}`;
269
- const fileSize = Number(stat.size);
270
- const canAppend =
271
- cached != null &&
272
- cached.queuePath === queuePath &&
273
- cached.devIno === devIno &&
274
- fileSize >= cached.consumedBytes;
275
- let seen = canAppend ? cached.seen : new Map();
276
- let offset = canAppend ? cached.consumedBytes : 0;
277
-
278
- // Integrity probe for the append assumption: repo writers only append or
279
- // atomically replace (inode change), but an EXTERNAL in-place rewrite
280
- // (cp over the file, shell redirect) keeps the inode with a same/larger
281
- // size while changing the prefix. The byte before our offset must be the
282
- // newline that terminated the last consumed line — anything else means the
283
- // prefix is no longer ours, so fall back to a full read.
284
- if (offset > 0) {
285
- try {
286
- const fd = fs.openSync(queuePath, "r");
287
- try {
288
- const probe = Buffer.allocUnsafe(1);
289
- if (fs.readSync(fd, probe, 0, 1, offset - 1) !== 1 || probe[0] !== 0x0a) {
290
- seen = new Map();
291
- offset = 0;
292
- }
293
- } finally {
294
- fs.closeSync(fd);
295
- }
296
- } catch {
297
- seen = new Map();
298
- offset = 0;
299
- }
300
- }
301
-
302
- let raw = "";
303
- if (fileSize > offset) {
304
- try {
305
- if (offset === 0) {
306
- raw = fs.readFileSync(queuePath, "utf8");
307
- } else {
308
- const fd = fs.openSync(queuePath, "r");
309
- try {
310
- const length = fileSize - offset;
311
- const buffer = Buffer.allocUnsafe(length);
312
- let read = 0;
313
- while (read < length) {
314
- // A concurrent truncation between stat and open makes readSync hit
315
- // EOF early — without the 0-byte break this loop never terminates.
316
- const n = fs.readSync(fd, buffer, read, length - read, offset + read);
317
- if (n === 0) break;
318
- read += n;
319
- }
320
- raw = buffer.toString("utf8", 0, read);
321
- } finally {
322
- fs.closeSync(fd);
323
- }
324
- }
325
- } catch (e) {
326
- // ENOENT is legitimate (queue deleted between stat and read); anything
327
- // else is a signal we don't want to hide behind an empty array forever —
328
- // the dashboard would otherwise render "0 tokens" with no clue the queue
329
- // was unreadable.
330
- if (e?.code !== "ENOENT") {
331
- console.error("[LocalAPI] readQueueData: failed to read queue:", e?.message || e);
332
- }
333
- return canAppend ? cached.rows : [];
334
- }
335
- }
336
-
337
- // Parse row-by-row so a single corrupted line (partial write, disk-full
338
- // truncation, …) does not wipe out every other row with it. An unterminated
339
- // tail is attempted (legacy writers may omit the final newline) but NOT
340
- // marked consumed — a mid-append partial line is re-read once it completes.
341
- // "\n" (0x0A) never appears inside a multi-byte UTF-8 sequence, so cutting
342
- // on the last newline is byte-safe.
343
- let consumedBytes = offset;
344
- let malformed = 0;
345
- if (raw) {
346
- const lastNewline = raw.lastIndexOf("\n");
347
- if (lastNewline !== -1) {
348
- consumedBytes += Buffer.byteLength(raw.slice(0, lastNewline), "utf8") + 1;
349
- }
350
- const lines = raw.split("\n");
351
- for (const line of lines) {
352
- if (!line.trim()) continue;
353
- try {
354
- const row = JSON.parse(line);
355
- // Account session states (and the pre-release watermark records
356
- // this branch may still hold in a dev queue) are cloud-side control
357
- // records, not usage rows - never surface them locally.
358
- if (row?.kind === "account_session_state" || row?.kind === "account_sync_watermark") continue;
359
- // Deduplicate: each sync appends cumulative totals per bucket, so for
360
- // each (source, model, hour_start) keep only the latest (last) entry.
361
- const key = `${row.source || ""}|${row.model || ""}|${row.hour_start || ""}`;
362
- seen.set(key, normalizeQueueRow(row));
363
- } catch {
364
- malformed += 1;
365
- }
366
- }
367
- }
368
- if (malformed > 0) {
369
- console.error(
370
- `[LocalAPI] readQueueData: skipped ${malformed} malformed line(s) in ${queuePath}`,
371
- );
372
- }
373
- const rows = Array.from(seen.values());
374
- queueDataCache = { queuePath, signature, devIno, consumedBytes, seen, rows };
375
- return rows;
376
- }
202
+ const readQueueData = createAsyncQueueReader({
203
+ label: "readQueueData",
204
+ parseRow(row) {
205
+ if (row?.kind === "account_session_state" || row?.kind === "account_sync_watermark") return null;
206
+ return {
207
+ key: `${row.source || ""}|${row.model || ""}|${row.hour_start || ""}`,
208
+ row: normalizeQueueRow(row),
209
+ };
210
+ },
211
+ });
377
212
 
378
213
  function rowDayKey(row, timeZoneContext) {
379
214
  const hs = row.hour_start;
@@ -401,8 +236,32 @@ function aggregateByDay(rows, timeZoneContext = null) {
401
236
  return cached;
402
237
  }
403
238
 
239
+ if (!cachedByTimeZone) {
240
+ cachedByTimeZone = new Map();
241
+ dailyAggregationCache.set(normalizedRows, cachedByTimeZone);
242
+ }
243
+ const pending = buildDailyAggregates(normalizedRows, timeZoneContext).then((daily) => {
244
+ // Pricing can refresh while a large aggregation yields. Rebuild consistently
245
+ // with the current catalog instead of publishing a mix of two revisions.
246
+ if (cacheKey !== timeZoneContextKey(timeZoneContext || {})) {
247
+ cachedByTimeZone.delete(cacheKey);
248
+ return aggregateByDay(normalizedRows, timeZoneContext);
249
+ }
250
+ return daily;
251
+ }).catch((error) => {
252
+ if (cachedByTimeZone.get(cacheKey) === pending) cachedByTimeZone.delete(cacheKey);
253
+ throw error;
254
+ });
255
+ // Share both completed and in-flight aggregates across summary/daily/heatmap.
256
+ boundedCacheSet(cachedByTimeZone, cacheKey, pending, MAX_TIME_ZONE_CACHE_ENTRIES);
257
+ return pending;
258
+ }
259
+
260
+ async function buildDailyAggregates(normalizedRows, timeZoneContext) {
404
261
  const byDay = new Map();
262
+ let processed = 0;
405
263
  for (const row of normalizedRows) {
264
+ if (++processed % AGGREGATION_BATCH_ROWS === 0) await yieldToRequests();
406
265
  if (!row.hour_start) continue;
407
266
  const day = rowDayKey(row, timeZoneContext);
408
267
  if (!day) continue;
@@ -438,11 +297,6 @@ function aggregateByDay(rows, timeZoneContext = null) {
438
297
  a.models[model] = (a.models[model] || 0) + (row.total_tokens || 0);
439
298
  }
440
299
  const daily = Array.from(byDay.values()).sort((a, b) => a.day.localeCompare(b.day));
441
- if (!cachedByTimeZone) {
442
- cachedByTimeZone = new Map();
443
- dailyAggregationCache.set(normalizedRows, cachedByTimeZone);
444
- }
445
- boundedCacheSet(cachedByTimeZone, cacheKey, daily, MAX_TIME_ZONE_CACHE_ENTRIES);
446
300
  return daily;
447
301
  }
448
302
 
@@ -546,9 +400,9 @@ function getRequestedUsageScope(url) {
546
400
  return normalizeUsageScope(url.searchParams.get("scope"));
547
401
  }
548
402
 
549
- function scopedQueueRows(queuePath, url) {
403
+ async function scopedQueueRows(queuePath, url) {
550
404
  const scope = getRequestedUsageScope(url);
551
- const allRows = readQueueData(queuePath);
405
+ const allRows = await readQueueData(queuePath);
552
406
  return {
553
407
  scope,
554
408
  allRows,
@@ -743,9 +597,11 @@ function formatPartsDayKey(parts) {
743
597
  return `${parts.year}-${String(parts.month).padStart(2, "0")}-${String(parts.day).padStart(2, "0")}`;
744
598
  }
745
599
 
746
- function aggregateHourlyByDay(rows, dayKey, timeZoneContext) {
600
+ async function aggregateHourlyByDay(rows, dayKey, timeZoneContext) {
747
601
  const byHour = new Map();
602
+ let processed = 0;
748
603
  for (const row of rows) {
604
+ if (++processed % AGGREGATION_BATCH_ROWS === 0) await yieldToRequests();
749
605
  if (!row.hour_start) continue;
750
606
  const parts = getZonedParts(new Date(row.hour_start), timeZoneContext);
751
607
  if (!parts) continue;
@@ -2474,7 +2330,7 @@ function createLocalApiHandler({ queuePath, serverVersion = null, trackerDataDir
2474
2330
  if (p === "/functions/tokentracker-wrapped") {
2475
2331
  const yearParam = url.searchParams.get("year");
2476
2332
  const year = yearParam ? Number(yearParam) : null;
2477
- const { rows, scope, excludedSources } = scopedQueueRows(qp, url);
2333
+ const { rows, scope, excludedSources } = await scopedQueueRows(qp, url);
2478
2334
  const { aggregateWrapped } = require("./wrapped-aggregator");
2479
2335
  const summary = aggregateWrapped(rows, year ? { year } : {});
2480
2336
  json(res, { scope, excluded_sources: excludedSources, ...summary });
@@ -2503,8 +2359,8 @@ function createLocalApiHandler({ queuePath, serverVersion = null, trackerDataDir
2503
2359
  const from = url.searchParams.get("from") || "";
2504
2360
  const to = url.searchParams.get("to") || "";
2505
2361
  const timeZoneContext = getTimeZoneContext(url);
2506
- const { rows, scope, excludedSources } = scopedQueueRows(qp, url);
2507
- const allDaily = aggregateByDay(rows, timeZoneContext);
2362
+ const { rows, scope, excludedSources } = await scopedQueueRows(qp, url);
2363
+ const allDaily = await aggregateByDay(rows, timeZoneContext);
2508
2364
  const daily = allDaily.filter((d) => d.day >= from && d.day <= to);
2509
2365
  const totals = daily.reduce(
2510
2366
  (acc, r) => {
@@ -2570,8 +2426,8 @@ function createLocalApiHandler({ queuePath, serverVersion = null, trackerDataDir
2570
2426
  const from = url.searchParams.get("from") || "";
2571
2427
  const to = url.searchParams.get("to") || "";
2572
2428
  const timeZoneContext = getTimeZoneContext(url);
2573
- const { rows, scope, excludedSources } = scopedQueueRows(qp, url);
2574
- const daily = aggregateByDay(rows, timeZoneContext).filter((d) => d.day >= from && d.day <= to);
2429
+ const { rows, scope, excludedSources } = await scopedQueueRows(qp, url);
2430
+ const daily = (await aggregateByDay(rows, timeZoneContext)).filter((d) => d.day >= from && d.day <= to);
2575
2431
  json(res, { from, to, scope, excluded_sources: excludedSources, data: daily });
2576
2432
  return true;
2577
2433
  }
@@ -2604,7 +2460,7 @@ function createLocalApiHandler({ queuePath, serverVersion = null, trackerDataDir
2604
2460
  json(res, { available: false, from, to, by_model: [], by_tool: [], totals: null });
2605
2461
  return true;
2606
2462
  }
2607
- const { rows, scope, excludedSources } = scopedQueueRows(qp, url);
2463
+ const { rows, scope, excludedSources } = await scopedQueueRows(qp, url);
2608
2464
  const result = computeQualityPerDollar(rows, outcomes, { from, to });
2609
2465
  json(res, { from, to, scope, excluded_sources: excludedSources, ...result });
2610
2466
  return true;
@@ -2677,8 +2533,8 @@ function createLocalApiHandler({ queuePath, serverVersion = null, trackerDataDir
2677
2533
  if (p === "/functions/tokentracker-usage-heatmap") {
2678
2534
  const weeks = parseInt(url.searchParams.get("weeks") || "52", 10);
2679
2535
  const timeZoneContext = getTimeZoneContext(url);
2680
- const { rows, scope, excludedSources } = scopedQueueRows(qp, url);
2681
- const daily = aggregateByDay(rows, timeZoneContext);
2536
+ const { rows, scope, excludedSources } = await scopedQueueRows(qp, url);
2537
+ const daily = await aggregateByDay(rows, timeZoneContext);
2682
2538
  const todayParts = getZonedParts(new Date(), timeZoneContext);
2683
2539
  const todayStr = formatPartsDayKey(todayParts) || new Date().toISOString().slice(0, 10);
2684
2540
  const end = new Date(`${todayStr}T00:00:00Z`);
@@ -2741,15 +2597,15 @@ function createLocalApiHandler({ queuePath, serverVersion = null, trackerDataDir
2741
2597
  const from = url.searchParams.get("from") || "";
2742
2598
  const to = url.searchParams.get("to") || "";
2743
2599
  const timeZoneContext = getTimeZoneContext(url);
2744
- const { rows: scopedRows, scope, excludedSources } = scopedQueueRows(qp, url);
2745
- const rows = scopedRows.filter((r) => {
2746
- if (!r.hour_start) return false;
2747
- const d = rowDayKey(r, timeZoneContext);
2748
- return d >= from && d <= to;
2749
- });
2750
-
2600
+ const { rows: scopedRows, scope, excludedSources } = await scopedQueueRows(qp, url);
2601
+ const pricingRevision = getPricingRevision();
2751
2602
  const bySource = new Map();
2752
- for (const row of rows) {
2603
+ let processed = 0;
2604
+ for (const row of scopedRows) {
2605
+ if (++processed % AGGREGATION_BATCH_ROWS === 0) await yieldToRequests();
2606
+ if (!row.hour_start) continue;
2607
+ const day = rowDayKey(row, timeZoneContext);
2608
+ if (day < from || day > to) continue;
2753
2609
  const src = row.source || "unknown";
2754
2610
  const mdl = row.model || "unknown";
2755
2611
  if (!bySource.has(src))
@@ -2783,6 +2639,7 @@ function createLocalApiHandler({ queuePath, serverVersion = null, trackerDataDir
2783
2639
  // dropped every premium — same tokens, fewer dollars than the hero.
2784
2640
  ma.totals.total_cost_usd = Number(ma.totals.total_cost_usd || 0) + computeRowCost(row);
2785
2641
  }
2642
+ if (pricingRevision !== getPricingRevision()) return handleLocalApi(req, res, url);
2786
2643
 
2787
2644
  const sources = Array.from(bySource.values()).map((s) => {
2788
2645
  s.models = Array.from(s.models.values())
@@ -2833,7 +2690,7 @@ function createLocalApiHandler({ queuePath, serverVersion = null, trackerDataDir
2833
2690
  timeZoneContext,
2834
2691
  });
2835
2692
  if (!Number(result?.totals?.total_tokens || 0)) {
2836
- const fallback = buildCodexCategoryFallbackFromQueue(readQueueData(qp), {
2693
+ const fallback = buildCodexCategoryFallbackFromQueue(await readQueueData(qp), {
2837
2694
  from,
2838
2695
  to,
2839
2696
  timeZoneContext,
@@ -2882,11 +2739,13 @@ function createLocalApiHandler({ queuePath, serverVersion = null, trackerDataDir
2882
2739
  ? Math.min(Math.floor(limitParam), PROJECT_USAGE_MAX_ENTRIES)
2883
2740
  : PROJECT_USAGE_MAX_ENTRIES;
2884
2741
  const { timeZoneContext, hasRange, dayInRange, projectRows } =
2885
- readProjectUsageContext(qp, url);
2742
+ await readProjectUsageContext(qp, url);
2886
2743
 
2887
- const aggregateEntries = (rows, keyOf, refOf) => {
2744
+ const aggregateEntries = async (rows, keyOf, refOf) => {
2888
2745
  const byKey = new Map();
2746
+ let processed = 0;
2889
2747
  for (const row of rows) {
2748
+ if (++processed % AGGREGATION_BATCH_ROWS === 0) await yieldToRequests();
2890
2749
  if (hasRange && !dayInRange(rowDayKey(row, timeZoneContext))) continue;
2891
2750
  const key = keyOf(row);
2892
2751
  if (!byKey.has(key)) {
@@ -2935,7 +2794,7 @@ function createLocalApiHandler({ queuePath, serverVersion = null, trackerDataDir
2935
2794
  }));
2936
2795
  };
2937
2796
 
2938
- let entries = aggregateEntries(
2797
+ let entries = await aggregateEntries(
2939
2798
  projectRows,
2940
2799
  (row) => row.project_key || "unknown",
2941
2800
  (row) => row.project_ref || "",
@@ -2947,8 +2806,8 @@ function createLocalApiHandler({ queuePath, serverVersion = null, trackerDataDir
2947
2806
  // totally empty. This path used to also exist for the non-empty case
2948
2807
  // and produce wrong numbers; keep it only as the empty fallback.
2949
2808
  if (entries.length === 0 && projectRows.length === 0) {
2950
- entries = aggregateEntries(
2951
- readQueueData(qp),
2809
+ entries = await aggregateEntries(
2810
+ await readQueueData(qp),
2952
2811
  (row) => row.source || "unknown",
2953
2812
  // Synthetic source-only row: leave project_ref empty rather than
2954
2813
  // fabricating `https://${src}.ai`, which resolves to unrelated
@@ -2973,7 +2832,7 @@ function createLocalApiHandler({ queuePath, serverVersion = null, trackerDataDir
2973
2832
  return true;
2974
2833
  }
2975
2834
  const { from, to, timeZoneContext, dayInRange, projectRows } =
2976
- readProjectUsageContext(qp, url);
2835
+ await readProjectUsageContext(qp, url);
2977
2836
 
2978
2837
  const emptyMeasures = () => ({
2979
2838
  total_tokens: 0,
@@ -3003,8 +2862,9 @@ function createLocalApiHandler({ queuePath, serverVersion = null, trackerDataDir
3003
2862
  const byDay = new Map();
3004
2863
  const bySource = new Map();
3005
2864
  let rangeTotalTokens = 0;
3006
-
2865
+ let processed = 0;
3007
2866
  for (const row of projectRows) {
2867
+ if (++processed % AGGREGATION_BATCH_ROWS === 0) await yieldToRequests();
3008
2868
  const day = rowDayKey(row, timeZoneContext);
3009
2869
  if (!dayInRange(day)) continue;
3010
2870
  rangeTotalTokens += Number(row.total_tokens || 0);
@@ -3286,8 +3146,8 @@ function createLocalApiHandler({ queuePath, serverVersion = null, trackerDataDir
3286
3146
  if (p === "/functions/tokentracker-usage-hourly") {
3287
3147
  const day = url.searchParams.get("day") || new Date().toISOString().slice(0, 10);
3288
3148
  const timeZoneContext = getTimeZoneContext(url);
3289
- const { rows, scope, excludedSources } = scopedQueueRows(qp, url);
3290
- const data = aggregateHourlyByDay(rows, day, timeZoneContext);
3149
+ const { rows, scope, excludedSources } = await scopedQueueRows(qp, url);
3150
+ const data = await aggregateHourlyByDay(rows, day, timeZoneContext);
3291
3151
  json(res, { day, scope, excluded_sources: excludedSources, data });
3292
3152
  return true;
3293
3153
  }
@@ -3297,9 +3157,11 @@ function createLocalApiHandler({ queuePath, serverVersion = null, trackerDataDir
3297
3157
  const from = url.searchParams.get("from") || "";
3298
3158
  const to = url.searchParams.get("to") || "";
3299
3159
  const timeZoneContext = getTimeZoneContext(url);
3300
- const { rows, scope, excludedSources } = scopedQueueRows(qp, url);
3160
+ const { rows, scope, excludedSources } = await scopedQueueRows(qp, url);
3301
3161
  const byMonth = new Map();
3162
+ let processed = 0;
3302
3163
  for (const row of rows) {
3164
+ if (++processed % AGGREGATION_BATCH_ROWS === 0) await yieldToRequests();
3303
3165
  if (!row.hour_start) continue;
3304
3166
  const day = rowDayKey(row, timeZoneContext);
3305
3167
  if (!day || day < from || day > to) continue;
@@ -3607,8 +3469,8 @@ function createLocalApiHandler({ queuePath, serverVersion = null, trackerDataDir
3607
3469
  // --- achievements (local badges) ---
3608
3470
  if (p === "/functions/tokentracker-achievements") {
3609
3471
  const timeZoneContext = getTimeZoneContext(url);
3610
- const queueRows = readQueueData(qp);
3611
- const { projectRows } = readProjectUsageContext(qp, url);
3472
+ const queueRows = await readQueueData(qp);
3473
+ const { projectRows } = await readProjectUsageContext(qp, url);
3612
3474
  json(res, {
3613
3475
  generated_at: new Date().toISOString(),
3614
3476
  achievements: computeLocalAchievements(queueRows, projectRows, { timeZoneContext }),