diff --git a/.gitignore b/.gitignore index 34ecbf0..2e7e35a 100644 --- a/.gitignore +++ b/.gitignore @@ -5,6 +5,15 @@ node_modules out dist *.tgz +index.js +index.js.map +index.d.ts +index.d.ts.map +src/**/*.js +src/**/*.js.map +src/**/*.d.ts +src/**/*.d.ts.map +!src/shims.d.ts # code coverage coverage @@ -40,6 +49,9 @@ antigravity-debug-*.log # Test artifacts test-file.ts +# Local scratch files +.tmp-origin-accounts.ts + # Local subrepos (not part of this project) CLIProxyAPI/ LLM-API-Key-Proxy/ diff --git a/docs/pr-395/pr1.png b/docs/pr-395/pr1.png new file mode 100644 index 0000000..0fc4a2a Binary files /dev/null and b/docs/pr-395/pr1.png differ diff --git a/docs/pr-395/pr2.png b/docs/pr-395/pr2.png new file mode 100644 index 0000000..d333858 Binary files /dev/null and b/docs/pr-395/pr2.png differ diff --git a/docs/pr-395/pr3.png b/docs/pr-395/pr3.png new file mode 100644 index 0000000..242c82d Binary files /dev/null and b/docs/pr-395/pr3.png differ diff --git a/docs/pr-395/pr4.png b/docs/pr-395/pr4.png new file mode 100644 index 0000000..7ea8b4e Binary files /dev/null and b/docs/pr-395/pr4.png differ diff --git a/docs/pr-395/pr5.png b/docs/pr-395/pr5.png new file mode 100644 index 0000000..42a2b5a Binary files /dev/null and b/docs/pr-395/pr5.png differ diff --git a/docs/pr-395/pr6.png b/docs/pr-395/pr6.png new file mode 100644 index 0000000..74e11ca Binary files /dev/null and b/docs/pr-395/pr6.png differ diff --git a/docs/pr-395/pr7.png b/docs/pr-395/pr7.png new file mode 100644 index 0000000..fbb92f8 Binary files /dev/null and b/docs/pr-395/pr7.png differ diff --git a/package-lock.json b/package-lock.json index e4f8a6b..534f090 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "opencode-antigravity-auth", - "version": "1.3.3-beta.2", + "version": "1.4.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "opencode-antigravity-auth", - "version": "1.3.3-beta.2", + "version": "1.4.6", "license": "MIT", "dependencies": { "@openauthjs/openauth": "^0.4.3", diff --git a/package.json b/package.json index a190071..da36e68 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,13 @@ "description": "Google Antigravity IDE OAuth auth plugin for Opencode - access Gemini 3 Pro and Claude 4.5 using Google credentials", "main": "./dist/index.js", "types": "./dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + } + }, "type": "module", "license": "MIT", "author": "noefabris", @@ -64,4 +71,4 @@ "xdg-basedir": "^5.1.0", "zod": "^4.0.0" } -} \ No newline at end of file +} diff --git a/src/plugin.ts b/src/plugin.ts index 6c7e57f..af1358f 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -1,9 +1,17 @@ -import { exec } from "node:child_process"; +import { spawn, spawnSync } from "node:child_process"; +import * as crypto from "node:crypto"; import { tool } from "@opencode-ai/plugin"; -import { ANTIGRAVITY_ENDPOINT_FALLBACKS, ANTIGRAVITY_ENDPOINT_PROD, ANTIGRAVITY_PROVIDER_ID, type HeaderStyle } from "./constants"; +import { + ANTIGRAVITY_ENDPOINT_FALLBACKS, + ANTIGRAVITY_DEFAULT_PROJECT_ID, + ANTIGRAVITY_ENDPOINT_PROD, + ANTIGRAVITY_HEADERS, + ANTIGRAVITY_PROVIDER_ID, + type HeaderStyle, +} from "./constants"; import { authorizeAntigravity, exchangeAntigravity } from "./antigravity/oauth"; import type { AntigravityTokenExchangeResult } from "./antigravity/oauth"; -import { accessTokenExpired, isOAuthAuth, parseRefreshParts, formatRefreshParts } from "./plugin/auth"; +import { accessTokenExpired, formatRefreshParts, isOAuthAuth, parseRefreshParts } from "./plugin/auth"; import { promptAddAnotherAccount, promptLoginMode, promptProjectId } from "./plugin/cli"; import { ensureProjectContext } from "./plugin/project"; import { @@ -32,20 +40,30 @@ import { import { EmptyResponseError } from "./plugin/errors"; import { AntigravityTokenRefreshError, refreshAccessToken } from "./plugin/token"; import { startOAuthListener, type OAuthListener } from "./plugin/server"; -import { clearAccounts, loadAccounts, saveAccounts, saveAccountsReplace } from "./plugin/storage"; -import { AccountManager, type ModelFamily, parseRateLimitReason, calculateBackoffMs, computeSoftQuotaCacheTtlMs } from "./plugin/accounts"; +import { + clearAccounts, + clearBlockedAccounts, + loadAccounts, + loadBlockedAccounts, + saveAccounts, + saveAccountsReplace, + saveBlockedAccounts, +} from "./plugin/storage"; +import { AccountManager, type ModelFamily, parseRateLimitReason, calculateBackoffMs } from "./plugin/accounts"; import { createAutoUpdateCheckerHook } from "./hooks/auto-update-checker"; import { loadConfig, initRuntimeConfig, type AntigravityConfig } from "./plugin/config"; import { createSessionRecoveryHook, getRecoverySuccessToast } from "./plugin/recovery"; -import { checkAccountsQuota } from "./plugin/quota"; +import { checkAccountsQuota, type AccountQuotaResult } from "./plugin/quota"; import { initDiskSignatureCache } from "./plugin/cache"; import { createProactiveRefreshQueue, type ProactiveRefreshQueue } from "./plugin/refresh-queue"; import { initLogger, createLogger } from "./plugin/logger"; import { initHealthTracker, getHealthTracker, initTokenTracker, getTokenTracker } from "./plugin/rotation"; import { executeSearch } from "./plugin/search"; +import { select, type MenuItem } from "./plugin/ui/select"; import type { GetAuth, LoaderResult, + OAuthAuthDetails, PluginClient, PluginContext, PluginResult, @@ -65,21 +83,572 @@ function getCapacityBackoffDelay(consecutiveFailures: number): number { const warmupAttemptedSessionIds = new Set(); const warmupSucceededSessionIds = new Set(); -// Track if this plugin instance is running in a child session (subagent, background task) -// Used to filter toasts based on toast_scope config +const log = createLogger("plugin"); + +// Used to filter toasts based on toast_scope config. let isChildSession = false; let childSessionParentID: string | undefined = undefined; -const log = createLogger("plugin"); +function summarizeUpstreamValidationMessage(message: string): string { + const raw = (message || "").trim(); + if (!raw) return "403 VALIDATION_REQUIRED (Antigravity)"; + + // Strip any appended debug sections and links; the actionable link is handled separately. + const withoutDebug = raw.split(/\n\s*\[Debug Info\][\s\S]*$/i)[0]?.trim() ?? raw; + const firstLine = withoutDebug.split(/\r?\n/).find((l) => l.trim())?.trim() ?? withoutDebug; + const withoutUrls = firstLine.replace(/https?:\/\/\S+/gi, "[link]"); + + // Keep it terse; the user already knows it is a verification error. + return withoutUrls.length > 160 ? `${withoutUrls.slice(0, 157)}...` : withoutUrls; +} + +function isGeminiCodeAssistLicenseError(text: string): boolean { + const t = (text || "").toLowerCase(); + if (!t) return false; + // Observed upstream error: "lack a Gemini Code Assist license... (#3501)" + return ( + t.includes("#3501") + || (t.includes("gemini code assist") && t.includes("license")) + || t.includes("lack a gemini code assist license") + ); +} + +function summarizeUpstreamLicenseMessage(message: string): string { + const raw = (message || "").trim(); + if (!raw) return "Gemini Code Assist license missing (#3501)"; + const withoutDebug = raw.split(/\n\s*\[Debug Info\][\s\S]*$/i)[0]?.trim() ?? raw; + const firstLine = withoutDebug.split(/\r?\n/).find((l) => l.trim())?.trim() ?? withoutDebug; + return firstLine.length > 180 ? `${firstLine.slice(0, 177)}...` : firstLine; +} + +const GEMINI_AUTH_SUCCESS_URL = "https://developers.google.com/gemini-code-assist/auth/auth_success_gemini"; + +function isGoogleAccountsHost(hostname: string): boolean { + const h = (hostname || "").toLowerCase(); + return h === "accounts.google.com" || h.endsWith(".accounts.google.com"); +} + +function ensureSigninContinueUrl(url: URL): URL { + const out = new URL(url.toString()); + if (!out.searchParams.get("sarp")) out.searchParams.set("sarp", "1"); + if (!out.searchParams.get("scc")) out.searchParams.set("scc", "1"); + if (!out.searchParams.get("continue")) out.searchParams.set("continue", GEMINI_AUTH_SUCCESS_URL); + if (!out.searchParams.get("flowName")) out.searchParams.set("flowName", "GlifWebSignIn"); + return out; +} + +function cleanAuthuserValue(input: string | null | undefined): string { + const raw = (input || "").trim(); + if (!raw) return ""; + return raw.replace(/^[\s"',]+|[\s"',]+$/g, ""); +} + +function normalizeAuthuserIndex(input: string | null | undefined): string { + const cleaned = cleanAuthuserValue(input); + if (!cleaned) return ""; + return /^\d+$/.test(cleaned) ? cleaned : ""; +} + +function sanitizeGoogleSigninParams(url: URL, accountEmail?: string): URL { + const out = new URL(url.toString()); + + // Some upstream responses occasionally include malformed authuser keys like + // `authuser%2522%2C`, which can lead to broken 400 links in browsers. + const weirdAuthuserKeys: string[] = []; + for (const [key, value] of out.searchParams.entries()) { + const normalizedKey = key.toLowerCase().replace(/%25/g, "%"); + if (normalizedKey.startsWith("authuser") && key !== "authuser") { + weirdAuthuserKeys.push(key); + const cleaned = normalizeAuthuserIndex(value); + if (cleaned && !out.searchParams.get("authuser")) { + out.searchParams.set("authuser", cleaned); + } + } + } + for (const key of weirdAuthuserKeys) { + out.searchParams.delete(key); + } + + const cleanedAuthuser = normalizeAuthuserIndex(out.searchParams.get("authuser")); + if (cleanedAuthuser) { + out.searchParams.set("authuser", cleanedAuthuser); + } else { + out.searchParams.delete("authuser"); + } + + if (accountEmail && accountEmail.trim()) { + const trimmedEmail = accountEmail.trim(); + if (!out.searchParams.get("Email")) out.searchParams.set("Email", trimmedEmail); + } + + return out; +} + +function normalizeGoogleVerificationUrl(rawUrl: string, accountEmail?: string): string { + const raw = (rawUrl || "").trim(); + if (!raw) return ""; + + let parsed: URL; + try { + parsed = new URL(raw); + } catch { + return raw; + } + + if (!isGoogleAccountsHost(parsed.hostname)) { + return raw; + } + + const path = parsed.pathname.toLowerCase(); + + // Keep Google-provided sign-in links intact; they often include opaque tokens + // (e.g. `plt=`) and rewriting them into synthesized chooser links can break flow. + if (path.includes("/signin/continue")) { + return ensureSigninContinueUrl(sanitizeGoogleSigninParams(parsed, accountEmail)).toString(); + } + + if (path.includes("/accountchooser")) { + const nested = parsed.searchParams.get("continue"); + if (nested) { + try { + const nestedUrl = new URL(nested); + if (isGoogleAccountsHost(nestedUrl.hostname) && nestedUrl.pathname.toLowerCase().includes("/signin/continue")) { + parsed.searchParams.set("continue", ensureSigninContinueUrl(nestedUrl).toString()); + } + } catch { + // Keep existing continue param when it is not parseable. + } + } + + if (accountEmail && !parsed.searchParams.get("Email")) { + parsed.searchParams.set("Email", accountEmail); + } + + return sanitizeGoogleSigninParams(parsed, accountEmail).toString(); + } + + return raw; +} + +function buildGoogleSigninFirstUrl(verificationUrl: string, accountEmail?: string): string { + const verify = (verificationUrl || "").trim(); + if (!verify) return ""; + + try { + const continueUrl = new URL(verify).toString(); + // Force add-session flow so browser consistently prompts sign-in first, + // then redirects to the verification continue URL after authentication. + const signin = new URL("https://accounts.google.com/signin/v2/identifier"); + signin.searchParams.set("continue", continueUrl); + signin.searchParams.set("followup", continueUrl); + signin.searchParams.set("service", "mail"); + signin.searchParams.set("emr", "1"); + signin.searchParams.set("flowName", "GlifWebSignIn"); + signin.searchParams.set("flowEntry", "AddSession"); + if (accountEmail && accountEmail.trim()) { + const trimmedEmail = accountEmail.trim(); + signin.searchParams.set("Email", trimmedEmail); + signin.searchParams.set("login_hint", trimmedEmail); + } + return signin.toString(); + } catch { + return verify; + } +} + +type VerificationScanStatus = "ok" | "blocked" | "license" | "error" | "skipped"; + +type VerificationAccountOrigin = "active" | "quarantined"; + +type VerificationScanResult = { + index: number; + origin: VerificationAccountOrigin; + refreshToken: string; + email?: string; + enabled: boolean; + status: VerificationScanStatus; + message?: string; + verifyUrl?: string; +}; + +async function fetchWithTimeout(url: string, options: RequestInit, timeoutMs: number): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), timeoutMs); + try { + return await fetch(url, { ...options, signal: controller.signal }); + } finally { + clearTimeout(timeout); + } +} + +async function withTimeout(promise: Promise, timeoutMs: number, message: string): Promise { + let timeout: ReturnType | undefined; + const timeoutPromise = new Promise((_resolve, reject) => { + timeout = setTimeout(() => reject(new Error(message)), timeoutMs); + }); + try { + return await Promise.race([promise, timeoutPromise]); + } finally { + if (timeout) clearTimeout(timeout); + } +} + +function extractValidationUrlAndMessage( + bodyText: string, + accountEmail?: string, +): { isValidationRequired: boolean; validationUrl?: string; message?: string } { + let upstreamMessage = bodyText; + const urlCandidates: string[] = []; + let isValidationRequired = false; + + const normalizeUrlCandidate = (input: string): string => { + let out = input.trim(); + if (!out) return ""; + + // Common HTML entity in some upstream logs. + out = out.replace(/&/g, "&"); + + // If we captured a JSON-escaped URL string (e.g. "...sarp=1\\u0026scc=1..."), + // decode any \uXXXX sequences to avoid broken links (400 malformed request). + // Apply twice to handle rare double-escaped strings. + for (let i = 0; i < 2; i++) { + const next = out.replace(/\\u([0-9a-fA-F]{4})/g, (_m, hex) => { + const code = Number.parseInt(hex, 16); + return Number.isFinite(code) ? String.fromCharCode(code) : _m; + }); + if (next === out) break; + out = next; + } + + return normalizeGoogleVerificationUrl(out, accountEmail); + }; + + const pushUrlCandidate = (input: string) => { + const normalized = normalizeUrlCandidate(input); + if (!normalized) return; + urlCandidates.push(normalized); + }; + + const collectFromParsed = (parsed: any) => { + if (!parsed || typeof parsed !== "object") return; + + if (typeof parsed?.error?.message === "string") { + upstreamMessage = parsed.error.message; + } + + const details = parsed?.error?.details; + if (!Array.isArray(details)) return; + + for (const detail of details) { + if (!detail || typeof detail !== "object") continue; + if (typeof detail?.reason === "string" && detail.reason.toUpperCase() === "VALIDATION_REQUIRED") { + isValidationRequired = true; + } + const metadata = detail?.metadata; + const url = metadata?.validation_url ?? metadata?.validationUrl ?? metadata?.validationURL; + if (typeof url === "string" && url.trim()) { + pushUrlCandidate(url); + } + } + }; + + // Some endpoints return `text/event-stream` (SSE) even for errors (403 VALIDATION_REQUIRED). + // Parse both raw JSON and SSE `data:` lines to capture the full validation_url (often includes `plt=`). + try { + collectFromParsed(JSON.parse(bodyText)); + } catch { + // ignore JSON parsing; fall back to SSE + string scanning below + } + + for (const line of bodyText.split(/\r?\n/)) { + const trimmed = line.trim(); + if (!trimmed.startsWith("data:")) continue; + const payload = trimmed.slice("data:".length).trim(); + if (!payload || payload === "[DONE]") continue; + try { + collectFromParsed(JSON.parse(payload)); + } catch { + // ignore malformed SSE chunks + } + } + + const lower = `${upstreamMessage}\n${bodyText}`.toLowerCase(); + if ( + lower.includes("validation_required") || + lower.includes("verify your account") || + lower.includes("verify your google account") + ) { + isValidationRequired = true; + } + + const combined = `${upstreamMessage}\n${bodyText}`; + for (const match of combined.matchAll(/https?:\/\/accounts\.google\.com\/\S+/gi)) { + const raw = match[0]?.trim(); + if (!raw) continue; + // Trim common trailing punctuation from logs. + pushUrlCandidate(raw.replace(/[)\]]+$/, "")); + } + + // Prefer the most "complete" looking URL (often includes a long `plt=` token). + const scoreUrl = (u: string): number => { + const lower = u.toLowerCase(); + let score = u.length; + if (lower.includes("plt=")) score += 10_000; + if (lower.includes("flowname=glifwebsignin")) score += 500; + if (lower.includes("signin/continue")) score += 100; + return score; + }; + + let validationUrl: string | undefined; + for (const u of urlCandidates) { + if (!u) continue; + if (!validationUrl || scoreUrl(u) > scoreUrl(validationUrl)) { + validationUrl = u; + } + } + + return { isValidationRequired, validationUrl, message: upstreamMessage }; +} + +async function scanAccountForVerification( + account: any, + client: PluginClient, + providerId: string, +): Promise { + const enabled = account.enabled !== false; + const email = account.email as string | undefined; + const index = account.index as number; + const origin = (account.origin as VerificationAccountOrigin) ?? "active"; + const refreshToken = account.refreshToken as string | undefined; + + if (!refreshToken) { + return { index, origin, refreshToken: "", email, enabled, status: "error", message: "missing refreshToken" }; + } + + // For quarantined accounts, we still scan even if the user previously disabled them, + // since they are already out of the active rotation pool. + if (!enabled && origin !== "quarantined") { + return { index, origin, refreshToken, email, enabled, status: "skipped", message: "disabled" }; + } + + let authRecord: OAuthAuthDetails = { + type: "oauth" as const, + refresh: formatRefreshParts({ + refreshToken, + projectId: account.projectId, + managedProjectId: account.managedProjectId, + }), + access: undefined, + expires: undefined, + }; + + try { + if (accessTokenExpired(authRecord)) { + const refreshed = await withTimeout( + refreshAccessToken(authRecord, client, providerId), + 10_000, + "token refresh timed out", + ); + if (!refreshed) { + return { index, origin, refreshToken, email, enabled, status: "error", message: "token refresh failed" }; + } + authRecord = refreshed; + } + + // IMPORTANT: Do not call ensureProjectContext() here. + // This scan is a best-effort health check and must not block on managed-project onboarding. + const parts = parseRefreshParts(authRecord.refresh); + const effectiveProjectId = parts.managedProjectId || parts.projectId || ANTIGRAVITY_DEFAULT_PROJECT_ID; + + const endpoint = ANTIGRAVITY_ENDPOINT_PROD; + const quotaUserAgent = ANTIGRAVITY_HEADERS["User-Agent"] || "antigravity/windows/amd64"; + // Use a real generateContent call to reliably trigger account verification blocks + // (VALIDATION_REQUIRED) and license errors (#3501). fetchAvailableModels can succeed + // even when generate requests are blocked. + // + // Use the streaming endpoint to match runtime behavior and ensure we receive the full + // validation URL (often includes a long `plt=` token). + const url = `${endpoint}/v1internal:streamGenerateContent?alt=sse`; + const body = { + project: effectiveProjectId, + model: "gemini-3-flash", + userAgent: "antigravity", + requestId: "verify-scan-" + crypto.randomUUID(), + requestType: "agent", + request: { + contents: [ + { + role: "user", + parts: [{ text: "ping" }], + }, + ], + generationConfig: { + maxOutputTokens: 1, + temperature: 0, + }, + }, + }; + + const resp = await fetchWithTimeout(url, { + method: "POST", + headers: { + Authorization: `Bearer ${authRecord.access ?? ""}`, + Accept: "text/event-stream", + "Content-Type": "application/json", + "User-Agent": quotaUserAgent, + }, + body: JSON.stringify(body), + }, 10_000); + + if (resp.ok) { + try { + await resp.body?.cancel(); + } catch { + // ignore; best-effort + } + return { index, origin, refreshToken, email, enabled, status: "ok" }; + } + + const text = await resp.text().catch(() => ""); + if (resp.status === 429) { + return { index, origin, refreshToken, email, enabled, status: "error", message: "rate-limited (429)" }; + } + if (isGeminiCodeAssistLicenseError(text)) { + return { index, origin, refreshToken, email, enabled, status: "license", message: summarizeUpstreamLicenseMessage(text) }; + } + + const extracted = extractValidationUrlAndMessage(text, email); + if (resp.status === 403 && extracted.isValidationRequired) { + return { + index, + origin, + refreshToken, + email, + enabled, + status: "blocked", + message: summarizeUpstreamValidationMessage(extracted.message ?? text), + verifyUrl: extracted.validationUrl, + }; + } + + return { + index, + origin, + refreshToken, + email, + enabled, + status: "error", + message: `${resp.status}: ${(extracted.message ?? text).trim().slice(0, 200)}`, + }; + } catch (err) { + return { index, origin, refreshToken, email, enabled, status: "error", message: err instanceof Error ? err.message : String(err) }; + } +} + +function formatVerificationScanStatusForDisplay(result: VerificationScanResult): string { + switch (result.status) { + case "ok": + return "OK"; + case "blocked": + return "BLOCKED"; + case "license": + return "NO LICENSE (#3501)"; + case "skipped": + return "SKIPPED"; + case "error": + default: + return result.message ? `ERROR (${result.message})` : "ERROR"; + } +} + +async function scanAllAccountsForVerification( + accounts: any[], + client: PluginClient, + providerId: string, + opts: { + concurrency?: number; + perAccountTimeoutMs?: number; + onProgress?: (p: { done: number; total: number; result: VerificationScanResult }) => void; + } = {}, + origin: VerificationAccountOrigin = "active", +): Promise { + const total = accounts.length; + const results: VerificationScanResult[] = new Array(total); + + const concurrency = Math.max(1, Math.min(opts.concurrency ?? 3, Math.max(1, total))); + const perAccountTimeoutMs = Math.max(1_000, opts.perAccountTimeoutMs ?? 25_000); + + let nextIndex = 0; + let done = 0; + + const worker = async () => { + while (true) { + const idx = nextIndex++; + if (idx >= total) return; + + const acc = accounts[idx]; + const email = acc?.email as string | undefined; + const enabled = acc?.enabled !== false; + const shouldScan = enabled || origin === "quarantined"; + const refreshToken = typeof acc?.refreshToken === "string" ? (acc.refreshToken as string) : ""; + + let result: VerificationScanResult; + try { + result = await withTimeout( + scanAccountForVerification({ ...acc, index: idx, origin }, client, providerId), + perAccountTimeoutMs, + `scan timed out after ${perAccountTimeoutMs}ms`, + ); + } catch (err) { + result = { + index: idx, + origin, + refreshToken, + email, + enabled, + status: shouldScan ? "error" : "skipped", + message: err instanceof Error ? err.message : String(err), + }; + } + + results[idx] = result; + done += 1; + opts.onProgress?.({ done, total, result }); + } + }; + + await Promise.all(Array.from({ length: concurrency }, () => worker())); + return results.filter((r): r is VerificationScanResult => Boolean(r)); +} + +async function openUrlInDefaultBrowser(url: string): Promise { + return openBrowser(url); +} + +function copyTextToClipboard(text: string): boolean { + // Best-effort: copy text to Windows clipboard using built-in tools. + // Use stdin piping (spawnSync input) to avoid shell escaping issues. + try { + const input = text.endsWith("\n") ? text : `${text}\n`; + const result = spawnSync("cmd", ["/c", "clip"], { + input, + encoding: "utf8", + windowsHide: true, + }); + return result.status === 0; + } catch (err) { + log.debug("Failed to copy URL to clipboard", { err }); + return false; + } +} // Module-level toast debounce to persist across requests (fixes toast spam) const rateLimitToastCooldowns = new Map(); const RATE_LIMIT_TOAST_COOLDOWN_MS = 5000; const MAX_TOAST_COOLDOWN_ENTRIES = 100; -// Track if "all accounts blocked" toasts were shown to prevent spam in while loop -let softQuotaToastShown = false; -let rateLimitToastShown = false; +// Track if "all accounts rate-limited" toast was shown to prevent spam in while loop +let allAccountsRateLimitedToastShown = false; // Module-level reference to AccountManager for access from auth.login let activeAccountManager: import("./plugin/accounts").AccountManager | null = null; @@ -107,57 +676,8 @@ function shouldShowRateLimitToast(message: string): boolean { return true; } -function resetAllAccountsBlockedToasts(): void { - softQuotaToastShown = false; - rateLimitToastShown = false; -} - -const quotaRefreshInProgressByEmail = new Set(); - -async function triggerAsyncQuotaRefreshForAccount( - accountManager: AccountManager, - accountIndex: number, - client: PluginClient, - providerId: string, - intervalMinutes: number, -): Promise { - if (intervalMinutes <= 0) return; - - const accounts = accountManager.getAccounts(); - const account = accounts[accountIndex]; - if (!account || account.enabled === false) return; - - const accountKey = account.email ?? `idx-${accountIndex}`; - if (quotaRefreshInProgressByEmail.has(accountKey)) return; - - const intervalMs = intervalMinutes * 60 * 1000; - const age = account.cachedQuotaUpdatedAt != null - ? Date.now() - account.cachedQuotaUpdatedAt - : Infinity; - - if (age < intervalMs) return; - - quotaRefreshInProgressByEmail.add(accountKey); - - try { - const accountsForCheck = accountManager.getAccountsForQuotaCheck(); - const singleAccount = accountsForCheck[accountIndex]; - if (!singleAccount) { - quotaRefreshInProgressByEmail.delete(accountKey); - return; - } - - const results = await checkAccountsQuota([singleAccount], client, providerId); - - if (results[0]?.status === "ok" && results[0]?.quota?.groups) { - accountManager.updateQuotaCache(accountIndex, results[0].quota.groups); - accountManager.requestSaveToDisk(); - } - } catch (err) { - log.debug(`quota-refresh-failed email=${accountKey}`, { error: String(err) }); - } finally { - quotaRefreshInProgressByEmail.delete(accountKey); - } +function resetAllAccountsRateLimitedToast(): void { + allAccountsRateLimitedToastShown = false; } function trackWarmupAttempt(sessionId: string): boolean { @@ -234,27 +754,61 @@ function shouldSkipLocalServer(): boolean { return isWSL2() || isRemoteEnvironment(); } +function sanitizeBrowserUrl(input: string): string { + const raw = (input || "").trim(); + if (!raw) { + throw new Error("empty url"); + } + + const parsed = new URL(raw); + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") { + throw new Error(`unsupported url protocol: ${parsed.protocol}`); + } + + // Prevent accidental control chars from leaking into process args. + const normalized = parsed.toString().replace(/[\r\n\t]/g, ""); + if (!normalized) { + throw new Error("invalid url"); + } + return normalized; +} + async function openBrowser(url: string): Promise { + const tryOpen = (command: string, args: string[]): boolean => { + try { + const child = spawn(command, args, { + detached: true, + stdio: "ignore", + windowsHide: true, + }); + child.unref(); + return true; + } catch { + return false; + } + }; + try { + const safeUrl = sanitizeBrowserUrl(url); if (process.platform === "darwin") { - exec(`open "${url}"`); - return true; + return tryOpen("open", [safeUrl]); } if (process.platform === "win32") { - exec(`start "" "${url}"`); - return true; + if (tryOpen("rundll32", ["url.dll,FileProtocolHandler", safeUrl])) { + return true; + } + // `explorer.exe` can open URLs without involving a shell (more reliable for `&` in query strings). + return tryOpen("explorer.exe", [safeUrl]); } if (isWSL()) { - try { - exec(`wslview "${url}"`); + if (tryOpen("wslview", [safeUrl])) { return true; - } catch {} + } } if (!process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) { return false; } - exec(`xdg-open "${url}"`); - return true; + return tryOpen("xdg-open", [safeUrl]); } catch { return false; } @@ -361,6 +915,12 @@ async function persistAccountPool( const stored = replaceAll ? null : await loadAccounts(); const accounts = stored?.accounts ? [...stored.accounts] : []; + // When preserving deletions, `saveAccounts` will drop refresh tokens that are only present + // in our in-memory storage unless they are explicitly marked as "added". Track those here + // so login/refresh flows don't get pruned. + const addedRefreshTokens = new Set(); + const removedRefreshTokens = new Set(); + const indexByRefreshToken = new Map(); const indexByEmail = new Map(); for (let i = 0; i < accounts.length; i++) { @@ -394,6 +954,8 @@ async function persistAccountPool( if (result.email) { indexByEmail.set(result.email, newIndex); } + addedRefreshTokens.add(parts.refreshToken); + removedRefreshTokens.delete(parts.refreshToken); accounts.push({ email: result.email, refreshToken: parts.refreshToken, @@ -427,6 +989,10 @@ async function persistAccountPool( if (oldToken !== parts.refreshToken) { indexByRefreshToken.delete(oldToken); indexByRefreshToken.set(parts.refreshToken, existingIndex); + removedRefreshTokens.add(oldToken); + addedRefreshTokens.delete(oldToken); + addedRefreshTokens.add(parts.refreshToken); + removedRefreshTokens.delete(parts.refreshToken); } } @@ -439,15 +1005,25 @@ async function persistAccountPool( ? 0 : (typeof stored?.activeIndex === "number" && Number.isFinite(stored.activeIndex) ? stored.activeIndex : 0); - await saveAccounts({ - version: 3, - accounts, - activeIndex: clampInt(activeIndex, 0, accounts.length - 1), - activeIndexByFamily: { - claude: clampInt(activeIndex, 0, accounts.length - 1), - gemini: clampInt(activeIndex, 0, accounts.length - 1), + await saveAccounts( + { + version: 3, + accounts, + activeIndex: clampInt(activeIndex, 0, accounts.length - 1), + activeIndexByFamily: { + claude: clampInt(activeIndex, 0, accounts.length - 1), + gemini: clampInt(activeIndex, 0, accounts.length - 1), + }, }, - }); + replaceAll + ? { merge: false } + : { + merge: true, + preserveDeletions: true, + addedRefreshTokens: Array.from(addedRefreshTokens), + removedRefreshTokens: Array.from(removedRefreshTokens), + }, + ); } function buildAuthSuccessFromStoredAccount(account: { @@ -859,9 +1435,9 @@ export const createAntigravityPlugin = (providerId: string) => async ( const eventHandler = async (input: { event: { type: string; properties?: unknown } }) => { // Forward to update checker await updateChecker.event(input); - - // Track if this is a child session (subagent, background task) - // This is used to filter toasts based on toast_scope config + + // Track if this is a child session (subagent, background task). + // This is used to filter toasts based on toast_scope config. if (input.event.type === "session.created") { const props = input.event.properties as { info?: { parentID?: string } } | undefined; if (props?.info?.parentID) { @@ -869,7 +1445,7 @@ export const createAntigravityPlugin = (providerId: string) => async ( childSessionParentID = props.info.parentID; log.debug("child-session-detected", { parentID: props.info.parentID }); } else { - // Reset for root sessions - important when plugin instance is reused + // Reset for root sessions - important when plugin instance is reused. isChildSession = false; childSessionParentID = undefined; log.debug("root-session-detected", {}); @@ -904,10 +1480,17 @@ export const createAntigravityPlugin = (providerId: string) => async ( query: { directory }, }).catch(() => {}); - // Show success toast (respects toast_scope for child sessions) + // Show success toast (respects quiet_mode + toast_scope for child sessions) const successToast = getRecoverySuccessToast(); - log.debug("recovery-toast", { ...successToast, isChildSession, toastScope: config.toast_scope }); - if (!(config.toast_scope === "root_only" && isChildSession)) { + const toastScope = config.toast_scope; + log.debug("recovery-toast", { + ...successToast, + isChildSession, + toastScope, + parentID: childSessionParentID, + }); + + if (!config.quiet_mode && !(toastScope === "root_only" && isChildSession)) { await client.tui.showToast({ body: { title: successToast.title, @@ -984,14 +1567,15 @@ export const createAntigravityPlugin = (providerId: string) => async ( const auth = await getAuth(); // If OpenCode has no valid OAuth auth, clear any stale account storage - if (!isOAuthAuth(auth)) { - try { - await clearAccounts(); - } catch { - // ignore - } - return {}; - } + if (!isOAuthAuth(auth)) { + try { + await clearAccounts(); + await clearBlockedAccounts(); + } catch { + // ignore + } + return {}; + } // Validate that stored accounts are in sync with OpenCode's auth // If OpenCode's refresh token doesn't match any stored account, clear stale storage @@ -1022,9 +1606,12 @@ export const createAntigravityPlugin = (providerId: string) => async ( const logPath = getLogFilePath(); if (logPath) { try { - await client.tui.showToast({ - body: { message: `Debug log: ${logPath}`, variant: "info" }, - }); + const toastScope = config.toast_scope; + if (!config.quiet_mode && !(toastScope === "root_only" && isChildSession)) { + await client.tui.showToast({ + body: { message: `Debug log: ${logPath}`, variant: "info" }, + }); + } } catch { // TUI may not be available } @@ -1093,22 +1680,13 @@ export const createAntigravityPlugin = (providerId: string) => async ( // Use while(true) loop to handle rate limits with backoff // This ensures we wait and retry when all accounts are rate-limited const quietMode = config.quiet_mode; - const toastScope = config.toast_scope; - // Helper to show toast without blocking on abort (respects quiet_mode and toast_scope) + // Helper to show toast without blocking on abort (respects quiet_mode) const showToast = async (message: string, variant: "info" | "warning" | "success" | "error") => { - // Always log to debug regardless of toast filtering - log.debug("toast", { message, variant, isChildSession, toastScope }); - if (quietMode) return; if (abortSignal?.aborted) return; - - // Filter toasts for child sessions when toast_scope is "root_only" - if (toastScope === "root_only" && isChildSession) { - log.debug("toast-suppressed-child-session", { message, variant, parentID: childSessionParentID }); - return; - } - + if (config.toast_scope === "root_only" && isChildSession) return; + if (variant === "warning" && message.toLowerCase().includes("rate")) { if (!shouldShowRateLimitToast(message)) { return; @@ -1125,18 +1703,26 @@ export const createAntigravityPlugin = (providerId: string) => async ( }; const hasOtherAccountWithAntigravity = (currentAccount: any): boolean => { - if (family !== "gemini") return false; - // Use AccountManager method which properly checks for disabled/cooling-down accounts - return accountManager.hasOtherAccountWithAntigravityAvailable(currentAccount.index, family, model); + return accountManager.hasOtherAccountWithAntigravityAvailable( + currentAccount.index, + family, + model, + ); }; while (true) { // Check for abort at the start of each iteration checkAborted(); - const accountCount = accountManager.getAccountCount(); - const cliFirst = getCliFirst(config); - const preferredHeaderStyle = getHeaderStyleFromUrl(urlString, family, cliFirst); + let accountCount = accountManager.getAccountCount(); + + if (accountCount === 0) { + // Accounts may have been restored/quarantined outside this process (e.g. via + // `opencode auth login`). Reload once so users don't need to restart OpenCode. + await accountManager.reloadFromDisk(); + accountCount = accountManager.getAccountCount(); + } + const preferredHeaderStyle = getHeaderStyleFromUrl(urlString, family, config.cli_first); const explicitQuota = isExplicitQuotaFromUrl(urlString); const allowQuotaFallback = config.quota_fallback && !explicitQuota && family === "gemini"; @@ -1144,19 +1730,12 @@ export const createAntigravityPlugin = (providerId: string) => async ( throw new Error("No Antigravity accounts available. Run `opencode auth login`."); } - const softQuotaCacheTtlMs = computeSoftQuotaCacheTtlMs( - config.soft_quota_cache_ttl_minutes, - config.quota_refresh_interval_minutes, - ); - let account = accountManager.getCurrentOrNextForFamily( family, model, config.account_selection_strategy, preferredHeaderStyle, config.pid_offset_enabled, - config.soft_quota_threshold_percent, - softQuotaCacheTtlMs, ); if (!account && allowQuotaFallback) { @@ -1169,7 +1748,6 @@ export const createAntigravityPlugin = (providerId: string) => async ( alternateHeaderStyle, config.pid_offset_enabled, config.soft_quota_threshold_percent, - softQuotaCacheTtlMs, ); if (account) { pushDebug( @@ -1179,36 +1757,6 @@ export const createAntigravityPlugin = (providerId: string) => async ( } if (!account) { - if (accountManager.areAllAccountsOverSoftQuota(family, config.soft_quota_threshold_percent, softQuotaCacheTtlMs, model)) { - const threshold = config.soft_quota_threshold_percent; - const softQuotaWaitMs = accountManager.getMinWaitTimeForSoftQuota(family, threshold, softQuotaCacheTtlMs, model); - const maxWaitMs = (config.max_rate_limit_wait_seconds ?? 300) * 1000; - - if (softQuotaWaitMs === null || (maxWaitMs > 0 && softQuotaWaitMs > maxWaitMs)) { - const waitTimeFormatted = softQuotaWaitMs ? formatWaitTime(softQuotaWaitMs) : "unknown"; - await showToast( - `All accounts over ${threshold}% quota threshold. Resets in ${waitTimeFormatted}.`, - "error" - ); - throw new Error( - `Quota protection: All ${accountCount} account(s) are over ${threshold}% usage for ${family}. ` + - `Quota resets in ${waitTimeFormatted}. ` + - `Add more accounts, wait for quota reset, or set soft_quota_threshold_percent: 100 to disable.` - ); - } - - const waitSecValue = Math.max(1, Math.ceil(softQuotaWaitMs / 1000)); - pushDebug(`all-over-soft-quota family=${family} accounts=${accountCount} waitMs=${softQuotaWaitMs}`); - - if (!softQuotaToastShown) { - await showToast(`All ${accountCount} account(s) over ${threshold}% quota. Waiting ${formatWaitTime(softQuotaWaitMs)}...`, "warning"); - softQuotaToastShown = true; - } - - await sleep(softQuotaWaitMs, abortSignal); - continue; - } - const strictWait = explicitQuota || !allowQuotaFallback; // All accounts are rate-limited - wait and retry const waitMs = accountManager.getMinWaitTimeForFamily( @@ -1247,9 +1795,9 @@ export const createAntigravityPlugin = (providerId: string) => async ( ); } - if (!rateLimitToastShown) { + if (!allAccountsRateLimitedToastShown) { await showToast(`All ${accountCount} account(s) rate-limited for ${family}. Waiting ${waitSecValue}s...`, "warning"); - rateLimitToastShown = true; + allAccountsRateLimitedToastShown = true; } // Wait for the rate-limit cooldown to expire, then retry @@ -1258,7 +1806,7 @@ export const createAntigravityPlugin = (providerId: string) => async ( } // Account is available - reset the toast flag - resetAllAccountsBlockedToasts(); + resetAllAccountsRateLimitedToast(); pushDebug( `selected idx=${account.index} email=${account.email ?? ""} family=${family} accounts=${accountCount} strategy=${config.account_selection_strategy}`, @@ -1460,8 +2008,8 @@ export const createAntigravityPlugin = (providerId: string) => async ( let shouldSwitchAccount = false; // Determine header style from model suffix: - // - Models with antigravity- prefix -> use Antigravity quota - // - Gemini models without explicit prefix -> follow cli_first + // - Models with :antigravity suffix -> use Antigravity quota + // - Models without suffix (default) -> use Gemini CLI quota // - Claude models -> always use Antigravity let headerStyle = preferredHeaderStyle; pushDebug(`headerStyle=${headerStyle} explicit=${explicitQuota}`); @@ -1471,53 +2019,22 @@ export const createAntigravityPlugin = (providerId: string) => async ( // Check if this header style is rate-limited for this account if (accountManager.isRateLimitedForHeaderStyle(account, family, headerStyle, model)) { - // Antigravity-first fallback: exhaust antigravity across ALL accounts before gemini-cli - if (config.quota_fallback && !explicitQuota && family === "gemini" && headerStyle === "antigravity" && !cliFirst) { - // Check if ANY other account has antigravity available - if (accountManager.hasOtherAccountWithAntigravityAvailable(account.index, family, model)) { - // Switch to another account with antigravity (preserve antigravity priority) - pushDebug(`antigravity rate-limited on account ${account.index}, but available on other accounts. Switching.`); - shouldSwitchAccount = true; - } else { - // All accounts exhausted antigravity - fall back to gemini-cli on this account - const alternateStyle = accountManager.getAvailableHeaderStyle(account, family, model); - const fallbackStyle = resolveQuotaFallbackHeaderStyle({ - quotaFallback: config.quota_fallback, - cliFirst, - explicitQuota, - family, - headerStyle, - alternateStyle, - }); - if (fallbackStyle) { - await showToast( - `Antigravity quota exhausted on all accounts. Using Gemini CLI quota.`, - "warning" - ); - headerStyle = fallbackStyle; - pushDebug(`all-accounts antigravity exhausted, quota fallback: ${headerStyle}`); - } else { - shouldSwitchAccount = true; - } - } - } else if (config.quota_fallback && !explicitQuota && family === "gemini") { - // gemini-cli rate-limited - try alternate style (antigravity) on same account + // Quota fallback: try alternate quota on same account (direction depends on cli_first). + if (family === "gemini") { const alternateStyle = accountManager.getAvailableHeaderStyle(account, family, model); const fallbackStyle = resolveQuotaFallbackHeaderStyle({ quotaFallback: config.quota_fallback, - cliFirst, + cliFirst: config.cli_first, explicitQuota, family, headerStyle, alternateStyle, }); + if (fallbackStyle) { const quotaName = headerStyle === "gemini-cli" ? "Gemini CLI" : "Antigravity"; const altQuotaName = fallbackStyle === "gemini-cli" ? "Gemini CLI" : "Antigravity"; - await showToast( - `${quotaName} quota exhausted, using ${altQuotaName} quota`, - "warning" - ); + await showToast(`${quotaName} quota exhausted, using ${altQuotaName} quota`, "warning"); headerStyle = fallbackStyle; pushDebug(`quota fallback: ${headerStyle}`); } else { @@ -1549,13 +2066,6 @@ export const createAntigravityPlugin = (providerId: string) => async ( const currentEndpoint = ANTIGRAVITY_ENDPOINT_FALLBACKS[i]; - // Skip sandbox endpoints for Gemini CLI models - they only work with Antigravity quota - // Gemini CLI models must use production endpoint (cloudcode-pa.googleapis.com) - if (headerStyle === "gemini-cli" && currentEndpoint !== ANTIGRAVITY_ENDPOINT_PROD) { - pushDebug(`Skipping sandbox endpoint ${currentEndpoint} for gemini-cli headerStyle`); - continue; - } - try { const prepared = prepareAntigravityRequest( input, @@ -1600,7 +2110,7 @@ export const createAntigravityPlugin = (providerId: string) => async ( tokenConsumed = getTokenTracker().consume(account.index); } - const response = await fetch(prepared.request, prepared.init); + let response = await fetch(prepared.request, prepared.init); pushDebug(`status=${response.status} ${response.statusText}`); @@ -1740,9 +2250,9 @@ export const createAntigravityPlugin = (providerId: string) => async ( accountManager.requestSaveToDisk(); - // For Gemini, preserve preferred quota across accounts before fallback + // For Gemini, try prioritized Antigravity across ALL accounts first if (family === "gemini") { - if (headerStyle === "antigravity" && !cliFirst) { + if (headerStyle === "antigravity") { // Check if any other account has Antigravity quota for this model if (hasOtherAccountWithAntigravity(account)) { pushDebug(`antigravity exhausted on account ${account.index}, but available on others. Switching account.`); @@ -1753,49 +2263,25 @@ export const createAntigravityPlugin = (providerId: string) => async ( } // All accounts exhausted for Antigravity on THIS model. - // Before falling back to gemini-cli, check if it's the last option (automatic fallback) - if (config.quota_fallback && !explicitQuota) { - const alternateStyle = accountManager.getAvailableHeaderStyle(account, family, model); - const fallbackStyle = resolveQuotaFallbackHeaderStyle({ - quotaFallback: config.quota_fallback, - cliFirst, - explicitQuota, - family, - headerStyle, - alternateStyle, - }); - if (fallbackStyle) { - const safeModelName = model || "this model"; - await showToast( - `Antigravity quota exhausted for ${safeModelName}. Switching to Gemini CLI quota...`, - "warning" - ); - headerStyle = fallbackStyle; - pushDebug(`quota fallback: ${headerStyle}`); - continue; - } - } - } else if (headerStyle === "gemini-cli" && cliFirst) { - if (config.quota_fallback && !explicitQuota) { - const alternateStyle = accountManager.getAvailableHeaderStyle(account, family, model); - const fallbackStyle = resolveQuotaFallbackHeaderStyle({ - quotaFallback: config.quota_fallback, - cliFirst, - explicitQuota, - family, - headerStyle, - alternateStyle, - }); - if (fallbackStyle) { - const safeModelName = model || "this model"; - await showToast( - `Gemini CLI quota exhausted for ${safeModelName}. Switching to Antigravity quota...`, - "warning" - ); - headerStyle = fallbackStyle; - pushDebug(`quota fallback: ${headerStyle}`); - continue; - } + // Before falling back to the alternate quota pool, honor cli_first fallback direction. + const alternateStyle = accountManager.getAvailableHeaderStyle(account, family, model); + const fallbackStyle = resolveQuotaFallbackHeaderStyle({ + quotaFallback: config.quota_fallback, + cliFirst: config.cli_first, + explicitQuota, + family, + headerStyle, + alternateStyle, + }); + if (fallbackStyle) { + const safeModelName = model || "this model"; + await showToast( + `Antigravity quota exhausted for ${safeModelName}. Switching to ${fallbackStyle === "gemini-cli" ? "Gemini CLI" : "Antigravity"} quota...`, + "warning", + ); + headerStyle = fallbackStyle; + pushDebug(`quota fallback: ${headerStyle}`); + continue; } } } @@ -1855,8 +2341,10 @@ export const createAntigravityPlugin = (providerId: string) => async ( resetRateLimitState(account.index, quotaKey); resetAccountFailureState(account.index); + // Only retry alternate endpoints for transient errors. + // A 403 is typically account-specific (e.g., VALIDATION_REQUIRED) and retrying + // other endpoints just hides the actionable verification link from the user. const shouldRetryEndpoint = ( - response.status === 403 || response.status === 404 || response.status >= 500 ); @@ -1887,20 +2375,113 @@ export const createAntigravityPlugin = (providerId: string) => async ( account.consecutiveFailures = 0; getHealthTracker().recordSuccess(account.index); accountManager.markAccountUsed(account.index); - - void triggerAsyncQuotaRefreshForAccount( - accountManager, - account.index, - client, - providerId, - config.quota_refresh_interval_minutes, - ); } logAntigravityDebugResponse(debugContext, response, { note: response.ok ? "Success" : `Error ${response.status}`, }); if (!response.ok) { await logResponseBody(debugContext, response, response.status); + + // If an account is missing a Gemini Code Assist license (#3501), + // automatically rotate to the next account (user requested behavior). + // + // This error is account/project-specific and will not be fixed by endpoint fallbacks. + // Avoid returning this error to the user if other accounts can succeed. + let errorBodyText = ""; + try { + errorBodyText = await response.clone().text(); + } catch { + errorBodyText = ""; + } + if (isGeminiCodeAssistLicenseError(errorBodyText)) { + const accountLabel = account.email ? account.email : `Account ${account.index + 1}`; + const upstreamMsg = summarizeUpstreamLicenseMessage(errorBodyText); + await showToast(`No Gemini Code Assist license (${accountLabel})`, "warning"); + + // Cool down this account for a while to avoid repeated selection loops. + const cooldownMs = 10 * 60 * 1000; + // Treat license errors as a project-scoped configuration problem. + accountManager.markAccountCoolingDown(account, cooldownMs, "project-error"); + accountManager.markRateLimited(account, cooldownMs, family, headerStyle, model); + accountManager.requestSaveToDisk(); + + lastError = new Error(upstreamMsg); + lastFailure = { + response, + streaming: prepared.streaming, + debugContext, + requestedModel: prepared.requestedModel, + projectId: prepared.projectId, + endpoint: prepared.endpoint, + effectiveModel: prepared.effectiveModel, + sessionId: prepared.sessionId, + toolDebugMissing: prepared.toolDebugMissing, + toolDebugSummary: prepared.toolDebugSummary, + toolDebugPayload: prepared.toolDebugPayload, + }; + + shouldSwitchAccount = true; + break; + } + + // 403 VALIDATION_REQUIRED: Google account needs manual verification. + // Runtime/TUI behavior must be minimal: quarantine the account so it won't be + // selected again, and tell the user to verify via `opencode auth login`. + if (response.status === 403) { + const extracted = extractValidationUrlAndMessage(errorBodyText, account.email); + if (extracted.isValidationRequired) { + const accountLabel = account.email ? account.email : `Account ${account.index + 1}`; + await showToast( + `Verification required (${accountLabel}). Run \`opencode auth login\` to verify.`, + "warning", + ); + + // Remove from the active pool (and persist) so rotation doesn't keep hitting it. + await accountManager.quarantineAccountForVerification( + account, + summarizeUpstreamValidationMessage(extracted.message ?? errorBodyText), + extracted.validationUrl, + ); + + if (accountManager.getAccountCount() === 0) { + const errorMessage = [ + "Antigravity account verification required.", + "", + `Blocked account: ${accountLabel}`, + "", + "Run: opencode auth login", + "Then: Verify blocked accounts", + "", + "After verification completes, retry your last prompt. (No restart required.)", + ].join("\n"); + const synthetic = createSyntheticErrorResponse( + errorMessage, + prepared.requestedModel ?? prepared.effectiveModel ?? "unknown", + "validation_required", + ); + return await transformAntigravityResponse( + synthetic, + true, + debugContext, + prepared.requestedModel, + prepared.projectId, + prepared.endpoint, + prepared.effectiveModel, + prepared.sessionId, + prepared.toolDebugMissing, + prepared.toolDebugSummary, + prepared.toolDebugPayload, + debugLines, + ); + } + + lastError = new Error( + "Antigravity account verification required. Run `opencode auth login` and choose \"Verify blocked accounts\".", + ); + shouldSwitchAccount = true; + break; + } + } // Handle 400 "Prompt too long" with synthetic response to avoid session lock if (response.status === 400) { @@ -1912,7 +2493,21 @@ export const createAntigravityPlugin = (providerId: string) => async ( "warning" ); const errorMessage = `[Antigravity Error] Context is too long for this model.\n\nPlease use /compact to reduce context size, then retry your request.\n\nAlternatively, you can:\n- Use /clear to start fresh\n- Use /undo to remove recent messages\n- Switch to a model with larger context window`; - return createSyntheticErrorResponse(errorMessage, prepared.requestedModel); + const synthetic = createSyntheticErrorResponse(errorMessage, prepared.requestedModel); + return await transformAntigravityResponse( + synthetic, + true, + debugContext, + prepared.requestedModel, + prepared.projectId, + prepared.endpoint, + prepared.effectiveModel, + prepared.sessionId, + prepared.toolDebugMissing, + prepared.toolDebugSummary, + prepared.toolDebugPayload, + debugLines, + ); } } } @@ -2115,8 +2710,16 @@ export const createAntigravityPlugin = (providerId: string) => async ( // Check for existing accounts and prompt user for login mode let startFresh = true; let refreshAccountIndex: number | undefined; - const existingStorage = await loadAccounts(); - if (existingStorage && existingStorage.accounts.length > 0) { + let existingStorage = await loadAccounts(); + let blockedStorage = await loadBlockedAccounts(); + const hasActiveAccounts = (existingStorage?.accounts?.length ?? 0) > 0; + const hasQuarantinedAccounts = blockedStorage.accounts.length > 0; + + if (!existingStorage) { + existingStorage = { version: 3, accounts: [], activeIndex: 0 }; + } + + if (hasActiveAccounts || hasQuarantinedAccounts) { let menuResult; while (true) { const now = Date.now(); @@ -2154,134 +2757,688 @@ export const createAntigravityPlugin = (providerId: string) => async ( menuResult = await promptLoginMode(existingAccounts); + if (menuResult.deleteAccountIndex !== undefined) { + const deleteIndex = menuResult.deleteAccountIndex; + const toDelete = existingStorage.accounts[deleteIndex]; + const label = toDelete?.email || `Account ${deleteIndex + 1}`; + const refreshToken = typeof toDelete?.refreshToken === "string" ? toDelete.refreshToken : undefined; + + const updatedAccounts = existingStorage.accounts.filter((_, idx) => idx !== deleteIndex); + existingStorage.accounts = updatedAccounts; + + // Clamp active index after deletion. + const nextActive = updatedAccounts.length > 0 + ? Math.min(existingStorage.activeIndex ?? 0, updatedAccounts.length - 1) + : 0; + existingStorage.activeIndex = nextActive; + + // Merge+preserve deletions so concurrent OpenCode processes don't resurrect the account. + await saveAccounts(existingStorage, { + merge: true, + preserveDeletions: true, + removedRefreshTokens: refreshToken ? [refreshToken] : undefined, + }); + // Also remove from blocked/quarantine storage if present. + if (refreshToken) { + blockedStorage.accounts = (blockedStorage.accounts ?? []).filter((a: any) => a?.refreshToken !== refreshToken); + await saveBlockedAccounts(blockedStorage); + } + console.log(`\nDeleted ${label}.\n`); + continue; + } + if (menuResult.mode === "check") { - console.log("\n📊 Checking quotas for all accounts...\n"); - const results = await checkAccountsQuota(existingStorage.accounts, client, providerId); - let storageUpdated = false; - - for (const res of results) { - const label = res.email || `Account ${res.index + 1}`; - const disabledStr = res.disabled ? " (disabled)" : ""; - console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`); - console.log(` ${label}${disabledStr}`); - console.log(`━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━`); - - if (res.status === "error") { - console.log(` ❌ Error: ${res.error}\n`); + type QuotaMenuChoice = + | { type: "back" } + | { type: "rescan" } + | { type: "account"; index: number }; + + type QuotaAction = { type: "back" } | { type: "copy" }; + + const isInteractive = Boolean(process.stdin.isTTY && process.stdout.isTTY); + + const pct = (value?: number) => + typeof value === "number" && Number.isFinite(value) + ? `${Math.round(value * 100)}%` + : "UNKNOWN"; + + const resetHint = (resetTime?: string) => { + if (!resetTime) return ""; + const delta = Date.parse(resetTime) - Date.now(); + if (!Number.isFinite(delta)) return ""; + return `, resets in ${formatWaitTime(delta)}`; + }; + + const summarizeGroups = (res: AccountQuotaResult): string => { + const groups = res.quota?.groups; + if (!groups) return ""; + const parts: string[] = []; + if (groups.claude) parts.push(`C:${pct(groups.claude.remainingFraction)}`); + if (groups["gemini-pro"]) parts.push(`P:${pct(groups["gemini-pro"].remainingFraction)}`); + if (groups["gemini-flash"]) parts.push(`F:${pct(groups["gemini-flash"].remainingFraction)}`); + return parts.join(" "); + }; + + const quotaErrorOf = (res: AccountQuotaResult): string | undefined => { + if (res.status === "error") return res.error || "Unknown error"; + if (res.quota?.error) return res.quota.error; + return undefined; + }; + + const badgeOf = (res: AccountQuotaResult): string => { + if (res.disabled) return "[disabled]"; + return quotaErrorOf(res) ? "[error]" : "[ok]"; + }; + + const colorOf = (res: AccountQuotaResult): MenuItem["color"] => { + if (res.disabled) return undefined; + return quotaErrorOf(res) ? "red" : "green"; + }; + + const runCheck = async (): Promise => { + console.log("\nChecking quotas for all accounts...\n"); + const results = await checkAccountsQuota(existingStorage.accounts, client, providerId, { + concurrency: 3, + perAccountTimeoutMs: 25_000, + onProgress: ({ done, total, result }) => { + const label = result.email || `Account ${result.index + 1}`; + const disabledStr = result.disabled ? " (disabled)" : ""; + const err = quotaErrorOf(result); + const status = result.status === "disabled" + ? "DISABLED" + : err ? `ERROR (${err})` : "OK"; + console.log(`[${done}/${total}] ${label}${disabledStr}: ${status}`); + }, + }); + + let needsSave = false; + const addedRefreshTokens = new Set(); + const removedRefreshTokens = new Set(); + for (const res of results) { + if (res.updatedAccount) { + const previous = existingStorage.accounts[res.index]; + const previousRefreshToken = previous?.refreshToken; + const nextRefreshToken = res.updatedAccount.refreshToken; + if ( + typeof previousRefreshToken === "string" + && previousRefreshToken + && typeof nextRefreshToken === "string" + && nextRefreshToken + && previousRefreshToken !== nextRefreshToken + ) { + removedRefreshTokens.add(previousRefreshToken); + addedRefreshTokens.add(nextRefreshToken); + } + existingStorage.accounts[res.index] = res.updatedAccount; + needsSave = true; + } + } + if (needsSave) { + await saveAccounts(existingStorage, { + merge: true, + preserveDeletions: true, + addedRefreshTokens: Array.from(addedRefreshTokens), + removedRefreshTokens: Array.from(removedRefreshTokens), + }); + } + + return results; + }; + + let results = await runCheck(); + + if (!isInteractive) { + for (const res of results) { + const label = res.email || `Account ${res.index + 1}`; + const disabledStr = res.disabled ? " (disabled)" : ""; + console.log(`\n${res.index + 1}. ${label}${disabledStr}`); + + const err = quotaErrorOf(res); + if (err) { + console.log(` Error: ${err}`); + continue; + } + + if (!res.quota || Object.keys(res.quota.groups).length === 0) { + console.log(" No quota information available."); + continue; + } + + const printGrp = (name: string, group: any) => { + if (!group) return; + console.log( + ` ${name}: ${pct(group.remainingFraction)}${resetHint(group.resetTime)}`, + ); + }; + printGrp("Claude", res.quota.groups.claude); + printGrp("Gemini 3 Pro", res.quota.groups["gemini-pro"]); + printGrp("Gemini 3 Flash", res.quota.groups["gemini-flash"]); + } + + console.log(""); + continue; + } + + const summaryCounts = (results: AccountQuotaResult[]) => { + const disabled = results.filter((r) => r.disabled).length; + const errored = results.filter((r) => !r.disabled && !!quotaErrorOf(r)).length; + const ok = Math.max(0, results.length - disabled - errored); + return { ok, errored, disabled }; + }; + + while (true) { + const counts = summaryCounts(results); + + const items: MenuItem[] = [ + { label: "Actions", value: { type: "back" }, kind: "heading" }, + { label: "Rescan accounts", value: { type: "rescan" }, color: "cyan" }, + { label: "Back", value: { type: "back" } }, + { label: "", value: { type: "back" }, separator: true }, + { label: "Accounts", value: { type: "back" }, kind: "heading" }, + ...results.map((res) => { + const label = res.email || `Account ${res.index + 1}`; + const numbered = `${res.index + 1}. ${label}`; + const hint = quotaErrorOf(res) ? "" : summarizeGroups(res); + return { + label: `${numbered} ${badgeOf(res)}`, + hint, + value: { type: "account" as const, index: res.index }, + color: colorOf(res), + }; + }), + ]; + + const choice = await select(items, { + message: "Check quotas", + subtitle: `Results: ${counts.ok} ok, ${counts.errored} error, ${counts.disabled} disabled`, + help: "Up/Down to select | Enter: details | Esc: back", + clearScreen: true, + }); + + if (!choice || choice.type === "back") break; + + if (choice.type === "rescan") { + results = await runCheck(); continue; } - // ANSI color codes - const colors = { - red: '\x1b[31m', - orange: '\x1b[33m', // Yellow/orange - green: '\x1b[32m', - reset: '\x1b[0m', - }; + const selected = results.find((r) => r.index === choice.index); + if (!selected) continue; - // Get color based on remaining percentage - const getColor = (remaining?: number): string => { - if (typeof remaining !== 'number') return colors.reset; - if (remaining < 0.2) return colors.red; - if (remaining < 0.6) return colors.orange; - return colors.green; - }; + const label = selected.email || `Account ${selected.index + 1}`; + const err = quotaErrorOf(selected); + + const detailLines: string[] = []; + if (selected.disabled) { + detailLines.push("This account is disabled and will not be used for requests."); + } + if (err) { + detailLines.push(`Error: ${err}`); + } + + const quota = selected.quota; + if (!quota || Object.keys(quota.groups).length === 0) { + if (!err) { + detailLines.push("No quota information available."); + } + } else { + const pushGrp = (name: string, group: any) => { + if (!group) return; + detailLines.push(`${name}: ${pct(group.remainingFraction)}${resetHint(group.resetTime)}`); + }; + pushGrp("Claude", quota.groups.claude); + pushGrp("Gemini 3 Pro", quota.groups["gemini-pro"]); + pushGrp("Gemini 3 Flash", quota.groups["gemini-flash"]); + if (quota.modelCount) detailLines.push(`Models counted: ${quota.modelCount}`); + } + + const copyText = `Quota check for ${label}\n` + detailLines.map((l) => `- ${l}`).join("\n"); + + const actionItems: MenuItem[] = [ + { label: "Details", value: { type: "back" }, kind: "heading" }, + ...detailLines.map( + (l): MenuItem => ({ label: l, value: { type: "back" }, kind: "heading" }), + ), + { label: "", value: { type: "back" }, separator: true }, + { label: "Copy details", value: { type: "copy" }, color: "cyan" }, + { label: "Back", value: { type: "back" } }, + ]; + + const action = await select(actionItems, { + message: `Quota: ${label}`, + subtitle: badgeOf(selected), + help: "Up/Down to select | Enter: confirm | Esc: back", + clearScreen: true, + }); + + if (!action || action.type === "back") continue; + + const copied = copyTextToClipboard(copyText); + if (copied) { + console.log("\nCopied quota details to clipboard.\n"); + } else { + console.log(`\n${copyText}\n`); + } + } + + console.log(""); + continue; + } + + if (menuResult.mode === "verify") { + const labelOf = (r: VerificationScanResult) => r.email || `Account ${r.index + 1}`; - // Helper to create colored progress bar - const createProgressBar = (remaining?: number, width: number = 20): string => { - if (typeof remaining !== 'number') return '░'.repeat(width) + ' ???'; - const filled = Math.round(remaining * width); - const empty = width - filled; - const color = getColor(remaining); - const bar = `${color}${'█'.repeat(filled)}${colors.reset}${'░'.repeat(empty)}`; - const pct = `${color}${Math.round(remaining * 100)}%${colors.reset}`.padStart(4 + color.length + colors.reset.length); - return `${bar} ${pct}`; + let scanResults: VerificationScanResult[] = []; + + const runScan = async () => { + // Reload quarantined accounts each scan so the UI reflects runtime quarantines. + blockedStorage = await loadBlockedAccounts(); + + const activeAccounts = existingStorage.accounts; + const quarantinedAccounts = blockedStorage.accounts; + + const total = activeAccounts.length + quarantinedAccounts.length; + let done = 0; + + console.log("\nScanning accounts for Google verification blocks...\n"); + + const onProgress = (result: VerificationScanResult) => { + done += 1; + const label = labelOf(result); + const disabledStr = result.enabled ? "" : " (disabled)"; + const originStr = result.origin === "quarantined" ? " (quarantined)" : ""; + console.log( + `[${done}/${Math.max(1, total)}] ${label}${disabledStr}${originStr}: ${formatVerificationScanStatusForDisplay(result)}`, + ); }; - // Helper to format reset time with days support - const formatReset = (resetTime?: string): string => { - if (!resetTime) return ''; - const ms = Date.parse(resetTime) - Date.now(); - if (ms <= 0) return ' (resetting...)'; - - const hours = ms / (1000 * 60 * 60); - if (hours >= 24) { - const days = Math.floor(hours / 24); - const remainingHours = Math.floor(hours % 24); - if (remainingHours > 0) { - return ` (resets in ${days}d ${remainingHours}h)`; + const activeResults = await scanAllAccountsForVerification( + activeAccounts, + client, + providerId, + { + concurrency: 3, + perAccountTimeoutMs: 25_000, + onProgress: ({ result }) => onProgress(result), + }, + "active", + ); + + const quarantinedResults = await scanAllAccountsForVerification( + quarantinedAccounts, + client, + providerId, + { + concurrency: 3, + perAccountTimeoutMs: 25_000, + onProgress: ({ result }) => onProgress(result), + }, + "quarantined", + ); + + scanResults = [...activeResults, ...quarantinedResults]; + + // Prefer the verification URL captured at quarantine time (from runtime), + // since the scan endpoint can sometimes return shortened URLs that 400. + const quarantinedByToken = new Map( + (blockedStorage.accounts ?? []) + .filter((a: any) => a?.refreshToken) + .map((a: any) => [a.refreshToken as string, a] as const), + ); + for (const r of scanResults) { + if (r.origin !== "quarantined") continue; + const blockedAcc = quarantinedByToken.get(r.refreshToken); + const storedUrl = blockedAcc?.verifyUrl; + if (typeof storedUrl !== "string" || !storedUrl.trim()) continue; + + if (!r.verifyUrl) { + r.verifyUrl = storedUrl.trim(); + continue; + } + + const hasPlt = (u: string) => u.toLowerCase().includes("plt="); + if (hasPlt(storedUrl) && !hasPlt(r.verifyUrl)) { + r.verifyUrl = storedUrl.trim(); } - return ` (resets in ${days}d)`; } - return ` (resets in ${formatWaitTime(ms)})`; - }; - // Display Gemini CLI Quota first (as requested - swap order) - const hasGeminiCli = res.geminiCliQuota && res.geminiCliQuota.models.length > 0; - console.log(`\n ┌─ Gemini CLI Quota`); - if (!hasGeminiCli) { - const errorMsg = res.geminiCliQuota?.error || "No Gemini CLI quota available"; - console.log(` │ └─ ${errorMsg}`); - } else { - const models = res.geminiCliQuota!.models; - models.forEach((model, idx) => { - const isLast = idx === models.length - 1; - const connector = isLast ? "└─" : "├─"; - const bar = createProgressBar(model.remainingFraction); - const reset = formatReset(model.resetTime); - const modelName = model.modelId.padEnd(29); - console.log(` │ ${connector} ${modelName} ${bar}${reset}`); + // Auto-restore quarantined accounts that are now OK. + const okQuarantined = scanResults.filter((r) => r.origin === "quarantined" && r.status === "ok"); + if (okQuarantined.length > 0) { + const okSet = new Set(okQuarantined.map((r) => r.refreshToken)); + const toRestore = blockedStorage.accounts.filter((a) => okSet.has(a.refreshToken)); + const remainingBlocked = blockedStorage.accounts.filter((a) => !okSet.has(a.refreshToken)); + + let restoredCount = 0; + for (const blockedAcc of toRestore) { + // Strip blocked-only fields before moving back into the active rotation pool. + const { blockedAt, blockedReason, verifyUrl, ...rest } = blockedAcc as any; + if (!rest?.refreshToken) continue; + if (!existingStorage.accounts.some((a) => a.refreshToken === rest.refreshToken)) { + existingStorage.accounts.push(rest); + restoredCount += 1; + } + } + + blockedStorage.accounts = remainingBlocked as any; + await saveAccounts(existingStorage, { + merge: true, + preserveDeletions: true, + addedRefreshTokens: toRestore.map((a: any) => a?.refreshToken).filter(Boolean), }); + await saveBlockedAccounts(blockedStorage); + + if (restoredCount > 0) { + console.log(`\nRestored ${restoredCount} verified account(s) to active rotation.\n`); + } } - // Display Antigravity Quota second - const hasAntigravity = res.quota && Object.keys(res.quota.groups).length > 0; - console.log(` │`); - console.log(` └─ Antigravity Quota`); - if (!hasAntigravity) { - const errorMsg = res.quota?.error || "No quota information available"; - console.log(` └─ ${errorMsg}`); - } else { - const groups = res.quota!.groups; - const groupEntries = [ - { name: "Claude", data: groups.claude }, - { name: "Gemini 3 Pro", data: groups["gemini-pro"] }, - { name: "Gemini 3 Flash", data: groups["gemini-flash"] }, - ].filter(g => g.data); - - groupEntries.forEach((g, idx) => { - const isLast = idx === groupEntries.length - 1; - const connector = isLast ? "└─" : "├─"; - const bar = createProgressBar(g.data!.remainingFraction); - const reset = formatReset(g.data!.resetTime); - const modelName = g.name.padEnd(29); - console.log(` ${connector} ${modelName} ${bar}${reset}`); - }); + const blocked = scanResults.filter((r) => r.status === "blocked"); + const license = scanResults.filter((r) => r.status === "license"); + const ok = scanResults.filter((r) => r.status === "ok"); + const skipped = scanResults.filter((r) => r.status === "skipped"); + const errors = scanResults.filter((r) => r.status === "error"); + + console.log( + `\nResults: ${ok.length} ok, ${blocked.length} blocked, ${license.length} no-license, ${errors.length} error, ${skipped.length} skipped\n`, + ); + }; + + await runScan(); + + const isInteractive = Boolean(process.stdin.isTTY && process.stdout.isTTY); + if (!isInteractive) { + const blocked = scanResults.filter((r) => r.status === "blocked" && !!r.verifyUrl); + if (blocked.length === 0) { + console.log("\nNo blocked accounts detected.\n"); + continue; + } + + console.log("\nBlocked accounts:\n"); + for (const b of blocked) { + console.log(`- ${labelOf(b)}${b.origin === "quarantined" ? " (quarantined)" : ""}`); + if (b.verifyUrl) console.log(` ${b.verifyUrl}`); } console.log(""); + continue; + } - // Cache quota data for soft quota protection - if (res.quota?.groups) { - const acc = existingStorage.accounts[res.index]; - if (acc) { - acc.cachedQuota = res.quota.groups; - acc.cachedQuotaUpdatedAt = Date.now(); - storageUpdated = true; + type VerifyMenuChoice = + | { type: "back" } + | { type: "rescan" } + | { type: "toggleAll" } + | { type: "account"; refreshToken: string }; + + const badgeOf = (r: VerificationScanResult): string => { + const originBadge = r.origin === "quarantined" ? "[quarantined]" : ""; + if (r.enabled === false || r.status === "skipped") { + return originBadge ? `${originBadge} [disabled]` : "[disabled]"; + } + const statusBadge = (() => { + switch (r.status) { + case "ok": + return "[ok]"; + case "blocked": + return "[blocked]"; + case "license": + return "[no-license]"; + case "error": + default: + return "[error]"; } + })(); + return originBadge ? `${originBadge} ${statusBadge}` : statusBadge; + }; + + const colorOf = (r: VerificationScanResult): MenuItem["color"] => { + if (r.enabled === false || r.status === "skipped") return undefined; + switch (r.status) { + case "ok": + return "green"; + case "blocked": + return "red"; + case "license": + return "yellow"; + case "error": + default: + return "red"; } + }; - if (res.updatedAccount) { - existingStorage.accounts[res.index] = { - ...res.updatedAccount, - cachedQuota: res.quota?.groups, - cachedQuotaUpdatedAt: Date.now(), - }; - storageUpdated = true; + const rankOf = (r: VerificationScanResult): number => { + // Prioritize actionable items first. + switch (r.status) { + case "blocked": + return 0; + case "error": + return 1; + case "license": + return 2; + case "ok": + return 3; + case "skipped": + default: + return 4; + } + }; + + type BlockedAction = + | { type: "back" } + | { type: "copySignin" } + | { type: "openSignin" } + | { type: "copyDirect" } + | { type: "openDirect" }; + type DetailAction = { type: "back" } | { type: "copy" }; + + let showAll = false; + + const sortedForUi = (): VerificationScanResult[] => + [...scanResults].sort((a, b) => { + const ra = rankOf(a); + const rb = rankOf(b); + if (ra !== rb) return ra - rb; + const ao = a.origin === "quarantined" ? 0 : 1; + const bo = b.origin === "quarantined" ? 0 : 1; + if (ao !== bo) return ao - bo; + return labelOf(a).localeCompare(labelOf(b)); + }); + + while (true) { + const sorted = sortedForUi(); + const blocked = sorted.filter((r) => r.status === "blocked"); + const issues = sorted.filter((r) => r.status === "license" || r.status === "error"); + const ok = sorted.filter((r) => r.status === "ok"); + const skipped = sorted.filter((r) => r.status === "skipped"); + + const hasActionable = blocked.length > 0 || issues.length > 0; + + const items: MenuItem[] = [ + { label: "Actions", value: { type: "back" }, kind: "heading" }, + { label: "Rescan accounts", value: { type: "rescan" }, color: "cyan" }, + { + label: showAll ? "Hide OK accounts" : "Show all accounts", + value: { type: "toggleAll" }, + color: "cyan", + }, + { label: "Back", value: { type: "back" } }, + { label: "", value: { type: "back" }, separator: true }, + ]; + + if (!hasActionable && !showAll) { + items.push({ label: "No blocked accounts detected.", value: { type: "back" }, kind: "heading" }); + } + + const pushGroup = (title: string, group: VerificationScanResult[]) => { + if (group.length === 0) return; + items.push({ label: title, value: { type: "back" }, kind: "heading" }); + for (const r of group) { + items.push({ + label: `${r.index + 1}. ${labelOf(r)} ${badgeOf(r)}`, + value: { type: "account" as const, refreshToken: r.refreshToken }, + color: colorOf(r), + }); + } + items.push({ label: "", value: { type: "back" }, separator: true }); + }; + + if (hasActionable || showAll) { + pushGroup("Blocked (verification required)", blocked); + pushGroup("Other issues", issues); + } + if (showAll) { + pushGroup("OK", ok); + pushGroup("Disabled", skipped); + } + + // Remove trailing separator if present. + const last = items[items.length - 1]; + if (last?.separator) items.pop(); + + const subtitle = showAll + ? "All accounts (blocked first)" + : hasActionable + ? "Actionable accounts first" + : "All accounts OK"; + + const choice = await select(items, { + message: "Verify blocked accounts", + subtitle, + help: "Up/Down to select | Enter: options | Esc: back", + clearScreen: true, + }); + + if (!choice || choice.type === "back") break; + + if (choice.type === "toggleAll") { + showAll = !showAll; + continue; + } + + if (choice.type === "rescan") { + await runScan(); + continue; + } + + const selected = scanResults.find((r) => r.refreshToken === choice.refreshToken); + if (!selected) continue; + + const selectedLabel = labelOf(selected); + const selectedStatus = formatVerificationScanStatusForDisplay(selected); + + if (selected.status === "blocked") { + const verifyUrl = selected.verifyUrl ?? ""; + if (!verifyUrl) { + console.log("\nBlocked account detected, but Google did not return a verification URL.\n"); + continue; + } + + const verifyLink = normalizeGoogleVerificationUrl(verifyUrl.trim(), selected.email); + const signinFirstLink = buildGoogleSigninFirstUrl(verifyLink, selected.email); + + const action = await select( + [ + { label: "Open sign-in page (prefill email)", value: { type: "openSignin" }, color: "cyan" }, + { label: "Copy sign-in page link", value: { type: "copySignin" }, color: "cyan" }, + { label: "Open direct verification link", value: { type: "openDirect" } }, + { label: "Copy direct verification link", value: { type: "copyDirect" } }, + { label: "Back", value: { type: "back" } }, + ], + { + message: `Verify ${selectedLabel}`, + subtitle: "Sign-in first prefills the blocked email, then continues to verification", + help: "Up/Down to select | Enter: confirm | Esc: back", + clearScreen: true, + }, + ); + + if (!action || action.type === "back") continue; + + if (action.type === "copySignin") { + const ok = copyTextToClipboard(signinFirstLink); + if (ok) { + console.log("\nSign-in link copied to clipboard (email prefilled).\n"); + } else { + console.log("\nFailed to copy sign-in link to clipboard.\n"); + console.log(`${signinFirstLink}\n`); + } + continue; + } + + if (action.type === "openSignin") { + const opened = await openUrlInDefaultBrowser(signinFirstLink); + if (opened) { + console.log("\nOpened sign-in page (email prefilled). After password, Google should continue to verification.\n"); + } else { + console.log("\nCould not open browser automatically. Use this sign-in link:\n"); + console.log(`${signinFirstLink}\n`); + } + continue; + } + + if (action.type === "copyDirect") { + const ok = copyTextToClipboard(verifyLink); + if (ok) { + console.log("\nDirect verification link copied to clipboard.\n"); + } else { + console.log("\nFailed to copy direct verification link to clipboard.\n"); + console.log(`${verifyLink}\n`); + } + continue; + } + + if (action.type === "openDirect") { + const opened = await openUrlInDefaultBrowser(verifyLink); + if (opened) { + console.log("\nOpened direct verification link in browser.\n"); + } else { + console.log("\nCould not open browser automatically. Use this verification link:\n"); + console.log(`${verifyLink}\n`); + } + continue; + } + + continue; + } + + const detailLines: string[] = [ + `Status: ${selectedStatus}`, + ]; + if (selected.origin === "quarantined") { + detailLines.push("Origin: quarantined (removed from rotation)"); + } + if (selected.message) { + detailLines.push(`Message: ${selected.message}`); + } + + const copyText = `${selectedLabel}\n` + detailLines.join("\n"); + + const actionItems: MenuItem[] = [ + { label: "Details", value: { type: "back" }, kind: "heading" }, + ...detailLines.map( + (l): MenuItem => ({ label: l, value: { type: "back" }, kind: "heading" }), + ), + { label: "", value: { type: "back" }, separator: true }, + { label: "Copy details", value: { type: "copy" }, color: "cyan" }, + { label: "Back", value: { type: "back" } }, + ]; + + const action = await select(actionItems, { + message: selectedLabel, + subtitle: selectedStatus, + help: "Up/Down to select | Enter: confirm | Esc: back", + clearScreen: true, + }); + + if (!action || action.type === "back") continue; + + const copied = copyTextToClipboard(copyText); + if (copied) { + console.log("\nCopied details to clipboard.\n"); + } else { + console.log(`\n${copyText}\n`); } } - if (storageUpdated) { - await saveAccounts(existingStorage); - } + console.log(""); continue; } @@ -2291,7 +3448,7 @@ export const createAntigravityPlugin = (providerId: string) => async ( const acc = existingStorage.accounts[menuResult.toggleAccountIndex]; if (acc) { acc.enabled = acc.enabled === false; - await saveAccounts(existingStorage); + await saveAccounts(existingStorage, { merge: true, preserveDeletions: true }); activeAccountManager?.setAccountEnabled(menuResult.toggleAccountIndex, acc.enabled); console.log(`\nAccount ${acc.email || menuResult.toggleAccountIndex + 1} ${acc.enabled ? 'enabled' : 'disabled'}.\n`); } @@ -2371,7 +3528,6 @@ export const createAntigravityPlugin = (providerId: string) => async ( }), }; } - if (menuResult.refreshAccountIndex !== undefined) { refreshAccountIndex = menuResult.refreshAccountIndex; const refreshEmail = existingStorage.accounts[refreshAccountIndex]?.email; @@ -2381,6 +3537,7 @@ export const createAntigravityPlugin = (providerId: string) => async ( if (menuResult.deleteAll) { await clearAccounts(); + await clearBlockedAccounts(); console.log("\nAll accounts deleted.\n"); startFresh = true; try { @@ -2523,21 +3680,38 @@ export const createAntigravityPlugin = (providerId: string) => async ( if (currentStorage) { const updatedAccounts = [...currentStorage.accounts]; const parts = parseRefreshParts(result.refresh); - if (parts.refreshToken) { + const previous = updatedAccounts[refreshAccountIndex]; + const previousRefreshToken = + typeof previous?.refreshToken === "string" ? previous.refreshToken : undefined; + const nextRefreshToken = parts.refreshToken; + + if (nextRefreshToken) { updatedAccounts[refreshAccountIndex] = { - email: result.email ?? updatedAccounts[refreshAccountIndex]?.email, - refreshToken: parts.refreshToken, - projectId: parts.projectId ?? updatedAccounts[refreshAccountIndex]?.projectId, - managedProjectId: parts.managedProjectId ?? updatedAccounts[refreshAccountIndex]?.managedProjectId, - addedAt: updatedAccounts[refreshAccountIndex]?.addedAt ?? Date.now(), + ...(previous ?? {}), + email: result.email ?? previous?.email, + refreshToken: nextRefreshToken, + projectId: parts.projectId ?? previous?.projectId, + managedProjectId: parts.managedProjectId ?? previous?.managedProjectId, + addedAt: previous?.addedAt ?? Date.now(), lastUsed: Date.now(), }; - await saveAccounts({ - version: 3, - accounts: updatedAccounts, - activeIndex: currentStorage.activeIndex, - activeIndexByFamily: currentStorage.activeIndexByFamily, - }); + await saveAccounts( + { + version: 3, + accounts: updatedAccounts, + activeIndex: currentStorage.activeIndex, + activeIndexByFamily: currentStorage.activeIndexByFamily, + }, + { + merge: true, + preserveDeletions: true, + addedRefreshTokens: [nextRefreshToken], + removedRefreshTokens: + previousRefreshToken && previousRefreshToken !== nextRefreshToken + ? [previousRefreshToken] + : undefined, + }, + ); } } } else { @@ -2807,34 +3981,10 @@ function getModelFamilyFromUrl(urlString: string): ModelFamily { return family; } -function resolveQuotaFallbackHeaderStyle(input: { - quotaFallback: boolean; - cliFirst: boolean; - explicitQuota: boolean; - family: ModelFamily; - headerStyle: HeaderStyle; - alternateStyle: HeaderStyle | null; -}): HeaderStyle | null { - if (!input.quotaFallback || input.explicitQuota || input.family !== "gemini") { - return null; - } - if (!input.alternateStyle || input.alternateStyle === input.headerStyle) { - return null; - } - if (input.cliFirst && input.headerStyle !== "gemini-cli") { - return null; - } - return input.alternateStyle; -} - -function getCliFirst(config: AntigravityConfig): boolean { - return (config as AntigravityConfig & { cli_first?: boolean }).cli_first ?? false; -} - function getHeaderStyleFromUrl( urlString: string, family: ModelFamily, - cliFirst: boolean = false, + cliFirst: boolean, ): HeaderStyle { if (family === "claude") { return "antigravity"; @@ -2843,8 +3993,10 @@ function getHeaderStyleFromUrl( if (!modelWithSuffix) { return cliFirst ? "gemini-cli" : "antigravity"; } - const { quotaPreference } = resolveModelWithTier(modelWithSuffix, { cli_first: cliFirst }); - return quotaPreference ?? "antigravity"; + const { quotaPreference } = resolveModelWithTier(modelWithSuffix, { + cli_first: cliFirst, + }); + return quotaPreference ?? (cliFirst ? "gemini-cli" : "antigravity"); } function isExplicitQuotaFromUrl(urlString: string): boolean { @@ -2856,6 +4008,39 @@ function isExplicitQuotaFromUrl(urlString: string): boolean { return explicitQuota ?? false; } +function resolveQuotaFallbackHeaderStyle(input: { + quotaFallback: boolean; + cliFirst: boolean; + explicitQuota: boolean; + family: ModelFamily; + headerStyle: HeaderStyle; + alternateStyle: HeaderStyle | null; +}): HeaderStyle | null { + const { + quotaFallback, + cliFirst, + explicitQuota, + family, + headerStyle, + alternateStyle, + } = input; + + if (!quotaFallback) return null; + if (explicitQuota) return null; + if (family !== "gemini") return null; + if (!alternateStyle) return null; + if (alternateStyle === headerStyle) return null; + + // Direction matters: + // - cli_first=true: start on gemini-cli, allow fallback to antigravity only + // - cli_first=false: start on antigravity, allow fallback to gemini-cli only + if (cliFirst) { + return headerStyle === "gemini-cli" && alternateStyle === "antigravity" ? alternateStyle : null; + } + return headerStyle === "antigravity" && alternateStyle === "gemini-cli" ? alternateStyle : null; +} + +// Exported only for unit tests. export const __testExports = { getHeaderStyleFromUrl, resolveQuotaFallbackHeaderStyle, diff --git a/src/plugin/accounts.test.ts b/src/plugin/accounts.test.ts index 01bf561..7b47455 100644 --- a/src/plugin/accounts.test.ts +++ b/src/plugin/accounts.test.ts @@ -1,7 +1,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import { AccountManager, type ModelFamily, type HeaderStyle, parseRateLimitReason, calculateBackoffMs, type RateLimitReason, resolveQuotaGroup } from "./accounts"; -import type { AccountStorageV3 } from "./storage"; +import { saveAccounts, type AccountStorageV3 } from "./storage"; import type { OAuthAuthDetails } from "./types"; // Mock storage to prevent test data from leaking to real config files @@ -541,6 +541,92 @@ describe("AccountManager", () => { expect(manager.getCurrentAccountForFamily("claude")?.parts.refreshToken).toBe("r2"); expect(manager.getCurrentAccountForFamily("gemini")?.parts.refreshToken).toBe("r2"); }); + + it("preserves -1 family sentinel from storage", () => { + const stored: AccountStorageV3 = { + version: 3, + accounts: [ + { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, + ], + activeIndex: 0, + activeIndexByFamily: { + claude: -1, + gemini: -1, + }, + }; + + const manager = new AccountManager(undefined, stored); + + expect(manager.getCurrentAccountForFamily("claude")).toBeNull(); + expect(manager.getCurrentAccountForFamily("gemini")).toBeNull(); + }); + + it("writes -1 family sentinel to storage when no enabled account exists", async () => { + const stored: AccountStorageV3 = { + version: 3, + accounts: [ + { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, + ], + activeIndex: 0, + }; + + const manager = new AccountManager(undefined, stored); + (manager as unknown as { currentAccountIndexByFamily: Record }).currentAccountIndexByFamily = { + claude: -1, + gemini: -1, + }; + + vi.mocked(saveAccounts).mockClear(); + await manager.saveToDisk(); + + const saved = vi.mocked(saveAccounts).mock.calls[0]?.[0] as AccountStorageV3 | undefined; + expect(saved).toBeDefined(); + expect(saved?.activeIndexByFamily).toEqual({ claude: -1, gemini: -1 }); + expect(saved?.activeIndex).toBe(0); + }); + + it("persists activeIndex from cursor not from claude family index", async () => { + const stored: AccountStorageV3 = { + version: 3, + accounts: [ + { refreshToken: "r1", projectId: "p1", addedAt: 1, lastUsed: 0 }, + { refreshToken: "r2", projectId: "p2", addedAt: 1, lastUsed: 0 }, + ], + activeIndex: 1, + activeIndexByFamily: { + claude: 0, + gemini: 1, + }, + }; + + const manager = new AccountManager(undefined, stored); + + vi.mocked(saveAccounts).mockClear(); + await manager.saveToDisk(); + + const saved = vi.mocked(saveAccounts).mock.calls[0]?.[0] as AccountStorageV3 | undefined; + expect(saved).toBeDefined(); + expect(saved?.activeIndex).toBe(1); + expect(saved?.activeIndexByFamily).toEqual({ claude: 0, gemini: 1 }); + }); + + it("uses -1 activeIndex sentinel when saving an empty account list", async () => { + const stored: AccountStorageV3 = { + version: 3, + accounts: [], + activeIndex: 0, + }; + + const manager = new AccountManager(undefined, stored); + + vi.mocked(saveAccounts).mockClear(); + await manager.saveToDisk(); + + const saved = vi.mocked(saveAccounts).mock.calls[0]?.[0] as AccountStorageV3 | undefined; + expect(saved).toBeDefined(); + expect(saved?.activeIndex).toBe(-1); + expect(saved?.activeIndexByFamily).toEqual({ claude: -1, gemini: -1 }); + }); }); describe("account cooldown (non-429 errors)", () => { diff --git a/src/plugin/accounts.ts b/src/plugin/accounts.ts index dae9e12..d43b2ea 100644 --- a/src/plugin/accounts.ts +++ b/src/plugin/accounts.ts @@ -1,16 +1,30 @@ import { formatRefreshParts, parseRefreshParts } from "./auth"; -import { loadAccounts, saveAccounts, type AccountStorageV3, type AccountMetadataV3, type RateLimitStateV3, type ModelFamily, type HeaderStyle, type CooldownReason } from "./storage"; +import { + loadAccounts, + loadBlockedAccounts, + saveAccounts, + saveBlockedAccounts, + type AccountStorageV3, + type BlockedAccountMetadataV1, + type RateLimitStateV3, + type ModelFamily, + type HeaderStyle, + type CooldownReason, +} from "./storage"; import type { OAuthAuthDetails, RefreshParts } from "./types"; import type { AccountSelectionStrategy } from "./config/schema"; import { getHealthTracker, getTokenTracker, selectHybridAccount, type AccountWithMetrics } from "./rotation"; import { generateFingerprint, type Fingerprint, type FingerprintVersion, MAX_FINGERPRINT_HISTORY } from "./fingerprint"; -import type { QuotaGroup, QuotaGroupSummary } from "./quota"; import { getModelFamily } from "./transform/model-resolver"; -import { debugLogToFile } from "./debug"; +import { createLogger } from "./logger"; +import type { QuotaGroup, QuotaGroupSummary } from "./quota"; import { ANTIGRAVITY_VERSION } from "../constants"; export type { ModelFamily, HeaderStyle, CooldownReason } from "./storage"; export type { AccountSelectionStrategy } from "./config/schema"; +export type { QuotaGroup, QuotaGroupSummary } from "./quota"; + +const log = createLogger("accounts"); /** * Update fingerprint userAgent to current version if outdated. @@ -49,6 +63,10 @@ const SERVER_ERROR_BACKOFF = 20_000; const UNKNOWN_BACKOFF = 60_000; const MIN_BACKOFF_MS = 2_000; +// Soft quota cache is best-effort and should fail open when stale/missing. +// Tests assume 10 minutes. +const DEFAULT_SOFT_QUOTA_CACHE_TTL_MS = 10 * 60 * 1000; + /** * Generate a random jitter value for backoff timing. * Helps prevent thundering herd problem when multiple clients retry simultaneously. @@ -160,8 +178,9 @@ export interface ManagedAccount { fingerprint?: import("./fingerprint").Fingerprint; /** History of previous fingerprints for this account */ fingerprintHistory?: FingerprintVersion[]; - /** Cached quota data from last checkAccountsQuota() call */ + /** Last cached quota summary by quota group (best-effort, in-memory). */ cachedQuota?: Partial>; + /** Timestamp of last quota cache update (ms since epoch). */ cachedQuotaUpdatedAt?: number; } @@ -176,6 +195,22 @@ function clampNonNegativeInt(value: unknown, fallback: number): number { return value < 0 ? 0 : Math.floor(value); } +function clampFamilyIndex( + value: unknown, + fallback: number, + accountCount: number, +): number { + if (accountCount <= 0) return -1; + + if (typeof value !== "number" || !Number.isFinite(value)) { + return Math.min(clampNonNegativeInt(fallback, 0), accountCount - 1); + } + + const normalized = Math.trunc(value); + if (normalized < 0) return -1; + return Math.min(normalized, accountCount - 1); +} + function getQuotaKey(family: ModelFamily, headerStyle: HeaderStyle, model?: string | null): QuotaKey { if (family === "claude") { return "claude"; @@ -236,15 +271,6 @@ function clearExpiredRateLimits(account: ManagedAccount): void { /** * Resolve the quota group for soft quota checks. - * - * When a model string is available, we can precisely determine the quota group. - * When model is null/undefined, we fall back based on family: - * - Claude → "claude" quota group - * - Gemini → "gemini-pro" (conservative fallback; may misclassify flash models) - * - * @param family - The model family ("claude" | "gemini") - * @param model - Optional model string for precise resolution - * @returns The QuotaGroup to use for soft quota checks */ export function resolveQuotaGroup(family: ModelFamily, model?: string | null): QuotaGroup { if (model) { @@ -262,39 +288,18 @@ function isOverSoftQuotaThreshold( ): boolean { if (thresholdPercent >= 100) return false; if (!account.cachedQuota) return false; - if (account.cachedQuotaUpdatedAt == null) return false; + const age = nowMs() - account.cachedQuotaUpdatedAt; if (age > cacheTtlMs) return false; - + const quotaGroup = resolveQuotaGroup(family, model); - const groupData = account.cachedQuota[quotaGroup]; if (groupData?.remainingFraction == null) return false; - + const remainingFraction = Math.max(0, Math.min(1, groupData.remainingFraction)); const usedPercent = (1 - remainingFraction) * 100; - const isOverThreshold = usedPercent >= thresholdPercent; - - if (isOverThreshold) { - const accountLabel = account.email || `Account ${account.index + 1}`; - debugLogToFile( - `[SoftQuota] Skipping ${accountLabel}: ${quotaGroup} usage ${usedPercent.toFixed(1)}% >= threshold ${thresholdPercent}%` + - (groupData.resetTime ? ` (resets: ${groupData.resetTime})` : '') - ); - } - - return isOverThreshold; -} - -export function computeSoftQuotaCacheTtlMs( - ttlConfig: "auto" | number, - refreshIntervalMinutes: number -): number { - if (ttlConfig === "auto") { - return Math.max(2 * refreshIntervalMinutes, 10) * 60 * 1000; - } - return ttlConfig * 60 * 1000; + return usedPercent >= thresholdPercent; } /** @@ -307,6 +312,7 @@ export function computeSoftQuotaCacheTtlMs( * Source of truth for the pool is `antigravity-accounts.json`. */ export class AccountManager { + private authFallback?: OAuthAuthDetails; private accounts: ManagedAccount[] = []; private cursor = 0; private currentAccountIndexByFamily: Record = { @@ -323,6 +329,8 @@ export class AccountManager { private savePending = false; private saveTimeout: ReturnType | null = null; private savePromiseResolvers: Array<() => void> = []; + private pendingAddedRefreshTokens = new Set(); + private pendingRemovedRefreshTokens = new Set(); static async loadFromDisk(authFallback?: OAuthAuthDetails): Promise { const stored = await loadAccounts(); @@ -330,6 +338,7 @@ export class AccountManager { } constructor(authFallback?: OAuthAuthDetails, stored?: AccountStorageV3 | null) { + this.authFallback = authFallback; const authParts = authFallback ? parseRefreshParts(authFallback.refresh) : null; if (stored && stored.accounts.length === 0) { @@ -374,54 +383,62 @@ export class AccountManager { fingerprint: acc.fingerprint ? updateFingerprintVersion(acc.fingerprint) : generateFingerprint(), - cachedQuota: acc.cachedQuota as Partial> | undefined, - cachedQuotaUpdatedAt: acc.cachedQuotaUpdatedAt, + fingerprintHistory: acc.fingerprintHistory?.length ? acc.fingerprintHistory : undefined, }; }) .filter((a): a is ManagedAccount => a !== null); this.cursor = clampNonNegativeInt(stored.activeIndex, 0); + + // Merge currently authenticated account into persisted storage if missing. + // This handles cases where a fresh OAuth login was performed in another process. + if (authFallback && authParts?.refreshToken) { + const hasMatching = this.accounts.some( + (acc) => acc.parts.refreshToken === authParts.refreshToken + ); + if (!hasMatching) { + const now = nowMs(); + const newAccount: ManagedAccount = { + index: this.accounts.length, + email: undefined, + addedAt: now, + lastUsed: 0, + parts: authParts, + access: authFallback.access, + expires: authFallback.expires, + enabled: true, + rateLimitResetTimes: {}, + touchedForQuota: {}, + fingerprint: generateFingerprint(), + }; + this.accounts.push(newAccount); + this.pendingAddedRefreshTokens.add(authParts.refreshToken); + this.requestSaveToDisk(); + } + } + if (this.accounts.length > 0) { this.cursor = this.cursor % this.accounts.length; const defaultIndex = this.cursor; - this.currentAccountIndexByFamily.claude = clampNonNegativeInt( + this.currentAccountIndexByFamily.claude = clampFamilyIndex( stored.activeIndexByFamily?.claude, - defaultIndex - ) % this.accounts.length; - this.currentAccountIndexByFamily.gemini = clampNonNegativeInt( + defaultIndex, + this.accounts.length, + ); + this.currentAccountIndexByFamily.gemini = clampFamilyIndex( stored.activeIndexByFamily?.gemini, - defaultIndex - ) % this.accounts.length; + defaultIndex, + this.accounts.length, + ); + } else { + this.cursor = 0; + this.currentAccountIndexByFamily.claude = -1; + this.currentAccountIndexByFamily.gemini = -1; } return; } - // If we have stored accounts, check if we need to add the current auth - if (authFallback && this.accounts.length > 0) { - const authParts = parseRefreshParts(authFallback.refresh); - const hasMatching = this.accounts.some(acc => acc.parts.refreshToken === authParts.refreshToken); - if (!hasMatching && authParts.refreshToken) { - const now = nowMs(); - const newAccount: ManagedAccount = { - index: this.accounts.length, - email: undefined, - addedAt: now, - lastUsed: 0, - parts: authParts, - access: authFallback.access, - expires: authFallback.expires, - enabled: true, - rateLimitResetTimes: {}, - touchedForQuota: {}, - }; - this.accounts.push(newAccount); - // Update indices to include the new account - this.currentAccountIndexByFamily.claude = Math.min(this.currentAccountIndexByFamily.claude, this.accounts.length - 1); - this.currentAccountIndexByFamily.gemini = Math.min(this.currentAccountIndexByFamily.gemini, this.accounts.length - 1); - } - } - if (authFallback) { const parts = parseRefreshParts(authFallback.refresh); if (parts.refreshToken) { @@ -440,6 +457,8 @@ export class AccountManager { touchedForQuota: {}, }, ]; + this.pendingAddedRefreshTokens.add(parts.refreshToken); + this.requestSaveToDisk(); this.cursor = 0; this.currentAccountIndexByFamily.claude = 0; this.currentAccountIndexByFamily.gemini = 0; @@ -504,12 +523,11 @@ export class AccountManager { headerStyle: HeaderStyle = 'antigravity', pidOffsetEnabled: boolean = false, softQuotaThresholdPercent: number = 100, - softQuotaCacheTtlMs: number = 10 * 60 * 1000, ): ManagedAccount | null { const quotaKey = getQuotaKey(family, headerStyle, model); if (strategy === 'round-robin') { - const next = this.getNextForFamily(family, model, headerStyle, softQuotaThresholdPercent, softQuotaCacheTtlMs); + const next = this.getNextForFamily(family, model, headerStyle, softQuotaThresholdPercent); if (next) { this.markTouchedForQuota(next, quotaKey); this.currentAccountIndexByFamily[family] = next.index; @@ -529,8 +547,8 @@ export class AccountManager { index: acc.index, lastUsed: acc.lastUsed, healthScore: healthTracker.getScore(acc.index), - isRateLimited: isRateLimitedForFamily(acc, family, model) || - isOverSoftQuotaThreshold(acc, family, softQuotaThresholdPercent, softQuotaCacheTtlMs, model), + isRateLimited: isRateLimitedForFamily(acc, family, model) + || isOverSoftQuotaThreshold(acc, family, softQuotaThresholdPercent, DEFAULT_SOFT_QUOTA_CACHE_TTL_MS, model), isCoolingDown: this.isAccountCoolingDown(acc), }; }); @@ -556,26 +574,28 @@ export class AccountManager { if (pidOffsetEnabled && !this.sessionOffsetApplied[family] && this.accounts.length > 1) { const pidOffset = process.pid % this.accounts.length; const baseIndex = this.currentAccountIndexByFamily[family] ?? 0; - const newIndex = (baseIndex + pidOffset) % this.accounts.length; - - debugLogToFile(`[Account] Applying PID offset: pid=${process.pid} offset=${pidOffset} family=${family} index=${baseIndex}->${newIndex}`); - - this.currentAccountIndexByFamily[family] = newIndex; + this.currentAccountIndexByFamily[family] = (baseIndex + pidOffset) % this.accounts.length; this.sessionOffsetApplied[family] = true; } const current = this.getCurrentAccountForFamily(family); - if (current) { + if (current && current.enabled !== false) { clearExpiredRateLimits(current); const isLimitedForRequestedStyle = isRateLimitedForHeaderStyle(current, family, headerStyle, model); - const isOverThreshold = isOverSoftQuotaThreshold(current, family, softQuotaThresholdPercent, softQuotaCacheTtlMs, model); - if (!isLimitedForRequestedStyle && !isOverThreshold && !this.isAccountCoolingDown(current)) { + const isOverSoftQuota = isOverSoftQuotaThreshold( + current, + family, + softQuotaThresholdPercent, + DEFAULT_SOFT_QUOTA_CACHE_TTL_MS, + model, + ); + if (!isLimitedForRequestedStyle && !this.isAccountCoolingDown(current) && !isOverSoftQuota) { this.markTouchedForQuota(current, quotaKey); return current; } } - const next = this.getNextForFamily(family, model, headerStyle, softQuotaThresholdPercent, softQuotaCacheTtlMs); + const next = this.getNextForFamily(family, model, headerStyle, softQuotaThresholdPercent); if (next) { this.markTouchedForQuota(next, quotaKey); this.currentAccountIndexByFamily[family] = next.index; @@ -583,13 +603,89 @@ export class AccountManager { return next; } - getNextForFamily(family: ModelFamily, model?: string | null, headerStyle: HeaderStyle = "antigravity", softQuotaThresholdPercent: number = 100, softQuotaCacheTtlMs: number = 10 * 60 * 1000): ManagedAccount | null { + updateQuotaCache(accountIndex: number, quota: Partial>): void { + const account = this.accounts.find((a) => a.index === accountIndex); + if (!account) return; + account.cachedQuota = { ...(account.cachedQuota ?? {}), ...quota }; + account.cachedQuotaUpdatedAt = nowMs(); + } + + /** + * Returns the minimum wait time (ms) until at least one account is under the soft quota threshold. + * + * - `0` means "an account is available right now" + * - `null` means "all accounts are over threshold and we don't know when it resets" + */ + getMinWaitTimeForSoftQuota( + family: ModelFamily, + thresholdPercent: number, + cacheTtlMs: number, + model?: string | null, + ): number | null { + if (thresholdPercent >= 100) return 0; + + const enabled = this.accounts.filter((a) => a.enabled !== false); + if (enabled.length === 0) return 0; + + const over = enabled.filter((a) => + isOverSoftQuotaThreshold(a, family, thresholdPercent, cacheTtlMs, model), + ); + + // If any account is under threshold (or cache is missing/stale), we can proceed now. + if (over.length !== enabled.length) return 0; + + const now = nowMs(); + const quotaGroup = resolveQuotaGroup(family, model); + const waits: number[] = []; + + for (const a of over) { + const resetTime = a.cachedQuota?.[quotaGroup]?.resetTime; + if (!resetTime) continue; + const resetMs = Date.parse(resetTime); + if (!Number.isFinite(resetMs)) continue; + // Past reset times are treated as unknown; fail open by returning null. + if (resetMs <= now) continue; + waits.push(resetMs - now); + } + + if (waits.length === 0) return null; + return Math.min(...waits); + } + + /** + * For Gemini only: returns true when a *different* account still has Antigravity quota available. + * Used to enforce "Antigravity-first" (switch accounts before falling back to gemini-cli). + */ + hasOtherAccountWithAntigravityAvailable( + currentAccountIndex: number, + family: ModelFamily, + model?: string | null, + ): boolean { + if (family !== "gemini") return false; + for (const acc of this.accounts) { + if (acc.index === currentAccountIndex) continue; + if (acc.enabled === false) continue; + clearExpiredRateLimits(acc); + if (this.isAccountCoolingDown(acc)) continue; + if (!isRateLimitedForHeaderStyle(acc, family, "antigravity", model)) { + return true; + } + } + return false; + } + + getNextForFamily( + family: ModelFamily, + model?: string | null, + headerStyle: HeaderStyle = "antigravity", + softQuotaThresholdPercent: number = 100, + ): ManagedAccount | null { const available = this.accounts.filter((a) => { clearExpiredRateLimits(a); - return a.enabled !== false && - !isRateLimitedForHeaderStyle(a, family, headerStyle, model) && - !isOverSoftQuotaThreshold(a, family, softQuotaThresholdPercent, softQuotaCacheTtlMs, model) && - !this.isAccountCoolingDown(a); + return a.enabled !== false + && !isRateLimitedForHeaderStyle(a, family, headerStyle, model) + && !this.isAccountCoolingDown(a) + && !isOverSoftQuotaThreshold(a, family, softQuotaThresholdPercent, DEFAULT_SOFT_QUOTA_CACHE_TTL_MS, model); }); if (available.length === 0) { @@ -753,49 +849,6 @@ export class AccountManager { return null; } - /** - * Check if any OTHER account has antigravity quota available for the given family/model. - * - * Used to determine whether to switch accounts vs fall back to gemini-cli: - * - If true: Switch to another account (preserve antigravity priority) - * - If false: All accounts exhausted antigravity, safe to fall back to gemini-cli - * - * @param currentAccountIndex - Index of the current account (will be excluded from check) - * @param family - Model family ("gemini" or "claude") - * @param model - Optional model name for model-specific rate limits - * @returns true if any other enabled, non-cooling-down account has antigravity available - */ - hasOtherAccountWithAntigravityAvailable( - currentAccountIndex: number, - family: ModelFamily, - model?: string | null - ): boolean { - // Claude has no gemini-cli fallback - always return false - // (This method is only relevant for Gemini's dual quota pools) - if (family === "claude") { - return false; - } - - return this.accounts.some(acc => { - // Skip current account - if (acc.index === currentAccountIndex) { - return false; - } - // Skip disabled accounts - if (acc.enabled === false) { - return false; - } - // Skip cooling down accounts - if (this.isAccountCoolingDown(acc)) { - return false; - } - // Clear expired rate limits before checking - clearExpiredRateLimits(acc); - // Check if antigravity is available for this account - return !isRateLimitedForHeaderStyle(acc, family, "antigravity", model); - }); - } - setAccountEnabled(accountIndex: number, enabled: boolean): boolean { const account = this.accounts[accountIndex]; if (!account) { @@ -826,13 +879,18 @@ export class AccountManager { } return this.removeAccount(account); } - removeAccount(account: ManagedAccount): boolean { const idx = this.accounts.indexOf(account); if (idx < 0) { return false; } + const refreshToken = account.parts.refreshToken; + if (refreshToken) { + this.pendingRemovedRefreshTokens.add(refreshToken); + this.pendingAddedRefreshTokens.delete(refreshToken); + } + this.accounts.splice(idx, 1); this.accounts.forEach((acc, index) => { acc.index = index; @@ -931,9 +989,205 @@ export class AccountManager { return [...this.accounts]; } + async reloadFromDisk(): Promise { + const stored = await loadAccounts(); + const authFallback = this.authFallback; + const authParts = authFallback ? parseRefreshParts(authFallback.refresh) : null; + + // Reset per-session-only state. This keeps behavior predictable after restore. + this.sessionOffsetApplied = { claude: false, gemini: false }; + this.lastToastAccountIndex = -1; + this.lastToastTime = 0; + this.pendingAddedRefreshTokens.clear(); + this.pendingRemovedRefreshTokens.clear(); + + if (!stored || stored.accounts.length === 0) { + this.accounts = []; + this.cursor = 0; + this.currentAccountIndexByFamily.claude = -1; + this.currentAccountIndexByFamily.gemini = -1; + return; + } + + const baseNow = nowMs(); + this.accounts = stored.accounts + .map((acc, index): ManagedAccount | null => { + if (!acc.refreshToken || typeof acc.refreshToken !== "string") { + return null; + } + const matchesFallback = !!( + authFallback && + authParts && + authParts.refreshToken && + acc.refreshToken === authParts.refreshToken + ); + + return { + index, + email: acc.email, + addedAt: clampNonNegativeInt(acc.addedAt, baseNow), + lastUsed: clampNonNegativeInt(acc.lastUsed, 0), + parts: { + refreshToken: acc.refreshToken, + projectId: acc.projectId, + managedProjectId: acc.managedProjectId, + }, + access: matchesFallback ? authFallback?.access : undefined, + expires: matchesFallback ? authFallback?.expires : undefined, + enabled: acc.enabled !== false, + rateLimitResetTimes: acc.rateLimitResetTimes ?? {}, + lastSwitchReason: acc.lastSwitchReason, + coolingDownUntil: acc.coolingDownUntil, + cooldownReason: acc.cooldownReason, + touchedForQuota: {}, + fingerprint: acc.fingerprint + ? updateFingerprintVersion(acc.fingerprint) + : generateFingerprint(), + fingerprintHistory: acc.fingerprintHistory?.length ? acc.fingerprintHistory : undefined, + }; + }) + .filter((a): a is ManagedAccount => a !== null); + + this.cursor = clampNonNegativeInt(stored.activeIndex, 0); + if (this.accounts.length === 0) { + this.cursor = 0; + this.currentAccountIndexByFamily.claude = -1; + this.currentAccountIndexByFamily.gemini = -1; + return; + } + + this.cursor = this.cursor % this.accounts.length; + const defaultIndex = this.cursor; + this.currentAccountIndexByFamily.claude = clampFamilyIndex( + stored.activeIndexByFamily?.claude, + defaultIndex, + this.accounts.length, + ); + this.currentAccountIndexByFamily.gemini = clampFamilyIndex( + stored.activeIndexByFamily?.gemini, + defaultIndex, + this.accounts.length, + ); + } + + async quarantineAccountForVerification( + account: ManagedAccount, + reason?: string, + verifyUrl?: string, + ): Promise { + const refreshToken = account.parts.refreshToken; + if (!refreshToken) return false; + + // Capture state for rollback in case persisting blocked storage fails. + // Losing the account is worse than leaving it in rotation. + const restoreIndex = this.accounts.indexOf(account); + const previousCursor = this.cursor; + const previousAccountIndexByFamily = { ...this.currentAccountIndexByFamily }; + const previousPendingAddedRefreshTokens = new Set(this.pendingAddedRefreshTokens); + const previousPendingRemovedRefreshTokens = new Set(this.pendingRemovedRefreshTokens); + + const record: BlockedAccountMetadataV1 = { + email: account.email, + refreshToken, + projectId: account.parts.projectId, + managedProjectId: account.parts.managedProjectId, + addedAt: account.addedAt, + lastUsed: account.lastUsed, + enabled: account.enabled, + lastSwitchReason: account.lastSwitchReason, + rateLimitResetTimes: Object.keys(account.rateLimitResetTimes).length > 0 ? account.rateLimitResetTimes : undefined, + coolingDownUntil: account.coolingDownUntil, + cooldownReason: account.cooldownReason, + fingerprint: account.fingerprint, + fingerprintHistory: account.fingerprintHistory?.length ? account.fingerprintHistory : undefined, + blockedAt: nowMs(), + blockedReason: reason, + verifyUrl, + }; + + const removed = this.removeAccount(account); + if (!removed) return false; + + try { + const blocked = await loadBlockedAccounts(); + const map = new Map(); + for (const a of blocked.accounts) { + if (a?.refreshToken) map.set(a.refreshToken, a); + } + map.set(record.refreshToken, record); + await saveBlockedAccounts({ version: 1, accounts: Array.from(map.values()) }); + } catch (error) { + // Best-effort: quarantining should not crash the request path. + log.warn("Failed to persist blocked account storage; restoring account", { + email: account.email, + refreshToken: refreshToken ? `${refreshToken.slice(0, 6)}...` : undefined, + verifyUrl, + error: String(error), + }); + + // Roll back in-memory removal and pending token deltas so we don't drop + // the account from disk on a later save. + try { + if (restoreIndex >= 0) { + const insertAt = Math.min(Math.max(0, restoreIndex), this.accounts.length); + this.accounts.splice(insertAt, 0, account); + this.accounts.forEach((acc, index) => { + acc.index = index; + }); + } else if (!this.accounts.includes(account)) { + this.accounts.push(account); + this.accounts.forEach((acc, index) => { + acc.index = index; + }); + } + + this.cursor = previousCursor; + this.currentAccountIndexByFamily = { ...previousAccountIndexByFamily }; + this.pendingAddedRefreshTokens = previousPendingAddedRefreshTokens; + this.pendingRemovedRefreshTokens = previousPendingRemovedRefreshTokens; + } catch (restoreError) { + log.warn("Failed to restore account after blocked-storage write failure", { + email: account.email, + refreshToken: refreshToken ? `${refreshToken.slice(0, 6)}...` : undefined, + error: String(restoreError), + }); + } + + return false; + } + + try { + await this.saveToDisk(); + } catch (error) { + // Best-effort persistence. + log.warn("Failed to persist active account removal after quarantining", { + email: account.email, + refreshToken: refreshToken ? `${refreshToken.slice(0, 6)}...` : undefined, + error: String(error), + }); + } + + return true; + } + async saveToDisk(): Promise { - const claudeIndex = Math.max(0, this.currentAccountIndexByFamily.claude); - const geminiIndex = Math.max(0, this.currentAccountIndexByFamily.gemini); + const accountCount = this.accounts.length; + const clampStoredIndex = (value: number): number => { + if (accountCount <= 0) return -1; + return Math.min(Math.max(0, value), accountCount - 1); + }; + + const activeIndex = clampStoredIndex(this.cursor); + const claudeIndex = clampFamilyIndex( + this.currentAccountIndexByFamily.claude, + activeIndex, + accountCount, + ); + const geminiIndex = clampFamilyIndex( + this.currentAccountIndexByFamily.gemini, + activeIndex, + accountCount, + ); const storage: AccountStorageV3 = { version: 3, @@ -951,17 +1205,30 @@ export class AccountManager { cooldownReason: a.cooldownReason, fingerprint: a.fingerprint, fingerprintHistory: a.fingerprintHistory?.length ? a.fingerprintHistory : undefined, - cachedQuota: a.cachedQuota && Object.keys(a.cachedQuota).length > 0 ? a.cachedQuota : undefined, - cachedQuotaUpdatedAt: a.cachedQuotaUpdatedAt, })), - activeIndex: claudeIndex, + activeIndex, activeIndexByFamily: { claude: claudeIndex, gemini: geminiIndex, }, }; - await saveAccounts(storage); + const addedRefreshTokens = Array.from(this.pendingAddedRefreshTokens); + const removedRefreshTokens = Array.from(this.pendingRemovedRefreshTokens); + + // IMPORTANT: + // This plugin can run in multiple processes concurrently (e.g. a long-running + // OpenCode session + `opencode auth login`). Use merge+pruning to avoid + // resurrecting accounts deleted in another process. + await saveAccounts(storage, { + merge: true, + addedRefreshTokens, + removedRefreshTokens, + preserveDeletions: true, + }); + + this.pendingAddedRefreshTokens.clear(); + this.pendingRemovedRefreshTokens.clear(); } requestSaveToDisk(): void { @@ -1093,91 +1360,4 @@ export class AccountManager { } return [...account.fingerprintHistory]; } - - updateQuotaCache(accountIndex: number, quotaGroups: Partial>): void { - const account = this.accounts[accountIndex]; - if (account) { - account.cachedQuota = quotaGroups; - account.cachedQuotaUpdatedAt = nowMs(); - } - } - - isAccountOverSoftQuota(account: ManagedAccount, family: ModelFamily, thresholdPercent: number, cacheTtlMs: number, model?: string | null): boolean { - return isOverSoftQuotaThreshold(account, family, thresholdPercent, cacheTtlMs, model); - } - - getAccountsForQuotaCheck(): AccountMetadataV3[] { - return this.accounts.map((a) => ({ - email: a.email, - refreshToken: a.parts.refreshToken, - projectId: a.parts.projectId, - managedProjectId: a.parts.managedProjectId, - addedAt: a.addedAt, - lastUsed: a.lastUsed, - enabled: a.enabled, - })); - } - - getOldestQuotaCacheAge(): number | null { - let oldest: number | null = null; - for (const acc of this.accounts) { - if (acc.enabled === false) continue; - if (acc.cachedQuotaUpdatedAt == null) return null; - const age = nowMs() - acc.cachedQuotaUpdatedAt; - if (oldest === null || age > oldest) oldest = age; - } - return oldest; - } - - areAllAccountsOverSoftQuota(family: ModelFamily, thresholdPercent: number, cacheTtlMs: number, model?: string | null): boolean { - if (thresholdPercent >= 100) return false; - const enabled = this.accounts.filter(a => a.enabled !== false); - if (enabled.length === 0) return false; - return enabled.every(a => isOverSoftQuotaThreshold(a, family, thresholdPercent, cacheTtlMs, model)); - } - - /** - * Get minimum wait time until any account's soft quota resets. - * Returns 0 if any account is available (not over threshold). - * Returns the minimum resetTime across all over-threshold accounts. - * Returns null if no resetTime data is available. - */ - getMinWaitTimeForSoftQuota( - family: ModelFamily, - thresholdPercent: number, - cacheTtlMs: number, - model?: string | null - ): number | null { - if (thresholdPercent >= 100) return 0; - - const enabled = this.accounts.filter(a => a.enabled !== false); - if (enabled.length === 0) return null; - - // If any account is available (not over threshold), no wait needed - const available = enabled.filter(a => !isOverSoftQuotaThreshold(a, family, thresholdPercent, cacheTtlMs, model)); - if (available.length > 0) return 0; - - // All accounts are over threshold - find earliest reset time - // For gemini family, we MUST have the model to distinguish pro vs flash quotas. - // Fail-open (return null = no wait info) if model is missing to avoid blocking on wrong quota. - if (!model && family !== "claude") return null; - const quotaGroup = resolveQuotaGroup(family, model); - const now = nowMs(); - const waitTimes: number[] = []; - - for (const acc of enabled) { - const groupData = acc.cachedQuota?.[quotaGroup]; - if (groupData?.resetTime) { - const resetTimestamp = Date.parse(groupData.resetTime); - if (Number.isFinite(resetTimestamp)) { - waitTimes.push(Math.max(0, resetTimestamp - now)); - } - } - } - - if (waitTimes.length === 0) return null; - const minWait = Math.min(...waitTimes); - // Treat 0 as stale cache (resetTime in the past) → fail-open to avoid spin loop - return minWait === 0 ? null : minWait; - } } diff --git a/src/plugin/cli.ts b/src/plugin/cli.ts index f16ad6f..9cdfb94 100644 --- a/src/plugin/cli.ts +++ b/src/plugin/cli.ts @@ -30,7 +30,7 @@ export async function promptAddAnotherAccount(currentCount: number): Promise void; +} + interface FetchAvailableModelsResponse { models?: Record; } @@ -88,10 +70,9 @@ function buildAuthFromAccount(account: AccountMetadataV3): OAuthAuthDetails { }; } -function normalizeRemainingFraction(value: unknown): number { - // If value is missing or invalid, treat as exhausted (0%) +function normalizeRemainingFraction(value: unknown): number | undefined { if (typeof value !== "number" || !Number.isFinite(value)) { - return 0; + return undefined; } if (value < 0) return 0; if (value > 1) return 1; @@ -172,6 +153,17 @@ function aggregateQuota(models?: Record): Quot return { groups, modelCount: totalCount }; } +function withTimeout(promise: Promise, timeoutMs: number, label: string): Promise { + if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) return promise; + let timeout: NodeJS.Timeout | undefined; + const timer = new Promise((_, reject) => { + timeout = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs); + }); + return Promise.race([promise, timer]).finally(() => { + if (timeout) clearTimeout(timeout); + }); +} + async function fetchWithTimeout(url: string, options: RequestInit, timeoutMs = FETCH_TIMEOUT_MS): Promise { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), timeoutMs); @@ -214,78 +206,6 @@ async function fetchAvailableModels( throw new Error(errors.join("; ") || "fetchAvailableModels failed"); } -async function fetchGeminiCliQuota( - accessToken: string, - projectId: string, -): Promise { - const endpoint = ANTIGRAVITY_ENDPOINT_PROD; - // Use Gemini CLI user-agent to get CLI quota buckets (not Antigravity buckets) - const platform = process.platform || "darwin"; - const arch = process.arch || "arm64"; - const geminiCliUserAgent = `GeminiCLI/1.0.0/gemini-2.5-pro (${platform}; ${arch})`; - - const body = projectId ? { project: projectId } : {}; - - try { - const response = await fetchWithTimeout(`${endpoint}/v1internal:retrieveUserQuota`, { - method: "POST", - headers: { - Authorization: `Bearer ${accessToken}`, - "Content-Type": "application/json", - "User-Agent": geminiCliUserAgent, - }, - body: JSON.stringify(body), - }); - - if (response.ok) { - const data = (await response.json()) as RetrieveUserQuotaResponse; - return data; - } - - // Non-OK response - return empty buckets - return { buckets: [] }; - } catch { - // Network error or timeout - return empty buckets - return { buckets: [] }; - } -} - -function aggregateGeminiCliQuota(response: RetrieveUserQuotaResponse): GeminiCliQuotaSummary { - const models: GeminiCliQuotaModel[] = []; - - if (!response.buckets || response.buckets.length === 0) { - return { models }; - } - - for (const bucket of response.buckets) { - if (!bucket.modelId) { - continue; - } - - // Filter out models we don't care about for Gemini CLI quotas - // Only show gemini-3-* and gemini-2.5-pro models (the premium ones) - const modelId = bucket.modelId; - const isRelevantModel = - modelId.startsWith("gemini-3-") || - modelId === "gemini-2.5-pro"; - - if (!isRelevantModel) { - continue; - } - - models.push({ - modelId: bucket.modelId, - remainingFraction: normalizeRemainingFraction(bucket.remainingFraction), - resetTime: bucket.resetTime, - }); - } - - // Sort by model ID for consistent display - models.sort((a, b) => a.modelId.localeCompare(b.modelId)); - - return { models }; -} - function applyAccountUpdates(account: AccountMetadataV3, auth: OAuthAuthDetails): AccountMetadataV3 | undefined { const parts = parseRefreshParts(auth.refresh); if (!parts.refreshToken) { @@ -311,85 +231,107 @@ export async function checkAccountsQuota( accounts: AccountMetadataV3[], client: PluginClient, providerId = ANTIGRAVITY_PROVIDER_ID, + options: CheckAccountsQuotaOptions = {}, ): Promise { - const results: AccountQuotaResult[] = []; - - logQuotaFetch("start", accounts.length); + const total = accounts.length; + const concurrency = Math.max(1, Math.min(options.concurrency ?? 3, total)); + const perAccountTimeoutMs = options.perAccountTimeoutMs ?? 25_000; - for (const [index, account] of accounts.entries()) { + const results: AccountQuotaResult[] = new Array(total); + let cursor = 0; + let done = 0; + + const checkOne = async (account: AccountMetadataV3, index: number): Promise => { const disabled = account.enabled === false; + if (disabled) { + return { + index, + email: account.email, + status: "disabled", + disabled: true, + }; + } let auth = buildAuthFromAccount(account); try { if (accessTokenExpired(auth)) { - const refreshed = await refreshAccessToken(auth, client, providerId); + const refreshed = await withTimeout( + refreshAccessToken(auth, client, providerId), + perAccountTimeoutMs, + `Token refresh for account ${index + 1}`, + ); if (!refreshed) { throw new Error("Token refresh failed"); } auth = refreshed; } - const projectContext = await ensureProjectContext(auth); - auth = projectContext.auth; + // Avoid ensureProjectContext here: it can trigger onboarding loops and long delays. + const parts = parseRefreshParts(auth.refresh ?? ""); + const effectiveProjectId = + parts.managedProjectId || parts.projectId || ANTIGRAVITY_DEFAULT_PROJECT_ID; + const updatedAccount = applyAccountUpdates(account, auth); let quotaResult: QuotaSummary; - let geminiCliQuotaResult: GeminiCliQuotaSummary; - - // Fetch both Antigravity and Gemini CLI quotas in parallel - const [antigravityResponse, geminiCliResponse] = await Promise.all([ - fetchAvailableModels(auth.access ?? "", projectContext.effectiveProjectId) - .catch((error): FetchAvailableModelsResponse => ({ models: undefined })), - fetchGeminiCliQuota(auth.access ?? "", projectContext.effectiveProjectId), - ]); - - // Process Antigravity quota - if (antigravityResponse.models === undefined) { + try { + const response = await fetchAvailableModels(auth.access ?? "", effectiveProjectId); + quotaResult = aggregateQuota(response.models); + } catch (error) { quotaResult = { groups: {}, modelCount: 0, - error: "Failed to fetch Antigravity quota", + error: error instanceof Error ? error.message : String(error), }; - } else { - quotaResult = aggregateQuota(antigravityResponse.models); - } - - // Process Gemini CLI quota - geminiCliQuotaResult = aggregateGeminiCliQuota(geminiCliResponse); - if (geminiCliResponse.buckets === undefined || geminiCliResponse.buckets.length === 0) { - geminiCliQuotaResult.error = geminiCliQuotaResult.models.length === 0 - ? "No Gemini CLI quota available" - : undefined; } - results.push({ + return { index, email: account.email, status: "ok", - disabled, + disabled: false, quota: quotaResult, - geminiCliQuota: geminiCliQuotaResult, updatedAccount, - }); - - // Log quota status for each family - for (const [family, groupQuota] of Object.entries(quotaResult.groups)) { - const remainingPercent = (groupQuota.remainingFraction ?? 0) * 100; - logQuotaStatus(account.email, index, remainingPercent, family); - } + }; } catch (error) { - results.push({ + return { index, email: account.email, status: "error", - disabled, + disabled: false, error: error instanceof Error ? error.message : String(error), - }); - logQuotaFetch("error", undefined, `account=${account.email ?? index} error=${error instanceof Error ? error.message : String(error)}`); + }; } - } + }; + + const worker = async (): Promise => { + while (true) { + const index = cursor; + cursor += 1; + if (index >= total) return; + + const account = accounts[index]!; + const result = await withTimeout( + checkOne(account, index), + perAccountTimeoutMs, + `Quota check for account ${index + 1}`, + ).catch((error) => ({ + index, + email: account.email, + status: "error" as const, + disabled: account.enabled === false, + error: error instanceof Error ? error.message : String(error), + })); + + results[index] = result; + done += 1; + options.onProgress?.({ done, total, result }); + } + }; + + const workers = Array.from({ length: concurrency }, () => worker()); + await Promise.all(workers); - logQuotaFetch("complete", accounts.length, `ok=${results.filter(r => r.status === "ok").length} errors=${results.filter(r => r.status === "error").length}`); return results; } diff --git a/src/plugin/request-helpers.test.ts b/src/plugin/request-helpers.test.ts index 703e504..0c9909c 100644 --- a/src/plugin/request-helpers.test.ts +++ b/src/plugin/request-helpers.test.ts @@ -1574,52 +1574,47 @@ describe("createSyntheticErrorResponse", () => { expect(text).toContain("Context too long"); expect(text).toContain("data:"); - expect(text).toContain("message_start"); - expect(text).toContain("message_stop"); + expect(text).toContain("\"response\""); }); - it("uses provided model in message_start event", async () => { + it("includes provided model in the SSE payload", async () => { const response = createSyntheticErrorResponse("Error", "claude-opus-4"); const text = await response.text(); expect(text).toContain("claude-opus-4"); }); - it("generates valid Claude SSE event structure", async () => { + it("generates valid Antigravity-style SSE structure", async () => { const response = createSyntheticErrorResponse("Test", "test-model"); const text = await response.text(); const lines = text.split("\n").filter((l) => l.startsWith("data:")); - expect(lines.length).toBeGreaterThanOrEqual(5); + expect(lines.length).toBeGreaterThanOrEqual(1); const events = lines.map((l) => JSON.parse(l.replace("data: ", ""))); - const eventTypes = events.map((e) => e.type); - - expect(eventTypes).toContain("message_start"); - expect(eventTypes).toContain("content_block_start"); - expect(eventTypes).toContain("content_block_delta"); - expect(eventTypes).toContain("content_block_stop"); - expect(eventTypes).toContain("message_stop"); + for (const e of events) { + expect(e.response).toBeDefined(); + } }); - it("includes error message in content_block_delta", async () => { + it("includes error message in response.candidates[0].content.parts[0].text", async () => { const response = createSyntheticErrorResponse("Something failed", "model"); const text = await response.text(); const lines = text.split("\n").filter((l) => l.startsWith("data:")); const events = lines.map((l) => JSON.parse(l.replace("data: ", ""))); - const delta = events.find((e) => e.type === "content_block_delta"); + const resp = events[0]?.response; - expect(delta?.delta?.text).toBe("Something failed"); + expect(resp?.candidates?.[0]?.content?.parts?.[0]?.text).toBe("Something failed"); }); - it("sets end_turn stop reason in message_delta", async () => { + it("sets finishReason STOP in response.candidates[0]", async () => { const response = createSyntheticErrorResponse("Error", "model"); const text = await response.text(); const lines = text.split("\n").filter((l) => l.startsWith("data:")); const events = lines.map((l) => JSON.parse(l.replace("data: ", ""))); - const messageDelta = events.find((e) => e.type === "message_delta"); + const resp = events[0]?.response; - expect(messageDelta?.delta?.stop_reason).toBe("end_turn"); + expect(resp?.candidates?.[0]?.finishReason).toBe("STOP"); }); }); diff --git a/src/plugin/request-helpers.ts b/src/plugin/request-helpers.ts index 74ffff0..65e79f0 100644 --- a/src/plugin/request-helpers.ts +++ b/src/plugin/request-helpers.ts @@ -1,4 +1,3 @@ -import { getKeepThinking } from "./config"; import { createLogger } from "./logger"; import { cacheSignature } from "./cache"; import { @@ -1102,12 +1101,12 @@ function filterContentArray( isClaudeModel?: boolean, isLastAssistantMessage: boolean = false, ): any[] { - // For Claude models, strip thinking blocks by default for reliability - // User can opt-in to keep thinking via config: { "keep_thinking": true } - if (isClaudeModel && !getKeepThinking()) { + // Claude thinking blocks include signed `signature` fields that cannot be replayed. + // To avoid hard-to-recover 400s from invalid signature reuse, always strip thinking + // blocks from outgoing requests for Claude models (tool blocks are preserved). + if (isClaudeModel) { return stripAllThinkingBlocks(contentArray); } - const filtered: any[] = []; for (const item of contentArray) { @@ -1500,6 +1499,59 @@ export function transformThinkingParts(response: unknown): unknown { return result; } +/** + * Strips reasoning/thinking parts from a response object. + * For Claude models, OpenCode will render these as "Thinking:" which users generally don't want. + */ +export function stripThinkingFromResponse(response: unknown): unknown { + if (!response || typeof response !== "object") { + return response; + } + + const resp = response as Record; + + const stripPartsArray = (parts: any[]): any[] => + parts.filter((p) => { + if (!p || typeof p !== "object") return true; + // OpenCode/Gemini format + if ((p as any).thought === true) return false; + // Anthropic-ish / normalized formats + const t = (p as any).type; + if (t === "thinking" || t === "redacted_thinking" || t === "reasoning") return false; + return true; + }); + + const out: Record = { ...resp }; + + if (Array.isArray(resp.candidates)) { + out.candidates = (resp.candidates as any[]).map((cand) => { + if (!cand || typeof cand !== "object") return cand; + const c = cand as Record; + const content = c.content as Record | undefined; + if (!content || typeof content !== "object") return cand; + if (!Array.isArray((content as any).parts)) return cand; + return { + ...c, + content: { + ...content, + parts: stripPartsArray((content as any).parts), + }, + }; + }); + } + + if (Array.isArray(resp.content)) { + out.content = stripPartsArray(resp.content as any[]); + } + + // Remove any extracted reasoning aggregate to avoid UI showing it elsewhere. + if ("reasoning_content" in out) { + delete (out as any).reasoning_content; + } + + return out; +} + /** * Ensures thinkingConfig is valid: includeThoughts only allowed when budget > 0. */ @@ -2731,77 +2783,33 @@ export function applyToolPairingFixes( export function createSyntheticErrorResponse( errorMessage: string, requestedModel: string = "unknown", + errorType: string = "prompt_too_long", ): Response { - // Generate a unique message ID - const messageId = `msg_synthetic_${Date.now()}`; - - // Build Claude SSE events that represent a complete message with error text - const events: string[] = []; - - // 1. message_start event - events.push(`event: message_start -data: ${JSON.stringify({ - type: "message_start", - message: { - id: messageId, - type: "message", - role: "assistant", - content: [], - model: requestedModel, - stop_reason: null, - stop_sequence: null, - usage: { input_tokens: 0, output_tokens: 0 }, + // IMPORTANT: + // OpenCode's Google provider expects Antigravity/Gemini-shaped SSE lines (data: {"response": ...}). + // Returning raw Claude SSE events here causes "Failed to process error response". + const outputTokens = Math.max(1, Math.ceil(errorMessage.length / 4)); + const payload = { + response: { + candidates: [ + { + content: { + role: "model", + parts: [{ text: errorMessage }], + }, + finishReason: "STOP", + }, + ], + modelVersion: requestedModel, + usageMetadata: { + promptTokenCount: 0, + candidatesTokenCount: outputTokens, + totalTokenCount: outputTokens, + }, }, - })} - -`); - - // 2. content_block_start event - events.push(`event: content_block_start -data: ${JSON.stringify({ - type: "content_block_start", - index: 0, - content_block: { type: "text", text: "" }, - })} - -`); - - // 3. content_block_delta event with the error message - events.push(`event: content_block_delta -data: ${JSON.stringify({ - type: "content_block_delta", - index: 0, - delta: { type: "text_delta", text: errorMessage }, - })} - -`); - - // 4. content_block_stop event - events.push(`event: content_block_stop -data: ${JSON.stringify({ - type: "content_block_stop", - index: 0, - })} - -`); - - // 5. message_delta event (end_turn) - events.push(`event: message_delta -data: ${JSON.stringify({ - type: "message_delta", - delta: { stop_reason: "end_turn", stop_sequence: null }, - usage: { output_tokens: Math.ceil(errorMessage.length / 4) }, - })} - -`); - - // 6. message_stop event - events.push(`event: message_stop -data: ${JSON.stringify({ type: "message_stop" })} - -`); + }; - const body = events.join(""); + const body = `data: ${JSON.stringify(payload)}\n\n`; return new Response(body, { status: 200, @@ -2810,8 +2818,7 @@ data: ${JSON.stringify({ type: "message_stop" })} "Cache-Control": "no-cache", "Connection": "keep-alive", "X-Antigravity-Synthetic": "true", - "X-Antigravity-Error-Type": "prompt_too_long", + "X-Antigravity-Error-Type": errorType, }, }); } - diff --git a/src/plugin/request.ts b/src/plugin/request.ts index d5a8c6f..3d08ba5 100644 --- a/src/plugin/request.ts +++ b/src/plugin/request.ts @@ -45,6 +45,7 @@ import { resolveThinkingConfig, rewriteAntigravityPreviewAccessError, transformThinkingParts, + stripThinkingFromResponse, type AntigravityApiBody, } from "./request-helpers"; import { @@ -105,9 +106,10 @@ function buildSignatureSessionKey( function shouldCacheThinkingSignatures(model?: string): boolean { if (typeof model !== "string") return false; const lower = model.toLowerCase(); - // Both Claude and Gemini 3 models require thought signature caching - // for multi-turn conversations with function calling - return lower.includes("claude") || lower.includes("gemini-3"); + // Gemini 3 uses unsigned "thought" parts that we may want to preserve/transform. + // Claude uses signed thinking blocks that MUST NOT be replayed (signatures are per-response), + // so we never cache signatures for Claude. + return lower.includes("gemini-3"); } function hashConversationSeed(seed: string): string { @@ -727,6 +729,13 @@ export function prepareAntigravityRequest( sessionId = signatureSessionKey; } + if (isClaude) { + // Defense in depth: some callers may include replayed messages/contents + // outside the nested request objects. Claude thinking signatures are not + // replayable, so strip thinking blocks from the entire wrapped payload. + deepFilterThinkingBlocks(wrappedBody, signatureSessionKey, getCachedSignature, true); + } + for (const req of requestObjects) { // Use stable session ID for signature caching across multi-turn conversations (req as any).sessionId = signatureSessionKey; @@ -739,30 +748,15 @@ export function prepareAntigravityRequest( // Step 1: Strip corrupted/unsigned thinking blocks FIRST deepFilterThinkingBlocks(req, signatureSessionKey, getCachedSignature, true); - // Step 2: THEN inject signed thinking from cache (after stripping) - if (isClaudeThinking && Array.isArray((req as any).contents)) { - (req as any).contents = ensureThinkingBeforeToolUseInContents((req as any).contents, signatureSessionKey); - } - if (isClaudeThinking && Array.isArray((req as any).messages)) { - (req as any).messages = ensureThinkingBeforeToolUseInMessages((req as any).messages, signatureSessionKey); - } - // Step 3: Apply tool pairing fixes (ID assignment, response matching, orphan recovery) applyToolPairingFixes(req as Record, true); } } if (isClaudeThinking && sessionId) { - const hasToolUse = requestObjects.some((req) => - (Array.isArray((req as any).contents) && hasToolUseInContents((req as any).contents)) || - (Array.isArray((req as any).messages) && hasToolUseInMessages((req as any).messages)), - ); - const hasSignedThinking = requestObjects.some((req) => - (Array.isArray((req as any).contents) && hasSignedThinkingInContents((req as any).contents)) || - (Array.isArray((req as any).messages) && hasSignedThinkingInMessages((req as any).messages)), - ); - const hasCachedThinking = defaultSignatureStore.has(signatureSessionKey); - needsSignedThinkingWarmup = hasToolUse && !hasSignedThinking && !hasCachedThinking; + // Claude thinking block signatures cannot be replayed. We always strip thinking blocks + // from outgoing requests, so a "warmup request for thinking signature" is not useful. + needsSignedThinkingWarmup = false; } body = JSON.stringify(wrappedBody); @@ -1179,25 +1173,11 @@ export function prepareAntigravityRequest( // Step 1: Strip corrupted/unsigned thinking blocks FIRST deepFilterThinkingBlocks(requestPayload, signatureSessionKey, getCachedSignature, true); - // Step 2: THEN inject signed thinking from cache (after stripping) - if (isClaudeThinking && Array.isArray(requestPayload.contents)) { - requestPayload.contents = ensureThinkingBeforeToolUseInContents(requestPayload.contents, signatureSessionKey); - } - if (isClaudeThinking && Array.isArray(requestPayload.messages)) { - requestPayload.messages = ensureThinkingBeforeToolUseInMessages(requestPayload.messages, signatureSessionKey); - } - - // Step 3: Check if warmup needed (AFTER injection attempt) - if (isClaudeThinking) { - const hasToolUse = - (Array.isArray(requestPayload.contents) && hasToolUseInContents(requestPayload.contents)) || - (Array.isArray(requestPayload.messages) && hasToolUseInMessages(requestPayload.messages)); - const hasSignedThinking = - (Array.isArray(requestPayload.contents) && hasSignedThinkingInContents(requestPayload.contents)) || - (Array.isArray(requestPayload.messages) && hasSignedThinkingInMessages(requestPayload.messages)); - const hasCachedThinking = defaultSignatureStore.has(signatureSessionKey); - needsSignedThinkingWarmup = hasToolUse && !hasSignedThinking && !hasCachedThinking; - } + // IMPORTANT: + // Claude thinking blocks are signed and signatures are not replayable across turns. + // Do not inject or "warm up" signed thinking from cache. We always strip all Claude + // thinking blocks from outgoing requests and let Claude generate fresh thinking. + needsSignedThinkingWarmup = false; } // For Claude models, ensure functionCall/tool use parts carry IDs (required by Anthropic). @@ -1511,6 +1491,8 @@ export async function transformAntigravityResponse( const isJsonResponse = contentType.includes("application/json"); const isEventStreamResponse = contentType.includes("text/event-stream"); + const isClaude = typeof effectiveModel === "string" && isClaudeModel(effectiveModel); + // Generate text for thinking injection: // - If debug=true: inject full debug logs // - If keep_thinking=true (but no debug): inject placeholder to trigger signature caching @@ -1518,7 +1500,8 @@ export async function transformAntigravityResponse( const debugText = isDebugEnabled() && Array.isArray(debugLines) && debugLines.length > 0 ? formatDebugLinesForThinking(debugLines) - : getKeepThinking() + // Claude thinking blocks are signed; never inject synthetic placeholders. + : (!isClaude && getKeepThinking()) ? SYNTHETIC_THINKING_PLACEHOLDER : undefined; const cacheSignatures = shouldCacheThinkingSignatures(effectiveModel); @@ -1546,7 +1529,10 @@ export async function transformAntigravityResponse( onCacheSignature: cacheSignature, onInjectDebug: injectDebugThinking, // onInjectSyntheticThinking removed - keep_thinking now uses debugText path - transformThinkingParts, + transformThinkingParts: (r: unknown) => { + const t = transformThinkingParts(r); + return isClaude ? stripThinkingFromResponse(t) : t; + }, }, { signatureSessionKey: sessionId, @@ -1575,24 +1561,39 @@ export async function transformAntigravityResponse( errorBody = { error: { message: text } }; } - // Inject Debug Info if (errorBody?.error) { - const debugInfo = `\n\n[Debug Info]\nRequested Model: ${requestedModel || "Unknown"}\nEffective Model: ${effectiveModel || "Unknown"}\nProject: ${projectId || "Unknown"}\nEndpoint: ${endpoint || "Unknown"}\nStatus: ${response.status}\nRequest ID: ${headers.get("x-request-id") || "N/A"}${toolDebugMissing !== undefined ? `\nTool Debug Missing: ${toolDebugMissing}` : ""}${toolDebugSummary ? `\nTool Debug Summary: ${toolDebugSummary}` : ""}${toolDebugPayload ? `\nTool Debug Payload: ${toolDebugPayload}` : ""}`; - const injectedDebug = debugText ? `\n\n${debugText}` : ""; - errorBody.error.message = (errorBody.error.message || "Unknown error") + debugInfo + injectedDebug; - - // Check if this is a recoverable thinking error - throw to trigger retry - const errorType = detectErrorType(errorBody.error.message || ""); + const rawMessage = + typeof errorBody.error.message === "string" && errorBody.error.message.trim() + ? errorBody.error.message + : text || "Unknown error"; + + // IMPORTANT: + // Determine recovery type from the raw upstream message BEFORE appending any debug info. + // Debug injection can contain words that falsely match recovery patterns. + const errorType = detectErrorType(rawMessage); if (errorType === "thinking_block_order") { const recoveryError = new Error("THINKING_RECOVERY_NEEDED"); (recoveryError as any).recoveryType = errorType; (recoveryError as any).originalError = errorBody; - (recoveryError as any).debugInfo = debugInfo; throw recoveryError; } + // Only append verbose debug info when debug mode is explicitly enabled. + // This keeps user-facing errors clean (no giant tool payloads) by default. + if (isDebugEnabled()) { + const debugInfo = `\n\n[Debug Info]\nRequested Model: ${requestedModel || "Unknown"}\nEffective Model: ${effectiveModel || "Unknown"}\nProject: ${projectId || "Unknown"}\nEndpoint: ${endpoint || "Unknown"}\nStatus: ${response.status}\nRequest ID: ${headers.get("x-request-id") || "N/A"}${toolDebugMissing !== undefined ? `\nTool Debug Missing: ${toolDebugMissing}` : ""}${toolDebugSummary ? `\nTool Debug Summary: ${toolDebugSummary}` : ""}${toolDebugPayload ? `\nTool Debug Payload: ${toolDebugPayload}` : ""}`; + const injectedDebug = + typeof debugText === "string" && debugText.startsWith(DEBUG_MESSAGE_PREFIX) + ? `\n\n${debugText}` + : ""; + errorBody.error.message = rawMessage + debugInfo + injectedDebug; + } else { + // Preserve the raw upstream error message when debug mode is off. + errorBody.error.message = rawMessage; + } + // Detect context length / prompt too long errors - signal to caller for toast - const errorMessage = errorBody.error.message?.toLowerCase() || ""; + const errorMessage = rawMessage.toLowerCase(); if ( errorMessage.includes("prompt is too long") || errorMessage.includes("context length exceeded") || @@ -1611,31 +1612,33 @@ export async function transformAntigravityResponse( headers.set("x-antigravity-context-error", "tool_pairing"); } + // Extract google.rpc.RetryInfo (if present) BEFORE returning the error response. + // This lets the caller honor Retry-After on capacity/rate limiting errors. + if (errorBody?.error?.details && Array.isArray(errorBody.error.details)) { + const retryInfo = errorBody.error.details.find( + (detail: any) => detail['@type'] === 'type.googleapis.com/google.rpc.RetryInfo' + ); + + if (retryInfo?.retryDelay) { + const match = retryInfo.retryDelay.match(/^([\d.]+)s$/); + if (match && match[1]) { + const retrySeconds = parseFloat(match[1]); + if (!isNaN(retrySeconds) && retrySeconds > 0) { + const retryAfterSec = Math.ceil(retrySeconds).toString(); + const retryAfterMs = Math.ceil(retrySeconds * 1000).toString(); + headers.set('Retry-After', retryAfterSec); + headers.set('retry-after-ms', retryAfterMs); + } + } + } + } + return new Response(JSON.stringify(errorBody), { status: response.status, statusText: response.statusText, headers }); } - - if (errorBody?.error?.details && Array.isArray(errorBody.error.details)) { - const retryInfo = errorBody.error.details.find( - (detail: any) => detail['@type'] === 'type.googleapis.com/google.rpc.RetryInfo' - ); - - if (retryInfo?.retryDelay) { - const match = retryInfo.retryDelay.match(/^([\d.]+)s$/); - if (match && match[1]) { - const retrySeconds = parseFloat(match[1]); - if (!isNaN(retrySeconds) && retrySeconds > 0) { - const retryAfterSec = Math.ceil(retrySeconds).toString(); - const retryAfterMs = Math.ceil(retrySeconds * 1000).toString(); - headers.set('Retry-After', retryAfterSec); - headers.set('retry-after-ms', retryAfterMs); - } - } - } - } } const init = { @@ -1694,7 +1697,10 @@ export async function transformAntigravityResponse( if (debugText) { responseBody = injectDebugThinking(responseBody, debugText); } - const transformed = transformThinkingParts(responseBody); + let transformed = transformThinkingParts(responseBody); + if (isClaude) { + transformed = stripThinkingFromResponse(transformed); + } return new Response(JSON.stringify(transformed), init); } diff --git a/src/plugin/storage.test.ts b/src/plugin/storage.test.ts index e935764..62606b4 100644 --- a/src/plugin/storage.test.ts +++ b/src/plugin/storage.test.ts @@ -3,10 +3,14 @@ import { deduplicateAccountsByEmail, migrateV2ToV3, loadAccounts, + loadBlockedAccounts, + saveAccounts, type AccountMetadata, type AccountStorage, + type AccountStorageV3, } from "./storage"; import { promises as fs } from "node:fs"; +import lockfile from "proper-lockfile"; import { existsSync, readFileSync, @@ -219,6 +223,7 @@ vi.mock("node:fs", async () => { writeFile: vi.fn(), mkdir: vi.fn().mockResolvedValue(undefined), access: vi.fn().mockResolvedValue(undefined), + chmod: vi.fn().mockResolvedValue(undefined), unlink: vi.fn(), rename: vi.fn().mockResolvedValue(undefined), appendFile: vi.fn(), @@ -431,6 +436,227 @@ describe("Storage Migration", () => { }); }); + describe("enabled persistence", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("persists explicit enabled=false when saving accounts", async () => { + vi.mocked(fs.readFile).mockImplementation((path) => { + if ((path as string).endsWith(".gitignore")) { + const error = new Error("ENOENT") as NodeJS.ErrnoException; + error.code = "ENOENT"; + return Promise.reject(error); + } + const error = new Error("ENOENT") as NodeJS.ErrnoException; + error.code = "ENOENT"; + return Promise.reject(error); + }); + + const incoming: AccountStorageV3 = { + version: 3, + accounts: [ + { + refreshToken: "r-disabled", + addedAt: now, + lastUsed: now, + enabled: false, + }, + ], + activeIndex: 0, + }; + + await saveAccounts(incoming); + + const saveCall = vi.mocked(fs.writeFile).mock.calls.find( + (call) => (call[0] as string).includes(".tmp") + ); + if (!saveCall) throw new Error("saveAccounts temp write call not found"); + const saved = JSON.parse(saveCall[1] as string) as AccountStorageV3; + + expect(saved.accounts[0]?.enabled).toBe(false); + }); + + it("preserves existing enabled=false during merge when incoming omits enabled", async () => { + const existingOnDisk: AccountStorageV3 = { + version: 3, + accounts: [ + { + refreshToken: "r1", + addedAt: now, + lastUsed: now - 5000, + enabled: false, + }, + ], + activeIndex: 0, + }; + + vi.mocked(fs.readFile).mockImplementation((path) => { + if ((path as string).endsWith(".gitignore")) { + const error = new Error("ENOENT") as NodeJS.ErrnoException; + error.code = "ENOENT"; + return Promise.reject(error); + } + return Promise.resolve(JSON.stringify(existingOnDisk)); + }); + + const incomingWithoutEnabled: AccountStorageV3 = { + version: 3, + accounts: [ + { + refreshToken: "r1", + addedAt: now, + lastUsed: now, + // intentionally omitted `enabled` + }, + ], + activeIndex: 0, + }; + + await saveAccounts(incomingWithoutEnabled, { merge: true }); + + const saveCall = vi.mocked(fs.writeFile).mock.calls.find( + (call) => (call[0] as string).includes(".tmp") + ); + if (!saveCall) throw new Error("saveAccounts temp write call not found"); + const saved = JSON.parse(saveCall[1] as string) as AccountStorageV3; + + expect(saved.accounts[0]?.enabled).toBe(false); + }); + }); + + describe("activeIndexByFamily clamping", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("clamps defined family indices without creating missing keys", async () => { + vi.mocked(fs.readFile).mockImplementation((path) => { + if ((path as string).endsWith(".gitignore")) { + const error = new Error("ENOENT") as NodeJS.ErrnoException; + error.code = "ENOENT"; + return Promise.reject(error); + } + const error = new Error("ENOENT") as NodeJS.ErrnoException; + error.code = "ENOENT"; + return Promise.reject(error); + }); + + const incoming: AccountStorageV3 = { + version: 3, + accounts: [ + { + refreshToken: "r-family", + addedAt: now, + lastUsed: now, + }, + ], + activeIndex: 99, + activeIndexByFamily: { + claude: 99, + }, + }; + + await saveAccounts(incoming); + + const saveCall = vi.mocked(fs.writeFile).mock.calls.find( + (call) => (call[0] as string).includes(".tmp") + ); + if (!saveCall) throw new Error("saveAccounts temp write call not found"); + const saved = JSON.parse(saveCall[1] as string) as AccountStorageV3; + + expect(saved.activeIndexByFamily?.claude).toBe(0); + expect(saved.activeIndexByFamily).not.toHaveProperty("gemini"); + }); + + it("preserves -1 sentinel values for family indices", async () => { + vi.mocked(fs.readFile).mockImplementation((path) => { + if ((path as string).endsWith(".gitignore")) { + const error = new Error("ENOENT") as NodeJS.ErrnoException; + error.code = "ENOENT"; + return Promise.reject(error); + } + const error = new Error("ENOENT") as NodeJS.ErrnoException; + error.code = "ENOENT"; + return Promise.reject(error); + }); + + const incoming: AccountStorageV3 = { + version: 3, + accounts: [ + { + refreshToken: "r-family", + addedAt: now, + lastUsed: now, + }, + ], + activeIndex: 0, + activeIndexByFamily: { + claude: -1, + gemini: 99, + }, + }; + + await saveAccounts(incoming); + + const saveCall = vi.mocked(fs.writeFile).mock.calls.find( + (call) => (call[0] as string).includes(".tmp") + ); + if (!saveCall) throw new Error("saveAccounts temp write call not found"); + const saved = JSON.parse(saveCall[1] as string) as AccountStorageV3; + + expect(saved.activeIndexByFamily?.claude).toBe(-1); + expect(saved.activeIndexByFamily?.gemini).toBe(0); + }); + }); + + describe("blocked account storage loading", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("loads blocked accounts under lock and normalizes malformed entries", async () => { + vi.mocked(fs.readFile).mockResolvedValue( + JSON.stringify({ + version: 1, + accounts: [ + { refreshToken: "r1", blockedAt: 123 }, + { refreshToken: "r1", blockedAt: "bad" }, + { refreshToken: "", blockedAt: 456 }, + null, + ], + }) + ); + + const result = await loadBlockedAccounts(); + + expect(lockfile.lock).toHaveBeenCalledTimes(1); + expect(vi.mocked(fs.chmod)).toHaveBeenCalledTimes(1); + expect(result.version).toBe(1); + expect(result.accounts).toHaveLength(1); + expect(result.accounts[0]?.refreshToken).toBe("r1"); + expect(result.accounts[0]?.blockedAt).toBe(0); + }); + + it("creates blocked storage file when missing before locked read", async () => { + vi.mocked(fs.access).mockRejectedValueOnce( + Object.assign(new Error("ENOENT"), { code: "ENOENT" }) + ); + vi.mocked(fs.readFile).mockResolvedValue(JSON.stringify({ version: 1, accounts: [] })); + + await loadBlockedAccounts(); + + const createCall = vi.mocked(fs.writeFile).mock.calls.find( + (call) => + typeof call[0] === "string" && + call[0].includes("antigravity-blocked-accounts.json") && + !call[0].includes(".tmp") + ); + expect(createCall).toBeDefined(); + expect(lockfile.lock).toHaveBeenCalledTimes(1); + }); + }); + describe("ensureGitignore", () => { const configDir = "/tmp/opencode-test"; @@ -470,6 +696,8 @@ describe("Storage Migration", () => { ".gitignore", "antigravity-accounts.json", "antigravity-accounts.json.*.tmp", + "antigravity-blocked-accounts.json", + "antigravity-blocked-accounts.json.*.tmp", "antigravity-signature-cache.json", "antigravity-logs/", ].join("\n"); @@ -534,6 +762,8 @@ describe("Storage Migration", () => { ".gitignore", "antigravity-accounts.json", "antigravity-accounts.json.*.tmp", + "antigravity-blocked-accounts.json", + "antigravity-blocked-accounts.json.*.tmp", "antigravity-signature-cache.json", "antigravity-logs/", ].join("\n"); diff --git a/src/plugin/storage.ts b/src/plugin/storage.ts index 4199a7c..6bf7e31 100644 --- a/src/plugin/storage.ts +++ b/src/plugin/storage.ts @@ -26,6 +26,8 @@ export const GITIGNORE_ENTRIES = [ ".gitignore", "antigravity-accounts.json", "antigravity-accounts.json.*.tmp", + "antigravity-blocked-accounts.json", + "antigravity-blocked-accounts.json.*.tmp", "antigravity-signature-cache.json", "antigravity-logs/", ]; @@ -193,9 +195,8 @@ export interface AccountMetadataV3 { cooldownReason?: CooldownReason; /** Per-account device fingerprint for rate limit mitigation */ fingerprint?: import("./fingerprint").Fingerprint; - /** Cached soft quota data */ - cachedQuota?: Record; - cachedQuotaUpdatedAt?: number; + /** History of previous fingerprints for this account */ + fingerprintHistory?: import("./fingerprint").FingerprintVersion[]; } export interface AccountStorageV3 { @@ -211,7 +212,7 @@ export interface AccountStorageV3 { type AnyAccountStorage = AccountStorageV1 | AccountStorage | AccountStorageV3; /** - * Gets the legacy Windows config directory (%APPDATA%\opencode). + * Gets the legacy Windows config directory (%APPDATA%\\opencode). * Used for migration from older plugin versions. */ function getLegacyWindowsConfigDir(): string { @@ -226,7 +227,7 @@ function getLegacyWindowsConfigDir(): string { * 1. OPENCODE_CONFIG_DIR env var (if set) * 2. ~/.config/opencode (all platforms, including Windows) * - * On Windows, also checks for legacy %APPDATA%\opencode path for migration. + * On Windows, also checks for legacy %APPDATA%\\opencode path for migration. */ function getConfigDir(): string { // 1. Check for explicit override via env var @@ -263,7 +264,6 @@ function migrateLegacyWindowsConfig(): boolean { try { // Ensure new config directory exists const newConfigDir = getConfigDir(); - mkdirSync(newConfigDir, { recursive: true }); // Try rename first (atomic, but fails across filesystems) @@ -322,10 +322,21 @@ export function getStoragePath(): string { return getStoragePathWithMigration(); } -/** - * Gets the config directory path. Exported for use by other modules. - */ -export { getConfigDir }; +export function getBlockedAccountsPath(): string { + return join(getConfigDir(), "antigravity-blocked-accounts.json"); +} + +export interface BlockedAccountMetadataV1 extends AccountMetadataV3 { + blockedAt: number; + blockedReason?: string; + /** Most recent Google verification URL (best-effort) */ + verifyUrl?: string; +} + +export interface BlockedAccountStorageV1 { + version: 1; + accounts: BlockedAccountMetadataV1[]; +} const LOCK_OPTIONS = { stale: 10000, @@ -362,6 +373,19 @@ async function ensureFileExists(path: string): Promise { } } +async function ensureBlockedAccountsFileExists(path: string): Promise { + try { + await fs.access(path); + } catch { + await fs.mkdir(dirname(path), { recursive: true }); + await fs.writeFile( + path, + JSON.stringify({ version: 1, accounts: [] }, null, 2), + { encoding: "utf-8", mode: 0o600 }, + ); + } +} + async function withFileLock(path: string, fn: () => Promise): Promise { await ensureFileExists(path); let release: (() => Promise) | null = null; @@ -379,6 +403,23 @@ async function withFileLock(path: string, fn: () => Promise): Promise { } } +async function withBlockedFileLock(path: string, fn: () => Promise): Promise { + await ensureBlockedAccountsFileExists(path); + let release: (() => Promise) | null = null; + try { + release = await lockfile.lock(path, LOCK_OPTIONS); + return await fn(); + } finally { + if (release) { + try { + await release(); + } catch (unlockError) { + log.warn("Failed to release lock", { error: String(unlockError) }); + } + } + } +} + function mergeAccountStorage( existing: AccountStorageV3, incoming: AccountStorageV3, @@ -643,15 +684,95 @@ export async function loadAccounts(): Promise { } } -export async function saveAccounts(storage: AccountStorageV3): Promise { +export interface SaveAccountsOptions { + /** + * When true (default), merges with on-disk storage under a file lock. + * When false, overwrites on-disk storage with `storage`. + */ + merge?: boolean; + /** + * Refresh tokens that are newly created in-memory and should be persisted even + * if they are not present on disk yet. + */ + addedRefreshTokens?: string[]; + /** Refresh tokens that should be removed from disk. */ + removedRefreshTokens?: string[]; + /** + * When true (default), prevents stale writers from re-introducing refresh + * tokens that were deleted from disk by another process. + */ + preserveDeletions?: boolean; +} + +export async function saveAccounts( + storage: AccountStorageV3, + options: SaveAccountsOptions = {}, +): Promise { const path = getStoragePath(); const configDir = dirname(path); await fs.mkdir(configDir, { recursive: true }); await ensureGitignore(configDir); await withFileLock(path, async () => { - const existing = await loadAccountsUnsafe(); - const merged = existing ? mergeAccountStorage(existing, storage) : storage; + const shouldMerge = options.merge !== false; + const preserveDeletions = options.preserveDeletions !== false; + const existing = shouldMerge ? await loadAccountsUnsafe() : null; + + let merged = existing ? mergeAccountStorage(existing, storage) : storage; + + const removedSet = new Set(options.removedRefreshTokens ?? []); + if (removedSet.size > 0) { + merged = { + ...merged, + accounts: merged.accounts.filter((a) => !removedSet.has(a.refreshToken)), + }; + } + + // Prevent stale processes from resurrecting deleted accounts: + // if an account refresh token is present only in the incoming `storage` + // (not on disk), we drop it unless it is explicitly marked as "added". + if (existing && preserveDeletions) { + const diskTokens = new Set(existing.accounts.map((a) => a.refreshToken).filter(Boolean)); + const addedTokens = new Set(options.addedRefreshTokens ?? []); + const incomingTokens = new Set(storage.accounts.map((a) => a.refreshToken).filter(Boolean)); + + const staleIncoming = new Set(); + for (const token of incomingTokens) { + if (!diskTokens.has(token) && !addedTokens.has(token)) { + staleIncoming.add(token); + } + } + + if (staleIncoming.size > 0) { + merged = { + ...merged, + accounts: merged.accounts.filter((a) => !staleIncoming.has(a.refreshToken)), + }; + } + } + + // Clamp indices after any removals/pruning so we don't write invalid indices. + const len = merged.accounts.length; + const clampIndex = (idx: unknown): number => { + if (len <= 0) return 0; + const n = typeof idx === "number" && Number.isFinite(idx) ? idx : 0; + return Math.min(Math.max(0, Math.trunc(n)), len - 1); + }; + const clampFamilyIndex = (idx: unknown): number => { + if (len <= 0) return -1; + const n = typeof idx === "number" && Number.isFinite(idx) ? Math.trunc(idx) : 0; + if (n < 0) return -1; + return Math.min(n, len - 1); + }; + merged.activeIndex = clampIndex(merged.activeIndex); + if (merged.activeIndexByFamily && typeof merged.activeIndexByFamily === "object") { + const family = merged.activeIndexByFamily; + merged.activeIndexByFamily = { + ...family, + ...(family.claude !== undefined ? { claude: clampFamilyIndex(family.claude) } : {}), + ...(family.gemini !== undefined ? { gemini: clampFamilyIndex(family.gemini) } : {}), + }; + } const tempPath = `${path}.${randomBytes(6).toString("hex")}.tmp`; const content = JSON.stringify(merged, null, 2); @@ -707,7 +828,7 @@ async function loadAccountsUnsafe(): Promise { await ensureSecurePermissions(path); const content = await fs.readFile(path, "utf-8"); - const parsed = JSON.parse(content); + const parsed = JSON.parse(content) as any; if (parsed.version === 1) { return migrateV2ToV3(migrateV1ToV2(parsed)); @@ -715,6 +836,9 @@ async function loadAccountsUnsafe(): Promise { if (parsed.version === 2) { return migrateV2ToV3(parsed); } + if (parsed.version !== 3 || !Array.isArray(parsed.accounts)) { + return null; + } return { ...parsed, @@ -740,3 +864,84 @@ export async function clearAccounts(): Promise { } } } + +export async function loadBlockedAccounts(): Promise { + const path = getBlockedAccountsPath(); + const empty: BlockedAccountStorageV1 = { version: 1, accounts: [] }; + + try { + return await withBlockedFileLock(path, async () => { + // Best effort to normalize permissions for pre-existing files. + await ensureSecurePermissions(path); + + const content = await fs.readFile(path, "utf-8"); + const parsed = JSON.parse(content) as Partial | null; + + if ( + !parsed || + typeof parsed !== "object" || + parsed.version !== 1 || + !Array.isArray(parsed.accounts) + ) { + return empty; + } + + const map = new Map(); + for (const acc of parsed.accounts) { + if (!acc || typeof acc !== "object") continue; + const refreshToken = (acc as any).refreshToken; + if (typeof refreshToken !== "string" || !refreshToken) continue; + const blockedAt = typeof (acc as any).blockedAt === "number" ? (acc as any).blockedAt : 0; + map.set(refreshToken, { ...(acc as any), blockedAt }); + } + + return { + version: 1, + accounts: Array.from(map.values()), + }; + }); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code === "ENOENT") { + return empty; + } + log.error("Failed to load blocked account storage", { error: String(error) }); + return empty; + } +} + +export async function saveBlockedAccounts(storage: BlockedAccountStorageV1): Promise { + const path = getBlockedAccountsPath(); + const configDir = dirname(path); + await fs.mkdir(configDir, { recursive: true }); + await ensureGitignore(configDir); + + await withBlockedFileLock(path, async () => { + const tempPath = `${path}.${randomBytes(6).toString("hex")}.tmp`; + const content = JSON.stringify(storage, null, 2); + + try { + await fs.writeFile(tempPath, content, { encoding: "utf-8", mode: 0o600 }); + await fs.rename(tempPath, path); + } catch (error) { + try { + await fs.unlink(tempPath); + } catch { + // ignore cleanup errors + } + throw error; + } + }); +} + +export async function clearBlockedAccounts(): Promise { + try { + const path = getBlockedAccountsPath(); + await fs.unlink(path); + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + if (code !== "ENOENT") { + log.error("Failed to clear blocked account storage", { error: String(error) }); + } + } +} diff --git a/src/plugin/ui/auth-menu.ts b/src/plugin/ui/auth-menu.ts index 5476983..17e8b2e 100644 --- a/src/plugin/ui/auth-menu.ts +++ b/src/plugin/ui/auth-menu.ts @@ -19,7 +19,7 @@ export type AuthMenuAction = | { type: 'select-account'; account: AccountInfo } | { type: 'delete-all' } | { type: 'check' } - | { type: 'manage' } + | { type: 'verify' } | { type: 'configure-models' } | { type: 'cancel' }; @@ -51,17 +51,24 @@ function getStatusBadge(status: AccountStatus | undefined): string { export async function showAuthMenu(accounts: AccountInfo[]): Promise { const items: MenuItem[] = [ - { label: 'Add new account', value: { type: 'add' } }, - { label: 'Check quotas', value: { type: 'check' } }, - { label: 'Manage accounts (enable/disable)', value: { type: 'manage' } }, - { label: 'Configure models in opencode config', value: { type: 'configure-models' } }, + { label: 'Actions', value: { type: 'cancel' }, kind: 'heading' }, + { label: 'Add account', value: { type: 'add' }, color: 'cyan' }, + { label: 'Check quotas', value: { type: 'check' }, color: 'cyan' }, + { label: 'Verify blocked accounts', value: { type: 'verify' }, color: 'cyan' }, + { label: 'Configure models in opencode.json', value: { type: 'configure-models' }, color: 'cyan' }, + + { label: '', value: { type: 'cancel' }, separator: true }, + + { label: 'Accounts', value: { type: 'cancel' }, kind: 'heading' }, ...accounts.map(account => { - const badge = getStatusBadge(account.status); + const statusBadge = getStatusBadge(account.status); + const currentBadge = account.isCurrentAccount ? ` ${ANSI.cyan}[current]${ANSI.reset}` : ''; const disabledBadge = account.enabled === false ? ` ${ANSI.red}[disabled]${ANSI.reset}` : ''; - const label = account.email || `Account ${account.index + 1}`; - const fullLabel = `${label}${badge ? ' ' + badge : ''}${disabledBadge}`; - + const baseLabel = account.email || `Account ${account.index + 1}`; + const numbered = `${account.index + 1}. ${baseLabel}`; + const fullLabel = `${numbered}${currentBadge}${statusBadge ? ' ' + statusBadge : ''}${disabledBadge}`; + return { label: fullLabel, hint: account.lastUsed ? `used ${formatRelativeTime(account.lastUsed)}` : '', @@ -69,13 +76,17 @@ export async function showAuthMenu(accounts: AccountInfo[]): Promise { hint?: string; disabled?: boolean; separator?: boolean; + /** Non-selectable label row (section heading). */ + kind?: 'heading'; color?: 'red' | 'green' | 'yellow' | 'cyan'; } export interface SelectOptions { message: string; subtitle?: string; + /** Override the help line shown at the bottom of the menu. */ + help?: string; + /** + * Clear the terminal before each render (opt-in). + * Useful for nested flows where previous logs make menus feel cluttered. + */ + clearScreen?: boolean; } const ESCAPE_TIMEOUT_MS = 50; +const ANSI_REGEX = new RegExp("\\x1b\\[[0-9;]*m", "g"); +const ANSI_LEADING_REGEX = new RegExp("^\\x1b\\[[0-9;]*m"); + +function stripAnsi(input: string): string { + return input.replace(ANSI_REGEX, ''); +} + +function truncateAnsi(input: string, maxVisibleChars: number): string { + if (maxVisibleChars <= 0) return ''; + + const visible = stripAnsi(input); + if (visible.length <= maxVisibleChars) return input; + + const suffix = maxVisibleChars >= 3 ? '...' : '.'.repeat(maxVisibleChars); + const keep = Math.max(0, maxVisibleChars - suffix.length); + + let out = ''; + let i = 0; + let kept = 0; + + while (i < input.length && kept < keep) { + // Preserve ANSI sequences without counting them. + if (input[i] === '\x1b') { + const m = input.slice(i).match(ANSI_LEADING_REGEX); + if (m) { + out += m[0]; + i += m[0].length; + continue; + } + } + + out += input[i]; + i += 1; + kept += 1; + } + + return out + suffix; +} + function getColorCode(color: MenuItem['color']): string { switch (color) { case 'red': return ANSI.red; @@ -38,7 +86,8 @@ export async function select( throw new Error('No menu items provided'); } - const enabledItems = items.filter(i => !i.disabled && !i.separator); + const isSelectable = (i: MenuItem) => !i.disabled && !i.separator && i.kind !== 'heading'; + const enabledItems = items.filter(isSelectable); if (enabledItems.length === 0) { throw new Error('All items disabled'); } @@ -50,43 +99,75 @@ export async function select( const { message, subtitle } = options; const { stdin, stdout } = process; - let cursor = items.findIndex(i => !i.disabled && !i.separator); + let cursor = items.findIndex(isSelectable); if (cursor === -1) cursor = 0; // Fallback, though validation above should prevent this let escapeTimeout: ReturnType | null = null; let isCleanedUp = false; - let isFirstRender = true; - - const getTotalLines = (): number => { - const subtitleLines = subtitle ? 3 : 0; - return 1 + subtitleLines + items.length + 1 + 1; - }; + let renderedLines = 0; const render = () => { - const totalLines = getTotalLines(); + const columns = stdout.columns ?? 80; + const rows = stdout.rows ?? 24; + const shouldClearScreen = options.clearScreen === true; + const previousRenderedLines = renderedLines; + + if (shouldClearScreen) { + stdout.write(ANSI.clearScreen + ANSI.moveTo(1, 1)); + } else if (previousRenderedLines > 0) { + stdout.write(ANSI.up(previousRenderedLines)); + } - if (!isFirstRender) { - stdout.write(ANSI.up(totalLines) + '\r'); + let linesWritten = 0; + const writeLine = (line: string) => { + stdout.write(`${ANSI.clearLine}${line}\n`); + linesWritten += 1; + }; + + // Subtitle renders as 3 lines: + // 1) blank "│" spacer, 2) subtitle line, 3) blank line. Header is counted separately. + const subtitleLines = subtitle ? 3 : 0; + const fixedLines = 1 + subtitleLines + 2; // header + subtitle + (help + bottom) + // Keep a small safety margin so the final newline doesn't scroll the terminal. + const maxVisibleItems = Math.max(1, Math.min(items.length, rows - fixedLines - 1)); + + // If the menu is taller than the viewport, only render a window around the cursor. + // This prevents terminal scrollback spam (e.g. repeated headers when pressing arrows). + let windowStart = 0; + let windowEnd = items.length; + if (items.length > maxVisibleItems) { + windowStart = cursor - Math.floor(maxVisibleItems / 2); + windowStart = Math.max(0, Math.min(windowStart, items.length - maxVisibleItems)); + windowEnd = windowStart + maxVisibleItems; } - isFirstRender = false; - stdout.write(`${ANSI.clearLine}${ANSI.dim}┌ ${ANSI.reset}${message}\n`); + const visibleItems = items.slice(windowStart, windowEnd); + const headerMessage = truncateAnsi(message, Math.max(1, columns - 4)); + writeLine(`${ANSI.dim}┌ ${ANSI.reset}${headerMessage}`); if (subtitle) { - stdout.write(`${ANSI.clearLine}${ANSI.dim}│${ANSI.reset}\n`); - stdout.write(`${ANSI.clearLine}${ANSI.cyan}◆${ANSI.reset} ${subtitle}\n`); - stdout.write(`${ANSI.clearLine}\n`); + writeLine(`${ANSI.dim}│${ANSI.reset}`); + const sub = truncateAnsi(subtitle, Math.max(1, columns - 4)); + writeLine(`${ANSI.cyan}◆${ANSI.reset} ${sub}`); + writeLine(""); } - for (let i = 0; i < items.length; i++) { - const item = items[i]; + for (let i = 0; i < visibleItems.length; i++) { + const itemIndex = windowStart + i; + const item = visibleItems[i]; if (!item) continue; if (item.separator) { - stdout.write(`${ANSI.clearLine}${ANSI.dim}│${ANSI.reset}\n`); + writeLine(`${ANSI.dim}│${ANSI.reset}`); continue; } - const isSelected = i === cursor; + if (item.kind === 'heading') { + const heading = truncateAnsi(`${ANSI.dim}${ANSI.bold}${item.label}${ANSI.reset}`, Math.max(1, columns - 6)); + writeLine(`${ANSI.cyan}│${ANSI.reset} ${heading}`); + continue; + } + + const isSelected = itemIndex === cursor; const colorCode = getColorCode(item.color); let labelText: string; @@ -102,15 +183,32 @@ export async function select( if (item.hint) labelText += ` ${ANSI.dim}${item.hint}${ANSI.reset}`; } + // Prevent wrapping: cursor positioning relies on a fixed line count. + labelText = truncateAnsi(labelText, Math.max(1, columns - 8)); + if (isSelected) { - stdout.write(`${ANSI.clearLine}${ANSI.cyan}│${ANSI.reset} ${ANSI.green}●${ANSI.reset} ${labelText}\n`); + writeLine(`${ANSI.cyan}│${ANSI.reset} ${ANSI.green}●${ANSI.reset} ${labelText}`); } else { - stdout.write(`${ANSI.clearLine}${ANSI.cyan}│${ANSI.reset} ${ANSI.dim}○${ANSI.reset} ${labelText}\n`); + writeLine(`${ANSI.cyan}│${ANSI.reset} ${ANSI.dim}○${ANSI.reset} ${labelText}`); + } + } + + const windowHint = items.length > visibleItems.length + ? ` (${windowStart + 1}-${windowEnd}/${items.length})` + : ''; + const helpText = options.help ?? `Up/Down to select | Enter: confirm | Esc: back${windowHint}`; + const help = truncateAnsi(helpText, Math.max(1, columns - 6)); + writeLine(`${ANSI.cyan}│${ANSI.reset} ${ANSI.dim}${help}${ANSI.reset}`); + writeLine(`${ANSI.cyan}└${ANSI.reset}`); + + if (!shouldClearScreen && previousRenderedLines > linesWritten) { + const extra = previousRenderedLines - linesWritten; + for (let i = 0; i < extra; i++) { + writeLine(""); } } - stdout.write(`${ANSI.clearLine}${ANSI.cyan}│${ANSI.reset} ${ANSI.dim}↑/↓ to select • Enter: confirm${ANSI.reset}\n`); - stdout.write(`${ANSI.clearLine}${ANSI.cyan}└${ANSI.reset}\n`); + renderedLines = linesWritten; }; return new Promise((resolve) => { @@ -154,7 +252,7 @@ export async function select( let next = from; do { next = (next + direction + items.length) % items.length; - } while (items[next]?.disabled || items[next]?.separator); + } while (items[next]?.disabled || items[next]?.separator || items[next]?.kind === 'heading'); return next; };