feat(antigravity-ide): added support for antigravity IDE (#418)

This commit is contained in:
KirDE 2026-06-09 22:12:01 +02:00 committed by GitHub
parent cf35854b09
commit 35a8518518
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 244 additions and 33 deletions

View file

@ -136,7 +136,7 @@ The `--provider` flag filters any command to a single provider: `codeburn report
**Gemini CLI** stores sessions as single JSON files. Each session embeds real token counts (input, output, cached, thoughts) per message, so no estimation is needed. Gemini reports input tokens inclusive of cached; CodeBurn subtracts cached from input before pricing to avoid double charging.
**Antigravity CLI** exposes exact usage through a short-lived local process while `agy` is running. Install the optional live hook with `codeburn antigravity-hook install` to capture short CLI sessions even when the menubar's 30-second refresh misses that window. The hook stores sanitized usage totals only, not prompts or local working-directory paths. Remove it with `codeburn antigravity-hook uninstall`; if `--force` replaced an existing statusLine command, uninstall restores that previous command.
**Antigravity CLI and IDE** automatically discover conversation session files on disk (stored under `.gemini/` folders) and query the running language server process to extract granular trajectory and pricing information. For the CLI, which runs as a short-lived process, you can optionally install a status line hook using `codeburn antigravity-hook install` to capture usage even when the menubar's 30-second refresh misses it. The IDE is detected automatically via the `--app-data-dir antigravity-ide` flag on Windows.
**Mistral Vibe** stores sessions as folders under `~/.vibe/logs/session/` (or `$VIBE_HOME/logs/session/`). CodeBurn reads cumulative prompt/completion totals and model pricing from `meta.json`, then reads `messages.jsonl` for the first user prompt and assistant tool calls. Subagent sessions under `agents/` are counted as separate Vibe sessions.

View file

@ -1,6 +1,6 @@
# Antigravity
Google Antigravity. The only provider that does not read files off disk: it speaks to a local language-server RPC endpoint instead.
Google Antigravity (CLI and IDE). CodeBurn discovers session files on disk and queries the local language-server RPC endpoint to parse them.
- **Source:** `src/providers/antigravity.ts`
- **Loading:** lazy via `src/providers/index.ts`. Lazy because the protobuf dependency is heavy.
@ -8,21 +8,21 @@ Google Antigravity. The only provider that does not read files off disk: it spea
## Where it reads from
A local HTTPS RPC endpoint exposed by Antigravity's language server. The parser:
CodeBurn discovers Antigravity sessions from local directories on disk, then queries the live language-server process (if running) to fetch detailed trajectory generator metadata:
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 16 MB).
1. **Session Discovery:** It scans the following folders for `.pb` or `.db` files:
- **Antigravity CLI:** `%USERPROFILE%\.gemini\antigravity-cli\conversations` (and `implicit`)
- **Antigravity App/older path:** `%USERPROFILE%\.gemini\antigravity\conversations`
- **Antigravity IDE (VSCode-based):** `%USERPROFILE%\.gemini\antigravity-ide\conversations` (and `implicit`)
2. **Language Server RPC Query:** It locates the active language-server process via `ps` on POSIX or `Get-CimInstance Win32_Process` on Windows. It extracts the port and CSRF token from the process arguments, and queries the local HTTPS RPC endpoint `GetCascadeTrajectoryGeneratorMetadata` to parse the session.
3. **Cache Fallback:** If the language server is not running, it falls back to the local results cache.
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.
forms are supported. The parser identifies the target app type using the `--app-data-dir` flag (e.g., `antigravity`, `antigravity-cli`, or `antigravity-ide`).
If the language server is not running, the parser falls back to the cached results file.
For Antigravity CLI (`agy`), CodeBurn can also install an opt-in status line
hook with `codeburn antigravity-hook install`. The hook records the CLI's
sanitized `context_window.current_usage` payload while `agy` is still alive,

View file

@ -3,9 +3,11 @@ import { execFile } from 'child_process'
import { randomBytes } from 'crypto'
import { basename, join } from 'path'
import { homedir } from 'os'
import { fileURLToPath } from 'url'
import https from 'https'
import { calculateCost } from '../models.js'
import { isSqliteAvailable, openDatabase } from '../sqlite.js'
import type { Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js'
type AntigravityConversationRoot = {
@ -30,6 +32,16 @@ const CONVERSATION_ROOTS: readonly AntigravityConversationRoot[] = [
project: 'antigravity-cli',
extensions: ['.pb'],
},
{
dir: join(homedir(), '.gemini', 'antigravity-ide', 'conversations'),
project: 'antigravity-ide',
extensions: ['.pb', '.db'],
},
{
dir: join(homedir(), '.gemini', 'antigravity-ide', 'implicit'),
project: 'antigravity-ide',
extensions: ['.pb'],
},
] as const
const CACHE_VERSION = 2
@ -42,7 +54,7 @@ export type ServerInfo = {
}
type ServerCandidate = ServerInfo & {
appDataDir?: 'antigravity' | 'antigravity-cli'
appDataDir?: 'antigravity' | 'antigravity-cli' | 'antigravity-ide'
}
type ModelMap = Record<string, string>
@ -69,9 +81,9 @@ export type GeneratorMetadata = {
}
type ModelMapResponse = {
models?: Record<string, { model?: string }>
models?: Record<string, { model?: string; displayName?: string }>
response?: {
models?: Record<string, { model?: string }>
models?: Record<string, { model?: string; displayName?: string }>
}
}
@ -131,9 +143,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']
const APP_DATA_DIR_FLAGS = ['app_data_dir']
const SERVER_PORT_FLAGS = ['https_server_port', 'extension_server_port', 'https-server-port', 'extension-server-port']
const CSRF_TOKEN_FLAGS = ['csrf_token', 'extension_server_csrf_token', 'csrf-token', 'extension-server-csrf-token']
const APP_DATA_DIR_FLAGS = ['app_data_dir', 'app-data-dir']
function getAgent(): https.Agent {
if (!httpsAgent) httpsAgent = new https.Agent({ rejectUnauthorized: false })
@ -174,15 +186,16 @@ function isLikelyCsrfToken(value: string): boolean {
return value.length >= 16 && /^[A-Za-z0-9._~:/+=-]+$/.test(value)
}
function normalizeAppDataDir(value: string | null): 'antigravity' | 'antigravity-cli' | undefined {
function normalizeAppDataDir(value: string | null): 'antigravity' | 'antigravity-cli' | 'antigravity-ide' | undefined {
if (!value) return undefined
const normalized = value.replace(/\\/g, '/').toLowerCase()
if (normalized.includes('antigravity-ide')) return 'antigravity-ide'
if (normalized.includes('antigravity-cli')) return 'antigravity-cli'
if (normalized.includes('antigravity')) return 'antigravity'
return undefined
}
export function extractAntigravityAppDataDirFromLine(line: string): 'antigravity' | 'antigravity-cli' | undefined {
export function extractAntigravityAppDataDirFromLine(line: string): 'antigravity' | 'antigravity-cli' | 'antigravity-ide' | undefined {
return normalizeAppDataDir(getFlagValue(line, APP_DATA_DIR_FLAGS))
}
@ -224,6 +237,35 @@ function parseAntigravityServerCandidates(lines: string[]): ServerCandidate[] {
.filter((server): server is ServerCandidate => server !== null)
}
function getCanonicalModelId(key: string, displayName?: string): string {
if (displayName) {
const lower = displayName.toLowerCase()
if (lower.includes('3.5 flash')) {
if (lower.includes('high')) return 'gemini-3.5-flash-high'
if (lower.includes('medium')) return 'gemini-3.5-flash-medium'
if (lower.includes('low')) return 'gemini-3.5-flash-low'
return 'gemini-3.5-flash'
}
if (lower.includes('3.1 pro')) {
if (lower.includes('high')) return 'gemini-3.1-pro-high'
if (lower.includes('low')) return 'gemini-3.1-pro-low'
return 'gemini-3.1-pro'
}
if (lower.includes('3.1 flash')) {
if (lower.includes('image')) return 'gemini-3.1-flash-image'
if (lower.includes('lite')) return 'gemini-3.1-flash-lite'
return 'gemini-3.1-flash'
}
if (lower.includes('3 flash')) {
return 'gemini-3-flash'
}
if (lower.includes('3 pro')) {
return 'gemini-3-pro'
}
}
return key
}
export function extractAntigravityModelMap(resp: unknown): ModelMap {
if (!resp || typeof resp !== 'object') return {}
const data = resp as ModelMapResponse
@ -232,7 +274,8 @@ export function extractAntigravityModelMap(resp: unknown): 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
const canonicalKey = getCanonicalModelId(key, info.displayName)
map[info.model] = canonicalKey
}
}
return map
@ -312,8 +355,70 @@ async function readProcessCommandLines(): Promise<string[]> {
return output.split('\n')
}
async function resolveEphemeralPort(csrfToken: string, appDataDir?: 'antigravity' | 'antigravity-cli'): Promise<ServerInfo | null> {
if (process.platform === 'win32') return null
async function resolveEphemeralPort(csrfToken: string, appDataDir?: 'antigravity' | 'antigravity-cli' | 'antigravity-ide'): Promise<ServerInfo | null> {
if (process.platform === 'win32') {
try {
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 { @{ PID = $_.ProcessId; Cmd = $_.CommandLine } | ConvertTo-Json -Compress }"
].join('; ')
const output = await execFileText('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', script], 5000)
let targetPid = 0
for (const line of output.split(/\r?\n/)) {
if (!line.trim()) continue
try {
const proc = JSON.parse(line) as { PID: number; Cmd: string }
const candidate = parseAntigravityServerCandidateFromLine(proc.Cmd)
if (candidate && candidate.csrfToken === csrfToken) {
if (!appDataDir || !candidate.appDataDir || candidate.appDataDir === appDataDir) {
targetPid = proc.PID
break
}
}
} catch { /* skip invalid parse */ }
}
if (targetPid === 0) return null
const portScript = `Get-NetTCPConnection -State Listen -OwningProcess ${targetPid} | Select-Object -ExpandProperty LocalPort`
const portOutput = await execFileText('powershell.exe', ['-NoProfile', '-NonInteractive', '-ExecutionPolicy', 'Bypass', '-Command', portScript], 5000)
const ports = portOutput.split(/\r?\n/)
.map(p => Number(p.trim()))
.filter(p => Number.isInteger(p) && p > 0)
for (const port of ports) {
try {
await new Promise((resolve, reject) => {
const req = https.request({
hostname: '127.0.0.1',
port: port,
path: '/exa.language_server_pb.LanguageServerService/GetAvailableModels',
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Connect-Protocol-Version': '1',
'X-Codeium-Csrf-Token': csrfToken,
'Content-Length': 2,
},
agent: getAgent(),
timeout: 1000,
}, (res) => {
if (res.statusCode === 200) resolve(true)
else reject(new Error())
})
req.on('error', reject)
req.write('{}')
req.end()
})
return { port, csrfToken }
} catch { /* try next port */ }
}
} catch { /* best-effort */ }
return null
}
try {
const processOutput = await execFileText('ps', ['-ww', '-eo', 'pid=,args='])
let pid = ''
@ -341,22 +446,23 @@ async function resolveEphemeralPort(csrfToken: string, appDataDir?: 'antigravity
return null
}
export function antigravityAppDataDirFromSourcePath(path: string): 'antigravity' | 'antigravity-cli' {
return path.replace(/\\/g, '/').toLowerCase().includes('/.gemini/antigravity-cli/')
? 'antigravity-cli'
: 'antigravity'
export function antigravityAppDataDirFromSourcePath(path: string): 'antigravity' | 'antigravity-cli' | 'antigravity-ide' {
const lower = path.replace(/\\/g, '/').toLowerCase()
if (lower.includes('/.gemini/antigravity-ide/')) return 'antigravity-ide'
if (lower.includes('/.gemini/antigravity-cli/')) return 'antigravity-cli'
return 'antigravity'
}
async function detectServer(appDataDir: 'antigravity' | 'antigravity-cli' = 'antigravity'): Promise<ServerInfo | null> {
async function detectServer(appDataDir: 'antigravity' | 'antigravity-cli' | 'antigravity-ide' = 'antigravity'): Promise<ServerInfo | null> {
if (cachedServers.has(appDataDir)) return cachedServers.get(appDataDir)!
try {
const candidates = parseAntigravityServerCandidates(await readProcessCommandLines())
const info = candidates.find(candidate => candidate.appDataDir === appDataDir)
?? (appDataDir === 'antigravity' ? candidates.find(candidate => candidate.appDataDir === undefined) : undefined)
?? null
if (info && info.port > 0) {
if (info && info.port > 0 && appDataDir !== 'antigravity-ide') {
cachedServers.set(appDataDir, { port: info.port, csrfToken: info.csrfToken })
} else if (info && info.port === 0) {
} else if (info) {
cachedServers.set(appDataDir, await resolveEphemeralPort(info.csrfToken, appDataDir))
} else {
cachedServers.set(appDataDir, null)
@ -768,10 +874,11 @@ export function shouldReparseAntigravitySource(path: string, cachedTurnCount: nu
async function findCascadeSource(cascadeId: string): Promise<SessionSource | null> {
const sources = await discoverAntigravitySessionSources()
return sources.find(source =>
source.path.replace(/\\/g, '/').toLowerCase().includes('/.gemini/antigravity-cli/') &&
antigravityCascadeIdFromPath(source.path) === cascadeId
) ?? null
return sources.find(source => {
const lower = source.path.replace(/\\/g, '/').toLowerCase()
return (lower.includes('/.gemini/antigravity-cli/') || lower.includes('/.gemini/antigravity-ide/')) &&
antigravityCascadeIdFromPath(source.path) === cascadeId
}) ?? null
}
export async function snapshotAntigravityStatusLinePayload(input: unknown): Promise<boolean> {
@ -791,7 +898,7 @@ export async function snapshotAntigravityStatusLinePayload(input: unknown): Prom
return true
}
const server = await detectServer('antigravity-cli')
const server = await detectServer(antigravityAppDataDirFromSourcePath(source.path))
if (!server) return false
let metadata: GeneratorMetadata[]
@ -813,6 +920,40 @@ export async function snapshotAntigravityStatusLinePayload(input: unknown): Prom
}
}
async function extractWorkspacePath(filePath: string): Promise<string | undefined> {
let text = ''
if (filePath.endsWith('.db') && isSqliteAvailable()) {
try {
const db = openDatabase(filePath)
const rows = db.query<{ data: Uint8Array }>('SELECT data FROM trajectory_metadata_blob')
db.close()
const textDecoder = new TextDecoder('utf-8', { fatal: false })
text = rows.map(r => textDecoder.decode(r.data)).join(' ')
} catch { /* ignore and fallback */ }
}
if (!text) {
try {
text = await readFile(filePath, 'utf-8')
} catch {
return undefined
}
}
const match = text.match(/file:\/\/\/[^\x00-\x1F\x7F"'\s]+/i)
if (!match) return undefined
try {
return fileURLToPath(match[0])
} catch {
return undefined
}
}
function sanitizeProject(path: string): string {
return basename(path.replace(/\\/g, '/'))
}
function createParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
return {
async *parse(): AsyncGenerator<ParsedProviderCall> {
@ -830,9 +971,15 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
const s = await stat(source.path).catch(() => null)
if (!s) return
const projectPath = await extractWorkspacePath(source.path)
const cached = cache.cascades[cascadeId]
if (cached && cached.mtimeMs === s.mtimeMs && cached.sizeBytes === s.size) {
for (const call of cached.calls) {
if (projectPath) {
call.projectPath = projectPath
call.project = sanitizeProject(projectPath)
}
if (seenKeys.has(call.deduplicationKey)) continue
seenKeys.add(call.deduplicationKey)
yield call
@ -844,6 +991,10 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
if (!server) {
if (cached) {
for (const call of cached.calls) {
if (projectPath) {
call.projectPath = projectPath
call.project = sanitizeProject(projectPath)
}
if (seenKeys.has(call.deduplicationKey)) continue
seenKeys.add(call.deduplicationKey)
yield call
@ -862,6 +1013,10 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
} catch {
if (cached) {
for (const call of cached.calls) {
if (projectPath) {
call.projectPath = projectPath
call.project = sanitizeProject(projectPath)
}
if (seenKeys.has(call.deduplicationKey)) continue
seenKeys.add(call.deduplicationKey)
yield call
@ -871,6 +1026,13 @@ function createParser(source: SessionSource, seenKeys: Set<string>): SessionPars
}
const results = buildCallsFromGeneratorMetadata(cascadeId, metadata, modelMap)
if (projectPath) {
const projectName = sanitizeProject(projectPath)
for (const call of results) {
call.projectPath = projectPath
call.project = projectName
}
}
cache.cascades[cascadeId] = {
mtimeMs: s.mtimeMs,

View file

@ -104,6 +104,7 @@ const PROVIDER_PARSE_VERSIONS: Record<string, string> = {
'kilo-code': 'worktree-project-grouping-v1',
'roo-code': 'worktree-project-grouping-v1',
warp: 'worktree-project-grouping-v1',
antigravity: 'worktree-project-grouping-v3',
}
// ── Cache Dir ──────────────────────────────────────────────────────────

View file

@ -76,6 +76,10 @@ describe('antigravity provider helpers', () => {
expect(extractAntigravityAppDataDirFromLine(
'language_server.exe --app_data_dir "C:\\Users\\Admin\\.gemini\\antigravity-cli" --extension_server_port 62225 --extension_server_csrf_token abcdef01-2345-6789-abcd-ef0123456789',
)).toBe('antigravity-cli')
expect(extractAntigravityAppDataDirFromLine(
'language_server_windows_x64.exe --app_data_dir antigravity-ide --extension_server_port 8720 --extension_server_csrf_token 39800f1b-343a-40b0-8eb5-850702450346',
)).toBe('antigravity-ide')
})
it('accepts Antigravity 2 ephemeral port zero', () => {
@ -135,6 +139,9 @@ describe('antigravity provider helpers', () => {
expect(extractAntigravityModelMap({
models: { bad: null, good: { model: 'MODEL_PLACEHOLDER_M9' } },
})).toEqual({ MODEL_PLACEHOLDER_M9: 'good' })
expect(extractAntigravityModelMap({
models: { 'gemini-3-flash-agent': { model: 'MODEL_PLACEHOLDER_M133', displayName: 'Gemini 3.5 Flash (High)' } },
})).toEqual({ MODEL_PLACEHOLDER_M133: 'gemini-3.5-flash-high' })
expect(extractAntigravityModelMap(null)).toEqual({})
})
@ -175,6 +182,14 @@ describe('antigravity provider helpers', () => {
expect(antigravityAppDataDirFromSourcePath(
'C:\\Users\\Admin\\.gemini\\antigravity-cli\\implicit\\session.pb',
)).toBe('antigravity-cli')
expect(antigravityAppDataDirFromSourcePath(
'/Users/dev/.gemini/antigravity-ide/conversations/session.db',
)).toBe('antigravity-ide')
expect(antigravityAppDataDirFromSourcePath(
'C:\\Users\\Admin\\.gemini\\antigravity-ide\\implicit\\session.pb',
)).toBe('antigravity-ide')
})
it('discovers legacy .pb files and Antigravity 2 .db files only', async () => {
@ -203,6 +218,39 @@ describe('antigravity provider helpers', () => {
}
})
it('discovers antigravity-ide conversation and implicit files', async () => {
const tempHome = await mkdtemp(join(tmpdir(), 'codeburn-home-'))
const conversationsDir = join(tempHome, '.gemini', 'antigravity-ide', 'conversations')
const implicitDir = join(tempHome, '.gemini', 'antigravity-ide', 'implicit')
await mkdir(conversationsDir, { recursive: true })
await mkdir(implicitDir, { recursive: true })
await writeFile(join(conversationsDir, 'session1.db'), '')
await writeFile(join(implicitDir, 'session2.pb'), '')
const roots = [
{
dir: conversationsDir,
project: 'antigravity-ide',
extensions: ['.pb', '.db'] as const,
},
{
dir: implicitDir,
project: 'antigravity-ide',
extensions: ['.pb'] as const,
},
]
const sources = await discoverAntigravitySessionSources(roots)
expect(sources).toEqual([
{ path: join(conversationsDir, 'session1.db'), project: 'antigravity-ide', provider: 'antigravity' },
{ path: join(implicitDir, 'session2.pb'), project: 'antigravity-ide', provider: 'antigravity' },
])
await rm(tempHome, { recursive: true, force: true })
})
it('displays Gemini 3.5 Flash thinking variants as the base model', () => {
const provider = createAntigravityProvider()