@ipv9/tokentracker-cli 0.39.44 → 0.39.45

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 (35) hide show
  1. package/dashboard/dist/assets/{Card-C_H8B1rI.js → Card-2-TQg7P7.js} +1 -1
  2. package/dashboard/dist/assets/DashboardPage-yb9ss9uE.js +60 -0
  3. package/dashboard/dist/assets/{FadeIn-BE8I9B4-.js → FadeIn-BOc6XtOK.js} +1 -1
  4. package/dashboard/dist/assets/{IpCheckPage-DQ9gR343.js → IpCheckPage-C2tIb68P.js} +1 -1
  5. package/dashboard/dist/assets/{LimitsPage-Balfhw3-.js → LimitsPage-BsuLQ9co.js} +1 -1
  6. package/dashboard/dist/assets/{LocalOnlyNotice-hxi161Wl.js → LocalOnlyNotice-C8R9KLef.js} +1 -1
  7. package/dashboard/dist/assets/{PopoverPopup-B4zbh-jA.js → PopoverPopup-BpfseoI7.js} +1 -1
  8. package/dashboard/dist/assets/{Select-DLYT4NCO.js → Select-Ctk8zze5.js} +1 -1
  9. package/dashboard/dist/assets/{SelectItemText-DUyZrDnS.js → SelectItemText-BKZlFyFs.js} +1 -1
  10. package/dashboard/dist/assets/{SettingsPage-BySJoulk.js → SettingsPage-CQXM8qGU.js} +1 -1
  11. package/dashboard/dist/assets/{SkillsPage-DpiMLTPo.js → SkillsPage-4EMm0eBX.js} +1 -1
  12. package/dashboard/dist/assets/{WidgetsPage-C6je8upG.js → WidgetsPage-C2sdX5g6.js} +1 -1
  13. package/dashboard/dist/assets/{WrappedPage-ByarX-vY.js → WrappedPage-CLiuEcQZ.js} +1 -1
  14. package/dashboard/dist/assets/{arrow-up-right-C-WAJGZ8.js → arrow-up-right-BDkp93DX.js} +1 -1
  15. package/dashboard/dist/assets/{download-BhOg_qb4.js → download-DZ6SoCSn.js} +1 -1
  16. package/dashboard/dist/assets/{format-Co2okzG-.js → format-CaW9kvsA.js} +1 -1
  17. package/dashboard/dist/assets/limitDisplay-DNU_w4O7.js +1 -0
  18. package/dashboard/dist/assets/{main-q9UoIq7C.js → main-DgyymGht.js} +7 -3
  19. package/dashboard/dist/assets/{mock-data-0XiGBeUV.js → mock-data-D6C7Fba3.js} +1 -1
  20. package/dashboard/dist/assets/{use-limits-display-prefs-DmEIJPtg.js → use-limits-display-prefs-B7cHBa7Y.js} +1 -1
  21. package/dashboard/dist/assets/{use-native-settings-QLM3Sd2C.js → use-native-settings-BKAzGuxw.js} +1 -1
  22. package/dashboard/dist/assets/{useCurrency-Dtize6Tx.js → useCurrency-BVr6Ajuu.js} +1 -1
  23. package/dashboard/dist/index.html +1 -1
  24. package/package.json +1 -1
  25. package/src/commands/doctor.js +6 -0
  26. package/src/lib/doctor.js +164 -10
  27. package/src/lib/local-api.js +79 -20
  28. package/src/lib/pricing/seed-snapshot.json +1 -1
  29. package/src/lib/process-list.js +91 -0
  30. package/src/lib/rollout.js +5 -2
  31. package/src/lib/single-flight.js +59 -0
  32. package/src/lib/transcript-suppression.js +133 -0
  33. package/src/lib/usage-limits.js +79 -19
  34. package/dashboard/dist/assets/DashboardPage-DOa3N76u.js +0 -60
  35. package/dashboard/dist/assets/limitDisplay-CPMQA7lm.js +0 -1
@@ -0,0 +1,91 @@
1
+ const cp = require("node:child_process");
2
+
3
+ // Shared process-listing primitives. Extracted from usage-limits.js so that a
4
+ // second caller (transcript-suppression.js) can reuse the parser instead of
5
+ // keeping a second copy of the same regex in the tree.
6
+ //
7
+ // PRIVACY: callers get raw command lines here, and a command line can contain a
8
+ // user's file paths. Nothing in this module writes, logs, caches, or returns a
9
+ // command line beyond the synchronous call — that constraint belongs to every
10
+ // caller, and CONTRIBUTING.md's rule ("never log, store, transmit, or print ...
11
+ // file paths from user code") is what it exists to satisfy.
12
+
13
+ const PS_BINARY = "/bin/ps";
14
+ // `-x` (own user, including processes with no controlling terminal) rather than
15
+ // `-ax` (every user on the box). The suppression check reports its findings over
16
+ // an unauthenticated loopback endpoint, and on a multi-user host `-a` would make
17
+ // that endpoint answer questions about other people's sessions. Scoping the scan
18
+ // itself is the narrow fix: a session TokenTracker could not have recorded
19
+ // anyway is one this user is not running.
20
+ //
21
+ // Verified on both supported platforms rather than assumed from documented
22
+ // semantics, because Linux `ps` is procps and parses dash-prefixed options as
23
+ // UNIX-style, where `-x` is not an option at all. It does accept this as the BSD
24
+ // `x`: on Debian 12 / procps-ng 4.0.2, `ps -x -o pid=,command=` exits 0 and
25
+ // lists one user, while `-ax` on the same box lists seven. macOS/BSD `ps` is the
26
+ // native case. Had procps rejected it, every Linux host would have fallen into
27
+ // `process_list_failed` — a permanent non-advisory warn, which would pin
28
+ // `degraded` for a whole platform.
29
+ //
30
+ // Frozen because two modules now share this array. Importing one constant stops
31
+ // the two scans from drifting apart editorially; freezing is what stops a caller
32
+ // pushing `-a` onto it at runtime.
33
+ const PS_ARGS = Object.freeze(["-x", "-o", "pid=,command="]);
34
+ const PS_TIMEOUT_MS = 4000;
35
+ const PS_MAX_BUFFER = 10 * 1024 * 1024;
36
+
37
+ function parseProcessLine(line) {
38
+ const match = String(line || "")
39
+ .trim()
40
+ .match(/^(\d+)\s+(.*)$/);
41
+ if (!match) return null;
42
+ return {
43
+ pid: Number(match[1]),
44
+ command: match[2],
45
+ };
46
+ }
47
+
48
+ // `/bin/ps` with these flags is a POSIX assumption. Windows has no equivalent
49
+ // at this path, and guessing at `tasklist` output would be an untested code
50
+ // path, so the honest answer there is "not supported" rather than "no problems
51
+ // found" — the caller must not turn this into a passing check.
52
+ function isProcessListSupported(platform = process.platform) {
53
+ return platform !== "win32";
54
+ }
55
+
56
+ // Returns { supported, ok, lines, reason }. `ok: false` never throws: a machine
57
+ // that refuses `ps` (sandbox, hardened runtime) is a normal condition here, and
58
+ // the caller reports it as "could not check" rather than as "nothing found".
59
+ function listProcessLines({ commandRunner, platform = process.platform } = {}) {
60
+ if (!isProcessListSupported(platform)) {
61
+ return { supported: false, ok: false, lines: [], reason: "unsupported_platform" };
62
+ }
63
+
64
+ const runner = typeof commandRunner === "function" ? commandRunner : cp.spawnSync;
65
+ let result;
66
+ try {
67
+ result = runner(PS_BINARY, PS_ARGS, {
68
+ encoding: "utf8",
69
+ maxBuffer: PS_MAX_BUFFER,
70
+ timeout: PS_TIMEOUT_MS,
71
+ });
72
+ } catch {
73
+ return { supported: true, ok: false, lines: [], reason: "process_list_failed" };
74
+ }
75
+
76
+ if (result?.error || result?.status !== 0) {
77
+ return { supported: true, ok: false, lines: [], reason: "process_list_failed" };
78
+ }
79
+
80
+ const stdout = typeof result?.stdout === "string" ? result.stdout : "";
81
+ return { supported: true, ok: true, lines: stdout.split("\n"), reason: null };
82
+ }
83
+
84
+ module.exports = {
85
+ PS_ARGS,
86
+ PS_BINARY,
87
+ PS_TIMEOUT_MS,
88
+ isProcessListSupported,
89
+ listProcessLines,
90
+ parseProcessLine,
91
+ };
@@ -3398,8 +3398,11 @@ async function parseHermesIncremental({ hermesPath, dbPath, cursors, queuePath,
3398
3398
  // Skip if delta is zero (session unchanged since last sync)
3399
3399
  if (dInput === 0 && dOutput === 0 && dCacheRead === 0 && dCacheWrite === 0 && dReasoning === 0) continue;
3400
3400
 
3401
- // Prefer ended_at for bucket placement; fall back to started_at
3402
- const epochSec = endedAt ?? startedAt;
3401
+ // A first observation has only the session start as a usable timestamp.
3402
+ // Attribute later active-session deltas to this sync so cross-day usage
3403
+ // does not keep growing the day on which the session originally started.
3404
+ // Once Hermes records completion, ended_at is authoritative for the final delta.
3405
+ const epochSec = endedAt ?? (prev ? Date.parse(updatedAt) / 1000 : startedAt);
3403
3406
  if (!epochSec || !Number.isFinite(epochSec)) continue;
3404
3407
  const tsIso = new Date(epochSec * 1000).toISOString();
3405
3408
  const bucketStart = toUtcHalfHourStart(tsIso);
@@ -0,0 +1,59 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Coalesce concurrent calls to one expensive async producer.
5
+ *
6
+ * The problem it solves is not "the same work runs twice" — it is that the work
7
+ * being duplicated fans out to every configured provider's private endpoint. Two
8
+ * dashboard tabs, a route mount, and a scheduled revalidation can all arrive
9
+ * inside the same second on a cold cache; without this, each one launches its own
10
+ * full sweep.
11
+ *
12
+ * `run(fn)` returns the in-flight promise when one exists, so every caller in a
13
+ * window shares a single execution and a single result object. The slot is
14
+ * released as soon as the work settles — success or failure — so the next call
15
+ * starts fresh work rather than replaying a stale outcome.
16
+ *
17
+ * Deliberately NOT keyed by argument: the single consumer here has one shared
18
+ * result for the whole process. A joining caller therefore receives the FIRST
19
+ * caller's work, arguments included. That is the intended trade for the fan-out,
20
+ * and it is safe only because every production call site passes the same inputs.
21
+ *
22
+ * @returns {(fn: () => Promise<any>) => Promise<any>}
23
+ */
24
+ function createSingleFlight() {
25
+ let inFlight = null;
26
+
27
+ return function run(fn) {
28
+ if (inFlight) return inFlight;
29
+
30
+ // `Promise.resolve().then(fn)` rather than `fn()` so a synchronous throw
31
+ // inside fn becomes a rejection on this path too, instead of escaping past
32
+ // the slot cleanup and wedging `inFlight` forever.
33
+ //
34
+ // Release carries no `inFlight === pending` identity guard, deliberately. A
35
+ // later run can only claim the slot after this one released it — while
36
+ // `pending` is unsettled every arrival joins instead of replacing it — so a
37
+ // stale callback clearing a successor's slot is unreachable, and a mutation
38
+ // test found the guard dead. Adding a way to clear the slot from outside
39
+ // would make it reachable again; there is none, and #141 requires that a
40
+ // cache reset specifically must not do it.
41
+ const pending = Promise.resolve()
42
+ .then(fn)
43
+ .finally(() => {
44
+ inFlight = null;
45
+ });
46
+
47
+ // Module state now holds a promise that real callers may all walk away from.
48
+ // Without a handler of its own, a rejection reaching only this reference is
49
+ // an unhandledRejection raised from state nobody is watching — a failure mode
50
+ // that did not exist while every caller owned its own promise. Callers still
51
+ // receive the rejection; this only marks the stored reference as handled.
52
+ pending.catch(() => {});
53
+
54
+ inFlight = pending;
55
+ return pending;
56
+ };
57
+ }
58
+
59
+ module.exports = { createSingleFlight };
@@ -0,0 +1,133 @@
1
+ const { listProcessLines, parseProcessLine } = require("./process-list");
2
+
3
+ // Detects Claude Code CLI processes started with `--no-session-persistence`.
4
+ //
5
+ // Why this exists: every Claude figure TokenTracker reports is parsed out of the
6
+ // session transcripts under `~/.claude/projects`. That flag tells the CLI to
7
+ // write no transcript at all, so those calls are unobservable here — they cost
8
+ // real tokens and contribute zero. Before this check, the only visible symptom
9
+ // was a source quietly reporting less than it should, which is indistinguishable
10
+ // from "the user worked less today".
11
+ //
12
+ // This is a *live* signal, not a historical one: it answers "is something
13
+ // running right now that I cannot see", which is why it needs no baseline, no
14
+ // threshold, and no per-source history.
15
+ //
16
+ // PRIVACY (CONTRIBUTING.md): the command line is read and discarded inside this
17
+ // call. What leaves this module is a count, a coarse reason, and the model ids —
18
+ // never a pid, an argv string, an environment value, or a file path. Model ids
19
+ // are already first-class tracked data (every queue row carries one), and they
20
+ // are the one field that tells a user *which* stream is unobservable, so they
21
+ // are included deliberately; they are also length- and charset-clamped below so
22
+ // that no arbitrary command-line text can ride out through this field.
23
+
24
+ const SUPPRESSION_FLAG = "--no-session-persistence";
25
+ const CLAUDE_BINARY_NAME = "claude";
26
+ const MODEL_FLAG = /(?:^|\s)--model[=\s]+([A-Za-z0-9._:-]{1,64})(?=\s|$)/;
27
+ const SUPPRESSION_FLAG_PATTERN = /(?:^|\s)--no-session-persistence(?:=|\s|$)/;
28
+ const DEFAULT_TTL_MS = 30_000;
29
+
30
+ let cache = null;
31
+
32
+ function resetTranscriptSuppressionCache() {
33
+ cache = null;
34
+ }
35
+
36
+ // argv[0] must be the `claude` binary. Matching on the flag alone would also
37
+ // match any shell, editor, or grep whose own command line happens to contain
38
+ // the string — including the process asking this question.
39
+ //
40
+ // The executable region is everything before the first `--flag`, not the first
41
+ // whitespace-delimited token: `ps` gives one flat string, so a binary installed
42
+ // under a path containing a space ("/Users/me/My Tools/claude") would otherwise
43
+ // be read as argv[0] = "/Users/me/My" and missed. A miss is the failure mode
44
+ // this whole check exists to prevent, so it is worth the wider window.
45
+ function isClaudeInvocation(command) {
46
+ const executableRegion = String(command || "").split(/\s+--/)[0] || "";
47
+ const base = executableRegion.trim().split(/[/\\]/).pop() || "";
48
+ return base.replace(/\.exe$/i, "").toLowerCase() === CLAUDE_BINARY_NAME;
49
+ }
50
+
51
+ function hasSuppressionFlag(command) {
52
+ return SUPPRESSION_FLAG_PATTERN.test(String(command || ""));
53
+ }
54
+
55
+ function extractModel(command) {
56
+ const match = String(command || "").match(MODEL_FLAG);
57
+ return match?.[1] || null;
58
+ }
59
+
60
+ // Pure: takes `ps` output lines, returns the deduplicated model ids of every
61
+ // suppressed Claude process. Deliberately returns models rather than processes —
62
+ // there is no pid in the return value for anything downstream to leak.
63
+ function findSuppressedModels(lines = []) {
64
+ const models = new Set();
65
+ let count = 0;
66
+
67
+ for (const line of lines) {
68
+ const parsed = parseProcessLine(line);
69
+ if (!parsed) continue;
70
+ if (!isClaudeInvocation(parsed.command)) continue;
71
+ if (!hasSuppressionFlag(parsed.command)) continue;
72
+ count += 1;
73
+ const model = extractModel(parsed.command);
74
+ if (model) models.add(model);
75
+ }
76
+
77
+ return { count, models: [...models].sort() };
78
+ }
79
+
80
+ // Returns { supported, checked, count, models, reason, checked_at }.
81
+ //
82
+ // `checked: false` is never reported as a clean result by callers. "I could not
83
+ // look" and "I looked and found nothing" are different answers, and collapsing
84
+ // them is how a monitor starts lying.
85
+ function detectTranscriptSuppression({
86
+ commandRunner,
87
+ platform = process.platform,
88
+ now = () => new Date(),
89
+ ttlMs = DEFAULT_TTL_MS,
90
+ useCache = true,
91
+ } = {}) {
92
+ const at = now();
93
+ const atMs = at.getTime();
94
+
95
+ if (useCache && cache && atMs - cache.atMs < ttlMs) {
96
+ return cache.value;
97
+ }
98
+
99
+ const listed = listProcessLines({ commandRunner, platform });
100
+
101
+ let value;
102
+ if (!listed.ok) {
103
+ value = {
104
+ supported: listed.supported,
105
+ checked: false,
106
+ count: 0,
107
+ models: [],
108
+ reason: listed.reason,
109
+ checked_at: at.toISOString(),
110
+ };
111
+ } else {
112
+ const { count, models } = findSuppressedModels(listed.lines);
113
+ value = {
114
+ supported: true,
115
+ checked: true,
116
+ count,
117
+ models,
118
+ reason: null,
119
+ checked_at: at.toISOString(),
120
+ };
121
+ }
122
+
123
+ if (useCache) cache = { atMs, value };
124
+ return value;
125
+ }
126
+
127
+ module.exports = {
128
+ DEFAULT_TTL_MS,
129
+ SUPPRESSION_FLAG,
130
+ detectTranscriptSuppression,
131
+ findSuppressedModels,
132
+ resetTranscriptSuppressionCache,
133
+ };
@@ -21,12 +21,21 @@ const {
21
21
  extractCursorSessionToken,
22
22
  fetchCursorUsageSummary,
23
23
  } = require("./cursor-config");
24
+ const { PS_ARGS, PS_BINARY, parseProcessLine } = require("./process-list");
25
+ const { createSingleFlight } = require("./single-flight");
24
26
 
25
27
  // 2-minute in-memory cache
26
28
  let cache = { data: null, fetchedAt: 0 };
29
+ // One slot per module instance, not a shared singleton: `delete require.cache[…]`
30
+ // is how the tests get a clean cache, and a shared instance would survive it.
31
+ const runUsageLimitsFetch = createSingleFlight();
27
32
  const CACHE_TTL_MS = 2 * 60 * 1000;
28
33
  const DEFAULT_PROVIDER_TIMEOUT_MS = 15_000;
29
34
  const ANTIGRAVITY_LIMITS_CACHE_FILE = "usage-limits-cache.json";
35
+ // Trust marker, not a user identity: only cache blocks written after the
36
+ // current-user process-scan fix may be served. Missing/unknown markers fail
37
+ // closed so a legacy multi-user cache cannot disclose another account.
38
+ const ANTIGRAVITY_CACHE_PROCESS_SCOPE = "current-user-v1";
30
39
  const ANTIGRAVITY_LIMITS_CACHE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
31
40
  const ANTIGRAVITY_LIMITS_CACHE_UNKNOWN_RESET_TTL_MS = 12 * 60 * 60 * 1000;
32
41
  const CLAUDE_LIMITS_CACHE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
@@ -986,11 +995,22 @@ async function fetchGeminiLimits({ home, env, fetchImpl = fetch, commandRunner }
986
995
 
987
996
  function runCommand(commandRunner, command, args, options = {}) {
988
997
  const runner = typeof commandRunner === "function" ? commandRunner : cp.spawnSync;
989
- return runner(command, args, {
998
+ const result = runner(command, args, {
990
999
  encoding: "utf8",
991
1000
  maxBuffer: 10 * 1024 * 1024,
992
1001
  ...options,
993
1002
  });
1003
+ // Every consumer reads spawnSync-style fields immediately. An async runner
1004
+ // is therefore a contract error, not supported concurrency. Attach a handler
1005
+ // immediately, then return a synchronous failure shape so the Promise cannot
1006
+ // escape as unhandled process state.
1007
+ if (result && typeof result.then === "function") {
1008
+ Promise.resolve(result).catch(() => {});
1009
+ const error = new TypeError("commandRunner must return synchronously");
1010
+ error.code = "COMMAND_RUNNER_ASYNC_UNSUPPORTED";
1011
+ return { status: 1, stdout: "", stderr: error.message, error };
1012
+ }
1013
+ return result;
994
1014
  }
995
1015
 
996
1016
  function whichBinary(binary, { commandRunner } = {}) {
@@ -1332,17 +1352,6 @@ function fetchKiroLimits({ commandRunner, now = new Date() } = {}) {
1332
1352
  }
1333
1353
  }
1334
1354
 
1335
- function parseProcessLine(line) {
1336
- const match = String(line || "")
1337
- .trim()
1338
- .match(/^(\d+)\s+(.*)$/);
1339
- if (!match) return null;
1340
- return {
1341
- pid: Number(match[1]),
1342
- command: match[2],
1343
- };
1344
- }
1345
-
1346
1355
  function isAntigravityCommandLine(command) {
1347
1356
  const lower = String(command || "").toLowerCase();
1348
1357
  return lower.includes("language_server")
@@ -1360,10 +1369,34 @@ function extractCommandFlag(command, flag) {
1360
1369
  return match?.[1] || null;
1361
1370
  }
1362
1371
 
1372
+ // Scans for the local Antigravity language server and reads its `--csrf_token`
1373
+ // out of the command line, so that the quota request below can authenticate to
1374
+ // it.
1375
+ //
1376
+ // The scan is scoped to the current user, and must stay that way. Under the
1377
+ // previous `-ax` this walked every account on the box and attached to whichever
1378
+ // Antigravity matched first. The token itself never reached an HTTP response —
1379
+ // `processInfo` is read field by field and never spread into a return value —
1380
+ // but what the token *fetches* does: `normalizeAntigravityResponse` returns
1381
+ // `account_email` and `account_plan`, `finalize` spreads them into the result,
1382
+ // and `getUsageLimits` serves that at `/functions/tokentracker-usage-limits`.
1383
+ // On a shared host this meant showing another person's email, plan and quota as
1384
+ // the local user's, and `writeAntigravityLimitsCache` persisted it to disk,
1385
+ // where the `!configured` branch would keep serving it after their process
1386
+ // exited.
1387
+ //
1388
+ // Sharing PS_ARGS with process-list.js is deliberate: two scans that must both
1389
+ // stay own-user should not be able to drift apart.
1363
1390
  function detectAntigravityProcess({ commandRunner } = {}) {
1364
- const result = runCommand(commandRunner, "/bin/ps", ["-ax", "-o", "pid=,command="], {
1391
+ const result = runCommand(commandRunner, PS_BINARY, PS_ARGS, {
1365
1392
  timeout: 4000,
1366
1393
  });
1394
+ if (result?.error?.code === "COMMAND_RUNNER_ASYNC_UNSUPPORTED") {
1395
+ return {
1396
+ configured: true,
1397
+ error: result.error?.message || "Antigravity process detection failed.",
1398
+ };
1399
+ }
1367
1400
  const lines = String(result?.stdout || "").split("\n");
1368
1401
 
1369
1402
  let sawProcess = false;
@@ -1435,6 +1468,7 @@ function hasAntigravityWindow(limits) {
1435
1468
  }
1436
1469
 
1437
1470
  function normalizeAntigravityCachedLimits(raw, { nowMs = Date.now() } = {}) {
1471
+ if (raw?.process_scope !== ANTIGRAVITY_CACHE_PROCESS_SCOPE) return null;
1438
1472
  const cachedAtMs = parseTimeMs(raw?.cached_at);
1439
1473
  if (!Number.isFinite(cachedAtMs)) return null;
1440
1474
  if (cachedAtMs > nowMs + 60_000) return null;
@@ -1463,6 +1497,7 @@ function writeAntigravityLimitsCache(limits, { home, nowMs = Date.now() } = {})
1463
1497
  if (!limits?.configured || limits.error || !hasAntigravityWindow(limits)) return;
1464
1498
  const payload = {
1465
1499
  antigravity: {
1500
+ process_scope: ANTIGRAVITY_CACHE_PROCESS_SCOPE,
1466
1501
  account_email: limits.account_email || null,
1467
1502
  account_plan: limits.account_plan || null,
1468
1503
  primary_window: limits.primary_window || null,
@@ -1899,7 +1934,20 @@ function withPlanLabel(obj, raw, brand) {
1899
1934
  return { ...obj, plan_label: normalizePlanLabel(raw, brand) };
1900
1935
  }
1901
1936
 
1902
- async function getUsageLimits({
1937
+ async function getUsageLimits(options = {}) {
1938
+ const nowMs = Date.now();
1939
+ if (cache.data && nowMs - cache.fetchedAt < CACHE_TTL_MS) {
1940
+ return cache.data;
1941
+ }
1942
+ // Past the cache there is exactly one thing to do, and it is expensive: sweep
1943
+ // every configured provider. Concurrent tabs, route mounts and revalidations
1944
+ // land here together on a cold cache, and a forced refresh clears the cache
1945
+ // first (local-api.js, the `refresh` param) — so "no cached data" is a common
1946
+ // state for several callers at once, not a rare one. They share one sweep.
1947
+ return runUsageLimitsFetch(() => fetchUsageLimits(options));
1948
+ }
1949
+
1950
+ async function fetchUsageLimits({
1903
1951
  home,
1904
1952
  env,
1905
1953
  platform,
@@ -1911,9 +1959,10 @@ async function getUsageLimits({
1911
1959
  providerTimeoutMs = DEFAULT_PROVIDER_TIMEOUT_MS,
1912
1960
  } = {}) {
1913
1961
  const nowMs = Date.now();
1914
- if (cache.data && nowMs - cache.fetchedAt < CACHE_TTL_MS) {
1915
- return cache.data;
1916
- }
1962
+ // The stale-Codex refresh is part of the same provider sweep and must share
1963
+ // its abort bound. Creating this before the serial prelude prevents one raw
1964
+ // OAuth fetch from holding the process-wide single-flight slot forever.
1965
+ const providerFetch = withFetchTimeout(fetchImpl, providerTimeoutMs);
1917
1966
 
1918
1967
  const [claudeToken, claudeSubscription, codexAuth] = await Promise.all([
1919
1968
  Promise.resolve().then(() => readClaudeCodeAccessToken({ platform, securityRunner, home })),
@@ -1933,7 +1982,7 @@ async function getUsageLimits({
1933
1982
  try {
1934
1983
  const newTokens = await refreshCodexTokens({
1935
1984
  refreshToken: codexAuth.refreshToken,
1936
- fetchImpl,
1985
+ fetchImpl: providerFetch,
1937
1986
  });
1938
1987
  const updatedAuth = await persistRefreshedAuth(
1939
1988
  codexAuth.authPath,
@@ -1956,7 +2005,6 @@ async function getUsageLimits({
1956
2005
  const codexAccountId = codexAuthRefreshed?.accountId || null;
1957
2006
  const codexPlanType = codexAuthRefreshed?.planType || null;
1958
2007
 
1959
- const providerFetch = withFetchTimeout(fetchImpl, providerTimeoutMs);
1960
2008
  const [claudeResult, codexResult, cursor, kimi, zai, gemini, kiro, antigravity, copilot] = await Promise.all([
1961
2009
  claudeToken
1962
2010
  ? withProviderTimeout(fetchClaudeUsageLimits(claudeToken, { fetchImpl: providerFetch, maxAttempts: 1 }), "Claude", providerTimeoutMs).then(
@@ -2057,8 +2105,20 @@ function resetUsageLimitsCache() {
2057
2105
  cache = { data: null, fetchedAt: 0 };
2058
2106
  }
2059
2107
 
2108
+ // The force protocol for the quota cache, in one place because two handlers
2109
+ // speak it: the CLI local API and the Vite dev middleware. They used to carry
2110
+ // their own copy of the same comparison, which is how one of them quietly stops
2111
+ // honouring a spelling — and a force that is silently downgraded to a cached
2112
+ // read looks exactly like a working one. `1` is what the dashboard client sends
2113
+ // (dashboard/src/lib/api.ts); `true` is what a human types into the URL bar.
2114
+ // Deliberately exact: no trimming, no case folding, no truthiness.
2115
+ function isForcedRefresh(value) {
2116
+ return value === "1" || value === "true";
2117
+ }
2118
+
2060
2119
  module.exports = {
2061
2120
  getUsageLimits,
2121
+ isForcedRefresh,
2062
2122
  normalizePlanLabel,
2063
2123
  resetUsageLimitsCache,
2064
2124
  extractGeminiOauthClientCredentials,