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
@@ -12,16 +12,25 @@
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");
22
+ const os = require("node:os");
21
23
  const path = require("node:path");
22
24
  const { resolveWorkbuddyHome } = require("./rollout");
23
25
  const { UNAVAILABLE, readWorkbuddyIpcDiscovery, openWorkbuddyIpc } = require("./workbuddy-ipc");
24
26
 
27
+ const WORKBUDDY_LIMITS_CACHE_FILE = "workbuddy-usage-limits-cache.json";
28
+ // Windows without a usable reset_at — the pooled resource window by design,
29
+ // plus a plan row whose cycle end never parsed — cannot be checked against a
30
+ // quota rollover, so they are only served while the snapshot is young enough
31
+ // to trust.
32
+ const WORKBUDDY_LIMITS_CACHE_UNKNOWN_RESET_TTL_MS = 12 * 60 * 60 * 1000;
33
+
25
34
  const PLAN_PRIORITY = Object.freeze({
26
35
  TCACA_code_027_0FCGVA6vSa: 4, // flagship
27
36
  TCACA_code_026_BaESVICNoi: 3, // advanced
@@ -278,8 +287,8 @@ function planWindow(row) {
278
287
  };
279
288
  }
280
289
 
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.
290
+ // Legacy merge for the pre-split unified row. The local app now publishes
291
+ // both editions side by side; cloud mirrors of this module still merge.
283
292
  function selectWorkbuddyLimits(china, international) {
284
293
  const editions = [china, international].filter(Boolean);
285
294
  const usable = (value) => value.configured === true && !value.error;
@@ -289,9 +298,10 @@ function selectWorkbuddyLimits(china, international) {
289
298
  || { configured: false };
290
299
  }
291
300
 
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.
301
+ // Pick the one subscription-plan package. Reward rows are ignored here — they
302
+ // feed the resource-pool window instead. When several plan packages exist, the
303
+ // highest tier that still has credit wins, matching the desktop app's
304
+ // pickActivePlanBase.
295
305
  function selectWorkbuddyPlanQuota(rows, identity = {}, variantId = "workbuddy") {
296
306
  const merged = mergePackageRows(rows.filter(Boolean));
297
307
  const subscriptionPackageCode = identity.code || "";
@@ -309,6 +319,93 @@ function selectWorkbuddyPlanQuota(rows, identity = {}, variantId = "workbuddy")
309
319
  };
310
320
  }
311
321
 
322
+ // The desktop app's billing meter splits an account into CreditGroups:
323
+ // plan_base (套餐基础积分) plus the non-plan pools plan_bonus (套餐赠送积分),
324
+ // addon (购买积分) and benefit (平台奖励积分). The app shows those pools as
325
+ // resource packages on top of the plan, so mirror that split: one window for
326
+ // the plan cycle, one pooled window for everything else. Groups the summary
327
+ // does not quantify are ignored; a pool with no credit at all stays null so
328
+ // the UI drops it like a missing window.
329
+ function collectResourceGroups(summary) {
330
+ const data = unwrapBillingBody(summary);
331
+ if (!data) return null;
332
+ const groups = [
333
+ data.CreditGroups,
334
+ data.creditGroups,
335
+ data.credit_groups,
336
+ ].find(Array.isArray);
337
+ if (groups) {
338
+ const pool = { total: 0, used: 0, remain: 0, remainDerived: 0, seen: false, usedSeen: false, remainSeen: false };
339
+ for (const group of groups) {
340
+ if (!group || typeof group !== "object") continue;
341
+ const kind = firstString(group, ["Kind", "kind"]);
342
+ if (kind && kind.toLowerCase() === "plan_base") continue;
343
+ const total = firstCount(group, ["CycleTotalCapacity", "total"]);
344
+ const used = firstCount(group, ["CycleUsedCapacity", "used"]);
345
+ const remain = firstCount(group, ["CycleRemainCapacity", "remain"]);
346
+ if (total === null && used === null && remain === null) continue;
347
+ pool.seen = true;
348
+ // A group that omits its usage is still counted: derive it from the
349
+ // capacity identity, the way planWindow does for the plan row.
350
+ const knownUsed = used ?? (total !== null && remain !== null ? Math.max(0, total - remain) : null);
351
+ pool.usedSeen = pool.usedSeen || knownUsed !== null;
352
+ pool.remainSeen = pool.remainSeen || remain !== null;
353
+ pool.total += total ?? 0;
354
+ pool.used += knownUsed ?? 0;
355
+ if (remain === null) pool.remainDerived += Math.max(0, (total ?? 0) - (used ?? 0));
356
+ else pool.remain += remain;
357
+ }
358
+ return pool.seen ? pool : null;
359
+ }
360
+ // Older summaries predate CreditGroups. Their package rows carry the same
361
+ // pools one row per package — every row that cannot be the subscription
362
+ // plan is resource credit.
363
+ const identity = readSubscriptionIdentity(summary);
364
+ const rows = collectPackageRows(summary)
365
+ .filter((row) => !isSelectablePlan(row, identity.code));
366
+ if (rows.length === 0) return null;
367
+ const derivedUsed = (row) =>
368
+ row.used ?? (row.total !== null && row.remain !== null ? Math.max(0, row.total - row.remain) : null);
369
+ return {
370
+ total: rows.reduce((sum, row) => sum + (row.total ?? 0), 0),
371
+ used: rows.reduce((sum, row) => sum + (derivedUsed(row) ?? 0), 0),
372
+ remain: rows.reduce((sum, row) => sum + (row.remain ?? 0), 0),
373
+ remainDerived: rows.reduce(
374
+ (sum, row) => sum + (row.remain === null ? Math.max(0, (row.total ?? 0) - (row.used ?? 0)) : 0),
375
+ 0,
376
+ ),
377
+ seen: true,
378
+ usedSeen: rows.some((row) => derivedUsed(row) !== null),
379
+ remainSeen: rows.some((row) => row.remain !== null),
380
+ };
381
+ }
382
+
383
+ // Resource credits have no shared cycle: benefit and add-on packages carry
384
+ // their own grants, so the pooled window has no reset. An empty pool (0/0)
385
+ // yields no window instead of a full or zeroed bar.
386
+ function resourceWindow(pool) {
387
+ if (!pool || !(pool.total > 0) || !Number.isFinite(pool.total)) return null;
388
+ // No group reported either side of the meter: usage is unknowable, not
389
+ // zero — hide the pool instead of rendering a full bar.
390
+ if (!pool.usedSeen && !pool.remainSeen) return null;
391
+ const used = pool.usedSeen || pool.used > 0
392
+ ? pool.used
393
+ : Math.max(0, pool.total - (pool.remain + pool.remainDerived));
394
+ const usedPercent = clampPercent((used / pool.total) * 100);
395
+ if (usedPercent === null) return null;
396
+ const remaining = pool.remainSeen
397
+ ? pool.remain + pool.remainDerived
398
+ : Math.max(0, pool.total - used);
399
+ if (!Number.isFinite(remaining)) return null;
400
+ return {
401
+ used_percent: usedPercent,
402
+ reset_at: null,
403
+ used_credits: used,
404
+ limit_credits: pool.total,
405
+ remaining_credits: remaining,
406
+ };
407
+ }
408
+
312
409
  function englishPlanLabel(row, identity, variantId) {
313
410
  // An actual English subscription/package name takes precedence over the
314
411
  // client's edition fallback. Unknown localized tiers stay unnamed.
@@ -362,12 +459,16 @@ function readAuthFile(filePath) {
362
459
  const auth = parsed?.auth;
363
460
  if (!auth || typeof auth !== "object") return null;
364
461
  const domain = isNonEmptyString(auth.domain) ? auth.domain.trim() : "";
462
+ // The app records the signed-in account next to its (often encrypted)
463
+ // session. The uid stays readable while the app is closed, so it is the
464
+ // one account signal that survives into the UNAVAILABLE state.
465
+ const uid = isNonEmptyString(parsed.account?.uid) ? parsed.account.uid.trim() : "";
365
466
  const token = auth.accessToken;
366
- if (isNonEmptyString(token)) return { token: token.trim(), domain, encrypted: false };
467
+ if (isNonEmptyString(token)) return { token: token.trim(), domain, encrypted: false, uid };
367
468
  if (token && typeof token === "object" && token.$wbEncrypted === 1) {
368
- return { token: null, domain, encrypted: true };
469
+ return { token: null, domain, encrypted: true, uid };
369
470
  }
370
- return { token: null, domain, encrypted: false };
471
+ return { token: null, domain, encrypted: false, uid };
371
472
  }
372
473
 
373
474
  function resolveSession(variant, { env, authFiles, workbuddyHome }) {
@@ -375,15 +476,120 @@ function resolveSession(variant, { env, authFiles, workbuddyHome }) {
375
476
  ? authFiles
376
477
  : authSearchDirs(env, workbuddyHome).flatMap((dir) => variant.authFileNames.map((name) => path.join(dir, name)));
377
478
  let encryptedForVariant = false;
479
+ let encryptedUid = "";
378
480
  for (const filePath of files) {
379
481
  if (!filePath) continue;
380
482
  const session = readAuthFile(filePath);
381
483
  if (!session) continue;
382
484
  if (!hostAllowed(hostOf(session.domain), variant.allowedHostSuffixes)) continue;
383
- if (session.token) return { token: session.token, encrypted: false, matched: true };
384
- if (session.encrypted) encryptedForVariant = true;
485
+ if (session.token) return { token: session.token, encrypted: false, matched: true, uid: session.uid };
486
+ if (session.encrypted) {
487
+ encryptedForVariant = true;
488
+ // First encrypted candidate wins: when the app is closed this record is
489
+ // what a live read would authenticate with, so its account is the one
490
+ // the cached windows must match.
491
+ encryptedUid = encryptedUid || session.uid;
492
+ }
493
+ }
494
+ return { token: null, encrypted: encryptedForVariant, matched: encryptedForVariant, uid: encryptedUid };
495
+ }
496
+
497
+ // ---------------------------------------------------------------------------
498
+ // Last-good disk cache for WorkBuddy quota windows (house pattern: codex /
499
+ // ark / qoder / zcode, issue #952). A live read — IPC or API — persists the
500
+ // windows per edition; when the live chain later fails, the stale windows are
501
+ // served with data-age markers instead of a bare "Start WorkBuddy and sign
502
+ // in". The steady state this covers is the app being closed: wbipc discovery
503
+ // is gone and the encrypted session cannot be read.
504
+ //
505
+ // Editions never borrow each other's data: workbuddy CN and workbuddyAi are
506
+ // separate partitions of one file, matching the edition-local socket
507
+ // discipline. Partitions are also scoped to the login that produced the
508
+ // windows, so an account switch followed by a live failure cannot serve the
509
+ // previous account's bars.
510
+ //
511
+ // The cache identity comes from the session selection itself (resolveSession):
512
+ // the record whose token supplies — or, while the app is closed, would supply
513
+ // — the live read. It is the app's own account uid, readable after the app
514
+ // closes when the encrypted session token is not, and it is an account id,
515
+ // not a credential: nothing secret is persisted or hashed. An account the
516
+ // records do not name ("unknown") matches only its own entries.
517
+
518
+ function workbuddyLimitsCachePath({ home } = {}) {
519
+ return path.join(home || os.homedir(), ".tokentracker", "tracker", WORKBUDDY_LIMITS_CACHE_FILE);
520
+ }
521
+
522
+ // A window whose reset_at has already passed is stale data, not a usable
523
+ // fallback — the quota has rolled over, so drop it. Undated windows (the
524
+ // resource pool, or a plan row without a readable cycle end) cannot be
525
+ // checked against a reset and are bounded by the snapshot-age TTL instead.
526
+ function workbuddyCachedWindow(window, { cachedAtMs, nowMs } = {}) {
527
+ if (!window || typeof window !== "object") return null;
528
+ const resetAtMs = Date.parse(window.reset_at || "");
529
+ if (Number.isFinite(resetAtMs)) return resetAtMs > nowMs ? window : null;
530
+ return nowMs - cachedAtMs <= WORKBUDDY_LIMITS_CACHE_UNKNOWN_RESET_TTL_MS ? window : null;
531
+ }
532
+
533
+ /** Read the last-good windows for one edition, scoped to the login that wrote them. */
534
+ function readWorkbuddyLimitsCache({ home, nowMs = Date.now(), variantId, identity } = {}) {
535
+ // An explicit home keeps the cache hermetic: callers that never said where
536
+ // the user's tracker state lives (tests, odd embedders) must not touch the
537
+ // developer's real cache file.
538
+ if (!home || !variantId || !identity) return null;
539
+ let parsed;
540
+ try {
541
+ parsed = JSON.parse(fs.readFileSync(workbuddyLimitsCachePath({ home }), "utf8"));
542
+ } catch (_error) {
543
+ return null;
385
544
  }
386
- return { token: null, encrypted: encryptedForVariant, matched: encryptedForVariant };
545
+ if (!parsed || typeof parsed !== "object") return null;
546
+ const raw = parsed[variantId];
547
+ if (!raw || typeof raw !== "object") return null;
548
+ if (raw.identity !== identity) return null;
549
+ const cachedAtMs = Date.parse(raw.cached_at || "");
550
+ if (!Number.isFinite(cachedAtMs) || cachedAtMs > nowMs + 60_000) return null;
551
+ const context = { cachedAtMs, nowMs };
552
+ const primaryWindow = workbuddyCachedWindow(raw.primary_window, context);
553
+ const secondaryWindow = workbuddyCachedWindow(raw.secondary_window, context);
554
+ // A partition whose windows all rolled or expired no longer describes the
555
+ // account's quota — withholding it lets the live error stay visible.
556
+ if (!primaryWindow && !secondaryWindow) return null;
557
+ return {
558
+ configured: true,
559
+ error: null,
560
+ plan_label: typeof raw.plan_label === "string" ? raw.plan_label : null,
561
+ primary_window: primaryWindow,
562
+ secondary_window: secondaryWindow,
563
+ cached: true,
564
+ stale: true,
565
+ cached_at: raw.cached_at,
566
+ source: "disk-cache",
567
+ };
568
+ }
569
+
570
+ /** Merge one edition's partition into the disk cache; best-effort, never throws. */
571
+ function writeWorkbuddyLimitsCache(limits, { home, nowMs = Date.now(), variantId, identity } = {}) {
572
+ if (!home || !variantId || !identity || !limits?.configured || limits.error) return;
573
+ if (!limits.primary_window && !limits.secondary_window) return;
574
+ const cachePath = workbuddyLimitsCachePath({ home });
575
+ let payload = {};
576
+ try {
577
+ const parsed = JSON.parse(fs.readFileSync(cachePath, "utf8"));
578
+ if (parsed && typeof parsed === "object") payload = parsed;
579
+ } catch (_error) {}
580
+ payload[variantId] = {
581
+ identity,
582
+ plan_label: typeof limits.plan_label === "string" ? limits.plan_label : null,
583
+ primary_window: limits.primary_window || null,
584
+ secondary_window: limits.secondary_window || null,
585
+ cached_at: new Date(nowMs).toISOString(),
586
+ };
587
+ try {
588
+ fs.mkdirSync(path.dirname(cachePath), { recursive: true });
589
+ const tmpPath = `${cachePath}.${process.pid}.tmp`;
590
+ fs.writeFileSync(tmpPath, JSON.stringify(payload, null, 2), { encoding: "utf8", mode: 0o600 });
591
+ fs.renameSync(tmpPath, cachePath);
592
+ } catch (_error) {}
387
593
  }
388
594
 
389
595
  function readProductEndpoint(workbuddyHome) {
@@ -479,6 +685,7 @@ async function fetchPlanQuota(post, variantId) {
479
685
  configured: true, error: null,
480
686
  plan_label: quota?.plan_label || englishPlanLabel({ code: identity.code }, identity, variantId),
481
687
  primary_window: quota?.primary_window || null,
688
+ secondary_window: resourceWindow(collectResourceGroups(summary)),
482
689
  };
483
690
  }
484
691
 
@@ -494,19 +701,24 @@ function scopedEnv(env, home) {
494
701
  return next;
495
702
  }
496
703
 
497
- async function fetchVariant(variantId, opts = {}) {
498
- const variant = variantById(variantId);
499
- if (!variant) return { configured: false };
500
- const env = scopedEnv(opts.env || process.env, opts.home);
501
- const workbuddyHome = opts.workbuddyHome || resolveWorkbuddyHome(env, variant.source);
502
- const installed = directoryExists(workbuddyHome);
704
+ // Live chain for one edition, unchanged by the cache layer: the IPC proxy
705
+ // while the app publishes it, then the API source with a resolvable session.
706
+ // The session is selected by the caller (fetchVariant) so the cache identity
707
+ // and the live read cannot disagree about the account. Throws propagate to
708
+ // the cache wrapper below; the { configured: true, error: UNAVAILABLE }
709
+ // result comes back when the app is closed and no session can be read.
710
+ async function fetchVariantLive(variant, variantId, { env, workbuddyHome, installed, opts, session }) {
503
711
  const discovery = readWorkbuddyIpcDiscovery(workbuddyHome);
504
712
  if (discovery) {
505
713
  // Discovery is edition-local. Never fall back to the other edition's
506
714
  // socket or allow an explicitly redirected product to be mislabelled.
507
715
  const productEndpoint = readProductEndpoint(workbuddyHome);
508
716
  if (productEndpoint && !originAllowed(productEndpoint, variant)) {
509
- throw new Error("WorkBuddy quota host does not match this edition.");
717
+ const error = new Error("WorkBuddy quota host does not match this edition.");
718
+ // Tagged so the cache wrapper surfaces the mislabelled host instead of
719
+ // stale bars from whichever login wrote them.
720
+ error.code = "EDITION_MISMATCH";
721
+ throw error;
510
722
  }
511
723
  const client = await openWorkbuddyIpc({
512
724
  discovery, signal: opts.signal, timeoutMs: opts.timeoutMs, connectImpl: opts.connectImpl,
@@ -517,10 +729,6 @@ async function fetchVariant(variantId, opts = {}) {
517
729
  client.close();
518
730
  }
519
731
  }
520
- const explicitToken = resolveToken(variant, env);
521
- const session = explicitToken
522
- ? { token: explicitToken, encrypted: false, matched: true }
523
- : resolveSession(variant, { env, authFiles: opts.authFiles, workbuddyHome });
524
732
  if (!session.token) {
525
733
  if (session.encrypted || installed) return { configured: true, error: UNAVAILABLE };
526
734
  if (!installed) return { configured: false };
@@ -535,12 +743,64 @@ async function fetchVariant(variantId, opts = {}) {
535
743
  workbuddyHome: opts.origin ? null : workbuddyHome,
536
744
  });
537
745
  if (!originAllowed(origin, variant)) {
538
- throw new Error("WorkBuddy quota host does not match this edition.");
746
+ const error = new Error("WorkBuddy quota host does not match this edition.");
747
+ error.code = "EDITION_MISMATCH";
748
+ throw error;
539
749
  }
540
750
  return { ...await fetchPlanQuota((apiPath, body) => postBilling(fetchImpl,
541
751
  `${origin}${apiPath}`, session.token, body, opts.signal), variantId), source: "api" };
542
752
  }
543
753
 
754
+ async function fetchVariant(variantId, opts = {}) {
755
+ const variant = variantById(variantId);
756
+ if (!variant) return { configured: false };
757
+ const env = scopedEnv(opts.env || process.env, opts.home);
758
+ const workbuddyHome = opts.workbuddyHome || resolveWorkbuddyHome(env, variant.source);
759
+ const installed = directoryExists(workbuddyHome);
760
+ // Sampled when used, not when the fetch starts: the live attempt can spend
761
+ // the whole provider budget before a cache read or write happens, and a
762
+ // window whose reset rolled in that span must not be served. An injected
763
+ // clock (tests) keeps every use on the same instant.
764
+ const nowMs = () => (Number.isFinite(opts.nowMs) ? opts.nowMs : Date.now());
765
+ // One session selection feeds both the live attempt and the cache identity:
766
+ // cached windows are stamped with (and served only to) the account whose
767
+ // record supplies — or, while the app is closed, would supply — the session
768
+ // token. An env-token session names no record; "unknown" matches only its
769
+ // own entries.
770
+ const explicitToken = resolveToken(variant, env);
771
+ const session = explicitToken
772
+ ? { token: explicitToken, encrypted: false, matched: true, uid: "" }
773
+ : resolveSession(variant, { env, authFiles: opts.authFiles, workbuddyHome });
774
+ const identity = (explicitToken ? "" : session.uid) || "unknown";
775
+ let live;
776
+ try {
777
+ live = await fetchVariantLive(variant, variantId, { env, workbuddyHome, installed, opts, session });
778
+ } catch (error) {
779
+ // Re-login stays actionable (opencode-go auth_error / codex reauth
780
+ // precedent) and a mislabelled host is a configuration fault, not
781
+ // transient data loss: neither may be masked by stale bars.
782
+ if (error?.code === "AUTH_EXPIRED" || error?.code === "EDITION_MISMATCH") throw error;
783
+ const cached = readWorkbuddyLimitsCache({ home: opts.home, nowMs: nowMs(), variantId, identity });
784
+ if (cached) return cached;
785
+ throw error;
786
+ }
787
+ if (live.configured === true && !live.error) {
788
+ // Live read is current as of now: persist it so a later failure serves
789
+ // stale bars instead of a vanished card.
790
+ writeWorkbuddyLimitsCache(live, { home: opts.home, nowMs: nowMs(), variantId, identity });
791
+ return live;
792
+ }
793
+ if (live.configured === true && live.error) {
794
+ // The steady state the cache exists for (UNAVAILABLE: app closed, session
795
+ // unreadable) and transient API/IPC failures. A never-signed-in install
796
+ // ({ configured: false }) stays untouched — an edition that produced no
797
+ // windows here has nothing to fall back to.
798
+ const cached = readWorkbuddyLimitsCache({ home: opts.home, nowMs: nowMs(), variantId, identity });
799
+ if (cached) return cached;
800
+ }
801
+ return live;
802
+ }
803
+
544
804
  function fetchWorkbuddyLimits(opts) {
545
805
  return fetchVariant("workbuddy", opts);
546
806
  }
@@ -556,9 +816,14 @@ module.exports = {
556
816
  selectWorkbuddyPlanQuota,
557
817
  selectWorkbuddyLimits,
558
818
  collectPackageRows,
819
+ collectResourceGroups,
820
+ resourceWindow,
559
821
  readSubscriptionIdentity,
560
822
  originAllowed,
561
823
  hostAllowed,
824
+ readWorkbuddyLimitsCache,
825
+ writeWorkbuddyLimitsCache,
826
+ workbuddyLimitsCachePath,
562
827
  fetchWorkbuddyLimits,
563
828
  fetchWorkbuddyAiLimits,
564
829
  };