diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index ac71852..5d4dbf4 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -20,14 +20,31 @@ jobs:
with:
node-version: 24
+ - name: Setup Bun
+ uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: 1.3
+
- name: Install dependencies
- run: npm install
+ run: bun install --frozen-lockfile
- name: Run type check
- run: npm run typecheck
+ run: bun run typecheck
- name: Build
- run: npm run build
+ run: bun run build
+
+ - name: Smoke test TUI
+ run: bun run --cwd packages/opencode smoke:tui
- name: Run tests
- run: npm test
+ run: bun run test
+
+ - name: Run E2E tests
+ run: bun run test:e2e
+
+ - name: Check formatting
+ run: bun run format:check
+
+ - name: Lint
+ run: bun run lint
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 5aa77e0..8b98a9a 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -34,17 +34,34 @@ jobs:
with:
node-version: "24"
+ - name: Setup Bun
+ uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: 1.3
+
- name: Install dependencies
- run: npm install
+ run: bun install --frozen-lockfile
- name: TypeScript typecheck
- run: npm run typecheck
+ run: bun run typecheck
- name: Build
- run: npm run build
+ run: bun run build
+
+ - name: Smoke test TUI
+ run: bun run --cwd packages/opencode smoke:tui
- name: Test
- run: npm test
+ run: bun run test
+
+ - name: Run E2E tests
+ run: bun run test:e2e
+
+ - name: Check formatting
+ run: bun run format:check
+
+ - name: Lint
+ run: bun run lint
publish-npm:
name: Publish ${{ matrix.package }} to npm
@@ -72,11 +89,16 @@ jobs:
node-version: "24"
registry-url: "https://registry.npmjs.org"
+ - name: Setup Bun
+ uses: oven-sh/setup-bun@v2
+ with:
+ bun-version: 1.3
+
- name: Ensure latest npm (for trusted publishing)
run: npm install -g npm@latest
- name: Install dependencies
- run: npm install
+ run: bun install --frozen-lockfile
- name: Resolve release version
id: version
@@ -89,7 +111,7 @@ jobs:
run: node scripts/version-sync.mjs "${{ steps.version.outputs.version }}"
- name: Build
- run: npm run build
+ run: bun run build
- name: Check whether package version is already published
id: published
diff --git a/.gitignore b/.gitignore
index 232e30c..59710e0 100644
--- a/.gitignore
+++ b/.gitignore
@@ -6,6 +6,9 @@ out
dist
*.tgz
+# precompiled OpenTUI sidebar tree (regenerated by build:tui)
+packages/opencode/src/tui-compiled/
+
# code coverage
coverage
*.lcov
diff --git a/AGENTS.MD b/AGENTS.MD
index 9f1b41f..c81e004 100644
--- a/AGENTS.MD
+++ b/AGENTS.MD
@@ -9,19 +9,28 @@ OpenCode plugin for Google Antigravity OAuth. Intercepts `fetch()` calls to `gen
## Build & Test Commands
```bash
-npm install # Install dependencies
-npm run build # Compile (tsc -p tsconfig.build.json)
-npm run typecheck # Type-check only (tsc --noEmit)
-npm test # Run all tests (vitest run)
-npx vitest run src/plugin/auth.test.ts # Single test file
-npx vitest run -t "test name here" # Single test by name
-npx vitest --watch src/plugin/auth.test.ts # Watch mode, single file
-npm run test:coverage # Coverage report
-npm run test:e2e:models # E2E: model availability check
-npm run test:e2e:regression # E2E: regression suite
+bun install # Install dependencies
+bun run build # Compile (tsc -p tsconfig.build.json)
+bun run typecheck # Type-check only (tsc --noEmit)
+bun run test # Run all unit tests (bun test --isolate across packages)
+bun test --isolate src/plugin/auth.test.ts # Single test file
+bun test --isolate -t "test name here" # Single test by name (uses bun:test's -t filter)
+bun test --isolate --watch src/plugin/auth.test.ts # Watch mode, single file
+bun run test:e2e # Deterministic e2e flows (mock Antigravity server, no network)
+bun run test:e2e:models # Live Antigravity model inventory (CI-gated, network)
+bun run test:e2e:regression # Cross-model + Gemini CLI regression (network)
+bun run --cwd packages/opencode smoke:tui # Pack-and-install smoke test for the TUI subpath
```
-No linter or formatter is configured. Style is enforced by convention (see below).
+Format and lint via Biome:
+
+```bash
+bun run format # biome format --write .
+bun run format:check # biome format . (CI gate)
+bun run lint # biome lint . (CI gate)
+```
+
+Pre-commit hooks (`.lefthook.yml`) run Biome on changed `*.{ts,tsx,js,mjs,json,jsonc,yml,yaml}` files via `bunx biome check --staged --no-errors-on-unmatched`. The repo pins the toolchain in `mise.toml` (`bun = "1.3"`, `node = "24"`) and CI uses `oven-sh/setup-bun@v2` with `bun-version: 1.3`.
## TypeScript Configuration
@@ -70,7 +79,7 @@ No linter or formatter is configured. Style is enforced by convention (see below
### Formatting
- 2-space indentation
-- Double quotes for strings
+- Single quotes for strings (Biome `quoteStyle: single`)
- Trailing commas in multiline constructs
- No semicolons (project convention)
@@ -80,24 +89,49 @@ No linter or formatter is configured. Style is enforced by convention (see below
## Module Structure
+The repository is a Bun workspace with three packages. The pre-monorepo
+single-root-`src/` layout was retired when the project split into
+`packages/{core,opencode,pi}` so the harness-agnostic engine, the host
+adapter, and the Pi host can ship on independent cadences.
+
```
-src/
-├── plugin.ts # Main entry, fetch interceptor
-├── constants.ts # Endpoints, headers, API config, system prompts
-├── antigravity/oauth.ts # OAuth token exchange
-└── plugin/
- ├── auth.ts # Token validation & refresh
- ├── request.ts # Request transformation (core logic)
- ├── request-helpers.ts # Schema cleaning, thinking filters
- ├── thinking-recovery.ts # Turn boundary detection
- ├── recovery.ts # Session recovery (tool_result_missing)
- ├── quota.ts # Quota checking (API usage stats)
- ├── cache.ts # Auth & signature caching
- ├── accounts.ts # Multi-account management & storage
- ├── storage.ts # Persistent storage schemas (Zod)
- ├── fingerprint.ts # Device fingerprint generation & headers
- ├── project.ts # Managed project context resolution
- └── debug.ts # Debug logging utilities
+packages/
+├── core/ # Harness-agnostic engine — auth, transform, storage, fingerprinting
+│ └── src/
+│ ├── index.ts # Public barrel
+│ ├── account-manager.ts # Per-account selection, rate-limit, quota, fingerprint
+│ ├── account-storage.ts # v4 schema + lock-held read-modify-write + fail-closed unreadable
+│ ├── agy-transport.ts # Bounded TLS pool, gzip/chunk decode, idle watchdog
+│ ├── agy-request-metadata.ts
+│ ├── antigravity/oauth.ts # OAuth token exchange + refresh
+│ ├── auth.ts # Token validation helpers
+│ ├── file-lock.ts # Fenced file lock for concurrent writes
+│ ├── fingerprint.ts # Device fingerprint construction
+│ ├── model-registry.ts # Anthropic / Gemini / GPT-OSS model definitions
+│ ├── project.ts # Managed project resolution
+│ ├── quota-manager.ts # Quota caching + fallbacks
+│ ├── rotation.ts # Account rotation state
+│ └── transform/ # Claude / Gemini / cross-model sanitizer + tests
+├── opencode/ # OpenCode host adapter — plugin entry, fetch interceptor, TUI
+│ └── src/
+│ ├── cli.ts # `antigravity-auth` CLI (login / list / quota)
+│ ├── plugin/ # Plugin factory, OAuth methods, account access, fetch
+│ │ ├── fetch-interceptor.ts # Outer/inner loop, retry/quota/routing pipeline
+│ │ ├── auth-loader.ts # Host `auth.loader()` integration
+│ │ ├── oauth-methods.ts # OAuth menu + callbacks
+│ │ ├── persist-account-pool.ts # Lock-held append after fresh OAuth login
+│ │ ├── storage.ts # Host-path adapter for account pool (re-exports core errors)
+│ │ └── ui/ # TUI sidebar, auth menu, quota-status, command dialogs
+│ ├── tui/ # Precompiled + raw TUI sources
+│ ├── rpc/ # Loopback RPC server + client (sidebar notifications)
+│ └── hooks/ # Lifecycle hooks (auto-update checker, etc.)
+└── pi/ # Pi host adapter — thinner facade around core
+ └── src/
+ ├── index.ts # Public barrel — `streamCortexKitAntigravity` + OAuth helpers
+ ├── convert.ts # Pi <-> Antigravity message-shape conversion
+ ├── credential-cache.ts # Packed refresh token cache
+ ├── paths.ts # Pi AGENT_DIR + account-pool path resolution
+ └── stream.ts # Stream factory consumed by `index.ts`
```
## Key Design Patterns
@@ -130,11 +164,11 @@ Per-account device fingerprints stored in `antigravity-accounts.json`. Each fing
## Testing
-- Framework: **Vitest 3** with native ESM
-- Config: `vitest.config.ts`
-- Tests colocated: `src/plugin/foo.test.ts` next to `src/plugin/foo.ts`
-- Use `describe`/`it`/`expect` — standard Vitest API
-- Mock with `vi.fn()`, `vi.spyOn()`, `vi.mock()`
+- Framework: **Bun's test runner** (`bun test`; the `bun:test` module re-exports a jest-compatible namespace)
+- Config: no per-package config — Bun discovers `*.test.ts` next to source. The `bunfig.toml` at the root and in each workspace preloads `test/setup.ts` (env-isolation + a per-test mkdtemp root + a `globalThis.stubbed` / `unstubAllGlobals` / `freshImport` helper). The e2e workspace uses `bunfig.toml` `root = "./src"` so its tests stay isolated from the unit workspace.
+- Tests colocated: `src/plugin/foo.test.ts` next to `src/plugin/foo.ts`. The e2e workspace uses `*.e2e.test.ts` so the root `bun run test` and `bun run test:e2e` selectors can target them precisely.
+- Use `describe`/`it`/`expect` from `bun:test` — the standard API.
+- Mock with `mock()`, `spyOn()`, and the `jest` namespace exported from `bun:test` (`jest.fn`, `jest.spyOn`, `jest.setSystemTime`, etc.). The `test/setup.ts` preload patches `jest.setSystemTime` / `jest.useRealTimers` so they also spy on `Date.now()` (Bun's `bun:test` clock only fakes the timer queue, not the wall clock).
## Documentation
@@ -142,3 +176,7 @@ Per-account device fingerprints stored in `antigravity-accounts.json`. Each fing
- [docs/ARCHITECTURE.md](docs/ARCHITECTURE.md) — Detailed architecture guide
- [docs/ANTIGRAVITY_API_SPEC.md](docs/ANTIGRAVITY_API_SPEC.md) — API reference
- [CHANGELOG.md](CHANGELOG.md) — Version history
+- [ARCHITECTURE.md](ARCHITECTURE.md) and [STRUCTURE.md](STRUCTURE.md) — repo-level architecture and file-system map
+- [biome.json](biome.json) — formatter + linter config
+- [lefthook.yml](lefthook.yml) — pre-commit Biome hook
+- [mise.toml](mise.toml) — pinned `bun` + `node` versions
diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md
index 8c1703b..cb07085 100644
--- a/ARCHITECTURE.md
+++ b/ARCHITECTURE.md
@@ -1,133 +1,816 @@
# Architecture
-## Pattern Overview
-
-**Overall:** Monorepo architecture with a harness-agnostic core & host-specific integration plugins.
-
-**Key Characteristics:**
-- **Shared Agnostic Core:** Consolidates all wire formats, OAuth code exchanges, model configurations, project context resolutions, and Claude/Gemini-specific transformations into the `@cortexkit/antigravity-auth-core` package.
-- **Interception & Extraction:** Translates host-specific formats (e.g. Gemini, Claude) into Google Cloud Code Assist (Antigravity) API envelopes, intercepts network transport levels, and parses downstream SSE response streams to strip metadata and recover signatures.
-- **TCP/TLS Raw Socket Transport:** Outbounds all Antigravity API requests via custom raw TLS sockets (`packages/core/src/agy-transport.ts`) supporting SSL connection racing and corporate HTTP proxy tunneling, bypassing default fetch agents.
-- **Multi-Account State & Cooldowns:** Manages account rotation, rate-limit backoffs (with jitter), token refresh queues, session-scoped account pinning, and secure POSIX permissions within the OpenCode wrapper.
-
-## Layers
-
-**Host Integration Layer (Wrappers):**
-- Purpose: Registers integration points and config/event bindings into target execution environments.
-- Location: `packages/opencode/` and `packages/pi/`
-- Contains: Main plugin interfaces, lifecycle hook listeners, interactive UI menus, and Pi-specific streaming adapters.
-- Depends on: `@cortexkit/antigravity-auth-core` and target client environments (`@opencode-ai/plugin`, `@earendil-works/pi-ai`, `@earendil-works/pi-coding-agent`).
-- Used by: OpenCode editor host and Pi agent runner.
-
-**Multi-Account Management:**
-- Purpose: Rotates OAuth accounts, tracks cooldowns, caches remaining quotas, builds device fingerprints, and monitors storage health.
-- Location: `packages/opencode/src/plugin/accounts.ts`, `packages/opencode/src/plugin/rotation.ts`, `packages/opencode/src/plugin/session-context.ts`, `packages/opencode/src/plugin/fingerprint.ts`, `packages/opencode/src/plugin/auth-doctor.ts`
-- Contains: `AccountManager`, `HealthScoreTracker`, `TokenBucketTracker`, fingerprint generation algorithms, self-healing diagnostic tasks, and daily request tracking counters.
-- Depends on: `@cortexkit/antigravity-auth-core` (for fingerprints, request metadata, auth types, and models).
-- Used by: OpenCode plugin orchestrator (`packages/opencode/src/plugin.ts`).
-
-**Core Authentication & Project Context:**
-- Purpose: Handles Google OAuth flow redirection, PKCE code exchanges, token refreshes, and dynamic GCP project context lookup or provisioning.
-- Location: `packages/core/src/antigravity/oauth.ts`, `packages/core/src/auth.ts`, `packages/core/src/project.ts`
-- Contains: `authorizeAntigravity`, `exchangeAntigravity`, `refreshAntigravityToken`, `ensureProjectContext`, `loadManagedProject`.
-- Depends on: `@openauthjs/openauth`
-- Used by: Account manager modules and Pi authentication hooks.
-
-**Payload Transformation:**
-- Purpose: Resolves client-supplied model names into logical Antigravity identifiers, injects tool description hardening rules, strips Claude thinking blocks, and sanitizes payload fields during model family swaps.
-- Location: `packages/core/src/transform/` and `packages/core/src/model-registry.ts`
-- Contains: `resolveModelWithTier`, `applyClaudeTransforms`, `applyGeminiTransforms`, `sanitizeCrossModelPayload`, `getGemini36FlashAntigravityModel`.
-- Depends on: `packages/core/src/constants.ts`
-- Used by: Host request interceptors and streaming parsers.
-
-**Low-Level Socket Transport:**
-- Purpose: Custom TCP/TLS socket transport for sending serialized HTTP requests directly to Google endpoints, bypassing proxy restrictions or system agents.
-- Location: `packages/core/src/agy-transport.ts`
-- Contains: `fetchWithAgyCliTransport`, SSL racing sockets, chunked decoding streams, and idle-timeout logic.
-- Depends on: Node `net` and `tls` built-ins.
-- Used by: Request pipelines, quota fetches, and project discovery.
-
-## Data Flow
-
-**OpenCode Interception and Request Transformation Pipeline:**
-
-1. Host triggers `loader()` function with client request — `packages/opencode/src/plugin.ts`
-2. `isGenerativeLanguageRequest()` checks if target URL corresponds to googleapis — `packages/opencode/src/plugin/request.ts`
-3. `AccountManager.getCurrentOrNextForFamily()` selects and pins an eligible Google account using model quota, rate limits, and host session identity — `packages/opencode/src/plugin/accounts.ts`
-4. `resolveModelWithTier()` converts user-facing model tag into Antigravity wire model ID — `packages/core/src/transform/model-resolver.ts`
-5. `prepareAntigravityRequest()` sanitizes properties, strips Claude thinking, and appends Claude tool instructions in a strict prefix-stabilized order to optimize prompt caching — `packages/opencode/src/plugin/request.ts`
-6. `buildFingerprintHeaders()` constructs the live-captured AGY CLI identity header — `packages/core/src/fingerprint.ts`
-7. `fetchWithAgyCliTransport()` sends the raw bytes over direct/proxied TLS socket connection — `packages/core/src/agy-transport.ts`
-8. `AccountManager.recordRequest()` registers request metrics and updates daily file counters — `packages/opencode/src/plugin/accounts.ts`
-9. `transformAntigravityResponse()` translates the resulting stream back to the expected Gemini client format — `packages/opencode/src/plugin/request.ts`
-10. Streaming transformer captures SSE tokens, caches signatures, and logs cache-hit rates via `onUsageMetadata` callback — `packages/opencode/src/plugin/core/streaming/transformer.ts`
-
-**Pi Extension Stream Mapping:**
-
-1. Extension triggers the `streamSimple` callback for model generation — `packages/pi/src/stream.ts`
-2. Model parameters are mapped, and cached authorization details are fetched — `packages/pi/src/stream.ts`
-3. `ensureProjectContext()` retrieves or provisions a Code Assist project ID — `packages/core/src/project.ts`
-4. Payload details map to a standard Gemini request structure — `packages/pi/src/convert.ts`
-5. SSE connection is initiated to the Antigravity daily endpoint via custom socket transport — `packages/core/src/agy-transport.ts`
-6. Incoming chunks are unwrapped and parsed into Pi-compatible text and tool-call events — `packages/pi/src/stream.ts`
-
-## Key Abstractions
-
-**`AccountManager`:**
-- Purpose: Stateful manager orchestrating Google accounts, token refreshes, health values, rate limit backoffs, and fingerprint history.
-- Location: `packages/opencode/src/plugin/accounts.ts`
-- Pattern: Selection state machine delegating to `HealthScoreTracker` and `TokenBucketTracker`.
-
-**`AgyRequestSessionStore`:**
-- Purpose: Keeps conversation and trajectory IDs stable per host session while deriving request step metadata from payload parts.
-- Location: `packages/core/src/agy-request-metadata.ts`
-- Pattern: Bounded session-context store shared by OpenCode and Pi; OpenCode hashes the workspace URI for its numeric session ID.
-
-**`fetchWithAgyCliTransport`:**
-- Purpose: Direct TCP/TLS streaming connection agent that replicates the official Google Cloud SDK/agy CLI networking behavior.
-- Location: `packages/core/src/agy-transport.ts`
-- Pattern: Raw socket read-write buffer stream with custom chunked-transfer decoding.
-
-**`ModelResolver` (`resolveModelWithTier`):**
-- Purpose: Maps external AI model tags (e.g. `claude-3-7-sonnet`, `gemini-3.6-flash`) into internal Google Antigravity identifiers, specifying thinking budgets and custom header styles.
-- Location: `packages/core/src/transform/model-resolver.ts`
-- Pattern: Regular expression and alias lookup maps backed by tiered route definitions in `packages/core/src/model-registry.ts`.
-
-**`ensureProjectContext`:**
-- Purpose: Automatically initializes, caches, and maintains valid Google Cloud project mappings for standard or enterprise accounts.
-- Location: `packages/core/src/project.ts`
-- Pattern: Async caching proxy with TTL checks and onboard fallback triggers.
-
-**`Cross-Model Sanitizer`:**
-- Purpose: Strips or converts metadata fields that would violate schema expectations when switching between Claude and Gemini model backends.
-- Location: `packages/core/src/transform/cross-model-sanitizer.ts`
-- Pattern: Recursive JSON tree pruning.
-
-## Entry Points
-
-**OpenCode Plugin Entry:**
-- Location: `packages/opencode/index.ts`
-- Triggers: OpenCode loading the package at host startup.
-- Responsibilities: Exposes `createAntigravityPlugin` to initialize interceptors, UI CLI systems, and event channels.
-
-**Pi Extension Entry:**
-- Location: `packages/pi/src/index.ts`
-- Triggers: Pi agent runtime loading extensions.
-- Responsibilities: Registers the "Google Antigravity (CortexKit OAuth)" provider, login menus, credentials refresh hooks, and stream processors.
-
-**Agnostic Core Entry:**
-- Location: `packages/core/src/index.ts`
-- Triggers: Sub-packages importing core libraries.
-- Responsibilities: Exports all shared constants, transforms, transport mechanisms, and OAuth helper functions.
-
-## Error Handling
-
-**Strategy:** Fail closed with active fallback and self-healing. Intercepted request errors (like 429/503) trigger account cooldown penalties and rotate execution to a different account rather than throwing to the client. Storage corruption or auth drift is checked during boot via `AuthDoctor` and self-healed. Invalid or mismatched thinking signatures utilize the `SKIP_THOUGHT_SIGNATURE` sentinel to prevent server-side verification failures. Capacity limits automatically regenerate the device fingerprint history.
-
-## Cross-Cutting Concerns
-
-**Logging:** A unified `createLogger` wrapper maps log records to the OpenCode TUI interface or a file sink (`packages/core/src/logger.ts`). At stream end, request rates, cache hit rates (HIT, MISS, WRITE), and remaining account quota are logged.
-
-**Caching:** Accounts and project identifiers are cached in memory (with TTL) and serialized to disk. Claude thinking block signatures are stored in memory and flushed to a signature cache on disk to persist across sessions (`packages/opencode/src/plugin/cache/signature-cache.ts`).
-
-**Storage:** Account pools are persisted to `antigravity-accounts.json` under the OpenCode/XDG configuration directory using `proper-lockfile` to prevent parallel write conflicts. Sensitive files use mode 0600 and their directories use mode 0700.
+This document is the system-of-record for the `@cortexkit/antigravity-auth*` stack at the v2.0 parity refactor. It is written from the **final** source tree on the current `main` (`9a98a15`) — every cited symbol has a live line reference against the files that actually ship, not the prior single-file plugin that the refactor decomposed into `packages/opencode/src/plugin/index.ts` plus the slim `plugins/opencode/index.ts` barrel.
+
+## System goals and boundaries
+
+The stack solves three problems at the harness boundary:
+
+1. **Let non-Google harnesses talk to Antigravity.** OpenCode and Pi both call `fetch()` against `generativelanguage.googleapis.com`; the Antigravity API only accepts requests shaped like the proprietary `agy` CLI. The plugin intercepts before the host's `fetch` and rebuilds the envelope (`User-Agent`, `Client-Metadata`, request body, SSE response) per credential.
+2. **Manage a pool of Google OAuth accounts.** Each account has a refresh token, an Antigravity `projectId`, an optional `managedProjectId`, a per-account fingerprint, and a quota cache. The runtime rotates, cooldowns, refreshes, and persists them — the host never sees this layer.
+3. **Render a live sidebar without writing to the host's terminal.** The OpenTUI tree is byte-perfect — any stray write corrupts the framebuffer. The plugin publishes a redacted snapshot to a file the TUI polls; slash commands flow over a loopback HTTP RPC with a bearer token the host's TUI process discovers through a port file.
+
+Boundaries that the refactor enforces explicitly:
+
+- **Core is harness-agnostic.** It only knows `fetch`, `node:fs`, `node:net`, and a `fetchAccountQuota` callback. It must not import `@opencode-ai/plugin` or `@earendil-works/pi-ai`. The corollary: every host runtime call is injected through `PluginDependencyOverrides` (`packages/opencode/src/plugin/dependencies.ts:1-204`).
+- **The TUI is read-only.** `packages/opencode/src/tui.tsx:1-533` and the `tui-compiled/` twin only import from `sidebar-state.ts`, `rpc/rpc-client.ts`, `rpc/rpc-dir.ts`, and the local `tui/` folder. OAuth tokens, account storage, the fetch interceptor — none of those run inside the render path.
+- **The plugin is one process, two render domains.** The server-side plugin (host-supplied `client`) and the OpenTUI sidebar live in separate process contexts. Communication is the loopback HTTP RPC plus the on-disk sidebar snapshot; the plugin never imports `@opentui/solid`.
+
+## Package and process topology
+
+Four published packages plus a private e2e workspace:
+
+```
+antigravity-auth/
+├── packages/
+│ ├── core/ # @cortexkit/antigravity-auth-core — harness-agnostic
+│ ├── opencode/ # @cortexkit/opencode-antigravity-auth — server + tui
+│ ├── pi/ # @cortexkit/pi-antigravity-auth — pi extension
+│ └── e2e-tests/ # private: black-box flows against a mock loopback server
+├── scripts/ # build, dev, schema, smoke
+└── package.json # root monorepo script surface
+```
+
+The dependency direction is strictly one-way:
+
+```mermaid
+graph LR
+ OA[opencode] --> Core[core]
+ PI[pi] --> Core
+ OA -. uses .-> SRP[otui sidecar via src/tui/entry.mjs]
+ E2E[e2e-tests] --> OA
+ E2E --> Core
+```
+
+`packages/opencode/package.json` exposes two `exports` subpaths that the host installer reads: `exports["."]` (the fetch interceptor / OAuth / quota controller) and `exports["./tui"]` (the OpenTUI sidebar). The host's `opencode plugin` installer writes a server entry to `opencode.json` and a TUI entry to `tui.json`; the host loads the two registrations independently. The Pi package's `pi.extensions` field (`packages/pi/package.json:34-38`) is the analogue. The core package has no peer dependencies on any host runtime; it only depends on Node built-ins and `@openauthjs/openauth`.
+
+### Process topology at runtime
+
+```mermaid
+flowchart LR
+ subgraph OCHost[OpenCode host process]
+ direction TB
+ ServerPlugin[server plugin
createAntigravityPlugin]
+ TuiPlugin[TUI plugin
src/tui.tsx default export]
+ ServerPlugin ~~~ TuiPlugin
+ end
+ subgraph Sidecar[Sidebar sidecar process]
+ direction TB
+ FileLogger[tui file logger]
+ end
+ subgraph Child[plugin runtime]
+ direction LR
+ Interceptor[createFetchInterceptor]
+ QuotaMgr[createOpenCodeQuotaManager]
+ AccMgr[AccountManager]
+ RPC[RPC server]
+ end
+ ServerPlugin --> Interceptor
+ ServerPlugin --> QuotaMgr
+ ServerPlugin --> AccMgr
+ ServerPlugin --> RPC
+ TuiPlugin -. poll file .-> SidebarState[sidebar-state.json]
+ SidebarState ~~~ RPC
+ RPC -. 127.0.0.1:port + Bearer .-> TuiPlugin
+ Interceptor --> AGY[Antigravity HTTPS]
+ QuotaMgr --> AGY
+ AccMgr --> Storage[(antigravity-accounts.json)]
+ RPC --> Notif[notification queue]
+```
+
+The TUI loads the compiled bundle (`packages/opencode/src/tui/entry.mjs:30-38`) and discovers the live server through a per-pid port file (`packages/opencode/src/rpc/port-file.ts:27-52`). The inter-process boundary is the deliberate design choice — it lets the TUI be a `solid-js` renderer with zero credential exposure.
+
+## Core library layers
+
+`packages/core/src/index.ts:1-30` is the public surface. Each top-level export is a self-contained module groups by concern:
+
+| Layer | Module | Lines | Responsibility |
+| --- | --- | --- | --- |
+| OAuth | `antigravity/oauth.ts` | core | `authorizeAntigravity`, `exchangeAntigravity`, PKCE pack/unpack |
+| Token state | `auth.ts` | `packages/core/src/auth.ts:1-63` | `parseRefreshParts`, `formatRefreshParts`, expiry buffer |
+| Transport | `agy-transport.ts` | `packages/core/src/agy-transport.ts:1-651` | TLS socket pool, chunked/gzip decode, header/idle timeouts |
+| Active timeout | `fetch-timeout.ts` | `packages/core/src/fetch-timeout.ts:1-54` | 15s header-only abort for `globalThis.fetch` callers |
+| Quota + planning | `quota-manager.ts` | `packages/core/src/quota-manager.ts:1-717` | Attributed fetch, exponential backoff, in-flight dedupe |
+| Account pool | `account-manager.ts` | `packages/core/src/account-manager.ts:1-2083` | Selection, rate-limit state, fingerprint, soft-quota |
+| Rotation | `rotation.ts` | `packages/core/src/rotation.ts:1-593` | Health score, token bucket, hybrid LRU classifier |
+| Locking | `file-lock.ts` | `packages/core/src/file-lock.ts:1-532` | Renewable fenced file lock with eviction marker |
+| Atomic file | `atomic-write.ts` | `packages/core/src/atomic-write.ts:1-52` | Temp + rename, 0o600, no copy fallback |
+| Fingerprint | `fingerprint.ts` | core | Per-account device fingerprint + history |
+| Project ctx | `project.ts` | core | `loadCodeAssist`, `ensureProjectContext` |
+| Session metadata | `agy-request-metadata.ts` | `packages/core/src/agy-request-metadata.ts:1-259` | ConversationId, trajectoryId, payload ordering |
+| Constants | `constants.ts` | `packages/core/src/constants.ts:1-269` | Endpoints, scopes, headers, sentinel values |
+| Logger | `logger.ts` | core | `createLogger('module-name')` |
+
+The account storage round-trip is owned by `packages/core/src/account-storage.ts`, which the opencode adapter wraps in `packages/opencode/src/plugin/storage.ts:1-410` (callers from the opencode side land there because the core exports a `store` interface that the host fills in).
+
+### Layer dependency rules
+
+```mermaid
+graph TB
+ subgraph Harness agnostic
+ Auth[auth / oauth]
+ Transport[agy-transport]
+ Quota[quota-manager]
+ Rotation[rotation]
+ Account[account-manager]
+ Metadata[agy-request-metadata]
+ Lock[file-lock + atomic-write]
+ Fingerprint[fingerprint]
+ Project[project]
+ end
+ Auth --> Transport
+ Quota --> Rotation
+ Account --> Rotation
+ Account --> Lock
+ Account --> Fingerprint
+ Project --> Auth
+ Metadata --> Project
+```
+
+The dependency graph is acyclic. No layer reaches into `account-manager` from `auth` — the auth module intentionally treats the refresh token as opaque so it can be reused by the Pi extension which has no concept of an account pool.
+
+## OpenCode server plugin
+
+### Composition root
+
+`packages/opencode/src/plugin/index.ts:75-287` is the single factory `createAntigravityPlugin(providerId, options)`. It is the only host-facing surface; the package barrel at `packages/opencode/index.ts:1-4` re-exports the two stable aliases `{AntigravityCLIOAuthPlugin, GoogleOAuthPlugin}` produced by binding the factory to `ANTIGRAVITY_PROVIDER_ID = 'google'` (`packages/core/src/constants.ts:178`).
+
+The factory wires one of every collaborator the host will ever need:
+
+1. `resolvePluginDependencies(options.dependencies)` (line 78) — replaces the legacy shared mutable globals with a per-instance dependency bag covering `fetchImpl`, `agyTransport`, `filesystemRoots`, `oauth`, and `clock`.
+2. `loadConfig(directory)` then `initRuntimeConfig(config)` (line 82-83) — the latter pushes the resolved config onto the `core` config singleton.
+3. `initHealthTracker` / `initTokenTracker` / `initDiskSignatureCache` (lines 88-111) — populate the global rotation trackers with the user-configured weights.
+4. `AgySessionRegistry` (line 113) — owns the per-workspace `AgyRequestSessionStore` from `packages/core/src/agy-request-metadata.ts:95-184`.
+5. `createPluginLifecycle({...})` (line 115) — the disposal root.
+6. `createOpenCodeQuotaManager` (line 126) — wraps the core `QuotaManager` with a `getAccountsForSidebar` closure so any quota refresh pushes the redacted sidebar snapshot.
+7. `createOperatorSettingsController` (line 149) — backs the `/antigravity-*` slash commands. Config writes go through the fenced-lock writer so a crash mid-write cannot corrupt the file.
+8. `createEventHandler`, `createSessionRecoveryHook`, `createAutoUpdateCheckerHook` (lines 161-174) — host lifecycle hooks.
+9. `createFetchInterceptor` (line 217) — the per-instance interceptor built from `dependencies.agyTransport` and `dependencies.fetchImpl`. A fresh `authLoader` invocation rebuilds the interceptor with the new `AccountManager` (`packages/opencode/src/plugin/auth-loader.ts:154-156`).
+10. `startRpcServer` (line 233) — the loopback HTTP server; the same factory call exposes `apply` (operator → plugin) and `drain` (plugin → TUI notifications) callbacks.
+
+The factory returns a `PluginResult` with `dispose`, `config`, `command.execute.before`, `event`, `tool`, and `auth` hooks (`packages/opencode/src/plugin/index.ts:265-286`). The host's `auth.loader` is the one piece of business logic that runs every model call.
+
+### Auth loader and the one active interceptor
+
+`packages/opencode/src/plugin/auth-loader.ts:61-196` is the function the host invokes on every `auth` call. Its job is to materialize the `AccountManager` from the current session's auth + the on-disk pool, then hand the host a `fetch` closure bound to a fresh interceptor.
+
+```mermaid
+sequenceDiagram
+ participant Host as OpenCode host
+ participant Loader as createAuthLoader
+ participant FSM as PluginLifecycle
+ participant AM as AccountManager
+ participant FI as FetchInterceptor
+ Host->>Loader: loader(getAuth, provider)
+ Loader->>Loader: onGetAuth(getAuth)
+ Loader->>Loader: auth = await getAuth()
+ alt auth is missing/stale
+ Loader->>Loader: detectAuthStorageDrift
+ Loader->>FS: loadAccounts
+ Loader->>Host: client.auth.set(restore)
+ end
+ Loader->>AM: AccountManager.loadFromDisk(auth)
+ Loader->>FSM: replaceAccountRuntime(am, refreshQueue)
+ alt config.proactive_token_refresh
+ Loader->>Loader: createProactiveRefreshQueue
+ Loader->>FSM: refreshQueue.start()
+ end
+ Loader->>FS: setSidebarMachineState(redacted accounts)
+ Loader->>FI: createFetch({ am, getAuth })
+ Loader->>FSI: previousRuntime.dispose()
+ Loader-->>Host: { apiKey: '', fetch: fetcher }
+```
+
+The redaction in `setSidebarMachineState` (`packages/opencode/src/plugin/auth-loader.ts:161-172`) uses `buildSidebarMachineStateFromAccounts` from `packages/opencode/src/sidebar-state.ts:484-500` so the TUI's snapshot is the only authoritative signal that the account pool exists.
+
+### Fetch interceptor
+
+`packages/opencode/src/plugin/fetch-interceptor.ts:1-2242` is the largest file in the tree. It owns the per-request retry/quota/routing pipeline. The outer loop at lines 518-... picks an account via `accountManager.getCurrentOrNextForFamily(...)` (line 616), then the inner loop at lines 1213-... walks the endpoint fallback list (`packages/core/src/constants.ts:46-49`) with per-endpoint capacity retry.
+
+Key gates in the request lifecycle:
+
+- `isGenerativeLanguageRequest(input)` (line 332) — any non-`generativelanguage.googleapis.com` URL passes through to the host fetch unchanged.
+- `accessTokenExpired(authRecord)` (`packages/core/src/auth.ts:40-45`) — refresh via `refreshAccessToken` (line 803) before the request goes out; on `invalid_grant` the account is removed and the pool is rewritten.
+- `ensureProjectContext(authRecord)` (line 925) — the most likely pre-flight failure; on rejection the account is cooled down via `markAccountCoolingDown` + `markRateLimited`.
+- `prepareAntigravityRequest` (line 1210) — full sanitization pass: `sanitizeCrossModelPayload`, Claude thinking-block stripping, prefix-stabilized tool caching, fingerprint header injection, request-metadata labels.
+- `transport(...)` (line 1011, 1059) — the only call site that uses the bounded `agyTransport` socket; non-Antigravity URL variants fall back to `upstreamFetch`.
+- `transformAntigravityResponse` (line 1016) — runs the streaming reverse transform. The streaming transformer at `packages/opencode/src/plugin/core/streaming/transformer.ts` captures SSE tokens, caches thinking signatures, and emits `usageMetadata` to the caller.
+
+The `RetryState` at `packages/opencode/src/plugin/fetch/retry-state.ts` and `WarmupState` at `packages/opencode/src/plugin/fetch/warmup.ts` are per-interceptor singletons; `createFetchInterceptor` constructs them at line 261-262 so disposing the plugin releases every counter.
+
+### Dependency seam
+
+`packages/opencode/src/plugin/dependencies.ts:1-204` defines the override surface. Production callers omit it; test/e2e callers pass `fetchImpl`, `agyTransport`, and `filesystemRoots` to redirect all outbound calls onto the mock server. The seam is the only reason the e2e workspace can run without touching the live Antigravity infrastructure (see **End-to-end data flows** below).
+
+## OpenTUI process and trust boundary
+
+### Two halves of the contract
+
+The OpenTUI plugin is wired through the package's `exports["./tui"]` subpath (`packages/opencode/package.json`). The host's `opencode plugin` installer reads that subpath and writes the registration into `tui.json`; the host loads the TUI entry point separately from the server entry, and the entry dispatches to either the precompiled Solid bundle or the raw `tui.tsx` based on the host runtime module's availability (`packages/opencode/src/tui/entry.mjs:30-66`).
+
+The two halves share a small amount of code through `packages/opencode/src/tui-compiled/` (the precompiled mirror) and the slim contract modules:
+
+- `packages/opencode/src/sidebar-state.ts` — the read seam in the TUI's compiled tree, the read+write seam in the server plugin (lines 1-39 document the split).
+- `packages/opencode/src/rpc/protocol.ts` — wire types (`CommandModalName`, `ApplyRequest`, `ApplyResult`, `RpcNotification`).
+- `packages/opencode/src/rpc/rpc-client.ts` — the TUI's HTTP client.
+- `packages/opencode/src/rpc/rpc-dir.ts` — resolves the per-project directory the port file lives in.
+
+The TUI imports **none** of the OAuth, account, or fetch interceptor modules. The compiler would let it (TypeScript has no runtime privilege check), but the import graph review at `packages/opencode/src/tui.tsx:1-22` documents the rule.
+
+### TUI render path
+
+`packages/opencode/src/tui.tsx` is a `TuiPlugin` that registers a `slots.sidebar_content` slot. The slot renders a `SidebarPanel` Solid component that:
+
+1. Polls `readSidebarState(props.stateFile)` every 2 seconds (`POLL_INTERVAL_MS = 2000`, line 50).
+2. Polls the RPC server's `/rpc/pending-notifications` endpoint every 500ms for any new slash-command dialog (line 132).
+3. Renders the per-account blocks: enabled badge, health bar, cooldown countdown, per-model quota bar (`` = `claude | gemini-pro | gemini-flash`, line 52).
+4. Renders the active session route (line 254-275) — one entry per session that has issued a request through the fetch interceptor.
+5. Surfaces a stale-routing indicator (`STALE_AFTER_MS = 15_000`, line 51) when the snapshot's `checkedAt` is older than the threshold or `routingAuthoritative` is false.
+
+When a notification arrives, `openCommandDialogFromNotification` (line 495-531) rebuilds the dialog flow via `collectDialogFlow` and reuses the host's `DialogSelect` / `confirm` / `prompt` primitives from `tui/command-dialogs.tsx`. The result is a host-branded modal that the user can navigate with the keyboard.
+
+### File logger
+
+`packages/opencode/src/tui/file-logger.ts:1-138` writes logs to `/cortexkit/antigravity-auth/tui.log` (mode 0o600, 1 MB tail-truncated). The file logger is the only place the TUI emits diagnostics; it never writes to stdout/stderr because the host owns the framebuffer.
+
+### RPC protocol
+
+`packages/opencode/src/rpc/protocol.ts:1-29` defines the wire shape:
+
+- `OpenDialogPayload` — `{ command, text, knobs }` — the message from plugin to TUI.
+- `RpcNotification` — `OpenDialogPayload & { id, sessionId? }` — the queued push.
+- `ApplyRequest` / `ApplyResult` — `{ command, arguments, sessionId? }` / `{ text, knobs }`.
+
+The server side is `packages/opencode/src/rpc/rpc-server.ts:1-300`:
+
+- `startRpcServer({ dir, apply, drain })` binds to `127.0.0.1:0` (line 87) so the OS picks a free port, generates a 32-byte hex bearer token (line 62), writes the port file via `writePortFile` (line 97), and returns `{ port, token, stop }`.
+- `handleRequest` (line 127-166) accepts `POST /rpc/apply` (server timeout 120s, `APPLY_TIMEOUT_MS`) and `POST /rpc/pending-notifications` (request timeout 2s, `REQUEST_TIMEOUT_MS`).
+- `isAuthorized` (line 168-177) uses `timingSafeEqual` after padding to a fixed length so callers with the wrong-length token cannot observe a timing side-channel.
+- `closeServer` resolves the in-flight keep-alive sockets via `closeAllConnections`.
+
+The client side is `packages/opencode/src/rpc/rpc-client.ts:1-76`. `createRpcClient(dir, expectedPid)` reads the port file via `discoverPortFile` (which skips dead pids) and posts JSON with `Authorization: Bearer `. The default 2s timeout is enforced via `fetchWithActiveTimeout` from `packages/core/src/fetch-timeout.ts:28-54`.
+
+The notification queue is `packages/opencode/src/rpc/notifications.ts:1-62`. `pushNotification` enqueues with a monotonic id; `drainNotifications` returns anything newer than the caller's `lastReceivedId` for the requested session. `isTuiConnected` reflects whether the TUI polled within the last 5 seconds (`CONNECTION_TTL_MS`, line 4).
+
+### Port file discovery
+
+`packages/opencode/src/rpc/port-file.ts:27-127` writes `/port-.json` with `{ pid, port, token }` (mode 0o600, atomically via `renameSync` from a `.tmp` sibling). `discoverPortFile` walks every `port-*.json` in the directory, validates the JSON, deletes dead-pid entries, and returns the most recent live entry. The pid-scoped naming means a crashed OpenCode process never blocks a new one from binding.
+
+`packages/opencode/src/rpc/rpc-dir.ts:1-27` resolves the directory: `ANTIGRAVITY_AUTH_RPC_DIR` env var wins, otherwise `/cortexkit/antigravity-auth/rpc/` so multiple workspaces can coexist.
+
+## Pi extension
+
+`packages/pi/src/index.ts:1-111` is the package's only entry. Pi's contract is `function (pi: ExtensionAPI): void` — the extension exports a default function that registers a custom OAuth provider:
+
+- `providerId = 'google-antigravity'` (line 16).
+- `pi.registerProvider(ANTIGRAVITY_PROVIDER_ID, { name, baseUrl, api, models, oauth, streamSimple })` (line 93-110).
+- `models` is `getPublicModelDefinitions()` filtered to drop image-output (Pi's `AssistantMessage` protocol has no image output type) and re-mapped onto Pi's `Model` shape (`packages/pi/src/index.ts:78-91`).
+- `oauth.login` invokes `authorizeAntigravity` from core, asks the host for the callback URL/code via `callbacks.onPrompt`, and calls `exchangeAntigravity` (line 22-58).
+- `oauth.refreshToken` reads the packed `refreshToken|projectId|managedProjectId` triple, calls `refreshAntigravityToken` for the bare refresh, and re-packs the project segments (line 60-75).
+- `oauth.getApiKey` bridges the packed refresh into the stream by stashing it in `credential-cache.ts` so the stream can rejoin project context after the access token is stripped (line 102-107).
+- `streamSimple` is `streamCortexKitAntigravity` from `packages/pi/src/stream.ts:1-...`.
+
+The package's `package.json` (`packages/pi/package.json:34-58`) declares `pi.extensions: ['./dist/index.js']` and pulls Pi's three peer dependencies from `@earendil-works/`.
+
+### Pi's package-name dependency on the host
+
+The Pi extension depends on the Pi runtime through `peerDependencies` (`packages/pi/package.json:48-52`) — `@earendil-works/pi-ai`, `@earendil-works/pi-coding-agent`, `@earendil-works/pi-tui` — and the package's `keywords` field includes `pi-package` (line 27) so the Pi package manager discovers it. The extension is resolved by name in the user's Pi config:
+
+```jsonc
+{
+ "extensions": ["@cortexkit/pi-antigravity-auth"]
+}
+```
+
+There is **no direct dependency on any specific version** — Pi handles the extension loading and the peer-dependency resolution at runtime. This keeps the extension portable across Pi versions.
+
+## End-to-end data flows
+
+### 1. OpenCode request / response
+
+```mermaid
+sequenceDiagram
+ participant Host as OpenCode host
+ participant Loader as Auth loader
+ participant FI as Fetch interceptor
+ participant AM as AccountManager
+ participant Stats as Quota/Sidebar
+ participant TX as agy-transport
+ participant AGY as antigravity.googleapis.com
+ Host->>Loader: loader(getAuth, provider)
+ Loader->>AM: getOrCreate from pool
+ AM-->>Loader: am ready
+ Loader->>FI: createFetch({ am, getAuth })
+ Host->>FI: fetch(generativelanguage URL)
+ FI->>AM: getCurrentOrNextForFamily(family, model)
+ AM-->>FI: account + headerStyle
+ FI->>FI: prepareAntigravityRequest
+ FI->>TX: transport(url, init, { timeoutMs: 180s })
+ TX-->>TX: TLS connect (~15s header timeout)
+ TX->>AGY: HTTPS request
+ AGY-->>TX: headers + chunked SSE
+ TX-->>FI: Response with body stream
+ FI->>FI: transformAntigravityResponse
+ FI->>Stats: upsertSidebarActiveRouting(sessionId, route)
+ FI-->>Host: SSE response
+ FI->>Stats: maybe bump quota cache
+```
+
+The header timeout is **15s** via `fetchWithActiveTimeout` (`packages/core/src/fetch-timeout.ts:13-54`) for any fetch through the standard primitive — but the Antigravity path uses the raw socket transport which has its own timeouts (see **Timeouts** below).
+
+### 2. OpenTUI sidebar render path
+
+```mermaid
+sequenceDiagram
+ participant Plugin as Server plugin
+ participant State as sidebar-state.json
+ participant RP as RPC server
+ participant TUI as TUI sidebar
+ participant User
+ Plugin->>State: setSidebarMachineState(redacted accounts)
+ Plugin->>State: upsertSidebarActiveRouting(sessionId, route)
+ Note right of State: every mutation passes through
fenced-lock + atomic-write
+ loop every 2s
+ TUI->>State: readSidebarState()
+ State-->>TUI: SidebarStateV1
+ end
+ loop every 500ms
+ TUI->>RP: pendingNotifications(lastId, sessionId)
+ RP-->>TUI: RpcNotification[]
+ end
+ User->>TUI: invokes /antigravity-quota
+ TUI->>RP: apply({ command, arguments, sessionId })
+ RP->>Plugin: applyCommand()
+ Plugin->>State: refresh snapshot
+ RP-->>TUI: ApplyResult
+ TUI->>TUI: render DialogSelect
+```
+
+### 3. Account lock-held read-modify-write
+
+The `setSidebarMachineState` writer is the canonical plate-spinner. Two writers racing (e.g. a quota refresh and a routing upsert) must not interleave merges against the same file.
+
+```mermaid
+sequenceDiagram
+ participant W1 as Writer 1
+ participant W2 as Writer 2
+ participant Chain as sidebarWriteChain
+ participant Lock as fenced file lock
+ participant File as sidebar-state.json
+ W1->>Chain: enqueue(work1)
+ W2->>Chain: enqueue(work2)
+ Note right of Chain: writes run sequentially,
failures never poison the chain
+ W1->>Lock: acquire (TTL 10s, retries 2s)
+ Lock-->>W1: grant
+ W1->>File: read JSON
+ W1->>W1: merge (machine state)
+ W1->>File: writeJsonAtomic (0o600)
+ W1->>Lock: release
+ W2->>Lock: acquire
+ Lock-->>W2: grant
+ W2->>File: read JSON
+ W2->>W2: merge (session-scoped routing)
+ W2->>File: writeJsonAtomic
+ W2->>Lock: release
+```
+
+The lock is `acquireFencedFileLock` from `packages/core/src/file-lock.ts:184-335` — see **Account persistence and concurrency** for the eviction marker protocol.
+
+### 4. OAuth login
+
+```mermaid
+sequenceDiagram
+ participant User
+ participant Host as OpenCode host
+ participant Plugin as Server plugin
+ participant OAuth as oauth-methods
+ participant Google as accounts.google.com
+ participant Storage as account-storage
+ User->>Host: /antigravity-account → login
+ Host->>Plugin: oauth-methods.authorize(inputs)
+ Plugin->>OAuth: promptLoginMode(existing)
+ OAuth->>User: Add another / login fresh / refresh
+ OAuth->>OAuth: authorizeAntigravity()
+ OAuth->>Google: authorization URL + PKCE
+ OAuth->>Google: local callback listener
+ Google-->>OAuth: code + state
+ OAuth->>OAuth: exchangeAntigravity(code, state)
+ OAuth->>Storage: persistAccountPool([result])
+ Storage-->>OAuth: storage written
+ OAuth-->>Host: oauth callback result
+ Host->>Plugin: auth.loader(getAuth)
+ Plugin->>Plugin: AccountManager.loadFromDisk
+```
+
+The local callback listener is `packages/opencode/src/plugin/server.ts:startOAuthListener`, which times out and falls back to manual paste for headless / WSL2 environments. The listener's port is `http://localhost:51121/oauth-callback` (`packages/core/src/constants.ts:29`).
+
+### 5. TUI dispose / route migration
+
+```mermaid
+sequenceDiagram
+ participant Plugin as Server plugin
+ participant Lifecycle
+ participant SB as Sidebar writes
+ participant Log as TUI file logger
+ participant RPC as RPC server
+ participant Disk as port file
+ Plugin->>Lifecycle: dispose()
+ Lifecycle->>Lifecycle: disposeAccountRuntime()
+ Lifecycle->>Lifecycle: shutdownDiskSignatureCache()
+ Lifecycle->>Lifecycle: sessionRegistry.clear()
+ Lifecycle->>SB: drainSidebarWrites()
+ SB-->>Lifecycle: drained
+ Lifecycle->>Log: writer closed
+ Lifecycle->>RPC: stop()
+ RPC->>Disk: unlink(port-.json)
+ Note right of Lifecycle: only after the snapshot landed
so the TUI's last frame is consistent
+```
+
+The order is enforced by `createPluginLifecycle` — `drainSidebarWrites` runs before any registered disposable (`packages/opencode/src/plugin/lifecycle.ts:72-91`). The plugin's `dispose` therefore guarantees that the last sidebar write from `upsertSidebarActiveRouting` has hit disk before the host moves on.
+
+## Account persistence and concurrency
+
+### Storage shape
+
+`packages/core/src/account-storage.ts` defines the on-disk shape: `AccountStorageV4` with a `version: 4`, `accounts[]`, `activeIndex`, `activeIndexByFamily: { claude, gemini }`. Each account carries `refreshToken`, `email`, `projectId`, `managedProjectId`, `addedAt`, `lastUsed`, `enabled`, `rateLimitResetTimes`, `coolingDownUntil`, `cooldownReason`, `fingerprint`, `fingerprintHistory`, `verificationRequired`, `accountIneligible`, `cachedQuota`, `cachedQuotaUpdatedAt`, `dailyRequestCounts`. The OpenCode adapter wraps this in `packages/opencode/src/plugin/storage.ts:1-410` which adds the on-disk path resolver and the `mutateAccountStorage` helper.
+
+### Why a custom lock instead of a third-party dependency
+
+The original lock implementation pulled in a third-party `flock`-style cross-process mutex. The refactor replaced it with a **`renovating fenced file lock`** (`packages/core/src/file-lock.ts:1-532`) because:
+
+1. **No external dependency.** `node:fs/promises` is enough; the entire mechanism is a `wx`-exclusive placement of a JSON blob and a `mkdir`+`writeFile` for the eviction marker.
+2. **Renewable.** The lock file at `${path}.${name}.lock` carries `{ ownerId, expiresAt }`. A `setInterval` (unref-ed) rewrites the expiration every `max(1000, floor(ttlMs / 3))` ms (line 365-460). A contender that loses its renewal just lets the lock expire.
+3. **Eviction marker prevents revive races.** A stale lock is only claimable if the contender first stamps `${lockPath}.evicting/owner.json` with its own `ownerId`, `pid`, and `createdAt` (line 250-279). The marker carries `pid` so `isProcessAlive(pid)` reclaims abandoned markers whose contender process died; `MARKER_TTL_MS = 30_000` (line 116) is the floor for that check. The contender re-verifies the marker owner at every destructive seam (unlink, re-acquire) — a contender whose marker was hijacked between the claim and the unlink backs off without touching the winner's lock file.
+4. **Stop renewal on ownership loss.** The renewal loop re-reads the lock file on every tick. If the file is gone or carries a different `ownerId`, the lock calls `markLost()` (line 353) which clears the interval and resolves `whenLost()` so callers awaiting it can abort the in-flight merge. The renewal also stages a `${lockPath}.${ownerId}.tmp` then re-reads the lock file before issuing a `rename(2)` so an eviction that slips in between the read and the write can never overwrite a fresh owner's content (line 391-434).
+5. **Idempotent terminal release.** `release()` is safe to call twice — the second call returns early without re-clearing the timer or attempting to unlink the lock file. The pre-`unlink` re-read checks the owner; if the lock is no longer ours, the function refuses to delete and cleans up the eviction marker instead (line 480-498).
+6. **`assertOwned()` detects footguns.** The lock interceptor calls `assertOwned()` after acquisition and before the merge; if the lockfile was reclaimed by another writer, the merged write is rejected with `FileLockOwnershipError` (`packages/core/src/file-lock.ts:73-87`) instead of corrupting state. The `FencedFileLock` interface also exposes `whenLost(): Promise` and `hasLost(): boolean` (line 71-82) so callers can observe ownership loss without polling.
+
+### Atomic write
+
+`packages/core/src/atomic-write.ts:1-52` does the rename-on-POSIX dance: stage `${path}.${randomUUID()}.tmp` at mode 0o600, rename onto the target, clean up on failure. There is **no copy fallback** — a copy-then-unlink after a failed rename can mask partial writes and is explicitly forbidden in the header comment. Callers decide whether to retry, surface, or back off.
+
+### Sidebar state merge
+
+`packages/opencode/src/sidebar-state.ts` is the most stateful writer. The merge seam (`mergeMachineState`, line 600-625) is deterministic:
+
+- **Stale writes are dropped.** If `next.checkedAt < existing.checkedAt`, the existing state is returned untouched (only `activeRouting` is pruned to evict expired entries).
+- **`routingAuthoritative` is sticky-true.** Once true, any later non-authoritative write preserves the `true` flag.
+- **`activeRouting` is merged independently and pruned** to the freshest 100 entries within 24h (`ACTIVE_ROUTING_MAX_AGE_MS`, `ACTIVE_ROUTING_MAX_ENTRIES`, line 118-120).
+
+The `sidebarWriteChain` (line 506-517) is the in-process serialization: every writer appends to a single Promise chain so two concurrent `setSidebarMachineState` calls do not race through the lock acquisition. Failures never poison the chain (line 511-516).
+
+### `drainSidebarWrites` semantics
+
+`packages/opencode/src/sidebar-state.ts:524-526` returns the chain's tail. The plugin lifecycle (`packages/opencode/src/plugin/lifecycle.ts:62-115`) drains it AFTER registered producers stop and BEFORE registered consumers are torn down, so a fetch-interceptor routing upsert that resolves during shutdown lands before the host closes the terminal framebuffer (see **Lifecycle and disposal** for the two-phase producer/consumer ordering).
+
+## Quota, routing, and killswitch semantics
+
+### Quota manager
+
+`packages/core/src/quota-manager.ts:1-717` is the harness-agnostic core. It exposes a `fetchAccountQuota` callback the host plugs in; the manager tracks per-account state (consecutive failures, backoffUntil, inflight promise, cached result) keyed by stable identity (`keyOf`, line 111-115) — email preferred, hash of refresh token as fallback.
+
+| Behavior | Where |
+| --- | --- |
+| Exponential backoff | `recordFailure` (line 138-155) — `max(10min, base * 2^fails)` |
+| In-flight dedupe | `refreshAccount` (line 224-230) — second caller awaits the first one's promise |
+| Manual bypass | `RefreshAccountOptions.force` (line 65-71) — manual quota dialog always uses `force: true` |
+| Disposed mid-fetch | `controller.signal.aborted` (line 248-250) — `dispose()` cancels all controllers |
+| Concurrency cap | Per-key, not pool-wide — backoff ends the storm |
+
+The aggregated result is split into `quota` (Antigravity-headers) and `geminiCliQuota` (Gemini CLI headers) — they share the same account object but the UI exposes both because the user can pick header style.
+
+### Selection algorithm
+
+`packages/core/src/account-manager.ts:getCurrentOrNextForFamily` (line 685-868) is the central dispatcher. Strategies:
+
+- **`sticky`** (default) — keep the current account until it goes unavailable; on unavailability, advance the cursor to the next account that is enabled, not rate-limited, not over the soft-quota threshold, and not cooling down.
+- **`round-robin`** — same as sticky but advances the cursor on every selection so a session rotates across accounts.
+- **`hybrid`** — uses `selectHybridAccount` from `packages/core/src/rotation.ts:376-433`, which scores each candidate on `2*health + 5*tokenFraction + 0.1*freshness`, applies a stickiness bonus to the current account, and only switches when the new account beats the current by `SWITCH_THRESHOLD = 100` (line 361).
+
+The `pidOffsetEnabled` flag (line 805-825) lets multi-session hosts distribute load: each session's pid hashes to a starting offset so two simultaneous sessions do not pick the same account.
+
+### Soft-quota protection
+
+`packages/core/src/account-manager.ts:isOverSoftQuotaThreshold` (line 230-258) reads the cached quota remaining-fraction. If the account's cached quota is older than `softQuotaCacheTtlMs` (computed via `computeSoftQuotaCacheTtlMs`, `packages/core/src/rotation.ts:90-101`), the soft-quota check is **skipped** — fresh quota data is a precondition.
+
+The protection is wedged to the single-account case: `getEffectiveSoftQuotaThreshold` (line 494-498) forces the threshold to 100 if there is only one enabled account. A user with no alternative rotation partner must not be blocked.
+
+### Routing
+
+Two header styles: `antigravity` (Electron-style UA + `X-Goog-Api-Client` + `Client-Metadata`) and `gemini-cli` (`GeminiCLI/{}/{}/({}; {})` UA). Claude has only `antigravity`; Gemini has both. The resolver is `resolveHeaderRoutingDecision` (`packages/opencode/src/plugin/fetch-routing.ts`) and the fallback is `resolveQuotaFallbackHeaderStyle`. When the preferred style is rate-limited for the chosen account, the interceptor either switches account (if another account has the preferred style available) or flips to the alternate style.
+
+The per-call routing decision is read live from `operatorSettings.get().routing` (`packages/opencode/src/plugin/fetch-interceptor.ts:460-466`) so a `/antigravity-routing` slash command takes effect on the next dispatched call without a plugin restart.
+
+### Killswitch
+
+`packages/opencode/src/plugin/killswitch.ts:1-284` exposes `evaluateKillswitchForAccount` and `throwIfAllKilled`. The operator sets a `minimum_remaining_percent` per family/model; any account whose freshest quota falls below the threshold is excluded from selection. The killswitch **fails open on missing/stale quota** so a cold start cannot deadlock the pipeline.
+
+The evaluation is **model-aware**: when a `model` is passed in `KillswitchEvaluateOptions` (or `quotaModel` on `throwIfAllKilled`), `quotaGroupForModel` (`packages/opencode/src/plugin/killswitch.ts:69-86`) maps the model string to the single quota group it draws on — a `gemini-pro` request checks ONLY `gemini-pro`, not the max of pro+flash. Callers that omit `model` keep the family-max behavior. The fetch interceptor precomputes an `eligibleIndexes` Set once per request (`packages/opencode/src/plugin/fetch-interceptor.ts:551-579`) and re-uses it after core selection and after the quota-fallback re-selection so a long-running request cannot see a different answer than the pre-filter.
+
+## OAuth and token lifecycle
+
+### Login
+
+1. `oauth-methods.authorize(inputs)` (`packages/opencode/src/plugin/oauth-methods.ts:382-...`) — handles the CLI menu (`Add another`, `Refresh`, `Check quotas`, `Verify accounts`, `Doctor`, `Manage`, `Cancel`).
+2. `authorizeAntigravity` from core — generates a PKCE verifier, calls the authorize endpoint, returns the URL.
+3. `startOAuthListener` from `packages/opencode/src/plugin/server.ts` — opens a localhost listener on `51121`; the redirect URL is `http://localhost:51121/oauth-callback`. For WSL2 / headless / no-X environments the plugin skips the listener and prompts the user to paste the redirect URL.
+4. `exchangeAntigravity(code, state)` — verifies the state, exchanges the code, returns `{ refresh, access, expires, email, projectId }`.
+5. `persistAccountPool([result], startFresh)` — re-reads the locked storage, appends the new account, writes atomically.
+
+### Refresh
+
+`packages/opencode/src/plugin/token.ts:refreshAccessToken` is the in-flight refresh path. The interceptor calls it when `accessTokenExpired(authRecord)` (a 60s buffer before `expires`). On `invalid_grant` the account is removed and the pool is rewritten; on transient failures the account is cooled down via `markAccountCoolingDown` + `markRateLimited`.
+
+`packages/opencode/src/plugin/refresh-queue.ts:1-349` adds the proactive refresh: `createProactiveRefreshQueue` runs every `proactive_refresh_check_interval_seconds` (default 5min) and refreshes any account whose access token expires within `proactive_refresh_buffer_seconds` (default 10min). The queue re-uses the global `fetcher` and pushes the refreshed quotas into the sidebar.
+
+### Disposal
+
+`shutdownDiskSignatureCache` (`packages/opencode/src/plugin/cache.ts`) flushes the in-memory signature cache to disk before the plugin tears down so the next session can resume without re-priming the cache.
+
+### Sentinel values
+
+`SKIP_THOUGHT_SIGNATURE = 'skip_thought_signature_validator'` (`packages/core/src/constants.ts:227`) is the sentinel for thinking-block signature bypass. The plugin injects it whenever a cache-miss or session-mismatch would otherwise cause a server-side validation failure. It is an officially supported Google API feature used by `gemini-cli` and the Google .NET SDK.
+
+## Request transformation and streaming response flow
+
+### Outbound transform
+
+`packages/opencode/src/plugin/request.ts:1-2854` is the upstream of `prepareAntigravityRequest`. The pipeline:
+
+1. **Sanitize** — strip Claude thinking blocks (`packages/core/src/transform/claude.ts`), normalize cross-model payloads (`packages/core/src/transform/cross-model-sanitizer.ts`), apply `applyGeminiTransforms` / `applyClaudeTransforms` (`packages/core/src/transform/`) depending on the resolved model family.
+2. **Resolve** — `resolveModelWithTier` from `packages/core/src/transform/model-resolver.ts` maps the user-facing tag (`claude-sonnet-4-6`) to the Antigravity wire model and the header style.
+3. **Inject** — `buildAgyRequestMetadata` from `packages/core/src/agy-request-metadata.ts:231-259` produces the `labels` block (`last_step_index`, `model_enum`, `trajectory_id`, `used_claude`, `used_claude_conservative`, `used_non_gemini_model`) and the `requestId` (`agent////`).
+4. **Stabilize prefix** — `orderAgyRequestPayloadInPlace` (line 190-210) reorders the payload so the field order is `contents → systemInstruction → tools → toolConfig → labels → generationConfig → sessionId`. This is the prefix the prompt cache keys on; a stable prefix is what gives Antigravity its cache hit rate.
+5. **Harden** — `CLAUDE_TOOL_SYSTEM_INSTRUCTION` (`packages/core/src/constants.ts:191-203`) is injected when tools are present to reduce hallucinated parameter names.
+
+### Inbound transform
+
+`packages/opencode/src/plugin/core/streaming/transformer.ts` is the streaming reverse transform. It:
+
+- Strips Antigravity's `response.envelope` metadata before the caller sees the body.
+- Splices `thoughtSignature` values back into thinking blocks so the caller can carry them on the next request.
+- Aggregates `usageMetadata` events into a single final emission.
+- Caches signatures to the disk-backed signature store (`packages/opencode/src/plugin/cache/signature-cache.ts`) keyed by `(model, sessionId, lastStepIndex)`.
+
+The transformer produces a `ReadableStream` so the host's `await response.text()` works without buffering.
+
+### Timeouts
+
+The stack has three distinct timeout systems, **deliberately separated**:
+
+| Timeout | Default | Scope | Where |
+| --- | --- | --- | --- |
+| Response header timeout | **15s** | `fetchWithActiveTimeout` for any caller using `globalThis.fetch` | `packages/core/src/fetch-timeout.ts:13-54` |
+| AGY response header timeout | **180s** | TLS connect + response headers via raw socket | `packages/core/src/agy-transport.ts:12` |
+| AGY idle timeout | **180s** | Stalled response body — kills the socket if no bytes for 180s | `packages/core/src/agy-transport.ts:16` |
+
+The 15s `ACTIVE_FETCH_TIMEOUT_MS` is stream-safe: it only aborts the request signal until the underlying `fetchImpl` resolves, then removes the timeout listener so the returned body can be streamed past the deadline (`packages/core/src/fetch-timeout.ts:28-54`). The 180s `DEFAULT_AGY_RESPONSE_HEADER_TIMEOUT_MS` covers the Antigravity `agy` CLI's own connect behavior, and the 180s `DEFAULT_AGY_IDLE_TIMEOUT_MS` is a watchdog against a hung body — it resets on every received chunk (line 505-525 in `agy-transport.ts`). The two are independent: a slow but streaming response triggers neither.
+
+```mermaid
+gantt
+ title Active timeout vs streaming idle timeout
+ dateFormat X
+ axisFormat %s
+ section 15s active-fetch
+ Abort if headers not received by 15s :crit, 0, 15s
+ section 180s agy header
+ Abort if no headers after 180s :crit, 0, 180s
+ section 180s agy idle
+ Reset on every body byte :active, 0, 180s
+ Silent between chunks : 0, 180s
+```
+
+### Endpoint fallback
+
+`ANTIGRAVITY_ENDPOINT_FALLBACKS = [DAILY, PROD]` (`packages/core/src/constants.ts:46-49`). The interceptor walks them in order under the `MAX_TOTAL_CAPACITY_RETRIES` cap (`packages/opencode/src/plugin/fetch-routing.ts`); for `gemini-cli` header style only `PROD` is used (line 1199-1207 of `fetch-interceptor.ts`).
+
+### Retry-after and rate-limit bookkeeping
+
+`retryAfterMsFromResponse` (`packages/opencode/src/plugin/fetch-interceptor.ts:117-138`) reads `retry-after-ms` first, then `retry-after` (seconds), then a 60s default. The result feeds `markRateLimitedWithReason` which combines the header with `calculateBackoffMs` from `packages/core/src/rotation.ts:59-88`:
+
+- `QUOTA_EXHAUSTED` — 1m → 5m → 30m → 2h scale-up.
+- `RATE_LIMIT_EXCEEDED` — 30s.
+- `MODEL_CAPACITY_EXHAUSTED` — 45s ± 15s jitter.
+- `SERVER_ERROR` — 20s.
+- `UNKNOWN` — 60s.
+
+The failure tally is per-account and resets after a 1h TTL (`markRateLimitedWithReason`, `packages/core/src/account-manager.ts:1058-1091`).
+
+## Sidebar snapshot and RPC protocols
+
+### Snapshot file
+
+`packages/opencode/src/sidebar-state.ts` documents the contract in its module header (line 1-38). The on-disk shape is `SidebarStateV1`:
+
+```ts
+{
+ version: 1,
+ checkedAt: number,
+ accounts: SidebarAccountState[], // redacted — no tokens, no project IDs
+ activeRouting: Record,
+ routingAuthoritative: boolean,
+ quotaBackoffUntil?: number,
+ lastError?: string
+}
+```
+
+The path is `getSidebarStateFile()` (line 193-198): `ANTIGRAVITY_AUTH_SIDEBAR_STATE_FILE` wins, otherwise `/cortexkit/antigravity-auth/sidebar-state.json`. The file is mode 0o600 with a 0o700 parent dir.
+
+### RPC bearer/discovery
+
+`packages/opencode/src/rpc/rpc-server.ts:62, 87` generates a 32-byte hex token and binds to `127.0.0.1:0`. The (pid, port, token) tuple is written to `/port-.json` via `packages/opencode/src/rpc/port-file.ts:27-52`. The TUI discovers the server with `discoverPortFile(dir, process.pid)` — file paths are pid-scoped so a crashed OpenCode cannot block a new one. The token is regenerated every plugin boot; the host process owns the only handle.
+
+### Notification queue
+
+`packages/opencode/src/rpc/notifications.ts:1-62` is a bounded queue (max 100 entries). The host-side push is `pushNotification(payload, sessionId?)` and the TUI-side drain is `drainNotifications(lastReceivedId, sessionId?)`. The 5-second `CONNECTION_TTL_MS` is the signal `isTuiConnected(sessionId?)` uses to suppress commands when the TUI is not running.
+
+The host's `apply` callback (line 233-260 of `plugin/index.ts`) calls `applyCommand(request, { sessionID, settings, onApplied: createSidebarRefresher(...) })` so every `/antigravity-*` mutation bumps the sidebar's `checkedAt` for the next TUI poll.
+
+## Lifecycle and disposal
+
+`createPluginLifecycle` (`packages/opencode/src/plugin/lifecycle.ts:1-125`) is the disposal root. Every registered disposable declares a **`LifecyclePhase`** of `'producer'` or `'consumer'` (line 13-19). The phase determines when the lifecycle tears it down relative to the sidebar drain:
+
+- **Producer** — runs on the same side of the sidebar drain as the fetch interceptor. The lifecycle disposes producers BEFORE the drain so a producer racing with shutdown cannot enqueue a write that lands after the drain asserts the queue is empty. The auth loader's fetch-interceptor runtime is registered as a producer (`packages/opencode/src/plugin/auth-loader.ts:83-93`).
+- **Consumer** — runs AFTER the sidebar drain. Consumers are the sinks (RPC server, file logger, auto-update checker) that the TUI / host talk to and that must stay alive until every queued write has landed.
+
+The order is significant and the code documents it (line 99-118):
+
+1. `disposeAccountRuntime()` — tear down the refresh queue, then the account manager.
+2. `shutdownDiskSignatureCache()` — flush the in-memory signature cache to disk.
+3. `sessionRegistry.clear()` — drop the per-session `AgyRequestSessionStore`.
+4. `clearFetchState()` — null the cached `getAuth` binding so a late-arriving `loader` call sees a clean slate.
+5. **Producers** — dispose every registered producer in registration order. After this step no new sidebar writes can be enqueued.
+6. `drainSidebarWrites()` — wait for every queued sidebar write to land. The file logger + RPC server are still alive so the TUI's last frame can observe a fully landed snapshot.
+7. **Consumers** — dispose every registered consumer in registration order (RPC server, file logger, auto-update checker, etc.).
+
+The default registration order is established by `packages/opencode/src/plugin/index.ts:80-263`; each registered disposable is free to register its own in `dispose()` using either `register(disposable, 'producer')` or the default consumer phase.
+
+The server emits a stop on the RPC server that closes `closeAllConnections()` (`packages/opencode/src/rpc/rpc-server.ts:281-292`) and unlinks the port file (line 116-119). The TUI detects the server has gone away when `discoverPortFile` returns `null` and surfaces the "Awaiting Antigravity state" empty state.
+
+`createAntigravityPlugin` returns the `dispose` of the lifecycle as the plugin's `dispose` so the host's plugin teardown drives ours.
+
+## Error handling and recovery strategy
+
+The plugin follows the **fail-open / heal-loud** doctrine. Errors are always converted to one of:
+
+- A toast on the host TUI (with debouncing via `RetryState.shouldShowRateLimitToast`).
+- A cooldown on the offending account (so the next request rotates).
+- A redacted `lastError` field in the sidebar snapshot.
+
+Specific recovery paths:
+
+- **Storage corruption** — `loadConfigFile` in `packages/opencode/src/plugin/config/loader.ts:64-95` swallows a bad JSON or schema mismatch and falls back to the default config. The plugin never crashes on a malformed user config.
+- **Auth drift** — `detectAuthStorageDrift` (`packages/opencode/src/plugin/auth-drift.ts`) compares the host's auth against the stored account pool and offers a `restorable` path that re-issues the host's auth from the stored account.
+- **Refresh token revoked** — `AntigravityTokenRefreshError` with `code: 'invalid_grant'` removes the account from the pool and persists the removal with `saveToDiskReplace` (`packages/opencode/src/plugin/fetch-interceptor.ts:875-880`).
+- **Project context failure** — `ensureProjectContext` failures mark the account as cooling down with reason `project-error` (line 947-961).
+- **Capacity exhaustion (529/503)** — the inner-account retry loop probes the next endpoint in the fallback list, capped at `MAX_TOTAL_CAPACITY_RETRIES`. Beyond the cap, the request rotates to the next account.
+- **All accounts over soft-quota** — `getMinWaitTimeForSoftQuota` returns the soonest reset; if the wait exceeds `max_rate_limit_wait_seconds` (default 300s) the interceptor returns a synthetic 200 envelope describing the wait instead of blocking the host.
+- **All accounts rate-limited, no quota fallback** — synthetic 200 with the same pattern as the soft-quota case.
+- **Killswitch trips** — `throwIfAllKilled` raises `AntigravityKillswitchError` (`packages/opencode/src/plugin/errors.ts`), intercepted at `packages/opencode/src/plugin/fetch-interceptor.ts:600-612` and returned as a synthetic error response.
+- **Cross-process lock contention** — `SidebarStateLockContentionError` after 2s of retries (`packages/opencode/src/sidebar-state.ts:122-126, 549-555`); the writer swallows it and the next attempt re-tries the merge.
+- **Process cancellation** — every long-running call honors `AbortSignal`; `connectTlsWithAbort` (`packages/core/src/agy-transport.ts:626-651`) races the TLS connect against the abort.
+
+The fundamental rule: **the user never sees a silent failure**. Either they see a toast, a synthetic error response, or the next account's attempt. The host's error reporting layer is never directly exposed to auth/quota failures.
+
+## Security model
+
+### Trust boundaries
+
+1. **Host → plugin.** The host passes an opaque auth record (`getAuth: () => Promise`). The plugin never sees the host's API key store, only the refresh token it has itself persisted.
+2. **Plugin → Antigravity.** Every request carries `Authorization: Bearer ` and the per-account fingerprint `User-Agent`. The token is short-lived; refresh is the proactive queue's job.
+3. **Plugin → TUI.** The TUI receives a **redacted snapshot**. `redactAccountForSidebar` (`packages/opencode/src/sidebar-state.ts:432-477`) zeroes the refresh token, access token, project ID, fingerprint, and every other credential-shaped field. The TUI only sees `id`, `label`, `enabled`, `health`, `current`, `cooldownUntil`, and the redacted `quota` block.
+4. **TUI → RPC server.** The TUI talks to the server over `127.0.0.1` only (the server refuses to bind elsewhere per `LOOPBACK_HOST = '127.0.0.1'`, `packages/opencode/src/rpc/rpc-server.ts:19`). The bearer token is a 32-byte hex (`randomBytes(32)` line 62) and is regenerated every plugin boot. Discoverability is through a per-pid port file rather than a fixed port, so a wild guess on the port side is needed as well.
+5. **Plugin → file system.** Sensitive files (account storage, signature cache, sidebar state, port file, TUI log) are written mode 0o600 with the parent directory at 0o700. POSIX rename replaces the inode so the new file inherits the staged mode bits. Windows is best-effort — the policy is enforced at the application layer because Windows does not honor POSIX mode bits.
+
+### Redaction rules
+
+The snapshot is the only place the TUI meets the live pool. The redaction is a structural contract, not an afterthought:
+
+- `SidebarQuotaKey` is a fixed 3-tuple (`claude | gemini-pro | gemini-flash`); any other key on the input is dropped.
+- `SidebarRoutingEntry` carries `accountId`, `modelFamily`, `headerStyle`, `updatedAt` — no token, no project ID, no fingerprint.
+- `normalizeAccount` rejects any account missing `id` or `label` (line 281-282) so a malformed disk snapshot cannot surface an empty box in the UI.
+- The TUI's compiled tree imports only `sidebar-state.ts`, the `rpc/` slim client, and the local `tui/` helpers. The TypeScript compiler would let it import the entire opencode package; the module-graph review is the only enforcement.
+
+### Token timing
+
+`accessTokenExpired` carries a 60s buffer (`ACCESS_TOKEN_EXPIRY_BUFFER_MS`, `packages/core/src/auth.ts:3`). The proactive refresh queue refreshes inside `proactive_refresh_buffer_seconds` (default 10min) so the user request never carries an expired token.
+
+## Cross-cutting concerns
+
+### Logging
+
+`createLogger('module-name')` (`packages/core/src/logger.ts`) produces a structured logger that maps to the OpenCode TUI's `app.log` channel or a file sink. Every module consumes it (`createLogger('plugin')`, `createLogger('auth-loader')`, …). The TUI's `tui/file-logger.ts` is the analogue for the sidecar — it writes to a rotating file (1 MB, 200 lines tail) and never touches stdout/stderr.
+
+### Caching
+
+| Cache | Location | Eviction |
+| --- | --- | --- |
+| Quota cache | `account.cachedQuota` (in-memory) + `account-storage.ts` (disk) | Per-account TTL via `softQuotaCacheTtlMs` |
+| Signature cache | `packages/opencode/src/plugin/cache/signature-cache.ts` (disk) + `initDiskSignatureCache` (memory) | TTL keyed by `(model, sessionId, lastStepIndex)` |
+| AgyRequestSessionStore | `packages/core/src/agy-request-metadata.ts:95-184` | 24h TTL or 256 entries, whichever lands first |
+| Sidebar routing map | `packages/opencode/src/sidebar-state.ts:118-120` | 24h max age, max 100 entries |
+| Account manager session state | `packages/core/src/account-manager.ts:535-561` | 24h TTL or 256 entries |
+
+### Randomness
+
+The two delays that carry jitter (`addJitter`, `randomDelay`) are at `packages/core/src/rotation.ts:290-313`. The interval retry backoff in `acquireFencedFileLock` (line 217-220) and the `firstRetryDelayMs` in the fetch interceptor (`packages/opencode/src/plugin/fetch-interceptor.ts:78`) are also jittered to break predictable patterns.
+
+### Configuration knobs
+
+`packages/opencode/src/plugin/config/schema.ts` (Zod 4 schema) defines every user-configurable knob. The plugin reads `config.health_score`, `config.token_bucket`, `config.signature_cache`, `config.proactive_token_refresh`, `config.account_selection_strategy`, `config.max_account_switches`, `config.quota_refresh_interval_minutes`, `config.soft_quota_threshold_percent`, `config.auto_update`, `config.quiet_mode`, `config.toast_scope`, `config.pid_offset_enabled`, `config.cli_first`, `config.quota_style_fallback`, `config.cache_warmup_on_switch`, `config.thinking_warmup`, `config.request_jitter_max_ms`, `config.verify_signature_cache`, `config.max_rate_limit_wait_seconds`, `config.session_recovery`, `config.keep_thinking`, `config.proactive_refresh_*`, `config.switch_on_first_rate_limit`, `config.switch_account_delay_ms`, `config.proactive_rotation_threshold_percent`, `config.claude_tool_hardening`, `config.claude_prompt_auto_caching`, and `config.killswitch_*`. The operator settings controller layers runtime overrides on top of the file config so `/antigravity-*` slash commands mutate the live values without rebooting the plugin.
+
+### OpenCode auto-update
+
+`packages/opencode/src/hooks/auto-update-checker/index.ts:1-196` lights up on the host's `session.created` event (skipped for child sessions — `props.info.parentID` check). It uses the XDG cache to memoize the resolved version; on a sessionless first run it does a network probe against the package's npm registry. In local dev mode it surfaces the local version as a toast and skips the network probe.
+
+### Diagnostics
+
+`packages/opencode/src/plugin/debug.ts` provides `startAntigravityDebugRequest`, `logAntigravityDebugResponse`, `logRateLimitEvent`, `logRateLimitSnapshot`, `logResponseBody`, `logAccountContext`. The OpenCode adapter wires them into the fetch interceptor so a debug-enabled user can trace every request, every retry, every quota update.
+
+## Testing and release gates
+
+### Unit tests
+
+`bun test` runs the colocated `*.test.ts` files. The plugin and core packages have `bun test --isolate ./src` (the `--isolate` flag prevents Bun's test runner from sharing module state across files). The tests cover:
+
+- `core/`: `account-manager.test.ts`, `agy-transport.test.ts`, `agy-request-metadata.test.ts`, `atomic-write.test.ts`, `file-lock.test.ts`, `fetch-timeout.test.ts`, `fingerprint.test.ts`, `model-registry.test.ts`, `project.test.ts`, `quota-manager.test.ts`, `rotation.test.ts`, `account-storage.test.ts`, `transform/cross-model-integration.test.ts`, `transform/claude.test.ts`, `transform/cross-model-sanitizer.test.ts`, `transform/gemini.test.ts`, `transform/model-resolver.test.ts`, `antigravity/oauth.test.ts`.
+- `opencode/`: `plugin.entry.test.ts`, `cli.test.ts`, `constants.test.ts`, plus per-module tests (`account-access`, `account-ineligibility`, `accounts`, `auth-drift`, `auth-loader`, `auth`, `auth-doctor`, `behavior-snapshot`, `cache`, `catalog`, `commands`, `config/{schema,updater,models,writer}`, `core/streaming/transformer`, `debug`, `event-handler`, `errors`, `fetch-interceptor`, `fetch/{retry-state,warmup}`, `fetch-routing`, `fingerprint`, `gemini-dump`, `google-search-tool`, `host-api-compatibility`, `image-saver`, `killswitch`, `lifecycle`, `logger`, `logging-utils`, `model-registry`, `model-specific-quota`, `oauth-methods`, `operator-settings`, `persist-account-pool`, `quota`, `quota-fallback`, `recovery`, `refresh-queue`, `request`, `request-helpers`, `rotation`, `search`, `session-context`, `storage`, `stores/signature-store`, `thinking-recovery`, `token`, `ui/{ansi,auth-menu,auth-menu.actions,model-status,quota-status}`, `version`, `hooks/auto-update-checker/{checker,index}`, `src/rpc/{notifications,port-file,rpc-server,rpc-client}`, `sidebar-state`, `tui.test.tsx`, `tui/command-dialogs.test.tsx`, `tui/file-logger.test.ts`.
+- `pi/`: `convert.test.ts`, `credential-cache.test.ts`, `index.test.ts`, `stream.test.ts`.
+
+### E2E
+
+`packages/e2e-tests/src/` is a private workspace with four flow tests:
+
+- `cli-flow.e2e.test.ts` — exercises the `antigravity-auth` CLI (login, list, quota).
+- `plugin-flow.e2e.test.ts` — drives the full fetch interceptor (quota refresh, generateContent, streaming SSE, `tokenExpiry401`, `rateLimit429`, `capacity503`, `delayedHeaders`).
+- `rpc-tui-flow.e2e.test.ts` — drives the TUI ↔ RPC bridge.
+- `fetch-guard.test.ts` — pins the loopback-only `globalThis.fetch` guard installed by the preload so a stray non-loopback URL throws `LiveNetworkDeniedError` instead of leaking to the live network.
+
+The harness (`packages/e2e-tests/src/harness.ts:1-314`) is the spine:
+
+- `beforeEach` in `packages/e2e-tests/src/setup.ts:86-118` allocates a `mkdtemp` root with HOME/XDG overrides and installs a loopback-only `globalThis.fetch` guard (`packages/e2e-tests/src/setup.ts:51-77`) — every test runs in its own filesystem sandbox with a hard network boundary.
+- `createE2eHarness` (line 59-163) starts a mock server on `127.0.0.1:0` (`packages/e2e-tests/src/mock-antigravity-server.ts:209-273`), installs a fetch router that rewrites every outbound URL to the mock, and writes a `quick_mode` config file disabling background quota refresh + auto-update.
+- **No live network.** The preload wraps `globalThis.fetch` with a `LOOPBACK_HOSTS = { '127.0.0.1', '::1', '[::1]', 'localhost' }` allowlist (line 43); any other hostname throws `LiveNetworkDeniedError` (line 34-41). The remaining loopback rewrite is enforced by `dependencies.agyTransport` and `dependencies.fetchImpl` overrides plus the `REWRITE_HOSTS` allow-list in `packages/e2e-tests/src/harness.ts:221-230`. A regression that re-introduces a live URL is caught by the fetch guard's deny record.
+- `afterEach` and `afterAll` (`packages/e2e-tests/src/setup.ts:120-153`) restore the original fetch and reap the per-test temp root.
+
+The e2e `bun test` runs from the root via `bun run test:e2e` (`package.json:12`).
+
+### Release gates
+
+The root `package.json` exposes the full gate surface:
+
+```jsonc
+{
+ "build": "bun run --cwd packages/core build && bun run --cwd packages/opencode build && bun run --cwd packages/pi build",
+ "typecheck": "bun run --cwd packages/core build && bun run --cwd packages/opencode typecheck && bun run --cwd packages/pi typecheck && tsc -p tsconfig.scripts.json",
+ "test": "bun run --cwd packages/core build && bun test --isolate",
+ "test:e2e": "bun test --isolate ./packages/e2e-tests/src/plugin-flow.e2e.test.ts ./packages/e2e-tests/src/cli-flow.e2e.test.ts ./packages/e2e-tests/src/rpc-tui-flow.e2e.test.ts",
+ "format": "biome format --write .",
+ "format:check": "biome format .",
+ "lint": "biome lint ."
+}
+```
+
+The `format:check` and `lint` paths run Biome 2.5.3 (`package.json:26`) with the config at `biome.json`. The build pipeline emits `dist/` for each package; the opencode build also runs the TUI compiler (`packages/opencode/package.json:64-65`).
+
+## Extension points and invariants
+
+### Adding a new model
+
+1. Add the model identity to the registry in `packages/core/src/model-registry.ts`.
+2. Add a `getQuotaGroupForModel` mapping so the quota manager classifies it correctly.
+3. If the model needs a non-default header style, add a `getAgyModelEnum` mapping in `packages/core/src/agy-request-metadata.ts:19-32`.
+4. Add the model to `applyClaudeTransforms` / `applyGeminiTransforms` if the transform needs to know about it.
+5. If the model is exposed via the Pi provider, add it to `getPublicModelDefinitions()`.
+
+The plugin auto-discovers the model from the registry on every `applyAntigravityProviderCatalog` call.
+
+### Adding a new slash command
+
+1. Add the command to `CommandModalName` in `packages/opencode/src/rpc/protocol.ts:1-8`.
+2. Add the command to `COMMANDS` in `packages/opencode/src/rpc/rpc-server.ts:26-33` so the server rejects unknown commands.
+3. Add the dialog flow collector branch in `packages/opencode/src/tui/command-dialogs.tsx`.
+4. Add the apply handler in `packages/opencode/src/plugin/commands.ts:applyCommand`.
+
+The TUI's notification poll will surface the new command from the next push onward.
+
+### Adding a new host
+
+The `PluginDependencyOverrides` seam (`packages/opencode/src/plugin/dependencies.ts:1-204`) is the integration point. A new host instantiates `createAntigravityPlugin(providerId, { dependencies: { ... } })` and ships a `PluginResult`. The harnesses for the e2e workspace (`packages/e2e-tests/src/harness.ts`) are the blueprint for the production-grade test rig.
+
+### Hard invariants
+
+These invariants are enforced by tests and should not be relaxed:
+
+1. **The TUI never imports `accounts.ts`, `storage.ts`, or any OAuth module.** The compile-time check is the diff review at `packages/opencode/src/tui.tsx:1-22`.
+2. **No live network during e2e.** The mock-server's request recorder and the `REWRITE_HOSTS` allow-list are the enforcement points.
+3. **Origin/refresh tokens never appear in the sidebar snapshot.** `redactAccountForSidebar` is the single pipeline; the snapshot's `lastError` is bounded to short strings.
+4. **The 15s and 180s timeouts are distinct.** A 15s header timeout is a hard fail; a 180s idle timeout is a stalled-body watchdog that resets on every chunk.
+5. **Fenced file locks are acquired before any state read-modify-write.** The lock is `path + name` scoped; the eviction marker protocol prevents revive races.
+6. **Fetch interceptor disposes wipe `configuredState`.** `disposed` flag at `packages/opencode/src/plugin/fetch-interceptor.ts:263` short-circuits any post-dispose call to `upstreamFetch` so a torn-down plugin never silently swallows a request.
+7. **Final snapshot writes land before disposal.** `drainSidebarWrites` is the seam; the lifecycle awaits it before tearing down the RPC server and file logger.
+8. **The plugin never reboots a host fetch.** The interceptor captures the host's `fetchImpl` at factory time (`packages/opencode/src/plugin/fetch-interceptor.ts:270-277`) so the plugin's own fetch call never recurses through itself.
+9. **The RPC server binds to loopback only.** `LOOPBACK_HOST = '127.0.0.1'` (`packages/opencode/src/rpc/rpc-server.ts:19`) is the literal — no env override, no relative binding.
+10. **The Pi extension's package-name contract is `pi.extensions`.** `packages/pi/package.json:34-38` is the source-of-truth; the extension's name (`@cortexkit/pi-antigravity-auth`) is what the user's Pi config references.
+
+The architecture is intentionally layered so the next harness (a CLI, a VS Code plugin, a Web extension) can plug in at the `core` boundary or the `opencode` boundary depending on whether it has its own fetch primitive.
diff --git a/README.md b/README.md
index 22b8537..7874d03 100644
--- a/README.md
+++ b/README.md
@@ -1,64 +1,428 @@
# Antigravity Auth
-Google Antigravity OAuth for coding agents. Authenticate with your Google
-account and access **Gemini 3.6**, **Claude 4.6**, and **GPT-OSS 120B** models through
-Antigravity's endpoints.
+Google Antigravity OAuth for coding agents. Authenticate with your Google account and access Antigravity quota for Gemini, Claude, and GPT-OSS models from OpenCode, the Pi coding agent, or the standalone CLI.
-This is a monorepo publishing three packages:
+This monorepo ships three packages:
-| Package | Description |
-| --- | --- |
-| [`@cortexkit/opencode-antigravity-auth`](packages/opencode) | OpenCode plugin — intercepts `fetch()` and transforms requests to the Antigravity format. Multi-account rotation, quota handling, session recovery. |
-| [`@cortexkit/pi-antigravity-auth`](packages/pi) | pi extension — registers a custom provider with OAuth login and a Gemini streaming implementation. |
-| [`@cortexkit/antigravity-auth-core`](packages/core) | Harness-agnostic core: OAuth, raw HTTP/1.1 transport, device fingerprint, request transforms, model registry, managed-project resolution. Shared by both harnesses. |
+| Package | Host | Role |
+| --- | --- | --- |
+| [`@cortexkit/opencode-antigravity-auth`](packages/opencode) | OpenCode 1.x server | Intercepts `fetch()`, runs the account pool + quota manager, drives slash commands, and exposes a TUI sidebar through a loopback RPC. |
+| [`@cortexkit/pi-antigravity-auth`](packages/pi) | Pi coding agent | Registers a custom provider with OAuth login + Gemini streaming. |
+| [`@cortexkit/antigravity-auth-core`](packages/core) | Any harness | Harness-agnostic core: OAuth PKCE, raw HTTP/1.1 transport, device fingerprint, request transforms, account pool, quota manager, durable storage. Both host packages depend on it. |
+
+## Risk and terms-of-service warning
+
+> **CAUTION — read before installing.**
+>
+> Using this software (and any proxy for Antigravity) violates Google's Terms of Service. Google account holders have reported **suspensions, bans, and shadow-bans** (restricted access without notice). The tool is **not endorsed by Google**, and every account is operated by the account holder. You assume all legal, financial, and technical risk. Operators and contributors are not liable for outcomes.
+
+The shipped headers, project IDs, and OAuth client identifiers match the publicly observable behavior of the upstream Antigravity CLI; they are not credentials and may be inspected in this repository. **OAuth credentials (refresh tokens, access tokens, browser cookies), bearer tokens, and request/response dumps never appear in this README or the project tree** — they live exclusively in `antigravity-accounts.json` on the operator's machine, the `~/.cache/opencode/antigravity-accounts.json` fallback, or the developer-controlled debug log directory. Do not paste any of them into issues or chat.
+
+## Packages and supported hosts
+
+| Host | Package version | Host requirement | Install path |
+| --- | --- | --- | --- |
+| OpenCode (server + TUI) | `2.0.0` | `@opencode-ai/plugin >=1.17.13 <2` · `@opentui/core`, `@opentui/keymap`, `@opentui/solid` all `^0.4.5` | `opencode plugin @cortexkit/opencode-antigravity-auth@latest` (writes the server entry to `opencode.json` and the TUI entry to `tui.json`) |
+| Pi coding agent | `2.0.0` | `@earendil-works/pi-ai`, `@earendil-works/pi-coding-agent`, `@earendil-works/pi-tui` (peer) | `pi install npm:@cortexkit/pi-antigravity-auth` then `/login google-antigravity` |
+| Standalone CLI | shipped inside the OpenCode package as the `antigravity-auth` bin | Node 20+ | `npx @cortexkit/opencode-antigravity-auth` |
+| Node libraries (harness builders) | `2.0.0` core | Node 20+ | `npm install @cortexkit/antigravity-auth-core` |
+
+The OpenCode package exposes two `exports` subpaths the host installer reads:
+
+- `exports["."]` — the bundled `dist/index.js` server root; `opencode plugin` writes this entry into `opencode.json`'s `plugin` array.
+- `exports["./tui"]` — `src/tui/entry.mjs`, the OpenTUI sidebar loader; `opencode plugin` writes this entry into `tui.json` so the TUI process picks it up on the next host start. The precompiled JSX tree ships under `dist/src/tui-compiled/` and is regenerated by `bun run build:tui`; the host's loader also accepts the raw `src/tui.tsx` source path for development installs.
+
+The host enforces the minimum OpenCode version through the package's `engines.opencode` field (`>=1.17.13 <2`); `opencode plugin` refuses to install a plugin that asks for an unsupported range. Both core and opencode packages are dual-target: the OpenCode package bundles a CommonJS-free ESM artifact via `esbuild`, while the core package ships `tsc`-emitted ESM. Runtime requirements match the `engines.node` field (`>=20`).
+
+## Installation matrix
+
+### OpenCode (npm — the only end-user install path)
+
+The supported installer writes both registrations in one step:
+
+```bash
+opencode plugin @cortexkit/opencode-antigravity-auth@latest
+```
+
+What this does:
-> [!CAUTION]
-> Using this software (and any proxy for Antigravity) violates Google's Terms of
-> Service. Accounts may be suspended or banned. This is an unofficial tool not
-> endorsed by Google; you assume all risks.
+- Reads the package's `exports["."]` (the server root) and writes it into `~/.config/opencode/opencode.json` under the `plugin` array — that is the `auth.loader` and `fetch` interceptor the host calls on every model dispatch.
+- Reads the package's `exports["./tui"]` (the OpenTUI sidebar loader) and writes it into `~/.config/opencode/tui.json` so the TUI process picks the sidebar up on the next host start.
+- Refuses to install when the host's OpenCode version falls outside the package's `engines.opencode` range (`>=1.17.13 <2`).
-## Installation
+#### Manual config (hand-edited configs)
-**OpenCode** — add to `~/.config/opencode/opencode.json`:
+If you cannot use `opencode plugin`, write both files by hand. The server entry lives in `opencode.json`:
-```json
+```jsonc
+// ~/.config/opencode/opencode.json
{
"plugin": ["@cortexkit/opencode-antigravity-auth@latest"]
}
```
-See the [OpenCode package README](packages/opencode/README.md) for model
-configuration, login, and troubleshooting.
+The TUI entry lives in a separate `tui.json` file under the same config dir:
+
+```jsonc
+// ~/.config/opencode/tui.json
+{
+ "plugin": ["@cortexkit/opencode-antigravity-auth"]
+}
+```
+
+The two files are independent — the host reads the server registration from `opencode.json` and the TUI registration from `tui.json`. Dropping one side disables that half of the plugin without touching the other.
-**pi** — install the extension package:
+### OpenCode from this checkout (contributors)
+
+Link the package into your local OpenCode config so the working-tree version is used:
+
+```bash
+bun install # at repo root
+bun run build # build core, opencode, pi in order
+bun run --cwd packages/opencode smoke:tui # smoke-test the bundled TUI
+```
+
+Then point `OPENCODE_CONFIG_DIR` at a project that has the development plugin entry, or copy `dist/` and `dist/src/tui-compiled/` into a directory OpenCode watches.
+
+### Pi
```bash
pi install npm:@cortexkit/pi-antigravity-auth
+pi # interactive
+/login google-antigravity # OAuth
```
-Then `/login google-antigravity`. See the [pi package README](packages/pi/README.md).
+Pi's extension loader discovers the package's `pi.extensions` field and registers the provider automatically; no manual wiring required.
+
+### Standalone CLI
-## Development
+The `antigravity-auth` bin lives inside the OpenCode package; no separate install is needed:
```bash
-npm install # install workspace dependencies
-npm run build # build core, then opencode + pi
-npm run typecheck # typecheck all packages
-npm test # run all package test suites
+npx -y @cortexkit/opencode-antigravity-auth login # OAuth flow, no plugin
+npx -y @cortexkit/opencode-antigravity-auth list # print accounts
+npx -y @cortexkit/opencode-antigravity-auth quota # live quota snapshot
+npx -y @cortexkit/opencode-antigravity-auth quota --refresh --json
```
-## Releasing
+The CLI writes to the same `antigravity-accounts.json` the OpenCode plugin reads, so a `login` from the standalone bin is visible to the plugin immediately on the next restart.
+
+## First login
+
+1. Install the package and start OpenCode (or the standalone CLI).
+2. For OpenCode: open the auth menu and choose **Antigravity (Google OAuth)**, or run `/antigravity-account add`.
+3. For the CLI: run `antigravity-auth login`. `--project ` pins a Google Cloud project before the flow; `--no-browser` prints the URL instead of opening it (useful in headless / SSH sessions — see [Troubleshooting](#troubleshooting)).
+4. Approve the consent screen. The browser redirects to `http://localhost:51121/oauth-callback`; the local listener completes the PKCE exchange, persists `refreshToken` / `accessToken` / `email` / optional `projectId` into `~/.config/opencode/antigravity-accounts.json` (mode `0600`).
+5. Verify with `antigravity-auth quota`.
+
+A fresh install may auto-fetch a project ID from the Cloud Code Assist API. If the lookup fails the plugin uses the hardcoded fallback project (`rising-fact-p41fc`); see [403 Permission Denied](https://github.com/cortexkit/antigravity-auth/issues) for manually setting a project ID.
+
+## Models and routing
+
+`packages/core/src/model-registry.ts` is the canonical source of truth. The OpenCode package re-exports it as `getAntigravityOpencodeModelIds()` (consumed by `applyAntigravityProviderCatalog`).
+
+| Family | Header style | Quota group | Routing |
+| --- | --- | --- | --- |
+| Claude (Opus / Sonnet thinking) | `antigravity` | `claude` | Antigravity only |
+| Gemini 3.x / 2.5 (Pro / Flash / Image) | `antigravity` then `gemini-cli` | `gemini-pro`, `gemini-flash` | Antigravity first; `cli_first: true` reverses it |
+| GPT-OSS 120B | `antigravity` | `gpt-oss` | Antigravity only |
+
+Header style controls the `User-Agent`, `X-Goog-Api-Client`, and `Client-Metadata` triplets that the request transform attaches. The interceptor picks style per request:
+
+- **Antigravity-first (default):** Gemini requests first try the Antigravity header set; on `429 RESOURCE_EXHAUSTED` they fall back to the Gemini CLI header set.
+- **CLI-first (`cli_first: true`):** Gemini requests first try the Gemini CLI header set; on `429` they fall back to Antigravity.
+- **Style-fallback (`quota_style_fallback: true`):** Re-sends the SAME request through the other header set when the active pool is rate-limited, consuming tokens from BOTH pools (default off to prevent double-spend across pools). The field is mutable from the `/antigravity-routing` dialog at runtime — toggling it does not require a restart.
+- **Claude always uses Antigravity** regardless of the toggles.
+
+When all accounts for a given family cool down, the fetch interceptor waits up to `max_rate_limit_wait_seconds` (default `300`) before failing fast.
+
+### Model IDs shipped in the registry
+
+- `antigravity-gemini-3-pro`, `antigravity-gemini-3-flash`, `antigravity-gemini-3.5-flash`, `antigravity-gemini-3.1-pro`, `antigravity-gemini-3.1-flash-image`
+- `antigravity-claude-sonnet-4-6`, `antigravity-claude-opus-4-6-thinking`
+- `antigravity-gpt-oss-120b-medium`
+- `gemini-2.5-flash`, `gemini-2.5-pro`, `gemini-3-flash-preview`, `gemini-3-pro-preview`, `gemini-3.1-pro-preview`, `gemini-3.1-pro-preview-customtools` (Gemini CLI namespace)
+
+Legacy `antigravity-gemini-3-flash` style aliases resolve through `MODEL_ALIASES` in the resolver; new model names should be added through the registry, not hand-coded in the transforms. The Transform layer renames old → new automatically.
+
+## Multi-account behavior
+
+The account pool persists in `antigravity-accounts.json` (storage schema `AccountStorageV4`) with locking via `acquireFencedFileLock` so concurrent writers cannot corrupt the file. Each account carries:
+
+- `refreshToken` (stored in plaintext; the file sits under `0600` with its parent directory at `0700` — filesystem permissions are the only protection at rest)
+- `email`, `enabled`, `coolingDownUntil`, `cachedQuota`, `healthScore`
+- `projectId` (optional; used by Gemini CLI)
+- `fingerprintHistory` and `verificationRequired` / `verificationUrl` (used by the verification flow)
+
+Account selection (`account_selection_strategy`):
+
+| Strategy | Behavior |
+| --- | --- |
+| `sticky` | Use one account until it is rate-limited; preserves Anthropic's prompt cache. Recommended for single-session use. |
+| `hybrid` (default) | Token bucket + health score + LRU freshness. Best for most workloads. |
+| `round-robin` | Rotate per request. Useful when running many short requests in parallel. |
+
+`pid_offset_enabled` shifts the per-session starting index so different PIDs begin at different accounts — recommended when running multiple parallel subagents and `oh-my-opencode` is installed. Per-request caps: `max_account_switches` (default `10`) bounds how many accounts a single request may try before failing.
+
+`switch_on_first_rate_limit` controls whether the interceptor switches accounts after the first 429 (default on) or retries the same account once first. `default_retry_after_seconds` (60) and `max_backoff_seconds` (60) shape the wait between attempts. `request_jitter_max_ms` adds timing jitter — set to `100–500` to avoid thundering-herd patterns when running many parallel agents.
+
+## Quota semantics and killswitch
+
+Each account carries a per-quota-group `cachedQuota` (`{ remainingFraction, resetTime }` for `claude`, `gemini-pro`, `gemini-flash`, `gpt-oss`). The quota manager refreshes them opportunistically:
+
+- **Periodic refresh:** every `quota_refresh_interval_minutes` after a successful request (default `30`; the schema migrated from 15 to 30 for v2 — set to `0` to disable).
+- **TTL:** `soft_quota_cache_ttl_minutes = "auto"` resolves to `max(2 × refresh_interval, 10)`. While the cache is fresh the threshold check is authoritative.
+- **Stale-TTL fail-open:** when the cache is older than the TTL OR for an account the plugin has never refreshed, the account is allowed through the soft-quota check. A cold start can never deadlock on the first dial.
+- **Soft-quota threshold:** `soft_quota_threshold_percent` (default `80`) — accounts with usage ≥ threshold are skipped as if rate-limited.
+- **All-exhausted:** when every account exceeds the threshold the interceptor waits for the earliest reset time, then waits up to `max_rate_limit_wait_seconds` before failing fast.
+- **Proactive rotation:** after a successful request, if the active account's remaining quota drops below `proactive_rotation_threshold_percent` (default `20`) the next request is dispatched from a warm-cache account to avoid a forced 429 mid-conversation.
+
+The **operator killswitch** (`/antigravity-killswitch`) is a hard rejection layer run before dispatch:
+
+- `enabled` (default `false`) — master switch.
+- `minimum_remaining_percent` — global threshold; accounts whose freshest cached quota is ≤ this are excluded.
+- `accounts` — per-account override keyed by `sha256(refreshToken).slice(0,12)`. The key never appears outside the config file; no raw token is ever written, logged, or sent over RPC.
+- **Fail-open on stale/missing cache:** a candidate with no fresh cached quota (TTL `5min` by default) is allowed through so the first dispatch after a cold start cannot deadlock.
+- **All-killed error:** when the entire pool is excluded, the interceptor throws `AntigravityKillswitchError` carrying a per-account summary so the host can surface a single, structured error rather than a synthetic body.
+
+Both filters are evaluated *before* the token bucket / health score pick, so the operator has the last word even on a healthy pool.
+
+## OpenCode sidebar and slash commands
-Releases are tag-driven. `scripts/release.sh ` runs checks, syncs all
-package versions (`scripts/version-sync.mjs`), commits, tags `v`, and
-pushes. The `Release` workflow then publishes all three packages to npm via
-trusted publishing.
+The sidebar lives under the OpenCode TUI; it is **read-only** and polls `sidebar-state.json` every two seconds. The state contract version is `1`; the host loader silently falls back to `DEFAULT_SIDEBAR_STATE` on malformed JSON or unknown versions.
+
+### Slash commands
+
+All six commands are registered in `packages/opencode/src/plugin/commands.ts` and the modal flow goes through `packages/opencode/src/tui/command-dialogs.tsx`. The plugin entry writes a sidebar freshness bump after every apply through `createSidebarRefresher` so the TUI's next poll sees a fresh `checkedAt`.
+
+| Command | Args | Effect |
+| --- | --- | --- |
+| `/antigravity-quota` | `[refresh]` | Status (or refresh) of cached quota across accounts. Returns within `2s` default. |
+| `/antigravity-account` | `add` · `refresh` · `remove` · `list` | Manage the account pool. `add` and `refresh` opt into a `120_000` ms RPC timeout (OAuth can take two minutes on a fresh login). `list` and `remove` use the `2_000` default. |
+| `/antigravity-routing` | `cli_first=true\|false` · `quota_style_fallback=true\|false` | Toggle the live routing flags. Omit a key to flip the current value. Persists immediately. |
+| `/antigravity-killswitch` | `enabled=true\|false` · `minimum_remaining_percent=0..100` | Configure the killswitch. Per-account thresholds are set via direct config edit. |
+| `/antigravity-dump` | `enable` · `disable` · `status` | Toggle wire-dump capture for the next request. (Backward-compatible alias: `/gemini-dump`.) |
+| `/antigravity-logging` | `error` · `warn` · `info` · `debug` · `trace` | Adjust runtime logging; takes effect immediately and persists across restarts. |
+
+Every apply RPC defaults to `2_000` ms. The account `add` / `refresh` paths opt into `120_000` ms because the OAuth flow can take up to two minutes on a fresh login. The two timeouts are visible to operators as the `timeoutMs` knob on the apply result.
+
+### Sidebar contract
+
+`getSidebarStateFile()` resolves to `$XDG_STATE_HOME/cortexkit/antigravity-auth/sidebar-state.json` (or `~/.local/state/...`). Override with `ANTIGRAVITY_AUTH_SIDEBAR_STATE_FILE`. The file carries:
+
+- `version: 1`, `checkedAt` (epoch ms), `accounts[]`, `activeRouting` (per-session map, pruned to 24h / 100 entries), `routingAuthoritative`, optional `quotaBackoffUntil` and `lastError`.
+
+Every writer follows the same recipe (serialize through `sidebarWriteChain` → acquire fenced file lock with `2s` retry → re-read and merge → atomic rename mode `0600`). If a cross-process lock holder does not release within `SIDEBAR_LOCK_TIMEOUT_MS` (`2_000` ms) the writer surfaces `SidebarStateLockContentionError`; the caller decides whether to surface a toast, drop the write, or retry next tick.
+
+## Standalone CLI
+
+`antigravity-auth` (the bin inside the OpenCode package) accepts three commands. The parser rejects anything else with `exit 2`.
+
+```bash
+antigravity-auth login [--project ] [--no-browser]
+antigravity-auth list [--json]
+antigravity-auth quota [--json] [--refresh]
+```
+
+- `login` performs the same PKCE + local callback flow as the plugin. `--project ` pins the project ID before the request; `--no-browser` prints the URL instead of opening it. The CLI auto-detects a headless session (`SSH_*`, `OPENCODE_HEADLESS=1`) and combines that with `--no-browser` for you.
+- `list` prints a fixed-width table (`INDEX`, `EMAIL`, `STATUS`) where `STATUS` is `active`, `disabled`, `ineligible`, or `verification-required`. `--json` emits the same data as JSON.
+- `quota` prints `ACCOUNT`, `STATUS`, `GROUP`, `REMAINING`, `RESET`. With `--refresh` it forces a live refresh; with `--json` it emits the same array as JSON. Exit code is `0` on success, `1` on a thrown error, and `2` on a parse failure.
+
+All commands write to the same `antigravity-accounts.json` the plugin reads; a login from the CLI shows up in the next sidebar poll after the plugin reload.
+
+## Pi usage
+
+```bash
+pi install npm:@cortexkit/pi-antigravity-auth
+pi
+/login google-antigravity # OAuth flow
+```
+
+The Pi extension registers the `google-antigravity` provider automatically through the package's `pi.extensions` field; no further wiring is needed. Both packages delegate to `@cortexkit/antigravity-auth-core`, so the request transport and model transforms are shared. The account-pool layer is not: Pi holds a single credential locally (no `antigravity-accounts.json`, no rotation, no killswitch, no operator settings). Multi-account rotation, quota routing, and the killswitch are OpenCode-only.
+
+## Configuration reference
+
+`packages/opencode/src/plugin/config/schema.ts` defines `AntigravityConfigSchema` (Zod); the generated JSON Schema ships at `packages/opencode/assets/antigravity.schema.json`. Every nested config property, default, and environment override is documented below. **Project** settings (`.opencode/antigravity.json`) override **user** settings (`~/.config/opencode/antigravity.json`), which in turn override env vars.
+
+| Path | Type | Default | Env override | Effect |
+| --- | --- | --- | --- | --- |
+| `quiet_mode` | bool | `false` | `OPENCODE_ANTIGRAVITY_QUIET=1` | Suppress rate-limit and account-switching toasts; recovery toasts always shown. |
+| `toast_scope` | `root_only` \| `all` | `root_only` | `OPENCODE_ANTIGRAVITY_TOAST_SCOPE=all` | `root_only` silences subagents; `all` is verbose. |
+| `debug` | bool | `false` | `OPENCODE_ANTIGRAVITY_DEBUG=1` | File logging to `~/.config/opencode/antigravity-logs/`. |
+| `debug_tui` | bool | `false` | `OPENCODE_ANTIGRAVITY_DEBUG_TUI=1` | TUI log-panel verbosity (independent of `debug`). |
+| `log_dir` | string | XDG-derived | `OPENCODE_ANTIGRAVITY_LOG_DIR=…` | Custom debug-log directory. |
+| `keep_thinking` | bool | `false` | `OPENCODE_ANTIGRAVITY_KEEP_THINKING=1` | Preserve Claude thinking blocks (signature caching); can destabilize models. |
+| `thinking_warmup` | bool | `false` | `OPENCODE_ANTIGRAVITY_THINKING_WARMUP=1` | Costly warmup probe at session start. |
+| `cache_warmup_on_switch` | bool | `true` | `OPENCODE_ANTIGRAVITY_CACHE_WARMUP_ON_SWITCH=1` | Cache-seeding probe after account rotation. |
+| `session_recovery` | bool | `true` | — | Auto-recover from `tool_result_missing` errors. |
+| `auto_resume` | bool | `true` | — | Send `resume_text` after recovery. |
+| `resume_text` | string | `"continue"` | — | What to send on auto-resume. |
+| `signature_cache.enabled` | bool | `true` | — | Disk-persist thinking-block signatures. |
+| `signature_cache.memory_ttl_seconds` | number (60-86400) | `3600` | — | In-memory signature TTL. |
+| `signature_cache.disk_ttl_seconds` | number (3600-604800) | `172800` | — | On-disk signature TTL. |
+| `signature_cache.write_interval_seconds` | number (10-600) | `60` | — | Background flush interval. |
+| `empty_response_max_attempts` | number (1-10) | `2` | — | Retries when Antigravity returns no candidates. |
+| `empty_response_retry_delay_ms` | number (500-10000) | `2000` | — | Delay between empty-response retries. |
+| `tool_id_recovery` | bool | `true` | — | Match orphan tool IDs by name. |
+| `claude_tool_hardening` | bool | `true` | — | Inject strict tool-usage rules for Claude. |
+| `claude_prompt_auto_caching` | bool | `false` | — | Add top-level `cache_control` when absent. |
+| `proactive_token_refresh` | bool | `true` | — | Refresh access tokens before expiry. |
+| `proactive_refresh_buffer_seconds` | number (60-7200) | `1800` | — | Pre-expiry refresh window. |
+| `proactive_refresh_check_interval_seconds` | number (30-1800) | `300` | — | Refresh-check cadence. |
+| `max_rate_limit_wait_seconds` | number (0-3600) | `300` | — | Cap on queueing wait; `0` waits indefinitely. |
+| `quota_fallback` | bool | `false` | — | **Deprecated**, ignored at runtime; kept for back-compat. |
+| `cli_first` | bool | `false` | — | CLI header first instead of Antigravity for Gemini. |
+| `account_selection_strategy` | `sticky` \| `round-robin` \| `hybrid` | `hybrid` | `OPENCODE_ANTIGRAVITY_ACCOUNT_SELECTION_STRATEGY` | Account picker. |
+| `pid_offset_enabled` | bool | `false` | `OPENCODE_ANTIGRAVITY_PID_OFFSET_ENABLED=1` | Per-PID starting account index. |
+| `switch_on_first_rate_limit` | bool | `true` | — | Switch immediately on first 429. |
+| `max_account_switches` | number (0-500) | `10` | `OPENCODE_ANTIGRAVITY_MAX_ACCOUNT_SWITCHES` | Account-switch budget per request. |
+| `quota_style_fallback` | bool | `false` | `OPENCODE_ANTIGRAVITY_QUOTA_STYLE_FALLBACK` | Re-send through the other header pool on exhaustion. |
+| `scheduling_mode` | `cache_first` \| `balance` \| `performance_first` | `cache_first` | `OPENCODE_ANTIGRAVITY_SCHEDULING_MODE` | Rate-limit scheduling strategy. |
+| `max_cache_first_wait_seconds` | number (5-300) | `60` | — | Wait cap when `scheduling_mode=cache_first`. |
+| `failure_ttl_seconds` | number (60-7200) | `3600` | — | Failure-count reset window. |
+| `default_retry_after_seconds` | number (1-300) | `60` | — | Fallback retry delay when 429 omits `Retry-After`. |
+| `max_backoff_seconds` | number (5-300) | `60` | — | Exponential backoff cap. |
+| `request_jitter_max_ms` | number (0-5000) | `0` | — | Random pre-request delay to break cadence. |
+| `switch_account_delay_ms` | number (0-10000) | `500` | — | Delay before switching accounts. |
+| `soft_quota_threshold_percent` | number (1-100) | `80` | — | Skip accounts above this usage %; `100` disables. |
+| `quota_refresh_interval_minutes` | number (0-120) | `30` | — | Background refresh cadence; `0` disables. |
+| `soft_quota_cache_ttl_minutes` | `auto` \| number (1-120) | `"auto"` | — | TTL for cached quota. `auto` = `max(2 × refresh, 10)` minutes. |
+| `proactive_rotation_threshold_percent` | number (0-100) | `20` | `OPENCODE_ANTIGRAVITY_PROACTIVE_ROTATION_THRESHOLD` | Proactive account switcher; `0` disables. |
+| `health_score.initial` | number (0-100) | `70` | — | Health-score starting value. |
+| `health_score.success_reward` | number (0-10) | `1` | — | Score gain per success. |
+| `health_score.rate_limit_penalty` | number (-50..0) | `-10` | — | Penalty per rate limit. |
+| `health_score.failure_penalty` | number (-100..0) | `-20` | — | Penalty per failure. |
+| `health_score.recovery_rate_per_hour` | number (0-20) | `2` | — | Slow heal over time. |
+| `health_score.min_usable` | number (0-100) | `50` | — | Account is gated below this. |
+| `health_score.max_score` | number (50-100) | `100` | — | Score ceiling. |
+| `token_bucket.max_tokens` | number (1-1000) | `50` | — | Max concurrent budget. |
+| `token_bucket.regeneration_rate_per_minute` | number (0.1-60) | `6` | — | Rate budget refills. |
+| `token_bucket.initial_tokens` | number (1-1000) | `50` | — | Starting budget. |
+| `auto_update` | bool | `true` | — | Background npm update check + auto-pin. |
+| `operator.routing.cli_first` | bool | `false` | — | Live override of `cli_first` (from `/antigravity-routing`). |
+| `operator.routing.quota_style_fallback` | bool | `false` | — | Live override of `quota_style_fallback`. |
+| `operator.killswitch.enabled` | bool | `false` | — | Master killswitch. |
+| `operator.killswitch.minimum_remaining_percent` | number (0-100) | `5` | — | Global hard block threshold. |
+| `operator.killswitch.accounts` | record | unset | — | Optional per-account override (key = `sha256(refreshToken).slice(0,12)`). Absent by default; falls back to `minimum_remaining_percent`. |
+| `operator.log_level` | `error` \| `warn` \| `info` \| `debug` \| `trace` | `"info"` | — | Runtime log-level filter (mutable from `/antigravity-logging`). |
+
+`globalThis.fetch` calls inside the package go through `fetchWithActiveTimeout` (see [State, cache, log, RPC, and dump files](#state-cache-log-rpc-and-dump-files)).
+
+## State, cache, log, RPC, and dump files
+
+| Purpose | Path | Mode | Notes |
+| --- | --- | --- | --- |
+| Account pool | `$OPENCODE_CONFIG_DIR/antigravity-accounts.json` (or `$XDG_CONFIG_HOME/opencode/antigravity-accounts.json`, fallback to `%APPDATA%\opencode\…`) | `0600` | Per-account tokens, quota cache, fingerprint history. Storage schema `V4`. |
+| Plugin config | `antigravity.json` (project `.opencode/`, then user) | `0600` | The Zod-validated config above; `OpenCodeConfigDir` environment variable override. |
+| Debug logs | `antigravity-logs/.log` (or `log_dir`) | `0600` | Created by the debug subsystem when `debug` is on. |
+| Sidebar state | `$XDG_STATE_HOME/cortexkit/antigravity-auth/sidebar-state.json` (override `ANTIGRAVITY_AUTH_SIDEBAR_STATE_FILE`) | `0600` | Versioned contract `1`; pruned to 24h/100 entries. |
+| RPC port file | `$XDG_STATE_HOME/cortexkit/antigravity-auth/rpc//port-.json` (override `ANTIGRAVITY_AUTH_RPC_DIR`) | `0600` dir `0700` | Loopback HTTP token bearer for the TUI. |
+| Signature cache | `signature-cache-disk-.json` under `keep_thinking` path | `0600` | 2-day default TTL. |
+| Wire dump | `antigravity-logs/dumps/` | `0600` | Sensitive — see [Security and redaction](#security-and-redaction). |
+
+The **fetch interceptor** uses `fetchWithActiveTimeout` (`packages/core/src/fetch-timeout.ts`). The contract is intentional: the **`15_000` ms header-timeout** aborts only the request until the underlying `fetch` resolves; once headers arrive the timeout listener is removed and the body may stream past the deadline. **Streaming responses are bounded by the `agy-transport.ts` idle-timeout watchdog (`180_000` ms / 3 min)**, so a stalled SSE chunk counts as a transport failure even though the header budget has been exceeded. Operators should not raise the 15s header window to chase a streaming response — the stream watchdog is the right knob.
+
+The **RPC** between the TUI (`packages/opencode/src/tui-compiled/`) and the server plugin (`packages/opencode/src/rpc/rpc-server.ts`) uses a per-PID port file (`$XDG_STATE_HOME/cortexkit/antigravity-auth/rpc//port-.json`). The TUI process reads the highest-mtime live entry, drops stale entries with `process.kill(pid, 0)` checks, and posts to `/rpc/apply` or `/rpc/pending-notifications` with a `Bearer ` header. Apply default timeout is `2_000` ms; `120_000` ms for `antigravity-account add`/`refresh`. Notifications are pushed over loopback HTTP and drained via the same `/rpc/pending-notifications` long-poll.
+
+**Disposal:** the plugin's `dispose()` (called on host shutdown) drains sidebar writes via `drainSidebarWrites()` BEFORE tearing down the RPC server and file logger so any in-flight write enqueued at shutdown lands before the host closes the terminal. The lifecycle disposes (in order): the active `AccountManager` + token refresh queue → the disk signature cache → the session registry → the fetch state → sidebar drain → registered disposables (RPC server, file logger).
+
+## Security and redaction
+
+- Refresh tokens live in `antigravity-accounts.json` with file mode `0600`. The file is rewritten through `acquireFencedFileLock` so concurrent writers never corrupt state. Existing overly-permissive files are tightened on load.
+- The sidebar state file carries only `id`/`label`/`enabled`/`health`/`current`/`cooldownUntil`/`quota` — never a refresh token, access token, project ID, fingerprint, or other credential-shaped field. `redactAccountForSidebar` is the single conversion point.
+- The killswitch `accounts` field is keyed by `sha256(refreshToken).slice(0,12)` so the raw token never lands in the config, sidebar, RPC payload, or apply response. The hash is deterministic per token but irreversible.
+- `debug` file logs MAY include request headers and response bodies for the model under test. Do not enable `debug` on a session that handles personally identifiable data. The wire dump produces a raw request/response pair; keep it out of issue reports. Operators can route logs to an encrypted directory via `log_dir`.
+- OAuth client ID and secret (`packages/core/src/constants.ts`) are part of the public Antigravity CLI client and are not sensitive on their own; they are not a credential pair.
+- This README and project tree never contain OAuth tokens, bearer tokens, account file contents, or request/response dumps.
+
+## Troubleshooting
+
+| Symptom | Likely cause | Fix |
+| --- | --- | --- |
+| OAuth callback hangs | The browser can't reach `localhost:51121` (SSH, remote dev, Docker, WSL2) | `antigravity-auth login --no-browser`, copy the URL into a browser that can reach the host; or `ssh -L 51121:localhost:51121 user@remote` to forward the callback port. |
+| Safari shows "Safari can't open the page" | HTTPS-Only mode blocks `http://localhost` callback | Use Chrome or Firefox; or temporarily disable HTTPS-Only mode (Safari → Settings → Privacy) during login. |
+| `Address already in use :51121` | Stale OAuth listener from a previous run | `lsof -i :51121` (or `netstat -ano \| findstr :51121` on Windows), kill the listed PID, retry. |
+| `Permission 'cloudaicompanion.companions.generateChat' denied` (`rising-fact-p41fc` fallback project) | Antigravity returned no project ID | Enable the `Gemini for Google Cloud API` (`cloudaicompanion.googleapis.com`) on a Google Cloud project and add `projectId` to the account in `antigravity-accounts.json`. Repeat per account in multi-account setups. |
+| All accounts show `cooling-down` (quota available) | The threshold filter is excluding the entire pool | Lower `soft_quota_threshold_percent`, or wait for `soft_quota_cache_ttl_minutes` to expire and let the stale-TTL fail-open kick in. Refresh quota via `/antigravity-quota refresh`. |
+| Sidebar shows "Awaiting Antigravity state" forever | The state file is missing or unreadable | Confirm `$XDG_STATE_HOME/cortexkit/antigravity-auth/sidebar-state.json` exists and is `0600`; verify the running plugin's user can write to the directory. |
+| `Antigravity RPC server is not available` from the TUI | The TUI is polling but no server has bound a port | Confirm the host process actually loaded the plugin (`config.command.antigravity-quota` should appear); the RPC dir (`ANTIGRAVITY_AUTH_RPC_DIR` or its default) must be writable. |
+| TUI shows "Awaiting antigravity bundle" / spawn fails | `bun run build:tui` was skipped, or the precompiled tree is missing | Run `bun run --cwd packages/opencode build` (it bundles `tsc` + `esbuild` + `build:tui`); confirm `dist/src/tui-compiled/tui.tsx` exists. |
+| `quota_backoff` recovered slowly | `quota_refresh_interval_minutes` is too high | Lower it; or run `/antigravity-quota refresh` to force a refresh. |
+| Malformed JSON in `antigravity.json` | Invalid escape / trailing comma | `bun -e 'JSON.parse(require("fs").readFileSync(require("os").homedir()+"/.config/opencode/antigravity.json","utf8"))'`; the schema reference in the file (`$schema: assets/antigravity.schema.json`) drives editor IntelliSense. |
+| `Invalid signature in thinking block` errors | `keep_thinking` left on through a Claude version bump | Set `keep_thinking: false`; or clear `signature_cache.disk_ttl_seconds` and restart. The `SKIP_THOUGHT_SIGNATURE` sentinel is injected automatically for known-bad signatures. |
+| Pre-`v2` inline quota helper script | Removed in `v2` — the script no longer ships | Use `antigravity-auth quota [--refresh] [--json]`; the npm-installed `antigravity-auth` binary replaces it. |
+| Debug log location unknown | `debug` toggled off, or `log_dir` misconfigured | `debug: true` + `log_dir: "/secure/path"`; logs sit under `antigravity-logs/` by default and contain request/response bodies — handle with care. |
+
+When none of the above resolves the issue: stop the host, delete `antigravity-accounts.json` (and optionally `~/.cache/opencode/node_modules/@cortexkit/opencode-antigravity-auth` if a stale npm cache is suspected), and run `opencode auth login` again. Re-add accounts one at a time so a single problem account can be isolated.
+
+## Development workflow
+
+This monorepo uses Bun for contribution commands; consumers install through npm. Keep both in mind when you write a script — npm users run `npx @cortexkit/opencode-antigravity-auth`, contributors run `bun`.
```bash
-./scripts/release.sh 1.0.0 # release
-./scripts/release.sh 1.0.0 --dry # preview
+# at repo root
+bun install # sync workspace
+bun run build # core -> opencode -> pi
+bun run typecheck # tsc across the workspace + scripts tsconfig
+bun run test # bun test --isolate across packages
+bun run test:e2e # plugin-flow + cli-flow + rpc-tui-flow
+bun run test:e2e:models # live Antigravity model inventory (gated by CI)
+bun run test:e2e:regression # cross-model + Gemini CLI regression
+bun run --cwd packages/opencode smoke:tui # bundle + spawn the TUI precompiled tree against the npm package shape
+bun run format:check && bun run lint # Biome format + lint (no auto-fix in CI)
```
+The TUI side ships a precompiled JSX tree (`packages/opencode/src/tui-compiled/`) generated by `bun run build:tui` (alias of `bunx tsx packages/opencode/scripts/build-tui.ts`). The output is gitignored; rebuild it as part of every `bun run build`. A smoke-test (`bun run --cwd packages/opencode smoke:tui`) installs the freshly-built artifact as a tarball into a scratch dir and confirms the host can resolve both the `server` and `tui` subpaths. CI runs the smoke-test before TUI-loading tests and before publishing.
+
+`bun run dev` symlinks all packages into a `dist-dev/` tree and watches for source changes. `bun run dev:clean` removes the dev symlinks; use it after switching branches.
+
+Pre-commit hooks (`.lefthook.yml`) run Biome on changed files; CI re-runs the full `bun run format:check && bun run lint` gate.
+
+## Test strategy
+
+The repo splits tests by what they exercise and what they need:
+
+| Scope | Tooling | Network | How to run |
+| --- | --- | --- | --- |
+| Unit (deterministic) | `bun test --isolate` (`packages/{core,opencode,pi}/src`, `packages/e2e-tests/src`) | No | `bun run test` |
+| Black-box e2e (deterministic) | Mock Antigravity server, harness runner (`packages/e2e-tests/`) | No | `bun run test:e2e` |
+| Live model inventory | `bun run test:e2e:models` (uses `test-models.ts`) | Yes — gated by CI label | `bun run test:e2e:models` |
+| Cross-model regression | `bun run test:e2e:regression` (`script/test-regression.ts`) | Yes | `bun run test:e2e:regression` |
+| TUI smoke | `bun run --cwd packages/opencode smoke:tui` (build-tarball install + spawn) | No | `bun run --cwd packages/opencode smoke:tui` |
+
+Deterministic tests run everywhere (including in CI). The live `test:e2e:models` and `test:e2e:regression` flows are gated by label because they hit real Google infrastructure. They live in `packages/opencode/script/` (TypeScript + shell launchers) and use `anthropic-agy` style fixtures kept under `test-fixtures/`.
+
+## Release process
+
+```bash
+./scripts/release.sh 1.7.0 # tag-driven release: typecheck -> test -> build -> sync version -> commit + tag + push
+./scripts/release.sh 1.7.0 --dry # dry run: previews version sync without committing/pushing
+```
+
+The script enforces semver, refuses to run on a dirty tree, and refuses to run on a non-`main`/`master` branch without confirmation. It runs `bun run typecheck` → `bun run test` → `bun run build` before any mutation. `scripts/version-sync.mjs` is a Node script (no Bun runtime dep) that walks every workspace and writes the new version into each `package.json`. The script tags the release and pushes it; the GitHub workflow is what runs the actual publish.
+
+The `Release` workflow (`.github/workflows/release.yml`) runs on tag push (or `workflow_dispatch`) and:
+
+1. **Test** job — checkout the tag ref → install Bun 1.3 + Node 24 → `bun install --frozen-lockfile` → `bun run typecheck` → `bun run build` → `bun run --cwd packages/opencode smoke:tui` → `bun test` → `bun run test:e2e` → `bun run format:check` → `bun run lint`.
+2. **Publish** job — matrixed across the three packages (`@cortexkit/antigravity-auth-core`, `@cortexkit/opencode-antigravity-auth`, `@cortexkit/pi-antigravity-auth`) with `max-parallel: 1` so each package picks up its own OIDC token; `npm Trusted Publishing` (`npm publish --workspace … --access public --provenance`) handles the upload. The job skips a package if its npm version is already published.
+3. **GitHub Release** job — `softprops/action-gh-release@v2` creates the GitHub release with `generate_release_notes: true`.
+
+CI (`.github/workflows/ci.yml`) reuses the same typecheck → build → smoke → test → e2e → format → lint chain on every push to `main` and on every pull request. The `issue-triage.yml` workflow handles GitHub-issue routing.
+
+## Architecture and structure links
+
+- [ARCHITECTURE.md](ARCHITECTURE.md) — system-of-record architecture doc across all three packages.
+- [STRUCTURE.md](STRUCTURE.md) — file-system map of the monorepo.
+- [packages/opencode/ARCHITECTURE.md](packages/opencode/ARCHITECTURE.md) — OpenCode plugin architecture in detail.
+- [packages/opencode/STRUCTURE.md](packages/opencode/STRUCTURE.md) — OpenCode package file-system map.
+- [packages/opencode/CHANGELOG.md](packages/opencode/CHANGELOG.md) — version history (current is `2.0.0`; previous major was `1.6.0`).
+- [packages/opencode/docs/CONFIGURATION.md](packages/opencode/docs/CONFIGURATION.md) — detailed option walkthrough.
+- [packages/opencode/docs/MULTI-ACCOUNT.md](packages/opencode/docs/MULTI-ACCOUNT.md) — load balancing, dual quota pools, account storage.
+- [packages/opencode/docs/MODEL-VARIANTS.md](packages/opencode/docs/MODEL-VARIANTS.md) — variants, thinking budgets, model selection.
+- [packages/opencode/docs/TROUBLESHOOTING.md](packages/opencode/docs/TROUBLESHOOTING.md) — long-form troubleshooting with platform-specific commands.
+- [packages/opencode/docs/ANTIGRAVITY_API_SPEC.md](packages/opencode/docs/ANTIGRAVITY_API_SPEC.md) — Antigravity API reference.
+- [packages/opencode/assets/antigravity.schema.json](packages/opencode/assets/antigravity.schema.json) — generated JSON Schema for IDE validation.
+
## License
-MIT
+MIT. See [LICENSE](LICENSE).
diff --git a/STRUCTURE.md b/STRUCTURE.md
index 286abbe..4026fbf 100644
--- a/STRUCTURE.md
+++ b/STRUCTURE.md
@@ -1,69 +1,465 @@
# Codebase Structure
-## Directory Layout
+This document is the file-system map of the `@cortexkit/antigravity-auth*` monorepo at the v2.0 parity refactor. It is written from the **tracked** source tree — every listed file is the actual path shipped on `main` (`c362269`); directories such as `dist/`, `node_modules/`, `packages/opencode/src/tui-compiled/`, and per-agent working roots are tracked as `.gitignore`d but called out where relevant.
+
+The stack has one business purpose (talk to the Google Antigravity `agy` CLI from non-Google harnesses) and three runtime surfaces (OpenCode server plugin, OpenTUI sidebar, Pi extension). These paragraphs are the only thing you have to read to navigate the tree:
+
+1. **Account, rotation, quota, transport, OAuth, and persistence live in `packages/core`.** That package has zero host-runtime imports — it only knows `fetch`, `node:fs`, `node:net`, and a `fetchAccountQuota` callback — and it owns the durable `account-storage.ts` engine and the `account-manager.ts` selection/rotation logic. The OpenCode side owns a thin adapter that wraps it (path resolution + `.gitignore` sync) and a fetch interceptor that drives it.
+2. **`packages/opencode/src/plugin/index.ts` is the only composition root the host calls.** Its default factories are aliased from `packages/opencode/index.ts` as `{AntigravityCLIOAuthPlugin, GoogleOAuthPlugin}`. Everything else under `packages/opencode/src/plugin/` is a subsystem wired by that root.
+3. **`packages/opencode/src/tui.tsx` and the `packages/opencode/src/tui-compiled/` twin are the OpenTUI sidebar.** They are read-only consumers of the on-disk `sidebar-state.json` and the loopback HTTP RPC. They never import from `packages/opencode/src/plugin/storage.ts`, `accounts.ts`, or any OAuth code; that boundary is enforced by the import graph documented in `ARCHITECTURE.md`.
+
+## Repository tree
```
antigravity-auth/
-├── packages/
-│ ├── core/ # Harness-agnostic core logic
-│ ├── opencode/ # OpenCode integration plugin wrapper
-│ └── pi/ # Pi coding agent extension wrapper
-├── scripts/ # Build and release utility scripts
-└── package.json # Root monorepo configuration
+├── AGENTS.MD # operator-facing agent manual
+├── ARCHITECTURE.md # system-of-record architecture doc
+├── STRUCTURE.md # this file — file-system map
+├── README.md # project landing page
+├── biome.json # Biome formatter + linter config
+├── bunfig.toml # Bun test preload (test/setup.ts + OpenTUI preload)
+├── bun.lock # Bun lockfile (committed)
+├── lefthook.yml # pre-commit Biome check
+├── mise.toml # Bun 1.3 + Node 24 toolchain pin
+├── tsconfig.scripts.json # tsconfig for scripts/, test/, e2e-tests
+├── package.json # root monorepo (npm workspaces: packages/*)
+├── models # JSON snapshot of upstream Antigravity model list
+├── .github/ # GitHub config: workflows, issue templates, FUNDING
+├── scripts/ # root build/dev/release/version-sync tooling
+├── test/ # repo-root test preload + cross-package sanity tests
+├── test-fixtures/ # frozen Antigravity CLI JSON fixtures used by tests
+└── packages/
+ ├── core/ # @cortexkit/antigravity-auth-core — harness-agnostic
+ ├── opencode/ # @cortexkit/opencode-antigravity-auth — server + tui
+ ├── pi/ # @cortexkit/pi-antigravity-auth — Pi extension
+ └── e2e-tests/ # private deterministic e2e workspace
+```
+
+The `models` blob at the repo root is a tracked JSON snapshot of the upstream Antigravity model inventory; the e2e tests assert it stays in sync with the metadata fixtures. Treat the `packages/opencode/src/tui-compiled/` directory as a build artifact: it is **gitignored** and regenerated by `packages/opencode/scripts/build-tui.ts`. Files are listed under **Generated and ignored artifacts** below.
+
+## Workspace responsibilities
+
+| Workspace | Runtime responsibility | Imports from |
+| --- | --- | --- |
+| `packages/core` | All harness-agnostic logic: OAuth PKCE, transport, account pool, rotation, quota, persistence. | Node built-ins only. |
+| `packages/opencode` | OpenCode server plugin (fetch interceptor, account runtime, slash commands, RPC) and the OpenTUI sidebar. | `@cortexkit/antigravity-auth-core`, `@opencode-ai/plugin`, `@opentui/*`, `solid-js`. |
+| `packages/pi` | Pi extension — a custom OAuth provider that bridges Pi's `pi.registerProvider` API to the core transport + transforms. | `@cortexkit/antigravity-auth-core`, `@earendil-works/pi-*` (peer). |
+| `packages/e2e-tests` | Private black-box flows against a mock Antigravity loopback server (`@cortexkit/antigravity-auth-e2e`, `private: true`). | `packages/opencode`, `packages/core`. |
+| `scripts/` | Workspace-level tooling: dev symlink lifecycle, version sync, release script. | Node built-ins + child process. |
+| `test/` | Preload + global types + a small handful of repo-root sanity tests (`environment.test.ts`, `dev.test.ts`). | Bun test harness. |
+| `test-fixtures/` | Frozen JSON snapshots of `agy` CLI version 1.1.3 / 1.1.5 metadata + streaming requests. | None. |
+| `.github/` | Workflows (CI, release, issue triage), issue templates, FUNDING. | GitHub Actions. |
+
+## `packages/core` inventory
+
+The harness-agnostic core. Every export under `packages/core/src/index.ts:1-30` is grouped by concern; the test files live next to the production code (`*.test.ts` co-location). The package ships to npm as `@cortexkit/antigravity-auth-core@2.0.0` and is the only dependency `packages/opencode` and `packages/pi` are allowed to take against internal code.
+
+- `packages/core/bunfig.toml` — `preload = ["../../test/setup.ts"]`. Test isolation root.
+- `packages/core/package.json` — declares `main`/`exports` on `./dist/`, lists peer deps (`@openauthjs/openauth`, `xdg-basedir`, `zod`).
+- `packages/core/tsconfig.json` — library tsconfig (extends `@tsconfig/bun`).
+- `packages/core/tsconfig.build.json` — emit + declaration tsconfig used by `bun run build` (`tsc -p tsconfig.build.json`).
+- `packages/core/LICENSE`, `packages/core/README.md` — MIT + repo readme.
+
+### Public surface (`packages/core/src/index.ts:1-30`)
+
+- **`packages/core/src/account-manager.ts`** — `AccountManager`: per-account selection, rate-limit state, fingerprint, soft-quota, health score. Hosts call `getCurrentOrNextForFamily`, `markAccountCoolingDown`, `markRateLimitedWithReason`.
+- **`packages/core/src/account-storage.ts`** — durable JSON shape `AccountStorageV4` with `accounts[]`, `activeIndex`, `activeIndexByFamily`; migration v1→v4; lock-held read-modify-write via the fenced file lock.
+- **`packages/core/src/account-types.ts`** — pure types (`ManagedAccount`, `AccountSessionIdentity`, `RateLimitReason`, `CooldownReason`, `AccountStorageV4`).
+- **`packages/core/src/agy-request-metadata.ts`** — `buildAgyRequestMetadata` (the labels block — `last_step_index`, `model_enum`, `trajectory_id`, `used_claude*`, `used_non_gemini_model`) and the per-workspace `AgyRequestSessionStore` registry.
+- **`packages/core/src/agy-transport.ts`** — bounded TLS socket pool, chunked + gzip body decode, idle-timeout watchdog. Hosts pass a `connectTlsWithAbort` factory through the dependency seam.
+- **`packages/core/src/antigravity/oauth.ts`** — `authorizeAntigravity`, `exchangeAntigravity`, `refreshAntigravityToken`. Owns PKCE pack/unpack, the `51121` callback URL constant, and the client metadata header.
+- **`packages/core/src/auth.ts`** — `parseRefreshParts`, `formatRefreshParts`, `accessTokenExpired` (60s buffer), refresh-token validity.
+- **`packages/core/src/auth-types.ts`** — packed/refresh token wire types.
+- **`packages/core/src/constants.ts`** — endpoints (`ANTIGRAVITY_ENDPOINT_FALLBACKS = [DAILY, PROD]`), scopes, request headers, sentinel values (`SKIP_THOUGHT_SIGNATURE`), `CLAUDE_TOOL_SYSTEM_INSTRUCTION`, OAuth prompt copy.
+- **`packages/core/src/fetch-timeout.ts`** — `fetchWithActiveTimeout` (15s header abort, stream-safe: the abort listener is removed once the body streams).
+- **`packages/core/src/file-lock.ts`** — renovating fenced file lock (`acquireFencedFileLock` + `releaseFencedFileLock`), eviction marker with `pid` / `createdAt` (`MARKER_TTL_MS = 30_000`) for stale-claim reclamation, `assertOwned` ownership check, `whenLost()` / `hasLost()` ownership-loss signals, idempotent terminal release, and TOCTOU-safe renewal that stages a temp file before `rename(2)`.
+- **`packages/core/src/fingerprint.ts`** — per-account device fingerprint + fingerprint history persistence helpers.
+- **`packages/core/src/logger.ts`** — `createLogger('module-name')` thin wrapper used by every other module.
+- **`packages/core/src/model-registry.ts`** — canonical model definitions, quota groups, header-style per-model metadata (mirrored to `packages/opencode/src/plugin/model-registry.ts` which adds harness-specific overrides).
+- **`packages/core/src/model-types.ts`** — `QuotaGroupSummary`, family enum, header style enum, model specification types.
+- **`packages/core/src/project.ts`** — `loadCodeAssist`, `ensureProjectContext`. Provisions the Antigravity `projectId` for an account.
+- **`packages/core/src/quota-manager.ts`** — `QuotaManager`: per-account quota cache, exponential backoff, in-flight dedupe (`refreshAccount` returns the existing promise), disposal honours `AbortSignal`.
+- **`packages/core/src/quota-types.ts`** — quota result shapes (`AccountQuotaResult`).
+- **`packages/core/src/rotation.ts`** — `HealthScoreTracker`, `TokenBucketTracker`, `selectHybridAccount` (the hybrid scoring formula used by `account-manager.getCurrentOrNextForFamily`), `calculateBackoffMs`, `computeSoftQuotaCacheTtlMs`.
+- **`packages/core/src/atomic-write.ts`** — temp-file + `rename` writer (mode `0o600`, no copy fallback); raw `node:fs` only.
+- **`packages/core/src/version.ts`** — exported `VERSION` constant; the build pipeline uses it for cache busting.
+
+### Transforms (`packages/core/src/transform/`)
+
+- `packages/core/src/transform/index.ts` — barrel re-export of all transforms and types.
+- `packages/core/src/transform/types.ts` — `TransformContext`, `TransformResult`, `ModelFamily`, `ThinkingTier`, `ResolvedModel`, `GoogleSearchConfig`, `ThinkingConfig`, `TransformDebugInfo`.
+- `packages/core/src/transform/claude.ts` — `applyClaudeTransforms`, `appendClaudeThinkingHint`, `buildClaudeThinkingConfig`, `computeClaudeMaxOutputTokens`, `ensureClaudeMaxOutputTokens`, `isClaudeModel`, `isClaudeThinkingModel`, `normalizeClaudeTools`, `configureClaudeToolConfig`.
+- `packages/core/src/transform/gemini.ts` — `applyGeminiTransforms`, `buildGemini3ThinkingConfig`, `buildGemini25ThinkingConfig`, `buildImageGenerationConfig`, `isGemini3Model`, `isGemini25Model`, `isGeminiModel`, `isImageGenerationModel`, `normalizeGeminiTools`, `toGeminiSchema`.
+- `packages/core/src/transform/cross-model-sanitizer.ts` — payload cleanup before family switch (`sanitizeCrossModelPayload`, `sanitizeCrossModelPayloadInPlace`, `stripClaudeThinkingFields`, `stripGeminiThinkingMetadata`, `getModelFamily`).
+- `packages/core/src/transform/model-resolver.ts` — `resolveModelWithTier`, `resolveModelWithVariant`, `resolveModelForHeaderStyle`, `MODEL_ALIASES`, `GEMINI_3_THINKING_LEVELS`, `THINKING_TIER_BUDGETS`.
+
+### Co-located tests
+
+Every production file has a corresponding `*.test.ts` sibling. The dense transform families (Claude, Gemini, cross-model sanitizer, model resolver) and the storage/rotation files each have a focused test file. Standalone tests: `account-manager.test.ts`, `account-storage.test.ts`, `agy-request-metadata.test.ts`, `agy-transport.test.ts`, `antigravity/oauth.test.ts`, `atomic-write.test.ts`, `fetch-timeout.test.ts`, `file-lock.test.ts`, `fingerprint.test.ts`, `model-registry.test.ts`, `project.test.ts`, `quota-manager.test.ts`, `rotation.test.ts`, plus all transforms: `transform/claude.test.ts`, `transform/gemini.test.ts`, `transform/cross-model-sanitizer.test.ts`, `transform/cross-model-integration.test.ts`, `transform/model-resolver.test.ts`. Run via `bun run --cwd packages/core test` (which delegates to `bun test --isolate ./src`).
+
+## `packages/opencode` inventory
+
+`@cortexkit/opencode-antigravity-auth`. Exposes two `exports` subpaths (`"."` and `"./tui"`) and pins the supported host range through `engines.opencode` (`>=1.17.13 <2`); the host installer (`opencode plugin`) reads the subpaths and writes a server entry to `opencode.json` and a TUI entry to `tui.json`. Production layout:
+
+```
+packages/opencode/
+├── index.ts # barrel: re-exports AntigravityCLIOAuthPlugin + GoogleOAuthPlugin
+├── assets/antigravity.schema.json # generated JSON schema for the plugin config (built by script/build-schema.ts)
+├── bunfig.toml # test preload
+├── package.json # peerDependencies on @opencode-ai/plugin + @opentui/*
+├── tsconfig.json / tsconfig.build.json # library + build tsconfigs
+├── docs/ # ANTIGRAVITY_API_SPEC, ARCHITECTURE, CONFIGURATION, MODEL-VARIANTS, MULTI-ACCOUNT, TROUBLESHOOTING
+├── script/ # one-off CLI tests + the schema builder (see Root tooling and CI below)
+├── scripts/ # build-tui.ts, build-tui.test.ts, smoke-tui-pack-install.ts
+├── src/ # all TypeScript source (see OpenCode TUI and RPC inventory below)
+└── dist/ # build output (gitignored)
+```
+
+The `src/tui-compiled/` directory lives under `src/` but is **gitignored** — see Generated and ignored artifacts.
+
+### Server plugin (`packages/opencode/src/`)
+
+- `packages/opencode/src/index.ts` — top-level barrel re-exporting `AntigravityCLIOAuthPlugin` and `GoogleOAuthPlugin` from `plugin/index.ts`.
+- `packages/opencode/src/cli.ts` — standalone CLI binary entry (the `bin` field of `package.json`); the build step bundles it to `dist/cli.js`.
+- `packages/opencode/src/antigravity/oauth.ts` — host-thin re-export / extension shim around `core/antigravity/oauth.ts` (used by the CLI's `OAuthLoginRequest`).
+- `packages/opencode/src/constants.ts` — plugin-level constants: command names (`ANTIGRAVITY_*_COMMAND_NAME`), modal command constants, `MODAL_COMMANDS`.
+- `packages/opencode/src/shims.d.ts` — ambient type shims for host globals.
+- `packages/opencode/src/sidebar-state.ts` — `SidebarStateV1` snapshot schema, `setSidebarMachineState` writer, `mergeMachineState`, `upsertSidebarActiveRouting`, `drainSidebarWrites`, fenced-lock + atomic-write chain. The single source of truth both the server and the TUI read.
+- `packages/opencode/src/rpc/` — the loopback HTTP RPC; see **OpenCode TUI and RPC inventory** below.
+
+### Plugin composition root (`packages/opencode/src/plugin/`)
+
+- **`packages/opencode/src/plugin/index.ts`** — `createAntigravityPlugin(providerId, options)` factory. The single composition root.
+- `packages/opencode/src/plugin/types.ts` — `PluginInput`, `PluginContext`, `PluginResult`, `GetAuth`, host types.
+- `packages/opencode/src/plugin/dependencies.ts` — `PluginDependencyOverrides` (the seam the e2e workspace uses to swap `fetchImpl`, `agyTransport`, `filesystemRoots`, `oauth`, `clock`) + `resolvePluginDependencies` default-resolver.
+- `packages/opencode/src/plugin/lifecycle.ts` — `createPluginLifecycle` — the disposal root that coordinates `disposeAccountRuntime`, `shutdownDiskSignatureCache`, `sessionRegistry.clear()`, `clearFetchState`, `drainSidebarWrites`, and the registered disposables (RPC server, file logger, auto-update checker). Disposables are registered with a `LifecyclePhase` of `'producer'` or `'consumer'`; producers (e.g. the auth loader's fetch-interceptor runtime) are disposed before the sidebar drain, consumers after.
+- `packages/opencode/src/plugin/auth-loader.ts` — `createAuthLoader` — the function the host invokes on every `auth.loader(...)` call. Detects auth drift, rebuilds the `AccountManager`, and returns a fresh `fetch` closure bound to a new fetch interceptor.
+- `packages/opencode/src/plugin/auth.ts` — token validation helpers used by the loader (refresh-parts checks, expiry buffer).
+- `packages/opencode/src/plugin/auth-doctor.ts` — self-healing diagnostics for an unrecoverable auth store.
+- `packages/opencode/src/plugin/auth-drift.ts` — `detectAuthStorageDrift`: compares the host's `getAuth()` result against the on-disk pool and surfaces a `restorable` path.
+- `packages/opencode/src/plugin/accounts.ts` — host-side `AccountManager` factory. Stores pool via `plugins/opencode/storage.ts`, exports `getCurrentOrNextForFamily`, `markAccountCoolingDown`, `markRateLimitedWithReason`.
+- `packages/opencode/src/plugin/account-access.ts` — CLI-facing account prompts (`promptAccountIndexForVerification`, `promptOpenVerificationUrl`, `createAccountAccessService`).
+- `packages/opencode/src/plugin/storage.ts` — host-path adapter: resolves `OPENCODE_CONFIG_DIR`, handles `%APPDATA%` on Windows, syncs the on-disk `.gitignore`. Delegates all data operations to `core/account-storage.ts`.
+- `packages/opencode/src/plugin/persist-account-pool.ts` — lock-held append-and-rewrite of `antigravity-accounts.json` used after a fresh OAuth login.
+- `packages/opencode/src/plugin/fetch-interceptor.ts` — `createFetchInterceptor` — the largest file in the tree. Outer loop picks an account, inner loop walks `ANTIGRAVITY_ENDPOINT_FALLBACKS`, retry/quota/routing pipeline, soft-quota + killswitch gates (killswitch evaluation is model-aware: a `gemini-pro` request checks ONLY the `gemini-pro` quota group via a precomputed `eligibleIndexes` Set that is reused after core selection and after the quota-fallback re-selection), sideline `setSidebarMachineState` writes, `transformAntigravityResponse` reverse-transform.
+- `packages/opencode/src/plugin/fetch/retry-state.ts` — per-interceptor `RetryState` (rate-limit toast debounce, retry counts).
+- `packages/opencode/src/plugin/fetch/warmup.ts` — `WarmupState` (probe accounts under load to detect soft-quota cliffs before they surprise the dispatcher).
+- `packages/opencode/src/plugin/fetch-routing.ts` — `resolveHeaderRoutingDecision`, `resolveQuotaFallbackHeaderStyle`, `getCurrentRoutingDecision` (live override from operator settings).
+- `packages/opencode/src/plugin/request.ts` — outbound transform pipeline (sanitize → resolve → inject metadata → order payload → harden).
+- `packages/opencode/src/plugin/request-helpers.ts` — `cleanGenerativeLanguageRequest`, thinking-block filtering, tool-id orphan recovery glue.
+- `packages/opencode/src/plugin/agy-request-metadata.ts` — host-side adapter around `core/agy-request-metadata.ts` with the session registry host binding.
+- `packages/opencode/src/plugin/agy-transport.ts` — host-side adapter around `core/agy-transport.ts`; thin wrapper for the dependency-injected transport.
+- `packages/opencode/src/plugin/transform/cross-model-sanitizer.ts` — host-side re-export of the core sanitizer (kept separate so a future OpenCode-specific override can fork without touching core).
+- `packages/opencode/src/plugin/transform/model-resolver.ts` — host-side re-export.
+- `packages/opencode/src/plugin/transform/types.ts` — host-side re-export.
+- `packages/opencode/src/plugin/transform/index.ts` — host-side barrel.
+- `packages/opencode/src/plugin/quota.ts` — `createOpenCodeQuotaManager` (the host wrapper over `core/quota-manager.ts`). Hands a `getAccountsForSidebar` closure to the core manager so quota refreshes push redacted sidebar snapshots.
+- `packages/opencode/src/plugin/refresh-queue.ts` — `createProactiveRefreshQueue` — background timer that refreshes tokens before the access token expires.
+- `packages/opencode/src/plugin/rotation.ts` — host thin wrapper around `core/rotation.ts`; pushes `initHealthTracker` and `initTokenTracker` per the user config.
+- `packages/opencode/src/plugin/token.ts` — `refreshAccessToken` — called by the fetch interceptor when a token is within the 60s expiry buffer.
+- `packages/opencode/src/plugin/oauth-methods.ts` — interactive OAuth menu (`Add another`, `Refresh`, `Check quotas`, `Verify accounts`, `Doctor`, `Manage`, `Cancel`); also drives `authorizeAntigravity` and `exchangeAntigravity` from core.
+- `packages/opencode/src/plugin/oauth-login.ts` — `performOAuthLogin`: orchestrates the `startOAuthListener` + `exchangeAntigravity` flow for the CLI path.
+- `packages/opencode/src/plugin/server.ts` — `startOAuthListener` (binds `http://localhost:51121` for the redirect, falls back to manual paste for headless / WSL2).
+- `packages/opencode/src/plugin/catalog.ts` — `applyAntigravityProviderCatalog` + `registerAntigravityCommands` — handles `config.provider.*` and `config.command.*` injection.
+- `packages/opencode/src/plugin/commands.ts` — `applyCommand` (RPC `apply` callback), `createCommandExecuteBefore`, `createSidebarRefresher`. The single wiring point for `/antigravity-*` slash commands.
+- `packages/opencode/src/plugin/cli.ts` — small terminal prompts (`promptProjectId`, `promptAddAnotherAccount`) used by the OAuth method flow.
+- `packages/opencode/src/plugin/ui/auth-menu.ts` — full-screen interactive auth menu renderer (`showAuthMenu`, `showAccountDetails`, `showFingerprintHistory`).
+- `packages/opencode/src/plugin/ui/model-status.ts` — per-model quota status rendering helpers.
+- `packages/opencode/src/plugin/ui/quota-status.ts` — quota summary rendering helpers.
+- `packages/opencode/src/plugin/ui/select.ts` — primitive select-with-description list (kept free of the host's `hide-property` so invalid options stay visible).
+- `packages/opencode/src/plugin/ui/confirm.ts` — primitive confirm dialog.
+- `packages/opencode/src/plugin/ui/ansi.ts` — ANSI helpers shared by the UI primitives.
+- `packages/opencode/src/plugin/event-handler.ts` — `createEventHandler` — host event hook (session.created / session.idle etc.).
+- `packages/opencode/src/plugin/recovery.ts` — `createSessionRecoveryHook` — auto-recovers sessions from `tool_result_missing` errors.
+- `packages/opencode/src/plugin/recovery/index.ts` — recovery subsystem barrel.
+- `packages/opencode/src/plugin/recovery/types.ts` — recovery state types.
+- `packages/opencode/src/plugin/recovery/constants.ts` — recovery limits (max attempts, retry delays).
+- `packages/opencode/src/plugin/recovery/storage.ts` — durable on-disk state for in-flight recovery attempts.
+- `packages/opencode/src/plugin/thinking-recovery.ts` — recovery for thinking-block signature mismatches (Gemini `skip_thought_signature_validator` path).
+- `packages/opencode/src/plugin/killswitch.ts` — `evaluateKillswitchForAccount`, `throwIfAllKilled` — operator-tuned per-family/minimum-remaining-percent gate. Evaluation is model-aware via the `model`/`quotaModel` option: a `gemini-pro` request checks ONLY the `gemini-pro` quota group, not the max of pro+flash.
+- `packages/opencode/src/plugin/operator-settings.ts` — `createOperatorSettingsController`: the runtime-mutable settings store (routing flags, killswitch, log level). Persists through the fenced-lock writer.
+- `packages/opencode/src/plugin/config/schema.ts` — Zod schema for `antigravity.json` (the public config schema). Schema description source for `script/build-schema.ts`.
+- `packages/opencode/src/plugin/config/loader.ts` — `loadConfig(directory)`, `loadConfigFile`. Computes the legacy Windows migration fallback. Swallows malformed JSON.
+- `packages/opencode/src/plugin/config/writer.ts` — fenced-lock writer for the user config (used by the operator settings controller).
+- `packages/opencode/src/plugin/config/updater.ts` — `updateOpencodeConfig` — generic writer helper for the host's opencode.json.
+- `packages/opencode/src/plugin/config/models.ts` — `OPENCODE_MODEL_DEFINITIONS` literal copy + the host `Model` adapter.
+- `packages/opencode/src/plugin/config/operator-settings-schema.ts` — Zod schema for the `operator.*` subtree.
+- `packages/opencode/src/plugin/config/index.ts` — barrel for the config subtree; also calls `loadConfig` + `initRuntimeConfig`.
+- `packages/opencode/src/plugin/cache/index.ts` — barrel for the cache subtree.
+- `packages/opencode/src/plugin/cache.ts` — backwards-compatible thin re-export of `cache/index.ts` (kept for legacy callers).
+- `packages/opencode/src/plugin/cache/signature-cache.ts` — disk-backed cache for thinking-block signatures.
+- `packages/opencode/src/plugin/stores/signature-store.ts` — `SignatureStore` persistence interface + memory-backed default.
+- `packages/opencode/src/plugin/core/streaming/index.ts` — barrel for the streaming subsubsystem.
+- `packages/opencode/src/plugin/core/streaming/transformer.ts` — the SSE reverse-transform: strips Antigravity envelope, splices `thoughtSignature`, aggregates `usageMetadata`, produces a `ReadableStream`.
+- `packages/opencode/src/plugin/core/streaming/types.ts` — `SignatureStore`, `StreamingCallbacks`, `StreamingOptions`, `StreamingUsageMetadata`, `ThoughtBuffer`.
+- `packages/opencode/src/plugin/debug.ts` — debug file logger for the server plugin (writes to the user-configured `log_dir`). Project IDs and fingerprint-form User-Agents are masked via `redactSensitive` before emission, and the logged request-body preview is field-redacted via `redactBodyForLog` so a project ID embedded in the body never leaks verbatim.
+- `packages/opencode/src/plugin/errors.ts` — domain error classes (`AntigravityKillswitchError`, `AntigravityTokenRefreshError`, `LiveNetworkDeniedError`, etc.).
+- `packages/opencode/src/plugin/fingerprint.ts` — host thin wrapper that pins the `FingerprintVersion` for an account.
+- `packages/opencode/src/plugin/gemini-dump.ts` — `/gemini-dump` modal command — saves raw payload/response to disk for debugging. Session IDs and project IDs are masked via `maskIdentifier`; header User-Agents are redacted by `redactForDump`; the request body written to disk is field-redacted via `redactJsonBodyString` so project IDs embedded in the body are masked.
+- `packages/opencode/src/plugin/google-search-tool.ts` — server-side `google_search` tool implementation.
+- `packages/opencode/src/plugin/image-saver.ts` — writes Gemini image-generation output to disk and returns the file path back to the caller.
+- `packages/opencode/src/plugin/logger.ts` — structured logger (`createLogger`, `initLogger`, `setRuntimeLogLevel`).
+- `packages/opencode/src/plugin/logging-utils.ts` — small ANSI/console helpers plus `redactSensitive` / `redactSensitiveFields` (case-insensitive walk over `token|refresh|access|project|fingerprint|deviceId|sessionId|sessionToken|secret|password|apiKey|clientSecret` keys — `project` catches bare `project` / `managedProjectId` body fields), and `redactJsonBodyString` / `redactBodyForLog` which field-redact a serialized JSON request body before it reaches a debug log or dump file.
+- `packages/opencode/src/plugin/model-registry.ts` — host overlay on `core/model-registry.ts` (OpenCode `Model` shape + `providerOptions` defaults).
+- `packages/opencode/src/plugin/project.ts` — host thin wrapper around `core/project.ts`.
+- `packages/opencode/src/plugin/prompt-context.ts` — extracts the active prompt context (workspace + system prompt) for cross-model sanitizer decisions.
+- `packages/opencode/src/plugin/search.ts` — `executeSearch` Google Search grounding tool implementation.
+- `packages/opencode/src/plugin/session-context.ts` — `AgySessionRegistry`, per-session `AgyRequestSessionStore` registration.
+- `packages/opencode/src/plugin/version.ts` — runs once at plugin boot to publish the installed plugin version (used by the auto-update checker).
+- `packages/opencode/src/plugin/host-api-compatibility.test.ts` — pins the opencode host SDK to a stable property subset (so a host bump that moves a name does not break us silently).
+
+### Host lifecycle hooks
+
+`packages/opencode/src/hooks/auto-update-checker/` — a self-contained module that runs in the background of the host:
+
+- `packages/opencode/src/hooks/auto-update-checker/index.ts` — barrel; `createAutoUpdateCheckerHook` is the host-side hook entry point.
+- `packages/opencode/src/hooks/auto-update-checker/checker.ts` — the actual update check: `npm view @cortexkit/opencode-antigravity-auth version` + auto-pin routine.
+- `packages/opencode/src/hooks/auto-update-checker/cache.ts` — on-disk cache of the last known good version.
+- `packages/opencode/src/hooks/auto-update-checker/constants.ts` — check interval, npm registry URL.
+- `packages/opencode/src/hooks/auto-update-checker/logging.ts` — internal logger.
+- `packages/opencode/src/hooks/auto-update-checker/types.ts` — types for the hook's report.
+
+### Co-located tests under `packages/opencode/src/`
+
+The plugin directory mirrors the source tree with `*.test.ts` siblings for nearly every file: `account-access`, `account-ineligibility.persistence`, `account-ineligibility`, `accounts`, `agy-transport`, `antigravity-first-fallback`, `auth-doctor`, `auth-drift`, `auth-loader`, `auth`, `cache`, `catalog`, `commands`, `core/streaming/transformer`, `debug`, `errors`, `event-handler`, `fetch-interceptor`, `fetch/retry-state`, `fetch-routing`, `fetch/warmup`, `gemini-dump`, `google-search-tool`, `host-api-compatibility`, `image-saver`, `killswitch`, `lifecycle`, `logger`, `logging-utils`, `model-specific-quota`, `oauth-methods`, `operator-settings`, `persist-account-pool`, `quota-fallback`, `quota`, `recovery`, `reexports`, `refresh-queue`, `request-helpers`, `request`, `rotation`, `search`, `session-context`, `storage`, `stores/signature-store`, `thinking-recovery`, `token`, plus the UI subtree (`ui/ansi`, `ui/auth-menu.actions`, `ui/auth-menu`, `ui/model-status`, `ui/quota-status`), `version`, and the host-SDK pins.
+
+Top-level co-located tests under `packages/opencode/src/`: `cli.test.ts`, `constants.test.ts`, `plugin-entry.test.ts`, `sidebar-state.test.ts`, `rpc/notifications.test.ts`, `rpc/port-file.test.ts`, `rpc/rpc-client.test.ts`, `rpc/rpc-server.test.ts`, and the TUI tests `tui/command-dialogs.test.tsx`, `tui/file-logger.test.ts`, `tui.test.tsx`. Run via `bun run --cwd packages/opencode test` (delegates to `bun test --isolate ./src`).
+
+## OpenCode TUI and RPC inventory
+
+The TUI is a thin Solid sidebar that polls a redacted snapshot file and a loopback HTTP RPC. Two halves of the contract: the **read seam** (sidebar file) and the **bidirectional seam** (RPC + bearer token).
+
+### Sidebar snapshot
+
+- `packages/opencode/src/sidebar-state.ts` — `SidebarStateV1` schema, `getSidebarStateFile()`, `setSidebarMachineState`, `mergeMachineState`, `upsertSidebarActiveRouting`, `buildSidebarMachineStateFromAccounts`, `drainSidebarWrites`. Header (lines 1-38) documents the split between server-side reads/writes and TUI-side reads.
+
+### `packages/opencode/src/rpc/` — the loopback HTTP RPC
+
+- `packages/opencode/src/rpc/protocol.ts` — wire types: `CommandModalName` (six modal commands), `OpenDialogPayload`, `RpcNotification`, `ApplyRequest`, `ApplyResult`.
+- `packages/opencode/src/rpc/rpc-server.ts` — `startRpcServer({ dir, apply, drain })`. Binds `127.0.0.1:0`, mints a 32-byte hex bearer token, writes the port file, serves `POST /rpc/apply` (server timeout 120s) and `POST /rpc/pending-notifications` (request timeout 2s). Uses `timingSafeEqual` for the bearer check.
+- `packages/opencode/src/rpc/rpc-client.ts` — `createRpcClient(dir, expectedPid)` — discovers the port file, posts JSON with `Authorization: Bearer `, enforces a 2s default via `fetchWithActiveTimeout`.
+- `packages/opencode/src/rpc/port-file.ts` — `writePortFile` / `discoverPortFile`. `/port-.json` with `{ pid, port, token }`, mode `0o600`, atomic via `renameSync` from a `.tmp` sibling.
+- `packages/opencode/src/rpc/rpc-dir.ts` — `getRpcDir`: `ANTIGRAVITY_AUTH_RPC_DIR` env wins, else `/cortexkit/antigravity-auth/rpc/`.
+- `packages/opencode/src/rpc/notifications.ts` — bounded queue (max 100 entries), `pushNotification`, `drainNotifications(lastReceivedId, sessionId?)`. `isTuiConnected` reflects whether the TUI polled within `CONNECTION_TTL_MS = 5000`.
+- Tests: `notifications.test.ts`, `port-file.test.ts`, `rpc-client.test.ts`, `rpc-server.test.ts`.
+
+### TUI render tree (`packages/opencode/src/tui/`, `packages/opencode/src/tui.tsx`)
+
+- `packages/opencode/src/tui.tsx` — `@jsxImportSource @opentui/solid`. The `TuiPlugin` default export registers the `slots.sidebar_content` slot. Hard rules for the file are documented in its module header (lines 1-22).
+- `packages/opencode/src/tui/entry.mjs` — host-aware loader. Probes `opentui:runtime-module:`; falls back to the raw `tui.tsx` if the host has no runtime module resolver.
+- `packages/opencode/src/tui/command-dialogs.tsx` — `DialogSelect` / `DialogConfirm` / `DialogPrompt` trees mounted from RPC notifications. Every `MODAL_COMMANDS` option has a stable `key` and an explanatory `description`.
+- `packages/opencode/src/tui/file-logger.ts` — write-only file logger under `/cortexkit/antigravity-auth/tui.log` (mode `0o600`, 1 MB tail-truncated). Parent dir is forced to `0o700` after every write and existing log files are re-`chmod`-ed to `0o600` to repair any leaked permissions. Never writes to stdout/stderr.
+- `packages/opencode/src/tui/ansi.ts` — small ANSI helpers used by the dialog tree.
+- Tests: `tui.test.tsx`, `tui/command-dialogs.test.tsx`, `tui/file-logger.test.ts`.
+
+## `packages/pi` inventory
+
+`@cortexkit/pi-antigravity-auth`. A small extension that bridges the Pi runtime's `registerProvider` API to the core transport. Layout:
+
+- `packages/pi/bunfig.toml` — `preload = ["../../test/setup.ts", "@opentui/solid/preload"]`. (OpenTUI preload is harmless here; the Pi extension itself does not use `@opentui/solid`.)
+- `packages/pi/package.json` — declares `main` on `./dist/index.js`, `peerDependencies` on `@earendil-works/pi-*`, runtime dependency only on `@cortexkit/antigravity-auth-core`.
+- `packages/pi/tsconfig.json`, `packages/pi/tsconfig.build.json` — library + build configs.
+- `packages/pi/LICENSE`, `packages/pi/README.md` — MIT + readme.
+
+### Source (`packages/pi/src/`)
+
+- **`packages/pi/src/index.ts`** — package entry. `providerId = 'google-antigravity'`; registers the provider with `pi.registerProvider`; defines the OAuth login + refresh flows against `core/antigravity/oauth.ts`.
+- `packages/pi/src/stream.ts` — `streamCortexKitAntigravity`: streams through `core/agy-transport.ts` + the core transforms, replays the SSE into Pi's `AssistantMessage`.
+- `packages/pi/src/convert.ts` — request/response converters between Pi's `Model` types and the core `RequestPayload`.
+- `packages/pi/src/credential-cache.ts` — stashes the packed `refreshToken|projectId|managedProjectId` triple so the stream can rejoin project context after the access token is stripped.
+- `packages/pi/src/paths.ts` — resolves the `PI_AGENT_DIR` and `PI_ANTIGRAVITY_AUTH_FILE` paths on every platform (`%APPDATA%` on Windows, `~/.pi/agent/` elsewhere).
+- `packages/pi/src/index.test.ts`, `convert.test.ts`, `credential-cache.test.ts`, `stream.test.ts` — co-located tests.
+
+## `packages/e2e-tests` inventory
+
+`@cortexkit/antigravity-auth-e2e` — `private: true`, not published. Houses deterministic black-box flows against a mock Antigravity loopback server.
+
+- `packages/e2e-tests/bunfig.toml` — `preload = ["./src/setup.ts"]`, `root = "./src"` so the e2e-runner only loads this workspace's tests.
+- `packages/e2e-tests/tsconfig.json` — extends `@tsconfig/bun`.
+- `packages/e2e-tests/package.json` — `private`, depends on `workspace:*` core + opencode packages.
+
+### Source (`packages/e2e-tests/src/`)
+
+- `packages/e2e-tests/src/setup.ts` — per-test temp root + env reset (`HOME`, `XDG_*`, `APPDATA`, `LOCALAPPDATA`, `OPENCODE_CONFIG_DIR`, `PI_AGENT_DIR`, `PI_ANTIGRAVITY_AUTH_FILE`, `OPENCODE_ANTIGRAVITY_GEMINI_DUMP_DIR`, `ANTIGRAVITY_AUTH_RPC_DIR`, `ANTIGRAVITY_AUTH_SIDEBAR_STATE_FILE`), plus a loopback-only `globalThis.fetch` guard (`installFetchGuard` / `restoreFetchGuard`, line 51-84) that throws `LiveNetworkDeniedError` for any non-loopback URL. The guard is installed in `beforeEach` and restored in `afterEach` so a stray live URL surfaces as a real exception that points at the offending call site. Each isolated test file's final `afterAll` disposes harnesses scoped to its roots before deleting them; a post-delete `existsSync` check throws if a root survives. The opt-in orphan sweep (`AGY_E2E_SWEEP_ORPHANS=1`) only removes `agy-e2e-*` entries older than 24h by mtime.
+- `packages/e2e-tests/src/harness.ts` — `E2eHarness`: builds a mock loopback server, a `PluginDependencyOverrides` bag that points the production plugin at `127.0.0.1`, and `createPlugin()` / `runCli()` helpers. The single seam every e2e test uses to talk to the plugin under test.
+- `packages/e2e-tests/src/fetch-router.ts` — `installFetchRouter` — routes `globalThis.fetch` through the mock when the URL targets the loopback server.
+- `packages/e2e-tests/src/mock-antigravity-server.ts` — `startMockAntigravityServer` + `MockServerHandle`. Configurable routes, scripted responses, request recorder.
+- `packages/e2e-tests/src/process-runner.ts` — `process-runner`: spawns the `antigravity-auth` CLI binary in a temp dir and pins stdout/stderr for assertions.
+- `packages/e2e-tests/src/cli-flow.e2e.test.ts` — CLI flows (login, refresh, quota check) against the mock.
+- `packages/e2e-tests/src/plugin-flow.e2e.test.ts` — full plugin instance lifecycle against the mock.
+- `packages/e2e-tests/src/rpc-tui-flow.e2e.test.ts` — RPC server boot + notification drain end-to-end.
+- `packages/e2e-tests/src/fetch-guard.test.ts` — pins the loopback-only `globalThis.fetch` guard: loopback URLs pass through, non-loopback URLs throw `LiveNetworkDeniedError`, the guard is restored in `afterEach`.
+- Unit tests under this workspace: `mock-antigravity-server.test.ts`, `process-runner.test.ts`.
+- Run via `bun run test:e2e` at the repo root (delegates to `bun test --isolate ./packages/e2e-tests/src/plugin-flow.e2e.test.ts ./packages/e2e-tests/src/cli-flow.e2e.test.ts ./packages/e2e-tests/src/rpc-tui-flow.e2e.test.ts ./packages/e2e-tests/src/fetch-guard.test.ts`).
+
+## Root tooling and CI
+
+### `scripts/`
+
+- `scripts/dev.ts` — `bun scripts/dev.ts`. Resolves dev paths, symlinks `packages/opencode/dist/index.js` into `.opencode/plugins/antigravity-auth.js`, then runs `tsc --watch` on core and `esbuild --watch` on opencode in parallel until SIGINT/SIGTERM. `--check` validates the symlink and exits.
+- `scripts/dev-clean.ts` — inverse of `dev.ts`; removes the development symlink.
+- `scripts/version-sync.mjs` — synchronized version bump for `packages/{core,opencode,pi}/package.json` (and the `@cortexkit/antigravity-auth-core` pin in dependents). Supports `--dry-run` and `--from-tag` (driven by `GITHUB_REF_NAME` in CI).
+- `scripts/release.sh` — semver validate + dry-run flag, pre-flight `bun run typecheck` / `bun run test` / `bun run build`, sync versions, commit, tag `v$X.Y.Z`, push to origin. GitHub Actions then runs the publish matrix.
+
+### `test/` (repo-root preload + sanity)
+
+- `test/setup.ts` — installs `mkdtempSync` test root + env overrides on every package workspace, defines `globalThis.stubbed` / `unstubAllGlobals` / `freshImport`, and patches `jest.setSystemTime` / `jest.useRealTimers` to also spy on `Date.now()`.
+- `test/global.d.ts` — type declarations for the helpers `test/setup.ts` installs.
+- `test/environment.test.ts` — pins the env-isolation contract (`HOME`, `XDG_*`, `APPDATA`, `OPENCODE_CONFIG_DIR`, `PI_AGENT_DIR`, etc.).
+- `test/dev.test.ts` — pins `scripts/dev.ts`'s `resolveDevPaths` / `createDevSymlink` / `removeDevSymlink` so a symlink-layout regression fails before the next release.
+- `test-fixtures/agy-cli-1.1.3-model-metadata.json` and `test-fixtures/agy-cli-1.1.3-stream-request.json` — frozen snapshots of the upstream `agy` CLI for comparator tests.
+- `test-fixtures/agy-cli-1.1.5-model-metadata.json` and `test-fixtures/agy-cli-1.1.5-stream-request.json` — newer frozen snapshots.
+
+### `tsconfig.scripts.json`
+
+Project-wide tsconfig used by `bun run typecheck` (root script `typecheck`) for `scripts/**/*.ts`, `test/**/*.ts`, and `packages/e2e-tests/src/**/*.ts`.
+
+### Root level
+
+- `package.json` — root monorepo (`@cortexkit/antigravity-auth`, `private: true`, `workspaces: [packages/*]`). Exposes `build`, `typecheck`, `test`, `test:e2e`, `test:e2e:models`, `test:e2e:regression`, `dev`, `dev:clean`, `format`, `format:check`, `lint`, `pack:core:dry`, `pack:opencode-dry`, `pack:pi-dry`.
+- `biome.json` — formatter + linter config (`indent: 2 spaces`, `semicolons: asNeeded`, `quoteStyle: single`). Test files (`**/*.test.ts`) and `packages/opencode/src/plugin/ui/select.ts` carry override files that disable a small set of rules where the test naturally needs them.
+- `bunfig.toml` — root: `preload = ["./test/setup.ts", "@opentui/solid/preload"]`.
+- `lefthook.yml` — pre-commit: `biome check --staged --no-errors-on-unmatched` on `*.{ts,tsx,js,mjs,json,jsonc,yml,yaml}`.
+- `mise.toml` — pins `bun = "1.3"` and `node = "24"`.
+- `bun.lock` — committed; CI uses `bun install --frozen-lockfile`.
+- `models` — tracked JSON snapshot of the upstream model inventory (referenced by the e2e tests for drift assertions).
+
+### `.github/`
+
+- `.github/workflows/ci.yml` — push/PR to `main`: Setup Node 24 + Bun 1.3, `bun install --frozen-lockfile`, `bun run typecheck`, `bun run build`, `bun run --cwd packages/opencode smoke:tui`, `bun test`, `bun run test:e2e`, `bun run format:check`, `bun run lint`.
+- `.github/workflows/release.yml` — on `v*` tag push + workflow_dispatch: runs the same checks (test/build/e2e/format/lint) before publishing via OIDC trusted publishing. `publish-npm` is a matrix job that publishes core, then opencode, then pi, each from a fresh job so npm obtains a per-package OIDC token. Closes with a `softprops/action-gh-release` GitHub Release.
+- `.github/workflows/issue-triage.yml` — self-hosted Opencode Triage agent: parses label output, applies `bug`/`enhancement`/`documentation`/`question`/`invalid` + `area/auth`/`area/models`/`area/config`/`area/compat`, posts an automated reply, and closes duplicates.
+- `.github/FUNDING.yml`, `.github/ISSUE_TEMPLATE/bug_report.yml`, `.github/ISSUE_TEMPLATE/feature_request.yml`, `.github/ISSUE_TEMPLATE/config.yml`.
+
+## Entry points and package exports
+
+| Package | File | Symbol | Purpose |
+| --- | --- | --- | --- |
+| `@cortexkit/antigravity-auth-core` | `packages/core/src/index.ts` | (barrel) | All harness-agnostic exports — re-exported per file. |
+| `@cortexkit/opencode-antigravity-auth` (`server`) | `packages/opencode/index.ts` | `AntigravityCLIOAuthPlugin`, `GoogleOAuthPlugin` | Host plugin registrations (bound factory with `ANTIGRAVITY_PROVIDER_ID = 'google'`). |
+| `@cortexkit/opencode-antigravity-auth` (`server` — raw) | `packages/opencode/src/plugin/index.ts` | `createAntigravityPlugin`, `PluginResult` | Direct composition root for tests + non-host callers. |
+| `@cortexkit/opencode-antigravity-auth` (`server` — CLI) | `packages/opencode/src/cli.ts` | default export | Standalone `antigravity-auth` CLI entry. Build emits `dist/cli.js`. |
+| `@cortexkit/opencode-antigravity-auth` (`tui`) | `packages/opencode/src/tui/entry.mjs` | `{ id: 'cortexkit.antigravity-auth', tui }` | OpenTUI sidebar entry. Resolves through `dist/src/tui.d.ts` (types) + `src/tui/entry.mjs` (loader). |
+| `@cortexkit/opencode-antigravity-auth` (`server` — fallback) | `packages/opencode/src/plugin-entry.test.ts` | test entry | Verifies the public barrel exports the two stable plugin names. |
+| `@cortexkit/pi-antigravity-auth` | `packages/pi/src/index.ts` | `default function` | Pi extension. Resolves as `pi.extensions: ['./dist/index.js']` per `packages/pi/package.json:34-38`. |
+
+The `exports` map at `packages/opencode/package.json` declares both `.` (the bundled server root) and `./tui` (the OpenTUI loader). The host's `opencode plugin` installer reads the subpaths and writes a server entry to `opencode.json` plus a TUI entry to `tui.json`; the host then resolves them as two independent plugin registrations.
+
+## Generated and ignored artifacts
+
+These directories exist at build time but are **gitignored** (see `.gitignore`). They are not part of the source tree:
+
+- `node_modules/` — Bun-installed dependencies, workspace-wide. Ignored.
+- `dist/` — `packages/core/dist/`, `packages/opencode/dist/`, `packages/pi/dist/`. Per-package `tsc --build` + esbuild output. Ignored. The published `files` allowlist (`packages/opencode/package.json:46-61`, etc.) packages `dist/` explicitly so the build output is what ships on npm.
+- `*.tgz` — `bun pm pack --dry-run` output staging. Ignored.
+- `coverage/`, `*.lcov`, `logs/`, `*.log`, `_.log`, `antigravity-debug-*.log` — coverage + log output. Ignored.
+- `*.tsbuildinfo` — TypeScript incremental compile cache. Ignored.
+- `.opencode/` — IDE + local OpenCode plugin dir. The root `.gitignore` ignores it; `scripts/dev.ts` makes `.opencode/plugins/antigravity-auth.js` a symlink during dev.
+- Agent working directories: `.hive/`, `.omo/`, `.opencode/`, `.sisyphus/`, `.alfonso/` — session state, historian dumps, run continuation, council runs. All ignored.
+- `test-file.ts` — temporary scratch file used by the dev loop. Ignored.
+- `package-lock.json` — npm optional-dep native-binary workaround. Ignored.
+
+### The precompiled OpenTUI tree
+
+`packages/opencode/src/tui-compiled/` is the **only** directory in the tree that ships in the npm tarball but is **gitignored** at the source level. It is rebuilt from source every release.
+
+- **Source manifest:** `packages/opencode/scripts/build-tui.ts` — `buildTui({ packageRoot })` walks the relative static import graph rooted at `src/tui.tsx`, transforms each `.tsx` through `@opentui/solid/scripts/solid-transform` (rewriting virtual runtime modules to `opentui:runtime-module:`), and copies non-TSX sibling files as-is.
+- **Shipped source allowlist:** `packages/opencode/scripts/build-tui.ts:91-102` (`SHIPPED_SOURCE_FILES`) is the canonical allowlist that ships — `src/tui.tsx`, `src/tui/entry.mjs`, `src/tui/ansi.ts`, `src/tui/command-dialogs.tsx`, `src/tui/file-logger.ts`, `src/sidebar-state.ts`, `src/rpc/rpc-client.ts`, `src/rpc/rpc-dir.ts`, `src/rpc/port-file.ts`, `src/rpc/protocol.ts`.
+- **Behavior:** the loader `src/tui/entry.mjs` probes `opentui:runtime-module:`; if that succeeds it loads `../tui-compiled/tui.tsx` (the host has a runtime-module resolver), otherwise it loads the dev fallback `../tui.tsx` via `@opentui/solid/preload`. `src/tui/entry.mjs` is intentionally NOT copied into the compiled tree — it ships via the `src/tui/` directory entry in `package.json` files.
+- **Test:** `packages/opencode/scripts/build-tui.test.ts` pins the SHIPPED_SOURCE_FILES list, the import-graph traversal, the transformed output, and the TUI's import graph against the credential-module allowlist (`account-manager`, `account-storage`, `oauth`, `quota-manager`, `rotation` — the latter via `FORBIDDEN_PATTERNS`).
+- **Pack smoke test:** `packages/opencode/scripts/smoke-tui-pack-install.ts` packs core + opencode via `bun pm pack`, installs them into a real consumer workspace with `bun install --no-save`, then resolves `@cortexkit/opencode-antigravity-auth/.` and `@cortexkit/opencode-antigravity-auth/tui` through the export map and asserts the file shipped by the tarball lines up with the path the loader expects. Run via `bun run --cwd packages/opencode smoke:tui`.
+- **Schema JSON:** `packages/opencode/assets/antigravity.schema.json` is also a generated artifact, produced by `packages/opencode/script/build-schema.ts` from `packages/opencode/src/plugin/config/schema.ts` (the Zod schema). Run via `bun run --cwd packages/opencode build:schema`.
+
+## Naming and test conventions
+
+- **Files / directories:** `kebab-case.ts` — `account-manager.ts`, `fetch-interceptor.ts`, `auto-update-checker/`, `core/streaming/`. No exceptions in tracked source.
+- **Test files:** co-located `*.test.ts` siblings (e.g. `account-storage.ts` ↔ `account-storage.test.ts`). The single exception is the e2e workspace, which uses `*.e2e.test.ts` for the three black-box flows so the root `bun test` and `bun test:e2e` selectors can target them precisely.
+- **Types / interfaces:** `PascalCase` — `AccountManager`, `AntigravityConfig`, `SidebarStateV1`, `PluginResult`.
+- **Functions / variables:** `camelCase` — `getCurrentOrNextForFamily`, `resolveModelWithTier`, `setSidebarMachineState`.
+- **Constants:** `UPPER_SNAKE_CASE` — `ANTIGRAVITY_PROVIDER_ID`, `CLAUDE_TOOL_SYSTEM_INSTRUCTION`, `SKIP_THOUGHT_SIGNATURE`, `DEFAULT_AGY_RESPONSE_HEADER_TIMEOUT_MS`.
+- **Test framework:** Bun's bundled test runner (`bun test`); tests can spell helpers as `bun:test` (`describe`, `it`, `expect`, `beforeEach`, `afterEach`, `jest` namespace). `bunfig.toml` in every workspace preloads `./test/setup.ts` for env isolation.
+- **Assertion style:** `expect(actual).toBe(...)`, `.toEqual(...)`, `.toMatchObject(...)`; no third-party assertion library.
+- **Test isolation env vars:** `ANTIGRAVITY_TEST_ROOT`, `HOME`, `USERPROFILE`, `XDG_CONFIG_HOME`, `XDG_CACHE_HOME`, `XDG_DATA_HOME`, `XDG_STATE_HOME`, `APPDATA`, `LOCALAPPDATA`, `OPENCODE_CONFIG_DIR`, `OPENCODE_ANTIGRAVITY_GEMINI_DUMP_DIR`, `PI_AGENT_DIR`, `PI_ANTIGRAVITY_AUTH_FILE`, `ANTIGRAVITY_AUTH_RPC_DIR`, `ANTIGRAVITY_AUTH_SIDEBAR_STATE_FILE`. Set automatically by `test/setup.ts`.
+- **Formatting:** Biome 2.5.3, two-space indent, semicolons omitted, single quotes, trailing commas. The root runner is `bun run format:check` and `bun run lint`.
+
+## Dependency-direction rules
+
+```
+ ┌─────────────────┐
+ │ packages/core │ ← harness-agnostic. Node built-ins only.
+ │ │ Owns: OAuth, transport, account pool,
+ │ │ rotation, quota, persistence, transforms.
+ └────────▲────────┘
+ │
+ ┌─────────────────┴─────────────────┐
+ │ │
+ ┌────┴─────────┐ ┌────────┴────────┐
+ │ packages/ │ │ packages/pi │ ← depends only on core.
+ │ opencode │ │ Pi runtime via │
+ │ server │ │ peerDeps only. │
+ │ plugin │ └─────────────────┘
+ │ │ │
+ │ ├──────┴──┐ TUI split is via file + RPC, not imports.
+ │ │ sidebar │
+ │ │ state │
+ │ │ + │
+ │ │ RPC │
+ │ ▼ ▼
+ │ ┌────────┐ ┌──────────┐
+ │ │ rpc/ │ │ tui.tsx │
+ │ │ (server)│ │ (TUI) │
+ │ └────────┘ └──────────┘
+ └─────────────────────────┘
+ ▲
+ │ (e2e workspace consumes server plugin under test)
+ ┌──────┴───────┐
+ │ packages/ │ ← private, depends on core + opencode.
+ │ e2e-tests │
+ └──────────────┘
```
-## Directory Purposes
-
-**`packages/core`:**
-- Purpose: Provides harness-agnostic utilities for Google Antigravity integrations.
-- Contains: Direct TCP/TLS socket transport, Google OAuth PKCE auth flow, Google Cloud project bootstrapping, Claude and Gemini request/response transforms, device fingerprint generators, and centralized model registries.
-- Key files: `packages/core/src/agy-transport.ts` (TCP/TLS socket), `packages/core/src/project.ts` (project resolution), `packages/core/src/transform/model-resolver.ts` (model mapping), `packages/core/src/model-registry.ts` (model definitions & route maps).
-
-**`packages/opencode`:**
-- Purpose: Integrates the Antigravity authentication and request transformation logic into the OpenCode host environment.
-- Contains: Fetch interceptors, account managers, interactive CLI authorization menus, signature caching, and session error recovery hooks.
-- Key files: `packages/opencode/src/plugin.ts` (main orchestrator), `packages/opencode/src/plugin/accounts.ts` (AccountManager), `packages/opencode/src/plugin/config/schema.ts` (Zod schema).
-
-**`packages/pi`:**
-- Purpose: Bridges the Antigravity core library into the Pi coding agent environment as a custom provider extension.
-- Contains: Pi-compatible authorization flow handlers and streaming translators.
-- Key files: `packages/pi/src/index.ts` (provider setup), `packages/pi/src/stream.ts` (stream mapping).
-
-## Key File Locations
-
-**Entry Points:**
-- `packages/core/src/index.ts`: Harness-agnostic library exports.
-- `packages/opencode/index.ts`: OpenCode plugin package entry.
-- `packages/pi/src/index.ts`: Pi extension provider entry.
-
-**Configuration:**
-- `packages/opencode/src/plugin/config/schema.ts`: OpenCode Zod runtime configuration schema.
-
-**Core Logic:**
-- `packages/core/src/agy-transport.ts`: Custom TCP/TLS transport socket implementation.
-- `packages/core/src/project.ts`: Project resolution, context loading, and GCP project provisioning.
-- `packages/core/src/model-registry.ts`: Model definitions, quota groups, and Gemini Flash tier routing maps.
-- `packages/core/src/transform/cross-model-sanitizer.ts`: Payload cleanup when switching model families.
-- `packages/opencode/src/plugin/accounts.ts`: Multi-account selection, rotation, metrics, and health scores.
-
-**Tests:**
-- `packages/core/src/**/*.test.ts`: Unit tests for core transport, models, and transforms.
-- `packages/opencode/src/**/*.test.ts`: OpenCode account manager, UI elements, and config validations.
-- `packages/pi/src/**/*.test.ts`: Pi converters and cache helpers.
-
-## Naming Conventions
-
-**Files:** `kebab-case.ts` — e.g., `model-resolver.ts`
-**Directories:** `kebab-case/` — e.g., `auto-update-checker/`
-**Types/Interfaces:** `PascalCase` — e.g., `AccountManager`, `AntigravityConfig`
-**Functions:** `camelCase` — e.g., `resolveModelWithTier`
-**Constants:** `UPPER_SNAKE_CASE` — e.g., `ANTIGRAVITY_ENDPOINT`
-
-## Where to Add New Code
-
-**New shared core logic / transport rule:** `packages/core/src/` — create helper module or edit existing transport/auth managers.
-**New model transform / payload filter:** `packages/core/src/transform/` — add custom Claude or Gemini schema conversion rules.
-**New OpenCode lifecycle hook:** `packages/opencode/src/hooks/[hook-name]/` — register in `packages/opencode/src/plugin.ts`.
-**New OpenCode plugin configuration field:** `packages/opencode/src/plugin/config/schema.ts` — extend `AntigravityConfigSchema`.
-**New Pi stream handler / message converter:** `packages/pi/src/` — adjust converters in `convert.ts` or mapping logic in `stream.ts`.
-**Tests:** Co-locate unit and regression tests alongside code using `*.test.ts`.
+Holds:
+
+- **`core` must not import from `opencode`, `pi`, or `e2e-tests`.** It only knows `fetch`, `node:fs`, `node:net`, and a `fetchAccountQuota` callback — it must not import `@opencode-ai/plugin` or `@earendil-works/pi-ai`. The corollary: every host-runtime call is injected through `PluginDependencyOverrides` (`packages/opencode/src/plugin/dependencies.ts`).
+- **`pi` depends only on `core` and Pi's peer deps.** It never reaches into `opencode`. The Pi extension has no concept of an account pool — `core/auth.ts` treats the refresh token as opaque so both Pi and OpenCode can reuse it.
+- **`opencode` server → OpenTUI communication is async and out-of-process.** The TUI imports nothing from `packages/opencode/src/plugin/{storage,accounts,fetch-interceptor,oauth-*}` — only `src/sidebar-state.ts`, `src/rpc/protocol.ts`, `src/rpc/rpc-client.ts`, `src/rpc/rpc-dir.ts`, and the local `src/tui/` folder. The compiler would let it, so the contract is enforced by the import-graph review at `packages/opencode/src/tui.tsx:1-22`, the import-graph gate in `packages/opencode/scripts/build-tui.test.ts`, and the e2e fixture in `packages/e2e-tests/src/rpc-tui-flow.e2e.test.ts`.
+- **`e2e-tests` does not take a peer on the host.** It loads the plugin source as an internal import so the production factory is exercised as-is.
+
+## Where to add code
+
+Pick the path that lines up with the smallest possible blast radius. The list below is in the same shape as the verification gates.
+
+- **New core primitive** (transport rule, persistence primitive, OAuth step): add or edit a file under `packages/core/src/`. Public surface changes go through `packages/core/src/index.ts`; co-locate a `*.test.ts` next to it. Type additions live in the matching `*-types.ts` file (e.g. `account-types.ts`, `auth-types.ts`, `model-types.ts`, `quota-types.ts`).
+- **New model transform or sanitization rule:** `packages/core/src/transform/`. Add a focused export to `transform/index.ts` (or re-export `transform/types.ts` for the type). Update the focused test sibling; if the change touches model resolution, also update `transform/model-resolver.ts` and its `model-resolver.test.ts`.
+- **New provider endpoint or sentinel value:** `packages/core/src/constants.ts`. If the value is per-account (overridable via config), the override goes in `packages/opencode/src/plugin/config/schema.ts` instead, and the fallback hard-codes the constant.
+- **New OpenCode hook** (host lifecycle — e.g. tool registration, event hook): add a module under `packages/opencode/src/plugin/`, wire it from `packages/opencode/src/plugin/index.ts`'s factory, register it as a disposable in `packages/opencode/src/plugin/lifecycle.ts`, and co-locate a `*.test.ts`. Examples: `event-handler.ts`, `recovery/recovery.ts`, `hooks/auto-update-checker/index.ts`, `google-search-tool.ts`.
+- **New modal command** (slash command that opens a dialog):
+ 1. Add the constant to `packages/opencode/src/constants.ts` (`ANTIGRAVITY_*_COMMAND_NAME`).
+ 2. Add the entry to `MODAL_COMMANDS` in `packages/opencode/src/plugin/commands.ts`.
+ 3. Register the canonical and (where applicable) alias names in `packages/opencode/src/plugin/catalog.ts`'s `registerAntigravityCommands` and `applyAntigravityProviderCatalog`.
+ 4. Add the modal name to `packages/opencode/src/rpc/protocol.ts`'s `CommandModalName` union.
+ 5. Build the dialog in `packages/opencode/src/tui/command-dialogs.tsx` (and `tui/command-dialogs.test.tsx`).
+ The three-wirings invariant is pinned by a dedicated test in `packages/opencode/src/plugin/commands.test.ts`.
+- **New sidebar field:** extend `SidebarStateV1` in `packages/opencode/src/sidebar-state.ts` (the schema) and the `buildSidebarMachineStateFromAccounts` + `mergeMachineState` writers; render the new field in `packages/opencode/src/tui.tsx` (and `packages/opencode/src/tui.test.tsx`). Remember that sidebar state is **public** — it lands in `/cortexkit/antigravity-auth/sidebar-state.json` and must contain no tokens or project IDs.
+- **New RPC method or notification shape:** `packages/opencode/src/rpc/protocol.ts` (wire types) + `packages/opencode/src/rpc/rpc-server.ts` (server handler) + `packages/opencode/src/rpc/rpc-client.ts` (TUI caller). Bump the bearer check if you add an unauthenticated path. Add a sibling `*.test.ts` per file.
+- **New CLI command:** `packages/opencode/src/cli.ts` (the bundled CLI binary). Update `scripts/dev.ts` only if the new command affects the dev symlink lifecycle, which it should not. Co-locate a CLI-level test under `packages/e2e-tests/src/cli-flow.e2e.test.ts`.
+- **New Pi behavior** (provider shape change, new path resolver, conversion rule): live under `packages/pi/src/`. The package surface is `src/index.ts`; stream adapters live in `src/stream.ts`; request/response converters live in `src/convert.ts`; path resolvers live in `src/paths.ts`. Co-locate a `*.test.ts` next to each.
+- **New E2E fixture:** `packages/e2e-tests/src/mock-antigravity-server.ts` for the server side, then a scenario in one of the three `*.e2e.test.ts` files. The harness contract is `E2eHarness` from `packages/e2e-tests/src/harness.ts`.
+- **New config field:** add the Zod field in `packages/opencode/src/plugin/config/schema.ts` (and the operator runtime override in `config/operator-settings-schema.ts` if the field is mutable from `/antigravity-*`). Re-run `bun run --cwd packages/opencode build:schema` so `assets/antigravity.schema.json` stays in sync. The loader tolerates a missing field — `loadConfigFile` in `config/loader.ts` swallows malformed JSON and falls back to defaults.
+
+## Change-together matrices
+
+These are the file clusters where one logical change needs to touch more than one path. A coordinated change should land in the same PR.
+
+| Change | Files that must move |
+| --- | --- |
+| Rename or change a core export | `packages/core/src/index.ts` + the source file + the per-file test + the re-exporter in `packages/opencode/src/plugin/transform/{index.ts,...}` (if it's a transform) + every `.test.ts` that imports the old name. |
+| Add a new `@cortexkit/antigravity-auth-core` dependency in `packages/opencode` or `packages/pi` | Bump the published version, then re-run `scripts/version-sync.mjs` so dependents pick up the new pin. |
+| Add a modal command | `constants.ts` (`ANTIGRAVITY_*_COMMAND_NAME`), `plugin/commands.ts` (`MODAL_COMMANDS`, `applyCommand`), `plugin/catalog.ts` (`registerAntigravityCommands`, `applyAntigravityProviderCatalog`), `rpc/protocol.ts` (`CommandModalName`), `tui/command-dialogs.tsx` (dialog payload builder) + the matching `*.test.ts` siblings. The pinning test in `plugin/commands.test.ts` will fail if any one drifts. |
+| Change `SidebarStateV1` schema | `packages/opencode/src/sidebar-state.ts` (schema + writers + merge), `packages/opencode/src/tui.tsx` (render), `packages/opencode/src/tui.test.tsx` (render assertions). The TUI is a polling consumer — version bumps are additive, not breaking. |
+| Change a session-shape contract | `packages/core/src/agy-request-metadata.ts` (storage, `buildAgyRequestMetadata`), `packages/opencode/src/plugin/agy-request-metadata.ts` (host adapter), `packages/opencode/src/plugin/session-context.ts` (registry), every `*.test.ts` that pins trajectory / labels. |
+| Change an OAuth step | `packages/core/src/antigravity/oauth.ts` (the truth), `packages/opencode/src/antigravity/oauth.ts` (host re-export), `packages/opencode/src/plugin/server.ts` (callback listener), `packages/opencode/src/plugin/oauth-methods.ts` (UI flow), `packages/pi/src/index.ts` (Pi adapter — uses `authorizeAntigravity` + `exchangeAntigravity` directly). |
+| Change the refresh queue cadence | `packages/core/src/auth.ts` (buffer), `packages/core/src/rotation.ts` (backoff), `packages/opencode/src/plugin/refresh-queue.ts` (cadence), the operator settings override in `config/operator-settings-schema.ts`, the dialog in `packages/opencode/src/plugin/commands.ts`. |
+| Bump a published package | `scripts/release.sh` (one command) + `scripts/version-sync.mjs` (writes the new version + cross-package pin). GitHub Actions re-tests, then publishes via OIDC. |
+| Change the loopback RPC contract | `packages/opencode/src/rpc/protocol.ts` + `rpc-server.ts` + `rpc-client.ts` + `rpc/notifications.ts` + `rpc/port-file.ts`. The TUI's `entry.mjs` loader does not change. |
+| Add a TUI-rendered sidebar field | `sidebar-state.ts` (schema + writer), `tui.tsx` (renderer). The TUI uses Bun's OpenTUI preload during dev (`bunfig.toml`), so no separate rebuild for dev iteration — only the `scripts/build-tui.ts` step for shipping. |
diff --git a/biome.jsonc b/biome.jsonc
new file mode 100644
index 0000000..7221bf8
--- /dev/null
+++ b/biome.jsonc
@@ -0,0 +1,78 @@
+{
+ "$schema": "https://biomejs.dev/schemas/2.5.3/schema.json",
+ "formatter": {
+ "indentStyle": "space",
+ "indentWidth": 2
+ },
+ "javascript": {
+ "formatter": {
+ "semicolons": "asNeeded",
+ "jsxQuoteStyle": "single",
+ "trailingCommas": "all",
+ "quoteStyle": "single"
+ }
+ },
+ "linter": {
+ "enabled": true
+ },
+ "files": {
+ "ignoreUnknown": true,
+ "includes": [
+ "packages/*/src/**",
+ "!packages/opencode/src/tui-compiled",
+ "packages/*/script/**",
+ "packages/*/scripts/**",
+ "scripts/**",
+ "test/**",
+ "*.json",
+ "packages/*/*.json",
+ ".github/**/*.yml"
+ ]
+ },
+ "overrides": [
+ {
+ "includes": ["**/*.test.ts"],
+ "linter": {
+ "rules": {
+ "suspicious": {
+ "noExplicitAny": "off",
+ "noThenProperty": "off"
+ },
+ "style": { "noNonNullAssertion": "off" },
+ "correctness": { "noUnusedFunctionParameters": "off" }
+ }
+ }
+ },
+ {
+ "includes": ["packages/opencode/src/plugin/ui/select.ts"],
+ "linter": {
+ "rules": {
+ "suspicious": { "noControlCharactersInRegex": "off" }
+ }
+ }
+ },
+ {
+ // Source (non-test) files: disable two rules whose warnings are
+ // per-site noise in this codebase. `noExplicitAny` fires 244 times
+ // for plugin dependency overrides + mock/fixture shapes that
+ // intentionally match structural contracts rather than named
+ // interfaces. `noNonNullAssertion` fires for sites where TS narrows
+ // module-level state poorly across function scopes (e.g. assignments
+ // inside `if (!x) { x = ... }` followed by `return x!`); each
+ // remaining site is reviewed individually.
+ "includes": [
+ "packages/*/src/**",
+ "!packages/*/src/**/*.test.ts",
+ "packages/*/script/**",
+ "packages/*/scripts/**",
+ "scripts/**"
+ ],
+ "linter": {
+ "rules": {
+ "suspicious": { "noExplicitAny": "off" },
+ "style": { "noNonNullAssertion": "off" }
+ }
+ }
+ }
+ ]
+}
diff --git a/bun.lock b/bun.lock
new file mode 100644
index 0000000..32cc700
--- /dev/null
+++ b/bun.lock
@@ -0,0 +1,769 @@
+{
+ "lockfileVersion": 1,
+ "configVersion": 1,
+ "workspaces": {
+ "": {
+ "name": "@cortexkit/antigravity-auth",
+ "devDependencies": {
+ "@biomejs/biome": "2.5.3",
+ "@opencode-ai/plugin": "1.17.13",
+ "@opentui/core": "0.4.5",
+ "@opentui/keymap": "0.4.5",
+ "@opentui/solid": "0.4.5",
+ "@tsconfig/bun": "1.0.10",
+ "@types/bun": "1.3",
+ "@types/node": "24.10.1",
+ "lefthook": "2.1.9",
+ "typescript": "6.0.3",
+ },
+ },
+ "packages/core": {
+ "name": "@cortexkit/antigravity-auth-core",
+ "version": "2.0.0",
+ "dependencies": {
+ "@openauthjs/openauth": "^0.4.3",
+ "xdg-basedir": "^5.1.0",
+ "zod": "^4.0.0",
+ },
+ },
+ "packages/e2e-tests": {
+ "name": "@cortexkit/antigravity-auth-e2e",
+ "version": "0.0.0",
+ "dependencies": {
+ "@cortexkit/antigravity-auth-core": "workspace:*",
+ "@cortexkit/opencode-antigravity-auth": "workspace:*",
+ },
+ "devDependencies": {
+ "@types/bun": "1.3",
+ "@types/node": "24.10.1",
+ "typescript": "6.0.3",
+ },
+ },
+ "packages/opencode": {
+ "name": "@cortexkit/opencode-antigravity-auth",
+ "version": "2.0.0",
+ "bin": {
+ "antigravity-auth": "./dist/cli.js",
+ },
+ "dependencies": {
+ "@cortexkit/antigravity-auth-core": "2.0.0",
+ "@openauthjs/openauth": "^0.4.3",
+ "jsonc-parser": "^3.3.1",
+ "solid-js": "1.9.12",
+ "xdg-basedir": "^5.1.0",
+ "zod": "^4.0.0",
+ },
+ "devDependencies": {
+ "@opencode-ai/sdk": "1.17.13",
+ "esbuild": "^0.27.7",
+ "zod-to-json-schema": "^3.25.1",
+ },
+ "peerDependencies": {
+ "@opencode-ai/plugin": "^1.17.13",
+ "@opentui/core": "^0.4.5",
+ "@opentui/keymap": "^0.4.5",
+ "@opentui/solid": "^0.4.5",
+ "typescript": "^5 || ^6",
+ },
+ },
+ "packages/pi": {
+ "name": "@cortexkit/pi-antigravity-auth",
+ "version": "2.0.0",
+ "dependencies": {
+ "@cortexkit/antigravity-auth-core": "2.0.0",
+ },
+ "devDependencies": {
+ "@earendil-works/pi-ai": "^0.79.1",
+ "@earendil-works/pi-coding-agent": "^0.79.1",
+ "@earendil-works/pi-tui": "^0.79.1",
+ "esbuild": "^0.27.7",
+ },
+ "peerDependencies": {
+ "@earendil-works/pi-ai": "*",
+ "@earendil-works/pi-coding-agent": "*",
+ "@earendil-works/pi-tui": "*",
+ },
+ },
+ },
+ "packages": {
+ "@ai-sdk/provider": ["@ai-sdk/provider@3.0.8", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-oGMAgGoQdBXbZqNG0Ze56CHjDZ1IDYOwGYxYjO5KLSlz5HiNQ9udIXsPZ61VWaHGZ5XW/jyjmr6t2xz2jGVwbQ=="],
+
+ "@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="],
+
+ "@anthropic-ai/sdk": ["@anthropic-ai/sdk@0.91.1", "", { "dependencies": { "json-schema-to-ts": "^3.1.1" }, "peerDependencies": { "zod": "^3.25.0 || ^4.0.0" }, "optionalPeers": ["zod"], "bin": { "anthropic-ai-sdk": "bin/cli" } }, "sha512-LAmu761tSN9r66ixvmciswUj/ZC+1Q4iAfpedTfSVLeswRwnY3n2Nb6Tsk+cLPP28aLOPWeMgIuTuCcMC6W/iw=="],
+
+ "@aws-crypto/sha256-browser": ["@aws-crypto/sha256-browser@5.2.0", "", { "dependencies": { "@aws-crypto/sha256-js": "^5.2.0", "@aws-crypto/supports-web-crypto": "^5.2.0", "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-locate-window": "^3.0.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw=="],
+
+ "@aws-crypto/sha256-js": ["@aws-crypto/sha256-js@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA=="],
+
+ "@aws-crypto/supports-web-crypto": ["@aws-crypto/supports-web-crypto@5.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg=="],
+
+ "@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="],
+
+ "@aws-sdk/client-bedrock-runtime": ["@aws-sdk/client-bedrock-runtime@3.1048.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.974.11", "@aws-sdk/credential-provider-node": "^3.972.42", "@aws-sdk/eventstream-handler-node": "^3.972.16", "@aws-sdk/middleware-eventstream": "^3.972.12", "@aws-sdk/middleware-websocket": "^3.972.19", "@aws-sdk/token-providers": "3.1048.0", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/fetch-http-handler": "^5.4.2", "@smithy/node-http-handler": "^4.7.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ=="],
+
+ "@aws-sdk/core": ["@aws-sdk/core@3.976.0", "", { "dependencies": { "@aws-sdk/types": "^3.974.2", "@aws-sdk/xml-builder": "^3.972.36", "@aws/lambda-invoke-store": "^0.3.0", "@smithy/core": "^3.29.4", "@smithy/signature-v4": "^5.6.5", "@smithy/types": "^4.16.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-0cjRaEdlVoOrsNb9pP5q1Syyc8pXw5xSj2Np2ryReRTr9FppIIRVSdZK4lbnfmc2Hvgux/xBOUU6baB7z8//uA=="],
+
+ "@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.60", "", { "dependencies": { "@aws-sdk/core": "^3.976.0", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.29.4", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-BAkxdoe7tpDDqCghGpuOeHQRbm/2znVvOQm0AvpQbA2tbfMN46doN4zx65fv85ImP3KADwc2zQPmbrlI9MPfMg=="],
+
+ "@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.62", "", { "dependencies": { "@aws-sdk/core": "^3.976.0", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.29.4", "@smithy/fetch-http-handler": "^5.6.6", "@smithy/node-http-handler": "^4.9.6", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-g/0fGqKTb9xpKdd9AtpmV5Eo3DFKbnkpA2+w0peISSlu7NfAoWOuYBFxsu+yWBtxU89ka55ezoZBCbFaS8pjYQ=="],
+
+ "@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.973.5", "", { "dependencies": { "@aws-sdk/core": "^3.976.0", "@aws-sdk/credential-provider-env": "^3.972.60", "@aws-sdk/credential-provider-http": "^3.972.62", "@aws-sdk/credential-provider-login": "^3.972.67", "@aws-sdk/credential-provider-process": "^3.972.60", "@aws-sdk/credential-provider-sso": "^3.973.4", "@aws-sdk/credential-provider-web-identity": "^3.972.66", "@aws-sdk/nested-clients": "^3.997.34", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.29.4", "@smithy/credential-provider-imds": "^4.4.9", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-ylubazcRfq2TVus/qXucSXeC42Qdjp5HQxTu68K/BsdMiZlcSLD1zkpoCgApXZX1Y6YJhtGGs7ZHhO/GuIgBlw=="],
+
+ "@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.67", "", { "dependencies": { "@aws-sdk/core": "^3.976.0", "@aws-sdk/nested-clients": "^3.997.34", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.29.4", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-CCygIKJ9YbI3n84OClSaSppkgKKHVj2TGT33c6FRORZrYNZQ1POmD+ip0FLYokiJAK7sSdc3YVkOsBm90oxWMQ=="],
+
+ "@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.71", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.60", "@aws-sdk/credential-provider-http": "^3.972.62", "@aws-sdk/credential-provider-ini": "^3.973.5", "@aws-sdk/credential-provider-process": "^3.972.60", "@aws-sdk/credential-provider-sso": "^3.973.4", "@aws-sdk/credential-provider-web-identity": "^3.972.66", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.29.4", "@smithy/credential-provider-imds": "^4.4.9", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-HIg7Q2osBzajQwL+1Vkyh2E7Gim3eTNb9RHIsOxDGjW0eZg4oEKtRs5sioCnc73ilhaOm4gX2lHVF8J7+nt2rg=="],
+
+ "@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.60", "", { "dependencies": { "@aws-sdk/core": "^3.976.0", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.29.4", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-YIo3f99hM43QdYG8hDzwGemnR/pU95b0kramqSJUTleCqaB7+HwKf7YZFHqvOgTqZTPx/mRmNIqoDRr3U0Z3Tw=="],
+
+ "@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.973.4", "", { "dependencies": { "@aws-sdk/core": "^3.976.0", "@aws-sdk/nested-clients": "^3.997.34", "@aws-sdk/token-providers": "3.1092.0", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.29.4", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-BPdmL8sSBOCv4ngZ+3LHxyc3CNqDCEK37CHioCk7zGrTMY5sUtkH8q+o6qA80nn6w3/fyBPGNE7OIRlmoOxRQA=="],
+
+ "@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.66", "", { "dependencies": { "@aws-sdk/core": "^3.976.0", "@aws-sdk/nested-clients": "^3.997.34", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.29.4", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-kSAziJboOmZmsR9/MTbiNjowl2BPes1bQuJpne4qAZ62ubi8fjfr/aupJSQje6udBoYxXTQbsL0e0kby2la3ng=="],
+
+ "@aws-sdk/eventstream-handler-node": ["@aws-sdk/eventstream-handler-node@3.972.29", "", { "dependencies": { "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.29.4", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-t3tKQRTVXsI2QNPE3CaNjHl0wRO9Xi3acZkAyti2RQsiFmZ9Gi0kArX2ighlRJ1BtDVuul413gThAgzyTfgmWA=="],
+
+ "@aws-sdk/middleware-eventstream": ["@aws-sdk/middleware-eventstream@3.972.24", "", { "dependencies": { "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.29.4", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-oykin4mDWxNOuYQ7SF1cHzgYeuFEkF4cdRwgvjFFbIklkx09qIFBiOgsORafG9sXZFO3TayMmQuAQYgADXhI8w=="],
+
+ "@aws-sdk/middleware-websocket": ["@aws-sdk/middleware-websocket@3.972.42", "", { "dependencies": { "@aws-sdk/core": "^3.976.0", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.29.4", "@smithy/fetch-http-handler": "^5.6.6", "@smithy/signature-v4": "^5.6.5", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-dw+GP8DC7QC2C8tUoK7DI8BnrNAjz8tb+uBHSrD2qJvxkCf58kTtFr98pljSrk+umU4n4HDW4eU2k7C2dWMzsg=="],
+
+ "@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.997.34", "", { "dependencies": { "@aws-sdk/core": "^3.976.0", "@aws-sdk/signature-v4-multi-region": "^3.996.41", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.29.4", "@smithy/fetch-http-handler": "^5.6.6", "@smithy/node-http-handler": "^4.9.6", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-Y9REVrSwmLM+Qy6sZJ7ofMC2S3Hr3tPP/4CzL5U1olPP7OGoF+6+Px0E49cVQBtSxJtyeLJMf0UaBErfeSahAA=="],
+
+ "@aws-sdk/signature-v4-multi-region": ["@aws-sdk/signature-v4-multi-region@3.996.41", "", { "dependencies": { "@aws-sdk/types": "^3.974.2", "@smithy/signature-v4": "^5.6.5", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-QMUytg+FQMGouc8gHS00KoYih3+N6cqmVI/pQGOIo7Nr7OpQaiXjSYOuL+vsPZ1tymY4LAQ8MYcHJmws5LRxng=="],
+
+ "@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1048.0", "", { "dependencies": { "@aws-sdk/core": "^3.974.11", "@aws-sdk/nested-clients": "^3.997.9", "@aws-sdk/types": "^3.973.8", "@smithy/core": "^3.24.2", "@smithy/types": "^4.14.1", "tslib": "^2.6.2" } }, "sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA=="],
+
+ "@aws-sdk/types": ["@aws-sdk/types@3.974.2", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-3W6IUtSxFbH6X7Wb7DzGCV5QiFQsd0g8bOfntpmDxQlzBoKWUMBu/JPQR0DwkE+Hpnxd6db1tXbOwdeHddG6cA=="],
+
+ "@aws-sdk/util-locate-window": ["@aws-sdk/util-locate-window@3.965.8", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-uUbMs1cBZPafD0ohUj6EwNf0fPZ534NvBxHox4hjX+0Rxq5paSYUem7+hi833pYrzrcnBATKIYpR02MDXT5M9g=="],
+
+ "@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.36", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-RdGmS1GLrtaTOLE1ElSluMldNrpk9Emq6uYs8SS8iHlu5xTAmM9rRkM91o48+rIRryBtyO9t+uLYCoMG6jVMVA=="],
+
+ "@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.3.0", "", {}, "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ=="],
+
+ "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="],
+
+ "@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="],
+
+ "@babel/core": ["@babel/core@7.28.0", "", { "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.27.1", "@babel/generator": "^7.28.0", "@babel/helper-compilation-targets": "^7.27.2", "@babel/helper-module-transforms": "^7.27.3", "@babel/helpers": "^7.27.6", "@babel/parser": "^7.28.0", "@babel/template": "^7.27.2", "@babel/traverse": "^7.28.0", "@babel/types": "^7.28.0", "convert-source-map": "^2.0.0", "debug": "^4.1.0", "gensync": "^1.0.0-beta.2", "json5": "^2.2.3", "semver": "^6.3.1" } }, "sha512-UlLAnTPrFdNGoFtbSXwcGFQBtQZJCNjaN6hQNP3UPvuNXT1i82N26KL3dZeIpNalWywr9IuQuncaAfUaS1g6sQ=="],
+
+ "@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="],
+
+ "@babel/helper-annotate-as-pure": ["@babel/helper-annotate-as-pure@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" } }, "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw=="],
+
+ "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="],
+
+ "@babel/helper-create-class-features-plugin": ["@babel/helper-create-class-features-plugin@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-member-expression-to-functions": "^7.29.7", "@babel/helper-optimise-call-expression": "^7.29.7", "@babel/helper-replace-supers": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", "@babel/traverse": "^7.29.7", "semver": "^6.3.1" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-IY3ZD9Tmooqr3TUhc3DUWxiuo8xx1DWLhd5M7hQ+ZWJamqM2BbalrBJb2MisSLoYorOj75U03qULCxQTY9r3hg=="],
+
+ "@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="],
+
+ "@babel/helper-member-expression-to-functions": ["@babel/helper-member-expression-to-functions@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-j+7JYmk1JYDtACIGj0QJqqWZjoUpMoEikQGADMaHgCMCSDqd2+P32rfcibUNrGOMWrlzK1WJBdxrB3JJQZwWtg=="],
+
+ "@babel/helper-module-imports": ["@babel/helper-module-imports@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g=="],
+
+ "@babel/helper-module-transforms": ["@babel/helper-module-transforms@7.29.7", "", { "dependencies": { "@babel/helper-module-imports": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg=="],
+
+ "@babel/helper-optimise-call-expression": ["@babel/helper-optimise-call-expression@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" } }, "sha512-+kmGVjcT9RGYzoDwdwEqEvGgKe3BYq+O1iGzjFubaNgZHwYHP6lsF2Yghf4kEuv9BV7tYDZ913aBW9am6YKong=="],
+
+ "@babel/helper-plugin-utils": ["@babel/helper-plugin-utils@7.29.7", "", {}, "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw=="],
+
+ "@babel/helper-replace-supers": ["@babel/helper-replace-supers@7.29.7", "", { "dependencies": { "@babel/helper-member-expression-to-functions": "^7.29.7", "@babel/helper-optimise-call-expression": "^7.29.7", "@babel/traverse": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0" } }, "sha512-atfGXWSeCiF4DnKZIfmJfQRkSw9b9gNNXR1kqKjbhG4pGYCOnkp8OcTB8E3NXjBu8NpheSnOeNKz8KT7UNFTmQ=="],
+
+ "@babel/helper-skip-transparent-expression-wrappers": ["@babel/helper-skip-transparent-expression-wrappers@7.29.7", "", { "dependencies": { "@babel/traverse": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-brcMGQaVzIeUb+6/bs1Av0f8YuNNjKY2JyvfRCsFuFsdKccEQ5Ges2y74D74NZ1Rz8lKJ9ksJkfqwQFJ/iNEyQ=="],
+
+ "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
+
+ "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
+
+ "@babel/helper-validator-option": ["@babel/helper-validator-option@7.29.7", "", {}, "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw=="],
+
+ "@babel/helpers": ["@babel/helpers@7.29.7", "", { "dependencies": { "@babel/template": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg=="],
+
+ "@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="],
+
+ "@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A=="],
+
+ "@babel/plugin-syntax-typescript": ["@babel/plugin-syntax-typescript@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-ngr+82Sh0xMz25TPCZi+nC2iTzjfCdWS2ONXTp/PtSCHCgaCNBpdMqgvJ2ccdLlClVZ7sisIgB914j/JFe+RZA=="],
+
+ "@babel/plugin-transform-modules-commonjs": ["@babel/plugin-transform-modules-commonjs@7.29.7", "", { "dependencies": { "@babel/helper-module-transforms": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-j0vCldybPC5b5dwCQOJ21uKtHzt7hxLygJTg9eF1ScfaikEDNfzn94XoW5Fi+seBR0nCyL23xaBFFkq7dTM8XQ=="],
+
+ "@babel/plugin-transform-typescript": ["@babel/plugin-transform-typescript@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-create-class-features-plugin": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/helper-skip-transparent-expression-wrappers": "^7.29.7", "@babel/plugin-syntax-typescript": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-jK52h8LaLc7JarhQV2ofeFMts4H7vnOXnqZNA6fYglBTZewRBE51KWt3BUltW1P+KoPsYkHoJeXePuz4zo2LMw=="],
+
+ "@babel/preset-typescript": ["@babel/preset-typescript@7.27.1", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.27.1", "@babel/helper-validator-option": "^7.27.1", "@babel/plugin-syntax-jsx": "^7.27.1", "@babel/plugin-transform-modules-commonjs": "^7.27.1", "@babel/plugin-transform-typescript": "^7.27.1" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-l7WfQfX0WK4M0v2RudjuQK4u99BS6yLHYEmdtVPP7lKV013zr9DygFuWNlnbvQ9LR+LS0Egz/XAvGx5U9MX0fQ=="],
+
+ "@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="],
+
+ "@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="],
+
+ "@babel/traverse": ["@babel/traverse@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.7", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/template": "^7.29.7", "@babel/types": "^7.29.7", "debug": "^4.3.1" } }, "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw=="],
+
+ "@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="],
+
+ "@biomejs/biome": ["@biomejs/biome@2.5.3", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "2.5.3", "@biomejs/cli-darwin-x64": "2.5.3", "@biomejs/cli-linux-arm64": "2.5.3", "@biomejs/cli-linux-arm64-musl": "2.5.3", "@biomejs/cli-linux-x64": "2.5.3", "@biomejs/cli-linux-x64-musl": "2.5.3", "@biomejs/cli-win32-arm64": "2.5.3", "@biomejs/cli-win32-x64": "2.5.3" }, "bin": { "biome": "bin/biome" } }, "sha512-MrJswFdei9EfDwwUy2tQrPDpK0AO+RmMFvBoaaJ6ayBc3sUbHdCE+XG5N8vp+5So41ZupZJQm0roHFFhMGVD7A=="],
+
+ "@biomejs/cli-darwin-arm64": ["@biomejs/cli-darwin-arm64@2.5.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QhYP9muVQ0nUO5zztFuPbEwi4+94sJWVjaZds9aMi1l/KNZBiUjdiSUrGHsTaMGDXrYl+r4AS2sUKfgH3w+V3g=="],
+
+ "@biomejs/cli-darwin-x64": ["@biomejs/cli-darwin-x64@2.5.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-NC1Ss13UaW7QZX+y8j44bF7AP0jSJdBl6iRhe0MAkvaSqZy+mWg3GaXsrb+eSoHoGDBtaXWEbMVV0iVN2cZ7cQ=="],
+
+ "@biomejs/cli-linux-arm64": ["@biomejs/cli-linux-arm64@2.5.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-ksx1KWeyYW18ILL04msF/J4ZBtBDN33znYK8Z/aNv/vlBVxL9/g3mGP+omgHJKy4+KWbK87vcmmpmurfNjSgiA=="],
+
+ "@biomejs/cli-linux-arm64-musl": ["@biomejs/cli-linux-arm64-musl@2.5.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-fccix0w6xp6csCXgxeC0dU/3ecgRQal0y+cv2SP9ajNlhe7Yrk2Ug7UDe2j9AT9ZDYitkXpvUKgZjjuoYeP4Vg=="],
+
+ "@biomejs/cli-linux-x64": ["@biomejs/cli-linux-x64@2.5.3", "", { "os": "linux", "cpu": "x64" }, "sha512-yMkJtilsgvILDcVkh187aVLTb64xYsrxYajx5kym+r1ULkO5HUOfu9AYKLGQbOVLwJtT2utNw7hhFNg+17mUYA=="],
+
+ "@biomejs/cli-linux-x64-musl": ["@biomejs/cli-linux-x64-musl@2.5.3", "", { "os": "linux", "cpu": "x64" }, "sha512-O/yU9YKRUiHhmcjF2f38PSjseVk3G4VLWYc0G2HWpzdBVREV6G8IGWIVEFf7MFPfWIzNUIvPsEjeAZQIOgnLcQ=="],
+
+ "@biomejs/cli-win32-arm64": ["@biomejs/cli-win32-arm64@2.5.3", "", { "os": "win32", "cpu": "arm64" }, "sha512-cX5z+GYwRcqEok0AH3KSfQGgqYd0Nomfp6Fbe1uiTtELE38hdH2k842wQ9wLNaF/JJ7r4rjJQ4VR+ce+fRmQbw=="],
+
+ "@biomejs/cli-win32-x64": ["@biomejs/cli-win32-x64@2.5.3", "", { "os": "win32", "cpu": "x64" }, "sha512-ExSaJWi4/u6+GXCszlSKpWSjKNbDseAYqqkCznsCsZ/4uidZ/BEqsCc5/3ctlq6dfIubdIIRSVLC/PG9xPl70Q=="],
+
+ "@cortexkit/antigravity-auth-core": ["@cortexkit/antigravity-auth-core@workspace:packages/core"],
+
+ "@cortexkit/antigravity-auth-e2e": ["@cortexkit/antigravity-auth-e2e@workspace:packages/e2e-tests"],
+
+ "@cortexkit/opencode-antigravity-auth": ["@cortexkit/opencode-antigravity-auth@workspace:packages/opencode"],
+
+ "@cortexkit/pi-antigravity-auth": ["@cortexkit/pi-antigravity-auth@workspace:packages/pi"],
+
+ "@earendil-works/pi-agent-core": ["@earendil-works/pi-agent-core@0.79.10", "", { "dependencies": { "@earendil-works/pi-ai": "^0.79.10", "ignore": "7.0.5", "typebox": "1.1.38", "yaml": "2.9.0" } }, "sha512-XKxgdjhcPuyjrthCOFSgfzT3xZ1uBrJ1IMVDxci1to6hIN6BIg9J5iY8q0pGXK1DLgATLP23da+1UyZLwA360Q=="],
+
+ "@earendil-works/pi-ai": ["@earendil-works/pi-ai@0.79.10", "", { "dependencies": { "@anthropic-ai/sdk": "0.91.1", "@aws-sdk/client-bedrock-runtime": "3.1048.0", "@google/genai": "1.52.0", "@mistralai/mistralai": "2.2.6", "@opentelemetry/api": "1.9.0", "@smithy/node-http-handler": "4.7.3", "http-proxy-agent": "7.0.2", "https-proxy-agent": "7.0.6", "openai": "6.26.0", "partial-json": "0.1.7", "typebox": "1.1.38" }, "bin": { "pi-ai": "dist/cli.js" } }, "sha512-9jR23tOl0BIUdQMn70Gr72xYBpM7Xgl9Lyv7gAnU1USfkNRuYG/f/edLl+n/Dp/RafDW3JI4DF7y/GhgkORuew=="],
+
+ "@earendil-works/pi-coding-agent": ["@earendil-works/pi-coding-agent@0.79.10", "", { "dependencies": { "@earendil-works/pi-agent-core": "^0.79.10", "@earendil-works/pi-ai": "^0.79.10", "@earendil-works/pi-tui": "^0.79.10", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", "diff": "8.0.4", "glob": "13.0.6", "highlight.js": "10.7.3", "hosted-git-info": "9.0.3", "ignore": "7.0.5", "jiti": "2.7.0", "minimatch": "10.2.5", "proper-lockfile": "4.1.2", "semver": "7.8.0", "typebox": "1.1.38", "undici": "8.5.0", "yaml": "2.9.0" }, "optionalDependencies": { "@mariozechner/clipboard": "0.3.9" }, "bin": { "pi": "dist/cli.js" } }, "sha512-YxaRhmgyDTvLDdGVbe7YzTHV80oL5mX5odg6EhGHz3w5Wu1Ix8DCw7bhtiOBLGQNFRcknia0zPmVWIj30XP1EA=="],
+
+ "@earendil-works/pi-tui": ["@earendil-works/pi-tui@0.79.10", "", { "dependencies": { "get-east-asian-width": "1.6.0", "marked": "18.0.5" } }, "sha512-FUVOjDn1DVwM1uHD5MNYboXQrXjIDbSt+BQ3py7nQWCY62tKfxgiM1OBMxTcwRWLfSdZHUPpV0hm1loIdUJnPw=="],
+
+ "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.7", "", { "os": "aix", "cpu": "ppc64" }, "sha512-EKX3Qwmhz1eMdEJokhALr0YiD0lhQNwDqkPYyPhiSwKrh7/4KRjQc04sZ8db+5DVVnZ1LmbNDI1uAMPEUBnQPg=="],
+
+ "@esbuild/android-arm": ["@esbuild/android-arm@0.27.7", "", { "os": "android", "cpu": "arm" }, "sha512-jbPXvB4Yj2yBV7HUfE2KHe4GJX51QplCN1pGbYjvsyCZbQmies29EoJbkEc+vYuU5o45AfQn37vZlyXy4YJ8RQ=="],
+
+ "@esbuild/android-arm64": ["@esbuild/android-arm64@0.27.7", "", { "os": "android", "cpu": "arm64" }, "sha512-62dPZHpIXzvChfvfLJow3q5dDtiNMkwiRzPylSCfriLvZeq0a1bWChrGx/BbUbPwOrsWKMn8idSllklzBy+dgQ=="],
+
+ "@esbuild/android-x64": ["@esbuild/android-x64@0.27.7", "", { "os": "android", "cpu": "x64" }, "sha512-x5VpMODneVDb70PYV2VQOmIUUiBtY3D3mPBG8NxVk5CogneYhkR7MmM3yR/uMdITLrC1ml/NV1rj4bMJuy9MCg=="],
+
+ "@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.27.7", "", { "os": "darwin", "cpu": "arm64" }, "sha512-5lckdqeuBPlKUwvoCXIgI2D9/ABmPq3Rdp7IfL70393YgaASt7tbju3Ac+ePVi3KDH6N2RqePfHnXkaDtY9fkw=="],
+
+ "@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.27.7", "", { "os": "darwin", "cpu": "x64" }, "sha512-rYnXrKcXuT7Z+WL5K980jVFdvVKhCHhUwid+dDYQpH+qu+TefcomiMAJpIiC2EM3Rjtq0sO3StMV/+3w3MyyqQ=="],
+
+ "@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.27.7", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-B48PqeCsEgOtzME2GbNM2roU29AMTuOIN91dsMO30t+Ydis3z/3Ngoj5hhnsOSSwNzS+6JppqWsuhTp6E82l2w=="],
+
+ "@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.27.7", "", { "os": "freebsd", "cpu": "x64" }, "sha512-jOBDK5XEjA4m5IJK3bpAQF9/Lelu/Z9ZcdhTRLf4cajlB+8VEhFFRjWgfy3M1O4rO2GQ/b2dLwCUGpiF/eATNQ=="],
+
+ "@esbuild/linux-arm": ["@esbuild/linux-arm@0.27.7", "", { "os": "linux", "cpu": "arm" }, "sha512-RkT/YXYBTSULo3+af8Ib0ykH8u2MBh57o7q/DAs3lTJlyVQkgQvlrPTnjIzzRPQyavxtPtfg0EopvDyIt0j1rA=="],
+
+ "@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.27.7", "", { "os": "linux", "cpu": "arm64" }, "sha512-RZPHBoxXuNnPQO9rvjh5jdkRmVizktkT7TCDkDmQ0W2SwHInKCAV95GRuvdSvA7w4VMwfCjUiPwDi0ZO6Nfe9A=="],
+
+ "@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.27.7", "", { "os": "linux", "cpu": "ia32" }, "sha512-GA48aKNkyQDbd3KtkplYWT102C5sn/EZTY4XROkxONgruHPU72l+gW+FfF8tf2cFjeHaRbWpOYa/uRBz/Xq1Pg=="],
+
+ "@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-a4POruNM2oWsD4WKvBSEKGIiWQF8fZOAsycHOt6JBpZ+JN2n2JH9WAv56SOyu9X5IqAjqSIPTaJkqN8F7XOQ5Q=="],
+
+ "@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-KabT5I6StirGfIz0FMgl1I+R1H73Gp0ofL9A3nG3i/cYFJzKHhouBV5VWK1CSgKvVaG4q1RNpCTR2LuTVB3fIw=="],
+
+ "@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.27.7", "", { "os": "linux", "cpu": "ppc64" }, "sha512-gRsL4x6wsGHGRqhtI+ifpN/vpOFTQtnbsupUF5R5YTAg+y/lKelYR1hXbnBdzDjGbMYjVJLJTd2OFmMewAgwlQ=="],
+
+ "@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.27.7", "", { "os": "linux", "cpu": "none" }, "sha512-hL25LbxO1QOngGzu2U5xeXtxXcW+/GvMN3ejANqXkxZ/opySAZMrc+9LY/WyjAan41unrR3YrmtTsUpwT66InQ=="],
+
+ "@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.27.7", "", { "os": "linux", "cpu": "s390x" }, "sha512-2k8go8Ycu1Kb46vEelhu1vqEP+UeRVj2zY1pSuPdgvbd5ykAw82Lrro28vXUrRmzEsUV0NzCf54yARIK8r0fdw=="],
+
+ "@esbuild/linux-x64": ["@esbuild/linux-x64@0.27.7", "", { "os": "linux", "cpu": "x64" }, "sha512-hzznmADPt+OmsYzw1EE33ccA+HPdIqiCRq7cQeL1Jlq2gb1+OyWBkMCrYGBJ+sxVzve2ZJEVeePbLM2iEIZSxA=="],
+
+ "@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-b6pqtrQdigZBwZxAn1UpazEisvwaIDvdbMbmrly7cDTMFnw/+3lVxxCTGOrkPVnsYIosJJXAsILG9XcQS+Yu6w=="],
+
+ "@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.27.7", "", { "os": "none", "cpu": "x64" }, "sha512-OfatkLojr6U+WN5EDYuoQhtM+1xco+/6FSzJJnuWiUw5eVcicbyK3dq5EeV/QHT1uy6GoDhGbFpprUiHUYggrw=="],
+
+ "@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.27.7", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-AFuojMQTxAz75Fo8idVcqoQWEHIXFRbOc1TrVcFSgCZtQfSdc1RXgB3tjOn/krRHENUB4j00bfGjyl2mJrU37A=="],
+
+ "@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.27.7", "", { "os": "openbsd", "cpu": "x64" }, "sha512-+A1NJmfM8WNDv5CLVQYJ5PshuRm/4cI6WMZRg1by1GwPIQPCTs1GLEUHwiiQGT5zDdyLiRM/l1G0Pv54gvtKIg=="],
+
+ "@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.27.7", "", { "os": "none", "cpu": "arm64" }, "sha512-+KrvYb/C8zA9CU/g0sR6w2RBw7IGc5J2BPnc3dYc5VJxHCSF1yNMxTV5LQ7GuKteQXZtspjFbiuW5/dOj7H4Yw=="],
+
+ "@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.27.7", "", { "os": "sunos", "cpu": "x64" }, "sha512-ikktIhFBzQNt/QDyOL580ti9+5mL/YZeUPKU2ivGtGjdTYoqz6jObj6nOMfhASpS4GU4Q/Clh1QtxWAvcYKamA=="],
+
+ "@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.27.7", "", { "os": "win32", "cpu": "arm64" }, "sha512-7yRhbHvPqSpRUV7Q20VuDwbjW5kIMwTHpptuUzV+AA46kiPze5Z7qgt6CLCK3pWFrHeNfDd1VKgyP4O+ng17CA=="],
+
+ "@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.27.7", "", { "os": "win32", "cpu": "ia32" }, "sha512-SmwKXe6VHIyZYbBLJrhOoCJRB/Z1tckzmgTLfFYOfpMAx63BJEaL9ExI8x7v0oAO3Zh6D/Oi1gVxEYr5oUCFhw=="],
+
+ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.27.7", "", { "os": "win32", "cpu": "x64" }, "sha512-56hiAJPhwQ1R4i+21FVF7V8kSD5zZTdHcVuRFMW0hn753vVfQN8xlx4uOPT4xoGH0Z/oVATuR82AiqSTDIpaHg=="],
+
+ "@google/genai": ["@google/genai@1.52.0", "", { "dependencies": { "google-auth-library": "^10.3.0", "p-retry": "^4.6.2", "protobufjs": "^7.5.4", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.25.2" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q=="],
+
+ "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
+
+ "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
+
+ "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
+
+ "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
+
+ "@mariozechner/clipboard": ["@mariozechner/clipboard@0.3.9", "", { "optionalDependencies": { "@mariozechner/clipboard-darwin-arm64": "0.3.9", "@mariozechner/clipboard-darwin-universal": "0.3.9", "@mariozechner/clipboard-darwin-x64": "0.3.9", "@mariozechner/clipboard-linux-arm64-gnu": "0.3.9", "@mariozechner/clipboard-linux-arm64-musl": "0.3.9", "@mariozechner/clipboard-linux-riscv64-gnu": "0.3.9", "@mariozechner/clipboard-linux-x64-gnu": "0.3.9", "@mariozechner/clipboard-linux-x64-musl": "0.3.9", "@mariozechner/clipboard-win32-arm64-msvc": "0.3.9", "@mariozechner/clipboard-win32-x64-msvc": "0.3.9" } }, "sha512-ABnA53mdfkGZwOFUdZNv2S0CWGO/EIuPj8Vv9xmBFmSYg/qFc7ihO6q5FcQjvoE67kZpWkEc4AhD6B/os04yuA=="],
+
+ "@mariozechner/clipboard-darwin-arm64": ["@mariozechner/clipboard-darwin-arm64@0.3.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-BfgV7vCEWZwJwZJw03r6bP5+tf0iI/ANuQYCxi9RNn7FrWB3yzGuMKCrNLRl6V761vXRdL8+OqZ0wd4TqlsNOQ=="],
+
+ "@mariozechner/clipboard-darwin-universal": ["@mariozechner/clipboard-darwin-universal@0.3.9", "", { "os": "darwin" }, "sha512-BGGR4iA9Z2shAjI65eI5xtyb3LYNlDW9X3gxKxDbqtbnREohsrqznov6zpKoIrsRWpzlYVEdKphS7ksJ0/ndSQ=="],
+
+ "@mariozechner/clipboard-darwin-x64": ["@mariozechner/clipboard-darwin-x64@0.3.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-4kURmCbS6nt8uYhtmWpUcJWyPHfmAr5dTpXD1nO3pIfa+TSQ9DbrGOYCKH+aEFW47XhQ4Vp8ZTszie+wfFvDKg=="],
+
+ "@mariozechner/clipboard-linux-arm64-gnu": ["@mariozechner/clipboard-linux-arm64-gnu@0.3.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-g59OkUGP2DDfCOIKypHeYgv2M55u/cKvXa5dSxFbEJ34XvIQMdcVmpKCkGUro3ZgefXiGVdwguvTMQGpHWzIXw=="],
+
+ "@mariozechner/clipboard-linux-arm64-musl": ["@mariozechner/clipboard-linux-arm64-musl@0.3.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-AGuJdgKsmJdm4Pych7kv3sqe591ERRaAHW3xjLooiFzn8J+PxUyof++7YZrB5Y5tpnTO+K18Og3taj2NpluCRQ=="],
+
+ "@mariozechner/clipboard-linux-riscv64-gnu": ["@mariozechner/clipboard-linux-riscv64-gnu@0.3.9", "", { "os": "linux", "cpu": "none" }, "sha512-DXBEAiuMpk7dhS1a9NzNxVAFi1vaKoPu7rQNgY8LIDLGrK3lnIp3nT10DUum+PKVJoJppIP+NAA8IZe4DMNDPw=="],
+
+ "@mariozechner/clipboard-linux-x64-gnu": ["@mariozechner/clipboard-linux-x64-gnu@0.3.9", "", { "os": "linux", "cpu": "x64" }, "sha512-WORrMLd6EpElEME7JRKfSaY34nW1P5LbdgK5YNCS1ncG2LqmITsSMEJ8nh2mpvxb3TxqbOOKgY7k9eMJYlW9Mw=="],
+
+ "@mariozechner/clipboard-linux-x64-musl": ["@mariozechner/clipboard-linux-x64-musl@0.3.9", "", { "os": "linux", "cpu": "x64" }, "sha512-/DHn+1DrfL6oRaPPWXaOKvonFFrni666fxd+zFqiQEfvBH0tsHVWjq9iqBk0oDp0qaPA72lIMy5BptxISBEhZQ=="],
+
+ "@mariozechner/clipboard-win32-arm64-msvc": ["@mariozechner/clipboard-win32-arm64-msvc@0.3.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-O5FHD3ErkMwMhNzAfu3ggy0ug4z7btZuoQgwwxlzPrwV2bxlD6WDpqBY4NCgICAgZdDKdp+loUEKVAVt8aYnhQ=="],
+
+ "@mariozechner/clipboard-win32-x64-msvc": ["@mariozechner/clipboard-win32-x64-msvc@0.3.9", "", { "os": "win32", "cpu": "x64" }, "sha512-ihQC3EufqEY81vhXBgVBtK4prL+wc62zJsSvxrgz7K1hsdt6OObz6v9p3Rn1OG3GJksTTKMJF0u/guMISHPhSA=="],
+
+ "@mistralai/mistralai": ["@mistralai/mistralai@2.2.6", "", { "dependencies": { "@opentelemetry/semantic-conventions": "^1.40.0", "ws": "^8.18.0", "zod": "^3.25.0 || ^4.0.0", "zod-to-json-schema": "^3.25.0" }, "peerDependencies": { "@opentelemetry/api": "^1.9.0" }, "optionalPeers": ["@opentelemetry/api"] }, "sha512-W8pX7zHxjJvMIpw8JMxeJEleapXX0Q9NPszdNzqkM3MIEoIGPObdodujj+WHteXEvGfaP/AMwlNyRfEzSY6dQQ=="],
+
+ "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.4", "", { "os": "darwin", "cpu": "arm64" }, "sha512-LCkGo6JDfaBhgST7UpPWgNgLINpcpabaHfyz5OBx75nUYxBsaEPxjnyNjWpeb/xBup/682QnBfRBy2/LvPutZQ=="],
+
+ "@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.4", "", { "os": "darwin", "cpu": "x64" }, "sha512-zExlW9zUJKZH/tOtVMttwjKa4Xm/3KcNjnE3dPN92uCktwavMxpgCA3MoJK/DOnTWsQgo224OaST27/mPNAf+w=="],
+
+ "@msgpackr-extract/msgpackr-extract-linux-arm": ["@msgpackr-extract/msgpackr-extract-linux-arm@3.0.4", "", { "os": "linux", "cpu": "arm" }, "sha512-Tg3yX65f5GbtXLkrYEHE5oibZG9epyYWas7FogTTEJeDEF9JlXJzKgXaNhT3UXlTOeA+AfZpYZYZ0uPj7Cfquw=="],
+
+ "@msgpackr-extract/msgpackr-extract-linux-arm64": ["@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.4", "", { "os": "linux", "cpu": "arm64" }, "sha512-dgX0P/9wGPJeHFBG+ZmhgE6bmtMt7NP5CRBGyyktpopdk/mW4POnrpQsSLtKI1dwpc+pPLuXHDh6vvskyQE/sw=="],
+
+ "@msgpackr-extract/msgpackr-extract-linux-x64": ["@msgpackr-extract/msgpackr-extract-linux-x64@3.0.4", "", { "os": "linux", "cpu": "x64" }, "sha512-8TNXMEjJc3QEy7R/x1INhgiU+XakDAFUzBhaz7+Rbrs8NH5UQeHQxxmzsSBJGyV6I1jW79undiQm8tOI+D+8FQ=="],
+
+ "@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.4", "", { "os": "win32", "cpu": "x64" }, "sha512-CmCXPQrkbwExx3j946/PtHWHbYJiCRBRDl4BlkRQcJB/YOwQxJRTpoo7aTsortjgoJ1x7opzTSxn7C+ASSLVjQ=="],
+
+ "@openauthjs/openauth": ["@openauthjs/openauth@0.4.3", "", { "dependencies": { "@standard-schema/spec": "1.0.0-beta.3", "aws4fetch": "1.0.20", "jose": "5.9.6" }, "peerDependencies": { "arctic": "^2.2.2", "hono": "^4.0.0" } }, "sha512-RlnjqvHzqcbFVymEwhlUEuac4utA5h4nhSK/i2szZuQmxTIqbGUxZ+nM+avM+VV4Ing+/ZaNLKILoXS3yrkOOw=="],
+
+ "@opencode-ai/plugin": ["@opencode-ai/plugin@1.17.13", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@opencode-ai/sdk": "1.17.13", "effect": "4.0.0-beta.83", "zod": "4.1.8" }, "peerDependencies": { "@opentui/core": ">=0.3.4", "@opentui/keymap": ">=0.3.4", "@opentui/solid": ">=0.3.4" }, "optionalPeers": ["@opentui/core", "@opentui/keymap", "@opentui/solid"] }, "sha512-JjoIs6qTPl0IrXo+J1GTqX9lcb2h2dMoW6BWMR2IbTT3MY2FQ/lvBXDzkx0d3zVRaYN+NTmV4u8Nth396xq+sQ=="],
+
+ "@opencode-ai/sdk": ["@opencode-ai/sdk@1.17.13", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-VItOGjMzRQx3zypwmeFLNhCiIx32kxS7FqzIJvVZLfyNGCifs3rfGC9qzNKWcxQo4SjNvAw++v4gWWU6Inv+JQ=="],
+
+ "@opentelemetry/api": ["@opentelemetry/api@1.9.0", "", {}, "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg=="],
+
+ "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.43.0", "", {}, "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg=="],
+
+ "@opentui/core": ["@opentui/core@0.4.5", "", { "dependencies": { "bun-ffi-structs": "0.2.4", "diff": "9.0.0", "marked": "17.0.1", "string-width": "7.2.0", "strip-ansi": "7.1.2" }, "optionalDependencies": { "@opentui/core-darwin-arm64": "0.4.5", "@opentui/core-darwin-x64": "0.4.5", "@opentui/core-linux-arm64": "0.4.5", "@opentui/core-linux-arm64-musl": "0.4.5", "@opentui/core-linux-x64": "0.4.5", "@opentui/core-linux-x64-musl": "0.4.5", "@opentui/core-win32-arm64": "0.4.5", "@opentui/core-win32-x64": "0.4.5" }, "peerDependencies": { "web-tree-sitter": "0.25.10" } }, "sha512-JsgRTPkA6e+Vxmumxai6SElOSlRQkbzNKHlCfemlArRiLhfC1IZ9RXJo2QH4xSu+uBOWAM90uss73/pPlkdEig=="],
+
+ "@opentui/core-darwin-arm64": ["@opentui/core-darwin-arm64@0.4.5", "", { "os": "darwin", "cpu": "arm64" }, "sha512-8KUG0oRidnR+oW1RSZJ72/PhZLl+qRRMk5U/mieF4c0SJ5V3tYACpBZAKzQfHNd1f7QzD8FHZct1lPpQgtmkWg=="],
+
+ "@opentui/core-darwin-x64": ["@opentui/core-darwin-x64@0.4.5", "", { "os": "darwin", "cpu": "x64" }, "sha512-R2bocsg55gwjOqCp/MWFgFYzRmsduKegB6nzgFAPCvAD/L5Jf30xpWJWFlSg3x8vxe1L9WJ84dfqa4M7mZZ3wA=="],
+
+ "@opentui/core-linux-arm64": ["@opentui/core-linux-arm64@0.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-R4MZ25a4CzOAGVjW9aj1hUfzQGVfCJwrwBDbNs2SXaIvzcZqkxCVtU4FoQ5LsaD0j/BdNQVg2CIfFkFsm1fDuQ=="],
+
+ "@opentui/core-linux-arm64-musl": ["@opentui/core-linux-arm64-musl@0.4.5", "", { "os": "linux", "cpu": "arm64" }, "sha512-ieqdyKI6EIYPalYAETB2wsdP83hr5Ifi+dFnBFUmdEEFHsoKwBmn2S7bsTOYlX7Bg03F4/YPIg+IvRpeC+cUJw=="],
+
+ "@opentui/core-linux-x64": ["@opentui/core-linux-x64@0.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-SNyuQoxMKI1vuJhgxSSW96adWM6LqFl2SoS3GM4tGeneGOanVVG2Y06PvlytXvF4cKik97t0rqkVMRetmOs93w=="],
+
+ "@opentui/core-linux-x64-musl": ["@opentui/core-linux-x64-musl@0.4.5", "", { "os": "linux", "cpu": "x64" }, "sha512-mKVKcIcPiSVVZZsdPSBoWwoa2/TCeQAaMDeHF7PFw2kt5bTXZPP7xxWfRQLCNIcA1eaGl59UuwUWHDR2Ve548Q=="],
+
+ "@opentui/core-win32-arm64": ["@opentui/core-win32-arm64@0.4.5", "", { "os": "win32", "cpu": "arm64" }, "sha512-GHTTsqeR45q2Iek9Rb7ty+x/hAKn2jZ1ujlCgPR8LBKyF7h0E1dNFryoZ7ehMc3kJndP1sKn836IemKFqxuDdQ=="],
+
+ "@opentui/core-win32-x64": ["@opentui/core-win32-x64@0.4.5", "", { "os": "win32", "cpu": "x64" }, "sha512-Y8T/yXCDGagRGiQrtmuB6AhRcPucKFs/Dre3v8kJwNYqDccI4FzUPKclZ7djfmRZNjl7JUqPhZZP/PwDpQocMg=="],
+
+ "@opentui/keymap": ["@opentui/keymap@0.4.5", "", { "dependencies": { "@opentui/core": "0.4.5" }, "peerDependencies": { "@opentui/react": "0.4.5", "@opentui/solid": "0.4.5", "react": ">=19.2.0", "solid-js": "1.9.12" }, "optionalPeers": ["@opentui/react", "@opentui/solid", "react", "solid-js"] }, "sha512-S1wzKHhF70zT6bH+VBFY+lSeTImLcIFW28JNQiME8MoPcy6KGPs7rKFSHrb/U7P8rsTJeRfW5A4d1Cy6PKodDg=="],
+
+ "@opentui/solid": ["@opentui/solid@0.4.5", "", { "dependencies": { "@babel/core": "7.28.0", "@babel/preset-typescript": "7.27.1", "@opentui/core": "0.4.5", "babel-plugin-module-resolver": "5.0.2", "babel-preset-solid": "1.9.12", "entities": "7.0.1", "s-js": "^0.4.9" }, "peerDependencies": { "solid-js": "1.9.12" } }, "sha512-B0RSkXnrtPVfEJOX+Hj+axjLJ3lzbG1BZw5I7Pvb9OPp48Vzg2cW2a3cSa86/q48ndLt647i/XwFPIw/jqnI5g=="],
+
+ "@oslojs/asn1": ["@oslojs/asn1@1.0.0", "", { "dependencies": { "@oslojs/binary": "1.0.0" } }, "sha512-zw/wn0sj0j0QKbIXfIlnEcTviaCzYOY3V5rAyjR6YtOByFtJiT574+8p9Wlach0lZH9fddD4yb9laEAIl4vXQA=="],
+
+ "@oslojs/binary": ["@oslojs/binary@1.0.0", "", {}, "sha512-9RCU6OwXU6p67H4NODbuxv2S3eenuQ4/WFLrsq+K/k682xrznH5EVWA7N4VFk9VYVcbFtKqur5YQQZc0ySGhsQ=="],
+
+ "@oslojs/crypto": ["@oslojs/crypto@1.0.1", "", { "dependencies": { "@oslojs/asn1": "1.0.0", "@oslojs/binary": "1.0.0" } }, "sha512-7n08G8nWjAr/Yu3vu9zzrd0L9XnrJfpMioQcvCMxBIiF5orECHe5/3J0jmXRVvgfqMm/+4oxlQ+Sq39COYLcNQ=="],
+
+ "@oslojs/encoding": ["@oslojs/encoding@1.1.0", "", {}, "sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ=="],
+
+ "@oslojs/jwt": ["@oslojs/jwt@0.2.0", "", { "dependencies": { "@oslojs/encoding": "0.4.1" } }, "sha512-bLE7BtHrURedCn4Mco3ma9L4Y1GR2SMBuIvjWr7rmQ4/W/4Jy70TIAgZ+0nIlk0xHz1vNP8x8DCns45Sb2XRbg=="],
+
+ "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="],
+
+ "@protobufjs/base64": ["@protobufjs/base64@1.1.2", "", {}, "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg=="],
+
+ "@protobufjs/codegen": ["@protobufjs/codegen@2.0.5", "", {}, "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g=="],
+
+ "@protobufjs/eventemitter": ["@protobufjs/eventemitter@1.1.1", "", {}, "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg=="],
+
+ "@protobufjs/fetch": ["@protobufjs/fetch@1.1.1", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.1" } }, "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw=="],
+
+ "@protobufjs/float": ["@protobufjs/float@1.0.2", "", {}, "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ=="],
+
+ "@protobufjs/path": ["@protobufjs/path@1.1.2", "", {}, "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA=="],
+
+ "@protobufjs/pool": ["@protobufjs/pool@1.1.0", "", {}, "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw=="],
+
+ "@protobufjs/utf8": ["@protobufjs/utf8@1.1.2", "", {}, "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug=="],
+
+ "@silvia-odwyer/photon-node": ["@silvia-odwyer/photon-node@0.3.4", "", {}, "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA=="],
+
+ "@smithy/core": ["@smithy/core@3.29.7", "", { "dependencies": { "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-BiEE2bnnGoPKdlGe3L+gOYORDHFGPuYVRLP7iUow/Sflm0B4hC4XY3FC1MRuc7ltzpW2xNnXopKi34TTkULlKQ=="],
+
+ "@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.4.12", "", { "dependencies": { "@smithy/core": "^3.29.7", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-ZZPDbl/aRp77aycuoMlo3BTayT4CE2a3uoqETYZU5ySnVbhpl5IJiY7dCZedn+ZusyDLqVv44IvKBiXd2/nK0Q=="],
+
+ "@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.6.9", "", { "dependencies": { "@smithy/core": "^3.29.7", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-EJktha5m5MXCwzdXrlWyqb9UCNHNFKlg+PmTpRsdX3dncJPTiqYleM9OKj2mLgdVJHR01d2tU4alG+z2NdH5rQ=="],
+
+ "@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="],
+
+ "@smithy/node-http-handler": ["@smithy/node-http-handler@4.7.3", "", { "dependencies": { "@smithy/core": "^3.24.3", "@smithy/types": "^4.14.2", "tslib": "^2.6.2" } }, "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA=="],
+
+ "@smithy/signature-v4": ["@smithy/signature-v4@5.6.8", "", { "dependencies": { "@smithy/core": "^3.29.7", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-iGBm6hIwD2MGvVRSgrjVWa4FXtXDq3akxu0DCpnkmBo0xtEHZ/siMRt7ycfZAefYr2UdywUgmGtoRLaq5u56pg=="],
+
+ "@smithy/types": ["@smithy/types@4.16.1", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-0JFs3V2y2M9tKW5na/qxe69Zv+uxLMO7QBbhxF/FHu/Gp2NFZAAL9tWl9PU02xxo07pb3G9FTyjNc6D5uZrJIg=="],
+
+ "@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="],
+
+ "@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="],
+
+ "@standard-schema/spec": ["@standard-schema/spec@1.0.0-beta.3", "", {}, "sha512-0ifF3BjA1E8SY9C+nUew8RefNOIq0cDlYALPty4rhUm8Rrl6tCM8hBT4bhGhx7I7iXD0uAgt50lgo8dD73ACMw=="],
+
+ "@tsconfig/bun": ["@tsconfig/bun@1.0.10", "", {}, "sha512-5AV5YknQjNyoYzZ/8NG0dawqew/wH+x7ANiCfCIn29qo0cdbd1EryvFD1k5NSZWLBMOI/fGqMIaxi58GPIP9Cg=="],
+
+ "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
+
+ "@types/node": ["@types/node@24.10.1", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-GNWcUTRBgIRJD5zj+Tq0fKOJ5XZajIiBroOF0yvj2bSU1WvNdYS/dn9UxwsujGW4JX06dnHyjV2y9rRaybH0iQ=="],
+
+ "@types/retry": ["@types/retry@0.12.0", "", {}, "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA=="],
+
+ "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="],
+
+ "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
+
+ "arctic": ["arctic@2.3.4", "", { "dependencies": { "@oslojs/crypto": "1.0.1", "@oslojs/encoding": "1.1.0", "@oslojs/jwt": "0.2.0" } }, "sha512-+p30BOWsctZp+CVYCt7oAean/hWGW42sH5LAcRQX56ttEkFJWbzXBhmSpibbzwSJkRrotmsA+oAoJoVsU0f5xA=="],
+
+ "aws4fetch": ["aws4fetch@1.0.20", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="],
+
+ "babel-plugin-jsx-dom-expressions": ["babel-plugin-jsx-dom-expressions@0.40.7", "", { "dependencies": { "@babel/helper-module-imports": "7.18.6", "@babel/plugin-syntax-jsx": "^7.18.6", "@babel/types": "^7.20.7", "html-entities": "2.3.3", "parse5": "^7.1.2" }, "peerDependencies": { "@babel/core": "^7.20.12" } }, "sha512-/O6JWUmjv03OI9lL2ry9bUjpD5S3PclM55RRJEyCdcFZ5W2SEA/59d+l2hNsk3gI6kiWRdRPdOtqZmsQzFN1pQ=="],
+
+ "babel-plugin-module-resolver": ["babel-plugin-module-resolver@5.0.2", "", { "dependencies": { "find-babel-config": "^2.1.1", "glob": "^9.3.3", "pkg-up": "^3.1.0", "reselect": "^4.1.7", "resolve": "^1.22.8" } }, "sha512-9KtaCazHee2xc0ibfqsDeamwDps6FZNo5S0Q81dUqEuFzVwPhcT4J5jOqIVvgCA3Q/wO9hKYxN/Ds3tIsp5ygg=="],
+
+ "babel-preset-solid": ["babel-preset-solid@1.9.12", "", { "dependencies": { "babel-plugin-jsx-dom-expressions": "^0.40.6" }, "peerDependencies": { "@babel/core": "^7.0.0", "solid-js": "^1.9.12" }, "optionalPeers": ["solid-js"] }, "sha512-LLqnuKVDlKpyBlMPcH6qEvs/wmS9a+NczppxJ3ryS/c0O5IiSFOIBQi9GzyiGDSbcJpx4Gr87jyFTos1MyEuWg=="],
+
+ "balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
+
+ "base64-js": ["base64-js@1.5.1", "", {}, "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="],
+
+ "baseline-browser-mapping": ["baseline-browser-mapping@2.11.0", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-oCu2wfipvX3AePSgmOuKkIywOu+8n9psz7hXYmk56ghpu3+7KzNIBopaOs4c9BrtdnTtW30unG9GTfHo7EwERQ=="],
+
+ "bignumber.js": ["bignumber.js@9.3.1", "", {}, "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="],
+
+ "bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="],
+
+ "brace-expansion": ["brace-expansion@5.0.7", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA=="],
+
+ "browserslist": ["browserslist@4.28.7", "", { "dependencies": { "baseline-browser-mapping": "^2.10.44", "caniuse-lite": "^1.0.30001806", "electron-to-chromium": "^1.5.393", "node-releases": "^2.0.51", "update-browserslist-db": "^1.2.3" }, "bin": { "browserslist": "cli.js" } }, "sha512-JxV13hNrFxqjOc8alRbq9dK1MM79NEXYpma2B2J4wAtpWS5zIEIKqWPGCl7N4o7Uc7B7itylh7SuDujATRyyTw=="],
+
+ "buffer-equal-constant-time": ["buffer-equal-constant-time@1.0.1", "", {}, "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA=="],
+
+ "bun-ffi-structs": ["bun-ffi-structs@0.2.4", "", { "peerDependencies": { "typescript": "^5" } }, "sha512-AJzsqoVFs1KBbJbWHIYrVZLDC3NhTqqh25awRXqzoLzmBAKr5oqk6+CwuYHAekKx+VBCYVohBoKuRq40dV+TYg=="],
+
+ "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
+
+ "caniuse-lite": ["caniuse-lite@1.0.30001806", "", {}, "sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw=="],
+
+ "chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="],
+
+ "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="],
+
+ "cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
+
+ "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
+
+ "data-uri-to-buffer": ["data-uri-to-buffer@4.0.1", "", {}, "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A=="],
+
+ "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" }, "peerDependencies": { "supports-color": "*" }, "optionalPeers": ["supports-color"] }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
+
+ "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
+
+ "diff": ["diff@9.0.0", "", {}, "sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw=="],
+
+ "ecdsa-sig-formatter": ["ecdsa-sig-formatter@1.0.11", "", { "dependencies": { "safe-buffer": "^5.0.1" } }, "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ=="],
+
+ "effect": ["effect@4.0.0-beta.83", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.8.0", "find-my-way-ts": "^0.1.6", "ini": "^7.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^2.0.1", "multipasta": "^0.2.7", "toml": "^4.1.1", "uuid": "^14.0.0", "yaml": "^2.9.0" } }, "sha512-0wsak8RtgGAr9UWSbVDgJHZcUqMSvicHcvaZv1MbMM7MCGgW4Rn/137J1MHQbwYPcwYGxT/IqehFd+UbYuj78w=="],
+
+ "electron-to-chromium": ["electron-to-chromium@1.5.395", "", {}, "sha512-7zt9Aw+SrmxLWLN0zhaTWZQiCdryLVrYTq5R7iZakLvi2UQPYMMsROYV/2qVCzMeCiSXHwKOU+sZ4zOVVlrtKA=="],
+
+ "emoji-regex": ["emoji-regex@10.6.0", "", {}, "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A=="],
+
+ "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
+
+ "es-errors": ["es-errors@1.3.0", "", {}, "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="],
+
+ "esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="],
+
+ "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="],
+
+ "extend": ["extend@3.0.2", "", {}, "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g=="],
+
+ "fast-check": ["fast-check@4.9.0", "", { "dependencies": { "pure-rand": "^8.0.0" } }, "sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg=="],
+
+ "fetch-blob": ["fetch-blob@3.2.0", "", { "dependencies": { "node-domexception": "^1.0.0", "web-streams-polyfill": "^3.0.3" } }, "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ=="],
+
+ "find-babel-config": ["find-babel-config@2.1.2", "", { "dependencies": { "json5": "^2.2.3" } }, "sha512-ZfZp1rQyp4gyuxqt1ZqjFGVeVBvmpURMqdIWXbPRfB97Bf6BzdK/xSIbylEINzQ0kB5tlDQfn9HkNXXWsqTqLg=="],
+
+ "find-my-way-ts": ["find-my-way-ts@0.1.6", "", {}, "sha512-a85L9ZoXtNAey3Y6Z+eBWW658kO/MwR7zIafkIUPUMf3isZG0NCs2pjW2wtjxAKuJPxMAsHUIP4ZPGv0o5gyTA=="],
+
+ "find-up": ["find-up@3.0.0", "", { "dependencies": { "locate-path": "^3.0.0" } }, "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg=="],
+
+ "formdata-polyfill": ["formdata-polyfill@4.0.10", "", { "dependencies": { "fetch-blob": "^3.1.2" } }, "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g=="],
+
+ "fs.realpath": ["fs.realpath@1.0.0", "", {}, "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw=="],
+
+ "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
+
+ "gaxios": ["gaxios@7.2.0", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2" } }, "sha512-CUVb4wcYe+771XevyH6HtGmXFAGGKkIC3kswAP8Z1JCe0j80JMaTPZH930DWFrvo0atjh18Arc0pEyUCWa5bfg=="],
+
+ "gcp-metadata": ["gcp-metadata@8.1.2", "", { "dependencies": { "gaxios": "^7.0.0", "google-logging-utils": "^1.0.0", "json-bigint": "^1.0.0" } }, "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg=="],
+
+ "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="],
+
+ "get-east-asian-width": ["get-east-asian-width@1.6.0", "", {}, "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA=="],
+
+ "glob": ["glob@13.0.6", "", { "dependencies": { "minimatch": "^10.2.2", "minipass": "^7.1.3", "path-scurry": "^2.0.2" } }, "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw=="],
+
+ "google-auth-library": ["google-auth-library@10.9.0", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^7.1.4", "gcp-metadata": "8.1.2", "google-logging-utils": "1.1.3", "jws": "^4.0.0" } }, "sha512-xtvUqvINPhTaBm7nXqlYPcrMHJPm1lCNdSovxnKKhTm+4JsvQ+KGVYJViLoH9Yxu8w+T0Qv5HubzYT9BLrppJg=="],
+
+ "google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="],
+
+ "graceful-fs": ["graceful-fs@4.2.11", "", {}, "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ=="],
+
+ "has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
+
+ "hasown": ["hasown@2.0.4", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A=="],
+
+ "highlight.js": ["highlight.js@10.7.3", "", {}, "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A=="],
+
+ "hono": ["hono@4.12.31", "", {}, "sha512-zJIHFrl6bq3RDd2YusFNCDlM8qUprxKswyi/OPzPyzKDdyBXDqWx8bZlZ7R+saTdSTatUmb3O7K4SspGPaEOQg=="],
+
+ "hosted-git-info": ["hosted-git-info@9.0.3", "", { "dependencies": { "lru-cache": "^11.1.0" } }, "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg=="],
+
+ "html-entities": ["html-entities@2.3.3", "", {}, "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA=="],
+
+ "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
+
+ "https-proxy-agent": ["https-proxy-agent@7.0.6", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "4" } }, "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw=="],
+
+ "ignore": ["ignore@7.0.5", "", {}, "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg=="],
+
+ "ini": ["ini@7.0.0", "", {}, "sha512-ifK0CgjALofS5bkrcTy4RaQ9Vx2Knf/eLeIO+NaswQEpH1UblrtTSCIvN71qQDMq0PeQ/SSPojvEJp9vvvfr+w=="],
+
+ "is-core-module": ["is-core-module@2.16.2", "", { "dependencies": { "hasown": "^2.0.3" } }, "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA=="],
+
+ "isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
+
+ "jiti": ["jiti@2.7.0", "", { "bin": { "jiti": "lib/jiti-cli.mjs" } }, "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ=="],
+
+ "jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="],
+
+ "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="],
+
+ "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="],
+
+ "json-bigint": ["json-bigint@1.0.0", "", { "dependencies": { "bignumber.js": "^9.0.0" } }, "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ=="],
+
+ "json-schema": ["json-schema@0.4.0", "", {}, "sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA=="],
+
+ "json-schema-to-ts": ["json-schema-to-ts@3.1.1", "", { "dependencies": { "@babel/runtime": "^7.18.3", "ts-algebra": "^2.0.0" } }, "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g=="],
+
+ "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="],
+
+ "jsonc-parser": ["jsonc-parser@3.3.1", "", {}, "sha512-HUgH65KyejrUFPvHFPbqOY0rsFip3Bo5wb4ngvdi1EpCYWUQDC5V+Y7mZws+DLkr4M//zQJoanu1SP+87Dv1oQ=="],
+
+ "jwa": ["jwa@2.0.1", "", { "dependencies": { "buffer-equal-constant-time": "^1.0.1", "ecdsa-sig-formatter": "1.0.11", "safe-buffer": "^5.0.1" } }, "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg=="],
+
+ "jws": ["jws@4.0.1", "", { "dependencies": { "jwa": "^2.0.1", "safe-buffer": "^5.0.1" } }, "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA=="],
+
+ "kubernetes-types": ["kubernetes-types@1.30.0", "", {}, "sha512-Dew1okvhM/SQcIa2rcgujNndZwU8VnSapDgdxlYoB84ZlpAD43U6KLAFqYo17ykSFGHNPrg0qry0bP+GJd9v7Q=="],
+
+ "lefthook": ["lefthook@2.1.9", "", { "optionalDependencies": { "lefthook-darwin-arm64": "2.1.9", "lefthook-darwin-x64": "2.1.9", "lefthook-freebsd-arm64": "2.1.9", "lefthook-freebsd-x64": "2.1.9", "lefthook-linux-arm64": "2.1.9", "lefthook-linux-x64": "2.1.9", "lefthook-openbsd-arm64": "2.1.9", "lefthook-openbsd-x64": "2.1.9", "lefthook-windows-arm64": "2.1.9", "lefthook-windows-x64": "2.1.9" }, "bin": { "lefthook": "bin/index.js" } }, "sha512-bwDaIOViTktE8kJLf9jP0p+H2/RDTlFFlc43Am2YgUsX22hI6Sq4RbzsrecwzY5y+MHTipOH7WsmWSEniePHWQ=="],
+
+ "lefthook-darwin-arm64": ["lefthook-darwin-arm64@2.1.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-119HryNcvr4nqn0wUIrNPgpMEPn9yMQzEcW/lezRsnb56PCJriJB92+MCySPVcWDxJnZef7o0T3jdnPNiSH7Qg=="],
+
+ "lefthook-darwin-x64": ["lefthook-darwin-x64@2.1.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-dwo5Tke2XcQCM56DGHgFKBfRbJIL6xs2wZ0zG1TUVZgl4t4mQUt6LiZ4V/ZQfYHTZF9qywvXoIlR5N35qOaiVQ=="],
+
+ "lefthook-freebsd-arm64": ["lefthook-freebsd-arm64@2.1.9", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-+09PVap6nl6xsaHch5JLtq7WvIR++U1Q2MzA2ai0M4uB/VP3AqrvKqHw6+9hjyKnIH+HHL83uqi77EAY+LaxLA=="],
+
+ "lefthook-freebsd-x64": ["lefthook-freebsd-x64@2.1.9", "", { "os": "freebsd", "cpu": "x64" }, "sha512-8XresjKIYpkE9ARgCtBEZgJZxAU3T4MIqzj4zNy15XRT59I1Us+QdqXTNm+pkZ41Yd2X/nxs2Pkvbq3NWWlIGw=="],
+
+ "lefthook-linux-arm64": ["lefthook-linux-arm64@2.1.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-1oNIQfwrPe6rgU2KcDM3aF6+hpZDCKx1TmawQKpXUY5gVsbZ7MqX0Sk/1lnnWxqPm+kQQ5f6J2dpFWd+4xH8jg=="],
+
+ "lefthook-linux-x64": ["lefthook-linux-x64@2.1.9", "", { "os": "linux", "cpu": "x64" }, "sha512-fT+7Q+BJyGp+CslFQkNXmdFRgyVXsPHPi9NAsDX0a6QOyNnoORByAsvx6zeAKuF5rL3BBgNfho1/v2RuGxGy9w=="],
+
+ "lefthook-openbsd-arm64": ["lefthook-openbsd-arm64@2.1.9", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-4bVuafBk3dddVNo0+3hMbjcJs4mqYAstxpPMmX2ufkudSTYFNIhWoqwuGVQV/SS/xdcOKJAldW4qayAzed2ysw=="],
+
+ "lefthook-openbsd-x64": ["lefthook-openbsd-x64@2.1.9", "", { "os": "openbsd", "cpu": "x64" }, "sha512-PmPoMmLP/wQQWcQ9u2YH86bTZ3UCfBsxuEmVTEyPU2U8R1qSTp5r/Gs3G8cN5Mxo91XB9oBERtF1n+xD3W6aVA=="],
+
+ "lefthook-windows-arm64": ["lefthook-windows-arm64@2.1.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-KphfkBKmwBnmolyrdhIl3lrBaOyTcCgXBT2AB/9OHnEXhOLvv5uTCUkrD4YRAxXPtFKq6UvnapIeoL3GZq0bdA=="],
+
+ "lefthook-windows-x64": ["lefthook-windows-x64@2.1.9", "", { "os": "win32", "cpu": "x64" }, "sha512-2qlUtkJHZ3MyUxgV5XTEmcrIoNZA07iwaquoswAcqv/1MeBFXlD+O+koFRfrzWng2O5WYEbpJnd8tvaYnV8fTA=="],
+
+ "locate-path": ["locate-path@3.0.0", "", { "dependencies": { "p-locate": "^3.0.0", "path-exists": "^3.0.0" } }, "sha512-7AO748wWnIhNqAuaty2ZWHkQHRSNfPVIsPIfwEOWO22AmaoVrWavlOcMR5nzTLNYvp36X220/maaRsrec1G65A=="],
+
+ "long": ["long@5.3.2", "", {}, "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA=="],
+
+ "lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="],
+
+ "marked": ["marked@17.0.1", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-boeBdiS0ghpWcSwoNm/jJBwdpFaMnZWRzjA6SkUMYb40SVaN1x7mmfGKp0jvexGcx+7y2La5zRZsYFZI6Qpypg=="],
+
+ "minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
+
+ "minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
+
+ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
+
+ "msgpackr": ["msgpackr@2.0.4", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.4" } }, "sha512-o1C5KRmuRt+apqMr1HuGSqWStZoRBUpEsCsl15uM9VdAF1qHLtvMOU2En747EnTyEl6c4pzPewRMFF31s1CNbA=="],
+
+ "msgpackr-extract": ["msgpackr-extract@3.0.4", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.4", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.4", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.4" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-4kmO/MdyUIkLIvTPr8VHLil4AtoKIoniWPIEk5+CDy0xnWC84azhSFmuJ7PxZdsYtiP5kEeQsORAVIeMgxT+Hw=="],
+
+ "multipasta": ["multipasta@0.2.8", "", {}, "sha512-ZPWuMKyv0cSO29f7hozp+k6+crZbQijV8ipMvxNxRf2SwtYGTX1ZX89Kd20VV4H9Znonx+EQn+iy1wGQsJ+b+Q=="],
+
+ "node-domexception": ["node-domexception@1.0.0", "", {}, "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ=="],
+
+ "node-fetch": ["node-fetch@3.3.2", "", { "dependencies": { "data-uri-to-buffer": "^4.0.0", "fetch-blob": "^3.1.4", "formdata-polyfill": "^4.0.10" } }, "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA=="],
+
+ "node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="],
+
+ "node-releases": ["node-releases@2.0.51", "", {}, "sha512-wRNIrw4DmVLKQlbgOMdkMx27Wrpzes2hh5Jtbi2bjPd+4wJstWIqP5A+lscnqbm0xxmT5Bpg8Lec5ItEBwx6BQ=="],
+
+ "openai": ["openai@6.26.0", "", { "peerDependencies": { "ws": "^8.18.0", "zod": "^3.25 || ^4.0" }, "optionalPeers": ["ws", "zod"], "bin": { "openai": "bin/cli" } }, "sha512-zd23dbWTjiJ6sSAX6s0HrCZi41JwTA1bQVs0wLQPZ2/5o2gxOJA5wh7yOAUgwYybfhDXyhwlpeQf7Mlgx8EOCA=="],
+
+ "p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="],
+
+ "p-locate": ["p-locate@3.0.0", "", { "dependencies": { "p-limit": "^2.0.0" } }, "sha512-x+12w/To+4GFfgJhBEpiDcLozRJGegY+Ei7/z0tSLkMmxGZNybVMSfWj9aJn8Z5Fc7dBUNJOOVgPv2H7IwulSQ=="],
+
+ "p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ=="],
+
+ "p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="],
+
+ "parse5": ["parse5@7.3.0", "", { "dependencies": { "entities": "^6.0.0" } }, "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw=="],
+
+ "partial-json": ["partial-json@0.1.7", "", {}, "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA=="],
+
+ "path-exists": ["path-exists@3.0.0", "", {}, "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ=="],
+
+ "path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
+
+ "path-parse": ["path-parse@1.0.7", "", {}, "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw=="],
+
+ "path-scurry": ["path-scurry@2.0.2", "", { "dependencies": { "lru-cache": "^11.0.0", "minipass": "^7.1.2" } }, "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg=="],
+
+ "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
+
+ "pkg-up": ["pkg-up@3.1.0", "", { "dependencies": { "find-up": "^3.0.0" } }, "sha512-nDywThFk1i4BQK4twPQ6TA4RT8bDY96yeuCVBWL3ePARCiEKDRSrNGbFIgUJpLp+XeIR65v8ra7WuJOFUBtkMA=="],
+
+ "proper-lockfile": ["proper-lockfile@4.1.2", "", { "dependencies": { "graceful-fs": "^4.2.4", "retry": "^0.12.0", "signal-exit": "^3.0.2" } }, "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA=="],
+
+ "protobufjs": ["protobufjs@7.6.5", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw=="],
+
+ "pure-rand": ["pure-rand@8.4.2", "", {}, "sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng=="],
+
+ "reselect": ["reselect@4.1.8", "", {}, "sha512-ab9EmR80F/zQTMNeneUr4cv+jSwPJgIlvEmVwLerwrWVbpLlBuls9XHzIeTFy4cegU2NHBp3va0LKOzU5qFEYQ=="],
+
+ "resolve": ["resolve@1.22.12", "", { "dependencies": { "es-errors": "^1.3.0", "is-core-module": "^2.16.1", "path-parse": "^1.0.7", "supports-preserve-symlinks-flag": "^1.0.0" }, "bin": { "resolve": "bin/resolve" } }, "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA=="],
+
+ "retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="],
+
+ "s-js": ["s-js@0.4.9", "", {}, "sha512-RtpOm+cM6O0sHg6IA70wH+UC3FZcND+rccBZpBAHzlUgNO2Bm5BN+FnM8+OBxzXdwpKWFwX11JGF0MFRkhSoIQ=="],
+
+ "safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
+
+ "semver": ["semver@7.8.0", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA=="],
+
+ "seroval": ["seroval@1.5.6", "", {}, "sha512-rVQVWjjSvlINzaQPZH5JFqsqEsIWdTxY3iJZCnTL/5gQbXIRooVZKI60tVCkOVfzcRPejboxO2t0P89dg5mQaA=="],
+
+ "seroval-plugins": ["seroval-plugins@1.5.6", "", { "peerDependencies": { "seroval": "^1.0" } }, "sha512-HXuLAX2pu/UByPpaeo/TaMfvMIi+1QqIoPJYCcAtU8QkVNwgR6MPlGuCQTErV1JwraaMbYaWVIBX7mppzGLATQ=="],
+
+ "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
+
+ "shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
+
+ "signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="],
+
+ "solid-js": ["solid-js@1.9.12", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.5.0", "seroval-plugins": "~1.5.0" } }, "sha512-QzKaSJq2/iDrWR1As6MHZQ8fQkdOBf8GReYb7L5iKwMGceg7HxDcaOHk0at66tNgn9U2U7dXo8ZZpLIAmGMzgw=="],
+
+ "string-width": ["string-width@7.2.0", "", { "dependencies": { "emoji-regex": "^10.3.0", "get-east-asian-width": "^1.0.0", "strip-ansi": "^7.1.0" } }, "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ=="],
+
+ "strip-ansi": ["strip-ansi@7.1.2", "", { "dependencies": { "ansi-regex": "^6.0.1" } }, "sha512-gmBGslpoQJtgnMAvOVqGZpEz9dyoKTCzy2nfz/n8aIFhN/jCE/rCmcxabB6jOOHV+0WNnylOxaxBQPSvcWklhA=="],
+
+ "supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
+
+ "supports-preserve-symlinks-flag": ["supports-preserve-symlinks-flag@1.0.0", "", {}, "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w=="],
+
+ "toml": ["toml@4.3.0", "", {}, "sha512-lVb8X9BsPVuH0M4BKeS91tXAmJvCjQ5UIyAbQFaxkKGyUFK2RPkhwaFSQH8vbpl1d23eu/IBH+dwVMHWaq9A5A=="],
+
+ "ts-algebra": ["ts-algebra@2.0.0", "", {}, "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw=="],
+
+ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
+
+ "typebox": ["typebox@1.1.38", "", {}, "sha512-pZ0aQPmMmXoUvSbeuWf/Hzsc+avNw/Zd6VeE8CFgkVGWyuHPJvqeJJDeJqLve+K70LvjYIoleGcoJHPT17cWoA=="],
+
+ "typescript": ["typescript@6.0.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw=="],
+
+ "undici": ["undici@8.5.0", "", {}, "sha512-xamtWoB1EshgjpmlXd7GGm2VfdDtw1+rD8uhry8pSNW3If6S8E0m2T2+orSKeZXEn/aPJMviCpDBA65WJt8zhg=="],
+
+ "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
+
+ "update-browserslist-db": ["update-browserslist-db@1.2.3", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w=="],
+
+ "uuid": ["uuid@14.0.1", "", { "bin": { "uuid": "dist-node/bin/uuid" } }, "sha512-6ZxzVpzDXDa3bJWaHilVayA+BH/1zmxCJoVgvmqJnid/gPoKHxUrS/aC/T6LGQtNHT+XHG9fXPJB4d+IrU30Ew=="],
+
+ "web-streams-polyfill": ["web-streams-polyfill@3.3.3", "", {}, "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw=="],
+
+ "web-tree-sitter": ["web-tree-sitter@0.25.10", "", { "peerDependencies": { "@types/emscripten": "^1.40.0" }, "optionalPeers": ["@types/emscripten"] }, "sha512-Y09sF44/13XvgVKgO2cNDw5rGk6s26MgoZPXLESvMXeefBf7i6/73eFurre0IsTW6E14Y0ArIzhUMmjoc7xyzA=="],
+
+ "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
+
+ "ws": ["ws@8.21.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-+0NTnW77fFN/DjQi6k/Sq/Yvk4Sgajw7urW8V+asjXnRgDs9gyGkdb7EzgfhA4goXsRIZKE28fzIXBHEzhuiWw=="],
+
+ "xdg-basedir": ["xdg-basedir@5.1.0", "", {}, "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ=="],
+
+ "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="],
+
+ "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="],
+
+ "zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="],
+
+ "zod-to-json-schema": ["zod-to-json-schema@3.25.2", "", { "peerDependencies": { "zod": "^3.25.28 || ^4" } }, "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA=="],
+
+ "@aws-sdk/credential-provider-http/@smithy/node-http-handler": ["@smithy/node-http-handler@4.9.9", "", { "dependencies": { "@smithy/core": "^3.29.7", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-xVBZ3hptB99iNO9XyWqEhC7KD9bP9UPXhuy3h5Y2ItCfBv160D9IIC/Fmmp3EbnWwit4C+KVqlSE+E29Nk/pPg=="],
+
+ "@aws-sdk/credential-provider-sso/@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1092.0", "", { "dependencies": { "@aws-sdk/core": "^3.976.0", "@aws-sdk/nested-clients": "^3.997.34", "@aws-sdk/types": "^3.974.2", "@smithy/core": "^3.29.4", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-hBYUAr6iBLNFcsiWTgtBb0stdSw39VOUq4Sp4A5caCNf66BAZplWN4FleKrVpJx5li2YgdnK2DqoFSMWC642FQ=="],
+
+ "@aws-sdk/nested-clients/@smithy/node-http-handler": ["@smithy/node-http-handler@4.9.9", "", { "dependencies": { "@smithy/core": "^3.29.7", "@smithy/types": "^4.16.1", "tslib": "^2.6.2" } }, "sha512-xVBZ3hptB99iNO9XyWqEhC7KD9bP9UPXhuy3h5Y2ItCfBv160D9IIC/Fmmp3EbnWwit4C+KVqlSE+E29Nk/pPg=="],
+
+ "@babel/core/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
+
+ "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="],
+
+ "@babel/helper-compilation-targets/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
+
+ "@babel/helper-create-class-features-plugin/semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="],
+
+ "@earendil-works/pi-coding-agent/diff": ["diff@8.0.4", "", {}, "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw=="],
+
+ "@earendil-works/pi-tui/marked": ["marked@18.0.5", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w=="],
+
+ "@opencode-ai/plugin/zod": ["zod@4.1.8", "", {}, "sha512-5R1P+WwQqmmMIEACyzSvo4JXHY5WiAFHRMg+zBZKgKS+Q1viRa0C1hmUKtHltoIFKtIdki3pRxkmpP74jnNYHQ=="],
+
+ "@oslojs/jwt/@oslojs/encoding": ["@oslojs/encoding@0.4.1", "", {}, "sha512-hkjo6MuIK/kQR5CrGNdAPZhS01ZCXuWDRJ187zh6qqF2+yMHZpD9fAYpX8q2bOO6Ryhl3XpCT6kUX76N8hhm4Q=="],
+
+ "babel-plugin-jsx-dom-expressions/@babel/helper-module-imports": ["@babel/helper-module-imports@7.18.6", "", { "dependencies": { "@babel/types": "^7.18.6" } }, "sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA=="],
+
+ "babel-plugin-module-resolver/glob": ["glob@9.3.5", "", { "dependencies": { "fs.realpath": "^1.0.0", "minimatch": "^8.0.2", "minipass": "^4.2.4", "path-scurry": "^1.6.1" } }, "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q=="],
+
+ "effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="],
+
+ "p-retry/retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="],
+
+ "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="],
+
+ "babel-plugin-module-resolver/glob/minimatch": ["minimatch@8.0.7", "", { "dependencies": { "brace-expansion": "^2.0.1" } }, "sha512-V+1uQNdzybxa14e/p00HZnQNNcTjnRJjDxg2V8wtkjFctq4M7hXFws4oekyTP0Jebeq7QYtpFyOeBAjc88zvYg=="],
+
+ "babel-plugin-module-resolver/glob/minipass": ["minipass@4.2.8", "", {}, "sha512-fNzuVyifolSLFL4NzpF+wEF4qrgqaaKX0haXPQEdQ7NKAN+WecoKMHV09YcuL/DHxrUsYQOK3MiuDf7Ip2OXfQ=="],
+
+ "babel-plugin-module-resolver/glob/path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
+
+ "babel-plugin-module-resolver/glob/minimatch/brace-expansion": ["brace-expansion@2.1.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA=="],
+
+ "babel-plugin-module-resolver/glob/path-scurry/lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
+
+ "babel-plugin-module-resolver/glob/path-scurry/minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
+
+ "babel-plugin-module-resolver/glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
+ }
+}
diff --git a/bunfig.toml b/bunfig.toml
new file mode 100644
index 0000000..7d2006e
--- /dev/null
+++ b/bunfig.toml
@@ -0,0 +1,2 @@
+[test]
+preload = ["./test/setup.ts", "@opentui/solid/preload"]
diff --git a/lefthook.yml b/lefthook.yml
new file mode 100644
index 0000000..2c30a5e
--- /dev/null
+++ b/lefthook.yml
@@ -0,0 +1,5 @@
+pre-commit:
+ commands:
+ biome:
+ glob: '*.{ts,tsx,js,mjs,json,jsonc,yml,yaml}'
+ run: bunx biome check --staged --no-errors-on-unmatched
diff --git a/mise.toml b/mise.toml
new file mode 100644
index 0000000..70fd63a
--- /dev/null
+++ b/mise.toml
@@ -0,0 +1,3 @@
+[tools]
+bun = "1.3"
+node = "24"
diff --git a/package.json b/package.json
index 81670b7..079ace6 100644
--- a/package.json
+++ b/package.json
@@ -6,13 +6,32 @@
"packages/*"
],
"scripts": {
- "build": "cd packages/core && npm run build && cd ../opencode && npm run build && cd ../pi && npm run build",
- "typecheck": "cd packages/core && npm run build && cd ../opencode && npm run typecheck && cd ../pi && npm run typecheck",
- "test": "cd packages/core && npm test && cd ../opencode && npm test && cd ../pi && npm test",
- "prepublishOnly": "npm run build"
+ "build": "bun run --cwd packages/core build && bun run --cwd packages/opencode build && bun run --cwd packages/pi build",
+ "typecheck": "bun run --cwd packages/core build && bun run --cwd packages/opencode typecheck && bun run --cwd packages/pi typecheck && tsc -p tsconfig.scripts.json",
+ "test": "bun run --cwd packages/core build && bun test --isolate packages/core/src packages/opencode/src packages/pi/src test/",
+ "test:e2e": "bun test --isolate ./packages/e2e-tests/src/plugin-flow.e2e.test.ts ./packages/e2e-tests/src/cli-flow.e2e.test.ts ./packages/e2e-tests/src/rpc-tui-flow.e2e.test.ts ./packages/e2e-tests/src/fetch-guard.test.ts",
+ "test:e2e:models": "bun run --cwd packages/opencode test:e2e:models",
+ "test:e2e:regression": "bun run --cwd packages/opencode test:e2e:regression",
+ "prepublishOnly": "bun run build",
+ "dev": "bun scripts/dev.ts",
+ "dev:clean": "bun scripts/dev-clean.ts",
+ "format": "biome format --write .",
+ "format:check": "biome format .",
+ "lint": "biome lint . --error-on-warnings",
+ "pack:core:dry": "cd packages/core && bun pm pack --dry-run",
+ "pack:opencode-dry": "cd packages/opencode && bun pm pack --dry-run",
+ "pack:pi-dry": "cd packages/pi && bun pm pack --dry-run"
},
"devDependencies": {
- "@types/node": "^24.10.1",
- "typescript": "^5.0.0"
+ "@biomejs/biome": "2.5.3",
+ "@opencode-ai/plugin": "1.17.13",
+ "@opentui/core": "0.4.5",
+ "@opentui/keymap": "0.4.5",
+ "@opentui/solid": "0.4.5",
+ "@types/bun": "1.3",
+ "@tsconfig/bun": "1.0.10",
+ "@types/node": "24.10.1",
+ "lefthook": "2.1.9",
+ "typescript": "6.0.3"
}
}
diff --git a/packages/core/bunfig.toml b/packages/core/bunfig.toml
new file mode 100644
index 0000000..18ad8d7
--- /dev/null
+++ b/packages/core/bunfig.toml
@@ -0,0 +1,2 @@
+[test]
+preload = ["../../test/setup.ts"]
diff --git a/packages/core/package.json b/packages/core/package.json
index bd614fe..dedde13 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -1,6 +1,6 @@
{
"name": "@cortexkit/antigravity-auth-core",
- "version": "1.1.0",
+ "version": "2.0.0",
"description": "Harness-agnostic core for Google Antigravity OAuth: transport, fingerprint, request transforms, accounts, quota",
"type": "module",
"license": "MIT",
@@ -16,6 +16,18 @@
".": {
"import": "./dist/index.js",
"types": "./dist/index.d.ts"
+ },
+ "./file-lock": {
+ "types": "./dist/file-lock.d.ts",
+ "import": "./dist/file-lock.js"
+ },
+ "./atomic-write": {
+ "types": "./dist/atomic-write.d.ts",
+ "import": "./dist/atomic-write.js"
+ },
+ "./fetch-timeout": {
+ "types": "./dist/fetch-timeout.d.ts",
+ "import": "./dist/fetch-timeout.js"
}
},
"files": [
@@ -26,19 +38,13 @@
"scripts": {
"build": "rm -rf dist && tsc -p tsconfig.build.json",
"typecheck": "tsc --noEmit",
- "test": "vitest run",
- "prepublishOnly": "npm run build"
+ "test": "bun test --isolate ./src",
+ "prepublishOnly": "bun run build"
},
"dependencies": {
"@openauthjs/openauth": "^0.4.3",
- "proper-lockfile": "^4.1.2",
"xdg-basedir": "^5.1.0",
"zod": "^4.0.0"
},
- "devDependencies": {
- "@types/node": "^24.10.1",
- "@types/proper-lockfile": "^4.1.4",
- "typescript": "^5.0.0",
- "vitest": "^3.0.0"
- }
+ "devDependencies": {}
}
diff --git a/packages/core/src/account-manager.test.ts b/packages/core/src/account-manager.test.ts
new file mode 100644
index 0000000..093bafa
--- /dev/null
+++ b/packages/core/src/account-manager.test.ts
@@ -0,0 +1,177 @@
+import { describe, expect, it } from 'bun:test'
+import { AccountManager } from './account-manager.ts'
+import type { AccountStorageStore } from './account-storage.ts'
+import type { AccountStorageV4 } from './account-types.ts'
+
+function createStore(initial: AccountStorageV4 | null = null) {
+ let state = initial
+ let mergedSaves = 0
+ let mutations = 0
+ const store: AccountStorageStore = {
+ load: async () => state,
+ saveMerged: async (_path, next) => {
+ mergedSaves++
+ state = next
+ return next
+ },
+ mutate: async (_path, fn) => {
+ mutations++
+ const current = state ?? { version: 4, accounts: [], activeIndex: 0 }
+ state = (await fn(current)) ?? current
+ return state
+ },
+ clear: async () => {
+ state = null
+ },
+ }
+ return {
+ store,
+ state: () => state,
+ mergedSaves: () => mergedSaves,
+ mutations: () => mutations,
+ }
+}
+
+const stored: AccountStorageV4 = {
+ version: 4,
+ accounts: [
+ { refreshToken: 'r1', projectId: 'p1', addedAt: 1, lastUsed: 0 },
+ { refreshToken: 'r2', projectId: 'p2', addedAt: 1, lastUsed: 0 },
+ ],
+ activeIndex: 0,
+}
+
+describe('core AccountManager', () => {
+ it('constructs from stored and fallback auth', () => {
+ const memory = createStore(stored)
+ const manager = new AccountManager(
+ { type: 'oauth', refresh: 'r3|p3' },
+ stored,
+ { store: memory.store },
+ )
+ expect(
+ manager.getAccounts().map((account) => account.parts.refreshToken),
+ ).toEqual(['r1', 'r2', 'r3'])
+ })
+
+ it.each([
+ 'sticky',
+ 'round-robin',
+ 'hybrid',
+ ] as const)('selects an account with %s strategy', (strategy) => {
+ const memory = createStore(stored)
+ const manager = new AccountManager(undefined, stored, {
+ store: memory.store,
+ now: () => 10_000,
+ })
+ expect(
+ manager.getCurrentOrNextForFamily('gemini', 'gemini-3-pro', strategy),
+ ).not.toBeNull()
+ })
+
+ it('tracks model-specific limits independently', () => {
+ let now = 1_000
+ const memory = createStore(stored)
+ const manager = new AccountManager(undefined, stored, {
+ store: memory.store,
+ now: () => now,
+ random: () => 0.5,
+ })
+ const first = manager.getAccounts()[0]!
+ manager.markRateLimitedWithReason(
+ first,
+ 'gemini',
+ 'antigravity',
+ 'gemini-3-pro',
+ 'RATE_LIMIT_EXCEEDED',
+ )
+ expect(
+ manager.isRateLimitedForHeaderStyle(
+ first,
+ 'gemini',
+ 'antigravity',
+ 'gemini-3-pro',
+ ),
+ ).toBe(true)
+ expect(
+ manager.isRateLimitedForHeaderStyle(
+ first,
+ 'gemini',
+ 'antigravity',
+ 'gemini-3-flash',
+ ),
+ ).toBe(false)
+ now += 30_001
+ expect(
+ manager.isRateLimitedForHeaderStyle(
+ first,
+ 'gemini',
+ 'antigravity',
+ 'gemini-3-pro',
+ ),
+ ).toBe(false)
+ })
+
+ it('isolates child selection from its exact parent', () => {
+ const memory = createStore(stored)
+ const manager = new AccountManager(undefined, stored, {
+ store: memory.store,
+ })
+ const select = (id: string, parentId?: string) =>
+ manager.getCurrentOrNextForFamily(
+ 'gemini',
+ null,
+ 'round-robin',
+ 'antigravity',
+ false,
+ 100,
+ 600_000,
+ { id, parentId },
+ )?.index
+ expect(select('root')).toBe(0)
+ expect(select('child', 'root')).toBe(1)
+ })
+
+ it('uses destructive store mutation for replacement saves', async () => {
+ const memory = createStore(stored)
+ const manager = new AccountManager(undefined, stored, {
+ store: memory.store,
+ })
+ manager.removeAccountByIndex(0)
+ await manager.saveToDiskReplace()
+ expect(memory.mutations()).toBe(1)
+ expect(memory.state()?.accounts).toHaveLength(1)
+ })
+
+ it('coalesces requested saves and dispose flushes immediately', async () => {
+ const memory = createStore(stored)
+ const manager = new AccountManager(undefined, stored, {
+ store: memory.store,
+ })
+ manager.requestSaveToDisk()
+ manager.requestSaveToDisk()
+ await manager.dispose()
+ expect(memory.mergedSaves()).toBe(1)
+ })
+})
+
+describe('AccountManager instance dependencies', () => {
+ it('keeps injected clocks isolated between manager instances', () => {
+ const firstMemory = createStore(stored)
+ const secondMemory = createStore(stored)
+ const first = new AccountManager(undefined, stored, {
+ store: firstMemory.store,
+ now: () => 1_000,
+ })
+ const second = new AccountManager(undefined, stored, {
+ store: secondMemory.store,
+ now: () => 9_000,
+ })
+
+ first.markAccountCoolingDown(first.getAccounts()[0]!, 500, 'auth-failure')
+ second.markAccountCoolingDown(second.getAccounts()[0]!, 500, 'auth-failure')
+
+ expect(first.getAccounts()[0]?.coolingDownUntil).toBe(1_500)
+ expect(second.getAccounts()[0]?.coolingDownUntil).toBe(9_500)
+ })
+})
diff --git a/packages/core/src/account-manager.ts b/packages/core/src/account-manager.ts
new file mode 100644
index 0000000..2ad6fa5
--- /dev/null
+++ b/packages/core/src/account-manager.ts
@@ -0,0 +1,2098 @@
+import type { AccountStorageStore } from './account-storage.ts'
+import { AccountStorageLockContentionError } from './account-storage.ts'
+import type {
+ AccountMetadataV3,
+ AccountSelectionStrategy,
+ AccountStorageV4,
+ CooldownReason,
+ HeaderStyle,
+ AccountModelFamily as ModelFamily,
+ RateLimitStateV3,
+} from './account-types.ts'
+import { formatRefreshParts, parseRefreshParts } from './auth.ts'
+import type { OAuthAuthDetails, RefreshParts } from './auth-types.ts'
+import {
+ type Fingerprint,
+ type FingerprintVersion,
+ generateFingerprint,
+ MAX_FINGERPRINT_HISTORY,
+ updateFingerprintVersion,
+} from './fingerprint.ts'
+import type { QuotaGroup, QuotaGroupSummary } from './quota-types.ts'
+import {
+ type AccountWithMetrics,
+ getHealthTracker,
+ getTokenTracker,
+ selectHybridAccount,
+} from './rotation.ts'
+import { getModelFamily } from './transform/model-resolver.ts'
+
+export type {
+ AccountSelectionStrategy,
+ CooldownReason,
+ HeaderStyle,
+ ModelFamily,
+}
+
+function isStorageLockContention(error: unknown): boolean {
+ if (error instanceof AccountStorageLockContentionError) return true
+ const message = String(error)
+ return (
+ message.includes('Lock file is already being held') ||
+ message.includes('ELOCKED')
+ )
+}
+
+export interface AccountManagerOptions {
+ store: AccountStorageStore
+ storagePath?: string
+ now?: () => number
+ random?: () => number
+ pid?: number
+ onDiagnostic?: (message: string, fields?: Record) => void
+}
+
+export type { RateLimitReason } from './rotation.ts'
+export {
+ calculateBackoffMs,
+ computeSoftQuotaCacheTtlMs,
+ parseRateLimitReason,
+} from './rotation.ts'
+
+import type { RateLimitReason } from './rotation.ts'
+import { calculateBackoffMs } from './rotation.ts'
+
+export type BaseQuotaKey = 'claude' | 'gemini-antigravity' | 'gemini-cli'
+export type QuotaKey = BaseQuotaKey | `${BaseQuotaKey}:${string}`
+
+export interface ManagedAccount {
+ index: number
+ email?: string
+ label?: string
+ addedAt: number
+ lastUsed: number
+ parts: RefreshParts
+ access?: string
+ expires?: number
+ enabled: boolean
+ rateLimitResetTimes: RateLimitStateV3
+ lastSwitchReason?: 'rate-limit' | 'initial' | 'rotation'
+ coolingDownUntil?: number
+ cooldownReason?: CooldownReason
+ touchedForQuota: Record
+ consecutiveFailures?: number
+ /** Timestamp of last failure for TTL-based reset of consecutiveFailures */
+ lastFailureTime?: number
+ /** Per-account device fingerprint for rate limit mitigation */
+ fingerprint?: import('./fingerprint').Fingerprint
+ /** History of previous fingerprints for this account */
+ fingerprintHistory?: FingerprintVersion[]
+ /** Cached quota data from last checkAccountsQuota() call */
+ cachedQuota?: Partial>
+ cachedQuotaUpdatedAt?: number
+ verificationRequired?: boolean
+ verificationRequiredAt?: number
+ verificationRequiredReason?: string
+ verificationUrl?: string
+ accountIneligible?: boolean
+ accountIneligibleAt?: number
+ accountIneligibleReason?: string
+ eligibilityStateUpdatedAt?: number
+ /** Daily request counts per model family */
+ dailyRequestCounts?: {
+ date: string
+ claude: number
+ gemini: number
+ }
+}
+
+function clampNonNegativeInt(value: unknown, fallback: number): number {
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
+ return fallback
+ }
+ return value < 0 ? 0 : Math.floor(value)
+}
+
+function getQuotaKey(
+ family: ModelFamily,
+ headerStyle: HeaderStyle,
+ model?: string | null,
+): QuotaKey {
+ if (family === 'claude') {
+ return 'claude'
+ }
+ const base =
+ headerStyle === 'gemini-cli' ? 'gemini-cli' : 'gemini-antigravity'
+ if (model) {
+ return `${base}:${model}`
+ }
+ return base
+}
+
+function isRateLimitedForQuotaKey(
+ account: ManagedAccount,
+ key: QuotaKey,
+ now: () => number,
+): boolean {
+ const resetTime = account.rateLimitResetTimes[key]
+ return resetTime !== undefined && now() < resetTime
+}
+
+function isRateLimitedForFamily(
+ account: ManagedAccount,
+ family: ModelFamily,
+ now: () => number,
+ model?: string | null,
+): boolean {
+ if (family === 'claude') {
+ return isRateLimitedForQuotaKey(account, 'claude', now)
+ }
+
+ const antigravityIsLimited = isRateLimitedForHeaderStyle(
+ account,
+ family,
+ 'antigravity',
+ now,
+ model,
+ )
+ const cliIsLimited = isRateLimitedForHeaderStyle(
+ account,
+ family,
+ 'gemini-cli',
+ now,
+ model,
+ )
+
+ return antigravityIsLimited && cliIsLimited
+}
+
+function isRateLimitedForHeaderStyle(
+ account: ManagedAccount,
+ family: ModelFamily,
+ headerStyle: HeaderStyle,
+ now: () => number,
+ model?: string | null,
+): boolean {
+ clearExpiredRateLimits(account, now)
+
+ if (family === 'claude') {
+ return isRateLimitedForQuotaKey(account, 'claude', now)
+ }
+
+ // Check model-specific quota first if provided
+ if (model) {
+ const modelKey = getQuotaKey(family, headerStyle, model)
+ if (isRateLimitedForQuotaKey(account, modelKey, now)) {
+ return true
+ }
+ }
+
+ // Then check base family quota
+ const baseKey = getQuotaKey(family, headerStyle)
+ return isRateLimitedForQuotaKey(account, baseKey, now)
+}
+
+function clearExpiredRateLimits(
+ account: ManagedAccount,
+ clock: () => number,
+): void {
+ const now = clock()
+ const keys = Object.keys(account.rateLimitResetTimes) as QuotaKey[]
+ for (const key of keys) {
+ const resetTime = account.rateLimitResetTimes[key]
+ if (resetTime !== undefined && now >= resetTime) {
+ delete account.rateLimitResetTimes[key]
+ }
+ }
+}
+
+/**
+ * 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) {
+ return getModelFamily(model)
+ }
+ return family === 'claude' ? 'claude' : 'gemini-pro'
+}
+
+function isOverSoftQuotaThreshold(
+ account: ManagedAccount,
+ family: ModelFamily,
+ thresholdPercent: number,
+ cacheTtlMs: number,
+ now: () => number,
+ model?: string | null,
+): boolean {
+ if (thresholdPercent >= 100) return false
+ if (!account.cachedQuota) return false
+
+ if (account.cachedQuotaUpdatedAt == null) return false
+ const age = now() - 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
+
+ return isOverThreshold
+}
+
+export interface AccountSessionIdentity {
+ id: string
+ parentId?: string | null
+}
+
+interface AccountSessionState {
+ parentId: string | null
+ currentAccountIndexByFamily: Record
+ cursorByFamily: Record
+ offsetAppliedByFamily: Record
+ usedAccounts: Set
+ lastAccessedAt: number
+}
+
+const ACCOUNT_SESSION_STATE_TTL_MS = 24 * 60 * 60 * 1000
+const MAX_ACCOUNT_SESSION_STATES = 256
+
+/**
+ * In-memory multi-account manager with sticky account selection.
+ *
+ * Uses the same account until it hits a rate limit (429), then switches.
+ * Rate limits are tracked per-model-family (claude/gemini) so an account
+ * rate-limited for Claude can still be used for Gemini.
+ *
+ * Source of truth for the pool is `antigravity-accounts.json`.
+ */
+export class AccountManager {
+ private accounts: ManagedAccount[] = []
+ private cursorByFamily: Record = { claude: 0, gemini: 0 }
+ private currentAccountIndexByFamily: Record = {
+ claude: -1,
+ gemini: -1,
+ }
+ private sessionOffsetApplied: Record = {
+ claude: false,
+ gemini: false,
+ }
+ private lastToastAccountIndex = -1
+ private lastToastTime = 0
+
+ private savePending = false
+ private saveTimeout: ReturnType | null = null
+ private saveInFlight: Promise | null = null
+ private disposed = false
+ private savePromiseResolvers: Array<{
+ resolve: () => void
+ reject: (err: unknown) => void
+ }> = []
+
+ private sessionStartTime: number
+ private sessionRequestCounts: Map<
+ string,
+ { claude: number; gemini: number }
+ > = new Map()
+ private sessionUsedAccounts: Set = new Set()
+ private requestSessionStates = new Map()
+
+ private readonly store: AccountStorageStore
+ private readonly storagePath: string
+ private readonly onDiagnostic: AccountManagerOptions['onDiagnostic']
+ private readonly now: () => number
+ private readonly random: () => number
+ private readonly pid: number
+
+ constructor(
+ authFallback: OAuthAuthDetails | undefined,
+ stored: AccountStorageV4 | null | undefined,
+ options: AccountManagerOptions,
+ ) {
+ this.store = options.store
+ this.storagePath = options.storagePath ?? ''
+ this.onDiagnostic = options.onDiagnostic
+ this.now = options.now ?? (() => Date.now())
+ this.random = options.random ?? (() => Math.random())
+ this.pid = options.pid ?? process.pid
+ this.sessionStartTime = this.now()
+ const authParts = authFallback
+ ? parseRefreshParts(authFallback.refresh)
+ : null
+
+ if (stored && stored.accounts.length === 0) {
+ this.accounts = []
+ this.cursorByFamily = { claude: 0, gemini: 0 }
+ return
+ }
+
+ if (stored && stored.accounts.length > 0) {
+ const baseNow = this.now()
+ this.accounts = stored.accounts
+ .map((acc, index): ManagedAccount | null => {
+ if (!acc.refreshToken || typeof acc.refreshToken !== 'string') {
+ return null
+ }
+ const matchesFallback = !!(
+ authFallback &&
+ authParts?.refreshToken &&
+ acc.refreshToken === authParts.refreshToken
+ )
+
+ return {
+ index,
+ email: acc.email,
+ label: acc.label,
+ 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 ?? generateFingerprint(),
+ fingerprintHistory: acc.fingerprintHistory ?? [],
+ cachedQuota: acc.cachedQuota as
+ | Partial>
+ | undefined,
+ cachedQuotaUpdatedAt: acc.cachedQuotaUpdatedAt,
+ dailyRequestCounts: acc.dailyRequestCounts,
+ verificationRequired: acc.verificationRequired,
+ verificationRequiredAt: acc.verificationRequiredAt,
+ verificationRequiredReason: acc.verificationRequiredReason,
+ verificationUrl: acc.verificationUrl,
+ accountIneligible: acc.accountIneligible,
+ accountIneligibleAt: acc.accountIneligibleAt,
+ accountIneligibleReason: acc.accountIneligibleReason,
+ eligibilityStateUpdatedAt: acc.eligibilityStateUpdatedAt,
+ }
+ })
+ .filter((a): a is ManagedAccount => a !== null)
+
+ // Update fingerprint versions to match the current runtime version.
+ // Saved fingerprints may carry an older version string; this ensures
+ // they always reflect the latest fetched (or fallback) version.
+ let fingerprintVersionChanged = false
+ for (const acc of this.accounts) {
+ if (acc.fingerprint && updateFingerprintVersion(acc.fingerprint)) {
+ fingerprintVersionChanged = true
+ }
+ }
+
+ const legacyCursor = clampNonNegativeInt(stored.activeIndex, 0)
+ if (this.accounts.length > 0) {
+ const defaultIndex = legacyCursor % this.accounts.length
+ this.currentAccountIndexByFamily.claude =
+ clampNonNegativeInt(
+ stored.activeIndexByFamily?.claude,
+ defaultIndex,
+ ) % this.accounts.length
+ this.currentAccountIndexByFamily.gemini =
+ clampNonNegativeInt(
+ stored.activeIndexByFamily?.gemini,
+ defaultIndex,
+ ) % this.accounts.length
+ this.cursorByFamily.claude = this.currentAccountIndexByFamily.claude
+ this.cursorByFamily.gemini = this.currentAccountIndexByFamily.gemini
+ }
+
+ // Persist updated fingerprint versions to disk
+ if (fingerprintVersionChanged) {
+ this.requestSaveToDisk()
+ }
+
+ // If current auth isn't in the loaded accounts, add it to the pool
+ if (authFallback && authParts?.refreshToken) {
+ const hasMatching = this.accounts.some(
+ (acc) => acc.parts.refreshToken === authParts.refreshToken,
+ )
+ if (!hasMatching) {
+ const now = this.now()
+ 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(),
+ fingerprintHistory: [],
+ }
+ this.accounts.push(newAccount)
+ }
+ }
+
+ return
+ }
+
+ if (authFallback) {
+ const parts = parseRefreshParts(authFallback.refresh)
+ if (parts.refreshToken) {
+ const now = this.now()
+ this.accounts = [
+ {
+ index: 0,
+ email: undefined,
+ addedAt: now,
+ lastUsed: 0,
+ parts,
+ access: authFallback.access,
+ expires: authFallback.expires,
+ enabled: true,
+ rateLimitResetTimes: {},
+ touchedForQuota: {},
+ },
+ ]
+ this.cursorByFamily = { claude: 0, gemini: 0 }
+ this.currentAccountIndexByFamily.claude = 0
+ this.currentAccountIndexByFamily.gemini = 0
+ }
+ }
+ }
+
+ getAccountCount(): number {
+ return this.getEnabledAccounts().length
+ }
+
+ getTotalAccountCount(): number {
+ return this.accounts.length
+ }
+
+ getEnabledAccounts(): ManagedAccount[] {
+ return this.accounts.filter((account) => account.enabled !== false)
+ }
+
+ private getEffectiveSoftQuotaThreshold(thresholdPercent: number): number {
+ // Soft-quota protection only has a purpose when another enabled account
+ // exists to rotate to. Never block the sole usable account.
+ return this.getEnabledAccounts().length > 1 ? thresholdPercent : 100
+ }
+
+ getAccountsSnapshot(): ManagedAccount[] {
+ return this.accounts.map((a) => ({
+ ...a,
+ parts: { ...a.parts },
+ rateLimitResetTimes: { ...a.rateLimitResetTimes },
+ }))
+ }
+
+ private getRequestSessionState(
+ identity: AccountSessionIdentity,
+ ): AccountSessionState {
+ const now = this.now()
+ this.pruneRequestSessionStates(now, identity.id)
+
+ const existing = this.requestSessionStates.get(identity.id)
+ if (existing) {
+ existing.lastAccessedAt = now
+ if (identity.parentId) {
+ existing.parentId = identity.parentId
+ }
+ return existing
+ }
+
+ const state: AccountSessionState = {
+ parentId: identity.parentId ?? null,
+ currentAccountIndexByFamily: { claude: -1, gemini: -1 },
+ cursorByFamily: { ...this.cursorByFamily },
+ offsetAppliedByFamily: { claude: false, gemini: false },
+ usedAccounts: new Set(),
+ lastAccessedAt: now,
+ }
+ this.requestSessionStates.set(identity.id, state)
+ return state
+ }
+
+ private pruneRequestSessionStates(now: number, preservedId: string): void {
+ const expiry = now - ACCOUNT_SESSION_STATE_TTL_MS
+ for (const [id, state] of this.requestSessionStates) {
+ if (id !== preservedId && state.lastAccessedAt < expiry) {
+ this.requestSessionStates.delete(id)
+ }
+ }
+
+ if (
+ this.requestSessionStates.size < MAX_ACCOUNT_SESSION_STATES ||
+ this.requestSessionStates.has(preservedId)
+ ) {
+ return
+ }
+
+ let oldestId: string | null = null
+ let oldestAccess = Number.POSITIVE_INFINITY
+ for (const [id, state] of this.requestSessionStates) {
+ if (id !== preservedId && state.lastAccessedAt < oldestAccess) {
+ oldestId = id
+ oldestAccess = state.lastAccessedAt
+ }
+ }
+ if (oldestId) {
+ this.requestSessionStates.delete(oldestId)
+ }
+ }
+
+ private getActiveIndex(
+ family: ModelFamily,
+ identity?: AccountSessionIdentity,
+ ): number {
+ return identity
+ ? this.getRequestSessionState(identity).currentAccountIndexByFamily[
+ family
+ ]
+ : this.currentAccountIndexByFamily[family]
+ }
+
+ private setActiveIndex(
+ family: ModelFamily,
+ index: number,
+ identity?: AccountSessionIdentity,
+ ): void {
+ if (!identity) {
+ this.currentAccountIndexByFamily[family] = index
+ return
+ }
+
+ const state = this.getRequestSessionState(identity)
+ state.currentAccountIndexByFamily[family] = index
+ if (!state.parentId) {
+ // Preserve a useful persisted starting point without coupling active root sessions.
+ this.currentAccountIndexByFamily[family] = index
+ }
+ }
+
+ private getCursor(
+ family: ModelFamily,
+ identity?: AccountSessionIdentity,
+ ): number {
+ return identity
+ ? this.getRequestSessionState(identity).cursorByFamily[family]
+ : this.cursorByFamily[family]
+ }
+
+ private advanceCursor(
+ family: ModelFamily,
+ identity?: AccountSessionIdentity,
+ ): void {
+ const nextGlobalCursor = this.cursorByFamily[family] + 1
+ this.cursorByFamily[family] = nextGlobalCursor
+ if (identity) {
+ this.getRequestSessionState(identity).cursorByFamily[family] += 1
+ }
+ }
+
+ private getUsedAccounts(identity?: AccountSessionIdentity): Set {
+ return identity
+ ? this.getRequestSessionState(identity).usedAccounts
+ : this.sessionUsedAccounts
+ }
+
+ private preferAccountOutsideParent(
+ accounts: ManagedAccount[],
+ family: ModelFamily,
+ identity?: AccountSessionIdentity,
+ ): ManagedAccount[] {
+ if (!identity) {
+ return accounts
+ }
+ const parentId = this.getRequestSessionState(identity).parentId
+ if (!parentId) {
+ return accounts
+ }
+ const parentState = this.requestSessionStates.get(parentId)
+ const parentIndex = parentState?.currentAccountIndexByFamily[family] ?? -1
+ if (parentIndex < 0) {
+ return accounts
+ }
+ const isolated = accounts.filter((account) => account.index !== parentIndex)
+ return isolated.length > 0 ? isolated : accounts
+ }
+
+ deleteSessionState(sessionId: string): void {
+ this.requestSessionStates.delete(sessionId)
+ }
+
+ getCurrentAccountForFamily(
+ family: ModelFamily,
+ identity?: AccountSessionIdentity,
+ ): ManagedAccount | null {
+ const currentIndex = this.getActiveIndex(family, identity)
+ if (currentIndex >= 0 && currentIndex < this.accounts.length) {
+ const account = this.accounts[currentIndex] ?? null
+ // Only return account if it's enabled - disabled accounts should not be selected
+ if (account && account.enabled !== false) {
+ return account
+ }
+ }
+ return null
+ }
+
+ markSwitched(
+ account: ManagedAccount,
+ reason: 'rate-limit' | 'initial' | 'rotation',
+ family: ModelFamily,
+ identity?: AccountSessionIdentity,
+ ): void {
+ account.lastSwitchReason = reason
+ this.setActiveIndex(family, account.index, identity)
+ }
+
+ /**
+ * Check if we should show an account switch toast.
+ * Debounces repeated toasts for the same account.
+ */
+ shouldShowAccountToast(accountIndex: number, debounceMs = 30000): boolean {
+ const now = this.now()
+ if (accountIndex !== this.lastToastAccountIndex) {
+ return true
+ }
+ return now - this.lastToastTime >= debounceMs
+ }
+
+ markToastShown(accountIndex: number): void {
+ this.lastToastAccountIndex = accountIndex
+ this.lastToastTime = this.now()
+ }
+
+ getCurrentOrNextForFamily(
+ family: ModelFamily,
+ model?: string | null,
+ strategy: AccountSelectionStrategy = 'sticky',
+ headerStyle: HeaderStyle = 'antigravity',
+ pidOffsetEnabled: boolean = false,
+ softQuotaThresholdPercent: number = 100,
+ softQuotaCacheTtlMs: number = 10 * 60 * 1000,
+ identity?: AccountSessionIdentity,
+ /**
+ * Account indexes the caller has ruled out (e.g. the operator
+ * killswitch pre-filter). Every selection path — pinned session,
+ * round-robin, hybrid, and sticky fallback — skips these indexes
+ * so a killed current account falls through to the next eligible
+ * account instead of collapsing the request into the rate-limit
+ * wait path.
+ */
+ excludeIndexes?: Set,
+ ): ManagedAccount | null {
+ const quotaKey = getQuotaKey(family, headerStyle, model)
+ const effectiveSoftQuotaThreshold = this.getEffectiveSoftQuotaThreshold(
+ softQuotaThresholdPercent,
+ )
+
+ // OpenCode may run many root and child sessions concurrently in one plugin
+ // process. Pin each exact session until its account becomes unavailable.
+ if (identity) {
+ const pinned = this.getCurrentAccountForFamily(family, identity)
+ if (pinned) {
+ clearExpiredRateLimits(pinned, this.now)
+ const unavailable =
+ (excludeIndexes?.has(pinned.index) ?? false) ||
+ isRateLimitedForHeaderStyle(
+ pinned,
+ family,
+ headerStyle,
+ this.now,
+ model,
+ ) ||
+ isOverSoftQuotaThreshold(
+ pinned,
+ family,
+ effectiveSoftQuotaThreshold,
+ softQuotaCacheTtlMs,
+ this.now,
+ model,
+ ) ||
+ this.isAccountCoolingDown(pinned)
+ if (!unavailable) {
+ this.markTouchedForQuota(pinned, quotaKey)
+ return pinned
+ }
+ }
+ }
+
+ if (strategy === 'round-robin') {
+ const next = this.getNextForFamily(
+ family,
+ model,
+ headerStyle,
+ effectiveSoftQuotaThreshold,
+ softQuotaCacheTtlMs,
+ identity,
+ excludeIndexes,
+ )
+ if (next) {
+ this.markTouchedForQuota(next, quotaKey)
+ this.setActiveIndex(family, next.index, identity)
+ }
+ return next
+ }
+
+ if (strategy === 'hybrid') {
+ const healthTracker = getHealthTracker()
+ const tokenTracker = getTokenTracker()
+
+ const eligibleAccounts = this.preferAccountOutsideParent(
+ this.accounts.filter(
+ (acc) => acc.enabled !== false && !excludeIndexes?.has(acc.index),
+ ),
+ family,
+ identity,
+ )
+ const accountsWithMetrics: AccountWithMetrics[] = eligibleAccounts.map(
+ (acc) => {
+ clearExpiredRateLimits(acc, this.now)
+ return {
+ index: acc.index,
+ lastUsed: acc.lastUsed,
+ healthScore: healthTracker.getScore(acc.index),
+ isRateLimited:
+ isRateLimitedForFamily(acc, family, this.now, model) ||
+ isOverSoftQuotaThreshold(
+ acc,
+ family,
+ effectiveSoftQuotaThreshold,
+ softQuotaCacheTtlMs,
+ this.now,
+ model,
+ ),
+ isCoolingDown: this.isAccountCoolingDown(acc),
+ }
+ },
+ )
+
+ // Get current account index for stickiness
+ const currentIndex = this.getActiveIndex(family, identity)
+
+ const selectedIndex = selectHybridAccount(
+ accountsWithMetrics,
+ tokenTracker,
+ currentIndex,
+ 50,
+ this.now,
+ )
+ if (selectedIndex !== null) {
+ const selected = this.accounts[selectedIndex]
+ if (selected) {
+ selected.lastUsed = this.now()
+ this.markTouchedForQuota(selected, quotaKey)
+ this.setActiveIndex(family, selected.index, identity)
+ return selected
+ }
+ }
+ }
+
+ // Fallback: sticky selection (used when hybrid finds no candidates)
+ // PID-based offset for multi-session distribution (opt-in)
+ // Different sessions (PIDs) will prefer different starting accounts
+ const offsetApplied = identity
+ ? this.getRequestSessionState(identity).offsetAppliedByFamily
+ : this.sessionOffsetApplied
+ if (
+ pidOffsetEnabled &&
+ !offsetApplied[family] &&
+ this.accounts.length > 1
+ ) {
+ const pidOffset = this.pid % this.accounts.length
+ const activeIndex = this.getActiveIndex(family, identity)
+ const baseIndex =
+ activeIndex >= 0 ? activeIndex : this.getCursor(family, identity)
+ const newIndex = (baseIndex + pidOffset) % this.accounts.length
+
+ this.onDiagnostic?.('Applying PID account offset', {
+ pid: this.pid,
+ offset: pidOffset,
+ family,
+ fromIndex: baseIndex,
+ toIndex: newIndex,
+ })
+
+ this.setActiveIndex(family, newIndex, identity)
+ offsetApplied[family] = true
+ }
+
+ const current = this.getCurrentAccountForFamily(family, identity)
+ if (current && !excludeIndexes?.has(current.index)) {
+ clearExpiredRateLimits(current, this.now)
+ const isLimitedForRequestedStyle = isRateLimitedForHeaderStyle(
+ current,
+ family,
+ headerStyle,
+ this.now,
+ model,
+ )
+ const isOverThreshold = isOverSoftQuotaThreshold(
+ current,
+ family,
+ effectiveSoftQuotaThreshold,
+ softQuotaCacheTtlMs,
+ this.now,
+ model,
+ )
+ if (
+ !isLimitedForRequestedStyle &&
+ !isOverThreshold &&
+ !this.isAccountCoolingDown(current)
+ ) {
+ this.markTouchedForQuota(current, quotaKey)
+ return current
+ }
+ }
+
+ const next = this.getNextForFamily(
+ family,
+ model,
+ headerStyle,
+ effectiveSoftQuotaThreshold,
+ softQuotaCacheTtlMs,
+ identity,
+ excludeIndexes,
+ )
+ if (next) {
+ this.markTouchedForQuota(next, quotaKey)
+ this.setActiveIndex(family, next.index, identity)
+ }
+ return next
+ }
+
+ getNextForFamily(
+ family: ModelFamily,
+ model?: string | null,
+ headerStyle: HeaderStyle = 'antigravity',
+ softQuotaThresholdPercent: number = 100,
+ softQuotaCacheTtlMs: number = 10 * 60 * 1000,
+ identity?: AccountSessionIdentity,
+ /** Indexes ruled out by the caller (e.g. killswitch pre-filter). */
+ excludeIndexes?: Set,
+ ): ManagedAccount | null {
+ const effectiveSoftQuotaThreshold = this.getEffectiveSoftQuotaThreshold(
+ softQuotaThresholdPercent,
+ )
+ const allAvailable = this.accounts.filter((account) => {
+ clearExpiredRateLimits(account, this.now)
+ return (
+ account.enabled !== false &&
+ !excludeIndexes?.has(account.index) &&
+ !isRateLimitedForHeaderStyle(
+ account,
+ family,
+ headerStyle,
+ this.now,
+ model,
+ ) &&
+ !isOverSoftQuotaThreshold(
+ account,
+ family,
+ effectiveSoftQuotaThreshold,
+ softQuotaCacheTtlMs,
+ this.now,
+ model,
+ ) &&
+ !this.isAccountCoolingDown(account)
+ )
+ })
+ const available = this.preferAccountOutsideParent(
+ allAvailable,
+ family,
+ identity,
+ )
+
+ if (available.length === 0) {
+ return null
+ }
+
+ const usedAccounts = this.getUsedAccounts(identity)
+ const sessionUsed = available.filter((account) =>
+ usedAccounts.has(account.index),
+ )
+ const candidates = sessionUsed.length > 0 ? sessionUsed : available
+
+ const cursor = this.getCursor(family, identity)
+ const account = candidates[cursor % candidates.length]
+ if (!account) {
+ return null
+ }
+
+ this.advanceCursor(family, identity)
+ return account
+ }
+ markRateLimited(
+ account: ManagedAccount,
+ retryAfterMs: number,
+ family: ModelFamily,
+ headerStyle: HeaderStyle = 'antigravity',
+ model?: string | null,
+ ): void {
+ const key = getQuotaKey(family, headerStyle, model)
+ account.rateLimitResetTimes[key] = this.now() + retryAfterMs
+ }
+
+ /**
+ * Mark an account as used after a successful API request.
+ * This updates the lastUsed timestamp for freshness calculations.
+ * Should be called AFTER request completion, not during account selection.
+ */
+ markAccountUsed(accountIndex: number): void {
+ const account = this.accounts.find((a) => a.index === accountIndex)
+ if (account) {
+ account.lastUsed = this.now()
+ }
+ }
+
+ recordSessionUsage(
+ accountIndex: number,
+ identity?: AccountSessionIdentity,
+ ): void {
+ this.getUsedAccounts(identity).add(accountIndex)
+ }
+
+ wasUsedInSession(
+ accountIndex: number,
+ identity?: AccountSessionIdentity,
+ ): boolean {
+ return this.getUsedAccounts(identity).has(accountIndex)
+ }
+
+ shouldProactivelyRotate(
+ family: ModelFamily,
+ model: string | null | undefined,
+ thresholdPercent: number,
+ cacheTtlMs: number,
+ identity?: AccountSessionIdentity,
+ ): boolean {
+ if (thresholdPercent <= 0) return false
+
+ const current = this.getCurrentAccountForFamily(family, identity)
+ if (!current?.cachedQuota || current.cachedQuotaUpdatedAt == null)
+ return false
+
+ const age = this.now() - current.cachedQuotaUpdatedAt
+ if (age > cacheTtlMs) return false
+
+ const quotaGroup = resolveQuotaGroup(family, model)
+ const groupData = current.cachedQuota[quotaGroup]
+ if (groupData?.remainingFraction == null) return false
+
+ const remainingPercent = Math.max(
+ 0,
+ Math.min(100, groupData.remainingFraction * 100),
+ )
+ return remainingPercent < thresholdPercent
+ }
+
+ proactivelyRotateForFamily(
+ family: ModelFamily,
+ model: string | null | undefined,
+ headerStyle: HeaderStyle,
+ softQuotaThresholdPercent: number,
+ softQuotaCacheTtlMs: number,
+ identity?: AccountSessionIdentity,
+ ): ManagedAccount | null {
+ const currentIndex = this.getActiveIndex(family, identity)
+
+ const candidates = this.preferAccountOutsideParent(
+ this.accounts.filter((acc) => {
+ if (acc.enabled === false) return false
+ if (acc.index === currentIndex) return false
+ clearExpiredRateLimits(acc, this.now)
+ if (
+ isRateLimitedForHeaderStyle(acc, family, headerStyle, this.now, model)
+ )
+ return false
+ if (
+ isOverSoftQuotaThreshold(
+ acc,
+ family,
+ softQuotaThresholdPercent,
+ softQuotaCacheTtlMs,
+ this.now,
+ model,
+ )
+ )
+ return false
+ if (this.isAccountCoolingDown(acc)) return false
+ return true
+ }),
+ family,
+ identity,
+ )
+
+ if (candidates.length === 0) return null
+
+ const usedAccounts = this.getUsedAccounts(identity)
+ const warmCandidates = candidates.filter((account) =>
+ usedAccounts.has(account.index),
+ )
+ const pool = warmCandidates.length > 0 ? warmCandidates : candidates
+
+ const quotaGroup = resolveQuotaGroup(family, model)
+ pool.sort((a, b) => {
+ const aRemaining = a.cachedQuota?.[quotaGroup]?.remainingFraction ?? 0
+ const bRemaining = b.cachedQuota?.[quotaGroup]?.remainingFraction ?? 0
+ return bRemaining - aRemaining
+ })
+
+ const selected = pool[0]
+ if (!selected) return null
+
+ const quotaKey = getQuotaKey(family, headerStyle, model)
+ this.markTouchedForQuota(selected, quotaKey)
+ this.setActiveIndex(family, selected.index, identity)
+
+ return selected
+ }
+
+ markRateLimitedWithReason(
+ account: ManagedAccount,
+ family: ModelFamily,
+ headerStyle: HeaderStyle,
+ model: string | null | undefined,
+ reason: RateLimitReason,
+ retryAfterMs?: number | null,
+ failureTtlMs: number = 3600_000, // Default 1 hour TTL
+ ): number {
+ const now = this.now()
+
+ // TTL-based reset: if last failure was more than failureTtlMs ago, reset count
+ if (
+ account.lastFailureTime !== undefined &&
+ now - account.lastFailureTime > failureTtlMs
+ ) {
+ account.consecutiveFailures = 0
+ }
+
+ const failures = (account.consecutiveFailures ?? 0) + 1
+ account.consecutiveFailures = failures
+ account.lastFailureTime = now
+
+ const backoffMs = calculateBackoffMs(
+ reason,
+ failures - 1,
+ retryAfterMs,
+ this.random,
+ )
+ const key = getQuotaKey(family, headerStyle, model)
+ account.rateLimitResetTimes[key] = now + backoffMs
+
+ return backoffMs
+ }
+
+ markRequestSuccess(account: ManagedAccount): void {
+ if (account.consecutiveFailures) {
+ account.consecutiveFailures = 0
+ }
+ }
+
+ clearAllRateLimitsForFamily(
+ family: ModelFamily,
+ model?: string | null,
+ ): void {
+ for (const account of this.accounts) {
+ if (family === 'claude') {
+ delete account.rateLimitResetTimes.claude
+ } else {
+ const antigravityKey = getQuotaKey(family, 'antigravity', model)
+ const cliKey = getQuotaKey(family, 'gemini-cli', model)
+ delete account.rateLimitResetTimes[antigravityKey]
+ delete account.rateLimitResetTimes[cliKey]
+ }
+ account.consecutiveFailures = 0
+ }
+ }
+
+ shouldTryOptimisticReset(
+ family: ModelFamily,
+ model?: string | null,
+ ): boolean {
+ const minWaitMs = this.getMinWaitTimeForFamily(family, model)
+ return minWaitMs > 0 && minWaitMs <= 2_000
+ }
+
+ markAccountCoolingDown(
+ account: ManagedAccount,
+ cooldownMs: number,
+ reason: CooldownReason,
+ ): void {
+ account.coolingDownUntil = this.now() + cooldownMs
+ account.cooldownReason = reason
+ }
+
+ isAccountCoolingDown(account: ManagedAccount): boolean {
+ if (account.coolingDownUntil === undefined) {
+ return false
+ }
+ if (this.now() >= account.coolingDownUntil) {
+ this.clearAccountCooldown(account)
+ return false
+ }
+ return true
+ }
+
+ clearAccountCooldown(account: ManagedAccount): void {
+ delete account.coolingDownUntil
+ delete account.cooldownReason
+ }
+
+ getAccountCooldownReason(
+ account: ManagedAccount,
+ ): CooldownReason | undefined {
+ return this.isAccountCoolingDown(account)
+ ? account.cooldownReason
+ : undefined
+ }
+
+ markTouchedForQuota(account: ManagedAccount, quotaKey: string): void {
+ account.touchedForQuota[quotaKey] = this.now()
+ }
+
+ isFreshForQuota(account: ManagedAccount, quotaKey: string): boolean {
+ const touchedAt = account.touchedForQuota[quotaKey]
+ if (!touchedAt) return true
+
+ const resetTime = account.rateLimitResetTimes[quotaKey as QuotaKey]
+ if (resetTime && touchedAt < resetTime) return true
+
+ return false
+ }
+
+ getFreshAccountsForQuota(
+ quotaKey: string,
+ family: ModelFamily,
+ model?: string | null,
+ ): ManagedAccount[] {
+ return this.accounts.filter((acc) => {
+ clearExpiredRateLimits(acc, this.now)
+ return (
+ acc.enabled !== false &&
+ this.isFreshForQuota(acc, quotaKey) &&
+ !isRateLimitedForFamily(acc, family, this.now, model) &&
+ !this.isAccountCoolingDown(acc)
+ )
+ })
+ }
+
+ isRateLimitedForHeaderStyle(
+ account: ManagedAccount,
+ family: ModelFamily,
+ headerStyle: HeaderStyle,
+ model?: string | null,
+ ): boolean {
+ return isRateLimitedForHeaderStyle(
+ account,
+ family,
+ headerStyle,
+ this.now,
+ model,
+ )
+ }
+
+ getAvailableHeaderStyle(
+ account: ManagedAccount,
+ family: ModelFamily,
+ model?: string | null,
+ ): HeaderStyle | null {
+ clearExpiredRateLimits(account, this.now)
+ if (family === 'claude') {
+ return isRateLimitedForHeaderStyle(
+ account,
+ family,
+ 'antigravity',
+ this.now,
+ )
+ ? null
+ : 'antigravity'
+ }
+ if (
+ !isRateLimitedForHeaderStyle(
+ account,
+ family,
+ 'antigravity',
+ this.now,
+ model,
+ )
+ ) {
+ return 'antigravity'
+ }
+ if (
+ !isRateLimitedForHeaderStyle(
+ account,
+ family,
+ 'gemini-cli',
+ this.now,
+ model,
+ )
+ ) {
+ return 'gemini-cli'
+ }
+ 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, this.now)
+ // Check if antigravity is available for this account
+ return !isRateLimitedForHeaderStyle(
+ acc,
+ family,
+ 'antigravity',
+ this.now,
+ model,
+ )
+ })
+ }
+
+ setAccountEnabled(accountIndex: number, enabled: boolean): boolean {
+ const account = this.accounts[accountIndex]
+ if (!account) {
+ return false
+ }
+ if (enabled && account.accountIneligible) {
+ return false
+ }
+ account.enabled = enabled
+
+ if (!enabled) {
+ for (const family of Object.keys(
+ this.currentAccountIndexByFamily,
+ ) as ModelFamily[]) {
+ if (this.currentAccountIndexByFamily[family] === accountIndex) {
+ const next = this.accounts.find(
+ (a, i) => i !== accountIndex && a.enabled !== false,
+ )
+ this.currentAccountIndexByFamily[family] = next?.index ?? -1
+ }
+ }
+ }
+
+ this.requestSaveToDisk()
+ return true
+ }
+
+ markAccountVerificationRequired(
+ accountIndex: number,
+ reason?: string,
+ verifyUrl?: string,
+ ): boolean {
+ const account = this.accounts[accountIndex]
+ if (!account) {
+ return false
+ }
+
+ const timestamp = this.now()
+ account.verificationRequired = true
+ account.verificationRequiredAt = timestamp
+ account.verificationRequiredReason = reason?.trim() || undefined
+ if (
+ account.accountIneligible === true ||
+ account.accountIneligibleAt !== undefined ||
+ account.accountIneligibleReason !== undefined
+ ) {
+ account.accountIneligible = false
+ account.accountIneligibleAt = undefined
+ account.accountIneligibleReason = undefined
+ account.eligibilityStateUpdatedAt = timestamp
+ }
+
+ const normalizedVerifyUrl = verifyUrl?.trim()
+ if (normalizedVerifyUrl) {
+ account.verificationUrl = normalizedVerifyUrl
+ }
+
+ if (account.enabled !== false) {
+ this.setAccountEnabled(accountIndex, false)
+ } else {
+ this.requestSaveToDisk()
+ }
+
+ return true
+ }
+
+ markAccountIneligible(accountIndex: number, reason?: string): boolean {
+ const account = this.accounts[accountIndex]
+ if (!account) {
+ return false
+ }
+
+ const timestamp = this.now()
+ account.accountIneligible = true
+ account.accountIneligibleAt = timestamp
+ account.accountIneligibleReason =
+ reason?.trim() || 'Google marked this account as ineligible.'
+ account.eligibilityStateUpdatedAt = timestamp
+ account.verificationRequired = false
+ account.verificationRequiredAt = undefined
+ account.verificationRequiredReason = undefined
+ account.verificationUrl = undefined
+
+ if (account.enabled !== false) {
+ this.setAccountEnabled(accountIndex, false)
+ } else {
+ this.requestSaveToDisk()
+ }
+ return true
+ }
+
+ clearAccountAccessBlocks(
+ accountIndex: number,
+ enableAccount = false,
+ ): boolean {
+ const account = this.accounts[accountIndex]
+ if (!account) {
+ return false
+ }
+
+ const wasVerificationRequired = account.verificationRequired === true
+ const wasIneligible = account.accountIneligible === true
+ const hadMetadata =
+ wasVerificationRequired ||
+ wasIneligible ||
+ account.verificationRequiredAt !== undefined ||
+ account.verificationRequiredReason !== undefined ||
+ account.verificationUrl !== undefined ||
+ account.accountIneligibleAt !== undefined ||
+ account.accountIneligibleReason !== undefined ||
+ account.eligibilityStateUpdatedAt !== undefined
+
+ account.verificationRequired = false
+ account.verificationRequiredAt = undefined
+ account.verificationRequiredReason = undefined
+ account.verificationUrl = undefined
+ account.accountIneligible = false
+ account.accountIneligibleAt = undefined
+ account.accountIneligibleReason = undefined
+ if (wasIneligible || account.eligibilityStateUpdatedAt !== undefined) {
+ account.eligibilityStateUpdatedAt = this.now()
+ }
+
+ if (
+ enableAccount &&
+ (wasVerificationRequired || wasIneligible) &&
+ account.enabled === false
+ ) {
+ this.setAccountEnabled(accountIndex, true)
+ } else if (hadMetadata) {
+ this.requestSaveToDisk()
+ }
+ return true
+ }
+
+ removeAccountByIndex(accountIndex: number): boolean {
+ if (accountIndex < 0 || accountIndex >= this.accounts.length) {
+ return false
+ }
+ const account = this.accounts[accountIndex]
+ if (!account) {
+ return false
+ }
+ return this.removeAccount(account)
+ }
+
+ removeAccount(account: ManagedAccount): boolean {
+ const idx = this.accounts.indexOf(account)
+ if (idx < 0) {
+ return false
+ }
+
+ this.accounts.splice(idx, 1)
+ this.accounts.forEach((acc, index) => {
+ acc.index = index
+ })
+
+ if (this.accounts.length === 0) {
+ this.cursorByFamily = { claude: 0, gemini: 0 }
+ this.currentAccountIndexByFamily.claude = -1
+ this.currentAccountIndexByFamily.gemini = -1
+ this.requestSessionStates.clear()
+ return true
+ }
+
+ for (const family of ['claude', 'gemini'] as ModelFamily[]) {
+ if (this.cursorByFamily[family] > idx) {
+ this.cursorByFamily[family] -= 1
+ }
+ this.cursorByFamily[family] =
+ this.cursorByFamily[family] % this.accounts.length
+
+ if (this.currentAccountIndexByFamily[family] > idx) {
+ this.currentAccountIndexByFamily[family] -= 1
+ }
+ if (this.currentAccountIndexByFamily[family] >= this.accounts.length) {
+ this.currentAccountIndexByFamily[family] = -1
+ }
+
+ for (const state of this.requestSessionStates.values()) {
+ const currentIndex = state.currentAccountIndexByFamily[family]
+ if (currentIndex === idx) {
+ state.currentAccountIndexByFamily[family] = -1
+ } else if (currentIndex > idx) {
+ state.currentAccountIndexByFamily[family] -= 1
+ }
+ if (state.cursorByFamily[family] > idx) {
+ state.cursorByFamily[family] -= 1
+ }
+ state.cursorByFamily[family] %= this.accounts.length
+ }
+ }
+
+ for (const state of this.requestSessionStates.values()) {
+ state.usedAccounts = new Set(
+ [...state.usedAccounts]
+ .filter((accountIndex) => accountIndex !== idx)
+ .map((accountIndex) =>
+ accountIndex > idx ? accountIndex - 1 : accountIndex,
+ ),
+ )
+ }
+
+ return true
+ }
+
+ updateFromAuth(account: ManagedAccount, auth: OAuthAuthDetails): void {
+ const parts = parseRefreshParts(auth.refresh)
+ // Preserve existing projectId/managedProjectId if not in the new parts
+ account.parts = {
+ ...parts,
+ projectId: parts.projectId ?? account.parts.projectId,
+ managedProjectId:
+ parts.managedProjectId ?? account.parts.managedProjectId,
+ }
+ account.access = auth.access
+ account.expires = auth.expires
+ }
+
+ toAuthDetails(account: ManagedAccount): OAuthAuthDetails {
+ return {
+ type: 'oauth',
+ refresh: formatRefreshParts(account.parts),
+ access: account.access,
+ expires: account.expires,
+ }
+ }
+
+ getMinWaitTimeForFamily(
+ family: ModelFamily,
+ model?: string | null,
+ headerStyle?: HeaderStyle,
+ strict?: boolean,
+ ): number {
+ const available = this.accounts.filter((a) => {
+ clearExpiredRateLimits(a, this.now)
+ return (
+ a.enabled !== false &&
+ (strict && headerStyle
+ ? !isRateLimitedForHeaderStyle(
+ a,
+ family,
+ headerStyle,
+ this.now,
+ model,
+ )
+ : !isRateLimitedForFamily(a, family, this.now, model))
+ )
+ })
+ if (available.length > 0) {
+ return 0
+ }
+
+ const waitTimes: number[] = []
+ for (const a of this.accounts) {
+ if (family === 'claude') {
+ const t = a.rateLimitResetTimes.claude
+ if (t !== undefined) waitTimes.push(Math.max(0, t - this.now()))
+ } else if (strict && headerStyle) {
+ const key = getQuotaKey(family, headerStyle, model)
+ const t = a.rateLimitResetTimes[key]
+ if (t !== undefined) waitTimes.push(Math.max(0, t - this.now()))
+ } else {
+ // For Gemini, account becomes available when EITHER pool expires for this model/family
+ const antigravityKey = getQuotaKey(family, 'antigravity', model)
+ const cliKey = getQuotaKey(family, 'gemini-cli', model)
+
+ const t1 = a.rateLimitResetTimes[antigravityKey]
+ const t2 = a.rateLimitResetTimes[cliKey]
+
+ const accountWait = Math.min(
+ t1 !== undefined ? Math.max(0, t1 - this.now()) : Infinity,
+ t2 !== undefined ? Math.max(0, t2 - this.now()) : Infinity,
+ )
+ if (accountWait !== Infinity) waitTimes.push(accountWait)
+ }
+ }
+
+ return waitTimes.length > 0 ? Math.min(...waitTimes) : 0
+ }
+
+ getAccounts(): ManagedAccount[] {
+ return [...this.accounts]
+ }
+
+ private buildStorageSnapshot(): AccountStorageV4 {
+ const claudeIndex = Math.max(0, this.currentAccountIndexByFamily.claude)
+ const geminiIndex = Math.max(0, this.currentAccountIndexByFamily.gemini)
+
+ return {
+ version: 4,
+ accounts: this.accounts.map((a) => ({
+ email: a.email,
+ label: a.label,
+ refreshToken: a.parts.refreshToken,
+ projectId: a.parts.projectId,
+ managedProjectId: a.parts.managedProjectId,
+ addedAt: a.addedAt,
+ lastUsed: a.lastUsed,
+ enabled: a.enabled,
+ rateLimitResetTimes:
+ Object.keys(a.rateLimitResetTimes).length > 0
+ ? a.rateLimitResetTimes
+ : undefined,
+ 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,
+ dailyRequestCounts: a.dailyRequestCounts,
+ verificationRequired: a.verificationRequired,
+ verificationRequiredAt: a.verificationRequiredAt,
+ verificationRequiredReason: a.verificationRequiredReason,
+ verificationUrl: a.verificationUrl,
+ accountIneligible: a.accountIneligible,
+ accountIneligibleAt: a.accountIneligibleAt,
+ accountIneligibleReason: a.accountIneligibleReason,
+ eligibilityStateUpdatedAt: a.eligibilityStateUpdatedAt,
+ })),
+ activeIndex: claudeIndex,
+ activeIndexByFamily: {
+ claude: claudeIndex,
+ gemini: geminiIndex,
+ },
+ }
+ }
+
+ async saveToDisk(): Promise {
+ await this.store.saveMerged(this.storagePath, this.buildStorageSnapshot())
+ }
+
+ /**
+ * Persist via full-file replace (no merge). Required after destructive
+ * operations (account removal) so a deleted account is not resurrected by
+ * mergeAccountStorage re-reading it from disk.
+ */
+ async saveToDiskReplace(): Promise {
+ const snapshot = this.buildStorageSnapshot()
+ await this.store.mutate(this.storagePath, () => snapshot)
+ }
+
+ requestSaveToDisk(): void {
+ if (this.disposed || this.savePending) {
+ return
+ }
+ this.savePending = true
+ this.saveTimeout = setTimeout(() => {
+ this.saveInFlight = this.executeSave().finally(() => {
+ this.saveInFlight = null
+ })
+ }, 1000)
+ }
+
+ async flushSaveToDisk(): Promise {
+ if (!this.savePending) {
+ await this.saveInFlight
+ return
+ }
+ return new Promise((resolve, reject) => {
+ this.savePromiseResolvers.push({ resolve, reject })
+ })
+ }
+ async dispose(): Promise {
+ if (this.disposed) return
+ this.disposed = true
+ if (this.saveTimeout) {
+ clearTimeout(this.saveTimeout)
+ this.saveTimeout = null
+ }
+ if (this.savePending) {
+ await this.executeSave()
+ }
+ await this.saveInFlight
+ }
+
+ private async executeSave(): Promise {
+ this.savePending = false
+ this.saveTimeout = null
+
+ const resolvers = this.savePromiseResolvers
+ this.savePromiseResolvers = []
+
+ try {
+ await this.saveToDisk()
+ for (const { resolve } of resolvers) {
+ resolve()
+ }
+ } catch (error) {
+ if (isStorageLockContention(error)) {
+ this.onDiagnostic?.(
+ 'Skipped account-state persist due to storage lock contention',
+ {
+ error: String(error),
+ },
+ )
+ for (const { resolve } of resolvers) {
+ resolve()
+ }
+ return
+ }
+
+ this.onDiagnostic?.('Failed to persist account state', {
+ error: String(error),
+ })
+ for (const { reject } of resolvers) {
+ reject(error)
+ }
+ }
+ }
+ // ========== Fingerprint Management ==========
+
+ /**
+ * Regenerate fingerprint for an account, saving the old one to history.
+ * @param accountIndex - Index of the account to regenerate fingerprint for
+ * @returns The new fingerprint, or null if account not found
+ */
+ regenerateAccountFingerprint(accountIndex: number): Fingerprint | null {
+ const account = this.accounts[accountIndex]
+ if (!account) return null
+
+ // Save current fingerprint to history if it exists
+ if (account.fingerprint) {
+ const historyEntry: FingerprintVersion = {
+ fingerprint: account.fingerprint,
+ timestamp: this.now(),
+ reason: 'regenerated',
+ }
+
+ if (!account.fingerprintHistory) {
+ account.fingerprintHistory = []
+ }
+
+ // Add to beginning of history (most recent first)
+ account.fingerprintHistory.unshift(historyEntry)
+
+ // Trim to max history size
+ if (account.fingerprintHistory.length > MAX_FINGERPRINT_HISTORY) {
+ account.fingerprintHistory = account.fingerprintHistory.slice(
+ 0,
+ MAX_FINGERPRINT_HISTORY,
+ )
+ }
+ }
+
+ // Generate and assign new fingerprint
+ account.fingerprint = generateFingerprint()
+ this.requestSaveToDisk()
+
+ return account.fingerprint
+ }
+
+ /**
+ * Restore a fingerprint from history for an account.
+ * @param accountIndex - Index of the account
+ * @param historyIndex - Index in the fingerprint history to restore from (0 = most recent)
+ * @returns The restored fingerprint, or null if account/history not found
+ */
+ restoreAccountFingerprint(
+ accountIndex: number,
+ historyIndex: number,
+ ): Fingerprint | null {
+ const account = this.accounts[accountIndex]
+ if (!account) return null
+
+ const history = account.fingerprintHistory
+ if (!history || historyIndex < 0 || historyIndex >= history.length) {
+ return null
+ }
+
+ // Capture the fingerprint to restore BEFORE modifying history
+ const fingerprintToRestore = history[historyIndex]!.fingerprint
+
+ // Save current fingerprint to history before restoring (if it exists)
+ if (account.fingerprint) {
+ const historyEntry: FingerprintVersion = {
+ fingerprint: account.fingerprint,
+ timestamp: this.now(),
+ reason: 'restored',
+ }
+
+ account.fingerprintHistory!.unshift(historyEntry)
+
+ // Trim to max history size
+ if (account.fingerprintHistory!.length > MAX_FINGERPRINT_HISTORY) {
+ account.fingerprintHistory = account.fingerprintHistory!.slice(
+ 0,
+ MAX_FINGERPRINT_HISTORY,
+ )
+ }
+ }
+
+ // Restore the fingerprint
+ account.fingerprint = { ...fingerprintToRestore, createdAt: this.now() }
+
+ this.requestSaveToDisk()
+
+ return account.fingerprint
+ }
+
+ /**
+ * Get fingerprint history for an account.
+ * @param accountIndex - Index of the account
+ * @returns Array of fingerprint versions, or empty array if not found
+ */
+ getAccountFingerprintHistory(accountIndex: number): FingerprintVersion[] {
+ const account = this.accounts[accountIndex]
+ if (!account?.fingerprintHistory) {
+ return []
+ }
+ return [...account.fingerprintHistory]
+ }
+
+ updateQuotaCache(
+ accountIndex: number,
+ quotaGroups: Partial>,
+ ): void {
+ const account = this.accounts[accountIndex]
+ if (account) {
+ account.cachedQuota = quotaGroups
+ account.cachedQuotaUpdatedAt = this.now()
+ }
+ }
+
+ /**
+ * Record a successful API request for an account.
+ * Tracks per model family with daily reset.
+ */
+ recordRequest(accountIndex: number, family: ModelFamily): void {
+ const account = this.accounts[accountIndex]
+ if (!account) return
+
+ const today = new Date(this.now()).toISOString().slice(0, 10)
+
+ if (
+ !account.dailyRequestCounts ||
+ account.dailyRequestCounts.date !== today
+ ) {
+ account.dailyRequestCounts = { date: today, claude: 0, gemini: 0 }
+ }
+
+ account.dailyRequestCounts[family]++
+ account.lastUsed = this.now()
+
+ // Also track for session
+ this.recordSessionRequest(accountIndex, family)
+ }
+
+ /**
+ * Get request counts for an account for today.
+ */
+ getDailyRequestCounts(
+ accountIndex: number,
+ ): { date: string; claude: number; gemini: number } | null {
+ const account = this.accounts[accountIndex]
+ if (!account?.dailyRequestCounts) return null
+
+ const today = new Date(this.now()).toISOString().slice(0, 10)
+ if (account.dailyRequestCounts.date !== today) return null
+
+ return { ...account.dailyRequestCounts }
+ }
+
+ /**
+ * Get total daily request counts across all accounts for a model family.
+ */
+ getTotalDailyRequests(family: ModelFamily): number {
+ const today = new Date(this.now()).toISOString().slice(0, 10)
+ let total = 0
+ for (const account of this.accounts) {
+ if (account.dailyRequestCounts?.date === today) {
+ total += account.dailyRequestCounts[family]
+ }
+ }
+ return total
+ }
+
+ /**
+ * Get a summary of daily request distribution across accounts.
+ * Returns accounts sorted by request count (descending).
+ */
+ getDailyRequestSummary(
+ family: ModelFamily,
+ ): Array<{ index: number; email?: string; count: number }> {
+ const today = new Date(this.now()).toISOString().slice(0, 10)
+ const result: Array<{ index: number; email?: string; count: number }> = []
+
+ for (const account of this.accounts) {
+ const count =
+ account.dailyRequestCounts?.date === today
+ ? account.dailyRequestCounts[family]
+ : 0
+ if (count > 0) {
+ result.push({ index: account.index, email: account.email, count })
+ }
+ }
+
+ return result.sort((a, b) => b.count - a.count)
+ }
+
+ /**
+ * Record a request for the current session (in-memory only).
+ */
+ recordSessionRequest(accountIndex: number, family: ModelFamily): void {
+ const key = String(accountIndex)
+ const current = this.sessionRequestCounts.get(key) ?? {
+ claude: 0,
+ gemini: 0,
+ }
+ current[family]++
+ this.sessionRequestCounts.set(key, current)
+ }
+
+ /**
+ * Get a summary of the current session's request usage.
+ */
+ getSessionSummary(): {
+ durationMinutes: number
+ totalClaude: number
+ totalGemini: number
+ requestsPerHour: number
+ accountsUsed: number
+ perAccount: Array<{
+ index: number
+ email?: string
+ claude: number
+ gemini: number
+ }>
+ } {
+ const durationMs = this.now() - this.sessionStartTime
+ const durationMinutes = Math.round(durationMs / 60000)
+ const durationHours = durationMs / 3600000
+
+ let totalClaude = 0
+ let totalGemini = 0
+ const perAccount: Array<{
+ index: number
+ email?: string
+ claude: number
+ gemini: number
+ }> = []
+
+ for (const [key, counts] of this.sessionRequestCounts) {
+ const idx = Number(key)
+ const account = this.accounts[idx]
+ totalClaude += counts.claude
+ totalGemini += counts.gemini
+ if (counts.claude > 0 || counts.gemini > 0) {
+ perAccount.push({
+ index: idx,
+ email: account?.email,
+ claude: counts.claude,
+ gemini: counts.gemini,
+ })
+ }
+ }
+
+ const totalRequests = totalClaude + totalGemini
+ const requestsPerHour =
+ durationHours > 0 ? Math.round(totalRequests / durationHours) : 0
+
+ return {
+ durationMinutes,
+ totalClaude,
+ totalGemini,
+ requestsPerHour,
+ accountsUsed: perAccount.length,
+ perAccount: perAccount.sort(
+ (a, b) => b.claude + b.gemini - (a.claude + a.gemini),
+ ),
+ }
+ }
+
+ isAccountOverSoftQuota(
+ account: ManagedAccount,
+ family: ModelFamily,
+ thresholdPercent: number,
+ cacheTtlMs: number,
+ model?: string | null,
+ ): boolean {
+ return isOverSoftQuotaThreshold(
+ account,
+ family,
+ this.getEffectiveSoftQuotaThreshold(thresholdPercent),
+ cacheTtlMs,
+ this.now,
+ 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 = this.now() - 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 <= 1) return false
+ return enabled.every((a) =>
+ isOverSoftQuotaThreshold(
+ a,
+ family,
+ thresholdPercent,
+ cacheTtlMs,
+ this.now,
+ 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 (enabled.length === 1) return 0
+
+ // If any account is available (not over threshold), no wait needed
+ const available = enabled.filter(
+ (a) =>
+ !isOverSoftQuotaThreshold(
+ a,
+ family,
+ thresholdPercent,
+ cacheTtlMs,
+ this.now,
+ 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 = this.now()
+ 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/packages/core/src/account-storage.test.ts b/packages/core/src/account-storage.test.ts
new file mode 100644
index 0000000..b1cd03c
--- /dev/null
+++ b/packages/core/src/account-storage.test.ts
@@ -0,0 +1,937 @@
+/**
+ * Account storage concurrency + migration tests.
+ *
+ * Locks onto the fenced-file-lock semantics: `mutateAccountStorage`
+ * must wait-and-succeed when the lock is held briefly by another
+ * writer (preserving the legacy `proper-lockfile` retry schedule), and
+ * must surface a typed `AccountStorageLockContentionError` once the
+ * schedule is exhausted against a still-held lock.
+ */
+
+import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
+import { chmod, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import {
+ AccountStorageLockContentionError,
+ AccountStorageUnreadableError,
+ clearAccountStorage,
+ deduplicateAccountsByEmail,
+ loadAccountStorage,
+ mergeAccountStorage,
+ mutateAccountStorage,
+ saveAccountStorage,
+ saveAccountStorageReplace,
+} from './account-storage.ts'
+import type { AccountMetadataV3, AccountStorageV4 } from './account-types.ts'
+import { acquireFencedFileLock } from './file-lock.ts'
+
+let root: string
+
+beforeEach(async () => {
+ root = await mkdtemp(join(tmpdir(), 'account-storage-'))
+})
+
+afterEach(async () => {
+ await rm(root, { recursive: true, force: true })
+})
+
+function storagePath(name = 'accounts.json'): string {
+ return join(root, name)
+}
+
+function makeV4(
+ accounts: AccountMetadataV3[],
+ activeIndex = 0,
+ activeIndexByFamily?: { claude?: number; gemini?: number },
+): AccountStorageV4 {
+ return {
+ version: 4,
+ accounts,
+ activeIndex,
+ activeIndexByFamily,
+ }
+}
+
+function makeV3(
+ accounts: AccountMetadataV3[],
+ activeIndex = 0,
+): {
+ version: 3
+ accounts: AccountMetadataV3[]
+ activeIndex: number
+ activeIndexByFamily?: { claude?: number; gemini?: number }
+} {
+ return {
+ version: 3,
+ accounts,
+ activeIndex,
+ activeIndexByFamily: { claude: 0, gemini: 0 },
+ }
+}
+
+function makeV2(
+ accounts: Array<{
+ email?: string
+ refreshToken: string
+ projectId?: string
+ managedProjectId?: string
+ addedAt: number
+ lastUsed: number
+ rateLimitResetTimes?: { claude?: number; gemini?: number }
+ }>,
+ activeIndex = 0,
+): {
+ version: 2
+ accounts: typeof accounts
+ activeIndex: number
+} {
+ return { version: 2, accounts, activeIndex }
+}
+
+function makeV1(
+ accounts: Array<{
+ email?: string
+ refreshToken: string
+ addedAt: number
+ lastUsed: number
+ isRateLimited?: boolean
+ rateLimitResetTime?: number
+ }>,
+ activeIndex = 0,
+): {
+ version: 1
+ accounts: typeof accounts
+ activeIndex: number
+} {
+ return { version: 1, accounts, activeIndex }
+}
+
+describe('deduplicateAccountsByEmail', () => {
+ it('returns empty array for empty input', () => {
+ expect(deduplicateAccountsByEmail([])).toEqual([])
+ })
+
+ it('keeps accounts without email', () => {
+ const accounts: AccountMetadataV3[] = [
+ { refreshToken: 'r1', addedAt: 1, lastUsed: 2 },
+ { refreshToken: 'r2', addedAt: 3, lastUsed: 4 },
+ ]
+ expect(deduplicateAccountsByEmail(accounts)).toEqual(accounts)
+ })
+
+ it('keeps the newest entry by lastUsed for duplicate emails', () => {
+ const accounts: AccountMetadataV3[] = [
+ { email: 'a@example.com', refreshToken: 'old', addedAt: 1, lastUsed: 1 },
+ { email: 'a@example.com', refreshToken: 'new', addedAt: 2, lastUsed: 9 },
+ ]
+ expect(deduplicateAccountsByEmail(accounts)).toHaveLength(1)
+ expect(deduplicateAccountsByEmail(accounts)[0]?.refreshToken).toBe('new')
+ })
+})
+
+describe('mergeAccountStorage', () => {
+ it('preserves a newer ineligible decision against a stale concurrent writer', () => {
+ const existing = makeV4([
+ {
+ refreshToken: 'r1',
+ addedAt: 1,
+ lastUsed: 1,
+ enabled: false,
+ accountIneligible: true,
+ accountIneligibleAt: 200,
+ accountIneligibleReason: 'ACCOUNT_INELIGIBLE',
+ eligibilityStateUpdatedAt: 200,
+ },
+ ])
+ const staleIncoming = makeV4([
+ {
+ refreshToken: 'r1',
+ addedAt: 1,
+ lastUsed: 2,
+ enabled: true,
+ accountIneligible: false,
+ eligibilityStateUpdatedAt: 100,
+ },
+ ])
+ expect(
+ mergeAccountStorage(existing, staleIncoming).accounts[0],
+ ).toMatchObject({
+ enabled: false,
+ accountIneligible: true,
+ accountIneligibleAt: 200,
+ accountIneligibleReason: 'ACCOUNT_INELIGIBLE',
+ eligibilityStateUpdatedAt: 200,
+ })
+ })
+
+ it('accepts a newer successful eligibility recheck', () => {
+ const existing = makeV4([
+ {
+ refreshToken: 'r1',
+ addedAt: 1,
+ lastUsed: 1,
+ enabled: false,
+ accountIneligible: true,
+ accountIneligibleAt: 200,
+ accountIneligibleReason: 'ACCOUNT_INELIGIBLE',
+ eligibilityStateUpdatedAt: 200,
+ },
+ ])
+ const rechecked = makeV4([
+ {
+ refreshToken: 'r1',
+ addedAt: 1,
+ lastUsed: 2,
+ enabled: true,
+ accountIneligible: false,
+ eligibilityStateUpdatedAt: 300,
+ },
+ ])
+ expect(mergeAccountStorage(existing, rechecked).accounts[0]).toMatchObject({
+ enabled: true,
+ accountIneligible: false,
+ eligibilityStateUpdatedAt: 300,
+ })
+ })
+})
+
+describe('loadAccountStorage migrations', () => {
+ const now = Date.now()
+ const future = now + 100_000
+
+ it('migrates v1 -> v4', async () => {
+ const v1 = makeV1(
+ [
+ {
+ email: 'a@example.com',
+ refreshToken: 'r1',
+ addedAt: now,
+ lastUsed: now,
+ isRateLimited: true,
+ rateLimitResetTime: future,
+ },
+ ],
+ 0,
+ )
+ await writeFile(storagePath(), JSON.stringify(v1), 'utf8')
+
+ const result = await loadAccountStorage(storagePath())
+ expect(result?.version).toBe(4)
+ expect(result?.accounts[0]).toMatchObject({
+ refreshToken: 'r1',
+ email: 'a@example.com',
+ })
+ expect(result?.accounts[0]?.rateLimitResetTimes).toEqual({
+ claude: future,
+ 'gemini-antigravity': future,
+ })
+ })
+
+ it('migrates v2 -> v4', async () => {
+ const v2 = makeV2(
+ [
+ {
+ refreshToken: 'r1',
+ addedAt: now,
+ lastUsed: now,
+ rateLimitResetTimes: { gemini: future },
+ },
+ ],
+ 0,
+ )
+ await writeFile(storagePath(), JSON.stringify(v2), 'utf8')
+
+ const result = await loadAccountStorage(storagePath())
+ expect(result?.version).toBe(4)
+ expect(result?.accounts[0]?.rateLimitResetTimes).toEqual({
+ 'gemini-antigravity': future,
+ })
+ })
+
+ it('migrates v3 -> v4 and drops fingerprint fields', async () => {
+ const v3 = makeV3([
+ {
+ refreshToken: 'r1',
+ addedAt: now,
+ lastUsed: now,
+ fingerprint: {
+ deviceId: 'd1',
+ sessionToken: 't1',
+ userAgent: 'ua',
+ apiClient: 'ac',
+ clientMetadata: { ideType: 'IDE', platform: 'P', pluginType: 'G' },
+ createdAt: now,
+ },
+ fingerprintHistory: [
+ {
+ fingerprint: {
+ deviceId: 'd0',
+ sessionToken: 't0',
+ userAgent: 'ua',
+ apiClient: 'ac',
+ clientMetadata: {
+ ideType: 'IDE',
+ platform: 'P',
+ pluginType: 'G',
+ },
+ createdAt: now - 1,
+ },
+ timestamp: now - 1,
+ reason: 'initial',
+ },
+ ],
+ },
+ ])
+ await writeFile(storagePath(), JSON.stringify(v3), 'utf8')
+
+ const result = await loadAccountStorage(storagePath())
+ expect(result?.version).toBe(4)
+ expect(result?.accounts[0]?.fingerprint).toBeUndefined()
+ expect(result?.accounts[0]?.fingerprintHistory).toBeUndefined()
+ })
+})
+
+describe('loadAccountStorage missing-vs-unreadable', () => {
+ it('returns null when the file is missing (ENOENT is not an error)', async () => {
+ expect(await loadAccountStorage(storagePath())).toBeNull()
+ })
+
+ it('throws AccountStorageUnreadableError on JSON parse error and creates a backup', async () => {
+ const path = storagePath()
+ await writeFile(path, '{ invalid json }', 'utf8')
+
+ let captured: unknown
+ try {
+ await loadAccountStorage(path)
+ } catch (error) {
+ captured = error
+ }
+ expect(captured).toBeInstanceOf(AccountStorageUnreadableError)
+ const err = captured as AccountStorageUnreadableError
+ expect(err.details.path).toBe(path)
+ expect(err.details.reason).toBe('malformed-json')
+ expect(err.details.backupPath).not.toBeNull()
+ // The original file must remain on disk for the user to recover.
+ const onDisk = await readFile(path, 'utf8')
+ expect(onDisk).toBe('{ invalid json }')
+ // The backup sidecar must hold a verbatim copy.
+ if (err.details.backupPath) {
+ const backup = await readFile(err.details.backupPath, 'utf8')
+ expect(backup).toBe('{ invalid json }')
+ }
+ })
+
+ it('throws AccountStorageUnreadableError when accounts is not an array', async () => {
+ const path = storagePath()
+ await writeFile(
+ path,
+ JSON.stringify({ version: 4, notAccounts: [] }),
+ 'utf8',
+ )
+
+ let captured: unknown
+ try {
+ await loadAccountStorage(path)
+ } catch (error) {
+ captured = error
+ }
+ expect(captured).toBeInstanceOf(AccountStorageUnreadableError)
+ const err = captured as AccountStorageUnreadableError
+ expect(err.details.reason).toBe('invalid-shape')
+ expect(err.details.backupPath).not.toBeNull()
+ })
+
+ it('throws AccountStorageUnreadableError on unknown version', async () => {
+ const path = storagePath()
+ await writeFile(
+ path,
+ JSON.stringify({ version: 999, accounts: [] }),
+ 'utf8',
+ )
+
+ let captured: unknown
+ try {
+ await loadAccountStorage(path)
+ } catch (error) {
+ captured = error
+ }
+ expect(captured).toBeInstanceOf(AccountStorageUnreadableError)
+ const err = captured as AccountStorageUnreadableError
+ expect(err.details.reason).toBe('unsupported-version')
+ expect(err.details.detail).toContain('999')
+ expect(err.details.backupPath).not.toBeNull()
+ })
+
+ it('throws AccountStorageUnreadableError when the file is a JSON array (not an object)', async () => {
+ const path = storagePath()
+ await writeFile(path, '[]', 'utf8')
+
+ await expect(loadAccountStorage(path)).rejects.toBeInstanceOf(
+ AccountStorageUnreadableError,
+ )
+ })
+
+ it('still surfaces ENOENT as null (NOT unreadable) when the parent directory does not exist', async () => {
+ const path = join(root, 'nested-missing', 'accounts.json')
+ expect(await loadAccountStorage(path)).toBeNull()
+ })
+})
+
+describe('loadAccountStorage normalization', () => {
+ it('deduplicates accounts sharing an email', async () => {
+ await writeFile(
+ storagePath(),
+ JSON.stringify(
+ makeV4([
+ {
+ email: 'a@example.com',
+ refreshToken: 'old',
+ addedAt: 1,
+ lastUsed: 1,
+ },
+ {
+ email: 'a@example.com',
+ refreshToken: 'new',
+ addedAt: 2,
+ lastUsed: 9,
+ },
+ ]),
+ ),
+ 'utf8',
+ )
+ const result = await loadAccountStorage(storagePath())
+ expect(result?.accounts).toHaveLength(1)
+ expect(result?.accounts[0]?.refreshToken).toBe('new')
+ })
+
+ it('clamps activeIndex to a valid range', async () => {
+ await writeFile(
+ storagePath(),
+ JSON.stringify({
+ version: 4,
+ accounts: [{ refreshToken: 'r1', addedAt: 1, lastUsed: 1 }],
+ activeIndex: 99,
+ }),
+ 'utf8',
+ )
+ const result = await loadAccountStorage(storagePath())
+ expect(result?.activeIndex).toBe(0)
+ })
+})
+
+describe('per-record shape validation (v4 records)', () => {
+ it('throws AccountStorageUnreadableError when a v4 record has a non-string refreshToken (load path)', async () => {
+ const path = storagePath('v4-bad-record.json')
+ const raw = JSON.stringify({
+ version: 4,
+ accounts: [
+ {
+ refreshToken: 'valid-token',
+ addedAt: 1,
+ lastUsed: 1,
+ },
+ {
+ // Upstream maintainer repro: a numeric refreshToken must NOT
+ // be silently filtered out — that destroys user accounts.
+ refreshToken: 12345,
+ addedAt: 2,
+ lastUsed: 2,
+ },
+ ],
+ activeIndex: 0,
+ })
+ await writeFile(path, raw, 'utf8')
+
+ let captured: unknown
+ try {
+ await loadAccountStorage(path)
+ } catch (error) {
+ captured = error
+ }
+ expect(captured).toBeInstanceOf(AccountStorageUnreadableError)
+ const err = captured as AccountStorageUnreadableError
+ expect(err.details.reason).toBe('invalid-shape')
+ expect(err.details.path).toBe(path)
+ expect(err.details.backupPath).not.toBeNull()
+ // The original file must remain byte-identical — no destructive
+ // normalization that drops the malformed record.
+ expect(await readFile(path, 'utf8')).toBe(raw)
+ if (err.details.backupPath) {
+ expect(await readFile(err.details.backupPath, 'utf8')).toBe(raw)
+ }
+ })
+
+ it('throws AccountStorageUnreadableError when a v4 record is missing refreshToken (mutate path)', async () => {
+ const path = storagePath('v4-missing-refresh.json')
+ const raw = JSON.stringify({
+ version: 4,
+ accounts: [
+ { refreshToken: 'a', addedAt: 1, lastUsed: 1 },
+ { addedAt: 2, lastUsed: 2 }, // no refreshToken
+ ],
+ activeIndex: 0,
+ })
+ await writeFile(path, raw, 'utf8')
+
+ let captured: unknown
+ try {
+ await mutateAccountStorage(path, (current) => current)
+ } catch (error) {
+ captured = error
+ }
+ expect(captured).toBeInstanceOf(AccountStorageUnreadableError)
+ const err = captured as AccountStorageUnreadableError
+ expect(err.details.reason).toBe('invalid-shape')
+ expect(await readFile(path, 'utf8')).toBe(raw)
+ expect(err.details.backupPath).not.toBeNull()
+ })
+
+ it('throws AccountStorageUnreadableError when a v4 record is not an object', async () => {
+ const path = storagePath('v4-non-object-record.json')
+ const raw = JSON.stringify({
+ version: 4,
+ accounts: [
+ { refreshToken: 'a', addedAt: 1, lastUsed: 1 },
+ 'not-an-object',
+ ],
+ activeIndex: 0,
+ })
+ await writeFile(path, raw, 'utf8')
+
+ let captured: unknown
+ try {
+ await loadAccountStorage(path)
+ } catch (error) {
+ captured = error
+ }
+ expect(captured).toBeInstanceOf(AccountStorageUnreadableError)
+ expect((captured as AccountStorageUnreadableError).details.reason).toBe(
+ 'invalid-shape',
+ )
+ expect(await readFile(path, 'utf8')).toBe(raw)
+ })
+
+ it('still migrates a legacy v1 record whose refreshToken is a string', async () => {
+ // Strict per-record validation must NOT apply before migration —
+ // a clean v1 record that migrates cleanly is fine.
+ const path = storagePath('legacy-v1-strict.json')
+ const v1 = {
+ version: 1,
+ accounts: [
+ {
+ email: 'a@example.com',
+ refreshToken: 'r1',
+ addedAt: 1,
+ lastUsed: 1,
+ },
+ ],
+ activeIndex: 0,
+ }
+ await writeFile(path, JSON.stringify(v1), 'utf8')
+ const result = await loadAccountStorage(path)
+ expect(result?.version).toBe(4)
+ expect(result?.accounts[0]?.refreshToken).toBe('r1')
+ })
+})
+
+describe('saveAccountStorage', () => {
+ it('persists the v4 file with secure permissions on POSIX', async () => {
+ if (process.platform === 'win32') return
+ const path = storagePath()
+ const storage = makeV4([
+ {
+ refreshToken: 'r1',
+ addedAt: 1,
+ lastUsed: 1,
+ },
+ ])
+ await saveAccountStorage(path, storage)
+ const stats = await stat(path)
+ expect((stats.mode & 0o777).toString(8)).toBe('600')
+ })
+
+ it('merges new accounts into an existing pool', async () => {
+ const path = storagePath()
+ await saveAccountStorage(
+ path,
+ makeV4([{ refreshToken: 'r1', addedAt: 1, lastUsed: 1 }]),
+ )
+ await saveAccountStorage(
+ path,
+ makeV4([{ refreshToken: 'r2', addedAt: 2, lastUsed: 2 }]),
+ )
+ const result = await loadAccountStorage(path)
+ const tokens = result?.accounts.map((a) => a.refreshToken).sort()
+ expect(tokens).toEqual(['r1', 'r2'])
+ })
+})
+
+describe('saveAccountStorageReplace', () => {
+ it('replaces the file (does not merge)', async () => {
+ const path = storagePath()
+ await saveAccountStorage(
+ path,
+ makeV4([{ refreshToken: 'r1', addedAt: 1, lastUsed: 1 }]),
+ )
+ await saveAccountStorageReplace(
+ path,
+ makeV4([{ refreshToken: 'r2', addedAt: 2, lastUsed: 2 }]),
+ )
+ const result = await loadAccountStorage(path)
+ expect(result?.accounts).toHaveLength(1)
+ expect(result?.accounts[0]?.refreshToken).toBe('r2')
+ })
+})
+
+describe('clearAccountStorage', () => {
+ it('removes an existing file', async () => {
+ const path = storagePath()
+ await saveAccountStorage(
+ path,
+ makeV4([{ refreshToken: 'r1', addedAt: 1, lastUsed: 1 }]),
+ )
+ await clearAccountStorage(path)
+ expect(await loadAccountStorage(path)).toBeNull()
+ })
+
+ it('succeeds silently when the file is already absent', async () => {
+ await expect(clearAccountStorage(storagePath())).resolves.toBeUndefined()
+ })
+})
+
+describe('mutateAccountStorage concurrency', () => {
+ it('serializes two add operations so both accounts remain', async () => {
+ const path = storagePath()
+ await saveAccountStorage(path, makeV4([], 0))
+
+ const delays: number[] = []
+ const start = Date.now()
+ const a = mutateAccountStorage(path, async (current) => {
+ // Pause to simulate the write window, giving B a chance to commit first.
+ await new Promise((r) => setTimeout(r, 150))
+ return {
+ version: 4,
+ accounts: [
+ ...current.accounts,
+ { refreshToken: 'a', addedAt: 1, lastUsed: 1 },
+ ],
+ activeIndex: 0,
+ }
+ })
+ // Yield the event loop so A acquires the lock first.
+ await new Promise((r) => setTimeout(r, 10))
+ const b = mutateAccountStorage(path, (current) => ({
+ version: 4,
+ accounts: [
+ ...current.accounts,
+ { refreshToken: 'b', addedAt: 2, lastUsed: 2 },
+ ],
+ activeIndex: 0,
+ }))
+
+ await Promise.all([a, b])
+ delays.push(Date.now() - start)
+
+ const result = await loadAccountStorage(path)
+ const tokens = result?.accounts.map((acc) => acc.refreshToken).sort()
+ expect(tokens).toEqual(['a', 'b'])
+ })
+
+ it('lets a remove-then-add sequence against fresh state leave A absent and B present', async () => {
+ const path = storagePath()
+ await saveAccountStorage(
+ path,
+ makeV4([
+ { refreshToken: 'a', addedAt: 1, lastUsed: 1 },
+ { refreshToken: 'b', addedAt: 2, lastUsed: 2 },
+ ]),
+ )
+
+ const remove = mutateAccountStorage(path, async (current) => {
+ // Pause so B can commit while we still hold the lock-less snapshot.
+ await new Promise((r) => setTimeout(r, 150))
+ return {
+ version: 4,
+ accounts: current.accounts.filter((acc) => acc.refreshToken !== 'a'),
+ activeIndex: 0,
+ }
+ })
+ await new Promise((r) => setTimeout(r, 10))
+ const add = mutateAccountStorage(path, (current) => ({
+ version: 4,
+ accounts: [
+ ...current.accounts,
+ { refreshToken: 'new-b', addedAt: 9, lastUsed: 9 },
+ ],
+ activeIndex: 0,
+ }))
+
+ await Promise.all([remove, add])
+
+ const result = await loadAccountStorage(path)
+ const tokens = result?.accounts.map((acc) => acc.refreshToken).sort()
+ expect(tokens).toEqual(['b', 'new-b'])
+ })
+
+ it('waits and commits when a competing lock is released mid-mutation', async () => {
+ const path = storagePath()
+ const held = await acquireFencedFileLock({
+ path,
+ name: 'accounts',
+ ttlMs: 10_000,
+ renew: true,
+ })
+ expect(held).not.toBeNull()
+ const heldLock = held!
+
+ // Schedule the competing lock to release well within the
+ // legacy wait-and-retry schedule (first retry sleeps 100ms,
+ // second sleeps 200ms — by then the holder should have released).
+ setTimeout(() => {
+ heldLock.release().catch(() => {})
+ }, 150)
+
+ const startedAt = Date.now()
+ const result = await mutateAccountStorage(path, (current) => ({
+ version: 4,
+ accounts: [
+ ...current.accounts,
+ { refreshToken: 'late', addedAt: 1, lastUsed: 1 },
+ ],
+ activeIndex: 0,
+ }))
+ const elapsed = Date.now() - startedAt
+
+ expect(elapsed).toBeGreaterThanOrEqual(100)
+ expect(result.accounts.map((acc) => acc.refreshToken)).toContain('late')
+ })
+
+ it('throws AccountStorageLockContentionError when the lock is held past the retry schedule', async () => {
+ const path = storagePath()
+ const held = await acquireFencedFileLock({
+ path,
+ name: 'accounts',
+ ttlMs: 10_000,
+ renew: true,
+ })
+ expect(held).not.toBeNull()
+ const heldLock = held!
+
+ try {
+ await expect(
+ mutateAccountStorage(path, (current) => current, {
+ sleep: async () => {
+ // Zero-delay sleep so the test does not block for the full schedule.
+ },
+ }),
+ ).rejects.toBeInstanceOf(AccountStorageLockContentionError)
+ } finally {
+ await heldLock.release()
+ }
+ })
+})
+
+describe('file mode after storage operations', () => {
+ it('chmods an existing v4 file to 0600 on POSIX', async () => {
+ if (process.platform === 'win32') return
+ const path = storagePath()
+ await writeFile(
+ path,
+ JSON.stringify(makeV4([{ refreshToken: 'r1', addedAt: 1, lastUsed: 1 }])),
+ 'utf8',
+ )
+ await chmod(path, 0o644)
+ expect(((await stat(path)).mode & 0o777).toString(8)).toBe('644')
+
+ await loadAccountStorage(path)
+
+ expect(((await stat(path)).mode & 0o777).toString(8)).toBe('600')
+ })
+
+ it('creates parent directories on demand', async () => {
+ const nested = join(root, 'nested', 'deep', 'accounts.json')
+ await saveAccountStorage(
+ nested,
+ makeV4([{ refreshToken: 'r1', addedAt: 1, lastUsed: 1 }]),
+ )
+ const raw = JSON.parse(await readFile(nested, 'utf8'))
+ expect(raw.version).toBe(4)
+ })
+})
+
+describe('ensureFileExists on initial write', () => {
+ it('seeds an empty v4 pool when mutate is called against a missing file', async () => {
+ const path = storagePath('first-write.json')
+ const result = await mutateAccountStorage(path, (current) => ({
+ ...current,
+ activeIndex: 0,
+ }))
+ expect(result.version).toBe(4)
+ expect(result.accounts).toEqual([])
+
+ const raw = JSON.parse(await readFile(path, 'utf8'))
+ expect(raw).toMatchObject({ version: 4, accounts: [], activeIndex: 0 })
+ })
+})
+
+describe('mutateAccountStorage fail-closed on unreadable file', () => {
+ // Snapshot the raw bytes of a corrupt file to verify the on-disk
+ // file is NEVER overwritten by a failed mutation.
+ async function seedCorrupt(
+ name: string,
+ contents: string,
+ ): Promise<{ path: string; raw: string }> {
+ const path = storagePath(name)
+ await writeFile(path, contents, 'utf8')
+ return { path, raw: contents }
+ }
+
+ it('throws AccountStorageUnreadableError against malformed JSON and does NOT overwrite the file', async () => {
+ const { path, raw } = await seedCorrupt(
+ 'corrupt-1.json',
+ '{ invalid json }',
+ )
+ const attemptedAdd: AccountStorageV4 = {
+ version: 4,
+ accounts: [
+ {
+ refreshToken: 'new-token',
+ addedAt: 1,
+ lastUsed: 1,
+ },
+ ],
+ activeIndex: 0,
+ }
+
+ let captured: unknown
+ try {
+ await mutateAccountStorage(path, () => attemptedAdd)
+ } catch (error) {
+ captured = error
+ }
+ expect(captured).toBeInstanceOf(AccountStorageUnreadableError)
+ const err = captured as AccountStorageUnreadableError
+ expect(err.details.reason).toBe('malformed-json')
+ expect(err.details.path).toBe(path)
+ expect(err.details.backupPath).not.toBeNull()
+
+ // The user's file must be byte-for-byte unchanged.
+ const onDisk = await readFile(path, 'utf8')
+ expect(onDisk).toBe(raw)
+ // The backup must hold a verbatim copy.
+ if (err.details.backupPath) {
+ const backup = await readFile(err.details.backupPath, 'utf8')
+ expect(backup).toBe(raw)
+ }
+ })
+
+ it('throws AccountStorageUnreadableError on invalid shape (accounts not an array)', async () => {
+ const { path, raw } = await seedCorrupt(
+ 'corrupt-2.json',
+ JSON.stringify({ version: 4, notAccounts: [] }),
+ )
+
+ let captured: unknown
+ try {
+ await mutateAccountStorage(path, (current) => current)
+ } catch (error) {
+ captured = error
+ }
+ expect(captured).toBeInstanceOf(AccountStorageUnreadableError)
+ expect((captured as AccountStorageUnreadableError).details.reason).toBe(
+ 'invalid-shape',
+ )
+ expect(await readFile(path, 'utf8')).toBe(raw)
+ })
+
+ it('throws AccountStorageUnreadableError on unsupported-version (e.g. a newer plugin wrote version 5)', async () => {
+ const { path, raw } = await seedCorrupt(
+ 'corrupt-3.json',
+ JSON.stringify({
+ version: 5,
+ accounts: [{ refreshToken: 'r1', addedAt: 1, lastUsed: 1 }],
+ }),
+ )
+
+ let captured: unknown
+ try {
+ await mutateAccountStorage(path, (current) => current)
+ } catch (error) {
+ captured = error
+ }
+ expect(captured).toBeInstanceOf(AccountStorageUnreadableError)
+ expect((captured as AccountStorageUnreadableError).details.reason).toBe(
+ 'unsupported-version',
+ )
+ expect(await readFile(path, 'utf8')).toBe(raw)
+ })
+
+ it('succeeds against a missing file (first-run UX unchanged)', async () => {
+ const path = storagePath('first-run.json')
+ const result = await mutateAccountStorage(path, (current) => ({
+ ...current,
+ accounts: [{ refreshToken: 'r1', addedAt: 1, lastUsed: 1 }],
+ activeIndex: 0,
+ }))
+ expect(result.accounts).toHaveLength(1)
+ expect(result.accounts[0]?.refreshToken).toBe('r1')
+ })
+
+ it('error message includes the path and the backup path so the user knows where their data went', async () => {
+ const { path } = await seedCorrupt('corrupt-4.json', '{ broken')
+
+ let captured: unknown
+ try {
+ await mutateAccountStorage(path, (current) => current)
+ } catch (error) {
+ captured = error
+ }
+ const err = captured as AccountStorageUnreadableError
+ expect(err.message).toContain(path)
+ if (err.details.backupPath) {
+ expect(err.message).toContain(err.details.backupPath)
+ }
+ })
+
+ it('still preserves the file when the backup itself fails', async () => {
+ const { path, raw } = await seedCorrupt('corrupt-5.json', '{ broken')
+
+ let captured: unknown
+ try {
+ await mutateAccountStorage(path, (current) => current, {
+ buildBackupPath: () => '/proc/this-cannot-be-written/corrupt-5.json',
+ })
+ } catch (error) {
+ captured = error
+ }
+ expect(captured).toBeInstanceOf(AccountStorageUnreadableError)
+ const err = captured as AccountStorageUnreadableError
+ expect(err.details.backupPath).toBeNull()
+ // Original file MUST remain intact.
+ expect(await readFile(path, 'utf8')).toBe(raw)
+ })
+
+ it('a legacy v1 file migrates in-place (no unreadable throw) and v4 ends up on disk', async () => {
+ const path = storagePath('legacy-v1.json')
+ const v1 = {
+ version: 1,
+ accounts: [
+ {
+ email: 'a@example.com',
+ refreshToken: 'r1',
+ addedAt: 1,
+ lastUsed: 1,
+ },
+ ],
+ activeIndex: 0,
+ }
+ await writeFile(path, JSON.stringify(v1), 'utf8')
+ const result = await mutateAccountStorage(path, (current) => current)
+ expect(result.version).toBe(4)
+ expect(result.accounts[0]?.refreshToken).toBe('r1')
+ const onDisk = JSON.parse(await readFile(path, 'utf8'))
+ expect(onDisk.version).toBe(4)
+ expect(onDisk.accounts[0]?.refreshToken).toBe('r1')
+ })
+})
diff --git a/packages/core/src/account-storage.ts b/packages/core/src/account-storage.ts
new file mode 100644
index 0000000..6196873
--- /dev/null
+++ b/packages/core/src/account-storage.ts
@@ -0,0 +1,833 @@
+/**
+ * Lock-held account storage engine.
+ *
+ * Owns the on-disk schema for the multi-account pool (v4) plus every
+ * migration from older versions, the load/merge/save primitives, and a
+ * `mutateAccountStorage` entry point that holds a fenced file lock for
+ * the duration of read-modify-write. Concurrent writes retry up to a
+ * bounded schedule (`100, 200, 400, 800, 1000ms` with factor 2 / max 1000)
+ * before surfacing a typed `AccountStorageLockContentionError`.
+ *
+ * Fail-closed semantics: when the accounts file exists but cannot be
+ * read as a valid v4 (parse error, schema mismatch, unknown future
+ * version, I/O error), `mutateAccountStorage` and `loadAccountStorage`
+ * throw a typed `AccountStorageUnreadableError` instead of treating
+ * the bad state as "empty pool" and overwriting the file on the next
+ * write. A best-effort `.corrupt-` backup is created
+ * before throwing so a future bug can never permanently destroy user
+ * data. The backup itself never throws.
+ *
+ * This module is harness-agnostic: it does not own a path (the harness
+ * adapter passes one in via `loadAccountStorage(path)`). The harness is
+ * responsible for picking the right on-disk path and ensuring the parent
+ * directory exists.
+ */
+
+import { chmod, copyFile, mkdir, readFile, unlink } from 'node:fs/promises'
+import { dirname } from 'node:path'
+import type {
+ AccountMetadataV3,
+ AccountStorageV3,
+ AccountStorageV4,
+ AnyAccountStorage,
+ RateLimitStateV2,
+ RateLimitStateV3,
+} from './account-types.ts'
+import { writeJsonAtomic } from './atomic-write.ts'
+import { acquireFencedFileLock, type FencedFileLock } from './file-lock.ts'
+import { createLogger } from './logger.ts'
+
+const log = createLogger('account-storage')
+
+/**
+ * Thrown when `mutateAccountStorage` exhausts its initial attempt plus
+ * five retries against a lock that is still held. Surfaces a typed
+ * signal harnesses can distinguish from transient I/O errors.
+ */
+export class AccountStorageLockContentionError extends Error {
+ readonly details: { path: string; attempts: number }
+
+ constructor(
+ message: string,
+ details: AccountStorageLockContentionError['details'],
+ ) {
+ super(message)
+ this.name = 'AccountStorageLockContentionError'
+ this.details = details
+ }
+}
+
+/**
+ * Reason the on-disk accounts file could not be parsed as a usable v4.
+ * Harnesses distinguish between `malformed-json` (the file is broken
+ * JSON — possibly truncated), `invalid-shape` (JSON parsed but the
+ * shape does not match the storage schema), `unsupported-version` (a
+ * version newer than v4 that this build cannot migrate), and `io-error`
+ * (a real I/O failure other than ENOENT — typically EACCES).
+ */
+export type AccountStorageUnreadableReason =
+ | 'malformed-json'
+ | 'invalid-shape'
+ | 'unsupported-version'
+ | 'io-error'
+
+/**
+ * Thrown when the on-disk accounts file exists but cannot be read as
+ * a valid v4. Distinguishes ENOENT (first-run UX: missing file is
+ * fine) from "the file is there but we can't trust it" (fail closed).
+ *
+ * `backupPath` is set when a `.corrupt-` sidecar was
+ * successfully written; consumers should mention it in any user-facing
+ * recovery message so the user knows their data is preserved.
+ */
+export class AccountStorageUnreadableError extends Error {
+ readonly details: {
+ path: string
+ reason: AccountStorageUnreadableReason
+ detail: string
+ backupPath: string | null
+ }
+
+ constructor(
+ message: string,
+ details: AccountStorageUnreadableError['details'],
+ ) {
+ super(message)
+ this.name = 'AccountStorageUnreadableError'
+ this.details = details
+ }
+}
+
+/**
+ * Discriminated result of an internal read attempt. Used by the
+ * mutation path so it can distinguish first-run (missing file is fine)
+ * from "file is corrupt, refuse to write" without overloading the
+ * public `null` contract that legitimate callers still expect.
+ */
+type StorageReadOutcome =
+ | { state: 'missing' }
+ | { state: 'ok'; storage: AccountStorageV4 }
+ | {
+ state: 'unreadable'
+ reason: AccountStorageUnreadableReason
+ detail: string
+ }
+
+export interface AccountStorageOptions {
+ /**
+ * Sleep override for deterministic retry timing in tests. Defaults to
+ * a real `setTimeout`-based sleep.
+ */
+ sleep?: (ms: number) => Promise
+ /**
+ * Backup override for deterministic backup paths in tests. Defaults
+ * to `path + '.corrupt-'` with the time captured at
+ * call time. Returning `null` skips the backup.
+ */
+ buildBackupPath?: (path: string, now: Date) => string | null
+ /**
+ * Clock override for deterministic timestamps in tests. Defaults to
+ * `() => new Date()`.
+ */
+ now?: () => Date
+}
+
+/**
+ * Configuration for the lock-acquisition retry schedule. Mirrors the
+ * legacy wait-and-retry schedule (5 retries, 100ms min, 1000ms max,
+ * factor 2) so an immediate contention throw is a regression.
+ */
+const RETRY_DELAYS_MS = [100, 200, 400, 800, 1000] as const
+
+const DEFAULT_SLEEP = (ms: number): Promise =>
+ new Promise((resolve) => setTimeout(resolve, ms))
+
+const DEFAULT_BUILD_BACKUP_PATH = (path: string, now: Date): string =>
+ `${path}.corrupt-${now.toISOString().replace(/[:.]/g, '-')}`
+
+async function ensureSecurePermissions(path: string): Promise {
+ try {
+ await chmod(path, 0o600)
+ } catch {
+ // best-effort; Windows + non-POSIX FS ignore this.
+ }
+}
+
+async function _ensureFileExists(path: string): Promise {
+ try {
+ await readFile(path)
+ } catch {
+ await mkdir(dirname(path), { recursive: true })
+ await writeJsonAtomic(path, { version: 4, accounts: [], activeIndex: 0 })
+ }
+}
+
+/**
+ * Copy a corrupt file to a `.corrupt-` sidecar. The
+ * backup itself never throws — if it fails (e.g. disk full, permission
+ * denied on the sidecar path), the original unreadable error is what
+ * the caller sees. Returns the backup path on success, `null` on
+ * failure.
+ */
+async function backupCorruptFile(
+ sourcePath: string,
+ buildBackupPath: (path: string, now: Date) => string | null,
+ now: Date,
+): Promise {
+ const backupPath = buildBackupPath(sourcePath, now)
+ if (!backupPath) return null
+ try {
+ await copyFile(sourcePath, backupPath)
+ await ensureSecurePermissions(backupPath)
+ return backupPath
+ } catch (backupError) {
+ log.warn('Failed to back up corrupt account storage file', {
+ sourcePath,
+ backupPath,
+ error: String(backupError),
+ })
+ return null
+ }
+}
+
+/**
+ * Deduplicate accounts that share an email, keeping the entry with the
+ * newest `lastUsed` then `addedAt`. Order of the kept entries in the
+ * output array is determined by the position of the *newest* matching
+ * account in the input array (not necessarily the original positions).
+ */
+export function deduplicateAccountsByEmail<
+ T extends { email?: string; lastUsed?: number; addedAt?: number },
+>(accounts: T[]): T[] {
+ const emailToNewestIndex = new Map()
+ const indicesToKeep = new Set()
+
+ for (let i = 0; i < accounts.length; i++) {
+ const acc = accounts[i]
+ if (!acc) continue
+
+ if (!acc.email) {
+ indicesToKeep.add(i)
+ continue
+ }
+
+ const existingIndex = emailToNewestIndex.get(acc.email)
+ if (existingIndex === undefined) {
+ emailToNewestIndex.set(acc.email, i)
+ continue
+ }
+
+ const existing = accounts[existingIndex]
+ if (!existing) {
+ emailToNewestIndex.set(acc.email, i)
+ continue
+ }
+
+ const currLastUsed = acc.lastUsed || 0
+ const existLastUsed = existing.lastUsed || 0
+ const currAddedAt = acc.addedAt || 0
+ const existAddedAt = existing.addedAt || 0
+
+ const isNewer =
+ currLastUsed > existLastUsed ||
+ (currLastUsed === existLastUsed && currAddedAt > existAddedAt)
+
+ if (isNewer) {
+ emailToNewestIndex.set(acc.email, i)
+ }
+ }
+
+ for (const idx of emailToNewestIndex.values()) {
+ indicesToKeep.add(idx)
+ }
+
+ const result: T[] = []
+ for (let i = 0; i < accounts.length; i++) {
+ if (indicesToKeep.has(i)) {
+ const acc = accounts[i]
+ if (acc) {
+ result.push(acc)
+ }
+ }
+ }
+
+ return result
+}
+
+function migrateV1ToV2(
+ v1: Extract,
+): Extract {
+ return {
+ version: 2,
+ accounts: v1.accounts.map((acc) => {
+ const rateLimitResetTimes: RateLimitStateV2 = {}
+ if (
+ acc.isRateLimited &&
+ acc.rateLimitResetTime &&
+ acc.rateLimitResetTime > Date.now()
+ ) {
+ rateLimitResetTimes.claude = acc.rateLimitResetTime
+ rateLimitResetTimes.gemini = acc.rateLimitResetTime
+ }
+ return {
+ email: acc.email,
+ refreshToken: acc.refreshToken,
+ projectId: acc.projectId,
+ managedProjectId: acc.managedProjectId,
+ addedAt: acc.addedAt,
+ lastUsed: acc.lastUsed,
+ lastSwitchReason: acc.lastSwitchReason,
+ rateLimitResetTimes:
+ Object.keys(rateLimitResetTimes).length > 0
+ ? rateLimitResetTimes
+ : undefined,
+ }
+ }),
+ activeIndex: v1.activeIndex,
+ }
+}
+
+export function migrateV2ToV3(
+ v2: Extract,
+): AccountStorageV3 {
+ return {
+ version: 3,
+ accounts: v2.accounts.map((acc) => {
+ const rateLimitResetTimes: RateLimitStateV3 = {}
+ if (
+ acc.rateLimitResetTimes?.claude &&
+ acc.rateLimitResetTimes.claude > Date.now()
+ ) {
+ rateLimitResetTimes.claude = acc.rateLimitResetTimes.claude
+ }
+ if (
+ acc.rateLimitResetTimes?.gemini &&
+ acc.rateLimitResetTimes.gemini > Date.now()
+ ) {
+ rateLimitResetTimes['gemini-antigravity'] =
+ acc.rateLimitResetTimes.gemini
+ }
+ return {
+ email: acc.email,
+ refreshToken: acc.refreshToken,
+ projectId: acc.projectId,
+ managedProjectId: acc.managedProjectId,
+ addedAt: acc.addedAt,
+ lastUsed: acc.lastUsed,
+ lastSwitchReason: acc.lastSwitchReason,
+ rateLimitResetTimes:
+ Object.keys(rateLimitResetTimes).length > 0
+ ? rateLimitResetTimes
+ : undefined,
+ }
+ }),
+ activeIndex: v2.activeIndex,
+ }
+}
+
+function migrateV3ToV4(v3: AccountStorageV3): AccountStorageV4 {
+ return {
+ version: 4,
+ accounts: v3.accounts.map((acc) => ({
+ ...acc,
+ fingerprint: undefined,
+ fingerprintHistory: undefined,
+ })),
+ activeIndex: v3.activeIndex,
+ activeIndexByFamily: v3.activeIndexByFamily,
+ }
+}
+
+/**
+ * Merge two v4 account pools keyed by `refreshToken`. Preserves
+ * `projectId`/`managedProjectId` from either side when the incoming
+ * payload omits them. Eligibility state survives via a per-field
+ * `eligibilityStateUpdatedAt` comparison so a stale concurrent writer
+ * cannot regress an explicit ineligible decision.
+ */
+export function mergeAccountStorage(
+ existing: AccountStorageV4,
+ incoming: AccountStorageV4,
+): AccountStorageV4 {
+ const accountMap = new Map()
+
+ for (const acc of existing.accounts) {
+ if (acc.refreshToken) {
+ accountMap.set(acc.refreshToken, acc)
+ }
+ }
+
+ for (const acc of incoming.accounts) {
+ if (!acc.refreshToken) continue
+ const existingAcc = accountMap.get(acc.refreshToken)
+ if (existingAcc) {
+ const eligibilitySource =
+ (acc.eligibilityStateUpdatedAt ?? 0) >=
+ (existingAcc.eligibilityStateUpdatedAt ?? 0)
+ ? acc
+ : existingAcc
+ const merged: AccountMetadataV3 = {
+ ...existingAcc,
+ ...acc,
+ projectId: acc.projectId ?? existingAcc.projectId,
+ managedProjectId: acc.managedProjectId ?? existingAcc.managedProjectId,
+ rateLimitResetTimes: {
+ ...existingAcc.rateLimitResetTimes,
+ ...acc.rateLimitResetTimes,
+ },
+ lastUsed: Math.max(existingAcc.lastUsed || 0, acc.lastUsed || 0),
+ accountIneligible: eligibilitySource.accountIneligible,
+ accountIneligibleAt: eligibilitySource.accountIneligibleAt,
+ accountIneligibleReason: eligibilitySource.accountIneligibleReason,
+ eligibilityStateUpdatedAt: eligibilitySource.eligibilityStateUpdatedAt,
+ }
+ if (merged.accountIneligible) {
+ merged.enabled = false
+ }
+ accountMap.set(acc.refreshToken, merged)
+ } else {
+ accountMap.set(acc.refreshToken, acc)
+ }
+ }
+
+ return {
+ version: 4,
+ accounts: Array.from(accountMap.values()),
+ activeIndex: incoming.activeIndex,
+ activeIndexByFamily: incoming.activeIndexByFamily,
+ }
+}
+
+/**
+ * Read the on-disk file and reduce it to a `StorageReadOutcome`. Used
+ * by `mutateAccountStorage` (which needs to distinguish "missing →
+ * first-run empty pool" from "exists but unreadable → refuse to write")
+ * and by `loadAccountStorage` (which maps unreadable to a thrown error).
+ */
+async function readAndNormalizeV4(path: string): Promise {
+ let raw: string
+ try {
+ raw = await readFile(path, 'utf-8')
+ } catch (error) {
+ const code = (error as NodeJS.ErrnoException).code
+ if (code === 'ENOENT') {
+ return { state: 'missing' }
+ }
+ return {
+ state: 'unreadable',
+ reason: 'io-error',
+ detail: `${code ?? 'UNKNOWN'}: ${(error as Error).message ?? String(error)}`,
+ }
+ }
+
+ let parsed: unknown
+ try {
+ parsed = JSON.parse(raw)
+ } catch (parseError) {
+ return {
+ state: 'unreadable',
+ reason: 'malformed-json',
+ detail: (parseError as Error).message ?? String(parseError),
+ }
+ }
+
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
+ return {
+ state: 'unreadable',
+ reason: 'invalid-shape',
+ detail: 'top-level value is not an object',
+ }
+ }
+
+ const candidate = parsed as { accounts?: unknown; version?: unknown }
+ if (!Array.isArray(candidate.accounts)) {
+ return {
+ state: 'unreadable',
+ reason: 'invalid-shape',
+ detail: '`accounts` is missing or not an array',
+ }
+ }
+
+ const parsedVersion = candidate.version
+ if (
+ parsedVersion !== 1 &&
+ parsedVersion !== 2 &&
+ parsedVersion !== 3 &&
+ parsedVersion !== 4
+ ) {
+ return {
+ state: 'unreadable',
+ reason: 'unsupported-version',
+ detail: `unsupported version: ${String(parsedVersion)}`,
+ }
+ }
+
+ let storage: AccountStorageV4 | null = null
+ switch (parsedVersion) {
+ case 1:
+ storage = migrateV3ToV4(
+ migrateV2ToV3(
+ migrateV1ToV2(parsed as Extract),
+ ),
+ )
+ break
+ case 2:
+ storage = migrateV3ToV4(
+ migrateV2ToV3(parsed as Extract),
+ )
+ break
+ case 3:
+ storage = migrateV3ToV4(parsed as AccountStorageV3)
+ break
+ case 4:
+ storage = parsed as AccountStorageV4
+ break
+ }
+
+ if (!storage) {
+ return {
+ state: 'unreadable',
+ reason: 'unsupported-version',
+ detail: `unhandled version after switch: ${String(parsedVersion)}`,
+ }
+ }
+
+ // Strict per-record validation. A v4 record must be a non-null object
+ // with a string `refreshToken`, finite numeric `addedAt` and `lastUsed`.
+ // Legacy versions (v1/v2/v3) reach this point only after migration,
+ // which already threw on a non-object entry and produced a populated
+ // refreshToken by reading it directly — so a record that survived
+ // migration is fine. We do NOT validate legacy versions BEFORE
+ // migration because the migration itself enforces the required fields
+ // by reading them off the input shape.
+ for (let i = 0; i < storage.accounts.length; i++) {
+ const detail = validateV4AccountRecord(storage.accounts[i], i)
+ if (detail !== null) {
+ return {
+ state: 'unreadable',
+ reason: 'invalid-shape',
+ detail,
+ }
+ }
+ }
+
+ const deduplicatedAccounts = deduplicateAccountsByEmail(storage.accounts)
+
+ let activeIndex =
+ typeof storage.activeIndex === 'number' &&
+ Number.isFinite(storage.activeIndex)
+ ? storage.activeIndex
+ : 0
+ if (deduplicatedAccounts.length > 0) {
+ activeIndex = Math.min(activeIndex, deduplicatedAccounts.length - 1)
+ activeIndex = Math.max(activeIndex, 0)
+ } else {
+ activeIndex = 0
+ }
+
+ return {
+ state: 'ok',
+ storage: {
+ version: 4,
+ accounts: deduplicatedAccounts,
+ activeIndex,
+ activeIndexByFamily: storage.activeIndexByFamily,
+ },
+ }
+}
+
+/**
+ * Return a human-readable detail string when `record` is not a valid
+ * v4 account record, `null` when it is. Used by `readAndNormalizeV4`
+ * to surface per-record shape problems as `invalid-shape`
+ * `AccountStorageUnreadableError` instances instead of silently
+ * filtering them out — the latter used to drop a malformed account
+ * without any user-visible signal and risked losing data.
+ */
+function validateV4AccountRecord(
+ record: unknown,
+ index: number,
+): string | null {
+ if (record === null || typeof record !== 'object' || Array.isArray(record)) {
+ return `accounts[${index}] is not an object`
+ }
+ const acc = record as Record
+ if (typeof acc.refreshToken !== 'string' || acc.refreshToken.length === 0) {
+ return `accounts[${index}].refreshToken is missing or not a non-empty string`
+ }
+ if (typeof acc.addedAt !== 'number' || !Number.isFinite(acc.addedAt)) {
+ return `accounts[${index}].addedAt is missing or not a finite number`
+ }
+ if (typeof acc.lastUsed !== 'number' || !Number.isFinite(acc.lastUsed)) {
+ return `accounts[${index}].lastUsed is missing or not a finite number`
+ }
+ return null
+}
+
+/**
+ * Load the account pool from `path`, migrating older versions in-place
+ * to v4 and persisting the migrated copy.
+ *
+ * Returns `null` when the file does not exist (first-run UX).
+ *
+ * Throws `AccountStorageUnreadableError` when the file exists but
+ * cannot be read as a valid v4 — callers must NOT treat this as an
+ * empty pool, or they will silently destroy the user's data on the
+ * next write.
+ */
+export async function loadAccountStorage(
+ path: string,
+): Promise {
+ const buildBackupPath = DEFAULT_BUILD_BACKUP_PATH
+ const now = () => new Date()
+ await ensureSecurePermissions(path)
+
+ const outcome = await readAndNormalizeV4(path)
+ if (outcome.state === 'missing') {
+ return null
+ }
+ if (outcome.state === 'unreadable') {
+ const backupPath = await backupCorruptFile(path, buildBackupPath, now())
+ throw new AccountStorageUnreadableError(
+ `Account storage at ${path} is unreadable (${outcome.reason}: ${outcome.detail}).` +
+ (backupPath
+ ? ` A backup was written to ${backupPath}. The plugin will refuse to write until the file is removed or repaired.`
+ : ' A backup could not be written; the file has been left in place. The plugin will refuse to write until the file is removed or repaired.'),
+ {
+ path,
+ reason: outcome.reason,
+ detail: outcome.detail,
+ backupPath,
+ },
+ )
+ }
+
+ // Persist the migrated copy for v1/v2/v3 so subsequent loads are
+ // a single pass. Migration never changes semantics; a write failure
+ // is logged but not fatal — the in-memory result is still correct.
+ if (outcome.storage.version === 4) {
+ const onDiskVersion = await readVersionOnly(path)
+ if (onDiskVersion !== null && onDiskVersion !== 4) {
+ try {
+ await saveAccountStorage(path, outcome.storage)
+ log.info('Migration to v4 complete')
+ } catch (saveError) {
+ log.warn('Failed to persist migrated storage', {
+ error: String(saveError),
+ })
+ }
+ }
+ }
+
+ return outcome.storage
+}
+
+async function readVersionOnly(path: string): Promise {
+ try {
+ const raw = await readFile(path, 'utf-8')
+ const parsed = JSON.parse(raw) as { version?: unknown }
+ return typeof parsed.version === 'number' ? parsed.version : null
+ } catch {
+ return null
+ }
+}
+
+/**
+ * Acquire the lock for `path`, retrying on contention up to the legacy
+ * wait-and-retry schedule (`100, 200, 400, 800, 1000ms`) so an
+ * immediate contention throw is a regression. Throws a typed
+ * `AccountStorageLockContentionError` after the initial attempt plus
+ * five retries — but only when the failure mode is "another writer
+ * holds the lock" (`null` from `acquireFencedFileLock`). Real I/O
+ * errors (permission denied, missing path, disk failure) are rethrown
+ * immediately so callers can distinguish them from contention.
+ */
+async function acquireWithRetry(
+ path: string,
+ sleep: (ms: number) => Promise,
+): Promise {
+ for (let attempt = 0; attempt <= RETRY_DELAYS_MS.length; attempt++) {
+ // acquireFencedFileLock throws on real I/O failure (permission,
+ // missing dir, disk error); it only returns `null` for a live,
+ // contended lock. Rethrow immediately so callers don't mistake a
+ // missing config dir for "someone else is writing".
+ const lock = await acquireFencedFileLock({
+ path,
+ name: 'accounts',
+ ttlMs: 10_000,
+ renew: true,
+ })
+ if (lock) {
+ return lock
+ }
+
+ if (attempt < RETRY_DELAYS_MS.length) {
+ const delay = RETRY_DELAYS_MS[attempt]
+ if (delay !== undefined) {
+ await sleep(delay)
+ }
+ }
+ }
+
+ throw new AccountStorageLockContentionError(
+ `account storage lock contention at ${path} after ${RETRY_DELAYS_MS.length + 1} attempts`,
+ {
+ path,
+ attempts: RETRY_DELAYS_MS.length + 1,
+ },
+ )
+}
+
+/**
+ * Run `mutate` against the freshest persisted pool while holding the
+ * file lock. The mutator may return a partial v4 (or `undefined` to
+ * keep the input unchanged) and is guaranteed to see the post-migration
+ * v4 shape.
+ *
+ * Throws `AccountStorageLockContentionError` after exhausting the
+ * retry schedule against a lock that is still held by another writer.
+ *
+ * Throws `AccountStorageUnreadableError` when the file exists but
+ * cannot be read as a valid v4. In that case the file is first copied
+ * to a `.corrupt-` sidecar (best-effort) and the write
+ * is aborted — a user with a corrupt-but-recoverable accounts file (or
+ * one written by a newer plugin version) who adds one account MUST NOT
+ * have their entire pool silently destroyed.
+ */
+export async function mutateAccountStorage(
+ path: string,
+ mutate: (
+ current: AccountStorageV4,
+ ) => AccountStorageV4 | undefined | Promise,
+ options: AccountStorageOptions = {},
+): Promise {
+ const sleep = options.sleep ?? DEFAULT_SLEEP
+ const buildBackupPath = options.buildBackupPath ?? DEFAULT_BUILD_BACKUP_PATH
+ const now = options.now ?? (() => new Date())
+ const lock = await acquireWithRetry(path, sleep)
+
+ try {
+ const outcome = await readAndNormalizeV4(path)
+
+ if (outcome.state === 'unreadable') {
+ const backupPath = await backupCorruptFile(path, buildBackupPath, now())
+ throw new AccountStorageUnreadableError(
+ `Refusing to write: account storage at ${path} is unreadable (${outcome.reason}: ${outcome.detail}).` +
+ (backupPath
+ ? ` A backup of the existing file was written to ${backupPath} and the on-disk file has been left untouched. Repair or remove the existing file before retrying.`
+ : ' A backup could not be written; the on-disk file has been left untouched. Repair or remove the existing file before retrying.'),
+ {
+ path,
+ reason: outcome.reason,
+ detail: outcome.detail,
+ backupPath,
+ },
+ )
+ }
+
+ const existing: AccountStorageV4 =
+ outcome.state === 'ok'
+ ? outcome.storage
+ : { version: 4, accounts: [], activeIndex: 0 }
+
+ // The mutator may be sync or async; awaiting its result lets long
+ // mutators hold the lock for the duration of their work (the OS-level
+ // create-lock + renewal keeps us exclusive that whole time).
+ const next = await mutate(existing)
+ const finalStorage: AccountStorageV4 = next ?? existing
+
+ await lock.assertOwned()
+ await writeJsonAtomic(path, finalStorage)
+
+ return finalStorage
+ } finally {
+ try {
+ await lock.release()
+ } catch (releaseError) {
+ log.warn('Failed to release account storage lock', {
+ error: String(releaseError),
+ })
+ }
+ }
+}
+
+/**
+ * Merge `incoming` into the persisted pool under the lock. Returns the
+ * merged v4 result that was written to disk.
+ */
+export async function saveAccountStorage(
+ path: string,
+ incoming: AccountStorageV4,
+): Promise {
+ return mutateAccountStorage(path, (current) =>
+ mergeAccountStorage(current, incoming),
+ )
+}
+
+/**
+ * Write `incoming` to disk unconditionally (no merge). Required for
+ * destructive operations like delete where the next-state must replace
+ * — never be merged with — what is on disk.
+ */
+export async function saveAccountStorageReplace(
+ path: string,
+ incoming: AccountStorageV4,
+): Promise {
+ return mutateAccountStorage(path, () => incoming)
+}
+
+/**
+ * Unlink the persisted pool while holding the lock so a concurrent
+ * debounced save cannot resurrect the pool mid-clear. Missing files are
+ * treated as a successful no-op (matches the legacy semantics).
+ */
+export async function clearAccountStorage(path: string): Promise {
+ const sleep = (ms: number): Promise =>
+ new Promise((resolve) => setTimeout(resolve, ms))
+ const lock = await acquireWithRetry(path, sleep)
+
+ try {
+ await unlink(path)
+ } catch (error) {
+ const code = (error as NodeJS.ErrnoException).code
+ if (code !== 'ENOENT') {
+ log.error('Failed to clear account storage', { error: String(error) })
+ throw error
+ }
+ } finally {
+ try {
+ await lock.release()
+ } catch (releaseError) {
+ log.warn('Failed to release account storage lock', {
+ error: String(releaseError),
+ })
+ }
+ }
+}
+
+/**
+ * Bundle the harness-facing storage primitives. Used by `AccountManager`
+ * so tests can inject a fake store without going through the filesystem.
+ */
+export interface AccountStorageStore {
+ load: (path: string) => Promise
+ saveMerged: (
+ path: string,
+ next: AccountStorageV4,
+ ) => Promise
+ mutate: (
+ path: string,
+ fn: (
+ current: AccountStorageV4,
+ ) => AccountStorageV4 | undefined | Promise,
+ options?: AccountStorageOptions,
+ ) => Promise
+ clear: (path: string) => Promise
+}
+
+export const defaultAccountStorageStore: AccountStorageStore = {
+ load: loadAccountStorage,
+ saveMerged: saveAccountStorage,
+ mutate: mutateAccountStorage,
+ clear: clearAccountStorage,
+}
diff --git a/packages/core/src/account-types.ts b/packages/core/src/account-types.ts
new file mode 100644
index 0000000..7076ee9
--- /dev/null
+++ b/packages/core/src/account-types.ts
@@ -0,0 +1,159 @@
+/**
+ * Harness-agnostic account storage types.
+ *
+ * Persisted pool metadata and selection/quota policy. Distinct from
+ * `auth-types.ts` (live credential/refresh/project transport contracts):
+ * account types are the on-disk schema for the multi-account pool.
+ */
+
+import type { Fingerprint, FingerprintVersion } from './fingerprint.ts'
+
+export type { HeaderStyle } from './constants.ts'
+
+/**
+ * Coarse routing key for the on-disk account pool. Distinct from the
+ * transform-level `ModelFamily` (which carries 'claude' | 'gemini-flash' |
+ * 'gemini-pro' for tiered routing): here we only need 'claude' vs 'gemini'
+ * to track per-family active indices. Named distinctly to avoid the
+ * shadow collision with `transform/types.ts` on re-export.
+ */
+export type AccountModelFamily = 'claude' | 'gemini'
+
+export interface RateLimitStateV3 {
+ claude?: number
+ 'gemini-antigravity'?: number
+ 'gemini-cli'?: number
+ [key: string]: number | undefined
+}
+
+export type AccountSelectionStrategy = 'sticky' | 'round-robin' | 'hybrid'
+
+export type CooldownReason =
+ | 'auth-failure'
+ | 'network-error'
+ | 'project-error'
+ | 'validation-required'
+
+export interface AccountMetadataV3 {
+ email?: string
+ refreshToken: string
+ projectId?: string
+ managedProjectId?: string
+ addedAt: number
+ lastUsed: number
+ enabled?: boolean
+ lastSwitchReason?: 'rate-limit' | 'initial' | 'rotation'
+ rateLimitResetTimes?: RateLimitStateV3
+ coolingDownUntil?: number
+ cooldownReason?: CooldownReason
+ /**
+ * Non-PII display label for the sidebar/telemetry. Sourced from the
+ * OAuth userinfo `name` field. When absent, call sites fall back to
+ * the PII-free `Account N` placeholder (e.g. `Account 1`) so
+ * previously-stored accounts retain a label.
+ */
+ label?: string
+ /** Per-account device fingerprint for rate limit mitigation */
+ fingerprint?: Fingerprint
+ fingerprintHistory?: FingerprintVersion[]
+ /** Set when Google asks the user to verify this account before requests can continue. */
+ verificationRequired?: boolean
+ verificationRequiredAt?: number
+ verificationRequiredReason?: string
+ verificationUrl?: string
+ /** Set when the API explicitly returns ACCOUNT_INELIGIBLE. */
+ accountIneligible?: boolean
+ accountIneligibleAt?: number
+ accountIneligibleReason?: string
+ eligibilityStateUpdatedAt?: number
+ /** Cached soft quota data (group-level aggregation) */
+ cachedQuota?: Record<
+ string,
+ { remainingFraction?: number; resetTime?: string; modelCount: number }
+ >
+ /** Cached per-model quota data (individual model granularity) */
+ cachedPerModelQuota?: {
+ modelId: string
+ displayName?: string
+ group: string | null
+ remainingFraction: number
+ resetTime?: string
+ }[]
+ cachedQuotaUpdatedAt?: number
+ /** Daily request counts per model family, resets when date changes */
+ dailyRequestCounts?: {
+ date: string
+ claude: number
+ gemini: number
+ }
+}
+
+export interface AccountStorageV4 {
+ version: 4
+ accounts: AccountMetadataV3[]
+ activeIndex: number
+ activeIndexByFamily?: {
+ claude?: number
+ gemini?: number
+ }
+}
+
+export interface AccountMetadataV1 {
+ email?: string
+ refreshToken: string
+ projectId?: string
+ managedProjectId?: string
+ addedAt: number
+ lastUsed: number
+ isRateLimited?: boolean
+ rateLimitResetTime?: number
+ lastSwitchReason?: 'rate-limit' | 'initial' | 'rotation'
+}
+
+export interface AccountStorageV1 {
+ version: 1
+ accounts: AccountMetadataV1[]
+ activeIndex: number
+}
+
+export interface RateLimitStateV2 {
+ claude?: number
+ gemini?: number
+}
+
+export interface AccountMetadataV2 {
+ email?: string
+ refreshToken: string
+ projectId?: string
+ managedProjectId?: string
+ addedAt: number
+ lastUsed: number
+ lastSwitchReason?: 'rate-limit' | 'initial' | 'rotation'
+ rateLimitResetTimes?: RateLimitStateV2
+}
+
+export interface AccountStorageV2 {
+ version: 2
+ accounts: AccountMetadataV2[]
+ activeIndex: number
+}
+
+export interface AccountStorageV3 {
+ version: 3
+ accounts: AccountMetadataV3[]
+ activeIndex: number
+ activeIndexByFamily?: {
+ claude?: number
+ gemini?: number
+ }
+}
+
+/**
+ * Discriminated union of every persisted account-storage version on disk.
+ * Used by `loadAccountStorage` to migrate older payloads to v4.
+ */
+export type AnyAccountStorage =
+ | AccountStorageV1
+ | AccountStorageV2
+ | AccountStorageV3
+ | AccountStorageV4
diff --git a/packages/core/src/agy-request-metadata.test.ts b/packages/core/src/agy-request-metadata.test.ts
index eb9cfcc..0187637 100644
--- a/packages/core/src/agy-request-metadata.test.ts
+++ b/packages/core/src/agy-request-metadata.test.ts
@@ -1,6 +1,5 @@
-import { readFileSync } from "node:fs"
-
-import { describe, expect, it } from "vitest"
+import { describe, expect, it } from 'bun:test'
+import { readFileSync } from 'node:fs'
import {
AgyRequestSessionStore,
@@ -10,32 +9,46 @@ import {
fnv1a64Signed,
getAgyModelEnum,
orderAgyRequestPayloadInPlace,
-} from "./agy-request-metadata.ts"
+} from './agy-request-metadata.ts'
type ModelMetadataFixture = {
- models: Record
+ models: Record<
+ string,
+ {
+ modelEnum: string
+ thinkingBudget: number | null
+ maxOutputTokens: number | null
+ }
+ >
}
-const MODEL_METADATA_FIXTURES = ["1.1.3", "1.1.5"].map((version) => JSON.parse(
- readFileSync(new URL(`../../../test-fixtures/agy-cli-${version}-model-metadata.json`, import.meta.url), "utf8"),
-) as ModelMetadataFixture)
-
-describe("agy request metadata", () => {
- it("matches the captured signed FNV-1a session ID for an empty workspace URI", () => {
- expect(fnv1a64Signed("")).toBe("-3750763034362895579")
- expect(fnv1a64Signed("hello")).toBe("-6615550055289275125")
+const MODEL_METADATA_FIXTURES = ['1.1.3', '1.1.5'].map(
+ (version) =>
+ JSON.parse(
+ readFileSync(
+ new URL(
+ `../../../test-fixtures/agy-cli-${version}-model-metadata.json`,
+ import.meta.url,
+ ),
+ 'utf8',
+ ),
+ ) as ModelMetadataFixture,
+)
+
+describe('agy request metadata', () => {
+ it('matches the captured signed FNV-1a session ID for an empty workspace URI', () => {
+ expect(fnv1a64Signed('')).toBe('-3750763034362895579')
+ expect(fnv1a64Signed('hello')).toBe('-6615550055289275125')
})
- it("keeps contexts stable by key and allocates monotonic request timestamps", () => {
- const sessions = new AgyRequestSessionStore("file:///workspace", { now: () => 100 })
+ it('keeps contexts stable by key and allocates monotonic request timestamps', () => {
+ const sessions = new AgyRequestSessionStore('file:///workspace', {
+ now: () => 100,
+ })
- const first = sessions.beginRequest("session-a")
- const second = sessions.beginRequest("session-a")
- const other = sessions.beginRequest("session-b")
+ const first = sessions.beginRequest('session-a')
+ const second = sessions.beginRequest('session-a')
+ const other = sessions.beginRequest('session-b')
expect(second.session).toBe(first.session)
expect(second.timestamp).toBe(101)
@@ -43,21 +56,23 @@ describe("agy request metadata", () => {
expect(other.session.numericSessionId).toBe(first.session.numericSessionId)
})
- it("creates stable session IDs with independently generated conversation and trajectory IDs", () => {
- expect(createAgyRequestSessionContext("file:///workspace", {
- conversationId: "conversation-id",
- trajectoryId: "trajectory-id",
- })).toEqual({
- conversationId: "conversation-id",
- trajectoryId: "trajectory-id",
- numericSessionId: fnv1a64Signed("file:///workspace"),
+ it('creates stable session IDs with independently generated conversation and trajectory IDs', () => {
+ expect(
+ createAgyRequestSessionContext('file:///workspace', {
+ conversationId: 'conversation-id',
+ trajectoryId: 'trajectory-id',
+ }),
+ ).toEqual({
+ conversationId: 'conversation-id',
+ trajectoryId: 'trajectory-id',
+ numericSessionId: fnv1a64Signed('file:///workspace'),
})
})
- it("orders request fields like captured agy 1.1.5 payloads", () => {
+ it('orders request fields like captured agy 1.1.5 payloads', () => {
const payload: Record = {
generationConfig: {},
- sessionId: "session",
+ sessionId: 'session',
contents: [],
labels: {},
toolConfig: {},
@@ -68,28 +83,31 @@ describe("agy request metadata", () => {
orderAgyRequestPayloadInPlace(payload)
expect(Object.keys(payload)).toEqual([
- "contents",
- "systemInstruction",
- "tools",
- "toolConfig",
- "labels",
- "generationConfig",
- "sessionId",
+ 'contents',
+ 'systemInstruction',
+ 'tools',
+ 'toolConfig',
+ 'labels',
+ 'generationConfig',
+ 'sessionId',
])
})
- it("derives last_step_index from the number of content parts", () => {
+ it('derives last_step_index from the number of content parts', () => {
const payload = {
contents: [
- { role: "user", parts: [{ text: "prompt" }] },
+ { role: 'user', parts: [{ text: 'prompt' }] },
{
- role: "model",
+ role: 'model',
parts: [
- { text: "thinking", thought: true },
- { functionCall: { name: "read", args: {} } },
+ { text: 'thinking', thought: true },
+ { functionCall: { name: 'read', args: {} } },
],
},
- { role: "user", parts: [{ functionResponse: { name: "read", response: {} } }] },
+ {
+ role: 'user',
+ parts: [{ functionResponse: { name: 'read', response: {} } }],
+ },
],
}
@@ -98,41 +116,49 @@ describe("agy request metadata", () => {
expect(countAgyRequestSteps({})).toBe(1)
})
- it("builds captured Claude request IDs, labels, and numeric session ID", () => {
- const session = createAgyRequestSessionContext("", {
- conversationId: "conversation-id",
- trajectoryId: "trajectory-id",
+ it('builds captured Claude request IDs, labels, and numeric session ID', () => {
+ const session = createAgyRequestSessionContext('', {
+ conversationId: 'conversation-id',
+ trajectoryId: 'trajectory-id',
})
- const metadata = buildAgyAgentRequestMetadata(session, {
- contents: [
- { role: "user", parts: [{ text: "prompt" }] },
- {
- role: "model",
- parts: [
- { text: "thinking", thought: true },
- { functionCall: { name: "read", args: {} } },
- ],
- },
- { role: "user", parts: [{ functionResponse: { name: "read", response: {} } }] },
- ],
- }, "claude-sonnet-4-6", 1_784_285_195_116)
+ const metadata = buildAgyAgentRequestMetadata(
+ session,
+ {
+ contents: [
+ { role: 'user', parts: [{ text: 'prompt' }] },
+ {
+ role: 'model',
+ parts: [
+ { text: 'thinking', thought: true },
+ { functionCall: { name: 'read', args: {} } },
+ ],
+ },
+ {
+ role: 'user',
+ parts: [{ functionResponse: { name: 'read', response: {} } }],
+ },
+ ],
+ },
+ 'claude-sonnet-4-6',
+ 1_784_285_195_116,
+ )
expect(metadata).toEqual({
- requestId: "agent/conversation-id/1784285195116/trajectory-id/5",
- sessionId: "-3750763034362895579",
+ requestId: 'agent/conversation-id/1784285195116/trajectory-id/5',
+ sessionId: '-3750763034362895579',
lastStepIndex: 4,
labels: {
- last_step_index: "4",
- model_enum: "MODEL_PLACEHOLDER_M35",
- trajectory_id: "trajectory-id",
- used_claude: "true",
- used_claude_conservative: "true",
- used_non_gemini_model: "true",
+ last_step_index: '4',
+ model_enum: 'MODEL_PLACEHOLDER_M35',
+ trajectory_id: 'trajectory-id',
+ used_claude: 'true',
+ used_claude_conservative: 'true',
+ used_non_gemini_model: 'true',
},
})
})
- it("matches every captured agy model enum fixture", () => {
+ it('matches every captured agy model enum fixture', () => {
for (const fixture of MODEL_METADATA_FIXTURES) {
for (const [model, expected] of Object.entries(fixture.models)) {
expect(getAgyModelEnum(model), model).toBe(expected.modelEnum)
@@ -140,35 +166,42 @@ describe("agy request metadata", () => {
}
})
- it("keeps cumulative non-Gemini usage flags when a session switches back to Gemini", () => {
- const session = createAgyRequestSessionContext("", {
- conversationId: "conversation-id",
- trajectoryId: "trajectory-id",
+ it('keeps cumulative non-Gemini usage flags when a session switches back to Gemini', () => {
+ const session = createAgyRequestSessionContext('', {
+ conversationId: 'conversation-id',
+ trajectoryId: 'trajectory-id',
})
- const payload = { contents: [{ role: "user", parts: [{ text: "prompt" }] }] }
+ const payload = {
+ contents: [{ role: 'user', parts: [{ text: 'prompt' }] }],
+ }
- buildAgyAgentRequestMetadata(session, payload, "claude-sonnet-4-6", 1)
- const gemini = buildAgyAgentRequestMetadata(session, payload, "gemini-3-flash-agent", 2)
+ buildAgyAgentRequestMetadata(session, payload, 'claude-sonnet-4-6', 1)
+ const gemini = buildAgyAgentRequestMetadata(
+ session,
+ payload,
+ 'gemini-3-flash-agent',
+ 2,
+ )
- expect(gemini.labels.used_claude).toBe("true")
- expect(gemini.labels.used_claude_conservative).toBe("true")
- expect(gemini.labels.used_non_gemini_model).toBe("true")
+ expect(gemini.labels.used_claude).toBe('true')
+ expect(gemini.labels.used_claude_conservative).toBe('true')
+ expect(gemini.labels.used_non_gemini_model).toBe('true')
})
- it("omits an unverified model enum while retaining safe labels", () => {
- const session = createAgyRequestSessionContext("", {
- conversationId: "conversation-id",
- trajectoryId: "trajectory-id",
+ it('omits an unverified model enum while retaining safe labels', () => {
+ const session = createAgyRequestSessionContext('', {
+ conversationId: 'conversation-id',
+ trajectoryId: 'trajectory-id',
})
const metadata = buildAgyAgentRequestMetadata(
session,
- { contents: [{ role: "user", parts: [{ text: "prompt" }] }] },
- "unknown-model",
+ { contents: [{ role: 'user', parts: [{ text: 'prompt' }] }] },
+ 'unknown-model',
1,
)
- expect(metadata.labels).not.toHaveProperty("model_enum")
- expect(metadata.labels.used_claude).toBe("false")
- expect(metadata.labels.used_non_gemini_model).toBe("false")
+ expect(metadata.labels).not.toHaveProperty('model_enum')
+ expect(metadata.labels.used_claude).toBe('false')
+ expect(metadata.labels.used_non_gemini_model).toBe('false')
})
})
diff --git a/packages/core/src/agy-request-metadata.ts b/packages/core/src/agy-request-metadata.ts
index 19798af..a80ea5c 100644
--- a/packages/core/src/agy-request-metadata.ts
+++ b/packages/core/src/agy-request-metadata.ts
@@ -1,5 +1,5 @@
-import { Buffer } from "node:buffer"
-import { randomUUID } from "node:crypto"
+import { Buffer } from 'node:buffer'
+import { randomUUID } from 'node:crypto'
const FNV1A_64_OFFSET_BASIS = 0xcbf29ce484222325n
const FNV1A_64_PRIME = 0x100000001b3n
@@ -7,28 +7,28 @@ const DEFAULT_SESSION_STATE_TTL_MS = 24 * 60 * 60 * 1000
const DEFAULT_MAX_SESSION_STATES = 256
const AGY_REQUEST_FIELD_ORDER = [
- "contents",
- "systemInstruction",
- "tools",
- "toolConfig",
- "labels",
- "generationConfig",
- "sessionId",
+ 'contents',
+ 'systemInstruction',
+ 'tools',
+ 'toolConfig',
+ 'labels',
+ 'generationConfig',
+ 'sessionId',
] as const
const AGY_MODEL_ENUM_BY_WIRE_MODEL: Readonly> = {
- "gemini-3.5-flash-extra-low": "MODEL_PLACEHOLDER_M187",
- "gemini-3.5-flash-low": "MODEL_PLACEHOLDER_M20",
- "gemini-3-flash-agent": "MODEL_PLACEHOLDER_M84",
- "gemini-3.6-flash-low": "MODEL_PLACEHOLDER_M266",
- "gemini-3.6-flash-medium": "MODEL_PLACEHOLDER_M265",
- "gemini-3.6-flash-high": "MODEL_PLACEHOLDER_M264",
- "gemini-3.1-pro-low": "MODEL_PLACEHOLDER_M36",
- "gemini-pro-agent": "MODEL_PLACEHOLDER_M16",
- "claude-sonnet-4-6": "MODEL_PLACEHOLDER_M35",
- "claude-opus-4-6-thinking": "MODEL_PLACEHOLDER_M26",
- "gemini-3.1-flash-image": "MODEL_PLACEHOLDER_M21",
- "gpt-oss-120b-medium": "MODEL_OPENAI_GPT_OSS_120B_MEDIUM",
+ 'gemini-3.5-flash-extra-low': 'MODEL_PLACEHOLDER_M187',
+ 'gemini-3.5-flash-low': 'MODEL_PLACEHOLDER_M20',
+ 'gemini-3-flash-agent': 'MODEL_PLACEHOLDER_M84',
+ 'gemini-3.6-flash-low': 'MODEL_PLACEHOLDER_M266',
+ 'gemini-3.6-flash-medium': 'MODEL_PLACEHOLDER_M265',
+ 'gemini-3.6-flash-high': 'MODEL_PLACEHOLDER_M264',
+ 'gemini-3.1-pro-low': 'MODEL_PLACEHOLDER_M36',
+ 'gemini-pro-agent': 'MODEL_PLACEHOLDER_M16',
+ 'claude-sonnet-4-6': 'MODEL_PLACEHOLDER_M35',
+ 'claude-opus-4-6-thinking': 'MODEL_PLACEHOLDER_M26',
+ 'gemini-3.1-flash-image': 'MODEL_PLACEHOLDER_M21',
+ 'gpt-oss-120b-medium': 'MODEL_OPENAI_GPT_OSS_120B_MEDIUM',
}
export interface AgyRequestSessionContext {
@@ -60,9 +60,9 @@ export interface AgyRequestLabels {
last_step_index: string
model_enum?: string
trajectory_id: string
- used_claude: "true" | "false"
- used_claude_conservative: "true" | "false"
- used_non_gemini_model: "true" | "false"
+ used_claude: 'true' | 'false'
+ used_claude_conservative: 'true' | 'false'
+ used_non_gemini_model: 'true' | 'false'
}
export interface AgyAgentRequestMetadata {
@@ -74,7 +74,7 @@ export interface AgyAgentRequestMetadata {
export function fnv1a64Signed(input: string): string {
let hash = FNV1A_64_OFFSET_BASIS
- for (const byte of Buffer.from(input, "utf8")) {
+ for (const byte of Buffer.from(input, 'utf8')) {
hash ^= BigInt(byte)
hash = BigInt.asUintN(64, hash * FNV1A_64_PRIME)
}
@@ -99,7 +99,10 @@ export class AgyRequestSessionStore {
private readonly maxEntries: number
private readonly now: () => number
- constructor(workspaceUri: string, options: AgyRequestSessionStoreOptions = {}) {
+ constructor(
+ workspaceUri: string,
+ options: AgyRequestSessionStoreOptions = {},
+ ) {
this.workspaceUri = workspaceUri
this.ttlMs = options.ttlMs ?? DEFAULT_SESSION_STATE_TTL_MS
this.maxEntries = options.maxEntries ?? DEFAULT_MAX_SESSION_STATES
@@ -128,7 +131,10 @@ export class AgyRequestSessionStore {
beginRequest(key: string): AgyRequestScope {
const session = this.getOrCreate(key)
const stored = this.entries.get(key)!
- const timestamp = Math.max(stored.lastAccessedAt, stored.lastRequestTimestamp + 1)
+ const timestamp = Math.max(
+ stored.lastAccessedAt,
+ stored.lastRequestTimestamp + 1,
+ )
stored.lastRequestTimestamp = timestamp
return { session, timestamp }
}
@@ -141,6 +147,10 @@ export class AgyRequestSessionStore {
this.entries.delete(key)
}
+ clear(): void {
+ this.entries.clear()
+ }
+
get size(): number {
return this.entries.size
}
@@ -153,7 +163,10 @@ export class AgyRequestSessionStore {
}
}
- while (this.entries.size >= this.maxEntries && !this.entries.has(preservedKey)) {
+ while (
+ this.entries.size >= this.maxEntries &&
+ !this.entries.has(preservedKey)
+ ) {
let oldestKey: string | null = null
let oldestAccess = Number.POSITIVE_INFINITY
for (const [key, value] of this.entries) {
@@ -174,7 +187,9 @@ export function getAgyModelEnum(model: string): string | undefined {
return AGY_MODEL_ENUM_BY_WIRE_MODEL[model.toLowerCase()]
}
-export function orderAgyRequestPayloadInPlace(payload: Record): void {
+export function orderAgyRequestPayloadInPlace(
+ payload: Record,
+): void {
const ordered: Record = {}
const remaining = new Set(Object.keys(payload))
@@ -202,7 +217,7 @@ export function countAgyRequestSteps(payload: Record): number {
let partCount = 0
for (const content of contents) {
- if (!content || typeof content !== "object" || Array.isArray(content)) {
+ if (!content || typeof content !== 'object' || Array.isArray(content)) {
continue
}
const parts = (content as Record).parts
@@ -220,18 +235,19 @@ export function buildAgyAgentRequestMetadata(
timestamp = Date.now(),
): AgyAgentRequestMetadata {
const lastStepIndex = countAgyRequestSteps(payload)
- const isClaude = model.toLowerCase().startsWith("claude-")
- const isNonGemini = isClaude || model.toLowerCase().startsWith("gpt-")
+ const isClaude = model.toLowerCase().startsWith('claude-')
+ const isNonGemini = isClaude || model.toLowerCase().startsWith('gpt-')
session.usedClaude = session.usedClaude === true || isClaude
- session.usedNonGeminiModel = session.usedNonGeminiModel === true || isNonGemini
+ session.usedNonGeminiModel =
+ session.usedNonGeminiModel === true || isNonGemini
const modelEnum = getAgyModelEnum(model)
const labels: AgyRequestLabels = {
last_step_index: String(lastStepIndex),
...(modelEnum ? { model_enum: modelEnum } : {}),
trajectory_id: session.trajectoryId,
- used_claude: session.usedClaude ? "true" : "false",
- used_claude_conservative: session.usedClaude ? "true" : "false",
- used_non_gemini_model: session.usedNonGeminiModel ? "true" : "false",
+ used_claude: session.usedClaude ? 'true' : 'false',
+ used_claude_conservative: session.usedClaude ? 'true' : 'false',
+ used_non_gemini_model: session.usedNonGeminiModel ? 'true' : 'false',
}
return {
diff --git a/packages/core/src/agy-transport.test.ts b/packages/core/src/agy-transport.test.ts
index 82e478e..a489aab 100644
--- a/packages/core/src/agy-transport.test.ts
+++ b/packages/core/src/agy-transport.test.ts
@@ -1,7 +1,6 @@
-import { readFileSync } from "node:fs"
-import * as net from "node:net"
-
-import { afterEach, describe, expect, it } from "vitest"
+import { afterEach, describe, expect, it } from 'bun:test'
+import { readFileSync } from 'node:fs'
+import * as net from 'node:net'
import {
buildAgyCliHeaderPairs,
@@ -9,7 +8,7 @@ import {
DEFAULT_AGY_IDLE_TIMEOUT_MS,
DEFAULT_AGY_RESPONSE_HEADER_TIMEOUT_MS,
fetchWithAgyCliTransport,
-} from "./agy-transport.ts"
+} from './agy-transport.ts'
type AgyWireFixture = {
capture: {
@@ -23,7 +22,13 @@ type AgyWireFixture = {
}
const AGY_1_1_5_WIRE_FIXTURE = JSON.parse(
- readFileSync(new URL("../../../test-fixtures/agy-cli-1.1.5-stream-request.json", import.meta.url), "utf8"),
+ readFileSync(
+ new URL(
+ '../../../test-fixtures/agy-cli-1.1.5-stream-request.json',
+ import.meta.url,
+ ),
+ 'utf8',
+ ),
) as AgyWireFixture
const savedProxyEnv = {
@@ -45,65 +50,76 @@ function restoreProxyEnv(): void {
}
}
-async function collect(stream: ContentLengthStream, inputs: Buffer[]): Promise {
+async function collect(
+ stream: ContentLengthStream,
+ inputs: Buffer[],
+): Promise {
const chunks: Buffer[] = []
- stream.on("data", (c: Buffer) => chunks.push(c))
- const done = new Promise((resolve) => stream.on("end", resolve))
+ stream.on('data', (c: Buffer) => chunks.push(c))
+ const done = new Promise((resolve) => stream.on('end', resolve))
for (const input of inputs) stream.write(input)
stream.end()
await done
return Buffer.concat(chunks)
}
-describe("agy transport", () => {
+describe('agy transport', () => {
afterEach(() => {
restoreProxyEnv()
})
- it("has bounded default header and idle timeouts", () => {
+ it('has bounded default header and idle timeouts', () => {
expect(DEFAULT_AGY_RESPONSE_HEADER_TIMEOUT_MS).toBe(180_000)
expect(DEFAULT_AGY_IDLE_TIMEOUT_MS).toBe(180_000)
})
- it("serializes the captured agy CLI 1.1.5 stream header contract", () => {
- const pairs = buildAgyCliHeaderPairs(AGY_1_1_5_WIRE_FIXTURE.capture.endpoint, {
- method: "POST",
- headers: {
- Authorization: "Bearer token",
- "Content-Type": "application/json",
- "User-Agent": "antigravity/cli/1.1.5 (aidev_client; os_type=darwin; arch=arm64; auth_method=consumer)",
- "Accept-Encoding": "gzip",
+ it('serializes the captured agy CLI 1.1.5 stream header contract', () => {
+ const pairs = buildAgyCliHeaderPairs(
+ AGY_1_1_5_WIRE_FIXTURE.capture.endpoint,
+ {
+ method: 'POST',
+ headers: {
+ Authorization: 'Bearer token',
+ 'Content-Type': 'application/json',
+ 'User-Agent':
+ 'antigravity/cli/1.1.5 (aidev_client; os_type=darwin; arch=arm64; auth_method=consumer)',
+ 'Accept-Encoding': 'gzip',
+ },
+ body: JSON.stringify({ request: { contents: [] } }),
},
- body: JSON.stringify({ request: { contents: [] } }),
- }).map(([name, value]) => [
+ ).map(([name, value]) => [
name,
- name === "Authorization" ? "" : value,
+ name === 'Authorization' ? '' : value,
])
expect(AGY_1_1_5_WIRE_FIXTURE.capture).toMatchObject({
- version: "1.1.5",
- httpVersion: "HTTP/1.1",
+ version: '1.1.5',
+ httpVersion: 'HTTP/1.1',
})
expect(pairs).toEqual(AGY_1_1_5_WIRE_FIXTURE.headers)
})
- it("rejects immediately when the signal is already aborted", async () => {
+ it('rejects immediately when the signal is already aborted', async () => {
const controller = new AbortController()
controller.abort()
await expect(
- fetchWithAgyCliTransport("https://example.com/x", { method: "POST" }, { signal: controller.signal }),
+ fetchWithAgyCliTransport(
+ 'https://example.com/x',
+ { method: 'POST' },
+ { signal: controller.signal },
+ ),
).rejects.toThrow(/aborted/i)
})
- it("times out while waiting for response headers", async () => {
+ it('times out while waiting for response headers', async () => {
const server = net.createServer((socket) => {
- socket.on("data", () => {
+ socket.on('data', () => {
// Accept the connection but never respond.
})
})
- await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve))
+ await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve))
const address = server.address()
- if (!address || typeof address === "string") throw new Error("no port")
+ if (!address || typeof address === 'string') throw new Error('no port')
process.env.HTTPS_PROXY = `http://127.0.0.1:${address.port}`
delete process.env.https_proxy
@@ -114,37 +130,60 @@ describe("agy transport", () => {
const debugLines: string[] = []
try {
- await expect(fetchWithAgyCliTransport("https://example.com/v1internal:streamGenerateContent", {
- method: "POST",
- headers: {
- "User-Agent": "antigravity/cli/1.1.5 (aidev_client; os_type=darwin; arch=arm64; auth_method=consumer)",
- },
- body: JSON.stringify({ x: 1 }),
- }, {
- timeoutMs: 20,
- onDebug: (line) => debugLines.push(line),
- })).rejects.toThrow("Antigravity request timed out waiting for response headers after 20ms")
-
- expect(debugLines.some((l) => l.includes("proxy CONNECT response timeout after 20ms"))).toBe(true)
+ await expect(
+ fetchWithAgyCliTransport(
+ 'https://example.com/v1internal:streamGenerateContent',
+ {
+ method: 'POST',
+ headers: {
+ 'User-Agent':
+ 'antigravity/cli/1.1.5 (aidev_client; os_type=darwin; arch=arm64; auth_method=consumer)',
+ },
+ body: JSON.stringify({ x: 1 }),
+ },
+ {
+ timeoutMs: 20,
+ onDebug: (line) => debugLines.push(line),
+ },
+ ),
+ ).rejects.toThrow(
+ 'Antigravity request timed out waiting for response headers after 20ms',
+ )
+
+ expect(
+ debugLines.some((l) =>
+ l.includes('proxy CONNECT response timeout after 20ms'),
+ ),
+ ).toBe(true)
} finally {
- await new Promise((resolve, reject) => server.close((e) => (e ? reject(e) : resolve())))
+ await new Promise((resolve, reject) =>
+ server.close((e) => (e ? reject(e) : resolve())),
+ )
}
})
- describe("ContentLengthStream", () => {
- it("emits exactly contentLength bytes and ends", async () => {
- const out = await collect(new ContentLengthStream(5), [Buffer.from("hello")])
- expect(out.toString()).toBe("hello")
+ describe('ContentLengthStream', () => {
+ it('emits exactly contentLength bytes and ends', async () => {
+ const out = await collect(new ContentLengthStream(5), [
+ Buffer.from('hello'),
+ ])
+ expect(out.toString()).toBe('hello')
})
- it("discards trailing bytes belonging to the next keep-alive response", async () => {
- const out = await collect(new ContentLengthStream(5), [Buffer.from("helloEXTRA_NEXT_RESPONSE")])
- expect(out.toString()).toBe("hello")
+ it('discards trailing bytes belonging to the next keep-alive response', async () => {
+ const out = await collect(new ContentLengthStream(5), [
+ Buffer.from('helloEXTRA_NEXT_RESPONSE'),
+ ])
+ expect(out.toString()).toBe('hello')
})
- it("reassembles a body split across chunks", async () => {
- const out = await collect(new ContentLengthStream(6), [Buffer.from("foo"), Buffer.from("bar"), Buffer.from("baz")])
- expect(out.toString()).toBe("foobar")
+ it('reassembles a body split across chunks', async () => {
+ const out = await collect(new ContentLengthStream(6), [
+ Buffer.from('foo'),
+ Buffer.from('bar'),
+ Buffer.from('baz'),
+ ])
+ expect(out.toString()).toBe('foobar')
})
})
})
diff --git a/packages/core/src/agy-transport.ts b/packages/core/src/agy-transport.ts
index 077534f..0276ee1 100644
--- a/packages/core/src/agy-transport.ts
+++ b/packages/core/src/agy-transport.ts
@@ -1,10 +1,10 @@
-import * as net from "node:net"
-import * as tls from "node:tls"
-import { Buffer } from "node:buffer"
-import { PassThrough, Readable, Transform } from "node:stream"
-import { createGunzip } from "node:zlib"
+import { Buffer } from 'node:buffer'
+import * as net from 'node:net'
+import { PassThrough, Readable, Transform } from 'node:stream'
+import * as tls from 'node:tls'
+import { createGunzip } from 'node:zlib'
-import { buildAntigravityHarnessUserAgent } from "./fingerprint.ts"
+import { buildAntigravityHarnessUserAgent } from './fingerprint.ts'
const DEFAULT_HTTPS_PORT = 443
const DEFAULT_PROXY_PORT = 8080
@@ -53,68 +53,85 @@ function headersToRecord(headers?: HeadersInit): Record {
return result
}
-function getHeader(headers: Record, name: string): string | undefined {
+function getHeader(
+ headers: Record,
+ name: string,
+): string | undefined {
return headers[name.toLowerCase()]
}
function bodyToBuffer(body: BodyInit | null | undefined): Buffer {
if (body == null) return Buffer.alloc(0)
- if (typeof body === "string") return Buffer.from(body)
+ if (typeof body === 'string') return Buffer.from(body)
if (body instanceof Uint8Array) return Buffer.from(body)
if (body instanceof ArrayBuffer) return Buffer.from(body)
- throw new Error("agy transport only supports string/byte request bodies")
+ throw new Error('agy transport only supports string/byte request bodies')
}
function shouldUseChunkedBody(url: URL): boolean {
- return url.pathname.includes(":streamGenerateContent")
+ return url.pathname.includes(':streamGenerateContent')
}
-export function buildAgyCliHeaderPairs(url: string, init: RequestInit = {}): HeaderPair[] {
+export function buildAgyCliHeaderPairs(
+ url: string,
+ init: RequestInit = {},
+): HeaderPair[] {
const parsedUrl = new URL(url)
const headers = headersToRecord(init.headers)
const body = bodyToBuffer(init.body)
- const host = parsedUrl.port ? `${parsedUrl.hostname}:${parsedUrl.port}` : parsedUrl.hostname
- const userAgent = getHeader(headers, "User-Agent") ?? buildAntigravityHarnessUserAgent()
- const authorization = getHeader(headers, "Authorization")
- const contentType = getHeader(headers, "Content-Type") ?? "application/json"
- const acceptEncoding = getHeader(headers, "Accept-Encoding") ?? "gzip"
+ const host = parsedUrl.port
+ ? `${parsedUrl.hostname}:${parsedUrl.port}`
+ : parsedUrl.hostname
+ const userAgent =
+ getHeader(headers, 'User-Agent') ?? buildAntigravityHarnessUserAgent()
+ const authorization = getHeader(headers, 'Authorization')
+ const contentType = getHeader(headers, 'Content-Type') ?? 'application/json'
+ const acceptEncoding = getHeader(headers, 'Accept-Encoding') ?? 'gzip'
const chunked = shouldUseChunkedBody(parsedUrl)
const pairs: HeaderPair[] = [
- ["Host", host],
- ["User-Agent", userAgent],
+ ['Host', host],
+ ['User-Agent', userAgent],
]
if (chunked) {
- pairs.push(["Transfer-Encoding", "chunked"])
+ pairs.push(['Transfer-Encoding', 'chunked'])
} else {
- pairs.push(["Content-Length", String(body.byteLength)])
+ pairs.push(['Content-Length', String(body.byteLength)])
}
if (authorization) {
- pairs.push(["Authorization", authorization])
+ pairs.push(['Authorization', authorization])
}
- pairs.push(["Content-Type", contentType])
- pairs.push(["Accept-Encoding", acceptEncoding])
+ pairs.push(['Content-Type', contentType])
+ pairs.push(['Accept-Encoding', acceptEncoding])
return pairs
}
function noProxyIncludes(hostname: string): boolean {
- const raw = process.env.NO_PROXY || process.env.no_proxy || ""
+ const raw = process.env.NO_PROXY || process.env.no_proxy || ''
if (!raw) return false
const host = hostname.toLowerCase()
- return raw.split(",").map((entry) => entry.trim().toLowerCase()).some((entry) => {
- if (!entry) return false
- if (entry === "*") return true
- if (entry.startsWith(".")) return host.endsWith(entry)
- return host === entry || host.endsWith(`.${entry}`)
- })
+ return raw
+ .split(',')
+ .map((entry) => entry.trim().toLowerCase())
+ .some((entry) => {
+ if (!entry) return false
+ if (entry === '*') return true
+ if (entry.startsWith('.')) return host.endsWith(entry)
+ return host === entry || host.endsWith(`.${entry}`)
+ })
}
function getHttpsProxy(url: URL): URL | undefined {
- if (url.protocol !== "https:" || noProxyIncludes(url.hostname)) return undefined
- const rawProxy = process.env.HTTPS_PROXY || process.env.https_proxy || process.env.ALL_PROXY || process.env.all_proxy
+ if (url.protocol !== 'https:' || noProxyIncludes(url.hostname))
+ return undefined
+ const rawProxy =
+ process.env.HTTPS_PROXY ||
+ process.env.https_proxy ||
+ process.env.ALL_PROXY ||
+ process.env.all_proxy
if (!rawProxy) return undefined
try {
return new URL(rawProxy)
@@ -123,17 +140,27 @@ function getHttpsProxy(url: URL): URL | undefined {
}
}
-function waitForHead(socket: net.Socket, timeoutMs: number, onTimeout: () => void): Promise<{ head: string; leftover: Buffer }> {
+function waitForHead(
+ socket: net.Socket,
+ timeoutMs: number,
+ onTimeout: () => void,
+): Promise<{ head: string; leftover: Buffer }> {
return new Promise((resolve, reject) => {
let buffer = Buffer.alloc(0)
const timeout = setTimeout(() => {
onTimeout()
- cleanup(() => reject(new Error(`Antigravity request timed out waiting for response headers after ${timeoutMs}ms`)))
+ cleanup(() =>
+ reject(
+ new Error(
+ `Antigravity request timed out waiting for response headers after ${timeoutMs}ms`,
+ ),
+ ),
+ )
}, timeoutMs)
const cleanup = (finish: () => void) => {
- socket.off("data", onData)
- socket.off("error", onError)
+ socket.off('data', onData)
+ socket.off('error', onError)
clearTimeout(timeout)
finish()
}
@@ -141,19 +168,24 @@ function waitForHead(socket: net.Socket, timeoutMs: number, onTimeout: () => voi
const onError = (error: Error) => cleanup(() => reject(error))
const onData = (chunk: Buffer) => {
buffer = Buffer.concat([buffer, chunk])
- const marker = buffer.indexOf("\r\n\r\n")
+ const marker = buffer.indexOf('\r\n\r\n')
if (marker === -1) return
- const head = buffer.subarray(0, marker).toString("latin1")
+ const head = buffer.subarray(0, marker).toString('latin1')
const leftover = buffer.subarray(marker + 4)
cleanup(() => resolve({ head, leftover }))
}
- socket.on("data", onData)
- socket.once("error", onError)
+ socket.on('data', onData)
+ socket.once('error', onError)
})
}
-async function connectViaProxy(proxyUrl: URL, targetUrl: URL, timeoutMs: number, onDebug?: (message: string) => void): Promise {
+async function connectViaProxy(
+ proxyUrl: URL,
+ targetUrl: URL,
+ timeoutMs: number,
+ onDebug?: (message: string) => void,
+): Promise {
const proxySocket = net.connect({
host: proxyUrl.hostname,
port: Number(proxyUrl.port || DEFAULT_PROXY_PORT),
@@ -163,14 +195,18 @@ async function connectViaProxy(proxyUrl: URL, targetUrl: URL, timeoutMs: number,
const timeout = setTimeout(() => {
onDebug?.(`agy transport proxy connect timeout after ${timeoutMs}ms`)
proxySocket.destroy()
- reject(new Error(`Antigravity request timed out connecting to HTTPS proxy after ${timeoutMs}ms`))
+ reject(
+ new Error(
+ `Antigravity request timed out connecting to HTTPS proxy after ${timeoutMs}ms`,
+ ),
+ )
}, timeoutMs)
const cleanup = () => clearTimeout(timeout)
- proxySocket.once("connect", () => {
+ proxySocket.once('connect', () => {
cleanup()
resolve()
})
- proxySocket.once("error", (error) => {
+ proxySocket.once('error', (error) => {
cleanup()
reject(error)
})
@@ -179,48 +215,65 @@ async function connectViaProxy(proxyUrl: URL, targetUrl: URL, timeoutMs: number,
const targetHost = targetUrl.hostname
const targetPort = Number(targetUrl.port || DEFAULT_HTTPS_PORT)
const auth = proxyUrl.username
- ? `Proxy-Authorization: Basic ${Buffer.from(`${decodeURIComponent(proxyUrl.username)}:${decodeURIComponent(proxyUrl.password)}`).toString("base64")}\r\n`
- : ""
+ ? `Proxy-Authorization: Basic ${Buffer.from(`${decodeURIComponent(proxyUrl.username)}:${decodeURIComponent(proxyUrl.password)}`).toString('base64')}\r\n`
+ : ''
proxySocket.write(
`CONNECT ${targetHost}:${targetPort} HTTP/1.1\r\n` +
`Host: ${targetHost}:${targetPort}\r\n` +
auth +
- "\r\n",
+ '\r\n',
)
const { head, leftover } = await waitForHead(proxySocket, timeoutMs, () => {
- onDebug?.(`agy transport proxy CONNECT response timeout after ${timeoutMs}ms`)
+ onDebug?.(
+ `agy transport proxy CONNECT response timeout after ${timeoutMs}ms`,
+ )
proxySocket.destroy()
})
if (!/^HTTP\/1\.[01] 2\d\d\b/.test(head)) {
proxySocket.destroy()
- throw new Error(`Proxy CONNECT failed: ${head.split("\r\n")[0] ?? "unknown"}`)
+ throw new Error(
+ `Proxy CONNECT failed: ${head.split('\r\n')[0] ?? 'unknown'}`,
+ )
}
if (leftover.length > 0) {
proxySocket.unshift(leftover)
}
return await new Promise((resolve, reject) => {
- const tlsSocket = tls.connect({ socket: proxySocket, servername: targetHost })
+ const tlsSocket = tls.connect({
+ socket: proxySocket,
+ servername: targetHost,
+ })
const timeout = setTimeout(() => {
- onDebug?.(`agy transport proxy TLS handshake timeout after ${timeoutMs}ms`)
+ onDebug?.(
+ `agy transport proxy TLS handshake timeout after ${timeoutMs}ms`,
+ )
tlsSocket.destroy()
- reject(new Error(`Antigravity request timed out during proxy TLS handshake after ${timeoutMs}ms`))
+ reject(
+ new Error(
+ `Antigravity request timed out during proxy TLS handshake after ${timeoutMs}ms`,
+ ),
+ )
}, timeoutMs)
const cleanup = () => clearTimeout(timeout)
- tlsSocket.once("secureConnect", () => {
+ tlsSocket.once('secureConnect', () => {
cleanup()
resolve(tlsSocket)
})
- tlsSocket.once("error", (error) => {
+ tlsSocket.once('error', (error: Error) => {
cleanup()
reject(error)
})
})
}
-async function connectDirect(targetUrl: URL, timeoutMs: number, onDebug?: (message: string) => void): Promise {
+async function connectDirect(
+ targetUrl: URL,
+ timeoutMs: number,
+ onDebug?: (message: string) => void,
+): Promise {
return await new Promise((resolve, reject) => {
const socket = tls.connect({
host: targetUrl.hostname,
@@ -230,32 +283,44 @@ async function connectDirect(targetUrl: URL, timeoutMs: number, onDebug?: (messa
const timeout = setTimeout(() => {
onDebug?.(`agy transport TLS connect timeout after ${timeoutMs}ms`)
socket.destroy()
- reject(new Error(`Antigravity request timed out connecting after ${timeoutMs}ms`))
+ reject(
+ new Error(
+ `Antigravity request timed out connecting after ${timeoutMs}ms`,
+ ),
+ )
}, timeoutMs)
const cleanup = () => clearTimeout(timeout)
- socket.once("secureConnect", () => {
+ socket.once('secureConnect', () => {
cleanup()
resolve(socket)
})
- socket.once("error", (error) => {
+ socket.once('error', (error: Error) => {
cleanup()
reject(error)
})
})
}
-async function connectTls(targetUrl: URL, timeoutMs: number, onDebug?: (message: string) => void): Promise {
+async function connectTls(
+ targetUrl: URL,
+ timeoutMs: number,
+ onDebug?: (message: string) => void,
+): Promise {
const proxyUrl = getHttpsProxy(targetUrl)
- return proxyUrl ? await connectViaProxy(proxyUrl, targetUrl, timeoutMs, onDebug) : await connectDirect(targetUrl, timeoutMs, onDebug)
+ return proxyUrl
+ ? await connectViaProxy(proxyUrl, targetUrl, timeoutMs, onDebug)
+ : await connectDirect(targetUrl, timeoutMs, onDebug)
}
function serializeRequest(url: URL, init: RequestInit, body: Buffer): Buffer {
- const method = init.method ?? "POST"
+ const method = init.method ?? 'POST'
const path = `${url.pathname}${url.search}`
const headerLines = buildAgyCliHeaderPairs(url.toString(), init)
.map(([key, value]) => `${key}: ${value}`)
- .join("\r\n")
- const head = Buffer.from(`${method} ${path} HTTP/1.1\r\n${headerLines}\r\n\r\n`)
+ .join('\r\n')
+ const head = Buffer.from(
+ `${method} ${path} HTTP/1.1\r\n${headerLines}\r\n\r\n`,
+ )
if (body.byteLength === 0) {
return head
@@ -269,13 +334,13 @@ function serializeRequest(url: URL, init: RequestInit, body: Buffer): Buffer {
head,
Buffer.from(`${body.byteLength.toString(16)}\r\n`),
body,
- Buffer.from("\r\n0\r\n\r\n"),
+ Buffer.from('\r\n0\r\n\r\n'),
])
}
function parseResponseHead(head: string): ParsedResponseHead {
- const lines = head.split("\r\n")
- const statusLine = lines.shift() ?? ""
+ const lines = head.split('\r\n')
+ const statusLine = lines.shift() ?? ''
const match = /^HTTP\/1\.[01]\s+(\d{3})\s*(.*)$/.exec(statusLine)
if (!match) {
throw new Error(`Invalid HTTP response: ${statusLine}`)
@@ -286,21 +351,21 @@ function parseResponseHead(head: string): ParsedResponseHead {
let gzip = false
let contentLength: number | undefined
for (const line of lines) {
- const index = line.indexOf(":")
+ const index = line.indexOf(':')
if (index <= 0) continue
const key = line.slice(0, index)
const value = line.slice(index + 1).trim()
const lowerKey = key.toLowerCase()
const lowerValue = value.toLowerCase()
- if (lowerKey === "transfer-encoding" && lowerValue.includes("chunked")) {
+ if (lowerKey === 'transfer-encoding' && lowerValue.includes('chunked')) {
chunked = true
continue
}
- if (lowerKey === "content-encoding" && lowerValue.includes("gzip")) {
+ if (lowerKey === 'content-encoding' && lowerValue.includes('gzip')) {
gzip = true
continue
}
- if (lowerKey === "content-length") {
+ if (lowerKey === 'content-length') {
const parsed = Number.parseInt(value, 10)
if (Number.isFinite(parsed) && parsed >= 0) {
contentLength = parsed
@@ -314,7 +379,7 @@ function parseResponseHead(head: string): ParsedResponseHead {
return {
status: Number(match[1]),
- statusText: match[2] ?? "",
+ statusText: match[2] ?? '',
headers,
chunked,
gzip,
@@ -330,7 +395,11 @@ export class ContentLengthStream extends Transform {
this.remaining = contentLength
}
- override _transform(chunk: Buffer, _encoding: BufferEncoding, callback: (error?: Error | null) => void): void {
+ override _transform(
+ chunk: Buffer,
+ _encoding: BufferEncoding,
+ callback: (error?: Error | null) => void,
+ ): void {
if (this.remaining <= 0) {
callback()
return
@@ -354,7 +423,11 @@ export class ContentLengthStream extends Transform {
class ChunkedDecodeStream extends Transform {
private buffer = Buffer.alloc(0)
- override _transform(chunk: Buffer, _encoding: BufferEncoding, callback: (error?: Error | null) => void): void {
+ override _transform(
+ chunk: Buffer,
+ _encoding: BufferEncoding,
+ callback: (error?: Error | null) => void,
+ ): void {
this.buffer = Buffer.concat([this.buffer, chunk])
try {
this.flushAvailableChunks()
@@ -375,10 +448,10 @@ class ChunkedDecodeStream extends Transform {
private flushAvailableChunks(): void {
while (true) {
- const lineEnd = this.buffer.indexOf("\r\n")
+ const lineEnd = this.buffer.indexOf('\r\n')
if (lineEnd === -1) return
- const sizeLine = this.buffer.subarray(0, lineEnd).toString("latin1")
- const sizeText = sizeLine.split(";", 1)[0]?.trim() ?? ""
+ const sizeLine = this.buffer.subarray(0, lineEnd).toString('latin1')
+ const sizeText = sizeLine.split(';', 1)[0]?.trim() ?? ''
const size = Number.parseInt(sizeText, 16)
if (!Number.isFinite(size)) {
throw new Error(`Invalid chunk size: ${sizeLine}`)
@@ -415,11 +488,13 @@ function buildResponseStream(
let responseBody: Readable = source
if (head.chunked) {
responseBody = responseBody.pipe(new ChunkedDecodeStream())
- } else if (typeof head.contentLength === "number") {
+ } else if (typeof head.contentLength === 'number') {
// Non-chunked with a known length: emit exactly contentLength bytes then
// end, rather than reading until socket EOF (which over-reads on keep-alive
// connections and never ends if the server keeps the socket open).
- responseBody = responseBody.pipe(new ContentLengthStream(head.contentLength))
+ responseBody = responseBody.pipe(
+ new ContentLengthStream(head.contentLength),
+ )
}
if (head.gzip) {
responseBody = responseBody.pipe(createGunzip())
@@ -438,35 +513,42 @@ function buildResponseStream(
if (!idleTimeoutMs || idleTimeoutMs <= 0) return
clearIdle()
idleTimer = setTimeout(() => {
- onDebug?.(`agy transport idle timeout after ${idleTimeoutMs}ms with no body data`)
- socket.destroy(new Error(`Antigravity response stalled: no data for ${idleTimeoutMs}ms`))
+ onDebug?.(
+ `agy transport idle timeout after ${idleTimeoutMs}ms with no body data`,
+ )
+ socket.destroy(
+ new Error(
+ `Antigravity response stalled: no data for ${idleTimeoutMs}ms`,
+ ),
+ )
}, idleTimeoutMs)
}
- socket.on("data", armIdle)
+ socket.on('data', armIdle)
armIdle()
- const abort = () => socket.destroy(new DOMException("The operation was aborted", "AbortError"))
+ const abort = () =>
+ socket.destroy(new DOMException('The operation was aborted', 'AbortError'))
const cleanup = () => {
clearIdle()
- socket.off("data", armIdle)
- signal?.removeEventListener("abort", abort)
+ socket.off('data', armIdle)
+ signal?.removeEventListener('abort', abort)
}
if (signal?.aborted) {
abort()
} else {
- signal?.addEventListener("abort", abort, { once: true })
+ signal?.addEventListener('abort', abort, { once: true })
}
- responseBody.once("end", () => {
+ responseBody.once('end', () => {
cleanup()
socket.destroy()
})
- responseBody.once("error", () => {
+ responseBody.once('error', () => {
cleanup()
socket.destroy()
})
- responseBody.once("close", cleanup)
- return Readable.toWeb(responseBody) as ReadableStream
+ responseBody.once('close', cleanup)
+ return Readable.toWeb(responseBody) as unknown as ReadableStream
}
export async function fetchWithAgyCliTransport(
@@ -475,39 +557,59 @@ export async function fetchWithAgyCliTransport(
options: AgyTransportOptions = {},
): Promise {
const parsedUrl = new URL(url)
- if (parsedUrl.protocol !== "https:") {
+ if (parsedUrl.protocol !== 'https:') {
throw new Error(`agy transport only supports https URLs: ${url}`)
}
if (options.signal?.aborted) {
- throw new DOMException("The operation was aborted", "AbortError")
+ throw new DOMException('The operation was aborted', 'AbortError')
}
const body = bodyToBuffer(init.body)
const requestBytes = serializeRequest(parsedUrl, init, body)
const timeoutMs = options.timeoutMs ?? DEFAULT_AGY_RESPONSE_HEADER_TIMEOUT_MS
const idleTimeoutMs = options.idleTimeoutMs ?? DEFAULT_AGY_IDLE_TIMEOUT_MS
- options.onDebug?.(`agy transport connecting to ${parsedUrl.hostname} with header timeout ${timeoutMs}ms`)
+ options.onDebug?.(
+ `agy transport connecting to ${parsedUrl.hostname} with header timeout ${timeoutMs}ms`,
+ )
// Race the connect against abort so a cancel during TLS/proxy connect is
// honored immediately instead of waiting out the connect timeout.
- const socket = await connectTlsWithAbort(parsedUrl, timeoutMs, options.signal, options.onDebug)
+ const socket = await connectTlsWithAbort(
+ parsedUrl,
+ timeoutMs,
+ options.signal,
+ options.onDebug,
+ )
const abort = () => {
- socket.destroy(new DOMException("The operation was aborted", "AbortError"))
+ socket.destroy(new DOMException('The operation was aborted', 'AbortError'))
}
try {
- options.signal?.addEventListener("abort", abort, { once: true })
+ options.signal?.addEventListener('abort', abort, { once: true })
socket.write(requestBytes)
- options.onDebug?.(`agy transport request dispatched (${requestBytes.byteLength} bytes)`)
+ options.onDebug?.(
+ `agy transport request dispatched (${requestBytes.byteLength} bytes)`,
+ )
const { head, leftover } = await waitForHead(socket, timeoutMs, () => {
- options.onDebug?.(`agy transport response header timeout after ${timeoutMs}ms`)
+ options.onDebug?.(
+ `agy transport response header timeout after ${timeoutMs}ms`,
+ )
socket.destroy()
})
const parsedHead = parseResponseHead(head)
- options.onDebug?.(`agy transport response headers received: ${parsedHead.status} ${parsedHead.statusText}`)
- const bodyStream = buildResponseStream(socket, leftover, parsedHead, options.signal, idleTimeoutMs, options.onDebug)
+ options.onDebug?.(
+ `agy transport response headers received: ${parsedHead.status} ${parsedHead.statusText}`,
+ )
+ const bodyStream = buildResponseStream(
+ socket,
+ leftover,
+ parsedHead,
+ options.signal,
+ idleTimeoutMs,
+ options.onDebug,
+ )
return new Response(bodyStream, {
status: parsedHead.status,
statusText: parsedHead.statusText,
@@ -517,7 +619,7 @@ export async function fetchWithAgyCliTransport(
socket.destroy()
throw error
} finally {
- options.signal?.removeEventListener("abort", abort)
+ options.signal?.removeEventListener('abort', abort)
}
}
@@ -533,8 +635,9 @@ async function connectTlsWithAbort(
const connectPromise = connectTls(targetUrl, timeoutMs, onDebug)
let onAbort: (() => void) | undefined
const abortPromise = new Promise((_, reject) => {
- onAbort = () => reject(new DOMException("The operation was aborted", "AbortError"))
- signal.addEventListener("abort", onAbort, { once: true })
+ onAbort = () =>
+ reject(new DOMException('The operation was aborted', 'AbortError'))
+ signal.addEventListener('abort', onAbort, { once: true })
})
try {
return await Promise.race([connectPromise, abortPromise])
@@ -543,6 +646,6 @@ async function connectTlsWithAbort(
void connectPromise.then((socket) => socket.destroy()).catch(() => {})
throw error
} finally {
- if (onAbort) signal.removeEventListener("abort", onAbort)
+ if (onAbort) signal.removeEventListener('abort', onAbort)
}
}
diff --git a/packages/core/src/antigravity/oauth.test.ts b/packages/core/src/antigravity/oauth.test.ts
new file mode 100644
index 0000000..236d96a
--- /dev/null
+++ b/packages/core/src/antigravity/oauth.test.ts
@@ -0,0 +1,296 @@
+import {
+ afterEach,
+ beforeEach,
+ describe,
+ expect,
+ it,
+ jest,
+ mock,
+ spyOn,
+} from 'bun:test'
+
+import {
+ ANTIGRAVITY_CLIENT_ID,
+ ANTIGRAVITY_REDIRECT_URI,
+ ANTIGRAVITY_SCOPES,
+} from '../constants.ts'
+import {
+ authorizeAntigravity,
+ exchangeAntigravity,
+ refreshAntigravityToken,
+} from './oauth.ts'
+
+/**
+ * Build a minimal token-exchange response body. Google issues access_token /
+ * refresh_token / expires_in and OpenCode derives expires via Date.now() offset.
+ */
+function tokenResponseBody(
+ overrides: {
+ access_token?: string
+ refresh_token?: string
+ expires_in?: number
+ skipRefresh?: boolean
+ } = {},
+): string {
+ const body: Record = {
+ access_token: overrides.access_token ?? 'access-1',
+ expires_in: overrides.expires_in ?? 3600,
+ }
+ if (!overrides.skipRefresh) {
+ body.refresh_token = overrides.refresh_token ?? 'refresh-1'
+ }
+ return JSON.stringify(body)
+}
+
+function userInfoBody(
+ email = 'user@example.com',
+ name = 'Alice Example',
+): string {
+ return JSON.stringify({ email, name })
+}
+
+describe('Antigravity OAuth', () => {
+ let fetchSpy: ReturnType
+
+ beforeEach(() => {
+ fetchSpy = spyOn(globalThis, 'fetch')
+ })
+
+ afterEach(() => {
+ fetchSpy.mockRestore()
+ mock.restore()
+ jest.useRealTimers()
+ })
+
+ describe('authorizeAntigravity', () => {
+ it('builds the Google authorization URL with PKCE + state + scopes + redirect', async () => {
+ const result = await authorizeAntigravity('project-1')
+
+ expect(result.projectId).toBe('project-1')
+ expect(result.verifier).toBeTruthy()
+
+ const url = new URL(result.url)
+ expect(url.origin + url.pathname).toBe(
+ 'https://accounts.google.com/o/oauth2/v2/auth',
+ )
+ expect(url.searchParams.get('client_id')).toBe(ANTIGRAVITY_CLIENT_ID)
+ expect(url.searchParams.get('response_type')).toBe('code')
+ expect(url.searchParams.get('redirect_uri')).toBe(
+ ANTIGRAVITY_REDIRECT_URI,
+ )
+ expect(url.searchParams.get('code_challenge')).toBeTruthy()
+ expect(url.searchParams.get('code_challenge_method')).toBe('S256')
+ expect(url.searchParams.get('access_type')).toBe('offline')
+ expect(url.searchParams.get('prompt')).toBe('consent')
+
+ const state = url.searchParams.get('state')
+ expect(state).toBeTruthy()
+
+ // State round-trips: state encodes the verifier + projectId so
+ // exchangeAntigravity can recover them without a side channel.
+ const padded = state
+ ?.replace(/-/g, '+')
+ .replace(/_/g, '/')
+ .padEnd(state?.length + ((4 - (state?.length % 4)) % 4), '=')
+ const decoded = JSON.parse(Buffer.from(padded, 'base64').toString('utf8'))
+ expect(decoded.verifier).toBe(result.verifier)
+ expect(decoded.projectId).toBe('project-1')
+
+ const requestedScopes = (url.searchParams.get('scope') ?? '').split(' ')
+ for (const scope of ANTIGRAVITY_SCOPES) {
+ expect(requestedScopes).toContain(scope)
+ }
+ })
+
+ it('encodes an empty projectId when none is provided', async () => {
+ const result = await authorizeAntigravity()
+ const url = new URL(result.url)
+ const state = url.searchParams.get('state')
+ const padded = state
+ ?.replace(/-/g, '+')
+ .replace(/_/g, '/')
+ .padEnd(state?.length + ((4 - (state?.length % 4)) % 4), '=')
+ const decoded = JSON.parse(Buffer.from(padded, 'base64').toString('utf8'))
+ expect(decoded.projectId).toBe('')
+ expect(decoded.verifier).toBe(result.verifier)
+ })
+ })
+
+ describe('exchangeAntigravity', () => {
+ function queueFetchResponses(responses: Response[]): void {
+ fetchSpy.mockImplementation(async () => {
+ const next = responses.shift()
+ if (!next) throw new Error('fetch called more times than queued')
+ return next
+ })
+ }
+
+ it('exchanges the auth code for tokens, fetches userinfo, and returns stored refresh', async () => {
+ queueFetchResponses([
+ new Response(tokenResponseBody({ expires_in: 1800 }), { status: 200 }),
+ new Response(userInfoBody('alice@example.com'), { status: 200 }),
+ ])
+
+ const { url, verifier } = await authorizeAntigravity('project-1')
+ const state = new URL(url).searchParams.get('state') ?? ''
+ expect(verifier).toBeTruthy()
+
+ const result = await exchangeAntigravity('auth-code', state)
+
+ expect(result.type).toBe('success')
+ if (result.type !== 'success') throw new Error('expected success')
+
+ expect(result.refresh).toBe('refresh-1|project-1')
+ expect(result.access).toBe('access-1')
+ expect(result.email).toBe('alice@example.com')
+ expect(result.label).toBe('Alice Example')
+ expect(result.projectId).toBe('project-1')
+
+ // Expires must be relative to the request wall clock, not 0 or +expires_in.
+ const expectedExpires = Date.now() + 1800 * 1000
+ expect(Math.abs(result.expires - expectedExpires)).toBeLessThan(5_000)
+
+ // Token POST sent the right verifier + code; userinfo used Bearer auth.
+ const calls = fetchSpy.mock.calls as Array<[string, RequestInit]>
+ expect(calls.length).toBe(2)
+ const [tokenUrl, tokenInit] = calls[0]!
+ const [userinfoUrl, userinfoInit] = calls[1]!
+ expect(tokenUrl).toBe('https://oauth2.googleapis.com/token')
+ const tokenBody = new URLSearchParams(tokenInit.body as string)
+ expect(tokenBody.get('code')).toBe('auth-code')
+ expect(tokenBody.get('code_verifier')).toBe(verifier)
+ expect(tokenBody.get('client_id')).toBe(ANTIGRAVITY_CLIENT_ID)
+ expect(tokenBody.get('redirect_uri')).toBe(ANTIGRAVITY_REDIRECT_URI)
+ expect(userinfoUrl).toBe(
+ 'https://www.googleapis.com/oauth2/v1/userinfo?alt=json',
+ )
+ const userinfoHeaders = userinfoInit.headers as Record
+ expect(userinfoHeaders.Authorization).toBe('Bearer access-1')
+ })
+
+ it('returns failed when the token exchange responds non-OK with the response body', async () => {
+ queueFetchResponses([
+ new Response('invalid_grant: bad code', { status: 400 }),
+ ])
+
+ const { url } = await authorizeAntigravity('')
+ const state = new URL(url).searchParams.get('state') ?? ''
+ const result = await exchangeAntigravity('bad-code', state)
+
+ expect(result.type).toBe('failed')
+ if (result.type !== 'failed') throw new Error('expected failure')
+ expect(result.error).toBe('invalid_grant: bad code')
+ })
+
+ it('returns failed when refresh_token is missing from the token response', async () => {
+ queueFetchResponses([
+ new Response(tokenResponseBody({ skipRefresh: true }), { status: 200 }),
+ new Response(userInfoBody('alice@example.com'), { status: 200 }),
+ ])
+
+ const { url } = await authorizeAntigravity('project-1')
+ const state = new URL(url).searchParams.get('state') ?? ''
+ const result = await exchangeAntigravity('auth-code', state)
+
+ expect(result.type).toBe('failed')
+ if (result.type !== 'failed') throw new Error('expected failure')
+ expect(result.error).toBe('Missing refresh token in response')
+ })
+
+ it('returns failed when userinfo is non-OK but token exchange succeeded', async () => {
+ queueFetchResponses([
+ new Response(tokenResponseBody({ expires_in: 3600 }), { status: 200 }),
+ new Response('forbidden', { status: 403 }),
+ ])
+
+ const { url } = await authorizeAntigravity('project-1')
+ const state = new URL(url).searchParams.get('state') ?? ''
+ const result = await exchangeAntigravity('auth-code', state)
+
+ // Missing email is OK — failure must be success-without-email, not failure.
+ expect(result.type).toBe('success')
+ if (result.type !== 'success') throw new Error('expected success')
+ expect(result.refresh).toBe('refresh-1|project-1')
+ expect(result.email).toBeUndefined()
+ expect(result.label).toBeUndefined()
+ })
+
+ it('uses empty projectId segment when no projectId is provided and discovery fails', async () => {
+ // Only the token endpoint succeeds; every loadCodeAssist endpoint fails
+ // so `fetchProjectID` returns ''.
+ queueFetchResponses([
+ new Response(tokenResponseBody({ expires_in: 3600 }), { status: 200 }),
+ new Response(userInfoBody('bob@example.com'), { status: 200 }),
+ // loadCodeAssist probes
+ new Response('server error', { status: 503 }),
+ new Response('server error', { status: 503 }),
+ new Response('server error', { status: 503 }),
+ new Response('server error', { status: 503 }),
+ ])
+
+ const { url } = await authorizeAntigravity('')
+ const state = new URL(url).searchParams.get('state') ?? ''
+ const result = await exchangeAntigravity('auth-code', state)
+
+ expect(result.type).toBe('success')
+ if (result.type !== 'success') throw new Error('expected success')
+ expect(result.refresh).toBe('refresh-1|')
+ expect(result.projectId).toBe('')
+ })
+ })
+
+ describe('refreshAntigravityToken', () => {
+ it('preserves the old refresh token when Google omits a replacement', async () => {
+ fetchSpy.mockResolvedValue(
+ new Response(
+ JSON.stringify({ access_token: 'new-access', expires_in: 3600 }),
+ { status: 200 },
+ ),
+ )
+
+ const result = await refreshAntigravityToken('original-refresh')
+
+ expect(result.access).toBe('new-access')
+ expect(result.refresh).toBe('original-refresh')
+ expect(
+ Math.abs(result.expires - (Date.now() + 3600 * 1000)),
+ ).toBeLessThan(5_000)
+
+ const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit]
+ const body = new URLSearchParams(init.body as string)
+ expect(body.get('grant_type')).toBe('refresh_token')
+ expect(body.get('refresh_token')).toBe('original-refresh')
+ expect(body.get('client_id')).toBe(ANTIGRAVITY_CLIENT_ID)
+ })
+
+ it('uses the new refresh token when Google issues a replacement', async () => {
+ fetchSpy.mockResolvedValue(
+ new Response(
+ JSON.stringify({
+ access_token: 'new-access',
+ expires_in: 3600,
+ refresh_token: 'rotated-refresh',
+ }),
+ { status: 200 },
+ ),
+ )
+
+ const result = await refreshAntigravityToken('original-refresh')
+
+ expect(result.refresh).toBe('rotated-refresh')
+ })
+
+ it('throws a status-bearing error on non-OK responses', async () => {
+ fetchSpy.mockResolvedValue(
+ new Response('{"error":"invalid_grant"}', {
+ status: 400,
+ statusText: 'Bad Request',
+ }),
+ )
+
+ await expect(refreshAntigravityToken('expired')).rejects.toThrow(
+ /Antigravity token refresh failed \(400 Bad Request\)/,
+ )
+ })
+ })
+})
diff --git a/packages/core/src/antigravity/oauth.ts b/packages/core/src/antigravity/oauth.ts
index c059f29..a8684bf 100644
--- a/packages/core/src/antigravity/oauth.ts
+++ b/packages/core/src/antigravity/oauth.ts
@@ -1,65 +1,66 @@
-import { generatePKCE } from "@openauthjs/openauth/pkce";
-
+import { generatePKCE } from '@openauthjs/openauth/pkce'
+import { fetchWithAgyCliTransport } from '../agy-transport.ts'
+import { calculateTokenExpiry } from '../auth.ts'
import {
ANTIGRAVITY_CLIENT_ID,
ANTIGRAVITY_CLIENT_SECRET,
- ANTIGRAVITY_REDIRECT_URI,
- ANTIGRAVITY_SCOPES,
ANTIGRAVITY_ENDPOINT_FALLBACKS,
ANTIGRAVITY_LOAD_ENDPOINTS,
+ ANTIGRAVITY_REDIRECT_URI,
+ ANTIGRAVITY_SCOPES,
GEMINI_CLI_HEADERS,
-} from "../constants.ts";
-import { createLogger } from "../logger.ts";
-import { calculateTokenExpiry } from "../auth.ts";
-import { fetchWithAgyCliTransport } from "../agy-transport.ts";
+} from '../constants.ts'
+import { fetchWithActiveTimeout } from '../fetch-timeout.ts'
import {
buildAntigravityHarnessBootstrapHeaders,
buildAntigravityLoadCodeAssistMetadata,
-} from "../fingerprint.ts";
+} from '../fingerprint.ts'
+import { createLogger } from '../logger.ts'
-const log = createLogger("oauth");
+const log = createLogger('oauth')
interface PkcePair {
- challenge: string;
- verifier: string;
+ challenge: string
+ verifier: string
}
interface AntigravityAuthState {
- verifier: string;
- projectId: string;
+ verifier: string
+ projectId: string
}
/**
* Result returned to the caller after constructing an OAuth authorization URL.
*/
export interface AntigravityAuthorization {
- url: string;
- verifier: string;
- projectId: string;
+ url: string
+ verifier: string
+ projectId: string
}
interface AntigravityTokenExchangeSuccess {
- type: "success";
- refresh: string;
- access: string;
- expires: number;
- email?: string;
- projectId: string;
+ type: 'success'
+ refresh: string
+ access: string
+ expires: number
+ email?: string
+ label?: string
+ projectId: string
}
interface AntigravityTokenExchangeFailure {
- type: "failed";
- error: string;
+ type: 'failed'
+ error: string
}
export type AntigravityTokenExchangeResult =
| AntigravityTokenExchangeSuccess
- | AntigravityTokenExchangeFailure;
+ | AntigravityTokenExchangeFailure
export interface AntigravityRefreshResult {
- access: string;
- refresh: string;
- expires: number;
+ access: string
+ refresh: string
+ expires: number
}
/**
@@ -70,152 +71,175 @@ export interface AntigravityRefreshResult {
export async function refreshAntigravityToken(
refreshToken: string,
): Promise {
- const startTime = Date.now();
- const response = await fetch("https://oauth2.googleapis.com/token", {
- method: "POST",
- headers: { "Content-Type": "application/x-www-form-urlencoded" },
- body: new URLSearchParams({
- grant_type: "refresh_token",
- refresh_token: refreshToken,
- client_id: ANTIGRAVITY_CLIENT_ID,
- client_secret: ANTIGRAVITY_CLIENT_SECRET,
- }),
- });
+ const startTime = Date.now()
+ const response = await fetchWithActiveTimeout(
+ 'https://oauth2.googleapis.com/token',
+ {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
+ body: new URLSearchParams({
+ grant_type: 'refresh_token',
+ refresh_token: refreshToken,
+ client_id: ANTIGRAVITY_CLIENT_ID,
+ client_secret: ANTIGRAVITY_CLIENT_SECRET,
+ }),
+ },
+ )
if (!response.ok) {
- const errorText = await response.text().catch(() => "");
+ const errorText = await response.text().catch(() => '')
throw new Error(
- `Antigravity token refresh failed (${response.status} ${response.statusText})${errorText ? ` - ${errorText}` : ""}`,
- );
+ `Antigravity token refresh failed (${response.status} ${response.statusText})${errorText ? ` - ${errorText}` : ''}`,
+ )
}
const payload = (await response.json()) as {
- access_token: string;
- expires_in: number;
- refresh_token?: string;
- };
+ access_token: string
+ expires_in: number
+ refresh_token?: string
+ }
return {
access: payload.access_token,
refresh: payload.refresh_token ?? refreshToken,
expires: calculateTokenExpiry(startTime, payload.expires_in),
- };
+ }
}
interface AntigravityTokenResponse {
- access_token: string;
- expires_in: number;
- refresh_token: string;
+ access_token: string
+ expires_in: number
+ refresh_token: string
}
interface AntigravityUserInfo {
- email?: string;
+ email?: string
+ name?: string
}
/**
* Encode an object into a URL-safe base64 string.
*/
function encodeState(payload: AntigravityAuthState): string {
- return Buffer.from(JSON.stringify(payload), "utf8").toString("base64url");
+ return Buffer.from(JSON.stringify(payload), 'utf8').toString('base64url')
}
/**
* Decode an OAuth state parameter back into its structured representation.
*/
function decodeState(state: string): AntigravityAuthState {
- const normalized = state.replace(/-/g, "+").replace(/_/g, "/");
- const padded = normalized.padEnd(normalized.length + ((4 - normalized.length % 4) % 4), "=");
- const json = Buffer.from(padded, "base64").toString("utf8");
- const parsed = JSON.parse(json);
- if (typeof parsed.verifier !== "string") {
- throw new Error("Missing PKCE verifier in state");
+ const normalized = state.replace(/-/g, '+').replace(/_/g, '/')
+ const padded = normalized.padEnd(
+ normalized.length + ((4 - (normalized.length % 4)) % 4),
+ '=',
+ )
+ const json = Buffer.from(padded, 'base64').toString('utf8')
+ const parsed = JSON.parse(json)
+ if (typeof parsed.verifier !== 'string') {
+ throw new Error('Missing PKCE verifier in state')
}
return {
verifier: parsed.verifier,
- projectId: typeof parsed.projectId === "string" ? parsed.projectId : "",
- };
+ projectId: typeof parsed.projectId === 'string' ? parsed.projectId : '',
+ }
}
/**
* Build the Antigravity OAuth authorization URL including PKCE and optional project metadata.
*/
-export async function authorizeAntigravity(projectId = ""): Promise {
- const pkce = (await generatePKCE()) as PkcePair;
-
- const url = new URL("https://accounts.google.com/o/oauth2/v2/auth");
- url.searchParams.set("client_id", ANTIGRAVITY_CLIENT_ID);
- url.searchParams.set("response_type", "code");
- url.searchParams.set("redirect_uri", ANTIGRAVITY_REDIRECT_URI);
- url.searchParams.set("scope", ANTIGRAVITY_SCOPES.join(" "));
- url.searchParams.set("code_challenge", pkce.challenge);
- url.searchParams.set("code_challenge_method", "S256");
+export async function authorizeAntigravity(
+ projectId = '',
+): Promise {
+ const pkce = (await generatePKCE()) as PkcePair
+
+ const url = new URL('https://accounts.google.com/o/oauth2/v2/auth')
+ url.searchParams.set('client_id', ANTIGRAVITY_CLIENT_ID)
+ url.searchParams.set('response_type', 'code')
+ url.searchParams.set('redirect_uri', ANTIGRAVITY_REDIRECT_URI)
+ url.searchParams.set('scope', ANTIGRAVITY_SCOPES.join(' '))
+ url.searchParams.set('code_challenge', pkce.challenge)
+ url.searchParams.set('code_challenge_method', 'S256')
url.searchParams.set(
- "state",
- encodeState({ verifier: pkce.verifier, projectId: projectId || "" }),
- );
- url.searchParams.set("access_type", "offline");
- url.searchParams.set("prompt", "consent");
+ 'state',
+ encodeState({ verifier: pkce.verifier, projectId: projectId || '' }),
+ )
+ url.searchParams.set('access_type', 'offline')
+ url.searchParams.set('prompt', 'consent')
return {
url: url.toString(),
verifier: pkce.verifier,
- projectId: projectId || "",
- };
+ projectId: projectId || '',
+ }
}
async function fetchProjectID(accessToken: string): Promise {
- const errors: string[] = [];
- const loadHeaders = buildAntigravityHarnessBootstrapHeaders(accessToken);
+ const errors: string[] = []
+ const loadHeaders = buildAntigravityHarnessBootstrapHeaders(accessToken)
const loadEndpoints = Array.from(
- new Set([...ANTIGRAVITY_LOAD_ENDPOINTS, ...ANTIGRAVITY_ENDPOINT_FALLBACKS]),
- );
+ new Set([
+ ...ANTIGRAVITY_LOAD_ENDPOINTS,
+ ...ANTIGRAVITY_ENDPOINT_FALLBACKS,
+ ]),
+ )
for (const baseEndpoint of loadEndpoints) {
try {
- const url = `${baseEndpoint}/v1internal:loadCodeAssist`;
- const response = await fetchWithAgyCliTransport(url, {
- method: "POST",
- headers: loadHeaders,
- body: JSON.stringify({ metadata: buildAntigravityLoadCodeAssistMetadata() }),
- }, { timeoutMs: 10000 });
+ const url = `${baseEndpoint}/v1internal:loadCodeAssist`
+ const response = await fetchWithAgyCliTransport(
+ url,
+ {
+ method: 'POST',
+ headers: loadHeaders,
+ body: JSON.stringify({
+ metadata: buildAntigravityLoadCodeAssistMetadata(),
+ }),
+ },
+ { timeoutMs: 10000 },
+ )
if (!response.ok) {
- const message = await response.text().catch(() => "");
+ const message = await response.text().catch(() => '')
errors.push(
`loadCodeAssist ${response.status} at ${baseEndpoint}${
- message ? `: ${message}` : ""
+ message ? `: ${message}` : ''
}`,
- );
- continue;
+ )
+ continue
}
- const data = await response.json();
- if (typeof data.cloudaicompanionProject === "string" && data.cloudaicompanionProject) {
- return data.cloudaicompanionProject;
+ const data = await response.json()
+ if (
+ typeof data.cloudaicompanionProject === 'string' &&
+ data.cloudaicompanionProject
+ ) {
+ return data.cloudaicompanionProject
}
if (
data.cloudaicompanionProject &&
- typeof data.cloudaicompanionProject.id === "string" &&
+ typeof data.cloudaicompanionProject.id === 'string' &&
data.cloudaicompanionProject.id
) {
- return data.cloudaicompanionProject.id;
+ return data.cloudaicompanionProject.id
}
- errors.push(`loadCodeAssist missing project id at ${baseEndpoint}`);
+ errors.push(`loadCodeAssist missing project id at ${baseEndpoint}`)
} catch (e) {
errors.push(
`loadCodeAssist error at ${baseEndpoint}: ${
e instanceof Error ? e.message : String(e)
}`,
- );
+ )
}
}
if (errors.length) {
- log.warn("Failed to resolve Antigravity project via loadCodeAssist", { errors: errors.join("; ") });
+ log.warn('Failed to resolve Antigravity project via loadCodeAssist', {
+ errors: errors.join('; '),
+ })
}
- return "";
+ return ''
}
/**
@@ -226,72 +250,77 @@ export async function exchangeAntigravity(
state: string,
): Promise {
try {
- const { verifier, projectId } = decodeState(state);
-
- const startTime = Date.now();
- const tokenResponse = await fetch("https://oauth2.googleapis.com/token", {
- method: "POST",
- headers: {
- "Content-Type": "application/x-www-form-urlencoded;charset=UTF-8",
- "Accept": "*/*",
- "Accept-Encoding": "gzip, deflate, br",
- "User-Agent": GEMINI_CLI_HEADERS["User-Agent"],
+ const { verifier, projectId } = decodeState(state)
+
+ const startTime = Date.now()
+ const tokenResponse = await fetchWithActiveTimeout(
+ 'https://oauth2.googleapis.com/token',
+ {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
+ Accept: '*/*',
+ 'Accept-Encoding': 'gzip, deflate, br',
+ 'User-Agent': GEMINI_CLI_HEADERS['User-Agent'],
+ },
+ body: new URLSearchParams({
+ client_id: ANTIGRAVITY_CLIENT_ID,
+ client_secret: ANTIGRAVITY_CLIENT_SECRET,
+ code,
+ grant_type: 'authorization_code',
+ redirect_uri: ANTIGRAVITY_REDIRECT_URI,
+ code_verifier: verifier,
+ }),
},
- body: new URLSearchParams({
- client_id: ANTIGRAVITY_CLIENT_ID,
- client_secret: ANTIGRAVITY_CLIENT_SECRET,
- code,
- grant_type: "authorization_code",
- redirect_uri: ANTIGRAVITY_REDIRECT_URI,
- code_verifier: verifier,
- }),
- });
+ )
if (!tokenResponse.ok) {
- const errorText = await tokenResponse.text();
- return { type: "failed", error: errorText };
+ const errorText = await tokenResponse.text()
+ return { type: 'failed', error: errorText }
}
- const tokenPayload = (await tokenResponse.json()) as AntigravityTokenResponse;
+ const tokenPayload =
+ (await tokenResponse.json()) as AntigravityTokenResponse
- const userInfoResponse = await fetch(
- "https://www.googleapis.com/oauth2/v1/userinfo?alt=json",
+ const userInfoResponse = await fetchWithActiveTimeout(
+ 'https://www.googleapis.com/oauth2/v1/userinfo?alt=json',
{
headers: {
Authorization: `Bearer ${tokenPayload.access_token}`,
- "User-Agent": GEMINI_CLI_HEADERS["User-Agent"],
+ 'User-Agent': GEMINI_CLI_HEADERS['User-Agent'],
},
},
- );
+ )
const userInfo = userInfoResponse.ok
? ((await userInfoResponse.json()) as AntigravityUserInfo)
- : {};
+ : {}
- const refreshToken = tokenPayload.refresh_token;
+ const refreshToken = tokenPayload.refresh_token
if (!refreshToken) {
- return { type: "failed", error: "Missing refresh token in response" };
+ return { type: 'failed', error: 'Missing refresh token in response' }
}
- let effectiveProjectId = projectId;
+ let effectiveProjectId = projectId
if (!effectiveProjectId) {
- effectiveProjectId = await fetchProjectID(tokenPayload.access_token);
+ effectiveProjectId = await fetchProjectID(tokenPayload.access_token)
}
- const storedRefresh = `${refreshToken}|${effectiveProjectId || ""}`;
+ const storedRefresh = `${refreshToken}|${effectiveProjectId || ''}`
return {
- type: "success",
+ type: 'success',
refresh: storedRefresh,
access: tokenPayload.access_token,
expires: calculateTokenExpiry(startTime, tokenPayload.expires_in),
email: userInfo.email,
- projectId: effectiveProjectId || "",
- };
+ label: userInfo.name?.trim() || undefined,
+ projectId: effectiveProjectId || '',
+ }
} catch (error) {
return {
- type: "failed",
- error: error instanceof Error ? error.message : "Unknown error",
- };
+ type: 'failed',
+ error: error instanceof Error ? error.message : 'Unknown error',
+ }
}
}
diff --git a/packages/core/src/atomic-write.test.ts b/packages/core/src/atomic-write.test.ts
new file mode 100644
index 0000000..8e4865b
--- /dev/null
+++ b/packages/core/src/atomic-write.test.ts
@@ -0,0 +1,232 @@
+import {
+ afterEach,
+ beforeEach,
+ describe,
+ expect,
+ it,
+ mock,
+ spyOn,
+} from 'bun:test'
+import * as fsp from 'node:fs/promises'
+import {
+ chmod,
+ mkdir,
+ mkdtemp,
+ readdir,
+ readFile,
+ rm,
+ stat,
+ writeFile,
+} from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+
+import { writeJsonAtomic } from './atomic-write.ts'
+
+let root: string
+
+beforeEach(async () => {
+ root = await mkdtemp(join(tmpdir(), 'atomic-write-'))
+})
+
+afterEach(async () => {
+ mock.restore()
+ await rm(root, { recursive: true, force: true })
+})
+
+describe('writeJsonAtomic', () => {
+ it('writes pretty-formatted JSON with a trailing newline on POSIX', async () => {
+ if (process.platform === 'win32') return
+
+ const target = join(root, 'state.json')
+ await writeJsonAtomic(target, { a: 1, b: [2, 3] })
+
+ const content = await readFile(target, 'utf8')
+ expect(content).toBe('{\n "a": 1,\n "b": [\n 2,\n 3\n ]\n}\n')
+ })
+
+ it('uses mode 0600 on POSIX', async () => {
+ const target = join(root, 'state.json')
+ await writeJsonAtomic(target, { value: 'hello' })
+
+ if (process.platform === 'win32') {
+ // Windows ignores POSIX mode bits; rely on inherited ACL + readback.
+ expect(JSON.parse(await readFile(target, 'utf8'))).toEqual({
+ value: 'hello',
+ })
+ return
+ }
+
+ const stats = await stat(target)
+ expect((stats.mode & 0o777).toString(8)).toBe('600')
+ })
+
+ it('creates parent directories recursively when the target dir does not exist', async () => {
+ const target = join(root, 'nested', 'deeper', 'state.json')
+ await writeJsonAtomic(target, { nested: true })
+
+ const content = JSON.parse(await readFile(target, 'utf8'))
+ expect(content).toEqual({ nested: true })
+ })
+
+ it('temp file lives in the same directory as the target and is cleaned up after rename', async () => {
+ const target = join(root, 'state.json')
+ await writeJsonAtomic(target, { v: 1 })
+
+ const entries = await readdir(root)
+ expect(entries.filter((n) => n.endsWith('.tmp'))).toHaveLength(0)
+ expect(entries).toContain('state.json')
+ })
+
+ it('atomically replaces existing target contents on a second write', async () => {
+ const target = join(root, 'state.json')
+ await writeJsonAtomic(target, { v: 1 })
+ const first = JSON.parse(await readFile(target, 'utf8'))
+ expect(first).toEqual({ v: 1 })
+
+ await writeJsonAtomic(target, { v: 2, extra: 'now' })
+ const second = JSON.parse(await readFile(target, 'utf8'))
+ expect(second).toEqual({ v: 2, extra: 'now' })
+
+ const entries = await readdir(root)
+ expect(entries.filter((n) => n.endsWith('.tmp'))).toHaveLength(0)
+ })
+
+ it('cleans up the temp file when rename fails and does not fall back to copyFile', async () => {
+ // Place a directory at the target path so the rename onto `target` fails
+ // with EISDIR on POSIX (and equivalent failure on Windows).
+ const target = join(root, 'state.json')
+ await mkdir(target, { recursive: true })
+
+ const renameSpy = spyOn(fsp, 'rename').mockImplementation(async () => {
+ const err = new Error('forced rename failure') as NodeJS.ErrnoException
+ err.code = 'EISDIR'
+ throw err
+ })
+ const copyFileSpy = spyOn(fsp, 'copyFile').mockImplementation(async () => {
+ throw new Error('copyFile must NOT be called when rename fails')
+ })
+
+ try {
+ await expect(writeJsonAtomic(target, { updated: true })).rejects.toThrow(
+ 'forced rename failure',
+ )
+ expect(renameSpy).toHaveBeenCalled()
+ expect(copyFileSpy).not.toHaveBeenCalled()
+ const entries = await readdir(root)
+ expect(entries.filter((n) => n.endsWith('.tmp'))).toHaveLength(0)
+ // The directory placeholder is still there untouched.
+ const targetStat = await stat(target)
+ expect(targetStat.isDirectory()).toBe(true)
+ } finally {
+ renameSpy.mockRestore()
+ copyFileSpy.mockRestore()
+ }
+ })
+
+ it('preserves the prior target contents on a failed rename', async () => {
+ const target = join(root, 'state.json')
+ await writeJsonAtomic(target, { original: 'yes' })
+
+ const renameSpy = spyOn(fsp, 'rename').mockImplementation(async () => {
+ const err = new Error('forced rename failure') as NodeJS.ErrnoException
+ err.code = 'EACCES'
+ throw err
+ })
+ const copyFileSpy = spyOn(fsp, 'copyFile').mockImplementation(async () => {
+ throw new Error('copyFile must NOT be called when rename fails')
+ })
+
+ try {
+ await expect(writeJsonAtomic(target, { updated: true })).rejects.toThrow()
+ const content = JSON.parse(await readFile(target, 'utf8'))
+ expect(content).toEqual({ original: 'yes' })
+ expect(copyFileSpy).not.toHaveBeenCalled()
+ } finally {
+ renameSpy.mockRestore()
+ copyFileSpy.mockRestore()
+ }
+ })
+
+ it('cleans up the staged temp file when writeFile fails after a partial write', async () => {
+ const target = join(root, 'state.json')
+
+ const writeSpy = spyOn(fsp, 'writeFile').mockImplementation(
+ async (filePath) => {
+ // First, write the temp path's inode so the partial-write state
+ // mirrors a real driver crash: file exists on disk but is incomplete,
+ // and the surrounding try/catch is still inside `writeFile`.
+ const { writeFileSync } = await import('node:fs')
+ writeFileSync(filePath as string, '{"partial":')
+ const err = new Error(
+ 'forced partial-write failure',
+ ) as NodeJS.ErrnoException
+ err.code = 'EIO'
+ throw err
+ },
+ )
+ const renameSpy = spyOn(fsp, 'rename').mockImplementation(async () => {
+ throw new Error('rename must NOT be called after writeFile failure')
+ })
+
+ try {
+ await expect(writeJsonAtomic(target, { value: 1 })).rejects.toThrow(
+ 'forced partial-write failure',
+ )
+ expect(writeSpy).toHaveBeenCalled()
+ expect(renameSpy).not.toHaveBeenCalled()
+ // The partial temp file must be removed in the finally block.
+ const entries = await readdir(root)
+ expect(entries.filter((n) => n.endsWith('.tmp'))).toHaveLength(0)
+ } finally {
+ writeSpy.mockRestore()
+ renameSpy.mockRestore()
+ }
+ })
+
+ it('on Windows, atomically replaces the target and reads back cleanly (inherited ACL)', async () => {
+ if (process.platform !== 'win32') return
+
+ const target = join(root, 'state.json')
+ await writeJsonAtomic(target, { win: true, n: 42 })
+
+ const back = JSON.parse(await readFile(target, 'utf8'))
+ expect(back).toEqual({ win: true, n: 42 })
+
+ const entries = await readdir(root)
+ expect(entries.filter((n) => n.endsWith('.tmp'))).toHaveLength(0)
+ })
+
+ it('serializes deeply nested objects with stable formatting', async () => {
+ const target = join(root, 'state.json')
+ const payload = {
+ top: 1,
+ list: [
+ { id: 'a', meta: { score: 0.5 } },
+ { id: 'b', meta: null },
+ ],
+ n: null,
+ }
+ await writeJsonAtomic(target, payload)
+ const text = await readFile(target, 'utf8')
+ expect(text.endsWith('\n')).toBe(true)
+ expect(JSON.parse(text)).toEqual(payload)
+ })
+})
+
+describe('chmod helper behavior', () => {
+ it('chmod 0o600 on an existing file tightens POSIX permissions without altering content', async () => {
+ if (process.platform === 'win32') return
+
+ const target = join(root, 'state.json')
+ await writeFile(target, '{"x":1}\n', { encoding: 'utf8' })
+
+ await chmod(target, 0o644)
+ let stats = await stat(target)
+ expect((stats.mode & 0o777).toString(8)).toBe('644')
+
+ await writeJsonAtomic(target, { x: 1 })
+ stats = await stat(target)
+ expect((stats.mode & 0o777).toString(8)).toBe('600')
+ })
+})
diff --git a/packages/core/src/atomic-write.ts b/packages/core/src/atomic-write.ts
new file mode 100644
index 0000000..29002cf
--- /dev/null
+++ b/packages/core/src/atomic-write.ts
@@ -0,0 +1,52 @@
+/**
+ * Atomic JSON file writer.
+ *
+ * Stages the payload at `${path}.${randomUUID()}.tmp` (same directory as
+ * the target so the rename is atomic on POSIX, and uses the same NTFS
+ * volume on Windows) and renames onto `path`. The `0o600` mode is enforced
+ * on the staged file; on POSIX the rename replaces the target's inode so
+ * the new file inherits the staged mode bits. Windows ignores POSIX mode
+ * bits and relies on the current user's inherited ACL — we do not attempt
+ * to harden ACLs from Node.
+ *
+ * Failures are deliberately NOT masked by a copy fallback: a blind
+ * copy-then-unlink after a failing replace can paper over partial writes
+ * and let concurrent writers silently corrupt state. The caller decides
+ * whether to retry, surface, or back off.
+ */
+
+import { randomUUID } from 'node:crypto'
+import { mkdir, rename, rm, writeFile } from 'node:fs/promises'
+import { dirname } from 'node:path'
+
+/**
+ * Atomically serialize `value` as pretty-printed JSON and rename it onto
+ * `path`. Throws if any step fails; the staged temp file is removed before
+ * rethrow so no `${path}..tmp` is left behind.
+ */
+export async function writeJsonAtomic(
+ path: string,
+ value: unknown,
+): Promise {
+ const serialized = `${JSON.stringify(value, null, 2)}\n`
+ const tempPath = `${path}.${randomUUID()}.tmp`
+
+ await mkdir(dirname(path), { recursive: true })
+
+ let renamed = false
+ try {
+ await writeFile(tempPath, serialized, {
+ encoding: 'utf8',
+ mode: 0o600,
+ })
+ await rename(tempPath, path)
+ renamed = true
+ } finally {
+ if (!renamed) {
+ // Cleanup the staged temp whether `writeFile` threw after a partial
+ // write or `rename` failed. `force: true` makes the call a no-op
+ // when the file never landed on disk.
+ await rm(tempPath, { force: true }).catch(() => {})
+ }
+ }
+}
diff --git a/packages/core/src/auth-types.ts b/packages/core/src/auth-types.ts
index fa7f082..10b2b90 100644
--- a/packages/core/src/auth-types.ts
+++ b/packages/core/src/auth-types.ts
@@ -1,14 +1,14 @@
// Harness-agnostic auth/credential types shared by core and harness packages.
export interface OAuthAuthDetails {
- type: "oauth"
+ type: 'oauth'
refresh: string
access?: string
expires?: number
}
export interface ApiKeyAuthDetails {
- type: "api_key"
+ type: 'api_key'
key: string
}
@@ -17,7 +17,10 @@ export interface NonOAuthAuthDetails {
[key: string]: unknown
}
-export type AuthDetails = OAuthAuthDetails | ApiKeyAuthDetails | NonOAuthAuthDetails
+export type AuthDetails =
+ | OAuthAuthDetails
+ | ApiKeyAuthDetails
+ | NonOAuthAuthDetails
export type GetAuth = () => Promise
diff --git a/packages/core/src/auth.ts b/packages/core/src/auth.ts
index 5f9aab3..301ca8e 100644
--- a/packages/core/src/auth.ts
+++ b/packages/core/src/auth.ts
@@ -1,40 +1,47 @@
-import type { OAuthAuthDetails, RefreshParts } from "./auth-types.ts";
+import type { OAuthAuthDetails, RefreshParts } from './auth-types.ts'
-const ACCESS_TOKEN_EXPIRY_BUFFER_MS = 60 * 1000;
+const ACCESS_TOKEN_EXPIRY_BUFFER_MS = 60 * 1000
export function isOAuthAuth(auth: unknown): auth is OAuthAuthDetails {
- return typeof auth === "object" && auth !== null && "type" in auth && auth.type === "oauth";
+ return (
+ typeof auth === 'object' &&
+ auth !== null &&
+ 'type' in auth &&
+ auth.type === 'oauth'
+ )
}
/**
* Splits a packed refresh string into its constituent refresh token and project IDs.
*/
export function parseRefreshParts(refresh: string): RefreshParts {
- const [refreshToken = "", projectId = "", managedProjectId = ""] = (refresh ?? "").split("|");
+ const [refreshToken = '', projectId = '', managedProjectId = ''] = (
+ refresh ?? ''
+ ).split('|')
return {
refreshToken,
projectId: projectId || undefined,
managedProjectId: managedProjectId || undefined,
- };
+ }
}
/**
* Serializes refresh token parts into the stored string format.
*/
export function formatRefreshParts(parts: RefreshParts): string {
- const projectSegment = parts.projectId ?? "";
- const base = `${parts.refreshToken}|${projectSegment}`;
- return parts.managedProjectId ? `${base}|${parts.managedProjectId}` : base;
+ const projectSegment = parts.projectId ?? ''
+ const base = `${parts.refreshToken}|${projectSegment}`
+ return parts.managedProjectId ? `${base}|${parts.managedProjectId}` : base
}
/**
* Determines whether an access token is expired or missing, with buffer for clock skew.
*/
export function accessTokenExpired(auth: OAuthAuthDetails): boolean {
- if (!auth.access || typeof auth.expires !== "number") {
- return true;
+ if (!auth.access || typeof auth.expires !== 'number') {
+ return true
}
- return auth.expires <= Date.now() + ACCESS_TOKEN_EXPIRY_BUFFER_MS;
+ return auth.expires <= Date.now() + ACCESS_TOKEN_EXPIRY_BUFFER_MS
}
/**
@@ -42,11 +49,14 @@ export function accessTokenExpired(auth: OAuthAuthDetails): boolean {
* @param requestTimeMs The local time when the request was initiated
* @param expiresInSeconds The duration returned by the server
*/
-export function calculateTokenExpiry(requestTimeMs: number, expiresInSeconds: unknown): number {
- const seconds = typeof expiresInSeconds === "number" ? expiresInSeconds : 3600;
+export function calculateTokenExpiry(
+ requestTimeMs: number,
+ expiresInSeconds: unknown,
+): number {
+ const seconds = typeof expiresInSeconds === 'number' ? expiresInSeconds : 3600
// Safety check for bad data - if it's not a positive number, treat as immediately expired
- if (isNaN(seconds) || seconds <= 0) {
- return requestTimeMs;
+ if (Number.isNaN(seconds) || seconds <= 0) {
+ return requestTimeMs
}
- return requestTimeMs + seconds * 1000;
+ return requestTimeMs + seconds * 1000
}
diff --git a/packages/core/src/constants.ts b/packages/core/src/constants.ts
index 098e732..739c964 100644
--- a/packages/core/src/constants.ts
+++ b/packages/core/src/constants.ts
@@ -2,38 +2,41 @@
* Constants used for Antigravity OAuth flows and Cloud Code Assist API integration.
*/
-import { buildAntigravityHarnessUserAgent } from "./fingerprint.ts";
+import { buildAntigravityHarnessUserAgent } from './fingerprint.ts'
-export const ANTIGRAVITY_CLIENT_ID = "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com";
+export const ANTIGRAVITY_CLIENT_ID =
+ '1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com'
/**
* Client secret issued for the Antigravity OAuth application.
*/
-export const ANTIGRAVITY_CLIENT_SECRET = "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";
+export const ANTIGRAVITY_CLIENT_SECRET = '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'
/**
* Scopes required for Antigravity integrations.
*/
export const ANTIGRAVITY_SCOPES: readonly string[] = [
- "https://www.googleapis.com/auth/cloud-platform",
- "https://www.googleapis.com/auth/userinfo.email",
- "https://www.googleapis.com/auth/userinfo.profile",
- "https://www.googleapis.com/auth/cclog",
- "https://www.googleapis.com/auth/experimentsandconfigs",
-];
+ 'https://www.googleapis.com/auth/cloud-platform',
+ 'https://www.googleapis.com/auth/userinfo.email',
+ 'https://www.googleapis.com/auth/userinfo.profile',
+ 'https://www.googleapis.com/auth/cclog',
+ 'https://www.googleapis.com/auth/experimentsandconfigs',
+]
/**
* OAuth redirect URI used by the local CLI callback server.
*/
-export const ANTIGRAVITY_REDIRECT_URI = "http://localhost:51121/oauth-callback";
+export const ANTIGRAVITY_REDIRECT_URI = 'http://localhost:51121/oauth-callback'
/**
* Root endpoints for the Antigravity API (in fallback order).
* Live agy CLI 1.1.5 traffic uses daily-cloudcode-pa.googleapis.com.
*/
-export const ANTIGRAVITY_ENDPOINT_DAILY = "https://daily-cloudcode-pa.googleapis.com";
-export const ANTIGRAVITY_ENDPOINT_AUTOPUSH = "https://autopush-cloudcode-pa.sandbox.googleapis.com";
-export const ANTIGRAVITY_ENDPOINT_PROD = "https://cloudcode-pa.googleapis.com";
+export const ANTIGRAVITY_ENDPOINT_DAILY =
+ 'https://daily-cloudcode-pa.googleapis.com'
+export const ANTIGRAVITY_ENDPOINT_AUTOPUSH =
+ 'https://autopush-cloudcode-pa.sandbox.googleapis.com'
+export const ANTIGRAVITY_ENDPOINT_PROD = 'https://cloudcode-pa.googleapis.com'
/**
* Endpoint fallback order (daily → prod).
@@ -43,7 +46,7 @@ export const ANTIGRAVITY_ENDPOINT_PROD = "https://cloudcode-pa.googleapis.com";
export const ANTIGRAVITY_ENDPOINT_FALLBACKS = [
ANTIGRAVITY_ENDPOINT_DAILY,
ANTIGRAVITY_ENDPOINT_PROD,
-] as const;
+] as const
/**
* Preferred endpoint order for project discovery.
* agy CLI probes daily-cloudcode-pa.googleapis.com first.
@@ -51,64 +54,79 @@ export const ANTIGRAVITY_ENDPOINT_FALLBACKS = [
export const ANTIGRAVITY_LOAD_ENDPOINTS = [
ANTIGRAVITY_ENDPOINT_DAILY,
ANTIGRAVITY_ENDPOINT_PROD,
-] as const;
+] as const
/**
* Primary endpoint to use (captured agy CLI daily endpoint).
*/
-export const ANTIGRAVITY_ENDPOINT = ANTIGRAVITY_ENDPOINT_DAILY;
+export const ANTIGRAVITY_ENDPOINT = ANTIGRAVITY_ENDPOINT_DAILY
/**
* Gemini CLI endpoint (production).
* Used for models without :antigravity suffix.
* Same as opencode-gemini-auth's GEMINI_CODE_ASSIST_ENDPOINT.
*/
-export const GEMINI_CLI_ENDPOINT = ANTIGRAVITY_ENDPOINT_PROD;
+export const GEMINI_CLI_ENDPOINT = ANTIGRAVITY_ENDPOINT_PROD
/**
* Hardcoded project id used when Antigravity does not return one (e.g., business/workspace accounts).
*/
-export const ANTIGRAVITY_DEFAULT_PROJECT_ID = "rising-fact-p41fc";
+export const ANTIGRAVITY_DEFAULT_PROJECT_ID = 'rising-fact-p41fc'
-export const ANTIGRAVITY_VERSION_FALLBACK = "1.18.3";
-let antigravityVersion = ANTIGRAVITY_VERSION_FALLBACK;
-let versionLocked = false;
+export const ANTIGRAVITY_VERSION_FALLBACK = '1.18.3'
+let antigravityVersion = ANTIGRAVITY_VERSION_FALLBACK
+let versionLocked = false
-export function getAntigravityVersion(): string { return antigravityVersion; }
+export function getAntigravityVersion(): string {
+ return antigravityVersion
+}
/**
* Set the runtime Antigravity version. Can only be called once (at startup).
* Subsequent calls are silently ignored to prevent accidental mutation.
*/
export function setAntigravityVersion(version: string): void {
- if (versionLocked) return;
- antigravityVersion = version;
- versionLocked = true;
+ if (versionLocked) return
+ antigravityVersion = version
+ versionLocked = true
+}
+
+/**
+ * Test-only: reset the version lock so `setAntigravityVersion` can run again.
+ * Bun has no Vitest-style module reset hook, and module state lives in this
+ * singleton — tests that exercise the lock need a way to start clean per
+ * scenario.
+ */
+export function __resetAntigravityVersionForTesting(): void {
+ antigravityVersion = ANTIGRAVITY_VERSION_FALLBACK
+ versionLocked = false
}
/** @deprecated Use getAntigravityVersion() for runtime access. */
-export const ANTIGRAVITY_VERSION = ANTIGRAVITY_VERSION_FALLBACK;
+export const ANTIGRAVITY_VERSION = ANTIGRAVITY_VERSION_FALLBACK
-export function getAntigravityHeaders(): HeaderSet & { "Client-Metadata": string } {
+export function getAntigravityHeaders(): HeaderSet & {
+ 'Client-Metadata': string
+} {
return {
- "User-Agent": `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Antigravity/${getAntigravityVersion()} Chrome/138.0.7204.235 Electron/37.3.1 Safari/537.36`,
- "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
- "Client-Metadata": `{"ideType":"ANTIGRAVITY","platform":"${process.platform === "win32" ? "WINDOWS" : "MACOS"}","pluginType":"GEMINI"}`,
- };
+ 'User-Agent': `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Antigravity/${getAntigravityVersion()} Chrome/138.0.7204.235 Electron/37.3.1 Safari/537.36`,
+ 'X-Goog-Api-Client': 'google-cloud-sdk vscode_cloudshelleditor/0.1',
+ 'Client-Metadata': `{"ideType":"ANTIGRAVITY","platform":"${process.platform === 'win32' ? 'WINDOWS' : 'MACOS'}","pluginType":"GEMINI"}`,
+ }
}
/** @deprecated Use getAntigravityHeaders() for runtime access. */
export const ANTIGRAVITY_HEADERS = {
- "User-Agent": `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Antigravity/${ANTIGRAVITY_VERSION} Chrome/138.0.7204.235 Electron/37.3.1 Safari/537.36`,
- "X-Goog-Api-Client": "google-cloud-sdk vscode_cloudshelleditor/0.1",
- "Client-Metadata": `{"ideType":"ANTIGRAVITY","platform":"${process.platform === "win32" ? "WINDOWS" : "MACOS"}","pluginType":"GEMINI"}`,
-} as const;
+ 'User-Agent': `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Antigravity/${ANTIGRAVITY_VERSION} Chrome/138.0.7204.235 Electron/37.3.1 Safari/537.36`,
+ 'X-Goog-Api-Client': 'google-cloud-sdk vscode_cloudshelleditor/0.1',
+ 'Client-Metadata': `{"ideType":"ANTIGRAVITY","platform":"${process.platform === 'win32' ? 'WINDOWS' : 'MACOS'}","pluginType":"GEMINI"}`,
+} as const
-export const GEMINI_CLI_VERSION = "1.0.0";
+export const GEMINI_CLI_VERSION = '1.0.0'
/**
* Default model used in Gemini CLI User-Agent when no model is specified.
*/
-export const GEMINI_CLI_DEFAULT_MODEL = "gemini-2.5-pro";
+export const GEMINI_CLI_DEFAULT_MODEL = 'gemini-2.5-pro'
/**
* Build Gemini CLI User-Agent string matching the official google-gemini/gemini-cli format.
@@ -117,43 +135,47 @@ export const GEMINI_CLI_DEFAULT_MODEL = "gemini-2.5-pro";
* @see https://github.com/google-gemini/gemini-cli
*/
export function buildGeminiCliUserAgent(model?: string): string {
- const effectiveModel = model || GEMINI_CLI_DEFAULT_MODEL;
- const platform = process.platform || "darwin";
- const arch = process.arch || "arm64";
- return `GeminiCLI/${GEMINI_CLI_VERSION}/${effectiveModel} (${platform}; ${arch})`;
+ const effectiveModel = model || GEMINI_CLI_DEFAULT_MODEL
+ const platform = process.platform || 'darwin'
+ const arch = process.arch || 'arm64'
+ return `GeminiCLI/${GEMINI_CLI_VERSION}/${effectiveModel} (${platform}; ${arch})`
}
/** @deprecated Use buildGeminiCliUserAgent() for runtime access. */
export const GEMINI_CLI_HEADERS = {
- "User-Agent": "google-api-nodejs-client/9.15.1",
- "X-Goog-Api-Client": "gl-node/22.17.0",
- "Client-Metadata": "ideType=IDE_UNSPECIFIED,platform=PLATFORM_UNSPECIFIED,pluginType=GEMINI",
-} as const;
+ 'User-Agent': 'google-api-nodejs-client/9.15.1',
+ 'X-Goog-Api-Client': 'gl-node/22.17.0',
+ 'Client-Metadata':
+ 'ideType=IDE_UNSPECIFIED,platform=PLATFORM_UNSPECIFIED,pluginType=GEMINI',
+} as const
export type HeaderSet = {
- "User-Agent": string;
- "X-Goog-Api-Client"?: string;
- "Client-Metadata"?: string;
-};
+ 'User-Agent': string
+ 'X-Goog-Api-Client'?: string
+ 'Client-Metadata'?: string
+}
-export function getRandomizedHeaders(style: HeaderStyle, model?: string): HeaderSet {
- if (style === "gemini-cli") {
+export function getRandomizedHeaders(
+ style: HeaderStyle,
+ model?: string,
+): HeaderSet {
+ if (style === 'gemini-cli') {
return {
- "User-Agent": buildGeminiCliUserAgent(model),
- "X-Goog-Api-Client": GEMINI_CLI_HEADERS["X-Goog-Api-Client"],
- "Client-Metadata": GEMINI_CLI_HEADERS["Client-Metadata"],
- };
+ 'User-Agent': buildGeminiCliUserAgent(model),
+ 'X-Goog-Api-Client': GEMINI_CLI_HEADERS['X-Goog-Api-Client'],
+ 'Client-Metadata': GEMINI_CLI_HEADERS['Client-Metadata'],
+ }
}
return {
- "User-Agent": buildAntigravityHarnessUserAgent(),
- };
+ 'User-Agent': buildAntigravityHarnessUserAgent(),
+ }
}
-export type HeaderStyle = "antigravity" | "gemini-cli";
+export type HeaderStyle = 'antigravity' | 'gemini-cli'
/**
* Provider identifier shared between the plugin loader and credential store.
*/
-export const ANTIGRAVITY_PROVIDER_ID = "google";
+export const ANTIGRAVITY_PROVIDER_ID = 'google'
// ============================================================================
// TOOL HALLUCINATION PREVENTION (Ported from LLM-API-Key-Proxy)
@@ -162,7 +184,7 @@ export const ANTIGRAVITY_PROVIDER_ID = "google";
/**
* System instruction for Claude tool usage hardening.
* Prevents hallucinated parameters by explicitly stating the rules.
- *
+ *
* This is injected when tools are present to reduce cases where Claude
* uses parameter names from its training data instead of the actual schema.
*/
@@ -177,31 +199,32 @@ You MUST follow these rules strictly:
5. When you see "STRICT PARAMETERS" in a tool description, those type definitions override any assumptions
6. Tool use in agentic workflows is REQUIRED - you must call tools with the exact parameters specified
-If you are unsure about a tool's parameters, YOU MUST read the schema definition carefully.`;
+If you are unsure about a tool's parameters, YOU MUST read the schema definition carefully.`
/**
* Template for parameter signature injection into tool descriptions.
* {params} will be replaced with the actual parameter list.
*/
-export const CLAUDE_DESCRIPTION_PROMPT = "\n\n⚠️ STRICT PARAMETERS: {params}.";
+export const CLAUDE_DESCRIPTION_PROMPT = '\n\n⚠️ STRICT PARAMETERS: {params}.'
-export const EMPTY_SCHEMA_PLACEHOLDER_NAME = "_placeholder";
-export const EMPTY_SCHEMA_PLACEHOLDER_DESCRIPTION = "Placeholder. Always pass true.";
+export const EMPTY_SCHEMA_PLACEHOLDER_NAME = '_placeholder'
+export const EMPTY_SCHEMA_PLACEHOLDER_DESCRIPTION =
+ 'Placeholder. Always pass true.'
/**
* Sentinel value to bypass thought signature validation.
- *
+ *
* When a thinking block has an invalid or missing signature (e.g., cache miss,
* session mismatch, plugin restart), this sentinel can be injected to skip
* validation instead of failing with "Invalid signature in thinking block".
- *
+ *
* This is an officially supported Google API feature, used by:
* - gemini-cli: https://github.com/google-gemini/gemini-cli
* - Google .NET SDK: PredictionServiceChatClient.cs
- *
+ *
* @see https://ai.google.dev/gemini-api/docs/thought-signatures
*/
-export const SKIP_THOUGHT_SIGNATURE = "skip_thought_signature_validator";
+export const SKIP_THOUGHT_SIGNATURE = 'skip_thought_signature_validator'
// ============================================================================
// GOOGLE SEARCH TOOL CONSTANTS
@@ -211,22 +234,22 @@ export const SKIP_THOUGHT_SIGNATURE = "skip_thought_signature_validator";
* Model used for Google Search grounding requests.
* Uses gemini-2.5-flash for fast, cost-effective search operations. (3-flash is always at capacity and doesn't support souce citation).
*/
-export const SEARCH_MODEL = "gemini-2.5-flash";
+export const SEARCH_MODEL = 'gemini-2.5-flash'
/**
* Thinking budget for deep search (more thorough analysis).
*/
-export const SEARCH_THINKING_BUDGET_DEEP = 16384;
+export const SEARCH_THINKING_BUDGET_DEEP = 16384
/**
* Thinking budget for fast search (quick results).
*/
-export const SEARCH_THINKING_BUDGET_FAST = 4096;
+export const SEARCH_THINKING_BUDGET_FAST = 4096
/**
* Timeout for search requests in milliseconds (60 seconds).
*/
-export const SEARCH_TIMEOUT_MS = 60000;
+export const SEARCH_TIMEOUT_MS = 60000
/**
* System instruction for the Google Search tool.
@@ -243,5 +266,4 @@ Guidelines:
- If analyzing URLs, extract the most relevant information
- Be concise but comprehensive in your responses
- If information is uncertain or conflicting, acknowledge it
-- Focus on answering the user's question directly`;
-
+- Focus on answering the user's question directly`
diff --git a/packages/core/src/fetch-timeout.test.ts b/packages/core/src/fetch-timeout.test.ts
new file mode 100644
index 0000000..d50a751
--- /dev/null
+++ b/packages/core/src/fetch-timeout.test.ts
@@ -0,0 +1,149 @@
+import { describe, expect, it, mock } from 'bun:test'
+
+import {
+ ACTIVE_FETCH_TIMEOUT_MS,
+ fetchWithActiveTimeout,
+} from './fetch-timeout.ts'
+
+/**
+ * Builds a mock fetch whose response promise respects the request's
+ * AbortSignal — when the signal aborts, the promise rejects so the helper
+ * under test can propagate the abort cleanly. With no resolution scheduled
+ * this simulates a server that accepted the connection but never produced
+ * response headers.
+ */
+function hangingFetch(): typeof fetch {
+ return mock(async (_input: RequestInfo | URL, init?: RequestInit) => {
+ return new Promise((_resolve, reject) => {
+ const signal = init?.signal
+ if (signal?.aborted) {
+ reject(signal.reason ?? new DOMException('aborted', 'AbortError'))
+ return
+ }
+ signal?.addEventListener(
+ 'abort',
+ () => {
+ reject(signal.reason ?? new DOMException('aborted', 'AbortError'))
+ },
+ { once: true },
+ )
+ })
+ }) as unknown as typeof fetch
+}
+
+function delayedResponseFetch(
+ resolveAfterMs: number,
+ body: string,
+ status = 200,
+): typeof fetch {
+ return mock(async (_input, _init) => {
+ await new Promise((resolve) => setTimeout(resolve, resolveAfterMs))
+ return new Response(body, { status })
+ }) as unknown as typeof fetch
+}
+
+describe('ACTIVE_FETCH_TIMEOUT_MS', () => {
+ it('defaults to fifteen seconds', () => {
+ expect(ACTIVE_FETCH_TIMEOUT_MS).toBe(15_000)
+ })
+})
+
+describe('fetchWithActiveTimeout', () => {
+ it('aborts an unresolved fetch after the configured timeout', async () => {
+ const fetchImpl = hangingFetch()
+ const start = Date.now()
+ let caught: unknown
+ try {
+ await fetchWithActiveTimeout(
+ 'https://example.test/slow',
+ { method: 'GET' },
+ { timeoutMs: 50, fetchImpl },
+ )
+ } catch (error) {
+ caught = error
+ }
+ expect(caught).toBeInstanceOf(DOMException)
+ const name = (caught as DOMException).name
+ expect(name === 'AbortError' || name === 'TimeoutError').toBe(true)
+ const elapsed = Date.now() - start
+ expect(elapsed).toBeGreaterThanOrEqual(45)
+ expect(elapsed).toBeLessThan(500)
+ })
+
+ it('preserves the caller-supplied abort reason when caller aborts first', async () => {
+ const fetchImpl = hangingFetch()
+ const callerController = new AbortController()
+ const callerReason = new Error('caller-cancelled')
+ const promise = fetchWithActiveTimeout(
+ 'https://example.test/slow',
+ { signal: callerController.signal },
+ { timeoutMs: 1000, fetchImpl },
+ )
+ callerController.abort(callerReason)
+ await expect(promise).rejects.toBe(callerReason)
+ })
+
+ it('lets a response body remain readable past the timeout window', async () => {
+ const fetchImpl = delayedResponseFetch(20, 'streamed-body')
+ const response = await fetchWithActiveTimeout(
+ 'https://example.test/stream',
+ undefined,
+ { timeoutMs: 50, fetchImpl },
+ )
+ // Wait past the timeout to confirm the body stream has not been aborted
+ // along with the active-fetch deadline. Headers resolved before timeout,
+ // so the body must remain readable.
+ await new Promise((resolve) => setTimeout(resolve, 120))
+ const body = await response.text()
+ expect(body).toBe('streamed-body')
+ })
+
+ it('forwards headers, method, and body to the underlying fetchImpl', async () => {
+ const calls: Array<[RequestInfo | URL, RequestInit?]> = []
+ const fetchImpl = mock(
+ async (input: RequestInfo | URL, init?: RequestInit) => {
+ calls.push([input, init])
+ return new Response('ok', { status: 200 })
+ },
+ ) as unknown as typeof fetch
+
+ await fetchWithActiveTimeout(
+ 'https://example.test/echo',
+ {
+ method: 'POST',
+ headers: { 'X-Trace': 'abc', 'Content-Type': 'text/plain' },
+ body: 'payload',
+ },
+ { timeoutMs: 1000, fetchImpl },
+ )
+
+ expect(calls.length).toBe(1)
+ const [input, init] = calls[0]!
+ expect(input).toBe('https://example.test/echo')
+ expect(init?.method).toBe('POST')
+ expect(init?.body).toBe('payload')
+ const headers = init?.headers as Record
+ expect(headers['X-Trace']).toBe('abc')
+ expect(headers['Content-Type']).toBe('text/plain')
+ expect(init?.signal).toBeInstanceOf(AbortSignal)
+ })
+
+ it('uses ACTIVE_FETCH_TIMEOUT_MS when no timeoutMs override is supplied', async () => {
+ // With a hanging fetch and the default 15s timeout we cannot wait that
+ // long in tests, so override to a tiny value and assert the override is
+ // honored. The default value is locked in by the constant assertion above.
+ const fetchImpl = hangingFetch()
+ let caught: unknown
+ try {
+ await fetchWithActiveTimeout('https://example.test/slow', undefined, {
+ fetchImpl,
+ timeoutMs: 25,
+ })
+ } catch (error) {
+ caught = error
+ }
+ expect(caught).toBeInstanceOf(DOMException)
+ const name = (caught as DOMException).name
+ expect(name === 'AbortError' || name === 'TimeoutError').toBe(true)
+ })
+})
diff --git a/packages/core/src/fetch-timeout.ts b/packages/core/src/fetch-timeout.ts
new file mode 100644
index 0000000..68aedc9
--- /dev/null
+++ b/packages/core/src/fetch-timeout.ts
@@ -0,0 +1,54 @@
+/**
+ * Stream-safe active-fetch timeout helper.
+ *
+ * Bounds the time spent waiting for HTTP response headers while leaving any
+ * returned response body fully readable until the caller (or its own signal)
+ * chooses to abort it. Naively forwarding `AbortSignal.timeout(...)` into
+ * `fetch()` aborts the body at the deadline even when headers arrived
+ * promptly — the helper only forwards the timeout into the request signal
+ * until the underlying fetch resolves, then drops the timeout listener so
+ * the body remains consumable.
+ */
+
+export const ACTIVE_FETCH_TIMEOUT_MS = 15_000
+
+export type ActiveFetchOptions = {
+ timeoutMs?: number
+ fetchImpl?: typeof fetch
+}
+
+/**
+ * Issue an HTTP fetch with a bounded active-fetch (header) wait.
+ *
+ * The `init.signal` is composed into the request abort signal so caller
+ * cancellation still propagates, but the 15s active timeout is only enforced
+ * until `fetchImpl` resolves — once headers arrive the timeout listener is
+ * removed and the returned Response body can be streamed past the deadline.
+ */
+export async function fetchWithActiveTimeout(
+ input: RequestInfo | URL,
+ init: RequestInit = {},
+ options: ActiveFetchOptions = {},
+): Promise {
+ const fetchImpl = options.fetchImpl ?? globalThis.fetch
+ const timeoutMs = options.timeoutMs ?? ACTIVE_FETCH_TIMEOUT_MS
+
+ const timeoutSignal = AbortSignal.timeout(timeoutMs)
+ const activeController = new AbortController()
+ const onTimeoutAbort = () => activeController.abort(timeoutSignal.reason)
+ if (timeoutSignal.aborted) {
+ onTimeoutAbort()
+ } else {
+ timeoutSignal.addEventListener('abort', onTimeoutAbort, { once: true })
+ }
+
+ const composedSignal = init.signal
+ ? AbortSignal.any([activeController.signal, init.signal])
+ : activeController.signal
+
+ try {
+ return await fetchImpl(input, { ...init, signal: composedSignal })
+ } finally {
+ timeoutSignal.removeEventListener('abort', onTimeoutAbort)
+ }
+}
diff --git a/packages/core/src/file-lock.test.ts b/packages/core/src/file-lock.test.ts
new file mode 100644
index 0000000..e285995
--- /dev/null
+++ b/packages/core/src/file-lock.test.ts
@@ -0,0 +1,1058 @@
+import { afterEach, beforeEach, describe, expect, it, spyOn } from 'bun:test'
+import * as fsp from 'node:fs/promises'
+import {
+ mkdir,
+ mkdtemp,
+ readFile,
+ rm,
+ stat,
+ utimes,
+ writeFile,
+} from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+
+import {
+ acquireFencedFileLock as acquireFencedFileLockUntracked,
+ type FencedFileLock,
+ type FencedFileLockOptions,
+ FileLockOwnershipError,
+ type FileLockStep,
+} from './file-lock.ts'
+
+let root: string
+const activeLocks = new Set()
+
+async function acquireFencedFileLock(
+ options: FencedFileLockOptions,
+): Promise {
+ const lock = await acquireFencedFileLockUntracked(options)
+ if (lock) activeLocks.add(lock)
+ return lock
+}
+
+beforeEach(async () => {
+ root = await mkdtemp(join(tmpdir(), 'file-lock-'))
+})
+
+afterEach(async () => {
+ await Promise.all(Array.from(activeLocks, (lock) => lock.release()))
+ activeLocks.clear()
+ await rm(root, { recursive: true, force: true })
+})
+
+async function readLock(lockPath: string): Promise<{
+ ownerId: string
+ expiresAt: number
+} | null> {
+ try {
+ const text = await readFile(lockPath, 'utf8')
+ const parsed = JSON.parse(text)
+ if (
+ typeof parsed === 'object' &&
+ parsed !== null &&
+ typeof parsed.ownerId === 'string' &&
+ typeof parsed.expiresAt === 'number'
+ ) {
+ return parsed
+ }
+ return null
+ } catch {
+ return null
+ }
+}
+
+async function writeLock(
+ lockPath: string,
+ ownerId: string,
+ expiresAt: number,
+): Promise {
+ await mkdir(lockPath.replace(/[^/]+$/, ''), { recursive: true }).catch(
+ () => {},
+ )
+ await writeFile(lockPath, JSON.stringify({ ownerId, expiresAt }), 'utf8')
+}
+
+async function corruptLock(lockPath: string): Promise {
+ await writeFile(lockPath, 'this is not json at all', 'utf8')
+}
+
+async function setMtime(
+ path: string,
+ ageMs: number,
+ now: number,
+): Promise {
+ const target = new Date(now - ageMs)
+ await utimes(path, target, target)
+}
+
+describe('acquireFencedFileLock — exclusive acquisition', () => {
+ it('grants a lock to the first acquirer and returns null to the second while the first is live', async () => {
+ const target = join(root, 'state.json')
+ const lockPath = `${target}.accounts.lock`
+
+ const first = await acquireFencedFileLock({
+ path: target,
+ name: 'accounts',
+ ttlMs: 60_000,
+ })
+ expect(first).not.toBeNull()
+ expect(first?.ownerId).toBeTruthy()
+
+ const contents = await readLock(lockPath)
+ expect(contents?.ownerId).toBe(first?.ownerId)
+ expect(contents?.expiresAt).toBeGreaterThan(Date.now() + 50_000)
+
+ const second = await acquireFencedFileLock({
+ path: target,
+ name: 'accounts',
+ ttlMs: 60_000,
+ })
+ expect(second).toBeNull()
+
+ await first?.release()
+ })
+
+ it('uses the per-name lock path shape under the target directory', async () => {
+ const target = join(root, 'state.json')
+ const lock = await acquireFencedFileLock({
+ path: target,
+ name: 'accounts',
+ ttlMs: 60_000,
+ })
+
+ const entries = await (await import('node:fs/promises')).readdir(root, {
+ recursive: true,
+ })
+ expect(entries.some((e) => e.endsWith('state.json.accounts.lock'))).toBe(
+ true,
+ )
+ await lock?.release()
+ })
+
+ it('allows a fresh acquirer after release', async () => {
+ const target = join(root, 'state.json')
+ const lockPath = `${target}.accounts.lock`
+
+ const first = await acquireFencedFileLock({
+ path: target,
+ name: 'accounts',
+ ttlMs: 60_000,
+ })
+ expect(first).not.toBeNull()
+ await first?.release()
+
+ // Lock file should be gone after release
+ expect(await readLock(lockPath)).toBeNull()
+
+ const second = await acquireFencedFileLock({
+ path: target,
+ name: 'accounts',
+ ttlMs: 60_000,
+ })
+ expect(second).not.toBeNull()
+ expect(second?.ownerId).not.toBe(first?.ownerId)
+ await second?.release()
+ })
+
+ it('isolates different lock names against each other', async () => {
+ const target = join(root, 'state.json')
+ const a = await acquireFencedFileLock({
+ path: target,
+ name: 'accounts',
+ ttlMs: 60_000,
+ })
+ const b = await acquireFencedFileLock({
+ path: target,
+ name: 'audit',
+ ttlMs: 60_000,
+ })
+ expect(a).not.toBeNull()
+ expect(b).not.toBeNull()
+ expect(a?.ownerId).not.toBe(b?.ownerId)
+ await a?.release()
+ await b?.release()
+ })
+})
+
+describe('acquireFencedFileLock — live-lock contention returns null', () => {
+ it('does not evict a lock whose expiresAt is still in the future', async () => {
+ const target = join(root, 'state.json')
+ const lockPath = `${target}.accounts.lock`
+
+ await writeLock(lockPath, 'live-owner', Date.now() + 60_000)
+
+ const observedSteps: FileLockStep[] = []
+ const second = await acquireFencedFileLock({
+ path: target,
+ name: 'accounts',
+ ttlMs: 60_000,
+ onStep: (step) => {
+ observedSteps.push(step)
+ },
+ })
+ expect(second).toBeNull()
+ // No eviction steps should fire when the lock is live.
+ expect(observedSteps).toEqual([])
+
+ const after = await readLock(lockPath)
+ expect(after?.ownerId).toBe('live-owner')
+ })
+})
+
+describe('acquireFencedFileLock — renewal extends expiry', () => {
+ it('re-writes the lock with a fresh expiresAt on each tick', async () => {
+ const target = join(root, 'state.json')
+ const lockPath = `${target}.accounts.lock`
+
+ let currentTime = 100_000
+ const lock = await acquireFencedFileLock({
+ path: target,
+ name: 'accounts',
+ ttlMs: 200,
+ renewIntervalMs: 25,
+ now: () => currentTime,
+ })
+ expect(lock).not.toBeNull()
+
+ const firstContents = await readLock(lockPath)
+ expect(firstContents?.ownerId).toBe(lock?.ownerId)
+ expect(firstContents?.expiresAt).toBe(currentTime + 200)
+
+ // Advance "real" world so the interval can fire; the implementation's
+ // renewal callback reads `now()` so `currentTime` stays in sync.
+ await new Promise((r) => setTimeout(r, 80))
+ currentTime += 80
+
+ const secondContents = await readLock(lockPath)
+ expect(secondContents?.ownerId).toBe(lock?.ownerId)
+ // The renewed expiresAt should be past the initial one (≥ firstContents.expiresAt).
+ expect(secondContents?.expiresAt).toBeGreaterThanOrEqual(
+ firstContents?.expiresAt,
+ )
+
+ // Stop renewal cleanly before tearDown removes the dir.
+ await lock?.release()
+ })
+
+ it('does not renew when the lock has been taken over by another owner', async () => {
+ const target = join(root, 'state.json')
+ const lockPath = `${target}.accounts.lock`
+
+ let currentTime = 200_000
+ const lock = await acquireFencedFileLock({
+ path: target,
+ name: 'accounts',
+ ttlMs: 200,
+ renewIntervalMs: 25,
+ now: () => currentTime,
+ })
+ expect(lock).not.toBeNull()
+
+ // Another contender replaces the lock file out from under us.
+ await writeLock(lockPath, 'other-process', currentTime + 60_000)
+
+ await new Promise((r) => setTimeout(r, 80))
+ currentTime += 80
+
+ const observed = await readLock(lockPath)
+ expect(observed?.ownerId).toBe('other-process')
+
+ await lock?.release()
+ })
+
+ it("renewal timer is unref'd so it does not keep the process alive", async () => {
+ // We cannot directly observe `unref()` from the test, but we can check
+ // that releasing the lock stops renewal (no further rewrites occur).
+ const target = join(root, 'state.json')
+ const lockPath = `${target}.accounts.lock`
+
+ let currentTime = 300_000
+ const lock = await acquireFencedFileLock({
+ path: target,
+ name: 'accounts',
+ ttlMs: 200,
+ renewIntervalMs: 25,
+ now: () => currentTime,
+ })
+
+ await new Promise((r) => setTimeout(r, 70))
+ currentTime += 70
+ const before = await readLock(lockPath)
+ expect(before?.ownerId).toBe(lock?.ownerId)
+
+ await lock?.release()
+ expect(await readLock(lockPath)).toBeNull()
+
+ // Wait past another renew tick; nothing should happen.
+ await new Promise((r) => setTimeout(r, 70))
+ currentTime += 70
+ expect(await readLock(lockPath)).toBeNull()
+ })
+})
+
+describe('acquireFencedFileLock — release deletes only its own lock', () => {
+ it('refuses to delete a lock that has been reassigned to another owner', async () => {
+ const target = join(root, 'state.json')
+ const lockPath = `${target}.accounts.lock`
+
+ const lock = await acquireFencedFileLock({
+ path: target,
+ name: 'accounts',
+ ttlMs: 60_000,
+ })
+
+ // Another process takes over the lock.
+ await writeLock(lockPath, 'other-process', Date.now() + 60_000)
+
+ await lock?.release()
+
+ const after = await readLock(lockPath)
+ expect(after?.ownerId).toBe('other-process')
+
+ // Cleanup the simulated "other process" lock so rm is happy.
+ await rm(lockPath, { force: true })
+ })
+
+ it('is a no-op when the lock file is already gone', async () => {
+ const target = join(root, 'state.json')
+ const lockPath = `${target}.accounts.lock`
+
+ const lock = await acquireFencedFileLock({
+ path: target,
+ name: 'accounts',
+ ttlMs: 60_000,
+ })
+ await rm(lockPath, { force: true })
+
+ await expect(lock?.release()).resolves.toBeUndefined()
+ })
+
+ it('release() awaits any in-flight renewal and prevents it from resurrecting the lock', async () => {
+ const target = join(root, 'state.json')
+ const lockPath = `${target}.accounts.lock`
+
+ // Capture the REAL writeFile before spying so the spy can delegate to
+ // it without re-entering itself.
+ const realWriteFile = fsp.writeFile
+ const writeSpy = spyOn(fsp, 'writeFile').mockImplementation(
+ async (filePath, content, opts) => {
+ // Slow lock-file writes so an in-flight renewal's writeFile takes
+ // longer than release()'s read+unlink — this guarantees the race
+ // becomes observable without the released-flag / in-flight-fence.
+ if (typeof filePath === 'string' && filePath.endsWith('.lock')) {
+ await new Promise((resolve) => setTimeout(resolve, 80))
+ }
+ return realWriteFile.call(
+ fsp,
+ filePath as never,
+ content as never,
+ opts as never,
+ )
+ },
+ )
+
+ try {
+ const lock = await acquireFencedFileLock({
+ path: target,
+ name: 'accounts',
+ ttlMs: 60_000,
+ renewIntervalMs: 5,
+ })
+
+ // Wait long enough for at least one renewal to be in-flight (and
+ // stuck mid-writeFile) when we trigger release().
+ await new Promise((resolve) => setTimeout(resolve, 30))
+
+ await lock?.release()
+
+ // With the fix: release awaits the in-flight renewal, then unlinks.
+ // Without the fix: release unlinks first, the still-pending renewal
+ // writeFile completes and resurrects the lock — this is the assertion
+ // that would fail. Read the lock contents back to check.
+ await stat(lockPath).then(
+ () => {
+ throw new Error(
+ `lock file ${lockPath} should be gone after release, but stat() succeeded`,
+ )
+ },
+ (err: NodeJS.ErrnoException) => {
+ if (err.code !== 'ENOENT') throw err
+ },
+ )
+
+ // Wait past the slow-writeFile window so any post-release queue
+ // entry has fully drained.
+ await new Promise((resolve) => setTimeout(resolve, 120))
+ await stat(lockPath).then(
+ () => {
+ throw new Error(
+ `lock file ${lockPath} resurrected after release window`,
+ )
+ },
+ (err: NodeJS.ErrnoException) => {
+ if (err.code !== 'ENOENT') throw err
+ },
+ )
+ } finally {
+ writeSpy.mockRestore()
+ }
+ })
+})
+
+describe('acquireFencedFileLock — assertOwned', () => {
+ it('throws FileLockOwnershipError when the lock file is reassigned to another owner', async () => {
+ const target = join(root, 'state.json')
+ const lockPath = `${target}.accounts.lock`
+
+ const lock = await acquireFencedFileLock({
+ path: target,
+ name: 'accounts',
+ ttlMs: 60_000,
+ })
+
+ await writeLock(lockPath, 'other-process', Date.now() + 60_000)
+
+ await expect(lock?.assertOwned()).rejects.toBeInstanceOf(
+ FileLockOwnershipError,
+ )
+
+ await rm(lockPath, { force: true })
+ })
+
+ it('throws FileLockOwnershipError when the lock has expired', async () => {
+ let currentTime = 400_000
+ const target = join(root, 'state.json')
+
+ const lock = await acquireFencedFileLock({
+ path: target,
+ name: 'accounts',
+ ttlMs: 1000,
+ renew: false,
+ now: () => currentTime,
+ })
+
+ currentTime += 2000
+ await expect(lock?.assertOwned()).rejects.toBeInstanceOf(
+ FileLockOwnershipError,
+ )
+
+ await lock?.release()
+ })
+
+ it('resolves cleanly when ownership and expiry still match', async () => {
+ const target = join(root, 'state.json')
+ const lock = await acquireFencedFileLock({
+ path: target,
+ name: 'accounts',
+ ttlMs: 60_000,
+ })
+ await expect(lock?.assertOwned()).resolves.toBeUndefined()
+ await lock?.release()
+ })
+})
+
+describe('acquireFencedFileLock — expired lock recovery', () => {
+ it('evicts and takes over a lock whose expiresAt is in the past', async () => {
+ const target = join(root, 'state.json')
+ const lockPath = `${target}.accounts.lock`
+
+ await writeLock(lockPath, 'zombie-owner', Date.now() - 60_000)
+
+ const observed: FileLockStep[] = []
+ const lock = await acquireFencedFileLock({
+ path: target,
+ name: 'accounts',
+ ttlMs: 60_000,
+ onStep: (step) => {
+ observed.push(step)
+ },
+ })
+ expect(lock).not.toBeNull()
+ expect(lock?.ownerId).not.toBe('zombie-owner')
+
+ // The full eviction chain should have run.
+ expect(observed).toContain('stale-marker-stat')
+ expect(observed).toContain('stale-marker-claimed')
+ expect(observed).toContain('stale-lock-confirmed')
+ expect(observed).toContain('eviction-marker-acquired')
+
+ const contents = await readLock(lockPath)
+ expect(contents?.ownerId).toBe(lock?.ownerId)
+
+ await lock?.release()
+ })
+})
+
+describe('acquireFencedFileLock — malformed lock fails closed', () => {
+ it('returns null on malformed JSON unless mtime proves the lock is stale', async () => {
+ const target = join(root, 'state.json')
+ const lockPath = `${target}.accounts.lock`
+
+ await corruptLock(lockPath)
+
+ const observed: FileLockStep[] = []
+ const result = await acquireFencedFileLock({
+ path: target,
+ name: 'accounts',
+ ttlMs: 60_000,
+ onStep: (step) => {
+ observed.push(step)
+ },
+ })
+ // Lock is malformed but file is brand new — fail closed.
+ expect(result).toBeNull()
+ expect(observed).toEqual([])
+
+ // Now backdate mtime past the TTL window so it proves staleness, then retry.
+ const lockStats = await stat(lockPath)
+ const now = Date.now()
+ await setMtime(lockPath, lockStats.mtimeMs - 0, now - 5 * 60_000)
+
+ const evicted = await acquireFencedFileLock({
+ path: target,
+ name: 'accounts',
+ ttlMs: 60_000,
+ })
+ expect(evicted).not.toBeNull()
+ await evicted?.release()
+ })
+
+ it('returns null on JSON that has the wrong shape (no ownerId/expiresAt) until mtime proves stale', async () => {
+ const target = join(root, 'state.json')
+ const lockPath = `${target}.accounts.lock`
+
+ await writeFile(lockPath, JSON.stringify({ stranger: 'shape' }), 'utf8')
+
+ const result = await acquireFencedFileLock({
+ path: target,
+ name: 'accounts',
+ ttlMs: 60_000,
+ })
+ expect(result).toBeNull()
+
+ const now = Date.now()
+ await setMtime(lockPath, 0, now - 5 * 60_000)
+
+ const evicted = await acquireFencedFileLock({
+ path: target,
+ name: 'accounts',
+ ttlMs: 60_000,
+ })
+ expect(evicted).not.toBeNull()
+ await evicted?.release()
+ })
+})
+
+describe('acquireFencedFileLock — stale eviction marker ownership', () => {
+ it('refuses to delete the lock when the marker has been reassigned to another evicter', async () => {
+ const target = join(root, 'state.json')
+ const lockPath = `${target}.accounts.lock`
+
+ await writeLock(lockPath, 'zombie', Date.now() - 60_000)
+
+ const stepsObserved: FileLockStep[] = []
+ const lock = await acquireFencedFileLock({
+ path: target,
+ name: 'accounts',
+ ttlMs: 60_000,
+ onStep: async (step) => {
+ stepsObserved.push(step)
+ if (step === 'stale-marker-claimed') {
+ // Hijack the marker synchronously inside the awaited hook so
+ // the implementation's next read sees the new owner before it
+ // can confirm "marker is still ours".
+ const markerPath = join(`${lockPath}.evicting`, 'owner.json')
+ await rm(markerPath, { force: true })
+ await writeFile(markerPath, JSON.stringify({ ownerId: 'hijacker' }), {
+ encoding: 'utf8',
+ })
+ }
+ },
+ })
+ expect(lock).toBeNull()
+
+ // The original zombie lock must still be intact — neither observer
+ // deleted it; the second observer lost the marker before the
+ // destructive seam, so it backed off.
+ const after = await readLock(lockPath)
+ expect(after?.ownerId).toBe('zombie')
+ })
+
+ it('refuses to delete when the lock is reassigned to a fresh owner after our marker claim', async () => {
+ const target = join(root, 'state.json')
+ const lockPath = `${target}.accounts.lock`
+
+ await writeLock(lockPath, 'zombie', Date.now() - 60_000)
+
+ const stepsObserved: FileLockStep[] = []
+ const lock = await acquireFencedFileLock({
+ path: target,
+ name: 'accounts',
+ ttlMs: 60_000,
+ onStep: async (step) => {
+ stepsObserved.push(step)
+ if (step === 'stale-marker-claimed') {
+ // Between our marker claim and the "stale-lock-confirmed"
+ // boundary, the real lock owner (zombie) refreshes the lock to
+ // a fresh, live payload.
+ await writeLock(lockPath, 'winner', Date.now() + 60_000)
+ }
+ },
+ })
+ expect(lock).toBeNull()
+
+ const after = await readLock(lockPath)
+ expect(after?.ownerId).toBe('winner')
+
+ // Clean up the leftover winner lock + evicting dir from the test.
+ await rm(lockPath, { force: true })
+ await rm(`${lockPath}.evicting`, { recursive: true, force: true })
+ })
+
+ it("an observer that lost the eviction marker never deletes the winner's fresh lock", async () => {
+ const target = join(root, 'state.json')
+ const lockPath = `${target}.accounts.lock`
+ const markerPath = join(`${lockPath}.evicting`, 'owner.json')
+
+ // Force the lock to look stale: corrupt its contents and backdate
+ // mtime so the malformed-with-stale-mtime branch is exercised.
+ await corruptLock(lockPath)
+ const now = Date.now()
+ await setMtime(lockPath, 0, now - 10 * 60_000)
+
+ // The "winner" is a different process that has, by this point,
+ // claimed the marker before us. We will overwrite the marker AFTER
+ // we observe our own stale-marker-claimed hook and ensure the
+ // implementation refuses to touch the lock file.
+ const stepsObserved: FileLockStep[] = []
+ const lock = await acquireFencedFileLock({
+ path: target,
+ name: 'accounts',
+ ttlMs: 60_000,
+ onStep: async (step) => {
+ stepsObserved.push(step)
+ if (step === 'stale-marker-claimed') {
+ await rm(markerPath, { force: true })
+ await writeFile(markerPath, JSON.stringify({ ownerId: 'winner' }), {
+ encoding: 'utf8',
+ })
+ }
+ },
+ })
+
+ expect(lock).toBeNull()
+
+ // The lock file must still exist with its corrupted contents (no
+ // destructive op ran).
+ const stillCorrupted = await readLock(lockPath)
+ expect(stillCorrupted).toBeNull() // malformed JSON returns null
+ const lockStats = await stat(lockPath)
+ expect(lockStats.isFile()).toBe(true)
+
+ await rm(lockPath, { force: true })
+ await rm(`${lockPath}.evicting`, { recursive: true, force: true })
+ })
+})
+
+describe('acquireFencedFileLock — onStep interleavings', () => {
+ it('fires the four steps in order during a clean eviction', async () => {
+ const target = join(root, 'state.json')
+ const lockPath = `${target}.accounts.lock`
+
+ await writeLock(lockPath, 'zombie', Date.now() - 60_000)
+
+ const observed: FileLockStep[] = []
+ const lock = await acquireFencedFileLock({
+ path: target,
+ name: 'accounts',
+ ttlMs: 60_000,
+ onStep: (step) => {
+ observed.push(step)
+ },
+ })
+ expect(lock).not.toBeNull()
+ expect(observed).toEqual([
+ 'stale-marker-stat',
+ 'stale-marker-claimed',
+ 'stale-lock-confirmed',
+ 'eviction-marker-acquired',
+ ])
+
+ await lock?.release()
+ })
+
+ it('does not fire eviction steps when the contended lock is live', async () => {
+ const target = join(root, 'state.json')
+ const lockPath = `${target}.accounts.lock`
+
+ await writeLock(lockPath, 'live', Date.now() + 60_000)
+
+ const observed: FileLockStep[] = []
+ const lock = await acquireFencedFileLock({
+ path: target,
+ name: 'accounts',
+ ttlMs: 60_000,
+ onStep: (step) => {
+ observed.push(step)
+ },
+ })
+ expect(lock).toBeNull()
+ expect(observed).toEqual([])
+ })
+})
+
+describe('acquireFencedFileLock — eviction marker TTL/PID reclamation', () => {
+ it('reclaims a stale eviction marker whose PID is dead', async () => {
+ const target = join(root, 'state.json')
+ const lockPath = `${target}.accounts.lock`
+ const evictingDir = `${lockPath}.evicting`
+ const markerPath = join(evictingDir, 'owner.json')
+
+ // Drop a stale marker with a dead PID (init(1) is always present on
+ // POSIX but its recycled quit claim is rare; use a clearly bogus PID
+ // to guarantee `process.kill(pid, 0)` throws ESRCH).
+ await mkdir(evictingDir, { recursive: true, mode: 0o700 })
+ await writeFile(
+ markerPath,
+ JSON.stringify({
+ ownerId: 'dead-evicter',
+ pid: 2_000_000_000,
+ createdAt: Date.now() - 60_000,
+ }),
+ 'utf8',
+ )
+
+ // The lock itself is genuinely stale, so the contender will hit
+ // the eviction path. With the stale-marker reclamation logic, the
+ // first waiter removes the dead-PID marker and proceeds.
+ await writeLock(lockPath, 'zombie', Date.now() - 60_000)
+
+ const lock = await acquireFencedFileLock({
+ path: target,
+ name: 'accounts',
+ ttlMs: 60_000,
+ })
+ expect(lock).not.toBeNull()
+ // The lock is now ours — proves the reclamation worked.
+ const contents = await readLock(lockPath)
+ expect(contents?.ownerId).toBe(lock?.ownerId)
+ await lock?.release()
+ })
+
+ it('reclaims a marker older than 30 seconds regardless of PID liveness', async () => {
+ const target = join(root, 'state.json')
+ const lockPath = `${target}.accounts.lock`
+ const evictingDir = `${lockPath}.evicting`
+ const markerPath = join(evictingDir, 'owner.json')
+
+ // Marker is fresh-but-just-tipping-over the TTL. Use our own pid
+ // so the liveness check is positive — only the age triggers the
+ // reclaim.
+ await mkdir(evictingDir, { recursive: true, mode: 0o700 })
+ await writeFile(
+ markerPath,
+ JSON.stringify({
+ ownerId: 'old-but-alive',
+ pid: process.pid,
+ createdAt: Date.now() - 31_000,
+ }),
+ 'utf8',
+ )
+
+ await writeLock(lockPath, 'zombie', Date.now() - 60_000)
+
+ const lock = await acquireFencedFileLock({
+ path: target,
+ name: 'accounts',
+ ttlMs: 60_000,
+ })
+ expect(lock).not.toBeNull()
+ const contents = await readLock(lockPath)
+ expect(contents?.ownerId).toBe(lock?.ownerId)
+ await lock?.release()
+ })
+
+ it('respects a fresh, live marker (does not reclaim an in-progress eviction)', async () => {
+ const target = join(root, 'state.json')
+ const lockPath = `${target}.accounts.lock`
+ const evictingDir = `${lockPath}.evicting`
+ const markerPath = join(evictingDir, 'owner.json')
+
+ // Live marker — fresh AND alive. The contender must respect it.
+ await mkdir(evictingDir, { recursive: true, mode: 0o700 })
+ await writeFile(
+ markerPath,
+ JSON.stringify({
+ ownerId: 'live-evicter',
+ pid: process.pid,
+ createdAt: Date.now(),
+ }),
+ 'utf8',
+ )
+
+ await writeLock(lockPath, 'zombie', Date.now() - 60_000)
+
+ const lock = await acquireFencedFileLock({
+ path: target,
+ name: 'accounts',
+ ttlMs: 60_000,
+ })
+ // Bounded retries exhausted — the contender backs off.
+ expect(lock).toBeNull()
+ // The live marker is still in place.
+ const stillThere = await readFile(markerPath, 'utf8')
+ expect(JSON.parse(stillThere).ownerId).toBe('live-evicter')
+
+ // Clean up.
+ await rm(evictingDir, { recursive: true, force: true })
+ await rm(lockPath, { force: true })
+ })
+})
+
+describe('acquireFencedFileLock — renewal TOCTOU', () => {
+ it('makes release terminal and resolves existing whenLost waiters', async () => {
+ const target = join(root, 'state.json')
+ const lockPath = `${target}.accounts.lock`
+ const lock = await acquireFencedFileLock({
+ path: target,
+ name: 'accounts',
+ ttlMs: 60_000,
+ renewIntervalMs: 10,
+ })
+ expect(lock).not.toBeNull()
+ const lost = lock?.whenLost()
+
+ await lock?.release()
+
+ await expect(lost).resolves.toBeUndefined()
+ await expect(lock?.whenLost()).resolves.toBeUndefined()
+ expect(lock?.hasLost()).toBe(true)
+ expect(await readLock(lockPath)).toBeNull()
+ await lock?.release()
+ })
+
+ it('stops the renewal interval as soon as ownership is lost', async () => {
+ const target = join(root, 'state.json')
+ const lockPath = `${target}.accounts.lock`
+ const clearIntervalSpy = spyOn(globalThis, 'clearInterval')
+
+ try {
+ const lock = await acquireFencedFileLock({
+ path: target,
+ name: 'accounts',
+ ttlMs: 60_000,
+ renewIntervalMs: 10,
+ })
+ expect(lock).not.toBeNull()
+
+ await writeLock(lockPath, 'thief', Date.now() + 60_000)
+ await lock?.whenLost()
+
+ expect(clearIntervalSpy).toHaveBeenCalledTimes(1)
+ await lock?.release()
+ } finally {
+ clearIntervalSpy.mockRestore()
+ await rm(lockPath, { force: true })
+ }
+ })
+
+ it('detects ownership change between async read and write — does not clobber the new owner', async () => {
+ const target = join(root, 'state.json')
+ const lockPath = `${target}.accounts.lock`
+
+ const lock = await acquireFencedFileLock({
+ path: target,
+ name: 'accounts',
+ ttlMs: 60_000,
+ renewIntervalMs: 10,
+ })
+ expect(lock).not.toBeNull()
+
+ // Owner B evicts A and takes over the lock with a live expiry.
+ // This is the "A pauses between read and write" moment: the moment
+ // A's renewal is mid-cycle, B rips the lock file out and rewrites
+ // it with B's ownerId. A's renewal must NOT clobber B's lock.
+ await writeLock(lockPath, 'owner-B', Date.now() + 60_000)
+
+ // Wait for A's renewal tick to fire and notice the mismatch.
+ await lock?.whenLost()
+
+ // B's lock must be intact — A's renewal must NOT have overwritten it.
+ const contents = await readLock(lockPath)
+ expect(contents?.ownerId).toBe('owner-B')
+
+ // A must report itself as lost.
+ expect(lock?.hasLost()).toBe(true)
+
+ // Release should refuse to delete (the lock is B's).
+ await lock?.release()
+ const afterRelease = await readLock(lockPath)
+ expect(afterRelease?.ownerId).toBe('owner-B')
+
+ await rm(lockPath, { force: true })
+ })
+
+ it('marks the lock lost when a replacement owner arrives between the renewal read and rename', async () => {
+ const target = join(root, 'state.json')
+ const lockPath = `${target}.accounts.lock`
+ const clearIntervalSpy = spyOn(globalThis, 'clearInterval')
+
+ let pausedResolve!: () => void
+ const paused = new Promise((resolve) => {
+ pausedResolve = resolve
+ })
+ let gateResolve!: () => void
+ const gate = new Promise((resolve) => {
+ gateResolve = resolve
+ })
+ let hookFired = false
+
+ try {
+ const lock = await acquireFencedFileLock({
+ path: target,
+ name: 'accounts',
+ ttlMs: 60_000,
+ renewIntervalMs: 10,
+ onStep: async (step) => {
+ if (step === 'renew-read' && !hookFired) {
+ hookFired = true
+ pausedResolve()
+ await gate
+ }
+ },
+ })
+ expect(lock).not.toBeNull()
+
+ // Wait for owner A's renewal tick to reach the read seam, then
+ // owner B takes over between A's read and A's rename.
+ await paused
+ await writeLock(lockPath, 'owner-B', Date.now() + 60_000)
+ gateResolve()
+
+ await lock?.whenLost()
+ expect(lock?.hasLost()).toBe(true)
+ // markLost() cleared the renewal interval exactly once.
+ expect(clearIntervalSpy).toHaveBeenCalledTimes(1)
+
+ // A's pre-rename re-read must catch the takeover before the
+ // rename — B's lock is intact, not clobbered by A's renewal.
+ const contents = await readLock(lockPath)
+ expect(contents?.ownerId).toBe('owner-B')
+
+ await lock?.release()
+ await rm(lockPath, { force: true })
+ } finally {
+ clearIntervalSpy.mockRestore()
+ }
+ })
+
+ it('marks the lock lost when a takeover lands right after the renewal rename (verify-after-commit)', async () => {
+ const target = join(root, 'state.json')
+ const lockPath = `${target}.accounts.lock`
+ const clearIntervalSpy = spyOn(globalThis, 'clearInterval')
+ let renewalTick: (() => void) | null = null
+ const setIntervalSpy = spyOn(globalThis, 'setInterval').mockImplementation(
+ ((callback: () => void) => {
+ renewalTick = callback
+ return 0 as unknown as ReturnType
+ }) as typeof setInterval,
+ )
+
+ let pausedResolve!: () => void
+ const paused = new Promise((resolve) => {
+ pausedResolve = resolve
+ })
+ let gateResolve!: () => void
+ const gate = new Promise((resolve) => {
+ gateResolve = resolve
+ })
+ let hookFired = false
+
+ try {
+ const lock = await acquireFencedFileLock({
+ path: target,
+ name: 'accounts',
+ ttlMs: 60_000,
+ renewIntervalMs: 60_000,
+ onStep: async (step) => {
+ if (step === 'renew-committed' && !hookFired) {
+ hookFired = true
+ pausedResolve()
+ await gate
+ }
+ },
+ })
+ expect(lock).not.toBeNull()
+ expect(renewalTick).not.toBeNull()
+
+ // Drive exactly one renewal. With no later timer callback available,
+ // only the immediate post-rename re-read can observe B's takeover.
+ renewalTick?.()
+ await paused
+ await writeLock(lockPath, 'owner-B', Date.now() + 60_000)
+ gateResolve()
+
+ let timeoutHandle: ReturnType | null = null
+ const loss = await Promise.race([
+ lock?.whenLost(),
+ new Promise<'timeout'>((resolve) => {
+ timeoutHandle = setTimeout(() => resolve('timeout'), 100)
+ }),
+ ]).finally(() => {
+ if (timeoutHandle !== null) clearTimeout(timeoutHandle)
+ })
+ expect(loss).toBeUndefined()
+ expect(lock?.hasLost()).toBe(true)
+ expect(clearIntervalSpy).toHaveBeenCalledTimes(1)
+
+ // A backed off after the verify re-read saw B; B's write is the
+ // final content and A's release refuses to delete it.
+ const contents = await readLock(lockPath)
+ expect(contents?.ownerId).toBe('owner-B')
+
+ await lock?.release()
+ const afterRelease = await readLock(lockPath)
+ expect(afterRelease?.ownerId).toBe('owner-B')
+ await rm(lockPath, { force: true })
+ } finally {
+ clearIntervalSpy.mockRestore()
+ setIntervalSpy.mockRestore()
+ }
+ })
+
+ it('whenLost() resolves promptly when the lock is taken over mid-renewal', async () => {
+ const target = join(root, 'state.json')
+ const lockPath = `${target}.accounts.lock`
+
+ const lock = await acquireFencedFileLock({
+ path: target,
+ name: 'accounts',
+ ttlMs: 60_000,
+ renewIntervalMs: 10,
+ })
+ expect(lock).not.toBeNull()
+ expect(lock?.hasLost()).toBe(false)
+
+ // Write a different owner into the lock. Note: this happens AFTER
+ // the initial acquire, so ownership is "fresh-stolen" — the renewal
+ // must detect and bail.
+ await writeLock(lockPath, 'thief', Date.now() + 60_000)
+
+ // Bounded wait — if whenLost() never resolves, the pipeline is
+ // broken. 500ms is generous for the 10ms tick + sync re-read.
+ const lostPromise = lock?.whenLost()
+ let timeoutHandle: ReturnType | null = null
+ const timeout = new Promise<'timeout'>((resolve) => {
+ timeoutHandle = setTimeout(() => resolve('timeout'), 1000)
+ })
+ const result = await Promise.race([lostPromise, timeout]).finally(() => {
+ if (timeoutHandle !== null) clearTimeout(timeoutHandle)
+ })
+ expect(result).toBeUndefined()
+ expect(lock?.hasLost()).toBe(true)
+
+ await lock?.release()
+ await rm(lockPath, { force: true })
+ })
+})
diff --git a/packages/core/src/file-lock.ts b/packages/core/src/file-lock.ts
new file mode 100644
index 0000000..04a7070
--- /dev/null
+++ b/packages/core/src/file-lock.ts
@@ -0,0 +1,552 @@
+/**
+ * Renewable fenced file lock.
+ *
+ * The lock file at `${path}.${name}.lock` holds the JSON
+ * `{ ownerId, expiresAt }`. Acquiring it uses an exclusive `wx` write so
+ * concurrent processes race deterministically — only one wins.
+ *
+ * A contended, live lock (ownerId present, expiresAt > now) returns
+ * `null` immediately; no eviction is attempted and no destructive op
+ * touches the file. A contended, stale lock (no owner, expired, or
+ * malformed-but-old) opens an eviction protocol that uses an exclusive
+ * "evicting" marker directory at `${lockPath}.evicting/owner.json` as a
+ * fence: the file holds the in-progress evicter's ownerId. The marker
+ * is verified before every destructive seam (re-read, unlink,
+ * re-acquire) so a contender whose marker has been hijacked will see
+ * ownership has changed and back off without touching the winner's lock.
+ *
+ * The renewal timer is `setInterval(...).unref()`-ed so it does not
+ * keep the Node/Bun runtime alive. Default renewal interval is
+ * `max(1000, floor(ttlMs / 3))`.
+ */
+
+import { randomUUID } from 'node:crypto'
+import {
+ mkdir,
+ readFile,
+ rename,
+ rm,
+ stat,
+ unlink,
+ writeFile,
+} from 'node:fs/promises'
+import { dirname, join } from 'node:path'
+
+const RENEW_MIN_INTERVAL_MS = 1_000
+const MARKER_CLAIM_MAX_ATTEMPTS = 8
+const MARKER_CLAIM_BACKOFF_MS = 25
+
+export type FileLockStep =
+ | 'stale-marker-stat'
+ | 'stale-marker-claimed'
+ | 'stale-lock-confirmed'
+ | 'eviction-marker-acquired'
+ | 'renew-read'
+ | 'renew-committed'
+
+export interface FencedFileLockOptions {
+ path: string
+ name: string
+ ttlMs: number
+ /**
+ * Defaults to `true`. When false, no renewal timer is scheduled.
+ */
+ renew?: boolean
+ /**
+ * Override the renewal cadence in milliseconds.
+ * Defaults to `max(RENEW_MIN_INTERVAL_MS, floor(ttlMs / 3))`.
+ */
+ renewIntervalMs?: number
+ /**
+ * Clock injection. Defaults to `Date.now`. Tests use this to
+ * simulate elapsed time without waiting for real wall-clock ticks.
+ */
+ now?: () => number
+ /**
+ * Hook invoked at well-defined milestones inside the eviction
+ * protocol and the renewal tick. Used by tests to inject
+ * interleavings and simulate racing contenders:
+ *
+ * - `renew-read` — after the renewal tick reads the lock payload,
+ * before the ownership checks and the temp-write/rename commit.
+ * - `renew-committed` — after the renewal rename lands, before the
+ * verify-after-commit re-read.
+ */
+ onStep?: (step: FileLockStep) => Promise | void
+}
+
+export interface FencedFileLock {
+ ownerId: string
+ assertOwned(): Promise
+ release(): Promise
+ /**
+ * Resolves when the renewal loop detects the lock has been taken over
+ * by another owner (or otherwise lost before `release()`). Resolves
+ * immediately if the lock is already lost.
+ */
+ whenLost(): Promise
+ hasLost(): boolean
+}
+
+/**
+ * Thrown by `assertOwned` when the lock file no longer carries our
+ * ownerId or has expired.
+ */
+export class FileLockOwnershipError extends Error {
+ readonly details: {
+ path: string
+ expectedOwner: string
+ observedOwner?: string
+ observedExpiresAt?: number
+ }
+
+ constructor(message: string, details: FileLockOwnershipError['details']) {
+ super(message)
+ this.name = 'FileLockOwnershipError'
+ this.details = details
+ }
+}
+
+interface LockPayload {
+ ownerId: string
+ expiresAt: number
+}
+
+interface MarkerPayload {
+ ownerId: string
+ /** Owning process ID — used to detect a dead/in-progress evicter. */
+ pid: number
+ /** Wall-clock time (ms) the marker was created. Used for the TTL floor. */
+ createdAt: number
+}
+
+const MARKER_TTL_MS = 30_000
+
+async function readLockPayload(path: string): Promise {
+ try {
+ const text = await readFile(path, 'utf8')
+ const parsed: unknown = JSON.parse(text)
+ if (
+ typeof parsed === 'object' &&
+ parsed !== null &&
+ typeof (parsed as Record).ownerId === 'string' &&
+ typeof (parsed as Record).expiresAt === 'number'
+ ) {
+ return parsed as LockPayload
+ }
+ return null
+ } catch {
+ return null
+ }
+}
+
+async function readMarkerPayload(path: string): Promise {
+ try {
+ const text = await readFile(path, 'utf8')
+ const parsed: unknown = JSON.parse(text)
+ if (
+ typeof parsed === 'object' &&
+ parsed !== null &&
+ typeof (parsed as Record).ownerId === 'string' &&
+ typeof (parsed as Record).pid === 'number' &&
+ typeof (parsed as Record).createdAt === 'number'
+ ) {
+ return parsed as MarkerPayload
+ }
+ return null
+ } catch {
+ return null
+ }
+}
+
+/**
+ * Best-effort liveness check for a PID. Returns `true` when the process
+ * is alive or when the platform lacks a reliable probe (Windows), so
+ * the marker is only reclaimed when we have positive evidence of death.
+ */
+function isProcessAlive(pid: number): boolean {
+ if (!Number.isInteger(pid) || pid <= 0) return false
+ try {
+ process.kill(pid, 0)
+ return true
+ } catch (err) {
+ const code = (err as NodeJS.ErrnoException).code
+ if (code === 'ESRCH') return false
+ // EPERM (process exists but we cannot signal it) still counts as alive.
+ return true
+ }
+}
+
+async function rmEvictingDir(evictingPath: string): Promise {
+ await rm(evictingPath, { force: true }).catch(() => {})
+ await rm(dirname(evictingPath), { recursive: true, force: true }).catch(
+ () => {},
+ )
+}
+
+async function sleep(ms: number): Promise {
+ await new Promise((resolve) => setTimeout(resolve, ms))
+}
+
+export async function acquireFencedFileLock(
+ options: FencedFileLockOptions,
+): Promise {
+ const lockPath = `${options.path}.${options.name}.lock`
+ const evictingDir = `${lockPath}.evicting`
+ const evictingPath = join(evictingDir, 'owner.json')
+
+ const ownerId = randomUUID()
+ const now = options.now ?? Date.now
+
+ const tryAcquire = async (
+ expiresAt: number,
+ ): Promise => {
+ const payload = JSON.stringify({ ownerId, expiresAt })
+ try {
+ await mkdir(dirname(lockPath), { recursive: true })
+ await writeFile(lockPath, payload, {
+ flag: 'wx',
+ encoding: 'utf8',
+ mode: 0o600,
+ })
+ return buildLock(lockPath, evictingPath, ownerId, options, now)
+ } catch (err) {
+ if ((err as NodeJS.ErrnoException).code === 'EEXIST') {
+ return null
+ }
+ throw err
+ }
+ }
+
+ // Outer loop: each iteration represents one full attempt to acquire
+ // (fresh OR after eviction). We may need to retry if the contender
+ // creates a new lock between our unlink and our re-acquire.
+ for (;;) {
+ const firstTry = await tryAcquire(now() + options.ttlMs)
+ if (firstTry) return firstTry
+
+ const observed = await readLockPayload(lockPath)
+ const lockStats = await stat(lockPath).catch(() => null)
+ if (!lockStats) continue // lock vanished between attempts
+
+ const currentTime = now()
+ const looksLive =
+ observed !== null &&
+ typeof observed.ownerId === 'string' &&
+ observed.expiresAt > currentTime
+ if (looksLive) {
+ return null
+ }
+
+ // malformed fail-closed: only proceed when mtime proves staleness.
+ const looksValid = observed !== null
+ if (!looksValid) {
+ const ageMs = currentTime - lockStats.mtimeMs
+ if (ageMs < options.ttlMs) return null
+ }
+
+ // Past this point we have decided the lock is stale and we will
+ // attempt eviction.
+ await options.onStep?.('stale-marker-stat')
+
+ // Claim the exclusive evicting marker (bounded retries so a stuck
+ // contender cannot trap us in an infinite loop). Before each retry,
+ // inspect the existing marker: if its PID is dead or the marker is
+ // older than MARKER_TTL_MS, it is abandoned and we reclaim it.
+ let markerClaimed = false
+ for (let attempt = 0; attempt < MARKER_CLAIM_MAX_ATTEMPTS; attempt++) {
+ try {
+ await mkdir(evictingDir, { recursive: true, mode: 0o700 })
+ await writeFile(
+ evictingPath,
+ JSON.stringify({
+ ownerId,
+ pid: process.pid,
+ createdAt: now(),
+ }),
+ {
+ flag: 'wx',
+ encoding: 'utf8',
+ mode: 0o600,
+ },
+ )
+ markerClaimed = true
+ break
+ } catch (err) {
+ if ((err as NodeJS.ErrnoException).code !== 'EEXIST') throw err
+ // Existing marker — check whether it is reclaimed-able.
+ const existing = await readMarkerPayload(evictingPath)
+ if (existing) {
+ const ageMs = now() - existing.createdAt
+ const deadPid = !isProcessAlive(existing.pid)
+ if (deadPid || ageMs > MARKER_TTL_MS) {
+ // Stale marker — recycle the directory and retry the claim.
+ await rmEvictingDir(evictingPath)
+ continue
+ }
+ }
+ await sleep(MARKER_CLAIM_BACKOFF_MS)
+ }
+ }
+ if (!markerClaimed) {
+ return null
+ }
+
+ await options.onStep?.('stale-marker-claimed')
+
+ // First re-check boundary: confirm the marker is still ours AND
+ // the lock still looks stale (its real owner may have refreshed).
+ let markerPayload = await readMarkerPayload(evictingPath)
+ if (markerPayload?.ownerId !== ownerId) continue
+
+ const observedAgain = await readLockPayload(lockPath)
+ const refreshed =
+ observedAgain !== null &&
+ observedAgain.ownerId !== ownerId &&
+ observedAgain.expiresAt > now()
+ if (refreshed) {
+ await rmEvictingDir(evictingPath)
+ return null
+ }
+
+ await options.onStep?.('stale-lock-confirmed')
+
+ // Second re-check boundary: marker must still be ours before
+ // unlinking. If we lost ownership between the previous check and
+ // this one, the winner is now responsible for the lock file.
+ markerPayload = await readMarkerPayload(evictingPath)
+ if (markerPayload?.ownerId !== ownerId) continue
+
+ try {
+ await unlink(lockPath)
+ } catch (err) {
+ const code = (err as NodeJS.ErrnoException).code
+ if (code !== 'ENOENT') {
+ await rmEvictingDir(evictingPath)
+ continue
+ }
+ }
+
+ await options.onStep?.('eviction-marker-acquired')
+
+ // Re-acquire while still holding the marker as the new lock's
+ // "we evicted this" witness. If another contender grabs between our
+ // unlink and our re-acquire, drop the marker and loop back.
+ const reentry = await tryAcquire(now() + options.ttlMs)
+ if (reentry) {
+ await rmEvictingDir(evictingPath)
+ return reentry
+ }
+ await rmEvictingDir(evictingPath)
+ }
+}
+
+function buildLock(
+ lockPath: string,
+ evictingPath: string,
+ ownerId: string,
+ options: FencedFileLockOptions,
+ now: () => number,
+): FencedFileLock {
+ let renewTimer: ReturnType | null = null
+ let inFlightRenew: Promise | null = null
+ let released = false
+ let lost = false
+ let lostResolve: (() => void) | null = null
+ let lostPromise: Promise | null = new Promise((resolve) => {
+ lostResolve = resolve
+ })
+
+ const markLost = (): void => {
+ if (lost) return
+ lost = true
+ if (renewTimer !== null) {
+ clearInterval(renewTimer)
+ renewTimer = null
+ }
+ const resolve = lostResolve
+ lostResolve = null
+ resolve?.()
+ }
+
+ const setupRenew = (): void => {
+ if (options.renew === false) return
+ const interval =
+ options.renewIntervalMs ??
+ Math.max(RENEW_MIN_INTERVAL_MS, Math.floor(options.ttlMs / 3))
+
+ const renew = async (): Promise => {
+ if (released || lost) return
+ try {
+ const observed = await readLockPayload(lockPath)
+ if (released || lost) return
+ await options.onStep?.('renew-read')
+ if (released || lost) return
+ // If the lock file is gone or carries a different ownerId, the
+ // lock has been taken over (e.g. owner B evicted and replaced
+ // the lock while this renewal was paused). Mark it lost so the
+ // next iteration stops renewing.
+ if (observed === null) {
+ markLost()
+ return
+ }
+ if (observed.ownerId !== ownerId) {
+ markLost()
+ return
+ }
+ if (observed.expiresAt > now()) {
+ if (released || lost) return
+ // Rename cannot compare-and-swap ownership. Re-read after the
+ // rename and mark this lock lost if another owner replaced it;
+ // writers call assertOwned() before mutating shared state, so a
+ // hijack in this seam is detected and stops future renewals.
+ const tempPath = `${lockPath}.${ownerId}.tmp`
+ let shouldCommit = false
+ try {
+ await writeFile(
+ tempPath,
+ JSON.stringify({
+ ownerId,
+ expiresAt: now() + options.ttlMs,
+ }),
+ { encoding: 'utf8', mode: 0o600 },
+ )
+ if (released || lost) {
+ await unlink(tempPath).catch(() => {})
+ return
+ }
+ const currentObserved = await readLockPayload(lockPath)
+ if (released || lost) {
+ await unlink(tempPath).catch(() => {})
+ return
+ }
+ if (!currentObserved || currentObserved.ownerId !== ownerId) {
+ // Another owner slipped in — leave their content intact,
+ // mark lost, drop our temp draft.
+ markLost()
+ await unlink(tempPath).catch(() => {})
+ return
+ }
+ shouldCommit = true
+ await rename(tempPath, lockPath)
+ await options.onStep?.('renew-committed')
+ if (released || lost) return
+ // Verify-after-commit: a replacement owner may have arrived
+ // between the re-read above and the rename — our rename then
+ // just overwrote their fresh lock — or may land a microsecond
+ // after our rename. Re-read and, if the file no longer carries
+ // our ownerId, mark lost and stop renewing so the double-owner
+ // window collapses to this seam; the fresh owner's next
+ // renewal/acquire attempt re-acquires.
+ const committed = await readLockPayload(lockPath)
+ if (released || lost) return
+ if (!committed || committed.ownerId !== ownerId) {
+ markLost()
+ return
+ }
+ } catch {
+ // Lost the race against a rename/evict — mark lost so the
+ // next iteration stops renewing, and clean up any temp draft.
+ markLost()
+ if (!shouldCommit) {
+ await unlink(tempPath).catch(() => {})
+ }
+ }
+ }
+ } catch {
+ // best-effort renewal; nothing to do on failure
+ }
+ }
+
+ // The timer callback is small: it only schedules a renew if one is not
+ // already in flight and the lock has not been released. The renew
+ // Promise itself runs to completion; its in-flight state is tracked
+ // so release() can await it before unlinking.
+ const tick = (): void => {
+ if (released) return
+ if (inFlightRenew) return
+ inFlightRenew = renew().finally(() => {
+ inFlightRenew = null
+ })
+ }
+
+ renewTimer = setInterval(tick, interval)
+ if (
+ renewTimer &&
+ typeof (renewTimer as { unref?: () => void }).unref === 'function'
+ ) {
+ ;(renewTimer as { unref: () => void }).unref()
+ }
+ }
+
+ const release = async (): Promise => {
+ if (released) return
+ released = true
+ if (renewTimer !== null) {
+ clearInterval(renewTimer)
+ renewTimer = null
+ }
+ const pendingRenew = inFlightRenew
+ inFlightRenew = null
+ if (pendingRenew) {
+ try {
+ await pendingRenew
+ } catch {
+ // renewal swallows its own errors; awaiting is just a fence
+ }
+ }
+
+ try {
+ const observed = await readLockPayload(lockPath)
+ if (!observed || observed.ownerId !== ownerId) {
+ // Not ours anymore — refuse to delete.
+ await rmEvictingDir(evictingPath).catch(() => {})
+ return
+ }
+ try {
+ await unlink(lockPath)
+ } catch (err) {
+ if ((err as NodeJS.ErrnoException).code !== 'ENOENT') throw err
+ }
+ await rmEvictingDir(evictingPath).catch(() => {})
+ } finally {
+ markLost()
+ renewTimer = null
+ inFlightRenew = null
+ lostResolve = null
+ lostPromise = null
+ }
+ }
+
+ const assertOwned = async (): Promise => {
+ const observed = await readLockPayload(lockPath)
+ if (!observed || observed.ownerId !== ownerId) {
+ throw new FileLockOwnershipError(
+ `file lock ${lockPath} is not owned by ${ownerId}`,
+ {
+ path: lockPath,
+ expectedOwner: ownerId,
+ observedOwner: observed?.ownerId,
+ observedExpiresAt: observed?.expiresAt,
+ },
+ )
+ }
+ if (observed.expiresAt <= now()) {
+ throw new FileLockOwnershipError(`file lock ${lockPath} has expired`, {
+ path: lockPath,
+ expectedOwner: ownerId,
+ observedOwner: observed.ownerId,
+ observedExpiresAt: observed.expiresAt,
+ })
+ }
+ }
+
+ setupRenew()
+ return {
+ ownerId,
+ assertOwned,
+ release,
+ whenLost: () => lostPromise ?? Promise.resolve(),
+ hasLost: () => lost,
+ }
+}
diff --git a/packages/core/src/fingerprint.test.ts b/packages/core/src/fingerprint.test.ts
new file mode 100644
index 0000000..aeb6dee
--- /dev/null
+++ b/packages/core/src/fingerprint.test.ts
@@ -0,0 +1,134 @@
+import { describe, expect, it } from 'bun:test'
+
+import {
+ buildAntigravityHarnessBootstrapHeaders,
+ buildAntigravityHarnessLoadCodeAssistUserAgent,
+ buildAntigravityHarnessPlatformArch,
+ buildAntigravityHarnessUserAgent,
+ buildAntigravityLoadCodeAssistMetadata,
+ clearSessionFingerprint,
+ generateFingerprint,
+ getSessionFingerprint,
+ regenerateSessionFingerprint,
+ updateFingerprintVersion,
+} from './fingerprint.ts'
+
+describe('Antigravity fingerprint', () => {
+ describe('User-Agent normalization', () => {
+ it('maps darwin/arm64 verbatim', () => {
+ expect(buildAntigravityHarnessPlatformArch('darwin', 'arm64')).toBe(
+ 'darwin/arm64',
+ )
+ })
+
+ it('normalizes win32 → windows and x64 → amd64', () => {
+ expect(buildAntigravityHarnessPlatformArch('win32', 'x64')).toBe(
+ 'windows/amd64',
+ )
+ })
+
+ it('builds the captured agy CLI User-Agent with auth_method=consumer', () => {
+ expect(buildAntigravityHarnessUserAgent('1.1.5', 'darwin', 'arm64')).toBe(
+ 'antigravity/cli/1.1.5 (aidev_client; os_type=darwin; arch=arm64; auth_method=consumer)',
+ )
+ expect(buildAntigravityHarnessUserAgent('1.1.5', 'win32', 'x64')).toBe(
+ 'antigravity/cli/1.1.5 (aidev_client; os_type=windows; arch=amd64; auth_method=consumer)',
+ )
+ })
+
+ it('loadCodeAssist UA is the canonical harness UA', () => {
+ expect(buildAntigravityHarnessLoadCodeAssistUserAgent()).toBe(
+ buildAntigravityHarnessUserAgent(),
+ )
+ })
+ })
+
+ describe('bootstrap headers', () => {
+ it('contains only the captured agy CLI metadata (no X-Goog-Api-Client / Client-Metadata)', () => {
+ const headers = buildAntigravityHarnessBootstrapHeaders('token')
+
+ expect(headers).toEqual({
+ 'User-Agent': expect.stringMatching(
+ /^antigravity\/cli\/1\.1\.5 \(aidev_client; os_type=.+; arch=.+; auth_method=consumer\)$/,
+ ),
+ Authorization: 'Bearer token',
+ 'Content-Type': 'application/json',
+ 'Accept-Encoding': 'gzip',
+ })
+ expect(headers['X-Goog-Api-Client']).toBeUndefined()
+ expect(headers['Client-Metadata']).toBeUndefined()
+ })
+
+ it('loadCodeAssist metadata only carries ideType=ANTIGRAVITY', () => {
+ expect(buildAntigravityLoadCodeAssistMetadata()).toEqual({
+ ideType: 'ANTIGRAVITY',
+ })
+ })
+ })
+
+ describe('generated fingerprint metadata', () => {
+ it('mirrors the captured User-Agent and antigravity-cli identity', () => {
+ const fingerprint = generateFingerprint()
+
+ expect(fingerprint.userAgent).toBe(buildAntigravityHarnessUserAgent())
+ expect(fingerprint.apiClient).toBe('antigravity-cli')
+ expect(fingerprint.clientMetadata).toEqual({
+ ideType: 'ANTIGRAVITY',
+ platform: process.platform === 'win32' ? 'WINDOWS' : 'MACOS',
+ pluginType: 'GEMINI',
+ })
+ expect(fingerprint.deviceId).toBeTruthy()
+ expect(fingerprint.sessionToken).toBeTruthy()
+ expect(typeof fingerprint.createdAt).toBe('number')
+ })
+ })
+
+ describe('old-fingerprint migration', () => {
+ it('rewrites legacy randomized / pre-1.1.3 fingerprints to the captured UA', () => {
+ const fingerprint = {
+ deviceId: 'device',
+ sessionToken: 'session',
+ userAgent: 'antigravity/1.18.3 win32/x64',
+ apiClient: 'google-cloud-sdk vscode/1.96.0',
+ clientMetadata: {
+ ideType: 'ANTIGRAVITY',
+ platform: 'WINDOWS',
+ pluginType: 'GEMINI',
+ },
+ createdAt: 0,
+ }
+
+ expect(updateFingerprintVersion(fingerprint)).toBe(true)
+ expect(fingerprint.userAgent).toBe(buildAntigravityHarnessUserAgent())
+ })
+
+ it('returns false and leaves the User-Agent unchanged when already up to date', () => {
+ const fingerprint = generateFingerprint()
+ expect(updateFingerprintVersion(fingerprint)).toBe(false)
+ })
+ })
+
+ describe('session fingerprint lifecycle', () => {
+ it('returns the same instance until regenerate or clear is called', () => {
+ const first = getSessionFingerprint()
+ const second = getSessionFingerprint()
+ expect(first).toBe(second)
+ })
+
+ it('regenerate produces a fresh non-empty instance distinct from the previous one', () => {
+ const original = getSessionFingerprint()
+ const next = regenerateSessionFingerprint()
+ expect(next).not.toBe(original)
+ expect(next.deviceId).toBeTruthy()
+ expect(next.sessionToken).toBeTruthy()
+ expect(next.userAgent).toBe(buildAntigravityHarnessUserAgent())
+ })
+
+ it('clearSessionFingerprint forces the next call to mint a new instance', () => {
+ const original = getSessionFingerprint()
+ clearSessionFingerprint()
+ const fresh = getSessionFingerprint()
+ expect(fresh).not.toBe(original)
+ })
+ })
+})
diff --git a/packages/core/src/fingerprint.ts b/packages/core/src/fingerprint.ts
index 8f4b64c..18b5afe 100644
--- a/packages/core/src/fingerprint.ts
+++ b/packages/core/src/fingerprint.ts
@@ -7,54 +7,54 @@
* retained for account history, but content requests only send User-Agent.
*/
-import * as crypto from "node:crypto";
+import * as crypto from 'node:crypto'
-export const AGY_CLI_VERSION = "1.1.5";
-const ANTIGRAVITY_API_CLIENT = "antigravity-cli";
+export const AGY_CLI_VERSION = '1.1.5'
+const ANTIGRAVITY_API_CLIENT = 'antigravity-cli'
export interface ClientMetadata {
- ideType: string;
- platform: string;
- pluginType: string;
+ ideType: string
+ platform: string
+ pluginType: string
}
export interface Fingerprint {
- deviceId: string;
- sessionToken: string;
- userAgent: string;
- apiClient: string;
- clientMetadata: ClientMetadata;
- createdAt: number;
+ deviceId: string
+ sessionToken: string
+ userAgent: string
+ apiClient: string
+ clientMetadata: ClientMetadata
+ createdAt: number
}
/**
* Fingerprint version for history tracking.
* Stores a snapshot of a fingerprint with metadata about when/why it was saved.
*/
export interface FingerprintVersion {
- fingerprint: Fingerprint;
- timestamp: number;
- reason: 'initial' | 'regenerated' | 'restored';
+ fingerprint: Fingerprint
+ timestamp: number
+ reason: 'initial' | 'regenerated' | 'restored'
}
/** Maximum number of fingerprint versions to keep per account */
-export const MAX_FINGERPRINT_HISTORY = 5;
+export const MAX_FINGERPRINT_HISTORY = 5
export interface FingerprintHeaders {
- "User-Agent": string;
+ 'User-Agent': string
}
function normalizeHarnessPlatform(platform = process.platform): string {
- return platform === "win32" ? "windows" : platform || "unknown";
+ return platform === 'win32' ? 'windows' : platform || 'unknown'
}
function normalizeHarnessArch(arch = process.arch): string {
switch (arch) {
- case "x64":
- return "amd64";
- case "ia32":
- return "386";
+ case 'x64':
+ return 'amd64'
+ case 'ia32':
+ return '386'
default:
- return arch || "unknown";
+ return arch || 'unknown'
}
}
@@ -62,47 +62,56 @@ export function buildAntigravityHarnessPlatformArch(
platform = process.platform,
arch = process.arch,
): string {
- return `${normalizeHarnessPlatform(platform)}/${normalizeHarnessArch(arch)}`;
+ return `${normalizeHarnessPlatform(platform)}/${normalizeHarnessArch(arch)}`
}
export function buildAntigravityHarnessUserAgent(
version = AGY_CLI_VERSION,
platform = process.platform,
arch = process.arch,
- authMethod = "consumer",
+ authMethod = 'consumer',
): string {
- const osType = normalizeHarnessPlatform(platform);
- const normalizedArch = normalizeHarnessArch(arch);
- return `antigravity/cli/${version} (aidev_client; os_type=${osType}; arch=${normalizedArch}; auth_method=${authMethod})`;
+ const osType = normalizeHarnessPlatform(platform)
+ const normalizedArch = normalizeHarnessArch(arch)
+ return `antigravity/cli/${version} (aidev_client; os_type=${osType}; arch=${normalizedArch}; auth_method=${authMethod})`
}
-export function buildAntigravityHarnessLoadCodeAssistUserAgent(version = AGY_CLI_VERSION): string {
- return buildAntigravityHarnessUserAgent(version);
+export function buildAntigravityHarnessLoadCodeAssistUserAgent(
+ version = AGY_CLI_VERSION,
+): string {
+ return buildAntigravityHarnessUserAgent(version)
}
-function platformToMetadataPlatform(platform: string = process.platform): "WINDOWS" | "MACOS" {
- return platform === "win32" ? "WINDOWS" : "MACOS";
+function platformToMetadataPlatform(
+ platform: string = process.platform,
+): 'WINDOWS' | 'MACOS' {
+ return platform === 'win32' ? 'WINDOWS' : 'MACOS'
}
-export function buildAntigravityLoadCodeAssistMetadata(): Record {
- return { ideType: "ANTIGRAVITY" };
+export function buildAntigravityLoadCodeAssistMetadata(): Record<
+ string,
+ string
+> {
+ return { ideType: 'ANTIGRAVITY' }
}
-export function buildAntigravityHarnessBootstrapHeaders(accessToken: string): Record {
+export function buildAntigravityHarnessBootstrapHeaders(
+ accessToken: string,
+): Record {
return {
- "User-Agent": buildAntigravityHarnessLoadCodeAssistUserAgent(),
+ 'User-Agent': buildAntigravityHarnessLoadCodeAssistUserAgent(),
Authorization: `Bearer ${accessToken}`,
- "Content-Type": "application/json",
- "Accept-Encoding": "gzip",
- };
+ 'Content-Type': 'application/json',
+ 'Accept-Encoding': 'gzip',
+ }
}
function generateDeviceId(): string {
- return crypto.randomUUID();
+ return crypto.randomUUID()
}
function generateSessionToken(): string {
- return crypto.randomBytes(16).toString("hex");
+ return crypto.randomBytes(16).toString('hex')
}
/**
@@ -116,19 +125,19 @@ export function generateFingerprint(): Fingerprint {
userAgent: buildAntigravityHarnessUserAgent(),
apiClient: ANTIGRAVITY_API_CLIENT,
clientMetadata: {
- ideType: "ANTIGRAVITY",
+ ideType: 'ANTIGRAVITY',
platform: platformToMetadataPlatform(),
- pluginType: "GEMINI",
+ pluginType: 'GEMINI',
},
createdAt: Date.now(),
- };
+ }
}
/**
* Collect the current content-request fingerprint.
*/
export function collectCurrentFingerprint(): Fingerprint {
- return generateFingerprint();
+ return generateFingerprint()
}
/**
@@ -138,34 +147,36 @@ export function collectCurrentFingerprint(): Fingerprint {
* Returns true if the User-Agent was changed.
*/
export function updateFingerprintVersion(fingerprint: Fingerprint): boolean {
- const userAgent = buildAntigravityHarnessUserAgent();
+ const userAgent = buildAntigravityHarnessUserAgent()
if (fingerprint.userAgent === userAgent) {
- return false;
+ return false
}
- fingerprint.userAgent = userAgent;
- return true;
+ fingerprint.userAgent = userAgent
+ return true
}
/**
* Build HTTP headers from a fingerprint object.
* These headers are used to identify the "device" making API requests.
*/
-export function buildFingerprintHeaders(fingerprint: Fingerprint | null): Partial {
+export function buildFingerprintHeaders(
+ fingerprint: Fingerprint | null,
+): Partial {
if (!fingerprint) {
- return {};
+ return {}
}
return {
- "User-Agent": fingerprint.userAgent,
- };
+ 'User-Agent': fingerprint.userAgent,
+ }
}
/**
* Session-level fingerprint instance.
* Generated once at module load, persists for the lifetime of the process.
*/
-let sessionFingerprint: Fingerprint | null = null;
+let sessionFingerprint: Fingerprint | null = null
/**
* Get or create the session fingerprint.
@@ -173,9 +184,9 @@ let sessionFingerprint: Fingerprint | null = null;
*/
export function getSessionFingerprint(): Fingerprint {
if (!sessionFingerprint) {
- sessionFingerprint = generateFingerprint();
+ sessionFingerprint = generateFingerprint()
}
- return sessionFingerprint;
+ return sessionFingerprint
}
/**
@@ -183,6 +194,15 @@ export function getSessionFingerprint(): Fingerprint {
* Call this to get a fresh identity (e.g., after rate limiting).
*/
export function regenerateSessionFingerprint(): Fingerprint {
- sessionFingerprint = generateFingerprint();
- return sessionFingerprint;
+ sessionFingerprint = generateFingerprint()
+ return sessionFingerprint
+}
+
+/**
+ * Clear the cached session fingerprint so the next `getSessionFingerprint`
+ * call generates a fresh one. Test-only escape hatch — production code should
+ * use `regenerateSessionFingerprint` to also return the new value.
+ */
+export function clearSessionFingerprint(): void {
+ sessionFingerprint = null
}
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 9793ba5..27e35f5 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -1,15 +1,30 @@
// @cortexkit/antigravity-auth-core
// Harness-agnostic core surface. Modules are migrated here in stages.
-export * from "./logger.ts"
-export * from "./constants.ts"
-export * from "./auth-types.ts"
-export * from "./auth.ts"
-export * from "./fingerprint.ts"
-export * from "./agy-transport.ts"
-export * from "./agy-request-metadata.ts"
-export * from "./version.ts"
-export * from "./project.ts"
-export * from "./model-types.ts"
-export * from "./model-registry.ts"
-export * from "./transform/index.ts"
-export * from "./antigravity/oauth.ts"
+
+export type {
+ AccountManagerOptions,
+ AccountSessionIdentity,
+ ManagedAccount,
+} from './account-manager.ts'
+export { AccountManager, resolveQuotaGroup } from './account-manager.ts'
+export * from './account-storage.ts'
+export * from './account-types.ts'
+export * from './agy-request-metadata.ts'
+export * from './agy-transport.ts'
+export * from './antigravity/oauth.ts'
+export * from './atomic-write.ts'
+export * from './auth.ts'
+export * from './auth-types.ts'
+export * from './constants.ts'
+export * from './fetch-timeout.ts'
+export * from './file-lock.ts'
+export * from './fingerprint.ts'
+export * from './logger.ts'
+export * from './model-registry.ts'
+export * from './model-types.ts'
+export * from './project.ts'
+export * from './quota-manager.ts'
+export * from './quota-types.ts'
+export * from './rotation.ts'
+export * from './transform/index.ts'
+export * from './version.ts'
diff --git a/packages/core/src/logger.ts b/packages/core/src/logger.ts
index bb65517..4a420f2 100644
--- a/packages/core/src/logger.ts
+++ b/packages/core/src/logger.ts
@@ -7,7 +7,7 @@
* sink is registered, an env-gated console fallback is used.
*/
-export type LogLevel = "debug" | "info" | "warn" | "error"
+export type LogLevel = 'debug' | 'info' | 'warn' | 'error'
export interface Logger {
debug(message: string, extra?: Record): void
@@ -25,7 +25,7 @@ export interface LogRecord {
export type LogSink = (record: LogRecord) => void
-const ENV_CONSOLE_LOG = "ANTIGRAVITY_CORE_CONSOLE_LOG"
+const ENV_CONSOLE_LOG = 'ANTIGRAVITY_CORE_CONSOLE_LOG'
let _sink: LogSink | null = null
@@ -37,7 +37,7 @@ export function setLogSink(sink: LogSink | null): void {
}
function isTruthyFlag(flag?: string): boolean {
- return flag === "1" || flag?.toLowerCase() === "true"
+ return flag === '1' || flag?.toLowerCase() === 'true'
}
function isConsoleLogEnabled(): boolean {
@@ -46,16 +46,16 @@ function isConsoleLogEnabled(): boolean {
function writeConsoleLog(level: LogLevel, ...args: unknown[]): void {
switch (level) {
- case "debug":
+ case 'debug':
console.debug(...args)
break
- case "info":
+ case 'info':
console.info(...args)
break
- case "warn":
+ case 'warn':
console.warn(...args)
break
- case "error":
+ case 'error':
console.error(...args)
break
}
@@ -68,7 +68,11 @@ function writeConsoleLog(level: LogLevel, ...args: unknown[]): void {
export function createLogger(module: string): Logger {
const service = `antigravity.${module}`
- const log = (level: LogLevel, message: string, extra?: Record): void => {
+ const log = (
+ level: LogLevel,
+ message: string,
+ extra?: Record,
+ ): void => {
if (_sink) {
try {
_sink({ service, level, message, extra })
@@ -85,9 +89,9 @@ export function createLogger(module: string): Logger {
}
return {
- debug: (message, extra) => log("debug", message, extra),
- info: (message, extra) => log("info", message, extra),
- warn: (message, extra) => log("warn", message, extra),
- error: (message, extra) => log("error", message, extra),
+ debug: (message, extra) => log('debug', message, extra),
+ info: (message, extra) => log('info', message, extra),
+ warn: (message, extra) => log('warn', message, extra),
+ error: (message, extra) => log('error', message, extra),
}
}
diff --git a/packages/core/src/model-registry.test.ts b/packages/core/src/model-registry.test.ts
index 90d9e9d..aa0482e 100644
--- a/packages/core/src/model-registry.test.ts
+++ b/packages/core/src/model-registry.test.ts
@@ -1,4 +1,4 @@
-import { describe, expect, it } from "vitest"
+import { describe, expect, it } from 'bun:test'
import {
getGemini35FlashAntigravityModel,
@@ -6,34 +6,34 @@ import {
getGemini36FlashAntigravityModel,
getPublicModelDefinitions,
getResolverAliasMap,
-} from "./model-registry.ts"
+} from './model-registry.ts'
const REQUIRED_PUBLIC_MODEL_FIELDS = [
- "id",
- "name",
- "release_date",
- "attachment",
- "reasoning",
- "temperature",
- "tool_call",
- "limit",
- "cost",
- "options",
+ 'id',
+ 'name',
+ 'release_date',
+ 'attachment',
+ 'reasoning',
+ 'temperature',
+ 'tool_call',
+ 'limit',
+ 'cost',
+ 'options',
] as const
-describe("model registry", () => {
- it("is the source of truth for the current public OpenCode model catalog", () => {
+describe('model registry', () => {
+ it('is the source of truth for the current public OpenCode model catalog', () => {
const definitions = getPublicModelDefinitions()
const modelNames = Object.keys(definitions).sort()
expect(modelNames).toEqual([
- "antigravity-claude-opus-4-6-thinking",
- "antigravity-claude-sonnet-4-6-thinking",
- "antigravity-gemini-3.1-flash-image",
- "antigravity-gemini-3.1-pro",
- "antigravity-gemini-3.5-flash",
- "antigravity-gemini-3.6-flash",
- "antigravity-gpt-oss-120b-medium",
+ 'antigravity-claude-opus-4-6-thinking',
+ 'antigravity-claude-sonnet-4-6-thinking',
+ 'antigravity-gemini-3.1-flash-image',
+ 'antigravity-gemini-3.1-pro',
+ 'antigravity-gemini-3.5-flash',
+ 'antigravity-gemini-3.6-flash',
+ 'antigravity-gpt-oss-120b-medium',
])
for (const definition of Object.values(definitions)) {
@@ -43,44 +43,64 @@ describe("model registry", () => {
}
})
- it("preserves live Gemini 3.5 Flash route mappings", () => {
- expect(getGemini35FlashAntigravityModel()).toBe("gemini-3-flash-agent")
- expect(getGemini35FlashAntigravityModel("high")).toBe("gemini-3-flash-agent")
- expect(getGemini35FlashAntigravityModel("medium")).toBe("gemini-3.5-flash-low")
- expect(getGemini35FlashAntigravityModel("low")).toBe("gemini-3.5-flash-extra-low")
- expect(getGemini35FlashGeminiCliFallbackModel()).toBe("gemini-3-flash-preview")
+ it('preserves live Gemini 3.5 Flash route mappings', () => {
+ expect(getGemini35FlashAntigravityModel()).toBe('gemini-3-flash-agent')
+ expect(getGemini35FlashAntigravityModel('high')).toBe(
+ 'gemini-3-flash-agent',
+ )
+ expect(getGemini35FlashAntigravityModel('medium')).toBe(
+ 'gemini-3.5-flash-low',
+ )
+ expect(getGemini35FlashAntigravityModel('low')).toBe(
+ 'gemini-3.5-flash-extra-low',
+ )
+ expect(getGemini35FlashGeminiCliFallbackModel()).toBe(
+ 'gemini-3-flash-preview',
+ )
})
- it("preserves live Gemini 3.6 Flash route mappings", () => {
- expect(getGemini36FlashAntigravityModel()).toBe("gemini-3.6-flash-medium")
- expect(getGemini36FlashAntigravityModel("high")).toBe("gemini-3.6-flash-high")
- expect(getGemini36FlashAntigravityModel("medium")).toBe("gemini-3.6-flash-medium")
- expect(getGemini36FlashAntigravityModel("low")).toBe("gemini-3.6-flash-low")
+ it('preserves live Gemini 3.6 Flash route mappings', () => {
+ expect(getGemini36FlashAntigravityModel()).toBe('gemini-3.6-flash-medium')
+ expect(getGemini36FlashAntigravityModel('high')).toBe(
+ 'gemini-3.6-flash-high',
+ )
+ expect(getGemini36FlashAntigravityModel('medium')).toBe(
+ 'gemini-3.6-flash-medium',
+ )
+ expect(getGemini36FlashAntigravityModel('low')).toBe('gemini-3.6-flash-low')
})
- it("keeps resolver aliases for supported agy CLI variants", () => {
+ it('keeps resolver aliases for supported agy CLI variants', () => {
const aliases = getResolverAliasMap()
- expect(aliases["gemini-3.5-flash-medium"]).toBe("gemini-3.5-flash")
- expect(aliases["gemini-3.6-flash-medium"]).toBe("gemini-3.6-flash")
- expect(aliases["gemini-claude-opus-4-6-thinking-medium"]).toBe("claude-opus-4-6-thinking")
- expect(aliases["gemini-claude-sonnet-4-6-thinking-high"]).toBe("claude-sonnet-4-6")
- expect(aliases["gpt-oss-120b"]).toBe("gpt-oss-120b-medium")
+ expect(aliases['gemini-3.5-flash-medium']).toBe('gemini-3.5-flash')
+ expect(aliases['gemini-3.6-flash-medium']).toBe('gemini-3.6-flash')
+ expect(aliases['gemini-claude-opus-4-6-thinking-medium']).toBe(
+ 'claude-opus-4-6-thinking',
+ )
+ expect(aliases['gemini-claude-sonnet-4-6-thinking-high']).toBe(
+ 'claude-sonnet-4-6',
+ )
+ expect(aliases['gpt-oss-120b']).toBe('gpt-oss-120b-medium')
})
- it("matches the live GPT-OSS capability metadata", () => {
- expect(getPublicModelDefinitions()["antigravity-gpt-oss-120b-medium"]).toMatchObject({
+ it('matches the live GPT-OSS capability metadata', () => {
+ expect(
+ getPublicModelDefinitions()['antigravity-gpt-oss-120b-medium'],
+ ).toMatchObject({
reasoning: true,
limit: { context: 131072, output: 32768 },
})
})
- it("advertises image output only on the image route", () => {
- expect(getPublicModelDefinitions()["antigravity-gemini-3.1-flash-image"]).toMatchObject({
+ it('advertises image output only on the image route', () => {
+ expect(
+ getPublicModelDefinitions()['antigravity-gemini-3.1-flash-image'],
+ ).toMatchObject({
reasoning: false,
modalities: {
- input: ["text", "image"],
- output: ["text", "image"],
+ input: ['text', 'image'],
+ output: ['text', 'image'],
},
})
})
diff --git a/packages/core/src/model-registry.ts b/packages/core/src/model-registry.ts
index 0387e94..b712fbb 100644
--- a/packages/core/src/model-registry.ts
+++ b/packages/core/src/model-registry.ts
@@ -1,7 +1,7 @@
-import type { ProviderModel } from "./model-types.ts"
-import type { ThinkingTier } from "./transform/types.ts"
+import type { ProviderModel } from './model-types.ts'
+import type { ThinkingTier } from './transform/types.ts'
-export type ModelThinkingLevel = "minimal" | "low" | "medium" | "high"
+export type ModelThinkingLevel = 'minimal' | 'low' | 'medium' | 'high'
export interface ModelThinkingConfig {
thinkingBudget: number
@@ -18,8 +18,12 @@ export interface ModelLimit {
output: number
}
-export type ModelModality = "text" | "image" | "pdf"
-export type ModelQuotaGroup = "claude" | "gemini-pro" | "gemini-flash" | "gpt-oss"
+export type ModelModality = 'text' | 'image' | 'pdf'
+export type ModelQuotaGroup =
+ | 'claude'
+ | 'gemini-pro'
+ | 'gemini-flash'
+ | 'gpt-oss'
export interface ModelModalities {
input: ModelModality[]
@@ -65,11 +69,11 @@ interface GeminiFlashRouteMetadata {
}
const DEFAULT_MODALITIES: ModelModalities = {
- input: ["text", "image", "pdf"],
- output: ["text"],
+ input: ['text', 'image', 'pdf'],
+ output: ['text'],
}
-const MODEL_RELEASE_DATE = ""
+const MODEL_RELEASE_DATE = ''
const DEFAULT_COST = { input: 0, output: 0 }
const DEFAULT_OPTIONS: Record = {}
@@ -80,7 +84,7 @@ function defineModel(
return {
id,
release_date: MODEL_RELEASE_DATE,
- attachment: model.modalities.input.some((modality) => modality !== "text"),
+ attachment: model.modalities.input.some((modality) => modality !== 'text'),
temperature: true,
tool_call: true,
cost: { ...DEFAULT_COST },
@@ -90,211 +94,233 @@ function defineModel(
}
const ALL_MODEL_DEFINITIONS: OpencodeModelDefinitions = {
- "antigravity-gemini-3.1-pro": defineModel("antigravity-gemini-3.1-pro", {
- name: "Gemini 3.1 Pro (Antigravity)",
+ 'antigravity-gemini-3.1-pro': defineModel('antigravity-gemini-3.1-pro', {
+ name: 'Gemini 3.1 Pro (Antigravity)',
reasoning: true,
limit: { context: 1048576, output: 65535 },
modalities: DEFAULT_MODALITIES,
variants: {
- low: { thinkingLevel: "low" },
- high: { thinkingLevel: "high" },
+ low: { thinkingLevel: 'low' },
+ high: { thinkingLevel: 'high' },
},
}),
- "antigravity-gemini-3.6-flash": defineModel("antigravity-gemini-3.6-flash", {
- name: "Gemini 3.6 Flash (Antigravity)",
+ 'antigravity-gemini-3.6-flash': defineModel('antigravity-gemini-3.6-flash', {
+ name: 'Gemini 3.6 Flash (Antigravity)',
reasoning: true,
limit: { context: 1048576, output: 65536 },
modalities: DEFAULT_MODALITIES,
variants: {
- low: { thinkingLevel: "low" },
- high: { thinkingLevel: "high" },
+ low: { thinkingLevel: 'low' },
+ high: { thinkingLevel: 'high' },
},
}),
- "antigravity-gemini-3.5-flash": defineModel("antigravity-gemini-3.5-flash", {
- name: "Gemini 3.5 Flash (Antigravity)",
+ 'antigravity-gemini-3.5-flash': defineModel('antigravity-gemini-3.5-flash', {
+ name: 'Gemini 3.5 Flash (Antigravity)',
reasoning: true,
limit: { context: 1048576, output: 65536 },
modalities: DEFAULT_MODALITIES,
variants: {
- low: { thinkingLevel: "low" },
- high: { thinkingLevel: "high" },
+ low: { thinkingLevel: 'low' },
+ high: { thinkingLevel: 'high' },
},
}),
- "antigravity-claude-sonnet-4-6-thinking": defineModel("antigravity-claude-sonnet-4-6-thinking", {
- name: "Claude Sonnet 4.6 Thinking (Antigravity)",
- reasoning: true,
- limit: { context: 250000, output: 64000 },
- modalities: DEFAULT_MODALITIES,
- variants: {
- low: { disabled: true },
- high: { disabled: true },
+ 'antigravity-claude-sonnet-4-6-thinking': defineModel(
+ 'antigravity-claude-sonnet-4-6-thinking',
+ {
+ name: 'Claude Sonnet 4.6 Thinking (Antigravity)',
+ reasoning: true,
+ limit: { context: 250000, output: 64000 },
+ modalities: DEFAULT_MODALITIES,
+ variants: {
+ low: { disabled: true },
+ high: { disabled: true },
+ },
},
- }),
- "antigravity-claude-opus-4-6-thinking": defineModel("antigravity-claude-opus-4-6-thinking", {
- name: "Claude Opus 4.6 Thinking (Antigravity)",
- reasoning: true,
- limit: { context: 250000, output: 64000 },
- modalities: DEFAULT_MODALITIES,
- variants: {
- low: { disabled: true },
- high: { disabled: true },
+ ),
+ 'antigravity-claude-opus-4-6-thinking': defineModel(
+ 'antigravity-claude-opus-4-6-thinking',
+ {
+ name: 'Claude Opus 4.6 Thinking (Antigravity)',
+ reasoning: true,
+ limit: { context: 250000, output: 64000 },
+ modalities: DEFAULT_MODALITIES,
+ variants: {
+ low: { disabled: true },
+ high: { disabled: true },
+ },
},
- }),
- "antigravity-gemini-3.1-flash-image": defineModel("antigravity-gemini-3.1-flash-image", {
- name: "Gemini 3.1 Flash Image (Antigravity)",
- reasoning: false,
- limit: { context: 66000, output: 33000 },
- modalities: {
- input: ["text", "image"],
- output: ["text", "image"],
+ ),
+ 'antigravity-gemini-3.1-flash-image': defineModel(
+ 'antigravity-gemini-3.1-flash-image',
+ {
+ name: 'Gemini 3.1 Flash Image (Antigravity)',
+ reasoning: false,
+ limit: { context: 66000, output: 33000 },
+ modalities: {
+ input: ['text', 'image'],
+ output: ['text', 'image'],
+ },
},
- }),
- "antigravity-gpt-oss-120b-medium": defineModel("antigravity-gpt-oss-120b-medium", {
- name: "GPT-OSS 120B Medium (Antigravity)",
- reasoning: true,
- limit: { context: 131072, output: 32768 },
- modalities: DEFAULT_MODALITIES,
- }),
- "gemini-2.5-flash": defineModel("gemini-2.5-flash", {
- name: "Gemini 2.5 Flash (Gemini CLI)",
+ ),
+ 'antigravity-gpt-oss-120b-medium': defineModel(
+ 'antigravity-gpt-oss-120b-medium',
+ {
+ name: 'GPT-OSS 120B Medium (Antigravity)',
+ reasoning: true,
+ limit: { context: 131072, output: 32768 },
+ modalities: DEFAULT_MODALITIES,
+ },
+ ),
+ 'gemini-2.5-flash': defineModel('gemini-2.5-flash', {
+ name: 'Gemini 2.5 Flash (Gemini CLI)',
reasoning: true,
limit: { context: 1048576, output: 65536 },
modalities: DEFAULT_MODALITIES,
}),
- "gemini-2.5-pro": defineModel("gemini-2.5-pro", {
- name: "Gemini 2.5 Pro (Gemini CLI)",
+ 'gemini-2.5-pro': defineModel('gemini-2.5-pro', {
+ name: 'Gemini 2.5 Pro (Gemini CLI)',
reasoning: true,
limit: { context: 1048576, output: 65536 },
modalities: DEFAULT_MODALITIES,
}),
- "gemini-3-flash-preview": defineModel("gemini-3-flash-preview", {
- name: "Gemini 3 Flash Preview (Gemini CLI)",
+ 'gemini-3-flash-preview': defineModel('gemini-3-flash-preview', {
+ name: 'Gemini 3 Flash Preview (Gemini CLI)',
reasoning: true,
limit: { context: 1048576, output: 65536 },
modalities: DEFAULT_MODALITIES,
}),
- "gemini-3.1-pro-preview": defineModel("gemini-3.1-pro-preview", {
- name: "Gemini 3.1 Pro Preview (Gemini CLI)",
+ 'gemini-3.1-pro-preview': defineModel('gemini-3.1-pro-preview', {
+ name: 'Gemini 3.1 Pro Preview (Gemini CLI)',
reasoning: true,
limit: { context: 1048576, output: 65535 },
modalities: DEFAULT_MODALITIES,
}),
- "gemini-3.5-flash-preview": defineModel("gemini-3.5-flash-preview", {
- name: "Gemini 3.5 Flash Preview (Gemini CLI)",
+ 'gemini-3.5-flash-preview': defineModel('gemini-3.5-flash-preview', {
+ name: 'Gemini 3.5 Flash Preview (Gemini CLI)',
reasoning: true,
limit: { context: 1048576, output: 65536 },
modalities: DEFAULT_MODALITIES,
}),
- "gemini-3.1-flash-image": defineModel("gemini-3.1-flash-image", {
- name: "Gemini 3.1 Flash Image (Gemini CLI)",
+ 'gemini-3.1-flash-image': defineModel('gemini-3.1-flash-image', {
+ name: 'Gemini 3.1 Flash Image (Gemini CLI)',
reasoning: false,
limit: { context: 66000, output: 33000 },
modalities: {
- input: ["text", "image"],
- output: ["text", "image"],
+ input: ['text', 'image'],
+ output: ['text', 'image'],
},
}),
- "gemini-3.1-flash-image-preview": defineModel("gemini-3.1-flash-image-preview", {
- name: "Gemini 3.1 Flash Image Preview (Gemini CLI)",
- reasoning: false,
- limit: { context: 66000, output: 33000 },
- modalities: {
- input: ["text", "image"],
- output: ["text", "image"],
+ 'gemini-3.1-flash-image-preview': defineModel(
+ 'gemini-3.1-flash-image-preview',
+ {
+ name: 'Gemini 3.1 Flash Image Preview (Gemini CLI)',
+ reasoning: false,
+ limit: { context: 66000, output: 33000 },
+ modalities: {
+ input: ['text', 'image'],
+ output: ['text', 'image'],
+ },
},
- }),
- "gemini-3.1-pro-preview-customtools": defineModel("gemini-3.1-pro-preview-customtools", {
- name: "Gemini 3.1 Pro Preview Custom Tools (Gemini CLI)",
- reasoning: true,
- limit: { context: 1048576, output: 65535 },
- modalities: DEFAULT_MODALITIES,
- }),
+ ),
+ 'gemini-3.1-pro-preview-customtools': defineModel(
+ 'gemini-3.1-pro-preview-customtools',
+ {
+ name: 'Gemini 3.1 Pro Preview Custom Tools (Gemini CLI)',
+ reasoning: true,
+ limit: { context: 1048576, output: 65535 },
+ modalities: DEFAULT_MODALITIES,
+ },
+ ),
}
const RESOLVER_ALIASES: Record = {
- "gemini-3.1-pro-low": "gemini-3.1-pro",
- "gemini-3.1-pro-high": "gemini-3.1-pro",
- "gemini-3-flash-low": "gemini-3-flash",
- "gemini-3-flash-medium": "gemini-3-flash",
- "gemini-3-flash-high": "gemini-3-flash",
- "gemini-3.5-flash-low": "gemini-3.5-flash",
- "gemini-3.5-flash-medium": "gemini-3.5-flash",
- "gemini-3.5-flash-high": "gemini-3.5-flash",
- "gemini-3.6-flash-low": "gemini-3.6-flash",
- "gemini-3.6-flash-medium": "gemini-3.6-flash",
- "gemini-3.6-flash-high": "gemini-3.6-flash",
- "gemini-claude-opus-4-6-thinking-low": "claude-opus-4-6-thinking",
- "gemini-claude-opus-4-6-thinking-medium": "claude-opus-4-6-thinking",
- "gemini-claude-opus-4-6-thinking-high": "claude-opus-4-6-thinking",
- "gemini-claude-sonnet-4-6-thinking-low": "claude-sonnet-4-6",
- "gemini-claude-sonnet-4-6-thinking-medium": "claude-sonnet-4-6",
- "gemini-claude-sonnet-4-6-thinking-high": "claude-sonnet-4-6",
- "gemini-claude-sonnet-4-6": "claude-sonnet-4-6",
- "claude-sonnet-4-6-thinking": "claude-sonnet-4-6",
- "claude-sonnet-4-6-thinking-low": "claude-sonnet-4-6",
- "claude-sonnet-4-6-thinking-medium": "claude-sonnet-4-6",
- "claude-sonnet-4-6-thinking-high": "claude-sonnet-4-6",
- "gpt-oss-120b": "gpt-oss-120b-medium",
+ 'gemini-3.1-pro-low': 'gemini-3.1-pro',
+ 'gemini-3.1-pro-high': 'gemini-3.1-pro',
+ 'gemini-3-flash-low': 'gemini-3-flash',
+ 'gemini-3-flash-medium': 'gemini-3-flash',
+ 'gemini-3-flash-high': 'gemini-3-flash',
+ 'gemini-3.5-flash-low': 'gemini-3.5-flash',
+ 'gemini-3.5-flash-medium': 'gemini-3.5-flash',
+ 'gemini-3.5-flash-high': 'gemini-3.5-flash',
+ 'gemini-3.6-flash-low': 'gemini-3.6-flash',
+ 'gemini-3.6-flash-medium': 'gemini-3.6-flash',
+ 'gemini-3.6-flash-high': 'gemini-3.6-flash',
+ 'gemini-claude-opus-4-6-thinking-low': 'claude-opus-4-6-thinking',
+ 'gemini-claude-opus-4-6-thinking-medium': 'claude-opus-4-6-thinking',
+ 'gemini-claude-opus-4-6-thinking-high': 'claude-opus-4-6-thinking',
+ 'gemini-claude-sonnet-4-6-thinking-low': 'claude-sonnet-4-6',
+ 'gemini-claude-sonnet-4-6-thinking-medium': 'claude-sonnet-4-6',
+ 'gemini-claude-sonnet-4-6-thinking-high': 'claude-sonnet-4-6',
+ 'gemini-claude-sonnet-4-6': 'claude-sonnet-4-6',
+ 'claude-sonnet-4-6-thinking': 'claude-sonnet-4-6',
+ 'claude-sonnet-4-6-thinking-low': 'claude-sonnet-4-6',
+ 'claude-sonnet-4-6-thinking-medium': 'claude-sonnet-4-6',
+ 'claude-sonnet-4-6-thinking-high': 'claude-sonnet-4-6',
+ 'gpt-oss-120b': 'gpt-oss-120b-medium',
}
const GEMINI_35_FLASH_ROUTES: GeminiFlashRouteMetadata = {
antigravity: {
- defaultModel: "gemini-3-flash-agent",
+ defaultModel: 'gemini-3-flash-agent',
byTier: {
- low: "gemini-3.5-flash-extra-low",
- medium: "gemini-3.5-flash-low",
- high: "gemini-3-flash-agent",
+ low: 'gemini-3.5-flash-extra-low',
+ medium: 'gemini-3.5-flash-low',
+ high: 'gemini-3-flash-agent',
},
},
- geminiCliFallbackModel: "gemini-3-flash-preview",
+ geminiCliFallbackModel: 'gemini-3-flash-preview',
}
const GEMINI_36_FLASH_ROUTES: AntigravityTieredRouteMetadata = {
- defaultModel: "gemini-3.6-flash-medium",
+ defaultModel: 'gemini-3.6-flash-medium',
byTier: {
- low: "gemini-3.6-flash-low",
- medium: "gemini-3.6-flash-medium",
- high: "gemini-3.6-flash-high",
+ low: 'gemini-3.6-flash-low',
+ medium: 'gemini-3.6-flash-medium',
+ high: 'gemini-3.6-flash-high',
},
}
const QUOTA_GROUP_BY_MODEL_ID: Record = {
- "claude-opus-4-6-thinking": "claude",
- "claude-opus-4-6": "claude",
- "claude-sonnet-4-6-thinking": "claude",
- "claude-sonnet-4-6": "claude",
- "gemini-pro-agent": "gemini-pro",
- "gemini-3.1-pro": "gemini-pro",
- "gemini-3.1-pro-low": "gemini-pro",
- "gemini-3.1-pro-high": "gemini-pro",
- "gemini-3-flash": "gemini-flash",
- "gemini-3-flash-agent": "gemini-flash",
- "gemini-3.5-flash-low": "gemini-flash",
- "gemini-3.5-flash-extra-low": "gemini-flash",
- "gemini-3.6-flash-low": "gemini-flash",
- "gemini-3.6-flash-medium": "gemini-flash",
- "gemini-3.6-flash-high": "gemini-flash",
- "gemini-3.6-flash-tiered": "gemini-flash",
- "gemini-3.1-flash-image": "gemini-flash",
- "gpt-oss-120b": "gpt-oss",
- "gpt-oss-120b-medium": "gpt-oss",
+ 'claude-opus-4-6-thinking': 'claude',
+ 'claude-opus-4-6': 'claude',
+ 'claude-sonnet-4-6-thinking': 'claude',
+ 'claude-sonnet-4-6': 'claude',
+ 'gemini-pro-agent': 'gemini-pro',
+ 'gemini-3.1-pro': 'gemini-pro',
+ 'gemini-3.1-pro-low': 'gemini-pro',
+ 'gemini-3.1-pro-high': 'gemini-pro',
+ 'gemini-3-flash': 'gemini-flash',
+ 'gemini-3-flash-agent': 'gemini-flash',
+ 'gemini-3.5-flash-low': 'gemini-flash',
+ 'gemini-3.5-flash-extra-low': 'gemini-flash',
+ 'gemini-3.6-flash-low': 'gemini-flash',
+ 'gemini-3.6-flash-medium': 'gemini-flash',
+ 'gemini-3.6-flash-high': 'gemini-flash',
+ 'gemini-3.6-flash-tiered': 'gemini-flash',
+ 'gemini-3.1-flash-image': 'gemini-flash',
+ 'gpt-oss-120b': 'gpt-oss',
+ 'gpt-oss-120b-medium': 'gpt-oss',
}
const ANTIGRAVITY_OPENCODE_MODEL_IDS = [
- "antigravity-gemini-3.6-flash",
- "antigravity-gemini-3.5-flash",
- "antigravity-gemini-3.1-pro",
- "antigravity-claude-sonnet-4-6-thinking",
- "antigravity-claude-opus-4-6-thinking",
- "antigravity-gemini-3.1-flash-image",
- "antigravity-gpt-oss-120b-medium",
+ 'antigravity-gemini-3.6-flash',
+ 'antigravity-gemini-3.5-flash',
+ 'antigravity-gemini-3.1-pro',
+ 'antigravity-claude-sonnet-4-6-thinking',
+ 'antigravity-claude-opus-4-6-thinking',
+ 'antigravity-gemini-3.1-flash-image',
+ 'antigravity-gpt-oss-120b-medium',
] as const
-function pickModelDefinitions(ids: readonly string[]): OpencodeModelDefinitions {
+function pickModelDefinitions(
+ ids: readonly string[],
+): OpencodeModelDefinitions {
return Object.fromEntries(ids.map((id) => [id, ALL_MODEL_DEFINITIONS[id]!]))
}
-export const OPENCODE_MODEL_DEFINITIONS = pickModelDefinitions(ANTIGRAVITY_OPENCODE_MODEL_IDS)
+export const OPENCODE_MODEL_DEFINITIONS = pickModelDefinitions(
+ ANTIGRAVITY_OPENCODE_MODEL_IDS,
+)
export function getPublicModelDefinitions(): OpencodeModelDefinitions {
return OPENCODE_MODEL_DEFINITIONS
@@ -312,7 +338,10 @@ export function getGemini35FlashAntigravityModel(tier?: ThinkingTier): string {
if (!tier) {
return GEMINI_35_FLASH_ROUTES.antigravity.defaultModel
}
- return GEMINI_35_FLASH_ROUTES.antigravity.byTier[tier] ?? GEMINI_35_FLASH_ROUTES.antigravity.defaultModel
+ return (
+ GEMINI_35_FLASH_ROUTES.antigravity.byTier[tier] ??
+ GEMINI_35_FLASH_ROUTES.antigravity.defaultModel
+ )
}
export function getGemini35FlashGeminiCliFallbackModel(): string {
@@ -323,9 +352,13 @@ export function getGemini36FlashAntigravityModel(tier?: ThinkingTier): string {
if (!tier) {
return GEMINI_36_FLASH_ROUTES.defaultModel
}
- return GEMINI_36_FLASH_ROUTES.byTier[tier] ?? GEMINI_36_FLASH_ROUTES.defaultModel
+ return (
+ GEMINI_36_FLASH_ROUTES.byTier[tier] ?? GEMINI_36_FLASH_ROUTES.defaultModel
+ )
}
-export function getQuotaGroupForModel(modelId: string): ModelQuotaGroup | undefined {
+export function getQuotaGroupForModel(
+ modelId: string,
+): ModelQuotaGroup | undefined {
return QUOTA_GROUP_BY_MODEL_ID[modelId.toLowerCase()]
}
diff --git a/packages/core/src/project.test.ts b/packages/core/src/project.test.ts
index ae3497a..bf93fbe 100644
--- a/packages/core/src/project.test.ts
+++ b/packages/core/src/project.test.ts
@@ -1,91 +1,117 @@
-import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
-
-vi.mock("./agy-transport.ts", () => ({
- fetchWithAgyCliTransport: vi.fn(),
-}))
-
-import { ANTIGRAVITY_ENDPOINT_PROD } from "./constants.ts"
-import { fetchWithAgyCliTransport } from "./agy-transport.ts"
+import {
+ afterEach,
+ beforeEach,
+ describe,
+ expect,
+ it,
+ mock,
+ spyOn,
+} from 'bun:test'
+
+import { ANTIGRAVITY_ENDPOINT_PROD } from './constants.ts'
import {
clearProvisionFailedKeys,
ensureProjectContext,
invalidateProjectContextCache,
loadManagedProject,
onboardManagedProject,
-} from "./project.ts"
+} from './project.ts'
+
+// `fetchWithAgyCliTransport` is imported dynamically inside each test so the
+// `mock.module` patch below takes effect — bun resolves the import against the
+// mocked module graph at call time.
+mock.module('./agy-transport.ts', () => ({
+ fetchWithAgyCliTransport: mock(),
+}))
function mockResponse(body: unknown): Response {
return new Response(JSON.stringify(body), { status: 200 })
}
-describe("project bootstrap", () => {
+describe('project bootstrap', () => {
beforeEach(() => {
invalidateProjectContextCache()
clearProvisionFailedKeys()
})
afterEach(() => {
- vi.restoreAllMocks()
+ mock.restore()
invalidateProjectContextCache()
clearProvisionFailedKeys()
})
- it("loads managed project with captured agy CLI loadCodeAssist fingerprint", async () => {
- const fetchSpy = vi.fn().mockResolvedValue(mockResponse({ cloudaicompanionProject: "proj" }))
- vi.mocked(fetchWithAgyCliTransport).mockImplementation(fetchSpy)
+ it('loads managed project with captured agy CLI loadCodeAssist fingerprint', async () => {
+ const fetchSpy = mock().mockResolvedValue(
+ mockResponse({ cloudaicompanionProject: 'proj' }),
+ )
+ const { fetchWithAgyCliTransport } = await import('./agy-transport.ts')
+ ;(fetchWithAgyCliTransport as any).mockImplementation(fetchSpy)
- const result = await loadManagedProject("token", "ignored-project")
+ const result = await loadManagedProject('token', 'ignored-project')
- expect(result?.cloudaicompanionProject).toBe("proj")
+ expect(result?.cloudaicompanionProject).toBe('proj')
const [, init] = fetchSpy.mock.calls[0] as [string, RequestInit]
const headers = init.headers as Record
const body = JSON.parse(init.body as string)
expect(headers).toEqual({
- "User-Agent": expect.stringMatching(
+ 'User-Agent': expect.stringMatching(
/^antigravity\/cli\/1\.1\.5 \(aidev_client; os_type=.+; arch=.+; auth_method=consumer\)$/,
),
- Authorization: "Bearer token",
- "Content-Type": "application/json",
- "Accept-Encoding": "gzip",
+ Authorization: 'Bearer token',
+ 'Content-Type': 'application/json',
+ 'Accept-Encoding': 'gzip',
})
- expect(headers["X-Goog-Api-Client"]).toBeUndefined()
- expect(headers["Client-Metadata"]).toBeUndefined()
- expect(body).toEqual({ metadata: { ideType: "ANTIGRAVITY" } })
+ expect(headers['X-Goog-Api-Client']).toBeUndefined()
+ expect(headers['Client-Metadata']).toBeUndefined()
+ expect(body).toEqual({ metadata: { ideType: 'ANTIGRAVITY' } })
})
- it("onboards with minimal tier body on prod first", async () => {
- const fetchSpy = vi.fn().mockResolvedValue(mockResponse({
- done: true,
- response: { cloudaicompanionProject: { id: "managed-project" } },
- }))
- vi.mocked(fetchWithAgyCliTransport).mockImplementation(fetchSpy)
-
- const result = await onboardManagedProject("token", "free-tier", "legacy-project")
-
- expect(result).toBe("managed-project")
+ it('onboards with minimal tier body on prod first', async () => {
+ const fetchSpy = mock().mockResolvedValue(
+ mockResponse({
+ done: true,
+ response: { cloudaicompanionProject: { id: 'managed-project' } },
+ }),
+ )
+ const { fetchWithAgyCliTransport } = await import('./agy-transport.ts')
+ ;(fetchWithAgyCliTransport as any).mockImplementation(fetchSpy)
+
+ const result = await onboardManagedProject(
+ 'token',
+ 'free-tier',
+ 'legacy-project',
+ )
+
+ expect(result).toBe('managed-project')
const [url, init] = fetchSpy.mock.calls[0] as [string, RequestInit]
const body = JSON.parse(init.body as string)
expect(url).toBe(`${ANTIGRAVITY_ENDPOINT_PROD}/v1internal:onboardUser`)
- expect(body).toEqual({ tierId: "free-tier" })
+ expect(body).toEqual({ tierId: 'free-tier' })
})
- it("does not retry managed-project provisioning after a cached failure expires", async () => {
+ it('does not retry managed-project provisioning after a cached failure expires', async () => {
let now = 1_000
- vi.spyOn(Date, "now").mockImplementation(() => now)
- const fetchSpy = vi.fn(async (url: string) => {
- if (url.includes("loadCodeAssist")) {
- return mockResponse({ allowedTiers: [{ id: "free-tier", isDefault: true }] })
+ spyOn(Date, 'now').mockImplementation(() => now)
+ const fetchSpy = mock(async (url: string) => {
+ if (url.includes('loadCodeAssist')) {
+ return mockResponse({
+ allowedTiers: [{ id: 'free-tier', isDefault: true }],
+ })
}
- return new Response("busy", { status: 503, statusText: "Service Unavailable" })
+ return new Response('busy', {
+ status: 503,
+ statusText: 'Service Unavailable',
+ })
})
- vi.mocked(fetchWithAgyCliTransport).mockImplementation(fetchSpy)
+ const { fetchWithAgyCliTransport } = await import('./agy-transport.ts')
+ ;(fetchWithAgyCliTransport as any).mockImplementation(fetchSpy)
const auth = {
- type: "oauth" as const,
- access: "access-token",
- refresh: "refresh-token",
+ type: 'oauth' as const,
+ access: 'access-token',
+ refresh: 'refresh-token',
expires: now + 60_000,
}
diff --git a/packages/core/src/project.ts b/packages/core/src/project.ts
index 57516f2..99d2063 100644
--- a/packages/core/src/project.ts
+++ b/packages/core/src/project.ts
@@ -1,107 +1,119 @@
+import { fetchWithAgyCliTransport } from './agy-transport.ts'
+import { formatRefreshParts, parseRefreshParts } from './auth.ts'
+import type { OAuthAuthDetails, ProjectContextResult } from './auth-types.ts'
import {
+ ANTIGRAVITY_DEFAULT_PROJECT_ID,
ANTIGRAVITY_ENDPOINT_FALLBACKS,
- ANTIGRAVITY_LOAD_ENDPOINTS,
ANTIGRAVITY_ENDPOINT_PROD,
- ANTIGRAVITY_DEFAULT_PROJECT_ID,
-} from "./constants.ts";
-import { fetchWithAgyCliTransport } from "./agy-transport.ts";
-import { formatRefreshParts, parseRefreshParts } from "./auth.ts";
+ ANTIGRAVITY_LOAD_ENDPOINTS,
+} from './constants.ts'
import {
buildAntigravityHarnessBootstrapHeaders,
buildAntigravityLoadCodeAssistMetadata,
-} from "./fingerprint.ts";
-import { createLogger } from "./logger.ts";
-import type { OAuthAuthDetails, ProjectContextResult } from "./auth-types.ts";
+} from './fingerprint.ts'
+import { createLogger } from './logger.ts'
-const log = createLogger("project");
+const log = createLogger('project')
/** TTL for project context cache entries (30 minutes). */
-const PROJECT_CONTEXT_CACHE_TTL_MS = 30 * 60 * 1000;
+const PROJECT_CONTEXT_CACHE_TTL_MS = 30 * 60 * 1000
interface CachedProjectContext {
- result: ProjectContextResult;
- cachedAt: number;
+ result: ProjectContextResult
+ cachedAt: number
}
-const projectContextResultCache = new Map();
-const projectContextPendingCache = new Map>();
-const provisionFailedKeys = new Set();
+const projectContextResultCache = new Map()
+const projectContextPendingCache = new Map<
+ string,
+ Promise
+>()
+const provisionFailedKeys = new Set()
interface AntigravityUserTier {
- id?: string;
- isDefault?: boolean;
- userDefinedCloudaicompanionProject?: boolean;
+ id?: string
+ isDefault?: boolean
+ userDefinedCloudaicompanionProject?: boolean
}
interface LoadCodeAssistPayload {
- cloudaicompanionProject?: string | { id?: string };
+ cloudaicompanionProject?: string | { id?: string }
currentTier?: {
- id?: string;
- };
- allowedTiers?: AntigravityUserTier[];
+ id?: string
+ }
+ allowedTiers?: AntigravityUserTier[]
}
interface OnboardUserPayload {
- done?: boolean;
+ done?: boolean
response?: {
cloudaicompanionProject?: {
- id?: string;
- };
- };
+ id?: string
+ }
+ }
}
-function buildBootstrapRequestBody(extra: Record = {}): Record {
+function buildBootstrapRequestBody(
+ extra: Record = {},
+): Record {
return {
...extra,
metadata: buildAntigravityLoadCodeAssistMetadata(),
- };
+ }
}
/**
* Selects the default tier ID from the allowed tiers list.
*/
-function getDefaultTierId(allowedTiers?: AntigravityUserTier[]): string | undefined {
+function getDefaultTierId(
+ allowedTiers?: AntigravityUserTier[],
+): string | undefined {
if (!allowedTiers || allowedTiers.length === 0) {
- return undefined;
+ return undefined
}
for (const tier of allowedTiers) {
if (tier?.isDefault) {
- return tier.id;
+ return tier.id
}
}
- return allowedTiers[0]?.id;
+ return allowedTiers[0]?.id
}
/**
* Promise-based delay utility.
*/
function wait(ms: number): Promise {
- return new Promise(function (resolve) {
- setTimeout(resolve, ms);
- });
+ return new Promise((resolve) => {
+ setTimeout(resolve, ms)
+ })
}
/**
* Extracts the cloudaicompanion project id from loadCodeAssist responses.
*/
-function extractManagedProjectId(payload: LoadCodeAssistPayload | null): string | undefined {
+function extractManagedProjectId(
+ payload: LoadCodeAssistPayload | null,
+): string | undefined {
if (!payload) {
- return undefined;
+ return undefined
}
- if (typeof payload.cloudaicompanionProject === "string") {
- return payload.cloudaicompanionProject;
+ if (typeof payload.cloudaicompanionProject === 'string') {
+ return payload.cloudaicompanionProject
}
- if (payload.cloudaicompanionProject && typeof payload.cloudaicompanionProject.id === "string") {
- return payload.cloudaicompanionProject.id;
+ if (
+ payload.cloudaicompanionProject &&
+ typeof payload.cloudaicompanionProject.id === 'string'
+ ) {
+ return payload.cloudaicompanionProject.id
}
- return undefined;
+ return undefined
}
/**
* Generates a cache key for project context based on refresh token.
*/
function getCacheKey(auth: OAuthAuthDetails): string | undefined {
- const refresh = auth.refresh?.trim();
- return refresh ? refresh : undefined;
+ const refresh = auth.refresh?.trim()
+ return refresh ? refresh : undefined
}
/**
@@ -109,18 +121,18 @@ function getCacheKey(auth: OAuthAuthDetails): string | undefined {
*/
export function invalidateProjectContextCache(refresh?: string): void {
if (!refresh) {
- projectContextPendingCache.clear();
- projectContextResultCache.clear();
- provisionFailedKeys.clear();
- return;
+ projectContextPendingCache.clear()
+ projectContextResultCache.clear()
+ provisionFailedKeys.clear()
+ return
}
- projectContextPendingCache.delete(refresh);
- projectContextResultCache.delete(refresh);
- provisionFailedKeys.delete(refresh);
+ projectContextPendingCache.delete(refresh)
+ projectContextResultCache.delete(refresh)
+ provisionFailedKeys.delete(refresh)
}
export function clearProvisionFailedKeys(): void {
- provisionFailedKeys.clear();
+ provisionFailedKeys.clear()
}
/**
@@ -130,39 +142,43 @@ export async function loadManagedProject(
accessToken: string,
_projectId?: string,
): Promise {
- const requestBody = buildBootstrapRequestBody();
- const loadHeaders = buildAntigravityHarnessBootstrapHeaders(accessToken);
+ const requestBody = buildBootstrapRequestBody()
+ const loadHeaders = buildAntigravityHarnessBootstrapHeaders(accessToken)
const loadEndpoints = Array.from(
- new Set([...ANTIGRAVITY_LOAD_ENDPOINTS, ...ANTIGRAVITY_ENDPOINT_FALLBACKS]),
- );
+ new Set([
+ ...ANTIGRAVITY_LOAD_ENDPOINTS,
+ ...ANTIGRAVITY_ENDPOINT_FALLBACKS,
+ ]),
+ )
for (const baseEndpoint of loadEndpoints) {
try {
const response = await fetchWithAgyCliTransport(
`${baseEndpoint}/v1internal:loadCodeAssist`,
{
- method: "POST",
+ method: 'POST',
headers: loadHeaders,
body: JSON.stringify(requestBody),
},
- );
+ )
if (!response.ok) {
- continue;
+ continue
}
- return (await response.json()) as LoadCodeAssistPayload;
+ return (await response.json()) as LoadCodeAssistPayload
} catch (error) {
- log.debug("Failed to load managed project", { endpoint: baseEndpoint, error: String(error) });
- continue;
+ log.debug('Failed to load managed project', {
+ endpoint: baseEndpoint,
+ error: String(error),
+ })
}
}
- return null;
+ return null
}
-
/**
* Onboards a managed project for the user, optionally retrying until completion.
*/
@@ -173,10 +189,14 @@ export async function onboardManagedProject(
attempts = 10,
delayMs = 5000,
): Promise {
- const requestBody: Record = { tierId };
+ const requestBody: Record = { tierId }
const onboardEndpoints = Array.from(
- new Set([ANTIGRAVITY_ENDPOINT_PROD, ...ANTIGRAVITY_LOAD_ENDPOINTS, ...ANTIGRAVITY_ENDPOINT_FALLBACKS]),
- );
+ new Set([
+ ANTIGRAVITY_ENDPOINT_PROD,
+ ...ANTIGRAVITY_LOAD_ENDPOINTS,
+ ...ANTIGRAVITY_ENDPOINT_FALLBACKS,
+ ]),
+ )
for (const baseEndpoint of onboardEndpoints) {
for (let attempt = 0; attempt < attempts; attempt += 1) {
@@ -184,79 +204,87 @@ export async function onboardManagedProject(
const response = await fetchWithAgyCliTransport(
`${baseEndpoint}/v1internal:onboardUser`,
{
- method: "POST",
+ method: 'POST',
headers: buildAntigravityHarnessBootstrapHeaders(accessToken),
body: JSON.stringify(requestBody),
},
- );
+ )
if (!response.ok) {
- log.debug("Onboard request failed", {
+ log.debug('Onboard request failed', {
endpoint: baseEndpoint,
status: response.status,
statusText: response.statusText,
- });
- break;
+ })
+ break
}
- const payload = (await response.json()) as OnboardUserPayload;
- const managedProjectId = payload.response?.cloudaicompanionProject?.id;
+ const payload = (await response.json()) as OnboardUserPayload
+ const managedProjectId = payload.response?.cloudaicompanionProject?.id
if (payload.done && managedProjectId) {
- return managedProjectId;
+ return managedProjectId
}
if (payload.done && projectId) {
- return projectId;
+ return projectId
}
} catch (error) {
- log.debug("Failed to onboard managed project", { endpoint: baseEndpoint, error: String(error) });
- break;
+ log.debug('Failed to onboard managed project', {
+ endpoint: baseEndpoint,
+ error: String(error),
+ })
+ break
}
- await wait(delayMs);
+ await wait(delayMs)
}
}
- return undefined;
+ return undefined
}
/**
* Resolves an effective project ID for the current auth state, caching results per refresh token.
*/
-export async function ensureProjectContext(auth: OAuthAuthDetails): Promise {
- const accessToken = auth.access;
+export async function ensureProjectContext(
+ auth: OAuthAuthDetails,
+): Promise {
+ const accessToken = auth.access
if (!accessToken) {
- return { auth, effectiveProjectId: "" };
+ return { auth, effectiveProjectId: '' }
}
- const cacheKey = getCacheKey(auth);
+ const cacheKey = getCacheKey(auth)
if (cacheKey) {
- const cached = projectContextResultCache.get(cacheKey);
- if (cached && (Date.now() - cached.cachedAt) < PROJECT_CONTEXT_CACHE_TTL_MS) {
- return cached.result;
+ const cached = projectContextResultCache.get(cacheKey)
+ if (cached && Date.now() - cached.cachedAt < PROJECT_CONTEXT_CACHE_TTL_MS) {
+ return cached.result
}
if (cached) {
// Expired — evict stale entry
- projectContextResultCache.delete(cacheKey);
- } const pending = projectContextPendingCache.get(cacheKey);
+ projectContextResultCache.delete(cacheKey)
+ }
+ const pending = projectContextPendingCache.get(cacheKey)
if (pending) {
- return pending;
+ return pending
}
}
const resolveContext = async (): Promise => {
- const parts = parseRefreshParts(auth.refresh);
+ const parts = parseRefreshParts(auth.refresh)
if (parts.managedProjectId) {
- return { auth, effectiveProjectId: parts.managedProjectId };
+ return { auth, effectiveProjectId: parts.managedProjectId }
}
- const fallbackProjectId = ANTIGRAVITY_DEFAULT_PROJECT_ID;
+ const fallbackProjectId = ANTIGRAVITY_DEFAULT_PROJECT_ID
if (cacheKey && provisionFailedKeys.has(cacheKey)) {
- const effectiveProjectId = parts.projectId || fallbackProjectId;
- return { auth, effectiveProjectId };
+ const effectiveProjectId = parts.projectId || fallbackProjectId
+ return { auth, effectiveProjectId }
}
- const persistManagedProject = async (managedProjectId: string): Promise => {
+ const persistManagedProject = async (
+ managedProjectId: string,
+ ): Promise => {
const updatedAuth: OAuthAuthDetails = {
...auth,
refresh: formatRefreshParts({
@@ -264,70 +292,81 @@ export async function ensureProjectContext(auth: OAuthAuthDetails): Promise {
- const nextKey = getCacheKey(result.auth) ?? cacheKey;
- projectContextPendingCache.delete(cacheKey);
- projectContextResultCache.set(nextKey, { result, cachedAt: Date.now() });
+ const nextKey = getCacheKey(result.auth) ?? cacheKey
+ projectContextPendingCache.delete(cacheKey)
+ projectContextResultCache.set(nextKey, { result, cachedAt: Date.now() })
if (nextKey !== cacheKey) {
- projectContextResultCache.delete(cacheKey);
+ projectContextResultCache.delete(cacheKey)
}
- return result;
+ return result
})
.catch((error) => {
- projectContextPendingCache.delete(cacheKey);
- throw error;
- });
+ projectContextPendingCache.delete(cacheKey)
+ throw error
+ })
- projectContextPendingCache.set(cacheKey, promise);
- return promise;
+ projectContextPendingCache.set(cacheKey, promise)
+ return promise
}
diff --git a/packages/core/src/quota-manager.test.ts b/packages/core/src/quota-manager.test.ts
new file mode 100644
index 0000000..c3287f6
--- /dev/null
+++ b/packages/core/src/quota-manager.test.ts
@@ -0,0 +1,745 @@
+import { afterEach, describe, expect, it } from 'bun:test'
+import type { AccountMetadataV3 } from './account-types.ts'
+import { createQuotaManager, type FetchAccountQuota } from './quota-manager.ts'
+import type { AccountQuotaResult } from './quota-types.ts'
+
+function makeAccount(
+ overrides: Partial = {},
+): AccountMetadataV3 {
+ return {
+ refreshToken: 'rt',
+ addedAt: 0,
+ lastUsed: 0,
+ ...overrides,
+ }
+}
+
+interface CallRecord {
+ account: AccountMetadataV3
+}
+
+function makeHarness(
+ overrides: Partial<{ failures: number; result: 'ok' | 'err' }> = {},
+) {
+ const calls: CallRecord[] = []
+ const failures = overrides.failures ?? 0
+ let invocations = 0
+
+ const fetch: FetchAccountQuota = async (account, _signal) => {
+ calls.push({ account })
+ invocations += 1
+ if (invocations <= failures) {
+ throw new Error(`synthetic failure #${invocations}`)
+ }
+ if (overrides.result === 'err') {
+ return {
+ index: 0,
+ status: 'error',
+ email: account.email,
+ error: 'synthetic',
+ }
+ }
+ return {
+ index: 0,
+ status: 'ok',
+ email: account.email,
+ quota: {
+ groups: { claude: { remainingFraction: 0.5, modelCount: 1 } },
+ modelCount: 1,
+ },
+ }
+ }
+
+ return { fetch, calls }
+}
+
+function keyOfAccount(account: AccountMetadataV3): string {
+ return account.email ?? `rt:${account.refreshToken}`
+}
+
+let managers: Array<{ dispose: () => void }> = []
+
+afterEach(() => {
+ for (const manager of managers) {
+ manager.dispose()
+ }
+ managers = []
+})
+
+function track(disposable: { dispose: () => void }) {
+ managers.push(disposable)
+ return disposable
+}
+
+describe('classifyQuotaGroup', () => {
+ it('classifies Antigravity claude models', () => {
+ const { classifyQuotaGroup } = createQuotaManager({
+ fetchAccountQuota: makeHarness().fetch,
+ keyOf: keyOfAccount,
+ })
+ expect(classifyQuotaGroup('claude-sonnet-4-6', 'Claude Sonnet 4.6')).toBe(
+ 'claude',
+ )
+ })
+
+ it('classifies gemini flash vs pro via registry', () => {
+ const { classifyQuotaGroup } = createQuotaManager({
+ fetchAccountQuota: makeHarness().fetch,
+ keyOf: keyOfAccount,
+ })
+ expect(
+ classifyQuotaGroup('gemini-3.5-flash-low', 'Gemini 3.5 Flash (Low)'),
+ ).toBe('gemini-flash')
+ expect(classifyQuotaGroup('gemini-3.1-pro', 'Gemini 3.1 Pro')).toBe(
+ 'gemini-pro',
+ )
+ })
+
+ it('classifies gpt-oss variants', () => {
+ const { classifyQuotaGroup } = createQuotaManager({
+ fetchAccountQuota: makeHarness().fetch,
+ keyOf: keyOfAccount,
+ })
+ expect(classifyQuotaGroup('gpt-oss-120b-medium', 'GPT-OSS 120B')).toBe(
+ 'gpt-oss',
+ )
+ })
+
+ it('returns null for unrecognized models', () => {
+ const { classifyQuotaGroup } = createQuotaManager({
+ fetchAccountQuota: makeHarness().fetch,
+ keyOf: keyOfAccount,
+ })
+ expect(classifyQuotaGroup('totally-unknown', 'Whatever')).toBeNull()
+ })
+})
+
+describe('aggregateQuota', () => {
+ it('aggregates per-model entries into groups by minimum remaining', () => {
+ const { aggregateQuota } = createQuotaManager({
+ fetchAccountQuota: makeHarness().fetch,
+ keyOf: keyOfAccount,
+ })
+ const summary = aggregateQuota({
+ 'claude-sonnet-4-6': {
+ quotaInfo: {
+ remainingFraction: 0.8,
+ resetTime: '2099-01-01T00:00:00Z',
+ },
+ displayName: 'Claude Sonnet 4.6',
+ modelName: 'Claude Sonnet 4.6',
+ },
+ 'gemini-3.1-pro-low': {
+ quotaInfo: { remainingFraction: 0.4 },
+ displayName: 'Gemini 3.1 Pro',
+ modelName: 'Gemini 3.1 Pro',
+ },
+ 'gemini-3.5-flash-low': {
+ quotaInfo: { remainingFraction: 0.1 },
+ displayName: 'Gemini Flash',
+ modelName: 'Gemini Flash',
+ },
+ })
+ expect(summary.groups.claude?.remainingFraction).toBe(0.8)
+ expect(summary.groups['gemini-pro']?.remainingFraction).toBe(0.4)
+ expect(summary.groups['gemini-flash']?.remainingFraction).toBe(0.1)
+ expect(summary.perModel).toHaveLength(3)
+ expect(summary.modelCount).toBe(3)
+ })
+
+ it('clamps out-of-range remaining fractions', () => {
+ const { aggregateQuota } = createQuotaManager({
+ fetchAccountQuota: makeHarness().fetch,
+ keyOf: keyOfAccount,
+ })
+ const summary = aggregateQuota({
+ 'claude-sonnet-4-6': {
+ quotaInfo: { remainingFraction: 5 },
+ displayName: 'Claude',
+ modelName: 'Claude',
+ },
+ 'gemini-3.5-flash-low': {
+ quotaInfo: { remainingFraction: -1 },
+ displayName: 'Flash',
+ modelName: 'Flash',
+ },
+ })
+ expect(summary.groups.claude?.remainingFraction).toBe(1)
+ expect(summary.groups['gemini-flash']?.remainingFraction).toBe(0)
+ })
+})
+
+describe('refreshAccount', () => {
+ it('attempts the fetch and stores the result indexed by stable key', async () => {
+ const harness = makeHarness()
+ const manager = track(
+ createQuotaManager({
+ fetchAccountQuota: harness.fetch,
+ keyOf: keyOfAccount,
+ }),
+ )
+
+ const account = makeAccount({ email: 'a@example.com' })
+ const result = await manager.refreshAccount(account, { index: 7 })
+
+ expect(result.status).toBe('ok')
+ expect(result.index).toBe(7)
+ expect(result.email).toBe('a@example.com')
+ expect(harness.calls).toHaveLength(1)
+
+ const cached = manager.getCached(account)
+ expect(cached?.status).toBe('ok')
+ expect(cached?.index).toBe(7)
+ })
+
+ it('treats disabled accounts as a disabled result without fetching', async () => {
+ const harness = makeHarness()
+ const manager = track(
+ createQuotaManager({
+ fetchAccountQuota: harness.fetch,
+ keyOf: keyOfAccount,
+ }),
+ )
+
+ const account = makeAccount({ email: 'a@example.com', enabled: false })
+ const result = await manager.refreshAccount(account, { index: 0 })
+
+ expect(result.status).toBe('disabled')
+ expect(result.disabled).toBe(true)
+ expect(harness.calls).toHaveLength(0)
+ expect(manager.getCached(account)?.status).toBe('disabled')
+ })
+
+ it('captures fetch errors as status="error"', async () => {
+ const harness = makeHarness({ failures: 1 })
+ const manager = track(
+ createQuotaManager({
+ fetchAccountQuota: harness.fetch,
+ keyOf: keyOfAccount,
+ }),
+ )
+
+ const account = makeAccount({ email: 'a@example.com' })
+ const result = await manager.refreshAccount(account, { index: 0 })
+
+ expect(result.status).toBe('error')
+ expect(result.error).toContain('synthetic failure #1')
+ expect(manager.getCached(account)?.status).toBe('error')
+ })
+
+ it('treats resolved error results (status="error") as failures for backoff', async () => {
+ let calls = 0
+ const fetch: FetchAccountQuota = async (account) => {
+ calls += 1
+ // Adapter contract: fail soft by resolving with status="error" instead
+ // of throwing. Core must still record a failure and apply backoff so
+ // the next call does not re-hammer a degraded account.
+ return {
+ index: 0,
+ email: account.email,
+ status: 'error',
+ error: 'adapter reported failure',
+ }
+ }
+
+ const manager = track(
+ createQuotaManager({
+ fetchAccountQuota: fetch,
+ keyOf: keyOfAccount,
+ baseBackoffMs: 1000,
+ maxBackoffMs: 8000,
+ }),
+ )
+
+ const account = makeAccount({ email: 'a@example.com' })
+
+ const first = await manager.refreshAccount(account, { index: 0 })
+ expect(first.status).toBe('error')
+ expect(manager.getBackoffUntil(account)).toBeGreaterThan(0)
+
+ // Second call within backoff window must be skipped — proves backoff
+ // was applied to a resolved (not thrown) error.
+ const second = await manager.refreshAccount(account, { index: 0 })
+ expect(second.status).toBe('error')
+ expect(calls).toBe(1)
+
+ // The cached result is preserved across the skipped call.
+ const cached = manager.getCached(account)
+ expect(cached?.status).toBe('error')
+ expect(cached?.error).toBe('adapter reported failure')
+ })
+
+ it('does not apply backoff for disabled results returned by the adapter', async () => {
+ const fetch: FetchAccountQuota = async (account) => ({
+ index: 0,
+ email: account.email,
+ status: 'disabled',
+ disabled: true,
+ })
+
+ const manager = track(
+ createQuotaManager({
+ fetchAccountQuota: fetch,
+ keyOf: keyOfAccount,
+ baseBackoffMs: 1000,
+ maxBackoffMs: 8000,
+ }),
+ )
+
+ const account = makeAccount({ email: 'a@example.com' })
+
+ await manager.refreshAccount(account, { index: 0 })
+ expect(manager.getBackoffUntil(account)).toBe(0)
+
+ await manager.refreshAccount(account, { index: 0 })
+ expect(manager.getBackoffUntil(account)).toBe(0)
+ })
+
+ it('dedupes concurrent fetches for the same account', async () => {
+ let invocations = 0
+ const fetch: FetchAccountQuota = async (_account, _signal) => {
+ invocations += 1
+ // yield to confirm both callers are awaiting the same promise
+ await new Promise((resolve) => setTimeout(resolve, 20))
+ return {
+ index: 0,
+ status: 'ok',
+ quota: { groups: {}, modelCount: 0 },
+ }
+ }
+
+ const manager = track(
+ createQuotaManager({ fetchAccountQuota: fetch, keyOf: keyOfAccount }),
+ )
+ const account = makeAccount({ email: 'a@example.com' })
+
+ const [a, b] = await Promise.all([
+ manager.refreshAccount(account, { index: 0 }),
+ manager.refreshAccount(account, { index: 0 }),
+ ])
+
+ expect(invocations).toBe(1)
+ expect(a.status).toBe('ok')
+ expect(b.status).toBe('ok')
+ })
+
+ it('lets independent accounts fetch in parallel', async () => {
+ const fetch: FetchAccountQuota = async (account, _signal) => {
+ await new Promise((resolve) => setTimeout(resolve, 20))
+ return {
+ index: 0,
+ status: 'ok',
+ email: account.email,
+ quota: { groups: {}, modelCount: 0 },
+ }
+ }
+
+ const manager = track(
+ createQuotaManager({ fetchAccountQuota: fetch, keyOf: keyOfAccount }),
+ )
+ const a = makeAccount({ email: 'a@example.com' })
+ const b = makeAccount({ email: 'b@example.com' })
+
+ const results = await manager.refreshAccounts([a, b], { indexFor: () => 0 })
+
+ expect(results).toHaveLength(2)
+ expect(results[0]?.status).toBe('ok')
+ expect(results[1]?.status).toBe('ok')
+ })
+})
+
+describe('backoff', () => {
+ it('skips fetch while inside the backoff window unless forced', async () => {
+ let now = 1_000_000
+ let calls = 0
+ const counterFetch: FetchAccountQuota = async () => {
+ calls += 1
+ if (calls === 1) {
+ throw new Error('first call fails')
+ }
+ return { index: 0, status: 'ok', quota: { groups: {}, modelCount: 0 } }
+ }
+
+ const manager = track(
+ createQuotaManager({
+ fetchAccountQuota: counterFetch,
+ keyOf: keyOfAccount,
+ now: () => now,
+ baseBackoffMs: 1000,
+ maxBackoffMs: 8000,
+ }),
+ )
+
+ const account = makeAccount({ email: 'a@example.com' })
+
+ // First call fails — seeds the backoff window
+ await manager.refreshAccount(account, { index: 0 })
+ expect(calls).toBe(1)
+ expect(manager.getBackoffUntil(account)).toBeGreaterThan(0)
+
+ // Second call within backoff window — should skip the fetch
+ const resultSkipped = await manager.refreshAccount(account, { index: 0 })
+ expect(calls).toBe(1)
+ expect(resultSkipped.status).toBe('error')
+
+ // Advance past backoff window
+ now += 5000
+ const resultAllowed = await manager.refreshAccount(account, { index: 0 })
+ expect(calls).toBe(2)
+ expect(resultAllowed.status).toBe('ok')
+
+ // Backoff cleared after success
+ expect(manager.getBackoffUntil(account)).toBe(0)
+ })
+
+ it('grows backoff exponentially and caps at maxBackoffMs', async () => {
+ let now = 0
+ const fetch: FetchAccountQuota = async () => {
+ throw new Error('persistent failure')
+ }
+
+ const manager = track(
+ createQuotaManager({
+ fetchAccountQuota: fetch,
+ keyOf: keyOfAccount,
+ now: () => now,
+ baseBackoffMs: 1000,
+ maxBackoffMs: 4000,
+ }),
+ )
+
+ const account = makeAccount({ email: 'a@example.com' })
+
+ // failures: 1 → backoff 1s, 2 → 2s, 3 → 4s, 4 → capped at 4s
+ // Use stable `now` values so the backoff math is predictable: backoffUntil
+ // is computed as `now() + baseBackoffMs * 2^(failures-1)`.
+ await manager.refreshAccount(account, { index: 0 })
+ expect(manager.getBackoffUntil(account)).toBe(0 + 1_000)
+
+ now = 2_000
+ await manager.refreshAccount(account, { index: 0 })
+ expect(manager.getBackoffUntil(account)).toBe(2_000 + 2_000)
+
+ now = 6_000
+ await manager.refreshAccount(account, { index: 0 })
+ expect(manager.getBackoffUntil(account)).toBe(6_000 + 4_000)
+
+ now = 11_000
+ await manager.refreshAccount(account, { index: 0 })
+ // Capped at maxBackoffMs (4000)
+ expect(manager.getBackoffUntil(account)).toBe(11_000 + 4_000)
+ })
+
+ it('force option bypasses backoff and triggers a fresh fetch', async () => {
+ const now = 0
+ const fetch: FetchAccountQuota = async () => {
+ throw new Error('persistent failure')
+ }
+
+ const manager = track(
+ createQuotaManager({
+ fetchAccountQuota: fetch,
+ keyOf: keyOfAccount,
+ now: () => now,
+ baseBackoffMs: 1000,
+ maxBackoffMs: 8000,
+ }),
+ )
+
+ const account = makeAccount({ email: 'a@example.com' })
+
+ await manager.refreshAccount(account, { index: 0 })
+ expect(manager.getBackoffUntil(account)).toBeGreaterThan(0)
+
+ // force=true should still call fetch despite backoff
+ const result = await manager.refreshAccount(account, {
+ index: 0,
+ force: true,
+ })
+ expect(result.status).toBe('error')
+ expect(manager.getBackoffUntil(account)).toBeGreaterThan(0)
+ })
+
+ it('keeps backoffs independent per account key', async () => {
+ let now = 0
+ const fetch: FetchAccountQuota = async (account) => {
+ throw new Error(`fail ${account.email ?? 'unknown'}`)
+ }
+
+ const manager = track(
+ createQuotaManager({
+ fetchAccountQuota: fetch,
+ keyOf: keyOfAccount,
+ now: () => now,
+ baseBackoffMs: 1000,
+ maxBackoffMs: 8000,
+ }),
+ )
+
+ const a = makeAccount({ email: 'a@example.com' })
+ const b = makeAccount({ email: 'b@example.com' })
+
+ await manager.refreshAccount(a, { index: 0 })
+ // B's backoff should still be 0
+ expect(manager.getBackoffUntil(b)).toBe(0)
+ expect(manager.getBackoffUntil(a)).toBeGreaterThan(0)
+
+ // B can still fetch even if A is backed off
+ now = 1
+ let calls = 0
+ const counterFetch: FetchAccountQuota = async (account) => {
+ calls += 1
+ if (account.email === 'b@example.com') {
+ return { index: 0, status: 'ok', quota: { groups: {}, modelCount: 0 } }
+ }
+ throw new Error('fail')
+ }
+ const manager2 = track(
+ createQuotaManager({
+ fetchAccountQuota: counterFetch,
+ keyOf: keyOfAccount,
+ now: () => now,
+ baseBackoffMs: 1000,
+ maxBackoffMs: 8000,
+ }),
+ )
+ await manager2.refreshAccount(a, { index: 0 })
+ await manager2.refreshAccount(b, { index: 0 })
+ expect(calls).toBe(2)
+ })
+})
+
+describe('getCached', () => {
+ it('returns undefined for unknown accounts', () => {
+ const manager = track(
+ createQuotaManager({
+ fetchAccountQuota: makeHarness().fetch,
+ keyOf: keyOfAccount,
+ }),
+ )
+ expect(manager.getCached(makeAccount({ email: 'nope' }))).toBeUndefined()
+ })
+
+ it('preserves result.index even when accounts reorder', async () => {
+ const fetch: FetchAccountQuota = async (account, _signal) => ({
+ index: 0,
+ status: 'ok',
+ email: account.email,
+ quota: { groups: {}, modelCount: 0 },
+ })
+
+ const manager = track(
+ createQuotaManager({ fetchAccountQuota: fetch, keyOf: keyOfAccount }),
+ )
+ const a = makeAccount({ email: 'a@example.com' })
+ const b = makeAccount({ email: 'b@example.com' })
+
+ await manager.refreshAccount(a, { index: 5 })
+ await manager.refreshAccount(b, { index: 9 })
+
+ expect(manager.getCached(a)?.index).toBe(5)
+ expect(manager.getCached(b)?.index).toBe(9)
+ })
+})
+
+describe('dispose', () => {
+ it('aborts in-flight fetches and rejects subsequent refreshes', async () => {
+ let activeController: AbortController | undefined
+ const fetch: FetchAccountQuota = async (account, signal) => {
+ activeController = new AbortController()
+ const composite = signal
+ ? AbortSignal.any([signal, activeController.signal])
+ : activeController.signal
+ try {
+ await new Promise((resolve, reject) => {
+ if (composite.aborted) {
+ reject(new Error('aborted'))
+ return
+ }
+ composite.addEventListener('abort', () =>
+ reject(new Error('aborted')),
+ )
+ setTimeout(resolve, 200)
+ })
+ return {
+ index: 0,
+ status: 'ok',
+ email: account.email,
+ quota: { groups: {}, modelCount: 0 },
+ }
+ } finally {
+ activeController = undefined
+ }
+ }
+
+ const manager = createQuotaManager({
+ fetchAccountQuota: fetch,
+ keyOf: keyOfAccount,
+ })
+ const account = makeAccount({ email: 'a@example.com' })
+
+ const pending = manager.refreshAccount(account, { index: 0 })
+ manager.dispose()
+ // Wait for abort controller to settle
+ await new Promise((resolve) => setTimeout(resolve, 10))
+
+ const result = await pending
+ expect(result.status).toBe('error')
+ expect(result.error).toContain('aborted')
+
+ // Subsequent refresh attempts after dispose should also fail with an aborted signal.
+ const next = await manager.refreshAccount(account, { index: 0 })
+ expect(next.status).toBe('error')
+ })
+
+ it('awaits the in-flight refresh before resolving so a post-refresh side effect is fenced', async () => {
+ // A producer (the opencode quota wrapper) fires a fire-and-forget
+ // sidebar write in the continuation after refreshAccount resolves.
+ // dispose() must not resolve until that in-flight refresh has
+ // settled, so the continuation's write is enqueued before the
+ // lifecycle drains the sidebar queue in the following phase. A
+ // side-effect attached to the refresh promise stands in for that
+ // continuation here.
+ const order: string[] = []
+ let resolveFetch: ((result: AccountQuotaResult) => void) | null = null
+ const fetch: FetchAccountQuota = (account) => {
+ order.push('fetch:start')
+ return new Promise((resolve) => {
+ resolveFetch = () =>
+ resolve({
+ index: 0,
+ status: 'ok',
+ email: account.email,
+ quota: { groups: {}, modelCount: 0 },
+ })
+ })
+ }
+
+ const manager = createQuotaManager({
+ fetchAccountQuota: fetch,
+ keyOf: keyOfAccount,
+ })
+ const account = makeAccount({ email: 'inflight@example.com' })
+
+ let sideEffectRan = false
+ const pending = manager
+ .refreshAccount(account, { index: 0 })
+ .then((result) => {
+ // Stand-in for the wrapper's fire-and-forget sidebar write.
+ sideEffectRan = true
+ order.push('side-effect')
+ return result
+ })
+
+ // The fetch is now mid-flight (awaiting resolveFetch). Kick off
+ // dispose, then release the fetch — dispose must await the in-flight
+ // refresh and its continuation before resolving.
+ const disposed = manager.dispose().then(() => {
+ order.push('dispose:resolved')
+ // The producer's side-effect was scheduled before dispose resolved.
+ expect(sideEffectRan).toBe(true)
+ })
+ resolveFetch?.()
+
+ await Promise.all([disposed, pending])
+
+ // fetch:start → (in-flight refresh settles + side-effect) → dispose.
+ expect(order).toEqual(['fetch:start', 'side-effect', 'dispose:resolved'])
+ })
+})
+
+describe('refreshAccounts', () => {
+ it('runs sequentially (preserving order) and returns attributed results', async () => {
+ const calls: string[] = []
+ const fetch: FetchAccountQuota = async (account, _signal) => {
+ calls.push(account.email ?? 'unknown')
+ return {
+ index: 0,
+ status: 'ok',
+ email: account.email,
+ quota: { groups: {}, modelCount: 0 },
+ }
+ }
+
+ const manager = track(
+ createQuotaManager({ fetchAccountQuota: fetch, keyOf: keyOfAccount }),
+ )
+ const accounts = [
+ makeAccount({ email: 'a@example.com' }),
+ makeAccount({ email: 'b@example.com' }),
+ makeAccount({ email: 'c@example.com' }),
+ ]
+
+ const results = await manager.refreshAccounts(accounts, {
+ indexFor: (account) =>
+ accounts.findIndex((acc) => acc.email === account.email),
+ })
+
+ expect(results.map((r) => r.email)).toEqual([
+ 'a@example.com',
+ 'b@example.com',
+ 'c@example.com',
+ ])
+ expect(results.map((r) => r.index)).toEqual([0, 1, 2])
+ expect(calls).toEqual(['a@example.com', 'b@example.com', 'c@example.com'])
+ })
+
+ it('respects force to bypass backoff for every account', async () => {
+ const now = 0
+ let calls = 0
+ const fetch: FetchAccountQuota = async () => {
+ calls += 1
+ return { index: 0, status: 'ok', quota: { groups: {}, modelCount: 0 } }
+ }
+
+ const manager = track(
+ createQuotaManager({
+ fetchAccountQuota: fetch,
+ keyOf: keyOfAccount,
+ now: () => now,
+ baseBackoffMs: 1000,
+ maxBackoffMs: 8000,
+ }),
+ )
+
+ const accounts = [makeAccount({ email: 'a@example.com' })]
+
+ // Prime backoff
+ const failFetch: FetchAccountQuota = async () => {
+ throw new Error('fail')
+ }
+ const failManager = track(
+ createQuotaManager({
+ fetchAccountQuota: failFetch,
+ keyOf: keyOfAccount,
+ now: () => now,
+ baseBackoffMs: 1000,
+ maxBackoffMs: 8000,
+ }),
+ )
+ await failManager.refreshAccount(accounts[0]!, { index: 0 })
+ expect(failManager.getBackoffUntil(accounts[0]!)).toBeGreaterThan(0)
+
+ // Use the ok manager but with same backoff seed by manually forcing
+ await manager.refreshAccounts(accounts, { indexFor: () => 0, force: true })
+ expect(calls).toBe(1)
+ })
+})
+
+describe('hashed log labels', () => {
+ it('produces a short hash label for log emissions', () => {
+ const { hashedLogLabel } = createQuotaManager({
+ fetchAccountQuota: makeHarness().fetch,
+ keyOf: keyOfAccount,
+ })
+ expect(hashedLogLabel('refresh-fail', 'a@example.com')).toMatch(
+ /^refresh-fail [a-f0-9]{8}$/,
+ )
+ // Different inputs yield different labels
+ expect(hashedLogLabel('refresh-fail', 'b@example.com')).not.toBe(
+ hashedLogLabel('refresh-fail', 'a@example.com'),
+ )
+ // Empty email still produces a label
+ expect(hashedLogLabel('x', '')).toMatch(/^x [a-f0-9]{8}$/)
+ })
+})
diff --git a/packages/core/src/quota-manager.ts b/packages/core/src/quota-manager.ts
new file mode 100644
index 0000000..b88aafb
--- /dev/null
+++ b/packages/core/src/quota-manager.ts
@@ -0,0 +1,747 @@
+/**
+ * Harness-agnostic attributed quota manager.
+ *
+ * Owns the per-account quota cache, in-flight de-duplication, and exponential
+ * backoff that backgrounds proactive quota refreshes. The manager has no host
+ * dependencies — harnesses supply a `fetchAccountQuota` callback that returns
+ * the already-attributed `AccountQuotaResult` (with `index`, `email`,
+ * `updatedAccount`, etc.) so core stays harness-agnostic.
+ *
+ * Manual quota dialogs must force a refresh even when background refresh is
+ * backed off; proactive refreshes must dedupe by stable account identity and
+ * respect backoff. Account identity is supplied via `keyOf` so reorders/
+ * removals of the underlying array do not invalidate cache entries.
+ */
+
+import { createHash } from 'node:crypto'
+import type { AccountMetadataV3 } from './account-types.ts'
+import { fetchWithActiveTimeout } from './fetch-timeout.ts'
+import { buildAntigravityHarnessUserAgent } from './fingerprint.ts'
+import { createLogger } from './logger.ts'
+import { getQuotaGroupForModel } from './model-registry.ts'
+import type {
+ AccountQuotaResult,
+ GeminiCliQuotaSummary,
+ PerModelQuotaEntry,
+ QuotaGroup,
+ QuotaGroupSummary,
+ QuotaSummary,
+} from './quota-types.ts'
+import { getModelFamily } from './transform/model-resolver.ts'
+
+const log = createLogger('quota-manager')
+
+export const QUOTA_MANAGER_DEFAULT_BASE_BACKOFF_MS = 30_000
+export const QUOTA_MANAGER_DEFAULT_MAX_BACKOFF_MS = 10 * 60 * 1000
+export const QUOTA_MANAGER_DEFAULT_TIMEOUT_MS = 10_000
+
+/**
+ * Signature for the harness-supplied quota fetch callback.
+ *
+ * `index`/`email`/`updatedAccount` come from the harness — the manager
+ * preserves the harness's attribution and only injects status/error info
+ * for disabled accounts and failed fetches.
+ */
+export type FetchAccountQuota = (
+ account: AccountMetadataV3,
+ signal: AbortSignal,
+) => Promise
+
+export interface QuotaManagerOptions {
+ fetchAccountQuota: FetchAccountQuota
+ /**
+ * Stable identity for an account. Used as cache key + backoff key. Should
+ * NOT be the array index — reorders and removals would otherwise corrupt
+ * cached state. The harness can hash the refresh token or compose email +
+ * a refresh-token fallback.
+ */
+ keyOf: (account: AccountMetadataV3) => string
+ now?: () => number
+ baseBackoffMs?: number
+ maxBackoffMs?: number
+ /** Per-fetch active timeout for the harness callback. */
+ fetchTimeoutMs?: number
+}
+
+export interface RefreshAccountOptions {
+ /** Position of the account in the harness-visible array. Preserved on result. */
+ index: number
+ /** Bypass backoff (used by manual quota dialogs). */
+ force?: boolean
+}
+
+export interface RefreshAccountsOptions {
+ indexFor: (account: AccountMetadataV3) => number
+ force?: boolean
+}
+
+export interface QuotaManager {
+ refreshAccount(
+ account: AccountMetadataV3,
+ options: RefreshAccountOptions,
+ ): Promise
+ refreshAccounts(
+ accounts: AccountMetadataV3[],
+ options: RefreshAccountsOptions,
+ ): Promise
+ getCached(account: AccountMetadataV3): AccountQuotaResult | undefined
+ getBackoffUntil(account: AccountMetadataV3): number
+ /** Stable hash for an account, used for log labels. */
+ hashedLogLabel(prefix: string, account: AccountMetadataV3 | string): string
+ /**
+ * Await any in-flight refresh, then cancel and reject subsequent
+ * refreshes. Returns a promise so a lifecycle producer can fence its
+ * fire-and-forget sidebar writes: awaiting `dispose()` guarantees no
+ * refresh is still mid-flight (and therefore cannot enqueue a write)
+ * once it resolves.
+ */
+ dispose(): Promise
+ /** Pure helpers exposed for tests and adapter wiring. */
+ classifyQuotaGroup: typeof classifyQuotaGroup
+ aggregateQuota: typeof aggregateQuota
+ aggregateGeminiCliQuota: typeof aggregateGeminiCliQuota
+}
+
+interface AccountState {
+ consecutiveFailures: number
+ backoffUntil: number
+ inflight?: Promise
+ controller?: AbortController
+ cached?: AccountQuotaResult
+}
+
+/**
+ * Default keyOf — prefers email, falls back to refresh-token hash so the
+ * same identity is keyed even when emails are missing.
+ */
+export function defaultKeyOf(account: AccountMetadataV3): string {
+ if (account.email) return `e:${account.email.toLowerCase()}`
+ const token = account.refreshToken || ''
+ return `t:${createHash('sha256').update(token).digest('hex').slice(0, 16)}`
+}
+
+export function createQuotaManager(options: QuotaManagerOptions): QuotaManager {
+ const now = options.now ?? (() => Date.now())
+ const baseBackoffMs =
+ options.baseBackoffMs ?? QUOTA_MANAGER_DEFAULT_BASE_BACKOFF_MS
+ const maxBackoffMs =
+ options.maxBackoffMs ?? QUOTA_MANAGER_DEFAULT_MAX_BACKOFF_MS
+ const fetchTimeoutMs =
+ options.fetchTimeoutMs ?? QUOTA_MANAGER_DEFAULT_TIMEOUT_MS
+
+ const state = new Map()
+ let disposed = false
+
+ const keyOf = (account: AccountMetadataV3): string => options.keyOf(account)
+
+ const recordSuccess = (key: string): void => {
+ const entry = state.get(key)
+ if (!entry) return
+ entry.consecutiveFailures = 0
+ entry.backoffUntil = 0
+ }
+
+ const recordFailure = (
+ key: string,
+ ): { backoffMs: number; backoffUntil: number } => {
+ const current = now()
+ const entry = state.get(key) ?? {
+ consecutiveFailures: 0,
+ backoffUntil: 0,
+ }
+ const failures = entry.consecutiveFailures + 1
+ const backoffMs = Math.min(
+ maxBackoffMs,
+ baseBackoffMs * 2 ** (failures - 1),
+ )
+ entry.consecutiveFailures = failures
+ entry.backoffUntil = current + backoffMs
+ state.set(key, entry)
+ return { backoffMs, backoffUntil: entry.backoffUntil }
+ }
+
+ const cacheResult = (key: string, result: AccountQuotaResult): void => {
+ const entry = state.get(key) ?? {
+ consecutiveFailures: 0,
+ backoffUntil: 0,
+ }
+ entry.cached = result
+ state.set(key, entry)
+ }
+
+ const buildDisabledResult = (
+ account: AccountMetadataV3,
+ index: number,
+ ): AccountQuotaResult => ({
+ index,
+ email: account.email,
+ status: 'disabled',
+ disabled: true,
+ quota: undefined,
+ geminiCliQuota: undefined,
+ updatedAccount: undefined,
+ })
+
+ const buildSkippedResult = (
+ account: AccountMetadataV3,
+ index: number,
+ backoffUntil: number,
+ ): AccountQuotaResult => {
+ const cached = state.get(keyOf(account))?.cached
+ if (cached) {
+ return { ...cached, index }
+ }
+ return {
+ index,
+ email: account.email,
+ status: 'error',
+ error: `quota refresh skipped (backoff until ${new Date(backoffUntil).toISOString()})`,
+ }
+ }
+
+ const refreshAccount = async (
+ account: AccountMetadataV3,
+ refreshOptions: RefreshAccountOptions,
+ ): Promise => {
+ if (disposed) {
+ return {
+ index: refreshOptions.index,
+ email: account.email,
+ status: 'error',
+ error: 'quota manager disposed',
+ }
+ }
+
+ const { index, force = false } = refreshOptions
+ const key = keyOf(account)
+
+ if (account.enabled === false) {
+ const result = buildDisabledResult(account, index)
+ cacheResult(key, result)
+ return result
+ }
+
+ const current = now()
+ const entry = state.get(key)
+ if (!force && entry && entry.backoffUntil > current) {
+ return buildSkippedResult(account, index, entry.backoffUntil)
+ }
+
+ const inflight = entry?.inflight
+ if (inflight) {
+ // Reuse the cached result of the in-flight fetch, but preserve this
+ // caller's requested index for attribution.
+ const cached = await inflight
+ return { ...cached, index }
+ }
+
+ const controller = new AbortController()
+ const stored = state.get(key) ?? {
+ consecutiveFailures: 0,
+ backoffUntil: 0,
+ }
+ stored.controller = controller
+ state.set(key, stored)
+
+ const promise = (async (): Promise => {
+ try {
+ const signal = AbortSignal.timeout(fetchTimeoutMs)
+ const composite = controller.signal.aborted
+ ? controller.signal
+ : AbortSignal.any([controller.signal, signal])
+
+ const result = await options.fetchAccountQuota(account, composite)
+ if (controller.signal.aborted) {
+ throw new Error('quota manager disposed mid-fetch')
+ }
+ const attributed: AccountQuotaResult = { ...result, index }
+ cacheResult(key, attributed)
+
+ // Adapter contract: `fetchAccountQuota` resolves with an
+ // attributed result. Failures should arrive as `{ status: 'error' }`
+ // rather than throwing — treat them like thrown failures so backoff
+ // actually protects the account from re-hammering. `disabled` and
+ // `ok` both count as success (no fetch happened, or it succeeded).
+ if (result.status === 'error') {
+ const { backoffMs } = recordFailure(key)
+ log.debug('quota-refresh-failed', {
+ key: hashKey(key),
+ backoffMs,
+ error: result.error ?? 'attributed error result',
+ })
+ return attributed
+ }
+
+ recordSuccess(key)
+ return attributed
+ } catch (error) {
+ const message = error instanceof Error ? error.message : String(error)
+ if (disposed || controller.signal.aborted) {
+ const failureResult: AccountQuotaResult = {
+ index,
+ email: account.email,
+ status: 'error',
+ error: message,
+ }
+ cacheResult(key, failureResult)
+ return failureResult
+ }
+ const { backoffMs } = recordFailure(key)
+ log.debug('quota-refresh-failed', {
+ key: hashKey(key),
+ backoffMs,
+ error: message,
+ })
+ const failureResult: AccountQuotaResult = {
+ index,
+ email: account.email,
+ status: 'error',
+ error: message,
+ }
+ cacheResult(key, failureResult)
+ return failureResult
+ } finally {
+ const finished = state.get(key)
+ if (finished) {
+ finished.inflight = undefined
+ finished.controller = undefined
+ state.set(key, finished)
+ }
+ }
+ })()
+
+ stored.inflight = promise
+ state.set(key, stored)
+
+ return promise
+ }
+
+ const refreshAccounts = async (
+ accounts: AccountMetadataV3[],
+ refreshOptions: RefreshAccountsOptions,
+ ): Promise => {
+ const results: AccountQuotaResult[] = []
+ for (const account of accounts) {
+ const index = refreshOptions.indexFor(account)
+ const result = await refreshAccount(account, {
+ index,
+ force: refreshOptions.force,
+ })
+ results.push(result)
+ }
+ return results
+ }
+
+ const getCached = (
+ account: AccountMetadataV3,
+ ): AccountQuotaResult | undefined => {
+ const entry = state.get(keyOf(account))
+ return entry?.cached
+ }
+
+ const getBackoffUntil = (account: AccountMetadataV3): number => {
+ return state.get(keyOf(account))?.backoffUntil ?? 0
+ }
+
+ const hashedLogLabel = (
+ prefix: string,
+ account: AccountMetadataV3 | string,
+ ): string => {
+ const identity = typeof account === 'string' ? account : keyOf(account)
+ return `${prefix} ${hashKey(identity)}`
+ }
+
+ const dispose = async (): Promise => {
+ if (disposed) return
+ disposed = true
+ // Snapshot the in-flight refreshes before aborting — the refresh
+ // `finally` clears `entry.inflight` as each promise settles, so we
+ // must capture the promises first.
+ const pending = Array.from(
+ state.values(),
+ (entry) => entry.inflight,
+ ).filter(
+ (promise): promise is Promise => promise != null,
+ )
+ // Abort so a genuinely-stuck fetch unwinds promptly (the refresh
+ // catches the abort and resolves with an error result rather than
+ // hanging dispose), then AWAIT the settled promises. Awaiting after
+ // the abort still fences the producer: the opencode quota wrapper's
+ // fire-and-forget sidebar write runs in the continuation after the
+ // core refresh resolves, so by the time dispose() resolves that
+ // write has already been enqueued onto the sidebar chain and the
+ // subsequent lifecycle drain will flush it.
+ for (const [, entry] of state) {
+ entry.controller?.abort()
+ }
+ if (pending.length > 0) {
+ // Each refresh swallows its own errors and resolves; awaiting is a
+ // fence, never a rejection surface.
+ await Promise.allSettled(pending)
+ }
+ for (const [, entry] of state) {
+ entry.inflight = undefined
+ }
+ }
+
+ return {
+ refreshAccount,
+ refreshAccounts,
+ getCached,
+ getBackoffUntil,
+ hashedLogLabel,
+ dispose,
+ classifyQuotaGroup,
+ aggregateQuota,
+ aggregateGeminiCliQuota,
+ }
+}
+
+function hashKey(key: string): string {
+ return createHash('sha256').update(key).digest('hex').slice(0, 8)
+}
+
+// ============================================================================
+// Pure helpers — also exported for adapter use.
+// ============================================================================
+
+/**
+ * Classify a model into its quota group.
+ */
+export function classifyQuotaGroup(
+ modelName: string,
+ displayName?: string,
+): QuotaGroup | null {
+ const registryGroup = getQuotaGroupForModel(modelName)
+ if (registryGroup) {
+ return registryGroup
+ }
+
+ const combined = `${modelName} ${displayName ?? ''}`.toLowerCase()
+ if (combined.includes('claude')) {
+ return 'claude'
+ }
+ const isGemini3 =
+ combined.includes('gemini-3') || combined.includes('gemini 3')
+ if (!isGemini3) {
+ return null
+ }
+ const family = getModelFamily(modelName)
+ return family === 'gemini-flash' ? 'gemini-flash' : 'gemini-pro'
+}
+
+function normalizeRemainingFraction(value: unknown): number {
+ if (typeof value !== 'number' || !Number.isFinite(value)) {
+ return 0
+ }
+ if (value < 0) return 0
+ if (value > 1) return 1
+ return value
+}
+
+function parseResetTime(resetTime?: string): number | null {
+ if (!resetTime) return null
+ const timestamp = Date.parse(resetTime)
+ if (!Number.isFinite(timestamp)) {
+ return null
+ }
+ return timestamp
+}
+
+export interface FetchAvailableModelEntry {
+ quotaInfo?: {
+ remainingFraction?: number
+ resetTime?: string
+ }
+ displayName?: string
+ modelName?: string
+}
+
+/**
+ * Aggregate per-model quota entries into group summaries + per-model list.
+ *
+ * Pure helper — exposed for harness adapters that want to reuse the same
+ * aggregation logic without re-implementing it.
+ */
+export function aggregateQuota(
+ models?: Record,
+): QuotaSummary {
+ const groups: Partial> = {}
+ const perModel: PerModelQuotaEntry[] = []
+ if (!models) {
+ return { groups, perModel, modelCount: 0 }
+ }
+
+ let totalCount = 0
+ for (const [modelName, entry] of Object.entries(models)) {
+ const group = classifyQuotaGroup(
+ modelName,
+ entry.displayName ?? entry.modelName,
+ )
+ const quotaInfo = entry.quotaInfo
+ const remainingFraction = quotaInfo
+ ? normalizeRemainingFraction(quotaInfo.remainingFraction)
+ : undefined
+ const resetTime = quotaInfo?.resetTime
+ const resetTimestamp = parseResetTime(resetTime)
+
+ totalCount += 1
+
+ perModel.push({
+ modelId: modelName,
+ displayName: entry.displayName ?? entry.modelName,
+ group,
+ remainingFraction: remainingFraction ?? 0,
+ resetTime,
+ })
+
+ if (!group) {
+ continue
+ }
+
+ const existing = groups[group]
+ const nextCount = (existing?.modelCount ?? 0) + 1
+ const nextRemaining =
+ remainingFraction === undefined
+ ? existing?.remainingFraction
+ : existing?.remainingFraction === undefined
+ ? remainingFraction
+ : Math.min(existing.remainingFraction, remainingFraction)
+
+ let nextResetTime = existing?.resetTime
+ if (resetTimestamp !== null) {
+ if (!existing?.resetTime) {
+ nextResetTime = resetTime
+ } else {
+ const existingTimestamp = parseResetTime(existing.resetTime)
+ if (existingTimestamp === null || resetTimestamp < existingTimestamp) {
+ nextResetTime = resetTime
+ }
+ }
+ }
+
+ groups[group] = {
+ remainingFraction: nextRemaining,
+ resetTime: nextResetTime,
+ modelCount: nextCount,
+ }
+ }
+
+ perModel.sort((a, b) => a.modelId.localeCompare(b.modelId))
+
+ return { groups, perModel, modelCount: totalCount }
+}
+
+export interface RetrieveUserQuotaBucket {
+ remainingAmount?: string
+ remainingFraction?: number
+ resetTime?: string
+ tokenType?: string
+ modelId?: string
+}
+
+export interface RetrieveUserQuotaResponse {
+ buckets?: RetrieveUserQuotaBucket[]
+}
+
+export interface FetchAvailableModelsResponse {
+ models?: Record
+}
+
+/**
+ * Aggregate Gemini CLI quota buckets into a summary.
+ */
+export function aggregateGeminiCliQuota(
+ response: RetrieveUserQuotaResponse,
+): GeminiCliQuotaSummary {
+ const models: GeminiCliQuotaSummary['models'] = []
+
+ if (!response.buckets || response.buckets.length === 0) {
+ return { models }
+ }
+
+ for (const bucket of response.buckets) {
+ if (!bucket.modelId) {
+ continue
+ }
+
+ const modelId = bucket.modelId
+ const isRelevantModel =
+ modelId.startsWith('gemini-3-') ||
+ modelId.startsWith('gemini-3.') ||
+ modelId.startsWith('gemini-2.5-')
+ if (!isRelevantModel) {
+ continue
+ }
+
+ models.push({
+ modelId: bucket.modelId,
+ remainingFraction: normalizeRemainingFraction(bucket.remainingFraction),
+ resetTime: bucket.resetTime,
+ })
+ }
+
+ models.sort((a, b) => a.modelId.localeCompare(b.modelId))
+
+ return { models }
+}
+
+// ============================================================================
+// Network helpers — used by the OpenCode adapter.
+// Re-exported so harnesses can call the same endpoint probes with their own
+// retry policy via `fetchWithActiveTimeout`.
+// ============================================================================
+
+export interface FetchAvailableModelsOptions {
+ accessToken: string
+ projectId: string
+ endpoints: readonly string[]
+ timeoutMs?: number
+ userAgent?: string
+ /**
+ * Override the transport used for the probe. Defaults to
+ * `fetchWithActiveTimeout` so callers get the same stream-safe active-fetch
+ * timeout that the rest of the core uses.
+ */
+ fetchVia?: (
+ url: string,
+ options: RequestInit,
+ extra: { timeoutMs: number; signal?: AbortSignal | null },
+ ) => Promise
+}
+
+export async function fetchAvailableModels(
+ options: FetchAvailableModelsOptions,
+): Promise {
+ const timeoutMs = options.timeoutMs ?? QUOTA_MANAGER_DEFAULT_TIMEOUT_MS
+ const userAgent = options.userAgent ?? buildAntigravityHarnessUserAgent()
+ const errors: string[] = []
+
+ const transport = options.fetchVia ?? defaultTransport
+
+ for (const endpoint of options.endpoints) {
+ const body = options.projectId ? { project: options.projectId } : {}
+ try {
+ const response = await transport(
+ `${endpoint}/v1internal:fetchAvailableModels`,
+ {
+ method: 'POST',
+ headers: {
+ 'User-Agent': userAgent,
+ Authorization: `Bearer ${options.accessToken}`,
+ 'Content-Type': 'application/json',
+ 'Accept-Encoding': 'gzip',
+ },
+ body: JSON.stringify(body),
+ },
+ { timeoutMs },
+ )
+
+ if (response.ok) {
+ return (await response.json()) as FetchAvailableModelsResponse
+ }
+
+ const status = response.status
+
+ if (status === 403 && options.projectId) {
+ try {
+ const retryResponse = await transport(
+ `${endpoint}/v1internal:fetchAvailableModels`,
+ {
+ method: 'POST',
+ headers: {
+ 'User-Agent': userAgent,
+ Authorization: `Bearer ${options.accessToken}`,
+ 'Content-Type': 'application/json',
+ 'Accept-Encoding': 'gzip',
+ },
+ body: JSON.stringify({}),
+ },
+ { timeoutMs },
+ )
+ if (retryResponse.ok) {
+ return (await retryResponse.json()) as FetchAvailableModelsResponse
+ }
+ } catch {
+ // Fall through to next endpoint
+ }
+ }
+
+ if (status === 429 || status >= 500) {
+ const message = await response.text().catch(() => '')
+ const snippet = message.trim().slice(0, 200)
+ errors.push(
+ `fetchAvailableModels ${status} at ${endpoint}${snippet ? `: ${snippet}` : ''}`,
+ )
+ continue
+ }
+
+ const message = await response.text().catch(() => '')
+ const snippet = message.trim().slice(0, 200)
+ errors.push(
+ `fetchAvailableModels ${status} at ${endpoint}${snippet ? `: ${snippet}` : ''}`,
+ )
+ break
+ } catch (error) {
+ errors.push(
+ `fetchAvailableModels network error at ${endpoint}: ${error instanceof Error ? error.message : String(error)}`,
+ )
+ }
+ }
+
+ throw new Error(errors.join('; ') || 'fetchAvailableModels failed')
+}
+
+export interface FetchGeminiCliQuotaOptions {
+ accessToken: string
+ projectId: string
+ endpoints: readonly string[]
+ timeoutMs?: number
+ userAgent?: string
+}
+
+export async function fetchGeminiCliQuota(
+ options: FetchGeminiCliQuotaOptions,
+): Promise {
+ const timeoutMs = options.timeoutMs ?? QUOTA_MANAGER_DEFAULT_TIMEOUT_MS
+ const userAgent = options.userAgent ?? buildAntigravityHarnessUserAgent()
+
+ for (const endpoint of options.endpoints) {
+ const body = options.projectId ? { project: options.projectId } : {}
+ try {
+ const response = await defaultTransport(
+ `${endpoint}/v1internal:retrieveUserQuota`,
+ {
+ method: 'POST',
+ headers: {
+ Authorization: `Bearer ${options.accessToken}`,
+ 'Content-Type': 'application/json',
+ 'User-Agent': userAgent,
+ },
+ body: JSON.stringify(body),
+ },
+ { timeoutMs },
+ )
+
+ if (response.ok) {
+ return (await response.json()) as RetrieveUserQuotaResponse
+ }
+
+ const status = response.status
+ if (status === 429 || status >= 500) {
+ continue
+ }
+ return { buckets: [] }
+ } catch {}
+ }
+
+ return { buckets: [] }
+}
+
+async function defaultTransport(
+ url: string,
+ init: RequestInit,
+ options: { timeoutMs: number },
+): Promise {
+ return fetchWithActiveTimeout(url, init, { timeoutMs: options.timeoutMs })
+}
diff --git a/packages/core/src/quota-types.ts b/packages/core/src/quota-types.ts
new file mode 100644
index 0000000..b9aca3d
--- /dev/null
+++ b/packages/core/src/quota-types.ts
@@ -0,0 +1,57 @@
+/**
+ * Harness-agnostic quota types.
+ *
+ * Quota group / per-model / CLI summary shapes shared between the core
+ * quota helpers and any harness that wants to display quota status. Kept
+ * separate from `account-types.ts` so quota evolution does not force a
+ * migration on the persisted account pool.
+ */
+
+import type { AccountMetadataV3 } from './account-types.ts'
+
+export type QuotaGroup = 'claude' | 'gemini-pro' | 'gemini-flash' | 'gpt-oss'
+
+export interface QuotaGroupSummary {
+ remainingFraction?: number
+ resetTime?: string
+ modelCount: number
+}
+
+export interface PerModelQuotaEntry {
+ modelId: string
+ displayName?: string
+ group: QuotaGroup | null
+ remainingFraction: number
+ resetTime?: string
+}
+
+export interface QuotaSummary {
+ groups: Partial>
+ perModel?: PerModelQuotaEntry[]
+ modelCount: number
+ error?: string
+}
+
+export interface GeminiCliQuotaModel {
+ modelId: string
+ remainingFraction: number
+ resetTime?: string
+}
+
+export interface GeminiCliQuotaSummary {
+ models: GeminiCliQuotaModel[]
+ error?: string
+}
+
+export type AccountQuotaStatus = 'ok' | 'disabled' | 'error'
+
+export interface AccountQuotaResult {
+ index: number
+ email?: string
+ status: AccountQuotaStatus
+ error?: string
+ disabled?: boolean
+ quota?: QuotaSummary
+ geminiCliQuota?: GeminiCliQuotaSummary
+ updatedAccount?: AccountMetadataV3
+}
diff --git a/packages/core/src/rotation.test.ts b/packages/core/src/rotation.test.ts
new file mode 100644
index 0000000..a8b0a23
--- /dev/null
+++ b/packages/core/src/rotation.test.ts
@@ -0,0 +1,78 @@
+import { describe, expect, it } from 'bun:test'
+
+import {
+ addJitter,
+ calculateBackoffMs,
+ computeSoftQuotaCacheTtlMs,
+ HealthScoreTracker,
+ parseRateLimitReason,
+ randomDelay,
+ selectHybridAccount,
+ sortByLruWithHealth,
+ TokenBucketTracker,
+} from './rotation.ts'
+
+describe('rotation primitives', () => {
+ it('parses rate-limit reasons and calculates deterministic backoff', () => {
+ expect(parseRateLimitReason(undefined, 'service overloaded', 503)).toBe(
+ 'MODEL_CAPACITY_EXHAUSTED',
+ )
+ expect(
+ calculateBackoffMs('MODEL_CAPACITY_EXHAUSTED', 0, null, () => 0.5),
+ ).toBe(45_000)
+ expect(computeSoftQuotaCacheTtlMs('auto', 3)).toBe(600_000)
+ })
+
+ it('injects random values for jitter helpers', () => {
+ expect(addJitter(1_000, 0.3, () => 0)).toBe(700)
+ expect(randomDelay(100, 500, () => 0.5)).toBe(300)
+ })
+
+ it('recovers health and tokens with injected time', () => {
+ let now = 0
+ const health = new HealthScoreTracker(
+ { initial: 70, failurePenalty: -20, recoveryRatePerHour: 10 },
+ () => now,
+ )
+ health.recordFailure(0)
+ expect(health.getScore(0)).toBe(50)
+ now = 2 * 60 * 60 * 1_000
+ expect(health.getScore(0)).toBe(70)
+
+ now = 0
+ const tokens = new TokenBucketTracker(
+ { initialTokens: 10, maxTokens: 10, regenerationRatePerMinute: 2 },
+ () => now,
+ )
+ tokens.consume(0, 10)
+ expect(tokens.getTokens(0)).toBe(0)
+ now = 5 * 60 * 1_000
+ expect(tokens.getTokens(0)).toBe(10)
+ })
+
+ it('sorts LRU candidates and hybrid-selects deterministically', () => {
+ const candidates = [
+ {
+ index: 0,
+ lastUsed: 5_000,
+ healthScore: 70,
+ isRateLimited: false,
+ isCoolingDown: false,
+ },
+ {
+ index: 1,
+ lastUsed: 1_000,
+ healthScore: 80,
+ isRateLimited: false,
+ isCoolingDown: false,
+ },
+ ]
+ expect(sortByLruWithHealth(candidates).map(({ index }) => index)).toEqual([
+ 1, 0,
+ ])
+ const tokens = new TokenBucketTracker({}, () => 10_000)
+ expect(
+ selectHybridAccount(candidates, tokens, null, 50, () => 10_000),
+ ).toBe(1)
+ })
+})
diff --git a/packages/core/src/rotation.ts b/packages/core/src/rotation.ts
new file mode 100644
index 0000000..082ce45
--- /dev/null
+++ b/packages/core/src/rotation.ts
@@ -0,0 +1,593 @@
+export type RateLimitReason =
+ | 'QUOTA_EXHAUSTED'
+ | 'RATE_LIMIT_EXCEEDED'
+ | 'MODEL_CAPACITY_EXHAUSTED'
+ | 'SERVER_ERROR'
+ | 'UNKNOWN'
+
+const QUOTA_EXHAUSTED_BACKOFFS = [
+ 60_000, 300_000, 1_800_000, 7_200_000,
+] as const
+const RATE_LIMIT_EXCEEDED_BACKOFF = 30_000
+const MODEL_CAPACITY_EXHAUSTED_BASE_BACKOFF = 45_000
+const MODEL_CAPACITY_EXHAUSTED_JITTER_MAX = 30_000
+const SERVER_ERROR_BACKOFF = 20_000
+const UNKNOWN_BACKOFF = 60_000
+const MIN_BACKOFF_MS = 2_000
+
+export function parseRateLimitReason(
+ reason: string | undefined,
+ message: string | undefined,
+ status?: number,
+): RateLimitReason {
+ if (status === 529 || status === 503) return 'MODEL_CAPACITY_EXHAUSTED'
+ if (status === 500) return 'SERVER_ERROR'
+ if (reason) {
+ const normalized = reason.toUpperCase()
+ if (
+ normalized === 'QUOTA_EXHAUSTED' ||
+ normalized === 'RATE_LIMIT_EXCEEDED' ||
+ normalized === 'MODEL_CAPACITY_EXHAUSTED'
+ ) {
+ return normalized
+ }
+ }
+ if (message) {
+ const normalized = message.toLowerCase()
+ if (
+ normalized.includes('capacity') ||
+ normalized.includes('overloaded') ||
+ normalized.includes('resource exhausted')
+ ) {
+ return 'MODEL_CAPACITY_EXHAUSTED'
+ }
+ if (
+ normalized.includes('per minute') ||
+ normalized.includes('rate limit') ||
+ normalized.includes('too many requests') ||
+ normalized.includes('presque')
+ ) {
+ return 'RATE_LIMIT_EXCEEDED'
+ }
+ if (normalized.includes('exhausted') || normalized.includes('quota')) {
+ return 'QUOTA_EXHAUSTED'
+ }
+ }
+ return 'UNKNOWN'
+}
+
+export function calculateBackoffMs(
+ reason: RateLimitReason,
+ consecutiveFailures: number,
+ retryAfterMs?: number | null,
+ random: () => number = Math.random,
+): number {
+ if (retryAfterMs && retryAfterMs > 0) {
+ return Math.max(retryAfterMs, MIN_BACKOFF_MS)
+ }
+ switch (reason) {
+ case 'QUOTA_EXHAUSTED':
+ return (
+ QUOTA_EXHAUSTED_BACKOFFS[
+ Math.min(consecutiveFailures, QUOTA_EXHAUSTED_BACKOFFS.length - 1)
+ ] ?? UNKNOWN_BACKOFF
+ )
+ case 'RATE_LIMIT_EXCEEDED':
+ return RATE_LIMIT_EXCEEDED_BACKOFF
+ case 'MODEL_CAPACITY_EXHAUSTED':
+ return (
+ MODEL_CAPACITY_EXHAUSTED_BASE_BACKOFF +
+ random() * MODEL_CAPACITY_EXHAUSTED_JITTER_MAX -
+ MODEL_CAPACITY_EXHAUSTED_JITTER_MAX / 2
+ )
+ case 'SERVER_ERROR':
+ return SERVER_ERROR_BACKOFF
+ default:
+ return UNKNOWN_BACKOFF
+ }
+}
+
+export function computeSoftQuotaCacheTtlMs(
+ ttlConfig: 'auto' | number,
+ refreshIntervalMinutes: number,
+): number {
+ return (
+ (ttlConfig === 'auto'
+ ? Math.max(2 * refreshIntervalMinutes, 10)
+ : ttlConfig) *
+ 60 *
+ 1000
+ )
+}
+
+/**
+ * Account Rotation System
+ *
+ * Implements advanced account selection algorithms:
+ * - Health Score: Track account wellness based on success/failure
+ * - LRU Selection: Prefer accounts with longest rest periods
+ * - Jitter: Add random variance to break predictable patterns
+ *
+ * Used by 'hybrid' strategy for improved ban prevention and load distribution.
+ */
+
+// ============================================================================
+// HEALTH SCORE SYSTEM
+// ============================================================================
+
+export interface HealthScoreConfig {
+ /** Initial score for new accounts (default: 70) */
+ initial: number
+ /** Points added on successful request (default: 1) */
+ successReward: number
+ /** Points removed on rate limit (default: -10) */
+ rateLimitPenalty: number
+ /** Points removed on failure (auth, network, etc.) (default: -20) */
+ failurePenalty: number
+ /** Points recovered per hour of rest (default: 2) */
+ recoveryRatePerHour: number
+ /** Minimum score to be considered usable (default: 50) */
+ minUsable: number
+ /** Maximum score cap (default: 100) */
+ maxScore: number
+}
+
+export const DEFAULT_HEALTH_SCORE_CONFIG: HealthScoreConfig = {
+ initial: 70,
+ successReward: 1,
+ rateLimitPenalty: -10,
+ failurePenalty: -20,
+ recoveryRatePerHour: 2,
+ minUsable: 50,
+ maxScore: 100,
+}
+
+interface HealthScoreState {
+ score: number
+ lastUpdated: number
+ lastSuccess: number
+ consecutiveFailures: number
+}
+
+/**
+ * Tracks health scores for accounts.
+ * Higher score = healthier account = preferred for selection.
+ */
+export class HealthScoreTracker {
+ private readonly scores = new Map()
+ private readonly config: HealthScoreConfig
+
+ private readonly now: () => number
+
+ constructor(
+ config: Partial = {},
+ now: () => number = Date.now,
+ ) {
+ this.now = now
+ this.config = { ...DEFAULT_HEALTH_SCORE_CONFIG, ...config }
+ }
+
+ /**
+ * Get current health score for an account, applying time-based recovery.
+ */
+ getScore(accountIndex: number): number {
+ const state = this.scores.get(accountIndex)
+ if (!state) {
+ return this.config.initial
+ }
+
+ // Apply passive recovery based on time since last update
+ const now = this.now()
+ const hoursSinceUpdate = (now - state.lastUpdated) / (1000 * 60 * 60)
+ const recoveredPoints = Math.floor(
+ hoursSinceUpdate * this.config.recoveryRatePerHour,
+ )
+
+ return Math.min(this.config.maxScore, state.score + recoveredPoints)
+ }
+
+ /**
+ * Record a successful request - improves health score.
+ */
+ recordSuccess(accountIndex: number): void {
+ const now = this.now()
+ const current = this.getScore(accountIndex)
+
+ this.scores.set(accountIndex, {
+ score: Math.min(
+ this.config.maxScore,
+ current + this.config.successReward,
+ ),
+ lastUpdated: now,
+ lastSuccess: now,
+ consecutiveFailures: 0,
+ })
+ }
+
+ /**
+ * Record a rate limit hit - moderate penalty.
+ */
+ recordRateLimit(accountIndex: number): void {
+ const now = this.now()
+ const state = this.scores.get(accountIndex)
+ const current = this.getScore(accountIndex)
+
+ this.scores.set(accountIndex, {
+ score: Math.max(0, current + this.config.rateLimitPenalty),
+ lastUpdated: now,
+ lastSuccess: state?.lastSuccess ?? 0,
+ consecutiveFailures: (state?.consecutiveFailures ?? 0) + 1,
+ })
+ }
+
+ /**
+ * Record a failure (auth, network, etc.) - larger penalty.
+ */
+ recordFailure(accountIndex: number): void {
+ const now = this.now()
+ const state = this.scores.get(accountIndex)
+ const current = this.getScore(accountIndex)
+
+ this.scores.set(accountIndex, {
+ score: Math.max(0, current + this.config.failurePenalty),
+ lastUpdated: now,
+ lastSuccess: state?.lastSuccess ?? 0,
+ consecutiveFailures: (state?.consecutiveFailures ?? 0) + 1,
+ })
+ }
+
+ /**
+ * Check if account is healthy enough to use.
+ */
+ isUsable(accountIndex: number): boolean {
+ return this.getScore(accountIndex) >= this.config.minUsable
+ }
+
+ /**
+ * Get consecutive failure count for an account.
+ */
+ getConsecutiveFailures(accountIndex: number): number {
+ return this.scores.get(accountIndex)?.consecutiveFailures ?? 0
+ }
+
+ /**
+ * Reset health state for an account (e.g., after removal).
+ */
+ reset(accountIndex: number): void {
+ this.scores.delete(accountIndex)
+ }
+
+ /**
+ * Get all scores for debugging/logging.
+ */
+ getSnapshot(): Map {
+ const result = new Map<
+ number,
+ { score: number; consecutiveFailures: number }
+ >()
+ for (const [index] of this.scores) {
+ result.set(index, {
+ score: this.getScore(index),
+ consecutiveFailures: this.getConsecutiveFailures(index),
+ })
+ }
+ return result
+ }
+}
+
+// ============================================================================
+// JITTER UTILITIES
+// ============================================================================
+
+/**
+ * Add random jitter to a delay value.
+ * Helps break predictable timing patterns.
+ *
+ * @param baseMs - Base delay in milliseconds
+ * @param jitterFactor - Fraction of base to vary (default: 0.3 = ±30%)
+ * @returns Jittered delay in milliseconds
+ */
+export function addJitter(
+ baseMs: number,
+ jitterFactor: number = 0.3,
+ random: () => number = Math.random,
+): number {
+ const jitterRange = baseMs * jitterFactor
+ const jitter = (random() * 2 - 1) * jitterRange // -jitterRange to +jitterRange
+ return Math.max(0, Math.round(baseMs + jitter))
+}
+
+/**
+ * Generate a random delay within a range.
+ *
+ * @param minMs - Minimum delay in milliseconds
+ * @param maxMs - Maximum delay in milliseconds
+ * @returns Random delay between min and max
+ */
+export function randomDelay(
+ minMs: number,
+ maxMs: number,
+ random: () => number = Math.random,
+): number {
+ return Math.round(minMs + random() * (maxMs - minMs))
+}
+
+// ============================================================================
+// LRU SELECTION
+// ============================================================================
+
+export interface AccountWithMetrics {
+ index: number
+ lastUsed: number
+ healthScore: number
+ isRateLimited: boolean
+ isCoolingDown: boolean
+}
+
+/**
+ * Sort accounts by LRU (least recently used first) with health score tiebreaker.
+ *
+ * Priority:
+ * 1. Filter out rate-limited and cooling-down accounts
+ * 2. Filter out unhealthy accounts (score < minUsable)
+ * 3. Sort by lastUsed ascending (oldest first = most rested)
+ * 4. Tiebreaker: higher health score wins
+ */
+export function sortByLruWithHealth(
+ accounts: AccountWithMetrics[],
+ minHealthScore: number = 50,
+): AccountWithMetrics[] {
+ return accounts
+ .filter(
+ (acc) =>
+ !acc.isRateLimited &&
+ !acc.isCoolingDown &&
+ acc.healthScore >= minHealthScore,
+ )
+ .sort((a, b) => {
+ // Primary: LRU (oldest lastUsed first)
+ const lruDiff = a.lastUsed - b.lastUsed
+ if (lruDiff !== 0) return lruDiff
+
+ // Tiebreaker: higher health score wins
+ return b.healthScore - a.healthScore
+ })
+}
+
+/** Stickiness bonus added to current account's score to prevent unnecessary switching */
+const STICKINESS_BONUS = 150
+
+/** Minimum score advantage required to switch away from current account */
+const SWITCH_THRESHOLD = 100
+
+/**
+ * Select account using hybrid strategy with stickiness:
+ * 1. Filter available accounts (not rate-limited, not cooling down, healthy, has tokens)
+ * 2. Calculate priority score: health (2x) + tokens (5x) + freshness (0.1x)
+ * 3. Apply stickiness bonus to current account
+ * 4. Only switch if another account beats current by SWITCH_THRESHOLD
+ *
+ * @param accounts - All accounts with their metrics
+ * @param tokenTracker - Token bucket tracker for token balances
+ * @param currentAccountIndex - Currently active account index (for stickiness)
+ * @param minHealthScore - Minimum health score to be considered
+ * @returns Best account index, or null if none available
+ */
+export function selectHybridAccount(
+ accounts: AccountWithMetrics[],
+ tokenTracker: TokenBucketTracker,
+ currentAccountIndex: number | null = null,
+ minHealthScore: number = 50,
+ now: () => number = Date.now,
+): number | null {
+ const candidates = accounts
+ .filter(
+ (acc) =>
+ !acc.isRateLimited &&
+ !acc.isCoolingDown &&
+ acc.healthScore >= minHealthScore &&
+ tokenTracker.hasTokens(acc.index),
+ )
+ .map((acc) => ({
+ ...acc,
+ tokens: tokenTracker.getTokens(acc.index),
+ }))
+
+ if (candidates.length === 0) {
+ return null
+ }
+
+ const maxTokens = tokenTracker.getMaxTokens()
+ const scored = candidates
+ .map((acc) => {
+ const baseScore = calculateHybridScore(acc, maxTokens, now)
+ // Apply stickiness bonus to current account
+ const stickinessBonus =
+ acc.index === currentAccountIndex ? STICKINESS_BONUS : 0
+ return {
+ index: acc.index,
+ baseScore,
+ score: baseScore + stickinessBonus,
+ isCurrent: acc.index === currentAccountIndex,
+ }
+ })
+ .sort((a, b) => b.score - a.score)
+
+ const best = scored[0]
+ if (!best) {
+ return null
+ }
+
+ // If current account is still a candidate, check if switch is warranted
+ const currentCandidate = scored.find((s) => s.isCurrent)
+ if (currentCandidate && !best.isCurrent) {
+ // Only switch if best beats current's BASE score by threshold
+ // (compare base scores to avoid circular stickiness bonus comparison)
+ const advantage = best.baseScore - currentCandidate.baseScore
+ if (advantage < SWITCH_THRESHOLD) {
+ return currentCandidate.index
+ }
+ }
+
+ return best.index
+}
+
+interface AccountWithTokens extends AccountWithMetrics {
+ tokens: number
+}
+
+function calculateHybridScore(
+ account: AccountWithTokens,
+ maxTokens: number,
+ now: () => number,
+): number {
+ const healthComponent = account.healthScore * 2 // 0-200
+ const tokenComponent = (account.tokens / maxTokens) * 100 * 5 // 0-500
+ const secondsSinceUsed = (now() - account.lastUsed) / 1000
+ const freshnessComponent = Math.min(secondsSinceUsed, 3600) * 0.1 // 0-360
+ return Math.max(0, healthComponent + tokenComponent + freshnessComponent)
+}
+
+// ============================================================================
+// TOKEN BUCKET SYSTEM
+// ============================================================================
+
+export interface TokenBucketConfig {
+ /** Maximum tokens per account (default: 50) */
+ maxTokens: number
+ /** Tokens regenerated per minute (default: 6) */
+ regenerationRatePerMinute: number
+ /** Initial tokens for new accounts (default: 50) */
+ initialTokens: number
+}
+
+export const DEFAULT_TOKEN_BUCKET_CONFIG: TokenBucketConfig = {
+ maxTokens: 50,
+ regenerationRatePerMinute: 6,
+ initialTokens: 50,
+}
+
+interface TokenBucketState {
+ tokens: number
+ lastUpdated: number
+}
+
+/**
+ * Client-side rate limiting using Token Bucket algorithm.
+ * Helps prevent hitting server 429s by tracking "cost" of requests.
+ */
+export class TokenBucketTracker {
+ private readonly buckets = new Map()
+ private readonly config: TokenBucketConfig
+
+ private readonly now: () => number
+
+ constructor(
+ config: Partial = {},
+ now: () => number = Date.now,
+ ) {
+ this.now = now
+ this.config = { ...DEFAULT_TOKEN_BUCKET_CONFIG, ...config }
+ }
+
+ /**
+ * Get current token balance for an account, applying regeneration.
+ */
+ getTokens(accountIndex: number): number {
+ const state = this.buckets.get(accountIndex)
+ if (!state) {
+ return this.config.initialTokens
+ }
+
+ const now = this.now()
+ const minutesSinceUpdate = (now - state.lastUpdated) / (1000 * 60)
+ const recoveredTokens =
+ minutesSinceUpdate * this.config.regenerationRatePerMinute
+
+ return Math.min(this.config.maxTokens, state.tokens + recoveredTokens)
+ }
+
+ /**
+ * Check if account has enough tokens for a request.
+ * @param cost Cost of the request (default: 1)
+ */
+ hasTokens(accountIndex: number, cost: number = 1): boolean {
+ return this.getTokens(accountIndex) >= cost
+ }
+
+ /**
+ * Consume tokens for a request.
+ * @returns true if tokens were consumed, false if insufficient
+ */
+ consume(accountIndex: number, cost: number = 1): boolean {
+ const current = this.getTokens(accountIndex)
+ if (current < cost) {
+ return false
+ }
+
+ this.buckets.set(accountIndex, {
+ tokens: current - cost,
+ lastUpdated: this.now(),
+ })
+ return true
+ }
+
+ /**
+ * Refund tokens (e.g., if request wasn't actually sent).
+ */
+ refund(accountIndex: number, amount: number = 1): void {
+ const current = this.getTokens(accountIndex)
+ this.buckets.set(accountIndex, {
+ tokens: Math.min(this.config.maxTokens, current + amount),
+ lastUpdated: this.now(),
+ })
+ }
+
+ getMaxTokens(): number {
+ return this.config.maxTokens
+ }
+}
+
+// ============================================================================
+// SINGLETON TRACKERS
+// ============================================================================
+
+let globalTokenTracker: TokenBucketTracker | null = null
+
+export function getTokenTracker(): TokenBucketTracker {
+ if (!globalTokenTracker) {
+ globalTokenTracker = new TokenBucketTracker()
+ }
+ return globalTokenTracker
+}
+
+export function initTokenTracker(
+ config: Partial,
+): TokenBucketTracker {
+ globalTokenTracker = new TokenBucketTracker(config)
+ return globalTokenTracker
+}
+
+let globalHealthTracker: HealthScoreTracker | null = null
+
+/**
+ * Get the global health score tracker instance.
+ * Creates one with default config if not initialized.
+ */
+export function getHealthTracker(): HealthScoreTracker {
+ if (!globalHealthTracker) {
+ globalHealthTracker = new HealthScoreTracker()
+ }
+ return globalHealthTracker
+}
+
+/**
+ * Initialize the global health tracker with custom config.
+ * Call this at plugin startup if custom config is needed.
+ */
+export function initHealthTracker(
+ config: Partial,
+): HealthScoreTracker {
+ globalHealthTracker = new HealthScoreTracker(config)
+ return globalHealthTracker
+}
diff --git a/packages/core/src/transform/claude.test.ts b/packages/core/src/transform/claude.test.ts
index 510acaa..ba810b4 100644
--- a/packages/core/src/transform/claude.test.ts
+++ b/packages/core/src/transform/claude.test.ts
@@ -1,849 +1,953 @@
-import { describe, it, expect } from "vitest";
+import { describe, expect, it } from 'bun:test'
import {
- isClaudeModel,
- isClaudeThinkingModel,
- configureClaudeToolConfig,
- buildClaudeThinkingConfig,
- ensureClaudeMaxOutputTokens,
- computeClaudeMaxOutputTokens,
appendClaudeThinkingHint,
- normalizeClaudeTools,
applyClaudeTransforms,
- CLAUDE_THINKING_MAX_OUTPUT_TOKENS,
+ buildClaudeThinkingConfig,
CLAUDE_INTERLEAVED_THINKING_HINT,
-} from "./claude";import type { RequestPayload } from "./types";
-
-describe("isClaudeModel", () => {
- it("returns true for claude model names", () => {
- expect(isClaudeModel("claude-sonnet-4-5")).toBe(true);
- expect(isClaudeModel("claude-opus-4-5")).toBe(true);
- expect(isClaudeModel("claude-3-opus")).toBe(true);
- expect(isClaudeModel("claude-3-5-sonnet")).toBe(true);
- });
-
- it("returns true for case-insensitive matches", () => {
- expect(isClaudeModel("CLAUDE-SONNET-4-5")).toBe(true);
- expect(isClaudeModel("Claude-Opus-4-5")).toBe(true);
- expect(isClaudeModel("cLaUdE-3-opus")).toBe(true);
- });
-
- it("returns true for prefixed claude models", () => {
- expect(isClaudeModel("antigravity-claude-sonnet-4-5")).toBe(true);
- expect(isClaudeModel("google/claude-opus-4-5")).toBe(true);
- });
-
- it("returns false for non-claude models", () => {
- expect(isClaudeModel("gemini-3-pro")).toBe(false);
- expect(isClaudeModel("gpt-4")).toBe(false);
- expect(isClaudeModel("llama-3")).toBe(false);
- expect(isClaudeModel("")).toBe(false);
- });
-
- it("returns false for similar but non-claude names", () => {
- expect(isClaudeModel("claudia-model")).toBe(false);
- expect(isClaudeModel("clade-model")).toBe(false);
- });
-});
-
-describe("isClaudeThinkingModel", () => {
- it("returns true for claude thinking models", () => {
- expect(isClaudeThinkingModel("claude-sonnet-4-5-thinking")).toBe(true);
- expect(isClaudeThinkingModel("claude-opus-4-5-thinking")).toBe(true);
- expect(isClaudeThinkingModel("claude-sonnet-4-5-thinking-high")).toBe(true);
- expect(isClaudeThinkingModel("claude-opus-4-5-thinking-low")).toBe(true);
- });
-
- it("returns true for case-insensitive matches", () => {
- expect(isClaudeThinkingModel("CLAUDE-SONNET-4-5-THINKING")).toBe(true);
- expect(isClaudeThinkingModel("Claude-Opus-4-5-Thinking")).toBe(true);
- });
-
- it("returns true for prefixed thinking models", () => {
- expect(isClaudeThinkingModel("antigravity-claude-sonnet-4-5-thinking")).toBe(true);
- expect(isClaudeThinkingModel("google/claude-opus-4-5-thinking-high")).toBe(true);
- });
-
- it("returns false for non-thinking claude models", () => {
- expect(isClaudeThinkingModel("claude-sonnet-4-5")).toBe(false);
- expect(isClaudeThinkingModel("claude-opus-4-5")).toBe(false);
- expect(isClaudeThinkingModel("claude-3-opus")).toBe(false);
- });
-
- it("returns false for non-claude models", () => {
- expect(isClaudeThinkingModel("gemini-3-pro-thinking")).toBe(false);
- expect(isClaudeThinkingModel("gpt-4-thinking")).toBe(false);
- });
-
- it("requires both claude and thinking keywords", () => {
- expect(isClaudeThinkingModel("thinking-model")).toBe(false);
- expect(isClaudeThinkingModel("claude-model")).toBe(false);
- });
-});
-
-describe("configureClaudeToolConfig", () => {
- it("creates toolConfig if not present", () => {
- const payload: RequestPayload = {};
- configureClaudeToolConfig(payload);
-
- expect(payload.toolConfig).toBeDefined();
- expect((payload.toolConfig as any).functionCallingConfig).toBeDefined();
- expect((payload.toolConfig as any).functionCallingConfig.mode).toBe("VALIDATED");
- });
-
- it("adds functionCallingConfig to existing toolConfig", () => {
+ CLAUDE_THINKING_MAX_OUTPUT_TOKENS,
+ computeClaudeMaxOutputTokens,
+ configureClaudeToolConfig,
+ ensureClaudeMaxOutputTokens,
+ isClaudeModel,
+ isClaudeThinkingModel,
+ normalizeClaudeTools,
+} from './claude'
+import type { RequestPayload } from './types'
+
+describe('isClaudeModel', () => {
+ it('returns true for claude model names', () => {
+ expect(isClaudeModel('claude-sonnet-4-5')).toBe(true)
+ expect(isClaudeModel('claude-opus-4-5')).toBe(true)
+ expect(isClaudeModel('claude-3-opus')).toBe(true)
+ expect(isClaudeModel('claude-3-5-sonnet')).toBe(true)
+ })
+
+ it('returns true for case-insensitive matches', () => {
+ expect(isClaudeModel('CLAUDE-SONNET-4-5')).toBe(true)
+ expect(isClaudeModel('Claude-Opus-4-5')).toBe(true)
+ expect(isClaudeModel('cLaUdE-3-opus')).toBe(true)
+ })
+
+ it('returns true for prefixed claude models', () => {
+ expect(isClaudeModel('antigravity-claude-sonnet-4-5')).toBe(true)
+ expect(isClaudeModel('google/claude-opus-4-5')).toBe(true)
+ })
+
+ it('returns false for non-claude models', () => {
+ expect(isClaudeModel('gemini-3-pro')).toBe(false)
+ expect(isClaudeModel('gpt-4')).toBe(false)
+ expect(isClaudeModel('llama-3')).toBe(false)
+ expect(isClaudeModel('')).toBe(false)
+ })
+
+ it('returns false for similar but non-claude names', () => {
+ expect(isClaudeModel('claudia-model')).toBe(false)
+ expect(isClaudeModel('clade-model')).toBe(false)
+ })
+})
+
+describe('isClaudeThinkingModel', () => {
+ it('returns true for claude thinking models', () => {
+ expect(isClaudeThinkingModel('claude-sonnet-4-5-thinking')).toBe(true)
+ expect(isClaudeThinkingModel('claude-opus-4-5-thinking')).toBe(true)
+ expect(isClaudeThinkingModel('claude-sonnet-4-5-thinking-high')).toBe(true)
+ expect(isClaudeThinkingModel('claude-opus-4-5-thinking-low')).toBe(true)
+ })
+
+ it('returns true for case-insensitive matches', () => {
+ expect(isClaudeThinkingModel('CLAUDE-SONNET-4-5-THINKING')).toBe(true)
+ expect(isClaudeThinkingModel('Claude-Opus-4-5-Thinking')).toBe(true)
+ })
+
+ it('returns true for prefixed thinking models', () => {
+ expect(
+ isClaudeThinkingModel('antigravity-claude-sonnet-4-5-thinking'),
+ ).toBe(true)
+ expect(isClaudeThinkingModel('google/claude-opus-4-5-thinking-high')).toBe(
+ true,
+ )
+ })
+
+ it('returns false for non-thinking claude models', () => {
+ expect(isClaudeThinkingModel('claude-sonnet-4-5')).toBe(false)
+ expect(isClaudeThinkingModel('claude-opus-4-5')).toBe(false)
+ expect(isClaudeThinkingModel('claude-3-opus')).toBe(false)
+ })
+
+ it('returns false for non-claude models', () => {
+ expect(isClaudeThinkingModel('gemini-3-pro-thinking')).toBe(false)
+ expect(isClaudeThinkingModel('gpt-4-thinking')).toBe(false)
+ })
+
+ it('requires both claude and thinking keywords', () => {
+ expect(isClaudeThinkingModel('thinking-model')).toBe(false)
+ expect(isClaudeThinkingModel('claude-model')).toBe(false)
+ })
+})
+
+describe('configureClaudeToolConfig', () => {
+ it('creates toolConfig if not present', () => {
+ const payload: RequestPayload = {}
+ configureClaudeToolConfig(payload)
+
+ expect(payload.toolConfig).toBeDefined()
+ expect((payload.toolConfig as any).functionCallingConfig).toBeDefined()
+ expect((payload.toolConfig as any).functionCallingConfig.mode).toBe(
+ 'VALIDATED',
+ )
+ })
+
+ it('adds functionCallingConfig to existing toolConfig', () => {
const payload: RequestPayload = {
toolConfig: { someOtherConfig: true },
- };
- configureClaudeToolConfig(payload);
-
- expect((payload.toolConfig as any).someOtherConfig).toBe(true);
- expect((payload.toolConfig as any).functionCallingConfig.mode).toBe("VALIDATED");
- });
-
- it("sets mode to VALIDATED on existing functionCallingConfig", () => {
+ }
+ configureClaudeToolConfig(payload)
+
+ expect((payload.toolConfig as any).someOtherConfig).toBe(true)
+ expect((payload.toolConfig as any).functionCallingConfig.mode).toBe(
+ 'VALIDATED',
+ )
+ })
+
+ it('sets mode to VALIDATED on existing functionCallingConfig', () => {
const payload: RequestPayload = {
toolConfig: {
- functionCallingConfig: { existingKey: "value" },
+ functionCallingConfig: { existingKey: 'value' },
},
- };
- configureClaudeToolConfig(payload);
-
- expect((payload.toolConfig as any).functionCallingConfig.existingKey).toBe("value");
- expect((payload.toolConfig as any).functionCallingConfig.mode).toBe("VALIDATED");
- });
-
- it("overwrites existing mode", () => {
+ }
+ configureClaudeToolConfig(payload)
+
+ expect((payload.toolConfig as any).functionCallingConfig.existingKey).toBe(
+ 'value',
+ )
+ expect((payload.toolConfig as any).functionCallingConfig.mode).toBe(
+ 'VALIDATED',
+ )
+ })
+
+ it('overwrites existing mode', () => {
const payload: RequestPayload = {
toolConfig: {
- functionCallingConfig: { mode: "AUTO" },
+ functionCallingConfig: { mode: 'AUTO' },
},
- };
- configureClaudeToolConfig(payload);
-
- expect((payload.toolConfig as any).functionCallingConfig.mode).toBe("VALIDATED");
- });
-
- it("handles null toolConfig gracefully", () => {
- const payload: RequestPayload = { toolConfig: null };
- configureClaudeToolConfig(payload);
-
- expect(payload.toolConfig).toBeDefined();
- });
-});
-
-describe("buildClaudeThinkingConfig", () => {
- it("builds config with include_thoughts only", () => {
- const config = buildClaudeThinkingConfig(true);
-
- expect(config).toEqual({ include_thoughts: true });
- });
-
- it("builds config with include_thoughts false", () => {
- const config = buildClaudeThinkingConfig(false);
-
- expect(config).toEqual({ include_thoughts: false });
- });
-
- it("includes thinking_budget when provided and positive", () => {
- const config = buildClaudeThinkingConfig(true, 8192);
-
+ }
+ configureClaudeToolConfig(payload)
+
+ expect((payload.toolConfig as any).functionCallingConfig.mode).toBe(
+ 'VALIDATED',
+ )
+ })
+
+ it('handles null toolConfig gracefully', () => {
+ const payload: RequestPayload = { toolConfig: null }
+ configureClaudeToolConfig(payload)
+
+ expect(payload.toolConfig).toBeDefined()
+ })
+})
+
+describe('buildClaudeThinkingConfig', () => {
+ it('builds config with include_thoughts only', () => {
+ const config = buildClaudeThinkingConfig(true)
+
+ expect(config).toEqual({ include_thoughts: true })
+ })
+
+ it('builds config with include_thoughts false', () => {
+ const config = buildClaudeThinkingConfig(false)
+
+ expect(config).toEqual({ include_thoughts: false })
+ })
+
+ it('includes thinking_budget when provided and positive', () => {
+ const config = buildClaudeThinkingConfig(true, 8192)
+
expect(config).toEqual({
include_thoughts: true,
thinking_budget: 8192,
- });
- });
-
- it("excludes thinking_budget when zero", () => {
- const config = buildClaudeThinkingConfig(true, 0);
-
- expect(config).toEqual({ include_thoughts: true });
- });
-
- it("excludes thinking_budget when negative", () => {
- const config = buildClaudeThinkingConfig(true, -100);
-
- expect(config).toEqual({ include_thoughts: true });
- });
-
- it("excludes thinking_budget when undefined", () => {
- const config = buildClaudeThinkingConfig(true, undefined);
-
- expect(config).toEqual({ include_thoughts: true });
- });
-
- it("handles various budget values", () => {
- expect(buildClaudeThinkingConfig(true, 8192)).toHaveProperty("thinking_budget", 8192);
- expect(buildClaudeThinkingConfig(true, 16384)).toHaveProperty("thinking_budget", 16384);
- expect(buildClaudeThinkingConfig(true, 32768)).toHaveProperty("thinking_budget", 32768);
- });
-});
-
-describe("ensureClaudeMaxOutputTokens", () => {
- it("sets maxOutputTokens when not present", () => {
- const config: Record