tokentracker-cli 1.1.3 → 1.1.5

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 (120) hide show
  1. package/README.de.md +5 -4
  2. package/README.ja.md +5 -4
  3. package/README.ko.md +5 -4
  4. package/README.md +20 -4
  5. package/README.zh-CN.md +5 -4
  6. package/dashboard/dist/assets/{AchievementBadge-dt_UDU_h.js → AchievementBadge-ClUmGxBJ.js} +1 -1
  7. package/dashboard/dist/assets/{AchievementsPage-B5DJ3S9l.js → AchievementsPage-BB0DFE3m.js} +2 -2
  8. package/dashboard/dist/assets/{AchievementsSection-CDxcyig2.js → AchievementsSection-BMCSk63s.js} +1 -1
  9. package/dashboard/dist/assets/{BadgeDetailModal-Cp98zNn2.js → BadgeDetailModal-DzJtvuKO.js} +1 -1
  10. package/dashboard/dist/assets/{BotAnimated-CR7MiUpo.js → BotAnimated-BviEasW4.js} +1 -1
  11. package/dashboard/dist/assets/{Card-C8IdDvlg.js → Card-BoHr-wLc.js} +1 -1
  12. package/dashboard/dist/assets/{ClawdAnimated-COeTlW8z.js → ClawdAnimated-Bwm8BfqY.js} +2 -2
  13. package/dashboard/dist/assets/{CommandPalette-Co9k2CCc.js → CommandPalette-Rb0Gfcg9.js} +1 -1
  14. package/dashboard/dist/assets/{CommunityStatsModal-C6XQeRgw.js → CommunityStatsModal-Ck7R7Q-d.js} +1 -1
  15. package/dashboard/dist/assets/{ConfirmModal-Ai4182O-.js → ConfirmModal-52Fu9wOi.js} +1 -1
  16. package/dashboard/dist/assets/{Controls-BbKzN-Pe.js → Controls-YlGr187A.js} +1 -1
  17. package/dashboard/dist/assets/DashboardPage-BRim2Rt6.js +1 -0
  18. package/dashboard/dist/assets/{DevicePage-CZbrtJmb.js → DevicePage-3mmhzfgd.js} +1 -1
  19. package/dashboard/dist/assets/{DialogClose-DMl8tAoX.js → DialogClose-D1KfBYUS.js} +1 -1
  20. package/dashboard/dist/assets/DialogDescription-BhWwQSSg.js +1 -0
  21. package/dashboard/dist/assets/{DialogTitle-BJxpp2t-.js → DialogTitle-DKye2nJK.js} +1 -1
  22. package/dashboard/dist/assets/{FadeIn-hU_MXl6R.js → FadeIn-mJp_ZFst.js} +1 -1
  23. package/dashboard/dist/assets/{HeaderGithubStar-CM5RSxxN.js → HeaderGithubStar-B2R_aqp9.js} +1 -1
  24. package/dashboard/dist/assets/{HoverTooltip-BZKfQyZQ.js → HoverTooltip-ElY02914.js} +1 -1
  25. package/dashboard/dist/assets/{IpCheckPage-BjDx-zja.js → IpCheckPage-CNzPUe3x.js} +1 -1
  26. package/dashboard/dist/assets/{LandingPage-CAt8BCDv.js → LandingPage-L25-CxXg.js} +2 -2
  27. package/dashboard/dist/assets/{LeaderboardAvatar-DiCrakjI.js → LeaderboardAvatar-BT7x5wAP.js} +1 -1
  28. package/dashboard/dist/assets/{LeaderboardPage-Dj3pHv6U.js → LeaderboardPage-bPce9gOU.js} +2 -2
  29. package/dashboard/dist/assets/{LeaderboardProfileModal-DEWbcP9p.js → LeaderboardProfileModal-DPrFGAp6.js} +3 -3
  30. package/dashboard/dist/assets/{LeaderboardProfilePage-CTbWHQkY.js → LeaderboardProfilePage-j2D1pwYO.js} +1 -1
  31. package/dashboard/dist/assets/{LimitsPage-V-98OUhF.js → LimitsPage-CSED1kBx.js} +1 -1
  32. package/dashboard/dist/assets/{LocalOnlyNotice-a4PqtUZY.js → LocalOnlyNotice-D6OERWez.js} +1 -1
  33. package/dashboard/dist/assets/{LoginCard-oD1A2Ru8.js → LoginCard-HgmRdIn8.js} +1 -1
  34. package/dashboard/dist/assets/{LoginModal-D8aSmxok.js → LoginModal-BSlEmKOV.js} +1 -1
  35. package/dashboard/dist/assets/{LoginPage-DIjhBv2X.js → LoginPage-HQAhowvh.js} +1 -1
  36. package/dashboard/dist/assets/{PetPage-D7601BTX.js → PetPage-DnbYfu-z.js} +1 -1
  37. package/dashboard/dist/assets/{PopoverPopup-aPnmKopO.js → PopoverPopup-DnyVdfB8.js} +1 -1
  38. package/dashboard/dist/assets/{ProviderIcon-rwyfcNlT.js → ProviderIcon-D8kgkZlR.js} +1 -1
  39. package/dashboard/dist/assets/{ResetPasswordPage-AF3SBgW2.js → ResetPasswordPage-ChtXGykP.js} +1 -1
  40. package/dashboard/dist/assets/{SegmentedControl-fnlScNMT.js → SegmentedControl-dHUpXqFk.js} +1 -1
  41. package/dashboard/dist/assets/{Select-DDjC3RjB.js → Select-C0tpjNiq.js} +1 -1
  42. package/dashboard/dist/assets/{SelectItemText-VhBWE2O8.js → SelectItemText-C3G_w14k.js} +1 -1
  43. package/dashboard/dist/assets/{ServiceStatusPage-BL3qqEsp.js → ServiceStatusPage-D6x4e1r8.js} +1 -1
  44. package/dashboard/dist/assets/{SessionsPage-DXYiuP2x.js → SessionsPage-DZH1QMWd.js} +1 -1
  45. package/dashboard/dist/assets/{SettingsPage-DTeA_ahr.js → SettingsPage-DGmDb2R4.js} +2 -2
  46. package/dashboard/dist/assets/{SkillsPage-CtTRwVQH.js → SkillsPage-ChloCHPa.js} +1 -1
  47. package/dashboard/dist/assets/{TokenGalaxy-CifK91V7.js → TokenGalaxy-DOC9wlkv.js} +1 -1
  48. package/dashboard/dist/assets/{ToolbarRootContext-ClGAIYoa.js → ToolbarRootContext-BB_d-qdY.js} +1 -1
  49. package/dashboard/dist/assets/{TrendMonitor-D1evFHRM.js → TrendMonitor-DVVzYdc7.js} +1 -1
  50. package/dashboard/dist/assets/{WidgetsPage-Cq4RYJDE.js → WidgetsPage-DHVSyU0i.js} +1 -1
  51. package/dashboard/dist/assets/{WrappedPage-CoC5RJH7.js → WrappedPage-BDi28nRt.js} +1 -1
  52. package/dashboard/dist/assets/__vite-browser-external-Bnc5aBhq.js +45 -0
  53. package/dashboard/dist/assets/{agent-logos-QLdA9OeA.js → agent-logos-BiGvZiLq.js} +1 -1
  54. package/dashboard/dist/assets/{arrow-up-right-BYLgB3bH.js → arrow-up-right-SvD82rok.js} +1 -1
  55. package/dashboard/dist/assets/{check-DfnIvqZD.js → check-jL5XErEW.js} +1 -1
  56. package/dashboard/dist/assets/{composite--JllTpIX.js → composite-DM6geXPA.js} +1 -1
  57. package/dashboard/dist/assets/{copy-ClKVBIgD.js → copy-Dy_GIzj2.js} +1 -1
  58. package/dashboard/dist/assets/{download-Di1-UFJX.js → download-BW_FS7nX.js} +1 -1
  59. package/dashboard/dist/assets/{external-link-D7zPVFLO.js → external-link-2RIovvCT.js} +1 -1
  60. package/dashboard/dist/assets/{flame-BMJTxjVg.js → flame-CIRJIxxA.js} +1 -1
  61. package/dashboard/dist/assets/{format-tokens-DCZM5Nfn.js → format-tokens-CT8ahEK4.js} +1 -1
  62. package/dashboard/dist/assets/{icons-I615W2IU.js → icons-7J1WD0C-.js} +1 -1
  63. package/dashboard/dist/assets/{index-BX3CXVdD.js → index-D3iSSyYr.js} +1 -1
  64. package/dashboard/dist/assets/{index-CwczM3BZ.js → index-Ddb2A6Mx.js} +1 -1
  65. package/dashboard/dist/assets/{index-BejnmdPe.js → index-_vJl6sF7.js} +1 -1
  66. package/dashboard/dist/assets/{info-CFJPny5Q.js → info-BjVWcOOC.js} +1 -1
  67. package/dashboard/dist/assets/{link-2-D1ydWh_w.js → link-2-CyRWnHWE.js} +1 -1
  68. package/dashboard/dist/assets/{loader-circle-Cq-J-OFg.js → loader-circle-DUpG5zah.js} +1 -1
  69. package/dashboard/dist/assets/{maximize-2-kIoSq57I.js → maximize-2-BVGCRkFZ.js} +1 -1
  70. package/dashboard/dist/assets/{modifiers.esm-CLojthuF.js → modifiers.esm-CZI7rED4.js} +1 -1
  71. package/dashboard/dist/assets/module-WSx8viXK.js +3 -0
  72. package/dashboard/dist/assets/{monitor-smartphone-DX2qWHYV.js → monitor-smartphone-DfBtd01T.js} +1 -1
  73. package/dashboard/dist/assets/{plus-B-JEpo3V.js → plus-CvbLCvjV.js} +1 -1
  74. package/dashboard/dist/assets/{provider-display-DJDdrTAS.js → provider-display-DC46GLrv.js} +2 -2
  75. package/dashboard/dist/assets/{react-Ctoe8iLl.js → react-mYr3J8Vw.js} +1 -1
  76. package/dashboard/dist/assets/{search-BTSQrlW-.js → search-CFaS1rSj.js} +1 -1
  77. package/dashboard/dist/assets/{skills-api-DKQyM8h7.js → skills-api-B1N6seFa.js} +1 -1
  78. package/dashboard/dist/assets/{sortable.esm-pOEL13xU.js → sortable.esm-BE2GiQaC.js} +1 -1
  79. package/dashboard/dist/assets/{store-B2RplzvW.js → store-DwBbExah.js} +1 -1
  80. package/dashboard/dist/assets/{terminal-BH_6_EW_.js → terminal-Bl2BuO9x.js} +1 -1
  81. package/dashboard/dist/assets/{trash-CC444Mz6.js → trash-D8beb27Y.js} +1 -1
  82. package/dashboard/dist/assets/{use-community-stats-CBXLdD_j.js → use-community-stats-DJG1DsXR.js} +1 -1
  83. package/dashboard/dist/assets/{use-latest-request-guard-DMuPfZQI.js → use-latest-request-guard-DYK8iyOM.js} +1 -1
  84. package/dashboard/dist/assets/{use-limits-display-prefs-CmsGTBA4.js → use-limits-display-prefs-BGmcFVX3.js} +1 -1
  85. package/dashboard/dist/assets/{use-ordered-list-BNJNpXjB.js → use-ordered-list-Cqia_VIq.js} +1 -1
  86. package/dashboard/dist/assets/use-reduced-motion-CmCk9Sb5.js +1 -0
  87. package/dashboard/dist/assets/{use-session-efficiency-pref-g_0ISxe1.js → use-session-efficiency-pref-Mj3OeVGZ.js} +1 -1
  88. package/dashboard/dist/assets/{use-transform-D36BHcXk.js → use-transform-D4BQIBHx.js} +1 -1
  89. package/dashboard/dist/assets/{use-usage-limits-Dxw3vzsD.js → use-usage-limits-BFrO7Mtq.js} +1 -1
  90. package/dashboard/dist/assets/{useCurrency-V2FE4TMo.js → useCurrency-CYJavR2h.js} +1 -1
  91. package/dashboard/dist/assets/{useTokenFormat-CEOwvzMt.js → useTokenFormat-CKruI7SP.js} +1 -1
  92. package/dashboard/dist/assets/{zap-BS7ZU76I.js → zap-a82fG76c.js} +1 -1
  93. package/dashboard/dist/brand-logos/trae.svg +1 -0
  94. package/dashboard/dist/index.html +13 -11
  95. package/dashboard/dist/ip-check.html +1 -1
  96. package/dashboard/dist/leaderboard.html +1 -1
  97. package/dashboard/dist/llms.txt +3 -2
  98. package/dashboard/dist/share.html +1 -1
  99. package/package.json +6 -4
  100. package/src/commands/init.js +9 -7
  101. package/src/commands/status.js +66 -11
  102. package/src/commands/sync.js +115 -11
  103. package/src/lib/codex-context-breakdown.js +3 -1
  104. package/src/lib/diagnostics.js +20 -1
  105. package/src/lib/doctor.js +30 -0
  106. package/src/lib/pricing/curated-overrides.json +7 -0
  107. package/src/lib/pricing/matcher.js +5 -0
  108. package/src/lib/pricing/seed-snapshot.json +1 -1
  109. package/src/lib/rollout.js +197 -18
  110. package/src/lib/scan-roots.js +277 -0
  111. package/src/lib/session-analytics.js +98 -16
  112. package/src/lib/trae-db.js +127 -0
  113. package/src/lib/trae-snapshot.js +230 -0
  114. package/src/lib/trae-sqlite.js +143 -0
  115. package/src/lib/trae-usage.js +121 -0
  116. package/dashboard/dist/assets/DashboardPage-DTcVGG4e.js +0 -1
  117. package/dashboard/dist/assets/DialogDescription-haPz7CX9.js +0 -1
  118. package/dashboard/dist/assets/__vite-browser-external-BDugA6I0.js +0 -45
  119. package/dashboard/dist/assets/module-DtWfRKM4.js +0 -3
  120. package/dashboard/dist/assets/use-reduced-motion-CNpG-Yoi.js +0 -1
@@ -47,6 +47,8 @@ const {
47
47
  buildDevinUsageEvents,
48
48
  } = require("./devin-usage");
49
49
  const { USD_TICKS_PER_USD, normalizeGrokUsage } = require("./grok-usage");
50
+ const { resolveTraeDbPaths, readTraeUsageRows } = require("./trae-db");
51
+ const { normalizeTraeUsage, normalizeTraeModel, traeTimestamp } = require("./trae-usage");
50
52
 
51
53
  const DEFAULT_SOURCE = "codex";
52
54
  const DEFAULT_MODEL = "unknown";
@@ -19662,12 +19664,13 @@ function mergeGrokUsagePrecision(current, next) {
19662
19664
  return "mixed";
19663
19665
  }
19664
19666
 
19665
- function clearGrokHourlyBuckets(hourlyState) {
19667
+ function clearSourceHourlyBuckets(hourlyState, source) {
19666
19668
  if (!hourlyState || typeof hourlyState !== "object") return;
19669
+ const prefix = `${source}${BUCKET_SEPARATOR}`;
19667
19670
  const buckets = hourlyState.buckets && typeof hourlyState.buckets === "object" ? hourlyState.buckets : null;
19668
19671
  if (buckets) {
19669
19672
  for (const key of Object.keys(buckets)) {
19670
- if (key.startsWith("grok|")) delete buckets[key];
19673
+ if (key.startsWith(prefix)) delete buckets[key];
19671
19674
  }
19672
19675
  }
19673
19676
  const groupQueued =
@@ -19676,12 +19679,12 @@ function clearGrokHourlyBuckets(hourlyState) {
19676
19679
  : null;
19677
19680
  if (groupQueued) {
19678
19681
  for (const key of Object.keys(groupQueued)) {
19679
- if (key.startsWith("grok|")) delete groupQueued[key];
19682
+ if (key.startsWith(prefix)) delete groupQueued[key];
19680
19683
  }
19681
19684
  }
19682
19685
  }
19683
19686
 
19684
- async function retractStaleGrokQueueRows(queuePath, keepKeys) {
19687
+ async function retractStaleSourceQueueRows(queuePath, source, keepKeys) {
19685
19688
  if (!queuePath) return 0;
19686
19689
  let raw = "";
19687
19690
  try {
@@ -19691,7 +19694,7 @@ async function retractStaleGrokQueueRows(queuePath, keepKeys) {
19691
19694
  throw error;
19692
19695
  }
19693
19696
 
19694
- const latestGrok = new Map();
19697
+ const latestRows = new Map();
19695
19698
  for (const line of raw.split("\n")) {
19696
19699
  if (!line.trim()) continue;
19697
19700
  let row;
@@ -19700,21 +19703,21 @@ async function retractStaleGrokQueueRows(queuePath, keepKeys) {
19700
19703
  } catch {
19701
19704
  continue;
19702
19705
  }
19703
- if ((row?.source || "") !== "grok") continue;
19706
+ if ((row?.source || "") !== source) continue;
19704
19707
  const model = normalizeModelInput(row.model) || DEFAULT_MODEL;
19705
19708
  const hourStart = typeof row.hour_start === "string" ? row.hour_start : null;
19706
19709
  if (!hourStart) continue;
19707
- latestGrok.set(bucketKey("grok", model, hourStart), { model, hour_start: hourStart, row });
19710
+ latestRows.set(bucketKey(source, model, hourStart), { model, hour_start: hourStart, row });
19708
19711
  }
19709
19712
 
19710
19713
  const zero = initTotals();
19711
19714
  const lines = [];
19712
- for (const [key, entry] of latestGrok.entries()) {
19715
+ for (const [key, entry] of latestRows.entries()) {
19713
19716
  if (keepKeys.has(key)) continue;
19714
19717
  if (totalsKey(entry.row) === totalsKey(zero)) continue;
19715
19718
  lines.push(
19716
19719
  JSON.stringify({
19717
- source: "grok",
19720
+ source,
19718
19721
  model: entry.model,
19719
19722
  hour_start: entry.hour_start,
19720
19723
  ...zero,
@@ -19814,7 +19817,7 @@ async function parseGrokBuildIncremental({
19814
19817
  legacySeen: true,
19815
19818
  };
19816
19819
  }
19817
- clearGrokHourlyBuckets(hourlyState);
19820
+ clearSourceHourlyBuckets(hourlyState, "grok");
19818
19821
  } else {
19819
19822
  sessionSnapshots = normalizeGrokSessionSnapshots(grokState);
19820
19823
  }
@@ -20195,7 +20198,7 @@ async function parseGrokBuildIncremental({
20195
20198
  if (!key.startsWith("grok|") || !bucket?.totals) continue;
20196
20199
  keepKeys.add(key);
20197
20200
  }
20198
- const retracted = await retractStaleGrokQueueRows(queuePath, keepKeys);
20201
+ const retracted = await retractStaleSourceQueueRows(queuePath, "grok", keepKeys);
20199
20202
  bucketsQueued += retracted;
20200
20203
  }
20201
20204
 
@@ -21687,13 +21690,188 @@ function isCjkCodePoint(code) {
21687
21690
  // ── Trae SOLO (ByteDance AI IDE) ─────────────────────────────────────────────
21688
21691
  // https://www.trae.ai
21689
21692
  //
21690
- // Trae SOLO is scoped to detection (init) + entitlement display (status):
21691
- // - init: resolveTraeStoragePath() proves an install exists.
21692
- // - status: readTraeEntitlementFromStorage() serves the plan/limits snapshot.
21693
- // Trae SOLO does NOT expose per-request token usage in a readable local format
21694
- // (session transcripts are SQLCipher-encrypted; memory summaries carry no
21695
- // token counts), so the token-count-only queue is intentionally never written
21696
- // for this provider — the cloud hourly table stays clean.
21693
+ // International TRAE usage comes from local encrypted chat_turn metadata.
21694
+ //
21695
+ // The ledger keeps one compact tuple per turn, keyed by a 128-bit digest, so a
21696
+ // correction can retract exactly what the turn contributed even after TRAE
21697
+ // deletes its history. total_tokens is the five token columns plus any input
21698
+ // without a cache split, which is counted but unpriced (normalizeTraeUsage).
21699
+ const TRAE_CURSOR_VERSION = 2;
21700
+ const HALF_HOUR_MS = 30 * 60 * 1000;
21701
+ const [T_SESSION, T_MODEL, T_BUCKET, T_MODIFIED, T_STORE, T_INPUT, T_CACHED, T_WRITTEN,
21702
+ T_OUTPUT, T_REASONING, T_CONVERSATIONS, T_ESTIMATED, T_UNPRICED] = Array.from({ length: 13 }, (_, i) => i);
21703
+
21704
+ function packTraeTurn({ session, model, bucketStart, totals, modifiedAt, store }) {
21705
+ return [session, model, Date.parse(bucketStart) / HALF_HOUR_MS, modifiedAt, store,
21706
+ totals.input_tokens, totals.cached_input_tokens, totals.cache_creation_input_tokens,
21707
+ totals.output_tokens, totals.reasoning_output_tokens, totals.conversation_count,
21708
+ totals.usage_precision === "estimated" ? 1 : 0, totals.unpriced_input_tokens || 0];
21709
+ }
21710
+
21711
+ function unpackTraeTotals(turn) {
21712
+ const totals = {
21713
+ input_tokens: turn[T_INPUT],
21714
+ cached_input_tokens: turn[T_CACHED],
21715
+ cache_creation_input_tokens: turn[T_WRITTEN],
21716
+ output_tokens: turn[T_OUTPUT],
21717
+ reasoning_output_tokens: turn[T_REASONING],
21718
+ total_tokens: turn[T_INPUT] + turn[T_CACHED] + turn[T_WRITTEN] + turn[T_OUTPUT] + turn[T_REASONING]
21719
+ + turn[T_UNPRICED],
21720
+ conversation_count: turn[T_CONVERSATIONS],
21721
+ };
21722
+ if (turn[T_ESTIMATED]) totals.usage_precision = "estimated";
21723
+ return totals;
21724
+ }
21725
+
21726
+ function traeTurnBucketStart(turn) {
21727
+ return new Date(turn[T_BUCKET] * HALF_HOUR_MS).toISOString();
21728
+ }
21729
+
21730
+ async function parseTraeIncremental({
21731
+ dbPaths,
21732
+ cursors,
21733
+ queuePath,
21734
+ env = process.env,
21735
+ onProgress,
21736
+ readUsageRows = readTraeUsageRows,
21737
+ } = {}) {
21738
+ const paths = dbPaths || resolveTraeDbPaths(env);
21739
+ const hourlyState = normalizeHourlyState(cursors.hourly);
21740
+ // Queue writes and reads may fail. Publish cursor changes only after the
21741
+ // append succeeds, including copies of the mutable bucket totals.
21742
+ hourlyState.buckets = Object.fromEntries(Object.entries(hourlyState.buckets).map(([key, bucket]) => [
21743
+ key, key.startsWith("trae|") ? { ...bucket, totals: { ...bucket.totals } } : bucket,
21744
+ ]));
21745
+ hourlyState.groupQueued = { ...hourlyState.groupQueued };
21746
+ // A ledger with another TRAE_CURSOR_VERSION cannot retract what it
21747
+ // contributed, so rebuild the TRAE buckets from the stores instead of
21748
+ // adding every turn on top of them.
21749
+ const prior = cursors.trae?.version === TRAE_CURSOR_VERSION ? cursors.trae : null;
21750
+ const rebuilding = !prior && Boolean(cursors.trae);
21751
+ if (rebuilding) clearSourceHourlyBuckets(hourlyState, "trae");
21752
+ const turns = { ...prior?.turns };
21753
+ const databases = { ...prior?.databases };
21754
+ const stores = [...(prior?.stores || [])];
21755
+ const touchedBuckets = new Set();
21756
+ const conversations = new Set(Object.values(turns)
21757
+ .filter((turn) => turn[T_CONVERSATIONS] > 0).map((turn) => turn[T_SESSION]));
21758
+ // 128-bit digests: identifiers are hashed before they are stored.
21759
+ const digest = (value) => crypto.createHash("sha256").update(value).digest().subarray(0, 16).toString("base64url");
21760
+ let recordsProcessed = 0;
21761
+ let eventsAggregated = 0;
21762
+ let recordsSkipped = 0;
21763
+ let estimatedRecords = 0;
21764
+ let unpricedRecords = 0;
21765
+ const errors = [];
21766
+ for (const dbPath of [...new Set(paths)]) {
21767
+ const databaseKey = digest(path.resolve(dbPath));
21768
+ let store = stores.indexOf(databaseKey);
21769
+ if (store < 0) store = stores.push(databaseKey) - 1;
21770
+ let rows;
21771
+ let fingerprint;
21772
+ let finalFingerprint;
21773
+ try {
21774
+ // existsSync also returns false for permission errors. Let stat/read
21775
+ // report inaccessible stores instead of treating them as absent.
21776
+ fssync.statSync(dbPath);
21777
+ fingerprint = devinSqliteFingerprint(dbPath);
21778
+ if (sameSqliteFingerprint(fingerprint, databases[databaseKey])) continue;
21779
+ rows = await readUsageRows(dbPath, { env });
21780
+ finalFingerprint = devinSqliteFingerprint(dbPath);
21781
+ } catch (err) {
21782
+ // One install can be locked, corrupt, or use a different key while
21783
+ // another remains readable. Its prior fingerprint stays retryable;
21784
+ // successful stores still publish together after the queue append.
21785
+ errors.push({ database: dbPath, message: err?.message || String(err) });
21786
+ continue;
21787
+ }
21788
+ for (const row of rows) {
21789
+ recordsProcessed += 1;
21790
+ const totals = normalizeTraeUsage(row.usage, { model: row.model });
21791
+ const timestamp = traeTimestamp(row.created_at);
21792
+ const bucketStart = timestamp && toUtcHalfHourStart(timestamp);
21793
+ const rowId = row.turn_id || row.id;
21794
+ if (!totals || !bucketStart || rowId == null || rowId === "") {
21795
+ recordsSkipped += 1;
21796
+ continue;
21797
+ }
21798
+ const session = digest(String(row.session_id || `${databaseKey}:${rowId}`));
21799
+ // Stable turn ids deduplicate copies across the two international app
21800
+ // stores. Numeric SQLite ids are only unique within their database.
21801
+ const identity = digest(JSON.stringify(row.turn_id
21802
+ ? [session, row.turn_id] : [databaseKey, rowId]));
21803
+ const previous = turns[identity];
21804
+ const modifiedAt = Date.parse(traeTimestamp(row.updated_at) || timestamp);
21805
+ // A copied turn in another install can lag behind the original. Never
21806
+ // let an unrelated rescan of that stale store retract newer usage.
21807
+ // Without a newer timestamp, conflicting copies retain their owner.
21808
+ if (previous && previous[T_STORE] !== store && modifiedAt <= previous[T_MODIFIED]) continue;
21809
+ if (!previous && totals.total_tokens === 0) continue;
21810
+ if (totals.usage_precision === "estimated") estimatedRecords += 1;
21811
+ if (totals.unpriced_input_tokens > 0) unpricedRecords += 1;
21812
+ totals.conversation_count = previous
21813
+ ? previous[T_CONVERSATIONS]
21814
+ : conversations.has(session) ? 0 : 1;
21815
+ conversations.add(session);
21816
+ const model = normalizeTraeModel(row.model);
21817
+ const next = packTraeTurn({ session, model, bucketStart, totals, modifiedAt, store });
21818
+ if (previous && next.every((value, i) => i === T_MODIFIED || i === T_STORE || value === previous[i])) {
21819
+ turns[identity] = next;
21820
+ continue;
21821
+ }
21822
+ if (previous) {
21823
+ const previousBucket = traeTurnBucketStart(previous);
21824
+ const old = getHourlyBucket(hourlyState, "trae", previous[T_MODEL], previousBucket);
21825
+ subtractTotals(old.totals, unpackTraeTotals(previous));
21826
+ touchedBuckets.add(bucketKey("trae", previous[T_MODEL], previousBucket));
21827
+ }
21828
+ const bucket = getHourlyBucket(hourlyState, "trae", model, bucketStart);
21829
+ addTotals(bucket.totals, totals);
21830
+ touchedBuckets.add(bucketKey("trae", model, bucketStart));
21831
+ turns[identity] = next;
21832
+ eventsAggregated += 1;
21833
+ }
21834
+ // A read that races with a writer must be retried on the next sync.
21835
+ databases[databaseKey] = sameSqliteFingerprint(fingerprint, finalFingerprint)
21836
+ ? fingerprint : null;
21837
+ if (typeof onProgress === "function") {
21838
+ onProgress({ recordsProcessed, eventsAggregated, bucketsQueued: touchedBuckets.size });
21839
+ }
21840
+ }
21841
+ // Rebuild precision from the retained contributions, including unchanged
21842
+ // stores. A correction can replace the last estimated turn with reported
21843
+ // usage without changing numeric totals, or move it to another bucket.
21844
+ const bucketPrecisions = new Map();
21845
+ for (const turn of Object.values(turns)) {
21846
+ const key = bucketKey("trae", turn[T_MODEL], traeTurnBucketStart(turn));
21847
+ if (!touchedBuckets.has(key) || unpackTraeTotals(turn).total_tokens === 0) continue;
21848
+ bucketPrecisions.set(key, mergeGrokUsagePrecision(
21849
+ bucketPrecisions.get(key), turn[T_ESTIMATED] ? "estimated" : "reported",
21850
+ ));
21851
+ }
21852
+ for (const key of touchedBuckets) {
21853
+ const precision = bucketPrecisions.get(key);
21854
+ hourlyState.buckets[key].usage_precision = precision === "reported" ? null : precision || null;
21855
+ }
21856
+ // A rebuild is all or nothing. Buckets rebuilt from a subset of the stores
21857
+ // would replace shared buckets with partial totals, and each retry would
21858
+ // append every row again. Keep the old hourly state and ledger instead.
21859
+ if (rebuilding && errors.length) {
21860
+ return { recordsProcessed, eventsAggregated, bucketsQueued: 0, recordsSkipped, estimatedRecords, unpricedRecords, errors };
21861
+ }
21862
+ await ensureDir(path.dirname(queuePath));
21863
+ let bucketsQueued = await enqueueTouchedBuckets({ queuePath, hourlyState, touchedBuckets });
21864
+ cursors.hourly = hourlyState;
21865
+ if (rebuilding) {
21866
+ // The old ledger may have filed a turn under another bucket key (model or
21867
+ // half-hour); retract those rows so the dashboard does not count it twice.
21868
+ const keepKeys = new Set(Object.keys(hourlyState.buckets).filter((key) => key.startsWith("trae|")));
21869
+ bucketsQueued += await retractStaleSourceQueueRows(queuePath, "trae", keepKeys);
21870
+ }
21871
+ cursors.trae = { version: TRAE_CURSOR_VERSION, stores, databases, turns };
21872
+ return { recordsProcessed, eventsAggregated, bucketsQueued, recordsSkipped, estimatedRecords, unpricedRecords, errors };
21873
+ }
21874
+
21697
21875
  // Ordered candidate app-dir names. The CN IDE build installs as "Trae CN" on
21698
21876
  // Windows; the international build installs as "TRAE SOLO". Both expose the same
21699
21877
  // iCubeServerData entitlement key, so either is a valid Trae IDE install.
@@ -24192,6 +24370,7 @@ async function parseCommandCodeIncremental({
24192
24370
  }
24193
24371
 
24194
24372
  module.exports = {
24373
+ parseTraeIncremental,
24195
24374
  listRolloutFiles,
24196
24375
  listRolloutFilesDeep,
24197
24376
  codexSessionIdFromPath,
@@ -0,0 +1,277 @@
1
+ "use strict";
2
+
3
+ // Persistent extra scan roots for Codex and Claude Code (#657).
4
+ //
5
+ // Agent harnesses run Codex / Claude Code with a private home
6
+ // (CODEX_HOME=..., CLAUDE_CONFIG_DIR=...). Those sessions were invisible to
7
+ // TokenTracker unless the sync happened to be spawned by a process that
8
+ // inherited the same environment: a hook-fired sync saw them, the native app's
9
+ // background refresh did not. Worse, the two producers then disagreed about
10
+ // where a rollout's cursor lives (the cursor store classifies a rollout as a
11
+ // Codex path — per-day shard — only when it sits under one of ITS codexRoots,
12
+ // everything else goes to core), never saw each other's cursor, and re-parsed
13
+ // the same file from byte 0 on every alternation (#639: one 30-minute bucket
14
+ // re-emitted 138 times, 59B tokens for a 1.2B day).
15
+ //
16
+ // So roots come from ONE persistent list that every producer reads, and the
17
+ // cursor store's codexRoots are derived from that same list.
18
+ //
19
+ // Per provider, in order (first occurrence wins; later duplicates by realpath
20
+ // are dropped):
21
+ // 1. the roots the caller already scans (native home, WSL install)
22
+ // 2. the spawning process's implicit root — CODEX_HOME / CLAUDE_CONFIG_DIR
23
+ // 3. config.scanRoots.<provider>[] from ~/.tokentracker/tracker/config.json
24
+ //
25
+ // Config shape:
26
+ // { "scanRoots": { "codex": ["~/.local/share/agent/codex"],
27
+ // "claude": ["~/.local/share/agent/claude", "~/.claude-native"] } }
28
+
29
+ const fs = require("node:fs");
30
+ const os = require("node:os");
31
+ const path = require("node:path");
32
+
33
+ const { readJson } = require("./fs");
34
+ const { resolveTrackerPaths } = require("./tracker-paths");
35
+
36
+ const PROVIDERS = Object.freeze({
37
+ codex: Object.freeze({ envVar: "CODEX_HOME" }),
38
+ claude: Object.freeze({ envVar: "CLAUDE_CONFIG_DIR" }),
39
+ });
40
+ const PROVIDER_NAMES = Object.freeze(Object.keys(PROVIDERS));
41
+
42
+ // Turn a configured root into a stable absolute path. `~` expands against
43
+ // `home`, and a RELATIVE entry is resolved against `home` too — never against
44
+ // process.cwd(), which differs between a hook-fired sync, the CLI and the
45
+ // desktop app and would make producers scan different directories.
46
+ function expandHome(value, home) {
47
+ if (typeof value !== "string") return null;
48
+ const trimmed = value.trim();
49
+ if (!trimmed) return null;
50
+ if (trimmed === "~") return path.resolve(home);
51
+ if (trimmed.startsWith("~/") || trimmed.startsWith("~\\")) {
52
+ return path.resolve(home, trimmed.slice(2));
53
+ }
54
+ return path.isAbsolute(trimmed) ? path.resolve(trimmed) : path.resolve(home, trimmed);
55
+ }
56
+
57
+ // The spawning process's implicit root — CODEX_HOME / CLAUDE_CONFIG_DIR —
58
+ // normalized ONCE, the same way configured entries are: `~` and relative
59
+ // values are anchored to `home`, never to process.cwd(). Every producer
60
+ // (sync discovery, the cursor store's codexRoots, status, diagnostics, the
61
+ // session browser) must use this value, or a relative CODEX_HOME such as
62
+ // `profiles/codex` is scanned under one directory and cursor-classified
63
+ // under another, and the same rollout replays. Returns null when unset.
64
+ function resolveEnvRoot(provider, { env = process.env, home = os.homedir() } = {}) {
65
+ const spec = PROVIDERS[provider];
66
+ if (!spec) return null;
67
+ const raw = env && typeof env[spec.envVar] === "string" ? env[spec.envVar] : "";
68
+ return expandHome(raw, home);
69
+ }
70
+
71
+ // Accepts { codex: string | string[], claude: string | string[] }. Anything
72
+ // else — wrong type, empty strings, unknown providers — is ignored rather than
73
+ // failing the sync: a malformed config must not stop collection.
74
+ function normalizeScanRootsConfig(raw) {
75
+ const out = {};
76
+ for (const provider of PROVIDER_NAMES) out[provider] = [];
77
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return out;
78
+ for (const provider of PROVIDER_NAMES) {
79
+ const value = raw[provider];
80
+ const list = Array.isArray(value) ? value : (typeof value === "string" ? [value] : []);
81
+ for (const entry of list) {
82
+ if (typeof entry !== "string" || !entry.trim()) continue;
83
+ out[provider].push(entry.trim());
84
+ }
85
+ }
86
+ return out;
87
+ }
88
+
89
+ // { exists, error }: `exists` is true only for a directory that can be
90
+ // LISTED, not merely stat'ed — a stat-able directory whose listing fails
91
+ // (mode 000, ACL) would otherwise be admitted to the scan, yield no files,
92
+ // read as "present" in status/doctor, and slip past the deferred-repair
93
+ // guard in sync. ENOENT and ENOTDIR (or a regular file in a directory's
94
+ // place) mean the root is absent (error null); any other failure (EACCES,
95
+ // ELOOP, EIO, ...) is reported as `error` so callers can say "unreadable"
96
+ // instead of "missing".
97
+ function scanRootDirState(target, deps = {}) {
98
+ const statSync = deps.statSync || fs.statSync;
99
+ const opendirSync = deps.opendirSync || fs.opendirSync;
100
+ try {
101
+ if (!statSync(target).isDirectory()) return { exists: false, error: null };
102
+ // Probe listability without reading entries; large roots stay cheap.
103
+ opendirSync(target).closeSync();
104
+ return { exists: true, error: null };
105
+ } catch (e) {
106
+ const code = e && typeof e.code === "string" ? e.code : "EUNKNOWN";
107
+ if (code === "ENOENT" || code === "ENOTDIR") return { exists: false, error: null };
108
+ return { exists: false, error: code };
109
+ }
110
+ }
111
+
112
+ function isDirectorySync(target, deps) {
113
+ return scanRootDirState(target, deps).exists;
114
+ }
115
+
116
+ function realpathOrSelf(target, deps) {
117
+ const realpathSync = deps.realpathSync || fs.realpathSync;
118
+ try {
119
+ return realpathSync(target);
120
+ } catch {
121
+ return target;
122
+ }
123
+ }
124
+
125
+ function identityKey(target, deps) {
126
+ const resolved = path.resolve(target);
127
+ const real = realpathOrSelf(resolved, deps);
128
+ return real.replace(/\\/g, "/").replace(/\/+$/, "");
129
+ }
130
+
131
+ // Drop later entries that resolve (realpath) to a directory already listed.
132
+ // Used at the projects/ level too: two Claude profiles may symlink one
133
+ // projects/ dir, and reading it under both spellings would double-parse every
134
+ // file, leaving correctness to the bounded claudeHashes layer.
135
+ function dedupeDirsByRealpath(dirs, deps = {}) {
136
+ const seen = new Set();
137
+ const out = [];
138
+ for (const dir of Array.isArray(dirs) ? dirs : []) {
139
+ if (typeof dir !== "string" || !dir) continue;
140
+ const key = identityKey(dir, deps);
141
+ if (seen.has(key)) continue;
142
+ seen.add(key);
143
+ out.push(dir);
144
+ }
145
+ return out;
146
+ }
147
+
148
+ // Resolve the full, ordered, deduplicated root list per provider.
149
+ //
150
+ // base: { codex: string[], claude: string[] } — roots the caller scans
151
+ // anyway (native home, WSL install); they keep their position and
152
+ // are labelled origin "native".
153
+ // env: CODEX_HOME / CLAUDE_CONFIG_DIR become origin "env" unless already
154
+ // covered by base.
155
+ // config.scanRoots.<provider>[] become origin "config".
156
+ //
157
+ // Every entry: { path, realPath, exists, error, origin }. `exists` is a
158
+ // readable-directory check and `error` carries a stat error code other than
159
+ // absence (see scanRootDirState); non-existent or unreadable configured roots
160
+ // are kept in the list (so cursor-path classification stays stable while a
161
+ // volume is unmounted) and callers that walk directories skip them via
162
+ // `exists`.
163
+ function resolveScanRoots({
164
+ home = os.homedir(),
165
+ env = process.env,
166
+ config = null,
167
+ base = {},
168
+ deps = {},
169
+ } = {}) {
170
+ const configured = normalizeScanRootsConfig(config && typeof config === "object" ? config.scanRoots : null);
171
+ const out = {};
172
+ for (const provider of PROVIDER_NAMES) {
173
+ const candidates = [];
174
+ for (const root of Array.isArray(base[provider]) ? base[provider] : []) {
175
+ if (typeof root === "string" && root.trim()) candidates.push({ raw: root, origin: "native" });
176
+ }
177
+ const envRoot = resolveEnvRoot(provider, { env, home });
178
+ if (envRoot) candidates.push({ raw: envRoot, origin: "env" });
179
+ for (const root of configured[provider]) candidates.push({ raw: root, origin: "config" });
180
+
181
+ const seen = new Set();
182
+ const entries = [];
183
+ for (const candidate of candidates) {
184
+ const expanded = expandHome(candidate.raw, home);
185
+ if (!expanded) continue;
186
+ const resolved = expanded;
187
+ const key = identityKey(resolved, deps);
188
+ if (seen.has(key)) continue;
189
+ seen.add(key);
190
+ const state = scanRootDirState(resolved, deps);
191
+ entries.push({
192
+ path: resolved,
193
+ realPath: realpathOrSelf(resolved, deps),
194
+ exists: state.exists,
195
+ error: state.error,
196
+ origin: candidate.origin,
197
+ });
198
+ }
199
+ out[provider] = entries;
200
+ }
201
+ return out;
202
+ }
203
+
204
+ // Append `extraDirs` to `baseDirs`, dropping any extra that resolves (realpath)
205
+ // to a directory already present. Base entries are never collapsed against
206
+ // each other: the native + WSL pair is listed on purpose (see sync.js) and
207
+ // their dedup is handled downstream by file-identity hashes.
208
+ function appendUniqueDirs(baseDirs, extraDirs, deps = {}) {
209
+ const out = [];
210
+ const seen = new Set();
211
+ for (const dir of Array.isArray(baseDirs) ? baseDirs : []) {
212
+ if (typeof dir !== "string" || !dir) continue;
213
+ seen.add(identityKey(dir, deps));
214
+ out.push(dir);
215
+ }
216
+ for (const dir of Array.isArray(extraDirs) ? extraDirs : []) {
217
+ if (typeof dir !== "string" || !dir) continue;
218
+ const key = identityKey(dir, deps);
219
+ if (seen.has(key)) continue;
220
+ seen.add(key);
221
+ out.push(dir);
222
+ }
223
+ return out;
224
+ }
225
+
226
+ // Roots beyond what the caller already scans: the "env" and "config" entries
227
+ // that exist on disk. Paths only, in list order.
228
+ function extraScanRootPaths(entries) {
229
+ return (Array.isArray(entries) ? entries : [])
230
+ .filter((entry) => entry && entry.origin !== "native" && entry.exists)
231
+ .map((entry) => entry.path);
232
+ }
233
+
234
+ function hasAnyScanChild(root, children, deps = {}) {
235
+ for (const child of Array.isArray(children) ? children : [children]) {
236
+ if (isDirectorySync(path.join(root, child), deps)) return true;
237
+ }
238
+ return false;
239
+ }
240
+
241
+ // Human label for status / doctor output.
242
+ function describeScanRootOrigin(entry, provider) {
243
+ if (!entry || entry.origin === "native") return "native";
244
+ if (entry.origin === "env") return PROVIDERS[provider]?.envVar || "env";
245
+ return "scanRoots";
246
+ }
247
+
248
+ // "" for a readable root, " (missing)" for an absent one, " (unreadable: CODE)"
249
+ // for a stat failure other than absence.
250
+ function describeScanRootState(entry) {
251
+ if (!entry || entry.exists) return "";
252
+ return entry.error ? ` (unreadable: ${entry.error})` : " (missing)";
253
+ }
254
+
255
+ // config.scanRoots as persisted in ~/.tokentracker/tracker/config.json for
256
+ // callers that do not already hold the config (the session browser).
257
+ async function loadScanRootsConfig({ home = os.homedir() } = {}) {
258
+ const { trackerDir } = await resolveTrackerPaths({ home });
259
+ const config = await readJson(path.join(trackerDir, "config.json")).catch(() => null);
260
+ return normalizeScanRootsConfig(config && typeof config === "object" ? config.scanRoots : null);
261
+ }
262
+
263
+ module.exports = {
264
+ PROVIDER_NAMES,
265
+ appendUniqueDirs,
266
+ dedupeDirsByRealpath,
267
+ describeScanRootOrigin,
268
+ describeScanRootState,
269
+ expandHome,
270
+ extraScanRootPaths,
271
+ hasAnyScanChild,
272
+ loadScanRootsConfig,
273
+ normalizeScanRootsConfig,
274
+ resolveEnvRoot,
275
+ resolveScanRoots,
276
+ scanRootDirState,
277
+ };