Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@
`<server>_<tool>` names, which the shared MCP pipeline did not recognize.
The provider now normalizes these to the canonical `mcp__<server>__<tool>`
form so MCP breakdowns and `optimize` work correctly. Closes #308.
- **Antigravity Windows language-server discovery.** Antigravity detection now
supports Windows process discovery, `--extension_server_port`,
`--extension_server_csrf_token`, `--flag=value` syntax, and both wrapped and
unwrapped Connect-RPC response shapes. Closes #249.
- **Mangled project names in dashboard.** The By Project and Top Sessions
panels decoded slugs by splitting on `-`, which broke directory names
containing dashes or dots (e.g. `my-project` rendered as `my/project`).
Expand Down
33 changes: 21 additions & 12 deletions docs/providers/antigravity.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,41 +3,50 @@
Google Antigravity. The only provider that does not read files off disk: it speaks to a local language-server RPC endpoint instead.

- **Source:** `src/providers/antigravity.ts`
- **Loading:** lazy (`src/providers/index.ts:14-27`). Lazy because the protobuf dependency is heavy.
- **Test:** none. Mocking the RPC endpoint cleanly is the open issue.
- **Loading:** lazy via `src/providers/index.ts`. Lazy because the protobuf dependency is heavy.
- **Test:** focused helper coverage in `tests/providers/antigravity.test.ts`.

## Where it reads from

A local HTTPS RPC endpoint exposed by Antigravity's language server. The parser:

1. Locates the running language-server process via `ps`.
1. Locates the running language-server process via `ps` on POSIX or
`Get-CimInstance Win32_Process` on Windows.
2. Reads its port and CSRF token from process metadata.
3. Calls `GetCascadeTrajectoryGeneratorMetadata` over HTTPS.
4. Validates the response (capped at 5-15 MB depending on cascade size).
4. Validates the response (capped at 16 MB).

If the language server is not running, the parser falls back to the cached results file (`antigravity.ts:262-272`).
Antigravity exposes slightly different process flags across platforms:
POSIX builds have used `--https_server_port` and `--csrf_token`; Windows
builds can expose `--extension_server_port` and
`--extension_server_csrf_token`. Both space-separated and `--flag=value`
forms are supported.

If the language server is not running, the parser falls back to the cached results file.

## Storage format

Protobuf. Cascade and response objects map to `ParsedProviderCall` directly; see `antigravity.ts:299-323`.
Protobuf. Cascade and response objects map to `ParsedProviderCall` directly.

## Caching

Custom file cache at `$CODEBURN_CACHE_DIR/antigravity-results.json` (defaults to `~/.cache/codeburn/`). The version constant is at `antigravity.ts:12`; the cache machinery (`loadCache`, `flushCache`) lives in `antigravity.ts:75-125`. The cache is also used as the data source when the RPC endpoint is unavailable, not just as an optimization. Bumping the cache version forces a recompute.
Custom file cache at `$CODEBURN_CACHE_DIR/antigravity-results.json` (defaults to `~/.cache/codeburn/`). The cache is also used as the data source when the RPC endpoint is unavailable, not just as an optimization. Bumping the cache version forces a recompute.

## Deduplication

Per `<cascadeId>:<responseId>` (`antigravity.ts:308`).
Per `<cascadeId>:<responseId>`.

## Quirks

- **Antigravity is the only provider that requires a live process.** A user who closes Antigravity loses the most-recent data until next launch (the cache covers older runs).
- The 5-15 MB cap on RPC responses is necessary because individual cascades can balloon. Raising it risks OOM on the user's machine.
- Token types are split across `inputTokens`, `responseOutputTokens`, and `thinkingOutputTokens` (`antigravity.ts:313-323`). Thinking is billed at output rate.
- The 16 MB cap on RPC responses is necessary because individual cascades can balloon. Raising it risks OOM on the user's machine.
- Token types are split across `inputTokens`, `responseOutputTokens`, and `thinkingOutputTokens`. Thinking is billed at output rate.

## When fixing a bug here

1. Reproducing requires Antigravity running locally. There is no fixture for the RPC, which is a real testing gap.
1. Reproducing the full provider path requires Antigravity running locally.
The unit tests cover process flag parsing and wrapped/unwrapped RPC response
extraction, but they do not stand up a live Antigravity RPC endpoint.
2. Before any change, capture a sample protobuf response (anonymized) so future regressions can be tested against a recording.
3. If the bug is "no data after Antigravity update", the protobuf schema may have shifted. The parser's response handling at `antigravity.ts:299-323` is the place to look.
3. If the bug is "no data after Antigravity update", the protobuf schema may have shifted. The parser's response handling is the place to look.
4. If the bug is "stale data", check whether the RPC is reachable; the cache fallback can mask connectivity issues.
146 changes: 110 additions & 36 deletions src/providers/antigravity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ const CACHE_VERSION = 2
const RPC_TIMEOUT_MS = 5000
const MAX_RESPONSE_BYTES = 16 * 1024 * 1024

type ServerInfo = {
export type ServerInfo = {
port: number
csrfToken: string
}
Expand All @@ -31,7 +31,7 @@ type UsageEntry = {
responseId?: string
}

type GeneratorMetadata = {
export type GeneratorMetadata = {
stepIndices?: number[]
chatModel?: {
model: string
Expand All @@ -42,6 +42,20 @@ type GeneratorMetadata = {
}
}

type ModelMapResponse = {
models?: Record<string, { model?: string }>
response?: {
models?: Record<string, { model?: string }>
}
}

type GeneratorMetadataResponse = {
generatorMetadata?: GeneratorMetadata[]
response?: {
generatorMetadata?: GeneratorMetadata[]
}
}

type CachedCascade = {
mtimeMs: number
sizeBytes: number
Expand All @@ -59,6 +73,9 @@ let memCache: AntigravityCache | null = null
let cacheDirty = false
let httpsAgent: https.Agent | undefined

const SERVER_PORT_FLAGS = ['https_server_port', 'extension_server_port']
const CSRF_TOKEN_FLAGS = ['csrf_token', 'extension_server_csrf_token']

function getAgent(): https.Agent {
if (!httpsAgent) httpsAgent = new https.Agent({ rejectUnauthorized: false })
return httpsAgent
Expand All @@ -72,6 +89,72 @@ function getCachePath(): string {
return join(getCacheDir(), 'antigravity-results.json')
}

function execFileText(command: string, args: string[], timeout = 3000): Promise<string> {
return new Promise((resolve, reject) => {
execFile(command, args, { encoding: 'utf-8', timeout, maxBuffer: 1024 * 1024 }, (err, stdout) => {
if (err) reject(err)
else resolve(stdout)
})
})
}

function getFlagValue(line: string, names: string[]): string | null {
for (const name of names) {
const match = line.match(new RegExp(`--${name}(?:=|\\s+)(?:"([^"]+)"|'([^']+)'|([^\\s]+))`, 'i'))
const value = match?.[1] ?? match?.[2] ?? match?.[3]
if (value && !value.startsWith('--')) return value
}
return null
}

function isLikelyCsrfToken(value: string): boolean {
return value.length >= 16 && /^[A-Za-z0-9._~:/+=-]+$/.test(value)
}

export function parseAntigravityServerInfoFromLine(line: string): ServerInfo | null {
const lower = line.toLowerCase()
if (!lower.includes('language_server') || !lower.includes('antigravity')) return null

const rawPort = getFlagValue(line, SERVER_PORT_FLAGS)
const csrfToken = getFlagValue(line, CSRF_TOKEN_FLAGS)
if (!rawPort || !csrfToken) return null
if (!isLikelyCsrfToken(csrfToken)) return null

const port = Number(rawPort)
if (!Number.isInteger(port) || port <= 0 || port > 65535) return null

return { port, csrfToken }
}

export function parseAntigravityServerInfo(lines: string[]): ServerInfo | null {
for (const line of lines) {
const server = parseAntigravityServerInfoFromLine(line)
if (server) return server
}
return null
}

export function extractAntigravityModelMap(resp: unknown): ModelMap {
if (!resp || typeof resp !== 'object') return {}
const data = resp as ModelMapResponse
const models = data.response?.models ?? data.models
const map: ModelMap = {}
if (!models) return map
for (const [key, info] of Object.entries(models)) {
if (info && typeof info === 'object' && typeof info.model === 'string') {
map[info.model] = key
}
}
return map
}

export function extractAntigravityGeneratorMetadata(resp: unknown): GeneratorMetadata[] {
if (!resp || typeof resp !== 'object') return []
const data = resp as GeneratorMetadataResponse
const metadata = data.response?.generatorMetadata ?? data.generatorMetadata
return Array.isArray(metadata) ? metadata : []
}

async function loadCache(): Promise<AntigravityCache> {
if (memCache) return memCache
try {
Expand Down Expand Up @@ -124,27 +207,27 @@ async function flushCache(liveCascadeIds?: Set<string>): Promise<void> {
} catch { /* best-effort */ }
}

async function readProcessCommandLines(): Promise<string[]> {
if (process.platform === 'win32') {
const script = [
"$ErrorActionPreference = 'SilentlyContinue'",
'[Console]::OutputEncoding = [System.Text.Encoding]::UTF8',
"Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -and $_.CommandLine -like '*language_server*' -and $_.CommandLine -like '*antigravity*' } | ForEach-Object { $_.CommandLine }",
].join('; ')
const output = await execFileText('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script], 5000)
return output.split(/\r?\n/)
}

const output = await execFileText('ps', ['-ww', '-eo', 'args'])
return output.split('\n')
}

async function detectServer(): Promise<ServerInfo | null> {
if (cachedServer !== undefined) return cachedServer
try {
const output = await new Promise<string>((resolve, reject) => {
execFile('ps', ['-eo', 'args'], { encoding: 'utf-8', timeout: 3000 }, (err, stdout) => {
if (err) reject(err)
else resolve(stdout)
})
})
for (const line of output.split('\n')) {
if (!line.includes('language_server') || !line.includes('antigravity')) continue
if (!line.includes('--https_server_port')) continue

const csrfMatch = line.match(/--csrf_token\s+([0-9a-f-]{32,})/)
const portMatch = line.match(/--https_server_port\s+(\d+)/)
if (csrfMatch && portMatch) {
cachedServer = { csrfToken: csrfMatch[1]!, port: parseInt(portMatch[1]!, 10) }
return cachedServer
}
}
} catch { /* ps failed or timed out */ }
cachedServer = parseAntigravityServerInfo(await readProcessCommandLines())
return cachedServer
} catch { /* process discovery failed or timed out */ }
cachedServer = null
return null
}
Expand Down Expand Up @@ -199,20 +282,12 @@ async function rpc(server: ServerInfo, method: string, body: Record<string, unkn

async function getModelMap(server: ServerInfo): Promise<ModelMap> {
if (cachedModelMap) return cachedModelMap
const map: ModelMap = {}
try {
const resp = await rpc(server, 'GetAvailableModels') as {
response?: { models?: Record<string, { model?: string }> }
}
const models = resp?.response?.models
if (models) {
for (const [key, info] of Object.entries(models)) {
if (info.model) map[info.model] = key
}
}
cachedModelMap = extractAntigravityModelMap(await rpc(server, 'GetAvailableModels'))
return cachedModelMap
} catch { /* best-effort */ }
cachedModelMap = map
return map
cachedModelMap = {}
return cachedModelMap
}

// Strip Antigravity-specific suffixes so the pricing DB can match
Expand Down Expand Up @@ -275,10 +350,9 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars

let metadata: GeneratorMetadata[]
try {
const resp = await rpc(server, 'GetCascadeTrajectoryGeneratorMetadata', { cascadeId }) as {
generatorMetadata?: GeneratorMetadata[]
}
metadata = resp?.generatorMetadata ?? []
metadata = extractAntigravityGeneratorMetadata(
await rpc(server, 'GetCascadeTrajectoryGeneratorMetadata', { cascadeId }),
)
} catch {
if (cached) {
for (const call of cached.calls) {
Expand Down
Loading
Loading