Skip to content
Merged
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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,6 +136,8 @@ 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.

**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.

**Kiro** stores conversations as `.chat` JSON files. Token counts are estimated from content length. The underlying model is not exposed, so sessions are labeled `kiro-auto` and costed at Sonnet rates.
Expand Down
17 changes: 16 additions & 1 deletion docs/providers/antigravity.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,13 @@ builds can expose `--extension_server_port` and
forms are supported.

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,
without prompts or local working-directory paths. It also attempts a best-effort
RPC snapshot for full response metadata. Remove it with
`codeburn antigravity-hook uninstall`; if `--force` replaced an existing
statusLine command, uninstall restores that previous command.

## Storage format

Expand All @@ -34,11 +41,19 @@ Custom file cache at `$CODEBURN_CACHE_DIR/antigravity-results.json` (defaults to

## Deduplication

Per `<cascadeId>:<responseId>`.
Per `<cascadeId>:<responseId>` for RPC data. The status line fallback collapses
repeated identical usage snapshots, ignores singleton intermediate snapshots
when a later stabilized usage total is observed for the same conversation, and
uses positive deltas for monotonic snapshots so cumulative counters are not
double-counted.

## 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).
- **Antigravity CLI has a shorter capture window than the desktop app.** `agy`
exposes its language server only while the CLI session is active. The status
line hook closes that gap for future sessions; older CLI `.pb` files still
cannot be priced exactly unless an RPC snapshot was captured.
- 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.

Expand Down
170 changes: 170 additions & 0 deletions src/antigravity-statusline.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,170 @@
import { mkdir, open, readFile, rename, unlink } from 'fs/promises'
import { randomBytes } from 'crypto'
import { dirname, join } from 'path'
import { homedir } from 'os'

import {
recordAntigravityStatusLinePayload,
snapshotAntigravityStatusLinePayload,
} from './providers/antigravity.js'

type Settings = Record<string, unknown> & {
statusLine?: {
type?: string
command?: string
padding?: number
}
}

type StatusLineSettings = NonNullable<Settings['statusLine']>

function isObject(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}

function isCodeBurnHook(command: unknown): boolean {
return typeof command === 'string' && command.includes('agy-statusline-hook')
}

function shellQuote(value: string): string {
if (process.platform === 'win32') return `"${value.replace(/(["\\])/g, '\\$1')}"`
return `'${value.replace(/'/g, `'\\''`)}'`
}

function hookCommand(): string {
const script = process.argv[1] || 'codeburn'
if (script === 'codeburn') return 'codeburn agy-statusline-hook'
return `${shellQuote(process.execPath)} ${shellQuote(script)} agy-statusline-hook`
}

function settingsPath(): string {
return process.env['CODEBURN_ANTIGRAVITY_SETTINGS_PATH']
?? join(homedir(), '.gemini', 'antigravity-cli', 'settings.json')
}

function codeburnCacheDir(): string {
return process.env['CODEBURN_CACHE_DIR'] ?? join(homedir(), '.cache', 'codeburn')
}

function previousStatusLinePath(): string {
return join(codeburnCacheDir(), 'antigravity-statusline-previous.json')
}

async function readSettings(): Promise<Settings> {
try {
const raw = await readFile(settingsPath(), 'utf-8')
const parsed = JSON.parse(raw)
return isObject(parsed) ? parsed as Settings : {}
} catch {
return {}
}
}

async function writeJsonAtomic(path: string, value: unknown): Promise<void> {
await mkdir(dirname(path), { recursive: true })
const tempPath = `${path}.${randomBytes(8).toString('hex')}.tmp`
const handle = await open(tempPath, 'w', 0o600)
try {
await handle.writeFile(`${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf-8' })
await handle.sync()
} finally {
await handle.close()
}
try {
await rename(tempPath, path)
} catch (err) {
try { await unlink(tempPath) } catch { /* cleanup */ }
throw err
}
}

async function writeSettings(settings: Settings): Promise<void> {
await writeJsonAtomic(settingsPath(), settings)
}

async function savePreviousStatusLine(statusLine: StatusLineSettings): Promise<void> {
await writeJsonAtomic(previousStatusLinePath(), {
savedAt: new Date().toISOString(),
statusLine,
})
}

async function readPreviousStatusLine(): Promise<StatusLineSettings | null> {
try {
const raw = await readFile(previousStatusLinePath(), 'utf-8')
const parsed = JSON.parse(raw)
if (!isObject(parsed) || !isObject(parsed.statusLine)) return null
return parsed.statusLine as StatusLineSettings
} catch {
return null
}
}

async function clearPreviousStatusLine(): Promise<void> {
try {
await unlink(previousStatusLinePath())
} catch { /* no previous hook backup */ }
}

export async function installAntigravityStatusLineHook(force = false): Promise<'installed' | 'already-installed'> {
const settings = await readSettings()
const existing = settings.statusLine
if (existing && !isCodeBurnHook(existing.command) && !force) {
throw new Error(
'Antigravity CLI already has a custom statusLine command. Re-run with --force to replace it.'
)
}

if (isCodeBurnHook(existing?.command)) return 'already-installed'
if (existing && !isCodeBurnHook(existing.command)) await savePreviousStatusLine(existing)

settings.statusLine = {
type: 'command',
command: hookCommand(),
padding: 0,
}
await writeSettings(settings)
return 'installed'
}

export async function uninstallAntigravityStatusLineHook(): Promise<'removed' | 'restored' | 'not-installed'> {
const settings = await readSettings()
if (!isCodeBurnHook(settings.statusLine?.command)) return 'not-installed'

const previous = await readPreviousStatusLine()
if (previous) settings.statusLine = previous
else delete settings.statusLine

await writeSettings(settings)
await clearPreviousStatusLine()
return previous ? 'restored' : 'removed'
}

const MAX_STDIN_BYTES = 1024 * 1024

function readStdin(): Promise<string> {
return new Promise((resolve, reject) => {
let input = ''
let bytes = 0
process.stdin.setEncoding('utf8')
process.stdin.on('data', chunk => {
bytes += Buffer.byteLength(chunk, 'utf8')
if (bytes > MAX_STDIN_BYTES) { process.stdin.destroy(); reject(new Error('stdin too large')); return }
input += chunk
})
process.stdin.on('end', () => resolve(input))
process.stdin.on('error', reject)
})
}

export async function runAgyStatusLineHook(): Promise<void> {
try {
const input = await readStdin()
const payload = input.trim() ? JSON.parse(input) : null
await recordAntigravityStatusLinePayload(payload)
await snapshotAntigravityStatusLinePayload(payload)
} catch {
// Status line hooks run inside the user's terminal UI. Never surface parser
// or transient RPC failures there; the next status line update can retry.
}
}
44 changes: 44 additions & 0 deletions src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ import { formatDateRangeLabel, parseDateRangeFlags, getDateRange, toPeriod, type
import { runOptimize, scanAndDetect } from './optimize.js'
import { renderCompare } from './compare.js'
import { getAllProviders } from './providers/index.js'
import {
installAntigravityStatusLineHook,
runAgyStatusLineHook,
uninstallAntigravityStatusLineHook,
} from './antigravity-statusline.js'
import { clearPlan, readConfig, readPlan, readPlans, saveConfig, savePlan, getConfigFilePath, type Plan, type PlanId, type PlanProvider } from './config.js'
import { clampResetDay, getPlanUsageOrNull, getPlanUsages, type PlanUsage } from './plan-usage.js'
import { getPresetPlan, isPlanId, isPlanProvider, PLAN_IDS, PLAN_PROVIDERS, planDisplayName } from './plans.js'
Expand Down Expand Up @@ -861,6 +866,45 @@ program
}
})

program
.command('antigravity-hook')
.description('Install or remove exact Antigravity CLI usage capture')
.argument('<action>', 'install or uninstall')
.option('--force', 'Replace an existing custom Antigravity CLI statusLine command')
.action(async (action: string, opts: { force?: boolean }) => {
try {
if (action === 'install') {
const result = await installAntigravityStatusLineHook(!!opts.force)
console.log(result === 'already-installed'
? '\n Antigravity CLI usage capture is already installed.\n'
: '\n Antigravity CLI usage capture installed.\n')
return
}
if (action === 'uninstall') {
const result = await uninstallAntigravityStatusLineHook()
console.log(result === 'not-installed'
? '\n Antigravity CLI usage capture is not installed.\n'
: result === 'restored'
? '\n Antigravity CLI usage capture removed; previous statusLine restored.\n'
: '\n Antigravity CLI usage capture removed.\n')
return
}
console.error('\n Usage: codeburn antigravity-hook <install|uninstall>\n')
process.exit(1)
} catch (err) {
const message = err instanceof Error ? err.message : String(err)
console.error(`\n Antigravity hook failed: ${message}\n`)
process.exit(1)
}
})

program
.command('agy-statusline-hook', { hidden: true })
.description('Internal Antigravity CLI statusLine hook')
.action(async () => {
await runAgyStatusLineHook()
})

program
.command('currency [code]')
.description('Set display currency (e.g. codeburn currency GBP)')
Expand Down
8 changes: 4 additions & 4 deletions src/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { readSessionLines } from './fs-utils.js'
import { calculateCost, getShortModelName } from './models.js'
import { discoverAllSessions, getProvider } from './providers/index.js'
import { flushCodexCache } from './codex-cache.js'
import { antigravityCascadeIdFromPath, flushAntigravityCache } from './providers/antigravity.js'
import { antigravityCascadeIdFromPath, flushAntigravityCache, shouldReparseAntigravitySource } from './providers/antigravity.js'
import { isSqliteBusyError } from './sqlite.js'
import {
type CachedCall,
Expand Down Expand Up @@ -1766,10 +1766,10 @@ function getOrCreateProviderSection(cache: SessionCache, provider: string): Prov
return section
}

function cachedFileNeedsProviderReparse(providerName: string, cached: CachedFile): boolean {
function cachedFileNeedsProviderReparse(providerName: string, sourcePath: string, cached: CachedFile): boolean {
// Antigravity data comes from the live server, not from the conversation file.
// A 0-turn cache entry may just mean the server was unavailable last run.
if (providerName === 'antigravity' && cached.turns.length === 0) return true
if (providerName === 'antigravity') return shouldReparseAntigravitySource(sourcePath, cached.turns.length)

if (providerName !== 'gemini') return false

Expand Down Expand Up @@ -1815,7 +1815,7 @@ async function parseProviderSources(

const cached = section.files[source.path]
const action = reconcileFile(fp, cached)
if (action.action === 'unchanged' && cached && !cachedFileNeedsProviderReparse(providerName, cached)) {
if (action.action === 'unchanged' && cached && !cachedFileNeedsProviderReparse(providerName, source.path, cached)) {
unchangedSources.push({ source, cached })
} else {
changedSources.push({ source, fp })
Expand Down
Loading
Loading