Skip to content

feat: add Antigravity CLI as a usage provider - #141

Merged
chattymin merged 3 commits into
chattymin:mainfrom
2JIHAN:feat/antigravity-cli-usage
Aug 7, 2026
Merged

chattymin merged 3 commits into
chattymin:mainfrom
2JIHAN:feat/antigravity-cli-usage

Conversation

@2JIHAN

@2JIHAN 2JIHAN commented Aug 5, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Antigravity CLI writes to ~/.gemini/antigravity-cli/, so it looks like the Gemini provider should already cover it. It shares nothing with Gemini CLI except that parent directory. Each conversation is its own SQLite database and the per-call token ledger sits inside a protobuf blob, while the Gemini scan only matches .json/.jsonl under ~/.gemini/tmp/. Every Antigravity turn currently reads as zero.

I took the field numbers from the CLI's own binary rather than from the data. It embeds its FileDescriptorProto pool, and ModelUsageStats, ChatStartMetadata and ChatModelMetadata came out of it directly. That mattered here, because two fields are shaped so that inferring them from sample values would have produced a plausible parser that was wrong. input_tokens excludes cache reads — the opposite of Gemini's promptTokenCount — and cache reads are the larger number by an order of magnitude, so subtracting them the way the Gemini CLI parser subtracts its own cached would have discarded most of the prompt. output_tokens is already thinking_output_tokens + response_output_tokens, an identity that holds on every record, so treating the three as siblings would have doubled the output. The schema has no total field at all; the total is the sum of the three populated counters, which is the identity Entry.total already keeps.

Turn time is chat_start_metadata.created_at. Identity is response_id, present and distinct on every recorded call — the turn's own id rather than the file it sits in, so a copied conversation cannot read as fresh spend. message_id, provider_assigned_message_id and cache_write_tokens are declared but never written.

Opening these databases takes two attempts. They are all in WAL mode, and mode=ro cannot create the -shm file a WAL database needs, so it fails outright on any conversation that has been checkpointed and has no -wal sibling — 28 of the 124 stores on my machine when I measured. immutable=1 reads those, and it is only reached after mode=ro fails, which is exactly when there is no uncommitted tail to miss. Reaching for it first would silently drop the newest turns of an active conversation. The open also has to be forced to read: sqlite3_open_v2 is lazy enough to report success on a database it cannot query, and a fallback that never runs looks like an empty conversation rather than a failed open. Scanning keys on the newest of the database and its two siblings, because a WAL commit leaves the main file's timestamp alone and a window keyed on the .db would skip precisely the conversations that had just moved.

Cost is zero. Antigravity is a subscription, reports no amount, and the models it names include claude-sonnet-4-6, which the rate table prices. The model string therefore carries an antigravity/ prefix that keeps it out of the exact table, and a short-circuit above the family fallbacks keeps it out of those. Same shape as Grok, same reason — an untrustworthy bill should read as $0 rather than as an estimate.

Two limits worth stating plainly. steps.metadata carries the same usage submessage and records 175 calls that gen_metadata never flushed, interrupted or errored turns worth 66,820 output tokens — near enough 2.5% of the total whichever of the two figures you divide by. They are left out because gen_metadata is the only one of the two that is self-contained — steps.metadata has no model name — and because it is the billed figure: on the 3,934 calls both tables record it is never lower, and it is higher on 42 of them, where it includes the retried attempts. Separately, token counts are uint64 on the wire, so an implausibly large value is discarded rather than widened into Int, where the next addition would trap the app on every refresh until the file changed.

Antigravity is its own UsageProvider, with its own tab. CONTRIBUTING.md reads a new AI CLI as a new provider, and although it shares ~/.gemini and its vendor with Gemini CLI it shares nothing else — folding it in would have labelled an Antigravity-only user's spend "Gemini", and knowing which tool the spend went to is the point of the per-service tabs. An unused Antigravity stays invisible: a provider with no snapshot raises no tab, and the compact surfaces are already gated on today's tokens. reportsCost is false, because the schema has no cost field and the rate lookup is short-circuited by the prefix — the cost is zero by construction, and reporting it would print $0.00 beside a subscription that never billed per token. It registers directly after Gemini, so the two ~/.gemini/ CLIs stay adjacent and index 0 — the default tab — is untouched. One consequence worth naming: Antigravity's week/month totals reach the aggregates only when it has usage today or a live 5h block, which is the admission rule every other provider is already under.

Verified against the live store as well as the fixtures: 3,934 records across 124 conversations, decoded in 0.14s, matching an independent decode of the same databases field for field. The fixtures encode the descriptor's field numbers rather than a guess at the shape; no real blob is committed, since chat_model carries the prompt text.

Type of change

  • Bug fix
  • New feature
  • Refactor / cleanup
  • Documentation
  • Other:

Checklist

  • swift build and swift test pass locally
  • PR title and description are written in English
  • UI changes are described above (before/after — images optional) — nothing under Sources/PokeTokenBar/UI/ changed, so the UI section is omitted
  • No copyrighted assets, secrets, or private tooling references are committed (see CONTRIBUTING)
  • Tests were added or updated for this change

Antigravity CLI writes to `~/.gemini/antigravity-cli/`, so it looks like it should
already be covered, but it shares nothing with Gemini CLI except that parent
directory. Each conversation is its own SQLite database and the per-call token
ledger sits in a protobuf blob, so the Gemini file scan — which only matches
`.json`/`.jsonl` under `~/.gemini/tmp/` — never sees any of it. Every Antigravity
turn has been reading as zero.

The field numbers come from the CLI's own binary rather than from the data: it
embeds its `FileDescriptorProto` pool, and `ModelUsageStats`, `ChatStartMetadata`
and `ChatModelMetadata` were read out of it. That mattered, because two of the
fields are shaped so that inferring them from sample values would have been wrong.
`input_tokens` excludes cache reads, the opposite of Gemini's `promptTokenCount`,
and cache reads are the larger number by an order of magnitude — subtracting them
the way the Gemini CLI parser subtracts its own `cached` would have thrown away
most of the prompt. And `output_tokens` is already `thinking_output_tokens +
response_output_tokens`, an identity that holds on every record, so treating the
three as siblings would have doubled the output. There is no total field anywhere
in the schema; the total is the sum of the three populated counters, which is the
identity `Entry.total` already keeps.

Turn time is `chat_start_metadata.created_at` and the identity is `response_id`,
which is present and distinct on every recorded call — the turn's own id rather
than the file it sits in, so a copied conversation cannot read as fresh spend.
`message_id` and `provider_assigned_message_id` are declared but never written,
and `cache_write_tokens` is never written either, which is consistent with
implicit caching reporting no writes.

Opening these databases needs two attempts. They are all in WAL mode, and
`mode=ro` cannot create the `-shm` file a WAL database needs, so it fails outright
on any conversation that has been checkpointed and has no `-wal` sibling — 28 of
the 124 stores here at the time this was written. `immutable=1` reads those, and
it is only reached after `mode=ro` fails, which is exactly when there is no
uncommitted tail to miss; reaching for it first would silently drop the newest
turns of an active conversation. The open also has to be forced to read, because
`sqlite3_open_v2` is lazy enough to report success on a database it cannot
actually query, and a fallback that never runs looks like an empty conversation
rather than a failed open. A scan is only complete when it ends on `SQLITE_DONE`
for the same reason the other SQLite-backed readers insist on it: the CLI writes
while the app polls, and half a conversation must not be cached as the whole of
it.

Scanning keys on the newest of the database and its `-wal` and `-shm` siblings. A
WAL commit leaves the main file's timestamp alone, so a window keyed on the `.db`
would skip precisely the conversations that had just moved.

Cost is zero. Antigravity is a subscription, reports no amount, and the models it
names include `claude-sonnet-4-6`, which the rate table prices — so the model
string carries an `antigravity/` prefix that keeps it out of the exact table, and
a short-circuit above the family fallbacks keeps it out of those too. The same
shape as Grok, and for the same reason: an untrustworthy bill should read as $0
rather than as an estimate.

Two limits worth stating. `steps.metadata` carries the same usage submessage and
records 175 calls that `gen_metadata` never flushed, interrupted or errored turns
worth 66,820 output tokens; they are left out because `gen_metadata` is the only
one of the two that is self-contained, and because `gen_metadata` is the billed
figure — on the 3,934 calls both tables record, it is never lower and is higher on
42 of them, where it includes the retried attempts. And token counts are `uint64`
on the wire, so anything implausibly large is discarded rather than widened into
`Int`, where the next addition would trap the app on every refresh until the file
changed.

Verified against the live store as well as the fixtures: 3,934 records across 124
conversations, decoded in 0.14s, matching an independent decode of the same
databases field for field. 469 tests pass and the logic-core coverage gate reports
87.86%.
@2JIHAN
2JIHAN force-pushed the feat/antigravity-cli-usage branch from fa82d9f to d10cb4f Compare August 5, 2026 03:38
@chattymin

chattymin commented Aug 5, 2026 •

Copy link
Copy Markdown
Owner

Thanks for this — the write-up is unusually thorough, and the two traps you called out (input_tokens excluding cache reads, output_tokens already being the sum) are exactly the kind of thing that would have shipped as a silently-wrong parser if they'd been inferred from sample values. Reading them out of the embedded FileDescriptorProto was the right call.

I verified what I could locally: build and the full suite pass, and the immutable=1 fallback is load-bearing — removing it makes testCheckpointedWalDatabaseIsStillReadable fail with 0 rows, so the WAL reasoning holds. I don't have Antigravity installed, so I could not check the field numbers against a live store; that part rests on your descriptor read. The fixtures re-state the field numbers as literals rather than sharing constants with the parser, and they include fields the parser never reads (api_provider, thinking, response), which is good evidence the shape is real rather than reverse-engineered from the parser.

On the provider question you raised: let's split it into its own UsageProvider. The "second Gemini-shaped tab is noise" concern is already handled — providers with no usage today are filtered out of the compact menu bar display, so an unused Antigravity never shows up. Folding it in has the opposite cost: someone who uses only Antigravity sees their usage labelled "Gemini", and the whole point of the app is knowing which tool the spend went to. Since you noted the reader doesn't depend on the choice, this should be a small change.

Four non-blocking notes, three of which are the same shape — a failure that leaves no trace:

  1. conversationEntries drops the whole conversation on SQLITE_BUSY. Discarding a partial read is right, but there's no log line, so a conversation that is persistently busy reads as zero with nothing to explain it.
  2. tokenCount returns 0 above tokenCeiling. Same thing — the guard is correct, the silence isn't.
  3. LocalAntigravityUsageCache invalidates on a 30s TTL only, while LocalUsageCache keys on (path, mtime, size). Low impact given the refresh cadence, just inconsistent.
  4. sqlite3_close → sqlite3_close_v2. It's safe today because the defer order finalizes first, but that's a property of statement ordering rather than of the close call.

None of these block the merge. If you'd rather land the split separately, say so and I'll take the notes as follow-ups.
@2JIHAN

Antigravity shares `~/.gemini/` with Gemini CLI and nothing else, so counting it
under the Gemini provider meant that someone who runs only Antigravity saw their
spend labelled "Gemini" — and knowing which tool the spend went to is the whole
point of the per-service tabs. The reader never depended on the choice:
`LocalAntigravityProvider` calls the same `LocalAntigravityUsageCache` the Gemini
provider used to fan out to, so the parse, the identity and the windows are
untouched.

A second Gemini-shaped tab costs nothing when it is unused. A provider with no
snapshot raises no tab, and the compact menu bar surfaces are already gated on
today's tokens, so an installed-but-unused Antigravity stays invisible.

`reportsCost` is false. The wire schema has no cost field and `ModelPricing`
short-circuits the `antigravity/` prefix, so the cost is zero by construction at
both of the two places a cost could come from. Reporting it would print `$0.00`
beside the tokens of a subscription that never billed per token — the artifact
`reportsCost` was introduced for, on Cursor. No total moves either way; only the
`$0.00` goes.

Registered directly after Gemini rather than at the end of the array, so the two
`~/.gemini/` CLIs stay adjacent in the tab bar. Index 0 is untouched, so the
default tab is still Claude Code.

`ProviderTabLayoutTests` now asserts its provider list against
`registeredProviderIDs`. That list was maintained by hand, so it could have gone
on measuring a tab bar that is not the one that ships — which is exactly what it
would have done here.
@2JIHAN

2JIHAN commented Aug 7, 2026 •

Copy link
Copy Markdown
Contributor Author

Split done — ccc30dd promotes Antigravity to its own UsageProvider. One commit, no change to the reader: LocalAntigravityProvider calls the same LocalAntigravityUsageCache that LocalGeminiProvider used to fan out to, so the parse, the identity and the windows are untouched. You were right that the reader didn't depend on the choice.

Two decisions in there worth flagging, since neither was forced:

reportsCost = false. The wire schema has no cost field and ModelPricing short-circuits the antigravity/ prefix, so the cost is zero by construction at both of the two places one could come from. Left at the default it would print $0.00 beside the tokens of a subscription that never billed per token — the artifact reportsCost was added for on Cursor. No total moves either way; only the $0.00 goes. Pinned by a test, the way CursorUsageTests pins Cursor's.

Registered after Gemini, not at the end. Keeps the two ~/.gemini/ CLIs adjacent, and leaves index 0 alone so the default tab is still Claude Code.

One consequence worth naming rather than letting you find it: Antigravity's week/month totals now only reach the aggregates when it has usage today or a live 5h block, because that is the admission rule for a snapshot at all (UsageStore's carrier-snapshot comment — creating one from week/month alone is what raised tabs for unused providers). Folded into Gemini it rode into the totals on Gemini's snapshot even when Antigravity itself had been idle for days. So this is the split putting it under the same rule as every other provider rather than a new behaviour, and nothing on main changes, since main doesn't read Antigravity at all. Say the word if you'd rather it kept the free ride.

While updating ProviderTabLayoutTests I noticed its provider list is maintained by hand and had never been checked against the registry — it would have gone on measuring a 7-tab bar while an 8-tab one shipped, which is exactly what it did here. It now asserts against registeredProviderIDs, so the next provider can't leave it stale.

READMEs in all three languages: the combined Gemini CLI · Antigravity row is two rows, and "counted under Gemini" / "Gemini 에 합산" / "Gemini に合算" is gone from the data-sources note. 473 tests pass and scripts/test-gate.sh reports 87.86%.

I'll take the four notes as a follow-up, as you offered — keeps this PR to the one thing you asked for. They're written and green already, so it should be a quick one. Two things that came out of doing them, in case they change how you'd like them landed:

  • The BUSY branch can't be reached from a test. immutable=1 disables locking, so the fallback reads straight through any lock a test could hold; the branch is reachable deterministically via a damaged page (SQLITE_CORRUPT) instead, with rows already read, which is the "half a conversation" case the guard is actually for.
  • Note 3 turns out to depend on note 1. Once a blob outlives the call that produced it, "drop the partial read and let the next refresh read it entire" stops being true by default — a failure filed under the current signature would read as no usage for as long as the store sat still. So the failure cases have to stay out of the cache, which needs the read to be a value rather than an [].

Two things I found next door and left alone, for whenever they're worth their own issue: LocalAdditionalUsageReader.query has the same unlogged open/prepare/non-DONE trio and the same sqlite3_close, and Hermes' cache entry replaces rather than merges — so one BUSY read there drops the whole Hermes tab for that cycle with nothing in the log.

Last thing: this PR's title is now the opposite of its diff, and it's the squash subject. Something like feat: add Antigravity CLI as a usage provider would keep the history honest — happy to retitle and rewrite the body paragraph that argues for folding it in, just didn't want to move your PR's title without asking.

@chattymin

Only `README.ja.md` conflicted: `main` reworded the on-device privacy line
(`モデル turn の実行` -> `モデルの推論実行`) on the same line this branch added
Antigravity to the tool list. Kept both.
@chattymin

Copy link
Copy Markdown
Owner

@2JIHAN Answers to your questions:

  1. Title: yes — retitle to feat: add Antigravity CLI as a usage provider and rewrite the body paragraph that argues for folding it in. It's the squash subject.
  2. Week/month admission rule: keep it as landed — same rule as every other provider is the point of the split.
  3. The two findings next door: worth filing, one issue each (different blast radius). If you'd rather not, I'll file them.
  4. Follow-up PR: agreed on both — the SQLITE_CORRUPT reproduction covers what the guard is for, and go with the return value that distinguishes failure from empty.

Happy to merge once the title and body are updated. The follow-up PR is welcome whenever it's ready.

Thank you for your interest in this project and for a contribution this thorough — it's appreciated.

@2JIHAN 2JIHAN changed the title feat: read Antigravity CLI usage under the Gemini provider feat: add Antigravity CLI as a usage provider Aug 7, 2026
@2JIHAN

2JIHAN commented Aug 7, 2026

Copy link
Copy Markdown
Contributor Author

Title and body updated.

Retitled to feat: add Antigravity CLI as a usage provider, and the paragraph that argued for folding it into Gemini is replaced with what the PR now does — its own provider and tab, reportsCost = false, registered after Gemini so index 0 stays Claude Code, and the week/month admission rule stated plainly rather than left to be discovered. Nothing else in the body needed touching: the -shm sentence and the store counts are still accurate for this diff.

main is merged in as well, since the branch had gone conflicting — README.ja.md only, where your reworded on-device line met the tool list this branch adds Antigravity to. Kept both. 558 tests pass and the gate reports 89.50%.

I'll file the two next-door findings as separate issues myself once this lands, so no need for you to pick those up.

Ready to merge whenever you are — and thank you for the review, it was a genuinely useful one.

@chattymin
chattymin merged commit 42acf7d into chattymin:main Aug 7, 2026
2 checks passed
@chattymin

Copy link
Copy Markdown
Owner

Thank you for your contribution!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants