tokentracker-cli 1.5.6 → 1.5.8

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 (128) hide show
  1. package/README.md +2 -0
  2. package/dashboard/dist/assets/{AchievementBadge-NKpY___e.js → AchievementBadge-Ck1DmXtu.js} +1 -1
  3. package/dashboard/dist/assets/{AchievementsPage-9nXmSX72.js → AchievementsPage-DjvMoc2i.js} +2 -2
  4. package/dashboard/dist/assets/{AchievementsSection-DttQciSY.js → AchievementsSection-DvTHE8Vd.js} +1 -1
  5. package/dashboard/dist/assets/{BadgeDetailModal-bvWEdJbe.js → BadgeDetailModal-COa2Rd6M.js} +1 -1
  6. package/dashboard/dist/assets/{BotAnimated-ChuUfph8.js → BotAnimated-CeR3jSce.js} +1 -1
  7. package/dashboard/dist/assets/{Card-SWtLDXt1.js → Card-bQCEOe_K.js} +1 -1
  8. package/dashboard/dist/assets/{CloudCheckoutPage-c2fNl1Rl.js → CloudCheckoutPage-DDH-3QOd.js} +1 -1
  9. package/dashboard/dist/assets/{CloudPage-CfwDjZMS.js → CloudPage-RygH3R8b.js} +2 -2
  10. package/dashboard/dist/assets/{CommandPalette-tWgPa295.js → CommandPalette-E7YqsdAH.js} +1 -1
  11. package/dashboard/dist/assets/{CommunityStatsModal-D7UaaUdM.js → CommunityStatsModal-B5heTDUE.js} +1 -1
  12. package/dashboard/dist/assets/{ConfirmModal-BrBcKhfW.js → ConfirmModal-D3vd882N.js} +1 -1
  13. package/dashboard/dist/assets/{Controls-CcvBkk7W.js → Controls-CK74JkE8.js} +1 -1
  14. package/dashboard/dist/assets/DashboardPage-DwTrlQZQ.js +1 -0
  15. package/dashboard/dist/assets/{DateRangePopover-BkN_pird.js → DateRangePopover-BxVCPuEX.js} +1 -1
  16. package/dashboard/dist/assets/{DevicePage-BbUL7WwT.js → DevicePage-B6x5fBdU.js} +1 -1
  17. package/dashboard/dist/assets/{DialogClose-BXO8SBFr.js → DialogClose-teXmwU1d.js} +1 -1
  18. package/dashboard/dist/assets/DialogDescription-BSCcGkVT.js +1 -0
  19. package/dashboard/dist/assets/{DialogTitle-Du332auj.js → DialogTitle-B3m-31sy.js} +1 -1
  20. package/dashboard/dist/assets/{ElectricLogo-CXLYfOt7.js → ElectricLogo-0wyvks4K.js} +1 -1
  21. package/dashboard/dist/assets/{FadeIn-HSQBOc8B.js → FadeIn-C60pLKXu.js} +1 -1
  22. package/dashboard/dist/assets/{HeaderGithubStar-D5tyQDGE.js → HeaderGithubStar-CCEUzMqo.js} +1 -1
  23. package/dashboard/dist/assets/{HoverTooltip-BhidhbnD.js → HoverTooltip-D9RtbhSZ.js} +1 -1
  24. package/dashboard/dist/assets/{IpCheckPage-WMEF1oS3.js → IpCheckPage-BCyW0SWY.js} +1 -1
  25. package/dashboard/dist/assets/{LandingPage-BmO-59ZP.js → LandingPage-DCEofDST.js} +2 -2
  26. package/dashboard/dist/assets/{LeaderboardPage-C2WyniEt.js → LeaderboardPage-CsTbalIm.js} +2 -2
  27. package/dashboard/dist/assets/{LeaderboardProBadge-DAZnMRNn.js → LeaderboardProBadge-Crun5RUJ.js} +1 -1
  28. package/dashboard/dist/assets/{LeaderboardProfileModal-D1BxcloV.js → LeaderboardProfileModal-Bim9cms6.js} +3 -3
  29. package/dashboard/dist/assets/{LeaderboardProfilePage-RPJDTwpG.js → LeaderboardProfilePage-D9pBE7f4.js} +1 -1
  30. package/dashboard/dist/assets/{LimitsPage-9ZOhKvn5.js → LimitsPage-CFhzwKzB.js} +6 -6
  31. package/dashboard/dist/assets/{LocalOnlyNotice-CH6w4hRw.js → LocalOnlyNotice-F_qczacX.js} +1 -1
  32. package/dashboard/dist/assets/{LoginCard-D24HsRjs.js → LoginCard-CQm_T5K2.js} +1 -1
  33. package/dashboard/dist/assets/{LoginModal-sLvHyi9U.js → LoginModal-BUf4mPCT.js} +1 -1
  34. package/dashboard/dist/assets/{LoginPage-PIvLEw1Y.js → LoginPage-njdEUQEX.js} +1 -1
  35. package/dashboard/dist/assets/{PetPage-DYcw_4P1.js → PetPage-COczIU1k.js} +2 -2
  36. package/dashboard/dist/assets/{PopoverPopup-D7ZCWq5f.js → PopoverPopup-DxB1k4g7.js} +1 -1
  37. package/dashboard/dist/assets/{ProviderIcon-B0kB52K-.js → ProviderIcon-BQfkoaQE.js} +1 -1
  38. package/dashboard/dist/assets/{ResetPasswordPage-B_NT4Xfl.js → ResetPasswordPage-PGRVaPTN.js} +1 -1
  39. package/dashboard/dist/assets/{Select-BtxjjP2Q.js → Select-CQVnpoBO.js} +1 -1
  40. package/dashboard/dist/assets/{SelectItemText-Co1M6XRC.js → SelectItemText-DmAn7osh.js} +1 -1
  41. package/dashboard/dist/assets/{SelfHostPage-XadfhFnd.js → SelfHostPage-BUURA-Vt.js} +1 -1
  42. package/dashboard/dist/assets/{ServiceStatusPage-ZEJyAxsQ.js → ServiceStatusPage-DuzSen0Q.js} +1 -1
  43. package/dashboard/dist/assets/SessionsPage-DndGq61W.js +3 -0
  44. package/dashboard/dist/assets/{SettingsPage-D4qHgIxn.js → SettingsPage-CHh0yqsZ.js} +3 -3
  45. package/dashboard/dist/assets/{SkillsPage-CkQCk2uF.js → SkillsPage-CCaYXQWN.js} +1 -1
  46. package/dashboard/dist/assets/{TokenGalaxy-Bx1vr4b0.js → TokenGalaxy-CGxUtq9x.js} +1 -1
  47. package/dashboard/dist/assets/{TooltipPopup-DQW8FWJO.js → TooltipPopup-B_B4qQD-.js} +1 -1
  48. package/dashboard/dist/assets/{TrendMonitor-BMNcY3N8.js → TrendMonitor-CdRQTLZL.js} +1 -1
  49. package/dashboard/dist/assets/{WidgetsPage-luRTge7O.js → WidgetsPage-DP_w32wh.js} +1 -1
  50. package/dashboard/dist/assets/{WrappedPage-B1j3WgXx.js → WrappedPage-C_DCqHUH.js} +1 -1
  51. package/dashboard/dist/assets/{__vite-browser-external-CvvpsW-U.js → __vite-browser-external-Lw3ipvYF.js} +3 -3
  52. package/dashboard/dist/assets/{agent-logos-DL4fdks6.js → agent-logos-DlnSKc2A.js} +1 -1
  53. package/dashboard/dist/assets/{arrow-left-DiOsjgdu.js → arrow-left-CjLKt_5C.js} +1 -1
  54. package/dashboard/dist/assets/{arrow-right-DBFXV7GU.js → arrow-right-Bh-Ezs8y.js} +1 -1
  55. package/dashboard/dist/assets/{arrow-up-right-BIvpDQHo.js → arrow-up-right-C87CvEJ5.js} +1 -1
  56. package/dashboard/dist/assets/{calendar-days--psnXUPW.js → calendar-days-B5VHwV9j.js} +1 -1
  57. package/dashboard/dist/assets/{check-DLqbsN00.js → check-D2uclldH.js} +1 -1
  58. package/dashboard/dist/assets/{chevron-down-C0P0oLzP.js → chevron-down-Beacyoub.js} +1 -1
  59. package/dashboard/dist/assets/{cloud-checkout-NSmb7QNy.js → cloud-checkout-3Y7NDSG1.js} +1 -1
  60. package/dashboard/dist/assets/{composite-D7ahPHYQ.js → composite-B28yORTL.js} +1 -1
  61. package/dashboard/dist/assets/{copy-COD7vwd6.js → copy-C8fZJwms.js} +1 -1
  62. package/dashboard/dist/assets/{download-CpSUX4U-.js → download-IIXYYmyo.js} +1 -1
  63. package/dashboard/dist/assets/{external-link-DXUjrooT.js → external-link-BEb0dms0.js} +1 -1
  64. package/dashboard/dist/assets/{flame-Dl3ZT9oo.js → flame-gmfpE-PO.js} +1 -1
  65. package/dashboard/dist/assets/{format-tokens-Ci49THbK.js → format-tokens-CKwy7NXi.js} +1 -1
  66. package/dashboard/dist/assets/{gift-DTHCN1Of.js → gift-twpInP_O.js} +1 -1
  67. package/dashboard/dist/assets/{icons-CB9s1e1k.js → icons-DQfkB5-5.js} +1 -1
  68. package/dashboard/dist/assets/{index-DFdfNKJM.js → index-ChVgw-dM.js} +1 -1
  69. package/dashboard/dist/assets/{index-Dozwi-Se.js → index-ClwErGKW.js} +1 -1
  70. package/dashboard/dist/assets/{index-BGT8ZQyn.js → index-D2-6Msd9.js} +1 -1
  71. package/dashboard/dist/assets/{index-DeX4t_5p.js → index-DzUxfO5e.js} +1 -1
  72. package/dashboard/dist/assets/{info-CqM2wmSI.js → info-BvndJDmD.js} +1 -1
  73. package/dashboard/dist/assets/{link-2-Byo7Rj3t.js → link-2-BhU_5JKG.js} +1 -1
  74. package/dashboard/dist/assets/{loader-circle-CMxd78Na.js → loader-circle-BR_62BDE.js} +1 -1
  75. package/dashboard/dist/assets/{local-usage-transfer-CBjnoeck.js → local-usage-transfer-CfoyR0hM.js} +1 -1
  76. package/dashboard/dist/assets/{modifiers.esm-Oowlk_FC.js → modifiers.esm-CnttlnRD.js} +1 -1
  77. package/dashboard/dist/assets/{monitor-smartphone-B2EgB_9q.js → monitor-smartphone-D4vzGxrq.js} +1 -1
  78. package/dashboard/dist/assets/{play-BSuP-UBL.js → play-BVWEKYTF.js} +1 -1
  79. package/dashboard/dist/assets/{plus-BWemSIuC.js → plus-B9GQ6VvV.js} +1 -1
  80. package/dashboard/dist/assets/{provider-display-BpzWuFDH.js → provider-display-DCFgR7FO.js} +1 -1
  81. package/dashboard/dist/assets/{react-cIJ4zx62.js → react-BKUD3UPr.js} +1 -1
  82. package/dashboard/dist/assets/{safePolygon-C_ZMtbgL.js → safePolygon-DPIi3FGf.js} +1 -1
  83. package/dashboard/dist/assets/{search-BK7eyNds.js → search-BBRJvYNz.js} +1 -1
  84. package/dashboard/dist/assets/{server-BqQHX-rd.js → server-Cg3zBK6a.js} +1 -1
  85. package/dashboard/dist/assets/{shield-check-CO5U6gRw.js → shield-check-BBzYplRb.js} +1 -1
  86. package/dashboard/dist/assets/{skills-api-CMye76ag.js → skills-api-CcAQ6fJX.js} +1 -1
  87. package/dashboard/dist/assets/{sortable.esm-Bvb61Lvj.js → sortable.esm-kNieGfR9.js} +1 -1
  88. package/dashboard/dist/assets/{store-CICV1KXb.js → store-DQpd_ZKv.js} +1 -1
  89. package/dashboard/dist/assets/{terminal-BKMWT1Ux.js → terminal-zj_c8_SG.js} +1 -1
  90. package/dashboard/dist/assets/{trash-D2Oqim4r.js → trash-Sl7eOlF7.js} +1 -1
  91. package/dashboard/dist/assets/{use-community-stats-CEqwmjct.js → use-community-stats-Bg-4yvQQ.js} +1 -1
  92. package/dashboard/dist/assets/{use-latest-request-guard-CTuzzu3F.js → use-latest-request-guard-COF23Bfs.js} +1 -1
  93. package/dashboard/dist/assets/{use-limits-display-prefs-CA2BLWkY.js → use-limits-display-prefs-QOlhCOgQ.js} +1 -1
  94. package/dashboard/dist/assets/{use-native-settings-B54wHSj9.js → use-native-settings-CKDqyirL.js} +1 -1
  95. package/dashboard/dist/assets/{use-ordered-list--PU9ekxY.js → use-ordered-list-mFHPMcF_.js} +1 -1
  96. package/dashboard/dist/assets/use-reduced-motion-B4rpC-Zt.js +1 -0
  97. package/dashboard/dist/assets/{use-transform-BRyR8szg.js → use-transform-CO5wZ0ML.js} +1 -1
  98. package/dashboard/dist/assets/{use-usage-limits-sufKdfDY.js → use-usage-limits-CeKjW8jG.js} +1 -1
  99. package/dashboard/dist/assets/{useCurrency-6oy7kSX8.js → useCurrency-WruYNKxG.js} +1 -1
  100. package/dashboard/dist/assets/{usePositioner-ZtDdVdJg.js → usePositioner-C3NGxtZa.js} +1 -1
  101. package/dashboard/dist/assets/{useTokenFormat-tiOJ-PeE.js → useTokenFormat-ObRZ_T9W.js} +1 -1
  102. package/dashboard/dist/assets/{zap-Dr1dXUjN.js → zap-CyZ3Tf1D.js} +1 -1
  103. package/dashboard/dist/index.html +1 -1
  104. package/dashboard/dist/ip-check.html +1 -1
  105. package/dashboard/dist/leaderboard.html +1 -1
  106. package/dashboard/dist/share.html +1 -1
  107. package/package.json +1 -1
  108. package/src/commands/init.js +9 -0
  109. package/src/commands/status.js +12 -6
  110. package/src/commands/sync.js +22 -4
  111. package/src/lib/claude-backend.js +291 -0
  112. package/src/lib/claude-config.js +1 -1
  113. package/src/lib/cursor-config.js +3 -1
  114. package/src/lib/devin-limits.js +65 -25
  115. package/src/lib/diagnostics.js +3 -0
  116. package/src/lib/doctor.js +13 -0
  117. package/src/lib/local-api.js +2 -0
  118. package/src/lib/local-usage-transfer.js +22 -1
  119. package/src/lib/pricing/index.js +12 -0
  120. package/src/lib/pricing/seed-snapshot.json +1 -1
  121. package/src/lib/rollout.js +70 -15
  122. package/src/lib/session-analytics.js +20 -8
  123. package/src/lib/trae-cn-config.js +32 -2
  124. package/src/lib/usage-limits.js +601 -35
  125. package/dashboard/dist/assets/DashboardPage-DJSgRHGq.js +0 -1
  126. package/dashboard/dist/assets/DialogDescription-B1HyKP0x.js +0 -1
  127. package/dashboard/dist/assets/SessionsPage-Cte1taUu.js +0 -3
  128. package/dashboard/dist/assets/use-reduced-motion-CZ8_YWc2.js +0 -1
@@ -1,3 +1,4 @@
1
+ const { readCustomBackend } = require("./claude-backend");
1
2
  const cp = require("node:child_process");
2
3
  const crypto = require("node:crypto");
3
4
  const fs = require("node:fs");
@@ -31,7 +32,7 @@ const { fetchGrokLimits, grokTokenSubject, loadGrokAuthEntry } = require("./grok
31
32
  const { fetchZcodeLimits } = require("./zcode-limits");
32
33
  const { fetchOpencodeGoLimits } = require("./opencode-go-limits");
33
34
  const { fetchCommandcodeLimits } = require("./commandcode-limits");
34
- const { fetchDevinLimits } = require("./devin-limits");
35
+ const { fetchDevinLimits, resolveDevinCacheIdentity } = require("./devin-limits");
35
36
  const { fetchQoderLimits, fetchQoderCnLimits } = require("./qoder-limits");
36
37
  const { fetchArkCodingPlanLimits } = require("./ark-coding-plan-limits");
37
38
  const { fetchArkAgentPlanLimits } = require("./ark-agent-plan-limits");
@@ -56,7 +57,8 @@ const execFileAsync = promisify(cp.execFile);
56
57
  // is never served (or joined onto) a response fetched while it was on, an aggregate
57
58
  // fetched while Antigravity was enabled is never served after the opt-out is set,
58
59
  // and a response fetched for a visibility selection is never served to one made
59
- // under a different one. Key = `${devin}-${antigravity}[-<hidden ids>]`.
60
+ // under a different one. Custom Claude origins also partition cached/in-flight
61
+ // responses so switching endpoints cannot resurrect Anthropic quota bars.
60
62
  const USAGE_LIMITS_BASE_SELECTION_KEYS = ["off-on", "off-off", "on-on", "on-off"];
61
63
  const cacheBySelection = {};
62
64
  for (const key of USAGE_LIMITS_BASE_SELECTION_KEYS) cacheBySelection[key] = { data: null, expiresAtMs: 0 };
@@ -198,6 +200,29 @@ const KIMI_LIMITS_CACHE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
198
200
  // (mirrors Claude / Codex / Cursor).
199
201
  const GROK_LIMITS_CACHE_FILE = "grok-usage-limits-cache.json";
200
202
  const GROK_LIMITS_CACHE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
203
+ // Kiro's quota windows come from a local kiro-cli round-trip (2s version probe +
204
+ // 20s per attempt) whose upstream call can time out or return an unparseable
205
+ // panel. Persist the last successful windows so a blip serves stale bars instead
206
+ // (mirrors Claude / Codex / OpenCode Go / Cursor).
207
+ const KIRO_LIMITS_CACHE_FILE = "kiro-usage-limits-cache.json";
208
+ const KIRO_LIMITS_CACHE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
209
+ // kiro-cli persists the signed-in account's CodeWhisperer profile ARN — a
210
+ // non-secret resource id — alongside its tokens in this file. It is the cache
211
+ // partition key, never the token itself.
212
+ const KIRO_AUTH_TOKEN_FILE = "kiro-auth-token.json";
213
+ // api.github.com is the most stable of the network providers, but a timeout or 5xx
214
+ // still blanks the Copilot card until the next poll. Persist the last successful
215
+ // windows so a blip serves stale bars instead (mirrors Claude / Codex / OpenCode Go /
216
+ // Cursor).
217
+ const COPILOT_LIMITS_CACHE_FILE = "copilot-usage-limits-cache.json";
218
+ const COPILOT_LIMITS_CACHE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
219
+ // Devin's GetPlanStatus RPC (server.codeium.com) is the only source of its
220
+ // daily/weekly quota windows — window state lives server-side. A transient
221
+ // timeout / 5xx previously blanked the card until the next poll — persist the
222
+ // last successful windows so a blip serves stale bars instead
223
+ // (mirrors OpenCode Go / Cursor).
224
+ const DEVIN_LIMITS_CACHE_FILE = "devin-usage-limits-cache.json";
225
+ const DEVIN_LIMITS_CACHE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
201
226
  // A 429 from the usage endpoint carries a long `retry-after` (often 20+ minutes). Persist
202
227
  // the cooldown so every surface — this process, the menu bar app's embedded server, a later
203
228
  // restart — stops calling until it expires. Hammering during the cooldown just renews the
@@ -1775,6 +1800,112 @@ function readKiroCreditsSummary({ home = os.homedir() } = {}) {
1775
1800
  }
1776
1801
  }
1777
1802
 
1803
+ // ─────────────────────────────────────────────────────────────────────────────
1804
+ // Kiro limits disk cache — last good windows parsed from the kiro-cli panel.
1805
+ // ─────────────────────────────────────────────────────────────────────────────
1806
+
1807
+ function resolveKiroLimitsCachePath({ home } = {}) {
1808
+ return path.join(home || os.homedir(), ".tokentracker", "tracker", KIRO_LIMITS_CACHE_FILE);
1809
+ }
1810
+
1811
+ function hasKiroWindow(limits) {
1812
+ return Boolean(limits?.primary_window || limits?.secondary_window);
1813
+ }
1814
+
1815
+ // Kiro windows carry `reset_at` as an ISO string (the plan's monthly reset date,
1816
+ // the bonus credits' own expiry). A window whose reset has already passed is
1817
+ // stale data, not a usable fallback, so drop it. Windows without a reset stamp
1818
+ // are kept — the overall cached_at max-age gate already bounds them.
1819
+ function isKiroCacheWindowUsable(window, { nowMs } = {}) {
1820
+ if (!window || typeof window !== "object") return false;
1821
+ const resetAtMs = parseTimeMs(window.reset_at);
1822
+ if (resetAtMs === null) return true;
1823
+ return resetAtMs > nowMs;
1824
+ }
1825
+
1826
+ function normalizeKiroCachedLimits(
1827
+ raw,
1828
+ { nowMs = Date.now(), maxAgeMs = KIRO_LIMITS_CACHE_MAX_AGE_MS } = {},
1829
+ ) {
1830
+ const cachedAtMs = parseTimeMs(raw?.cached_at);
1831
+ if (!Number.isFinite(cachedAtMs)) return null;
1832
+ if (cachedAtMs > nowMs + 60_000) return null;
1833
+ if (nowMs - cachedAtMs > maxAgeMs) return null;
1834
+
1835
+ const cached = {
1836
+ configured: true,
1837
+ error: null,
1838
+ plan_name: typeof raw?.plan_name === "string" ? raw.plan_name : null,
1839
+ primary_window: isKiroCacheWindowUsable(raw?.primary_window, { nowMs }) ? raw.primary_window : null,
1840
+ secondary_window: isKiroCacheWindowUsable(raw?.secondary_window, { nowMs }) ? raw.secondary_window : null,
1841
+ stale: true,
1842
+ cached_at: raw.cached_at,
1843
+ };
1844
+ return hasKiroWindow(cached) ? cached : null;
1845
+ }
1846
+
1847
+ // The signed-in Kiro account partitions the cache (zcode / Cursor login-identity
1848
+ // precedent): the id is the CodeWhisperer profile ARN kiro-cli itself persists
1849
+ // next to its tokens. Reads require the stored identity to match the current
1850
+ // one, and a write without an identity is refused — after an account switch the
1851
+ // previous account's windows must never resurface as another login's fallback.
1852
+ function readKiroLimitsCache({ home, nowMs = Date.now(), identity } = {}) {
1853
+ if (!identity) return null;
1854
+ try {
1855
+ const parsed = JSON.parse(fs.readFileSync(resolveKiroLimitsCachePath({ home }), "utf8"));
1856
+ const entry = parsed?.kiro;
1857
+ if (entry?.identity !== identity) return null;
1858
+ return normalizeKiroCachedLimits(entry, { nowMs });
1859
+ } catch (_error) {
1860
+ return null;
1861
+ }
1862
+ }
1863
+
1864
+ function writeKiroLimitsCache(limits, { home, nowMs = Date.now(), identity } = {}) {
1865
+ if (!identity) return;
1866
+ if (!limits?.configured || limits.error || !hasKiroWindow(limits)) return;
1867
+ const cachePath = resolveKiroLimitsCachePath({ home });
1868
+ const payload = {
1869
+ kiro: {
1870
+ identity,
1871
+ plan_name: limits.plan_name || null,
1872
+ primary_window: limits.primary_window || null,
1873
+ secondary_window: limits.secondary_window || null,
1874
+ cached_at: new Date(nowMs).toISOString(),
1875
+ },
1876
+ };
1877
+ try {
1878
+ fs.mkdirSync(path.dirname(cachePath), { recursive: true });
1879
+ const tmpPath = `${cachePath}.${process.pid}.tmp`;
1880
+ fs.writeFileSync(tmpPath, JSON.stringify(payload, null, 2), { encoding: "utf8", mode: 0o600 });
1881
+ fs.renameSync(tmpPath, cachePath);
1882
+ } catch (_error) {}
1883
+ }
1884
+
1885
+ function resolveKiroAuthIdentity({ home } = {}) {
1886
+ try {
1887
+ const parsed = JSON.parse(
1888
+ fs.readFileSync(
1889
+ path.join(home || os.homedir(), ".aws", "sso", "cache", KIRO_AUTH_TOKEN_FILE),
1890
+ "utf8",
1891
+ ),
1892
+ );
1893
+ const profileArn = parsed?.profileArn;
1894
+ return typeof profileArn === "string" && profileArn ? profileArn : null;
1895
+ } catch (_error) {
1896
+ return null;
1897
+ }
1898
+ }
1899
+
1900
+ // Any login-shaped CLI output (not-logged-in banner, oauth error, auth portal
1901
+ // failure) collapses into one "Not logged in to Kiro..." error inside
1902
+ // parseKiroUsageOutput, so the slot's error string is the whole auth story.
1903
+ function isKiroAuthError(message) {
1904
+ return /not logged in to kiro|kiro-cli login|login required|oauth error|failed to initialize auth portal/i.test(
1905
+ String(message || ""),
1906
+ );
1907
+ }
1908
+
1778
1909
  // ─────────────────────────────────────────────────────────────────────────────
1779
1910
  // GitHub Copilot — `GET https://api.github.com/copilot_internal/user`
1780
1911
  // Reuses the OAuth token from the user's existing Copilot install. Older clients
@@ -1796,7 +1927,10 @@ const COPILOT_AUTH_DB_SQL =
1796
1927
  "SELECT user_login, auth_authority, scopes, token_schema_version, hex(token_ciphertext) AS token_hex, "
1797
1928
  + "last_used_at, updated_at FROM oauth_tokens ORDER BY last_used_at DESC, updated_at DESC";
1798
1929
 
1799
- function readPlaintextCopilotOauthToken({ home }) {
1930
+ // Returns { token, user } for the first usable entry — `user` is the non-secret
1931
+ // GitHub login stored alongside the OAuth token by the editors that write these
1932
+ // files (absent on hand-rolled setups), or null when a token carries no login.
1933
+ function readPlaintextCopilotAuth({ home }) {
1800
1934
  const candidates = [
1801
1935
  path.join(home, ".config", "github-copilot", "apps.json"),
1802
1936
  path.join(home, ".config", "github-copilot", "hosts.json"),
@@ -1819,14 +1953,21 @@ function readPlaintextCopilotOauthToken({ home }) {
1819
1953
  if (!value || typeof value !== "object") continue;
1820
1954
  const token = typeof value.oauth_token === "string" ? value.oauth_token : "";
1821
1955
  if (!token) continue;
1956
+ const user = typeof value.user === "string" && value.user.trim()
1957
+ ? value.user.trim()
1958
+ : null;
1822
1959
  const host = String(key).split(":")[0];
1823
- if (host === "github.com") return token;
1824
- if (!fallback) fallback = token;
1960
+ if (host === "github.com") return { token, user };
1961
+ if (!fallback) fallback = { token, user };
1825
1962
  }
1826
1963
  }
1827
1964
  return fallback;
1828
1965
  }
1829
1966
 
1967
+ function readPlaintextCopilotOauthToken({ home }) {
1968
+ return readPlaintextCopilotAuth({ home })?.token || null;
1969
+ }
1970
+
1830
1971
  function readMacosKeychainGenericPassword({ service, account, securityRunner } = {}) {
1831
1972
  const runner = typeof securityRunner === "function" ? securityRunner : cp.spawnSync;
1832
1973
  if (runner === cp.spawnSync && !fs.existsSync(MACOS_SECURITY_BIN)) return null;
@@ -1967,6 +2108,14 @@ function readCopilotAuthDbRows({ home, sqliteReader, asyncReader = false } = {})
1967
2108
  return reader(dbPath, COPILOT_AUTH_DB_SQL, { label: "GitHub Copilot" });
1968
2109
  }
1969
2110
 
2111
+ // The non-secret GitHub login of the row the token came from — used as the
2112
+ // cache-partition identity for the Copilot limits fallback.
2113
+ function copilotRowLogin(row) {
2114
+ return typeof row?.user_login === "string" && row.user_login.trim()
2115
+ ? row.user_login.trim()
2116
+ : null;
2117
+ }
2118
+
1970
2119
  function resolveCopilotAuthDbTokenFromRows(rows, {
1971
2120
  platform = process.platform,
1972
2121
  securityRunner,
@@ -1982,7 +2131,7 @@ function resolveCopilotAuthDbTokenFromRows(rows, {
1982
2131
  const schemaVersion = parseCopilotAuthDbSchemaVersion(row?.token_schema_version);
1983
2132
  if (schemaVersion === 0) {
1984
2133
  const plaintextToken = decodeCopilotSchemaV0Token(row);
1985
- if (plaintextToken) return plaintextToken;
2134
+ if (plaintextToken) return { token: plaintextToken, login: copilotRowLogin(row) };
1986
2135
  continue;
1987
2136
  }
1988
2137
 
@@ -2001,7 +2150,7 @@ function resolveCopilotAuthDbTokenFromRows(rows, {
2001
2150
  }
2002
2151
  if (!keyBase64) continue;
2003
2152
  const token = decryptCopilotAuthDbToken(keyBase64, ciphertextHex);
2004
- if (token) return token;
2153
+ if (token) return { token, login: copilotRowLogin(row) };
2005
2154
  }
2006
2155
  return null;
2007
2156
  }
@@ -2019,7 +2168,7 @@ async function resolveCopilotAuthDbTokenFromRowsAsync(rows, {
2019
2168
  const schemaVersion = parseCopilotAuthDbSchemaVersion(row?.token_schema_version);
2020
2169
  if (schemaVersion === 0) {
2021
2170
  const plaintextToken = decodeCopilotSchemaV0Token(row);
2022
- if (plaintextToken) return plaintextToken;
2171
+ if (plaintextToken) return { token: plaintextToken, login: copilotRowLogin(row) };
2023
2172
  continue;
2024
2173
  }
2025
2174
 
@@ -2035,7 +2184,7 @@ async function resolveCopilotAuthDbTokenFromRowsAsync(rows, {
2035
2184
  }
2036
2185
  if (!keyBase64) continue;
2037
2186
  const token = decryptCopilotAuthDbToken(keyBase64, ciphertextHex);
2038
- if (token) return token;
2187
+ if (token) return { token, login: copilotRowLogin(row) };
2039
2188
  }
2040
2189
  return null;
2041
2190
  }
@@ -2047,7 +2196,7 @@ function readCopilotAuthDbToken({ home, platform = process.platform, securityRun
2047
2196
  } catch (_e) {
2048
2197
  return null;
2049
2198
  }
2050
- return resolveCopilotAuthDbTokenFromRows(rows, { platform, securityRunner });
2199
+ return resolveCopilotAuthDbTokenFromRows(rows, { platform, securityRunner })?.token || null;
2051
2200
  }
2052
2201
 
2053
2202
  async function readCopilotAuthDbTokenAsync({ home, platform = process.platform, securityRunner, sqliteReader } = {}) {
@@ -2057,19 +2206,59 @@ async function readCopilotAuthDbTokenAsync({ home, platform = process.platform,
2057
2206
  } catch (_e) {
2058
2207
  return null;
2059
2208
  }
2060
- return resolveCopilotAuthDbTokenFromRowsAsync(rows, { platform, securityRunner });
2209
+ const resolved = await resolveCopilotAuthDbTokenFromRowsAsync(rows, { platform, securityRunner });
2210
+ return resolved?.token || null;
2061
2211
  }
2062
2212
 
2063
- function readCopilotOauthToken({
2213
+ // Token + cache-partition identity in one resolution: the identity is the
2214
+ // non-secret GitHub login of whichever auth source produced the token
2215
+ // (the plaintext files' `user` field, or the auth.db row's `user_login`).
2216
+ // A token without a readable login yields identity null — the cache write
2217
+ // side refuses null identities.
2218
+ function readCopilotAuth({
2064
2219
  home = os.homedir(),
2065
2220
  platform = process.platform,
2066
2221
  securityRunner,
2067
2222
  sqliteReader,
2068
2223
  } = {}) {
2069
- const plaintext = readPlaintextCopilotOauthToken({ home });
2070
- if (plaintext) return plaintext;
2224
+ const plaintext = readPlaintextCopilotAuth({ home });
2225
+ if (plaintext?.token) return { token: plaintext.token, identity: plaintext.user };
2071
2226
  // No plaintext token found — recover the live one from the encrypted auth.db.
2072
- return readCopilotAuthDbToken({ home, platform, securityRunner, sqliteReader });
2227
+ let rows;
2228
+ try {
2229
+ rows = readCopilotAuthDbRows({ home, sqliteReader });
2230
+ } catch (_e) {
2231
+ return null;
2232
+ }
2233
+ const resolved = resolveCopilotAuthDbTokenFromRows(rows, { platform, securityRunner });
2234
+ return resolved ? { token: resolved.token, identity: resolved.login } : null;
2235
+ }
2236
+
2237
+ async function readCopilotAuthAsync({
2238
+ home = os.homedir(),
2239
+ platform = process.platform,
2240
+ securityRunner,
2241
+ sqliteReader,
2242
+ } = {}) {
2243
+ const plaintext = readPlaintextCopilotAuth({ home });
2244
+ if (plaintext?.token) return { token: plaintext.token, identity: plaintext.user };
2245
+ let rows;
2246
+ try {
2247
+ rows = await readCopilotAuthDbRows({ home, sqliteReader, asyncReader: true });
2248
+ } catch (_e) {
2249
+ return null;
2250
+ }
2251
+ const resolved = await resolveCopilotAuthDbTokenFromRowsAsync(rows, { platform, securityRunner });
2252
+ return resolved ? { token: resolved.token, identity: resolved.login } : null;
2253
+ }
2254
+
2255
+ function readCopilotOauthToken({
2256
+ home = os.homedir(),
2257
+ platform = process.platform,
2258
+ securityRunner,
2259
+ sqliteReader,
2260
+ } = {}) {
2261
+ return readCopilotAuth({ home, platform, securityRunner, sqliteReader })?.token || null;
2073
2262
  }
2074
2263
 
2075
2264
  async function readCopilotOauthTokenAsync({
@@ -2078,9 +2267,7 @@ async function readCopilotOauthTokenAsync({
2078
2267
  securityRunner,
2079
2268
  sqliteReader,
2080
2269
  } = {}) {
2081
- const plaintext = readPlaintextCopilotOauthToken({ home });
2082
- if (plaintext) return plaintext;
2083
- return readCopilotAuthDbTokenAsync({ home, platform, securityRunner, sqliteReader });
2270
+ return (await readCopilotAuthAsync({ home, platform, securityRunner, sqliteReader }))?.token || null;
2084
2271
  }
2085
2272
 
2086
2273
  function copilotRequestHeaders(token) {
@@ -2165,12 +2352,13 @@ async function fetchCopilotLimits({
2165
2352
  sqliteReader,
2166
2353
  } = {}) {
2167
2354
  const otel = describeCopilotOtelStatus({ home, env });
2168
- const token = await readCopilotOauthTokenAsync({
2355
+ const auth = await readCopilotAuthAsync({
2169
2356
  home: home || (env.HOME || os.homedir()),
2170
2357
  platform,
2171
2358
  securityRunner,
2172
2359
  sqliteReader,
2173
2360
  });
2361
+ const token = auth?.token || null;
2174
2362
  if (!token) return { configured: false, ...otel };
2175
2363
 
2176
2364
  try {
@@ -2179,7 +2367,9 @@ async function fetchCopilotLimits({
2179
2367
  headers: copilotRequestHeaders(token),
2180
2368
  });
2181
2369
  if (res.status === 401 || res.status === 403) {
2182
- throw new Error("GitHub Copilot token rejected. Re-authenticate via GitHub Copilot CLI/extension.");
2370
+ const authError = new Error("GitHub Copilot token rejected. Re-authenticate via GitHub Copilot CLI/extension.");
2371
+ authError.code = "AUTH_EXPIRED";
2372
+ throw authError;
2183
2373
  }
2184
2374
  if (!res.ok) {
2185
2375
  throw new Error(`GitHub Copilot API error: HTTP ${res.status}`);
@@ -2194,7 +2384,16 @@ async function fetchCopilotLimits({
2194
2384
  const chatWindow = buildCopilotWindow(snapshots.chat, resetIso);
2195
2385
 
2196
2386
  if (!premiumWindow && !chatWindow) {
2197
- return { configured: true, error: null, plan_name: planName, primary_window: null, secondary_window: null, ...otel };
2387
+ return {
2388
+ configured: true,
2389
+ error: null,
2390
+ plan_name: planName,
2391
+ primary_window: null,
2392
+ secondary_window: null,
2393
+ // Internal cache-partition key (stripped before the payload is served).
2394
+ cache_identity: auth.identity,
2395
+ ...otel,
2396
+ };
2198
2397
  }
2199
2398
 
2200
2399
  return {
@@ -2203,10 +2402,21 @@ async function fetchCopilotLimits({
2203
2402
  plan_name: planName,
2204
2403
  primary_window: premiumWindow,
2205
2404
  secondary_window: chatWindow,
2405
+ // Internal cache-partition key (stripped before the payload is served).
2406
+ cache_identity: auth.identity,
2206
2407
  ...otel,
2207
2408
  };
2208
2409
  } catch (error) {
2209
- return { configured: true, error: error?.message || "Unknown error", ...otel };
2410
+ return {
2411
+ configured: true,
2412
+ error: error?.message || "Unknown error",
2413
+ // A token-rejection error is actionable (re-authenticate) and must not be hidden
2414
+ // behind the stale disk-cache fallback (opencode-go auth_error precedent).
2415
+ // Internal marker — stripped before the payload is served.
2416
+ ...(error?.code === "AUTH_EXPIRED" ? { auth_error: true } : {}),
2417
+ cache_identity: auth.identity,
2418
+ ...otel,
2419
+ };
2210
2420
  }
2211
2421
  }
2212
2422
 
@@ -2216,11 +2426,16 @@ async function fetchKiroLimits({
2216
2426
  platform = process.platform,
2217
2427
  home = os.homedir(),
2218
2428
  } = {}) {
2429
+ // Identity captured BEFORE the CLI runs: if the account switches while
2430
+ // /usage is in flight, the write side can compare against a fresh read and
2431
+ // refuse instead of stamping the previous account's panel under the new
2432
+ // login. Internal marker — stripped before the payload is served.
2433
+ const cacheIdentity = resolveKiroAuthIdentity({ home });
2219
2434
  const trackedCredits = readKiroCreditsSummary({ home });
2220
2435
  const binaryPath = await whichBinary("kiro-cli", { commandRunner });
2221
2436
  if (!binaryPath) {
2222
2437
  return trackedCredits
2223
- ? { configured: true, error: null, ...trackedCredits }
2438
+ ? { configured: true, error: null, cache_identity: cacheIdentity, ...trackedCredits }
2224
2439
  : { configured: false };
2225
2440
  }
2226
2441
 
@@ -2278,6 +2493,7 @@ async function fetchKiroLimits({
2278
2493
  configured: true,
2279
2494
  error: null,
2280
2495
  ...parseKiroCommandResult(result, { now }),
2496
+ cache_identity: cacheIdentity,
2281
2497
  ...(trackedCredits || {}),
2282
2498
  };
2283
2499
  } catch (error) {
@@ -2289,6 +2505,7 @@ async function fetchKiroLimits({
2289
2505
  return {
2290
2506
  configured: true,
2291
2507
  error: error?.message || "Unknown error",
2508
+ cache_identity: cacheIdentity,
2292
2509
  ...(trackedCredits || {}),
2293
2510
  };
2294
2511
  }
@@ -3193,6 +3410,25 @@ function isGrokCacheWindowUsable(window, { nowMs } = {}) {
3193
3410
  return resetAtMs > nowMs;
3194
3411
  }
3195
3412
 
3413
+ function resolveCopilotLimitsCachePath({ home } = {}) {
3414
+ return path.join(home || os.homedir(), ".tokentracker", "tracker", COPILOT_LIMITS_CACHE_FILE);
3415
+ }
3416
+
3417
+ function hasCopilotWindow(limits) {
3418
+ return Boolean(limits?.primary_window || limits?.secondary_window);
3419
+ }
3420
+
3421
+ // Copilot windows carry `reset_at` as an ISO string built from the monthly
3422
+ // quota_reset_date. A window whose reset has already passed is stale data, not a
3423
+ // usable fallback, so drop it. Windows without a reset stamp are kept — the overall
3424
+ // cached_at max-age gate already bounds them.
3425
+ function isCopilotCacheWindowUsable(window, { nowMs } = {}) {
3426
+ if (!window || typeof window !== "object") return false;
3427
+ const resetAtMs = parseTimeMs(window.reset_at);
3428
+ if (resetAtMs === null) return true;
3429
+ return resetAtMs > nowMs;
3430
+ }
3431
+
3196
3432
  function normalizeGrokCachedLimits(
3197
3433
  raw,
3198
3434
  { nowMs = Date.now(), maxAgeMs = GROK_LIMITS_CACHE_MAX_AGE_MS } = {},
@@ -3247,6 +3483,45 @@ function readGrokLimitsCache({ home, nowMs = Date.now(), identity } = {}) {
3247
3483
  }
3248
3484
  }
3249
3485
 
3486
+ function normalizeCopilotCachedLimits(
3487
+ raw,
3488
+ { nowMs = Date.now(), maxAgeMs = COPILOT_LIMITS_CACHE_MAX_AGE_MS } = {},
3489
+ ) {
3490
+ const cachedAtMs = parseTimeMs(raw?.cached_at);
3491
+ if (!Number.isFinite(cachedAtMs)) return null;
3492
+ if (cachedAtMs > nowMs + 60_000) return null;
3493
+ if (nowMs - cachedAtMs > maxAgeMs) return null;
3494
+
3495
+ const cached = {
3496
+ configured: true,
3497
+ error: null,
3498
+ plan_name: typeof raw?.plan_name === "string" ? raw.plan_name : null,
3499
+ primary_window: isCopilotCacheWindowUsable(raw?.primary_window, { nowMs }) ? raw.primary_window : null,
3500
+ secondary_window: isCopilotCacheWindowUsable(raw?.secondary_window, { nowMs }) ? raw.secondary_window : null,
3501
+ stale: true,
3502
+ cached_at: raw.cached_at,
3503
+ };
3504
+ return hasCopilotWindow(cached) ? cached : null;
3505
+ }
3506
+
3507
+ // The signed-in GitHub account partitions the cache (zcode login-identity precedent):
3508
+ // the id is the non-secret GitHub login stored alongside the OAuth token (the
3509
+ // plaintext files' `user` field, or the auth.db row's `user_login`). A single home
3510
+ // resolves one active token, so one stamped entry suffices; a read requires the stored
3511
+ // identity to match the current one, and a write without an identity is refused — an
3512
+ // unreadable login must never pin another account's bars in place.
3513
+ function readCopilotLimitsCache({ home, nowMs = Date.now(), identity } = {}) {
3514
+ if (!identity) return null;
3515
+ try {
3516
+ const parsed = JSON.parse(fs.readFileSync(resolveCopilotLimitsCachePath({ home }), "utf8"));
3517
+ const entry = parsed?.copilot;
3518
+ if (entry?.identity !== identity) return null;
3519
+ return normalizeCopilotCachedLimits(entry, { nowMs });
3520
+ } catch (_error) {
3521
+ return null;
3522
+ }
3523
+ }
3524
+
3250
3525
  function writeGrokLimitsCache(limits, { home, nowMs = Date.now(), identity } = {}) {
3251
3526
  if (!identity) return;
3252
3527
  if (!limits?.configured || limits.error || !hasGrokWindow(limits)) return;
@@ -3276,6 +3551,27 @@ function writeGrokLimitsCache(limits, { home, nowMs = Date.now(), identity } = {
3276
3551
  } catch (_error) {}
3277
3552
  }
3278
3553
 
3554
+ function writeCopilotLimitsCache(limits, { home, nowMs = Date.now(), identity } = {}) {
3555
+ if (!identity) return;
3556
+ if (!limits?.configured || limits.error || !hasCopilotWindow(limits)) return;
3557
+ const cachePath = resolveCopilotLimitsCachePath({ home });
3558
+ const payload = {
3559
+ copilot: {
3560
+ identity,
3561
+ plan_name: limits.plan_name || null,
3562
+ primary_window: limits.primary_window || null,
3563
+ secondary_window: limits.secondary_window || null,
3564
+ cached_at: new Date(nowMs).toISOString(),
3565
+ },
3566
+ };
3567
+ try {
3568
+ fs.mkdirSync(path.dirname(cachePath), { recursive: true });
3569
+ const tmpPath = `${cachePath}.${process.pid}.tmp`;
3570
+ fs.writeFileSync(tmpPath, JSON.stringify(payload, null, 2), { encoding: "utf8", mode: 0o600 });
3571
+ fs.renameSync(tmpPath, cachePath);
3572
+ } catch (_error) {}
3573
+ }
3574
+
3279
3575
  // Failure-only identity re-derivation for the cache fallback: when the provider
3280
3576
  // slot's result was produced by the outer timeout race, the fetcher's own
3281
3577
  // identity field is lost, so read auth.json once to learn who we are serving for.
@@ -3288,6 +3584,118 @@ function resolveGrokCacheIdentity({ home, env } = {}) {
3288
3584
  }
3289
3585
  }
3290
3586
 
3587
+ // Failure-only identity re-derivation for the cache fallback: when the provider slot's
3588
+ // result was produced by the outer timeout race, the fetcher's identity field is lost,
3589
+ // so re-resolve the (still-local) auth store — mirroring the token-source priority so
3590
+ // the identity always belongs to the account the token resolution would pick. Async
3591
+ // like the fetcher's own resolution so a failed poll never blocks the embedded
3592
+ // server's event loop on SQLite / Keychain.
3593
+ async function resolveCopilotCacheIdentity({
3594
+ home = os.homedir(),
3595
+ platform = process.platform,
3596
+ securityRunner,
3597
+ sqliteReader,
3598
+ } = {}) {
3599
+ try {
3600
+ const resolved = await readCopilotAuthAsync({ home, platform, securityRunner, sqliteReader });
3601
+ return resolved?.identity || null;
3602
+ } catch (_error) {
3603
+ return null;
3604
+ }
3605
+ }
3606
+
3607
+ function resolveDevinLimitsCachePath({ home } = {}) {
3608
+ return path.join(home || os.homedir(), ".tokentracker", "tracker", DEVIN_LIMITS_CACHE_FILE);
3609
+ }
3610
+
3611
+ function hasDevinWindow(limits) {
3612
+ return Boolean(limits?.primary_window || limits?.secondary_window);
3613
+ }
3614
+
3615
+ // Devin windows carry `reset_at` as an ISO string. A window whose reset has
3616
+ // already passed is stale data, not a usable fallback, so drop it. Windows
3617
+ // without a reset stamp are kept — the overall cached_at max-age gate already
3618
+ // bounds them.
3619
+ function isDevinCacheWindowUsable(window, { nowMs } = {}) {
3620
+ if (!window || typeof window !== "object") return false;
3621
+ const resetAtMs = parseTimeMs(window.reset_at);
3622
+ if (resetAtMs === null) return true;
3623
+ return resetAtMs > nowMs;
3624
+ }
3625
+
3626
+ function normalizeDevinCachedLimits(
3627
+ raw,
3628
+ { nowMs = Date.now(), maxAgeMs = DEVIN_LIMITS_CACHE_MAX_AGE_MS } = {},
3629
+ ) {
3630
+ const cachedAtMs = parseTimeMs(raw?.cached_at);
3631
+ if (!Number.isFinite(cachedAtMs)) return null;
3632
+ if (cachedAtMs > nowMs + 60_000) return null;
3633
+ if (nowMs - cachedAtMs > maxAgeMs) return null;
3634
+
3635
+ const cached = {
3636
+ configured: true,
3637
+ error: null,
3638
+ plan_label: typeof raw?.plan_label === "string" ? raw.plan_label : null,
3639
+ primary_window: isDevinCacheWindowUsable(raw?.primary_window, { nowMs }) ? raw.primary_window : null,
3640
+ secondary_window: isDevinCacheWindowUsable(raw?.secondary_window, { nowMs }) ? raw.secondary_window : null,
3641
+ stale: true,
3642
+ cached_at: raw.cached_at,
3643
+ };
3644
+ return hasDevinWindow(cached) ? cached : null;
3645
+ }
3646
+
3647
+ // Match the current credential-file revision, including on timeout fallback.
3648
+ // Legacy unpartitioned entries are deliberately ignored.
3649
+ function readDevinLimitsCache({ home, nowMs = Date.now(), identity } = {}) {
3650
+ if (!identity) return null;
3651
+ try {
3652
+ const parsed = JSON.parse(fs.readFileSync(resolveDevinLimitsCachePath({ home }), "utf8"));
3653
+ if (parsed?.devin?.identity !== identity) return null;
3654
+ return normalizeDevinCachedLimits(parsed.devin, { nowMs });
3655
+ } catch (_error) {
3656
+ return null;
3657
+ }
3658
+ }
3659
+
3660
+ function writeDevinLimitsCache(limits, { home, nowMs = Date.now(), identity } = {}) {
3661
+ if (!identity) return;
3662
+ if (!limits?.configured || limits.error || !hasDevinWindow(limits)) return;
3663
+ const cachePath = resolveDevinLimitsCachePath({ home });
3664
+ const payload = {
3665
+ devin: {
3666
+ identity,
3667
+ plan_label: limits.plan_label || null,
3668
+ primary_window: limits.primary_window || null,
3669
+ secondary_window: limits.secondary_window || null,
3670
+ cached_at: new Date(nowMs).toISOString(),
3671
+ },
3672
+ };
3673
+ try {
3674
+ fs.mkdirSync(path.dirname(cachePath), { recursive: true });
3675
+ const tmpPath = `${cachePath}.${process.pid}.tmp`;
3676
+ fs.writeFileSync(tmpPath, JSON.stringify(payload, null, 2), { encoding: "utf8", mode: 0o600 });
3677
+ fs.renameSync(tmpPath, cachePath);
3678
+ } catch (_error) {}
3679
+ }
3680
+
3681
+ function clearDevinLimitsCache({ home } = {}) {
3682
+ try {
3683
+ fs.rmSync(resolveDevinLimitsCachePath({ home }), { force: true });
3684
+ } catch (_error) {}
3685
+ }
3686
+
3687
+ // Only transient failures fall back to the disk cache. Requests without a
3688
+ // status (timeouts, transport failures, non-JSON or schema-malformed bodies)
3689
+ // are blips by construction; 429 and 5xx are the classic retryable classes.
3690
+ // Everything else — the ambiguous HTTP 400 the docs pin as a plain provider
3691
+ // error, and any other non-auth 4xx — is potentially persistent, and stale
3692
+ // bars must not mask it for up to the whole TTL.
3693
+ function isDevinTransientFetchError(reason) {
3694
+ const status = reason?.statusCode;
3695
+ if (typeof status !== "number") return true;
3696
+ return status === 429 || status >= 500;
3697
+ }
3698
+
3291
3699
  function resolveClaudeRateLimitPath({ home } = {}) {
3292
3700
  return path.join(home || os.homedir(), ".tokentracker", "tracker", CLAUDE_RATE_LIMIT_FILE);
3293
3701
  }
@@ -4630,7 +5038,14 @@ function cacheExpiresAtMs(data, fetchedAtMs) {
4630
5038
 
4631
5039
  /** Return cached quota or share one in-flight fetch for the requested provider selection. */
4632
5040
  async function getUsageLimits(options = {}) {
4633
- const selection = usageLimitsSelectionKey(options);
5041
+ // Resolve current routing before any aggregate cache or in-flight selection.
5042
+ // Keep this snapshot for the fetch so a settings change during an await cannot
5043
+ // publish one backend's response into another backend's cache partition.
5044
+ const claudeBackend = normalizeHiddenProviders(options.hiddenProviders)?.includes("claude")
5045
+ ? null
5046
+ : readCustomBackend({ home: options.home || os.homedir(), env: options.env });
5047
+ const customOrigin = claudeBackend?.backend === "custom" ? claudeBackend.backend_origin : null;
5048
+ const selection = usageLimitsSelectionKey(options) + (customOrigin ? `-claude:${customOrigin}` : "");
4634
5049
  const cache = cacheBySelection[selection];
4635
5050
  const nowMs = Date.now();
4636
5051
  // Unseen selections (a hidden set never requested before this process's
@@ -4644,6 +5059,7 @@ async function getUsageLimits(options = {}) {
4644
5059
  const cacheGeneration = usageLimitsCacheGeneration;
4645
5060
  const promise = fetchUsageLimitsUncached({
4646
5061
  ...options,
5062
+ claudeBackend,
4647
5063
  selectionKey: selection,
4648
5064
  cacheGeneration,
4649
5065
  }).finally(() => {
@@ -4676,6 +5092,7 @@ async function fetchUsageLimitsUncached({
4676
5092
  // "fetch every configured provider" (the legacy behavior clients that don't
4677
5093
  // send the parameter keep getting).
4678
5094
  hiddenProviders = null,
5095
+ claudeBackend = null,
4679
5096
  // Generation captured by getUsageLimits before the fetch began. A provider
4680
5097
  // key save bumps it, and this fetch must then stop at returning its own
4681
5098
  // result: publishing it would restore pre-save data for every later caller.
@@ -4693,16 +5110,17 @@ async function fetchUsageLimitsUncached({
4693
5110
  const injectedNowMs = Number.isFinite(nowMs) ? nowMs : undefined;
4694
5111
  nowMs = injectedNowMs ?? Date.now();
4695
5112
  const hiddenSet = new Set(normalizeHiddenProviders(hiddenProviders) || []);
5113
+ const customClaudeBackend = !hiddenSet.has("claude") && claudeBackend?.backend === "custom";
4696
5114
 
4697
5115
  // A hidden provider must not have its local credentials read at all (#956):
4698
5116
  // these upfront token/subscription probes are the first file and keychain
4699
5117
  // touches of the Claude/Codex chains, so they are gated here and every
4700
5118
  // downstream step keys off the resulting null token.
4701
5119
  const [claudeOauth, claudeSubscription, codexAuth] = await Promise.all([
4702
- hiddenSet.has("claude")
5120
+ hiddenSet.has("claude") || customClaudeBackend
4703
5121
  ? Promise.resolve(null)
4704
5122
  : Promise.resolve().then(() => readClaudeCodeOauthToken({ platform, securityRunner, home, nowMs })),
4705
- hiddenSet.has("claude")
5123
+ hiddenSet.has("claude") || customClaudeBackend
4706
5124
  ? Promise.resolve(null)
4707
5125
  : Promise.resolve().then(() => detectClaudeCodeSubscriptionDetails({ platform, securityRunner, home })),
4708
5126
  hiddenSet.has("codex")
@@ -4889,9 +5307,10 @@ async function fetchUsageLimitsUncached({
4889
5307
  ),
4890
5308
  // Devin (devin.ai): daily/weekly subscription quota from the official
4891
5309
  // GetPlanStatus RPC, keyed by the session token the Devin CLI stores in
4892
- // ~/.local/share/devin/credentials.toml. No local fallback — window state
4893
- // lives server-side. fetchDevinLimits throws on auth expiry so the
4894
- // assemble step below can flag auth_action_required.
5310
+ // ~/.local/share/devin/credentials.toml — window state lives server-side,
5311
+ // so the assemble step below serves the last-good disk cache when this
5312
+ // poll fails transiently. fetchDevinLimits throws on auth expiry so the
5313
+ // assemble step can flag auth_action_required.
4895
5314
  hiddenSet.has("devin") ? hiddenSettled : withProviderTimeout(fetchDevinLimits({ home, env, enabled: devinEnabled === true, fetchImpl: providerFetch }), "Devin", providerTimeoutMs)
4896
5315
  .then(
4897
5316
  (value) => ({ status: "fulfilled", value }),
@@ -4934,7 +5353,9 @@ async function fetchUsageLimitsUncached({
4934
5353
  ]);
4935
5354
 
4936
5355
  let claude;
4937
- if (!claudeToken) {
5356
+ if (customClaudeBackend) {
5357
+ claude = { configured: true, ...claudeBackend, limits_status: "not_applicable", cost_status: "unknown", error: null };
5358
+ } else if (!claudeToken) {
4938
5359
  // Claude Code blanks `accessToken`/`refreshToken` in place when its login expires
4939
5360
  // (macOS Keychain item and .credentials.json alike) instead of removing the entry,
4940
5361
  // so "no token" is ambiguous: never signed in, or signed in and expired. An entry
@@ -5010,7 +5431,7 @@ async function fetchUsageLimitsUncached({
5010
5431
  // waiting out the poll interval. Re-read here (not the pre-fetch value) so a
5011
5432
  // cool-down just armed by this cycle's 429 is included; a successful read above
5012
5433
  // cleared the file, so this is null in the happy path.
5013
- if (claude.configured) {
5434
+ if (claude.configured && claude.limits_status !== "not_applicable") {
5014
5435
  const claudeCooldownMs = readClaudeRateLimitRetryAtMs({
5015
5436
  home,
5016
5437
  nowMs,
@@ -5141,8 +5562,13 @@ async function fetchUsageLimitsUncached({
5141
5562
 
5142
5563
  // Devin: server-owned quota windows like CommandCode — a fulfilled
5143
5564
  // `configured: false` means no CLI credentials; a rejected fetch with
5144
- // AUTH_EXPIRED flags auth_action_required for the re-sign-in hint.
5565
+ // AUTH_EXPIRED flags auth_action_required for the re-sign-in hint, and a
5566
+ // CREDENTIALS_INVALID error is a broken local sign-in (unreadable file,
5567
+ // missing token, unsupported api_server_url) — both actionable, never
5568
+ // masked by the disk cache below.
5145
5569
  let devinObj;
5570
+ const devinCredentialsInvalid =
5571
+ devinRaw?.status === "rejected" && devinRaw.reason?.code === "CREDENTIALS_INVALID";
5146
5572
  if (devinRaw?.status === "fulfilled") {
5147
5573
  const value = devinRaw.value;
5148
5574
  devinObj = value && value.configured === false
@@ -5155,6 +5581,50 @@ async function fetchUsageLimitsUncached({
5155
5581
  : { configured: true, error: reason?.message || "Unknown error" };
5156
5582
  }
5157
5583
 
5584
+ // Devin: persist last successful windows so a transient timeout/5xx on the
5585
+ // server.codeium.com route serves stale bars instead of a flashing "fetch
5586
+ // failed" (mirrors OpenCode Go / Cursor). Auth and broken-credential errors
5587
+ // punch through above, ambiguous/persistent HTTP rejections punch through
5588
+ // below, an opted-out / unconfigured provider has no window state to fall
5589
+ // back on, and a successful windowless read (legacy billing strategy)
5590
+ // invalidates any prior entry instead of leaving it for a later blip to
5591
+ // resurrect the former plan's bars.
5592
+ if (
5593
+ devinObj
5594
+ && devinObj.configured !== false
5595
+ && devinObj.auth_action_required !== "reauth"
5596
+ && !devinCredentialsInvalid
5597
+ ) {
5598
+ const currentIdentity = resolveDevinCacheIdentity({ home, env });
5599
+ if (!devinObj.error) {
5600
+ // A response belongs to the credentials read before its request. Never
5601
+ // write or clear a new login's cache after an in-flight replacement.
5602
+ if (cacheGeneration === usageLimitsCacheGeneration
5603
+ && currentIdentity && devinObj.cache_identity === currentIdentity) {
5604
+ if (hasDevinWindow(devinObj)) {
5605
+ writeDevinLimitsCache(devinObj, { home, nowMs, identity: currentIdentity });
5606
+ } else {
5607
+ clearDevinLimitsCache({ home });
5608
+ }
5609
+ }
5610
+ } else if (isDevinTransientFetchError(devinRaw?.reason)) {
5611
+ // The fallback lands after the whole provider round: sample the clock
5612
+ // here (tests keep their injected clock) so a window that rolled over
5613
+ // while the poll was waiting is dropped instead of served.
5614
+ const cached = readDevinLimitsCache({
5615
+ home,
5616
+ nowMs: injectedNowMs ?? Date.now(),
5617
+ identity: currentIdentity,
5618
+ });
5619
+ if (cached) devinObj = cached;
5620
+ }
5621
+ }
5622
+
5623
+ if (devinObj && "cache_identity" in devinObj) {
5624
+ const { cache_identity: _identity, ...devinPayload } = devinObj;
5625
+ devinObj = devinPayload;
5626
+ }
5627
+
5158
5628
  // Cursor: persist last successful windows so a transient timeout/5xx on the
5159
5629
  // cursor.com / api2.cursor.sh routes serves stale bars instead of a flashing
5160
5630
  // "fetch failed" (mirrors Codex / Claude / OpenCode Go). A session-expiry error
@@ -5244,6 +5714,45 @@ async function fetchUsageLimitsUncached({
5244
5714
  ? { ...cursorWithCache, quaternary_window: null }
5245
5715
  : cursorWithCache;
5246
5716
 
5717
+ // Copilot: same fallback contract as Cursor for the api.github.com route — a
5718
+ // transient timeout/5xx serves stale bars instead of a flashing "fetch failed",
5719
+ // while a token-rejection error (401/403 — "re-authenticate") is actionable and
5720
+ // passes through untouched. The otel probe fields ride along on every slot
5721
+ // result, so a cached overlay spreads the live object first and lets the cached
5722
+ // entry win only the limit fields.
5723
+ let copilotWithCache;
5724
+ if (!copilot || copilot.configured === false) {
5725
+ copilotWithCache = copilot;
5726
+ } else if (copilot.auth_error === true) {
5727
+ copilotWithCache = copilot;
5728
+ } else if (!copilot.error && hasCopilotWindow(copilot)) {
5729
+ copilotWithCache = {
5730
+ ...copilot,
5731
+ stale: false,
5732
+ cached_at: new Date(nowMs).toISOString(),
5733
+ };
5734
+ // Only the login captured with the request can own its windows. An
5735
+ // unknown login must never be promoted to a newly signed-in account.
5736
+ const identity = copilot.cache_identity;
5737
+ if (typeof identity === "string" && identity
5738
+ && identity === await resolveCopilotCacheIdentity({ home, platform, securityRunner })
5739
+ && cacheGeneration === usageLimitsCacheGeneration) {
5740
+ writeCopilotLimitsCache(copilotWithCache, { home, nowMs, identity });
5741
+ }
5742
+ } else {
5743
+ // Re-read the current login even when the failed request carries an identity:
5744
+ // the account may have changed while the request was in flight.
5745
+ const identity = await resolveCopilotCacheIdentity({ home, platform, securityRunner });
5746
+ // Sample after the async auth lookup too: a slow poll can straddle a reset.
5747
+ const cached = readCopilotLimitsCache({ home, nowMs: Date.now(), identity });
5748
+ copilotWithCache = cached ? { ...copilot, ...cached } : copilot;
5749
+ }
5750
+ // cache_identity / auth_error are internal cache-plumbing markers, not payload.
5751
+ if (copilotWithCache && ("cache_identity" in copilotWithCache || "auth_error" in copilotWithCache)) {
5752
+ const { cache_identity: _identity, auth_error: _authError, ...copilotPayload } = copilotWithCache;
5753
+ copilotWithCache = copilotPayload;
5754
+ }
5755
+
5247
5756
  // Kimi: persist last successful windows so a transient timeout/5xx on the
5248
5757
  // api.kimi.com / api.kimi.ai coding routes serves stale bars instead of a
5249
5758
  // flashing "fetch failed" (mirrors Cursor / Codex / OpenCode Go). Re-login
@@ -5348,9 +5857,61 @@ async function fetchUsageLimitsUncached({
5348
5857
  geminiWithCache = geminiPayload;
5349
5858
  }
5350
5859
 
5860
+ // Kiro: the slot runs the local kiro-cli (its own 2s/20s command budgets,
5861
+ // never rejects), so the failure modes are CLI timeouts, unparseable panels
5862
+ // and backend hiccups — persist the last successful windows and serve them
5863
+ // stale on any of those (mirrors Codex / OpenCode Go / Cursor). A
5864
+ // not-logged-in panel is actionable and must not be hidden behind stale
5865
+ // cache, so it passes through untouched. The credits-only success (kiro-cli
5866
+ // absent, sidecar present) is not a fetch failure and keeps its shape.
5867
+ // Identity comes from the fetcher's pre-CLI capture (cache_identity), and a
5868
+ // write only lands while a fresh read of the auth file still agrees — an
5869
+ // account switch mid-flight refuses the write instead of stamping the
5870
+ // previous account's panel under the new login.
5871
+ const kiroAuthBlocked = isKiroAuthError(kiro?.error);
5872
+ let kiroWithCache;
5873
+ if (!kiro || kiro.configured === false || kiroAuthBlocked) {
5874
+ kiroWithCache = kiro;
5875
+ } else if (!kiro.error && hasKiroWindow(kiro)) {
5876
+ kiroWithCache = {
5877
+ ...kiro,
5878
+ stale: false,
5879
+ cached_at: new Date(nowMs).toISOString(),
5880
+ };
5881
+ if (cacheGeneration === usageLimitsCacheGeneration) {
5882
+ const identity = typeof kiro.cache_identity === "string"
5883
+ ? kiro.cache_identity
5884
+ : resolveKiroAuthIdentity({ home });
5885
+ if (identity && identity === resolveKiroAuthIdentity({ home })) {
5886
+ writeKiroLimitsCache(kiroWithCache, { home, nowMs, identity });
5887
+ }
5888
+ }
5889
+ } else if (!kiro.error) {
5890
+ kiroWithCache = kiro;
5891
+ } else {
5892
+ const cached = readKiroLimitsCache({
5893
+ home,
5894
+ // Gate against the wall clock, not the aggregate's entry stamp: a slow
5895
+ // failed CLI read can straddle a reset, and an expired window must not
5896
+ // pass the reset gate on a timestamp from before the read started.
5897
+ nowMs: Date.now(),
5898
+ // The current login, never the fetch-time capture — a switch that
5899
+ // happened mid-flight must not match the previous account's entry.
5900
+ identity: resolveKiroAuthIdentity({ home }),
5901
+ });
5902
+ // The failed slot already carries the credits-sidecar fields the fetcher
5903
+ // merged in; the cached windows overlay on top of them.
5904
+ kiroWithCache = cached ? { ...kiro, ...cached } : kiro;
5905
+ }
5906
+ // cache_identity is internal cache plumbing, not payload.
5907
+ if (kiroWithCache && "cache_identity" in kiroWithCache) {
5908
+ const { cache_identity: _identity, ...kiroPayload } = kiroWithCache;
5909
+ kiroWithCache = kiroPayload;
5910
+ }
5911
+
5351
5912
  const data = {
5352
5913
  fetched_at: new Date(nowMs).toISOString(),
5353
- claude: withPlanLabel(claude, claudePlanType, "Claude"),
5914
+ claude: claude.limits_status === "not_applicable" ? claude : withPlanLabel(claude, claudePlanType, "Claude"),
5354
5915
  codex: withPlanLabel(codex, codex.plan_type, "Codex"),
5355
5916
  cursor: withPlanLabel(cursorLimits, cursorLimits.membership_type, "Cursor"),
5356
5917
  // Kimi's subType (TYPE_PURCHASE/TYPE_EVENT) is the credit *source*, not a plan
@@ -5359,9 +5920,9 @@ async function fetchUsageLimitsUncached({
5359
5920
  // "Kimi Type_event" (issue #130), so show the bare brand instead.
5360
5921
  kimi: withPlanLabel(kimiWithCache, null, "Kimi"),
5361
5922
  gemini: withPlanLabel(geminiWithCache, geminiWithCache.account_plan, "Gemini"),
5362
- kiro: withPlanLabel(kiro, kiro.plan_name, "Kiro"),
5923
+ kiro: withPlanLabel(kiroWithCache, kiroWithCache.plan_name, "Kiro"),
5363
5924
  antigravity: withPlanLabel(antigravity, antigravity.account_plan, "Antigravity"),
5364
- copilot: withPlanLabel(copilot, copilot.plan_name, "Copilot"),
5925
+ copilot: withPlanLabel(copilotWithCache, copilotWithCache.plan_name, "Copilot"),
5365
5926
  // grok-limits already maps subscriptionTier to xAI's own product names
5366
5927
  // ("SuperGrok Heavy", "X Premium+", "API Key"). The shared normalization
5367
5928
  // would Title-Case them into "Supergrok Heavy" / "Api Key" and drop "Free"
@@ -5399,6 +5960,9 @@ async function fetchUsageLimitsUncached({
5399
5960
  // Devin) is metadata-free by contract — it was never fetched, so it must
5400
5961
  // not be aged into a "disk-cache"/stale provenance.
5401
5962
  if (provider.configured === false && Object.keys(provider).length === 1) continue;
5963
+ // A custom-backend state is local routing information, not an official
5964
+ // provider API reading with a capture time or quota freshness.
5965
+ if (provider.limits_status === "not_applicable") continue;
5402
5966
  const capturedAt = provider.cached_at || data.fetched_at;
5403
5967
  const ageMs = Math.max(0, nowMs - Date.parse(capturedAt || ""));
5404
5968
  const stale = provider.stale === true || (Number.isFinite(ageMs) && ageMs > 10 * 60 * 1000);
@@ -5503,6 +6067,8 @@ module.exports = {
5503
6067
  detectAntigravityProcess,
5504
6068
  fetchAntigravityLimits,
5505
6069
  fetchCopilotLimits,
6070
+ readCopilotAuth,
6071
+ readCopilotAuthAsync,
5506
6072
  readCopilotOauthToken,
5507
6073
  readCopilotOauthTokenAsync,
5508
6074
  readCopilotAuthDbToken,