shariq-pi-extensions 0.2.1 → 0.2.2

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/README.md CHANGED
@@ -22,7 +22,7 @@ Run `/reload` in an existing Pi session after installation; new sessions load th
22
22
 
23
23
  The package contains:
24
24
 
25
- - Antigravity provider
25
+ - Antigravity OAuth provider with multi-account quota-aware rotation and `/antigravity` dashboard
26
26
  - structured user questions
27
27
  - managed background terminals
28
28
  - copy-all transcript support
@@ -6,7 +6,7 @@ Last verified: 2026-08-20
6
6
 
7
7
  ### [Antigravity Provider](../extensions/antigravity-provider/README.md)
8
8
 
9
- Registers the `antigravity` provider and `/login antigravity` flow for supported Google Antigravity models. Use `/antigravity.doctor` to inspect non-secret provider status. Credentials remain in Pi auth storage.
9
+ Registers the `antigravity` provider and `/login antigravity` flow for supported Google Antigravity models. Repeating login adds or updates accounts in the secure `<agent-dir>/antigravity/accounts.json` pool. Requests use quota-aware least-recently-used balancing, rotate before streaming on account-specific auth/rate/quota/capacity failures, honor known reset times, and refresh stored OAuth tokens. `/antigravity` shows account state and live model quotas and can enable or disable accounts; `/antigravity.doctor` reports sanitized provider and rotation diagnostics.
10
10
 
11
11
  ### [Cursor provider](../extensions/cursor-provider/README.md)
12
12
 
@@ -4,11 +4,14 @@ Local persistent Pi provider for Google Antigravity-compatible models.
4
4
 
5
5
  - Package module: `extensions/antigravity-provider/`
6
6
  - Provider id: `antigravity`
7
- - Login command: `/login antigravity`
7
+ - Login command: `/login antigravity` (repeat once per Google account)
8
+ - Account and quota dashboard: `/antigravity`
8
9
  - Doctor command: `/antigravity.doctor`
9
10
 
10
11
  The provider includes an IPv4 OAuth token-exchange fallback for Node environments where the default request fails. Its deterministic catalog follows Antigravity CLI 1.1.13 model identifiers and runtime behavior.
11
12
 
13
+ Successful logins are added to `<agent-dir>/antigravity/accounts.json`, written with owner-only permissions. Existing Pi OAuth credentials are migrated into that pool without exposing token values. Requests select the least recently used eligible account, skip disabled/cooling/exhausted accounts, refresh expiring OAuth tokens, and rotate to another account when an auth, rate, quota, or capacity failure occurs before response streaming begins. Cached per-model remaining quota and reset times guide selection; `/antigravity` refreshes the authoritative catalog and can reversibly enable or disable accounts.
14
+
12
15
  Current public model IDs:
13
16
  - `antigravity/gemini-3.7-flash`
14
17
  - `antigravity/gemini-3.6-flash`
@@ -0,0 +1,343 @@
1
+ import { createHash } from "node:crypto";
2
+ import * as fs from "node:fs";
3
+ import * as path from "node:path";
4
+ import { getAgentDir } from "@earendil-works/pi-coding-agent";
5
+ import type { OAuthCredentials } from "./oauth.ts";
6
+ import { ANTIGRAVITY_ROUTING } from "./models.ts";
7
+
8
+ export const ANTIGRAVITY_STATE_DIR = path.join(getAgentDir(), "antigravity");
9
+ export const ANTIGRAVITY_ACCOUNTS_PATH = path.join(ANTIGRAVITY_STATE_DIR, "accounts.json");
10
+
11
+ const AUTH_COOLDOWN_MS = 24 * 60 * 60 * 1_000;
12
+ const RATE_COOLDOWN_MS = 30 * 60 * 1_000;
13
+ const CAPACITY_COOLDOWN_MS = 60 * 1_000;
14
+ const REFRESH_SKEW_MS = 60 * 1_000;
15
+
16
+ export type AntigravityQuotaGroup = "gemini" | "non-gemini";
17
+
18
+ export interface AntigravityQuotaEntry {
19
+ modelId: string;
20
+ displayName?: string;
21
+ group: AntigravityQuotaGroup;
22
+ remainingFraction: number;
23
+ resetTime?: string;
24
+ }
25
+
26
+ export interface AntigravityAccount {
27
+ id: string;
28
+ email?: string;
29
+ refresh: string;
30
+ access: string;
31
+ expires: number;
32
+ projectId: string;
33
+ disabled?: boolean;
34
+ addedAt: number;
35
+ lastUsedAt?: number;
36
+ cooldownUntil?: number;
37
+ cooldownReason?: "auth" | "rate" | "quota" | "capacity";
38
+ lastError?: string;
39
+ quota?: AntigravityQuotaEntry[];
40
+ quotaUpdatedAt?: number;
41
+ quotaError?: string;
42
+ }
43
+
44
+ interface AntigravityAccountFile {
45
+ version: 1;
46
+ accounts: AntigravityAccount[];
47
+ }
48
+
49
+ export interface AntigravityAccountStatus extends AntigravityAccount {
50
+ active: boolean;
51
+ }
52
+
53
+ function finiteNumber(value: unknown): number | undefined {
54
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
55
+ }
56
+
57
+ function credentialId(refresh: string, email?: string): string {
58
+ return createHash("sha256").update(refresh || email || "antigravity").digest("hex").slice(0, 20);
59
+ }
60
+
61
+ function normalizeAccount(value: unknown): AntigravityAccount | undefined {
62
+ if (!value || typeof value !== "object") return undefined;
63
+ const raw = value as Record<string, unknown>;
64
+ if (typeof raw.refresh !== "string" || !raw.refresh) return undefined;
65
+ if (typeof raw.access !== "string") return undefined;
66
+ const projectId = typeof raw.projectId === "string" ? raw.projectId : "";
67
+ if (!projectId) return undefined;
68
+ const email = typeof raw.email === "string" && raw.email ? raw.email : undefined;
69
+ const quota = Array.isArray(raw.quota)
70
+ ? raw.quota.flatMap((item): AntigravityQuotaEntry[] => {
71
+ if (!item || typeof item !== "object") return [];
72
+ const entry = item as Record<string, unknown>;
73
+ if (typeof entry.modelId !== "string") return [];
74
+ if (entry.group !== "gemini" && entry.group !== "non-gemini") return [];
75
+ const remainingFraction = finiteNumber(entry.remainingFraction);
76
+ if (remainingFraction === undefined) return [];
77
+ return [{
78
+ modelId: entry.modelId,
79
+ displayName: typeof entry.displayName === "string" ? entry.displayName : undefined,
80
+ group: entry.group,
81
+ remainingFraction: Math.max(0, Math.min(1, remainingFraction)),
82
+ resetTime: typeof entry.resetTime === "string" ? entry.resetTime : undefined,
83
+ }];
84
+ })
85
+ : undefined;
86
+ return {
87
+ id: typeof raw.id === "string" && raw.id ? raw.id : credentialId(raw.refresh, email),
88
+ email,
89
+ refresh: raw.refresh,
90
+ access: raw.access,
91
+ expires: finiteNumber(raw.expires) ?? 0,
92
+ projectId,
93
+ disabled: raw.disabled === true,
94
+ addedAt: finiteNumber(raw.addedAt) ?? Date.now(),
95
+ lastUsedAt: finiteNumber(raw.lastUsedAt),
96
+ cooldownUntil: finiteNumber(raw.cooldownUntil),
97
+ cooldownReason: raw.cooldownReason === "auth" || raw.cooldownReason === "rate" || raw.cooldownReason === "quota" || raw.cooldownReason === "capacity" ? raw.cooldownReason : undefined,
98
+ lastError: typeof raw.lastError === "string" ? raw.lastError.slice(0, 300) : undefined,
99
+ quota,
100
+ quotaUpdatedAt: finiteNumber(raw.quotaUpdatedAt),
101
+ quotaError: typeof raw.quotaError === "string" ? raw.quotaError.slice(0, 300) : undefined,
102
+ };
103
+ }
104
+
105
+ export function loadAntigravityAccounts(): AntigravityAccount[] {
106
+ try {
107
+ const parsed = JSON.parse(fs.readFileSync(ANTIGRAVITY_ACCOUNTS_PATH, "utf8")) as Partial<AntigravityAccountFile>;
108
+ return Array.isArray(parsed.accounts)
109
+ ? parsed.accounts.map(normalizeAccount).filter((account): account is AntigravityAccount => Boolean(account))
110
+ : [];
111
+ } catch {
112
+ return [];
113
+ }
114
+ }
115
+
116
+ function saveAntigravityAccounts(accounts: AntigravityAccount[]) {
117
+ fs.mkdirSync(ANTIGRAVITY_STATE_DIR, { recursive: true, mode: 0o700 });
118
+ fs.chmodSync(ANTIGRAVITY_STATE_DIR, 0o700);
119
+ const temporary = `${ANTIGRAVITY_ACCOUNTS_PATH}.${process.pid}.tmp`;
120
+ fs.writeFileSync(temporary, `${JSON.stringify({ version: 1, accounts }, null, 2)}\n`, { mode: 0o600 });
121
+ fs.renameSync(temporary, ANTIGRAVITY_ACCOUNTS_PATH);
122
+ fs.chmodSync(ANTIGRAVITY_ACCOUNTS_PATH, 0o600);
123
+ }
124
+
125
+ function replaceAccount(id: string, update: (account: AntigravityAccount) => AntigravityAccount) {
126
+ const accounts = loadAntigravityAccounts();
127
+ const index = accounts.findIndex((account) => account.id === id);
128
+ if (index < 0) return undefined;
129
+ const next = update(accounts[index]!);
130
+ accounts[index] = next;
131
+ saveAntigravityAccounts(accounts);
132
+ return next;
133
+ }
134
+
135
+ export function upsertAntigravityAccount(
136
+ credentials: OAuthCredentials,
137
+ options: { clearCooldown?: boolean } = {},
138
+ ): AntigravityAccount {
139
+ const id = credentialId(credentials.refresh, credentials.email);
140
+ const accounts = loadAntigravityAccounts();
141
+ const index = accounts.findIndex((account) => account.id === id || Boolean(credentials.email && account.email === credentials.email));
142
+ const existing = index >= 0 ? accounts[index] : undefined;
143
+ const account: AntigravityAccount = {
144
+ ...existing,
145
+ id,
146
+ email: credentials.email || existing?.email,
147
+ refresh: credentials.refresh,
148
+ access: credentials.access,
149
+ expires: credentials.expires,
150
+ projectId: credentials.projectId || existing?.projectId || "",
151
+ addedAt: existing?.addedAt ?? Date.now(),
152
+ disabled: existing?.disabled ?? false,
153
+ cooldownUntil: options.clearCooldown ? undefined : existing?.cooldownUntil,
154
+ cooldownReason: options.clearCooldown ? undefined : existing?.cooldownReason,
155
+ lastError: options.clearCooldown ? undefined : existing?.lastError,
156
+ };
157
+ if (index >= 0) accounts[index] = account;
158
+ else accounts.push(account);
159
+ saveAntigravityAccounts(accounts);
160
+ return account;
161
+ }
162
+
163
+ export function setAntigravityAccountEnabled(id: string, enabled: boolean) {
164
+ return replaceAccount(id, (account) => ({
165
+ ...account,
166
+ disabled: !enabled,
167
+ ...(enabled ? { cooldownUntil: undefined, cooldownReason: undefined, lastError: undefined } : {}),
168
+ }));
169
+ }
170
+
171
+ export function removeAntigravityAccount(id: string) {
172
+ const accounts = loadAntigravityAccounts();
173
+ const next = accounts.filter((account) => account.id !== id);
174
+ if (next.length === accounts.length) return false;
175
+ saveAntigravityAccounts(next);
176
+ return true;
177
+ }
178
+
179
+ function quotaGroupForModel(modelId: string): AntigravityQuotaGroup {
180
+ return modelId.startsWith("gemini-") ? "gemini" : "non-gemini";
181
+ }
182
+
183
+ function resetAt(entry: AntigravityQuotaEntry | undefined): number | undefined {
184
+ if (!entry?.resetTime) return undefined;
185
+ const value = Date.parse(entry.resetTime);
186
+ return Number.isFinite(value) ? value : undefined;
187
+ }
188
+
189
+ export function quotaForModel(account: AntigravityAccount, modelId: string) {
190
+ const group = quotaGroupForModel(modelId);
191
+ const routing = ANTIGRAVITY_ROUTING[modelId];
192
+ const relatedIds = new Set([
193
+ modelId,
194
+ routing?.off,
195
+ routing?.defaultRequestId,
196
+ ...Object.values(routing?.routing ?? {}),
197
+ ].filter((value): value is string => typeof value === "string"));
198
+ const related = account.quota?.filter((entry) =>
199
+ relatedIds.has(entry.modelId) || entry.modelId.startsWith(`${modelId}-`)
200
+ ) ?? [];
201
+ if (related.length) return related.sort((left, right) => left.remainingFraction - right.remainingFraction)[0];
202
+ const grouped = account.quota?.filter((entry) => entry.group === group) ?? [];
203
+ return grouped.sort((left, right) => left.remainingFraction - right.remainingFraction)[0];
204
+ }
205
+
206
+ export function eligibleAntigravityAccounts(
207
+ accounts: AntigravityAccount[],
208
+ modelId: string,
209
+ now = Date.now(),
210
+ ) {
211
+ return accounts
212
+ .filter((account) => {
213
+ if (account.disabled) return false;
214
+ if (account.cooldownUntil && account.cooldownUntil > now) return false;
215
+ const quota = quotaForModel(account, modelId);
216
+ const reset = resetAt(quota);
217
+ return !(quota && quota.remainingFraction <= 0 && reset && reset > now);
218
+ })
219
+ .sort((left, right) => (left.lastUsedAt ?? 0) - (right.lastUsedAt ?? 0));
220
+ }
221
+
222
+ export function selectAntigravityAccounts(modelId: string, now = Date.now()) {
223
+ return eligibleAntigravityAccounts(loadAntigravityAccounts(), modelId, now);
224
+ }
225
+
226
+ export function markAntigravityAccountUsed(id: string) {
227
+ return replaceAccount(id, (account) => ({ ...account, lastUsedAt: Date.now() }));
228
+ }
229
+
230
+ function parseRetryAfter(message: string, now = Date.now()): number | undefined {
231
+ const iso = message.match(/(?:reset(?:s| time)?(?: at| in)?|retry after)[^\n]*?(\d{4}-\d{2}-\d{2}T[^\s,;]+)/i)?.[1];
232
+ if (iso) {
233
+ const parsed = Date.parse(iso);
234
+ if (Number.isFinite(parsed) && parsed > now) return parsed;
235
+ }
236
+ const duration = message.match(/(?:reset(?:s)? in|retry after)\s*(?:(\d+)h)?\s*(?:(\d+)m)?\s*(?:(\d+(?:\.\d+)?)s)?/i);
237
+ if (!duration) return undefined;
238
+ const milliseconds = ((Number(duration[1] || 0) * 3600) + (Number(duration[2] || 0) * 60) + Number(duration[3] || 0)) * 1_000;
239
+ return milliseconds > 0 ? now + milliseconds : undefined;
240
+ }
241
+
242
+ export function classifyAntigravityFailure(message: string, modelId: string, account?: AntigravityAccount) {
243
+ const lower = message.toLowerCase();
244
+ const now = Date.now();
245
+ if (/\b401\b/.test(lower) || lower.includes("invalid_grant") || lower.includes("login expired") || lower.includes("credentials are invalid")) {
246
+ return { reason: "auth" as const, until: now + AUTH_COOLDOWN_MS };
247
+ }
248
+ if (/\b429\b/.test(lower) || lower.includes("rate limit") || lower.includes("quota reached") || lower.includes("resource exhausted")) {
249
+ const quotaReset = resetAt(account ? quotaForModel(account, modelId) : undefined);
250
+ const explicitReset = parseRetryAfter(message, now);
251
+ return {
252
+ reason: lower.includes("quota") || lower.includes("exhaust") ? "quota" as const : "rate" as const,
253
+ until: explicitReset || (quotaReset && quotaReset > now ? quotaReset : now + RATE_COOLDOWN_MS),
254
+ };
255
+ }
256
+ if (/\b503\b/.test(lower) || /\b529\b/.test(lower) || lower.includes("capacity") || lower.includes("overloaded")) {
257
+ return { reason: "capacity" as const, until: now + CAPACITY_COOLDOWN_MS };
258
+ }
259
+ return undefined;
260
+ }
261
+
262
+ export function markAntigravityAccountFailure(id: string, modelId: string, message: string) {
263
+ const account = loadAntigravityAccounts().find((entry) => entry.id === id);
264
+ const failure = classifyAntigravityFailure(message, modelId, account);
265
+ if (!failure) return false;
266
+ replaceAccount(id, (current) => ({
267
+ ...current,
268
+ cooldownUntil: failure.until,
269
+ cooldownReason: failure.reason,
270
+ lastError: message.slice(0, 300),
271
+ lastUsedAt: Date.now(),
272
+ }));
273
+ return true;
274
+ }
275
+
276
+ export function updateAntigravityAccountQuota(id: string, quota: AntigravityQuotaEntry[] | undefined, error?: string) {
277
+ return replaceAccount(id, (account) => ({
278
+ ...account,
279
+ ...(quota ? { quota, quotaUpdatedAt: Date.now(), quotaError: undefined } : { quotaError: error?.slice(0, 300) }),
280
+ }));
281
+ }
282
+
283
+ export function parseAntigravityQuota(value: unknown): AntigravityQuotaEntry[] {
284
+ const entries: AntigravityQuotaEntry[] = [];
285
+ const seen = new Set<string>();
286
+ const visit = (node: unknown, keyHint?: string) => {
287
+ if (!node || typeof node !== "object") return;
288
+ if (Array.isArray(node)) {
289
+ for (const item of node) visit(item);
290
+ return;
291
+ }
292
+ const raw = node as Record<string, unknown>;
293
+ const quota = raw.quotaInfo && typeof raw.quotaInfo === "object" ? raw.quotaInfo as Record<string, unknown> : raw;
294
+ const remaining = finiteNumber(quota.remainingFraction);
295
+ const modelId = [raw.modelId, raw.id, raw.name, raw.model, keyHint].find((item): item is string => typeof item === "string" && /gemini|claude|gpt-oss/i.test(item));
296
+ if (modelId && remaining !== undefined) {
297
+ const normalized = modelId.replace(/^models\//, "");
298
+ if (!seen.has(normalized)) {
299
+ seen.add(normalized);
300
+ entries.push({
301
+ modelId: normalized,
302
+ displayName: typeof raw.displayName === "string" ? raw.displayName : typeof raw.label === "string" ? raw.label : undefined,
303
+ group: normalized.startsWith("gemini-") ? "gemini" : "non-gemini",
304
+ remainingFraction: Math.max(0, Math.min(1, remaining)),
305
+ resetTime: typeof quota.resetTime === "string" ? quota.resetTime : undefined,
306
+ });
307
+ }
308
+ }
309
+ for (const [key, child] of Object.entries(raw)) {
310
+ if (child && typeof child === "object") visit(child, key);
311
+ }
312
+ };
313
+ visit(value);
314
+ return entries;
315
+ }
316
+
317
+ const refreshes = new Map<string, Promise<AntigravityAccount>>();
318
+
319
+ export function resolveAntigravityAccount(
320
+ account: AntigravityAccount,
321
+ refresh: (credentials: OAuthCredentials) => Promise<OAuthCredentials>,
322
+ now = Date.now(),
323
+ ): Promise<AntigravityAccount> {
324
+ if (account.access && account.expires > now + REFRESH_SKEW_MS) return Promise.resolve(account);
325
+ const existing = refreshes.get(account.id);
326
+ if (existing) return existing;
327
+ const pending = refresh({
328
+ refresh: account.refresh,
329
+ access: account.access,
330
+ expires: account.expires,
331
+ projectId: account.projectId,
332
+ email: account.email,
333
+ }).then(upsertAntigravityAccount).finally(() => refreshes.delete(account.id));
334
+ refreshes.set(account.id, pending);
335
+ return pending;
336
+ }
337
+
338
+ export function antigravityAccountStatuses(now = Date.now()): AntigravityAccountStatus[] {
339
+ return loadAntigravityAccounts().map((account) => ({
340
+ ...account,
341
+ active: !account.disabled && !(account.cooldownUntil && account.cooldownUntil > now),
342
+ }));
343
+ }
@@ -19,9 +19,20 @@ import {
19
19
  jsonOrTextError,
20
20
  loadCodeAssist,
21
21
  fetchAvailableRuntimeModel,
22
+ refreshAntigravityToken,
22
23
  DEFAULT_PROJECT_ID,
23
24
  } from "./oauth.ts";
24
25
  import { getAntigravityRequestModelId, PROVIDER_ID } from "./models.ts";
26
+ import {
27
+ classifyAntigravityFailure,
28
+ loadAntigravityAccounts,
29
+ markAntigravityAccountFailure,
30
+ markAntigravityAccountUsed,
31
+ resolveAntigravityAccount,
32
+ selectAntigravityAccounts,
33
+ type AntigravityAccount,
34
+ } from "./accounts.ts";
35
+ import { refreshAntigravityQuotas } from "./quotas.ts";
25
36
 
26
37
  const ANTIGRAVITY_SYSTEM_INSTRUCTION =
27
38
  "You are Antigravity, a powerful agentic AI coding assistant designed by Google DeepMind. " +
@@ -435,7 +446,7 @@ async function streamResponse(response: Response, stream: AssistantMessageEventS
435
446
  return hasContent;
436
447
  }
437
448
 
438
- export function streamAntigravity(model: any, context: any, options?: any): any {
449
+ function streamAntigravitySingle(model: any, context: any, options?: any): any {
439
450
  const stream = createAssistantMessageEventStream();
440
451
  void (async () => {
441
452
  const output = createOutput(model);
@@ -517,3 +528,83 @@ export function streamAntigravity(model: any, context: any, options?: any): any
517
528
  })();
518
529
  return stream;
519
530
  }
531
+
532
+ function antigravityErrorEvent(model: any, message: string) {
533
+ const output = createOutput(model);
534
+ output.stopReason = "error";
535
+ output.errorMessage = message;
536
+ return { type: "error" as const, reason: "error" as const, error: output };
537
+ }
538
+
539
+ function antigravityEventError(event: any): string | undefined {
540
+ if (event?.type !== "error") return undefined;
541
+ return event.error?.errorMessage || event.error?.message || event.reason;
542
+ }
543
+
544
+ export function streamAntigravity(model: any, context: any, options?: any): any {
545
+ const stream = createAssistantMessageEventStream();
546
+ void (async () => {
547
+ const stored = loadAntigravityAccounts();
548
+ const selected = selectAntigravityAccounts(model.id);
549
+ const candidates: Array<AntigravityAccount | undefined> = stored.length ? selected : [undefined];
550
+ if (!candidates.length) {
551
+ stream.push(antigravityErrorEvent(model, `No Antigravity account has available ${model.id} quota. Open /antigravity to refresh quotas or enable an account.`));
552
+ stream.end();
553
+ return;
554
+ }
555
+
556
+ let lastError = "No Antigravity account completed the request.";
557
+ for (const candidate of candidates) {
558
+ if (options?.signal?.aborted) throw options.signal.reason ?? new Error("Request was aborted");
559
+ let account = candidate;
560
+ let apiKey = options?.apiKey;
561
+ try {
562
+ if (account) {
563
+ account = await resolveAntigravityAccount(account, refreshAntigravityToken);
564
+ apiKey = JSON.stringify({ token: account.access, projectId: account.projectId });
565
+ }
566
+ } catch (error) {
567
+ lastError = safeError(error);
568
+ if (account) markAntigravityAccountFailure(account.id, model.id, lastError);
569
+ continue;
570
+ }
571
+
572
+ const inner = streamAntigravitySingle(model, context, { ...options, apiKey });
573
+ const buffered: any[] = [];
574
+ let started = false;
575
+ let rotate = false;
576
+ for await (const event of inner as AsyncIterable<any>) {
577
+ const error = antigravityEventError(event);
578
+ if (error && !started && account) {
579
+ lastError = error;
580
+ const retryable = Boolean(classifyAntigravityFailure(error, model.id, account));
581
+ if (retryable) {
582
+ markAntigravityAccountFailure(account.id, model.id, error);
583
+ void refreshAntigravityQuotas({ force: true, signal: options?.signal }).catch(() => {});
584
+ rotate = true;
585
+ break;
586
+ }
587
+ }
588
+ if (!started && event?.type === "start") {
589
+ started = true;
590
+ if (account) markAntigravityAccountUsed(account.id);
591
+ for (const pending of buffered) stream.push(pending);
592
+ buffered.length = 0;
593
+ }
594
+ if (started || event?.type === "error") stream.push(event);
595
+ else buffered.push(event);
596
+ }
597
+ if (rotate) continue;
598
+ for (const pending of buffered) stream.push(pending);
599
+ stream.end();
600
+ return;
601
+ }
602
+
603
+ stream.push(antigravityErrorEvent(model, `All configured Antigravity accounts are cooling down, exhausted, or failed. Last error: ${lastError}`));
604
+ stream.end();
605
+ })().catch((error) => {
606
+ stream.push(antigravityErrorEvent(model, safeError(error)));
607
+ stream.end();
608
+ });
609
+ return stream;
610
+ }
@@ -0,0 +1,248 @@
1
+ import type { ExtensionContext, KeybindingsManager } from "@earendil-works/pi-coding-agent";
2
+ import { truncateToWidth, type Component, type TUI } from "@earendil-works/pi-tui";
3
+ import { frameBottom, frameTop, joinSides, meter, oneLine, padLine, stateLabel } from "../../shared/tui-dashboard.ts";
4
+ import type { AntigravityAccountStatus, AntigravityQuotaEntry } from "./accounts.ts";
5
+
6
+ export interface AntigravityDashboardSnapshot {
7
+ authentication: string;
8
+ modelCount: number;
9
+ accounts: AntigravityAccountStatus[];
10
+ warning?: string;
11
+ }
12
+
13
+ type Theme = ExtensionContext["ui"]["theme"];
14
+
15
+ function remaining(value: number) {
16
+ return `${Math.round(Math.max(0, Math.min(1, value)) * 100)}%`;
17
+ }
18
+
19
+ function until(timestamp: number | string | undefined) {
20
+ const value = typeof timestamp === "number" ? timestamp : timestamp ? Date.parse(timestamp) : Number.NaN;
21
+ if (!Number.isFinite(value) || value <= Date.now()) return "now";
22
+ const seconds = Math.max(0, Math.round((value - Date.now()) / 1_000));
23
+ const days = Math.floor(seconds / 86_400);
24
+ const hours = Math.floor((seconds % 86_400) / 3_600);
25
+ const minutes = Math.floor((seconds % 3_600) / 60);
26
+ if (days) return `${days}d ${hours}h`;
27
+ if (hours) return `${hours}h ${minutes}m`;
28
+ return `${minutes}m`;
29
+ }
30
+
31
+ function groupQuota(quota: AntigravityQuotaEntry[] | undefined, group: "gemini" | "non-gemini") {
32
+ return (quota ?? []).filter((entry) => entry.group === group).sort((left, right) => left.remainingFraction - right.remainingFraction);
33
+ }
34
+
35
+ function accountState(account: AntigravityAccountStatus) {
36
+ if (account.disabled) return "disabled";
37
+ if (account.cooldownUntil && account.cooldownUntil > Date.now()) return account.cooldownReason === "auth" ? "auth cooldown" : "cooldown";
38
+ const quota = account.quota ?? [];
39
+ if (quota.length && quota.every((entry) => entry.remainingFraction <= 0 && (!entry.resetTime || Date.parse(entry.resetTime) > Date.now()))) return "exhausted";
40
+ if (account.quotaError && !quota.length) return "refresh error";
41
+ return quota.length ? "ready" : "not refreshed";
42
+ }
43
+
44
+ function stateColor(state: string) {
45
+ if (state === "ready") return "success" as const;
46
+ if (state === "not refreshed" || state === "disabled") return "muted" as const;
47
+ if (state === "refresh error" || state === "auth cooldown") return "error" as const;
48
+ return "warning" as const;
49
+ }
50
+
51
+ function compactGroup(label: string, entries: AntigravityQuotaEntry[]) {
52
+ if (!entries.length) return `${label}: unavailable`;
53
+ const worst = entries[0]!;
54
+ return `${label}: ${remaining(worst.remainingFraction)} remaining${worst.resetTime ? ` · resets ${until(worst.resetTime)}` : ""}`;
55
+ }
56
+
57
+ export class AntigravityDashboard implements Component {
58
+ private selected = 0;
59
+ private refreshing = false;
60
+ private closed = false;
61
+ private readonly tui: TUI;
62
+ private readonly theme: Theme;
63
+ private readonly keys: KeybindingsManager;
64
+ private snapshot: AntigravityDashboardSnapshot;
65
+ private readonly refreshData: (force: boolean) => Promise<AntigravityDashboardSnapshot>;
66
+ private readonly toggleAccount: (id: string, enabled: boolean) => Promise<AntigravityDashboardSnapshot>;
67
+ private readonly done: () => void;
68
+
69
+ constructor(
70
+ tui: TUI,
71
+ theme: Theme,
72
+ keys: KeybindingsManager,
73
+ snapshot: AntigravityDashboardSnapshot,
74
+ refreshData: (force: boolean) => Promise<AntigravityDashboardSnapshot>,
75
+ toggleAccount: (id: string, enabled: boolean) => Promise<AntigravityDashboardSnapshot>,
76
+ done: () => void,
77
+ ) {
78
+ this.tui = tui;
79
+ this.theme = theme;
80
+ this.keys = keys;
81
+ this.snapshot = snapshot;
82
+ this.refreshData = refreshData;
83
+ this.toggleAccount = toggleAccount;
84
+ this.done = done;
85
+ }
86
+
87
+ startRefresh(force: boolean) {
88
+ if (this.refreshing || this.closed) return;
89
+ this.refreshing = true;
90
+ this.tui.requestRender();
91
+ void this.refreshData(force)
92
+ .then((snapshot) => this.replaceSnapshot(snapshot))
93
+ .catch((error) => {
94
+ if (!this.closed) this.snapshot = { ...this.snapshot, warning: `Refresh failed: ${error instanceof Error ? error.message : String(error)}` };
95
+ })
96
+ .finally(() => {
97
+ if (!this.closed) {
98
+ this.refreshing = false;
99
+ this.tui.requestRender();
100
+ }
101
+ });
102
+ }
103
+
104
+ private replaceSnapshot(snapshot: AntigravityDashboardSnapshot) {
105
+ if (this.closed) return;
106
+ const id = this.snapshot.accounts[this.selected]?.id;
107
+ this.snapshot = snapshot;
108
+ const index = id ? snapshot.accounts.findIndex((account) => account.id === id) : -1;
109
+ this.selected = index >= 0 ? index : Math.min(this.selected, Math.max(0, snapshot.accounts.length - 1));
110
+ }
111
+
112
+ dispose() { this.closed = true; }
113
+ invalidate() {}
114
+
115
+ handleInput(data: string) {
116
+ const accounts = this.snapshot.accounts;
117
+ if (this.keys.matches(data, "tui.select.cancel")) {
118
+ this.closed = true;
119
+ this.done();
120
+ return;
121
+ }
122
+ if (this.keys.matches(data, "tui.select.up") || data === "k") {
123
+ if (accounts.length) this.selected = (this.selected - 1 + accounts.length) % accounts.length;
124
+ } else if (this.keys.matches(data, "tui.select.down") || data === "j") {
125
+ if (accounts.length) this.selected = (this.selected + 1) % accounts.length;
126
+ } else if (data === "r") {
127
+ this.startRefresh(true);
128
+ } else if (data === "d" && accounts[this.selected] && !this.refreshing) {
129
+ const account = accounts[this.selected]!;
130
+ this.refreshing = true;
131
+ void this.toggleAccount(account.id, account.disabled === true)
132
+ .then((snapshot) => this.replaceSnapshot(snapshot))
133
+ .catch((error) => {
134
+ if (!this.closed) this.snapshot = { ...this.snapshot, warning: error instanceof Error ? error.message : String(error) };
135
+ })
136
+ .finally(() => {
137
+ if (!this.closed) {
138
+ this.refreshing = false;
139
+ this.tui.requestRender();
140
+ }
141
+ });
142
+ }
143
+ this.tui.requestRender();
144
+ }
145
+
146
+ render(width: number) {
147
+ const rows = this.tui.terminal.rows || 30;
148
+ const bodyHeight = Math.max(8, rows - 7);
149
+ const accounts = this.snapshot.accounts;
150
+ this.selected = Math.min(this.selected, Math.max(0, accounts.length - 1));
151
+ const selected = accounts[this.selected];
152
+ const active = accounts.filter((account) => account.active).length;
153
+ const right = this.refreshing
154
+ ? this.theme.fg("warning", "refreshing…")
155
+ : this.snapshot.warning
156
+ ? this.theme.fg("warning", oneLine(this.snapshot.warning))
157
+ : this.theme.fg("muted", `${active}/${accounts.length} active · ${this.snapshot.modelCount} models`);
158
+ const title = ` ${this.theme.fg("accent", this.theme.bold("◆ ANTIGRAVITY"))} ${this.theme.fg("dim", `· ${this.snapshot.authentication}`)}`;
159
+ const lines = width >= 64
160
+ ? [joinSides(title, `${right} `, width)]
161
+ : [truncateToWidth(title, width, ""), truncateToWidth(` ${right}`, width, "")];
162
+ lines.push(frameTop(this.theme, width, `ACCOUNTS ${accounts.length} · QUOTA REMAINING`));
163
+ const inner = Math.max(1, width - 2);
164
+ if (width >= 104 && selected) {
165
+ const leftWidth = Math.max(46, Math.floor((inner - 1) * 0.45));
166
+ const rightWidth = Math.max(24, inner - leftWidth - 1);
167
+ const list = this.renderAccounts(leftWidth, bodyHeight, true);
168
+ const detail = this.renderDetail(selected, rightWidth, bodyHeight);
169
+ for (let row = 0; row < bodyHeight; row++) {
170
+ lines.push(this.theme.fg("border", "│") + padLine(list[row] ?? "", leftWidth) + this.theme.fg("borderMuted", "│") + padLine(detail[row] ?? "", rightWidth) + this.theme.fg("border", "│"));
171
+ }
172
+ } else {
173
+ const list = this.renderAccounts(inner, bodyHeight, false);
174
+ for (let row = 0; row < bodyHeight; row++) lines.push(this.theme.fg("border", "│") + padLine(list[row] ?? "", inner) + this.theme.fg("border", "│"));
175
+ }
176
+ lines.push(frameBottom(this.theme, width));
177
+ lines.push(truncateToWidth(`${this.theme.fg("accent", " ↑↓ / j k")} ${this.theme.fg("dim", "select")} ${this.theme.fg("accent", "r")} ${this.theme.fg("dim", "refresh")} ${this.theme.fg("accent", "d")} ${this.theme.fg("dim", "enable/disable")} ${this.theme.fg("accent", "esc")} ${this.theme.fg("dim", "close")}`, width, ""));
178
+ return lines.map((line) => truncateToWidth(line, width, ""));
179
+ }
180
+
181
+ private renderAccounts(width: number, height: number, compact: boolean) {
182
+ const accounts = this.snapshot.accounts;
183
+ if (!accounts.length) return [this.theme.fg("muted", " No Antigravity accounts saved."), this.theme.fg("dim", " Run /login antigravity once per Google account.")];
184
+ const rowsPerAccount = compact ? 1 : 4;
185
+ const visibleCount = Math.max(1, Math.floor(height / rowsPerAccount));
186
+ const start = Math.min(Math.max(0, this.selected - Math.floor(visibleCount / 2)), Math.max(0, accounts.length - visibleCount));
187
+ const lines: string[] = [];
188
+ for (const [offset, account] of accounts.slice(start, start + visibleCount).entries()) {
189
+ const index = start + offset;
190
+ const selected = index === this.selected;
191
+ const state = accountState(account);
192
+ const marker = selected ? this.theme.fg("accent", "◆") : " ";
193
+ const title = this.theme.fg(selected ? "accent" : "text", oneLine(account.email || `account-${account.id.slice(0, 6)}`));
194
+ const first = joinSides(` ${marker} ${title}`, `${stateLabel(this.theme, stateColor(state), state)} `, width);
195
+ lines.push(selected ? this.theme.bg("selectedBg", padLine(first, width)) : first);
196
+ if (!compact) {
197
+ lines.push(` ${this.theme.fg("muted", compactGroup("Gemini", groupQuota(account.quota, "gemini")))}`);
198
+ lines.push(` ${this.theme.fg("muted", compactGroup("Claude / GPT", groupQuota(account.quota, "non-gemini")))}`);
199
+ lines.push("");
200
+ }
201
+ }
202
+ return lines.slice(0, height);
203
+ }
204
+
205
+ private renderDetail(account: AntigravityAccountStatus, width: number, height: number) {
206
+ const state = accountState(account);
207
+ const lines = [
208
+ ` ${stateLabel(this.theme, stateColor(state), state)} ${this.theme.fg("accent", this.theme.bold(oneLine(account.email || `account-${account.id.slice(0, 6)}`)))}`,
209
+ ` ${this.theme.fg("dim", account.quotaUpdatedAt ? `quota updated ${until(account.quotaUpdatedAt + 15 * 60 * 1_000)} refresh window` : "quota has not been refreshed")}`,
210
+ ];
211
+ if (account.cooldownUntil && account.cooldownUntil > Date.now()) lines.push(` ${this.theme.fg("warning", `${account.cooldownReason || "rotation"} cooldown · ${until(account.cooldownUntil)}`)}`);
212
+ if (account.quotaError) lines.push(` ${this.theme.fg("error", oneLine(account.quotaError))}`);
213
+ if (account.lastError) lines.push(` ${this.theme.fg("warning", oneLine(account.lastError))}`);
214
+ lines.push("", ...this.renderQuotaGroup("Gemini", groupQuota(account.quota, "gemini"), width));
215
+ lines.push("", ...this.renderQuotaGroup("Claude / GPT-OSS", groupQuota(account.quota, "non-gemini"), width));
216
+ if (account.lastUsedAt) lines.push("", ` ${this.theme.fg("muted", "LAST USED")} ${this.theme.fg("text", new Date(account.lastUsedAt).toLocaleString())}`);
217
+ return lines.slice(0, height);
218
+ }
219
+
220
+ private renderQuotaGroup(name: string, entries: AntigravityQuotaEntry[], width: number) {
221
+ const lines = [` ${this.theme.fg("accent", this.theme.bold(`${name.toUpperCase()} · REMAINING`))}`];
222
+ if (!entries.length) return [...lines, ` ${this.theme.fg("muted", "Unavailable")}`];
223
+ const meterWidth = Math.max(10, width - 29);
224
+ for (const entry of entries.slice(0, 8)) {
225
+ const percent = entry.remainingFraction * 100;
226
+ const color = percent <= 0 ? "error" : percent <= 15 ? "warning" : "active";
227
+ const label = oneLine(entry.displayName || entry.modelId).slice(0, 16).padEnd(16);
228
+ lines.push(` ${this.theme.fg("muted", label)} ${meter(this.theme, percent, 100, meterWidth, color)} ${this.theme.fg("text", remaining(entry.remainingFraction))}${entry.resetTime ? ` ${this.theme.fg("dim", until(entry.resetTime))}` : ""}`);
229
+ }
230
+ return lines;
231
+ }
232
+ }
233
+
234
+ export async function openAntigravityDashboard(
235
+ ctx: ExtensionContext,
236
+ initial: AntigravityDashboardSnapshot,
237
+ refresh: (force: boolean) => Promise<AntigravityDashboardSnapshot>,
238
+ toggle: (id: string, enabled: boolean) => Promise<AntigravityDashboardSnapshot>,
239
+ ) {
240
+ await ctx.ui.custom<void>(
241
+ (tui, theme, keys, done) => {
242
+ const dashboard = new AntigravityDashboard(tui, theme, keys, initial, refresh, toggle, () => done(undefined));
243
+ queueMicrotask(() => dashboard.startRefresh(false));
244
+ return dashboard;
245
+ },
246
+ { overlay: true, overlayOptions: { anchor: "center", width: "100%", maxHeight: "100%" } },
247
+ );
248
+ }
@@ -1,4 +1,8 @@
1
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
1
+ import {
2
+ readStoredCredential,
3
+ type ExtensionAPI,
4
+ type ExtensionContext,
5
+ } from "@earendil-works/pi-coding-agent";
2
6
  import {
3
7
  PROVIDER_ID,
4
8
  PROVIDER_NAME,
@@ -18,8 +22,43 @@ import {
18
22
  DEFAULT_ENDPOINT,
19
23
  } from "./oauth.ts";
20
24
  import { streamAntigravity } from "./cloud-code-assist.ts";
25
+ import {
26
+ antigravityAccountStatuses,
27
+ setAntigravityAccountEnabled,
28
+ upsertAntigravityAccount,
29
+ } from "./accounts.ts";
30
+ import { refreshAntigravityQuotas } from "./quotas.ts";
31
+ import {
32
+ openAntigravityDashboard,
33
+ type AntigravityDashboardSnapshot,
34
+ } from "./dashboard.ts";
21
35
 
22
36
  export default function antigravityProviderExtension(pi: ExtensionAPI) {
37
+ const dashboardSnapshot = (ctx: ExtensionContext): AntigravityDashboardSnapshot => {
38
+ const auth = ctx.modelRegistry.getProviderAuthStatus(PROVIDER_ID);
39
+ return {
40
+ authentication: auth.configured ? auth.source || auth.label || "configured" : "not authenticated",
41
+ modelCount: ANTIGRAVITY_MODELS.length,
42
+ accounts: antigravityAccountStatuses(),
43
+ };
44
+ };
45
+
46
+ const migrateStoredCredential = () => {
47
+ try {
48
+ const credential = readStoredCredential(PROVIDER_ID) as any;
49
+ if (credential?.type !== "oauth" || typeof credential.refresh !== "string" || typeof credential.access !== "string") return;
50
+ upsertAntigravityAccount({
51
+ refresh: credential.refresh,
52
+ access: credential.access,
53
+ expires: typeof credential.expires === "number" ? credential.expires : 0,
54
+ projectId: typeof credential.projectId === "string" ? credential.projectId : undefined,
55
+ email: typeof credential.email === "string" ? credential.email : undefined,
56
+ });
57
+ } catch {
58
+ // A malformed or unavailable Pi credential must not block extension startup.
59
+ }
60
+ };
61
+
23
62
  pi.registerProvider(PROVIDER_ID, {
24
63
  name: PROVIDER_NAME,
25
64
  baseUrl: DEFAULT_ENDPOINT,
@@ -34,9 +73,43 @@ export default function antigravityProviderExtension(pi: ExtensionAPI) {
34
73
  streamSimple: streamAntigravity,
35
74
  } as any);
36
75
 
76
+ pi.on("session_start", (_event, ctx) => {
77
+ (ctx.modelRegistry as any).authStorage?.reload?.();
78
+ migrateStoredCredential();
79
+ void refreshAntigravityQuotas({ signal: ctx.signal }).catch(() => {
80
+ // Cached quota state remains available; /antigravity reports refresh failures.
81
+ });
82
+ });
83
+
84
+ pi.on("agent_end", (_event, ctx) => {
85
+ if (ctx.model?.provider !== PROVIDER_ID) return;
86
+ void refreshAntigravityQuotas({ signal: ctx.signal }).catch(() => {});
87
+ });
88
+
89
+ pi.registerCommand("antigravity", {
90
+ description: "Open Antigravity accounts, rotation, and quota dashboard",
91
+ handler: async (_args, ctx) => {
92
+ migrateStoredCredential();
93
+ await openAntigravityDashboard(
94
+ ctx,
95
+ dashboardSnapshot(ctx),
96
+ async (force) => {
97
+ await refreshAntigravityQuotas({ force, signal: ctx.signal });
98
+ return dashboardSnapshot(ctx);
99
+ },
100
+ async (id, enabled) => {
101
+ setAntigravityAccountEnabled(id, enabled);
102
+ if (enabled) await refreshAntigravityQuotas({ force: true, signal: ctx.signal });
103
+ return dashboardSnapshot(ctx);
104
+ },
105
+ );
106
+ },
107
+ });
108
+
37
109
  pi.registerCommand("antigravity.doctor", {
38
110
  description: "Show sanitized Antigravity provider diagnostics",
39
111
  handler: async (_args, ctx) => {
112
+ const accounts = antigravityAccountStatuses();
40
113
  const lines = [
41
114
  `provider=${PROVIDER_ID}`,
42
115
  `lastResolvedRuntimeModel=${lastResolvedRuntimeModel || "none"}`,
@@ -48,6 +121,9 @@ export default function antigravityProviderExtension(pi: ExtensionAPI) {
48
121
  `lastError=${lastError || "none"}`,
49
122
  "transport=native-streamSimple",
50
123
  "runtimeCli=not-used",
124
+ `accountsConfigured=${accounts.length}`,
125
+ `accountsActive=${accounts.filter((account) => account.active).length}`,
126
+ "rotation=quota-aware-lru",
51
127
  ];
52
128
  const text = lines.join("\n");
53
129
  if (ctx.hasUI) ctx.ui.notify(`Antigravity doctor\n${text}`, "info");
@@ -320,6 +320,32 @@ function findDynamicModel(value: any, requestedId: string): DynamicModelInfo | u
320
320
  return undefined;
321
321
  }
322
322
 
323
+ export async function fetchAntigravityQuotaCatalog(token: string, projectId: string, signal?: AbortSignal): Promise<unknown> {
324
+ let lastFailure = "Antigravity quota catalog is unavailable.";
325
+ const bodies = [{}, { cloudaicompanionProject: projectId }, { project: projectId }];
326
+ for (const endpoint of endpointCandidates()) {
327
+ for (const candidateBody of bodies) {
328
+ try {
329
+ const res = await fetch(`${endpoint}/v1internal:fetchAvailableModels`, {
330
+ method: "POST",
331
+ headers: antigravityHeaders(token),
332
+ body: JSON.stringify(candidateBody),
333
+ signal: boundedFetchSignal(signal),
334
+ });
335
+ lastStatus = res.status;
336
+ lastEndpoint = endpoint;
337
+ if (res.ok) return await res.json();
338
+ lastFailure = `HTTP ${res.status}: ${jsonOrTextError(await res.text())}`;
339
+ } catch (error) {
340
+ if (signal?.aborted) throw error;
341
+ lastFailure = safeError(error);
342
+ lastError = lastFailure;
343
+ }
344
+ }
345
+ }
346
+ throw new Error(lastFailure);
347
+ }
348
+
323
349
  export async function fetchAvailableRuntimeModel(token: string, projectId: string, requestedRuntimeModel: string, signal?: AbortSignal): Promise<DynamicModelInfo | undefined> {
324
350
  const bodies = [{}, { cloudaicompanionProject: projectId }, { project: projectId }];
325
351
  for (const endpoint of endpointCandidates()) {
@@ -463,13 +489,16 @@ export async function loginAntigravity(callbacks: OAuthCallbacks): Promise<OAuth
463
489
  if (!tokenData.refresh_token) throw new Error("No refresh token received. Re-run /login antigravity and allow offline access.");
464
490
 
465
491
  const [email, discoveredProject] = await Promise.all([getUserEmail(tokenData.access_token), loadCodeAssist(tokenData.access_token)]);
466
- return {
492
+ const credentials = {
467
493
  refresh: tokenData.refresh_token,
468
494
  access: tokenData.access_token,
469
495
  expires: Date.now() + tokenData.expires_in * 1000 - 5 * 60 * 1000,
470
496
  projectId: discoveredProject || DEFAULT_PROJECT_ID,
471
497
  email,
472
498
  };
499
+ const { upsertAntigravityAccount } = await import("./accounts.ts");
500
+ upsertAntigravityAccount(credentials, { clearCooldown: true });
501
+ return credentials;
473
502
  } finally {
474
503
  server.close();
475
504
  }
@@ -485,13 +514,16 @@ export async function refreshAntigravityToken(credentials: OAuthCredentials): Pr
485
514
  if (!response.ok) throw new Error(`Antigravity token refresh failed: ${await response.text()}`);
486
515
  const data = (await response.json()) as { access_token: string; expires_in: number; refresh_token?: string };
487
516
  const discoveredProject = await loadCodeAssist(data.access_token);
488
- return {
517
+ const refreshed = {
489
518
  ...credentials,
490
519
  refresh: data.refresh_token || credentials.refresh,
491
520
  access: data.access_token,
492
521
  expires: Date.now() + data.expires_in * 1000 - 5 * 60 * 1000,
493
522
  projectId: discoveredProject || credentials.projectId || DEFAULT_PROJECT_ID,
494
523
  };
524
+ const { upsertAntigravityAccount } = await import("./accounts.ts");
525
+ upsertAntigravityAccount(refreshed);
526
+ return refreshed;
495
527
  }
496
528
 
497
529
  export function getApiKey(credentials: OAuthCredentials): string {
@@ -0,0 +1,40 @@
1
+ import {
2
+ antigravityAccountStatuses,
3
+ parseAntigravityQuota,
4
+ resolveAntigravityAccount,
5
+ updateAntigravityAccountQuota,
6
+ type AntigravityAccountStatus,
7
+ } from "./accounts.ts";
8
+ import {
9
+ fetchAntigravityQuotaCatalog,
10
+ refreshAntigravityToken,
11
+ } from "./oauth.ts";
12
+
13
+ export const ANTIGRAVITY_QUOTA_TTL_MS = 15 * 60 * 1_000;
14
+ let refreshPromise: Promise<AntigravityAccountStatus[]> | undefined;
15
+
16
+ export function refreshAntigravityQuotas(options: { force?: boolean; signal?: AbortSignal } = {}) {
17
+ if (refreshPromise) return refreshPromise;
18
+ refreshPromise = (async () => {
19
+ const now = Date.now();
20
+ const accounts = antigravityAccountStatuses(now);
21
+ for (const account of accounts) {
22
+ if (account.disabled) continue;
23
+ if (!options.force && account.quotaUpdatedAt && now - account.quotaUpdatedAt < ANTIGRAVITY_QUOTA_TTL_MS) continue;
24
+ try {
25
+ const resolved = await resolveAntigravityAccount(account, refreshAntigravityToken, now);
26
+ const catalog = await fetchAntigravityQuotaCatalog(resolved.access, resolved.projectId, options.signal);
27
+ const quota = parseAntigravityQuota(catalog);
28
+ if (!quota.length) throw new Error("Antigravity returned no recognizable quota entries.");
29
+ updateAntigravityAccountQuota(resolved.id, quota);
30
+ } catch (error) {
31
+ if (options.signal?.aborted) throw error;
32
+ updateAntigravityAccountQuota(account.id, undefined, error instanceof Error ? error.message : String(error));
33
+ }
34
+ }
35
+ return antigravityAccountStatuses();
36
+ })().finally(() => {
37
+ refreshPromise = undefined;
38
+ });
39
+ return refreshPromise;
40
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "shariq-pi-extensions",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "Cross-platform extension suite for the Pi coding agent.",
5
5
  "license": "MIT",
6
6
  "author": "Shariq Riaz",