tokentracker-cli 1.5.2 → 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 (125) hide show
  1. package/dashboard/dist/assets/{AchievementBadge-B5kmt0VD.js → AchievementBadge-uuDGO9Mq.js} +1 -1
  2. package/dashboard/dist/assets/{AchievementsPage-C0FlkOo0.js → AchievementsPage-BCTVT7Jo.js} +2 -2
  3. package/dashboard/dist/assets/{AchievementsSection-CRcp80Pp.js → AchievementsSection-B6fJSJSr.js} +1 -1
  4. package/dashboard/dist/assets/{BadgeDetailModal-DAFjGbKW.js → BadgeDetailModal-FjUN__9G.js} +1 -1
  5. package/dashboard/dist/assets/{BotAnimated-CO0bbimB.js → BotAnimated-B-KqY_Wr.js} +1 -1
  6. package/dashboard/dist/assets/{Card-Xhr5exz8.js → Card-DS8T8piP.js} +1 -1
  7. package/dashboard/dist/assets/{CloudCheckoutPage-DsQ7FmsI.js → CloudCheckoutPage-_0UjsFPu.js} +1 -1
  8. package/dashboard/dist/assets/{CloudPage-DkSexu5V.js → CloudPage-BstgVBaV.js} +2 -2
  9. package/dashboard/dist/assets/{CommandPalette-Bm0huQdj.js → CommandPalette-DtVsYZgc.js} +1 -1
  10. package/dashboard/dist/assets/{CommunityStatsModal-BsL8GN8Q.js → CommunityStatsModal-DTpEJqV3.js} +1 -1
  11. package/dashboard/dist/assets/{ConfirmModal-_WPp292g.js → ConfirmModal-Lfdh2vr7.js} +1 -1
  12. package/dashboard/dist/assets/{Controls-ktzn68KM.js → Controls-DpwQ7pkq.js} +1 -1
  13. package/dashboard/dist/assets/DashboardPage-DUFWejkE.js +1 -0
  14. package/dashboard/dist/assets/{DateRangePopover-DWr4zIzF.js → DateRangePopover-CrWgrzT2.js} +1 -1
  15. package/dashboard/dist/assets/{DevicePage-CBa8Mk47.js → DevicePage-DbK5KWJS.js} +1 -1
  16. package/dashboard/dist/assets/{DialogClose-CZ5jzgtb.js → DialogClose-Dd2B_o03.js} +1 -1
  17. package/dashboard/dist/assets/DialogDescription-CLy0arbt.js +1 -0
  18. package/dashboard/dist/assets/{DialogTitle-Dr0N6q15.js → DialogTitle-3JQBVha3.js} +1 -1
  19. package/dashboard/dist/assets/{ElectricLogo-sYqgrO6-.js → ElectricLogo-LYAqG8pG.js} +1 -1
  20. package/dashboard/dist/assets/{FadeIn-Qq-hcrZW.js → FadeIn-DhDvUor7.js} +1 -1
  21. package/dashboard/dist/assets/{HeaderGithubStar-B77zSv7i.js → HeaderGithubStar-9f1XmgYC.js} +1 -1
  22. package/dashboard/dist/assets/{HoverTooltip-lRDaUmoW.js → HoverTooltip-BXhHAkP7.js} +1 -1
  23. package/dashboard/dist/assets/{IpCheckPage-B6AvHZjA.js → IpCheckPage-ChPdDeMl.js} +1 -1
  24. package/dashboard/dist/assets/{LandingPage-DgzOjf3x.js → LandingPage-DnJ7Y9zw.js} +2 -2
  25. package/dashboard/dist/assets/{LeaderboardPage-BtjjtDMC.js → LeaderboardPage-BF9isLzu.js} +2 -2
  26. package/dashboard/dist/assets/{LeaderboardProBadge-B2wNTUTU.js → LeaderboardProBadge-BM4JKgkC.js} +1 -1
  27. package/dashboard/dist/assets/{LeaderboardProfileModal-DJJckwHh.js → LeaderboardProfileModal-DfZABOpN.js} +3 -3
  28. package/dashboard/dist/assets/{LeaderboardProfilePage-DJdxByl2.js → LeaderboardProfilePage-BaZ38iT5.js} +1 -1
  29. package/dashboard/dist/assets/LimitsPage-C7LbG3LX.js +6 -0
  30. package/dashboard/dist/assets/{LocalOnlyNotice-B2lVqf_H.js → LocalOnlyNotice-DvhlYoqQ.js} +1 -1
  31. package/dashboard/dist/assets/{LoginCard-i0gg1yby.js → LoginCard-g_Gbnzhs.js} +1 -1
  32. package/dashboard/dist/assets/{LoginModal-B-loLNZM.js → LoginModal-DkJrI6b7.js} +1 -1
  33. package/dashboard/dist/assets/{LoginPage-D_swI75j.js → LoginPage-CqCKSo0n.js} +1 -1
  34. package/dashboard/dist/assets/{PetPage-DDpKvpjG.js → PetPage-DbF7ROsj.js} +2 -2
  35. package/dashboard/dist/assets/{PopoverPopup-Gja4MyEK.js → PopoverPopup-4WuH41ZT.js} +1 -1
  36. package/dashboard/dist/assets/{ProviderIcon-DOPj7tAZ.js → ProviderIcon-6j0A2KD9.js} +1 -1
  37. package/dashboard/dist/assets/{ResetPasswordPage-BVADLk89.js → ResetPasswordPage-DBd-iwuM.js} +1 -1
  38. package/dashboard/dist/assets/{Select-D-38tN-E.js → Select-BBEpLeiG.js} +1 -1
  39. package/dashboard/dist/assets/{SelectItemText-jN61ukxk.js → SelectItemText-Cn_U_rrO.js} +1 -1
  40. package/dashboard/dist/assets/{SelfHostPage-k7AkGpl1.js → SelfHostPage-2WZc6Rml.js} +1 -1
  41. package/dashboard/dist/assets/ServiceStatusPage-B-g4MDl0.js +1 -0
  42. package/dashboard/dist/assets/{SessionsPage-BymccEEz.js → SessionsPage-CWS0iQ1L.js} +1 -1
  43. package/dashboard/dist/assets/{SettingsPage-DfnMCAuA.js → SettingsPage-rXNr96i-.js} +3 -3
  44. package/dashboard/dist/assets/{SkillsPage-YyIHIaHN.js → SkillsPage-CudTvbZL.js} +1 -1
  45. package/dashboard/dist/assets/{TokenGalaxy-BLcvZuQ9.js → TokenGalaxy-BRKrbTsJ.js} +1 -1
  46. package/dashboard/dist/assets/{TooltipPopup-3E8kyVzz.js → TooltipPopup-CsLKXhWC.js} +1 -1
  47. package/dashboard/dist/assets/{TrendMonitor-ByKAdXSF.js → TrendMonitor-8onUTbkk.js} +1 -1
  48. package/dashboard/dist/assets/{WidgetsPage-BgEVIIzL.js → WidgetsPage-CcwE0lz8.js} +1 -1
  49. package/dashboard/dist/assets/{WrappedPage-HjbX8QaA.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-BVF2siPQ.js → agent-logos-ChBA1nAH.js} +1 -1
  53. package/dashboard/dist/assets/{arrow-left-CYO24PGP.js → arrow-left-Bb5mfoPc.js} +1 -1
  54. package/dashboard/dist/assets/{arrow-right-Xg9PZYhw.js → arrow-right-D1Ti2XUI.js} +1 -1
  55. package/dashboard/dist/assets/{arrow-up-right-B7ah6Qv-.js → arrow-up-right-B_CjA_y8.js} +1 -1
  56. package/dashboard/dist/assets/{calendar-days-BV_iG938.js → calendar-days-DdfrAqie.js} +1 -1
  57. package/dashboard/dist/assets/{check--EZH8RTp.js → check-29zGw1MK.js} +1 -1
  58. package/dashboard/dist/assets/{chevron-down-BzGTlk0-.js → chevron-down-DAzlFX7g.js} +1 -1
  59. package/dashboard/dist/assets/{cloud-checkout-BgyxPHEh.js → cloud-checkout-C2SeCjFM.js} +1 -1
  60. package/dashboard/dist/assets/{composite-C0KrmLEK.js → composite-DIf735Hq.js} +1 -1
  61. package/dashboard/dist/assets/{copy-BtucSK2U.js → copy-BOuOM0HQ.js} +1 -1
  62. package/dashboard/dist/assets/{download-CWHq03Y2.js → download-50tQRpGe.js} +1 -1
  63. package/dashboard/dist/assets/{external-link-DCTXF5VZ.js → external-link-BhlQClVe.js} +1 -1
  64. package/dashboard/dist/assets/{flame-BE5TsqVF.js → flame-CeGfA-0L.js} +1 -1
  65. package/dashboard/dist/assets/{format-tokens-ThNIMWnZ.js → format-tokens-Cq7YiAkJ.js} +1 -1
  66. package/dashboard/dist/assets/{gift-DgaN9s4-.js → gift-Do-UFhiT.js} +1 -1
  67. package/dashboard/dist/assets/{icons-B4r5EiDC.js → icons-C6EZ_HmP.js} +1 -1
  68. package/dashboard/dist/assets/{index-CKhcCLNC.js → index-BFrbVqsm.js} +1 -1
  69. package/dashboard/dist/assets/{index-DqCtgU0G.js → index-BWhbBAd9.js} +1 -1
  70. package/dashboard/dist/assets/{index-fNKH4U7j.js → index-D9kwYWLX.js} +1 -1
  71. package/dashboard/dist/assets/{index-DFF9xo6f.js → index-c0crpe9Z.js} +1 -1
  72. package/dashboard/dist/assets/{info-B4YzRR4y.js → info-B4MXmsNE.js} +1 -1
  73. package/dashboard/dist/assets/{link-2-DzbUbaBw.js → link-2-DuWCslzl.js} +1 -1
  74. package/dashboard/dist/assets/{loader-circle-B9c7D-7v.js → loader-circle-C2I3NZAO.js} +1 -1
  75. package/dashboard/dist/assets/{local-usage-transfer-btuk_b0Y.js → local-usage-transfer-BQasvsUF.js} +1 -1
  76. package/dashboard/dist/assets/{modifiers.esm-BjrU-Xz7.js → modifiers.esm-DbeNS6FT.js} +1 -1
  77. package/dashboard/dist/assets/{monitor-smartphone-Bwn4tb4q.js → monitor-smartphone-C6Y4ktsc.js} +1 -1
  78. package/dashboard/dist/assets/{play-DIFOwGIQ.js → play-CwgcDJC2.js} +1 -1
  79. package/dashboard/dist/assets/{plus-BpBqnwVg.js → plus-B796wsUO.js} +1 -1
  80. package/dashboard/dist/assets/{provider-display-DSrs4u7T.js → provider-display-Cw26RFDm.js} +1 -1
  81. package/dashboard/dist/assets/{react-BgG7_MY0.js → react-d7Bk1-_K.js} +1 -1
  82. package/dashboard/dist/assets/{safePolygon-BUGWIDOM.js → safePolygon-CDE0dNv_.js} +1 -1
  83. package/dashboard/dist/assets/{search-aHrmBmtZ.js → search-DtKvsdRs.js} +1 -1
  84. package/dashboard/dist/assets/{server-DOEvwlEU.js → server-gAURHUyz.js} +1 -1
  85. package/dashboard/dist/assets/{shield-check-BPTnLhsT.js → shield-check-DFVdDTwh.js} +1 -1
  86. package/dashboard/dist/assets/{skills-api-C052Mwez.js → skills-api-2rdPUrJI.js} +1 -1
  87. package/dashboard/dist/assets/{sortable.esm-DaZKhjhu.js → sortable.esm-DFlfwnJr.js} +1 -1
  88. package/dashboard/dist/assets/{store-Cq30Pofw.js → store-BKPCk_de.js} +1 -1
  89. package/dashboard/dist/assets/{terminal-CoSDSkE7.js → terminal-BtWFakvS.js} +1 -1
  90. package/dashboard/dist/assets/{trash-BAmt-kPj.js → trash-bpr9zTAM.js} +1 -1
  91. package/dashboard/dist/assets/{use-community-stats-Bxq0kBhm.js → use-community-stats-DMnGgLne.js} +1 -1
  92. package/dashboard/dist/assets/{use-latest-request-guard-DmIQDlvk.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-s_CIC8Bb.js → use-native-settings-OthqiF6g.js} +1 -1
  95. package/dashboard/dist/assets/{use-ordered-list-BHhykAbt.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-CVeMFlS6.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-DilqL2Iv.js → useCurrency-CAA-tQ27.js} +1 -1
  100. package/dashboard/dist/assets/{usePositioner-BcnySw8a.js → usePositioner-Cj2DPmwM.js} +1 -1
  101. package/dashboard/dist/assets/{useTokenFormat-CvKW46WP.js → useTokenFormat-Bf5-f7Hx.js} +1 -1
  102. package/dashboard/dist/assets/{zap-D7VRNTwk.js → zap-B8Fg6PaC.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 +22 -13
  109. package/src/lib/deepseek-status.js +157 -0
  110. package/src/lib/local-api.js +44 -4
  111. package/src/lib/pricing/seed-snapshot.json +1 -1
  112. package/src/lib/provider-status.js +15 -2
  113. package/src/lib/qoder-limits.js +17 -2
  114. package/src/lib/usage-limits.js +181 -39
  115. package/src/lib/workbuddy-limits.js +191 -18
  116. package/src/lib/zcode-limits.js +143 -35
  117. package/dashboard/dist/assets/DashboardPage-CdNXi3Im.js +0 -1
  118. package/dashboard/dist/assets/DialogDescription-kCZ4gN09.js +0 -1
  119. package/dashboard/dist/assets/LimitsPage-gSBHSBi_.js +0 -6
  120. package/dashboard/dist/assets/ServiceStatusPage-D_Fa_pYz.js +0 -1
  121. package/dashboard/dist/assets/__vite-browser-external-Br-KAXh6.css +0 -1
  122. package/dashboard/dist/assets/__vite-browser-external-D_cMeuqI.js +0 -45
  123. package/dashboard/dist/assets/use-limits-display-prefs-CyAn1fme.js +0 -1
  124. package/dashboard/dist/assets/use-reduced-motion-BmFyFE3S.js +0 -1
  125. 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 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
+
26
34
  const PLAN_PRIORITY = Object.freeze({
27
35
  TCACA_code_027_0FCGVA6vSa: 4, // flagship
28
36
  TCACA_code_026_BaESVICNoi: 3, // advanced
@@ -451,12 +459,16 @@ function readAuthFile(filePath) {
451
459
  const auth = parsed?.auth;
452
460
  if (!auth || typeof auth !== "object") return null;
453
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() : "";
454
466
  const token = auth.accessToken;
455
- if (isNonEmptyString(token)) return { token: token.trim(), domain, encrypted: false };
467
+ if (isNonEmptyString(token)) return { token: token.trim(), domain, encrypted: false, uid };
456
468
  if (token && typeof token === "object" && token.$wbEncrypted === 1) {
457
- return { token: null, domain, encrypted: true };
469
+ return { token: null, domain, encrypted: true, uid };
458
470
  }
459
- return { token: null, domain, encrypted: false };
471
+ return { token: null, domain, encrypted: false, uid };
460
472
  }
461
473
 
462
474
  function resolveSession(variant, { env, authFiles, workbuddyHome }) {
@@ -464,15 +476,120 @@ function resolveSession(variant, { env, authFiles, workbuddyHome }) {
464
476
  ? authFiles
465
477
  : authSearchDirs(env, workbuddyHome).flatMap((dir) => variant.authFileNames.map((name) => path.join(dir, name)));
466
478
  let encryptedForVariant = false;
479
+ let encryptedUid = "";
467
480
  for (const filePath of files) {
468
481
  if (!filePath) continue;
469
482
  const session = readAuthFile(filePath);
470
483
  if (!session) continue;
471
484
  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;
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
+ }
474
493
  }
475
- return { token: null, encrypted: encryptedForVariant, matched: encryptedForVariant };
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;
544
+ }
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) {}
476
593
  }
477
594
 
478
595
  function readProductEndpoint(workbuddyHome) {
@@ -584,19 +701,24 @@ function scopedEnv(env, home) {
584
701
  return next;
585
702
  }
586
703
 
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);
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 }) {
593
711
  const discovery = readWorkbuddyIpcDiscovery(workbuddyHome);
594
712
  if (discovery) {
595
713
  // Discovery is edition-local. Never fall back to the other edition's
596
714
  // socket or allow an explicitly redirected product to be mislabelled.
597
715
  const productEndpoint = readProductEndpoint(workbuddyHome);
598
716
  if (productEndpoint && !originAllowed(productEndpoint, variant)) {
599
- 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;
600
722
  }
601
723
  const client = await openWorkbuddyIpc({
602
724
  discovery, signal: opts.signal, timeoutMs: opts.timeoutMs, connectImpl: opts.connectImpl,
@@ -607,10 +729,6 @@ async function fetchVariant(variantId, opts = {}) {
607
729
  client.close();
608
730
  }
609
731
  }
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
732
  if (!session.token) {
615
733
  if (session.encrypted || installed) return { configured: true, error: UNAVAILABLE };
616
734
  if (!installed) return { configured: false };
@@ -625,12 +743,64 @@ async function fetchVariant(variantId, opts = {}) {
625
743
  workbuddyHome: opts.origin ? null : workbuddyHome,
626
744
  });
627
745
  if (!originAllowed(origin, variant)) {
628
- 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;
629
749
  }
630
750
  return { ...await fetchPlanQuota((apiPath, body) => postBilling(fetchImpl,
631
751
  `${origin}${apiPath}`, session.token, body, opts.signal), variantId), source: "api" };
632
752
  }
633
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
+
634
804
  function fetchWorkbuddyLimits(opts) {
635
805
  return fetchVariant("workbuddy", opts);
636
806
  }
@@ -651,6 +821,9 @@ module.exports = {
651
821
  readSubscriptionIdentity,
652
822
  originAllowed,
653
823
  hostAllowed,
824
+ readWorkbuddyLimitsCache,
825
+ writeWorkbuddyLimitsCache,
826
+ workbuddyLimitsCachePath,
654
827
  fetchWorkbuddyLimits,
655
828
  fetchWorkbuddyAiLimits,
656
829
  };
@@ -1156,6 +1156,10 @@ function readZcodeLimitsCache({ home, nowMs = Date.now(), providerKeys = [], ide
1156
1156
  configured: true,
1157
1157
  error: null,
1158
1158
  provider_key: providerKey,
1159
+ // Live results carry their provider's region for the other_region
1160
+ // attach; the cached shape mirrors them so a cache-served card keeps
1161
+ // that section too (issue #959).
1162
+ region: zcodeRegionFromProviderId(providerKey),
1159
1163
  plan_kind: typeof raw.plan_kind === "string" ? raw.plan_kind : null,
1160
1164
  plan_id: typeof raw.plan_id === "string" ? raw.plan_id : null,
1161
1165
  plan_label: typeof raw.plan_label === "string" ? raw.plan_label : null,
@@ -1172,27 +1176,23 @@ function readZcodeLimitsCache({ home, nowMs = Date.now(), providerKeys = [], ide
1172
1176
  return null;
1173
1177
  }
1174
1178
 
1175
- /** Merge one provider partition into the disk cache; best-effort, never throws. */
1176
- function writeZcodeLimitsCache(limits, { home, nowMs = Date.now(), providerKey, identity } = {}) {
1177
- if (!providerKey || !identity || !limits?.configured || limits.error) return;
1178
- if (!limits.primary_window && !limits.secondary_window && !limits.tertiary_window) return;
1179
+ /**
1180
+ * Merge partitions into the disk cache atomically; best-effort, never throws.
1181
+ * Cross-process semantics are last-writer-wins on purpose, matching the house
1182
+ * pattern (ark / qoder / codex caches): a concurrent writer can discard a
1183
+ * partition added between its read and rename, which costs one refresh cycle
1184
+ * — the next fetch rewrites it. Snapshots in particular keep getting
1185
+ * re-persisted on every fetch while their region's rows are inside the scan
1186
+ * window, so a lost write self-heals long before the window expires.
1187
+ */
1188
+ function mergeZcodeLimitsCacheEntries(entries, { home } = {}) {
1179
1189
  const cachePath = zcodeLimitsCachePath({ home });
1180
1190
  let payload = {};
1181
1191
  try {
1182
1192
  const parsed = JSON.parse(fs.readFileSync(cachePath, "utf8"));
1183
1193
  if (parsed && typeof parsed === "object") payload = parsed;
1184
1194
  } catch (_error) {}
1185
- payload[providerKey] = {
1186
- identity,
1187
- plan_kind: typeof limits.plan_kind === "string" ? limits.plan_kind : null,
1188
- plan_id: typeof limits.plan_id === "string" ? limits.plan_id : null,
1189
- plan_label: typeof limits.plan_label === "string" ? limits.plan_label : null,
1190
- buckets: Array.isArray(limits.buckets) ? limits.buckets : [],
1191
- primary_window: limits.primary_window || null,
1192
- secondary_window: limits.secondary_window || null,
1193
- tertiary_window: limits.tertiary_window || null,
1194
- cached_at: new Date(nowMs).toISOString(),
1195
- };
1195
+ Object.assign(payload, entries);
1196
1196
  try {
1197
1197
  fs.mkdirSync(path.dirname(cachePath), { recursive: true });
1198
1198
  const tmpPath = `${cachePath}.${process.pid}.tmp`;
@@ -1201,28 +1201,129 @@ function writeZcodeLimitsCache(limits, { home, nowMs = Date.now(), providerKey,
1201
1201
  } catch (_error) {}
1202
1202
  }
1203
1203
 
1204
+ /** Merge one provider partition into the disk cache; best-effort, never throws. */
1205
+ function writeZcodeLimitsCache(limits, { home, nowMs = Date.now(), providerKey, identity } = {}) {
1206
+ if (!providerKey || !identity || !limits?.configured || limits.error) return;
1207
+ if (!limits.primary_window && !limits.secondary_window && !limits.tertiary_window) return;
1208
+ mergeZcodeLimitsCacheEntries({
1209
+ [providerKey]: {
1210
+ identity,
1211
+ plan_kind: typeof limits.plan_kind === "string" ? limits.plan_kind : null,
1212
+ plan_id: typeof limits.plan_id === "string" ? limits.plan_id : null,
1213
+ plan_label: typeof limits.plan_label === "string" ? limits.plan_label : null,
1214
+ buckets: Array.isArray(limits.buckets) ? limits.buckets : [],
1215
+ primary_window: limits.primary_window || null,
1216
+ secondary_window: limits.secondary_window || null,
1217
+ tertiary_window: limits.tertiary_window || null,
1218
+ cached_at: new Date(nowMs).toISOString(),
1219
+ },
1220
+ }, { home });
1221
+ }
1222
+
1223
+ // Issue #959: ZCode only queries the balance of the region currently in use,
1224
+ // so the other region's log rows stop appearing the moment it goes idle and
1225
+ // then age out of the 6h scan window — the other_region section would vanish.
1226
+ // While a region's rows are still fresh, snapshot its latest record into a
1227
+ // per-region cache partition; withZcodeOtherRegion falls back to it after the
1228
+ // window expires. The snapshot shape comes straight from
1229
+ // normalizeZcodeBalanceResponse, so the no-reset_credits / no-start_plan red
1230
+ // line holds structurally. Log records carry no login id, so unlike the
1231
+ // windows partitions there is no identity guard here: staleness is
1232
+ // self-attested by the snapshot's log_timestamp (rendered as the section's
1233
+ // time row), and the section stays collapsed and informational.
1234
+ function zcodeLogSnapshotCacheKey(region) {
1235
+ return `log-snapshot:${region}`;
1236
+ }
1237
+
1238
+ function writeZcodeLogSnapshotCache(byRegion, { home, nowMs = Date.now() } = {}) {
1239
+ if (!(byRegion instanceof Map) || byRegion.size === 0) return;
1240
+ const entries = {};
1241
+ for (const [region, record] of byRegion) {
1242
+ if (region !== "bigmodel" && region !== "zai") continue;
1243
+ let normalized;
1244
+ try {
1245
+ normalized = normalizeZcodeBalanceResponse(record.body);
1246
+ } catch (_error) {
1247
+ continue;
1248
+ }
1249
+ entries[zcodeLogSnapshotCacheKey(region)] = {
1250
+ source: "zcode-log",
1251
+ region,
1252
+ provider_key: record.providerKey,
1253
+ log_timestamp: record.log_timestamp,
1254
+ ...normalized,
1255
+ cached_at: new Date(nowMs).toISOString(),
1256
+ };
1257
+ }
1258
+ if (Object.keys(entries).length > 0) mergeZcodeLimitsCacheEntries(entries, { home });
1259
+ }
1260
+
1261
+ /**
1262
+ * Read one region's persisted log snapshot, shaped exactly like the fresh
1263
+ * withZcodeOtherRegion attach (issue #938). Deliberately no per-window
1264
+ * expiry: clipping aged windows would make the cached section render
1265
+ * differently from the same record served fresh inside the scan window.
1266
+ */
1267
+ function readZcodeLogSnapshotCache(region, { home, nowMs = Date.now() } = {}) {
1268
+ if (region !== "bigmodel" && region !== "zai") return null;
1269
+ let parsed;
1270
+ try {
1271
+ parsed = JSON.parse(fs.readFileSync(zcodeLimitsCachePath({ home }), "utf8"));
1272
+ } catch (_error) {
1273
+ return null;
1274
+ }
1275
+ const raw = parsed?.[zcodeLogSnapshotCacheKey(region)];
1276
+ if (!raw || typeof raw !== "object" || raw.source !== "zcode-log" || raw.region !== region) return null;
1277
+ if (!Number.isFinite(Date.parse(typeof raw.log_timestamp === "string" ? raw.log_timestamp : ""))) return null;
1278
+ const cachedAtMs = Date.parse(typeof raw.cached_at === "string" ? raw.cached_at : "");
1279
+ if (!Number.isFinite(cachedAtMs) || cachedAtMs > nowMs + 60_000) return null;
1280
+ return {
1281
+ source: "zcode-log",
1282
+ region,
1283
+ provider_key: typeof raw.provider_key === "string" ? raw.provider_key : null,
1284
+ log_timestamp: raw.log_timestamp,
1285
+ plan_kind: typeof raw.plan_kind === "string" ? raw.plan_kind : null,
1286
+ plan_id: typeof raw.plan_id === "string" ? raw.plan_id : null,
1287
+ plan_label: typeof raw.plan_label === "string" ? raw.plan_label : null,
1288
+ buckets: Array.isArray(raw.buckets) ? raw.buckets : [],
1289
+ primary_window: raw.primary_window || null,
1290
+ secondary_window: raw.secondary_window || null,
1291
+ tertiary_window: raw.tertiary_window || null,
1292
+ };
1293
+ }
1294
+
1204
1295
  /**
1205
1296
  * Attach the freshest logged balance snapshot from the user's other region
1206
1297
  * (issue #938): ZCode's own usage logs carry both regions' billing data even
1207
- * when only the active region's credentials are on disk. The snapshot is
1298
+ * when only the active region's credentials are on disk. Once those rows age
1299
+ * out of the 6h scan window (ZCode only queries the active region's balance,
1300
+ * issue #959), fall back to the snapshot persisted by
1301
+ * writeZcodeLogSnapshotCache while they were fresh. Either way the section is
1208
1302
  * read-only and deliberately carries no reset_credits / start_plan extras —
1209
1303
  * those are login-scoped and must never ride along across regions.
1210
1304
  */
1211
- function withZcodeOtherRegion(result, byRegion) {
1305
+ function withZcodeOtherRegion(result, byRegion, { home, env = process.env, nowMs = Date.now() } = {}) {
1212
1306
  if (!result || result.error || !result.region) return result;
1213
1307
  const otherRegion = result.region === "bigmodel" ? "zai" : "bigmodel";
1214
1308
  const record = byRegion instanceof Map ? byRegion.get(otherRegion) : null;
1215
- if (!record) return result;
1216
- return {
1217
- ...result,
1218
- other_region: {
1219
- source: "zcode-log",
1220
- region: otherRegion,
1221
- provider_key: record.providerKey,
1222
- log_timestamp: record.log_timestamp,
1223
- ...normalizeZcodeBalanceResponse(record.body),
1224
- },
1225
- };
1309
+ if (record) {
1310
+ return {
1311
+ ...result,
1312
+ other_region: {
1313
+ source: "zcode-log",
1314
+ region: otherRegion,
1315
+ provider_key: record.providerKey,
1316
+ log_timestamp: record.log_timestamp,
1317
+ ...normalizeZcodeBalanceResponse(record.body),
1318
+ },
1319
+ };
1320
+ }
1321
+ // Saved snapshots are log-derived data, so the log-fallback kill switch
1322
+ // silences them along with the scan that feeds them.
1323
+ if (env?.TOKENTRACKER_ZCODE_DISABLE_LOG_FALLBACK === "1") return result;
1324
+ const snapshot = readZcodeLogSnapshotCache(otherRegion, { home, nowMs });
1325
+ if (!snapshot) return result;
1326
+ return { ...result, other_region: snapshot };
1226
1327
  }
1227
1328
 
1228
1329
  /**
@@ -1264,8 +1365,9 @@ function loadZcodeLoginScopedTokens(family, auth, { home, env } = {}) {
1264
1365
  * logs, and finally the last-good disk cache. The result carries the winning
1265
1366
  * provider's `region` ("bigmodel" | "zai") and, when ZCode's own logs hold one,
1266
1367
  * an `other_region` snapshot of the second region's plan (issue #938) — read-only
1267
- * log data, never extended with login-scoped extras. Credentials and internal
1268
- * team routing identifiers are omitted from the result.
1368
+ * log data, never extended with login-scoped extras, served from the logs while
1369
+ * fresh and from the disk cache once the scan window expires (issue #959).
1370
+ * Credentials and internal team routing identifiers are omitted from the result.
1269
1371
  */
1270
1372
  async function fetchZcodeLimits({ home, env, fetchImpl = fetch, nowMs = Date.now(), providerTimeoutMs } = {}) {
1271
1373
  const startedAt = Date.now();
@@ -1283,11 +1385,15 @@ async function fetchZcodeLimits({ home, env, fetchImpl = fetch, nowMs = Date.now
1283
1385
  providerKeys: authCandidates.map((auth) => auth.providerKey),
1284
1386
  nowMs,
1285
1387
  });
1388
+ // Issue #959: capture each region's freshest log record while it is still
1389
+ // inside the scan window, so the other_region section survives the window
1390
+ // expiring (ZCode stops writing rows for a region the moment it goes idle).
1391
+ writeZcodeLogSnapshotCache(logScan.byRegion, { home, nowMs });
1286
1392
  const requireProviderMatch = authCandidates.some((auth) => auth.planKind === "coding-plan");
1287
1393
  const selectLogFallback = (errors) => {
1288
1394
  const record = logScan.preferred
1289
1395
  || (requireProviderMatch && authCandidates.length > 0 ? null : logScan.fallback);
1290
- return withZcodeOtherRegion(zcodeLogFallbackResult(record, errors), logScan.byRegion);
1396
+ return withZcodeOtherRegion(zcodeLogFallbackResult(record, errors), logScan.byRegion, { home, env, nowMs });
1291
1397
  };
1292
1398
  if (!authCandidates.length) {
1293
1399
  return selectLogFallback([]) || { configured: false };
@@ -1403,13 +1509,13 @@ async function fetchZcodeLimits({ home, env, fetchImpl = fetch, nowMs = Date.now
1403
1509
  }
1404
1510
  }
1405
1511
  }
1406
- return withZcodeOtherRegion(result, logScan.byRegion);
1512
+ return withZcodeOtherRegion(result, logScan.byRegion, { home, env, nowMs });
1407
1513
  } catch (error) {
1408
1514
  errors.push(`${auth.providerKey}: ${error?.message || "Unknown error"}`);
1409
1515
  partitionFailures.push({ providerKey: auth.providerKey, authError: error?.authError === true });
1410
1516
  }
1411
1517
  }
1412
- if (emptySuccess) return withZcodeOtherRegion(emptySuccess, logScan.byRegion);
1518
+ if (emptySuccess) return withZcodeOtherRegion(emptySuccess, logScan.byRegion, { home, env, nowMs });
1413
1519
  // Rank fallback partitions in live candidate priority order, and withhold
1414
1520
  // any partition whose attempts all ended in auth errors: re-login is
1415
1521
  // actionable, so neither a stale log record nor stale cache bars may mask
@@ -1428,7 +1534,7 @@ async function fetchZcodeLimits({ home, env, fetchImpl = fetch, nowMs = Date.now
1428
1534
  // A log record for a provider with no live candidate is untouched by this
1429
1535
  // gate: nothing was attempted for it, so no error is being masked.
1430
1536
  if (logRecord && !authErrorOnlyProviders.has(logRecord.providerKey)) {
1431
- return withZcodeOtherRegion(zcodeLogFallbackResult(logRecord, errors), logScan.byRegion);
1537
+ return withZcodeOtherRegion(zcodeLogFallbackResult(logRecord, errors), logScan.byRegion, { home, env, nowMs });
1432
1538
  }
1433
1539
  // Last stop before the error result: serve the disk cache in live candidate
1434
1540
  // priority order, scoped to the login that produced the windows so an
@@ -1440,7 +1546,7 @@ async function fetchZcodeLimits({ home, env, fetchImpl = fetch, nowMs = Date.now
1440
1546
  identityByKey.get(auth.providerKey).add(zcodeCacheIdentity(loginIdFor(zcodeProviderFamily(auth.providerKey)), auth.teamContext));
1441
1547
  }
1442
1548
  const cached = readZcodeLimitsCache({ home, nowMs, providerKeys: cacheEligible, identityByKey });
1443
- if (cached) return withZcodeOtherRegion(cached, logScan.byRegion);
1549
+ if (cached) return withZcodeOtherRegion(cached, logScan.byRegion, { home, env, nowMs });
1444
1550
  return {
1445
1551
  configured: true,
1446
1552
  error: errors[0] || "Unknown error",
@@ -1474,6 +1580,8 @@ module.exports = {
1474
1580
  fetchZcodeStartPlanGrants,
1475
1581
  readZcodeLimitsCache,
1476
1582
  writeZcodeLimitsCache,
1583
+ writeZcodeLogSnapshotCache,
1584
+ readZcodeLogSnapshotCache,
1477
1585
  zcodeCacheIdentity,
1478
1586
  fetchZcodeLimits,
1479
1587
  };