tokentracker-cli 1.1.2 → 1.1.4

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 (117) hide show
  1. package/README.de.md +6 -4
  2. package/README.ja.md +6 -4
  3. package/README.ko.md +6 -4
  4. package/README.md +7 -4
  5. package/README.zh-CN.md +6 -4
  6. package/dashboard/dist/assets/{AchievementBadge-CX7g238N.js → AchievementBadge-BGA-HT_B.js} +1 -1
  7. package/dashboard/dist/assets/{AchievementsPage-BbkEymra.js → AchievementsPage-CtRh5AjH.js} +2 -2
  8. package/dashboard/dist/assets/{AchievementsSection-CmhoerE7.js → AchievementsSection-BarRcKaN.js} +1 -1
  9. package/dashboard/dist/assets/{BadgeDetailModal-996i13Vf.js → BadgeDetailModal-Bg0BDAN2.js} +1 -1
  10. package/dashboard/dist/assets/{BotAnimated-B22CfAlF.js → BotAnimated-By9J3-dn.js} +1 -1
  11. package/dashboard/dist/assets/{Card-DFd9hCFB.js → Card-CIoJYi2j.js} +1 -1
  12. package/dashboard/dist/assets/{ClawdAnimated-DXKN28M2.js → ClawdAnimated-Dkvc2xe8.js} +2 -2
  13. package/dashboard/dist/assets/{CommandPalette-DVRs34TV.js → CommandPalette-BssDtHRl.js} +1 -1
  14. package/dashboard/dist/assets/{CommunityStatsModal-BExjH9E8.js → CommunityStatsModal-CQrgLlDb.js} +1 -1
  15. package/dashboard/dist/assets/{ConfirmModal-KJhOrJ98.js → ConfirmModal-C3hBPgYc.js} +1 -1
  16. package/dashboard/dist/assets/{Controls-xnIEnOqd.js → Controls-BSW6a1HS.js} +1 -1
  17. package/dashboard/dist/assets/DashboardPage-Dhv7fejy.js +1 -0
  18. package/dashboard/dist/assets/{DevicePage-Bo8e-KVq.js → DevicePage-D5eZn0wo.js} +1 -1
  19. package/dashboard/dist/assets/{DialogClose-gBd-BmR7.js → DialogClose-BltQpaJo.js} +1 -1
  20. package/dashboard/dist/assets/DialogDescription-Cof52Icj.js +1 -0
  21. package/dashboard/dist/assets/{DialogTitle-B0UbsqD7.js → DialogTitle-CVddcRPZ.js} +1 -1
  22. package/dashboard/dist/assets/{FadeIn-Dv2DtOPn.js → FadeIn-goPv73MT.js} +1 -1
  23. package/dashboard/dist/assets/{HeaderGithubStar-1kEsgGdK.js → HeaderGithubStar-BDBrrkzt.js} +1 -1
  24. package/dashboard/dist/assets/{HoverTooltip-DQlpkw2L.js → HoverTooltip-qyMezXOs.js} +1 -1
  25. package/dashboard/dist/assets/{IpCheckPage-CFlkmSlA.js → IpCheckPage-Ces8JIPb.js} +1 -1
  26. package/dashboard/dist/assets/{LandingPage-DNs6mSij.js → LandingPage-B2TmLGno.js} +2 -2
  27. package/dashboard/dist/assets/{LeaderboardAvatar-DSltCiRr.js → LeaderboardAvatar-CVVRoNua.js} +1 -1
  28. package/dashboard/dist/assets/{LeaderboardPage-CkjUMA-k.js → LeaderboardPage-xtt-bOY8.js} +2 -2
  29. package/dashboard/dist/assets/{LeaderboardProfileModal-DJjaD7Kz.js → LeaderboardProfileModal-BM4d_I_r.js} +3 -3
  30. package/dashboard/dist/assets/{LeaderboardProfilePage-Bue7tJoi.js → LeaderboardProfilePage-BWqhFLQX.js} +1 -1
  31. package/dashboard/dist/assets/{LimitsPage-Ctbvl8Q8.js → LimitsPage-BZL4Pcl8.js} +1 -1
  32. package/dashboard/dist/assets/{LocalOnlyNotice-LjNI8j4I.js → LocalOnlyNotice-D4gnOEJj.js} +1 -1
  33. package/dashboard/dist/assets/LoginCard-CB17FQ5w.js +1 -0
  34. package/dashboard/dist/assets/{LoginModal-CUmfCsHY.js → LoginModal-BI-O1rH4.js} +1 -1
  35. package/dashboard/dist/assets/{LoginPage-CjJb9qFM.js → LoginPage-DnGzsvJ4.js} +1 -1
  36. package/dashboard/dist/assets/{PetPage-BWKEnBEW.js → PetPage-NRnIKMaJ.js} +1 -1
  37. package/dashboard/dist/assets/{PopoverPopup-DeXj3kM3.js → PopoverPopup-DHoK6UJZ.js} +1 -1
  38. package/dashboard/dist/assets/{ProviderIcon-DL-oa-UM.js → ProviderIcon-BTkdx9o2.js} +1 -1
  39. package/dashboard/dist/assets/{ResetPasswordPage-CeeKMN8H.js → ResetPasswordPage-C6K0w318.js} +1 -1
  40. package/dashboard/dist/assets/{SegmentedControl-Bx5Z9tYf.js → SegmentedControl-gEoWioBE.js} +1 -1
  41. package/dashboard/dist/assets/{Select-DRbgbSPd.js → Select-CI-qtEYu.js} +1 -1
  42. package/dashboard/dist/assets/{SelectItemText-D792o2Et.js → SelectItemText-q-yhzzjw.js} +1 -1
  43. package/dashboard/dist/assets/{ServiceStatusPage-NjQ1oGqx.js → ServiceStatusPage-C4xD1CEe.js} +1 -1
  44. package/dashboard/dist/assets/{SessionsPage-Cr2uHP91.js → SessionsPage-CS0Yun2z.js} +1 -1
  45. package/dashboard/dist/assets/{SettingsPage-Av8pVbqT.js → SettingsPage-GVY_ktz4.js} +2 -2
  46. package/dashboard/dist/assets/{SkillsPage-feW4j329.js → SkillsPage-COPRL95O.js} +1 -1
  47. package/dashboard/dist/assets/{TokenGalaxy-DX36F36Q.js → TokenGalaxy-Czi0Ic48.js} +1 -1
  48. package/dashboard/dist/assets/{ToolbarRootContext-CzMlVMfI.js → ToolbarRootContext-BZkTiBJB.js} +1 -1
  49. package/dashboard/dist/assets/{TrendMonitor-BosWE34g.js → TrendMonitor-CnIsuxV-.js} +1 -1
  50. package/dashboard/dist/assets/{WidgetsPage-DhoypTuq.js → WidgetsPage-OdX_Fsul.js} +1 -1
  51. package/dashboard/dist/assets/{WrappedPage-DR6BoipR.js → WrappedPage-h9N63-F8.js} +1 -1
  52. package/dashboard/dist/assets/__vite-browser-external-BFNWaGsk.js +45 -0
  53. package/dashboard/dist/assets/{agent-logos-QSScjsfz.js → agent-logos-BDiPxrST.js} +1 -1
  54. package/dashboard/dist/assets/{arrow-up-right-BJQYadF-.js → arrow-up-right-DtFQUeJG.js} +1 -1
  55. package/dashboard/dist/assets/{check-CINIOTsF.js → check-C35onaNL.js} +1 -1
  56. package/dashboard/dist/assets/{composite-YV7-iFo_.js → composite-qxBzts0W.js} +1 -1
  57. package/dashboard/dist/assets/{copy-Bp_U7bAZ.js → copy-D_D71TLc.js} +1 -1
  58. package/dashboard/dist/assets/{download-CVOIkdjl.js → download-DMQI2hQe.js} +1 -1
  59. package/dashboard/dist/assets/{external-link-P6LDhEET.js → external-link-CemM4dWf.js} +1 -1
  60. package/dashboard/dist/assets/{flame-AbZCJ878.js → flame-CIvk0SP5.js} +1 -1
  61. package/dashboard/dist/assets/{format-tokens-M5bvANSq.js → format-tokens-D3JSvcFl.js} +1 -1
  62. package/dashboard/dist/assets/{icons-DpRn5Rxt.js → icons-Ccf5W0AL.js} +1 -1
  63. package/dashboard/dist/assets/{index-KQ5H65Ds.js → index-Brd2evyz.js} +1 -1
  64. package/dashboard/dist/assets/{index-CW2bybhs.js → index-C8Wri_HI.js} +1 -1
  65. package/dashboard/dist/assets/{index-L3O7cNuj.js → index-CFXmD_KX.js} +1 -1
  66. package/dashboard/dist/assets/{info-GEiJwgYk.js → info-CafrKEG8.js} +1 -1
  67. package/dashboard/dist/assets/{link-2-D8KpexwS.js → link-2-Ds-9Nzlp.js} +1 -1
  68. package/dashboard/dist/assets/{loader-circle-r0nEab80.js → loader-circle-DA4I55-V.js} +1 -1
  69. package/dashboard/dist/assets/{maximize-2-azGbxGbz.js → maximize-2-CjMPt5kU.js} +1 -1
  70. package/dashboard/dist/assets/{modifiers.esm-Cke4_92g.js → modifiers.esm-DIxTS09h.js} +1 -1
  71. package/dashboard/dist/assets/module-WSx8viXK.js +3 -0
  72. package/dashboard/dist/assets/{monitor-smartphone-BnHiLGNs.js → monitor-smartphone-IZpIOxn3.js} +1 -1
  73. package/dashboard/dist/assets/{plus-CJbY6Uuk.js → plus-vbLFNs70.js} +1 -1
  74. package/dashboard/dist/assets/{provider-display-B05KW8SE.js → provider-display-BAu_UIfV.js} +2 -2
  75. package/dashboard/dist/assets/{react-CingcaIm.js → react-Djb4VGKb.js} +1 -1
  76. package/dashboard/dist/assets/{search-CyffdJFU.js → search-Ct6LY9Ke.js} +1 -1
  77. package/dashboard/dist/assets/{skills-api-CdDPrjMM.js → skills-api-BnGI2gEp.js} +1 -1
  78. package/dashboard/dist/assets/{sortable.esm-B48YEoXA.js → sortable.esm-CjCGd6g5.js} +1 -1
  79. package/dashboard/dist/assets/{store-B0W0vLvA.js → store-CBxkOqRe.js} +1 -1
  80. package/dashboard/dist/assets/{terminal-CJ0D4hSx.js → terminal-Dlpdp5Az.js} +1 -1
  81. package/dashboard/dist/assets/{trash-DH-_Q3A5.js → trash-pxUSKv66.js} +1 -1
  82. package/dashboard/dist/assets/{use-community-stats-y6OaeOVs.js → use-community-stats-tHaixRIw.js} +1 -1
  83. package/dashboard/dist/assets/{use-latest-request-guard-DP7kE0Xa.js → use-latest-request-guard-Cs44WPfL.js} +1 -1
  84. package/dashboard/dist/assets/{use-limits-display-prefs-Bgcto2k1.js → use-limits-display-prefs-Dw56PXWy.js} +1 -1
  85. package/dashboard/dist/assets/{use-ordered-list-1L7o2taR.js → use-ordered-list-DpLx8oLV.js} +1 -1
  86. package/dashboard/dist/assets/use-reduced-motion-DrlJ9X6p.js +1 -0
  87. package/dashboard/dist/assets/{use-session-efficiency-pref-36sqzSZl.js → use-session-efficiency-pref-BN3FtBvr.js} +1 -1
  88. package/dashboard/dist/assets/{use-transform-DZgeA77H.js → use-transform-C4nTGgzb.js} +1 -1
  89. package/dashboard/dist/assets/{use-usage-limits-C5P1O9oT.js → use-usage-limits-Dmwdc1yo.js} +1 -1
  90. package/dashboard/dist/assets/{useCurrency-Dwv89bBn.js → useCurrency-DSc3A_Ih.js} +1 -1
  91. package/dashboard/dist/assets/{useTokenFormat-BGeVB81B.js → useTokenFormat-BHjcMgm8.js} +1 -1
  92. package/dashboard/dist/assets/{zap-D18Nt3M5.js → zap-BE8i_wu2.js} +1 -1
  93. package/dashboard/dist/brand-logos/trae.svg +1 -0
  94. package/dashboard/dist/index.html +15 -11
  95. package/dashboard/dist/ip-check.html +1 -1
  96. package/dashboard/dist/leaderboard.html +1 -1
  97. package/dashboard/dist/llms.txt +4 -2
  98. package/dashboard/dist/share.html +1 -1
  99. package/package.json +6 -4
  100. package/src/commands/init.js +20 -7
  101. package/src/commands/status.js +39 -9
  102. package/src/commands/sync.js +62 -1
  103. package/src/lib/pricing/curated-overrides.json +7 -0
  104. package/src/lib/pricing/matcher.js +5 -0
  105. package/src/lib/pricing/seed-snapshot.json +1 -1
  106. package/src/lib/rollout.js +845 -20
  107. package/src/lib/trae-db.js +127 -0
  108. package/src/lib/trae-snapshot.js +230 -0
  109. package/src/lib/trae-sqlite.js +143 -0
  110. package/src/lib/trae-usage.js +121 -0
  111. package/src/lib/wsl-probe.js +55 -12
  112. package/dashboard/dist/assets/DashboardPage-BzsiCwjX.js +0 -1
  113. package/dashboard/dist/assets/DialogDescription-CTEI9PvU.js +0 -1
  114. package/dashboard/dist/assets/LoginCard-U_3SnvtN.js +0 -1
  115. package/dashboard/dist/assets/__vite-browser-external-DYTQCEz4.js +0 -45
  116. package/dashboard/dist/assets/module-DtWfRKM4.js +0 -3
  117. package/dashboard/dist/assets/use-reduced-motion-BQxHT0gI.js +0 -1
@@ -3,6 +3,7 @@ const fssync = require("node:fs");
3
3
  const os = require("node:os");
4
4
  const path = require("node:path");
5
5
  const readline = require("node:readline");
6
+ const { isDeepStrictEqual } = require("node:util");
6
7
 
7
8
  const crypto = require("node:crypto");
8
9
  const { ensureDir, writeJson, chmod600IfPossible } = require("./fs");
@@ -46,6 +47,8 @@ const {
46
47
  buildDevinUsageEvents,
47
48
  } = require("./devin-usage");
48
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");
49
52
 
50
53
  const DEFAULT_SOURCE = "codex";
51
54
  const DEFAULT_MODEL = "unknown";
@@ -6886,8 +6889,7 @@ async function parseClaudeScienceIncremental({
6886
6889
 
6887
6890
  const bucketsQueued = await enqueueTouchedBuckets({ queuePath, hourlyState, touchedBuckets });
6888
6891
  const updatedAt = new Date().toISOString();
6889
- hourlyState.updatedAt = updatedAt;
6890
- state.updatedAt = updatedAt;
6892
+ if (bucketsQueued > 0) hourlyState.updatedAt = updatedAt;
6891
6893
  cursors.hourly = hourlyState;
6892
6894
  cursors.claudeScience = state;
6893
6895
  return { recordsProcessed, eventsAggregated, bucketsQueued };
@@ -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.
@@ -23554,7 +23732,645 @@ async function parseDshIncremental({ sessionFiles, cursors, queuePath, onProgres
23554
23732
  }
23555
23733
 
23556
23734
 
23735
+ // ── Command Code (`cmd`, commandcode.ai) — passive session-log reader (issue #630) ──
23736
+ //
23737
+ // Command Code keeps one JSONL transcript per conversation under
23738
+ // `~/.commandcode/projects/<cwd-slug>/<session-id>.jsonl`. The first line is a
23739
+ // session header carrying the launch `cwd`; every completed assistant turn
23740
+ // appends a record whose TOP-LEVEL `model` and `usage` are Command Code's own
23741
+ // accounting for that request:
23742
+ //
23743
+ // {"type":"message","id":"…","parentId":"…","timestamp":"…","effort":"…",
23744
+ // "model":"deepseek/deepseek-v4.1-flash",
23745
+ // "usage":{"inputTokens":…,"outputTokens":…,"cacheReadTokens":…,
23746
+ // "cacheWriteTokens":…,"costUsd":…},"message":{…}}
23747
+ //
23748
+ // Two accounting conventions are load-bearing:
23749
+ //
23750
+ // 1. AI SDK-normalized `inputTokens` ALREADY INCLUDES cache reads and writes.
23751
+ // `uncached = inputTokens - cacheReadTokens - cacheWriteTokens`; keeping
23752
+ // either cache category in input double counts it in `total_tokens`.
23753
+ // 2. `costUsd` is Command Code's display-rate estimate, not a billed amount.
23754
+ // Ignore it and emit the zero cost sentinel; local readers and cloud
23755
+ // endpoints estimate cost from the shared model price table.
23756
+ //
23757
+ // Transcripts are append-only in practice, but a resume/compaction REWRITES the
23758
+ // file, so byte offsets are the wrong cursor shape here. This reader rebuilds a
23759
+ // per-file snapshot and reconciles it against a durable observation ledger keyed
23760
+ // by `recordId|timestamp`. Forks and clones copy those fields into transcripts
23761
+ // with new session headers, so the header cannot be part of the key. Files whose
23762
+ // (size, mtime) pair is unchanged can reuse their owned ledger records. A
23763
+ // non-owning duplicate is re-read rather than storing a second full ledger.
23764
+ const COMMAND_CODE_SOURCE = "command-code";
23765
+ const COMMAND_CODE_STATE_VERSION = 3;
23766
+ const COMMAND_CODE_FILE_CACHE_VERSION = 3;
23767
+ const COMMAND_CODE_HEADER_MAX_BYTES = 65536;
23768
+ const COMMAND_CODE_HOME_DIR = ".commandcode";
23769
+ const COMMAND_CODE_PROJECTS_DIR = "projects";
23770
+
23771
+ function isCommandCodeSessionLogName(name) {
23772
+ return (
23773
+ typeof name === "string" &&
23774
+ name.endsWith(".jsonl") &&
23775
+ !name.includes(".prompts.") &&
23776
+ !name.endsWith(".checkpoints.jsonl")
23777
+ );
23778
+ }
23779
+
23780
+ // Precedence mirrors the other passive readers: an explicit TokenTracker
23781
+ // override first, then the CLI's own `~/.commandcode`.
23782
+ function resolveCommandCodeHome(env = process.env) {
23783
+ const explicit = env?.TOKENTRACKER_COMMANDCODE_HOME;
23784
+ if (typeof explicit === "string" && explicit.trim()) return path.resolve(explicit.trim());
23785
+ return path.join(os.homedir(), COMMAND_CODE_HOME_DIR);
23786
+ }
23787
+
23788
+ // Windows users commonly run the `cmd` CLI inside WSL while TokenTracker itself
23789
+ // runs natively. Respect the repository-wide WSL mode contract and keep explicit
23790
+ // home overrides authoritative: an override is a complete user choice, not one
23791
+ // half of an automatic native/WSL discovery pair.
23792
+ function resolveCommandCodeHomes(env = process.env, deps = {}) {
23793
+ const override = env?.TOKENTRACKER_COMMANDCODE_HOME;
23794
+ const overridden = typeof override === "string" && override.trim().length > 0;
23795
+ const nativeHome = deps.nativeHome || resolveCommandCodeHome(env);
23796
+ const platform = deps.platform || process.platform;
23797
+ if (overridden || platform !== "win32") return [nativeHome];
23798
+
23799
+ const probePath = deps.existsSync || ((candidate) => fssync.statSync(candidate).isDirectory());
23800
+ const existsSync = (candidate) => {
23801
+ try { return probePath(candidate); }
23802
+ catch (error) {
23803
+ if (isCommandCodePathMissing(error)) return false;
23804
+ throw error;
23805
+ }
23806
+ };
23807
+ // Native errors remain visible. WSL is optional and must not suppress native
23808
+ // observations when its executable, identity or UNC existence probes fail.
23809
+ const nativeValue = wsl.shouldProbeNative(env) && existsSync(nativeHome) ? nativeHome : null;
23810
+ const discoverWslHome = deps.discoverWslHome || wsl.discoverWslHome;
23811
+ let wslValue = null;
23812
+ if (wsl.shouldProbeWsl(env)) {
23813
+ try {
23814
+ wslValue = discoverWslHome(COMMAND_CODE_HOME_DIR, { ...deps, env, existsSync, strict: true });
23815
+ } catch (_error) { }
23816
+ }
23817
+ const resolved = wsl.resolveAllWin32Paths({
23818
+ nativeValue,
23819
+ wslValue,
23820
+ env,
23821
+ platform,
23822
+ });
23823
+ return [...new Set([resolved.native, resolved.wsl].filter(Boolean))];
23824
+ }
23825
+
23826
+ // Walk `<home>/projects/<cwd-slug>/` for `<session-id>.jsonl` transcripts. The
23827
+ // sibling checkpoint snapshots and `.prompts.` sidecars are not transcripts
23828
+ // and must not inflate discovery or status session counts.
23829
+ async function resolveCommandCodeSessionFiles(env = process.env, deps = {}) {
23830
+ const out = new Set();
23831
+ const homes = resolveCommandCodeHomes(env, deps);
23832
+ const nativeHome = deps.nativeHome || resolveCommandCodeHome(env);
23833
+ const overridden = typeof env?.TOKENTRACKER_COMMANDCODE_HOME === "string" && env.TOKENTRACKER_COMMANDCODE_HOME.trim();
23834
+ for (const home of homes) {
23835
+ const rootFiles = [];
23836
+ try {
23837
+ const projectsRoot = path.join(home, COMMAND_CODE_PROJECTS_DIR);
23838
+ for (const project of await readCommandCodeDirectory(projectsRoot)) {
23839
+ if (!project.isDirectory()) continue;
23840
+ const projectDir = path.join(projectsRoot, project.name);
23841
+ for (const entry of await readCommandCodeDirectory(projectDir)) {
23842
+ if (!entry.isFile() || !isCommandCodeSessionLogName(entry.name)) continue;
23843
+ rootFiles.push(path.join(projectDir, entry.name));
23844
+ }
23845
+ }
23846
+ } catch (error) {
23847
+ const optionalWsl = !overridden && (deps.platform || process.platform) === "win32" && home !== nativeHome;
23848
+ if (!optionalWsl) throw error;
23849
+ // A selected wsl-first root may become inaccessible after discovery.
23850
+ // Fall back to native without changing shared WSL callers' mode rules.
23851
+ if (wsl.shouldProbeNative(env) && !homes.includes(nativeHome)) homes.push(nativeHome);
23852
+ continue;
23853
+ }
23854
+ for (const file of rootFiles) out.add(file);
23855
+ }
23856
+ return [...out].sort((a, b) => a.localeCompare(b));
23857
+ }
23858
+
23859
+ function isCommandCodePathMissing(error) {
23860
+ return error?.code === "ENOENT" || error?.code === "ENOTDIR" || error?.code === "EISDIR";
23861
+ }
23862
+
23863
+ async function readCommandCodeDirectory(directory) {
23864
+ try {
23865
+ return await fs.readdir(directory, { withFileTypes: true });
23866
+ } catch (error) {
23867
+ if (isCommandCodePathMissing(error)) return [];
23868
+ // Native errors stay visible; optional WSL errors are isolated by the caller.
23869
+ throw error;
23870
+ }
23871
+ }
23872
+
23873
+ // Row models are provider-qualified ("deepseek/deepseek-v4.1-flash"); the
23874
+ // pricing tables and bucket keys use the bare id, matching the dsh reader.
23875
+ function normalizeCommandCodeModelName(value) {
23876
+ if (typeof value !== "string") return null;
23877
+ const trimmed = value.trim();
23878
+ if (!trimmed) return null;
23879
+ const slash = trimmed.lastIndexOf("/");
23880
+ const name = slash >= 0 ? trimmed.slice(slash + 1) : trimmed;
23881
+ return name || null;
23882
+ }
23883
+
23884
+ // Map Command Code's usage object onto disjoint queue columns. `inputTokens`
23885
+ // already includes cache reads and writes (see the section comment), so subtract
23886
+ // both back out first. Explicit numeric zero usage can correct an existing
23887
+ // observation; empty placeholders are not usage. Cost uses model-table pricing.
23888
+ function commandCodeUsageToTotals(usage) {
23889
+ if (!usage || typeof usage !== "object") return null;
23890
+ const inclusiveInput = toNonNegativeInt(usage.inputTokens);
23891
+ const cachedInput = toNonNegativeInt(usage.cacheReadTokens);
23892
+ const cacheWrite = toNonNegativeInt(usage.cacheWriteTokens);
23893
+ const output = toNonNegativeInt(usage.outputTokens);
23894
+ const input = Math.max(0, inclusiveInput - cachedInput - cacheWrite);
23895
+ const total = input + cachedInput + cacheWrite + output;
23896
+ const explicitZero = ["inputTokens", "outputTokens"].every((field) =>
23897
+ typeof usage[field] === "number" && Number.isFinite(usage[field]) && usage[field] === 0,
23898
+ ) && ["cacheReadTokens", "cacheWriteTokens"].every((field) =>
23899
+ usage[field] === undefined || (typeof usage[field] === "number" && usage[field] === 0),
23900
+ );
23901
+ if (total === 0 && !explicitZero) return null;
23902
+ return {
23903
+ input_tokens: input,
23904
+ cached_input_tokens: cachedInput,
23905
+ cache_creation_input_tokens: cacheWrite,
23906
+ output_tokens: output,
23907
+ reasoning_output_tokens: 0,
23908
+ total_tokens: total,
23909
+ billable_total_tokens: total,
23910
+ total_cost_usd: 0,
23911
+ conversation_count: total > 0 ? 1 : 0,
23912
+ };
23913
+ }
23914
+
23915
+ // Check record framing without JSON-decoding its body. Finding an early usage
23916
+ // field is not proof that an interrupted append completed the enclosing object.
23917
+ function isCompleteCommandCodeRecord(raw) {
23918
+ const text = raw.trim();
23919
+ if (text[0] !== "{") return false;
23920
+ const closers = [];
23921
+ let inString = false;
23922
+ for (let index = 0; index < text.length; index++) {
23923
+ const char = text[index];
23924
+ if (inString) {
23925
+ if (char === "\\") index += 1;
23926
+ else if (char === '"') inString = false;
23927
+ continue;
23928
+ }
23929
+ if (char === '"') inString = true;
23930
+ else if (char === "{") closers.push("}");
23931
+ else if (char === "[") closers.push("]");
23932
+ else if (char === "}" || char === "]") {
23933
+ if (char !== closers.pop()) return false;
23934
+ if (closers.length === 0) return index === text.length - 1;
23935
+ }
23936
+ }
23937
+ return false;
23938
+ }
23939
+
23940
+ // Extract selected top-level metadata from transcript text scanned locally.
23941
+ // findDshJsonProperty slices the needed fields; JSON decoding is limited to
23942
+ // selected metadata rather than whole message records or their bodies.
23943
+ // Raw transcript text is present in memory while scanning; prompt, reply and
23944
+ // code bodies are not persisted or uploaded by this reader. The parser test's
23945
+ // JSON.parse guard checks that body content is not JSON-decoded.
23946
+ function extractCommandCodeLine(line) {
23947
+ const raw = String(line || "");
23948
+ if (!isCompleteCommandCodeRecord(raw)) return null;
23949
+ const type = parseDshJsonString(findDshJsonProperty(raw, "type"));
23950
+ if (type === "session") {
23951
+ return {
23952
+ kind: "session",
23953
+ sessionId: parseDshJsonString(findDshJsonProperty(raw, "id")),
23954
+ cwd: parseDshJsonString(findDshJsonProperty(raw, "cwd")),
23955
+ };
23956
+ }
23957
+ if (type !== "message") return null;
23958
+ const id = parseDshJsonString(findDshJsonProperty(raw, "id"));
23959
+ const timestamp = parseDshJsonString(findDshJsonProperty(raw, "timestamp"));
23960
+ if (!id || !timestamp) return null;
23961
+ const usageRaw = findDshJsonProperty(raw, "usage");
23962
+ if (!usageRaw) return null;
23963
+ let usage = null;
23964
+ try {
23965
+ usage = JSON.parse(usageRaw);
23966
+ } catch (_error) {
23967
+ return null;
23968
+ }
23969
+ const totals = commandCodeUsageToTotals(usage);
23970
+ if (!totals) return null;
23971
+ const bucketStart = toUtcHalfHourStart(timestamp);
23972
+ if (!bucketStart) return null;
23973
+ const model = normalizeCommandCodeModelName(
23974
+ parseDshJsonString(findDshJsonProperty(raw, "model")),
23975
+ );
23976
+ return {
23977
+ kind: "message",
23978
+ id,
23979
+ timestamp,
23980
+ model: model || DEFAULT_MODEL,
23981
+ totals,
23982
+ bucketStart,
23983
+ };
23984
+ }
23985
+
23986
+ // Parse one transcript. A torn tail simply contributes nothing — the rebuild
23987
+ // reconciliation picks it up on the next sync once the record is complete.
23988
+ function extractCommandCodeSessionUsage(text) {
23989
+ const records = [];
23990
+ const headerRanges = [];
23991
+ let sessionId = null;
23992
+ let cwd = null;
23993
+ let byteOffset = 0;
23994
+ for (const line of String(text || "").split("\n")) {
23995
+ const length = Buffer.byteLength(line);
23996
+ const start = byteOffset;
23997
+ byteOffset += length + 1;
23998
+ const parsed = extractCommandCodeLine(line);
23999
+ if (!parsed) continue;
24000
+ if (parsed.kind === "session") {
24001
+ const headerLength = Buffer.byteLength(line.trimStart());
24002
+ headerRanges.push({ start: start + length - headerLength, length: headerLength });
24003
+ if (!sessionId && parsed.sessionId) sessionId = parsed.sessionId;
24004
+ if (!cwd && parsed.cwd) cwd = parsed.cwd;
24005
+ continue;
24006
+ }
24007
+ records.push(parsed);
24008
+ }
24009
+ return { sessionId, cwd, records, headerRanges };
24010
+ }
24011
+
24012
+ // Four independent counters are sufficient for current observations. Keep
24013
+ // legacy totals verbatim until a surviving session/id proves their replacement.
24014
+ function compactCommandCodeMessage(value, legacy = false) {
24015
+ const totals = legacy ? { ...value.totals } : {
24016
+ input_tokens: value.totals.input_tokens || 0,
24017
+ cached_input_tokens: value.totals.cached_input_tokens || 0,
24018
+ cache_creation_input_tokens: value.totals.cache_creation_input_tokens || 0,
24019
+ output_tokens: value.totals.output_tokens || 0,
24020
+ };
24021
+ return {
24022
+ totals, bucketStart: value.bucketStart, model: value.model,
24023
+ projectKey: value.projectKey || null, projectRef: value.projectRef || null,
24024
+ ...(legacy ? { legacy: true } : {}),
24025
+ };
24026
+ }
24027
+
24028
+ function expandCommandCodeTotals(totals) {
24029
+ const total = totals.input_tokens + totals.cached_input_tokens + totals.cache_creation_input_tokens + totals.output_tokens;
24030
+ return {
24031
+ ...totals,
24032
+ reasoning_output_tokens: totals.reasoning_output_tokens || 0,
24033
+ total_tokens: totals.total_tokens ?? total,
24034
+ billable_total_tokens: totals.billable_total_tokens ?? totals.total_tokens ?? total,
24035
+ total_cost_usd: 0,
24036
+ conversation_count: totals.conversation_count ?? (total > 0 ? 1 : 0),
24037
+ };
24038
+ }
24039
+
24040
+ function normalizeCommandCodeState(raw) {
24041
+ const messages = {};
24042
+ if (raw?.messages && typeof raw.messages === "object") {
24043
+ for (const [key, value] of Object.entries(raw.messages)) {
24044
+ if (!value?.totals || !value.bucketStart || !value.model) continue;
24045
+ messages[key] = compactCommandCodeMessage(value, !raw.version || raw.version < 2 || value.legacy === true);
24046
+ }
24047
+ }
24048
+ const files = {};
24049
+ // v2 already uses exact id|timestamp accounting. Legacy session/id caches
24050
+ // must be reread once, but absent legacy ledger rows are never discarded.
24051
+ if (raw?.version >= 2 && raw.files && typeof raw.files === "object") {
24052
+ for (const [key, value] of Object.entries(raw.files)) {
24053
+ if (!value || typeof value !== "object") continue;
24054
+ const size = Number(value.size);
24055
+ const mtimeMs = Number(value.mtimeMs);
24056
+ if (!Number.isFinite(size) || !Number.isFinite(mtimeMs)) continue;
24057
+ files[key] = { size, mtimeMs };
24058
+ }
24059
+ }
24060
+ const fileIndex = {};
24061
+ // Older caches omitted uncounted zero identities and accepted torn records.
24062
+ // Re-read them once while retaining the durable ledger above.
24063
+ if (raw?.fileCacheVersion === COMMAND_CODE_FILE_CACHE_VERSION && raw.fileIndex) {
24064
+ for (const [key, value] of Object.entries(raw.fileIndex)) {
24065
+ if (!files[key] || !Array.isArray(value?.messageKeys) || !Array.isArray(value.headerRanges)) continue;
24066
+ if (value.messageKeys.some((entry) => typeof entry !== "string")) continue;
24067
+ if (value.zeroKeys && (!Array.isArray(value.zeroKeys) || value.zeroKeys.some((entry) => typeof entry !== "string"))) continue;
24068
+ let end = 0;
24069
+ const validRanges = value.headerRanges.every((range) => {
24070
+ if (!Number.isSafeInteger(range?.start) || !Number.isSafeInteger(range.length)) return false;
24071
+ if (range.start < end || range.length < 0 || range.start + range.length > files[key].size) return false;
24072
+ end = range.start + range.length;
24073
+ return true;
24074
+ });
24075
+ if (!validRanges) continue;
24076
+ fileIndex[key] = {
24077
+ messageKeys: [...new Set(value.messageKeys)],
24078
+ headerRanges: value.headerRanges.map(({ start, length }) => ({ start, length })),
24079
+ completeOwnedSnapshot: value.completeOwnedSnapshot === true,
24080
+ ...(value.zeroKeys?.length ? { zeroKeys: [...new Set(value.zeroKeys)] } : {}),
24081
+ };
24082
+ }
24083
+ }
24084
+ return { version: COMMAND_CODE_STATE_VERSION, fileCacheVersion: COMMAND_CODE_FILE_CACHE_VERSION, messages, files, fileIndex };
24085
+ }
24086
+
24087
+ // Snapshot one transcript through a single descriptor: the change check and the
24088
+ // read share one handle, so a writer cannot swap the file between them (the
24089
+ // TOCTOU shape CodeQL reports as js/file-system-race). Returns null for a
24090
+ // missing or non-file path while preserving durable history. Other observation
24091
+ // failures propagate before any queues or cursors are published.
24092
+ async function readCommandCodeSessionSnapshot(filePath, previous = null, headerRanges = null) {
24093
+ let handle;
24094
+ try {
24095
+ handle = await fs.open(filePath, "r");
24096
+ let stat = await handle.stat();
24097
+ if (!stat.isFile()) return null;
24098
+ const metadata = { size: stat.size, mtimeMs: stat.mtimeMs };
24099
+ if (
24100
+ previous &&
24101
+ previous.size === metadata.size &&
24102
+ previous.mtimeMs === metadata.mtimeMs &&
24103
+ (!headerRanges || headerRanges.reduce((sum, range) => sum + range.length, 0) <= COMMAND_CODE_HEADER_MAX_BYTES)
24104
+ ) {
24105
+ if (!headerRanges) return { ...metadata, unchanged: true, text: null };
24106
+ // Store byte ranges, never cwd. Positional reads through this same handle
24107
+ // refresh only header lines, preserving Unicode and leading whitespace
24108
+ // without materializing any of the cached message bodies.
24109
+ const headers = [];
24110
+ let complete = true;
24111
+ for (const { start, length } of headerRanges) {
24112
+ const data = Buffer.alloc(length);
24113
+ let offset = 0;
24114
+ while (offset < length) {
24115
+ const { bytesRead } = await handle.read(data, offset, length - offset, start + offset);
24116
+ if (!bytesRead) { complete = false; break; }
24117
+ offset += bytesRead;
24118
+ }
24119
+ if (!complete) break;
24120
+ headers.push(data.toString("utf8"));
24121
+ }
24122
+ const finalStat = await handle.stat();
24123
+ if (complete && finalStat.size === stat.size && finalStat.mtimeMs === stat.mtimeMs) {
24124
+ return { ...metadata, unchanged: true, text: headers.join("\n") };
24125
+ }
24126
+ // The file changed during the header read. Rebuild through this handle;
24127
+ // positional reads above did not advance its full-read file position.
24128
+ stat = finalStat;
24129
+ }
24130
+ const data = await handle.readFile();
24131
+ const finalStat = await handle.stat();
24132
+ const finalMetadata = { size: finalStat.size, mtimeMs: finalStat.mtimeMs };
24133
+ // Appends and equal-size rewrites during a full read both need a retry:
24134
+ // old bytes must not be acknowledged under the rewritten file's metadata.
24135
+ if (
24136
+ finalMetadata.size !== data.length ||
24137
+ finalMetadata.size !== stat.size ||
24138
+ finalMetadata.mtimeMs !== stat.mtimeMs
24139
+ ) finalMetadata.mtimeMs = -1;
24140
+ return { ...finalMetadata, unchanged: false, text: data.toString("utf8") };
24141
+ } catch (error) {
24142
+ if (isCommandCodePathMissing(error)) return null;
24143
+ throw error;
24144
+ } finally {
24145
+ if (handle) await handle.close().catch(() => {});
24146
+ }
24147
+ }
24148
+
24149
+ // Rebuild-and-diff sync for `~/.commandcode/projects/**/*.jsonl`. A record's
24150
+ // identity is `recordId|timestamp`, so a rewritten or forked transcript
24151
+ // reconciles instead of double counting. Missing records retain their history;
24152
+ // only surviving observations can correct it. Cursor state is committed after
24153
+ // both queue appends succeed, so a failed write retries without losing or
24154
+ // inflating usage.
24155
+ async function parseCommandCodeIncremental({
24156
+ sessionFiles,
24157
+ cursors,
24158
+ queuePath,
24159
+ projectQueuePath,
24160
+ onProgress,
24161
+ publicRepoResolver,
24162
+ } = {}) {
24163
+ await ensureDir(path.dirname(queuePath));
24164
+ const files = Array.isArray(sessionFiles) ? sessionFiles : [];
24165
+ // Normalizers retain nested bucket objects. Isolate this provider's working
24166
+ // state so an append failure cannot publish totals or queuedKey changes
24167
+ // through the original cursors before both queues have succeeded.
24168
+ const hourlyState = normalizeHourlyState(structuredClone(cursors?.hourly));
24169
+ const state = normalizeCommandCodeState(cursors?.commandCode);
24170
+ const schemaMigrated = Boolean(cursors?.commandCode &&
24171
+ (cursors.commandCode.version !== COMMAND_CODE_STATE_VERSION ||
24172
+ cursors.commandCode.fileCacheVersion !== COMMAND_CODE_FILE_CACHE_VERSION));
24173
+ const projectEnabled = typeof projectQueuePath === "string" && projectQueuePath.length > 0;
24174
+ const projectState = projectEnabled
24175
+ ? normalizeProjectState(structuredClone(cursors?.projectHourly))
24176
+ : null;
24177
+ const projectTouchedBuckets = projectEnabled ? new Set() : null;
24178
+ const projectMetaCache = projectEnabled ? new Map() : null;
24179
+ const publicRepoCache = projectEnabled ? new Map() : null;
24180
+ const touchedBuckets = new Set();
24181
+ const cb = typeof onProgress === "function" ? onProgress : null;
24182
+
24183
+ const currentByKey = new Map();
24184
+ const currentOwners = new Map();
24185
+ const observedLegacyKeys = new Set();
24186
+ const nextFiles = {};
24187
+ const nextFileIndex = {};
24188
+ let recordsProcessed = 0;
24189
+
24190
+ for (let fileIdx = 0; fileIdx < files.length; fileIdx++) {
24191
+ const filePath = files[fileIdx];
24192
+ const index = state.fileIndex[filePath];
24193
+ const ownsSnapshot = index?.completeOwnedSnapshot &&
24194
+ index.messageKeys.every((key) => state.messages[key] && !state.messages[key].legacy) &&
24195
+ // A previously uncounted zero can become a correction when another copy
24196
+ // introduces the same identity. Re-read its actual model and counters.
24197
+ !(index.zeroKeys || []).some((key) => state.messages[key] || currentByKey.has(key));
24198
+ const snapshot = await readCommandCodeSessionSnapshot(
24199
+ filePath,
24200
+ ownsSnapshot ? state.files[filePath] : null,
24201
+ projectEnabled && ownsSnapshot ? index.headerRanges : null,
24202
+ );
24203
+ if (!snapshot) continue;
24204
+ nextFiles[filePath] = { size: snapshot.size, mtimeMs: snapshot.mtimeMs };
24205
+
24206
+ const parsed = extractCommandCodeSessionUsage(snapshot.text);
24207
+
24208
+ let projectKey = null;
24209
+ let projectRef = null;
24210
+ if (projectEnabled) {
24211
+ const rawCwd = typeof parsed.cwd === "string" ? parsed.cwd.trim() : "";
24212
+ if (rawCwd) {
24213
+ const startDir = wsl.mapWslCwdToUnc(rawCwd, filePath);
24214
+ const context = await resolveProjectContextForPath({
24215
+ startDir,
24216
+ projectMetaCache,
24217
+ publicRepoCache,
24218
+ publicRepoResolver,
24219
+ projectState,
24220
+ });
24221
+ projectKey = context?.projectKey || null;
24222
+ projectRef = context?.projectRef || null;
24223
+ }
24224
+ }
24225
+
24226
+ if (snapshot.unchanged) {
24227
+ nextFileIndex[filePath] = { ...index };
24228
+ for (const key of index.messageKeys) {
24229
+ const value = state.messages[key];
24230
+ currentByKey.set(key, projectEnabled ? { ...value, projectKey, projectRef } : value);
24231
+ currentOwners.set(key, filePath);
24232
+ }
24233
+ continue;
24234
+ }
24235
+
24236
+ const messageKeys = new Set();
24237
+ for (const record of parsed.records) {
24238
+ recordsProcessed += 1;
24239
+ const key = `${COMMAND_CODE_SOURCE}:${record.id}|${record.timestamp}`;
24240
+ messageKeys.add(key);
24241
+ if (parsed.sessionId) observedLegacyKeys.add(`${COMMAND_CODE_SOURCE}:${parsed.sessionId}|${record.id}`);
24242
+ currentOwners.set(key, filePath);
24243
+ currentByKey.set(key, compactCommandCodeMessage({
24244
+ totals: record.totals,
24245
+ bucketStart: record.bucketStart,
24246
+ model: record.model,
24247
+ projectKey,
24248
+ projectRef,
24249
+ }));
24250
+ }
24251
+ nextFileIndex[filePath] = { messageKeys: [...messageKeys], headerRanges: parsed.headerRanges };
24252
+
24253
+ if (cb && (fileIdx % 25 === 0 || fileIdx === files.length - 1)) {
24254
+ cb({
24255
+ index: fileIdx + 1,
24256
+ total: files.length,
24257
+ messagesProcessed: currentByKey.size,
24258
+ eventsAggregated: 0,
24259
+ bucketsQueued: 0,
24260
+ });
24261
+ }
24262
+ }
24263
+
24264
+ let eventsAggregated = 0;
24265
+
24266
+ function applyObservation(value, subtract = false) {
24267
+ const totals = expandCommandCodeTotals(value.totals);
24268
+ const apply = subtract ? subtractTotals : addTotals;
24269
+ apply(getHourlyBucket(hourlyState, COMMAND_CODE_SOURCE, value.model, value.bucketStart).totals, totals);
24270
+ touchedBuckets.add(bucketKey(COMMAND_CODE_SOURCE, value.model, value.bucketStart));
24271
+ if (projectEnabled && value.projectKey) {
24272
+ apply(getProjectBucket(projectState, value.projectKey, COMMAND_CODE_SOURCE, value.bucketStart, value.projectRef).totals, totals);
24273
+ projectTouchedBuckets.add(projectBucketKey(value.projectKey, COMMAND_CODE_SOURCE, value.bucketStart));
24274
+ }
24275
+ }
24276
+
24277
+ // Legacy session-based identities can only be removed when that session/id
24278
+ // actually survives. An empty or partially compacted source proves nothing.
24279
+ for (const [key, prev] of Object.entries(state.messages)) {
24280
+ if (!prev.legacy || !observedLegacyKeys.has(key)) continue;
24281
+ applyObservation(prev, true);
24282
+ delete state.messages[key];
24283
+ eventsAggregated += 1;
24284
+ }
24285
+
24286
+ for (const [key, prev] of Object.entries(state.messages)) {
24287
+ if (!currentByKey.has(key)) continue;
24288
+ const cur = currentByKey.get(key);
24289
+ const unchanged =
24290
+ totalsKey(expandCommandCodeTotals(prev.totals)) === totalsKey(expandCommandCodeTotals(cur.totals)) &&
24291
+ prev.bucketStart === cur.bucketStart && prev.model === cur.model &&
24292
+ prev.projectKey === cur.projectKey;
24293
+ if (!unchanged) {
24294
+ applyObservation(prev, true);
24295
+ applyObservation(cur);
24296
+ eventsAggregated += 1;
24297
+ }
24298
+ state.messages[key] = cur;
24299
+ }
24300
+
24301
+ for (const [key, cur] of currentByKey) {
24302
+ if (state.messages[key]) continue;
24303
+ // A standalone zero is an empty turn, not a new accounting event. Retain
24304
+ // zeros only as corrections to a previously counted exact-key record.
24305
+ if (expandCommandCodeTotals(cur.totals).total_tokens === 0) continue;
24306
+ applyObservation(cur);
24307
+ state.messages[key] = cur;
24308
+ eventsAggregated += 1;
24309
+ }
24310
+
24311
+ for (const [filePath, index] of Object.entries(nextFileIndex)) {
24312
+ // Check ownership before dropping uncounted keys: a positive record that
24313
+ // lost to a zero in another file is still a non-owning snapshot.
24314
+ const ownsAllRecords = index.messageKeys.every((key) => currentOwners.get(key) === filePath);
24315
+ index.completeOwnedSnapshot = ownsAllRecords;
24316
+ // Retain the identities of uncounted zeros without full message metadata.
24317
+ // They need no body reread until a conflicting observation appears.
24318
+ const zeroKeys = [...new Set([
24319
+ ...(index.zeroKeys || []),
24320
+ ...index.messageKeys.filter((key) => !state.messages[key]),
24321
+ ])];
24322
+ if (zeroKeys.length) index.zeroKeys = zeroKeys;
24323
+ index.messageKeys = index.messageKeys.filter((key) => state.messages[key]);
24324
+ }
24325
+
24326
+ // Ignore cost-only changes (including old display estimates). The zero
24327
+ // sentinel is written only when actual accounting otherwise queues a bucket.
24328
+ function prepareBuckets(touched, next, previous) {
24329
+ for (const key of touched) {
24330
+ const totals = next.buckets[key].totals;
24331
+ const old = previous?.buckets?.[key]?.totals;
24332
+ if (old && totalsKey({ ...old, total_cost_usd: 0 }) === totalsKey({ ...totals, total_cost_usd: 0 })) {
24333
+ touched.delete(key);
24334
+ continue;
24335
+ }
24336
+ totals.total_cost_usd = 0;
24337
+ }
24338
+ }
24339
+ prepareBuckets(touchedBuckets, hourlyState, cursors?.hourly);
24340
+ if (projectEnabled) prepareBuckets(projectTouchedBuckets, projectState, cursors?.projectHourly);
24341
+
24342
+ const bucketsQueued = await enqueueTouchedBuckets({ queuePath, hourlyState, touchedBuckets });
24343
+ const projectBucketsQueued = projectEnabled
24344
+ ? await enqueueTouchedProjectBuckets({ projectQueuePath, projectState, projectTouchedBuckets })
24345
+ : 0;
24346
+
24347
+ const updatedAt = new Date().toISOString();
24348
+ if (bucketsQueued > 0) hourlyState.updatedAt = updatedAt;
24349
+ state.files = nextFiles;
24350
+ state.fileIndex = nextFileIndex;
24351
+ // Non-owning fork copies are reread to resolve conflicts, but those reads
24352
+ // alone need not rewrite an identical persistent ledger and file index.
24353
+ const cursorUnchanged = isDeepStrictEqual(state, cursors.commandCode);
24354
+ cursors.hourly = hourlyState;
24355
+ cursors.commandCode = state;
24356
+ if (projectState) {
24357
+ if (projectBucketsQueued > 0) projectState.updatedAt = updatedAt;
24358
+ cursors.projectHourly = projectState;
24359
+ }
24360
+
24361
+ return {
24362
+ recordsProcessed,
24363
+ messagesProcessed: currentByKey.size,
24364
+ eventsAggregated,
24365
+ bucketsQueued,
24366
+ projectBucketsQueued,
24367
+ schemaMigrated,
24368
+ cursorUnchanged,
24369
+ };
24370
+ }
24371
+
23557
24372
  module.exports = {
24373
+ parseTraeIncremental,
23558
24374
  listRolloutFiles,
23559
24375
  listRolloutFilesDeep,
23560
24376
  codexSessionIdFromPath,
@@ -23792,4 +24608,13 @@ module.exports = {
23792
24608
  dshUsageToTotals,
23793
24609
  extractDshSessionUsage,
23794
24610
  parseDshIncremental,
24611
+ // Command Code (`cmd`) — passive session-log reader
24612
+ resolveCommandCodeHome,
24613
+ resolveCommandCodeHomes,
24614
+ resolveCommandCodeSessionFiles,
24615
+ isCommandCodeSessionLogName,
24616
+ normalizeCommandCodeModelName,
24617
+ commandCodeUsageToTotals,
24618
+ extractCommandCodeSessionUsage,
24619
+ parseCommandCodeIncremental,
23795
24620
  };