tokentracker-cli 1.5.5 → 1.5.7

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 (132) hide show
  1. package/README.md +2 -0
  2. package/THIRD_PARTY_NOTICES.md +10 -0
  3. package/dashboard/dist/THIRD_PARTY_NOTICES.txt +10 -0
  4. package/dashboard/dist/assets/{AchievementBadge-BYxdWhS1.js → AchievementBadge-DIHKXKgm.js} +1 -1
  5. package/dashboard/dist/assets/{AchievementsPage-Bv9o7Aj8.js → AchievementsPage-U66jjKFf.js} +2 -2
  6. package/dashboard/dist/assets/{AchievementsSection-DE5bZGxg.js → AchievementsSection-BAW79xKg.js} +1 -1
  7. package/dashboard/dist/assets/{BadgeDetailModal-CgZaSVTp.js → BadgeDetailModal-IfomOw9B.js} +1 -1
  8. package/dashboard/dist/assets/{BotAnimated-CwhPfUBI.js → BotAnimated-B33gSUM3.js} +1 -1
  9. package/dashboard/dist/assets/{Card-Y7qaxMuE.js → Card-dtrkef-y.js} +1 -1
  10. package/dashboard/dist/assets/{CloudCheckoutPage-DVl7jk6b.js → CloudCheckoutPage-jYPgHJYE.js} +1 -1
  11. package/dashboard/dist/assets/{CloudPage-BnIvpHGH.js → CloudPage-BumdgM5w.js} +2 -2
  12. package/dashboard/dist/assets/{CommandPalette-CzMv2Zxn.js → CommandPalette-CFEeoIo3.js} +1 -1
  13. package/dashboard/dist/assets/{CommunityStatsModal-D8sOZqUc.js → CommunityStatsModal-DL3MrPmb.js} +1 -1
  14. package/dashboard/dist/assets/{ConfirmModal-Bun5xKMm.js → ConfirmModal-BRdtCv2g.js} +1 -1
  15. package/dashboard/dist/assets/{Controls-ltfnvkFW.js → Controls-Bb-_ZAsT.js} +1 -1
  16. package/dashboard/dist/assets/DashboardPage-GXwt6DoC.js +1 -0
  17. package/dashboard/dist/assets/{DateRangePopover-e3gJYqFJ.js → DateRangePopover-CHkFCFkE.js} +1 -1
  18. package/dashboard/dist/assets/{DevicePage-JgT16dEp.js → DevicePage-Da753sLS.js} +1 -1
  19. package/dashboard/dist/assets/{DialogClose-DeWert7h.js → DialogClose-4A_jISLc.js} +1 -1
  20. package/dashboard/dist/assets/DialogDescription-CiEhDvt9.js +1 -0
  21. package/dashboard/dist/assets/{DialogTitle-BfK_yOeR.js → DialogTitle-C0oQdI9n.js} +1 -1
  22. package/dashboard/dist/assets/{ElectricLogo-Bbev0Y8Q.js → ElectricLogo-Bv-sB-O-.js} +1 -1
  23. package/dashboard/dist/assets/{FadeIn-fypYWLDK.js → FadeIn-Ch2AuGxY.js} +1 -1
  24. package/dashboard/dist/assets/{HeaderGithubStar-Cnil2Lbo.js → HeaderGithubStar-C_dy8a5K.js} +1 -1
  25. package/dashboard/dist/assets/{HoverTooltip-Bw_8tru-.js → HoverTooltip-iuzo5YeN.js} +1 -1
  26. package/dashboard/dist/assets/{IpCheckPage-CHnhUgA2.js → IpCheckPage-BKEID9fw.js} +1 -1
  27. package/dashboard/dist/assets/{LandingPage-Bq4iQS6S.js → LandingPage-DZt5JWZ7.js} +2 -2
  28. package/dashboard/dist/assets/{LeaderboardPage-D42lUdw-.js → LeaderboardPage-CYrCQF5P.js} +2 -2
  29. package/dashboard/dist/assets/{LeaderboardProBadge-eTKceT90.js → LeaderboardProBadge-B8vG2SAW.js} +1 -1
  30. package/dashboard/dist/assets/{LeaderboardProfileModal-D-3tRrOR.js → LeaderboardProfileModal-DpAvBJPT.js} +3 -3
  31. package/dashboard/dist/assets/{LeaderboardProfilePage-DkRCJoiA.js → LeaderboardProfilePage-CofOk8GO.js} +1 -1
  32. package/dashboard/dist/assets/{LimitsPage-dUXjP3_1.js → LimitsPage-DXknvxtI.js} +6 -6
  33. package/dashboard/dist/assets/{LocalOnlyNotice-CtpjB6Z2.js → LocalOnlyNotice-BBVCEm5Q.js} +1 -1
  34. package/dashboard/dist/assets/{LoginCard-zrRd8o54.js → LoginCard-DcPazHo1.js} +1 -1
  35. package/dashboard/dist/assets/{LoginModal-DNl5Rt1e.js → LoginModal-CGXPaeWD.js} +1 -1
  36. package/dashboard/dist/assets/{LoginPage-BuB707vV.js → LoginPage-Ct6Al1pz.js} +1 -1
  37. package/dashboard/dist/assets/{PetPage-BpDriXkp.js → PetPage-8NPEAa-c.js} +2 -2
  38. package/dashboard/dist/assets/{PopoverPopup-rQNSs8R6.js → PopoverPopup-R69ks89h.js} +1 -1
  39. package/dashboard/dist/assets/{ProviderIcon-CumyO371.js → ProviderIcon-svF44YE0.js} +1 -1
  40. package/dashboard/dist/assets/{ResetPasswordPage-ecUHfJAk.js → ResetPasswordPage-DJIYKu8C.js} +1 -1
  41. package/dashboard/dist/assets/{Select-YVEqEhk4.js → Select-BVK4XZD5.js} +1 -1
  42. package/dashboard/dist/assets/{SelectItemText-D1NBc4uc.js → SelectItemText-Bh74D8lr.js} +1 -1
  43. package/dashboard/dist/assets/{SelfHostPage-8nAin68c.js → SelfHostPage-8gj2lAFp.js} +1 -1
  44. package/dashboard/dist/assets/{ServiceStatusPage-D1zxuzOg.js → ServiceStatusPage-CWPV_Mou.js} +1 -1
  45. package/dashboard/dist/assets/SessionsPage-Bo5plK6O.js +3 -0
  46. package/dashboard/dist/assets/{SettingsPage-COqP8ZbJ.js → SettingsPage-BptJrWn-.js} +3 -3
  47. package/dashboard/dist/assets/{SkillsPage-DCX7xTj0.js → SkillsPage-CRgGXnJS.js} +1 -1
  48. package/dashboard/dist/assets/{TokenGalaxy-D4n7AR-R.js → TokenGalaxy-dgOy0Eo7.js} +1 -1
  49. package/dashboard/dist/assets/{TooltipPopup-DvSoB_eS.js → TooltipPopup-DpGnyoFz.js} +1 -1
  50. package/dashboard/dist/assets/{TrendMonitor-5GVzh0Ye.js → TrendMonitor-BEy_GbMg.js} +1 -1
  51. package/dashboard/dist/assets/{WidgetsPage-BIc4MBiu.js → WidgetsPage-ChUelFrP.js} +1 -1
  52. package/dashboard/dist/assets/{WrappedPage-BpQk4g0o.js → WrappedPage-n63RsX8z.js} +1 -1
  53. package/dashboard/dist/assets/{__vite-browser-external-B_rKFTTN.js → __vite-browser-external-D_JnEwiu.js} +3 -3
  54. package/dashboard/dist/assets/{agent-logos-DVg-sTm4.js → agent-logos-DQ1h5uQF.js} +1 -1
  55. package/dashboard/dist/assets/{arrow-left-Ch6Q7eU5.js → arrow-left-Bo3Sg_cg.js} +1 -1
  56. package/dashboard/dist/assets/{arrow-right-B2B9GvSI.js → arrow-right-DdxkL8Ul.js} +1 -1
  57. package/dashboard/dist/assets/{arrow-up-right-DwNO_uSb.js → arrow-up-right-CpvelPD2.js} +1 -1
  58. package/dashboard/dist/assets/{calendar-days---NjJlBi.js → calendar-days-BvNVya0e.js} +1 -1
  59. package/dashboard/dist/assets/{check-D3mhgRrW.js → check-tO40Glrg.js} +1 -1
  60. package/dashboard/dist/assets/{chevron-down-x-b39Zto.js → chevron-down-Dnrra3mC.js} +1 -1
  61. package/dashboard/dist/assets/{cloud-checkout-CycWyTsg.js → cloud-checkout-COC4zB8s.js} +1 -1
  62. package/dashboard/dist/assets/{composite-CnA1qtYn.js → composite-C0NgnRTl.js} +1 -1
  63. package/dashboard/dist/assets/{copy-JRnRMgdX.js → copy-DTKN2SO4.js} +1 -1
  64. package/dashboard/dist/assets/{download-CRLqGD62.js → download-CSq2Yd3D.js} +1 -1
  65. package/dashboard/dist/assets/{external-link-BrmaWV_t.js → external-link-BtGedmuQ.js} +1 -1
  66. package/dashboard/dist/assets/{flame-meQ5-vRD.js → flame-ChjXIFSx.js} +1 -1
  67. package/dashboard/dist/assets/{format-tokens-D8mIJGz1.js → format-tokens-CXqZY114.js} +1 -1
  68. package/dashboard/dist/assets/{gift-Cd0FPXgO.js → gift-YFwBvsUA.js} +1 -1
  69. package/dashboard/dist/assets/{icons-D-PTKw7b.js → icons-DCiBpbK0.js} +1 -1
  70. package/dashboard/dist/assets/{index-C_6gEM83.js → index-B7sRX2EC.js} +1 -1
  71. package/dashboard/dist/assets/{index-HLYcwmPD.js → index-DMqIbTL4.js} +1 -1
  72. package/dashboard/dist/assets/{index-DCIZaLNC.js → index-Dpmdg98T.js} +1 -1
  73. package/dashboard/dist/assets/{index-CMV9UKNf.js → index-XSya-vft.js} +1 -1
  74. package/dashboard/dist/assets/{info-e0tP9P3D.js → info-PYzcoobJ.js} +1 -1
  75. package/dashboard/dist/assets/{link-2-DZ2TQhER.js → link-2-BxSrdKyR.js} +1 -1
  76. package/dashboard/dist/assets/{loader-circle-DjflRntu.js → loader-circle-CgvCCDTH.js} +1 -1
  77. package/dashboard/dist/assets/{local-usage-transfer-XTZLA3XH.js → local-usage-transfer-eTzMdhbs.js} +1 -1
  78. package/dashboard/dist/assets/{modifiers.esm-BpEQ8-Gt.js → modifiers.esm-B796CRz-.js} +1 -1
  79. package/dashboard/dist/assets/{monitor-smartphone-VVBcI30G.js → monitor-smartphone-M5nbcGRy.js} +1 -1
  80. package/dashboard/dist/assets/{play-DN4Sb3W_.js → play-12ZzPH9r.js} +1 -1
  81. package/dashboard/dist/assets/{plus-Cz_k7arH.js → plus-OqDA7xZy.js} +1 -1
  82. package/dashboard/dist/assets/{provider-display-S1mSaydF.js → provider-display-JlEBv1VB.js} +1 -1
  83. package/dashboard/dist/assets/{react-MQBcjrn_.js → react-CeDcnQho.js} +1 -1
  84. package/dashboard/dist/assets/{safePolygon-InKxYcXd.js → safePolygon-B_SGhQbo.js} +1 -1
  85. package/dashboard/dist/assets/{search-C-N0RDi0.js → search-D9wz4rWp.js} +1 -1
  86. package/dashboard/dist/assets/{server-CZkn8vIZ.js → server-UCMw4AQF.js} +1 -1
  87. package/dashboard/dist/assets/{shield-check-DPrGFouZ.js → shield-check-lJ0-Z5Ot.js} +1 -1
  88. package/dashboard/dist/assets/{skills-api-wMwM-GpF.js → skills-api-Da-WZbvW.js} +1 -1
  89. package/dashboard/dist/assets/{sortable.esm-CUPP1ihT.js → sortable.esm-Bj1ZkbqW.js} +1 -1
  90. package/dashboard/dist/assets/{store-BpVdRYnQ.js → store-Bpwwxxsy.js} +1 -1
  91. package/dashboard/dist/assets/{terminal-C-7TFeKg.js → terminal-BHQMC4hm.js} +1 -1
  92. package/dashboard/dist/assets/{trash-Be8cd4HD.js → trash-yTV6vhGX.js} +1 -1
  93. package/dashboard/dist/assets/{use-community-stats-cG0WrB4Y.js → use-community-stats-rrE4Ohic.js} +1 -1
  94. package/dashboard/dist/assets/{use-latest-request-guard-C91enyPD.js → use-latest-request-guard-D4Y1aCyd.js} +1 -1
  95. package/dashboard/dist/assets/{use-limits-display-prefs-DdwoLqPD.js → use-limits-display-prefs-ChHr4XEF.js} +1 -1
  96. package/dashboard/dist/assets/{use-native-settings-BR-NiMKh.js → use-native-settings-BmoCls3D.js} +1 -1
  97. package/dashboard/dist/assets/{use-ordered-list-oqV724BP.js → use-ordered-list-BZSSYcLx.js} +1 -1
  98. package/dashboard/dist/assets/use-reduced-motion-CKTIXp4-.js +1 -0
  99. package/dashboard/dist/assets/{use-transform-BsNSgVD6.js → use-transform-CJO1b0Iu.js} +1 -1
  100. package/dashboard/dist/assets/{use-usage-limits-Dk8LcMU-.js → use-usage-limits-BbAC_LWP.js} +1 -1
  101. package/dashboard/dist/assets/{useCurrency-FxerRfd2.js → useCurrency-B2enOMwJ.js} +1 -1
  102. package/dashboard/dist/assets/{usePositioner-D2J-rSfX.js → usePositioner-BaoW4QRL.js} +1 -1
  103. package/dashboard/dist/assets/{useTokenFormat-CaI2nl2C.js → useTokenFormat-B2bbeEu-.js} +1 -1
  104. package/dashboard/dist/assets/{zap-D3C37XAj.js → zap-CT5a8-_j.js} +1 -1
  105. package/dashboard/dist/brand-logos/factory.svg +8 -0
  106. package/dashboard/dist/index.html +1 -1
  107. package/dashboard/dist/ip-check.html +1 -1
  108. package/dashboard/dist/leaderboard.html +1 -1
  109. package/dashboard/dist/share.html +1 -1
  110. package/package.json +1 -1
  111. package/src/commands/init.js +9 -0
  112. package/src/commands/status.js +12 -6
  113. package/src/commands/sync.js +22 -4
  114. package/src/lib/claude-backend.js +291 -0
  115. package/src/lib/claude-config.js +1 -1
  116. package/src/lib/cursor-config.js +34 -9
  117. package/src/lib/cursor-safe-storage.js +119 -0
  118. package/src/lib/devin-limits.js +65 -25
  119. package/src/lib/diagnostics.js +3 -0
  120. package/src/lib/doctor.js +13 -0
  121. package/src/lib/local-api.js +2 -0
  122. package/src/lib/local-usage-transfer.js +22 -1
  123. package/src/lib/pricing/index.js +12 -0
  124. package/src/lib/pricing/seed-snapshot.json +1 -1
  125. package/src/lib/rollout.js +70 -15
  126. package/src/lib/session-analytics.js +20 -8
  127. package/src/lib/trae-cn-config.js +32 -2
  128. package/src/lib/usage-limits.js +601 -35
  129. package/dashboard/dist/assets/DashboardPage-C3Xu6_3t.js +0 -1
  130. package/dashboard/dist/assets/DialogDescription-Ca6W3nCT.js +0 -1
  131. package/dashboard/dist/assets/SessionsPage-B6ksEER_.js +0 -3
  132. package/dashboard/dist/assets/use-reduced-motion-DW5YHBBI.js +0 -1
@@ -1,3 +1,4 @@
1
+ const { preserveLegacyClaudeGroups, preserveLegacyClaudePrefix, createBackendResolver, mergeBackendUsage, claudeLines } = require("./claude-backend");
1
2
  const { claudeUserIdentity, collectClaudeForkAliases } = require("./claude-user-identity");
2
3
  const fs = require("node:fs/promises");
3
4
  const fssync = require("node:fs");
@@ -352,6 +353,7 @@ async function parseRolloutIncremental({
352
353
  const projectFreshnessNowMs = Date.now();
353
354
  const touchedBuckets = new Set();
354
355
  const defaultSource = normalizeSourceInput(source) || DEFAULT_SOURCE;
356
+ preserveCodexModelBuckets(hourlyState, touchedBuckets);
355
357
  const syncDiagnostics = diagnostics && typeof diagnostics === "object" ? diagnostics : null;
356
358
 
357
359
  if (!cursors.files || typeof cursors.files !== "object") {
@@ -1084,6 +1086,9 @@ async function parseClaudeIncremental({
1084
1086
  const prevHashes = Array.isArray(cursors.claudeHashes) ? cursors.claudeHashes : [];
1085
1087
  const seenMessageHashes = new Set(prevHashes);
1086
1088
  const defaultSource = normalizeSourceInput(source) || "claude";
1089
+ preserveLegacyClaudeGroups(files.filter((entry) =>
1090
+ (typeof entry === "string" ? defaultSource : normalizeSourceInput(entry?.source) || defaultSource) === "claude"
1091
+ ).map((entry) => typeof entry === "string" ? entry : entry?.path), path.dirname(queuePath));
1087
1092
  // Resolve all changed-file lineage before counting: fork files may sort first.
1088
1093
  const forkTimes = new Map();
1089
1094
  const forkAliases = await collectClaudeForkAliases(files, cursors,
@@ -1153,6 +1158,7 @@ async function parseClaudeIncremental({
1153
1158
  const truncated = sameInode && prevOffset > st.size;
1154
1159
  const startOffset = sameInode && !truncated ? prevOffset : 0;
1155
1160
 
1161
+ if (fileSource === "claude") preserveLegacyClaudePrefix(filePath, path.dirname(queuePath), startOffset);
1156
1162
  let fileId = null;
1157
1163
  if (seenFileIds) {
1158
1164
  const cachedFileId =
@@ -1287,6 +1293,7 @@ async function parseClaudeIncremental({
1287
1293
  projectKey,
1288
1294
  seenMessageHashes,
1289
1295
  forkAliases,
1296
+ backendResolver: fileSource === "claude" ? createBackendResolver(filePath, path.dirname(queuePath)) : null,
1290
1297
  });
1291
1298
 
1292
1299
  cursors.files[key] = {
@@ -3134,6 +3141,7 @@ async function parseClaudeFile({
3134
3141
  projectKey,
3135
3142
  seenMessageHashes,
3136
3143
  forkAliases,
3144
+ backendResolver,
3137
3145
  }) {
3138
3146
  const st = fileStat || (await fs.stat(filePath).catch(() => null));
3139
3147
  if (!st || !st.isFile()) return { endOffset: startOffset, eventsAggregated: 0 };
@@ -3141,15 +3149,14 @@ async function parseClaudeFile({
3141
3149
  const endOffset = st.size;
3142
3150
  if (startOffset >= endOffset) return { endOffset, eventsAggregated: 0 };
3143
3151
 
3144
- const stream = fssync.createReadStream(filePath, { encoding: "utf8", start: startOffset });
3145
- const rl = readline.createInterface({ input: stream, crlfDelay: Infinity });
3146
-
3147
3152
  let eventsAggregated = 0;
3148
3153
  // Separator-agnostic: WSL installs are scanned over \\wsl$ UNC paths where
3149
3154
  // path.join produces backslashes, and a forward-slash-only check would
3150
3155
  // misclassify subagent transcripts as main sessions (#307).
3151
3156
  const isMainSession = !/[\\/]subagents[\\/]/.test(filePath);
3152
- for await (const line of rl) {
3157
+ let byteOffset = startOffset;
3158
+ for await (const { line, start: lineStart, end } of claudeLines(filePath, startOffset, endOffset)) {
3159
+ byteOffset = end;
3153
3160
  if (!line) continue;
3154
3161
 
3155
3162
  // Count user-typed messages as conversations (main sessions only).
@@ -3211,6 +3218,8 @@ async function parseClaudeFile({
3211
3218
 
3212
3219
  if (dedupHash) seenMessageHashes.add(dedupHash);
3213
3220
  delta.conversation_count = 0;
3221
+ const backend = backendResolver?.(obj, lineStart, byteOffset);
3222
+ if (backend) delta.backend_usage = [{ ...delta, ...backend }];
3214
3223
 
3215
3224
  const bucketStart = toUtcHalfHourStart(tokenTimestamp);
3216
3225
  if (!bucketStart) continue;
@@ -3232,9 +3241,7 @@ async function parseClaudeFile({
3232
3241
  eventsAggregated += 1;
3233
3242
  }
3234
3243
 
3235
- rl.close();
3236
- stream.close?.();
3237
- return { endOffset, eventsAggregated };
3244
+ return { endOffset: byteOffset, eventsAggregated };
3238
3245
  }
3239
3246
 
3240
3247
  async function parseGeminiFile({
@@ -3514,6 +3521,40 @@ async function parseOpencodeMessageFile({
3514
3521
  };
3515
3522
  }
3516
3523
 
3524
+ // Codex models are observed per rollout/turn. A busier sibling agent in the
3525
+ // same half-hour is not evidence for the model of an unnamed request (#927).
3526
+ // Re-emit stored buckets once on upgrade, including idle historical groups:
3527
+ // their totals still retain the original attribution, so no log replay is
3528
+ // needed. Queue readers replace rows by (source, model, hour_start).
3529
+ function preserveCodexModelBuckets(hourlyState, touchedBuckets) {
3530
+ const upgrading = hourlyState.modelAttributionPolicy?.codex !== "observed";
3531
+ if (!upgrading && !Object.keys(hourlyState.groupQueued).some(
3532
+ key => key.startsWith(`${DEFAULT_SOURCE}${BUCKET_SEPARATOR}`),
3533
+ )) return;
3534
+ const legacyGroups = new Set();
3535
+ for (const [key, bucket] of Object.entries(hourlyState.buckets || {})) {
3536
+ const parsed = parseBucketKey(key);
3537
+ if (parsed.source !== DEFAULT_SOURCE || !bucket?.totals) continue;
3538
+ const groupKey = groupBucketKey(parsed.source, parsed.hourStart);
3539
+ const legacy = Object.prototype.hasOwnProperty.call(hourlyState.groupQueued, groupKey);
3540
+ if (legacy) legacyGroups.add(groupKey);
3541
+ if (!upgrading && !legacy) continue;
3542
+ bucket.queuedKey = null;
3543
+ bucket.retractedUnknownKey = null;
3544
+ touchedBuckets.add(key);
3545
+ }
3546
+ for (const groupKey of legacyGroups) {
3547
+ const hourStart = groupKey.slice(DEFAULT_SOURCE.length + BUCKET_SEPARATOR.length);
3548
+ // Legacy groups emitted a combined unknown row for multiple named models.
3549
+ // Replace it with the actual unknown total (or zero) before splitting.
3550
+ const unknown = getHourlyBucket(hourlyState, DEFAULT_SOURCE, DEFAULT_MODEL, hourStart);
3551
+ unknown.queuedKey = null;
3552
+ touchedBuckets.add(bucketKey(DEFAULT_SOURCE, DEFAULT_MODEL, hourStart));
3553
+ delete hourlyState.groupQueued[groupKey];
3554
+ }
3555
+ hourlyState.modelAttributionPolicy = { ...hourlyState.modelAttributionPolicy, codex: "observed" };
3556
+ }
3557
+
3517
3558
  async function enqueueTouchedBuckets({ queuePath, hourlyState, touchedBuckets }) {
3518
3559
  if (!touchedBuckets || touchedBuckets.size === 0) return 0;
3519
3560
 
@@ -3607,10 +3648,10 @@ async function enqueueTouchedBuckets({ queuePath, hourlyState, touchedBuckets })
3607
3648
 
3608
3649
  const toAppend = [];
3609
3650
  for (const group of groupedBuckets.values()) {
3610
- // A local reconstruction may opt into observed-only attribution. Preserve
3611
- // every model bucket, including unknown, instead of guessing from the
3612
- // busiest model in the same half-hour. The policy lives in cursor state
3613
- // so hooks, desktop refreshes, and restarted CLI processes agree.
3651
+ // Codex defaults to observed-only attribution; reconstructions can opt
3652
+ // other sources in. Preserve every model bucket, including unknown,
3653
+ // instead of guessing from the busiest model in the same half-hour.
3654
+ // Persist the policy so hooks, desktop refreshes and restarts agree.
3614
3655
  if (hourlyState.modelAttributionPolicy?.[group.source] === "observed") {
3615
3656
  // A group last aligned under the default policy must not keep the stale
3616
3657
  // dominant attribution downstream. Mirror the default path's alignment
@@ -3662,6 +3703,7 @@ async function enqueueTouchedBuckets({ queuePath, hourlyState, touchedBuckets })
3662
3703
  total_cost_usd: totals.total_cost_usd || 0,
3663
3704
  usage_precision: usagePrecision || undefined,
3664
3705
  ...prioritySubsetOf(totals),
3706
+ ...(totals.backend_usage ? { backend_usage: totals.backend_usage } : {}),
3665
3707
  conversation_count: totals.conversation_count,
3666
3708
  }));
3667
3709
  bucket.queuedKey = key;
@@ -3749,6 +3791,7 @@ async function enqueueTouchedBuckets({ queuePath, hourlyState, touchedBuckets })
3749
3791
  total_cost_usd: totals.total_cost_usd || 0,
3750
3792
  usage_precision: usagePrecision || undefined,
3751
3793
  ...prioritySubsetOf(totals),
3794
+ ...(totals.backend_usage ? { backend_usage: totals.backend_usage } : {}),
3752
3795
  conversation_count: totals.conversation_count,
3753
3796
  }),
3754
3797
  );
@@ -3830,6 +3873,7 @@ async function enqueueTouchedBuckets({ queuePath, hourlyState, touchedBuckets })
3830
3873
  total_cost_usd: unknownBucket.totals.total_cost_usd || 0,
3831
3874
  usage_precision: usagePrecision || undefined,
3832
3875
  ...prioritySubsetOf(unknownBucket.totals),
3876
+ ...(unknownBucket.totals.backend_usage ? { backend_usage: unknownBucket.totals.backend_usage } : {}),
3833
3877
  conversation_count: unknownBucket.totals.conversation_count,
3834
3878
  }),
3835
3879
  );
@@ -3879,6 +3923,7 @@ async function enqueueTouchedBuckets({ queuePath, hourlyState, touchedBuckets })
3879
3923
  billable_total_tokens: group.totals.billable_total_tokens ?? group.totals.total_tokens,
3880
3924
  total_cost_usd: group.totals.total_cost_usd || 0,
3881
3925
  ...prioritySubsetOf(group.totals),
3926
+ ...(group.totals.backend_usage ? { backend_usage: group.totals.backend_usage } : {}),
3882
3927
  conversation_count: group.totals.conversation_count,
3883
3928
  }),
3884
3929
  );
@@ -3926,6 +3971,7 @@ async function enqueueTouchedProjectBuckets({
3926
3971
  project_ref: projectRef,
3927
3972
  project_key: projectKey,
3928
3973
  source: bucket.source,
3974
+ ...(totals.backend_usage ? { backend_usage: totals.backend_usage } : {}),
3929
3975
  hour_start: bucket.hour_start,
3930
3976
  input_tokens: totals.input_tokens,
3931
3977
  cached_input_tokens: totals.cached_input_tokens,
@@ -4038,18 +4084,29 @@ function normalizeHourlyState(raw) {
4038
4084
  const version = Number(state.version || 1);
4039
4085
  const rawBuckets = state.buckets && typeof state.buckets === "object" ? state.buckets : {};
4040
4086
  const buckets = {};
4041
- const groupQueued = {};
4087
+ const existingGroupQueued =
4088
+ state.groupQueued && typeof state.groupQueued === "object" ? state.groupQueued : {};
4042
4089
  const modelAttributionPolicy = Object.fromEntries(
4043
4090
  Object.entries(state.modelAttributionPolicy || {})
4044
4091
  .filter(([, policy]) => policy === "observed"),
4045
4092
  );
4046
4093
 
4047
4094
  if (!Number.isFinite(version) || version < 2) {
4095
+ const groupQueued = { ...existingGroupQueued };
4048
4096
  for (const [key, value] of Object.entries(rawBuckets)) {
4049
4097
  const parsed = parseBucketKey(key);
4050
4098
  const hourStart = parsed.hourStart;
4051
4099
  if (!hourStart) continue;
4052
4100
  const source = normalizeSourceInput(parsed.source) || DEFAULT_SOURCE;
4101
+ // Older repairs persisted model-aware keys without a schema version.
4102
+ // Preserve them before v1 normalization can collapse sibling models into
4103
+ // unknown; preserveCodexModelBuckets needs the original attribution (#927).
4104
+ const firstSeparator = key.indexOf(BUCKET_SEPARATOR);
4105
+ const hasModel = firstSeparator > 0 && key.indexOf(BUCKET_SEPARATOR, firstSeparator + 1) > 0;
4106
+ if (hasModel) {
4107
+ buckets[bucketKey(source, parsed.model, hourStart)] = value;
4108
+ continue;
4109
+ }
4053
4110
  const normalizedKey = bucketKey(source, DEFAULT_MODEL, hourStart);
4054
4111
  buckets[normalizedKey] = value;
4055
4112
  if (value?.queuedKey) {
@@ -4073,9 +4130,6 @@ function normalizeHourlyState(raw) {
4073
4130
  buckets[normalizedKey] = value;
4074
4131
  }
4075
4132
 
4076
- const existingGroupQueued =
4077
- state.groupQueued && typeof state.groupQueued === "object" ? state.groupQueued : {};
4078
-
4079
4133
  return {
4080
4134
  version: 3,
4081
4135
  buckets,
@@ -4529,6 +4583,7 @@ function prioritySubsetOf(totals) {
4529
4583
  }
4530
4584
 
4531
4585
  function addTotals(target, delta) {
4586
+ mergeBackendUsage(target, delta);
4532
4587
  for (const [field] of SPEED_TIER_SUBSET_FIELDS) {
4533
4588
  const value = Number(delta?.[field]) || 0;
4534
4589
  if (value > 0) target[field] = (Number(target[field]) || 0) + value;
@@ -1,4 +1,5 @@
1
1
  "use strict";
2
+ const { createBackendResolver, mergeBackendUsage, claudeLines } = require("./claude-backend");
2
3
 
3
4
  // Metadata-only session analytics sidecar.
4
5
  //
@@ -146,6 +147,7 @@ function tokenTotals(usage) {
146
147
  }
147
148
 
148
149
  function addTotals(target, delta) {
150
+ mergeBackendUsage(target, delta);
149
151
  for (const key of ["input_tokens", "cached_input_tokens", "cache_creation_input_tokens", "output_tokens", "reasoning_output_tokens", "total_tokens"]) {
150
152
  target[key] = finite(target[key]) + finite(delta?.[key]);
151
153
  }
@@ -226,6 +228,7 @@ function normalizeModelUsageRows(rows) {
226
228
  };
227
229
  byModel.set(model, row);
228
230
  }
231
+ mergeBackendUsage(row, value);
229
232
  for (const field of MODEL_USAGE_SUM_FIELDS) row[field] += finite(value?.[field]);
230
233
  row.performance = mergePerformance(row.performance, value?.performance);
231
234
  if (Array.isArray(value?.selected_models)) {
@@ -266,7 +269,7 @@ function normalizeModelUsageRows(rows) {
266
269
  // most of it the long_context_* counters for sessions with no requests over
267
270
  // OPENAI_LONG_CONTEXT_INPUT_THRESHOLD in src/lib/pricing/index.js.
268
271
  function serializeModelUsageRow(row) {
269
- const out = { model: row.model };
272
+ const out = { model: row.model, ...(row.backend_usage ? { backend_usage: row.backend_usage } : {}) };
270
273
  for (const field of MODEL_USAGE_SUM_FIELDS) {
271
274
  const value = finite(row[field]);
272
275
  if (value !== 0) out[field] = value;
@@ -299,7 +302,7 @@ function repriceSessionRecord(record) {
299
302
  let hasUnpricedUsage = false;
300
303
  for (const row of modelUsage) {
301
304
  row.cost_usd = computeRowCost({ source: record.source, ...row });
302
- row.pricing = getModelPricingInfo(row.model, { source: record.source });
305
+ row.pricing = getModelPricingInfo(row.model, { ...row, source: record.source });
303
306
  if (row.total_tokens > 0 && row.pricing.status === "unpriced") hasUnpricedUsage = true;
304
307
  }
305
308
  record.model_usage = modelUsage;
@@ -366,7 +369,7 @@ function modelUsageForAggregation(record) {
366
369
  }
367
370
  const priced = rows.map((row) => denseModelUsageRow({
368
371
  ...row,
369
- pricing: getModelPricingInfo(row.model, { source: record.source }),
372
+ pricing: getModelPricingInfo(row.model, { ...row, source: record.source }),
370
373
  cost_usd: Number.isFinite(Number(row.cost_usd))
371
374
  ? Number(row.cost_usd)
372
375
  : computeRowCost({ source: record.source, ...row }),
@@ -548,7 +551,7 @@ function isRecoverableSessionReadError(error) {
548
551
  return ["ENOENT", "EACCES", "EPERM", "EISDIR", "EIO", "ESTALE"].includes(error?.code);
549
552
  }
550
553
 
551
- async function scanClaudeSession(filePath) {
554
+ async function scanClaudeSession(filePath, { trackerDir, legacyOffsets = {} } = {}) {
552
555
  // Native and WSL mounts can disappear independently after discovery. Keep
553
556
  // the surviving mirror instead of dropping the whole logical session.
554
557
  const filePaths = readableSessionPaths(filePath);
@@ -600,9 +603,8 @@ async function scanClaudeSession(filePath) {
600
603
  for (const currentFilePath of filePaths) {
601
604
  const currentRecordHashes = new Set();
602
605
  try {
603
- const input = fs.createReadStream(currentFilePath, { encoding: "utf8" });
604
- const lines = readline.createInterface({ input, crlfDelay: Infinity });
605
- for await (const line of lines) {
606
+ const backend = trackerDir ? createBackendResolver(currentFilePath, trackerDir, { legacyOffset: legacyOffsets[currentFilePath] || 0 }) : null;
607
+ for await (const { line, start, end } of claudeLines(currentFilePath)) {
606
608
  if (filePaths.length > 1) {
607
609
  const recordHash = crypto.createHash("sha256").update(line).digest("base64url");
608
610
  if (priorRecordHashes.has(recordHash)) continue;
@@ -649,6 +651,8 @@ async function scanClaudeSession(filePath) {
649
651
  if (candidateModel) model = candidateModel;
650
652
  currentTurnModel = model;
651
653
  const usage = normalizeClaudeUsage(obj.message.usage);
654
+ const identity = backend && usage.total_tokens > 0 ? backend(obj, start, end) : null;
655
+ if (identity) usage.backend_usage = [{ ...usage, ...identity }];
652
656
  addTotals(tokens, usage);
653
657
  if (usage.total_tokens > 0) {
654
658
  const modelUsage = modelUsageByModel.get(model) || { model, ...emptyTotals(), usage_events: 0 };
@@ -2343,6 +2347,8 @@ async function buildSessionAnalyticsInternal({ home = os.homedir(), force = fals
2343
2347
  const retryOmpScan = fs.existsSync(incompleteOmpPath);
2344
2348
  const incompleteDshPath = `${metaPath}.dsh-incomplete`;
2345
2349
  const retryDshScan = fs.existsSync(incompleteDshPath);
2350
+ let provenanceMeta = null;
2351
+ try { provenanceMeta = JSON.parse(fs.readFileSync(metaPath, "utf8")); } catch { /* first scan */ }
2346
2352
  let previousMeta = null;
2347
2353
  if (!force) {
2348
2354
  try {
@@ -2468,7 +2474,12 @@ async function buildSessionAnalyticsInternal({ home = os.homedir(), force = fals
2468
2474
  }
2469
2475
  if (!rows) {
2470
2476
  try {
2471
- const scanned = await entry.scan(entry.filePath);
2477
+ const legacyOffsets = {};
2478
+ if (entry.source === "claude") {
2479
+ const sizes = provenanceMeta?.files?.[cacheKey]?.stat_key?.split("|") || [];
2480
+ entry.filePath.forEach((file, index) => { legacyOffsets[file] = Number(sizes[index]?.split(":")[0]) || 0; });
2481
+ }
2482
+ const scanned = await entry.scan(entry.filePath, { trackerDir: path.dirname(sidecarPath), legacyOffsets });
2472
2483
  rows = Array.isArray(scanned) ? scanned.filter(Boolean) : (scanned ? [scanned] : []);
2473
2484
  // Batch sources count the files they had to give up on themselves.
2474
2485
  // A dsh batch is one deduplicated inventory, so a single unreadable log
@@ -2659,6 +2670,7 @@ function scopeSessionUsage(row, from, to, context) {
2659
2670
  const modelName = previousModelUsage ? previousModelUsage.model : (row.model || bucket.model);
2660
2671
  const model = byModel.get(modelName) || {
2661
2672
  ...(previousModelUsage || {}),
2673
+ backend_usage: undefined,
2662
2674
  model: modelName,
2663
2675
  ...Object.fromEntries(MODEL_USAGE_SUM_FIELDS.filter((field) => field !== "edit_turns").map((field) => [field, 0])),
2664
2676
  usage_events: 0,
@@ -34,6 +34,9 @@ function isTraeCnUsageEnabled(env = process.env) {
34
34
  }
35
35
  const TRAE_CN_APP_DIRS = ["TRAE SOLO CN", "Trae CN"];
36
36
  const TRAE_CN_AUTH_KEY = "iCubeAuthInfo://icube.cloudide";
37
+ const TRAE_CN_AUTH_HELP = "Sign in again in the TRAE CN app, then rerun tokentracker sync --source=trae-cn. " +
38
+ "If multiple CN apps are installed, set TOKENTRACKER_TRAE_CN_HOME to the signed-in app data root. " +
39
+ "Readable local auth does not prove API access; TokenTracker cannot refresh the app session.";
37
40
 
38
41
  const TRAE_CN_USAGE_URL = "https://api.trae.cn/trae/api/v1/pay/query_user_usage_group_by_session";
39
42
  // usage_type [7] mirrors the constant the official TRAE client sends when its
@@ -282,6 +285,26 @@ function extractTraeCnToken(auth) {
282
285
  throw new Error("Trae CN auth data has no token.");
283
286
  }
284
287
 
288
+ // Local-only doctor probe. Return an allowlisted state, never credentials or
289
+ // exception text. Opting out also avoids reading/decrypting the auth payload.
290
+ function inspectTraeCnAuth(options = {}) {
291
+ const enabled = isTraeCnUsageEnabled(options.env ?? process.env);
292
+ const storagePath = resolveTraeCnStoragePath(options);
293
+ const result = { usage_opt_in: enabled, storage_path: storagePath, auth: "unchecked" };
294
+ if (!enabled) return result;
295
+ try {
296
+ const auth = readTraeCnAuthFromStorage(options);
297
+ if (!auth) result.auth = "not-signed-in";
298
+ else {
299
+ extractTraeCnToken(auth);
300
+ result.auth = "readable";
301
+ }
302
+ } catch (error) {
303
+ result.auth = error?.code === "TRAE_CN_STORAGE_UNREADABLE" ? "unreadable" : "malformed";
304
+ }
305
+ return result;
306
+ }
307
+
285
308
  // ---------------------------------------------------------------------------
286
309
  // Usage API
287
310
  // ---------------------------------------------------------------------------
@@ -365,7 +388,7 @@ async function fetchTraeCnUsagePage({
365
388
  throw new Error("Trae CN usage API request failed.");
366
389
  }
367
390
  if (response.status === 401 || response.status === 403) {
368
- throw usageError(`Trae CN usage API returned HTTP ${response.status}.`, { status: response.status });
391
+ throw usageError(`Trae CN usage API returned HTTP ${response.status}; authorization was rejected. ${TRAE_CN_AUTH_HELP}`, { status: response.status });
369
392
  }
370
393
  if (!response.ok) {
371
394
  throw usageError(`Trae CN usage API returned HTTP ${response.status}.`, { status: response.status });
@@ -377,8 +400,13 @@ async function fetchTraeCnUsagePage({
377
400
  throw new Error("Trae CN usage API returned a non-JSON response.");
378
401
  }
379
402
  const apiCode = parsed?.data?.code ?? parsed?.code;
403
+ if (apiCode !== undefined &&
404
+ !((typeof apiCode === "number" || (typeof apiCode === "string" && /^\d+$/.test(apiCode))) &&
405
+ Number.isSafeInteger(Number(apiCode)))) {
406
+ throw new Error("Trae CN usage API returned an invalid error code.");
407
+ }
380
408
  if (apiCode !== undefined && Number(apiCode) !== 0) {
381
- throw usageError(`Trae CN usage API returned error code ${apiCode}.`, {
409
+ throw usageError(`Trae CN usage API returned error code ${Number(apiCode)}.`, {
382
410
  status: response.status,
383
411
  apiCode: Number(apiCode),
384
412
  });
@@ -543,6 +571,8 @@ module.exports = {
543
571
  isTraeCnUsageEnabled,
544
572
  TRAE_CN_APP_DIRS,
545
573
  TRAE_CN_AUTH_KEY,
574
+ TRAE_CN_AUTH_HELP,
575
+ inspectTraeCnAuth,
546
576
  TRAE_CN_USAGE_URL,
547
577
  TRAE_CN_USAGE_TYPE,
548
578
  TRAE_CN_USAGE_PAGE_SIZE,