tokentracker-cli 1.5.0 → 1.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (132) hide show
  1. package/README.md +1 -1
  2. package/README.zh-CN.md +1 -1
  3. package/dashboard/dist/.well-known/submitby-ai/6e2fa70e-9eb2-4c97-a9a9-fed567a3d4a9.txt +1 -0
  4. package/dashboard/dist/assets/AchievementBadge-B5kmt0VD.js +1 -0
  5. package/dashboard/dist/assets/{AchievementsPage-9_MxdI7O.js → AchievementsPage-C0FlkOo0.js} +2 -2
  6. package/dashboard/dist/assets/{AchievementsSection-CKDQGcJh.js → AchievementsSection-CRcp80Pp.js} +1 -1
  7. package/dashboard/dist/assets/{BadgeDetailModal-DdrXPVRL.js → BadgeDetailModal-DAFjGbKW.js} +1 -1
  8. package/dashboard/dist/assets/{BotAnimated-DsJDdDid.js → BotAnimated-CO0bbimB.js} +1 -1
  9. package/dashboard/dist/assets/{Card-Bz-sWY1C.js → Card-Xhr5exz8.js} +1 -1
  10. package/dashboard/dist/assets/{CloudCheckoutPage-DRjZcE0W.js → CloudCheckoutPage-DsQ7FmsI.js} +1 -1
  11. package/dashboard/dist/assets/{CloudPage-BNDyyX9r.js → CloudPage-DkSexu5V.js} +2 -2
  12. package/dashboard/dist/assets/{CommandPalette-LbE6Icc0.js → CommandPalette-Bm0huQdj.js} +1 -1
  13. package/dashboard/dist/assets/{CommunityStatsModal-DuEcAOB9.js → CommunityStatsModal-BsL8GN8Q.js} +1 -1
  14. package/dashboard/dist/assets/{ConfirmModal-B5V7ONKv.js → ConfirmModal-_WPp292g.js} +1 -1
  15. package/dashboard/dist/assets/{Controls-ZfN0QwwD.js → Controls-ktzn68KM.js} +1 -1
  16. package/dashboard/dist/assets/{DashboardPage-DqZPFmlv.js → DashboardPage-CdNXi3Im.js} +1 -1
  17. package/dashboard/dist/assets/{DateRangePopover-CbkBeaEI.js → DateRangePopover-DWr4zIzF.js} +1 -1
  18. package/dashboard/dist/assets/{DevicePage-DrHjmpPH.js → DevicePage-CBa8Mk47.js} +1 -1
  19. package/dashboard/dist/assets/{DialogClose-DHaAwV1u.js → DialogClose-CZ5jzgtb.js} +1 -1
  20. package/dashboard/dist/assets/DialogDescription-kCZ4gN09.js +1 -0
  21. package/dashboard/dist/assets/{DialogTitle-CBhd4oLc.js → DialogTitle-Dr0N6q15.js} +1 -1
  22. package/dashboard/dist/assets/{ElectricLogo-CSWd3b2H.js → ElectricLogo-sYqgrO6-.js} +1 -1
  23. package/dashboard/dist/assets/{FadeIn-D5p6nDla.js → FadeIn-Qq-hcrZW.js} +1 -1
  24. package/dashboard/dist/assets/{HeaderGithubStar-BKVSzY27.js → HeaderGithubStar-B77zSv7i.js} +1 -1
  25. package/dashboard/dist/assets/{HoverTooltip-CvkAyCDm.js → HoverTooltip-lRDaUmoW.js} +1 -1
  26. package/dashboard/dist/assets/{IpCheckPage-BEMJGKtl.js → IpCheckPage-B6AvHZjA.js} +1 -1
  27. package/dashboard/dist/assets/{LandingPage-iRp33enQ.js → LandingPage-DgzOjf3x.js} +2 -2
  28. package/dashboard/dist/assets/{LeaderboardPage-DVMPrN4z.js → LeaderboardPage-BtjjtDMC.js} +2 -2
  29. package/dashboard/dist/assets/{LeaderboardProBadge-DwnaJoje.js → LeaderboardProBadge-B2wNTUTU.js} +1 -1
  30. package/dashboard/dist/assets/{LeaderboardProfileModal-DnzVweLM.js → LeaderboardProfileModal-DJJckwHh.js} +3 -3
  31. package/dashboard/dist/assets/{LeaderboardProfilePage-BHNuPuW7.js → LeaderboardProfilePage-DJdxByl2.js} +1 -1
  32. package/dashboard/dist/assets/LimitsPage-gSBHSBi_.js +6 -0
  33. package/dashboard/dist/assets/{LocalOnlyNotice-DStlbPET.js → LocalOnlyNotice-B2lVqf_H.js} +1 -1
  34. package/dashboard/dist/assets/{LoginCard-I47ukXU5.js → LoginCard-i0gg1yby.js} +1 -1
  35. package/dashboard/dist/assets/{LoginModal-C4iotNjY.js → LoginModal-B-loLNZM.js} +1 -1
  36. package/dashboard/dist/assets/{LoginPage-46QSEnG0.js → LoginPage-D_swI75j.js} +1 -1
  37. package/dashboard/dist/assets/{PetPage-SvgDUR8o.js → PetPage-DDpKvpjG.js} +2 -2
  38. package/dashboard/dist/assets/{PopoverPopup-k2ejLK1I.js → PopoverPopup-Gja4MyEK.js} +1 -1
  39. package/dashboard/dist/assets/{ProviderIcon-Bx1XKzin.js → ProviderIcon-DOPj7tAZ.js} +1 -1
  40. package/dashboard/dist/assets/{ResetPasswordPage-CcKIDknp.js → ResetPasswordPage-BVADLk89.js} +1 -1
  41. package/dashboard/dist/assets/{Select-D0hLQyU8.js → Select-D-38tN-E.js} +1 -1
  42. package/dashboard/dist/assets/{SelectItemText-DJGBJhDK.js → SelectItemText-jN61ukxk.js} +1 -1
  43. package/dashboard/dist/assets/{SelfHostPage-CeIFGRep.js → SelfHostPage-k7AkGpl1.js} +1 -1
  44. package/dashboard/dist/assets/ServiceStatusPage-D_Fa_pYz.js +1 -0
  45. package/dashboard/dist/assets/{SessionsPage-D6h5vSZn.js → SessionsPage-BymccEEz.js} +1 -1
  46. package/dashboard/dist/assets/{SettingsPage-BZgpB5SB.js → SettingsPage-DfnMCAuA.js} +3 -3
  47. package/dashboard/dist/assets/{SkillsPage-CtFuZ1ob.js → SkillsPage-YyIHIaHN.js} +1 -1
  48. package/dashboard/dist/assets/{TokenGalaxy-DC-no047.js → TokenGalaxy-BLcvZuQ9.js} +1 -1
  49. package/dashboard/dist/assets/{TooltipPopup-ZM0Ie2_C.js → TooltipPopup-3E8kyVzz.js} +1 -1
  50. package/dashboard/dist/assets/{TrendMonitor-fpLaqJ-o.js → TrendMonitor-ByKAdXSF.js} +1 -1
  51. package/dashboard/dist/assets/{WidgetsPage-DIoHY6ZJ.js → WidgetsPage-BgEVIIzL.js} +1 -1
  52. package/dashboard/dist/assets/{WrappedPage-D1-7o8Z1.js → WrappedPage-HjbX8QaA.js} +1 -1
  53. package/dashboard/dist/assets/__vite-browser-external-D_cMeuqI.js +45 -0
  54. package/dashboard/dist/assets/{agent-logos-YBC1iAbE.js → agent-logos-BVF2siPQ.js} +1 -1
  55. package/dashboard/dist/assets/{arrow-left-BIe3z7iW.js → arrow-left-CYO24PGP.js} +1 -1
  56. package/dashboard/dist/assets/{arrow-right-CJmRcvYZ.js → arrow-right-Xg9PZYhw.js} +1 -1
  57. package/dashboard/dist/assets/{arrow-up-right-IgkBHoCl.js → arrow-up-right-B7ah6Qv-.js} +1 -1
  58. package/dashboard/dist/assets/{calendar-days-DTuIv3ts.js → calendar-days-BV_iG938.js} +1 -1
  59. package/dashboard/dist/assets/{check-DrXhhj46.js → check--EZH8RTp.js} +1 -1
  60. package/dashboard/dist/assets/{chevron-down-DHaFKHMK.js → chevron-down-BzGTlk0-.js} +1 -1
  61. package/dashboard/dist/assets/{cloud-checkout-BffVMyzI.js → cloud-checkout-BgyxPHEh.js} +1 -1
  62. package/dashboard/dist/assets/{composite-M_CP_KSx.js → composite-C0KrmLEK.js} +1 -1
  63. package/dashboard/dist/assets/{copy-D5xoS-Q8.js → copy-BtucSK2U.js} +1 -1
  64. package/dashboard/dist/assets/{download-CqomMv4-.js → download-CWHq03Y2.js} +1 -1
  65. package/dashboard/dist/assets/{external-link--q3pCw6b.js → external-link-DCTXF5VZ.js} +1 -1
  66. package/dashboard/dist/assets/{flame-CUGlDV__.js → flame-BE5TsqVF.js} +1 -1
  67. package/dashboard/dist/assets/{format-tokens-BZ6TX9Km.js → format-tokens-ThNIMWnZ.js} +1 -1
  68. package/dashboard/dist/assets/{gift-BBgOeC20.js → gift-DgaN9s4-.js} +1 -1
  69. package/dashboard/dist/assets/{icons-BELeB4ms.js → icons-B4r5EiDC.js} +1 -1
  70. package/dashboard/dist/assets/{index-B1B6oAbl.js → index-CKhcCLNC.js} +1 -1
  71. package/dashboard/dist/assets/{index-CsdgnKGf.js → index-DFF9xo6f.js} +1 -1
  72. package/dashboard/dist/assets/{index-CoRqgJ71.js → index-DqCtgU0G.js} +1 -1
  73. package/dashboard/dist/assets/{index-DkBvk4Qg.js → index-fNKH4U7j.js} +33 -33
  74. package/dashboard/dist/assets/{info-BG1vQZ1r.js → info-B4YzRR4y.js} +1 -1
  75. package/dashboard/dist/assets/{link-2-oi5TrXqi.js → link-2-DzbUbaBw.js} +1 -1
  76. package/dashboard/dist/assets/{loader-circle-BQQ_Xbby.js → loader-circle-B9c7D-7v.js} +1 -1
  77. package/dashboard/dist/assets/{local-usage-transfer-Dwz-clJ-.js → local-usage-transfer-btuk_b0Y.js} +1 -1
  78. package/dashboard/dist/assets/{modifiers.esm-C7a817l7.js → modifiers.esm-BjrU-Xz7.js} +1 -1
  79. package/dashboard/dist/assets/module-ZyVsxBG_.js +3 -0
  80. package/dashboard/dist/assets/{monitor-smartphone-Bnbo7uRg.js → monitor-smartphone-Bwn4tb4q.js} +1 -1
  81. package/dashboard/dist/assets/{play-C_xI_JM_.js → play-DIFOwGIQ.js} +1 -1
  82. package/dashboard/dist/assets/{plus-xF6PK5As.js → plus-BpBqnwVg.js} +1 -1
  83. package/dashboard/dist/assets/{provider-display-DxquW3js.js → provider-display-DSrs4u7T.js} +1 -1
  84. package/dashboard/dist/assets/{react-CGK0_x3G.js → react-BgG7_MY0.js} +1 -1
  85. package/dashboard/dist/assets/{safePolygon-CCoDMR4f.js → safePolygon-BUGWIDOM.js} +1 -1
  86. package/dashboard/dist/assets/{search-EZAGpmfJ.js → search-aHrmBmtZ.js} +1 -1
  87. package/dashboard/dist/assets/{server-2qbrx2G0.js → server-DOEvwlEU.js} +1 -1
  88. package/dashboard/dist/assets/{shield-check-D8kUejpE.js → shield-check-BPTnLhsT.js} +1 -1
  89. package/dashboard/dist/assets/{skills-api-DYPf1Vxh.js → skills-api-C052Mwez.js} +1 -1
  90. package/dashboard/dist/assets/{sortable.esm-D9gC08dQ.js → sortable.esm-DaZKhjhu.js} +1 -1
  91. package/dashboard/dist/assets/{store-BWUCbK6Y.js → store-Cq30Pofw.js} +1 -1
  92. package/dashboard/dist/assets/{terminal-B16b7r6H.js → terminal-CoSDSkE7.js} +1 -1
  93. package/dashboard/dist/assets/{trash-BIEoE13C.js → trash-BAmt-kPj.js} +1 -1
  94. package/dashboard/dist/assets/use-community-stats-Bxq0kBhm.js +1 -0
  95. package/dashboard/dist/assets/{use-latest-request-guard-CYKu-VOB.js → use-latest-request-guard-DmIQDlvk.js} +1 -1
  96. package/dashboard/dist/assets/use-limits-display-prefs-CyAn1fme.js +1 -0
  97. package/dashboard/dist/assets/{use-native-settings-vTg4j5Ln.js → use-native-settings-s_CIC8Bb.js} +1 -1
  98. package/dashboard/dist/assets/{use-ordered-list-CtXedlqH.js → use-ordered-list-BHhykAbt.js} +1 -1
  99. package/dashboard/dist/assets/use-reduced-motion-BmFyFE3S.js +1 -0
  100. package/dashboard/dist/assets/{use-transform-CdeW5xhz.js → use-transform-CVeMFlS6.js} +1 -1
  101. package/dashboard/dist/assets/use-usage-limits-DRZMI20u.js +1 -0
  102. package/dashboard/dist/assets/{useCurrency-bqycM681.js → useCurrency-DilqL2Iv.js} +1 -1
  103. package/dashboard/dist/assets/{usePositioner-DEXS10fY.js → usePositioner-BcnySw8a.js} +1 -1
  104. package/dashboard/dist/assets/{useTokenFormat-YF6hY8_m.js → useTokenFormat-CvKW46WP.js} +1 -1
  105. package/dashboard/dist/assets/{zap-Dg-ij-lq.js → zap-D7VRNTwk.js} +1 -1
  106. package/dashboard/dist/index.html +24 -1
  107. package/dashboard/dist/indexnow-key.txt +1 -0
  108. package/dashboard/dist/ip-check.html +1 -1
  109. package/dashboard/dist/leaderboard.html +1 -1
  110. package/dashboard/dist/share.html +1 -1
  111. package/dashboard/dist/tt6a1c9e2f4b8d.txt +1 -0
  112. package/package.json +3 -3
  113. package/src/lib/codex-rollout-parser.js +43 -6
  114. package/src/lib/codex-usage-record.js +21 -0
  115. package/src/lib/pricing/curated-overrides.json +94 -0
  116. package/src/lib/pricing/seed-snapshot.json +1 -1
  117. package/src/lib/qoder-limits.js +252 -55
  118. package/src/lib/rollout.js +68 -14
  119. package/src/lib/session-analytics.js +10 -3
  120. package/src/lib/usage-limits.js +6 -4
  121. package/src/lib/workbuddy-limits.js +101 -9
  122. package/src/lib/zcode-limits.js +350 -43
  123. package/dashboard/dist/assets/AchievementBadge-Dn39pyx9.js +0 -1
  124. package/dashboard/dist/assets/DialogDescription-CEY6jbs9.js +0 -1
  125. package/dashboard/dist/assets/LimitsPage-9LitacBf.js +0 -6
  126. package/dashboard/dist/assets/ServiceStatusPage-DMtw0YDh.js +0 -1
  127. package/dashboard/dist/assets/__vite-browser-external-DZF5L1pt.js +0 -45
  128. package/dashboard/dist/assets/module-C2SOSUuR.js +0 -3
  129. package/dashboard/dist/assets/use-community-stats-DWS7YKbM.js +0 -1
  130. package/dashboard/dist/assets/use-limits-display-prefs-CEAjVxFr.js +0 -1
  131. package/dashboard/dist/assets/use-reduced-motion-DVLsAhSA.js +0 -1
  132. package/dashboard/dist/assets/use-usage-limits-DmDa5Py8.js +0 -1
@@ -40,6 +40,14 @@ const QODER_ACTIVITY_CACHE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
40
40
  const QODER_LIMITS_CACHE_FILE = "qoder-usage-limits-cache.json";
41
41
  const QODER_LIMITS_CACHE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
42
42
  const QODER_LIMITS_CACHE_UNKNOWN_RESET_TTL_MS = 12 * 60 * 60 * 1000;
43
+ // Qoder Desktop 0.4+ writes its account usage summary to main.log, which can
44
+ // accumulate a whole session before the quota is read. Scan a bounded window
45
+ // backwards instead of assuming the summary is near EOF.
46
+ const QODER_LOG_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
47
+ const QODER_LOG_SCAN_MAX_BYTES = 8 * 1024 * 1024;
48
+ const QODER_LOG_SCAN_CHUNK_BYTES = 512 * 1024;
49
+ const QODER_LOG_SCAN_OVERLAP_BYTES = 64 * 1024;
50
+ const QODER_LOG_MAX_FILES = 8;
43
51
  const QODER_SERVER_PUBLIC_KEY = `-----BEGIN PUBLIC KEY-----
44
52
  MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDA8iMH5c02LilrsERw9t6Pv5Nc
45
53
  4k6Pz1EaDicBMpdpxKduSZu5OANqUq8er4GM95omAGIOPOh+Nx0spthYA2BqGz+l
@@ -97,7 +105,10 @@ function normalizeQuotaSummary(summary) {
97
105
  if (total === 0 && (used !== 0 || remaining !== 0)) {
98
106
  throw new Error("Qoder zero total quota must have zero usage and remaining.");
99
107
  }
100
- const usagePercentage = providedPercentage ?? (total > 0 ? (used / total) * 100 : 100);
108
+ // An empty 0/0 quota (no active plan) renders as 0%, matching Qoder's own
109
+ // Credits UI and the desktop/CLI paths; a server-provided percentage still
110
+ // wins when present.
111
+ const usagePercentage = providedPercentage ?? (total > 0 ? (used / total) * 100 : 0);
101
112
  return {
102
113
  used,
103
114
  total,
@@ -123,7 +134,7 @@ function normalizeQoderUsageResponse(response) {
123
134
  const usedPercent = shared
124
135
  ? total > 0
125
136
  ? (used / total) * 100
126
- : 100
137
+ : 0
127
138
  : base.usagePercentage;
128
139
 
129
140
  return {
@@ -271,16 +282,17 @@ function normalizeQoderRpcUsage(response) {
271
282
  const used = quota ? finiteNumber(quota.used, "userQuota.used") : 0;
272
283
  const total = quota ? finiteNumber(quota.total, "userQuota.total") : 0;
273
284
  const remaining = quota ? finiteNumber(quota.remaining, "userQuota.remaining") : 0;
274
- const reportedPercent = Number(response.totalUsagePercentage ?? quota?.percentage);
285
+ // totalUsagePercentage aggregates the plan and add-on buckets and reaches
286
+ // this function on mixed scales (the OpenAPI returns fractions, the desktop
287
+ // IPC returns percents), so the primary window always derives its percent
288
+ // from its own quota numbers.
275
289
  const usedPercent = total === 0
276
290
  ? 0
277
291
  : addOnWindow
278
292
  ? (used / total) * 100
279
293
  : response.isQuotaExceeded === true
280
294
  ? 100
281
- : Number.isFinite(reportedPercent)
282
- ? reportedPercent
283
- : (used / total) * 100;
295
+ : (used / total) * 100;
284
296
  const normalizedExpiry = normalizeResetAt(response.expiresAt);
285
297
  const expiryMs = normalizedExpiry ? Date.parse(normalizedExpiry) : NaN;
286
298
  return {
@@ -575,7 +587,38 @@ async function fetchQoderUsage(cookieHeader, site, fetchImpl) {
575
587
  return normalizeQoderUsageResponse(await response.json());
576
588
  }
577
589
 
578
- function listRecentQoderRendererLogs(logRoot, maxFiles = 12) {
590
+ function qoderLogRoots({
591
+ home = os.homedir(),
592
+ platform = process.platform,
593
+ env = process.env,
594
+ appDir = "Qoder",
595
+ envPrefix = "QODER",
596
+ } = {}) {
597
+ const explicitLogRoot = env[`${envPrefix}_LOG_ROOT`];
598
+ if (typeof explicitLogRoot === "string" && explicitLogRoot.trim()) {
599
+ return [path.resolve(explicitLogRoot.trim())];
600
+ }
601
+ const configuredHome = env[`${envPrefix}_HOME`];
602
+ if (typeof configuredHome === "string" && configuredHome.trim()) {
603
+ return [path.join(path.resolve(configuredHome.trim()), "logs")];
604
+ }
605
+ const base = platform === "darwin"
606
+ ? path.join(home, "Library", "Application Support")
607
+ : platform === "win32"
608
+ ? (env.APPDATA || path.join(home, "AppData", "Roaming"))
609
+ : path.join(home, ".config");
610
+ // Qoder Desktop 0.4.x moved its Electron profile from Qoder[CN]/ to the
611
+ // bundle id directory and writes the account usage summary to main.log.
612
+ const bundleId = appDir === "QoderCN" || envPrefix === "QODER_CN"
613
+ ? "com.qodercn.app.stable"
614
+ : "com.qoder.app.stable";
615
+ return [
616
+ path.join(base, appDir, "logs"),
617
+ path.join(base, bundleId, "logs"),
618
+ ];
619
+ }
620
+
621
+ function listRecentQoderLogs(logRoot, maxFiles = QODER_LOG_MAX_FILES) {
579
622
  if (!fs.existsSync(logRoot)) return [];
580
623
  const files = [];
581
624
  const stack = [{ dir: logRoot, depth: 0 }];
@@ -591,19 +634,112 @@ function listRecentQoderRendererLogs(logRoot, maxFiles = 12) {
591
634
  const full = path.join(current.dir, entry.name);
592
635
  if (entry.isDirectory() && current.depth < 4) {
593
636
  stack.push({ dir: full, depth: current.depth + 1 });
594
- } else if (entry.isFile() && entry.name === "renderer.log") {
637
+ } else if (entry.isFile() && (entry.name === "main.log" || entry.name === "renderer.log")) {
595
638
  let mtimeMs = 0;
639
+ let size = 0;
596
640
  try {
597
- mtimeMs = fs.statSync(full).mtimeMs;
641
+ const stat = fs.statSync(full);
642
+ mtimeMs = stat.mtimeMs;
643
+ size = stat.size;
598
644
  } catch (_error) {}
599
- files.push({ full, mtimeMs });
645
+ files.push({ full, mtimeMs, size });
600
646
  }
601
647
  }
602
648
  }
603
649
  return files
604
650
  .sort((a, b) => b.mtimeMs - a.mtimeMs)
605
- .slice(0, maxFiles)
606
- .map((entry) => entry.full);
651
+ .slice(0, maxFiles);
652
+ }
653
+
654
+ // Returns a parse hit, or null after a completed scan with no hit; undefined
655
+ // means the scan itself failed (open/read) so callers must not treat it as
656
+ // "no summary".
657
+ function scanQoderLogFromEnd(logPath, parse, {
658
+ maxBytes = QODER_LOG_SCAN_MAX_BYTES,
659
+ chunkBytes = QODER_LOG_SCAN_CHUNK_BYTES,
660
+ overlapBytes = QODER_LOG_SCAN_OVERLAP_BYTES,
661
+ } = {}) {
662
+ let fd;
663
+ try {
664
+ fd = fs.openSync(logPath, "r");
665
+ } catch (_error) {
666
+ return undefined;
667
+ }
668
+ try {
669
+ const size = fs.fstatSync(fd).size;
670
+ const floor = Math.max(0, size - maxBytes);
671
+ let end = size;
672
+ let carry = "";
673
+ while (end > floor) {
674
+ const start = Math.max(floor, end - chunkBytes);
675
+ const length = end - start;
676
+ const buffer = Buffer.alloc(length);
677
+ const read = fs.readSync(fd, buffer, 0, length, start);
678
+ const text = buffer.subarray(0, read).toString("utf8") + carry;
679
+ const parsed = parse(text);
680
+ if (parsed) return parsed;
681
+ // Keep this chunk's head so a line split across the boundary parses
682
+ // when the previous chunk is prepended.
683
+ carry = text.slice(0, overlapBytes);
684
+ end = start;
685
+ }
686
+ } catch (_error) {
687
+ return undefined;
688
+ } finally {
689
+ fs.closeSync(fd);
690
+ }
691
+ return null;
692
+ }
693
+
694
+ function parseQoderAccountUsageLog(text, { fallbackObservedAtMs = null } = {}) {
695
+ if (typeof text !== "string" || !text.includes('"qoderUsage"')) return null;
696
+ const lines = text.split(/\r?\n/);
697
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
698
+ const line = lines[index];
699
+ if (!line.includes('"qoderUsage"')) continue;
700
+ const jsonStart = line.indexOf('{"') >= 0 ? line.indexOf('{"') : line.indexOf("{");
701
+ if (jsonStart < 0) continue;
702
+ let payload;
703
+ try {
704
+ payload = JSON.parse(line.slice(jsonStart));
705
+ } catch (_error) {
706
+ try {
707
+ payload = JSON.parse(line.slice(jsonStart, line.lastIndexOf("}") + 1));
708
+ } catch (_error2) {
709
+ continue;
710
+ }
711
+ }
712
+ const usage = payload?.qoderUsage;
713
+ if (!usage || typeof usage !== "object") continue;
714
+ let limits;
715
+ try {
716
+ limits = normalizeQoderRpcUsage({
717
+ userType: usage.userType,
718
+ expiresAt: usage.expiresAt,
719
+ isQuotaExceeded: usage.isQuotaExceeded,
720
+ totalUsagePercentage: usage.totalUsagePercentage,
721
+ userQuota: usage.userQuota,
722
+ addOnQuota: usage.addOnQuota,
723
+ });
724
+ } catch (_error) {
725
+ continue;
726
+ }
727
+ if (!limits) continue;
728
+ limits.source = "local-log";
729
+ const timestampMatch = /^\[([^\]]+)\]/.exec(line);
730
+ const lineObservedAtMs = timestampMatch ? Date.parse(timestampMatch[1]) : NaN;
731
+ return {
732
+ limits,
733
+ observedAtMs: Number.isFinite(lineObservedAtMs) ? lineObservedAtMs : fallbackObservedAtMs,
734
+ // The summary's own timestamp, or null when the line carries no parseable
735
+ // one and observedAtMs fell back to the file mtime. Callers that order
736
+ // this snapshot against other sources must use it, not observedAtMs: an
737
+ // mtime bump from an unrelated append would otherwise masquerade as a
738
+ // fresh observation.
739
+ lineObservedAtMs: Number.isFinite(lineObservedAtMs) ? lineObservedAtMs : null,
740
+ };
741
+ }
742
+ return null;
607
743
  }
608
744
 
609
745
  function parseQoderQuotaLog(text) {
@@ -640,46 +776,80 @@ function parseQoderQuotaLog(text) {
640
776
  };
641
777
  }
642
778
 
643
- function readQoderLocalQuota({
779
+ // Maps a log path to its "size:mtimeMs" signature from the last completed
780
+ // scan that found no usable summary (failed I/O is never recorded). Qoder
781
+ // only appends to these logs, so an unchanged signature cannot have gained
782
+ // one; pruning to the current candidate set keeps the map bounded.
783
+ const qoderNoSummaryScans = new Map();
784
+
785
+ function readQoderLocalQuotaWithAge({
644
786
  home = os.homedir(),
645
787
  platform = process.platform,
646
788
  env = process.env,
647
789
  appDir = "Qoder",
648
790
  envPrefix = "QODER",
791
+ nowMs = Date.now(),
792
+ maxAgeMs = QODER_LOG_MAX_AGE_MS,
793
+ olderThanMs = -Infinity,
649
794
  } = {}) {
650
- const logRootKey = `${envPrefix}_LOG_ROOT`;
651
- const homeKey = `${envPrefix}_HOME`;
652
- const explicitLogRoot =
653
- typeof env[logRootKey] === "string" && env[logRootKey].trim()
654
- ? path.resolve(env[logRootKey].trim())
655
- : null;
656
- const configuredRoot =
657
- typeof env[homeKey] === "string" && env[homeKey].trim()
658
- ? path.resolve(env[homeKey].trim())
659
- : null;
660
- const logRoot = explicitLogRoot || (configuredRoot
661
- ? path.join(configuredRoot, "logs")
662
- : platform === "darwin"
663
- ? path.join(home, "Library", "Application Support", appDir, "logs")
664
- : platform === "win32"
665
- ? path.join(env.APPDATA || path.join(home, "AppData", "Roaming"), appDir, "logs")
666
- : path.join(home, ".config", appDir, "logs"));
667
- for (const logPath of listRecentQoderRendererLogs(logRoot)) {
668
- try {
669
- const fd = fs.openSync(logPath, "r");
670
- try {
671
- const stat = fs.fstatSync(fd);
672
- const length = Math.min(stat.size, 512 * 1024);
673
- const buffer = Buffer.alloc(length);
674
- fs.readSync(fd, buffer, 0, length, Math.max(0, stat.size - length));
675
- const parsed = parseQoderQuotaLog(buffer.toString("utf8"));
676
- if (parsed) return parsed;
677
- } finally {
678
- fs.closeSync(fd);
795
+ const candidates = [];
796
+ for (const logRoot of qoderLogRoots({ home, platform, env, appDir, envPrefix })) {
797
+ for (const entry of listRecentQoderLogs(logRoot)) {
798
+ if (nowMs - entry.mtimeMs > maxAgeMs) continue;
799
+ // A file last written at or before the cache snapshot cannot hold an
800
+ // observation newer than it (a line's time never exceeds the file
801
+ // mtime), so scanning it could never win the timestamp comparison.
802
+ if (entry.mtimeMs <= olderThanMs) continue;
803
+ candidates.push(entry);
804
+ }
805
+ }
806
+ candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
807
+ const scanned = candidates.slice(0, QODER_LOG_MAX_FILES);
808
+ const seenPaths = new Set();
809
+ let best = null;
810
+ for (const { full, mtimeMs, size } of scanned) {
811
+ seenPaths.add(full);
812
+ const signature = `${size}:${mtimeMs}`;
813
+ if (qoderNoSummaryScans.get(full) === signature) continue;
814
+ // A stale modern summary must not end the scan: fall through so a legacy
815
+ // match in the same chunk — or deeper — can still be used; only a fresh
816
+ // modern result returns early.
817
+ const parsed = scanQoderLogFromEnd(full, (text) => {
818
+ const accountUsage = parseQoderAccountUsageLog(text, { fallbackObservedAtMs: mtimeMs });
819
+ if (accountUsage && nowMs - (accountUsage.observedAtMs ?? mtimeMs) <= maxAgeMs) {
820
+ return accountUsage;
679
821
  }
680
- } catch (_error) {}
822
+ const legacy = parseQoderQuotaLog(text);
823
+ return legacy ? { limits: legacy, observedAtMs: mtimeMs, lineObservedAtMs: null } : null;
824
+ });
825
+ if (parsed === undefined) continue; // Scan failed (I/O); retry on the next call.
826
+ if (!parsed) {
827
+ qoderNoSummaryScans.set(full, signature);
828
+ continue;
829
+ }
830
+ const observedAtMs = parsed.observedAtMs ?? mtimeMs;
831
+ if (nowMs - observedAtMs > maxAgeMs) continue;
832
+ // A summary that cannot beat the cache cutoff is not eligible: keep
833
+ // scanning, a deeper file may still hold a newer line that can.
834
+ if ((parsed.lineObservedAtMs ?? 0) <= olderThanMs) continue;
835
+ // A file's mtime order need not match its summary-line order: an append
836
+ // without a fresh summary leaves an old line in a freshly touched file.
837
+ // Keep the newest eligible line across the whole scan instead of the
838
+ // first hit — advancing the cache cutoff afterwards must not lock out a
839
+ // summary that was newer all along.
840
+ const lineObservedAtMs = parsed.lineObservedAtMs ?? null;
841
+ if (!best || (lineObservedAtMs ?? 0) > (best.lineObservedAtMs ?? 0)) {
842
+ best = { limits: parsed.limits, lineObservedAtMs };
843
+ }
681
844
  }
682
- return null;
845
+ for (const cachedPath of qoderNoSummaryScans.keys()) {
846
+ if (!seenPaths.has(cachedPath)) qoderNoSummaryScans.delete(cachedPath);
847
+ }
848
+ return best;
849
+ }
850
+
851
+ function readQoderLocalQuota(options = {}) {
852
+ return readQoderLocalQuotaWithAge(options)?.limits ?? null;
683
853
  }
684
854
 
685
855
  function siteFromEnv(value) {
@@ -760,9 +930,34 @@ async function fetchQoderLimits({
760
930
  }
761
931
  }
762
932
 
763
- // Once native sources are unavailable, prefer a bounded disk snapshot before
764
- // the manual-cookie or local-log fallback.
933
+ // A 0.4.x desktop app refreshes its main.log summary whenever the account
934
+ // page reloads, so the newest line can be newer than the last-good cache.
935
+ // Compare the summary's own timestamp against the cache instead of always
936
+ // trusting the cache: only a parseable line timestamp may win, and files
937
+ // last written before the cache are skipped without a scan. Without a valid
938
+ // cache there is nothing to compare against, so the summary defers to the
939
+ // live manual-cookie session below and only serves as its fallback.
765
940
  const cachedLimits = readQoderLimitsCache({ home, nowMs, namespace: cacheNamespace });
941
+ const cachedAtMs = Date.parse(cachedLimits?.cached_at || "");
942
+ const cacheFloorMs = Number.isFinite(cachedAtMs) ? cachedAtMs : -Infinity;
943
+ const localScan = readQoderLocalQuotaWithAge({
944
+ home,
945
+ platform,
946
+ env,
947
+ appDir,
948
+ envPrefix,
949
+ nowMs,
950
+ olderThanMs: cacheFloorMs,
951
+ });
952
+ if (localScan && Number.isFinite(cacheFloorMs) && (localScan.lineObservedAtMs ?? 0) > cacheFloorMs) {
953
+ const local = localScan.limits;
954
+ if (activityWindow) {
955
+ local.secondary_window = activityWindow;
956
+ local.source += activitySource === "disk-cache" ? "+cached-activity" : "+activity";
957
+ }
958
+ writeQoderLimitsCache(local, { home, nowMs, namespace: cacheNamespace });
959
+ return local;
960
+ }
766
961
  if (cachedLimits) {
767
962
  if (activityWindow) cachedLimits.secondary_window = activityWindow;
768
963
  return cachedLimits;
@@ -791,15 +986,15 @@ async function fetchQoderLimits({
791
986
  // Qoder 1.18 can return an all-zero active plan with usage_percentage=0
792
987
  // while the desktop client separately marks the account quota-exceeded.
793
988
  // Preserve that status as metadata, but match Qoder's Credits UI by
794
- // rendering an empty 0/0 bucket as 0%, not a full red 100% bar.
795
- const local = result.primary_window?.limit_credits === 0
796
- ? readQoderLocalQuota({ home, platform, env, appDir, envPrefix })
797
- : null;
798
- if (local) {
799
- result.quota_exceeded = local.quota_exceeded === true;
989
+ // rendering an empty 0/0 bucket as 0%, not a full red 100% bar. With no
990
+ // valid cache the log scan above only ran as a companion — it lost its
991
+ // spot ahead of this session — so its result is still available here.
992
+ const zeroPlanLocal = result.primary_window?.limit_credits === 0 ? localScan : null;
993
+ if (zeroPlanLocal) {
994
+ result.quota_exceeded = zeroPlanLocal.limits.quota_exceeded === true;
800
995
  result.primary_window.reset_at = null;
801
996
  result.source = "provider-api+local-log";
802
- result.plan_label = local.plan_label || null;
997
+ result.plan_label = zeroPlanLocal.limits.plan_label || null;
803
998
  }
804
999
  if (activityWindow) {
805
1000
  result.secondary_window = activityWindow;
@@ -817,8 +1012,8 @@ async function fetchQoderLimits({
817
1012
  }
818
1013
  }
819
1014
 
820
- const local = readQoderLocalQuota({ home, platform, env, appDir, envPrefix });
821
- if (local) {
1015
+ if (localScan) {
1016
+ const local = localScan.limits;
822
1017
  if (activityWindow) {
823
1018
  local.secondary_window = activityWindow;
824
1019
  local.source += activitySource === "disk-cache" ? "+cached-activity" : "+activity";
@@ -826,6 +1021,7 @@ async function fetchQoderLimits({
826
1021
  writeQoderLimitsCache(local, { home, nowMs, namespace: cacheNamespace });
827
1022
  return local;
828
1023
  }
1024
+
829
1025
  if (activityWindow) {
830
1026
  return {
831
1027
  configured: true,
@@ -874,6 +1070,7 @@ module.exports = {
874
1070
  readQoderLimitsCache,
875
1071
  writeQoderLimitsCache,
876
1072
  parseQoderQuotaLog,
1073
+ parseQoderAccountUsageLog,
877
1074
  readQoderLocalQuota,
878
1075
  fetchQoderLimits,
879
1076
  fetchQoderCnLimits,
@@ -35,6 +35,7 @@ const {
35
35
  createCompactionResponseIds,
36
36
  createUsageRecordState,
37
37
  extractTokenUsageRecord,
38
+ isCodexTotalOnlyResetSentinel,
38
39
  isCodexTurnEndEvent,
39
40
  isRecordOnly,
40
41
  noteLineAfterUsageRecord,
@@ -3606,6 +3607,67 @@ async function enqueueTouchedBuckets({ queuePath, hourlyState, touchedBuckets })
3606
3607
 
3607
3608
  const toAppend = [];
3608
3609
  for (const group of groupedBuckets.values()) {
3610
+ // A local reconstruction may opt into observed-only attribution. Preserve
3611
+ // every model bucket, including unknown, instead of guessing from the
3612
+ // busiest model in the same half-hour. The policy lives in cursor state
3613
+ // so hooks, desktop refreshes, and restarted CLI processes agree.
3614
+ if (hourlyState.modelAttributionPolicy?.[group.source] === "observed") {
3615
+ // A group last aligned under the default policy must not keep the stale
3616
+ // dominant attribution downstream. Mirror the default path's alignment
3617
+ // correction — one zero row for the old model — then observed mode
3618
+ // re-emits every bucket, unknown included, and the alignment state is
3619
+ // dropped so the correction fires at most once per group.
3620
+ const alignedModelBucket = group.buckets.get(DEFAULT_MODEL) || null;
3621
+ const staleAlignedModel = (() => {
3622
+ if (!alignedModelBucket?.alignedModel) return null;
3623
+ const normalized = normalizeModelInput(alignedModelBucket.alignedModel);
3624
+ return normalized && normalized !== DEFAULT_MODEL && !group.buckets.has(normalized)
3625
+ ? normalized
3626
+ : null;
3627
+ })();
3628
+ if (staleAlignedModel) {
3629
+ const zeroTotals = initTotals();
3630
+ toAppend.push(JSON.stringify({
3631
+ source: group.source,
3632
+ model: staleAlignedModel,
3633
+ hour_start: group.hourStart,
3634
+ input_tokens: zeroTotals.input_tokens,
3635
+ cached_input_tokens: zeroTotals.cached_input_tokens,
3636
+ cache_creation_input_tokens: zeroTotals.cache_creation_input_tokens,
3637
+ output_tokens: zeroTotals.output_tokens,
3638
+ reasoning_output_tokens: zeroTotals.reasoning_output_tokens,
3639
+ total_tokens: zeroTotals.total_tokens,
3640
+ billable_total_tokens: zeroTotals.billable_total_tokens,
3641
+ total_cost_usd: zeroTotals.total_cost_usd,
3642
+ conversation_count: zeroTotals.conversation_count,
3643
+ }));
3644
+ }
3645
+ for (const bucket of group.buckets.values()) bucket.alignedModel = null;
3646
+ for (const [model, bucket] of group.buckets.entries()) {
3647
+ const totals = bucket.totals;
3648
+ const usagePrecision = bucket.usage_precision || null;
3649
+ const key = usagePrecision ? `${totalsKey(totals)}|${usagePrecision}` : totalsKey(totals);
3650
+ if (bucket.queuedKey === key) continue;
3651
+ toAppend.push(JSON.stringify({
3652
+ source: group.source,
3653
+ model,
3654
+ hour_start: group.hourStart,
3655
+ input_tokens: totals.input_tokens,
3656
+ cached_input_tokens: totals.cached_input_tokens,
3657
+ cache_creation_input_tokens: totals.cache_creation_input_tokens,
3658
+ output_tokens: totals.output_tokens,
3659
+ reasoning_output_tokens: totals.reasoning_output_tokens,
3660
+ total_tokens: totals.total_tokens,
3661
+ billable_total_tokens: totals.billable_total_tokens ?? totals.total_tokens,
3662
+ total_cost_usd: totals.total_cost_usd || 0,
3663
+ usage_precision: usagePrecision || undefined,
3664
+ ...prioritySubsetOf(totals),
3665
+ conversation_count: totals.conversation_count,
3666
+ }));
3667
+ bucket.queuedKey = key;
3668
+ }
3669
+ continue;
3670
+ }
3609
3671
  const unknownBucket = group.buckets.get(DEFAULT_MODEL) || null;
3610
3672
  const dominantModel = pickDominantModel(group.buckets);
3611
3673
  let alignedModel = null;
@@ -3977,6 +4039,10 @@ function normalizeHourlyState(raw) {
3977
4039
  const rawBuckets = state.buckets && typeof state.buckets === "object" ? state.buckets : {};
3978
4040
  const buckets = {};
3979
4041
  const groupQueued = {};
4042
+ const modelAttributionPolicy = Object.fromEntries(
4043
+ Object.entries(state.modelAttributionPolicy || {})
4044
+ .filter(([, policy]) => policy === "observed"),
4045
+ );
3980
4046
 
3981
4047
  if (!Number.isFinite(version) || version < 2) {
3982
4048
  for (const [key, value] of Object.entries(rawBuckets)) {
@@ -3994,6 +4060,7 @@ function normalizeHourlyState(raw) {
3994
4060
  version: 3,
3995
4061
  buckets,
3996
4062
  groupQueued,
4063
+ ...(Object.keys(modelAttributionPolicy).length > 0 ? { modelAttributionPolicy } : {}),
3997
4064
  updatedAt: typeof state.updatedAt === "string" ? state.updatedAt : null,
3998
4065
  };
3999
4066
  }
@@ -4013,6 +4080,7 @@ function normalizeHourlyState(raw) {
4013
4080
  version: 3,
4014
4081
  buckets,
4015
4082
  groupQueued: version >= 3 ? existingGroupQueued : {},
4083
+ ...(Object.keys(modelAttributionPolicy).length > 0 ? { modelAttributionPolicy } : {}),
4016
4084
  updatedAt: typeof state.updatedAt === "string" ? state.updatedAt : null,
4017
4085
  };
4018
4086
  }
@@ -5246,20 +5314,6 @@ function normalizeUsage(u) {
5246
5314
  return out;
5247
5315
  }
5248
5316
 
5249
- function isCodexTotalOnlyResetSentinel(lastUsage, totalUsage) {
5250
- const componentTotal = (usage) => [
5251
- usage?.input_tokens,
5252
- usage?.cached_input_tokens,
5253
- usage?.cache_creation_input_tokens ?? usage?.cache_write_input_tokens,
5254
- usage?.output_tokens,
5255
- usage?.reasoning_output_tokens,
5256
- ].reduce((sum, value) => sum + Math.max(0, Number(value) || 0), 0);
5257
- return Number(lastUsage?.total_tokens || 0) > 0
5258
- && Number(totalUsage?.total_tokens || 0) === 0
5259
- && componentTotal(lastUsage) === 0
5260
- && componentTotal(totalUsage) === 0;
5261
- }
5262
-
5263
5317
  // Stable dedup key for one Claude jsonl entry. Anthropic's official protocol
5264
5318
  // guarantees `message.id` is globally unique per response, so msgId alone is a
5265
5319
  // valid dedup key. Older code required both msgId AND requestId, which short-
@@ -93,6 +93,8 @@ const {
93
93
  // date-filtered session views and cached sidecars keep the 6x premium.
94
94
  // v27 adds oh-my-pi (omp) sessions from ~/.omp/agent/sessions/**/*.jsonl.
95
95
  const SIDECAR_VERSION = 27;
96
+ // Invalidate only Codex token summaries; other sources keep their cached rows.
97
+ const CODEX_USAGE_VERSION = 1;
96
98
  const EDIT_TOOLS = new Set([
97
99
  "apply_patch",
98
100
  "edit",
@@ -803,6 +805,7 @@ async function scanCodexSession(filePath) {
803
805
  seenTokenEvents: new Set(),
804
806
  collectBreakdowns: false,
805
807
  collectModelUsage: true,
808
+ ledgerUsage: true,
806
809
  onObject(obj, model) {
807
810
  signalCollector.consume(obj, model);
808
811
  performanceCollector.consume(obj, model);
@@ -2273,7 +2276,7 @@ async function buildSessionAnalyticsInternal({ home = os.homedir(), force = fals
2273
2276
  previousMeta = JSON.parse(fs.readFileSync(metaPath, "utf8"));
2274
2277
  const checkedAt = Date.parse(previousMeta.checked_at || previousMeta.generated_at || "");
2275
2278
  if (
2276
- !retryZcodeScan && !retryOpencodeScan && !retryKimiScan && !retryOmpScan && !retryDshScan && previousMeta.version === SIDECAR_VERSION &&
2279
+ !retryZcodeScan && !retryOpencodeScan && !retryKimiScan && !retryOmpScan && !retryDshScan && previousMeta.version === SIDECAR_VERSION && previousMeta.codex_usage_version === CODEX_USAGE_VERSION &&
2277
2280
  Number.isFinite(checkedAt) &&
2278
2281
  Date.now() - checkedAt < Math.max(0, Number(cacheTtlMs) || 0)
2279
2282
  ) {
@@ -2314,7 +2317,7 @@ async function buildSessionAnalyticsInternal({ home = os.homedir(), force = fals
2314
2317
  // db + its WAL sidecars: the trio is one logical file for change detection.
2315
2318
  ...[...(discovered.zcode || []), ...(discovered.opencode || [])].flatMap((dbPath) => [dbPath, `${dbPath}-wal`, `${dbPath}-shm`]),
2316
2319
  ]);
2317
- if (!force && !retryZcodeScan && !retryOpencodeScan && !retryKimiScan && !retryOmpScan && !retryDshScan && previousMeta?.version === SIDECAR_VERSION && previousMeta.signature === signature) {
2320
+ if (!force && !retryZcodeScan && !retryOpencodeScan && !retryKimiScan && !retryOmpScan && !retryDshScan && previousMeta?.version === SIDECAR_VERSION && previousMeta.codex_usage_version === CODEX_USAGE_VERSION && previousMeta.signature === signature) {
2318
2321
  await writeAtomic(metaPath, `${JSON.stringify({ ...previousMeta, checked_at: new Date().toISOString() })}\n`);
2319
2322
  return readSidecar(sidecarPath);
2320
2323
  }
@@ -2344,6 +2347,7 @@ async function buildSessionAnalyticsInternal({ home = os.homedir(), force = fals
2344
2347
  // vanished mid-scan). Swallowing these silently made sessions disappear with
2345
2348
  // no signal at all; count them so the API can say so.
2346
2349
  let skippedFiles = 0;
2350
+ let incompleteCodexUsage = false;
2347
2351
  let incompleteZcodeScan = false;
2348
2352
  let incompleteOpencodeScan = false;
2349
2353
  let incompleteKimiScan = false;
@@ -2354,6 +2358,7 @@ async function buildSessionAnalyticsInternal({ home = os.homedir(), force = fals
2354
2358
  const statKey = analyticsEntryStatKey(entry.source, entry.filePath);
2355
2359
  if (!statKey) {
2356
2360
  skippedFiles += 1;
2361
+ if (entry.source === "codex") incompleteCodexUsage = true;
2357
2362
  if (entry.source === "zcode") incompleteZcodeScan = true;
2358
2363
  if (entry.source === "opencode") incompleteOpencodeScan = true;
2359
2364
  if (entry.source === "kimi") incompleteKimiScan = true;
@@ -2362,7 +2367,7 @@ async function buildSessionAnalyticsInternal({ home = os.homedir(), force = fals
2362
2367
  continue;
2363
2368
  }
2364
2369
  let rows = null;
2365
- if (!force && !(entry.source === "zcode" && retryZcodeScan) && !(entry.source === "opencode" && retryOpencodeScan) && !(entry.source === "kimi" && retryKimiScan) && !(entry.source === "omp" && retryOmpScan) && !(entry.source === "dsh" && retryDshScan) && previousFiles[cacheKey]?.stat_key === statKey) {
2370
+ if (!force && !(entry.source === "codex" && previousMeta?.codex_usage_version !== CODEX_USAGE_VERSION) && !(entry.source === "zcode" && retryZcodeScan) && !(entry.source === "opencode" && retryOpencodeScan) && !(entry.source === "kimi" && retryKimiScan) && !(entry.source === "omp" && retryOmpScan) && !(entry.source === "dsh" && retryDshScan) && previousFiles[cacheKey]?.stat_key === statKey) {
2366
2371
  if (entry.source === "zcode" || entry.source === "opencode" || entry.source === "kimi" || entry.source === "omp" || entry.source === "dsh") {
2367
2372
  // One db scans to many cached rows; recover them all from the previous
2368
2373
  // pass. An empty recovery means the sidecar lost its rows — rescan.
@@ -2392,6 +2397,7 @@ async function buildSessionAnalyticsInternal({ home = os.homedir(), force = fals
2392
2397
  // One active/partial session must not poison the sidecar, but do not
2393
2398
  // pretend it never existed either.
2394
2399
  skippedFiles += 1;
2400
+ if (entry.source === "codex") incompleteCodexUsage = true;
2395
2401
  if (entry.source === "zcode") incompleteZcodeScan = true;
2396
2402
  if (entry.source === "opencode") incompleteOpencodeScan = true;
2397
2403
  if (entry.source === "kimi") incompleteKimiScan = true;
@@ -2455,6 +2461,7 @@ async function buildSessionAnalyticsInternal({ home = os.homedir(), force = fals
2455
2461
  const generatedAt = new Date().toISOString();
2456
2462
  await writeAtomic(metaPath, `${JSON.stringify({
2457
2463
  version: SIDECAR_VERSION,
2464
+ codex_usage_version: incompleteCodexUsage ? 0 : CODEX_USAGE_VERSION,
2458
2465
  signature,
2459
2466
  generated_at: generatedAt,
2460
2467
  checked_at: generatedAt,
@@ -35,7 +35,7 @@ const { fetchDevinLimits } = require("./devin-limits");
35
35
  const { fetchQoderLimits, fetchQoderCnLimits } = require("./qoder-limits");
36
36
  const { fetchArkCodingPlanLimits } = require("./ark-coding-plan-limits");
37
37
  const { fetchArkAgentPlanLimits } = require("./ark-agent-plan-limits");
38
- const { fetchWorkbuddyLimits, fetchWorkbuddyAiLimits, selectWorkbuddyLimits } = require("./workbuddy-limits");
38
+ const { fetchWorkbuddyLimits, fetchWorkbuddyAiLimits } = require("./workbuddy-limits");
39
39
  const { fetchProviderServiceStatus } = require("./provider-status");
40
40
  const { resolveKimiProfile } = require("./kimi-profile");
41
41
  const { readSqliteJsonRows, readSqliteJsonRowsAsync } = require("./sqlite-reader");
@@ -3938,7 +3938,7 @@ function normalizePlanLabel(raw, brand) {
3938
3938
  let s = String(raw).replace(/[_-]+/g, " ").replace(/\s+/g, " ").trim();
3939
3939
  if (!s) return null;
3940
3940
  const lower = s.toLowerCase();
3941
- if (["free", "none", "unknown"].includes(lower)) return null;
3941
+ if (["free", "none", "unknown", "invalid"].includes(lower)) return null;
3942
3942
  if (brand && lower === brand.toLowerCase()) return null; // e.g. Kiro defaults plan_name to "Kiro" when parse fails
3943
3943
  if (brand) {
3944
3944
  s = s.replace(new RegExp("^" + brand + "\\s+", "i"), "").trim();
@@ -4506,8 +4506,10 @@ async function fetchUsageLimitsUncached({
4506
4506
  codingPlan: withPlanLabel(codingPlan, codingPlan?.plan_label, "Ark Coding Plan"),
4507
4507
  agentPlan: withPlanLabel(agentPlan, agentPlan?.plan_label, "Ark Agent Plan"),
4508
4508
  // WorkBuddy preserves provider English names and official edition labels
4509
- // verbatim (including Free, STANDARD and Pro-Trial).
4510
- workbuddy: selectWorkbuddyLimits(workbuddy, workbuddyAi),
4509
+ // verbatim (including Free, STANDARD and Pro-Trial). China and AI publish
4510
+ // as separate providers — one row per signed-in edition, never merged.
4511
+ workbuddy,
4512
+ workbuddyAi,
4511
4513
  };
4512
4514
 
4513
4515
  for (const [providerName, provider] of Object.entries(data)) {