tokentracker-cli 0.87.15 → 0.87.17

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 (109) hide show
  1. package/README.de.md +2 -2
  2. package/README.ja.md +2 -2
  3. package/README.ko.md +2 -2
  4. package/README.md +3 -3
  5. package/README.zh-CN.md +2 -2
  6. package/dashboard/dist/assets/{AchievementBadge-Ca-tQO2m.js → AchievementBadge-BsEQ2tmj.js} +1 -1
  7. package/dashboard/dist/assets/{AchievementsPage-l8piG098.js → AchievementsPage-DaIE18Oj.js} +2 -2
  8. package/dashboard/dist/assets/{AchievementsSection-DILM1j6f.js → AchievementsSection-D5Chjra6.js} +1 -1
  9. package/dashboard/dist/assets/{BadgeDetailModal-CP4kIXJ6.js → BadgeDetailModal-DiF5CC4Q.js} +1 -1
  10. package/dashboard/dist/assets/{Card-vJSP5AIR.js → Card-Bh1l9XVa.js} +1 -1
  11. package/dashboard/dist/assets/{ClawdAnimated-DRXRgleI.js → ClawdAnimated-B2tb_uy9.js} +1 -1
  12. package/dashboard/dist/assets/{CommandPalette-CFYDM40w.js → CommandPalette-BDYmI6KM.js} +1 -1
  13. package/dashboard/dist/assets/{CommunityStatsModal-DeBswg2S.js → CommunityStatsModal-DZDixdxG.js} +1 -1
  14. package/dashboard/dist/assets/{ConfirmModal-DnHQ6ZjR.js → ConfirmModal-B3HhWPG1.js} +1 -1
  15. package/dashboard/dist/assets/{Controls-CEgdStDg.js → Controls-eSVS0G3S.js} +1 -1
  16. package/dashboard/dist/assets/{DashboardPage-CRA9C12j.js → DashboardPage-DMyBPJ9W.js} +1 -1
  17. package/dashboard/dist/assets/{DevicePage-DJHYCHvk.js → DevicePage-BhpTKx-c.js} +1 -1
  18. package/dashboard/dist/assets/{DialogClose-Dfv009JY.js → DialogClose-Dt2M164c.js} +1 -1
  19. package/dashboard/dist/assets/DialogDescription-D0EQV4SO.js +1 -0
  20. package/dashboard/dist/assets/{DialogTitle-B50QLs-X.js → DialogTitle-CBQEeY1z.js} +1 -1
  21. package/dashboard/dist/assets/{FadeIn-C0gR94fP.js → FadeIn-9wsSzh_a.js} +1 -1
  22. package/dashboard/dist/assets/{HeaderGithubStar-C640OuU1.js → HeaderGithubStar-BOg2yeJ0.js} +1 -1
  23. package/dashboard/dist/assets/{HoverTooltip-DX83f-xN.js → HoverTooltip-OaMkhUbs.js} +1 -1
  24. package/dashboard/dist/assets/{IpCheckPage-BSFyNVPV.js → IpCheckPage-BFaVui2O.js} +1 -1
  25. package/dashboard/dist/assets/{LandingPage-SsOTjgMK.js → LandingPage-CGybB6xR.js} +2 -2
  26. package/dashboard/dist/assets/{LeaderboardAvatar-B2195RbN.js → LeaderboardAvatar-DFR_SDle.js} +1 -1
  27. package/dashboard/dist/assets/{LeaderboardPage-B2HVZ1LA.js → LeaderboardPage-Dx3nyCZc.js} +2 -2
  28. package/dashboard/dist/assets/{LeaderboardProfileModal-CV07_l8k.js → LeaderboardProfileModal-B9nLJTGv.js} +3 -3
  29. package/dashboard/dist/assets/{LeaderboardProfilePage-Dx3je17Y.js → LeaderboardProfilePage-B4vNvdiq.js} +1 -1
  30. package/dashboard/dist/assets/LimitsPage-D-J5R4qu.js +4 -0
  31. package/dashboard/dist/assets/{LocalOnlyNotice-CIcgZ_DK.js → LocalOnlyNotice-CeJ1Dv_8.js} +1 -1
  32. package/dashboard/dist/assets/{LoginCard-RoSvQEcf.js → LoginCard-CHQRkWQs.js} +1 -1
  33. package/dashboard/dist/assets/{LoginModal-CyyqwoxT.js → LoginModal-B5559j1K.js} +1 -1
  34. package/dashboard/dist/assets/{LoginPage-Df1TNN5I.js → LoginPage-C1P2Quq6.js} +1 -1
  35. package/dashboard/dist/assets/{PetPage-DNzJwiHw.js → PetPage-Wt-HcAHU.js} +1 -1
  36. package/dashboard/dist/assets/{PopoverPopup-DQeWO0iF.js → PopoverPopup-CVI4w0E6.js} +1 -1
  37. package/dashboard/dist/assets/{ProviderIcon-lzUcnmCH.js → ProviderIcon-BswKVGR0.js} +1 -1
  38. package/dashboard/dist/assets/{ResetPasswordPage-zMccm6ur.js → ResetPasswordPage-sRWSceeH.js} +1 -1
  39. package/dashboard/dist/assets/{SegmentedControl-CJFGvTrI.js → SegmentedControl-bXs9Ly1S.js} +1 -1
  40. package/dashboard/dist/assets/{Select-C3Omtbu7.js → Select-BxZQJSA8.js} +1 -1
  41. package/dashboard/dist/assets/{SelectItemText-yhAb3Grm.js → SelectItemText-BhC7_m4l.js} +1 -1
  42. package/dashboard/dist/assets/{ServiceStatusPage-DKO2U7tP.js → ServiceStatusPage-DnoQGIzM.js} +1 -1
  43. package/dashboard/dist/assets/{SessionsPage-Db7QQKkq.js → SessionsPage-D_rk7urK.js} +1 -1
  44. package/dashboard/dist/assets/{SettingsPage-Wt0c9Tom.js → SettingsPage-d1uhJJUI.js} +1 -1
  45. package/dashboard/dist/assets/{SkillsPage-CiIK-x9u.js → SkillsPage-Dmv3vfV2.js} +1 -1
  46. package/dashboard/dist/assets/{TokenGalaxy-lenaRGrK.js → TokenGalaxy-BRozBDv9.js} +1 -1
  47. package/dashboard/dist/assets/{ToolbarRootContext-DM5LAbiN.js → ToolbarRootContext-BxAIwde2.js} +1 -1
  48. package/dashboard/dist/assets/{TrendMonitor-DsjkB17f.js → TrendMonitor-bTygbWe4.js} +1 -1
  49. package/dashboard/dist/assets/{WidgetsPage-DRHgj8mv.js → WidgetsPage-Bdgv5NIE.js} +1 -1
  50. package/dashboard/dist/assets/{WrappedPage-CnqvO_pe.js → WrappedPage-tLVxl_4c.js} +1 -1
  51. package/dashboard/dist/assets/{__vite-browser-external-Dt0UC4VW.js → __vite-browser-external-DFfWg5Zv.js} +14 -11
  52. package/dashboard/dist/assets/{agent-logos-B5opZwZN.js → agent-logos-DVtP3hhF.js} +1 -1
  53. package/dashboard/dist/assets/{arrow-up-right-DthPMeHa.js → arrow-up-right-a--zz4P2.js} +1 -1
  54. package/dashboard/dist/assets/{check-BjhrWH3T.js → check-Cb87RDI8.js} +1 -1
  55. package/dashboard/dist/assets/{chevron-right-BllZqKsq.js → chevron-right-BaXnYJmR.js} +1 -1
  56. package/dashboard/dist/assets/{copy-DPdzUcUh.js → copy-BP5bVEyA.js} +1 -1
  57. package/dashboard/dist/assets/{download-BSl1NoxY.js → download-DqquZt0D.js} +1 -1
  58. package/dashboard/dist/assets/{external-link-DpSJMzDV.js → external-link-iXDmG0bb.js} +1 -1
  59. package/dashboard/dist/assets/{flame-BuP9lXyr.js → flame-DHqiSC5J.js} +1 -1
  60. package/dashboard/dist/assets/{format-tokens-DxpuHAMo.js → format-tokens-DsygRhxH.js} +1 -1
  61. package/dashboard/dist/assets/{icons-N_skbXnV.js → icons-D6xBLJA4.js} +1 -1
  62. package/dashboard/dist/assets/{index-CkdHt2G8.js → index-DSrgwJkM.js} +1 -1
  63. package/dashboard/dist/assets/{index-VgEi64Pa.js → index-HfUVkowX.js} +1 -1
  64. package/dashboard/dist/assets/{index-BJbM1KfA.js → index-cM3RnbJG.js} +1 -1
  65. package/dashboard/dist/assets/{info-B7vTy0RN.js → info-Cui6vFPM.js} +1 -1
  66. package/dashboard/dist/assets/limits-providers-CdzvnLpk.js +1 -0
  67. package/dashboard/dist/assets/{link-2-BL9btf_U.js → link-2-DX3nm7aU.js} +1 -1
  68. package/dashboard/dist/assets/{loader-circle-B3ZUl9Xl.js → loader-circle-C8Sw1OT7.js} +1 -1
  69. package/dashboard/dist/assets/{maximize-2-B3oqZR-c.js → maximize-2-Czi9wWkF.js} +1 -1
  70. package/dashboard/dist/assets/{modifiers.esm-CJv-4uLD.js → modifiers.esm-BmnoKiX1.js} +1 -1
  71. package/dashboard/dist/assets/{monitor-smartphone-DpSeKStM.js → monitor-smartphone-B0J0HWPL.js} +1 -1
  72. package/dashboard/dist/assets/{provider-display-ChgG1v12.js → provider-display-C6qI5Ghp.js} +1 -1
  73. package/dashboard/dist/assets/{react-CiQpGAt7.js → react-BVu5drox.js} +1 -1
  74. package/dashboard/dist/assets/{search-CR5Y7wkH.js → search-C1-spbaq.js} +1 -1
  75. package/dashboard/dist/assets/{skills-api-CYV3Aau7.js → skills-api-C3VUeo0l.js} +1 -1
  76. package/dashboard/dist/assets/{sortable.esm-Cp2tEWCE.js → sortable.esm-vred0beO.js} +1 -1
  77. package/dashboard/dist/assets/{store-D579LpDz.js → store-B4CtFHMN.js} +1 -1
  78. package/dashboard/dist/assets/{terminal-B1Afo2zN.js → terminal-CCly6A7O.js} +1 -1
  79. package/dashboard/dist/assets/{trash-2-O7jz3Z3p.js → trash-2-Doikp-3o.js} +1 -1
  80. package/dashboard/dist/assets/{use-community-stats-DpHf0WZG.js → use-community-stats-4JEUXgQD.js} +1 -1
  81. package/dashboard/dist/assets/{use-limits-display-prefs-BVCtTqo4.js → use-limits-display-prefs-CvmEm5O5.js} +1 -1
  82. package/dashboard/dist/assets/{use-ordered-list-C7mhlVII.js → use-ordered-list-Cwd6mjTH.js} +1 -1
  83. package/dashboard/dist/assets/use-reduced-motion-DqkEunos.js +1 -0
  84. package/dashboard/dist/assets/{use-session-efficiency-pref-C3HNT8gg.js → use-session-efficiency-pref-DSE0hMDn.js} +1 -1
  85. package/dashboard/dist/assets/{use-transform-Cn4pqfbJ.js → use-transform-CHAOJvjo.js} +1 -1
  86. package/dashboard/dist/assets/{use-usage-limits-BAF6oNaO.js → use-usage-limits-DqjvXdmC.js} +1 -1
  87. package/dashboard/dist/assets/{useCurrency-DmofuCKt.js → useCurrency-C6VcRFbY.js} +1 -1
  88. package/dashboard/dist/assets/{useScrollLock-2HXHWZMK.js → useScrollLock-BtQ9MIFV.js} +1 -1
  89. package/dashboard/dist/assets/{useTokenFormat-CSI-l9pP.js → useTokenFormat-DVtSl1vw.js} +1 -1
  90. package/dashboard/dist/assets/{zap-BcxNvHjJ.js → zap-Bqvqpei6.js} +1 -1
  91. package/dashboard/dist/index.html +4 -4
  92. package/dashboard/dist/ip-check.html +1 -1
  93. package/dashboard/dist/leaderboard.html +1 -1
  94. package/dashboard/dist/share.html +1 -1
  95. package/package.json +1 -1
  96. package/src/commands/serve.js +11 -3
  97. package/src/commands/status.js +34 -0
  98. package/src/commands/sync.js +95 -1
  99. package/src/lib/diagnostics.js +10 -0
  100. package/src/lib/fs.js +79 -1
  101. package/src/lib/pricing/seed-snapshot.json +1 -1
  102. package/src/lib/qoder-limits.js +89 -34
  103. package/src/lib/rollout.js +226 -43
  104. package/src/lib/session-analytics.js +99 -5
  105. package/src/lib/usage-limits.js +93 -14
  106. package/dashboard/dist/assets/DialogDescription-BcNmkFVH.js +0 -1
  107. package/dashboard/dist/assets/LimitsPage-uQNPuFCp.js +0 -4
  108. package/dashboard/dist/assets/limits-providers-DXlRXQck.js +0 -1
  109. package/dashboard/dist/assets/use-reduced-motion-CWJDDawC.js +0 -1
@@ -1195,6 +1195,7 @@ async function parseOpencodeIncremental({
1195
1195
  const publicRepoCache = projectEnabled ? new Map() : null;
1196
1196
  const opencodeState = normalizeOpencodeState(cursors?.opencode);
1197
1197
  const messageIndex = opencodeState.messages;
1198
+ const fingerprintIndex = buildOpencodeFingerprintIndex(messageIndex);
1198
1199
  const touchedBuckets = new Set();
1199
1200
  const defaultSource = normalizeSourceInput(source) || "opencode";
1200
1201
 
@@ -1255,6 +1256,7 @@ async function parseOpencodeIncremental({
1255
1256
  const result = await parseOpencodeMessageFile({
1256
1257
  filePath,
1257
1258
  messageIndex,
1259
+ fingerprintIndex,
1258
1260
  fallbackTotals,
1259
1261
  fallbackMessageKey,
1260
1262
  hourlyState,
@@ -1279,10 +1281,13 @@ async function parseOpencodeIncremental({
1279
1281
  eventsAggregated += result.eventsAggregated;
1280
1282
 
1281
1283
  if (result.messageKey && result.shouldUpdate) {
1282
- messageIndex[result.messageKey] = {
1283
- lastTotals: result.lastTotals,
1284
- updatedAt: new Date().toISOString(),
1285
- };
1284
+ recordOpencodeMessage({
1285
+ messageIndex,
1286
+ fingerprintIndex,
1287
+ messageKey: result.messageKey,
1288
+ totals: result.lastTotals,
1289
+ fingerprint: result.fingerprint || null,
1290
+ });
1286
1291
  }
1287
1292
 
1288
1293
  if (cb) {
@@ -2554,6 +2559,7 @@ async function parseGeminiFile({
2554
2559
  async function parseOpencodeMessageFile({
2555
2560
  filePath,
2556
2561
  messageIndex,
2562
+ fingerprintIndex,
2557
2563
  fallbackTotals,
2558
2564
  fallbackMessageKey,
2559
2565
  hourlyState,
@@ -2608,9 +2614,22 @@ async function parseOpencodeMessageFile({
2608
2614
  return { messageKey, lastTotals, eventsAggregated: 0, shouldUpdate: false };
2609
2615
  }
2610
2616
 
2617
+ // `Session.fork` re-materialises the parent's prefix under new message ids in
2618
+ // a new session — count it once (issue #426, see deriveOpencodeMessageFingerprint).
2619
+ const fingerprint = deriveOpencodeMessageFingerprint({ msg, totals: currentTotals, source });
2620
+ if (!lastTotals && isOpencodeForkCopy(fingerprintIndex, fingerprint, messageKey)) {
2621
+ return { messageKey, lastTotals: null, fingerprint, eventsAggregated: 0, shouldUpdate: false };
2622
+ }
2623
+
2611
2624
  const delta = diffGeminiTotals(currentTotals, lastTotals);
2612
2625
  if (!delta || isAllZeroUsage(delta)) {
2613
- return { messageKey, lastTotals: currentTotals, eventsAggregated: 0, shouldUpdate: true };
2626
+ return {
2627
+ messageKey,
2628
+ lastTotals: currentTotals,
2629
+ fingerprint,
2630
+ eventsAggregated: 0,
2631
+ shouldUpdate: true,
2632
+ };
2614
2633
  }
2615
2634
  delta.conversation_count = 1;
2616
2635
 
@@ -2650,7 +2669,13 @@ async function parseOpencodeMessageFile({
2650
2669
  addTotals(projectBucket.totals, delta);
2651
2670
  projectTouchedBuckets.add(projectBucketKey(projectKey, source, bucketStart));
2652
2671
  }
2653
- return { messageKey, lastTotals: currentTotals, eventsAggregated: 1, shouldUpdate: true };
2672
+ return {
2673
+ messageKey,
2674
+ lastTotals: currentTotals,
2675
+ fingerprint,
2676
+ eventsAggregated: 1,
2677
+ shouldUpdate: true,
2678
+ };
2654
2679
  }
2655
2680
 
2656
2681
  async function enqueueTouchedBuckets({ queuePath, hourlyState, touchedBuckets }) {
@@ -3193,6 +3218,110 @@ function deriveOpencodeMessageKey(msg, fallback) {
3193
3218
  return fallback;
3194
3219
  }
3195
3220
 
3221
+ // ── OpenCode fork-copy dedup (issue #426) ──────────────────────────────────
3222
+ //
3223
+ // `Session.fork` copies every parent message up to the fork point into a BRAND
3224
+ // NEW session. Verified against the shipped opencode binary: the copy is
3225
+ // `{ ...parentMessage.info, sessionID: newSession.id, id: newMessageId }` — so
3226
+ // `time.created`, `time.completed`, `modelID`, `providerID` and the entire
3227
+ // `tokens` payload survive verbatim and only the two identity fields change.
3228
+ // The forked session is created WITHOUT a parentID (confirmed empirically:
3229
+ // POST /session/:id/fork returns parentID=null), so the copies cannot be linked
3230
+ // to their origin by ancestry — content is the only available discriminator.
3231
+ // Because our dedup key is `sessionID|messageID`, every copied prefix used to be
3232
+ // counted a second time (a real fork of a 170-message session re-added 20.3M
3233
+ // tokens; the reporter measured ~2.0B across ~20 forks).
3234
+ //
3235
+ // The fingerprint is deliberately narrow — issue #187 is the standing reminder
3236
+ // that a loose dedup predicate deletes real usage. A collision requires the SAME
3237
+ // millisecond for BOTH created and completed, byte-equal values in all five
3238
+ // token columns, the same model, the same provider AND the same source. On top
3239
+ // of that we only ever drop a match that lives in a DIFFERENT session, since
3240
+ // forking always mints a new one — two turns inside one session are never
3241
+ // treated as copies of each other.
3242
+ function deriveOpencodeMessageFingerprint({ msg, totals, source }) {
3243
+ if (!totals || isAllZeroUsage(totals)) return null;
3244
+ const created = coerceEpochMs(msg?.time?.created) || 0;
3245
+ const completed = coerceEpochMs(msg?.time?.completed) || 0;
3246
+ if (!created && !completed) return null;
3247
+ const model = normalizeModelInput(msg?.modelID || msg?.model || msg?.modelId) || DEFAULT_MODEL;
3248
+ const provider = normalizeMessageKeyPart(msg?.providerID || msg?.provider || msg?.providerId);
3249
+ const raw = [
3250
+ normalizeSourceInput(source) || "opencode",
3251
+ created,
3252
+ completed,
3253
+ totals.input_tokens,
3254
+ totals.output_tokens,
3255
+ totals.cached_input_tokens,
3256
+ totals.cache_creation_input_tokens,
3257
+ totals.reasoning_output_tokens,
3258
+ model,
3259
+ provider,
3260
+ ].join("");
3261
+ // Hashed rather than stored raw: the fingerprint is persisted per message in
3262
+ // cursors.json, and heavy OpenCode users carry tens of thousands of entries.
3263
+ return crypto.createHash("sha256").update(raw).digest("base64url").slice(0, 22);
3264
+ }
3265
+
3266
+ // `fingerprint -> messageKey` for the messages already counted in this cursor
3267
+ // namespace. Rebuilt per parse run; entries written before this version carry no
3268
+ // fingerprint and simply do not participate (no retroactive rewrite of history).
3269
+ function buildOpencodeFingerprintIndex(messageIndex) {
3270
+ const byFingerprint = new Map();
3271
+ if (!messageIndex || typeof messageIndex !== "object") return byFingerprint;
3272
+ for (const [key, entry] of Object.entries(messageIndex)) {
3273
+ const fingerprint = entry && typeof entry.fingerprint === "string" ? entry.fingerprint : null;
3274
+ if (fingerprint && !byFingerprint.has(fingerprint)) byFingerprint.set(fingerprint, key);
3275
+ }
3276
+ return byFingerprint;
3277
+ }
3278
+
3279
+ function opencodeMessageKeySession(messageKey) {
3280
+ if (typeof messageKey !== "string") return null;
3281
+ const idx = messageKey.indexOf("|");
3282
+ return idx > 0 ? messageKey.slice(0, idx) : null;
3283
+ }
3284
+
3285
+ // True only when an already-counted message in ANOTHER session carries the exact
3286
+ // same fingerprint — i.e. this row is a fork copy. Keys without a resolvable
3287
+ // session (the JSON reader's file-path fallback) never dedup: unproven identity
3288
+ // must not delete usage.
3289
+ function isOpencodeForkCopy(fingerprintIndex, fingerprint, messageKey) {
3290
+ if (!fingerprint || !fingerprintIndex) return false;
3291
+ const owner = fingerprintIndex.get(fingerprint);
3292
+ if (!owner || owner === messageKey) return false;
3293
+ const ownerSession = opencodeMessageKeySession(owner);
3294
+ const session = opencodeMessageKeySession(messageKey);
3295
+ if (!ownerSession || !session) return false;
3296
+ return ownerSession !== session;
3297
+ }
3298
+
3299
+ // Persist a message's snapshot + fingerprint and claim the fingerprint for it.
3300
+ // Writes only when something actually changed so a steady-state sync leaves the
3301
+ // cursor untouched. A falsy `fingerprint` means "unknown" and preserves whatever
3302
+ // the entry already carried; a new one releases the old claim so a stale
3303
+ // mid-stream snapshot cannot shadow an unrelated message later.
3304
+ function recordOpencodeMessage({ messageIndex, fingerprintIndex, messageKey, totals, fingerprint }) {
3305
+ if (!messageIndex || !messageKey) return;
3306
+ const prev = messageIndex[messageKey];
3307
+ const prevTotals = prev && typeof prev.lastTotals === "object" ? prev.lastTotals : null;
3308
+ const prevFingerprint = prev && typeof prev.fingerprint === "string" ? prev.fingerprint : null;
3309
+ const nextFingerprint = fingerprint || prevFingerprint;
3310
+ if (sameGeminiTotals(totals, prevTotals) && prevFingerprint === nextFingerprint) return;
3311
+
3312
+ if (fingerprintIndex && prevFingerprint && prevFingerprint !== nextFingerprint) {
3313
+ if (fingerprintIndex.get(prevFingerprint) === messageKey) {
3314
+ fingerprintIndex.delete(prevFingerprint);
3315
+ }
3316
+ }
3317
+ const entry = { lastTotals: totals, updatedAt: new Date().toISOString() };
3318
+ if (nextFingerprint) entry.fingerprint = nextFingerprint;
3319
+ messageIndex[messageKey] = entry;
3320
+ if (fingerprintIndex && nextFingerprint && !fingerprintIndex.has(nextFingerprint)) {
3321
+ fingerprintIndex.set(nextFingerprint, messageKey);
3322
+ }
3323
+ }
3324
+
3196
3325
  function getHourlyBucket(state, source, model, hourStart) {
3197
3326
  const buckets = state.buckets;
3198
3327
  const normalizedSource = normalizeSourceInput(source) || DEFAULT_SOURCE;
@@ -4219,6 +4348,7 @@ async function parseOpencodeDbIncremental({
4219
4348
  const cursorNamespace = typeof cursorKey === "string" && cursorKey.length > 0 ? cursorKey : "opencode";
4220
4349
  const opencodeState = normalizeOpencodeState(cursors?.[cursorNamespace]);
4221
4350
  const messageIndex = opencodeState.messages;
4351
+ const fingerprintIndex = buildOpencodeFingerprintIndex(messageIndex);
4222
4352
  const touchedBuckets = new Set();
4223
4353
  const defaultSource = normalizeSourceInput(source) || "opencode";
4224
4354
 
@@ -4247,15 +4377,30 @@ async function parseOpencodeDbIncremental({
4247
4377
  continue;
4248
4378
  }
4249
4379
 
4380
+ // A fork copy of an already-counted turn contributes nothing — skip it
4381
+ // outright so it never claims an index entry of its own (issue #426).
4382
+ const fingerprint = deriveOpencodeMessageFingerprint({
4383
+ msg,
4384
+ totals: currentTotals,
4385
+ source: defaultSource,
4386
+ });
4387
+ if (!lastTotals && isOpencodeForkCopy(fingerprintIndex, fingerprint, messageKey)) {
4388
+ messagesProcessed += 1;
4389
+ continue;
4390
+ }
4391
+
4250
4392
  const delta = diffGeminiTotals(currentTotals, lastTotals);
4251
4393
  if (!delta || isAllZeroUsage(delta)) {
4252
- // Update index with current totals even if no delta (normalization may have changed)
4253
- if (!sameGeminiTotals(currentTotals, lastTotals)) {
4254
- messageIndex[messageKey] = {
4255
- lastTotals: currentTotals,
4256
- updatedAt: new Date().toISOString(),
4257
- };
4258
- }
4394
+ // Refresh the index even without a delta: normalization may have changed,
4395
+ // and pre-#426 entries need their fingerprint backfilled so a fork taken
4396
+ // from an old session is still recognised.
4397
+ recordOpencodeMessage({
4398
+ messageIndex,
4399
+ fingerprintIndex,
4400
+ messageKey,
4401
+ totals: currentTotals,
4402
+ fingerprint,
4403
+ });
4259
4404
  messagesProcessed += 1;
4260
4405
  if (cb) {
4261
4406
  cb({
@@ -4312,10 +4457,13 @@ async function parseOpencodeDbIncremental({
4312
4457
  }
4313
4458
  }
4314
4459
 
4315
- messageIndex[messageKey] = {
4316
- lastTotals: currentTotals,
4317
- updatedAt: new Date().toISOString(),
4318
- };
4460
+ recordOpencodeMessage({
4461
+ messageIndex,
4462
+ fingerprintIndex,
4463
+ messageKey,
4464
+ totals: currentTotals,
4465
+ fingerprint,
4466
+ });
4319
4467
  messagesProcessed += 1;
4320
4468
  eventsAggregated += 1;
4321
4469
 
@@ -4372,22 +4520,26 @@ function resolveQoderDbPath({
4372
4520
  home = os.homedir(),
4373
4521
  env = process.env,
4374
4522
  platform = process.platform,
4523
+ appDir = "Qoder",
4524
+ envPrefix = "QODER",
4375
4525
  } = {}) {
4376
- if (typeof env.QODER_DB_PATH === "string" && env.QODER_DB_PATH.trim()) {
4377
- return path.resolve(env.QODER_DB_PATH.trim());
4526
+ const dbPathKey = `${envPrefix}_DB_PATH`;
4527
+ const homeKey = `${envPrefix}_HOME`;
4528
+ if (typeof env[dbPathKey] === "string" && env[dbPathKey].trim()) {
4529
+ return path.resolve(env[dbPathKey].trim());
4378
4530
  }
4379
4531
  let root;
4380
- if (typeof env.QODER_HOME === "string" && env.QODER_HOME.trim()) {
4381
- root = path.resolve(env.QODER_HOME.trim());
4532
+ if (typeof env[homeKey] === "string" && env[homeKey].trim()) {
4533
+ root = path.resolve(env[homeKey].trim());
4382
4534
  } else if (platform === "darwin") {
4383
- root = path.join(home, "Library", "Application Support", "Qoder");
4535
+ root = path.join(home, "Library", "Application Support", appDir);
4384
4536
  } else if (platform === "win32") {
4385
4537
  root = path.join(
4386
4538
  env.APPDATA || path.join(home, "AppData", "Roaming"),
4387
- "Qoder",
4539
+ appDir,
4388
4540
  );
4389
4541
  } else {
4390
- root = path.join(home, ".config", "Qoder");
4542
+ root = path.join(home, ".config", appDir);
4391
4543
  }
4392
4544
  return path.join(root, "SharedClientCache", "cache", "db", "local.db");
4393
4545
  }
@@ -4396,17 +4548,21 @@ function resolveQoderDbPaths({
4396
4548
  home = os.homedir(),
4397
4549
  env = process.env,
4398
4550
  platform = process.platform,
4551
+ appDir = "Qoder",
4552
+ envPrefix = "QODER",
4399
4553
  deps = {},
4400
4554
  } = {}) {
4401
- const nativeValue = resolveQoderDbPath({ home, env, platform });
4402
- if (platform !== "win32" || env.QODER_DB_PATH || env.QODER_HOME) {
4555
+ const dbPathKey = `${envPrefix}_DB_PATH`;
4556
+ const homeKey = `${envPrefix}_HOME`;
4557
+ const nativeValue = resolveQoderDbPath({ home, env, platform, appDir, envPrefix });
4558
+ if (platform !== "win32" || env[dbPathKey] || env[homeKey]) {
4403
4559
  return { native: nativeValue, wsl: null };
4404
4560
  }
4405
4561
  const existsSync = deps.existsSync || fssync.existsSync;
4406
4562
  const native = wsl.shouldProbeNative(env) && existsSync(nativeValue) ? nativeValue : null;
4407
4563
  const discoverWslHome = deps.discoverWslHome || wsl.discoverWslHome;
4408
4564
  const wslRoot = wsl.shouldProbeWsl(env)
4409
- ? discoverWslHome(".config/Qoder", { ...deps, env })
4565
+ ? discoverWslHome(`.config/${appDir}`, { ...deps, env })
4410
4566
  : null;
4411
4567
  const wslValue = wslRoot
4412
4568
  ? path.join(wslRoot, "SharedClientCache", "cache", "db", "local.db")
@@ -4420,6 +4576,23 @@ function resolveQoderDbPaths({
4420
4576
  });
4421
4577
  }
4422
4578
 
4579
+ // Qoder CN keeps its own data directory (Application Support/QoderCN on macOS,
4580
+ // AppData/Roaming/QoderCN on Windows, .config/QoderCN on Linux), so it needs
4581
+ // its own path resolution. The token schema is identical to the international
4582
+ // edition — the same QODER_USAGE_SQL and parser are reused with a distinct
4583
+ // source/cursor namespace to avoid rowid collisions between the two DBs. CN
4584
+ // honors its own env overrides (QODER_CN_DB_PATH / QODER_CN_HOME) so that
4585
+ // QODER_HOME/QODER_DB_PATH, which point at the international install, never
4586
+ // redirect the CN resolver onto the same database (that would double-count).
4587
+ function resolveQoderCnDbPaths({
4588
+ home = os.homedir(),
4589
+ env = process.env,
4590
+ platform = process.platform,
4591
+ deps = {},
4592
+ } = {}) {
4593
+ return resolveQoderDbPaths({ home, env, platform, appDir: "QoderCN", envPrefix: "QODER_CN", deps });
4594
+ }
4595
+
4423
4596
  async function readQoderDbMessages(dbPath, sqliteOptions = {}) {
4424
4597
  if (!dbPath || !fssync.existsSync(dbPath)) return [];
4425
4598
  return readSqliteJsonRowsAsync(dbPath, QODER_USAGE_SQL, {
@@ -4517,11 +4690,13 @@ async function parseQoderDbIncremental({
4517
4690
  projectQueuePath,
4518
4691
  onProgress,
4519
4692
  publicRepoResolver,
4693
+ sourceKey = "qoder",
4694
+ cursorKey = "qoder",
4520
4695
  } = {}) {
4521
4696
  await ensureDir(path.dirname(queuePath));
4522
4697
  const rows = Array.isArray(dbMessages) ? dbMessages : [];
4523
4698
  const hourlyState = normalizeHourlyState(cursors?.hourly);
4524
- const qoderState = normalizeQoderState(cursors?.qoder);
4699
+ const qoderState = normalizeQoderState(cursors?.[cursorKey]);
4525
4700
  const touchedBuckets = new Set();
4526
4701
  const projectEnabled = typeof projectQueuePath === "string" && projectQueuePath.length > 0;
4527
4702
  const projectState = projectEnabled ? normalizeProjectState(cursors?.projectHourly) : null;
@@ -4606,40 +4781,40 @@ async function parseQoderDbIncremental({
4606
4781
  if (previousTotals && previous.bucketStart && previous.model) {
4607
4782
  const oldBucket = getHourlyBucket(
4608
4783
  hourlyState,
4609
- "qoder",
4784
+ sourceKey,
4610
4785
  previous.model,
4611
4786
  previous.bucketStart,
4612
4787
  );
4613
4788
  subtractTotals(oldBucket.totals, previousTotals);
4614
- touchedBuckets.add(bucketKey("qoder", previous.model, previous.bucketStart));
4789
+ touchedBuckets.add(bucketKey(sourceKey, previous.model, previous.bucketStart));
4615
4790
  if (projectEnabled && previous.projectKey) {
4616
4791
  const oldProjectBucket = getProjectBucket(
4617
4792
  projectState,
4618
4793
  previous.projectKey,
4619
- "qoder",
4794
+ sourceKey,
4620
4795
  previous.bucketStart,
4621
4796
  previous.projectRef || null,
4622
4797
  );
4623
4798
  subtractTotals(oldProjectBucket.totals, previousTotals);
4624
4799
  projectTouchedBuckets.add(
4625
- projectBucketKey(previous.projectKey, "qoder", previous.bucketStart),
4800
+ projectBucketKey(previous.projectKey, sourceKey, previous.bucketStart),
4626
4801
  );
4627
4802
  }
4628
4803
  }
4629
4804
 
4630
- const bucket = getHourlyBucket(hourlyState, "qoder", model, bucketStart);
4805
+ const bucket = getHourlyBucket(hourlyState, sourceKey, model, bucketStart);
4631
4806
  addTotals(bucket.totals, currentTotals);
4632
- touchedBuckets.add(bucketKey("qoder", model, bucketStart));
4807
+ touchedBuckets.add(bucketKey(sourceKey, model, bucketStart));
4633
4808
  if (projectEnabled && projectKey) {
4634
4809
  const projectBucket = getProjectBucket(
4635
4810
  projectState,
4636
4811
  projectKey,
4637
- "qoder",
4812
+ sourceKey,
4638
4813
  bucketStart,
4639
4814
  projectRef,
4640
4815
  );
4641
4816
  addTotals(projectBucket.totals, currentTotals);
4642
- projectTouchedBuckets.add(projectBucketKey(projectKey, "qoder", bucketStart));
4817
+ projectTouchedBuckets.add(projectBucketKey(projectKey, sourceKey, bucketStart));
4643
4818
  }
4644
4819
  qoderState.messages[messageKey] = {
4645
4820
  totals: currentTotals,
@@ -4682,7 +4857,7 @@ async function parseQoderDbIncremental({
4682
4857
  hourlyState.updatedAt = updatedAt;
4683
4858
  qoderState.updatedAt = updatedAt;
4684
4859
  cursors.hourly = hourlyState;
4685
- cursors.qoder = qoderState;
4860
+ cursors[cursorKey] = qoderState;
4686
4861
  if (projectState) {
4687
4862
  projectState.updatedAt = updatedAt;
4688
4863
  cursors.projectHourly = projectState;
@@ -12055,18 +12230,25 @@ function resolvePiSessionFiles(env = process.env) {
12055
12230
  const sessionsDir = path.join(agentDir, "sessions");
12056
12231
  if (!fssync.existsSync(sessionsDir)) return [];
12057
12232
  const files = [];
12233
+ const walk = (dir) => {
12234
+ let entries;
12235
+ try { entries = fssync.readdirSync(dir, { withFileTypes: true }); } catch { return; }
12236
+ for (const entry of entries) {
12237
+ const full = path.join(dir, entry.name);
12238
+ if (entry.isDirectory()) {
12239
+ walk(full);
12240
+ } else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
12241
+ files.push(full);
12242
+ }
12243
+ }
12244
+ };
12058
12245
  try {
12059
12246
  for (const cwdDir of fssync.readdirSync(sessionsDir)) {
12060
12247
  const cwdPath = path.join(sessionsDir, cwdDir);
12061
12248
  let stat;
12062
12249
  try { stat = fssync.statSync(cwdPath); } catch { continue; }
12063
12250
  if (!stat.isDirectory()) continue;
12064
- let entries;
12065
- try { entries = fssync.readdirSync(cwdPath); } catch { continue; }
12066
- for (const entry of entries) {
12067
- if (!entry.endsWith(".jsonl")) continue;
12068
- files.push(path.join(cwdPath, entry));
12069
- }
12251
+ walk(cwdPath);
12070
12252
  }
12071
12253
  } catch {
12072
12254
  // ignore — return what we have
@@ -15816,6 +15998,7 @@ module.exports = {
15816
15998
  readZcodeDbMessages,
15817
15999
  resolveQoderDbPath,
15818
16000
  resolveQoderDbPaths,
16001
+ resolveQoderCnDbPaths,
15819
16002
  readQoderDbMessages,
15820
16003
  resolveKiroBasePath,
15821
16004
  resolveKiroDbPath,
@@ -31,6 +31,7 @@ const readline = require("node:readline");
31
31
  const { listClaudeProjectFiles, listRolloutFilesDeep, claudeMessageDedupKey } = require("./rollout");
32
32
  const { parseCodexRolloutFile } = require("./codex-rollout-parser");
33
33
  const { computeRowCost } = require("./pricing");
34
+ const wsl = require("./wsl-probe");
34
35
 
35
36
  // Bump the sidecar when derived metrics change so cached rows are rebuilt
36
37
  // instead of leaving the dashboard on the previous (over-counted) heuristic.
@@ -761,14 +762,105 @@ async function scanGrokSession(filePath) {
761
762
  });
762
763
  }
763
764
 
764
- async function discoverSessionFiles(home) {
765
+ // Multi-root discovery: on Windows a native install and a WSL one both count.
766
+ // `sync` already walks both Claude homes (src/commands/sync.js), so without this
767
+ // the WSL work lands in the token totals but is invisible in the session browser
768
+ // and its project list. TOKENTRACKER_WSL_MODE gates which sides are probed;
769
+ // duplicated files synced between environments collapse via the Codex session-id
770
+ // pass below and claudeMessageDedupKey downstream.
771
+ function providerRoots(home, providerDir, env, deps = {}) {
772
+ const platform = deps.platform || process.platform;
773
+ const homedir = deps.homedir || os.homedir;
774
+ const discoverWslHome = deps.discoverWslHome || wsl.discoverWslHome;
775
+ const roots = [];
776
+ if (platform !== "win32" || wsl.shouldProbeNative(env)) {
777
+ roots.push(path.join(home, providerDir));
778
+ }
779
+ // Only the machine's own home has a WSL sibling worth probing.
780
+ // discoverWslHome resolves \\wsl$ independently of `home`, so probing for an
781
+ // injected home (tests, a custom HOME) would splice the machine's live WSL
782
+ // sessions into what the caller expects to be an isolated tree.
783
+ //
784
+ // This defaults rather than hard-codes: the whole reason to thread a
785
+ // non-default home is to scan a tree that is not os.homedir(), and such a
786
+ // caller would otherwise lose WSL discovery silently, with nothing failing.
787
+ // `probeWsl` is the deliberate opt-in for that case.
788
+ const probeWsl = deps.probeWsl !== undefined
789
+ ? Boolean(deps.probeWsl)
790
+ : path.resolve(home) === path.resolve(homedir());
791
+ if (platform === "win32" && probeWsl && wsl.shouldProbeWsl(env)) {
792
+ const wslRoot = discoverWslHome(providerDir, { env });
793
+ if (wslRoot) roots.push(wslRoot);
794
+ }
795
+ return [...new Set(roots)];
796
+ }
797
+
798
+ // Collapse the same Claude session discovered under more than one root.
799
+ //
800
+ // Codex gets this from its session-id pass below; Claude only ever had per-file
801
+ // message dedup (`claudeMessageDedupKey` inside `scanClaudeSession`), which
802
+ // cannot see a second copy of the same file under a different path spelling.
803
+ // Every discovered path becomes its own row keyed by the resolved file path, so
804
+ // a WSL `$HOME` pointing at the Windows profile — the same files reachable as
805
+ // both `C:\Users\dev\.claude\...` and `\\wsl$\Ubuntu\home\dev\.claude\...` —
806
+ // duplicated sessions in the browser, the project list and the CSV export.
807
+ //
808
+ // Cross-root only, on purpose: a single root is passed through verbatim, so
809
+ // single-install machines are unaffected, and two same-named files inside one
810
+ // tree stay distinct (unproven identity must not delete a session). A basename
811
+ // without a session UUID never participates either.
812
+ function dedupeClaudeFilesAcrossRoots(groups) {
813
+ const rootGroups = (groups || []).filter((group) => Array.isArray(group) && group.length > 0);
814
+ if (rootGroups.length <= 1) return [...new Set(rootGroups[0] || [])];
815
+
816
+ const mtimeOf = (filePath) => {
817
+ try { return fs.statSync(filePath).mtimeMs; } catch { return -Infinity; }
818
+ };
819
+ // Preserve root order for everything kept, so native precedence is stable.
820
+ // Claims are compared only against EARLIER roots: two same-UUID files inside
821
+ // one tree are siblings, not copies, and must both survive.
822
+ const ordered = [];
823
+ const winnerBySession = new Map();
824
+ for (const group of rootGroups) {
825
+ const claimedHere = new Map();
826
+ for (const filePath of group) {
827
+ const id = path.basename(filePath).match(/^([0-9a-f-]{36})\.jsonl$/i)?.[1] || null;
828
+ if (!id || claimedHere.has(id)) {
829
+ // No session identity, or a sibling within this same root.
830
+ if (!ordered.includes(filePath)) ordered.push(filePath);
831
+ continue;
832
+ }
833
+ claimedHere.set(id, filePath);
834
+ const previous = winnerBySession.get(id);
835
+ if (previous === undefined) {
836
+ winnerBySession.set(id, filePath);
837
+ ordered.push(filePath);
838
+ continue;
839
+ }
840
+ if (previous === filePath) continue;
841
+ // Same session in an earlier root: keep the newer file, mirroring Codex.
842
+ if (mtimeOf(filePath) > mtimeOf(previous)) {
843
+ winnerBySession.set(id, filePath);
844
+ ordered[ordered.indexOf(previous)] = filePath;
845
+ }
846
+ }
847
+ }
848
+ return [...new Set(ordered)];
849
+ }
850
+
851
+ async function discoverSessionFiles(home, env = process.env, deps = {}) {
765
852
  const grokHome = resolveGrokHome(home);
766
- const [allClaude, codex, archived, grok] = await Promise.all([
767
- listClaudeProjectFiles(path.join(home, ".claude", "projects")),
768
- listRolloutFilesDeep(path.join(home, ".codex", "sessions")),
769
- listRolloutFilesDeep(path.join(home, ".codex", "archived_sessions")),
853
+ const claudeRoots = providerRoots(home, ".claude", env, deps);
854
+ const codexRoots = providerRoots(home, ".codex", env, deps);
855
+ const [claudeGroups, codexGroups, archivedGroups, grok] = await Promise.all([
856
+ Promise.all(claudeRoots.map((r) => listClaudeProjectFiles(path.join(r, "projects")))),
857
+ Promise.all(codexRoots.map((r) => listRolloutFilesDeep(path.join(r, "sessions")))),
858
+ Promise.all(codexRoots.map((r) => listRolloutFilesDeep(path.join(r, "archived_sessions")))),
770
859
  listGrokSessionFiles(path.join(grokHome, "sessions")),
771
860
  ]);
861
+ const allClaude = dedupeClaudeFilesAcrossRoots(claudeGroups);
862
+ const codex = [...new Set(codexGroups.flat())];
863
+ const archived = [...new Set(archivedGroups.flat())];
772
864
  // Claude Memory stores thousands of background observer transcripts beside
773
865
  // real Claude Code sessions. They contain <synthetic>/haiku bookkeeping and
774
866
  // no user coding outcome, so scanning them both slows the card dramatically
@@ -1262,4 +1354,6 @@ module.exports = {
1262
1354
  listSessionsForBrowser,
1263
1355
  resumeCommandFor,
1264
1356
  sessionsToCsv,
1357
+ providerRoots,
1358
+ dedupeClaudeFilesAcrossRoots,
1265
1359
  };