@jameslovespancakes/pi-plus 1.0.21 → 1.0.23

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
@@ -37,9 +37,10 @@ the app's Stop button stops the agent. Your selected model still runs through pi
37
37
  ──────────────────────────────────────
38
38
  ```
39
39
 
40
- **`/claude-remote`** toggles On/Off. On connects now and auto-starts in future
41
- interactive sessions; Off disconnects and disables auto-start. The footer dot
42
- is green when connected and red otherwise.
40
+ **`/claude-remote`** toggles On/Off for this session only. Startup, new sessions,
41
+ resumes, forks, and reloads always start Off; old auto-start preferences are
42
+ ignored. Off disconnects immediately. The footer dot is green when connected
43
+ and red otherwise.
43
44
 
44
45
  Off by default. Requires your primary Anthropic OAuth login via `/login`.
45
46
  Experimental: text input and completed-message mirroring, not token streaming.
@@ -88,6 +89,16 @@ usage in the footer. Supports Anthropic, OpenAI Codex, Gemini, Kimi Code, and xA
88
89
  **`/routing quota-aware`** uses reported capacity; **`/routing sequential`**
89
90
  follows account order. **`/usage`** refreshes the bars.
90
91
 
92
+ Claude usage comes from response headers first, with a shared cached status
93
+ fetch when needed. Cooldowns survive restarts and are respected by `/usage`;
94
+ usage checks never generate model responses or consume inference tokens.
95
+
96
+ Gemini login and reauthorization confirm account access before reporting success.
97
+ If Google requires verification, its verification page opens through pi's auth UI;
98
+ complete it, then choose **check again**. Credentials are saved only after the
99
+ server confirms access. Background quota checks never open a browser; they point
100
+ you to `/login gemini` or `/accounts reauth gemini <name>` when verification is needed.
101
+
91
102
  Quota visibility depends on the provider. Where no usage endpoint exists,
92
103
  pi-plus shows the last observed rate-limit reading rather than inventing one.
93
104
 
@@ -114,11 +125,23 @@ boundary, including workflow subagents—not just through prompt instructions.
114
125
  ──────────────────────────────────────
115
126
  Providers
116
127
  › ● Anthropic Allowed
117
- ● OpenRouter Needs Approval
128
+ ● OpenRouter Off
118
129
  ──────────────────────────────────────
119
130
  ```
120
131
 
121
132
  **`/provider`** opens the picker. Enter or Space toggles access in place.
133
+ OpenRouter cycles **Off → On → On (ZDR) → Off**. Grants are session-local;
134
+ explicit policy denials stay locked. Other providers keep their existing toggles.
135
+
136
+ **On (ZDR)** restricts OpenRouter inference to Zero Data Retention endpoints,
137
+ including workflow agents and compaction. No eligible endpoint means an error,
138
+ never a non-ZDR retry; fallback among ZDR endpoints is allowed. Native Chat
139
+ Completions and Anthropic Messages routes are supported; unsupported APIs or
140
+ custom endpoints fail closed. Ordinary On does not remove account-level privacy
141
+ rules. ZDR does not cover separately enabled search plugins or local session logs.
142
+
143
+ Commands: `/provider approve openrouter`, `/provider zdr openrouter`, and
144
+ `/provider remove openrouter`.
122
145
 
123
146
  ## Workflows
124
147
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jameslovespancakes/pi-plus",
3
- "version": "1.0.21",
3
+ "version": "1.0.23",
4
4
  "type": "module",
5
5
  "description": "pi and more",
6
6
  "license": "MIT",
@@ -1,6 +1,5 @@
1
- import { createHash, randomUUID } from "node:crypto";
2
- import { closeSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from "node:fs";
3
- import { dirname } from "node:path";
1
+ import { createHash } from "node:crypto";
2
+ import { acquireFileLease } from "../file-lease.ts";
4
3
  import { refreshAbortSignal } from "../accounts/routing.ts";
5
4
  import {
6
5
  configPath as defaultConfigPath,
@@ -12,10 +11,7 @@ import {
12
11
  } from "./store.ts";
13
12
  import { refreshToken, type RefreshOptions, type TokenSet } from "./oauth.ts";
14
13
 
15
- /** Uses free response headers first, polling only stale idle accounts. */
16
-
17
- const QUOTA_URL = "https://api.anthropic.com/api/oauth/usage";
18
- const TIMEOUT_MS = 10_000;
14
+ /** Quota parsing and pooled credential refresh. Status I/O lives in usage-cache.ts. */
19
15
  /** Refresh early enough to absorb transient OAuth rate limits before expiry. */
20
16
  export const ACCESS_REFRESH_WINDOW_MS = 4 * 60 * 60_000;
21
17
  /** Background refresh cadence; each process adds a small startup jitter. */
@@ -24,26 +20,8 @@ const REFRESH_LOCK_TTL_MS = 60_000;
24
20
  const REFRESH_JOIN_WAIT_MS = 16_000;
25
21
  /** Snapshot lifetime and minimum interval between polls. */
26
22
  export const QUOTA_FRESH_MS = 10 * 60_000;
27
- /** After a 429, wait at least this long before touching the endpoint again. */
28
- export const QUOTA_BACKOFF_MS = 15 * 60_000;
29
-
30
- /** Per-account earliest next poll, set when the endpoint rate limits us. */
31
- const blockedUntil = new Map<string, number>();
32
-
33
- /** True when a recent 429 means this account must not be polled yet. */
34
- export function isPollBlocked(accountId: string, now = Date.now()): boolean {
35
- const until = blockedUntil.get(accountId);
36
- if (until === undefined) return false;
37
- if (now >= until) { blockedUntil.delete(accountId); return false; }
38
- return true;
39
- }
40
-
41
- /** Visible for tests; clears the backoff table. */
42
- export function resetPollBackoff(): void {
43
- blockedUntil.clear();
44
- }
45
-
46
23
  const pct = (value: unknown): number | undefined => {
24
+ if (value == null || typeof value === "boolean" || (typeof value === "string" && !value.trim())) return undefined;
47
25
  const n = typeof value === "number" ? value : Number(value);
48
26
  return Number.isFinite(n) ? Math.min(100, Math.max(0, n)) : undefined;
49
27
  };
@@ -58,6 +36,7 @@ export function parseQuota(body: any, now = Date.now()): QuotaSnapshot {
58
36
  remainingPercent: 100 - used,
59
37
  resetsAt: typeof raw?.resets_at === "string" ? raw.resets_at : undefined,
60
38
  checkedAt: now,
39
+ capacity: typeof raw?.limit_dollars === "number" && raw.limit_dollars > 0 ? raw.limit_dollars : undefined,
61
40
  };
62
41
  };
63
42
 
@@ -79,9 +58,15 @@ export function parseQuota(body: any, now = Date.now()): QuotaSnapshot {
79
58
  })
80
59
  .filter(Boolean);
81
60
 
61
+ for (const [id, raw] of [["opus", body?.seven_day_opus ?? body?.seven_day_omelette], ["sonnet", body?.seven_day_sonnet]] as const) {
62
+ const value = window(raw);
63
+ if (value && !scoped.some((entry: { id: string }) => entry.id === id)) scoped.push({ id, ...value });
64
+ }
65
+
82
66
  return {
83
67
  five_hour: window(body?.five_hour),
84
68
  seven_day: window(body?.seven_day),
69
+ extra: body?.extra_usage?.is_enabled ? window(body.extra_usage) : undefined,
85
70
  scoped: scoped.length ? scoped : undefined,
86
71
  checkedAt: now,
87
72
  source: "poll",
@@ -114,7 +99,7 @@ export function parseQuotaHeaders(
114
99
 
115
100
  const window = (prefix: string) => {
116
101
  const raw = get(`${prefix}-utilization`);
117
- if (raw === undefined) return undefined;
102
+ if (raw === undefined || !raw.trim()) return undefined;
118
103
  const fraction = Number(raw);
119
104
  if (!Number.isFinite(fraction)) return undefined;
120
105
 
@@ -137,60 +122,6 @@ export function parseQuotaHeaders(
137
122
  return { five_hour, seven_day, checkedAt: now, source: "headers" };
138
123
  }
139
124
 
140
- /** Merges header quota while preserving polled model limits. */
141
- export function applyQuotaHeaders(
142
- accountId: string,
143
- headers: Record<string, unknown> | undefined,
144
- now = Date.now(),
145
- ): boolean {
146
- const fresh = parseQuotaHeaders(headers, now);
147
- if (!fresh) return false;
148
-
149
- const storage = loadAccounts();
150
- const account = storage?.accounts.find((a) => a.id === accountId);
151
- if (!account) return false;
152
-
153
- // Skip unchanged values to avoid credential writes on every response.
154
- const previous = account.quota;
155
- const unchanged =
156
- previous?.five_hour?.usedPercent === fresh.five_hour?.usedPercent
157
- && previous?.seven_day?.usedPercent === fresh.seven_day?.usedPercent;
158
- if (unchanged) return false;
159
-
160
- saveAccount({ ...account, quota: { ...fresh, scoped: previous?.scoped } });
161
- return true;
162
- }
163
-
164
- /** Polls one account and records rate-limit backoff. */
165
- export async function pollQuota(
166
- accessToken: string,
167
- accountId?: string,
168
- ): Promise<QuotaSnapshot | undefined> {
169
- if (accountId && isPollBlocked(accountId)) return undefined;
170
- try {
171
- const response = await fetch(QUOTA_URL, {
172
- headers: {
173
- Authorization: `Bearer ${accessToken}`,
174
- "anthropic-beta": "oauth-2025-04-20",
175
- Accept: "application/json",
176
- },
177
- signal: AbortSignal.timeout(TIMEOUT_MS),
178
- });
179
- if (response.status === 429 && accountId) {
180
- // `retry-after` is commonly 0 here, which is not a usable hint, so the
181
- // floor is ours rather than the server's.
182
- const hint = Number(response.headers.get("retry-after")) * 1000;
183
- const wait = Number.isFinite(hint) && hint > 0 ? hint : QUOTA_BACKOFF_MS;
184
- blockedUntil.set(accountId, Date.now() + Math.max(wait, QUOTA_BACKOFF_MS));
185
- return undefined;
186
- }
187
- if (!response.ok) return undefined;
188
- return parseQuota(await response.json());
189
- } catch {
190
- return undefined;
191
- }
192
- }
193
-
194
125
  export function accessTokenNeedsRefresh(account: Account, now = Date.now()): boolean {
195
126
  return !account.access
196
127
  || typeof account.expires !== "number"
@@ -205,14 +136,6 @@ export interface EnsureAccessTokenOptions {
205
136
  refresh?: RefreshTokenFn;
206
137
  }
207
138
 
208
- export interface RefreshAllQuotaOptions extends EnsureAccessTokenOptions {
209
- poll?: typeof pollQuota;
210
- }
211
-
212
- interface RefreshLock {
213
- release(): void;
214
- }
215
-
216
139
  const refreshes = new Map<string, Promise<string | undefined>>();
217
140
 
218
141
  function refreshLockPath(accountId: string, config: string): string {
@@ -220,45 +143,6 @@ function refreshLockPath(accountId: string, config: string): string {
220
143
  return `${statePath(config)}.refresh-${id}.lock`;
221
144
  }
222
145
 
223
- /** Cross-process exclusion for Anthropic's rotating refresh tokens. */
224
- function acquireRefreshLock(accountId: string, config: string, now: number): RefreshLock | undefined {
225
- const path = refreshLockPath(accountId, config);
226
- const owner = randomUUID();
227
- mkdirSync(dirname(path), { recursive: true });
228
-
229
- for (let attempt = 0; attempt < 2; attempt++) {
230
- try {
231
- const fd = openSync(path, "wx", 0o600);
232
- try {
233
- writeFileSync(fd, JSON.stringify({ owner, expiresAt: now + REFRESH_LOCK_TTL_MS }), "utf8");
234
- } finally {
235
- closeSync(fd);
236
- }
237
- return {
238
- release() {
239
- try {
240
- const current = JSON.parse(readFileSync(path, "utf8"));
241
- if (current?.owner === owner) rmSync(path, { force: true });
242
- } catch {
243
- // A stale or externally removed lock needs no cleanup.
244
- }
245
- },
246
- };
247
- } catch (error: any) {
248
- if (error?.code !== "EEXIST") throw error;
249
- try {
250
- const current = JSON.parse(readFileSync(path, "utf8"));
251
- if (Number(current?.expiresAt) > now) return undefined;
252
- rmSync(path, { force: true });
253
- } catch {
254
- // A malformed lock is stale; remove it and retry once.
255
- try { rmSync(path, { force: true }); } catch { /* best effort */ }
256
- }
257
- }
258
- }
259
- return undefined;
260
- }
261
-
262
146
  function storedAccount(accountId: string, config: string): Account | undefined {
263
147
  return loadAccounts(config)?.accounts.find((candidate) => candidate.id === accountId);
264
148
  }
@@ -304,7 +188,7 @@ async function refreshAccountNow(
304
188
  if (!accessTokenNeedsRefresh(latest, now())) return latest.access;
305
189
  if (!latest.refresh) return usableAccess(latest, now());
306
190
 
307
- const lock = acquireRefreshLock(account.id, config, now());
191
+ const lock = acquireFileLease(refreshLockPath(account.id, config), REFRESH_LOCK_TTL_MS, now());
308
192
  if (!lock) return joinConcurrentRefresh(latest, config, now);
309
193
 
310
194
  try {
@@ -381,41 +265,3 @@ export async function refreshDueAccessTokens(
381
265
  }));
382
266
  return results.filter(Boolean).length;
383
267
  }
384
-
385
- /** Refreshes due access tokens, then polls only stale quota snapshots. */
386
- export async function refreshAllQuota(
387
- force = false,
388
- config = defaultConfigPath(),
389
- options: RefreshAllQuotaOptions = {},
390
- ): Promise<number> {
391
- await refreshDueAccessTokens(config, options);
392
- const storage = loadAccounts(config);
393
- if (!storage) return 0;
394
- const now = options.now?.() ?? Date.now();
395
- const poll = options.poll ?? pollQuota;
396
-
397
- const stale = storage.accounts.filter(
398
- (a) => a.enabled !== false && a.type === "oauth"
399
- && (force || !isFresh(a.quota, now))
400
- && !isPollBlocked(a.id, now));
401
-
402
- const results = await Promise.all(stale.map(async (account) => {
403
- try {
404
- const token = await ensureAccessToken(account, { ...options, config });
405
- if (!token) return false;
406
- const quota = await poll(token, account.id);
407
- if (!quota) return false;
408
- // Do not restore the pre-refresh account snapshot here. That used to
409
- // overwrite a freshly rotated access/refresh pair with the now-invalid
410
- // old pair immediately after a successful quota poll.
411
- const current = storedAccount(account.id, config);
412
- if (!current || current.access !== token) return false;
413
- saveAccount({ ...current, quota }, config);
414
- return true;
415
- } catch {
416
- return false;
417
- }
418
- }));
419
-
420
- return results.filter(Boolean).length;
421
- }
@@ -20,6 +20,7 @@ export const CONFIG_FILE = "anthropic-auth.json";
20
20
  export const STATE_FILE = "anthropic-auth-state.json";
21
21
 
22
22
  export interface QuotaWindow {
23
+ capacity?: number;
23
24
  remainingPercent?: number;
24
25
  usedPercent?: number;
25
26
  resetsAt?: string;
@@ -30,6 +31,7 @@ export interface QuotaWindow {
30
31
  export interface QuotaSnapshot {
31
32
  five_hour?: QuotaWindow;
32
33
  seven_day?: QuotaWindow;
34
+ extra?: QuotaWindow;
33
35
  scoped?: QuotaWindow[];
34
36
  checkedAt?: number;
35
37
  source?: "poll" | "headers";
@@ -0,0 +1,141 @@
1
+ import { createHash } from "node:crypto";
2
+ import { acquireFileLease } from "../file-lease.ts";
3
+ import { readJson, writeJson } from "../store.ts";
4
+ import { cachedAnthropicAccountIdentity } from "./identity.ts";
5
+ import { isFresh, parseQuota, parseQuotaHeaders, QUOTA_FRESH_MS } from "./quota.ts";
6
+ import { loadAccounts, statePath, type QuotaSnapshot } from "./store.ts";
7
+
8
+ export const QUOTA_BACKOFF_MS = 15 * 60_000;
9
+ const MAX_BACKOFF_MS = 2 * 60 * 60_000;
10
+ const LEASE_MS = 60_000;
11
+
12
+ export interface ClaudeUsageAccount {
13
+ access: string;
14
+ identity?: string;
15
+ id?: string;
16
+ /** Read old snapshots without maintaining a second quota store. */
17
+ quota?: QuotaSnapshot;
18
+ }
19
+ interface UsageCache {
20
+ quota?: QuotaSnapshot;
21
+ nextPollAt?: number;
22
+ failures?: number;
23
+ error?: string;
24
+ }
25
+
26
+ const hash = (value: string) => createHash("sha256").update(value).digest("hex");
27
+ function cachePath(account: ClaudeUsageAccount): string {
28
+ const key = account.identity ? `identity:${account.identity}` : account.id ? `account:${account.id}` : `token:${account.access}`;
29
+ return `${statePath()}.usage-${hash(key)}.json`;
30
+ }
31
+
32
+ function cachePaths(account: ClaudeUsageAccount): string[] {
33
+ // The token alias preserves cooldown when identity discovery is temporarily unavailable.
34
+ return [...new Set([cachePath(account), cachePath({ access: account.access })])];
35
+ }
36
+ function lockCache(paths: string[], now: number): (() => void) | undefined {
37
+ const leases: Array<{ release(): void }> = [];
38
+ const release = () => leases.forEach((lease) => lease.release());
39
+ try {
40
+ for (const path of paths) {
41
+ const lease = acquireFileLease(`${path}.lock`, LEASE_MS, now);
42
+ if (!lease) { release(); return undefined; }
43
+ leases.push(lease);
44
+ }
45
+ return release;
46
+ } catch (error) { release(); throw error; }
47
+ }
48
+ const readCache = (paths: string[]): UsageCache => paths.map((path) => readJson<UsageCache>(path, {}))
49
+ .sort((a, b) => (b.nextPollAt ?? 0) - (a.nextPollAt ?? 0))[0] ?? {};
50
+ const saveCache = (paths: string[], cache: UsageCache): boolean => paths.every((path) => writeJson(path, cache, false, 0o600));
51
+
52
+ /** Preserve missing windows and each window's own observation time. */
53
+ function mergeQuota(previous: QuotaSnapshot | undefined, next: QuotaSnapshot): QuotaSnapshot {
54
+ const merged = { ...previous, ...next };
55
+ for (const key of ["five_hour", "seven_day"] as const) {
56
+ const old = previous?.[key], fresh = next[key];
57
+ const observedAt = fresh?.checkedAt ?? next.checkedAt ?? 0;
58
+ merged[key] = fresh && observedAt >= (old?.checkedAt ?? 0) ? {
59
+ ...old, ...fresh,
60
+ resetsAt: fresh.resetsAt ?? (Date.parse(old?.resetsAt ?? "") > observedAt ? old?.resetsAt : undefined),
61
+ } : old;
62
+ }
63
+ merged.scoped = next.scoped ?? previous?.scoped;
64
+ merged.extra = next.extra ?? previous?.extra;
65
+ return merged;
66
+ }
67
+
68
+ /** Shared disk cache, with token-keyed observations as a fallback before identity discovery. */
69
+ export function cachedClaudeQuota(account: ClaudeUsageAccount): QuotaSnapshot | undefined {
70
+ const samples = [account.quota, ...cachePaths(account).map((path) => readJson<UsageCache>(path, {}).quota)]
71
+ .filter((quota): quota is QuotaSnapshot => !!quota)
72
+ .sort((a, b) => (a.checkedAt ?? 0) - (b.checkedAt ?? 0));
73
+ return samples.reduce<QuotaSnapshot | undefined>((merged, sample) => mergeQuota(merged, sample), undefined);
74
+ }
75
+
76
+ /** Free telemetry from the actual request credential; never use a global last-routed account. */
77
+ export function observeClaudeQuota(access: string, headers: Record<string, unknown>, now = Date.now()): void {
78
+ const fresh = parseQuotaHeaders(headers, now);
79
+ if (!fresh) return;
80
+ const account = loadAccounts()?.accounts.find((candidate) => candidate.access === access);
81
+ const target = { access, id: account?.id, identity: account?.identity ?? cachedAnthropicAccountIdentity(access), quota: account?.quota };
82
+ const paths = cachePaths(target);
83
+ const release = lockCache(paths, now);
84
+ if (!release) return; // An in-flight status fetch will refresh this account instead.
85
+ try {
86
+ saveCache(paths, { ...readCache(paths), quota: mergeQuota(cachedClaudeQuota(target), fresh) });
87
+ } finally { release(); }
88
+ }
89
+
90
+ function hasFreshWindows(quota: QuotaSnapshot | undefined, now: number): boolean {
91
+ return [quota?.five_hour, quota?.seven_day].every((window) =>
92
+ window && isFresh({ checkedAt: window.checkedAt ?? quota?.checkedAt }, now));
93
+ }
94
+
95
+ /** The only Claude status fetch path. Manual refresh and process restarts cannot bypass its cooldown. */
96
+ export async function readClaudeQuota(account: ClaudeUsageAccount, now = Date.now()): Promise<{ quota?: QuotaSnapshot; error?: string }> {
97
+ const paths = cachePaths(account);
98
+ const cached = () => cachedClaudeQuota(account);
99
+ const initial = cached();
100
+ if (hasFreshWindows(initial, now)) return { quota: initial };
101
+ const release = lockCache(paths, now);
102
+ if (!release) return { quota: cached() };
103
+ try {
104
+ const current = readCache(paths);
105
+ const quota = cached();
106
+ if (hasFreshWindows(quota, now)) return { quota };
107
+ if ((current.nextPollAt ?? 0) > now) return { quota, error: current.error };
108
+
109
+ // Reserve before I/O: even a crash or failed final write must not trigger a retry storm.
110
+ if (!saveCache(paths, { ...current, quota, nextPollAt: now + QUOTA_BACKOFF_MS })) {
111
+ return { quota, error: "Could not persist usage cooldown" };
112
+ }
113
+ let error: string;
114
+ let retryAfter = 0;
115
+ try {
116
+ const response = await fetch("https://api.anthropic.com/api/oauth/usage", {
117
+ headers: { Authorization: `Bearer ${account.access}`, "anthropic-beta": "oauth-2025-04-20", Accept: "application/json" },
118
+ signal: AbortSignal.timeout(10_000),
119
+ });
120
+ if (response.ok) {
121
+ const fresh = parseQuota(await response.json(), now);
122
+ if (fresh.five_hour || fresh.seven_day) {
123
+ saveCache(paths, { quota: fresh, nextPollAt: now + QUOTA_FRESH_MS, failures: 0 });
124
+ return { quota: fresh };
125
+ }
126
+ error = "Usage windows unavailable";
127
+ } else {
128
+ error = `HTTP ${response.status}`;
129
+ const hint = response.headers.get("retry-after");
130
+ if (hint) retryAfter = /^\s*\d+(\.\d+)?\s*$/.test(hint) ? Number(hint) * 1000 : Date.parse(hint) - now;
131
+ }
132
+ } catch {
133
+ error = "Usage request failed"; // Never echo tokens or raw provider bodies.
134
+ }
135
+ const failures = Math.min((current.failures ?? 0) + 1, 8);
136
+ const backoff = Math.min(MAX_BACKOFF_MS, QUOTA_BACKOFF_MS * 2 ** (failures - 1));
137
+ const wait = Math.max(backoff, Number.isFinite(retryAfter) ? retryAfter : 0);
138
+ saveCache(paths, { quota, failures, error, nextPollAt: now + wait });
139
+ return { quota, error };
140
+ } finally { release(); }
141
+ }
package/src/core/env.ts CHANGED
@@ -15,7 +15,6 @@ export type EnvKey =
15
15
  | "AGENT_BOARD_NAME"
16
16
  | "AGENT_BOARD_MODE"
17
17
  | "AGENT_BOARD_SSH"
18
- | "PI_CLAUDE_REMOTE"
19
18
  | "PI_CLAUDE_REMOTE_ALLOW_INBOUND"
20
19
  | "CLAUDE_TRUSTED_DEVICE_TOKEN";
21
20
 
@@ -26,7 +25,6 @@ export const ENV_KEYS: { key: EnvKey; label: string; secret: boolean }[] = [
26
25
  { key: "AGENT_BOARD_NAME", label: "Agent board display name", secret: false },
27
26
  { key: "AGENT_BOARD_MODE", label: "Agent board deployment (local|remote|external)", secret: false },
28
27
  { key: "AGENT_BOARD_SSH", label: "Agent board SSH host, when remote", secret: false },
29
- { key: "PI_CLAUDE_REMOTE", label: "Claude Remote auto-start (1|0)", secret: false },
30
28
  { key: "PI_CLAUDE_REMOTE_ALLOW_INBOUND", label: "Claude Remote input (1|0)", secret: false },
31
29
  { key: "CLAUDE_TRUSTED_DEVICE_TOKEN", label: "Claude trusted-device token (optional)", secret: true },
32
30
  ];
@@ -0,0 +1,32 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { closeSync, mkdirSync, openSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
3
+ import { dirname } from "node:path";
4
+
5
+ /** Non-blocking cross-process lease. Never delete a live or partially written lock. */
6
+ export function acquireFileLease(path: string, ttlMs: number, now = Date.now()): { release(): void } | undefined {
7
+ mkdirSync(dirname(path), { recursive: true });
8
+ const owner = randomUUID();
9
+ for (let attempt = 0; attempt < 2; attempt++) {
10
+ try {
11
+ const fd = openSync(path, "wx", 0o600);
12
+ try { writeFileSync(fd, JSON.stringify({ owner, expiresAt: now + ttlMs }), "utf8"); }
13
+ finally { closeSync(fd); }
14
+ return { release() {
15
+ try {
16
+ if (JSON.parse(readFileSync(path, "utf8")).owner === owner) rmSync(path, { force: true });
17
+ } catch { /* already removed */ }
18
+ } };
19
+ } catch (error: any) {
20
+ if (error?.code !== "EEXIST") throw error;
21
+ try {
22
+ let expiresAt: number;
23
+ try { expiresAt = Number(JSON.parse(readFileSync(path, "utf8")).expiresAt); }
24
+ catch { expiresAt = NaN; }
25
+ if (!Number.isFinite(expiresAt)) expiresAt = statSync(path).mtimeMs + ttlMs;
26
+ if (expiresAt > now) return undefined;
27
+ rmSync(path, { force: true });
28
+ } catch { return undefined; }
29
+ }
30
+ }
31
+ return undefined;
32
+ }
@@ -57,7 +57,14 @@ function timeoutSignal(signal?: AbortSignal): AbortSignal {
57
57
  }
58
58
 
59
59
  /** POSTs to one endpoint; undefined for any non-2xx or transport failure. */
60
- async function postJson(endpoint: string, path: string, token: string, body: unknown, signal?: AbortSignal): Promise<unknown> {
60
+ async function postJson(
61
+ endpoint: string,
62
+ path: string,
63
+ token: string,
64
+ body: unknown,
65
+ signal?: AbortSignal,
66
+ onFailure?: (status: number, body: unknown) => void,
67
+ ): Promise<unknown> {
61
68
  try {
62
69
  const response = await fetch(`${endpoint}/v1internal:${path}`, {
63
70
  method: "POST",
@@ -65,7 +72,10 @@ async function postJson(endpoint: string, path: string, token: string, body: unk
65
72
  body: JSON.stringify(body),
66
73
  signal: timeoutSignal(signal),
67
74
  });
68
- if (!response.ok) return undefined;
75
+ if (!response.ok) {
76
+ if (onFailure) onFailure(response.status, await response.json().catch(() => undefined));
77
+ return undefined;
78
+ }
69
79
  return await response.json();
70
80
  } catch (error) {
71
81
  // A caller abort is a decision, not an endpoint failure to route around.
@@ -154,6 +164,28 @@ export async function fetchUserEmail(token: string, signal?: AbortSignal): Promi
154
164
  }
155
165
  }
156
166
 
167
+ /** Only Google's observed verification destination may be opened during login. */
168
+ function verificationUrl(value: unknown): string | undefined {
169
+ if (typeof value !== "string") return undefined;
170
+ try {
171
+ const url = new URL(value);
172
+ if (url.protocol === "https:" && url.hostname === "accounts.google.com"
173
+ && !url.port && !url.username && !url.password && url.pathname === "/signin/continue") return url.href;
174
+ } catch { /* An invalid link must never reach the browser. */ }
175
+ return undefined;
176
+ }
177
+
178
+ export class GeminiVerificationRequiredError extends Error {
179
+ readonly verificationUrl?: string;
180
+
181
+ constructor(url?: unknown) {
182
+ super("Google account verification required. Sign in to Antigravity with this account to continue.");
183
+ this.name = "GeminiVerificationRequiredError";
184
+ // The challenge URL is for the interactive auth UI, not serialized errors.
185
+ Object.defineProperty(this, "verificationUrl", { value: verificationUrl(url), enumerable: false });
186
+ }
187
+ }
188
+
157
189
  /** One `retrieveUserQuota` bucket: a runtime model's remaining share of its window. */
158
190
  export interface QuotaBucket {
159
191
  modelId: string;
@@ -173,8 +205,22 @@ export async function fetchUserQuota(
173
205
  projectId: string,
174
206
  signal?: AbortSignal,
175
207
  ): Promise<QuotaBucket[]> {
208
+ let lastStatus: number | undefined;
209
+ let verificationRequired: GeminiVerificationRequiredError | undefined;
210
+ const onFailure = (status: number, body: unknown) => {
211
+ lastStatus = status;
212
+ const error = isRecord(body) && isRecord(body.error) ? body.error : undefined;
213
+ if (status !== 403 || !error) return;
214
+ const detail = Array.isArray(error.details) ? error.details.find((item) => isRecord(item)
215
+ && item["@type"] === "type.googleapis.com/google.rpc.ErrorInfo"
216
+ && item.domain === "cloudcode-pa.googleapis.com" && item.reason === "VALIDATION_REQUIRED") : undefined;
217
+ if (detail || (typeof error.message === "string" && /verify your account/i.test(error.message))) {
218
+ const candidate = new GeminiVerificationRequiredError(detail?.metadata?.validation_url);
219
+ if (!verificationRequired?.verificationUrl) verificationRequired = candidate;
220
+ }
221
+ };
176
222
  for (const endpoint of GEMINI_ENDPOINTS) {
177
- const answer = await postJson(endpoint, "retrieveUserQuota", token, { project: projectId }, signal);
223
+ const answer = await postJson(endpoint, "retrieveUserQuota", token, { project: projectId }, signal, onFailure);
178
224
  if (!isRecord(answer)) continue;
179
225
  const buckets = Array.isArray(answer.buckets) ? answer.buckets : [];
180
226
  return buckets.flatMap((bucket): QuotaBucket[] => {
@@ -190,7 +236,8 @@ export async function fetchUserQuota(
190
236
  }];
191
237
  });
192
238
  }
193
- throw new Error("Gemini did not return quota from any endpoint.");
239
+ if (verificationRequired) throw verificationRequired;
240
+ throw new Error(`Gemini did not return quota from any endpoint${lastStatus ? ` (HTTP ${lastStatus})` : ""}.`);
194
241
  }
195
242
 
196
243
  /** One entry of `fetchAvailableModels`, keyed by its runtime model id. */