tokentracker-cli 0.47.3 → 0.49.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 (55) hide show
  1. package/README.ja.md +3 -1
  2. package/README.ko.md +3 -1
  3. package/README.md +3 -1
  4. package/README.zh-CN.md +3 -1
  5. package/dashboard/dist/assets/{ActivityHeatmap-DNXs3lKI.js → ActivityHeatmap-CItg7FNN.js} +1 -1
  6. package/dashboard/dist/assets/{Card-DH3Er6A7.js → Card-B9jWqeQm.js} +1 -1
  7. package/dashboard/dist/assets/{DashboardPage-BwObNlNQ.js → DashboardPage-D-UdQdmb.js} +1 -1
  8. package/dashboard/dist/assets/{DevicePage-C8TbfrgM.js → DevicePage-BmhKFgDo.js} +1 -1
  9. package/dashboard/dist/assets/{DialogTitle-BN06NuBQ.js → DialogTitle-Dcuz0ACc.js} +1 -1
  10. package/dashboard/dist/assets/{FadeIn-BGQdmOIT.js → FadeIn-B-oMQCv_.js} +1 -1
  11. package/dashboard/dist/assets/{HeaderGithubStar-DF-zHHNK.js → HeaderGithubStar-C6x-l5U0.js} +1 -1
  12. package/dashboard/dist/assets/IpCheckPage-D1tltH7T.js +20 -0
  13. package/dashboard/dist/assets/{LandingPage-B9OiRAGX.js → LandingPage-DnjQLNxt.js} +1 -1
  14. package/dashboard/dist/assets/{LeaderboardAvatar-CrulAkZy.js → LeaderboardAvatar-PNgWQxaR.js} +1 -1
  15. package/dashboard/dist/assets/{LeaderboardPage-pLMt5N8v.js → LeaderboardPage-DRCSwReV.js} +3 -3
  16. package/dashboard/dist/assets/{LeaderboardProfileModal-trFYROeI.js → LeaderboardProfileModal-DvbGnWDm.js} +1 -1
  17. package/dashboard/dist/assets/{LeaderboardProfilePage-XQ5aGbP1.js → LeaderboardProfilePage-Bmk16GFd.js} +1 -1
  18. package/dashboard/dist/assets/LimitsPage-DsdyKs5Z.js +2 -0
  19. package/dashboard/dist/assets/{LocalOnlyNotice-BGFFlM7g.js → LocalOnlyNotice-CIa2X9wJ.js} +1 -1
  20. package/dashboard/dist/assets/{LoginPage-0FLg6Gtf.js → LoginPage-CnDLx50g.js} +1 -1
  21. package/dashboard/dist/assets/{PopoverPopup-ClduWm0G.js → PopoverPopup-ZsSFlvKU.js} +1 -1
  22. package/dashboard/dist/assets/{Select-yOe-loKw.js → Select-CY2FgOFv.js} +1 -1
  23. package/dashboard/dist/assets/{SelectItemText-_4uFhB_8.js → SelectItemText-DI_GTNc2.js} +1 -1
  24. package/dashboard/dist/assets/{SettingsPage-mndeQq_K.js → SettingsPage-D1DZkCMo.js} +1 -1
  25. package/dashboard/dist/assets/{SkillsPage-C0NHenLh.js → SkillsPage-DorZCQ0W.js} +1 -1
  26. package/dashboard/dist/assets/{WidgetsPage-X0PwMFCK.js → WidgetsPage-BDLa_UaU.js} +1 -1
  27. package/dashboard/dist/assets/{WrappedPage-Cytzx6OI.js → WrappedPage-BKn5Q7iM.js} +1 -1
  28. package/dashboard/dist/assets/{agent-logos-CzYpdOy_.js → agent-logos-CM4Rt9bu.js} +1 -1
  29. package/dashboard/dist/assets/{arrow-up-right-CE98MOft.js → arrow-up-right-jP0XdZdv.js} +1 -1
  30. package/dashboard/dist/assets/{download-DwKe306W.js → download-D8Hvx1kr.js} +1 -1
  31. package/dashboard/dist/assets/{info-_hRvsmjv.js → info-vv2jU1_C.js} +1 -1
  32. package/dashboard/dist/assets/{main-BO2Z3JNm.js → main-9P4Ny5Xr.js} +15 -15
  33. package/dashboard/dist/assets/main-Br5SsufY.css +1 -0
  34. package/dashboard/dist/assets/use-limits-display-prefs-BZVYlv9P.js +1 -0
  35. package/dashboard/dist/assets/{use-native-settings-D40wCPJV.js → use-native-settings-CR_f7uLH.js} +1 -1
  36. package/dashboard/dist/assets/use-usage-limits-CvgpaBd_.js +1 -0
  37. package/dashboard/dist/assets/{useCurrency-CZqPmMxC.js → useCurrency-Cq0FIlJZ.js} +1 -1
  38. package/dashboard/dist/assets/{useScrollLock-D-_sDVky.js → useScrollLock-B5U1SJQV.js} +1 -1
  39. package/dashboard/dist/brand-logos/hermes.svg +1 -1
  40. package/dashboard/dist/index.html +2 -2
  41. package/dashboard/dist/share.html +2 -2
  42. package/package.json +1 -1
  43. package/src/commands/device-login.js +19 -3
  44. package/src/commands/sync.js +538 -348
  45. package/src/lib/local-api.js +11 -2
  46. package/src/lib/pricing/curated-overrides.json +2 -1
  47. package/src/lib/pricing/seed-snapshot.json +1 -1
  48. package/src/lib/rollout.js +129 -17
  49. package/src/lib/usage-limits.js +118 -25
  50. package/src/lib/wrapped-aggregator.js +12 -1
  51. package/dashboard/dist/assets/IpCheckPage-CgPFKRRL.js +0 -15
  52. package/dashboard/dist/assets/LimitsPage-CWLEVM54.js +0 -2
  53. package/dashboard/dist/assets/main-BfK9LoKV.css +0 -1
  54. package/dashboard/dist/assets/use-limits-display-prefs-ur9_stXM.js +0 -1
  55. package/dashboard/dist/assets/use-usage-limits-BhXhFRP0.js +0 -1
@@ -746,6 +746,9 @@ async function parseRolloutFile({
746
746
  let model = typeof lastModel === "string" ? lastModel : null;
747
747
  let totals = lastTotal && typeof lastTotal === "object" ? lastTotal : null;
748
748
  let currentCwd = null;
749
+ let currentDate = null;
750
+ let isForkedRollout = false;
751
+ const rolloutDate = rolloutDateFromPath(filePath);
749
752
  let currentProjectRef = projectRef || null;
750
753
  let currentProjectKey = projectKey || null;
751
754
  let eventsAggregated = 0;
@@ -756,7 +759,10 @@ async function parseRolloutFile({
756
759
  const maybeTurnContext =
757
760
  !maybeTokenCount &&
758
761
  (line.includes('"turn_context"') || line.includes('"session_meta"')) &&
759
- (line.includes('"model"') || line.includes('"cwd"'));
762
+ (line.includes('"model"') ||
763
+ line.includes('"cwd"') ||
764
+ line.includes('"current_date"') ||
765
+ line.includes('"forked_from_id"'));
760
766
  if (!maybeTokenCount && !maybeTurnContext) continue;
761
767
 
762
768
  let obj;
@@ -771,6 +777,12 @@ async function parseRolloutFile({
771
777
  obj?.payload &&
772
778
  typeof obj.payload === "object"
773
779
  ) {
780
+ if (obj.type === "session_meta" && typeof obj.payload.forked_from_id === "string") {
781
+ isForkedRollout = obj.payload.forked_from_id.trim().length > 0;
782
+ }
783
+ if (obj.type === "turn_context" && typeof obj.payload.current_date === "string") {
784
+ currentDate = normalizeIsoDate(obj.payload.current_date);
785
+ }
774
786
  if (typeof obj.payload.model === "string") {
775
787
  model = obj.payload.model;
776
788
  }
@@ -812,6 +824,9 @@ async function parseRolloutFile({
812
824
  totals = totalUsage;
813
825
  }
814
826
 
827
+ // date matching is conservative; same-day fork replays are still counted.
828
+ if (isForkedReplayToken({ isForkedRollout, rolloutDate, currentDate })) continue;
829
+
815
830
  const bucketStart = toUtcHalfHourStart(tokenTimestamp);
816
831
  if (!bucketStart) continue;
817
832
 
@@ -1772,6 +1787,21 @@ function toUtcHalfHourStart(ts) {
1772
1787
  return bucketStart.toISOString();
1773
1788
  }
1774
1789
 
1790
+ function rolloutDateFromPath(filePath) {
1791
+ const match = path.basename(String(filePath || "")).match(/^rollout-(\d{4}-\d{2}-\d{2})T/);
1792
+ return match ? match[1] : null;
1793
+ }
1794
+
1795
+ function normalizeIsoDate(value) {
1796
+ const raw = typeof value === "string" ? value.trim() : "";
1797
+ const match = raw.match(/^(\d{4}-\d{2}-\d{2})/);
1798
+ return match ? match[1] : null;
1799
+ }
1800
+
1801
+ function isForkedReplayToken({ isForkedRollout, rolloutDate, currentDate }) {
1802
+ return Boolean(isForkedRollout && rolloutDate && currentDate && currentDate < rolloutDate);
1803
+ }
1804
+
1775
1805
  function normalizeNonNegativeNumber(value) {
1776
1806
  const n = Number(value || 0);
1777
1807
  if (!Number.isFinite(n) || n <= 0) return 0;
@@ -2619,13 +2649,32 @@ async function parseCursorApiIncremental({
2619
2649
  const total = records.length;
2620
2650
 
2621
2651
  if (records.length > 0) {
2622
- for (const [key, bucket] of Object.entries(hourlyState.buckets || {})) {
2623
- const parsed = parseBucketKey(key);
2624
- const sourceKey = normalizeSourceInput(parsed.source) || DEFAULT_SOURCE;
2625
- if (sourceKey !== defaultSource) continue;
2626
- if (!bucket?.totals) continue;
2627
- bucket.totals = initTotals();
2628
- touchedBuckets.add(key);
2652
+ // Guard (2026-06 audit): only wipe buckets the fetched export can
2653
+ // actually rebuild. The wipe-then-refill design assumes the CSV is a
2654
+ // FULL-history export; if Cursor ever windows or truncates the export,
2655
+ // unconditionally zeroing every bucket would erase (and upload zeros
2656
+ // over) all history older than the response. Wiping from the earliest
2657
+ // record onward is identical for full exports and fail-safe for
2658
+ // partial ones.
2659
+ let earliestBucketStart = null;
2660
+ for (const record of records) {
2661
+ if (!record?.date) continue;
2662
+ const b = toUtcHalfHourStart(record.date);
2663
+ if (b && (!earliestBucketStart || b < earliestBucketStart)) earliestBucketStart = b;
2664
+ }
2665
+ // No parseable record date at all means the export is malformed —
2666
+ // refilling would add nothing, so wiping would zero out (and upload
2667
+ // zeros over) the entire history. Skip the wipe entirely.
2668
+ if (earliestBucketStart) {
2669
+ for (const [key, bucket] of Object.entries(hourlyState.buckets || {})) {
2670
+ const parsed = parseBucketKey(key);
2671
+ const sourceKey = normalizeSourceInput(parsed.source) || DEFAULT_SOURCE;
2672
+ if (sourceKey !== defaultSource) continue;
2673
+ if (!bucket?.totals) continue;
2674
+ if (parsed.hourStart && parsed.hourStart < earliestBucketStart) continue;
2675
+ bucket.totals = initTotals();
2676
+ touchedBuckets.add(key);
2677
+ }
2629
2678
  }
2630
2679
  }
2631
2680
 
@@ -3076,7 +3125,7 @@ function readHermesSessions(dbPath, lastCompletedEpoch, unfinishedSessionIds = [
3076
3125
  // in-progress (ended_at IS NULL), OR sessions that were previously observed
3077
3126
  // unfinished. Hermes updates token counts in real-time, including a final
3078
3127
  // delta when an active session later gets ended_at set.
3079
- const sql = `SELECT id, model, started_at, ended_at, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, reasoning_tokens, message_count FROM sessions WHERE (started_at >= ${since} OR ended_at IS NULL${forceIncludeSql}) AND (input_tokens > 0 OR output_tokens > 0 OR cache_read_tokens > 0 OR reasoning_tokens > 0) ORDER BY started_at ASC`;
3128
+ const sql = `SELECT id, model, started_at, ended_at, input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, reasoning_tokens, message_count FROM sessions WHERE (started_at >= ${since} OR ended_at IS NULL${forceIncludeSql}) AND (input_tokens > 0 OR output_tokens > 0 OR cache_read_tokens > 0 OR cache_write_tokens > 0 OR reasoning_tokens > 0) ORDER BY started_at ASC`;
3080
3129
 
3081
3130
  let snapshot = null;
3082
3131
  let effectiveDbPath = dbPath;
@@ -3166,7 +3215,13 @@ async function parseHermesIncremental({ hermesPath, dbPath, cursors, queuePath,
3166
3215
  const cacheWrite = toNonNegativeInt(row.cache_write_tokens);
3167
3216
  const reasoning = toNonNegativeInt(row.reasoning_tokens);
3168
3217
  const messageCount = toNonNegativeInt(row.message_count);
3169
- if (inputTokens === 0 && outputTokens === 0 && cacheRead === 0 && reasoning === 0) continue;
3218
+ if (
3219
+ inputTokens === 0 &&
3220
+ outputTokens === 0 &&
3221
+ cacheRead === 0 &&
3222
+ cacheWrite === 0 &&
3223
+ reasoning === 0
3224
+ ) continue;
3170
3225
 
3171
3226
  // Save current snapshot for next sync
3172
3227
  nextSnapshots[row.id] = { in: inputTokens, out: outputTokens, cacheRead, cacheWrite, reasoning, message_count: messageCount };
@@ -3850,13 +3905,26 @@ async function parseKiroCliIncremental({ sessionFiles, cursors, queuePath, onPro
3850
3905
  const updatedAt = new Date().toISOString();
3851
3906
  hourlyState.updatedAt = updatedAt;
3852
3907
  cursors.hourly = hourlyState;
3853
- cursors.kiroCli = { ...kiroCliState, requests: cappedEarly, updatedAt };
3908
+ cursors.kiroCli = {
3909
+ ...kiroCliState,
3910
+ requests: cappedEarly.requests,
3911
+ watermarkMs: Math.max(Number(kiroCliState.watermarkMs) || 0, cappedEarly.watermarkMs),
3912
+ updatedAt,
3913
+ };
3854
3914
  return { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued };
3855
3915
  }
3856
3916
  const cb = typeof onProgress === "function" ? onProgress : null;
3857
3917
  let recordsProcessed = 0;
3858
3918
  let eventsAggregated = 0;
3859
3919
 
3920
+ // 2026-06 audit fix: requests older than the persisted prune watermark were
3921
+ // already counted once and had their cursor entry pruned by
3922
+ // clampAndCapKiroCliState — re-processing them (prev === undefined) re-ADDED
3923
+ // their tokens to the same bucket on every sync, inflating old buckets
3924
+ // without bound. Skip them; the watermark only ever advances, and starts at
3925
+ // 0 so a first-ever parse still ingests the full DB history.
3926
+ const kiroCliWatermarkMs = Number(kiroCliState.watermarkMs) || 0;
3927
+
3860
3928
  for (let i = 0; i < flat.length; i++) {
3861
3929
  const r = flat[i];
3862
3930
  recordsProcessed++;
@@ -3871,6 +3939,7 @@ async function parseKiroCliIncremental({ sessionFiles, cursors, queuePath, onPro
3871
3939
 
3872
3940
  const tsMs = Number(r.request_start_timestamp_ms);
3873
3941
  if (!Number.isFinite(tsMs) || tsMs <= 0) continue;
3942
+ if (tsMs < kiroCliWatermarkMs) continue;
3874
3943
  const bucketStart = toUtcHalfHourStart(new Date(tsMs).toISOString());
3875
3944
  if (!bucketStart) continue;
3876
3945
 
@@ -3952,7 +4021,12 @@ async function parseKiroCliIncremental({ sessionFiles, cursors, queuePath, onPro
3952
4021
  const updatedAt = new Date().toISOString();
3953
4022
  hourlyState.updatedAt = updatedAt;
3954
4023
  cursors.hourly = hourlyState;
3955
- cursors.kiroCli = { ...kiroCliState, requests: cappedState, updatedAt };
4024
+ cursors.kiroCli = {
4025
+ ...kiroCliState,
4026
+ requests: cappedState.requests,
4027
+ watermarkMs: Math.max(kiroCliWatermarkMs, cappedState.watermarkMs),
4028
+ updatedAt,
4029
+ };
3956
4030
 
3957
4031
  return { recordsProcessed, eventsAggregated, bucketsQueued };
3958
4032
  }
@@ -3978,6 +4052,14 @@ function clampAndCapKiroCliState({ requestState, hourlyState, touchedBuckets })
3978
4052
  }
3979
4053
  // TASK-004: cap cursors.kiroCli.requests by age + count. Runs LAST so
3980
4054
  // nothing active or just-retracted is pruned mid-flight.
4055
+ //
4056
+ // 2026-06 audit fix: pruning an entry while readKiroCliRequests has no
4057
+ // time floor meant the same request came back next sync with
4058
+ // `prev === undefined` and was re-ADDED to its (old) bucket — every sync,
4059
+ // forever. The returned watermarkMs records how far this prune reached;
4060
+ // the parse loop skips any request older than the persisted watermark, so
4061
+ // a pruned request can never be re-counted. First-ever parse still counts
4062
+ // arbitrarily old history (watermark starts at 0).
3981
4063
  const ageCutoffMs = Date.now() - KIRO_CLI_CURSOR_MAX_AGE_MS;
3982
4064
  const cappedEntries = [];
3983
4065
  for (const [reqId, entry] of Object.entries(requestState)) {
@@ -3986,13 +4068,22 @@ function clampAndCapKiroCliState({ requestState, hourlyState, touchedBuckets })
3986
4068
  if (!Number.isFinite(ts) || ts < ageCutoffMs) continue;
3987
4069
  cappedEntries.push([reqId, entry, ts]);
3988
4070
  }
4071
+ // +30min margin: entries are pruned by bucketStart (half-hour floor) while
4072
+ // the parse loop skips by raw request ts, which can sit up to 30 minutes
4073
+ // after its bucketStart. Without the margin a request whose bucket just
4074
+ // crossed the cutoff would be pruned yet still pass the skip, re-adding for
4075
+ // a few syncs until the watermark catches up.
4076
+ let watermarkMs = ageCutoffMs + 30 * 60 * 1000;
3989
4077
  if (cappedEntries.length > KIRO_CLI_CURSOR_MAX_ENTRIES) {
3990
4078
  cappedEntries.sort((a, b) => b[2] - a[2]); // newest first
4079
+ // Newest EVICTED entry sits at index MAX_ENTRIES after the sort; the
4080
+ // watermark must clear it so count-capped evictions can't re-add either.
4081
+ watermarkMs = Math.max(watermarkMs, cappedEntries[KIRO_CLI_CURSOR_MAX_ENTRIES][2] + 1);
3991
4082
  cappedEntries.length = KIRO_CLI_CURSOR_MAX_ENTRIES;
3992
4083
  }
3993
4084
  const capped = {};
3994
4085
  for (const [reqId, entry] of cappedEntries) capped[reqId] = entry;
3995
- return capped;
4086
+ return { requests: capped, watermarkMs };
3996
4087
  }
3997
4088
 
3998
4089
  // Back-compat path: per-session .json files (the old fixture shape). Emits
@@ -5174,7 +5265,13 @@ async function parseRoocodeIncremental({
5174
5265
  const cacheReads = toNonNegativeInt(payload.cacheReads);
5175
5266
  const cacheWrites = toNonNegativeInt(payload.cacheWrites);
5176
5267
  if (tokensIn === 0 && tokensOut === 0 && cacheReads === 0 && cacheWrites === 0) {
5177
- seenIds.add(dedupKey);
5268
+ // Cline-family extensions write `api_req_started` at request START
5269
+ // (zero tokens) and back-fill the SAME message in place (same ts)
5270
+ // once the request completes. Marking the zero placeholder as seen
5271
+ // would skip the back-filled tokens forever — a sync racing an
5272
+ // in-flight request silently under-counted that turn. Leave it
5273
+ // unseen; the file-level mtime gate re-evaluates it when the task
5274
+ // file is rewritten.
5178
5275
  continue;
5179
5276
  }
5180
5277
 
@@ -6443,7 +6540,10 @@ async function parseKilocodeIncremental({
6443
6540
  const cacheReads = toNonNegativeInt(payload.cacheReads);
6444
6541
  const cacheWrites = toNonNegativeInt(payload.cacheWrites);
6445
6542
  if (tokensIn === 0 && tokensOut === 0 && cacheReads === 0 && cacheWrites === 0) {
6446
- seenIds.add(dedupKey);
6543
+ // See the roocode parser: `api_req_started` is written at request
6544
+ // START with zero tokens and back-filled in place (same ts) on
6545
+ // completion. Marking the placeholder seen would drop the
6546
+ // back-filled tokens forever when a sync races an in-flight request.
6447
6547
  continue;
6448
6548
  }
6449
6549
 
@@ -6583,7 +6683,13 @@ async function parseOmpIncremental({
6583
6683
  const cacheWrite = toNonNegativeInt(usage.cacheWrite);
6584
6684
  const reasoningTokens = toNonNegativeInt(usage.reasoningTokens);
6585
6685
 
6586
- if (input === 0 && output === 0 && cacheRead === 0 && cacheWrite === 0) {
6686
+ if (
6687
+ input === 0 &&
6688
+ output === 0 &&
6689
+ cacheRead === 0 &&
6690
+ cacheWrite === 0 &&
6691
+ reasoningTokens === 0
6692
+ ) {
6587
6693
  seenIds.add(entryId);
6588
6694
  continue;
6589
6695
  }
@@ -6826,7 +6932,13 @@ async function parsePiIncremental({
6826
6932
  const cacheWrite = toNonNegativeInt(usage.cacheWrite);
6827
6933
  const reasoningTokens = toNonNegativeInt(usage.reasoningTokens);
6828
6934
 
6829
- if (input === 0 && output === 0 && cacheRead === 0 && cacheWrite === 0) {
6935
+ if (
6936
+ input === 0 &&
6937
+ output === 0 &&
6938
+ cacheRead === 0 &&
6939
+ cacheWrite === 0 &&
6940
+ reasoningTokens === 0
6941
+ ) {
6830
6942
  seenIds.add(entryId);
6831
6943
  continue;
6832
6944
  }
@@ -677,8 +677,8 @@ function expandGeminiExecutableCandidates({ home } = {}) {
677
677
  return candidates;
678
678
  }
679
679
 
680
- function extractGeminiOauthClientCredentials({ commandRunner, home } = {}) {
681
- const result = runCommand(commandRunner, "which", ["gemini"], { timeout: 2000 });
680
+ async function extractGeminiOauthClientCredentials({ commandRunner, home } = {}) {
681
+ const result = await runCommand(commandRunner, "which", ["gemini"], { timeout: 2000 });
682
682
  const geminiPath = typeof result?.stdout === "string" ? result.stdout.trim() : "";
683
683
 
684
684
  const geminiPaths = [
@@ -732,7 +732,7 @@ async function refreshGeminiAccessToken({
732
732
  fetchImpl = fetch,
733
733
  commandRunner,
734
734
  }) {
735
- const oauthClient = extractGeminiOauthClientCredentials({ commandRunner, home });
735
+ const oauthClient = await extractGeminiOauthClientCredentials({ commandRunner, home });
736
736
  if (!oauthClient?.clientId || !oauthClient?.clientSecret) {
737
737
  throw new Error("Gemini API error: Could not find Gemini CLI OAuth configuration");
738
738
  }
@@ -940,24 +940,97 @@ async function fetchGeminiLimits({ home, env, fetchImpl = fetch, commandRunner }
940
940
  }
941
941
  }
942
942
 
943
+ // Async command runner. Previously this wrapped `cp.spawnSync`, which blocked the
944
+ // Node event loop for the full command duration (up to 20s for Kiro) and froze every
945
+ // other local-api endpoint plus the other providers' withProviderTimeout races.
946
+ // Returns a promise for a spawnSync-shaped result: { status, stdout, stderr, error? }.
947
+ // Injected runners (tests) may stay synchronous — their return value is wrapped in
948
+ // Promise.resolve so both sync and async runners work.
943
949
  function runCommand(commandRunner, command, args, options = {}) {
944
- const runner = typeof commandRunner === "function" ? commandRunner : cp.spawnSync;
945
- return runner(command, args, {
950
+ const merged = {
946
951
  encoding: "utf8",
947
952
  maxBuffer: 10 * 1024 * 1024,
948
953
  ...options,
954
+ };
955
+ if (typeof commandRunner === "function") {
956
+ return Promise.resolve(commandRunner(command, args, merged));
957
+ }
958
+
959
+ const { timeout, maxBuffer, ...spawnOptions } = merged;
960
+ return new Promise((resolve) => {
961
+ let child;
962
+ try {
963
+ child = cp.spawn(command, args, { ...spawnOptions, stdio: ["ignore", "pipe", "pipe"] });
964
+ } catch (error) {
965
+ resolve({ status: null, stdout: "", stderr: "", error });
966
+ return;
967
+ }
968
+
969
+ let stdout = "";
970
+ let stderr = "";
971
+ let settled = false;
972
+ let timedOut = false;
973
+ let timer = null;
974
+ let hardTimer = null;
975
+
976
+ const settle = ({ status = null, error = null } = {}) => {
977
+ if (settled) return;
978
+ settled = true;
979
+ if (timer) clearTimeout(timer);
980
+ if (hardTimer) clearTimeout(hardTimer);
981
+ let finalError = error;
982
+ if (!finalError && timedOut) {
983
+ finalError = new Error(`spawn ${command} ETIMEDOUT`);
984
+ finalError.code = "ETIMEDOUT";
985
+ }
986
+ const result = { status, stdout, stderr };
987
+ if (finalError) result.error = finalError;
988
+ resolve(result);
989
+ };
990
+
991
+ if (Number.isFinite(timeout) && timeout > 0) {
992
+ timer = setTimeout(() => {
993
+ timedOut = true;
994
+ try { child.kill("SIGTERM"); } catch (_error) {}
995
+ // Guarantee settlement even if the child ignores SIGTERM or keeps stdio open.
996
+ hardTimer = setTimeout(() => {
997
+ try { child.kill("SIGKILL"); } catch (_error) {}
998
+ settle({ status: null });
999
+ }, 1000);
1000
+ if (typeof hardTimer.unref === "function") hardTimer.unref();
1001
+ }, timeout);
1002
+ }
1003
+
1004
+ const collect = (stream, append) => {
1005
+ if (!stream) return;
1006
+ stream.setEncoding("utf8");
1007
+ stream.on("data", (chunk) => {
1008
+ append(chunk);
1009
+ if (stdout.length + stderr.length > maxBuffer) {
1010
+ const error = new Error(`spawn ${command} maxBuffer length exceeded`);
1011
+ error.code = "ERR_CHILD_PROCESS_STDIO_MAXBUFFER";
1012
+ try { child.kill("SIGKILL"); } catch (_error) {}
1013
+ settle({ status: null, error });
1014
+ }
1015
+ });
1016
+ };
1017
+ collect(child.stdout, (chunk) => { stdout += chunk; });
1018
+ collect(child.stderr, (chunk) => { stderr += chunk; });
1019
+
1020
+ child.on("error", (error) => settle({ status: null, error }));
1021
+ child.on("close", (code) => settle({ status: timedOut ? null : code }));
949
1022
  });
950
1023
  }
951
1024
 
952
- function whichBinary(binary, { commandRunner } = {}) {
953
- const result = runCommand(commandRunner, "which", [binary], { timeout: 2000 });
1025
+ async function whichBinary(binary, { commandRunner } = {}) {
1026
+ const result = await runCommand(commandRunner, "which", [binary], { timeout: 2000 });
954
1027
  if (result?.error || result?.status !== 0) return null;
955
1028
  const stdout = typeof result?.stdout === "string" ? result.stdout.trim() : "";
956
1029
  return stdout ? stdout.split("\n")[0] : null;
957
1030
  }
958
1031
 
959
- function isBinaryAvailable(binary, { commandRunner } = {}) {
960
- return whichBinary(binary, { commandRunner }) !== null;
1032
+ async function isBinaryAvailable(binary, { commandRunner } = {}) {
1033
+ return (await whichBinary(binary, { commandRunner })) !== null;
961
1034
  }
962
1035
 
963
1036
  function stripAnsi(text) {
@@ -1223,12 +1296,12 @@ async function fetchCopilotLimits({ home, env = process.env, fetchImpl = fetch }
1223
1296
  }
1224
1297
  }
1225
1298
 
1226
- function fetchKiroLimits({ commandRunner, now = new Date() } = {}) {
1227
- if (!isBinaryAvailable("kiro-cli", { commandRunner })) {
1299
+ async function fetchKiroLimits({ commandRunner, now = new Date() } = {}) {
1300
+ if (!(await isBinaryAvailable("kiro-cli", { commandRunner }))) {
1228
1301
  return { configured: false };
1229
1302
  }
1230
1303
 
1231
- const result = runCommand(
1304
+ const result = await runCommand(
1232
1305
  commandRunner,
1233
1306
  "kiro-cli",
1234
1307
  ["chat", "--no-interactive", "/usage"],
@@ -1291,8 +1364,8 @@ function extractCommandFlag(command, flag) {
1291
1364
  return match?.[1] || null;
1292
1365
  }
1293
1366
 
1294
- function detectAntigravityProcess({ commandRunner } = {}) {
1295
- const result = runCommand(commandRunner, "/bin/ps", ["-ax", "-o", "pid=,command="], {
1367
+ async function detectAntigravityProcess({ commandRunner } = {}) {
1368
+ const result = await runCommand(commandRunner, "/bin/ps", ["-ax", "-o", "pid=,command="], {
1296
1369
  timeout: 4000,
1297
1370
  });
1298
1371
  const lines = String(result?.stdout || "").split("\n");
@@ -1494,7 +1567,7 @@ function clearClaudeRateLimitCooldown({ home } = {}) {
1494
1567
  } catch (_error) {}
1495
1568
  }
1496
1569
 
1497
- function resolveLsofBinary({ commandRunner } = {}) {
1570
+ async function resolveLsofBinary({ commandRunner } = {}) {
1498
1571
  for (const candidate of ["/usr/sbin/lsof", "/usr/bin/lsof"]) {
1499
1572
  if (fs.existsSync(candidate)) return candidate;
1500
1573
  }
@@ -1513,12 +1586,12 @@ function parseListeningPorts(output) {
1513
1586
  return Array.from(ports).sort((a, b) => a - b);
1514
1587
  }
1515
1588
 
1516
- function listAntigravityPorts(pid, { commandRunner } = {}) {
1517
- const lsof = resolveLsofBinary({ commandRunner });
1589
+ async function listAntigravityPorts(pid, { commandRunner } = {}) {
1590
+ const lsof = await resolveLsofBinary({ commandRunner });
1518
1591
  if (!lsof) {
1519
1592
  throw new Error("Antigravity port detection needs lsof. Install it, then retry.");
1520
1593
  }
1521
- const result = runCommand(
1594
+ const result = await runCommand(
1522
1595
  commandRunner,
1523
1596
  lsof,
1524
1597
  ["-nP", "-iTCP", "-sTCP:LISTEN", "-a", "-p", String(pid)],
@@ -1768,7 +1841,7 @@ async function probeAntigravityPort(port, csrfToken, { timeoutMs, requestFn } =
1768
1841
  }
1769
1842
 
1770
1843
  async function fetchAntigravityLimits({ home, commandRunner, requestFn, timeoutMs = 8000, nowMs = Date.now() } = {}) {
1771
- const processInfo = detectAntigravityProcess({ commandRunner });
1844
+ const processInfo = await detectAntigravityProcess({ commandRunner });
1772
1845
  if (!processInfo.configured) {
1773
1846
  return readAntigravityLimitsCache({ home, nowMs }) || { configured: false };
1774
1847
  }
@@ -1787,7 +1860,7 @@ async function fetchAntigravityLimits({ home, commandRunner, requestFn, timeoutM
1787
1860
  };
1788
1861
 
1789
1862
  try {
1790
- const ports = listAntigravityPorts(processInfo.pid, { commandRunner });
1863
+ const ports = await listAntigravityPorts(processInfo.pid, { commandRunner });
1791
1864
  let workingPort = null;
1792
1865
  for (const port of ports) {
1793
1866
  if (await probeAntigravityPort(port, processInfo.csrfToken, { timeoutMs, requestFn })) {
@@ -1864,7 +1937,29 @@ function withPlanLabel(obj, raw, brand) {
1864
1937
  return { ...obj, plan_label: normalizePlanLabel(raw, brand) };
1865
1938
  }
1866
1939
 
1867
- async function getUsageLimits({
1940
+ // Single-flight guard: concurrent cache misses share one upstream fetch instead of
1941
+ // each triggering the full 9-provider round (Claude's OAuth usage endpoint 429s when
1942
+ // hammered). Survives an external resetUsageLimitsCache() (refresh=1 path in
1943
+ // local-api.js): a refresh arriving while a fetch is already running reuses that
1944
+ // in-flight fetch and returns its result.
1945
+ let inFlightFetch = null;
1946
+
1947
+ async function getUsageLimits(options = {}) {
1948
+ const nowMs = Date.now();
1949
+ if (cache.data && nowMs - cache.fetchedAt < CACHE_TTL_MS) {
1950
+ return cache.data;
1951
+ }
1952
+ if (inFlightFetch) {
1953
+ return inFlightFetch;
1954
+ }
1955
+ const promise = fetchUsageLimitsUncached(options).finally(() => {
1956
+ if (inFlightFetch === promise) inFlightFetch = null;
1957
+ });
1958
+ inFlightFetch = promise;
1959
+ return promise;
1960
+ }
1961
+
1962
+ async function fetchUsageLimitsUncached({
1868
1963
  home,
1869
1964
  env,
1870
1965
  platform,
@@ -1876,9 +1971,6 @@ async function getUsageLimits({
1876
1971
  providerTimeoutMs = DEFAULT_PROVIDER_TIMEOUT_MS,
1877
1972
  } = {}) {
1878
1973
  const nowMs = Date.now();
1879
- if (cache.data && nowMs - cache.fetchedAt < CACHE_TTL_MS) {
1880
- return cache.data;
1881
- }
1882
1974
 
1883
1975
  const [claudeToken, claudeSubscription, codexAuth] = await Promise.all([
1884
1976
  Promise.resolve().then(() => readClaudeCodeAccessToken({ platform, securityRunner, home })),
@@ -1949,7 +2041,7 @@ async function getUsageLimits({
1949
2041
  .catch((reason) => ({ configured: true, error: reason?.message || "Unknown error" })),
1950
2042
  withProviderTimeout(fetchGeminiLimits({ home, env, fetchImpl: providerFetch, commandRunner }), "Gemini", providerTimeoutMs)
1951
2043
  .catch((reason) => ({ configured: true, error: reason?.message || "Unknown error" })),
1952
- Promise.resolve().then(() => fetchKiroLimits({ commandRunner, now })),
2044
+ fetchKiroLimits({ commandRunner, now }),
1953
2045
  fetchAntigravityLimits({ home, commandRunner, requestFn, nowMs }),
1954
2046
  withProviderTimeout(fetchCopilotLimits({ home, env, fetchImpl: providerFetch }), "GitHub Copilot", providerTimeoutMs)
1955
2047
  .catch((reason) => ({ configured: true, error: reason?.message || "Unknown error" })),
@@ -2043,6 +2135,7 @@ module.exports = {
2043
2135
  getUsageLimits,
2044
2136
  normalizePlanLabel,
2045
2137
  resetUsageLimitsCache,
2138
+ runCommand,
2046
2139
  extractGeminiOauthClientCredentials,
2047
2140
  loadKimiCredentials,
2048
2141
  normalizeCursorUsageSummary,
@@ -14,6 +14,14 @@
14
14
  * React Wrapped page.
15
15
  */
16
16
 
17
+ // Lazy require to avoid loading the full local-api module (and its pricing
18
+ // tables) until rows are actually aggregated. local-api.js itself requires
19
+ // this module lazily inside a handler, so there is no top-level cycle either
20
+ // way — but keeping this lazy preserves wrapped-aggregator as a light import.
21
+ function normalizeQueueRow(row) {
22
+ return require("./local-api").normalizeQueueRow(row);
23
+ }
24
+
17
25
  function isFiniteNumber(n) {
18
26
  return typeof n === "number" && Number.isFinite(n);
19
27
  }
@@ -70,7 +78,10 @@ function aggregateWrapped(rows, opts = {}) {
70
78
  for (const row of Array.isArray(rows) ? rows : []) {
71
79
  if (!row || typeof row !== "object") continue;
72
80
  const key = `${row.source || ""}|${row.model || ""}|${row.hour_start || ""}`;
73
- dedup.set(key, row);
81
+ // Apply the same legacy-row corrections (codex inclusive-input, cursor
82
+ // billable=0) as local-api.js readQueueData so `tracker wrapped` matches
83
+ // the dashboard for identical data.
84
+ dedup.set(key, normalizeQueueRow(row));
74
85
  }
75
86
  const all = Array.from(dedup.values());
76
87