tokentracker-cli 0.94.1 → 0.95.1

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 (126) hide show
  1. package/README.de.md +9 -7
  2. package/README.ja.md +8 -6
  3. package/README.ko.md +8 -6
  4. package/README.md +23 -10
  5. package/README.zh-CN.md +8 -6
  6. package/dashboard/dist/assets/{AchievementBadge-DnmgEa4q.js → AchievementBadge-Cyju9OnL.js} +1 -1
  7. package/dashboard/dist/assets/{AchievementsPage-B7YTbAuK.js → AchievementsPage-Bh2dQR-I.js} +2 -2
  8. package/dashboard/dist/assets/{AchievementsSection-CwgT5sqH.js → AchievementsSection-DSFAM0Zp.js} +1 -1
  9. package/dashboard/dist/assets/{BadgeDetailModal-Dets6jzW.js → BadgeDetailModal-CgVF0VI0.js} +1 -1
  10. package/dashboard/dist/assets/{BotAnimated-5KwUvC9F.js → BotAnimated-DI-rTxI9.js} +1 -1
  11. package/dashboard/dist/assets/{Card-jf9w-4-N.js → Card-DAlV-PSI.js} +1 -1
  12. package/dashboard/dist/assets/{ClawdAnimated-CGxDVQ0c.js → ClawdAnimated-oOYKgcpq.js} +2 -2
  13. package/dashboard/dist/assets/{CommandPalette-B1bUUf32.js → CommandPalette-bYE5uD63.js} +1 -1
  14. package/dashboard/dist/assets/CommunityStatsModal-DCx0sQjk.js +1 -0
  15. package/dashboard/dist/assets/{ConfirmModal-BR-aIRH9.js → ConfirmModal-DrYf9m15.js} +1 -1
  16. package/dashboard/dist/assets/{Controls-CbHsJAqV.js → Controls-DAift9Z4.js} +1 -1
  17. package/dashboard/dist/assets/DashboardPage-CLswnOil.js +1 -0
  18. package/dashboard/dist/assets/{DevicePage-DDlicLLn.js → DevicePage-uWLgMC4Z.js} +1 -1
  19. package/dashboard/dist/assets/{DialogClose-B89uLXqp.js → DialogClose-NitVrdVC.js} +1 -1
  20. package/dashboard/dist/assets/DialogDescription-CQuJCP4L.js +1 -0
  21. package/dashboard/dist/assets/{DialogTitle-jOyycYKF.js → DialogTitle-CA_ZstJL.js} +1 -1
  22. package/dashboard/dist/assets/{FadeIn-BEND42ze.js → FadeIn-CIkYDamA.js} +1 -1
  23. package/dashboard/dist/assets/{HeaderGithubStar-Dacl1WGS.js → HeaderGithubStar-CYTJNLpL.js} +1 -1
  24. package/dashboard/dist/assets/{HoverTooltip-B2Mh2LTi.js → HoverTooltip-jh3sI8NC.js} +1 -1
  25. package/dashboard/dist/assets/{IpCheckPage-CPEuRhiU.js → IpCheckPage-Brpe-N7B.js} +1 -1
  26. package/dashboard/dist/assets/{LandingPage-BYWNR_dx.js → LandingPage-D4PjaNcN.js} +2 -2
  27. package/dashboard/dist/assets/{LeaderboardAvatar-B9_5LDVj.js → LeaderboardAvatar-D9dc6frq.js} +1 -1
  28. package/dashboard/dist/assets/{LeaderboardPage-_Hl1NaIa.js → LeaderboardPage-CEuqopiC.js} +2 -2
  29. package/dashboard/dist/assets/{LeaderboardProfileModal-Cydp7CyA.js → LeaderboardProfileModal-TeYdXeIA.js} +3 -3
  30. package/dashboard/dist/assets/{LeaderboardProfilePage-Dz0ecujs.js → LeaderboardProfilePage-DmtuwJkL.js} +1 -1
  31. package/dashboard/dist/assets/{LimitsPage-CmdT2qOZ.js → LimitsPage-BJW3teYA.js} +2 -2
  32. package/dashboard/dist/assets/{LocalOnlyNotice-DhuPGUkw.js → LocalOnlyNotice-V8_KFKOD.js} +1 -1
  33. package/dashboard/dist/assets/{LoginCard-DQtHaX_5.js → LoginCard-ud0lCY_u.js} +1 -1
  34. package/dashboard/dist/assets/{LoginModal-DvpPRnYV.js → LoginModal-Bnemx4UD.js} +1 -1
  35. package/dashboard/dist/assets/{LoginPage-DOjfmdwb.js → LoginPage-v89BHssa.js} +1 -1
  36. package/dashboard/dist/assets/{PetPage-Dd6U9fh2.js → PetPage-Bg9kEi7o.js} +1 -1
  37. package/dashboard/dist/assets/{PopoverPopup-B1V01u10.js → PopoverPopup-CCHVwh0w.js} +1 -1
  38. package/dashboard/dist/assets/{ProviderIcon-BLRe55c7.js → ProviderIcon-BecpRylD.js} +1 -1
  39. package/dashboard/dist/assets/{ResetPasswordPage-BJ95vy1S.js → ResetPasswordPage-CsUtFeUr.js} +1 -1
  40. package/dashboard/dist/assets/{SegmentedControl-DacS_m9M.js → SegmentedControl-3rBqbzW_.js} +1 -1
  41. package/dashboard/dist/assets/{Select-bQDO5BEA.js → Select-DZ2ll7Qv.js} +1 -1
  42. package/dashboard/dist/assets/{SelectItemText-CUGrs9CY.js → SelectItemText-Dr1W47W6.js} +1 -1
  43. package/dashboard/dist/assets/{ServiceStatusPage-CsERvmVC.js → ServiceStatusPage-Cc1LXdAh.js} +1 -1
  44. package/dashboard/dist/assets/{SessionsPage-0VvqEfPx.js → SessionsPage-ytOFN1h9.js} +1 -1
  45. package/dashboard/dist/assets/{SettingsPage-9EHh2HfD.js → SettingsPage-2wYQ2ly6.js} +2 -2
  46. package/dashboard/dist/assets/SkillsPage-BEDAwBTN.js +1 -0
  47. package/dashboard/dist/assets/{TokenGalaxy-DSKTHQSs.js → TokenGalaxy-DxG0dBeG.js} +1 -1
  48. package/dashboard/dist/assets/{ToolbarRootContext-DP4mQLbq.js → ToolbarRootContext-DG1pr5UP.js} +1 -1
  49. package/dashboard/dist/assets/TrendMonitor-CeuEQ0XC.js +19 -0
  50. package/dashboard/dist/assets/{WidgetsPage-Cr6Wg9QX.js → WidgetsPage-ALDlZ5aR.js} +1 -1
  51. package/dashboard/dist/assets/{WrappedPage-DqQvRcnA.js → WrappedPage-BF59ouR-.js} +1 -1
  52. package/dashboard/dist/assets/{__vite-browser-external-BHz2N7Ih.css → __vite-browser-external-CCMdjCX2.css} +1 -1
  53. package/dashboard/dist/assets/__vite-browser-external-DXtgoDkV.js +45 -0
  54. package/dashboard/dist/assets/{agent-logos-BjIh_mqf.js → agent-logos-D6Ya1qw1.js} +1 -1
  55. package/dashboard/dist/assets/{arrow-up-right-BIhbN0NO.js → arrow-up-right-DUR69elJ.js} +1 -1
  56. package/dashboard/dist/assets/{check-U-wNzPvd.js → check-9_1xp5h5.js} +1 -1
  57. package/dashboard/dist/assets/{composite-BR91CxNl.js → composite-CEzovOpb.js} +1 -1
  58. package/dashboard/dist/assets/{copy-Bw3hXBCu.js → copy-DEoLQqJE.js} +1 -1
  59. package/dashboard/dist/assets/{download-WOqcQzvK.js → download-BNjfcJ1g.js} +1 -1
  60. package/dashboard/dist/assets/{external-link-BZtf6WfS.js → external-link-fV_WJ5f_.js} +1 -1
  61. package/dashboard/dist/assets/{flame-B5kp2VI9.js → flame-DqpuFJ14.js} +1 -1
  62. package/dashboard/dist/assets/{format-tokens-BOCnuMuJ.js → format-tokens-CatddjmK.js} +1 -1
  63. package/dashboard/dist/assets/{icons-CA3FiIa_.js → icons-CBT1KI7V.js} +1 -1
  64. package/dashboard/dist/assets/{index-DjWC63Bs.js → index-BK3SzL7v.js} +1 -1
  65. package/dashboard/dist/assets/{index-DQaanK52.js → index-_2LEUZTl.js} +1 -1
  66. package/dashboard/dist/assets/{index-mEG0HZ-a.js → index-tK7s2SeO.js} +1 -1
  67. package/dashboard/dist/assets/{info-Djer9i3-.js → info-DBotcfT9.js} +1 -1
  68. package/dashboard/dist/assets/{limits-providers-F-RnNFAb.js → limits-providers-CjMU709b.js} +1 -1
  69. package/dashboard/dist/assets/{link-2-DV4ocD0l.js → link-2-Wc54x2fh.js} +1 -1
  70. package/dashboard/dist/assets/{loader-circle-DJK72c-o.js → loader-circle-nRhGIddE.js} +1 -1
  71. package/dashboard/dist/assets/{maximize-2-BwBA_lMm.js → maximize-2-Dw3_GuKW.js} +1 -1
  72. package/dashboard/dist/assets/{modifiers.esm-B3sl4qyZ.js → modifiers.esm-rTZ5BUxp.js} +1 -1
  73. package/dashboard/dist/assets/module-xpSFyNBK.js +3 -0
  74. package/dashboard/dist/assets/{monitor-smartphone-BUTpM4ng.js → monitor-smartphone-CNVAqV_N.js} +1 -1
  75. package/dashboard/dist/assets/{plus-BA9RP5L6.js → plus-B5oPOXhp.js} +1 -1
  76. package/dashboard/dist/assets/{provider-display-DqsJzGSr.js → provider-display-DhAUWfC6.js} +2 -2
  77. package/dashboard/dist/assets/{react-BYw5AVSz.js → react-BKACWrsB.js} +1 -1
  78. package/dashboard/dist/assets/{search-BzcnFWVD.js → search-DgddmV09.js} +1 -1
  79. package/dashboard/dist/assets/{skills-api-vAME1P7G.js → skills-api-BT1uiqeq.js} +1 -1
  80. package/dashboard/dist/assets/{sortable.esm-BpEiRqqI.js → sortable.esm-UCoNfZ6z.js} +1 -1
  81. package/dashboard/dist/assets/{store-GeT5fhRX.js → store-ZswY8Voz.js} +1 -1
  82. package/dashboard/dist/assets/{terminal-CZCMYQ8K.js → terminal-BtQyotJS.js} +1 -1
  83. package/dashboard/dist/assets/{trash-2-DzlWEwKO.js → trash-2-Dc_NLbWs.js} +1 -1
  84. package/dashboard/dist/assets/{use-community-stats-DOvJfj-i.js → use-community-stats-yOKag_ou.js} +1 -1
  85. package/dashboard/dist/assets/use-latest-request-guard-BEiszvlI.js +1 -0
  86. package/dashboard/dist/assets/{use-limits-display-prefs-BmLs4HyN.js → use-limits-display-prefs-C-l_Mxvy.js} +1 -1
  87. package/dashboard/dist/assets/{use-ordered-list-BgozDSSV.js → use-ordered-list-CKjRaO6K.js} +1 -1
  88. package/dashboard/dist/assets/use-reduced-motion-BlOkflqU.js +1 -0
  89. package/dashboard/dist/assets/{use-session-efficiency-pref-HP1ckVkS.js → use-session-efficiency-pref-DPI_--W2.js} +1 -1
  90. package/dashboard/dist/assets/{use-transform-B4VSogOx.js → use-transform-BQUem5OA.js} +1 -1
  91. package/dashboard/dist/assets/use-usage-limits-lvkMBnNR.js +1 -0
  92. package/dashboard/dist/assets/{useCurrency-CSuzCBAL.js → useCurrency-C5xhS1QV.js} +1 -1
  93. package/dashboard/dist/assets/{useTokenFormat-ClnL9OFe.js → useTokenFormat-FBMikfEq.js} +1 -1
  94. package/dashboard/dist/assets/{zap-C_T9CCiS.js → zap-BsCNYelz.js} +1 -1
  95. package/dashboard/dist/brand-logos/lmstudio.svg +12 -0
  96. package/dashboard/dist/brand-logos/unsloth.svg +1 -0
  97. package/dashboard/dist/index.html +16 -12
  98. package/dashboard/dist/ip-check.html +2 -2
  99. package/dashboard/dist/leaderboard.html +2 -2
  100. package/dashboard/dist/llms.txt +4 -2
  101. package/dashboard/dist/share.html +2 -2
  102. package/package.json +2 -2
  103. package/src/commands/device-login.js +5 -10
  104. package/src/commands/init.js +28 -1
  105. package/src/commands/serve.js +6 -0
  106. package/src/commands/status.js +75 -8
  107. package/src/commands/sync.js +189 -18
  108. package/src/lib/cloud-account.js +102 -29
  109. package/src/lib/fs.js +30 -0
  110. package/src/lib/local-api.js +99 -13
  111. package/src/lib/pricing/index.js +5 -0
  112. package/src/lib/pricing/matcher.js +15 -0
  113. package/src/lib/pricing/seed-snapshot.json +1 -1
  114. package/src/lib/rollout.js +1418 -28
  115. package/src/lib/usage-limits.js +507 -55
  116. package/src/lib/zcode-limits.js +65 -8
  117. package/dashboard/dist/assets/CommunityStatsModal-DRDpZycV.js +0 -1
  118. package/dashboard/dist/assets/DashboardPage-B5XMQoxy.js +0 -1
  119. package/dashboard/dist/assets/DialogDescription-DiVbwvTR.js +0 -1
  120. package/dashboard/dist/assets/SkillsPage-B1AcLX41.js +0 -1
  121. package/dashboard/dist/assets/TrendMonitor-rabtcE5w.js +0 -19
  122. package/dashboard/dist/assets/__vite-browser-external-C8KwSyTz.js +0 -45
  123. package/dashboard/dist/assets/chevron-right-Du-QVNj4.js +0 -1
  124. package/dashboard/dist/assets/module-D2D5r9gC.js +0 -3
  125. package/dashboard/dist/assets/use-reduced-motion-Bc3RFJdg.js +0 -1
  126. package/dashboard/dist/assets/use-usage-limits-DNNhD2H6.js +0 -1
@@ -73,8 +73,27 @@ function decodeJwtExpMs(token) {
73
73
  // The popover polls frequently, so caching avoids hammering /api/auth/refresh.
74
74
  let tokenCache = { cacheKey: null, accessToken: null, expMs: 0 };
75
75
 
76
+ // In-flight refresh de-duplication ("single flight"), keyed the same way as
77
+ // `tokenCache`. A full popover refresh fires six account reads concurrently; on
78
+ // a cold cache every one of them used to POST /api/auth/refresh with the SAME
79
+ // refresh token. When the backend rotates refresh tokens, the losers of that
80
+ // race present an already-consumed token and fail — which is exactly the
81
+ // intermittent "Activity silently dropped to this-machine data" symptom.
82
+ const mintInflight = new Map();
83
+
76
84
  function __resetCloudAccountCacheForTests() {
77
85
  tokenCache = { cacheKey: null, accessToken: null, expMs: 0 };
86
+ mintInflight.clear();
87
+ }
88
+
89
+ // Failure classes surfaced to local-api so it can tell an intentional local
90
+ // view ("signed out", "cloud sync off") apart from a temporary cloud outage.
91
+ class AccountAuthError extends Error {
92
+ constructor(code, message) {
93
+ super(message || code);
94
+ this.name = "AccountAuthError";
95
+ this.code = code;
96
+ }
78
97
  }
79
98
 
80
99
  function csrfTokenFromRefreshPayload(data) {
@@ -87,30 +106,11 @@ function csrfTokenFromRefreshPayload(data) {
87
106
  }
88
107
 
89
108
  /**
90
- * Mint (or reuse a cached) InsForge access token from a refresh token.
91
- * @returns {Promise<{accessToken: string, refreshToken: string|null, csrfToken: string|null}|null>}
92
- * null when no refresh token is available or the refresh failed.
109
+ * Perform the actual refresh POST. Throws a classified `AccountAuthError` on
110
+ * every failure so callers can distinguish a transient outage (timeout,
111
+ * offline, rotated-token rejection) from an intentionally local view.
93
112
  */
94
- async function mintAccessToken({
95
- baseUrl,
96
- anonKey,
97
- refreshToken,
98
- fetchImpl = fetch,
99
- now = Date.now,
100
- skewMs = 60_000,
101
- timeoutMs,
102
- } = {}) {
103
- if (!refreshToken) return null;
104
- const root = String(baseUrl || DEFAULT_BASE_URL).replace(/\/$/, "");
105
- const cacheKey = `${root}\0${refreshToken}`;
106
- if (
107
- tokenCache.cacheKey === cacheKey &&
108
- tokenCache.accessToken &&
109
- tokenCache.expMs - skewMs > now()
110
- ) {
111
- return { accessToken: tokenCache.accessToken, refreshToken: null, csrfToken: null };
112
- }
113
-
113
+ async function performMint({ root, cacheKey, anonKey, refreshToken, fetchImpl, now, timeoutMs }) {
114
114
  const headers = { "Content-Type": "application/json", Accept: "application/json" };
115
115
  if (anonKey) headers.apikey = anonKey;
116
116
 
@@ -132,24 +132,37 @@ async function mintAccessToken({
132
132
  body: JSON.stringify({ refresh_token: refreshToken }),
133
133
  signal,
134
134
  });
135
- } catch {
136
- return null;
135
+ } catch (e) {
136
+ const aborted = e && (e.name === "AbortError" || e.name === "TimeoutError");
137
+ throw new AccountAuthError(
138
+ aborted ? "auth_timeout" : "auth_network",
139
+ e?.message || "token refresh failed",
140
+ );
137
141
  } finally {
138
142
  if (timeoutId) {
139
143
  clearTimeout(timeoutId);
140
144
  }
141
145
  }
142
- if (!res || !res.ok) return null;
146
+ if (!res || !res.ok) {
147
+ const err = new AccountAuthError(
148
+ "auth_rejected",
149
+ `token refresh failed with HTTP ${res ? res.status : "?"}`,
150
+ );
151
+ err.status = res ? res.status : 0;
152
+ throw err;
153
+ }
143
154
 
144
155
  let data = null;
145
156
  try {
146
157
  data = await res.json();
147
- } catch {
148
- return null;
158
+ } catch (e) {
159
+ throw new AccountAuthError("auth_invalid", e?.message || "token refresh returned invalid JSON");
149
160
  }
150
161
 
151
162
  const accessToken = accessTokenFromRefreshPayload(data);
152
- if (!accessToken) return null;
163
+ if (!accessToken) {
164
+ throw new AccountAuthError("auth_invalid", "token refresh returned no access token");
165
+ }
153
166
 
154
167
  const expMs = decodeJwtExpMs(accessToken) || now() + 10 * 60_000;
155
168
  tokenCache = { cacheKey, accessToken, expMs };
@@ -166,6 +179,60 @@ async function mintAccessToken({
166
179
  };
167
180
  }
168
181
 
182
+ /**
183
+ * Mint (or reuse a cached) InsForge access token from a refresh token.
184
+ *
185
+ * Concurrent callers with the same (baseUrl, refreshToken) share ONE in-flight
186
+ * refresh; see `mintInflight`.
187
+ *
188
+ * @param {boolean} [throwOnFailure] when true, reject with a classified
189
+ * `AccountAuthError` instead of resolving to `null`.
190
+ * @returns {Promise<{accessToken: string, refreshToken: string|null, csrfToken: string|null}|null>}
191
+ * null when no refresh token is available or the refresh failed.
192
+ */
193
+ async function mintAccessToken({
194
+ baseUrl,
195
+ anonKey,
196
+ refreshToken,
197
+ fetchImpl = fetch,
198
+ now = Date.now,
199
+ skewMs = 60_000,
200
+ timeoutMs,
201
+ throwOnFailure = false,
202
+ } = {}) {
203
+ if (!refreshToken) {
204
+ if (throwOnFailure) throw new AccountAuthError("auth_missing_refresh_token", "not signed in");
205
+ return null;
206
+ }
207
+ const root = String(baseUrl || DEFAULT_BASE_URL).replace(/\/$/, "");
208
+ const cacheKey = `${root}\0${refreshToken}`;
209
+ if (
210
+ tokenCache.cacheKey === cacheKey &&
211
+ tokenCache.accessToken &&
212
+ tokenCache.expMs - skewMs > now()
213
+ ) {
214
+ return { accessToken: tokenCache.accessToken, refreshToken: null, csrfToken: null };
215
+ }
216
+
217
+ let inflight = mintInflight.get(cacheKey);
218
+ if (!inflight) {
219
+ inflight = performMint({ root, cacheKey, anonKey, refreshToken, fetchImpl, now, timeoutMs })
220
+ .finally(() => {
221
+ // Only clear our own entry: a later caller may already have started a
222
+ // fresh mint after this one settled.
223
+ if (mintInflight.get(cacheKey) === inflight) mintInflight.delete(cacheKey);
224
+ });
225
+ mintInflight.set(cacheKey, inflight);
226
+ }
227
+
228
+ try {
229
+ return await inflight;
230
+ } catch (e) {
231
+ if (throwOnFailure) throw e;
232
+ return null;
233
+ }
234
+ }
235
+
169
236
  /**
170
237
  * GET a `tokentracker-account-*` edge function, forwarding the popover's query
171
238
  * params (minus `account`/`scope`, which are local-only routing knobs).
@@ -237,6 +304,7 @@ async function fetchAccountUsage({
237
304
  } = {}) {
238
305
  const slug = accountSlugFor(usageSlug);
239
306
  if (!slug) return null;
307
+ if (!refreshToken) return null;
240
308
 
241
309
  const startTime = now();
242
310
  const getRemainingTimeout = () => {
@@ -246,6 +314,9 @@ async function fetchAccountUsage({
246
314
  return remaining > 0 ? remaining : 1;
247
315
  };
248
316
 
317
+ // `throwOnFailure` keeps "not signed in" (null, handled above) distinct from
318
+ // "signed in but the refresh call failed" (throws) so the caller does not
319
+ // silently downgrade an account view into a this-machine view.
249
320
  const minted = await mintAccessToken({
250
321
  baseUrl,
251
322
  anonKey,
@@ -253,6 +324,7 @@ async function fetchAccountUsage({
253
324
  fetchImpl,
254
325
  now,
255
326
  timeoutMs: getRemainingTimeout(),
327
+ throwOnFailure: true,
256
328
  });
257
329
  if (!minted) return null;
258
330
 
@@ -269,6 +341,7 @@ async function fetchAccountUsage({
269
341
  }
270
342
 
271
343
  module.exports = {
344
+ AccountAuthError,
272
345
  USAGE_TO_ACCOUNT_SLUG,
273
346
  accountSlugFor,
274
347
  accessTokenFromRefreshPayload,
package/src/lib/fs.js CHANGED
@@ -413,6 +413,35 @@ async function openLock(
413
413
  }
414
414
  }
415
415
 
416
+ async function updateJsonLocked(
417
+ filePath,
418
+ update,
419
+ { timeoutMs = 30_000, retryMs = 10 } = {},
420
+ ) {
421
+ const lockPath = `${filePath}.lock`;
422
+ const deadline = Date.now() + timeoutMs;
423
+ let lock = null;
424
+ while (!lock) {
425
+ lock = await openLock(lockPath, { quietIfLocked: true });
426
+ if (lock) break;
427
+ if (Date.now() >= deadline) {
428
+ throw new Error(`Timed out waiting to update JSON file: ${filePath}`);
429
+ }
430
+ await new Promise((resolve) => setTimeout(resolve, retryMs));
431
+ }
432
+
433
+ try {
434
+ const current = (await readJson(filePath)) || {};
435
+ const next = await update(current);
436
+ if (next == null) return current;
437
+ await writeJson(filePath, next);
438
+ await chmod600IfPossible(filePath);
439
+ return next;
440
+ } finally {
441
+ await lock.release();
442
+ }
443
+ }
444
+
416
445
  module.exports = {
417
446
  ensureDir,
418
447
  writeFileAtomic,
@@ -422,4 +451,5 @@ module.exports = {
422
451
  chmod600IfPossible,
423
452
  openLock,
424
453
  inspectLock,
454
+ updateJsonLocked,
425
455
  };
@@ -14,6 +14,11 @@ const { accountSlugFor, fetchAccountUsage, mintAccessToken } = require("./cloud-
14
14
  const { getOrCreateMachineId, computeStableMachineId } = require("./machine-id");
15
15
 
16
16
  const SYNC_TIMEOUT_MS = 120_000;
17
+ // A failed account-view request should not stall every dashboard refresh
18
+ // while an expired session or unreachable backend keeps timing out. The first
19
+ // request still falls back normally; subsequent fan-out requests reuse that
20
+ // decision for a short cooldown and return local data immediately.
21
+ const ACCOUNT_VIEW_FAILURE_BACKOFF_MS = 2 * 60_000;
17
22
  const TRACKER_BIN = path.resolve(__dirname, "../../bin/tracker.js");
18
23
  const MAX_DEVICE_NAME_LENGTH = 128;
19
24
 
@@ -219,8 +224,12 @@ function normalizeQueueRow(row) {
219
224
  // non-zero billable usage (GitHub issue #106). Treat billing and usage as
220
225
  // orthogonal: bump billable up to total_tokens at read time so historical
221
226
  // queue.jsonl entries render correctly without requiring a file rewrite.
227
+ // Antigravity had the same symptom from e69e2746a (billable deleted during
228
+ // the O(N) refactoring, leaving 167 local rows with billable=0 until the
229
+ // parser fix). Apply the same read-time healing so upgraded installs show
230
+ // correct heatmap / rolling numbers without a queue rewrite.
222
231
  const sourceName = String(normalized.source || "").toLowerCase();
223
- if (sourceName === "cursor") {
232
+ if (sourceName === "cursor" || sourceName === "antigravity") {
224
233
  const totalTokens = Number(normalized.total_tokens || 0);
225
234
  const billable = Number(normalized.billable_total_tokens || 0);
226
235
  if (totalTokens > 0 && billable < totalTokens) {
@@ -1135,6 +1144,30 @@ function createLocalApiHandler({ queuePath }) {
1135
1144
  const cookiePath = path.join(trackerDataDir, "relay-cookies.json");
1136
1145
  const localSyncDeviceTokenCache = new Map();
1137
1146
  const localSyncDeviceTokenInflight = new Map();
1147
+ // A dashboard refresh fans out to several account-view endpoints. Keep the
1148
+ // circuit-breaker state per session instead of one global slot (a successful
1149
+ // request for one account must not clear another account's failure), and
1150
+ // suppress the duplicate fan-out while the first cloud probe is pending.
1151
+ const accountViewFailures = new Map();
1152
+ const accountViewInFlight = new Set();
1153
+
1154
+ function accountViewFailureUntil(failureKey) {
1155
+ const until = accountViewFailures.get(failureKey) || 0;
1156
+ if (until > Date.now()) return until;
1157
+ if (until) accountViewFailures.delete(failureKey);
1158
+ return 0;
1159
+ }
1160
+
1161
+ function rememberAccountViewFailure(failureKey) {
1162
+ accountViewFailures.set(failureKey, Date.now() + ACCOUNT_VIEW_FAILURE_BACKOFF_MS);
1163
+ // Refresh-token rotation or account switching can produce many keys over a
1164
+ // long-lived tray session. Bound the map without affecting the active key.
1165
+ while (accountViewFailures.size > 16) {
1166
+ const oldest = accountViewFailures.keys().next().value;
1167
+ if (!oldest || oldest === failureKey) break;
1168
+ accountViewFailures.delete(oldest);
1169
+ }
1170
+ }
1138
1171
 
1139
1172
  // Load persisted cookies on startup
1140
1173
  try {
@@ -1399,15 +1432,52 @@ function createLocalApiHandler({ queuePath }) {
1399
1432
  }
1400
1433
  }
1401
1434
 
1402
- // Returns "served" when the cross-device aggregate was written to `res`, or
1403
- // "fallthrough" when the caller should serve the local single-machine data.
1404
- // Any failure (not signed in, cloud sync off, network/auth error) falls
1405
- // through so the popover always renders something.
1435
+ // Why a *classified* fallback: the native popover keeps the last successful
1436
+ // account (cross-device) snapshot on screen when a cloud read fails
1437
+ // transiently, but must switch to this-machine data the moment the user signs
1438
+ // out or turns cloud sync off. Both used to look identical from the outside
1439
+ // (HTTP 200 + local payload + `X-TokenTracker-Account-View: 0`), so a single
1440
+ // timed-out read silently shrank Activity to one device until the next
1441
+ // manual sync. See ACCOUNT_FALLBACK_* below for the vocabulary.
1442
+ const ACCOUNT_FALLBACK_CLOUD_SYNC_OFF = "cloud-sync-off";
1443
+ const ACCOUNT_FALLBACK_SIGNED_OUT = "signed-out";
1444
+
1445
+ // Every transient reason is prefixed "transient-"; clients only need the
1446
+ // prefix, so new reasons can be added without a client change.
1447
+ function classifyAccountFallback(err) {
1448
+ if (!err) return "transient-error";
1449
+ if (err.name === "AbortError" || err.name === "TimeoutError" || err.code === "auth_timeout") {
1450
+ return "transient-timeout";
1451
+ }
1452
+ if (err.code === "auth_rejected" || err.code === "auth_invalid" || err.status === 401 || err.status === 403) {
1453
+ return "transient-auth";
1454
+ }
1455
+ if (err.code === "auth_network") return "transient-network";
1456
+ if (Number.isFinite(err.status) && err.status > 0) return "transient-upstream";
1457
+ return "transient-network";
1458
+ }
1459
+
1460
+ // Returns "served" when the cross-device aggregate was written to `res`,
1461
+ // otherwise a fallback reason (see above) telling the caller — and, through
1462
+ // the `X-TokenTracker-Account-Fallback` header, the popover — why the local
1463
+ // single-machine data is being served instead.
1406
1464
  async function tryServeAccountView(usageSlug, url, res) {
1407
- if (!getCloudSyncPref()) return "fallthrough";
1465
+ if (!getCloudSyncPref()) return ACCOUNT_FALLBACK_CLOUD_SYNC_OFF;
1408
1466
  const refreshToken = getRefreshTokenForCloud();
1409
- if (!refreshToken) return "fallthrough";
1467
+ if (!refreshToken) return ACCOUNT_FALLBACK_SIGNED_OUT;
1410
1468
  const runtime = resolveRuntimeConfig();
1469
+ const failureKey = `${runtime.baseUrl}\0${refreshToken}`;
1470
+ const requestKey = `${failureKey}\0${usageSlug}\0${url.searchParams.toString()}`;
1471
+ if (accountViewFailureUntil(failureKey) > Date.now()) {
1472
+ return "transient-backoff";
1473
+ }
1474
+ // Six native dashboard requests normally arrive together. If the cloud is
1475
+ // already being probed for this session, let the remaining requests use
1476
+ // their local fallback instead of opening more timeout-bound sockets.
1477
+ if (accountViewInFlight.has(requestKey)) {
1478
+ return "transient-inflight";
1479
+ }
1480
+ accountViewInFlight.add(requestKey);
1411
1481
  const envTimeout = process.env.TOKENTRACKER_HTTP_TIMEOUT_MS
1412
1482
  ? parseInt(process.env.TOKENTRACKER_HTTP_TIMEOUT_MS, 10)
1413
1483
  : 0;
@@ -1421,9 +1491,16 @@ function createLocalApiHandler({ queuePath }) {
1421
1491
  refreshToken,
1422
1492
  timeoutMs,
1423
1493
  });
1424
- if (!out || out.data == null) return "fallthrough";
1494
+ // `null` here means the slug has no cloud equivalent, or the session
1495
+ // vanished mid-request — a routing/session fact, not an outage.
1496
+ if (!out) return ACCOUNT_FALLBACK_SIGNED_OUT;
1497
+ if (out.data == null) {
1498
+ rememberAccountViewFailure(failureKey);
1499
+ return "transient-upstream";
1500
+ }
1425
1501
  if (out.rotatedRefreshToken) setRelayRefreshToken(out.rotatedRefreshToken);
1426
1502
  if (out.rotatedCsrfToken) setRelayCsrfToken(out.rotatedCsrfToken);
1503
+ accountViewFailures.delete(failureKey);
1427
1504
  res.writeHead(200, {
1428
1505
  "Content-Type": "application/json",
1429
1506
  "Cache-Control": "no-store",
@@ -1433,11 +1510,17 @@ function createLocalApiHandler({ queuePath }) {
1433
1510
  return "served";
1434
1511
  } catch (e) {
1435
1512
  // Signed in + cloud sync on, but the cloud read failed (offline, token
1436
- // rejected, edge error, or timeout). Fall back to local data rather than erroring.
1513
+ // rejected, edge error, or timeout). Serve local data rather than
1514
+ // erroring, but say so: this is a *temporary* downgrade and the client
1515
+ // must not treat it as the user's real data scope.
1516
+ const reason = classifyAccountFallback(e);
1437
1517
  if (resolveRuntimeConfig().debug) {
1438
- console.warn(`[LocalAPI] account view fallback for ${usageSlug}:`, e?.message || e);
1518
+ console.warn(`[LocalAPI] account view fallback (${reason}) for ${usageSlug}:`, e?.message || e);
1439
1519
  }
1440
- return "fallthrough";
1520
+ rememberAccountViewFailure(failureKey);
1521
+ return reason;
1522
+ } finally {
1523
+ accountViewInFlight.delete(requestKey);
1441
1524
  }
1442
1525
  }
1443
1526
 
@@ -2006,14 +2089,17 @@ function createLocalApiHandler({ queuePath }) {
2006
2089
  // --- account (cross-device) view proxy for the native popover ---
2007
2090
  // When ?account=1 and the user is signed in with cloud sync on, serve the
2008
2091
  // same cross-device aggregate the dashboard shows; otherwise tag the
2009
- // response (X-TokenTracker-Account-View: 0) so the popover knows it got
2010
- // local single-machine data, and fall through to the local handler below.
2092
+ // response (X-TokenTracker-Account-View: 0 plus an
2093
+ // X-TokenTracker-Account-Fallback reason) so the popover knows it got local
2094
+ // single-machine data and whether that is permanent (signed out / cloud
2095
+ // sync off) or transient, and fall through to the local handler below.
2011
2096
  if (url.searchParams.get("account") === "1") {
2012
2097
  const usageSlug = p.startsWith("/functions/") ? p.slice("/functions/".length) : "";
2013
2098
  if (accountSlugFor(usageSlug)) {
2014
2099
  const result = await tryServeAccountView(usageSlug, url, res);
2015
2100
  if (result === "served") return true;
2016
2101
  res.setHeader("X-TokenTracker-Account-View", "0");
2102
+ res.setHeader("X-TokenTracker-Account-Fallback", result);
2017
2103
  }
2018
2104
  }
2019
2105
 
@@ -21,6 +21,10 @@ const PI_SUBSCRIPTION_SOURCES = new Set([
21
21
  "prime-agent-github-copilot",
22
22
  "prime-agent-copilot",
23
23
  ]);
24
+ // LM Studio's server logs describe inference served by the local developer
25
+ // server (including LM Link). Secure Cloud usage has a separate billing path
26
+ // and is not present in these logs.
27
+ const LOCAL_INFERENCE_SOURCES = new Set(["lmstudio"]);
24
28
  const SOURCES_WITH_AUTHORITATIVE_COST = new Set(["grok"]);
25
29
  const SEED_SNAPSHOT_PATH = path.resolve(__dirname, "seed-snapshot.json");
26
30
  const DEEPSEEK_TIME_PRICED_MODELS = [
@@ -169,6 +173,7 @@ function getRowPricing(row) {
169
173
  // computeRowCost in src/lib/local-api.js. Moved here so vite mock + local
170
174
  // server share one source of truth.
171
175
  function computeRowCost(row) {
176
+ if (LOCAL_INFERENCE_SOURCES.has(String(row?.source || "").toLowerCase())) return 0;
172
177
  // Pi can route a turn through a subscription-backed Copilot provider. Pi's
173
178
  // usage record reports a zero marginal cost for those turns; do not
174
179
  // reinterpret the Claude model name as an Anthropic API bill.
@@ -164,6 +164,20 @@ function normalizeWorkbuddyModel(model) {
164
164
  return model;
165
165
  }
166
166
 
167
+ // Unsloth Studio can mix local engines and paid API providers in one database.
168
+ // The parser qualifies metered models with their provider and marks local,
169
+ // subscription-backed, or ambiguous custom routes as unpriced. Returning a
170
+ // sentinel here prevents the generic reverse-substring matcher from mistaking
171
+ // a locally hosted model name for the similarly named cloud SKU.
172
+ function normalizeUnslothModel(model) {
173
+ if (typeof model !== "string") return model;
174
+ const lower = model.trim().toLowerCase();
175
+ if (lower.startsWith("local/") || lower.startsWith("unpriced/")) {
176
+ return "__tokentracker_unpriced_unsloth_model__";
177
+ }
178
+ return model;
179
+ }
180
+
167
181
  // Per-source model-name normalizers, applied at pricing-lookup time only (the
168
182
  // raw model name is preserved for storage/display). Add a source here when its
169
183
  // model strings don't match the LiteLLM/curated keys verbatim.
@@ -174,6 +188,7 @@ const SOURCE_MODEL_NORMALIZERS = {
174
188
  "pi-anthropic": normalizeClaudeModel,
175
189
  "prime-agent-anthropic": normalizeClaudeModel,
176
190
  zed: normalizeZedModel,
191
+ unsloth: normalizeUnslothModel,
177
192
  workbuddy: normalizeWorkbuddyModel,
178
193
  };
179
194