@phnx-labs/agents-cli 1.22.45 → 1.22.47

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 (177) hide show
  1. package/CHANGELOG.md +303 -0
  2. package/LICENSE +102 -182
  3. package/README.md +54 -17
  4. package/dist/cli/command-registry.d.ts +2 -1
  5. package/dist/cli/command-registry.js +6 -2
  6. package/dist/commands/accounts.js +56 -12
  7. package/dist/commands/auth.d.ts +2 -0
  8. package/dist/commands/auth.js +229 -0
  9. package/dist/commands/browser-sessions-picker.js +9 -7
  10. package/dist/commands/browser.js +266 -15
  11. package/dist/commands/config.js +26 -4
  12. package/dist/commands/cost.d.ts +1 -1
  13. package/dist/commands/detach.d.ts +13 -0
  14. package/dist/commands/detach.js +11 -8
  15. package/dist/commands/exec.js +55 -38
  16. package/dist/commands/feed-watch.d.ts +2 -0
  17. package/dist/commands/feed-watch.js +28 -0
  18. package/dist/commands/feed.js +27 -0
  19. package/dist/commands/feedback.js +3 -19
  20. package/dist/commands/insights.d.ts +1 -1
  21. package/dist/commands/insights.js +2 -2
  22. package/dist/commands/lease.js +3 -3
  23. package/dist/commands/routines.js +1 -1
  24. package/dist/commands/sessions-picker-factory.d.ts +2 -1
  25. package/dist/commands/sessions-picker-factory.js +1 -1
  26. package/dist/commands/sessions-stop.d.ts +24 -0
  27. package/dist/commands/sessions-stop.js +66 -0
  28. package/dist/commands/sessions-trace.d.ts +82 -0
  29. package/dist/commands/sessions-trace.js +387 -0
  30. package/dist/commands/sessions.js +4 -0
  31. package/dist/commands/ssh.d.ts +15 -0
  32. package/dist/commands/ssh.js +194 -16
  33. package/dist/commands/teams.js +34 -2
  34. package/dist/commands/view.d.ts +2 -0
  35. package/dist/commands/view.js +19 -32
  36. package/dist/commands/watchdog.js +9 -14
  37. package/dist/lib/account-capabilities.d.ts +4 -0
  38. package/dist/lib/account-capabilities.js +13 -9
  39. package/dist/lib/account-registry.d.ts +2 -0
  40. package/dist/lib/account-registry.js +30 -2
  41. package/dist/lib/accounting/rotate.d.ts +9 -0
  42. package/dist/lib/accounting/rotate.js +12 -2
  43. package/dist/lib/accounting/usage.d.ts +92 -6
  44. package/dist/lib/accounting/usage.js +329 -75
  45. package/dist/lib/agent-modes.js +29 -1
  46. package/dist/lib/agent-spec/agents.d.ts +4 -3
  47. package/dist/lib/agent-spec/agents.js +20 -7
  48. package/dist/lib/analytics/mix-commands.d.ts +1 -1
  49. package/dist/lib/analytics/mix-commands.js +2 -2
  50. package/dist/lib/auth-health.d.ts +13 -3
  51. package/dist/lib/auth-health.js +50 -8
  52. package/dist/lib/browser/devices.d.ts +19 -0
  53. package/dist/lib/browser/devices.js +23 -0
  54. package/dist/lib/browser/ipc.d.ts +1 -1
  55. package/dist/lib/browser/ipc.js +33 -7
  56. package/dist/lib/browser/profiles.d.ts +106 -1
  57. package/dist/lib/browser/profiles.js +300 -17
  58. package/dist/lib/browser/remote-control.d.ts +26 -2
  59. package/dist/lib/browser/remote-control.js +33 -3
  60. package/dist/lib/browser/runtime-state.d.ts +9 -0
  61. package/dist/lib/browser/runtime-state.js +24 -8
  62. package/dist/lib/browser/service.d.ts +37 -0
  63. package/dist/lib/browser/service.js +78 -20
  64. package/dist/lib/browser/sessions-list.d.ts +1 -3
  65. package/dist/lib/browser/sessions-list.js +3 -16
  66. package/dist/lib/browser/types.d.ts +12 -1
  67. package/dist/lib/cli-resources.js +17 -15
  68. package/dist/lib/codex-policy.d.ts +11 -1
  69. package/dist/lib/codex-policy.js +20 -5
  70. package/dist/lib/config-keys.d.ts +4 -3
  71. package/dist/lib/config-keys.js +17 -7
  72. package/dist/lib/daemon/runner.js +15 -2
  73. package/dist/lib/daemon-ticks.js +2 -2
  74. package/dist/lib/device-config.d.ts +25 -8
  75. package/dist/lib/device-config.js +90 -17
  76. package/dist/lib/devices/config-migration.d.ts +7 -0
  77. package/dist/lib/devices/config-migration.js +67 -3
  78. package/dist/lib/devices/harness-inventory.js +20 -3
  79. package/dist/lib/devices/health.d.ts +27 -2
  80. package/dist/lib/devices/health.js +39 -9
  81. package/dist/lib/devices/interactive-host.d.ts +15 -0
  82. package/dist/lib/devices/interactive-host.js +53 -0
  83. package/dist/lib/devices/registry.d.ts +61 -4
  84. package/dist/lib/devices/registry.js +94 -38
  85. package/dist/lib/devices/stats-cache.d.ts +6 -0
  86. package/dist/lib/devices/stats-cache.js +10 -2
  87. package/dist/lib/exec.d.ts +25 -3
  88. package/dist/lib/exec.js +75 -22
  89. package/dist/lib/feed/answer.d.ts +29 -0
  90. package/dist/lib/feed/answer.js +145 -0
  91. package/dist/lib/feed/attention.d.ts +83 -0
  92. package/dist/lib/feed/attention.js +0 -0
  93. package/dist/lib/feed/feed.d.ts +109 -0
  94. package/dist/lib/feed/feed.js +154 -3
  95. package/dist/lib/feed/pr-status.d.ts +14 -0
  96. package/dist/lib/feed/pr-status.js +47 -0
  97. package/dist/lib/feed/watch.d.ts +59 -0
  98. package/dist/lib/feed/watch.js +180 -0
  99. package/dist/lib/fleet/capture.js +6 -0
  100. package/dist/lib/fleet/remote-login.js +8 -3
  101. package/dist/lib/fleet/types.d.ts +17 -0
  102. package/dist/lib/harness/adapter.d.ts +11 -0
  103. package/dist/lib/harness/adapters/claude.js +38 -24
  104. package/dist/lib/harness/adapters/codex.js +2 -4
  105. package/dist/lib/hooks/cache.js +8 -0
  106. package/dist/lib/hooks/match.d.ts +3 -0
  107. package/dist/lib/hooks/match.js +25 -0
  108. package/dist/lib/hosts/passthrough.js +16 -0
  109. package/dist/lib/hosts/registry.js +7 -0
  110. package/dist/lib/identity/client.d.ts +53 -0
  111. package/dist/lib/identity/client.js +106 -0
  112. package/dist/lib/identity/index.d.ts +115 -0
  113. package/dist/lib/identity/index.js +82 -0
  114. package/dist/lib/menubar/MenubarHelper.app/Contents/CodeResources +0 -0
  115. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  116. package/dist/lib/menubar/install-menubar.d.ts +13 -0
  117. package/dist/lib/menubar/install-menubar.js +26 -2
  118. package/dist/lib/open-url.d.ts +60 -2
  119. package/dist/lib/open-url.js +167 -12
  120. package/dist/lib/probe.d.ts +8 -0
  121. package/dist/lib/probe.js +105 -0
  122. package/dist/lib/refresh-coordinator.d.ts +1 -1
  123. package/dist/lib/refresh-coordinator.js +1 -1
  124. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  125. package/dist/lib/secrets/Agents CLI.app/Contents/Info.plist +2 -0
  126. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  127. package/dist/lib/secrets/Agents CLI.app/Contents/Resources/AppIcon.icns +0 -0
  128. package/dist/lib/secrets/Agents CLI.app/Contents/_CodeSignature/CodeResources +13 -1
  129. package/dist/lib/session/active.d.ts +67 -0
  130. package/dist/lib/session/active.js +69 -1
  131. package/dist/lib/session/db.js +4 -2
  132. package/dist/lib/session/discover.d.ts +2 -0
  133. package/dist/lib/session/discover.js +12 -6
  134. package/dist/lib/session/prompt.d.ts +35 -0
  135. package/dist/lib/session/prompt.js +56 -0
  136. package/dist/lib/session/remote/watch.d.ts +1 -1
  137. package/dist/lib/session/remote/watch.js +6 -1
  138. package/dist/lib/session/render.js +7 -3
  139. package/dist/lib/session/session-cache.js +5 -1
  140. package/dist/lib/session/trajectory-compare.d.ts +47 -0
  141. package/dist/lib/session/trajectory-compare.js +116 -0
  142. package/dist/lib/session/trajectory-html.d.ts +15 -0
  143. package/dist/lib/session/trajectory-html.js +750 -0
  144. package/dist/lib/session/trajectory-lineage.d.ts +92 -0
  145. package/dist/lib/session/trajectory-lineage.js +195 -0
  146. package/dist/lib/session/trajectory-text.d.ts +37 -0
  147. package/dist/lib/session/trajectory-text.js +277 -0
  148. package/dist/lib/session/trajectory.d.ts +102 -0
  149. package/dist/lib/session/trajectory.js +335 -0
  150. package/dist/lib/startup/command-registry.d.ts +5 -2
  151. package/dist/lib/startup/command-registry.js +8 -5
  152. package/dist/lib/state.d.ts +1 -1
  153. package/dist/lib/state.js +4 -2
  154. package/dist/lib/teams/agents.js +9 -0
  155. package/dist/lib/terminal/inject.d.ts +19 -0
  156. package/dist/lib/terminal/inject.js +19 -9
  157. package/dist/lib/tmux/session.d.ts +46 -13
  158. package/dist/lib/tmux/session.js +173 -27
  159. package/dist/lib/types.d.ts +16 -0
  160. package/dist/lib/usage-backoff.d.ts +2 -1
  161. package/dist/lib/usage-backoff.js +60 -19
  162. package/dist/lib/usage-refresh.d.ts +17 -3
  163. package/dist/lib/usage-refresh.js +57 -11
  164. package/dist/lib/view-types.d.ts +2 -2
  165. package/dist/lib/watchdog/log.d.ts +1 -1
  166. package/dist/lib/watchdog/log.js +1 -1
  167. package/dist/lib/watchdog/runner.d.ts +36 -45
  168. package/dist/lib/watchdog/runner.js +150 -119
  169. package/dist/lib/watchdog/service.d.ts +0 -1
  170. package/dist/lib/watchdog/service.js +0 -1
  171. package/dist/lib/watchdog/watchdog-agent.d.ts +38 -0
  172. package/dist/lib/watchdog/watchdog-agent.js +59 -0
  173. package/dist/lib/watchdog/watchdog.d.ts +11 -2
  174. package/dist/lib/watchdog/watchdog.js +37 -104
  175. package/package.json +2 -2
  176. package/dist/commands/usage.d.ts +0 -27
  177. package/dist/commands/usage.js +0 -96
@@ -118,6 +118,57 @@ export function usageUnreachableError(agent, cause) {
118
118
  ? `${agent} usage read failed: ${detail}`
119
119
  : `${agent} usage read failed.`;
120
120
  }
121
+ /**
122
+ * Marker for a log-based (`network: false`) provider — Codex, Grok — that has
123
+ * simply never recorded a rate-limit event on this machine yet: no session
124
+ * log exists, or no session in it carries usage data. Distinct on purpose from
125
+ * `usageUnreachableError`: that one means the local log COULDN'T be read (a
126
+ * real failure worth surfacing distinctly); this one means there is nothing to
127
+ * read because the account has not run here, which is expected for a fresh
128
+ * install and should render as a benign state, not an error (RUSH-3040).
129
+ */
130
+ export const USAGE_NO_RECENT_USAGE_MARKER = 'no usage recorded yet';
131
+ export const USAGE_BENIGN_STATE = Symbol('usageBenignState');
132
+ /**
133
+ * Shared error-classification + 429 backoff for a networked usage fetch whose
134
+ * only signal is an HTTP status (or none at all, on a network failure) —
135
+ * Antigravity's :retrieveUserQuota and Muse's Meta Model API probe are both
136
+ * this shape. They were added to `USAGE_SOURCES` after the four original
137
+ * `usageXError` constructors and did not get their own scheme (usage.ts's
138
+ * error handling was written for "four networked providers"; RUSH-3040).
139
+ * Route every no-snapshot outcome for either through this one function so a
140
+ * future entry cannot be added second-class again — it owns noting the 429
141
+ * backoff, so callers must NOT also call {@link noteUsageRateLimited} for the
142
+ * same response.
143
+ */
144
+ export function classifyUsageFetchFailure(agent, agentId, status, retryAfterHeader, usageScope) {
145
+ if (status === 429) {
146
+ noteUsageRateLimited(agentId, retryAfterHeader ?? null, { account: usageScope });
147
+ return usageRejectedError(agent, 429);
148
+ }
149
+ if (status !== null)
150
+ return usageRejectedError(agent, status);
151
+ return usageUnreachableError(agent);
152
+ }
153
+ /** Classify a `UsageInfo.error` string into its {@link UsageErrorKind}, or null when there is no error. */
154
+ export function classifyUsageErrorKind(error) {
155
+ if (!error)
156
+ return null;
157
+ if (isUsageHeadlessScopeError(error))
158
+ return 'headless-scope';
159
+ if (error.startsWith('No readable '))
160
+ return 'no-credential';
161
+ if (error.includes('credential expired'))
162
+ return 'expired-credential';
163
+ if (error.includes('rate-limited this machine') || error.includes('is rate-limiting the usage endpoint')) {
164
+ return 'rate-limited';
165
+ }
166
+ if (error.includes('rejected the usage read'))
167
+ return 'rejected';
168
+ if (error.includes('usage read failed'))
169
+ return 'unreachable';
170
+ return 'rejected';
171
+ }
121
172
  /**
122
173
  * True when a Claude OAuth access token is within the refresh leeway of expiry
123
174
  * (or already expired) — i.e. it "would need a refresh" before the next use.
@@ -165,6 +216,14 @@ const COMPACT_BAR_LEN = 5;
165
216
  const USAGE_BAR_LEN = 10;
166
217
  const FULL = '\u2588';
167
218
  const EMPTY = '\u2591';
219
+ /** Construct the benign no-local-log result without overloading `error`. */
220
+ export function usageNoRecentUsageInfo() {
221
+ return { snapshot: null, error: null, [USAGE_BENIGN_STATE]: 'no-recent-usage' };
222
+ }
223
+ /** Read a benign state for the human renderer; symbols are omitted by JSON serialization. */
224
+ export function getUsageBenignState(info) {
225
+ return info[USAGE_BENIGN_STATE] ?? null;
226
+ }
168
227
  /** The single registry of agent usage sources and their transport. */
169
228
  const USAGE_SOURCES = {
170
229
  claude: { fetch: getClaudeUsageInfo, network: true },
@@ -333,6 +392,9 @@ async function fetchLiveUsageDeduped(input, usageKey, cached, fileOnly) {
333
392
  home: input.home,
334
393
  cliVersion: input.cliVersion,
335
394
  organizationId: input.info.organizationId,
395
+ // Scope this fetch's 429 backoff to the account being fetched, so one
396
+ // throttled account cannot park the whole provider (RUSH-3036).
397
+ usageScope: usageKey,
336
398
  fileOnly,
337
399
  });
338
400
  if (usage.snapshot) {
@@ -391,6 +453,26 @@ export function pickCompactUsageWindows(windows, maxWindows) {
391
453
  }
392
454
  return chosen;
393
455
  }
456
+ /** Human label for a classified usage error, for the no-bars branch of {@link formatUsageSummary}. */
457
+ function formatUsageErrorKindLabel(kind, detail) {
458
+ switch (kind) {
459
+ case 'no-credential':
460
+ return 'sign in / provision token';
461
+ case 'expired-credential':
462
+ return 're-auth for usage';
463
+ case 'rate-limited': {
464
+ const retryHint = detail?.match(/not retrying for (.+)\.$/)?.[1] ?? null;
465
+ return retryHint ? `rate-limited (retry ~${retryHint})` : 'rate-limited';
466
+ }
467
+ case 'rejected':
468
+ case 'unreachable':
469
+ case 'headless-scope':
470
+ case null:
471
+ case undefined:
472
+ default:
473
+ return 'usage unavailable';
474
+ }
475
+ }
394
476
  /** Format a one-line usage summary with compact bars for inline display. */
395
477
  export function formatUsageSummary(plan, snapshot, planWidth = 3, opts) {
396
478
  const parts = [];
@@ -398,7 +480,10 @@ export function formatUsageSummary(plan, snapshot, planWidth = 3, opts) {
398
480
  parts.push(chalk.gray(plan.padEnd(planWidth)));
399
481
  }
400
482
  if (snapshot) {
401
- if (snapshot.unavailable?.reason === 'session_limit') {
483
+ if (snapshot.unavailable?.reason === 'out_of_credits') {
484
+ parts.push(chalk.red('out of credits'));
485
+ }
486
+ else if (snapshot.unavailable?.reason === 'session_limit' && snapshot.unavailable.resetsAt) {
402
487
  parts.push(chalk.yellow(`session-limited (${formatResetHint(snapshot.unavailable.resetsAt)})`));
403
488
  }
404
489
  // Compact rows show BLOCKING windows — the same set
@@ -442,11 +527,16 @@ export function formatUsageSummary(plan, snapshot, planWidth = 3, opts) {
442
527
  // mistaken for a missing setup-token or seeding failure (RUSH-2392).
443
528
  parts.push(chalk.dim(USAGE_HEADLESS_SCOPE_MARKER));
444
529
  }
530
+ else if (opts?.benignState === 'no-recent-usage') {
531
+ parts.push(chalk.dim(USAGE_NO_RECENT_USAGE_MARKER));
532
+ }
445
533
  else if (opts?.unavailable) {
446
534
  // Signed-in account we could NOT fetch usage for (no live token in a reachable
447
535
  // home / org mismatch / fetch error). Say so explicitly instead of drawing a
448
- // blank gauge that reads like "0% used".
449
- parts.push(chalk.dim('usage unavailable'));
536
+ // blank gauge that reads like "0% used" — and name the SPECIFIC cause when
537
+ // the caller passed one, rather than the generic bucket that used to cover
538
+ // ~6 different failures (RUSH-3040).
539
+ parts.push(chalk.dim(formatUsageErrorKindLabel(opts.errorKind, opts.errorDetail)));
450
540
  }
451
541
  return parts.join(' ');
452
542
  }
@@ -472,8 +562,14 @@ export function formatUsageSummary(plan, snapshot, planWidth = 3, opts) {
472
562
  export function deriveUsageStatusFromSnapshot(snapshot) {
473
563
  if (!snapshot)
474
564
  return null;
475
- if (snapshot.unavailable && snapshot.unavailable.resetsAt.getTime() > Date.now()) {
476
- return 'rate_limited';
565
+ if (snapshot.unavailable) {
566
+ // out_of_credits has no clock — it stays blocking until a successful run
567
+ // clears it. session_limit blocks only until its reset time.
568
+ if (snapshot.unavailable.reason === 'out_of_credits')
569
+ return 'rate_limited';
570
+ if (snapshot.unavailable.resetsAt && snapshot.unavailable.resetsAt.getTime() > Date.now()) {
571
+ return 'rate_limited';
572
+ }
477
573
  }
478
574
  if (snapshot.windows.length === 0)
479
575
  return null;
@@ -589,11 +685,17 @@ async function getCodexUsageInfo(options) {
589
685
  return { snapshot: null, error: null };
590
686
  }
591
687
  const files = collectCodexSessionFiles(options?.home, sinceMs);
688
+ const now = new Date();
592
689
  for (const filePath of files) {
593
690
  const match = await readLatestCodexRateLimits(filePath);
594
691
  if (!match)
595
692
  continue;
596
- const windows = normalizeCodexWindows(match.rateLimits);
693
+ // Same freshness filter Grok already applies (RUSH-3040): a window whose
694
+ // reset time or windowMinutes-derived expiry has passed is a STALE read,
695
+ // not a current one — rendering it as-is is how a codex bar kept showing
696
+ // "100% used" past its own reset. Try the next-older session file rather
697
+ // than surfacing a stale bar.
698
+ const windows = normalizeCodexWindows(match.rateLimits).filter((window) => isCachedUsageWindowFresh(window, match.capturedAt, now));
597
699
  if (windows.length === 0)
598
700
  continue;
599
701
  return {
@@ -606,10 +708,14 @@ async function getCodexUsageInfo(options) {
606
708
  error: null,
607
709
  };
608
710
  }
609
- return { snapshot: null, error: null };
711
+ // No session ever recorded a rate-limit event on this machine (or none of
712
+ // the ones found were still fresh) — a benign "nothing to show yet", not a
713
+ // failure (RUSH-3040). Distinct from the outer catch below, which is a
714
+ // genuine read/parse failure.
715
+ return usageNoRecentUsageInfo();
610
716
  }
611
- catch {
612
- return { snapshot: null, error: null };
717
+ catch (err) {
718
+ return { snapshot: null, error: usageUnreachableError('Codex', err) };
613
719
  }
614
720
  }
615
721
  /**
@@ -662,7 +768,7 @@ async function getClaudeUsageInfo(options) {
662
768
  }
663
769
  // Honour a live Retry-After rather than re-arming the penalty (see
664
770
  // usage-backoff.ts). No request at all while the window is open.
665
- const throttledUntil = usageRateLimitedUntil('claude');
771
+ const throttledUntil = usageRateLimitedUntil('claude', Date.now(), options?.usageScope);
666
772
  if (throttledUntil) {
667
773
  return { snapshot: null, error: usageThrottledError('Claude', throttledUntil) };
668
774
  }
@@ -678,7 +784,7 @@ async function getClaudeUsageInfo(options) {
678
784
  });
679
785
  if (!response.ok) {
680
786
  if (response.status === 429) {
681
- noteUsageRateLimited('claude', response.headers.get('retry-after'));
787
+ noteUsageRateLimited('claude', response.headers.get('retry-after'), { account: options?.usageScope });
682
788
  }
683
789
  // Setup-token is user:inference only; usage needs user:profile → 403
684
790
  // with a scope-requirement body. Distinct from a real rejection so the
@@ -771,7 +877,7 @@ async function getKimiUsageInfo(options) {
771
877
  }
772
878
  // Honour a live Retry-After rather than re-arming the penalty (see
773
879
  // usage-backoff.ts). No request at all while the window is open.
774
- const throttledUntil = usageRateLimitedUntil('kimi');
880
+ const throttledUntil = usageRateLimitedUntil('kimi', Date.now(), options?.usageScope);
775
881
  if (throttledUntil) {
776
882
  return { snapshot: null, error: usageThrottledError('Kimi', throttledUntil) };
777
883
  }
@@ -787,7 +893,7 @@ async function getKimiUsageInfo(options) {
787
893
  // way there are no bars to draw, and the status is what tells them apart.
788
894
  if (!response.ok) {
789
895
  if (response.status === 429) {
790
- noteUsageRateLimited('kimi', response.headers.get('retry-after'));
896
+ noteUsageRateLimited('kimi', response.headers.get('retry-after'), { account: options?.usageScope });
791
897
  }
792
898
  return { snapshot: null, error: usageRejectedError('Kimi', response.status) };
793
899
  }
@@ -905,7 +1011,7 @@ async function getDroidUsageInfo(options) {
905
1011
  }
906
1012
  // Honour a live Retry-After rather than re-arming the penalty (see
907
1013
  // usage-backoff.ts). No request at all while the window is open.
908
- const throttledUntil = usageRateLimitedUntil('droid');
1014
+ const throttledUntil = usageRateLimitedUntil('droid', Date.now(), options?.usageScope);
909
1015
  if (throttledUntil) {
910
1016
  return { snapshot: null, error: usageThrottledError('Droid', throttledUntil) };
911
1017
  }
@@ -920,7 +1026,7 @@ async function getDroidUsageInfo(options) {
920
1026
  // 401 => revoked/expired token. No bars to draw, and the status says why.
921
1027
  if (!response.ok) {
922
1028
  if (response.status === 429) {
923
- noteUsageRateLimited('droid', response.headers.get('retry-after'));
1029
+ noteUsageRateLimited('droid', response.headers.get('retry-after'), { account: options?.usageScope });
924
1030
  }
925
1031
  return { snapshot: null, error: usageRejectedError('Droid', response.status) };
926
1032
  }
@@ -947,7 +1053,7 @@ async function getDroidUsageInfo(options) {
947
1053
  }
948
1054
  }
949
1055
  /** Probe Claude's OAuth token against the usage endpoint. Never refreshes — reports `expired` for a near-expiry token; see the comment below (RUSH-1822). */
950
- export async function probeClaudeStatus(home, cliVersion) {
1056
+ export async function probeClaudeStatus(home, cliVersion, usageScope) {
951
1057
  // accessTokenCache: the daemon warms this probe every ~3 min per account, so it
952
1058
  // reads ONLY the file-based setup-token and never the interactive login —
953
1059
  // transmitting that ACL-bound token to the usage API from a background loop is
@@ -972,7 +1078,7 @@ export async function probeClaudeStatus(home, cliVersion) {
972
1078
  // window is open, report the throttle from the recorded state instead of
973
1079
  // firing again and re-arming it (usage-backoff.ts). This 3-min-cadence
974
1080
  // probe is what created the loop it now respects.
975
- if (usageRateLimitedUntil('claude'))
1081
+ if (usageRateLimitedUntil('claude', Date.now(), usageScope))
976
1082
  return { status: 429, token: 'present' };
977
1083
  try {
978
1084
  const response = await fetch(CLAUDE_USAGE_URL, {
@@ -986,7 +1092,7 @@ export async function probeClaudeStatus(home, cliVersion) {
986
1092
  signal: AbortSignal.timeout(8000),
987
1093
  });
988
1094
  if (response.status === 429) {
989
- noteUsageRateLimited('claude', response.headers.get('retry-after'));
1095
+ noteUsageRateLimited('claude', response.headers.get('retry-after'), { account: usageScope });
990
1096
  }
991
1097
  // Setup-token is user:inference only; usage needs user:profile → 403.
992
1098
  // That is NOT a revocation — the account still runs (RUSH-2392).
@@ -1014,7 +1120,7 @@ export async function probeClaudeStatus(home, cliVersion) {
1014
1120
  }
1015
1121
  }
1016
1122
  /** Probe Kimi's OAuth token against the /usages endpoint. Never refreshes (single-use rotation — see getKimiUsageInfo). */
1017
- export async function probeKimiStatus(home) {
1123
+ export async function probeKimiStatus(home, usageScope) {
1018
1124
  const credPath = resolveKimiCredentialPath(home);
1019
1125
  if (!credPath)
1020
1126
  return { status: null, token: 'missing' };
@@ -1038,7 +1144,7 @@ export async function probeKimiStatus(home) {
1038
1144
  // is what created the loop it now respects. It sits AFTER the local
1039
1145
  // missing/expired checks — as in probeClaudeStatus and probeDroidStatus — so
1040
1146
  // a genuinely broken credential is never misreported as merely throttled.
1041
- if (usageRateLimitedUntil('kimi'))
1147
+ if (usageRateLimitedUntil('kimi', Date.now(), usageScope))
1042
1148
  return { status: 429, token: 'present' };
1043
1149
  try {
1044
1150
  const response = await fetch(KIMI_USAGES_URL, {
@@ -1047,7 +1153,7 @@ export async function probeKimiStatus(home) {
1047
1153
  signal: AbortSignal.timeout(8000),
1048
1154
  });
1049
1155
  if (response.status === 429) {
1050
- noteUsageRateLimited('kimi', response.headers.get('retry-after'));
1156
+ noteUsageRateLimited('kimi', response.headers.get('retry-after'), { account: usageScope });
1051
1157
  }
1052
1158
  return { status: response.status, token: 'present' };
1053
1159
  }
@@ -1056,7 +1162,7 @@ export async function probeKimiStatus(home) {
1056
1162
  }
1057
1163
  }
1058
1164
  /** Probe Droid's WorkOS token against the billing-limits endpoint. Never refreshes (single-use rotation — see getDroidUsageInfo). */
1059
- export async function probeDroidStatus(home) {
1165
+ export async function probeDroidStatus(home, usageScope) {
1060
1166
  const cred = decryptDroidAuthPayload(home || os.homedir());
1061
1167
  const accessToken = cred?.access_token;
1062
1168
  if (typeof accessToken !== 'string' || !accessToken)
@@ -1068,7 +1174,7 @@ export async function probeDroidStatus(home) {
1068
1174
  // window is open, report the throttle from the recorded state instead of
1069
1175
  // firing again and re-arming it (usage-backoff.ts). This 3-min-cadence
1070
1176
  // probe is what created the loop it now respects.
1071
- if (usageRateLimitedUntil('droid'))
1177
+ if (usageRateLimitedUntil('droid', Date.now(), usageScope))
1072
1178
  return { status: 429, token: 'present' };
1073
1179
  try {
1074
1180
  const response = await fetch(DROID_USAGE_URL, {
@@ -1077,7 +1183,7 @@ export async function probeDroidStatus(home) {
1077
1183
  signal: AbortSignal.timeout(8000),
1078
1184
  });
1079
1185
  if (response.status === 429) {
1080
- noteUsageRateLimited('droid', response.headers.get('retry-after'));
1186
+ noteUsageRateLimited('droid', response.headers.get('retry-after'), { account: usageScope });
1081
1187
  }
1082
1188
  return { status: response.status, token: 'present' };
1083
1189
  }
@@ -1487,12 +1593,9 @@ export function writeClaudeUsageCache(usageKey, snapshot, cachePath = getClaudeU
1487
1593
  // refresh cannot drop another account's row (lost update).
1488
1594
  const cache = readClaudeUsageCacheFile(cachePath);
1489
1595
  const prior = cache[usageKey];
1490
- const priorReset = parseDateValue(prior?.unavailable?.resetsAt);
1491
1596
  cache[usageKey] = serializeClaudeUsageSnapshot({
1492
1597
  ...snapshot,
1493
- unavailable: priorReset && priorReset.getTime() > Date.now()
1494
- ? { reason: 'session_limit', resetsAt: priorReset }
1495
- : snapshot.unavailable,
1598
+ unavailable: carryForwardUnavailable(prior?.unavailable, snapshot.unavailable),
1496
1599
  });
1497
1600
  atomicWriteFileSync(cachePath, JSON.stringify(cache, null, 2), 'utf-8');
1498
1601
  });
@@ -1530,7 +1633,10 @@ function serializeClaudeUsageSnapshot(snapshot) {
1530
1633
  capturedAt: snapshot.capturedAt?.toISOString() || null,
1531
1634
  plan: snapshot.plan ?? null,
1532
1635
  unavailable: snapshot.unavailable
1533
- ? { reason: snapshot.unavailable.reason, resetsAt: snapshot.unavailable.resetsAt.toISOString() }
1636
+ ? {
1637
+ reason: snapshot.unavailable.reason,
1638
+ resetsAt: snapshot.unavailable.resetsAt?.toISOString(),
1639
+ }
1534
1640
  : undefined,
1535
1641
  windows: snapshot.windows.map((window) => ({
1536
1642
  key: window.key,
@@ -1552,6 +1658,10 @@ function serializeClaudeUsageSnapshot(snapshot) {
1552
1658
  * candidate (RUSH-2858). Dropping mirrors the Grok collector, and an all-expired
1553
1659
  * snapshot deserializes to null so `readClaudeUsageCache` deletes the entry and
1554
1660
  * callers surface "usage unavailable" plus the recorded throttle reason.
1661
+ *
1662
+ * A row that carries a plan survives even with no fresh windows: the plan is a
1663
+ * truthful reading in its own right, and losing it is what made the cached view
1664
+ * contradict the refreshed one for meterless harnesses. See the guard below.
1555
1665
  */
1556
1666
  function deserializeClaudeUsageSnapshot(snapshot, now) {
1557
1667
  const capturedAt = parseDateValue(snapshot.capturedAt);
@@ -1565,11 +1675,18 @@ function deserializeClaudeUsageSnapshot(snapshot, now) {
1565
1675
  windowMinutes: window.windowMinutes,
1566
1676
  }))
1567
1677
  .filter((window) => isCachedUsageWindowFresh(window, capturedAt, now));
1568
- const unavailableReset = parseDateValue(snapshot.unavailable?.resetsAt);
1569
- const unavailable = unavailableReset && unavailableReset.getTime() > now.getTime()
1570
- ? { reason: 'session_limit', resetsAt: unavailableReset }
1571
- : undefined;
1572
- if (windows.length === 0 && !unavailable) {
1678
+ const unavailable = deserializeUnavailable(snapshot.unavailable, now);
1679
+ // A windowless row is not automatically worthless. Grok's collector reports
1680
+ // the subscription tier and no meters at all, so treating "no fresh windows"
1681
+ // as "nothing cached" deleted the only truthful thing we knew about the
1682
+ // account: `--refresh` wrote {plan: 'SuperGrok Heavy', windows: []}, the very
1683
+ // next plain `agents view` deserialized it to null, `readClaudeUsageCache`
1684
+ // pruned the row, and the row rendered "usage unavailable" one read after a
1685
+ // successful refresh. Keep a plan-bearing row — it renders as the plan alone,
1686
+ // and `deriveUsageStatusFromSnapshot` still returns null for zero windows, so
1687
+ // it can never read as a 0% bar or an "available" badge (the RUSH-2858
1688
+ // property that made expired windows drop in the first place).
1689
+ if (windows.length === 0 && !unavailable && !snapshot.plan) {
1573
1690
  return null;
1574
1691
  }
1575
1692
  return {
@@ -1581,6 +1698,82 @@ function deserializeClaudeUsageSnapshot(snapshot, now) {
1581
1698
  unavailable,
1582
1699
  };
1583
1700
  }
1701
+ /**
1702
+ * Carry a prior refusal marker forward across a daemon usage refresh, and drop
1703
+ * an expired one. A live `snapshot.unavailable` (a refusal just observed) wins.
1704
+ * `out_of_credits` survives refreshes with no reset — only a successful run
1705
+ * clears it (clearClaudeAccountRefusal). A `session_limit` survives only while
1706
+ * its reset time is still in the future.
1707
+ */
1708
+ function carryForwardUnavailable(prior, live) {
1709
+ if (live)
1710
+ return live;
1711
+ if (!prior)
1712
+ return undefined;
1713
+ if (prior.reason === 'out_of_credits')
1714
+ return { reason: 'out_of_credits' };
1715
+ const reset = parseDateValue(prior.resetsAt);
1716
+ return reset && reset.getTime() > Date.now()
1717
+ ? { reason: 'session_limit', resetsAt: reset }
1718
+ : undefined;
1719
+ }
1720
+ /**
1721
+ * Deserialize a cached `unavailable` marker, dropping an expired session_limit
1722
+ * but keeping a clock-less out_of_credits.
1723
+ */
1724
+ function deserializeUnavailable(cached, now) {
1725
+ if (!cached)
1726
+ return undefined;
1727
+ if (cached.reason === 'out_of_credits')
1728
+ return { reason: 'out_of_credits' };
1729
+ const reset = parseDateValue(cached.resetsAt);
1730
+ return reset && reset.getTime() > now.getTime()
1731
+ ? { reason: 'session_limit', resetsAt: reset }
1732
+ : undefined;
1733
+ }
1734
+ /**
1735
+ * Persist a Claude tokens/credits exhaustion (`out of usage credits` / `monthly
1736
+ * spend limit`) from a real run. Unlike a rate/session limit this does NOT reset
1737
+ * on a clock, so no reset time is stored — rotation excludes the account until a
1738
+ * later successful run clears it via {@link clearClaudeAccountRefusal}.
1739
+ */
1740
+ export function noteClaudeOutOfCredits(usageKey, cachePath = getClaudeUsageCachePath()) {
1741
+ try {
1742
+ ensureLockTarget(cachePath, '{}');
1743
+ withFileLock(cachePath, () => {
1744
+ const cache = readClaudeUsageCacheFile(cachePath);
1745
+ const existing = cache[usageKey] ?? { capturedAt: null, windows: [] };
1746
+ cache[usageKey] = { ...existing, unavailable: { reason: 'out_of_credits' } };
1747
+ atomicWriteFileSync(cachePath, JSON.stringify(cache, null, 2), 'utf-8');
1748
+ });
1749
+ }
1750
+ catch {
1751
+ /* best-effort cache write — lock busy or disk full */
1752
+ }
1753
+ }
1754
+ /**
1755
+ * Clear any persisted refusal marker for an account after a run SUCCEEDS on it.
1756
+ * This is the recovery path for `out_of_credits` (which has no clock) and also
1757
+ * proactively clears a stale `session_limit` the moment the account serves again.
1758
+ */
1759
+ export function clearClaudeAccountRefusal(usageKey, cachePath = getClaudeUsageCachePath()) {
1760
+ try {
1761
+ if (!fs.existsSync(cachePath))
1762
+ return;
1763
+ withFileLock(cachePath, () => {
1764
+ const cache = readClaudeUsageCacheFile(cachePath);
1765
+ const existing = cache[usageKey];
1766
+ if (!existing?.unavailable)
1767
+ return;
1768
+ const { unavailable: _drop, ...rest } = existing;
1769
+ cache[usageKey] = rest;
1770
+ atomicWriteFileSync(cachePath, JSON.stringify(cache, null, 2), 'utf-8');
1771
+ });
1772
+ }
1773
+ catch {
1774
+ /* best-effort cache write */
1775
+ }
1776
+ }
1584
1777
  /**
1585
1778
  * Persist a Claude session-limit refusal from a real run until its stated reset.
1586
1779
  * This quota is not part of Anthropic's five-hour/weekly usage response.
@@ -1873,11 +2066,12 @@ async function getGrokUsageInfo(options) {
1873
2066
  try {
1874
2067
  const base = options?.home || os.homedir();
1875
2068
  const logPath = path.join(base, '.grok', 'logs', 'unified.jsonl');
2069
+ // No log yet: a benign "nothing recorded here", not a failure (RUSH-3040).
1876
2070
  if (!fs.existsSync(logPath))
1877
- return { snapshot: null, error: null };
2071
+ return usageNoRecentUsageInfo();
1878
2072
  const match = await readLatestGrokBilling(logPath);
1879
2073
  if (!match)
1880
- return { snapshot: null, error: null };
2074
+ return usageNoRecentUsageInfo();
1881
2075
  // Grok has no live usage API (`network: false`) — bars are last-seen from
1882
2076
  // this machine's unified.jsonl only. Drop windows whose billing period has
1883
2077
  // already ended so a stale 100% does not paint "rate-limited" after reset,
@@ -1886,6 +2080,10 @@ async function getGrokUsageInfo(options) {
1886
2080
  // (see readLatestGrokBilling).
1887
2081
  const now = new Date();
1888
2082
  const windows = match.windows.filter((window) => isCachedUsageWindowFresh(window, match.capturedAt, now));
2083
+ // NOTE: an empty `windows` here (all windows expired) still returns a real
2084
+ // snapshot, not the no-recent-usage marker — the plan/tier is still valid
2085
+ // and callers (e.g. `formatUsageSummary`) render "no bars" correctly from
2086
+ // an empty array. The marker above is only for "nothing was ever recorded".
1889
2087
  return {
1890
2088
  snapshot: {
1891
2089
  source: 'last_seen',
@@ -1897,8 +2095,8 @@ async function getGrokUsageInfo(options) {
1897
2095
  error: null,
1898
2096
  };
1899
2097
  }
1900
- catch {
1901
- return { snapshot: null, error: null };
2098
+ catch (err) {
2099
+ return { snapshot: null, error: usageUnreachableError('Grok', err) };
1902
2100
  }
1903
2101
  }
1904
2102
  /**
@@ -1912,16 +2110,40 @@ async function getGrokUsageInfo(options) {
1912
2110
  async function getMuseUsageInfo(options) {
1913
2111
  try {
1914
2112
  const base = options?.home || os.homedir();
1915
- const live = await probeMuseRateLimits(base);
1916
- if (live)
1917
- return { snapshot: live, error: null };
2113
+ // Honour a live Retry-After rather than re-arming the penalty (see
2114
+ // usage-backoff.ts). The local log fallback still works while the live
2115
+ // probe is throttled only report the throttle when there is truly
2116
+ // nothing else to show.
2117
+ const throttledUntil = usageRateLimitedUntil('muse', Date.now(), options?.usageScope);
2118
+ if (throttledUntil) {
2119
+ const local = await readMuseLocalSessionUsage(base);
2120
+ if (local)
2121
+ return { snapshot: local, error: null };
2122
+ return { snapshot: null, error: usageThrottledError('Muse', throttledUntil) };
2123
+ }
2124
+ const probe = await probeMuseRateLimits(base);
2125
+ if (probe.snapshot)
2126
+ return { snapshot: probe.snapshot, error: null };
1918
2127
  const local = await readMuseLocalSessionUsage(base);
1919
- if (!local)
1920
- return { snapshot: null, error: null };
1921
- return { snapshot: local, error: null };
2128
+ if (local)
2129
+ return { snapshot: local, error: null };
2130
+ // No live snapshot and no local log every source came up empty. Only
2131
+ // now does the probe's own outcome become the reported error, mirroring
2132
+ // Cursor's "surface the last resort's failure" pattern above.
2133
+ if (!probe.hasKey)
2134
+ return { snapshot: null, error: usageNoCredentialError('Muse') };
2135
+ if (probe.noHeaders)
2136
+ return usageNoRecentUsageInfo();
2137
+ return {
2138
+ snapshot: null,
2139
+ error: classifyUsageFetchFailure('Muse', 'muse', probe.status, probe.retryAfter, options?.usageScope),
2140
+ };
1922
2141
  }
1923
- catch {
1924
- return { snapshot: null, error: null };
2142
+ catch (err) {
2143
+ // A thrown request (timeout, DNS, TLS, a malformed payload) is a failed
2144
+ // read like any other — staying silent here would hand the caller a stale
2145
+ // snapshot to render as confirmed (RUSH-3040).
2146
+ return { snapshot: null, error: usageUnreachableError('Muse', err) };
1925
2147
  }
1926
2148
  }
1927
2149
  /** Resolve a Muse API key from env or auth.json without logging the value. */
@@ -1955,25 +2177,28 @@ function resolveMuseApiKey(base) {
1955
2177
  }
1956
2178
  /**
1957
2179
  * Probe Meta Model API for rate-limit headers. Uses GET /v1/models (no token
1958
- * spend). Returns null when unauthenticated or the headers are absent.
2180
+ * spend). The 429 backoff is noted by the CALLER via
2181
+ * {@link classifyUsageFetchFailure}, not here, so a throttled read is recorded
2182
+ * exactly once regardless of which branch of `getMuseUsageInfo` observes it.
1959
2183
  */
1960
2184
  async function probeMuseRateLimits(base) {
1961
- if (usageRateLimitedUntil('muse'))
1962
- return null;
1963
2185
  const key = resolveMuseApiKey(base);
1964
2186
  if (!key)
1965
- return null;
2187
+ return { snapshot: null, hasKey: false, status: null, retryAfter: null, noHeaders: false };
1966
2188
  try {
1967
2189
  const response = await fetch('https://api.meta.ai/v1/models', {
1968
2190
  headers: { Authorization: `Bearer ${key}` },
1969
2191
  signal: AbortSignal.timeout(8_000),
1970
2192
  });
1971
- if (response.status === 429) {
1972
- noteUsageRateLimited('muse', response.headers.get('retry-after'));
1973
- return null;
2193
+ if (!response.ok) {
2194
+ return {
2195
+ snapshot: null,
2196
+ hasKey: true,
2197
+ status: response.status,
2198
+ retryAfter: response.headers.get('retry-after'),
2199
+ noHeaders: false,
2200
+ };
1974
2201
  }
1975
- if (!response.ok)
1976
- return null;
1977
2202
  const limitTokens = headerNumber(response.headers, 'x-ratelimit-limit-tokens');
1978
2203
  const remainingTokens = headerNumber(response.headers, 'x-ratelimit-remaining-tokens');
1979
2204
  const limitRequests = headerNumber(response.headers, 'x-ratelimit-limit-requests');
@@ -2001,18 +2226,25 @@ async function probeMuseRateLimits(base) {
2001
2226
  windowMinutes: 1,
2002
2227
  });
2003
2228
  }
2004
- if (windows.length === 0)
2005
- return null;
2229
+ if (windows.length === 0) {
2230
+ return { snapshot: null, hasKey: true, status: response.status, retryAfter: null, noHeaders: true };
2231
+ }
2006
2232
  return {
2007
- source: 'live',
2008
- sourceLabel: 'Meta Model API rate limits',
2009
- capturedAt: new Date(),
2010
- windows,
2011
- plan: 'Meta Model API',
2233
+ snapshot: {
2234
+ source: 'live',
2235
+ sourceLabel: 'Meta Model API rate limits',
2236
+ capturedAt: new Date(),
2237
+ windows,
2238
+ plan: 'Meta Model API',
2239
+ },
2240
+ hasKey: true,
2241
+ status: response.status,
2242
+ retryAfter: null,
2243
+ noHeaders: false,
2012
2244
  };
2013
2245
  }
2014
2246
  catch {
2015
- return null;
2247
+ return { snapshot: null, hasKey: true, status: null, retryAfter: null, noHeaders: false };
2016
2248
  }
2017
2249
  }
2018
2250
  function headerNumber(headers, name) {
@@ -2578,10 +2810,14 @@ async function refreshAntigravityAccessToken(refreshToken) {
2578
2810
  }
2579
2811
  /**
2580
2812
  * POST :retrieveUserQuota against the Code Assist endpoints in order, returning
2581
- * the first successful bucket list. null when every endpoint rejects (expired
2582
- * token, no quota API for the account) or the network fails.
2813
+ * the first successful bucket list. `buckets: null` when every endpoint rejects
2814
+ * (expired token, no quota API for the account) or the network fails — `status`
2815
+ * carries the LAST rejection's HTTP status (or null when every attempt threw)
2816
+ * so the caller can classify the failure instead of it reading as silence.
2583
2817
  */
2584
2818
  async function fetchAntigravityQuota(accessToken) {
2819
+ let lastStatus = null;
2820
+ let lastRetryAfter = null;
2585
2821
  for (const url of ANTIGRAVITY_QUOTA_URLS) {
2586
2822
  try {
2587
2823
  const response = await fetch(url, {
@@ -2594,16 +2830,19 @@ async function fetchAntigravityQuota(accessToken) {
2594
2830
  body: '{}',
2595
2831
  signal: AbortSignal.timeout(5000),
2596
2832
  });
2597
- if (!response.ok)
2833
+ if (!response.ok) {
2834
+ lastStatus = response.status;
2835
+ lastRetryAfter = response.headers.get('retry-after');
2598
2836
  continue;
2837
+ }
2599
2838
  const data = (await response.json());
2600
- return Array.isArray(data?.buckets) ? data.buckets : [];
2839
+ return { buckets: Array.isArray(data?.buckets) ? data.buckets : [], status: response.status, retryAfter: null };
2601
2840
  }
2602
2841
  catch {
2603
2842
  continue;
2604
2843
  }
2605
2844
  }
2606
- return null;
2845
+ return { buckets: null, status: lastStatus, retryAfter: lastRetryAfter };
2607
2846
  }
2608
2847
  /** Compact model tag for the inline bar — 'gemini-2.5-flash-lite' => '2.5FL'. */
2609
2848
  export function antigravityModelShortLabel(modelId) {
@@ -2665,16 +2904,28 @@ async function getAntigravityUsageInfo(options) {
2665
2904
  try {
2666
2905
  const token = await loadAntigravityOauth(options?.home);
2667
2906
  if (!token)
2668
- return { snapshot: null, error: null };
2907
+ return { snapshot: null, error: usageNoCredentialError('Antigravity') };
2669
2908
  let accessToken = normalizeString(token.access_token);
2670
2909
  if ((!accessToken || antigravityTokenNeedsRefresh(token.expiry)) && token.refresh_token) {
2671
2910
  accessToken = await refreshAntigravityAccessToken(token.refresh_token);
2672
2911
  }
2673
2912
  if (!accessToken)
2674
- return { snapshot: null, error: null };
2675
- const buckets = await fetchAntigravityQuota(accessToken);
2676
- if (!buckets)
2677
- return { snapshot: null, error: null };
2913
+ return { snapshot: null, error: usageExpiredCredentialError('Antigravity') };
2914
+ // Honour a live Retry-After rather than re-arming the penalty (see
2915
+ // usage-backoff.ts). No request at all while the window is open. Antigravity
2916
+ // previously had NO rate-limit backoff at all (RUSH-3040) — every refresh
2917
+ // re-hit a throttled endpoint.
2918
+ const throttledUntil = usageRateLimitedUntil('antigravity', Date.now(), options?.usageScope);
2919
+ if (throttledUntil) {
2920
+ return { snapshot: null, error: usageThrottledError('Antigravity', throttledUntil) };
2921
+ }
2922
+ const { buckets, status, retryAfter } = await fetchAntigravityQuota(accessToken);
2923
+ if (!buckets) {
2924
+ return {
2925
+ snapshot: null,
2926
+ error: classifyUsageFetchFailure('Antigravity', 'antigravity', status, retryAfter, options?.usageScope),
2927
+ };
2928
+ }
2678
2929
  const windows = normalizeAntigravityWindows(buckets);
2679
2930
  if (windows.length === 0)
2680
2931
  return { snapshot: null, error: null };
@@ -2688,7 +2939,10 @@ async function getAntigravityUsageInfo(options) {
2688
2939
  error: null,
2689
2940
  };
2690
2941
  }
2691
- catch {
2692
- return { snapshot: null, error: null };
2942
+ catch (err) {
2943
+ // A thrown request (timeout, DNS, TLS, a malformed payload) is a failed
2944
+ // read like any other — staying silent here would hand the caller a stale
2945
+ // snapshot to render as confirmed (RUSH-3040).
2946
+ return { snapshot: null, error: usageUnreachableError('Antigravity', err) };
2693
2947
  }
2694
2948
  }