From 773aa3a666f1196f36c9dc9e26f056faabe70dce Mon Sep 17 00:00:00 2001 From: nuchareviews-beep <319816943+nuchareviews-beep@users.noreply.github.com> Date: Sun, 23 Aug 2026 00:01:39 +1000 Subject: [PATCH 1/3] Add Antigravity as a usage source bb-plugin-antigravity-acp bridges BB to the local `agy` CLI, but agy has no session log of its own in a stable, parseable shape - the bridge is the source of truth, writing one JSONL line per turn to ~/.antigravity-acp/usage.jsonl in the same generation-fact shape this plugin already reads for FX, so the new source reuses that parsing path rather than inventing a second file format. Verified against a real log line produced by a live provider-bridge turn, not just synthetic fixtures. --- README.md | 3 ++- collectors.test.ts | 20 ++++++++++++++++++ collectors.ts | 3 ++- lib/host-json-collector.test.ts | 36 +++++++++++++++++++++++++++++++++ lib/host-json-collector.ts | 27 ++++++++++++++++++++++--- server.test.ts | 6 ++++++ server.ts | 2 ++ 7 files changed, 92 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 8020c17..515aa4a 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ Track coding-agent token usage and estimated API cost across every machine enrol ## Features -- Collect usage from Codex, Claude Code, FX, Grok Agent, OpenCode, Pi, and Prime Agent. +- Collect usage from Codex, Claude Code, FX, Grok Agent, OpenCode, Pi, Prime Agent, and Antigravity. - Separate the coding agent from the underlying model provider. - Group charts and cost summaries by agent or model provider. - Filter by machine, agent, model provider, and the last 7, 30, or 90 days. @@ -23,6 +23,7 @@ Track coding-agent token usage and estimated API cost across every machine enrol - Pi: `~/.pi/agent/sessions/**/*.jsonl`, plus optional extra roots in plugin settings - Prime Agent: root sessions in `~/.prime/agent/sessions/*.jsonl` and recursive-agent sessions under `~/.prime/agent/session-artifacts/**/*.jsonl`, plus optional custom session directories in plugin settings - OpenCode: assistant-message usage from the last 90 days, recorded by `opencode db` +- Antigravity: `~/.antigravity-acp/usage.jsonl`, written by the `bb-plugin-antigravity-acp` provider bridge (the `agy` CLI has no session log of its own in a stable, parseable shape, so the bridge is the source of truth, one line per turn it runs) JSON-log collection requires Node.js on each enrolled machine. Logs are streamed and reduced to usage metadata on that machine, so large histories are not transferred through BB's file API. A metadata-only per-file cache in `~/.cache/bb-plugin-usage/json-log-scan-v1/` makes later syncs reparse only changed files. The initial 365-day scan can take longer on machines with large histories. diff --git a/collectors.test.ts b/collectors.test.ts index 6f51e8c..7f03ef2 100644 --- a/collectors.test.ts +++ b/collectors.test.ts @@ -163,4 +163,24 @@ describe("usage collectors", () => { pricingStatus: "unknown", }); }); + + it("parses Antigravity host aggregates with the Antigravity agent name", () => { + const content = JSON.stringify([{ + day: "2026-08-09", + modelProviderId: "google", + model: "gemini-4-ultra-preview", + loggedCostUsd: null, + uncachedInputTokens: 13814, + cachedInputTokens: 0, + cacheWriteTokens: 0, + outputTokens: 53, + }]); + expect(parseHostUsageAggregates(content, "antigravity", machine)[0]).toMatchObject({ + eventKey: "antigravity:machine-a:2026-08-09:google:gemini-4-ultra-preview", + agentId: "antigravity", + agentName: "Antigravity", + modelProviderId: "google", + processedTokens: 13867, + }); + }); }); diff --git a/collectors.ts b/collectors.ts index cd4bceb..49ecc04 100644 --- a/collectors.ts +++ b/collectors.ts @@ -1,6 +1,6 @@ import { normalizeProviderId, resolvePricing, type PricingStatus } from "./lib/pricing"; -export type AgentId = "codex" | "claude" | "fx" | "grok" | "opencode" | "pi" | "prime"; +export type AgentId = "codex" | "claude" | "fx" | "grok" | "opencode" | "pi" | "prime" | "antigravity"; export type UsageRecord = { eventKey: string; @@ -285,6 +285,7 @@ export function parseHostUsageAggregates(content: string, agentId: Exclude { diff --git a/lib/host-json-collector.test.ts b/lib/host-json-collector.test.ts index e65a9f7..1354c53 100644 --- a/lib/host-json-collector.test.ts +++ b/lib/host-json-collector.test.ts @@ -146,6 +146,42 @@ describe("host JSON usage collector", () => { expect(second.rows).toEqual(first.rows); }); + it("streams Antigravity's provider-bridge usage log", async () => { + const directory = await temporaryDirectory(); + const root = join(directory, "usage.jsonl"); + const cachePath = join(directory, "cache", "antigravity.json"); + await writeFile(root, [ + { kind: "coverage", status: "partial" }, + { kind: "generation", fact: { + created_at_ms: Date.parse("2026-08-09T00:00:00Z"), + provider: "google", + model: "gemini-4-ultra-preview", + input_tokens: 13814, + output_tokens: 27, + thinking_tokens: 26, + cache_read_tokens: 0, + total_cost: null, + } }, + ].map((value) => JSON.stringify(value)).join("\n")); + + const first = await scan("antigravity", root, cachePath); + expect(first).toMatchObject({ fileCount: 1, changedFileCount: 1, reusedFileCount: 0, failureCount: 0 }); + expect(first.rows).toEqual([expect.objectContaining({ + day: "2026-08-09", + modelProviderId: "google", + model: "gemini-4-ultra-preview", + uncachedInputTokens: 13814, + cachedInputTokens: 0, + cacheWriteTokens: 0, + outputTokens: 53, + loggedCostUsd: null, + })]); + + const second = await scan("antigravity", root, cachePath); + expect(second).toMatchObject({ fileCount: 1, changedFileCount: 0, reusedFileCount: 1, failureCount: 0 }); + expect(second.rows).toEqual(first.rows); + }); + it("counts each Claude API response once across repeated rows, files, and cached scans", async () => { const directory = await temporaryDirectory(); const root = join(directory, "projects"); diff --git a/lib/host-json-collector.ts b/lib/host-json-collector.ts index 4d9e53c..3c4f16e 100644 --- a/lib/host-json-collector.ts +++ b/lib/host-json-collector.ts @@ -44,7 +44,7 @@ const aggregateSchema = z.object({ outputTokens: z.number().int().nonnegative(), }); const scanResultSchema = z.object({ - agentId: z.enum(["codex", "claude", "fx", "grok", "pi", "prime"]), + agentId: z.enum(["codex", "claude", "fx", "grok", "pi", "prime", "antigravity"]), fileCount: z.number().int().nonnegative(), changedFileCount: z.number().int().nonnegative(), reusedFileCount: z.number().int().nonnegative(), @@ -63,7 +63,7 @@ async function hostJsonCollector(encodedInput: string, dependencies: CollectorDe const scanBegin = "__BB_USAGE_SCAN_BEGIN__"; const scanEnd = "__BB_USAGE_SCAN_END__"; const input = JSON.parse(buffer.from(encodedInput, "base64").toString("utf8")) as HostJsonScanInput; - const allowedAgents = new Set(["codex", "claude", "fx", "grok", "pi", "prime"]); + const allowedAgents = new Set(["codex", "claude", "fx", "grok", "pi", "prime", "antigravity"]); if (!allowedAgents.has(input.agentId)) throw new Error("Unsupported usage agent."); if (!/^\d{4}-\d{2}-\d{2}$/.test(input.sinceDay)) throw new Error("Invalid usage history boundary."); @@ -165,7 +165,7 @@ async function hostJsonCollector(encodedInput: string, dependencies: CollectorDe function matches(filePath: string) { const name = path.basename(filePath); if (input.agentId === "codex") return name.startsWith("rollout-") && name.endsWith(".jsonl"); - if (input.agentId === "fx") return name === "usage.jsonl"; + if (input.agentId === "fx" || input.agentId === "antigravity") return name === "usage.jsonl"; if (input.agentId === "grok") return name === "unified.jsonl"; return name.endsWith(".jsonl"); } @@ -295,6 +295,27 @@ async function hostJsonCollector(encodedInput: string, dependencies: CollectorDe continue; } + if (input.agentId === "antigravity") { + // Written by bb-plugin-antigravity-acp's provider bridge, one line + // per turn it forwards to the local `agy` CLI (agy has no session + // log of its own in this shape — the bridge is the source of truth). + if (value.kind !== "generation") continue; + const fact = object(value.fact); + const usageDay = day(fact?.created_at_ms); + if (!fact || !usageDay) continue; + add(rows, { + day: usageDay, + modelProviderId: text(fact.provider, "google"), + model: text(fact.model, "unknown"), + loggedCostUsd: finite(fact.total_cost), + uncachedInputTokens: count(fact.input_tokens), + cachedInputTokens: count(fact.cache_read_tokens), + cacheWriteTokens: 0, + outputTokens: count(fact.output_tokens) + count(fact.thinking_tokens), + }); + continue; + } + if (input.agentId === "pi" || input.agentId === "prime") { if (value.type !== "message") continue; const message = object(value.message); diff --git a/server.test.ts b/server.test.ts index cbf08f9..d0f6584 100644 --- a/server.test.ts +++ b/server.test.ts @@ -11,6 +11,12 @@ import plugin, { } from "./server"; describe("JSON agent roots", () => { + it("points Antigravity at the provider bridge's own usage log", () => { + expect(jsonAgentRoots("/home/user", "antigravity", { piSessionRoots: "", primeSessionRoots: "" })).toEqual([ + "/home/user/.antigravity-acp/usage.jsonl", + ]); + }); + it("includes Prime root and recursive-agent sessions", () => { expect(jsonAgentRoots("/home/user", "prime", { piSessionRoots: "", primeSessionRoots: "" })).toEqual([ "/home/user/.prime/agent/sessions", diff --git a/server.ts b/server.ts index 22b0322..7ae0d51 100644 --- a/server.ts +++ b/server.ts @@ -66,6 +66,7 @@ const AGENTS = [ { id: "opencode", name: "OpenCode" }, { id: "pi", name: "Pi" }, { id: "prime", name: "Prime Agent" }, + { id: "antigravity", name: "Antigravity" }, ] as const satisfies ReadonlyArray<{ id: AgentId; name: string }>; const LIMIT_PROVIDERS = [ @@ -275,6 +276,7 @@ export function jsonAgentRoots(home: string, agentId: HostJsonAgentId, settings: : agentId === "claude" ? [`${home}/.claude/projects`] : agentId === "fx" ? [`${home}/.fx/usage.jsonl`] : agentId === "grok" ? [`${home}/.grok/logs`] + : agentId === "antigravity" ? [`${home}/.antigravity-acp/usage.jsonl`] : agentId === "prime" ? resolvedPrimeRoots : [`${home}/.pi/agent/sessions`, ...configuredRoots(settings.piSessionRoots, home).filter((root) => { const defaultPrimeAgentRoot = `${home}/.prime/agent`; From ccdab3ce54b28f91e9d5f86b3992ec4a3e068cc6 Mon Sep 17 00:00:00 2001 From: nuchareviews-beep <319816943+nuchareviews-beep@users.noreply.github.com> Date: Sun, 23 Aug 2026 22:25:13 +1000 Subject: [PATCH 2/3] Wire antigravity into syncAll's dispatch (was missing) AGENTS and jsonAgentRoots already knew about "antigravity", but syncAll()'s Promise.all never called syncJsonAgent(..., "antigravity", ...) -- the UI would show the filter option with no sync ever running behind it. The existing unit tests didn't catch this because they call scan()/parseHostUsageAggregates directly, bypassing syncAll entirely. Adds the missing dispatch call plus a regression test that drives the real, unmodified plugin factory through its public sync() RPC (mocking only bb.sdk.hosts/terminals) and asserts a row actually lands in the database for antigravity -- the exact gap the previous tests missed. Verified separately against real accumulated usage data from a live bb-plugin-antigravity-acp session: 3 real records parsed and priced correctly end to end. --- server.test.ts | 115 +++++++++++++++++++++++++++++++++++++++++++++++++ server.ts | 1 + 2 files changed, 116 insertions(+) diff --git a/server.test.ts b/server.test.ts index d0f6584..a0571fb 100644 --- a/server.test.ts +++ b/server.test.ts @@ -1,4 +1,5 @@ import Database from "better-sqlite3"; +import { gunzipSync, gzipSync } from "node:zlib"; import { describe, expect, it, vi } from "vitest"; import type { BbPluginApi } from "@bb/plugin-sdk"; @@ -10,6 +11,19 @@ import plugin, { dashboardRecordsSql, extractOpenCodeJson, jsonAgentRoots, loadProviderLimits, openCodeCommand, runHostCommand, syncOpenCode, } from "./server"; +// Same markers host-json-collector.ts's hostJsonCollector() wraps its +// gzipped result in; not exported (they're a private wire format between +// the generated host script and extractHostJsonScan), so the fixture +// reproduces them rather than importing. +const SCAN_BEGIN = "__BB_USAGE_SCAN_BEGIN__"; +const SCAN_END = "__BB_USAGE_SCAN_END__"; + +function fakeHostScanOutput(agentId: string, rows: Array>) { + const scan = { agentId, fileCount: 1, changedFileCount: 1, reusedFileCount: 0, failureCount: 0, error: null, rows }; + const encoded = gzipSync(Buffer.from(JSON.stringify(scan))).toString("base64"); + return `${SCAN_BEGIN}\n${encoded}\n${SCAN_END}\n__BB_HOST_COMMAND_DONE__:0\n`; +} + describe("JSON agent roots", () => { it("points Antigravity at the provider bridge's own usage log", () => { expect(jsonAgentRoots("/home/user", "antigravity", { piSessionRoots: "", primeSessionRoots: "" })).toEqual([ @@ -73,6 +87,107 @@ describe("sync RPC", () => { expect(handlers?.sync()).toEqual({ ok: true }); expect(bb.sdk.hosts.list).toHaveBeenCalledOnce(); }); + + it("actually dispatches an Antigravity scan through syncAll, not just through direct scan() calls", async () => { + // Regression test for the exact gap flagged in review on + // https://github.com/MayankBansal12/bb-plugin-usage/pull/21: AGENTS and + // jsonAgentRoots knew about "antigravity", but syncAll()'s Promise.all + // never called syncJsonAgent(..., "antigravity", ...), so no scan ever + // ran for it in production even though the unit tests (which call + // scan()/parseHostUsageAggregates directly) all passed. This drives the + // real, unmodified plugin factory end-to-end through its public sync() + // RPC and asserts a row actually lands in the database for Antigravity. + const db = new Database(":memory:"); + let handlers: { sync: () => unknown } | undefined; + + // The command is a shell wrapper around `node -e eval(gunzip(base64(...)))` + // where the gzipped payload is the generated collector script with + // agentId/roots baked in as a literal object — decode it the same way + // to tell which JSON-agent sync this particular terminal is for. + function agentIdFromCommand(command: string): string | null { + // Outer layer: eval(gunzip(base64(