tokentracker-cli 1.5.2 → 1.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. package/dashboard/dist/assets/{AchievementBadge-B5kmt0VD.js → AchievementBadge-CKNb_cwg.js} +1 -1
  2. package/dashboard/dist/assets/{AchievementsPage-C0FlkOo0.js → AchievementsPage-u9P8DXJI.js} +2 -2
  3. package/dashboard/dist/assets/{AchievementsSection-CRcp80Pp.js → AchievementsSection-BSCUJ4DR.js} +1 -1
  4. package/dashboard/dist/assets/{BadgeDetailModal-DAFjGbKW.js → BadgeDetailModal-Bg7cE3Zq.js} +1 -1
  5. package/dashboard/dist/assets/{BotAnimated-CO0bbimB.js → BotAnimated-C7D2WbQA.js} +1 -1
  6. package/dashboard/dist/assets/{Card-Xhr5exz8.js → Card-B4IqAhai.js} +1 -1
  7. package/dashboard/dist/assets/{CloudCheckoutPage-DsQ7FmsI.js → CloudCheckoutPage-P75pH9Ym.js} +1 -1
  8. package/dashboard/dist/assets/{CloudPage-DkSexu5V.js → CloudPage-DDEZfmZg.js} +2 -2
  9. package/dashboard/dist/assets/{CommandPalette-Bm0huQdj.js → CommandPalette-CER_AklA.js} +1 -1
  10. package/dashboard/dist/assets/{CommunityStatsModal-BsL8GN8Q.js → CommunityStatsModal-Cbb74eib.js} +1 -1
  11. package/dashboard/dist/assets/{ConfirmModal-_WPp292g.js → ConfirmModal-Q1ZQdZHt.js} +1 -1
  12. package/dashboard/dist/assets/{Controls-ktzn68KM.js → Controls-DaGOfcoz.js} +1 -1
  13. package/dashboard/dist/assets/DashboardPage-CXI7RmxO.js +1 -0
  14. package/dashboard/dist/assets/{DateRangePopover-DWr4zIzF.js → DateRangePopover-DSS1Ira_.js} +1 -1
  15. package/dashboard/dist/assets/{DevicePage-CBa8Mk47.js → DevicePage-6zPLYydh.js} +1 -1
  16. package/dashboard/dist/assets/{DialogClose-CZ5jzgtb.js → DialogClose-DdCQ36LK.js} +1 -1
  17. package/dashboard/dist/assets/DialogDescription-DztHQvj-.js +1 -0
  18. package/dashboard/dist/assets/{DialogTitle-Dr0N6q15.js → DialogTitle-BjP217Y1.js} +1 -1
  19. package/dashboard/dist/assets/{ElectricLogo-sYqgrO6-.js → ElectricLogo-Dhq8PM4h.js} +1 -1
  20. package/dashboard/dist/assets/{FadeIn-Qq-hcrZW.js → FadeIn-CGNbED4E.js} +1 -1
  21. package/dashboard/dist/assets/{HeaderGithubStar-B77zSv7i.js → HeaderGithubStar-DwqEFzb6.js} +1 -1
  22. package/dashboard/dist/assets/{HoverTooltip-lRDaUmoW.js → HoverTooltip-CQZTgyQy.js} +1 -1
  23. package/dashboard/dist/assets/{IpCheckPage-B6AvHZjA.js → IpCheckPage-Bz09HXLn.js} +1 -1
  24. package/dashboard/dist/assets/{LandingPage-DgzOjf3x.js → LandingPage-DC-lZsYR.js} +2 -2
  25. package/dashboard/dist/assets/{LeaderboardPage-BtjjtDMC.js → LeaderboardPage-D8MpLM_t.js} +2 -2
  26. package/dashboard/dist/assets/{LeaderboardProBadge-B2wNTUTU.js → LeaderboardProBadge-pdXgokhE.js} +1 -1
  27. package/dashboard/dist/assets/{LeaderboardProfileModal-DJJckwHh.js → LeaderboardProfileModal-nHZLiE0m.js} +3 -3
  28. package/dashboard/dist/assets/{LeaderboardProfilePage-DJdxByl2.js → LeaderboardProfilePage-BKv3oIAW.js} +1 -1
  29. package/dashboard/dist/assets/LimitsPage-TmRTgSCb.js +6 -0
  30. package/dashboard/dist/assets/{LocalOnlyNotice-B2lVqf_H.js → LocalOnlyNotice-aF01udyR.js} +1 -1
  31. package/dashboard/dist/assets/{LoginCard-i0gg1yby.js → LoginCard-BaroD0Ct.js} +1 -1
  32. package/dashboard/dist/assets/{LoginModal-B-loLNZM.js → LoginModal-0jgyMGVM.js} +1 -1
  33. package/dashboard/dist/assets/{LoginPage-D_swI75j.js → LoginPage-BUx8U58i.js} +1 -1
  34. package/dashboard/dist/assets/{PetPage-DDpKvpjG.js → PetPage-CpOjtJqg.js} +2 -2
  35. package/dashboard/dist/assets/{PopoverPopup-Gja4MyEK.js → PopoverPopup-BYjfer-M.js} +1 -1
  36. package/dashboard/dist/assets/{ProviderIcon-DOPj7tAZ.js → ProviderIcon-NtYa2h0u.js} +1 -1
  37. package/dashboard/dist/assets/{ResetPasswordPage-BVADLk89.js → ResetPasswordPage-CYUg7TR1.js} +1 -1
  38. package/dashboard/dist/assets/{Select-D-38tN-E.js → Select-CYP95o5y.js} +1 -1
  39. package/dashboard/dist/assets/{SelectItemText-jN61ukxk.js → SelectItemText-BCDfusOM.js} +1 -1
  40. package/dashboard/dist/assets/{SelfHostPage-k7AkGpl1.js → SelfHostPage-BmVc2rIT.js} +1 -1
  41. package/dashboard/dist/assets/ServiceStatusPage-Dyp8NwIn.js +1 -0
  42. package/dashboard/dist/assets/SessionsPage-tGEj6Ztz.js +3 -0
  43. package/dashboard/dist/assets/{SettingsPage-DfnMCAuA.js → SettingsPage-CCMbq5wc.js} +3 -3
  44. package/dashboard/dist/assets/{SkillsPage-YyIHIaHN.js → SkillsPage-C4O4fbaQ.js} +1 -1
  45. package/dashboard/dist/assets/{TokenGalaxy-BLcvZuQ9.js → TokenGalaxy-D2d738xr.js} +1 -1
  46. package/dashboard/dist/assets/{TooltipPopup-3E8kyVzz.js → TooltipPopup-BJfFYesG.js} +1 -1
  47. package/dashboard/dist/assets/{TrendMonitor-ByKAdXSF.js → TrendMonitor-CElakFSU.js} +1 -1
  48. package/dashboard/dist/assets/{WidgetsPage-BgEVIIzL.js → WidgetsPage-Cvd1LbJm.js} +1 -1
  49. package/dashboard/dist/assets/{WrappedPage-HjbX8QaA.js → WrappedPage-wRC1tE_a.js} +1 -1
  50. package/dashboard/dist/assets/{__vite-browser-external-Br-KAXh6.css → __vite-browser-external-CMBWGkOq.css} +1 -1
  51. package/dashboard/dist/assets/__vite-browser-external-V4TPLCJG.js +45 -0
  52. package/dashboard/dist/assets/{agent-logos-BVF2siPQ.js → agent-logos-Ba57HF3w.js} +1 -1
  53. package/dashboard/dist/assets/{arrow-left-CYO24PGP.js → arrow-left-B1bKnxXL.js} +1 -1
  54. package/dashboard/dist/assets/{arrow-right-Xg9PZYhw.js → arrow-right-DrOlKzX5.js} +1 -1
  55. package/dashboard/dist/assets/{arrow-up-right-B7ah6Qv-.js → arrow-up-right-hlYpo3yw.js} +1 -1
  56. package/dashboard/dist/assets/{calendar-days-BV_iG938.js → calendar-days-CSY6SEsI.js} +1 -1
  57. package/dashboard/dist/assets/{check--EZH8RTp.js → check-xrP1gLw5.js} +1 -1
  58. package/dashboard/dist/assets/{chevron-down-BzGTlk0-.js → chevron-down-6-zT5n9d.js} +1 -1
  59. package/dashboard/dist/assets/{cloud-checkout-BgyxPHEh.js → cloud-checkout-BZymZD4K.js} +1 -1
  60. package/dashboard/dist/assets/{composite-C0KrmLEK.js → composite-C9izWafa.js} +1 -1
  61. package/dashboard/dist/assets/{copy-BtucSK2U.js → copy-Dkas5wZX.js} +1 -1
  62. package/dashboard/dist/assets/{download-CWHq03Y2.js → download-CagGRs0_.js} +1 -1
  63. package/dashboard/dist/assets/{external-link-DCTXF5VZ.js → external-link-DRl10hxd.js} +1 -1
  64. package/dashboard/dist/assets/{flame-BE5TsqVF.js → flame-CkvmsTFB.js} +1 -1
  65. package/dashboard/dist/assets/{format-tokens-ThNIMWnZ.js → format-tokens-BtwhO3mV.js} +1 -1
  66. package/dashboard/dist/assets/{gift-DgaN9s4-.js → gift-YzjXkMQx.js} +1 -1
  67. package/dashboard/dist/assets/{icons-B4r5EiDC.js → icons-CHhKYs0u.js} +1 -1
  68. package/dashboard/dist/assets/{index-DqCtgU0G.js → index-B0MQZTrM.js} +1 -1
  69. package/dashboard/dist/assets/{index-fNKH4U7j.js → index-C2DK8av-.js} +1 -1
  70. package/dashboard/dist/assets/{index-CKhcCLNC.js → index-CJknN9Jd.js} +1 -1
  71. package/dashboard/dist/assets/{index-DFF9xo6f.js → index-DojzoMqd.js} +1 -1
  72. package/dashboard/dist/assets/{info-B4YzRR4y.js → info-Torep-Ti.js} +1 -1
  73. package/dashboard/dist/assets/{link-2-DzbUbaBw.js → link-2-DE5Yu-z6.js} +1 -1
  74. package/dashboard/dist/assets/{loader-circle-B9c7D-7v.js → loader-circle-BL8LxD9n.js} +1 -1
  75. package/dashboard/dist/assets/{local-usage-transfer-btuk_b0Y.js → local-usage-transfer-8X1InWix.js} +1 -1
  76. package/dashboard/dist/assets/{modifiers.esm-BjrU-Xz7.js → modifiers.esm-D60-6UQa.js} +1 -1
  77. package/dashboard/dist/assets/{monitor-smartphone-Bwn4tb4q.js → monitor-smartphone-C-IFPjqk.js} +1 -1
  78. package/dashboard/dist/assets/{play-DIFOwGIQ.js → play-n6uWrdH9.js} +1 -1
  79. package/dashboard/dist/assets/{plus-BpBqnwVg.js → plus-B2f6TaNj.js} +1 -1
  80. package/dashboard/dist/assets/{provider-display-DSrs4u7T.js → provider-display-DWqCEcsP.js} +1 -1
  81. package/dashboard/dist/assets/{react-BgG7_MY0.js → react-B-zfi_j6.js} +1 -1
  82. package/dashboard/dist/assets/{safePolygon-BUGWIDOM.js → safePolygon-C2PsvI-v.js} +1 -1
  83. package/dashboard/dist/assets/{search-aHrmBmtZ.js → search-DZOaTLF9.js} +1 -1
  84. package/dashboard/dist/assets/{server-DOEvwlEU.js → server-DIp4Qfsv.js} +1 -1
  85. package/dashboard/dist/assets/{shield-check-BPTnLhsT.js → shield-check-Cs2M8-iT.js} +1 -1
  86. package/dashboard/dist/assets/{skills-api-C052Mwez.js → skills-api-CoYy6Tov.js} +1 -1
  87. package/dashboard/dist/assets/{sortable.esm-DaZKhjhu.js → sortable.esm-B5_hjIfs.js} +1 -1
  88. package/dashboard/dist/assets/{store-Cq30Pofw.js → store-BLXqtJzb.js} +1 -1
  89. package/dashboard/dist/assets/{terminal-CoSDSkE7.js → terminal-CEMKX3ba.js} +1 -1
  90. package/dashboard/dist/assets/{trash-BAmt-kPj.js → trash-DOWYaXjK.js} +1 -1
  91. package/dashboard/dist/assets/{use-community-stats-Bxq0kBhm.js → use-community-stats-BjRI1Bzz.js} +1 -1
  92. package/dashboard/dist/assets/{use-latest-request-guard-DmIQDlvk.js → use-latest-request-guard-T7L5qBU6.js} +1 -1
  93. package/dashboard/dist/assets/use-limits-display-prefs-vNIrCPZA.js +1 -0
  94. package/dashboard/dist/assets/{use-native-settings-s_CIC8Bb.js → use-native-settings-Cg5Zb4q_.js} +1 -1
  95. package/dashboard/dist/assets/{use-ordered-list-BHhykAbt.js → use-ordered-list-BF-wIvmG.js} +1 -1
  96. package/dashboard/dist/assets/use-reduced-motion-B9K5mIx4.js +1 -0
  97. package/dashboard/dist/assets/{use-transform-CVeMFlS6.js → use-transform-CbJ1IcNc.js} +1 -1
  98. package/dashboard/dist/assets/use-usage-limits-Dgg3jpC4.js +1 -0
  99. package/dashboard/dist/assets/{useCurrency-DilqL2Iv.js → useCurrency-3oBHWfSe.js} +1 -1
  100. package/dashboard/dist/assets/{usePositioner-BcnySw8a.js → usePositioner-D0YrIW0A.js} +1 -1
  101. package/dashboard/dist/assets/{useTokenFormat-CvKW46WP.js → useTokenFormat-DZAHWE_R.js} +1 -1
  102. package/dashboard/dist/assets/{zap-D7VRNTwk.js → zap-kLub6Jiv.js} +1 -1
  103. package/dashboard/dist/index.html +2 -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 +25 -15
  109. package/src/lib/accept-encoding.js +59 -0
  110. package/src/lib/cursor-config.js +3 -1
  111. package/src/lib/deepseek-limits.js +280 -0
  112. package/src/lib/deepseek-status.js +157 -0
  113. package/src/lib/grok-limits.js +40 -1
  114. package/src/lib/local-api.js +248 -10
  115. package/src/lib/opencode-status.js +88 -0
  116. package/src/lib/pricing/index.js +182 -79
  117. package/src/lib/pricing/seed-snapshot.json +1 -1
  118. package/src/lib/provider-api-key-store.js +2 -0
  119. package/src/lib/provider-status.js +27 -2
  120. package/src/lib/qoder-limits.js +17 -2
  121. package/src/lib/session-analytics.js +194 -29
  122. package/src/lib/static-server.js +74 -2
  123. package/src/lib/usage-limits.js +714 -53
  124. package/src/lib/workbuddy-limits.js +291 -26
  125. package/src/lib/zcode-limits.js +143 -35
  126. package/dashboard/dist/assets/DashboardPage-CdNXi3Im.js +0 -1
  127. package/dashboard/dist/assets/DialogDescription-kCZ4gN09.js +0 -1
  128. package/dashboard/dist/assets/LimitsPage-gSBHSBi_.js +0 -6
  129. package/dashboard/dist/assets/ServiceStatusPage-D_Fa_pYz.js +0 -1
  130. package/dashboard/dist/assets/SessionsPage-BymccEEz.js +0 -3
  131. package/dashboard/dist/assets/__vite-browser-external-D_cMeuqI.js +0 -45
  132. package/dashboard/dist/assets/use-limits-display-prefs-CyAn1fme.js +0 -1
  133. package/dashboard/dist/assets/use-reduced-motion-BmFyFE3S.js +0 -1
  134. package/dashboard/dist/assets/use-usage-limits-DRZMI20u.js +0 -1
@@ -19,10 +19,18 @@
19
19
  // Display names are English regardless of the backend's localized names.
20
20
 
21
21
  const fs = require("node:fs");
22
+ const os = require("node:os");
22
23
  const path = require("node:path");
23
24
  const { resolveWorkbuddyHome } = require("./rollout");
24
25
  const { UNAVAILABLE, readWorkbuddyIpcDiscovery, openWorkbuddyIpc } = require("./workbuddy-ipc");
25
26
 
27
+ const WORKBUDDY_LIMITS_CACHE_FILE = "workbuddy-usage-limits-cache.json";
28
+ // Windows without a usable reset_at — the pooled resource window unless it
29
+ // carries a readable expiring_at, plus a plan row whose cycle end never
30
+ // parsed — cannot be checked against a quota rollover, so they are only
31
+ // served while the snapshot is young enough to trust.
32
+ const WORKBUDDY_LIMITS_CACHE_UNKNOWN_RESET_TTL_MS = 12 * 60 * 60 * 1000;
33
+
26
34
  const PLAN_PRIORITY = Object.freeze({
27
35
  TCACA_code_027_0FCGVA6vSa: 4, // flagship
28
36
  TCACA_code_026_BaESVICNoi: 3, // advanced
@@ -187,6 +195,9 @@ function normalizePackageRow(row) {
187
195
  used,
188
196
  cycleEnd: firstString(row, ["CycleEndTime", "cycleEnd", "cycle_end"])
189
197
  || firstString(row, ["DeductionEndTime", "expireAt"]),
198
+ // Set once a grant has terminated (spent or revoked); active grants
199
+ // carry an empty string.
200
+ terminatedAt: firstString(row, ["ExpiredTime", "expiredTime"]),
190
201
  };
191
202
  }
192
203
 
@@ -398,6 +409,59 @@ function resourceWindow(pool) {
398
409
  };
399
410
  }
400
411
 
412
+ // Resource packages die individually: paid add-ons and reward grants each
413
+ // carry their own cycle end, so the pooled window can advertise when its
414
+ // credits start to lapse — "by <day>, <credits> expire" beats a bare date
415
+ // because a date alone cannot say how much is at stake. Grants are bucketed
416
+ // by local calendar day and only the earliest day that still holds credit is
417
+ // reported; later days belong in a per-grant breakdown, not the pooled bar.
418
+ // Rows that could be the subscription plan are not resource credit (the same
419
+ // rule collectResourceGroups applies), and a grant with no remaining credit,
420
+ // a past end, an unreadable end, or a termination stamp has nothing to lose.
421
+ function collectExpiringResourceCredits(rows, identity = {}, nowMs = Date.now()) {
422
+ const grants = new Map();
423
+ for (const row of rows) {
424
+ if (!row || isSelectablePlan(row, identity.code)) continue;
425
+ if (row.terminatedAt) continue;
426
+ const remain = row.remain
427
+ ?? (row.total !== null && row.used !== null ? Math.max(0, row.total - row.used) : null);
428
+ if (remain === null || remain <= 0) continue;
429
+ const endMs = Date.parse(normalizeResetAt(row.cycleEnd) || "");
430
+ if (!Number.isFinite(endMs) || endMs <= nowMs) continue;
431
+ // The summary and the paid/free pages list the same grant; one code with
432
+ // one end time is one grant, and the later listing wins the way
433
+ // mergePackageRows merges.
434
+ grants.set(`${row.code}\u0000${endMs}`, { endMs, remain });
435
+ }
436
+ let earliest = null;
437
+ for (const grant of grants.values()) {
438
+ const day = localDayNumber(grant.endMs);
439
+ if (!earliest || day < earliest.day) {
440
+ earliest = { day, endMs: grant.endMs, firstMs: grant.endMs, credits: grant.remain };
441
+ } else if (day === earliest.day) {
442
+ // Grants lapsing the same day merge; the moment to advertise is the
443
+ // last one, when the day's final grant dies, while the first one marks
444
+ // when the pool's numbers actually start to drift.
445
+ earliest.endMs = Math.max(earliest.endMs, grant.endMs);
446
+ earliest.firstMs = Math.min(earliest.firstMs, grant.endMs);
447
+ earliest.credits += grant.remain;
448
+ }
449
+ }
450
+ if (!earliest) return null;
451
+ return {
452
+ at: new Date(earliest.endMs).toISOString(),
453
+ firstAt: new Date(earliest.firstMs).toISOString(),
454
+ credits: earliest.credits,
455
+ };
456
+ }
457
+
458
+ // Year*10000 + month*100 + day, local time — an ordering key that survives
459
+ // unpadded string comparison and matches how the end times parse.
460
+ function localDayNumber(ms) {
461
+ const date = new Date(ms);
462
+ return date.getFullYear() * 10000 + (date.getMonth() + 1) * 100 + date.getDate();
463
+ }
464
+
401
465
  function englishPlanLabel(row, identity, variantId) {
402
466
  // An actual English subscription/package name takes precedence over the
403
467
  // client's edition fallback. Unknown localized tiers stay unnamed.
@@ -451,12 +515,16 @@ function readAuthFile(filePath) {
451
515
  const auth = parsed?.auth;
452
516
  if (!auth || typeof auth !== "object") return null;
453
517
  const domain = isNonEmptyString(auth.domain) ? auth.domain.trim() : "";
518
+ // The app records the signed-in account next to its (often encrypted)
519
+ // session. The uid stays readable while the app is closed, so it is the
520
+ // one account signal that survives into the UNAVAILABLE state.
521
+ const uid = isNonEmptyString(parsed.account?.uid) ? parsed.account.uid.trim() : "";
454
522
  const token = auth.accessToken;
455
- if (isNonEmptyString(token)) return { token: token.trim(), domain, encrypted: false };
523
+ if (isNonEmptyString(token)) return { token: token.trim(), domain, encrypted: false, uid };
456
524
  if (token && typeof token === "object" && token.$wbEncrypted === 1) {
457
- return { token: null, domain, encrypted: true };
525
+ return { token: null, domain, encrypted: true, uid };
458
526
  }
459
- return { token: null, domain, encrypted: false };
527
+ return { token: null, domain, encrypted: false, uid };
460
528
  }
461
529
 
462
530
  function resolveSession(variant, { env, authFiles, workbuddyHome }) {
@@ -464,15 +532,130 @@ function resolveSession(variant, { env, authFiles, workbuddyHome }) {
464
532
  ? authFiles
465
533
  : authSearchDirs(env, workbuddyHome).flatMap((dir) => variant.authFileNames.map((name) => path.join(dir, name)));
466
534
  let encryptedForVariant = false;
535
+ let encryptedUid = "";
467
536
  for (const filePath of files) {
468
537
  if (!filePath) continue;
469
538
  const session = readAuthFile(filePath);
470
539
  if (!session) continue;
471
540
  if (!hostAllowed(hostOf(session.domain), variant.allowedHostSuffixes)) continue;
472
- if (session.token) return { token: session.token, encrypted: false, matched: true };
473
- if (session.encrypted) encryptedForVariant = true;
541
+ if (session.token) return { token: session.token, encrypted: false, matched: true, uid: session.uid };
542
+ if (session.encrypted) {
543
+ encryptedForVariant = true;
544
+ // First encrypted candidate wins: when the app is closed this record is
545
+ // what a live read would authenticate with, so its account is the one
546
+ // the cached windows must match.
547
+ encryptedUid = encryptedUid || session.uid;
548
+ }
549
+ }
550
+ return { token: null, encrypted: encryptedForVariant, matched: encryptedForVariant, uid: encryptedUid };
551
+ }
552
+
553
+ // ---------------------------------------------------------------------------
554
+ // Last-good disk cache for WorkBuddy quota windows (house pattern: codex /
555
+ // ark / qoder / zcode, issue #952). A live read — IPC or API — persists the
556
+ // windows per edition; when the live chain later fails, the stale windows are
557
+ // served with data-age markers instead of a bare "Start WorkBuddy and sign
558
+ // in". The steady state this covers is the app being closed: wbipc discovery
559
+ // is gone and the encrypted session cannot be read.
560
+ //
561
+ // Editions never borrow each other's data: workbuddy CN and workbuddyAi are
562
+ // separate partitions of one file, matching the edition-local socket
563
+ // discipline. Partitions are also scoped to the login that produced the
564
+ // windows, so an account switch followed by a live failure cannot serve the
565
+ // previous account's bars.
566
+ //
567
+ // The cache identity comes from the session selection itself (resolveSession):
568
+ // the record whose token supplies — or, while the app is closed, would supply
569
+ // — the live read. It is the app's own account uid, readable after the app
570
+ // closes when the encrypted session token is not, and it is an account id,
571
+ // not a credential: nothing secret is persisted or hashed. An account the
572
+ // records do not name ("unknown") matches only its own entries.
573
+
574
+ function workbuddyLimitsCachePath({ home } = {}) {
575
+ return path.join(home || os.homedir(), ".tokentracker", "tracker", WORKBUDDY_LIMITS_CACHE_FILE);
576
+ }
577
+
578
+ // A window whose reset_at has already passed is stale data, not a usable
579
+ // fallback — the quota has rolled over, so drop it. Undated windows (the
580
+ // resource pool without a readable expiring_at, or a plan row without a
581
+ // readable cycle end) cannot be checked against a reset and are bounded by
582
+ // the snapshot-age TTL instead.
583
+ function workbuddyCachedWindow(window, { cachedAtMs, nowMs } = {}) {
584
+ if (!window || typeof window !== "object") return null;
585
+ const resetAtMs = Date.parse(window.reset_at || "");
586
+ if (Number.isFinite(resetAtMs)) return resetAtMs > nowMs ? window : null;
587
+ // The resource pool has no reset but may carry its earliest grant expiry.
588
+ // expiring_first_at is the first moment the advertised credits start to
589
+ // lapse — from then on the pool's numbers no longer describe the account,
590
+ // so the snapshot is trusted exactly until then; expiring_at (the day's
591
+ // end) is only the display anchor. The same discipline as a reset date,
592
+ // not the undated TTL.
593
+ const expiringAtMs = Date.parse(window.expiring_first_at || "");
594
+ const anchorMs = Number.isFinite(expiringAtMs) ? expiringAtMs : Date.parse(window.expiring_at || "");
595
+ if (Number.isFinite(anchorMs)) return anchorMs > nowMs ? window : null;
596
+ return nowMs - cachedAtMs <= WORKBUDDY_LIMITS_CACHE_UNKNOWN_RESET_TTL_MS ? window : null;
597
+ }
598
+
599
+ /** Read the last-good windows for one edition, scoped to the login that wrote them. */
600
+ function readWorkbuddyLimitsCache({ home, nowMs = Date.now(), variantId, identity } = {}) {
601
+ // An explicit home keeps the cache hermetic: callers that never said where
602
+ // the user's tracker state lives (tests, odd embedders) must not touch the
603
+ // developer's real cache file.
604
+ if (!home || !variantId || !identity) return null;
605
+ let parsed;
606
+ try {
607
+ parsed = JSON.parse(fs.readFileSync(workbuddyLimitsCachePath({ home }), "utf8"));
608
+ } catch (_error) {
609
+ return null;
474
610
  }
475
- return { token: null, encrypted: encryptedForVariant, matched: encryptedForVariant };
611
+ if (!parsed || typeof parsed !== "object") return null;
612
+ const raw = parsed[variantId];
613
+ if (!raw || typeof raw !== "object") return null;
614
+ if (raw.identity !== identity) return null;
615
+ const cachedAtMs = Date.parse(raw.cached_at || "");
616
+ if (!Number.isFinite(cachedAtMs) || cachedAtMs > nowMs + 60_000) return null;
617
+ const context = { cachedAtMs, nowMs };
618
+ const primaryWindow = workbuddyCachedWindow(raw.primary_window, context);
619
+ const secondaryWindow = workbuddyCachedWindow(raw.secondary_window, context);
620
+ // A partition whose windows all rolled or expired no longer describes the
621
+ // account's quota — withholding it lets the live error stay visible.
622
+ if (!primaryWindow && !secondaryWindow) return null;
623
+ return {
624
+ configured: true,
625
+ error: null,
626
+ plan_label: typeof raw.plan_label === "string" ? raw.plan_label : null,
627
+ primary_window: primaryWindow,
628
+ secondary_window: secondaryWindow,
629
+ cached: true,
630
+ stale: true,
631
+ cached_at: raw.cached_at,
632
+ source: "disk-cache",
633
+ };
634
+ }
635
+
636
+ /** Merge one edition's partition into the disk cache; best-effort, never throws. */
637
+ function writeWorkbuddyLimitsCache(limits, { home, nowMs = Date.now(), variantId, identity } = {}) {
638
+ if (!home || !variantId || !identity || !limits?.configured || limits.error) return;
639
+ if (!limits.primary_window && !limits.secondary_window) return;
640
+ const cachePath = workbuddyLimitsCachePath({ home });
641
+ let payload = {};
642
+ try {
643
+ const parsed = JSON.parse(fs.readFileSync(cachePath, "utf8"));
644
+ if (parsed && typeof parsed === "object") payload = parsed;
645
+ } catch (_error) {}
646
+ payload[variantId] = {
647
+ identity,
648
+ plan_label: typeof limits.plan_label === "string" ? limits.plan_label : null,
649
+ primary_window: limits.primary_window || null,
650
+ secondary_window: limits.secondary_window || null,
651
+ cached_at: new Date(nowMs).toISOString(),
652
+ };
653
+ try {
654
+ fs.mkdirSync(path.dirname(cachePath), { recursive: true });
655
+ const tmpPath = `${cachePath}.${process.pid}.tmp`;
656
+ fs.writeFileSync(tmpPath, JSON.stringify(payload, null, 2), { encoding: "utf8", mode: 0o600 });
657
+ fs.renameSync(tmpPath, cachePath);
658
+ } catch (_error) {}
476
659
  }
477
660
 
478
661
  function readProductEndpoint(workbuddyHome) {
@@ -539,18 +722,27 @@ async function postBilling(fetchImpl, url, token, body = {}, signal) {
539
722
  return response.json();
540
723
  }
541
724
 
542
- async function fetchPlanQuota(post, variantId) {
725
+ async function fetchPlanQuota(post, variantId, nowMs) {
543
726
  const prefix = "/billing/meter/";
544
727
  const summary = await post(`${prefix}get-user-resource-summary`, {});
545
728
  if (!unwrapBillingBody(summary)) throw new Error("WorkBuddy quota request was rejected.");
546
729
  const identity = readSubscriptionIdentity(summary);
730
+ const summaryRows = collectPackageRows(summary);
547
731
  const planCodes = [...new Set([...Object.keys(PLAN_PRIORITY),
548
732
  ...(identity.code && !isRewardCode(identity.code) ? [identity.code] : [])])];
733
+ // Resource grants ride along in the same pages query. The summary carries
734
+ // their aggregated pool row without a cycle end, so the per-grant rows —
735
+ // where each grant's own CycleEndTime lives — only come back when their
736
+ // package codes are requested; a plan-code-only listing never sees them.
737
+ const resourceCodes = summaryRows
738
+ .filter((row) => !isSelectablePlan(row, identity.code))
739
+ .map((row) => row.code);
740
+ const packageCodes = [...new Set([...planCodes, ...resourceCodes])];
549
741
  const packageRows = await Promise.all(["paid", "free"].map(async (kind) => {
550
742
  const rows = [];
551
743
  for (let page = 1; page <= 10; page++) {
552
744
  const payload = await post(`${prefix}get-user-resource-${kind}-packages`, {
553
- PageNumber: page, PageSize: 100, PackageCodes: planCodes,
745
+ PageNumber: page, PageSize: 100, PackageCodes: packageCodes,
554
746
  Status: [0, 3], ...(kind === "paid" ? { NeedRenewInfo: true } : {}),
555
747
  });
556
748
  const data = unwrapBillingBody(payload);
@@ -561,14 +753,28 @@ async function fetchPlanQuota(post, variantId) {
561
753
  }
562
754
  throw new Error("WorkBuddy quota package list was incomplete.");
563
755
  }));
564
- const quota = selectWorkbuddyPlanQuota([
565
- ...collectPackageRows(summary), ...packageRows.flat(),
566
- ], identity, variantId);
756
+ const quota = selectWorkbuddyPlanQuota([...summaryRows, ...packageRows.flat()], identity, variantId);
757
+ const secondary = resourceWindow(collectResourceGroups(summary));
758
+ if (secondary) {
759
+ // The pooled bar cannot say which credits lapse first; the grant rows
760
+ // carry each grant's cycle end. The summary's own pool row has no cycle
761
+ // end and skips the expiry bucket, so grants are not double-counted. A
762
+ // pool without a window is dropped wholesale, so expiry has nothing to
763
+ // attach to.
764
+ const expiring = collectExpiringResourceCredits(
765
+ [...summaryRows, ...packageRows.flat()], identity, nowMs,
766
+ );
767
+ if (expiring) {
768
+ secondary.expiring_at = expiring.at;
769
+ secondary.expiring_first_at = expiring.firstAt;
770
+ secondary.expiring_credits = expiring.credits;
771
+ }
772
+ }
567
773
  return {
568
774
  configured: true, error: null,
569
775
  plan_label: quota?.plan_label || englishPlanLabel({ code: identity.code }, identity, variantId),
570
776
  primary_window: quota?.primary_window || null,
571
- secondary_window: resourceWindow(collectResourceGroups(summary)),
777
+ secondary_window: secondary,
572
778
  };
573
779
  }
574
780
 
@@ -584,33 +790,35 @@ function scopedEnv(env, home) {
584
790
  return next;
585
791
  }
586
792
 
587
- async function fetchVariant(variantId, opts = {}) {
588
- const variant = variantById(variantId);
589
- if (!variant) return { configured: false };
590
- const env = scopedEnv(opts.env || process.env, opts.home);
591
- const workbuddyHome = opts.workbuddyHome || resolveWorkbuddyHome(env, variant.source);
592
- const installed = directoryExists(workbuddyHome);
793
+ // Live chain for one edition, unchanged by the cache layer: the IPC proxy
794
+ // while the app publishes it, then the API source with a resolvable session.
795
+ // The session is selected by the caller (fetchVariant) so the cache identity
796
+ // and the live read cannot disagree about the account. Throws propagate to
797
+ // the cache wrapper below; the { configured: true, error: UNAVAILABLE }
798
+ // result comes back when the app is closed and no session can be read.
799
+ async function fetchVariantLive(variant, variantId, { env, workbuddyHome, installed, opts, session }) {
593
800
  const discovery = readWorkbuddyIpcDiscovery(workbuddyHome);
594
801
  if (discovery) {
595
802
  // Discovery is edition-local. Never fall back to the other edition's
596
803
  // socket or allow an explicitly redirected product to be mislabelled.
597
804
  const productEndpoint = readProductEndpoint(workbuddyHome);
598
805
  if (productEndpoint && !originAllowed(productEndpoint, variant)) {
599
- throw new Error("WorkBuddy quota host does not match this edition.");
806
+ const error = new Error("WorkBuddy quota host does not match this edition.");
807
+ // Tagged so the cache wrapper surfaces the mislabelled host instead of
808
+ // stale bars from whichever login wrote them.
809
+ error.code = "EDITION_MISMATCH";
810
+ throw error;
600
811
  }
601
812
  const client = await openWorkbuddyIpc({
602
813
  discovery, signal: opts.signal, timeoutMs: opts.timeoutMs, connectImpl: opts.connectImpl,
603
814
  });
604
815
  try {
605
- return { ...await fetchPlanQuota((apiPath, body) => client.postBilling(apiPath, body), variantId), source: "ipc" };
816
+ return { ...await fetchPlanQuota((apiPath, body) => client.postBilling(apiPath, body), variantId,
817
+ Number.isFinite(opts.nowMs) ? opts.nowMs : undefined), source: "ipc" };
606
818
  } finally {
607
819
  client.close();
608
820
  }
609
821
  }
610
- const explicitToken = resolveToken(variant, env);
611
- const session = explicitToken
612
- ? { token: explicitToken, encrypted: false, matched: true }
613
- : resolveSession(variant, { env, authFiles: opts.authFiles, workbuddyHome });
614
822
  if (!session.token) {
615
823
  if (session.encrypted || installed) return { configured: true, error: UNAVAILABLE };
616
824
  if (!installed) return { configured: false };
@@ -625,10 +833,63 @@ async function fetchVariant(variantId, opts = {}) {
625
833
  workbuddyHome: opts.origin ? null : workbuddyHome,
626
834
  });
627
835
  if (!originAllowed(origin, variant)) {
628
- throw new Error("WorkBuddy quota host does not match this edition.");
836
+ const error = new Error("WorkBuddy quota host does not match this edition.");
837
+ error.code = "EDITION_MISMATCH";
838
+ throw error;
629
839
  }
630
840
  return { ...await fetchPlanQuota((apiPath, body) => postBilling(fetchImpl,
631
- `${origin}${apiPath}`, session.token, body, opts.signal), variantId), source: "api" };
841
+ `${origin}${apiPath}`, session.token, body, opts.signal), variantId,
842
+ Number.isFinite(opts.nowMs) ? opts.nowMs : undefined), source: "api" };
843
+ }
844
+
845
+ async function fetchVariant(variantId, opts = {}) {
846
+ const variant = variantById(variantId);
847
+ if (!variant) return { configured: false };
848
+ const env = scopedEnv(opts.env || process.env, opts.home);
849
+ const workbuddyHome = opts.workbuddyHome || resolveWorkbuddyHome(env, variant.source);
850
+ const installed = directoryExists(workbuddyHome);
851
+ // Sampled when used, not when the fetch starts: the live attempt can spend
852
+ // the whole provider budget before a cache read or write happens, and a
853
+ // window whose reset rolled in that span must not be served. An injected
854
+ // clock (tests) keeps every use on the same instant.
855
+ const nowMs = () => (Number.isFinite(opts.nowMs) ? opts.nowMs : Date.now());
856
+ // One session selection feeds both the live attempt and the cache identity:
857
+ // cached windows are stamped with (and served only to) the account whose
858
+ // record supplies — or, while the app is closed, would supply — the session
859
+ // token. An env-token session names no record; "unknown" matches only its
860
+ // own entries.
861
+ const explicitToken = resolveToken(variant, env);
862
+ const session = explicitToken
863
+ ? { token: explicitToken, encrypted: false, matched: true, uid: "" }
864
+ : resolveSession(variant, { env, authFiles: opts.authFiles, workbuddyHome });
865
+ const identity = (explicitToken ? "" : session.uid) || "unknown";
866
+ let live;
867
+ try {
868
+ live = await fetchVariantLive(variant, variantId, { env, workbuddyHome, installed, opts, session });
869
+ } catch (error) {
870
+ // Re-login stays actionable (opencode-go auth_error / codex reauth
871
+ // precedent) and a mislabelled host is a configuration fault, not
872
+ // transient data loss: neither may be masked by stale bars.
873
+ if (error?.code === "AUTH_EXPIRED" || error?.code === "EDITION_MISMATCH") throw error;
874
+ const cached = readWorkbuddyLimitsCache({ home: opts.home, nowMs: nowMs(), variantId, identity });
875
+ if (cached) return cached;
876
+ throw error;
877
+ }
878
+ if (live.configured === true && !live.error) {
879
+ // Live read is current as of now: persist it so a later failure serves
880
+ // stale bars instead of a vanished card.
881
+ writeWorkbuddyLimitsCache(live, { home: opts.home, nowMs: nowMs(), variantId, identity });
882
+ return live;
883
+ }
884
+ if (live.configured === true && live.error) {
885
+ // The steady state the cache exists for (UNAVAILABLE: app closed, session
886
+ // unreadable) and transient API/IPC failures. A never-signed-in install
887
+ // ({ configured: false }) stays untouched — an edition that produced no
888
+ // windows here has nothing to fall back to.
889
+ const cached = readWorkbuddyLimitsCache({ home: opts.home, nowMs: nowMs(), variantId, identity });
890
+ if (cached) return cached;
891
+ }
892
+ return live;
632
893
  }
633
894
 
634
895
  function fetchWorkbuddyLimits(opts) {
@@ -647,10 +908,14 @@ module.exports = {
647
908
  selectWorkbuddyLimits,
648
909
  collectPackageRows,
649
910
  collectResourceGroups,
911
+ collectExpiringResourceCredits,
650
912
  resourceWindow,
651
913
  readSubscriptionIdentity,
652
914
  originAllowed,
653
915
  hostAllowed,
916
+ readWorkbuddyLimitsCache,
917
+ writeWorkbuddyLimitsCache,
918
+ workbuddyLimitsCachePath,
654
919
  fetchWorkbuddyLimits,
655
920
  fetchWorkbuddyAiLimits,
656
921
  };