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
3 changes: 2 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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.

Expand Down
20 changes: 20 additions & 0 deletions collectors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
});
});
3 changes: 2 additions & 1 deletion collectors.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -285,6 +285,7 @@ export function parseHostUsageAggregates(content: string, agentId: Exclude<Agent
: agentId === "grok" ? "Grok Agent"
: agentId === "fx" ? "FX"
: agentId === "prime" ? "Prime Agent"
: agentId === "antigravity" ? "Antigravity"
: "Pi";

return values.flatMap((raw) => {
Expand Down
36 changes: 36 additions & 0 deletions lib/host-json-collector.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
27 changes: 24 additions & 3 deletions lib/host-json-collector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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<HostJsonAgentId>(["codex", "claude", "fx", "grok", "pi", "prime"]);
const allowedAgents = new Set<HostJsonAgentId>(["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.");

Expand Down Expand Up @@ -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");
}
Expand Down Expand Up @@ -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);
Expand Down
6 changes: 6 additions & 0 deletions server.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
2 changes: 2 additions & 0 deletions server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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`;
Expand Down