@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.
- package/CHANGELOG.md +240 -0
- package/README.md +6 -0
- package/dist/bin/agents +0 -0
- package/dist/commands/exec.js +7 -1
- package/dist/commands/harness.d.ts +27 -0
- package/dist/commands/harness.js +120 -13
- package/dist/commands/profiles.d.ts +3 -0
- package/dist/commands/profiles.js +1 -1
- package/dist/commands/routines.d.ts +19 -0
- package/dist/commands/routines.js +28 -6
- package/dist/commands/secrets.d.ts +10 -1
- package/dist/commands/secrets.js +18 -6
- package/dist/commands/sessions-browser.d.ts +4 -0
- package/dist/commands/sessions-browser.js +51 -9
- package/dist/commands/sessions-favorite.d.ts +20 -0
- package/dist/commands/sessions-favorite.js +120 -0
- package/dist/commands/sessions.d.ts +103 -20
- package/dist/commands/sessions.js +356 -62
- package/dist/commands/setup-secrets.d.ts +7 -0
- package/dist/commands/setup-secrets.js +12 -9
- package/dist/commands/versions.js +12 -4
- package/dist/commands/view.d.ts +14 -1
- package/dist/commands/view.js +103 -128
- package/dist/lib/agents.d.ts +4 -2
- package/dist/lib/agents.js +21 -6
- package/dist/lib/hosts/dispatch.js +19 -1
- package/dist/lib/hq/floor.js +12 -0
- package/dist/lib/menubar/MenubarHelper.app/Contents/MacOS/MenubarHelper +0 -0
- package/dist/lib/picker.d.ts +27 -2
- package/dist/lib/picker.js +71 -7
- package/dist/lib/profiles.d.ts +48 -0
- package/dist/lib/profiles.js +67 -0
- package/dist/lib/rotate.d.ts +24 -2
- package/dist/lib/rotate.js +63 -6
- package/dist/lib/secrets/Agents CLI.app/Contents/CodeResources +0 -0
- package/dist/lib/secrets/Agents CLI.app/Contents/MacOS/Agents CLI +0 -0
- package/dist/lib/session/active.d.ts +109 -3
- package/dist/lib/session/active.js +269 -13
- package/dist/lib/session/db.d.ts +14 -0
- package/dist/lib/session/db.js +35 -0
- package/dist/lib/session/favorites.d.ts +39 -0
- package/dist/lib/session/favorites.js +101 -0
- package/dist/lib/session/host-link.d.ts +68 -0
- package/dist/lib/session/host-link.js +64 -0
- package/dist/lib/session/presence.d.ts +85 -0
- package/dist/lib/session/presence.js +150 -0
- package/dist/lib/session/remote-list.d.ts +10 -0
- package/dist/lib/session/remote-list.js +47 -9
- package/dist/lib/tmux/binary.d.ts +7 -0
- package/dist/lib/tmux/binary.js +11 -1
- package/dist/lib/types.d.ts +4 -3
- package/dist/lib/usage-backoff.d.ts +29 -0
- package/dist/lib/usage-backoff.js +165 -0
- package/dist/lib/usage.d.ts +112 -5
- package/dist/lib/usage.js +464 -46
- package/dist/lib/watchdog/runner.d.ts +13 -0
- package/dist/lib/watchdog/runner.js +16 -1
- 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,
|
|
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
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* progress bars for the
|
|
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/
|
|
113
|
-
* via a live API, Codex/Grok via local session logs.
|
|
114
|
-
* concept, so callers use this to decide whether
|
|
115
|
-
* flagging as "usage unavailable" (a signed-in
|
|
116
|
-
* versus simply not applicable (
|
|
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
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
|
514
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
|
636
|
-
// empty bar.
|
|
749
|
+
// 401 => revoked/expired token. No bars to draw, and the status says why.
|
|
637
750
|
if (!response.ok) {
|
|
638
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
|
1631
|
-
//
|
|
1632
|
-
if (!response.ok)
|
|
1633
|
-
|
|
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
|
}
|