@phnx-labs/agents-cli 1.20.89 → 1.20.90

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 (58) hide show
  1. package/CHANGELOG.md +240 -0
  2. package/README.md +6 -0
  3. package/dist/bin/agents +0 -0
  4. package/dist/commands/exec.js +7 -1
  5. package/dist/commands/harness.d.ts +27 -0
  6. package/dist/commands/harness.js +120 -13
  7. package/dist/commands/profiles.d.ts +3 -0
  8. package/dist/commands/profiles.js +1 -1
  9. package/dist/commands/routines.d.ts +19 -0
  10. package/dist/commands/routines.js +28 -6
  11. package/dist/commands/secrets.d.ts +10 -1
  12. package/dist/commands/secrets.js +18 -6
  13. package/dist/commands/sessions-browser.d.ts +4 -0
  14. package/dist/commands/sessions-browser.js +51 -9
  15. package/dist/commands/sessions-favorite.d.ts +20 -0
  16. package/dist/commands/sessions-favorite.js +120 -0
  17. package/dist/commands/sessions.d.ts +103 -20
  18. package/dist/commands/sessions.js +356 -62
  19. package/dist/commands/setup-secrets.d.ts +7 -0
  20. package/dist/commands/setup-secrets.js +12 -9
  21. package/dist/commands/versions.js +12 -4
  22. package/dist/commands/view.d.ts +14 -1
  23. package/dist/commands/view.js +103 -128
  24. package/dist/lib/agents.d.ts +4 -2
  25. package/dist/lib/agents.js +21 -6
  26. package/dist/lib/hosts/dispatch.js +19 -1
  27. package/dist/lib/hq/floor.js +12 -0
  28. package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
  29. package/dist/lib/picker.d.ts +27 -2
  30. package/dist/lib/picker.js +71 -7
  31. package/dist/lib/profiles.d.ts +48 -0
  32. package/dist/lib/profiles.js +67 -0
  33. package/dist/lib/rotate.d.ts +24 -2
  34. package/dist/lib/rotate.js +63 -6
  35. package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
  36. package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
  37. package/dist/lib/session/active.d.ts +109 -3
  38. package/dist/lib/session/active.js +269 -13
  39. package/dist/lib/session/db.d.ts +14 -0
  40. package/dist/lib/session/db.js +35 -0
  41. package/dist/lib/session/favorites.d.ts +39 -0
  42. package/dist/lib/session/favorites.js +101 -0
  43. package/dist/lib/session/host-link.d.ts +68 -0
  44. package/dist/lib/session/host-link.js +64 -0
  45. package/dist/lib/session/presence.d.ts +85 -0
  46. package/dist/lib/session/presence.js +150 -0
  47. package/dist/lib/session/remote-list.d.ts +10 -0
  48. package/dist/lib/session/remote-list.js +47 -9
  49. package/dist/lib/tmux/binary.d.ts +7 -0
  50. package/dist/lib/tmux/binary.js +11 -1
  51. package/dist/lib/types.d.ts +4 -3
  52. package/dist/lib/usage-backoff.d.ts +29 -0
  53. package/dist/lib/usage-backoff.js +165 -0
  54. package/dist/lib/usage.d.ts +112 -5
  55. package/dist/lib/usage.js +464 -46
  56. package/dist/lib/watchdog/runner.d.ts +13 -0
  57. package/dist/lib/watchdog/runner.js +16 -1
  58. package/package.json +1 -1
package/dist/lib/usage.js CHANGED
@@ -1,11 +1,13 @@
1
1
  /**
2
- * Usage and rate-limit tracking for Claude, Codex, Kimi, Droid, Grok, and Cursor agents.
2
+ * Usage and rate-limit tracking for Claude, Codex, Kimi, Droid, Grok, Cursor,
3
+ * and Antigravity agents.
3
4
  *
4
5
  * Fetches live usage data from each agent's usage API (Anthropic OAuth for
5
- * Claude, Kimi Code /usages, Factory billing limits for Droid) or parses
6
- * rate-limit events from Codex session logs. Results are normalized into a
7
- * common UsageSnapshot shape, cached to disk, and rendered as terminal
8
- * progress bars for the `agents view` command.
6
+ * Claude, Kimi Code /usages, Factory billing limits for Droid, Google Code
7
+ * Assist :retrieveUserQuota for Antigravity) or parses rate-limit events from
8
+ * Codex session logs. Results are normalized into a common UsageSnapshot
9
+ * shape, cached to disk, and rendered as terminal progress bars for the
10
+ * `agents view` command.
9
11
  */
10
12
  import { execFile } from 'child_process';
11
13
  import { createHash } from 'crypto';
@@ -19,6 +21,7 @@ import { decodeJwtPayload, decryptDroidAuthPayload } from './agents.js';
19
21
  import { walkForFiles } from './fs-walk.js';
20
22
  import { getKeychainToken, setKeychainToken, deleteKeychainToken, isKeychainBackendOverridden, } from './secrets/index.js';
21
23
  import { resolveClaudeSetupToken } from './claude-account-token.js';
24
+ import { formatBackoffRemaining, noteUsageRateLimited, usageRateLimitedUntil, } from './usage-backoff.js';
22
25
  import { getCacheDir } from './state.js';
23
26
  const execFileAsync = promisify(execFile);
24
27
  const CLAUDE_USAGE_URL = 'https://api.anthropic.com/api/oauth/usage';
@@ -26,6 +29,58 @@ const CLAUDE_TOKEN_URL = 'https://platform.claude.com/v1/oauth/token';
26
29
  const CLAUDE_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
27
30
  const CLAUDE_OAUTH_BETA_HEADER = 'oauth-2025-04-20';
28
31
  const CLAUDE_REFRESH_LEEWAY_MS = 5 * 60 * 1000;
32
+ /**
33
+ * Why a usage read produced no snapshot, when the cause is the credential or the
34
+ * server rather than the payload. Every provider used to return `error: null`
35
+ * for all three, which made an account nobody can read indistinguishable from a
36
+ * healthy one: the caller fell back to whatever was in the SWR cache and
37
+ * rendered its bars as fact. On `yosemite-s1` that hid five Claude accounts
38
+ * whose stored access token had expired — one of them eleven days earlier —
39
+ * behind a cache frozen for 26h, and balanced routing launched into an account
40
+ * that was actually at its weekly cap.
41
+ *
42
+ * No usage read ever refreshes a token (RUSH-1822 for Claude; the same rule for
43
+ * Kimi/Droid/Cursor, whose own CLIs rotate on their next launch), so an expired
44
+ * credential cannot heal on its own — the account stays unreadable until that
45
+ * agent actually runs, or a long-lived token is provisioned for it.
46
+ *
47
+ * Shared across all four networked providers on purpose: the failure shape is
48
+ * identical, and wiring only Claude would leave `agents view --refresh`
49
+ * reporting Claude accounts while silently presenting stale Kimi, Droid, and
50
+ * Cursor readings as confirmed.
51
+ */
52
+ export function usageNoCredentialError(agent) {
53
+ return `No readable ${agent} credential — sign in, or provision a long-lived token for this account.`;
54
+ }
55
+ export function usageExpiredCredentialError(agent) {
56
+ return `${agent} credential expired — re-auth this account (a usage read never refreshes it).`;
57
+ }
58
+ export function usageRejectedError(agent, status) {
59
+ return status === 429
60
+ ? `${agent} is rate-limiting the usage endpoint for this machine (HTTP 429).`
61
+ : `${agent} rejected the usage read (HTTP ${status}).`;
62
+ }
63
+ /**
64
+ * The read threw rather than answering — a timeout, DNS/TLS failure, a payload
65
+ * that would not parse, a credential that would not decrypt. Every provider
66
+ * swallowed these into `error: null`, which is the same silence as an expired
67
+ * token: the caller renders a stale snapshot as confirmed. The cause is carried
68
+ * verbatim because these are the failures a user cannot otherwise see.
69
+ */
70
+ /**
71
+ * The provider told us to back off and we are still inside that window, so this
72
+ * read made no request at all. Distinct from `usageRejectedError(agent, 429)`,
73
+ * which is the 429 itself: this one says we are *honouring* it.
74
+ */
75
+ export function usageThrottledError(agent, untilMs) {
76
+ return `${agent} rate-limited this machine — not retrying for ${formatBackoffRemaining(untilMs)}.`;
77
+ }
78
+ export function usageUnreachableError(agent, cause) {
79
+ const detail = cause instanceof Error ? cause.message : typeof cause === 'string' ? cause : '';
80
+ return detail
81
+ ? `${agent} usage read failed: ${detail}`
82
+ : `${agent} usage read failed.`;
83
+ }
29
84
  /**
30
85
  * True when a Claude OAuth access token is within the refresh leeway of expiry
31
86
  * (or already expired) — i.e. it "would need a refresh" before the next use.
@@ -67,6 +122,7 @@ const USAGE_SOURCES = {
67
122
  droid: { fetch: getDroidUsageInfo, network: true },
68
123
  grok: { fetch: getGrokUsageInfo, network: false },
69
124
  cursor: { fetch: getCursorUsageInfo, network: true },
125
+ antigravity: { fetch: getAntigravityUsageInfo, network: true },
70
126
  };
71
127
  export const USAGE_SOURCE_AGENT_IDS = Object.keys(USAGE_SOURCES);
72
128
  function getUsageSource(agentId) {
@@ -109,11 +165,11 @@ export function buildCanonicalUsageContext(inputs) {
109
165
  return { canonicalByUsageKey, usageFetchInputs };
110
166
  }
111
167
  /**
112
- * Whether an agent exposes usage/limit data we can render — Claude/Kimi/Droid/Cursor
113
- * via a live API, Codex/Grok via local session logs. Everything else has no usage
114
- * concept, so callers use this to decide whether a missing snapshot is worth
115
- * flagging as "usage unavailable" (a signed-in Claude account with no data)
116
- * versus simply not applicable (Antigravity, OpenCode).
168
+ * Whether an agent exposes usage/limit data we can render — Claude/Kimi/Droid/
169
+ * Cursor/Antigravity via a live API, Codex/Grok via local session logs.
170
+ * Everything else has no usage concept, so callers use this to decide whether
171
+ * a missing snapshot is worth flagging as "usage unavailable" (a signed-in
172
+ * Claude account with no data) versus simply not applicable (OpenCode).
117
173
  */
118
174
  export function agentReportsUsage(agentId) {
119
175
  return getUsageSource(agentId) !== undefined;
@@ -137,6 +193,18 @@ export async function getUsageInfoByIdentity(inputs, opts) {
137
193
  }
138
194
  const USAGE_CACHE_FRESH_MS = 2 * 60 * 1000; // 2 minutes — fresh window: don't refresh.
139
195
  const USAGE_CACHE_SWR_MS = 24 * 60 * 60 * 1000; // 24 hours — beyond this, block on live fetch.
196
+ /**
197
+ * How stale a cached snapshot may be before the read stops serving it and blocks
198
+ * on the network. Defaults to the full 24h stale-while-revalidate window; a
199
+ * caller that is about to ROUTE on the number passes a shorter `maxAgeMs` and
200
+ * gets a live read instead of a day-old one. Never widens past 24h — a caller
201
+ * cannot opt into more staleness than the cache policy allows.
202
+ */
203
+ export function swrWindowMsFor(maxAgeMs) {
204
+ if (maxAgeMs === undefined || !Number.isFinite(maxAgeMs))
205
+ return USAGE_CACHE_SWR_MS;
206
+ return Math.min(USAGE_CACHE_SWR_MS, Math.max(0, maxAgeMs));
207
+ }
140
208
  /** In-process dedup: don't fire concurrent background refreshes for the same identity. */
141
209
  const inFlightRefreshes = new Map();
142
210
  /**
@@ -159,8 +227,8 @@ export async function getUsageInfoForIdentity(input, opts) {
159
227
  // stay off the network on the hot path. Everything else (Codex reads local
160
228
  // session logs) takes the legacy blocking path. The on-disk cache is shared and
161
229
  // keyed by usageKey, which is namespaced per agent (`claude:org=…`,
162
- // `kimi:user=…`, `droid:org=…`, `cursor:user=…`), so one cache file holds every
163
- // account without collision.
230
+ // `kimi:user=…`, `droid:org=…`, `cursor:user=…`, `antigravity:sub=…`), so one
231
+ // cache file holds every account without collision.
164
232
  const usesNetworkUsage = getUsageSource(input.agentId)?.network === true;
165
233
  if (!usesNetworkUsage || !usageKey) {
166
234
  return getUsageInfo(input.agentId, {
@@ -181,7 +249,15 @@ export async function getUsageInfoForIdentity(input, opts) {
181
249
  }
182
250
  // Stale-while-revalidate: cache exists and isn't ancient, return it now and
183
251
  // refresh in the background so the next invocation has fresh data.
184
- if (cached && ageMs < USAGE_CACHE_SWR_MS) {
252
+ //
253
+ // `maxAgeMs` shortens that window for callers that are about to make a
254
+ // DECISION on the number rather than display it. Serving a day-old snapshot
255
+ // to the account router is how a launch lands on an already-exhausted
256
+ // account: the box picks from its own cache, the background refresh lands
257
+ // after the choice is made, and nothing reconciles. Display callers keep the
258
+ // full 24h window and stay off the hot path.
259
+ const swrWindowMs = swrWindowMsFor(opts?.maxAgeMs);
260
+ if (cached && ageMs < swrWindowMs) {
185
261
  triggerBackgroundUsageRefresh(input, usageKey);
186
262
  return { snapshot: cached, error: null };
187
263
  }
@@ -262,6 +338,12 @@ export function formatUsageSummary(plan, snapshot, planWidth = 3, opts) {
262
338
  if (windows.length > 0) {
263
339
  parts.push(windows.join(' '));
264
340
  }
341
+ // The bars came from the cache and the live read that should have confirmed
342
+ // them failed, so they are the last thing we saw — not the current state.
343
+ // Drawing them unmarked is what let a 26h-old "48% used" read as fact.
344
+ if (opts?.unverified) {
345
+ parts.push(chalk.yellow('unverified'));
346
+ }
265
347
  }
266
348
  else if (opts?.unavailable) {
267
349
  // Signed-in account we could NOT fetch usage for (no live token in a reachable
@@ -408,17 +490,25 @@ async function getClaudeUsageInfo(options) {
408
490
  // path and usage needs only the access token, so it kills the Touch ID storm.
409
491
  const oauth = await loadClaudeOauth(options?.home, { accessTokenCache: true });
410
492
  if (!oauth?.accessToken) {
411
- return { snapshot: null, error: null };
493
+ return { snapshot: null, error: usageNoCredentialError('Claude') };
412
494
  }
413
495
  const requestedOrgId = normalizeString(options?.organizationId);
414
496
  const liveOrgId = normalizeString(oauth.organizationUuid);
415
497
  if (!isClaudeUsageOrgMatch(requestedOrgId, liveOrgId)) {
498
+ // Not a fault: this home is signed into a different org than the identity
499
+ // being read, so there is nothing to report for it.
416
500
  return { snapshot: null, error: null };
417
501
  }
418
502
  // Read-only: never refresh a single-use token just to read usage (RUSH-1822).
419
503
  const accessToken = claudeUsageAccessTokenNoRefresh(oauth);
420
504
  if (!accessToken) {
421
- return { snapshot: null, error: null };
505
+ return { snapshot: null, error: usageExpiredCredentialError('Claude') };
506
+ }
507
+ // Honour a live Retry-After rather than re-arming the penalty (see
508
+ // usage-backoff.ts). No request at all while the window is open.
509
+ const throttledUntil = usageRateLimitedUntil('claude');
510
+ if (throttledUntil) {
511
+ return { snapshot: null, error: usageThrottledError('Claude', throttledUntil) };
422
512
  }
423
513
  const response = await fetch(CLAUDE_USAGE_URL, {
424
514
  method: 'GET',
@@ -431,7 +521,10 @@ async function getClaudeUsageInfo(options) {
431
521
  signal: AbortSignal.timeout(5000),
432
522
  });
433
523
  if (!response.ok) {
434
- return { snapshot: null, error: formatClaudeUsageError(response.status) };
524
+ if (response.status === 429) {
525
+ noteUsageRateLimited('claude', response.headers.get('retry-after'));
526
+ }
527
+ return { snapshot: null, error: usageRejectedError('Claude', response.status) };
435
528
  }
436
529
  const data = await response.json();
437
530
  const windows = normalizeClaudeWindows(data);
@@ -448,8 +541,11 @@ async function getClaudeUsageInfo(options) {
448
541
  error: null,
449
542
  };
450
543
  }
451
- catch {
452
- return { snapshot: null, error: 'Usage data unavailable right now.' };
544
+ catch (err) {
545
+ // A thrown request (timeout, DNS, TLS, a malformed payload) is a failed
546
+ // read like any other — staying silent here would hand the caller a stale
547
+ // snapshot to render as confirmed, which is the bug this file just closed.
548
+ return { snapshot: null, error: usageUnreachableError('Claude', err) };
453
549
  }
454
550
  }
455
551
  /**
@@ -492,15 +588,21 @@ async function getKimiUsageInfo(options) {
492
588
  try {
493
589
  const credPath = resolveKimiCredentialPath(options?.home);
494
590
  if (!credPath)
495
- return { snapshot: null, error: null };
591
+ return { snapshot: null, error: usageNoCredentialError('Kimi') };
496
592
  const cred = JSON.parse(fs.readFileSync(credPath, 'utf-8'));
497
593
  const accessToken = cred?.access_token;
498
594
  if (typeof accessToken !== 'string' || !accessToken) {
499
- return { snapshot: null, error: null };
595
+ return { snapshot: null, error: usageNoCredentialError('Kimi') };
500
596
  }
501
597
  const expiresAt = typeof cred?.expires_at === 'number' ? cred.expires_at : null;
502
598
  if (expiresAt !== null && Date.now() / 1000 >= expiresAt) {
503
- return { snapshot: null, error: null };
599
+ return { snapshot: null, error: usageExpiredCredentialError('Kimi') };
600
+ }
601
+ // Honour a live Retry-After rather than re-arming the penalty (see
602
+ // usage-backoff.ts). No request at all while the window is open.
603
+ const throttledUntil = usageRateLimitedUntil('kimi');
604
+ if (throttledUntil) {
605
+ return { snapshot: null, error: usageThrottledError('Kimi', throttledUntil) };
504
606
  }
505
607
  const response = await fetch(KIMI_USAGES_URL, {
506
608
  method: 'GET',
@@ -510,10 +612,13 @@ async function getKimiUsageInfo(options) {
510
612
  },
511
613
  signal: AbortSignal.timeout(5000),
512
614
  });
513
- // 401/403/404 => expired token or no Kimi For Coding subscription; render
514
- // nothing rather than a misleading empty bar.
615
+ // 401/403 => expired token, 404 => no Kimi For Coding subscription. Either
616
+ // way there are no bars to draw, and the status is what tells them apart.
515
617
  if (!response.ok) {
516
- return { snapshot: null, error: null };
618
+ if (response.status === 429) {
619
+ noteUsageRateLimited('kimi', response.headers.get('retry-after'));
620
+ }
621
+ return { snapshot: null, error: usageRejectedError('Kimi', response.status) };
517
622
  }
518
623
  const data = await response.json();
519
624
  const windows = normalizeKimiWindows(data);
@@ -531,8 +636,11 @@ async function getKimiUsageInfo(options) {
531
636
  error: null,
532
637
  };
533
638
  }
534
- catch {
535
- return { snapshot: null, error: null };
639
+ catch (err) {
640
+ // A thrown request (timeout, DNS, TLS, a malformed payload) is a failed
641
+ // read like any other — staying silent here would hand the caller a stale
642
+ // snapshot to render as confirmed, which is the bug this file just closed.
643
+ return { snapshot: null, error: usageUnreachableError('Kimi', err) };
536
644
  }
537
645
  }
538
646
  /** Normalize the Kimi /usages payload into the common UsageWindow shape. */
@@ -618,11 +726,17 @@ async function getDroidUsageInfo(options) {
618
726
  const cred = decryptDroidAuthPayload(options?.home || os.homedir());
619
727
  const accessToken = cred?.access_token;
620
728
  if (typeof accessToken !== 'string' || !accessToken) {
621
- return { snapshot: null, error: null };
729
+ return { snapshot: null, error: usageNoCredentialError('Droid') };
622
730
  }
623
731
  const exp = decodeJwtPayload(accessToken)?.exp;
624
732
  if (typeof exp === 'number' && Date.now() / 1000 >= exp) {
625
- return { snapshot: null, error: null };
733
+ return { snapshot: null, error: usageExpiredCredentialError('Droid') };
734
+ }
735
+ // Honour a live Retry-After rather than re-arming the penalty (see
736
+ // usage-backoff.ts). No request at all while the window is open.
737
+ const throttledUntil = usageRateLimitedUntil('droid');
738
+ if (throttledUntil) {
739
+ return { snapshot: null, error: usageThrottledError('Droid', throttledUntil) };
626
740
  }
627
741
  const response = await fetch(DROID_USAGE_URL, {
628
742
  method: 'GET',
@@ -632,10 +746,12 @@ async function getDroidUsageInfo(options) {
632
746
  },
633
747
  signal: AbortSignal.timeout(5000),
634
748
  });
635
- // 401 => revoked/expired token; render nothing rather than a misleading
636
- // empty bar.
749
+ // 401 => revoked/expired token. No bars to draw, and the status says why.
637
750
  if (!response.ok) {
638
- return { snapshot: null, error: null };
751
+ if (response.status === 429) {
752
+ noteUsageRateLimited('droid', response.headers.get('retry-after'));
753
+ }
754
+ return { snapshot: null, error: usageRejectedError('Droid', response.status) };
639
755
  }
640
756
  const data = await response.json();
641
757
  const windows = normalizeDroidWindows(data);
@@ -652,8 +768,11 @@ async function getDroidUsageInfo(options) {
652
768
  error: null,
653
769
  };
654
770
  }
655
- catch {
656
- return { snapshot: null, error: null };
771
+ catch (err) {
772
+ // A thrown request (timeout, DNS, TLS, a malformed payload) is a failed
773
+ // read like any other — staying silent here would hand the caller a stale
774
+ // snapshot to render as confirmed, which is the bug this file just closed.
775
+ return { snapshot: null, error: usageUnreachableError('Droid', err) };
657
776
  }
658
777
  }
659
778
  /** Probe Claude's OAuth token against the usage endpoint. Never refreshes — reports `expired` for a near-expiry token; see the comment below (RUSH-1822). */
@@ -677,6 +796,12 @@ export async function probeClaudeStatus(home, cliVersion) {
677
796
  if (claudeAccessTokenNeedsRefresh(oauth?.expiresAt ?? null)) {
678
797
  return { status: null, token: 'expired' };
679
798
  }
799
+ // A probe is a request like any other: while the provider's Retry-After
800
+ // window is open, report the throttle from the recorded state instead of
801
+ // firing again and re-arming it (usage-backoff.ts). This 3-min-cadence
802
+ // probe is what created the loop it now respects.
803
+ if (usageRateLimitedUntil('claude'))
804
+ return { status: 429, token: 'present' };
680
805
  try {
681
806
  const response = await fetch(CLAUDE_USAGE_URL, {
682
807
  method: 'GET',
@@ -688,6 +813,9 @@ export async function probeClaudeStatus(home, cliVersion) {
688
813
  },
689
814
  signal: AbortSignal.timeout(8000),
690
815
  });
816
+ if (response.status === 429) {
817
+ noteUsageRateLimited('claude', response.headers.get('retry-after'));
818
+ }
691
819
  return { status: response.status, token: 'present' };
692
820
  }
693
821
  catch (err) {
@@ -713,12 +841,23 @@ export async function probeKimiStatus(home) {
713
841
  return { status: null, token: 'missing' };
714
842
  if (expiresAt !== null && Date.now() / 1000 >= expiresAt)
715
843
  return { status: null, token: 'expired' };
844
+ // A probe is a request like any other: while the provider's Retry-After
845
+ // window is open, report the throttle from the recorded state instead of
846
+ // firing again and re-arming it (usage-backoff.ts). This 3-min-cadence probe
847
+ // is what created the loop it now respects. It sits AFTER the local
848
+ // missing/expired checks — as in probeClaudeStatus and probeDroidStatus — so
849
+ // a genuinely broken credential is never misreported as merely throttled.
850
+ if (usageRateLimitedUntil('kimi'))
851
+ return { status: 429, token: 'present' };
716
852
  try {
717
853
  const response = await fetch(KIMI_USAGES_URL, {
718
854
  method: 'GET',
719
855
  headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' },
720
856
  signal: AbortSignal.timeout(8000),
721
857
  });
858
+ if (response.status === 429) {
859
+ noteUsageRateLimited('kimi', response.headers.get('retry-after'));
860
+ }
722
861
  return { status: response.status, token: 'present' };
723
862
  }
724
863
  catch (err) {
@@ -734,12 +873,21 @@ export async function probeDroidStatus(home) {
734
873
  const exp = decodeJwtPayload(accessToken)?.exp;
735
874
  if (typeof exp === 'number' && Date.now() / 1000 >= exp)
736
875
  return { status: null, token: 'expired' };
876
+ // A probe is a request like any other: while the provider's Retry-After
877
+ // window is open, report the throttle from the recorded state instead of
878
+ // firing again and re-arming it (usage-backoff.ts). This 3-min-cadence
879
+ // probe is what created the loop it now respects.
880
+ if (usageRateLimitedUntil('droid'))
881
+ return { status: 429, token: 'present' };
737
882
  try {
738
883
  const response = await fetch(DROID_USAGE_URL, {
739
884
  method: 'GET',
740
885
  headers: { Authorization: `Bearer ${accessToken}`, Accept: 'application/json' },
741
886
  signal: AbortSignal.timeout(8000),
742
887
  });
888
+ if (response.status === 429) {
889
+ noteUsageRateLimited('droid', response.headers.get('retry-after'));
890
+ }
743
891
  return { status: response.status, token: 'present' };
744
892
  }
745
893
  catch (err) {
@@ -1320,13 +1468,6 @@ export async function isClaudeAuthValid(home) {
1320
1468
  function getClaudeUserAgent(cliVersion) {
1321
1469
  return cliVersion ? `claude-code/${cliVersion}` : 'claude-code';
1322
1470
  }
1323
- /** Map an HTTP status code to a user-facing error message. */
1324
- function formatClaudeUsageError(status) {
1325
- if (status === 429) {
1326
- return 'Usage data unavailable right now.';
1327
- }
1328
- return 'Could not load usage data right now.';
1329
- }
1330
1471
  /** Clamp a numeric value to 0..100, returning null for non-finite values. */
1331
1472
  function normalizePercent(value) {
1332
1473
  if (typeof value !== 'number' || !Number.isFinite(value)) {
@@ -1613,12 +1754,16 @@ async function getCursorUsageInfo(options) {
1613
1754
  const base = options?.home || os.homedir();
1614
1755
  const creds = readCursorCredentials(base);
1615
1756
  if (!creds)
1616
- return { snapshot: null, error: null };
1757
+ return { snapshot: null, error: usageNoCredentialError('Cursor') };
1617
1758
  const exp = decodeJwtPayload(creds.accessToken)?.exp;
1618
1759
  if (typeof exp === 'number' && Date.now() / 1000 >= exp) {
1619
- return { snapshot: null, error: null };
1760
+ return { snapshot: null, error: usageExpiredCredentialError('Cursor') };
1620
1761
  }
1621
1762
  const url = `${CURSOR_USAGE_URL}?user=${encodeURIComponent(creds.sub)}`;
1763
+ const throttledUntil = usageRateLimitedUntil('cursor');
1764
+ if (throttledUntil) {
1765
+ return { snapshot: null, error: usageThrottledError('Cursor', throttledUntil) };
1766
+ }
1622
1767
  const response = await fetch(url, {
1623
1768
  method: 'GET',
1624
1769
  headers: {
@@ -1627,10 +1772,14 @@ async function getCursorUsageInfo(options) {
1627
1772
  },
1628
1773
  signal: AbortSignal.timeout(5000),
1629
1774
  });
1630
- // 401/redirect => revoked/expired session; render nothing rather than a
1631
- // misleading empty bar.
1632
- if (!response.ok)
1633
- return { snapshot: null, error: null };
1775
+ // 401/redirect => revoked/expired session. No bars to draw, and the status
1776
+ // says why.
1777
+ if (!response.ok) {
1778
+ if (response.status === 429) {
1779
+ noteUsageRateLimited('cursor', response.headers.get('retry-after'));
1780
+ }
1781
+ return { snapshot: null, error: usageRejectedError('Cursor', response.status) };
1782
+ }
1634
1783
  const data = (await response.json());
1635
1784
  return {
1636
1785
  snapshot: {
@@ -1642,6 +1791,275 @@ async function getCursorUsageInfo(options) {
1642
1791
  error: null,
1643
1792
  };
1644
1793
  }
1794
+ catch (err) {
1795
+ // A thrown request (timeout, DNS, TLS, a malformed payload) is a failed
1796
+ // read like any other — staying silent here would hand the caller a stale
1797
+ // snapshot to render as confirmed, which is the bug this file just closed.
1798
+ return { snapshot: null, error: usageUnreachableError('Cursor', err) };
1799
+ }
1800
+ }
1801
+ // ---------------------------------------------------------------------------
1802
+ // Antigravity (`agy`) usage — Google Code Assist per-model quota buckets
1803
+ // ---------------------------------------------------------------------------
1804
+ const ANTIGRAVITY_TOKEN_URL = 'https://oauth2.googleapis.com/token';
1805
+ // Production Code Assist endpoint first; the daily track is where `agy` itself
1806
+ // points when the account is enrolled in the daily channel (its log shows
1807
+ // daily-cloudcode-pa), so fall back to it when prod rejects the call.
1808
+ const ANTIGRAVITY_QUOTA_URLS = [
1809
+ 'https://cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota',
1810
+ 'https://daily-cloudcode-pa.googleapis.com/v1internal:retrieveUserQuota',
1811
+ ];
1812
+ // The public installed-app OAuth client the released `agy` binary itself
1813
+ // ships (Google installed-app clients are non-confidential by design — the
1814
+ // same client community tooling uses). Needed because a Google token refresh
1815
+ // requires the client id/secret pair the login was minted under.
1816
+ const ANTIGRAVITY_CLIENT_ID = '1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com';
1817
+ const ANTIGRAVITY_CLIENT_SECRET = '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';
1818
+ /** Refresh leeway — treat an access token expiring within a minute as expired. */
1819
+ const ANTIGRAVITY_REFRESH_LEEWAY_MS = 60 * 1000;
1820
+ /**
1821
+ * Parse a stored `agy` OAuth payload into its token. Handles both on-disk
1822
+ * shapes: the raw `{ token: {…} }` JSON (Linux file fallback) and the
1823
+ * `go-keyring-base64:<base64>` wrapper zalando/go-keyring writes into the
1824
+ * macOS Keychain item (service `gemini`, account `antigravity`). Never throws
1825
+ * (malformed input => null).
1826
+ */
1827
+ export function parseAntigravityOauthPayload(raw) {
1828
+ try {
1829
+ let text = raw.trim();
1830
+ if (text.startsWith('go-keyring-base64:')) {
1831
+ text = Buffer.from(text.slice('go-keyring-base64:'.length), 'base64').toString('utf-8');
1832
+ }
1833
+ const token = JSON.parse(text)?.token;
1834
+ if (!token || typeof token !== 'object')
1835
+ return null;
1836
+ if (typeof token.access_token !== 'string' && typeof token.refresh_token !== 'string') {
1837
+ return null;
1838
+ }
1839
+ return token;
1840
+ }
1841
+ catch {
1842
+ return null;
1843
+ }
1844
+ }
1845
+ /**
1846
+ * True when the stored access token is expired (or inside the refresh leeway).
1847
+ * A missing/unparseable expiry is treated as still-fresh — the quota call
1848
+ * below is the source of truth if the token is actually dead (401 => render
1849
+ * nothing), and we never want to force a refresh without evidence.
1850
+ */
1851
+ export function antigravityTokenNeedsRefresh(expiry, nowMs = Date.now()) {
1852
+ if (!expiry)
1853
+ return false;
1854
+ const ms = Date.parse(expiry);
1855
+ if (Number.isNaN(ms))
1856
+ return false;
1857
+ return nowMs + ANTIGRAVITY_REFRESH_LEEWAY_MS >= ms;
1858
+ }
1859
+ /**
1860
+ * Resolve the `agy` OAuth credential file. agy is a self-updating global
1861
+ * install (no per-version homes), but check the passed home first and then the
1862
+ * active location under the real HOME — mirrors resolveKimiCredentialPath.
1863
+ * Present only on Linux without a Secret Service daemon; macOS logins live in
1864
+ * the Keychain instead.
1865
+ */
1866
+ function resolveAntigravityCredentialPath(home) {
1867
+ const rel = ['.gemini', 'antigravity-cli', 'antigravity-oauth-token'];
1868
+ const perHome = path.join(home || os.homedir(), ...rel);
1869
+ try {
1870
+ if (fs.existsSync(perHome))
1871
+ return perHome;
1872
+ }
1873
+ catch { /* unreadable */ }
1874
+ const active = path.join(process.env.AGENTS_REAL_HOME || os.homedir(), ...rel);
1875
+ if (active !== perHome) {
1876
+ try {
1877
+ if (fs.existsSync(active))
1878
+ return active;
1879
+ }
1880
+ catch { /* unreadable */ }
1881
+ }
1882
+ return null;
1883
+ }
1884
+ /**
1885
+ * Load the stored `agy` OAuth token: the file fallback first, then the OS
1886
+ * keyring (macOS Keychain / Linux Secret Service — go-keyring's two stores;
1887
+ * the probe command pair mirrors antigravityOsKeyringProbe in agents.ts, with
1888
+ * `-w` on macOS to read the secret value, not just metadata). Returns null on
1889
+ * Windows or when no readable credential exists. Honors the
1890
+ * AGENTS_NO_KEYCHAIN_PROBE=1 test guard.
1891
+ */
1892
+ async function loadAntigravityOauth(home) {
1893
+ const credPath = resolveAntigravityCredentialPath(home);
1894
+ if (credPath) {
1895
+ try {
1896
+ const parsed = parseAntigravityOauthPayload(fs.readFileSync(credPath, 'utf-8'));
1897
+ if (parsed)
1898
+ return parsed;
1899
+ }
1900
+ catch { /* unreadable file — fall through to the keyring */ }
1901
+ }
1902
+ if (process.env.AGENTS_NO_KEYCHAIN_PROBE === '1')
1903
+ return null;
1904
+ const probe = process.platform === 'darwin'
1905
+ ? { cmd: 'security', args: ['find-generic-password', '-w', '-s', 'gemini', '-a', 'antigravity'] }
1906
+ : process.platform === 'linux'
1907
+ ? { cmd: 'secret-tool', args: ['lookup', 'service', 'gemini', 'username', 'antigravity'] }
1908
+ : null;
1909
+ if (!probe)
1910
+ return null;
1911
+ try {
1912
+ const { stdout } = await execFileAsync(probe.cmd, probe.args, { timeout: 5000 });
1913
+ return parseAntigravityOauthPayload(stdout);
1914
+ }
1915
+ catch {
1916
+ return null;
1917
+ }
1918
+ }
1919
+ /**
1920
+ * Refresh an `agy` access token against Google's token endpoint. This is safe
1921
+ * from a read path in a way Claude/WorkOS refreshes are NOT: Google's OAuth
1922
+ * refresh tokens are stable and non-rotating — a refresh mints a new access
1923
+ * token and leaves the refresh token (and every other live access token)
1924
+ * valid, so refreshing here cannot invalidate a concurrently running `agy`.
1925
+ * We still never write the refreshed token back: `agy` rewrites its own
1926
+ * keychain item on launch, and a read-only usage fetch must not mutate the
1927
+ * user's credential.
1928
+ */
1929
+ async function refreshAntigravityAccessToken(refreshToken) {
1930
+ try {
1931
+ const response = await fetch(ANTIGRAVITY_TOKEN_URL, {
1932
+ method: 'POST',
1933
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
1934
+ body: new URLSearchParams({
1935
+ client_id: ANTIGRAVITY_CLIENT_ID,
1936
+ client_secret: ANTIGRAVITY_CLIENT_SECRET,
1937
+ refresh_token: refreshToken,
1938
+ grant_type: 'refresh_token',
1939
+ }).toString(),
1940
+ signal: AbortSignal.timeout(8000),
1941
+ });
1942
+ if (!response.ok)
1943
+ return null;
1944
+ const data = (await response.json());
1945
+ return typeof data.access_token === 'string' && data.access_token ? data.access_token : null;
1946
+ }
1947
+ catch {
1948
+ return null;
1949
+ }
1950
+ }
1951
+ /**
1952
+ * POST :retrieveUserQuota against the Code Assist endpoints in order, returning
1953
+ * the first successful bucket list. null when every endpoint rejects (expired
1954
+ * token, no quota API for the account) or the network fails.
1955
+ */
1956
+ async function fetchAntigravityQuota(accessToken) {
1957
+ for (const url of ANTIGRAVITY_QUOTA_URLS) {
1958
+ try {
1959
+ const response = await fetch(url, {
1960
+ method: 'POST',
1961
+ headers: {
1962
+ Authorization: `Bearer ${accessToken}`,
1963
+ 'Content-Type': 'application/json',
1964
+ Accept: 'application/json',
1965
+ },
1966
+ body: '{}',
1967
+ signal: AbortSignal.timeout(5000),
1968
+ });
1969
+ if (!response.ok)
1970
+ continue;
1971
+ const data = (await response.json());
1972
+ return Array.isArray(data?.buckets) ? data.buckets : [];
1973
+ }
1974
+ catch {
1975
+ continue;
1976
+ }
1977
+ }
1978
+ return null;
1979
+ }
1980
+ /** Compact model tag for the inline bar — 'gemini-2.5-flash-lite' => '2.5FL'. */
1981
+ export function antigravityModelShortLabel(modelId) {
1982
+ const stripped = modelId.replace(/^gemini-/i, '');
1983
+ const parts = stripped.split('-').filter(Boolean);
1984
+ if (parts.length === 0)
1985
+ return modelId;
1986
+ const [version, ...rest] = parts;
1987
+ return version + rest.map((part) => (part[0] ? part[0].toUpperCase() : '')).join('');
1988
+ }
1989
+ /**
1990
+ * Normalize the per-model quota buckets into the common UsageWindow shape —
1991
+ * one window per model (`gemini-3.1-pro`, `gemini-2.5-flash`, …), keyed
1992
+ * `session` since each bucket is a short-cycle quota with its own reset time.
1993
+ * Duplicate buckets for one model keep the LOWEST remaining fraction (the
1994
+ * most conservative read). Sorted most-used first so the bar closest to
1995
+ * throttling leads the row. `windowMinutes` stays null: the API reports only
1996
+ * the reset timestamp, not the window length, and an inferred 5h session
1997
+ * length would wrongly zero the SWR cache between resets.
1998
+ */
1999
+ export function normalizeAntigravityWindows(buckets) {
2000
+ const byModel = new Map();
2001
+ for (const bucket of buckets) {
2002
+ const modelId = normalizeString(bucket?.modelId);
2003
+ const remaining = bucket?.remainingFraction;
2004
+ if (!modelId || typeof remaining !== 'number' || !Number.isFinite(remaining))
2005
+ continue;
2006
+ const existing = byModel.get(modelId);
2007
+ if (!existing || remaining < existing.remaining) {
2008
+ byModel.set(modelId, { bucket, remaining });
2009
+ }
2010
+ }
2011
+ const windows = [];
2012
+ for (const [modelId, { bucket, remaining }] of byModel) {
2013
+ const usedPercent = normalizePercent((1 - remaining) * 100);
2014
+ if (usedPercent === null)
2015
+ continue;
2016
+ windows.push({
2017
+ key: 'session',
2018
+ label: modelId,
2019
+ shortLabel: antigravityModelShortLabel(modelId),
2020
+ usedPercent,
2021
+ resetsAt: parseDateValue(bucket.resetTime),
2022
+ windowMinutes: null,
2023
+ });
2024
+ }
2025
+ windows.sort((a, b) => b.usedPercent - a.usedPercent);
2026
+ return windows;
2027
+ }
2028
+ /**
2029
+ * Fetch Antigravity usage via Google Code Assist's :retrieveUserQuota — the
2030
+ * quota API `agy` itself talks to (its log shows the sibling :loadCodeAssist
2031
+ * and :fetchAvailableModels calls on the same host). Auth is the stored `agy`
2032
+ * OAuth token (OS keyring on macOS, file fallback on Linux), refreshed
2033
+ * in-memory when expired — safe because Google's refresh tokens are
2034
+ * non-rotating (see refreshAntigravityAccessToken).
2035
+ */
2036
+ async function getAntigravityUsageInfo(options) {
2037
+ try {
2038
+ const token = await loadAntigravityOauth(options?.home);
2039
+ if (!token)
2040
+ return { snapshot: null, error: null };
2041
+ let accessToken = normalizeString(token.access_token);
2042
+ if ((!accessToken || antigravityTokenNeedsRefresh(token.expiry)) && token.refresh_token) {
2043
+ accessToken = await refreshAntigravityAccessToken(token.refresh_token);
2044
+ }
2045
+ if (!accessToken)
2046
+ return { snapshot: null, error: null };
2047
+ const buckets = await fetchAntigravityQuota(accessToken);
2048
+ if (!buckets)
2049
+ return { snapshot: null, error: null };
2050
+ const windows = normalizeAntigravityWindows(buckets);
2051
+ if (windows.length === 0)
2052
+ return { snapshot: null, error: null };
2053
+ return {
2054
+ snapshot: {
2055
+ source: 'live',
2056
+ sourceLabel: 'live account data',
2057
+ capturedAt: new Date(),
2058
+ windows,
2059
+ },
2060
+ error: null,
2061
+ };
2062
+ }
1645
2063
  catch {
1646
2064
  return { snapshot: null, error: null };
1647
2065
  }