tokentracker-cli 1.5.1 → 1.5.3

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 (130) hide show
  1. package/dashboard/dist/assets/{AchievementBadge-mB7HZSGE.js → AchievementBadge-uuDGO9Mq.js} +1 -1
  2. package/dashboard/dist/assets/{AchievementsPage-DLzeq_eo.js → AchievementsPage-BCTVT7Jo.js} +2 -2
  3. package/dashboard/dist/assets/{AchievementsSection-DrXQyDns.js → AchievementsSection-B6fJSJSr.js} +1 -1
  4. package/dashboard/dist/assets/{BadgeDetailModal-WrgKRybi.js → BadgeDetailModal-FjUN__9G.js} +1 -1
  5. package/dashboard/dist/assets/{BotAnimated-DFRaixVK.js → BotAnimated-B-KqY_Wr.js} +1 -1
  6. package/dashboard/dist/assets/{Card-bFq2tlO8.js → Card-DS8T8piP.js} +1 -1
  7. package/dashboard/dist/assets/{CloudCheckoutPage-gwhGxavy.js → CloudCheckoutPage-_0UjsFPu.js} +1 -1
  8. package/dashboard/dist/assets/{CloudPage-BD4iBYeZ.js → CloudPage-BstgVBaV.js} +2 -2
  9. package/dashboard/dist/assets/{CommandPalette-C_KlPl0U.js → CommandPalette-DtVsYZgc.js} +1 -1
  10. package/dashboard/dist/assets/{CommunityStatsModal-Cl1Bgv8M.js → CommunityStatsModal-DTpEJqV3.js} +1 -1
  11. package/dashboard/dist/assets/{ConfirmModal-NWoYevYC.js → ConfirmModal-Lfdh2vr7.js} +1 -1
  12. package/dashboard/dist/assets/{Controls-D2-D2cfA.js → Controls-DpwQ7pkq.js} +1 -1
  13. package/dashboard/dist/assets/DashboardPage-DUFWejkE.js +1 -0
  14. package/dashboard/dist/assets/{DateRangePopover-8EMVXuH6.js → DateRangePopover-CrWgrzT2.js} +1 -1
  15. package/dashboard/dist/assets/{DevicePage-DbP8d6bM.js → DevicePage-DbK5KWJS.js} +1 -1
  16. package/dashboard/dist/assets/{DialogClose-CEstgLa1.js → DialogClose-Dd2B_o03.js} +1 -1
  17. package/dashboard/dist/assets/DialogDescription-CLy0arbt.js +1 -0
  18. package/dashboard/dist/assets/{DialogTitle-DP5TLLbz.js → DialogTitle-3JQBVha3.js} +1 -1
  19. package/dashboard/dist/assets/{ElectricLogo-Uvi3yMum.js → ElectricLogo-LYAqG8pG.js} +1 -1
  20. package/dashboard/dist/assets/{FadeIn-Dpzihg6f.js → FadeIn-DhDvUor7.js} +1 -1
  21. package/dashboard/dist/assets/{HeaderGithubStar-DllqemKr.js → HeaderGithubStar-9f1XmgYC.js} +1 -1
  22. package/dashboard/dist/assets/{HoverTooltip-Dc-TAH6l.js → HoverTooltip-BXhHAkP7.js} +1 -1
  23. package/dashboard/dist/assets/{IpCheckPage-jd1D5ZKX.js → IpCheckPage-ChPdDeMl.js} +1 -1
  24. package/dashboard/dist/assets/{LandingPage-CB1ZVw-a.js → LandingPage-DnJ7Y9zw.js} +2 -2
  25. package/dashboard/dist/assets/{LeaderboardPage-Bt2cRRmE.js → LeaderboardPage-BF9isLzu.js} +2 -2
  26. package/dashboard/dist/assets/{LeaderboardProBadge-BVlyVOFf.js → LeaderboardProBadge-BM4JKgkC.js} +1 -1
  27. package/dashboard/dist/assets/{LeaderboardProfileModal-CwQFRJg_.js → LeaderboardProfileModal-DfZABOpN.js} +3 -3
  28. package/dashboard/dist/assets/{LeaderboardProfilePage-CeqcTbUG.js → LeaderboardProfilePage-BaZ38iT5.js} +1 -1
  29. package/dashboard/dist/assets/LimitsPage-C7LbG3LX.js +6 -0
  30. package/dashboard/dist/assets/{LocalOnlyNotice-B2_HoCVD.js → LocalOnlyNotice-DvhlYoqQ.js} +1 -1
  31. package/dashboard/dist/assets/{LoginCard-nw9JOc2d.js → LoginCard-g_Gbnzhs.js} +1 -1
  32. package/dashboard/dist/assets/{LoginModal-BHNLTK1D.js → LoginModal-DkJrI6b7.js} +1 -1
  33. package/dashboard/dist/assets/{LoginPage-LI2GZKhM.js → LoginPage-CqCKSo0n.js} +1 -1
  34. package/dashboard/dist/assets/{PetPage-CK6sPeI0.js → PetPage-DbF7ROsj.js} +2 -2
  35. package/dashboard/dist/assets/{PopoverPopup-BsXchEVa.js → PopoverPopup-4WuH41ZT.js} +1 -1
  36. package/dashboard/dist/assets/{ProviderIcon-vgCEy0dh.js → ProviderIcon-6j0A2KD9.js} +1 -1
  37. package/dashboard/dist/assets/{ResetPasswordPage-B4CPj2vX.js → ResetPasswordPage-DBd-iwuM.js} +1 -1
  38. package/dashboard/dist/assets/{Select-BkAy6P2r.js → Select-BBEpLeiG.js} +1 -1
  39. package/dashboard/dist/assets/{SelectItemText-C0RNqDrD.js → SelectItemText-Cn_U_rrO.js} +1 -1
  40. package/dashboard/dist/assets/{SelfHostPage-B-_w-Z-a.js → SelfHostPage-2WZc6Rml.js} +1 -1
  41. package/dashboard/dist/assets/ServiceStatusPage-B-g4MDl0.js +1 -0
  42. package/dashboard/dist/assets/{SessionsPage-CmWXBqD2.js → SessionsPage-CWS0iQ1L.js} +1 -1
  43. package/dashboard/dist/assets/{SettingsPage-CA70zP-t.js → SettingsPage-rXNr96i-.js} +3 -3
  44. package/dashboard/dist/assets/{SkillsPage-CNamhfzU.js → SkillsPage-CudTvbZL.js} +1 -1
  45. package/dashboard/dist/assets/{TokenGalaxy-CXrVBSk6.js → TokenGalaxy-BRKrbTsJ.js} +1 -1
  46. package/dashboard/dist/assets/{TooltipPopup-CR4MqElX.js → TooltipPopup-CsLKXhWC.js} +1 -1
  47. package/dashboard/dist/assets/{TrendMonitor-DQfcrEve.js → TrendMonitor-8onUTbkk.js} +1 -1
  48. package/dashboard/dist/assets/{WidgetsPage-BBXxtZbP.js → WidgetsPage-CcwE0lz8.js} +1 -1
  49. package/dashboard/dist/assets/{WrappedPage-C8i2KlMF.js → WrappedPage-Cj9LLYqP.js} +1 -1
  50. package/dashboard/dist/assets/__vite-browser-external-07djSGmy.js +45 -0
  51. package/dashboard/dist/assets/__vite-browser-external-BDldtsD2.css +1 -0
  52. package/dashboard/dist/assets/{agent-logos-CxQb8vir.js → agent-logos-ChBA1nAH.js} +1 -1
  53. package/dashboard/dist/assets/{arrow-left-D6zkcLVt.js → arrow-left-Bb5mfoPc.js} +1 -1
  54. package/dashboard/dist/assets/{arrow-right-BGb-XVn_.js → arrow-right-D1Ti2XUI.js} +1 -1
  55. package/dashboard/dist/assets/{arrow-up-right-XTStagDJ.js → arrow-up-right-B_CjA_y8.js} +1 -1
  56. package/dashboard/dist/assets/{calendar-days-Cz3NGZiS.js → calendar-days-DdfrAqie.js} +1 -1
  57. package/dashboard/dist/assets/{check-1z_vmHPD.js → check-29zGw1MK.js} +1 -1
  58. package/dashboard/dist/assets/{chevron-down-CkSBY3bE.js → chevron-down-DAzlFX7g.js} +1 -1
  59. package/dashboard/dist/assets/{cloud-checkout-pD5C4r_S.js → cloud-checkout-C2SeCjFM.js} +1 -1
  60. package/dashboard/dist/assets/{composite-BtDzx3a2.js → composite-DIf735Hq.js} +1 -1
  61. package/dashboard/dist/assets/{copy-Ctv_wxuD.js → copy-BOuOM0HQ.js} +1 -1
  62. package/dashboard/dist/assets/{download-DKbKMGSR.js → download-50tQRpGe.js} +1 -1
  63. package/dashboard/dist/assets/{external-link-g9cU017t.js → external-link-BhlQClVe.js} +1 -1
  64. package/dashboard/dist/assets/{flame-DZq3kXUt.js → flame-CeGfA-0L.js} +1 -1
  65. package/dashboard/dist/assets/{format-tokens-CXQfx0PC.js → format-tokens-Cq7YiAkJ.js} +1 -1
  66. package/dashboard/dist/assets/{gift-CKlKjGP6.js → gift-Do-UFhiT.js} +1 -1
  67. package/dashboard/dist/assets/{icons-xPGMnBjl.js → icons-C6EZ_HmP.js} +1 -1
  68. package/dashboard/dist/assets/{index-fVbP5wOv.js → index-BFrbVqsm.js} +1 -1
  69. package/dashboard/dist/assets/{index-CDBOKcDh.js → index-BWhbBAd9.js} +1 -1
  70. package/dashboard/dist/assets/{index-Dl0oFLNr.js → index-D9kwYWLX.js} +1 -1
  71. package/dashboard/dist/assets/{index-BwOjMtB2.js → index-c0crpe9Z.js} +1 -1
  72. package/dashboard/dist/assets/{info-D9xNIuGh.js → info-B4MXmsNE.js} +1 -1
  73. package/dashboard/dist/assets/{link-2-Bk5wYt2n.js → link-2-DuWCslzl.js} +1 -1
  74. package/dashboard/dist/assets/{loader-circle-CcN3pHWb.js → loader-circle-C2I3NZAO.js} +1 -1
  75. package/dashboard/dist/assets/{local-usage-transfer-CYcy1ici.js → local-usage-transfer-BQasvsUF.js} +1 -1
  76. package/dashboard/dist/assets/{modifiers.esm-DbpEzkJe.js → modifiers.esm-DbeNS6FT.js} +1 -1
  77. package/dashboard/dist/assets/{monitor-smartphone-WkWv1qIS.js → monitor-smartphone-C6Y4ktsc.js} +1 -1
  78. package/dashboard/dist/assets/{play-2yZGWMes.js → play-CwgcDJC2.js} +1 -1
  79. package/dashboard/dist/assets/{plus-mOF8tG1F.js → plus-B796wsUO.js} +1 -1
  80. package/dashboard/dist/assets/{provider-display-7gaOvgMp.js → provider-display-Cw26RFDm.js} +1 -1
  81. package/dashboard/dist/assets/{react-CdPiPDhk.js → react-d7Bk1-_K.js} +1 -1
  82. package/dashboard/dist/assets/{safePolygon-C7FJLAlh.js → safePolygon-CDE0dNv_.js} +1 -1
  83. package/dashboard/dist/assets/{search-C63nCrjo.js → search-DtKvsdRs.js} +1 -1
  84. package/dashboard/dist/assets/{server-BaG1rnuv.js → server-gAURHUyz.js} +1 -1
  85. package/dashboard/dist/assets/{shield-check-D_4-Cm6L.js → shield-check-DFVdDTwh.js} +1 -1
  86. package/dashboard/dist/assets/{skills-api-DfbzwpDv.js → skills-api-2rdPUrJI.js} +1 -1
  87. package/dashboard/dist/assets/{sortable.esm-D-v8FRX5.js → sortable.esm-DFlfwnJr.js} +1 -1
  88. package/dashboard/dist/assets/{store-Dv_FYrXK.js → store-BKPCk_de.js} +1 -1
  89. package/dashboard/dist/assets/{terminal-FQi6MaYt.js → terminal-BtWFakvS.js} +1 -1
  90. package/dashboard/dist/assets/{trash-TbK96dEz.js → trash-bpr9zTAM.js} +1 -1
  91. package/dashboard/dist/assets/{use-community-stats-DGoWVZuk.js → use-community-stats-DMnGgLne.js} +1 -1
  92. package/dashboard/dist/assets/{use-latest-request-guard-Dg6uSaYt.js → use-latest-request-guard-BSBlJJHe.js} +1 -1
  93. package/dashboard/dist/assets/use-limits-display-prefs-eYlvnUKn.js +1 -0
  94. package/dashboard/dist/assets/{use-native-settings-DIk0AF3b.js → use-native-settings-OthqiF6g.js} +1 -1
  95. package/dashboard/dist/assets/{use-ordered-list-CUqV3Zgl.js → use-ordered-list-DpAtr0R-.js} +1 -1
  96. package/dashboard/dist/assets/use-reduced-motion-DF0grwHs.js +1 -0
  97. package/dashboard/dist/assets/{use-transform-D_-w22c8.js → use-transform-BRz9dz_2.js} +1 -1
  98. package/dashboard/dist/assets/use-usage-limits-CwmhudAX.js +1 -0
  99. package/dashboard/dist/assets/{useCurrency-B_AuLCsL.js → useCurrency-CAA-tQ27.js} +1 -1
  100. package/dashboard/dist/assets/{usePositioner-DfxvIif8.js → usePositioner-Cj2DPmwM.js} +1 -1
  101. package/dashboard/dist/assets/{useTokenFormat-bbO13V4s.js → useTokenFormat-Bf5-f7Hx.js} +1 -1
  102. package/dashboard/dist/assets/{zap-k07-EXTK.js → zap-B8Fg6PaC.js} +1 -1
  103. package/dashboard/dist/index.html +6 -2
  104. package/dashboard/dist/ip-check.html +2 -2
  105. package/dashboard/dist/leaderboard.html +2 -2
  106. package/dashboard/dist/share.html +2 -2
  107. package/package.json +1 -1
  108. package/src/commands/serve.js +22 -13
  109. package/src/lib/codex-rollout-parser.js +43 -6
  110. package/src/lib/codex-usage-record.js +21 -0
  111. package/src/lib/deepseek-status.js +157 -0
  112. package/src/lib/local-api.js +44 -4
  113. package/src/lib/pricing/curated-overrides.json +94 -0
  114. package/src/lib/pricing/seed-snapshot.json +1 -1
  115. package/src/lib/provider-status.js +15 -2
  116. package/src/lib/qoder-limits.js +95 -23
  117. package/src/lib/rollout.js +68 -14
  118. package/src/lib/session-analytics.js +10 -3
  119. package/src/lib/usage-limits.js +187 -43
  120. package/src/lib/workbuddy-limits.js +292 -27
  121. package/src/lib/zcode-limits.js +457 -42
  122. package/dashboard/dist/assets/DashboardPage-vITMnPXx.js +0 -1
  123. package/dashboard/dist/assets/DialogDescription-0wcygxtH.js +0 -1
  124. package/dashboard/dist/assets/LimitsPage-Bp4ZORmR.js +0 -6
  125. package/dashboard/dist/assets/ServiceStatusPage-5YevsGGD.js +0 -1
  126. package/dashboard/dist/assets/__vite-browser-external-BEEzY7mq.js +0 -45
  127. package/dashboard/dist/assets/__vite-browser-external-Br-KAXh6.css +0 -1
  128. package/dashboard/dist/assets/use-limits-display-prefs-Djbdp7fi.js +0 -1
  129. package/dashboard/dist/assets/use-reduced-motion-DMCok-vZ.js +0 -1
  130. package/dashboard/dist/assets/use-usage-limits-h7aGsIQr.js +0 -1
@@ -9,6 +9,8 @@
9
9
  // Statuspage.io contract (`/api/v2/status.json`):
10
10
  // { "page": { "updated_at": ISO }, "status": { "indicator": "none|minor|major|critical", "description": "..." } }
11
11
 
12
+ const { parseDeepSeekStatusPage } = require("./deepseek-status");
13
+
12
14
  const STATUS_PROBE_TIMEOUT_MS = 6_000;
13
15
  // Status pages change on incident timescales; 5 minutes keeps the limits
14
16
  // poll (2-min TTL upstream) from hammering statuspage.io every cycle.
@@ -23,6 +25,13 @@ const STATUS_PAGE_SPECS = {
23
25
  apiUrl: "https://status.claude.com/api/v2/status.json",
24
26
  pageUrl: "https://status.claude.com",
25
27
  },
28
+ deepseek: {
29
+ // Flashcat-hosted Next.js page: no public JSON API and no CORS. Relayed
30
+ // server-side here; `format` switches the fetch to HTML + RSC parsing.
31
+ apiUrl: "https://status.deepseek.com/",
32
+ pageUrl: "https://status.deepseek.com",
33
+ format: "deepseek-rsc",
34
+ },
26
35
  };
27
36
 
28
37
  const VALID_INDICATORS = new Set(["none", "minor", "major", "critical"]);
@@ -68,13 +77,17 @@ async function fetchProviderServiceStatus(providerId, { fetchImpl = fetch, nowMs
68
77
  const controller = new AbortController();
69
78
  const timer = setTimeout(() => controller.abort(), STATUS_PROBE_TIMEOUT_MS);
70
79
  try {
80
+ const isDeepSeek = spec.format === "deepseek-rsc";
71
81
  const res = await fetchImpl(spec.apiUrl, {
72
82
  method: "GET",
73
- headers: { Accept: "application/json" },
83
+ headers: { Accept: isDeepSeek ? "text/html" : "application/json" },
74
84
  signal: controller.signal,
75
85
  });
76
86
  if (res.ok) {
77
- data = normalizeStatusPayload(await res.json(), spec, nowMs);
87
+ const payload = isDeepSeek
88
+ ? parseDeepSeekStatusPage(await res.text())
89
+ : await res.json();
90
+ data = normalizeStatusPayload(payload, spec, nowMs);
78
91
  }
79
92
  } finally {
80
93
  clearTimeout(timer);
@@ -40,6 +40,9 @@ 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
+ // Align with usage-limits provenance aging (10 min): a cache hit fresher than
44
+ // this inherits its written source and is not flagged stale (#955 self-lock).
45
+ const QODER_LIMITS_CACHE_FRESH_TTL_MS = 10 * 60 * 1000;
43
46
  // Qoder Desktop 0.4+ writes its account usage summary to main.log, which can
44
47
  // accumulate a whole session before the quota is read. Scan a bounded window
45
48
  // backwards instead of assuming the summary is near EOF.
@@ -105,7 +108,10 @@ function normalizeQuotaSummary(summary) {
105
108
  if (total === 0 && (used !== 0 || remaining !== 0)) {
106
109
  throw new Error("Qoder zero total quota must have zero usage and remaining.");
107
110
  }
108
- const usagePercentage = providedPercentage ?? (total > 0 ? (used / total) * 100 : 100);
111
+ // An empty 0/0 quota (no active plan) renders as 0%, matching Qoder's own
112
+ // Credits UI and the desktop/CLI paths; a server-provided percentage still
113
+ // wins when present.
114
+ const usagePercentage = providedPercentage ?? (total > 0 ? (used / total) * 100 : 0);
109
115
  return {
110
116
  used,
111
117
  total,
@@ -131,7 +137,7 @@ function normalizeQoderUsageResponse(response) {
131
137
  const usedPercent = shared
132
138
  ? total > 0
133
139
  ? (used / total) * 100
134
- : 100
140
+ : 0
135
141
  : base.usagePercentage;
136
142
 
137
143
  return {
@@ -279,16 +285,17 @@ function normalizeQoderRpcUsage(response) {
279
285
  const used = quota ? finiteNumber(quota.used, "userQuota.used") : 0;
280
286
  const total = quota ? finiteNumber(quota.total, "userQuota.total") : 0;
281
287
  const remaining = quota ? finiteNumber(quota.remaining, "userQuota.remaining") : 0;
282
- const reportedPercent = Number(response.totalUsagePercentage ?? quota?.percentage);
288
+ // totalUsagePercentage aggregates the plan and add-on buckets and reaches
289
+ // this function on mixed scales (the OpenAPI returns fractions, the desktop
290
+ // IPC returns percents), so the primary window always derives its percent
291
+ // from its own quota numbers.
283
292
  const usedPercent = total === 0
284
293
  ? 0
285
294
  : addOnWindow
286
295
  ? (used / total) * 100
287
296
  : response.isQuotaExceeded === true
288
297
  ? 100
289
- : Number.isFinite(reportedPercent)
290
- ? reportedPercent
291
- : (used / total) * 100;
298
+ : (used / total) * 100;
292
299
  const normalizedExpiry = normalizeResetAt(response.expiresAt);
293
300
  const expiryMs = normalizedExpiry ? Date.parse(normalizedExpiry) : NaN;
294
301
  return {
@@ -431,6 +438,15 @@ function readQoderLimitsCache({
431
438
  if (nowMs - cachedAtMs > QODER_LIMITS_CACHE_MAX_AGE_MS && !hasUnexpiredDatedWindow) {
432
439
  return null;
433
440
  }
441
+ // Provenance must describe the data's age, not the transport (#955): a
442
+ // successful refresh writes this cache and immediately floors later log
443
+ // scans, so the next refresh re-serves the same payload. Marking every
444
+ // hit stale/disk-cache made the Limits page permanently partial.
445
+ const ageMs = nowMs - cachedAtMs;
446
+ const fresh = ageMs <= QODER_LIMITS_CACHE_FRESH_TTL_MS;
447
+ const writtenSource = typeof raw.source === "string" && raw.source.trim()
448
+ ? raw.source.trim()
449
+ : null;
434
450
  return {
435
451
  configured: true,
436
452
  error: null,
@@ -440,9 +456,9 @@ function readQoderLimitsCache({
440
456
  secondary_window: secondaryWindow,
441
457
  ...(tertiaryWindow ? { tertiary_window: tertiaryWindow } : {}),
442
458
  cached: true,
443
- stale: true,
459
+ stale: !fresh,
444
460
  cached_at: raw.cached_at,
445
- source: "disk-cache",
461
+ source: fresh && writtenSource ? writtenSource : "disk-cache",
446
462
  };
447
463
  } catch (_error) {
448
464
  return null;
@@ -463,6 +479,9 @@ function writeQoderLimitsCache(limits, {
463
479
  primary_window: limits.primary_window || null,
464
480
  secondary_window: limits.secondary_window || null,
465
481
  ...(limits.tertiary_window ? { tertiary_window: limits.tertiary_window } : {}),
482
+ // Remember the winning source so a fresh re-serve keeps honest
483
+ // provenance instead of always claiming "disk-cache" (#955).
484
+ source: typeof limits.source === "string" ? limits.source : null,
466
485
  cached_at: new Date(nowMs).toISOString(),
467
486
  },
468
487
  };
@@ -727,6 +746,12 @@ function parseQoderAccountUsageLog(text, { fallbackObservedAtMs = null } = {}) {
727
746
  return {
728
747
  limits,
729
748
  observedAtMs: Number.isFinite(lineObservedAtMs) ? lineObservedAtMs : fallbackObservedAtMs,
749
+ // The summary's own timestamp, or null when the line carries no parseable
750
+ // one and observedAtMs fell back to the file mtime. Callers that order
751
+ // this snapshot against other sources must use it, not observedAtMs: an
752
+ // mtime bump from an unrelated append would otherwise masquerade as a
753
+ // fresh observation.
754
+ lineObservedAtMs: Number.isFinite(lineObservedAtMs) ? lineObservedAtMs : null,
730
755
  };
731
756
  }
732
757
  return null;
@@ -772,7 +797,7 @@ function parseQoderQuotaLog(text) {
772
797
  // one; pruning to the current candidate set keeps the map bounded.
773
798
  const qoderNoSummaryScans = new Map();
774
799
 
775
- function readQoderLocalQuota({
800
+ function readQoderLocalQuotaWithAge({
776
801
  home = os.homedir(),
777
802
  platform = process.platform,
778
803
  env = process.env,
@@ -780,17 +805,23 @@ function readQoderLocalQuota({
780
805
  envPrefix = "QODER",
781
806
  nowMs = Date.now(),
782
807
  maxAgeMs = QODER_LOG_MAX_AGE_MS,
808
+ olderThanMs = -Infinity,
783
809
  } = {}) {
784
810
  const candidates = [];
785
811
  for (const logRoot of qoderLogRoots({ home, platform, env, appDir, envPrefix })) {
786
812
  for (const entry of listRecentQoderLogs(logRoot)) {
787
813
  if (nowMs - entry.mtimeMs > maxAgeMs) continue;
814
+ // A file last written at or before the cache snapshot cannot hold an
815
+ // observation newer than it (a line's time never exceeds the file
816
+ // mtime), so scanning it could never win the timestamp comparison.
817
+ if (entry.mtimeMs <= olderThanMs) continue;
788
818
  candidates.push(entry);
789
819
  }
790
820
  }
791
821
  candidates.sort((a, b) => b.mtimeMs - a.mtimeMs);
792
822
  const scanned = candidates.slice(0, QODER_LOG_MAX_FILES);
793
823
  const seenPaths = new Set();
824
+ let best = null;
794
825
  for (const { full, mtimeMs, size } of scanned) {
795
826
  seenPaths.add(full);
796
827
  const signature = `${size}:${mtimeMs}`;
@@ -804,7 +835,7 @@ function readQoderLocalQuota({
804
835
  return accountUsage;
805
836
  }
806
837
  const legacy = parseQoderQuotaLog(text);
807
- return legacy ? { limits: legacy, observedAtMs: mtimeMs } : null;
838
+ return legacy ? { limits: legacy, observedAtMs: mtimeMs, lineObservedAtMs: null } : null;
808
839
  });
809
840
  if (parsed === undefined) continue; // Scan failed (I/O); retry on the next call.
810
841
  if (!parsed) {
@@ -813,12 +844,27 @@ function readQoderLocalQuota({
813
844
  }
814
845
  const observedAtMs = parsed.observedAtMs ?? mtimeMs;
815
846
  if (nowMs - observedAtMs > maxAgeMs) continue;
816
- return parsed.limits;
847
+ // A summary that cannot beat the cache cutoff is not eligible: keep
848
+ // scanning, a deeper file may still hold a newer line that can.
849
+ if ((parsed.lineObservedAtMs ?? 0) <= olderThanMs) continue;
850
+ // A file's mtime order need not match its summary-line order: an append
851
+ // without a fresh summary leaves an old line in a freshly touched file.
852
+ // Keep the newest eligible line across the whole scan instead of the
853
+ // first hit — advancing the cache cutoff afterwards must not lock out a
854
+ // summary that was newer all along.
855
+ const lineObservedAtMs = parsed.lineObservedAtMs ?? null;
856
+ if (!best || (lineObservedAtMs ?? 0) > (best.lineObservedAtMs ?? 0)) {
857
+ best = { limits: parsed.limits, lineObservedAtMs };
858
+ }
817
859
  }
818
860
  for (const cachedPath of qoderNoSummaryScans.keys()) {
819
861
  if (!seenPaths.has(cachedPath)) qoderNoSummaryScans.delete(cachedPath);
820
862
  }
821
- return null;
863
+ return best;
864
+ }
865
+
866
+ function readQoderLocalQuota(options = {}) {
867
+ return readQoderLocalQuotaWithAge(options)?.limits ?? null;
822
868
  }
823
869
 
824
870
  function siteFromEnv(value) {
@@ -899,9 +945,34 @@ async function fetchQoderLimits({
899
945
  }
900
946
  }
901
947
 
902
- // Once native sources are unavailable, prefer a bounded disk snapshot before
903
- // the manual-cookie or local-log fallback.
948
+ // A 0.4.x desktop app refreshes its main.log summary whenever the account
949
+ // page reloads, so the newest line can be newer than the last-good cache.
950
+ // Compare the summary's own timestamp against the cache instead of always
951
+ // trusting the cache: only a parseable line timestamp may win, and files
952
+ // last written before the cache are skipped without a scan. Without a valid
953
+ // cache there is nothing to compare against, so the summary defers to the
954
+ // live manual-cookie session below and only serves as its fallback.
904
955
  const cachedLimits = readQoderLimitsCache({ home, nowMs, namespace: cacheNamespace });
956
+ const cachedAtMs = Date.parse(cachedLimits?.cached_at || "");
957
+ const cacheFloorMs = Number.isFinite(cachedAtMs) ? cachedAtMs : -Infinity;
958
+ const localScan = readQoderLocalQuotaWithAge({
959
+ home,
960
+ platform,
961
+ env,
962
+ appDir,
963
+ envPrefix,
964
+ nowMs,
965
+ olderThanMs: cacheFloorMs,
966
+ });
967
+ if (localScan && Number.isFinite(cacheFloorMs) && (localScan.lineObservedAtMs ?? 0) > cacheFloorMs) {
968
+ const local = localScan.limits;
969
+ if (activityWindow) {
970
+ local.secondary_window = activityWindow;
971
+ local.source += activitySource === "disk-cache" ? "+cached-activity" : "+activity";
972
+ }
973
+ writeQoderLimitsCache(local, { home, nowMs, namespace: cacheNamespace });
974
+ return local;
975
+ }
905
976
  if (cachedLimits) {
906
977
  if (activityWindow) cachedLimits.secondary_window = activityWindow;
907
978
  return cachedLimits;
@@ -930,15 +1001,15 @@ async function fetchQoderLimits({
930
1001
  // Qoder 1.18 can return an all-zero active plan with usage_percentage=0
931
1002
  // while the desktop client separately marks the account quota-exceeded.
932
1003
  // 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;
1004
+ // rendering an empty 0/0 bucket as 0%, not a full red 100% bar. With no
1005
+ // valid cache the log scan above only ran as a companion — it lost its
1006
+ // spot ahead of this session — so its result is still available here.
1007
+ const zeroPlanLocal = result.primary_window?.limit_credits === 0 ? localScan : null;
1008
+ if (zeroPlanLocal) {
1009
+ result.quota_exceeded = zeroPlanLocal.limits.quota_exceeded === true;
939
1010
  result.primary_window.reset_at = null;
940
1011
  result.source = "provider-api+local-log";
941
- result.plan_label = local.plan_label || null;
1012
+ result.plan_label = zeroPlanLocal.limits.plan_label || null;
942
1013
  }
943
1014
  if (activityWindow) {
944
1015
  result.secondary_window = activityWindow;
@@ -956,8 +1027,8 @@ async function fetchQoderLimits({
956
1027
  }
957
1028
  }
958
1029
 
959
- const local = readQoderLocalQuota({ home, platform, env, appDir, envPrefix, nowMs });
960
- if (local) {
1030
+ if (localScan) {
1031
+ const local = localScan.limits;
961
1032
  if (activityWindow) {
962
1033
  local.secondary_window = activityWindow;
963
1034
  local.source += activitySource === "disk-cache" ? "+cached-activity" : "+activity";
@@ -965,6 +1036,7 @@ async function fetchQoderLimits({
965
1036
  writeQoderLimitsCache(local, { home, nowMs, namespace: cacheNamespace });
966
1037
  return local;
967
1038
  }
1039
+
968
1040
  if (activityWindow) {
969
1041
  return {
970
1042
  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,