tokentracker-cli 1.5.1 → 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 (123) hide show
  1. package/dashboard/dist/assets/{AchievementBadge-mB7HZSGE.js → AchievementBadge-B5kmt0VD.js} +1 -1
  2. package/dashboard/dist/assets/{AchievementsPage-DLzeq_eo.js → AchievementsPage-C0FlkOo0.js} +2 -2
  3. package/dashboard/dist/assets/{AchievementsSection-DrXQyDns.js → AchievementsSection-CRcp80Pp.js} +1 -1
  4. package/dashboard/dist/assets/{BadgeDetailModal-WrgKRybi.js → BadgeDetailModal-DAFjGbKW.js} +1 -1
  5. package/dashboard/dist/assets/{BotAnimated-DFRaixVK.js → BotAnimated-CO0bbimB.js} +1 -1
  6. package/dashboard/dist/assets/{Card-bFq2tlO8.js → Card-Xhr5exz8.js} +1 -1
  7. package/dashboard/dist/assets/{CloudCheckoutPage-gwhGxavy.js → CloudCheckoutPage-DsQ7FmsI.js} +1 -1
  8. package/dashboard/dist/assets/{CloudPage-BD4iBYeZ.js → CloudPage-DkSexu5V.js} +2 -2
  9. package/dashboard/dist/assets/{CommandPalette-C_KlPl0U.js → CommandPalette-Bm0huQdj.js} +1 -1
  10. package/dashboard/dist/assets/{CommunityStatsModal-Cl1Bgv8M.js → CommunityStatsModal-BsL8GN8Q.js} +1 -1
  11. package/dashboard/dist/assets/{ConfirmModal-NWoYevYC.js → ConfirmModal-_WPp292g.js} +1 -1
  12. package/dashboard/dist/assets/{Controls-D2-D2cfA.js → Controls-ktzn68KM.js} +1 -1
  13. package/dashboard/dist/assets/{DashboardPage-vITMnPXx.js → DashboardPage-CdNXi3Im.js} +1 -1
  14. package/dashboard/dist/assets/{DateRangePopover-8EMVXuH6.js → DateRangePopover-DWr4zIzF.js} +1 -1
  15. package/dashboard/dist/assets/{DevicePage-DbP8d6bM.js → DevicePage-CBa8Mk47.js} +1 -1
  16. package/dashboard/dist/assets/{DialogClose-CEstgLa1.js → DialogClose-CZ5jzgtb.js} +1 -1
  17. package/dashboard/dist/assets/DialogDescription-kCZ4gN09.js +1 -0
  18. package/dashboard/dist/assets/{DialogTitle-DP5TLLbz.js → DialogTitle-Dr0N6q15.js} +1 -1
  19. package/dashboard/dist/assets/{ElectricLogo-Uvi3yMum.js → ElectricLogo-sYqgrO6-.js} +1 -1
  20. package/dashboard/dist/assets/{FadeIn-Dpzihg6f.js → FadeIn-Qq-hcrZW.js} +1 -1
  21. package/dashboard/dist/assets/{HeaderGithubStar-DllqemKr.js → HeaderGithubStar-B77zSv7i.js} +1 -1
  22. package/dashboard/dist/assets/{HoverTooltip-Dc-TAH6l.js → HoverTooltip-lRDaUmoW.js} +1 -1
  23. package/dashboard/dist/assets/{IpCheckPage-jd1D5ZKX.js → IpCheckPage-B6AvHZjA.js} +1 -1
  24. package/dashboard/dist/assets/{LandingPage-CB1ZVw-a.js → LandingPage-DgzOjf3x.js} +2 -2
  25. package/dashboard/dist/assets/{LeaderboardPage-Bt2cRRmE.js → LeaderboardPage-BtjjtDMC.js} +2 -2
  26. package/dashboard/dist/assets/{LeaderboardProBadge-BVlyVOFf.js → LeaderboardProBadge-B2wNTUTU.js} +1 -1
  27. package/dashboard/dist/assets/{LeaderboardProfileModal-CwQFRJg_.js → LeaderboardProfileModal-DJJckwHh.js} +3 -3
  28. package/dashboard/dist/assets/{LeaderboardProfilePage-CeqcTbUG.js → LeaderboardProfilePage-DJdxByl2.js} +1 -1
  29. package/dashboard/dist/assets/LimitsPage-gSBHSBi_.js +6 -0
  30. package/dashboard/dist/assets/{LocalOnlyNotice-B2_HoCVD.js → LocalOnlyNotice-B2lVqf_H.js} +1 -1
  31. package/dashboard/dist/assets/{LoginCard-nw9JOc2d.js → LoginCard-i0gg1yby.js} +1 -1
  32. package/dashboard/dist/assets/{LoginModal-BHNLTK1D.js → LoginModal-B-loLNZM.js} +1 -1
  33. package/dashboard/dist/assets/{LoginPage-LI2GZKhM.js → LoginPage-D_swI75j.js} +1 -1
  34. package/dashboard/dist/assets/{PetPage-CK6sPeI0.js → PetPage-DDpKvpjG.js} +2 -2
  35. package/dashboard/dist/assets/{PopoverPopup-BsXchEVa.js → PopoverPopup-Gja4MyEK.js} +1 -1
  36. package/dashboard/dist/assets/{ProviderIcon-vgCEy0dh.js → ProviderIcon-DOPj7tAZ.js} +1 -1
  37. package/dashboard/dist/assets/{ResetPasswordPage-B4CPj2vX.js → ResetPasswordPage-BVADLk89.js} +1 -1
  38. package/dashboard/dist/assets/{Select-BkAy6P2r.js → Select-D-38tN-E.js} +1 -1
  39. package/dashboard/dist/assets/{SelectItemText-C0RNqDrD.js → SelectItemText-jN61ukxk.js} +1 -1
  40. package/dashboard/dist/assets/{SelfHostPage-B-_w-Z-a.js → SelfHostPage-k7AkGpl1.js} +1 -1
  41. package/dashboard/dist/assets/ServiceStatusPage-D_Fa_pYz.js +1 -0
  42. package/dashboard/dist/assets/{SessionsPage-CmWXBqD2.js → SessionsPage-BymccEEz.js} +1 -1
  43. package/dashboard/dist/assets/{SettingsPage-CA70zP-t.js → SettingsPage-DfnMCAuA.js} +3 -3
  44. package/dashboard/dist/assets/{SkillsPage-CNamhfzU.js → SkillsPage-YyIHIaHN.js} +1 -1
  45. package/dashboard/dist/assets/{TokenGalaxy-CXrVBSk6.js → TokenGalaxy-BLcvZuQ9.js} +1 -1
  46. package/dashboard/dist/assets/{TooltipPopup-CR4MqElX.js → TooltipPopup-3E8kyVzz.js} +1 -1
  47. package/dashboard/dist/assets/{TrendMonitor-DQfcrEve.js → TrendMonitor-ByKAdXSF.js} +1 -1
  48. package/dashboard/dist/assets/{WidgetsPage-BBXxtZbP.js → WidgetsPage-BgEVIIzL.js} +1 -1
  49. package/dashboard/dist/assets/{WrappedPage-C8i2KlMF.js → WrappedPage-HjbX8QaA.js} +1 -1
  50. package/dashboard/dist/assets/__vite-browser-external-D_cMeuqI.js +45 -0
  51. package/dashboard/dist/assets/{agent-logos-CxQb8vir.js → agent-logos-BVF2siPQ.js} +1 -1
  52. package/dashboard/dist/assets/{arrow-left-D6zkcLVt.js → arrow-left-CYO24PGP.js} +1 -1
  53. package/dashboard/dist/assets/{arrow-right-BGb-XVn_.js → arrow-right-Xg9PZYhw.js} +1 -1
  54. package/dashboard/dist/assets/{arrow-up-right-XTStagDJ.js → arrow-up-right-B7ah6Qv-.js} +1 -1
  55. package/dashboard/dist/assets/{calendar-days-Cz3NGZiS.js → calendar-days-BV_iG938.js} +1 -1
  56. package/dashboard/dist/assets/{check-1z_vmHPD.js → check--EZH8RTp.js} +1 -1
  57. package/dashboard/dist/assets/{chevron-down-CkSBY3bE.js → chevron-down-BzGTlk0-.js} +1 -1
  58. package/dashboard/dist/assets/{cloud-checkout-pD5C4r_S.js → cloud-checkout-BgyxPHEh.js} +1 -1
  59. package/dashboard/dist/assets/{composite-BtDzx3a2.js → composite-C0KrmLEK.js} +1 -1
  60. package/dashboard/dist/assets/{copy-Ctv_wxuD.js → copy-BtucSK2U.js} +1 -1
  61. package/dashboard/dist/assets/{download-DKbKMGSR.js → download-CWHq03Y2.js} +1 -1
  62. package/dashboard/dist/assets/{external-link-g9cU017t.js → external-link-DCTXF5VZ.js} +1 -1
  63. package/dashboard/dist/assets/{flame-DZq3kXUt.js → flame-BE5TsqVF.js} +1 -1
  64. package/dashboard/dist/assets/{format-tokens-CXQfx0PC.js → format-tokens-ThNIMWnZ.js} +1 -1
  65. package/dashboard/dist/assets/{gift-CKlKjGP6.js → gift-DgaN9s4-.js} +1 -1
  66. package/dashboard/dist/assets/{icons-xPGMnBjl.js → icons-B4r5EiDC.js} +1 -1
  67. package/dashboard/dist/assets/{index-fVbP5wOv.js → index-CKhcCLNC.js} +1 -1
  68. package/dashboard/dist/assets/{index-BwOjMtB2.js → index-DFF9xo6f.js} +1 -1
  69. package/dashboard/dist/assets/{index-CDBOKcDh.js → index-DqCtgU0G.js} +1 -1
  70. package/dashboard/dist/assets/{index-Dl0oFLNr.js → index-fNKH4U7j.js} +1 -1
  71. package/dashboard/dist/assets/{info-D9xNIuGh.js → info-B4YzRR4y.js} +1 -1
  72. package/dashboard/dist/assets/{link-2-Bk5wYt2n.js → link-2-DzbUbaBw.js} +1 -1
  73. package/dashboard/dist/assets/{loader-circle-CcN3pHWb.js → loader-circle-B9c7D-7v.js} +1 -1
  74. package/dashboard/dist/assets/{local-usage-transfer-CYcy1ici.js → local-usage-transfer-btuk_b0Y.js} +1 -1
  75. package/dashboard/dist/assets/{modifiers.esm-DbpEzkJe.js → modifiers.esm-BjrU-Xz7.js} +1 -1
  76. package/dashboard/dist/assets/{monitor-smartphone-WkWv1qIS.js → monitor-smartphone-Bwn4tb4q.js} +1 -1
  77. package/dashboard/dist/assets/{play-2yZGWMes.js → play-DIFOwGIQ.js} +1 -1
  78. package/dashboard/dist/assets/{plus-mOF8tG1F.js → plus-BpBqnwVg.js} +1 -1
  79. package/dashboard/dist/assets/{provider-display-7gaOvgMp.js → provider-display-DSrs4u7T.js} +1 -1
  80. package/dashboard/dist/assets/{react-CdPiPDhk.js → react-BgG7_MY0.js} +1 -1
  81. package/dashboard/dist/assets/{safePolygon-C7FJLAlh.js → safePolygon-BUGWIDOM.js} +1 -1
  82. package/dashboard/dist/assets/{search-C63nCrjo.js → search-aHrmBmtZ.js} +1 -1
  83. package/dashboard/dist/assets/{server-BaG1rnuv.js → server-DOEvwlEU.js} +1 -1
  84. package/dashboard/dist/assets/{shield-check-D_4-Cm6L.js → shield-check-BPTnLhsT.js} +1 -1
  85. package/dashboard/dist/assets/{skills-api-DfbzwpDv.js → skills-api-C052Mwez.js} +1 -1
  86. package/dashboard/dist/assets/{sortable.esm-D-v8FRX5.js → sortable.esm-DaZKhjhu.js} +1 -1
  87. package/dashboard/dist/assets/{store-Dv_FYrXK.js → store-Cq30Pofw.js} +1 -1
  88. package/dashboard/dist/assets/{terminal-FQi6MaYt.js → terminal-CoSDSkE7.js} +1 -1
  89. package/dashboard/dist/assets/{trash-TbK96dEz.js → trash-BAmt-kPj.js} +1 -1
  90. package/dashboard/dist/assets/{use-community-stats-DGoWVZuk.js → use-community-stats-Bxq0kBhm.js} +1 -1
  91. package/dashboard/dist/assets/{use-latest-request-guard-Dg6uSaYt.js → use-latest-request-guard-DmIQDlvk.js} +1 -1
  92. package/dashboard/dist/assets/use-limits-display-prefs-CyAn1fme.js +1 -0
  93. package/dashboard/dist/assets/{use-native-settings-DIk0AF3b.js → use-native-settings-s_CIC8Bb.js} +1 -1
  94. package/dashboard/dist/assets/{use-ordered-list-CUqV3Zgl.js → use-ordered-list-BHhykAbt.js} +1 -1
  95. package/dashboard/dist/assets/use-reduced-motion-BmFyFE3S.js +1 -0
  96. package/dashboard/dist/assets/{use-transform-D_-w22c8.js → use-transform-CVeMFlS6.js} +1 -1
  97. package/dashboard/dist/assets/use-usage-limits-DRZMI20u.js +1 -0
  98. package/dashboard/dist/assets/{useCurrency-B_AuLCsL.js → useCurrency-DilqL2Iv.js} +1 -1
  99. package/dashboard/dist/assets/{usePositioner-DfxvIif8.js → usePositioner-BcnySw8a.js} +1 -1
  100. package/dashboard/dist/assets/{useTokenFormat-bbO13V4s.js → useTokenFormat-CvKW46WP.js} +1 -1
  101. package/dashboard/dist/assets/{zap-k07-EXTK.js → zap-D7VRNTwk.js} +1 -1
  102. package/dashboard/dist/index.html +5 -1
  103. package/dashboard/dist/ip-check.html +1 -1
  104. package/dashboard/dist/leaderboard.html +1 -1
  105. package/dashboard/dist/share.html +1 -1
  106. package/package.json +1 -1
  107. package/src/lib/codex-rollout-parser.js +43 -6
  108. package/src/lib/codex-usage-record.js +21 -0
  109. package/src/lib/pricing/curated-overrides.json +94 -0
  110. package/src/lib/pricing/seed-snapshot.json +1 -1
  111. package/src/lib/qoder-limits.js +78 -21
  112. package/src/lib/rollout.js +68 -14
  113. package/src/lib/session-analytics.js +10 -3
  114. package/src/lib/usage-limits.js +6 -4
  115. package/src/lib/workbuddy-limits.js +101 -9
  116. package/src/lib/zcode-limits.js +350 -43
  117. package/dashboard/dist/assets/DialogDescription-0wcygxtH.js +0 -1
  118. package/dashboard/dist/assets/LimitsPage-Bp4ZORmR.js +0 -6
  119. package/dashboard/dist/assets/ServiceStatusPage-5YevsGGD.js +0 -1
  120. package/dashboard/dist/assets/__vite-browser-external-BEEzY7mq.js +0 -45
  121. package/dashboard/dist/assets/use-limits-display-prefs-Djbdp7fi.js +0 -1
  122. package/dashboard/dist/assets/use-reduced-motion-DMCok-vZ.js +0 -1
  123. package/dashboard/dist/assets/use-usage-limits-h7aGsIQr.js +0 -1
@@ -105,7 +105,10 @@ function normalizeQuotaSummary(summary) {
105
105
  if (total === 0 && (used !== 0 || remaining !== 0)) {
106
106
  throw new Error("Qoder zero total quota must have zero usage and remaining.");
107
107
  }
108
- 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);
109
112
  return {
110
113
  used,
111
114
  total,
@@ -131,7 +134,7 @@ function normalizeQoderUsageResponse(response) {
131
134
  const usedPercent = shared
132
135
  ? total > 0
133
136
  ? (used / total) * 100
134
- : 100
137
+ : 0
135
138
  : base.usagePercentage;
136
139
 
137
140
  return {
@@ -279,16 +282,17 @@ function normalizeQoderRpcUsage(response) {
279
282
  const used = quota ? finiteNumber(quota.used, "userQuota.used") : 0;
280
283
  const total = quota ? finiteNumber(quota.total, "userQuota.total") : 0;
281
284
  const remaining = quota ? finiteNumber(quota.remaining, "userQuota.remaining") : 0;
282
- 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.
283
289
  const usedPercent = total === 0
284
290
  ? 0
285
291
  : addOnWindow
286
292
  ? (used / total) * 100
287
293
  : response.isQuotaExceeded === true
288
294
  ? 100
289
- : Number.isFinite(reportedPercent)
290
- ? reportedPercent
291
- : (used / total) * 100;
295
+ : (used / total) * 100;
292
296
  const normalizedExpiry = normalizeResetAt(response.expiresAt);
293
297
  const expiryMs = normalizedExpiry ? Date.parse(normalizedExpiry) : NaN;
294
298
  return {
@@ -727,6 +731,12 @@ function parseQoderAccountUsageLog(text, { fallbackObservedAtMs = null } = {}) {
727
731
  return {
728
732
  limits,
729
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,
730
740
  };
731
741
  }
732
742
  return null;
@@ -772,7 +782,7 @@ function parseQoderQuotaLog(text) {
772
782
  // one; pruning to the current candidate set keeps the map bounded.
773
783
  const qoderNoSummaryScans = new Map();
774
784
 
775
- function readQoderLocalQuota({
785
+ function readQoderLocalQuotaWithAge({
776
786
  home = os.homedir(),
777
787
  platform = process.platform,
778
788
  env = process.env,
@@ -780,17 +790,23 @@ function readQoderLocalQuota({
780
790
  envPrefix = "QODER",
781
791
  nowMs = Date.now(),
782
792
  maxAgeMs = QODER_LOG_MAX_AGE_MS,
793
+ olderThanMs = -Infinity,
783
794
  } = {}) {
784
795
  const candidates = [];
785
796
  for (const logRoot of qoderLogRoots({ home, platform, env, appDir, envPrefix })) {
786
797
  for (const entry of listRecentQoderLogs(logRoot)) {
787
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;
788
803
  candidates.push(entry);
789
804
  }
790
805
  }
791
806
  candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
792
807
  const scanned = candidates.slice(0, QODER_LOG_MAX_FILES);
793
808
  const seenPaths = new Set();
809
+ let best = null;
794
810
  for (const { full, mtimeMs, size } of scanned) {
795
811
  seenPaths.add(full);
796
812
  const signature = `${size}:${mtimeMs}`;
@@ -804,7 +820,7 @@ function readQoderLocalQuota({
804
820
  return accountUsage;
805
821
  }
806
822
  const legacy = parseQoderQuotaLog(text);
807
- return legacy ? { limits: legacy, observedAtMs: mtimeMs } : null;
823
+ return legacy ? { limits: legacy, observedAtMs: mtimeMs, lineObservedAtMs: null } : null;
808
824
  });
809
825
  if (parsed === undefined) continue; // Scan failed (I/O); retry on the next call.
810
826
  if (!parsed) {
@@ -813,12 +829,27 @@ function readQoderLocalQuota({
813
829
  }
814
830
  const observedAtMs = parsed.observedAtMs ?? mtimeMs;
815
831
  if (nowMs - observedAtMs > maxAgeMs) continue;
816
- return parsed.limits;
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
+ }
817
844
  }
818
845
  for (const cachedPath of qoderNoSummaryScans.keys()) {
819
846
  if (!seenPaths.has(cachedPath)) qoderNoSummaryScans.delete(cachedPath);
820
847
  }
821
- return null;
848
+ return best;
849
+ }
850
+
851
+ function readQoderLocalQuota(options = {}) {
852
+ return readQoderLocalQuotaWithAge(options)?.limits ?? null;
822
853
  }
823
854
 
824
855
  function siteFromEnv(value) {
@@ -899,9 +930,34 @@ async function fetchQoderLimits({
899
930
  }
900
931
  }
901
932
 
902
- // Once native sources are unavailable, prefer a bounded disk snapshot before
903
- // 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.
904
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
+ }
905
961
  if (cachedLimits) {
906
962
  if (activityWindow) cachedLimits.secondary_window = activityWindow;
907
963
  return cachedLimits;
@@ -930,15 +986,15 @@ async function fetchQoderLimits({
930
986
  // Qoder 1.18 can return an all-zero active plan with usage_percentage=0
931
987
  // while the desktop client separately marks the account quota-exceeded.
932
988
  // Preserve that status as metadata, but match Qoder's Credits UI by
933
- // rendering an empty 0/0 bucket as 0%, not a full red 100% bar.
934
- const local = result.primary_window?.limit_credits === 0
935
- ? readQoderLocalQuota({ home, platform, env, appDir, envPrefix, nowMs })
936
- : null;
937
- if (local) {
938
- 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;
939
995
  result.primary_window.reset_at = null;
940
996
  result.source = "provider-api+local-log";
941
- result.plan_label = local.plan_label || null;
997
+ result.plan_label = zeroPlanLocal.limits.plan_label || null;
942
998
  }
943
999
  if (activityWindow) {
944
1000
  result.secondary_window = activityWindow;
@@ -956,8 +1012,8 @@ async function fetchQoderLimits({
956
1012
  }
957
1013
  }
958
1014
 
959
- const local = readQoderLocalQuota({ home, platform, env, appDir, envPrefix, nowMs });
960
- if (local) {
1015
+ if (localScan) {
1016
+ const local = localScan.limits;
961
1017
  if (activityWindow) {
962
1018
  local.secondary_window = activityWindow;
963
1019
  local.source += activitySource === "disk-cache" ? "+cached-activity" : "+activity";
@@ -965,6 +1021,7 @@ async function fetchQoderLimits({
965
1021
  writeQoderLimitsCache(local, { home, nowMs, namespace: cacheNamespace });
966
1022
  return local;
967
1023
  }
1024
+
968
1025
  if (activityWindow) {
969
1026
  return {
970
1027
  configured: true,
@@ -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)) {
@@ -12,10 +12,11 @@
12
12
  // WorkBuddy adds its own credentials, including encrypted desktop sessions.
13
13
  // Legacy plaintext credentials remain a fallback when no local IPC exists.
14
14
  //
15
- // Only the subscription plan package is returned — the row the app calls
16
- // 套餐基础积分, with that package's cycle end as the reset. Reward grants
17
- // (活动 / 成长计划 / bonus / extra / gift) are dropped. Display names are
18
- // English regardless of the backend's localized package names.
15
+ // Only two windows are returned, mirroring the desktop app's own split:
16
+ // the subscription plan package (the row the app calls 套餐基础积分, with that
17
+ // package's cycle end as the reset) and one pooled resource-package window
18
+ // for every non-plan credit group (套餐赠送积分 / 购买积分 / 平台奖励积分).
19
+ // Display names are English regardless of the backend's localized names.
19
20
 
20
21
  const fs = require("node:fs");
21
22
  const path = require("node:path");
@@ -278,8 +279,8 @@ function planWindow(row) {
278
279
  };
279
280
  }
280
281
 
281
- // One product row, one account's balance. Prefer a usable quota; when both
282
- // editions are connected China wins deterministically. Never add their pools.
282
+ // Legacy merge for the pre-split unified row. The local app now publishes
283
+ // both editions side by side; cloud mirrors of this module still merge.
283
284
  function selectWorkbuddyLimits(china, international) {
284
285
  const editions = [china, international].filter(Boolean);
285
286
  const usable = (value) => value.configured === true && !value.error;
@@ -289,9 +290,10 @@ function selectWorkbuddyLimits(china, international) {
289
290
  || { configured: false };
290
291
  }
291
292
 
292
- // Pick the one subscription-plan package. Reward rows are ignored. When
293
- // several plan packages exist, the highest tier that still has credit wins,
294
- // matching the desktop app's pickActivePlanBase.
293
+ // Pick the one subscription-plan package. Reward rows are ignored here — they
294
+ // feed the resource-pool window instead. When several plan packages exist, the
295
+ // highest tier that still has credit wins, matching the desktop app's
296
+ // pickActivePlanBase.
295
297
  function selectWorkbuddyPlanQuota(rows, identity = {}, variantId = "workbuddy") {
296
298
  const merged = mergePackageRows(rows.filter(Boolean));
297
299
  const subscriptionPackageCode = identity.code || "";
@@ -309,6 +311,93 @@ function selectWorkbuddyPlanQuota(rows, identity = {}, variantId = "workbuddy")
309
311
  };
310
312
  }
311
313
 
314
+ // The desktop app's billing meter splits an account into CreditGroups:
315
+ // plan_base (套餐基础积分) plus the non-plan pools plan_bonus (套餐赠送积分),
316
+ // addon (购买积分) and benefit (平台奖励积分). The app shows those pools as
317
+ // resource packages on top of the plan, so mirror that split: one window for
318
+ // the plan cycle, one pooled window for everything else. Groups the summary
319
+ // does not quantify are ignored; a pool with no credit at all stays null so
320
+ // the UI drops it like a missing window.
321
+ function collectResourceGroups(summary) {
322
+ const data = unwrapBillingBody(summary);
323
+ if (!data) return null;
324
+ const groups = [
325
+ data.CreditGroups,
326
+ data.creditGroups,
327
+ data.credit_groups,
328
+ ].find(Array.isArray);
329
+ if (groups) {
330
+ const pool = { total: 0, used: 0, remain: 0, remainDerived: 0, seen: false, usedSeen: false, remainSeen: false };
331
+ for (const group of groups) {
332
+ if (!group || typeof group !== "object") continue;
333
+ const kind = firstString(group, ["Kind", "kind"]);
334
+ if (kind && kind.toLowerCase() === "plan_base") continue;
335
+ const total = firstCount(group, ["CycleTotalCapacity", "total"]);
336
+ const used = firstCount(group, ["CycleUsedCapacity", "used"]);
337
+ const remain = firstCount(group, ["CycleRemainCapacity", "remain"]);
338
+ if (total === null && used === null && remain === null) continue;
339
+ pool.seen = true;
340
+ // A group that omits its usage is still counted: derive it from the
341
+ // capacity identity, the way planWindow does for the plan row.
342
+ const knownUsed = used ?? (total !== null && remain !== null ? Math.max(0, total - remain) : null);
343
+ pool.usedSeen = pool.usedSeen || knownUsed !== null;
344
+ pool.remainSeen = pool.remainSeen || remain !== null;
345
+ pool.total += total ?? 0;
346
+ pool.used += knownUsed ?? 0;
347
+ if (remain === null) pool.remainDerived += Math.max(0, (total ?? 0) - (used ?? 0));
348
+ else pool.remain += remain;
349
+ }
350
+ return pool.seen ? pool : null;
351
+ }
352
+ // Older summaries predate CreditGroups. Their package rows carry the same
353
+ // pools one row per package — every row that cannot be the subscription
354
+ // plan is resource credit.
355
+ const identity = readSubscriptionIdentity(summary);
356
+ const rows = collectPackageRows(summary)
357
+ .filter((row) => !isSelectablePlan(row, identity.code));
358
+ if (rows.length === 0) return null;
359
+ const derivedUsed = (row) =>
360
+ row.used ?? (row.total !== null && row.remain !== null ? Math.max(0, row.total - row.remain) : null);
361
+ return {
362
+ total: rows.reduce((sum, row) => sum + (row.total ?? 0), 0),
363
+ used: rows.reduce((sum, row) => sum + (derivedUsed(row) ?? 0), 0),
364
+ remain: rows.reduce((sum, row) => sum + (row.remain ?? 0), 0),
365
+ remainDerived: rows.reduce(
366
+ (sum, row) => sum + (row.remain === null ? Math.max(0, (row.total ?? 0) - (row.used ?? 0)) : 0),
367
+ 0,
368
+ ),
369
+ seen: true,
370
+ usedSeen: rows.some((row) => derivedUsed(row) !== null),
371
+ remainSeen: rows.some((row) => row.remain !== null),
372
+ };
373
+ }
374
+
375
+ // Resource credits have no shared cycle: benefit and add-on packages carry
376
+ // their own grants, so the pooled window has no reset. An empty pool (0/0)
377
+ // yields no window instead of a full or zeroed bar.
378
+ function resourceWindow(pool) {
379
+ if (!pool || !(pool.total > 0) || !Number.isFinite(pool.total)) return null;
380
+ // No group reported either side of the meter: usage is unknowable, not
381
+ // zero — hide the pool instead of rendering a full bar.
382
+ if (!pool.usedSeen && !pool.remainSeen) return null;
383
+ const used = pool.usedSeen || pool.used > 0
384
+ ? pool.used
385
+ : Math.max(0, pool.total - (pool.remain + pool.remainDerived));
386
+ const usedPercent = clampPercent((used / pool.total) * 100);
387
+ if (usedPercent === null) return null;
388
+ const remaining = pool.remainSeen
389
+ ? pool.remain + pool.remainDerived
390
+ : Math.max(0, pool.total - used);
391
+ if (!Number.isFinite(remaining)) return null;
392
+ return {
393
+ used_percent: usedPercent,
394
+ reset_at: null,
395
+ used_credits: used,
396
+ limit_credits: pool.total,
397
+ remaining_credits: remaining,
398
+ };
399
+ }
400
+
312
401
  function englishPlanLabel(row, identity, variantId) {
313
402
  // An actual English subscription/package name takes precedence over the
314
403
  // client's edition fallback. Unknown localized tiers stay unnamed.
@@ -479,6 +568,7 @@ async function fetchPlanQuota(post, variantId) {
479
568
  configured: true, error: null,
480
569
  plan_label: quota?.plan_label || englishPlanLabel({ code: identity.code }, identity, variantId),
481
570
  primary_window: quota?.primary_window || null,
571
+ secondary_window: resourceWindow(collectResourceGroups(summary)),
482
572
  };
483
573
  }
484
574
 
@@ -556,6 +646,8 @@ module.exports = {
556
646
  selectWorkbuddyPlanQuota,
557
647
  selectWorkbuddyLimits,
558
648
  collectPackageRows,
649
+ collectResourceGroups,
650
+ resourceWindow,
559
651
  readSubscriptionIdentity,
560
652
  originAllowed,
561
653
  hostAllowed,