diff --git a/README.md b/README.md
index ad6a4ac..443f261 100644
--- a/README.md
+++ b/README.md
@@ -1,6 +1,6 @@
# Tokenleak
-See where your AI tokens actually go. Tokenleak reads usage data from **Claude Code**, **Codex**, **Cursor**, **Gemini**, **GitHub Copilot**, **Amp**, **Qwen**, **Roo Code**, **Kilo Code**, **OpenClaw**, **Hermes**, **Pi (`pi-mono`)**, and **OpenCode**, then renders terminal dashboards, heatmaps, compare reports, explain/focus reports, and shareable image cards from the CLI.
+See where your AI tokens actually go. Tokenleak reads usage data from **Claude Code**, **Codex**, **Cursor**, **Gemini**, **GitHub Copilot**, **Amp**, **Codebuff**, **Droid**, **Qwen**, **Roo Code**, **Kilo Code**, **Kimi**, **Kilo CLI**, **Mux**, **Crush**, **OpenClaw**, **Hermes**, **Goose**, **Antigravity**, **Zed Agent**, **Kiro**, **Trae**, **Synthetic**, **Pi (`pi-mono`)**, and **OpenCode**, then renders terminal dashboards, heatmaps, compare reports, explain/focus reports, and shareable image cards from the CLI.

@@ -16,18 +16,30 @@ Tokenleak auto-detects supported providers from their local logs and storage. Th
|
| Gemini | `~/.gemini/tmp/**/*.{json,jsonl}` | `gemini`, `google` | Yes |
|
| GitHub Copilot | `~/.copilot/otel/**/*.jsonl` | `copilot`, `github-copilot`, `copilot-otel` | Yes |
|
| Amp | `${XDG_DATA_HOME:-~/.local/share}/amp/threads/T-*.json` | `amp`, `sourcegraph-amp` | Yes |
+|
| Codebuff | `~/.config/manicode/projects/**/chats/**/chat-messages.json` | `codebuff`, `manicode` | Yes |
+|
| Droid | `~/.factory/sessions/*.settings.json` | `droid`, `factory` | Yes |
|
| Qwen | `~/.qwen/projects/**/*.jsonl` | `qwen` | Yes |
|
| Roo Code | `~/.config/Code/User/globalStorage/rooveterinaryinc.roo-cline/tasks/**/ui_messages.json` | `roo-code`, `roo`, `roocode` | Yes |
-|
| Kilo Code | `~/.config/Code/User/globalStorage/kilocode.kilo-code/tasks/**/ui_messages.json` | `kilo-code`, `kilo`, `kilocode` | Yes |
+|
| Kilo Code | `~/.config/Code/User/globalStorage/kilocode.kilo-code/tasks/**/ui_messages.json` | `kilo-code`, `kilocode` | Yes |
+|
| Kimi CLI | `~/.kimi/sessions/**/wire.jsonl` | `kimi`, `kimi-cli` | Yes |
+|
| Kilo CLI | `~/.local/share/kilo/kilo.db` | `kilo`, `kilo-cli` | Yes |
+|
| Mux | `~/.mux/sessions/**/session-usage.json` | `mux` | Yes |
+|
| Crush | `${XDG_DATA_HOME:-~/.local/share}/crush/projects.json` project databases | `crush` | Yes |
|
| OpenClaw | `~/.openclaw/agents/**/*.jsonl*` | `openclaw`, `open-claw` | Yes |
-|
| Hermes | `${HERMES_HOME:-~/.hermes}/state.db` | `hermes` | Yes |
+|
| Hermes | `${HERMES_HOME:-~/.hermes}/state.db` | `hermes`, `hermes-agent` | Yes |
+|
| Goose | `${XDG_DATA_HOME:-~/.local/share}/goose/sessions/sessions.db` | `goose` | Yes |
+|
| Antigravity | `~/.config/tokenleak/antigravity-cache/sessions/*.jsonl` cache | `antigravity` | Yes |
+|
| Zed Agent | `${XDG_DATA_HOME:-~/.local/share}/zed/threads/threads.db` | `zed`, `zed-agent` | Yes |
+|
| Kiro | `~/.kiro/sessions/cli/*.json` | `kiro` | Yes |
+|
| Trae | `~/.config/tokenleak/trae-cache/sessions/*.json` cache | `trae` | Yes |
+|
| Synthetic | `~/.local/share/octofriend/sqlite.db` | `synthetic`, `octofriend` | Yes |
|
| OpenCode | `~/.local/share/opencode/storage/message//*.json` or `~/.config/opencode/storage/message//*.json`
Legacy: `~/.opencode/opencode.db`, `~/.opencode/sessions.db`, `~/.opencode/sessions/*.json` | `open-code`, `opencode`, `open_code` | Yes |
|
| Pi (`pi-mono`) | `~/.pi/agent/sessions/**/*.jsonl` | `pi`, `pi-mono` | Yes |
- Use `CLAUDE_CONFIG_DIR` to override the Claude Code base directory.
- Use `CODEX_HOME` to override the Codex base directory.
- Use `TOKENLEAK_CURSOR_DIR` to override the Cursor credentials/cache directory.
-- Use `TOKENLEAK_GEMINI_DIR`, `TOKENLEAK_COPILOT_OTEL_DIR`, `TOKENLEAK_AMP_DIR`, `TOKENLEAK_QWEN_DIR`, `TOKENLEAK_ROO_CODE_DIR`, `TOKENLEAK_KILO_CODE_DIR`, `TOKENLEAK_OPENCLAW_DIR`, and `TOKENLEAK_HERMES_DIR` to override the new provider data locations.
+- Use `TOKENLEAK_GEMINI_DIR`, `TOKENLEAK_COPILOT_OTEL_DIR`, `TOKENLEAK_AMP_DIR`, `TOKENLEAK_CODEBUFF_DIR`, `TOKENLEAK_DROID_DIR`, `TOKENLEAK_QWEN_DIR`, `TOKENLEAK_ROO_CODE_DIR`, `TOKENLEAK_KILO_CODE_DIR`, `TOKENLEAK_KIMI_DIR`, `TOKENLEAK_KILO_DIR`, `TOKENLEAK_MUX_DIR`, `TOKENLEAK_CRUSH_DIR`, `TOKENLEAK_OPENCLAW_DIR`, `TOKENLEAK_HERMES_DIR`, `TOKENLEAK_GOOSE_DIR`, `TOKENLEAK_ANTIGRAVITY_DIR`, `TOKENLEAK_ZED_DIR`, `TOKENLEAK_KIRO_DIR`, `TOKENLEAK_TRAE_DIR`, and `TOKENLEAK_SYNTHETIC_DIR` to override provider data locations.
- Hermes also honors `HERMES_HOME`.
- Use `PI_CODING_AGENT_DIR` to override the Pi base directory.
- See [Provider details](#provider-details) for the parser behavior and per-provider notes.
@@ -632,6 +644,28 @@ Reads Sourcegraph Amp thread JSON files and combines usage ledger rows with mess
| **Provider name** | `amp` |
| **Aliases** | `sourcegraph-amp` |
+### Codebuff
+
+Reads Codebuff/Manicode chat history files and parses assistant usage from direct metadata or provider-options run-state fallbacks.
+
+| | |
+| ----------------- | -------------------------------------------------------------------- |
+| **Data location** | `~/.config/manicode/projects/**/chats/**/chat-messages.json` |
+| **Override** | Set `TOKENLEAK_CODEBUFF_DIR` environment variable |
+| **Provider name** | `codebuff` |
+| **Aliases** | `manicode` |
+
+### Droid
+
+Reads Factory Droid session settings files and uses sibling JSONL session text to recover a model name when Droid only records a provider lock.
+
+| | |
+| ----------------- | ------------------------------------------------- |
+| **Data location** | `~/.factory/sessions/*.settings.json` |
+| **Override** | Set `TOKENLEAK_DROID_DIR` environment variable |
+| **Provider name** | `droid` |
+| **Aliases** | `factory` |
+
### Qwen
Reads Qwen CLI project JSONL logs. Assistant records with `usageMetadata` are parsed for prompt, candidate, thought, and cached-content tokens.
@@ -653,7 +687,51 @@ Reads VS Code extension task logs from `ui_messages.json` and uses sibling `api_
| **Kilo data** | `~/.config/Code/User/globalStorage/kilocode.kilo-code/tasks/**/ui_messages.json` |
| **Override** | Set `TOKENLEAK_ROO_CODE_DIR` or `TOKENLEAK_KILO_CODE_DIR` environment variable |
| **Provider names** | `roo-code`, `kilo-code` |
-| **Aliases** | `roo`, `roocode`, `kilo`, `kilocode` |
+| **Aliases** | `roo`, `roocode`, `kilocode` |
+
+### Kimi CLI
+
+Reads Kimi CLI wire-protocol JSONL files. `StatusUpdate` messages provide input, output, and cache token counts.
+
+| | |
+| ----------------- | ------------------------------------------------- |
+| **Data location** | `~/.kimi/sessions/**/wire.jsonl` |
+| **Override** | Set `TOKENLEAK_KIMI_DIR` environment variable |
+| **Provider name** | `kimi` |
+| **Aliases** | `kimi-cli` |
+
+### Kilo CLI
+
+Reads Kilo CLI SQLite usage rows from the local Kilo data directory. This is separate from the VS Code Kilo Code extension provider (`kilo-code`).
+
+| | |
+| ----------------- | ------------------------------------------------- |
+| **Data location** | `~/.local/share/kilo/kilo.db` |
+| **Override** | Set `TOKENLEAK_KILO_DIR` environment variable |
+| **Provider name** | `kilo` |
+| **Aliases** | `kilo-cli` |
+
+### Mux
+
+Reads Mux session usage JSON files and preserves provider-reported per-model cost totals when present.
+
+| | |
+| ----------------- | ------------------------------------------------- |
+| **Data location** | `~/.mux/sessions/**/session-usage.json` |
+| **Override** | Set `TOKENLEAK_MUX_DIR` environment variable |
+| **Provider name** | `mux` |
+| **Aliases** | None |
+
+### Crush
+
+Reads project-level Crush SQLite databases discovered from the Crush project registry. Crush exposes reliable session-level cost even when token breakdown is unavailable, so Tokenleak preserves cost without fabricating tokens.
+
+| | |
+| ----------------- | ------------------------------------------------------------------------ |
+| **Data location** | `${XDG_DATA_HOME:-~/.local/share}/crush/projects.json` project registry |
+| **Override** | Set `TOKENLEAK_CRUSH_DIR` environment variable |
+| **Provider name** | `crush` |
+| **Aliases** | None |
### OpenClaw
@@ -668,15 +746,81 @@ Reads OpenClaw agent transcripts and optional `sessions.json` indexes. Model-cha
### Hermes
-Reads aggregated Hermes Agent session rows from the local SQLite state database.
+Reads aggregated Hermes Agent session rows from the local SQLite state database. The parser accepts schema variants where optional cost or cache columns are absent, as long as `id`, `model`, `started_at`, and at least one token or cost signal are present.
| | |
| ----------------- | ------------------------------------------------- |
| **Data location** | `${HERMES_HOME:-~/.hermes}/state.db` |
| **Override** | Set `HERMES_HOME` or `TOKENLEAK_HERMES_DIR` |
| **Provider name** | `hermes` |
+| **Aliases** | `hermes-agent` |
+
+### Goose
+
+Reads Goose session totals from the local `sessions.db` SQLite database. Tokenleak scans the XDG path by default; use the override for macOS Application Support or legacy Block/goose locations.
+
+| | |
+| ----------------- | --------------------------------------------------------------- |
+| **Data location** | `${XDG_DATA_HOME:-~/.local/share}/goose/sessions/sessions.db` |
+| **Override** | Set `TOKENLEAK_GOOSE_DIR` or `GOOSE_PATH_ROOT` |
+| **Provider name** | `goose` |
+| **Aliases** | None |
+
+### Antigravity
+
+Reads normalized Antigravity JSONL cache files. Tokenleak does not currently connect to the local Antigravity language-server RPC or create this cache; place compatible JSONL artifacts in the cache directory first.
+
+| | |
+| ----------------- | ------------------------------------------------------------------ |
+| **Data location** | `~/.config/tokenleak/antigravity-cache/sessions/*.jsonl` |
+| **Override** | Set `TOKENLEAK_ANTIGRAVITY_DIR` environment variable |
+| **Provider name** | `antigravity` |
+| **Aliases** | None |
+
+### Zed Agent
+
+Reads hosted Zed Agent rows from Zed's `threads.db`. External ACP agent rows are ignored to avoid double-counting usage that belongs to another provider.
+
+| | |
+| ----------------- | --------------------------------------------------------------------------- |
+| **Data location** | `${XDG_DATA_HOME:-~/.local/share}/zed/threads/threads.db` |
+| **Override** | Set `TOKENLEAK_ZED_DIR` environment variable |
+| **Provider name** | `zed` |
+| **Aliases** | `zed-agent` |
+
+### Kiro
+
+Reads Kiro CLI session JSON files with explicit turn token metadata. SQLite import can be enabled by pointing the override at a compatible local data source.
+
+| | |
+| ----------------- | ------------------------------------------------- |
+| **Data location** | `~/.kiro/sessions/cli/*.json` |
+| **Override** | Set `TOKENLEAK_KIRO_DIR` environment variable |
+| **Provider name** | `kiro` |
| **Aliases** | None |
+### Trae
+
+Reads cached Trae usage API JSON files. Tokenleak does not currently authenticate with Trae or fetch this cache; export or sync compatible JSON first, then point Tokenleak at the cache.
+
+| | |
+| ----------------- | ---------------------------------------------------------- |
+| **Data location** | `~/.config/tokenleak/trae-cache/sessions/*.json` |
+| **Override** | Set `TOKENLEAK_TRAE_DIR` environment variable |
+| **Provider name** | `trae` |
+| **Aliases** | None |
+
+### Synthetic
+
+Reads Octofriend/Synthetic SQLite token rows when available. Synthetic is also kept as an explicit provider filter so it does not duplicate unrelated provider usage during normal all-provider scans.
+
+| | |
+| ----------------- | ----------------------------------------------------- |
+| **Data location** | `~/.local/share/octofriend/sqlite.db` |
+| **Override** | Set `TOKENLEAK_SYNTHETIC_DIR` environment variable |
+| **Provider name** | `synthetic` |
+| **Aliases** | `octofriend` |
+
### OpenCode
Reads usage data from current OpenCode message storage when available. Falls back to legacy SQLite databases or legacy JSON session files.
@@ -853,12 +997,25 @@ All fields are optional. Only include the ones you want to override.
| `TOKENLEAK_GEMINI_DIR` | `~/.gemini/tmp` | Gemini CLI temp/session directory |
| `TOKENLEAK_COPILOT_OTEL_DIR` | `~/.copilot/otel` | GitHub Copilot OTEL JSONL directory |
| `TOKENLEAK_AMP_DIR` | `${XDG_DATA_HOME:-~/.local/share}/amp/threads` | Amp thread directory |
+| `TOKENLEAK_CODEBUFF_DIR` | `~/.config/manicode` | Codebuff/Manicode project chat root |
+| `TOKENLEAK_DROID_DIR` | `~/.factory/sessions` | Droid session settings directory |
| `TOKENLEAK_QWEN_DIR` | `~/.qwen/projects` | Qwen project log directory |
| `TOKENLEAK_ROO_CODE_DIR` | VS Code Roo Code task storage | Roo Code task-log directory |
| `TOKENLEAK_KILO_CODE_DIR` | VS Code Kilo Code task storage | Kilo Code task-log directory |
+| `TOKENLEAK_KIMI_DIR` | `~/.kimi` | Kimi CLI root directory |
+| `TOKENLEAK_KILO_DIR` | `~/.local/share/kilo/kilo.db` | Kilo CLI SQLite database path |
+| `TOKENLEAK_MUX_DIR` | `~/.mux/sessions` | Mux session usage directory |
+| `TOKENLEAK_CRUSH_DIR` | Crush project registry discovery | Crush data directory or `crush.db` path |
| `TOKENLEAK_OPENCLAW_DIR` | `~/.openclaw/agents` | OpenClaw agent transcript directory |
| `TOKENLEAK_HERMES_DIR` | `~/.hermes` | Hermes directory containing `state.db` |
| `HERMES_HOME` | `~/.hermes` | Hermes home directory |
+| `TOKENLEAK_GOOSE_DIR` | `${XDG_DATA_HOME:-~/.local/share}/goose/sessions/sessions.db` | Goose SQLite database path |
+| `GOOSE_PATH_ROOT` | unset | Goose root directory override |
+| `TOKENLEAK_ANTIGRAVITY_DIR` | `~/.config/tokenleak/antigravity-cache/sessions` | Antigravity normalized JSONL cache directory |
+| `TOKENLEAK_ZED_DIR` | `${XDG_DATA_HOME:-~/.local/share}/zed/threads/threads.db` | Zed Agent SQLite database path |
+| `TOKENLEAK_KIRO_DIR` | `~/.kiro/sessions/cli` | Kiro CLI session JSON directory |
+| `TOKENLEAK_TRAE_DIR` | `~/.config/tokenleak/trae-cache/sessions` | Trae cached usage JSON directory |
+| `TOKENLEAK_SYNTHETIC_DIR` | `~/.local/share/octofriend/sqlite.db` | Synthetic/Octofriend SQLite database path |
| `PI_CODING_AGENT_DIR` | `~/.pi/agent` | Pi coding agent directory (sessions live under `sessions/`) |
## What Tokenleak tracks
diff --git a/packages/cli/src/cli.test.ts b/packages/cli/src/cli.test.ts
index d4e3805..922dde6 100644
--- a/packages/cli/src/cli.test.ts
+++ b/packages/cli/src/cli.test.ts
@@ -48,6 +48,7 @@ import {
} from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
+import { Database } from 'bun:sqlite';
const REGISTRY_FIXTURES_DIR = join(import.meta.dir, '..', '..', 'registry', 'src', '__fixtures__');
@@ -760,6 +761,44 @@ describe('run', () => {
cleanup();
}
});
+
+ test('resolveTabbedDashboardProviders keeps synthetic selection scoped', async () => {
+ const root = mkdtempSync(join(tmpdir(), 'tokenleak-synthetic-cli-'));
+ const dbPath = join(root, 'sqlite.db');
+ const db = new Database(dbPath);
+ db.run('CREATE TABLE messages (id TEXT, model TEXT, input_tokens INTEGER, output_tokens INTEGER, timestamp INTEGER, session_id TEXT)');
+ db.run("INSERT INTO messages VALUES ('m1', 'hf:org/model', 1, 1, 1770724800000, 's1')");
+ db.close();
+ const previousEnv = process.env;
+
+ try {
+ process.env = { ...process.env, TOKENLEAK_SYNTHETIC_DIR: dbPath };
+ const providers = await resolveTabbedDashboardProviders({ providerNames: ['synthetic'] });
+ expect(providers.map((provider) => provider.name)).toEqual(['synthetic']);
+ } finally {
+ process.env = previousEnv;
+ rmSync(root, { recursive: true, force: true });
+ }
+ });
+
+ test('resolveTabbedDashboardProviders excludes synthetic from default provider scans', async () => {
+ const root = mkdtempSync(join(tmpdir(), 'tokenleak-synthetic-default-cli-'));
+ const dbPath = join(root, 'sqlite.db');
+ const db = new Database(dbPath);
+ db.run('CREATE TABLE messages (id TEXT, model TEXT, input_tokens INTEGER, output_tokens INTEGER, timestamp INTEGER, session_id TEXT)');
+ db.run("INSERT INTO messages VALUES ('m1', 'hf:org/model', 1, 1, 1770724800000, 's1')");
+ db.close();
+ const previousEnv = process.env;
+
+ try {
+ process.env = { ...process.env, TOKENLEAK_SYNTHETIC_DIR: dbPath };
+ const providers = await resolveTabbedDashboardProviders({ providerNames: [] });
+ expect(providers.map((provider) => provider.name)).not.toContain('synthetic');
+ } finally {
+ process.env = previousEnv;
+ rmSync(root, { recursive: true, force: true });
+ }
+ });
});
describe('runFocus', () => {
@@ -985,15 +1024,30 @@ describe('CLI invocation', () => {
expect(stdout).toContain('gemini');
expect(stdout).toContain('copilot');
expect(stdout).toContain('amp');
+ expect(stdout).toContain('codebuff');
+ expect(stdout).toContain('droid');
expect(stdout).toContain('qwen');
expect(stdout).toContain('roo-code');
expect(stdout).toContain('kilo-code');
+ expect(stdout).toContain('kimi');
+ expect(stdout).toContain('kilo');
+ expect(stdout).toContain('mux');
+ expect(stdout).toContain('crush');
expect(stdout).toContain('openclaw');
expect(stdout).toContain('hermes');
+ expect(stdout).toContain('goose');
+ expect(stdout).toContain('antigravity');
+ expect(stdout).toContain('zed');
+ expect(stdout).toContain('kiro');
+ expect(stdout).toContain('trae');
+ expect(stdout).toContain('synthetic');
expect(stdout).toContain('pi');
expect(stdout).toContain('open-code');
expect(stdout).toContain('github-copilot');
expect(stdout).toContain('sourcegraph-amp');
+ expect(stdout).toContain('manicode');
+ expect(stdout).toContain('kilo-cli');
+ expect(stdout).toContain('octofriend');
});
test('--all-providers with provider filter exits with code 1', async () => {
diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts
index c932f84..8228179 100644
--- a/packages/cli/src/cli.ts
+++ b/packages/cli/src/cli.ts
@@ -46,11 +46,23 @@ import {
GeminiProvider,
CopilotProvider,
AmpProvider,
+ CodebuffProvider,
+ DroidProvider,
QwenProvider,
RooCodeProvider,
KiloCodeProvider,
+ KimiProvider,
+ KiloProvider,
+ MuxProvider,
+ CrushProvider,
OpenClawProvider,
HermesProvider,
+ GooseProvider,
+ AntigravityProvider,
+ ZedProvider,
+ KiroProvider,
+ TraeProvider,
+ SyntheticProvider,
OpenCodeProvider,
PiProvider,
MODEL_PRICING,
@@ -140,16 +152,34 @@ const PROVIDER_ALIASES: Record = {
'copilot-otel': 'copilot',
amp: 'amp',
'sourcegraph-amp': 'amp',
+ codebuff: 'codebuff',
+ manicode: 'codebuff',
+ droid: 'droid',
+ factory: 'droid',
qwen: 'qwen',
'roo-code': 'roo-code',
roo: 'roo-code',
roocode: 'roo-code',
'kilo-code': 'kilo-code',
- kilo: 'kilo-code',
kilocode: 'kilo-code',
+ kimi: 'kimi',
+ 'kimi-cli': 'kimi',
+ kilo: 'kilo',
+ 'kilo-cli': 'kilo',
+ mux: 'mux',
+ crush: 'crush',
openclaw: 'openclaw',
'open-claw': 'openclaw',
hermes: 'hermes',
+ 'hermes-agent': 'hermes',
+ goose: 'goose',
+ antigravity: 'antigravity',
+ zed: 'zed',
+ 'zed-agent': 'zed',
+ kiro: 'kiro',
+ trae: 'trae',
+ synthetic: 'synthetic',
+ octofriend: 'synthetic',
};
const PROVIDER_ALIAS_GROUPS: Record = {
'claude-code': ['anthropic', 'claude', 'claudecode'],
@@ -160,10 +190,18 @@ const PROVIDER_ALIAS_GROUPS: Record = {
gemini: ['google'],
copilot: ['github-copilot', 'copilot-otel'],
amp: ['sourcegraph-amp'],
+ codebuff: ['manicode'],
+ droid: ['factory'],
'roo-code': ['roo', 'roocode'],
- 'kilo-code': ['kilo', 'kilocode'],
+ 'kilo-code': ['kilocode'],
+ kimi: ['kimi-cli'],
+ kilo: ['kilo-cli'],
openclaw: ['open-claw'],
+ hermes: ['hermes-agent'],
+ zed: ['zed-agent'],
+ synthetic: ['octofriend'],
};
+const EXPLICIT_ONLY_PROVIDERS = new Set(['synthetic']);
interface ProviderFilterConfig {
provider?: string;
@@ -226,6 +264,10 @@ function providerMatchesFilter(provider: IProvider, requested: Set): boo
return candidates.some((candidate) => requested.has(candidate));
}
+function isExplicitOnlyProvider(provider: IProvider): boolean {
+ return EXPLICIT_ONLY_PROVIDERS.has(provider.name);
+}
+
function buildHelpText(): string {
return [
`tokenleak ${VERSION}`,
@@ -672,11 +714,23 @@ function registerBuiltInProviders(registry: ProviderRegistry): void {
registry.register(new GeminiProvider());
registry.register(new CopilotProvider());
registry.register(new AmpProvider());
+ registry.register(new CodebuffProvider());
+ registry.register(new DroidProvider());
registry.register(new QwenProvider());
registry.register(new RooCodeProvider());
registry.register(new KiloCodeProvider());
+ registry.register(new KimiProvider());
+ registry.register(new KiloProvider());
+ registry.register(new MuxProvider());
+ registry.register(new CrushProvider());
registry.register(new OpenClawProvider());
registry.register(new HermesProvider());
+ registry.register(new GooseProvider());
+ registry.register(new AntigravityProvider());
+ registry.register(new ZedProvider());
+ registry.register(new KiroProvider());
+ registry.register(new TraeProvider());
+ registry.register(new SyntheticProvider());
registry.register(new PiProvider());
registry.register(new OpenCodeProvider());
}
@@ -745,6 +799,10 @@ async function selectAvailableProviders(
const registry = createRegistry();
let available = await registry.getAvailable();
+ if (!requestedProviders.has('synthetic')) {
+ available = available.filter((provider) => !isExplicitOnlyProvider(provider));
+ }
+
if (!config.allProviders && requestedProviders.size > 0) {
if (
config.provider &&
@@ -1247,6 +1305,19 @@ const PROVIDER_COLORS: Record = {
'claude-code': 179, // amber
codex: 71, // green
cursor: 78, // spring green
+ codebuff: 33,
+ droid: 208,
+ kimi: 244,
+ kilo: 214,
+ mux: 205,
+ crush: 196,
+ goose: 37,
+ antigravity: 99,
+ zed: 38,
+ kiro: 63,
+ trae: 44,
+ synthetic: 42,
+ hermes: 35,
pi: 73, // cyan/teal
'open-code': 68, // indigo/steel blue
};
diff --git a/packages/mcp/src/resources/overview.ts b/packages/mcp/src/resources/overview.ts
index ad65320..37cc106 100644
--- a/packages/mcp/src/resources/overview.ts
+++ b/packages/mcp/src/resources/overview.ts
@@ -6,11 +6,11 @@ import {
} from '@tokenleak/core';
import type { ProviderRegistry } from '@tokenleak/registry';
import { resolveRange } from '../shared/date-range.js';
-import { loadProviderData } from '../shared/provider-load.js';
+import { getAvailableProvidersForRequest, loadProviderData } from '../shared/provider-load.js';
export async function handleOverview(registry: ProviderRegistry): Promise {
const range = resolveRange({});
- const available = await registry.getAvailable();
+ const available = await getAvailableProvidersForRequest(registry);
const { data, warnings } = await loadProviderData(available, range);
diff --git a/packages/mcp/src/server.ts b/packages/mcp/src/server.ts
index 91116a6..e419b7f 100644
--- a/packages/mcp/src/server.ts
+++ b/packages/mcp/src/server.ts
@@ -7,11 +7,23 @@ import {
GeminiProvider,
CopilotProvider,
AmpProvider,
+ CodebuffProvider,
+ DroidProvider,
QwenProvider,
RooCodeProvider,
KiloCodeProvider,
+ KimiProvider,
+ KiloProvider,
+ MuxProvider,
+ CrushProvider,
OpenClawProvider,
HermesProvider,
+ GooseProvider,
+ AntigravityProvider,
+ ZedProvider,
+ KiroProvider,
+ TraeProvider,
+ SyntheticProvider,
PiProvider,
OpenCodeProvider,
} from '@tokenleak/registry';
@@ -46,11 +58,23 @@ function createDefaultRegistry(): ProviderRegistry {
registry.register(new GeminiProvider());
registry.register(new CopilotProvider());
registry.register(new AmpProvider());
+ registry.register(new CodebuffProvider());
+ registry.register(new DroidProvider());
registry.register(new QwenProvider());
registry.register(new RooCodeProvider());
registry.register(new KiloCodeProvider());
+ registry.register(new KimiProvider());
+ registry.register(new KiloProvider());
+ registry.register(new MuxProvider());
+ registry.register(new CrushProvider());
registry.register(new OpenClawProvider());
registry.register(new HermesProvider());
+ registry.register(new GooseProvider());
+ registry.register(new AntigravityProvider());
+ registry.register(new ZedProvider());
+ registry.register(new KiroProvider());
+ registry.register(new TraeProvider());
+ registry.register(new SyntheticProvider());
registry.register(new PiProvider());
registry.register(new OpenCodeProvider());
return registry;
diff --git a/packages/mcp/src/shared/provider-load.ts b/packages/mcp/src/shared/provider-load.ts
index 3d41a6d..054a24c 100644
--- a/packages/mcp/src/shared/provider-load.ts
+++ b/packages/mcp/src/shared/provider-load.ts
@@ -1,11 +1,24 @@
import type { DateRange, ProviderData, ProviderWarning } from '@tokenleak/core';
-import type { IProvider } from '@tokenleak/registry';
+import type { IProvider, ProviderRegistry } from '@tokenleak/registry';
+
+const EXPLICIT_ONLY_PROVIDERS = new Set(['synthetic']);
export interface LoadedProviderData {
data: ProviderData[];
warnings: ProviderWarning[];
}
+export async function getAvailableProvidersForRequest(
+ registry: ProviderRegistry,
+ providerName?: string,
+): Promise {
+ const available = await registry.getAvailable();
+ if (providerName) {
+ return available.filter((provider) => provider.name === providerName);
+ }
+ return available.filter((provider) => !EXPLICIT_ONLY_PROVIDERS.has(provider.name));
+}
+
export async function loadProviderData(
providers: IProvider[],
range: DateRange,
diff --git a/packages/mcp/src/tools/compare-periods.ts b/packages/mcp/src/tools/compare-periods.ts
index 2772f6f..67eaa88 100644
--- a/packages/mcp/src/tools/compare-periods.ts
+++ b/packages/mcp/src/tools/compare-periods.ts
@@ -10,7 +10,7 @@ import {
import type { DateRange } from '@tokenleak/core';
import type { ProviderRegistry } from '@tokenleak/registry';
import { assertValidDate, validateRange } from '../shared/date-range.js';
-import { loadProviderData } from '../shared/provider-load.js';
+import { getAvailableProvidersForRequest, loadProviderData } from '../shared/provider-load.js';
async function loadAndAggregate(
providers: Awaited>,
@@ -53,7 +53,7 @@ export async function handleComparePeriods(
previousRange = computePreviousPeriod(currentRange);
}
- const available = await registry.getAvailable();
+ const available = await getAvailableProvidersForRequest(registry);
const [currentResult, previousResult] = await Promise.all([
loadAndAggregate(available, currentRange),
diff --git a/packages/mcp/src/tools/get-cost-breakdown.ts b/packages/mcp/src/tools/get-cost-breakdown.ts
index 6a7de80..f4fdea2 100644
--- a/packages/mcp/src/tools/get-cost-breakdown.ts
+++ b/packages/mcp/src/tools/get-cost-breakdown.ts
@@ -1,7 +1,7 @@
import { aggregate, mergeProviderData } from '@tokenleak/core';
import type { ProviderRegistry } from '@tokenleak/registry';
import { resolveRange } from '../shared/date-range.js';
-import { loadProviderData } from '../shared/provider-load.js';
+import { getAvailableProvidersForRequest, loadProviderData } from '../shared/provider-load.js';
export async function handleGetCostBreakdown(
args: { days?: number; since?: string; until?: string },
@@ -9,7 +9,7 @@ export async function handleGetCostBreakdown(
) {
try {
const range = resolveRange(args);
- const available = await registry.getAvailable();
+ const available = await getAvailableProvidersForRequest(registry);
const { data, warnings } = await loadProviderData(available, range);
diff --git a/packages/mcp/src/tools/get-daily-usage.ts b/packages/mcp/src/tools/get-daily-usage.ts
index 6ad535f..615d62b 100644
--- a/packages/mcp/src/tools/get-daily-usage.ts
+++ b/packages/mcp/src/tools/get-daily-usage.ts
@@ -1,7 +1,7 @@
import { buildDailyCostCompleteness, mergeProviderData } from '@tokenleak/core';
import type { ProviderRegistry } from '@tokenleak/registry';
import { resolveRange } from '../shared/date-range.js';
-import { loadProviderData } from '../shared/provider-load.js';
+import { getAvailableProvidersForRequest, loadProviderData } from '../shared/provider-load.js';
const DEFAULT_DAILY_DAYS = 14;
@@ -11,10 +11,7 @@ export async function handleGetDailyUsage(
) {
try {
const range = resolveRange(args, DEFAULT_DAILY_DAYS);
- const available = await registry.getAvailable();
- const filtered = args.provider
- ? available.filter((p) => p.name === args.provider)
- : available;
+ const filtered = await getAvailableProvidersForRequest(registry, args.provider);
const { data, warnings } = await loadProviderData(filtered, range);
diff --git a/packages/mcp/src/tools/get-efficiency-advice.ts b/packages/mcp/src/tools/get-efficiency-advice.ts
index 213616e..8d5355b 100644
--- a/packages/mcp/src/tools/get-efficiency-advice.ts
+++ b/packages/mcp/src/tools/get-efficiency-advice.ts
@@ -8,7 +8,7 @@ import {
import { MODEL_PRICING } from '@tokenleak/registry';
import type { ProviderRegistry } from '@tokenleak/registry';
import { resolveRange } from '../shared/date-range.js';
-import { loadProviderData } from '../shared/provider-load.js';
+import { getAvailableProvidersForRequest, loadProviderData } from '../shared/provider-load.js';
export async function handleGetEfficiencyAdvice(
args: { days?: number; since?: string; until?: string; provider?: string },
@@ -16,10 +16,7 @@ export async function handleGetEfficiencyAdvice(
) {
try {
const range = resolveRange(args);
- const available = await registry.getAvailable();
- const filtered = args.provider
- ? available.filter((p) => p.name === args.provider)
- : available;
+ const filtered = await getAvailableProvidersForRequest(registry, args.provider);
const { data, warnings } = await loadProviderData(filtered, range);
diff --git a/packages/mcp/src/tools/get-receipt-lines.ts b/packages/mcp/src/tools/get-receipt-lines.ts
index 946de1d..da14a5f 100644
--- a/packages/mcp/src/tools/get-receipt-lines.ts
+++ b/packages/mcp/src/tools/get-receipt-lines.ts
@@ -2,7 +2,7 @@ import { buildReceipt, SCHEMA_VERSION } from '@tokenleak/core';
import type { UsageEvent } from '@tokenleak/core';
import type { ProviderRegistry } from '@tokenleak/registry';
import { resolveRange } from '../shared/date-range.js';
-import { loadProviderData } from '../shared/provider-load.js';
+import { getAvailableProvidersForRequest, loadProviderData } from '../shared/provider-load.js';
export async function handleGetReceiptLines(
args: { days?: number; since?: string; until?: string; provider?: string; topLines?: number },
@@ -10,10 +10,7 @@ export async function handleGetReceiptLines(
) {
try {
const range = resolveRange(args);
- const available = await registry.getAvailable();
- const filtered = args.provider
- ? available.filter((p) => p.name === args.provider)
- : available;
+ const filtered = await getAvailableProvidersForRequest(registry, args.provider);
const { data, warnings } = await loadProviderData(filtered, range);
diff --git a/packages/mcp/src/tools/get-streaks-and-habits.ts b/packages/mcp/src/tools/get-streaks-and-habits.ts
index 00ec362..f21bced 100644
--- a/packages/mcp/src/tools/get-streaks-and-habits.ts
+++ b/packages/mcp/src/tools/get-streaks-and-habits.ts
@@ -1,7 +1,7 @@
import { aggregate, mergeProviderData, buildMoreStats } from '@tokenleak/core';
import type { ProviderRegistry } from '@tokenleak/registry';
import { resolveRange } from '../shared/date-range.js';
-import { loadProviderData } from '../shared/provider-load.js';
+import { getAvailableProvidersForRequest, loadProviderData } from '../shared/provider-load.js';
const DEFAULT_HABITS_DAYS = 90;
@@ -11,7 +11,7 @@ export async function handleGetStreaksAndHabits(
) {
try {
const range = resolveRange(args, DEFAULT_HABITS_DAYS);
- const available = await registry.getAvailable();
+ const available = await getAvailableProvidersForRequest(registry);
const { data, warnings } = await loadProviderData(available, range);
diff --git a/packages/mcp/src/tools/get-usage-summary.ts b/packages/mcp/src/tools/get-usage-summary.ts
index 28aee04..de92241 100644
--- a/packages/mcp/src/tools/get-usage-summary.ts
+++ b/packages/mcp/src/tools/get-usage-summary.ts
@@ -5,7 +5,11 @@ import {
} from '@tokenleak/core';
import type { ProviderRegistry } from '@tokenleak/registry';
import { resolveRange } from '../shared/date-range.js';
-import { loadProviderData, summarizeProviderData } from '../shared/provider-load.js';
+import {
+ getAvailableProvidersForRequest,
+ loadProviderData,
+ summarizeProviderData,
+} from '../shared/provider-load.js';
export async function handleGetUsageSummary(
args: { days?: number; since?: string; until?: string; provider?: string },
@@ -13,10 +17,7 @@ export async function handleGetUsageSummary(
) {
try {
const range = resolveRange(args);
- const available = await registry.getAvailable();
- const filtered = args.provider
- ? available.filter((p) => p.name === args.provider)
- : available;
+ const filtered = await getAvailableProvidersForRequest(registry, args.provider);
const { data, warnings } = await loadProviderData(filtered, range);
diff --git a/packages/registry/src/index.ts b/packages/registry/src/index.ts
index 9e34443..7e62ae4 100644
--- a/packages/registry/src/index.ts
+++ b/packages/registry/src/index.ts
@@ -30,11 +30,23 @@ export {
GeminiProvider,
CopilotProvider,
AmpProvider,
+ CodebuffProvider,
+ DroidProvider,
QwenProvider,
RooCodeProvider,
KiloCodeProvider,
+ KimiProvider,
+ KiloProvider,
+ MuxProvider,
+ CrushProvider,
OpenClawProvider,
HermesProvider,
+ GooseProvider,
+ AntigravityProvider,
+ ZedProvider,
+ KiroProvider,
+ TraeProvider,
+ SyntheticProvider,
OpenCodeProvider,
PiProvider,
} from './providers/index';
diff --git a/packages/registry/src/providers/antigravity.ts b/packages/registry/src/providers/antigravity.ts
new file mode 100644
index 0000000..6c7ad5f
--- /dev/null
+++ b/packages/registry/src/providers/antigravity.ts
@@ -0,0 +1,104 @@
+import { existsSync } from 'node:fs';
+import { homedir } from 'node:os';
+import { join } from 'node:path';
+import type { DateRange, ProviderColors, ProviderData } from '@tokenleak/core';
+import type { IProvider } from '../provider';
+import { splitJsonlRecords } from '../parsers/jsonl-splitter';
+import { isInRange } from '../utils';
+import {
+ buildProviderData,
+ collectFiles,
+ extractDate,
+ nonNegativeNumber,
+ objectValue,
+ sessionIdFromFile,
+ stringValue,
+ timestampToIso,
+ type LocalProviderMetadata,
+ type LocalUsageRecord,
+} from './local-usage';
+
+const PROVIDER_NAME = 'antigravity';
+const DISPLAY_NAME = 'Antigravity';
+const DEFAULT_BASE_DIR = join(homedir(), '.config', 'tokenleak', 'antigravity-cache', 'sessions');
+const COLORS: ProviderColors = {
+ primary: '#7c3aed',
+ secondary: '#c084fc',
+ gradient: ['#7c3aed', '#c084fc'],
+};
+const METADATA: LocalProviderMetadata = { provider: PROVIDER_NAME, displayName: DISPLAY_NAME, colors: COLORS };
+
+function resolveBaseDir(baseDir?: string): string {
+ return baseDir ?? process.env['TOKENLEAK_ANTIGRAVITY_DIR'] ?? DEFAULT_BASE_DIR;
+}
+
+function isAntigravityFile(_path: string, name: string): boolean {
+ return name.endsWith('.jsonl');
+}
+
+async function parseAntigravityFile(file: string, range: DateRange): Promise {
+ const records: LocalUsageRecord[] = [];
+ let fallbackModel: string | null = null;
+ const fallbackSessionId = sessionIdFromFile(file);
+
+ try {
+ for await (const record of splitJsonlRecords(file)) {
+ const entry = objectValue(record);
+ if (!entry) continue;
+
+ if (entry['type'] === 'session_meta') {
+ fallbackModel = stringValue(entry['modelId']) ?? stringValue(entry['model_id']) ?? fallbackModel;
+ continue;
+ }
+
+ if (entry['type'] !== 'usage') continue;
+ const timestamp = timestampToIso(entry['timestamp']);
+ const date = timestamp ? extractDate(timestamp) : null;
+ if (!timestamp || !date || !isInRange(date, range)) continue;
+
+ const inputTokens = nonNegativeNumber(entry['input']);
+ const outputTokens = nonNegativeNumber(entry['output']) + nonNegativeNumber(entry['reasoning']);
+ const cacheReadTokens = nonNegativeNumber(entry['cacheRead']) || nonNegativeNumber(entry['cache_read']);
+ const cacheWriteTokens = nonNegativeNumber(entry['cacheWrite']) || nonNegativeNumber(entry['cache_write']);
+ if (inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens === 0) continue;
+
+ records.push({
+ date,
+ timestamp,
+ model: stringValue(entry['modelId']) ?? stringValue(entry['model_id']) ?? fallbackModel ?? 'unknown',
+ inputTokens,
+ outputTokens,
+ cacheReadTokens,
+ cacheWriteTokens,
+ sessionId: stringValue(entry['sessionId']) ?? stringValue(entry['session_id']) ?? fallbackSessionId,
+ });
+ }
+ } catch {
+ return records;
+ }
+
+ return records;
+}
+
+export class AntigravityProvider implements IProvider {
+ readonly name = PROVIDER_NAME;
+ readonly displayName = DISPLAY_NAME;
+ readonly colors = COLORS;
+ private readonly baseDir: string;
+
+ constructor(baseDir?: string) {
+ this.baseDir = resolveBaseDir(baseDir);
+ }
+
+ async isAvailable(): Promise {
+ return existsSync(this.baseDir) && collectFiles(this.baseDir, isAntigravityFile).length > 0;
+ }
+
+ async load(range: DateRange): Promise {
+ const records: LocalUsageRecord[] = [];
+ for (const file of collectFiles(this.baseDir, isAntigravityFile)) {
+ records.push(...await parseAntigravityFile(file, range));
+ }
+ return buildProviderData(METADATA, records);
+ }
+}
diff --git a/packages/registry/src/providers/codebuff.ts b/packages/registry/src/providers/codebuff.ts
new file mode 100644
index 0000000..ec9dc18
--- /dev/null
+++ b/packages/registry/src/providers/codebuff.ts
@@ -0,0 +1,139 @@
+import { existsSync, readFileSync } from 'node:fs';
+import { homedir } from 'node:os';
+import { join } from 'node:path';
+import type { DateRange, ProviderColors, ProviderData } from '@tokenleak/core';
+import type { IProvider } from '../provider';
+import { isInRange } from '../utils';
+import {
+ buildProviderData,
+ collectFiles,
+ extractDate,
+ fileModifiedTimestamp,
+ nonNegativeNumber,
+ objectValue,
+ safeNumber,
+ stringValue,
+ timestampToIso,
+ type LocalProviderMetadata,
+ type LocalUsageRecord,
+} from './local-usage';
+
+const PROVIDER_NAME = 'codebuff';
+const DISPLAY_NAME = 'Codebuff';
+const DEFAULT_BASE_DIR = join(homedir(), '.config', 'manicode');
+const COLORS: ProviderColors = {
+ primary: '#2563eb',
+ secondary: '#38bdf8',
+ gradient: ['#2563eb', '#38bdf8'],
+};
+const METADATA: LocalProviderMetadata = { provider: PROVIDER_NAME, displayName: DISPLAY_NAME, colors: COLORS };
+
+function resolveBaseDir(baseDir?: string): string {
+ return baseDir ?? process.env['TOKENLEAK_CODEBUFF_DIR'] ?? DEFAULT_BASE_DIR;
+}
+
+function isCodebuffFile(_path: string, name: string): boolean {
+ return name === 'chat-messages.json';
+}
+
+function chatIdToIso(chatId: string): string | null {
+ const t = chatId.indexOf('T');
+ if (t < 0) return null;
+ const rebuilt = `${chatId.slice(0, t)}${chatId.slice(t).replace('-', ':').replace('-', ':')}`;
+ return timestampToIso(rebuilt);
+}
+
+function contextFromPath(file: string): { projectId: string; sessionId: string; chatId: string } {
+ const parts = file.split(/[\\/]/);
+ const chatId = parts.at(-2) ?? 'unknown';
+ const chatsIndex = parts.lastIndexOf('chats');
+ const projectId = chatsIndex > 0 ? parts[chatsIndex - 1]! : 'unknown';
+ return { projectId, chatId, sessionId: `${projectId}/${chatId}` };
+}
+
+function usageFromMessage(message: Record): Record | null {
+ const metadata = objectValue(message['metadata']);
+ const direct = objectValue(metadata?.['usage']) ?? objectValue(objectValue(metadata?.['codebuff'])?.['usage']);
+ if (direct) return direct;
+
+ const history = objectValue(objectValue(objectValue(metadata?.['runState'])?.['sessionState'])?.['mainAgentState'])?.['messageHistory'];
+ if (Array.isArray(history)) {
+ for (const entryValue of [...history].reverse()) {
+ const entry = objectValue(entryValue);
+ const providerOptions = objectValue(entry?.['providerOptions']);
+ const usage = objectValue(providerOptions?.['usage']) ?? objectValue(providerOptions?.['tokenUsage']);
+ if (usage) return usage;
+ }
+ }
+
+ return null;
+}
+
+function parseCodebuffFile(file: string, range: DateRange): LocalUsageRecord[] {
+ let messages: unknown[];
+ try {
+ const parsed = JSON.parse(readFileSync(file, 'utf-8'));
+ messages = Array.isArray(parsed) ? parsed : [];
+ } catch {
+ return [];
+ }
+
+ const context = contextFromPath(file);
+ const fallbackTimestamp = chatIdToIso(context.chatId) ?? fileModifiedTimestamp(file);
+ const records: LocalUsageRecord[] = [];
+
+ for (const messageValue of messages) {
+ const message = objectValue(messageValue);
+ if (!message) continue;
+ const role = stringValue(message['variant']) ?? stringValue(message['role']);
+ if (role !== 'assistant' && role !== 'agent' && role !== 'ai') continue;
+ const usage = usageFromMessage(message);
+ if (!usage) continue;
+
+ const timestamp = timestampToIso(message['timestamp']) ?? timestampToIso(message['createdAt']) ?? fallbackTimestamp;
+ const date = extractDate(timestamp);
+ if (!date || !isInRange(date, range)) continue;
+
+ const inputTokens = nonNegativeNumber(usage['inputTokens']) || nonNegativeNumber(usage['input']);
+ const outputTokens = nonNegativeNumber(usage['outputTokens']) || nonNegativeNumber(usage['output']);
+ const cacheReadTokens =
+ nonNegativeNumber(usage['cacheReadInputTokens']) || nonNegativeNumber(usage['cacheReadTokens']);
+ const cacheWriteTokens =
+ nonNegativeNumber(usage['cacheCreationInputTokens']) || nonNegativeNumber(usage['cacheWriteTokens']);
+ if (inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens === 0) continue;
+
+ records.push({
+ date,
+ timestamp,
+ model: stringValue(usage['model']) ?? stringValue(usage['modelName']) ?? 'codebuff-unknown',
+ inputTokens,
+ outputTokens,
+ cacheReadTokens,
+ cacheWriteTokens,
+ explicitCost: safeNumber(usage['credits']) ?? safeNumber(usage['cost']) ?? undefined,
+ sessionId: context.sessionId,
+ projectId: context.projectId,
+ });
+ }
+
+ return records;
+}
+
+export class CodebuffProvider implements IProvider {
+ readonly name = PROVIDER_NAME;
+ readonly displayName = DISPLAY_NAME;
+ readonly colors = COLORS;
+ private readonly baseDir: string;
+
+ constructor(baseDir?: string) {
+ this.baseDir = resolveBaseDir(baseDir);
+ }
+
+ async isAvailable(): Promise {
+ return existsSync(this.baseDir) && collectFiles(this.baseDir, isCodebuffFile).length > 0;
+ }
+
+ async load(range: DateRange): Promise {
+ return buildProviderData(METADATA, collectFiles(this.baseDir, isCodebuffFile).flatMap((file) => parseCodebuffFile(file, range)));
+ }
+}
diff --git a/packages/registry/src/providers/crush.ts b/packages/registry/src/providers/crush.ts
new file mode 100644
index 0000000..41b6e98
--- /dev/null
+++ b/packages/registry/src/providers/crush.ts
@@ -0,0 +1,126 @@
+import { existsSync, readFileSync } from 'node:fs';
+import { homedir } from 'node:os';
+import { dirname, isAbsolute, join, resolve } from 'node:path';
+import { Database } from 'bun:sqlite';
+import type { DateRange, ProviderColors, ProviderData } from '@tokenleak/core';
+import type { IProvider } from '../provider';
+import { isInRange } from '../utils';
+import {
+ buildProviderData,
+ extractDate,
+ safeNumber,
+ stringValue,
+ timestampToIso,
+ type LocalProviderMetadata,
+ type LocalUsageRecord,
+} from './local-usage';
+
+const PROVIDER_NAME = 'crush';
+const DISPLAY_NAME = 'Crush';
+const COLORS: ProviderColors = {
+ primary: '#ef4444',
+ secondary: '#fca5a5',
+ gradient: ['#ef4444', '#fca5a5'],
+};
+const METADATA: LocalProviderMetadata = { provider: PROVIDER_NAME, displayName: DISPLAY_NAME, colors: COLORS };
+
+interface CrushSession {
+ id: string;
+ cost: number;
+ created_at: number;
+ updated_at: number;
+}
+
+function defaultRegistryPath(): string {
+ return join(process.env['XDG_DATA_HOME'] ?? join(homedir(), '.local', 'share'), 'crush', 'projects.json');
+}
+
+function discoverCrushDbs(): string[] {
+ const override = process.env['TOKENLEAK_CRUSH_DIR'];
+ if (override && existsSync(override)) {
+ return override.endsWith('.db') ? [override] : [join(override, 'crush.db')].filter(existsSync);
+ }
+
+ const registryPath = defaultRegistryPath();
+ try {
+ const parsed = JSON.parse(readFileSync(registryPath, 'utf-8'));
+ const projects = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.projects) ? parsed.projects : [];
+ return projects.flatMap((project: unknown) => {
+ if (typeof project !== 'object' || project === null) return [];
+ const p = project as Record;
+ const projectPath = stringValue(p['path']);
+ const dataDir = stringValue(p['data_dir']) ?? '.crush';
+ if (!projectPath) return [];
+ const resolvedDir = isAbsolute(dataDir) ? dataDir : resolve(projectPath, dataDir);
+ const db = join(resolvedDir, 'crush.db');
+ return existsSync(db) ? [db] : [];
+ });
+ } catch {
+ return [];
+ }
+}
+
+function loadRows(dbPath: string): CrushSession[] {
+ let db: InstanceType;
+ try {
+ db = new Database(dbPath, { readonly: true });
+ } catch {
+ return [];
+ }
+ try {
+ return db.query(`
+ SELECT id, cost, created_at, updated_at
+ FROM sessions
+ WHERE parent_session_id IS NULL
+ AND (COALESCE(message_count, 0) > 0 OR COALESCE(cost, 0) > 0)
+ `).all() as CrushSession[];
+ } catch {
+ return [];
+ } finally {
+ db.close();
+ }
+}
+
+function rowToRecord(dbPath: string, row: CrushSession, range: DateRange): LocalUsageRecord | null {
+ const timestamp = timestampToIso(row.updated_at || row.created_at);
+ const date = timestamp ? extractDate(timestamp) : null;
+ if (!timestamp || !date || !isInRange(date, range)) return null;
+ const cost = safeNumber(row.cost) ?? 0;
+ if (cost <= 0) return null;
+ return {
+ date,
+ timestamp,
+ model: 'session-total',
+ inputTokens: 0,
+ outputTokens: 0,
+ cacheReadTokens: 0,
+ cacheWriteTokens: 0,
+ explicitCost: cost,
+ sessionId: `${dbPath}:${row.id}`,
+ projectId: dirname(dbPath).split(/[\\/]/).at(-1),
+ };
+}
+
+export class CrushProvider implements IProvider {
+ readonly name = PROVIDER_NAME;
+ readonly displayName = DISPLAY_NAME;
+ readonly colors = COLORS;
+ private readonly dbPaths: string[];
+
+ constructor(dbPaths?: string[]) {
+ this.dbPaths = dbPaths ?? discoverCrushDbs();
+ }
+
+ async isAvailable(): Promise {
+ return this.dbPaths.some(existsSync);
+ }
+
+ async load(range: DateRange): Promise {
+ const records = this.dbPaths.flatMap((dbPath) =>
+ loadRows(dbPath)
+ .map((row) => rowToRecord(dbPath, row, range))
+ .filter((record): record is LocalUsageRecord => record !== null),
+ );
+ return buildProviderData(METADATA, records);
+ }
+}
diff --git a/packages/registry/src/providers/droid.ts b/packages/registry/src/providers/droid.ts
new file mode 100644
index 0000000..1f0db72
--- /dev/null
+++ b/packages/registry/src/providers/droid.ts
@@ -0,0 +1,119 @@
+import { existsSync, readFileSync } from 'node:fs';
+import { homedir } from 'node:os';
+import { join } from 'node:path';
+import type { DateRange, ProviderColors, ProviderData } from '@tokenleak/core';
+import type { IProvider } from '../provider';
+import { isInRange } from '../utils';
+import {
+ buildProviderData,
+ collectFiles,
+ extractDate,
+ fileModifiedTimestamp,
+ nonNegativeNumber,
+ objectValue,
+ sessionIdFromFile,
+ stringValue,
+ timestampToIso,
+ type LocalProviderMetadata,
+ type LocalUsageRecord,
+} from './local-usage';
+
+const PROVIDER_NAME = 'droid';
+const DISPLAY_NAME = 'Droid';
+const DEFAULT_BASE_DIR = join(homedir(), '.factory', 'sessions');
+const COLORS: ProviderColors = {
+ primary: '#f97316',
+ secondary: '#fdba74',
+ gradient: ['#f97316', '#fdba74'],
+};
+const METADATA: LocalProviderMetadata = { provider: PROVIDER_NAME, displayName: DISPLAY_NAME, colors: COLORS };
+
+function resolveBaseDir(baseDir?: string): string {
+ return baseDir ?? process.env['TOKENLEAK_DROID_DIR'] ?? DEFAULT_BASE_DIR;
+}
+
+function isDroidSettings(_path: string, name: string): boolean {
+ return name.endsWith('.settings.json');
+}
+
+function normalizeDroidModel(model: string): string {
+ return model
+ .replace(/^custom:/i, '')
+ .replace(/\[[^\]]*]/g, '')
+ .trim()
+ .replace(/-+$/g, '')
+ .toLowerCase()
+ .replace(/\./g, '-')
+ .replace(/\s+/g, '-')
+ .replace(/-+/g, '-');
+}
+
+function modelFromJsonl(settingsFile: string): string | null {
+ try {
+ const text = readFileSync(settingsFile.replace(/\.settings\.json$/, '.jsonl'), 'utf-8');
+ const match = /Model:\s*([^["\\\[]+)/i.exec(text);
+ return match?.[1] ? normalizeDroidModel(match[1]) : null;
+ } catch {
+ return null;
+ }
+}
+
+function parseDroidFile(file: string, range: DateRange): LocalUsageRecord[] {
+ let settings: Record;
+ try {
+ const parsed = JSON.parse(readFileSync(file, 'utf-8'));
+ const obj = objectValue(parsed);
+ if (!obj) return [];
+ settings = obj;
+ } catch {
+ return [];
+ }
+
+ const usage = objectValue(settings['tokenUsage']);
+ if (!usage) return [];
+ const timestamp = timestampToIso(settings['providerLockTimestamp']) ?? fileModifiedTimestamp(file);
+ const date = extractDate(timestamp);
+ if (!date || !isInRange(date, range)) return [];
+
+ const inputTokens = nonNegativeNumber(usage['inputTokens']);
+ const outputTokens = nonNegativeNumber(usage['outputTokens']) + nonNegativeNumber(usage['thinkingTokens']);
+ const cacheReadTokens = nonNegativeNumber(usage['cacheReadTokens']);
+ const cacheWriteTokens = nonNegativeNumber(usage['cacheCreationTokens']);
+ if (inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens === 0) return [];
+
+ const provider = stringValue(settings['providerLock']);
+ const model = settings['model']
+ ? normalizeDroidModel(String(settings['model']))
+ : modelFromJsonl(file) ?? `${provider ?? 'droid'}-unknown`;
+
+ return [{
+ date,
+ timestamp,
+ model,
+ inputTokens,
+ outputTokens,
+ cacheReadTokens,
+ cacheWriteTokens,
+ sessionId: sessionIdFromFile(file).replace(/\.settings$/i, ''),
+ projectId: provider ?? undefined,
+ }];
+}
+
+export class DroidProvider implements IProvider {
+ readonly name = PROVIDER_NAME;
+ readonly displayName = DISPLAY_NAME;
+ readonly colors = COLORS;
+ private readonly baseDir: string;
+
+ constructor(baseDir?: string) {
+ this.baseDir = resolveBaseDir(baseDir);
+ }
+
+ async isAvailable(): Promise {
+ return existsSync(this.baseDir) && collectFiles(this.baseDir, isDroidSettings).length > 0;
+ }
+
+ async load(range: DateRange): Promise {
+ return buildProviderData(METADATA, collectFiles(this.baseDir, isDroidSettings).flatMap((file) => parseDroidFile(file, range)));
+ }
+}
diff --git a/packages/registry/src/providers/goose.ts b/packages/registry/src/providers/goose.ts
new file mode 100644
index 0000000..3a4e245
--- /dev/null
+++ b/packages/registry/src/providers/goose.ts
@@ -0,0 +1,119 @@
+import { existsSync } from 'node:fs';
+import { homedir } from 'node:os';
+import { join } from 'node:path';
+import { Database } from 'bun:sqlite';
+import type { DateRange, ProviderColors, ProviderData } from '@tokenleak/core';
+import type { IProvider } from '../provider';
+import { isInRange } from '../utils';
+import {
+ buildProviderData,
+ extractDate,
+ nonNegativeNumber,
+ objectValue,
+ stringValue,
+ timestampToIso,
+ type LocalProviderMetadata,
+ type LocalUsageRecord,
+} from './local-usage';
+
+const PROVIDER_NAME = 'goose';
+const DISPLAY_NAME = 'Goose';
+const COLORS: ProviderColors = {
+ primary: '#0f766e',
+ secondary: '#5eead4',
+ gradient: ['#0f766e', '#5eead4'],
+};
+const METADATA: LocalProviderMetadata = { provider: PROVIDER_NAME, displayName: DISPLAY_NAME, colors: COLORS };
+
+interface GooseRow {
+ id: string;
+ model_config_json?: string | null;
+ provider_name?: string | null;
+ created_at: string;
+ total_tokens?: number | null;
+ input_tokens?: number | null;
+ output_tokens?: number | null;
+ accumulated_total_tokens?: number | null;
+ accumulated_input_tokens?: number | null;
+ accumulated_output_tokens?: number | null;
+}
+
+function defaultDbPaths(): string[] {
+ const override = process.env['TOKENLEAK_GOOSE_DIR'] ?? process.env['GOOSE_PATH_ROOT'];
+ if (override) return [override.endsWith('.db') ? override : join(override, 'sessions', 'sessions.db')];
+ return [
+ join(process.env['XDG_DATA_HOME'] ?? join(homedir(), '.local', 'share'), 'goose', 'sessions', 'sessions.db'),
+ join(homedir(), 'Library', 'Application Support', 'goose', 'sessions', 'sessions.db'),
+ join(homedir(), 'Library', 'Application Support', 'Block', 'goose', 'sessions', 'sessions.db'),
+ join(homedir(), '.local', 'share', 'Block', 'goose', 'sessions', 'sessions.db'),
+ ];
+}
+
+function loadRows(dbPath: string): GooseRow[] {
+ let db: InstanceType;
+ try {
+ db = new Database(dbPath, { readonly: true });
+ } catch {
+ return [];
+ }
+ try {
+ return db.query('SELECT * FROM sessions').all() as GooseRow[];
+ } catch {
+ return [];
+ } finally {
+ db.close();
+ }
+}
+
+function rowToRecord(row: GooseRow, range: DateRange): LocalUsageRecord | null {
+ const config = objectValue(row.model_config_json ? JSON.parse(row.model_config_json) : null);
+ const model = stringValue(config?.['model_name']);
+ if (!model) return null;
+ const timestamp = timestampToIso(row.created_at);
+ const date = timestamp ? extractDate(timestamp) : null;
+ if (!timestamp || !date || !isInRange(date, range)) return null;
+ const inputTokens = nonNegativeNumber(row.accumulated_input_tokens ?? row.input_tokens);
+ const baseOutput = nonNegativeNumber(row.accumulated_output_tokens ?? row.output_tokens);
+ const total = nonNegativeNumber(row.accumulated_total_tokens ?? row.total_tokens);
+ const outputTokens = baseOutput + Math.max(0, total - inputTokens - baseOutput);
+ if (inputTokens + outputTokens === 0) return null;
+ return {
+ date,
+ timestamp,
+ model,
+ inputTokens,
+ outputTokens,
+ cacheReadTokens: 0,
+ cacheWriteTokens: 0,
+ sessionId: row.id,
+ projectId: stringValue(row.provider_name) ?? undefined,
+ };
+}
+
+export class GooseProvider implements IProvider {
+ readonly name = PROVIDER_NAME;
+ readonly displayName = DISPLAY_NAME;
+ readonly colors = COLORS;
+ private readonly dbPaths: string[];
+
+ constructor(dbPath?: string) {
+ this.dbPaths = dbPath ? [dbPath] : defaultDbPaths();
+ }
+
+ async isAvailable(): Promise {
+ return this.dbPaths.some(existsSync);
+ }
+
+ async load(range: DateRange): Promise {
+ const records = this.dbPaths.flatMap((dbPath) => loadRows(dbPath))
+ .map((row) => {
+ try {
+ return rowToRecord(row, range);
+ } catch {
+ return null;
+ }
+ })
+ .filter((record): record is LocalUsageRecord => record !== null);
+ return buildProviderData(METADATA, records);
+ }
+}
diff --git a/packages/registry/src/providers/hermes.ts b/packages/registry/src/providers/hermes.ts
index 6e1df79..70b67eb 100644
--- a/packages/registry/src/providers/hermes.ts
+++ b/packages/registry/src/providers/hermes.ts
@@ -43,6 +43,39 @@ interface HermesRow {
actual_cost_usd?: number | null;
}
+function tableColumns(db: InstanceType, table: string): Set {
+ try {
+ return new Set(
+ (db.query(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>)
+ .map((row) => row.name),
+ );
+ } catch {
+ return new Set();
+ }
+}
+
+function selectColumn(columns: Set, name: keyof HermesRow, fallback: string = 'NULL'): string {
+ return columns.has(name) ? name : `${fallback} AS ${name}`;
+}
+
+function signalPredicate(columns: Set): string {
+ const tokenColumns = [
+ 'input_tokens',
+ 'output_tokens',
+ 'cache_read_tokens',
+ 'cache_write_tokens',
+ 'reasoning_tokens',
+ ].filter((name) => columns.has(name));
+ const predicates = tokenColumns.map((name) => `COALESCE(${name}, 0) > 0`);
+ if (columns.has('actual_cost_usd') || columns.has('estimated_cost_usd')) {
+ const actual = columns.has('actual_cost_usd') ? 'actual_cost_usd' : 'NULL';
+ const estimated = columns.has('estimated_cost_usd') ? 'estimated_cost_usd' : 'NULL';
+ predicates.push(`COALESCE(${actual}, ${estimated}, 0) > 0`);
+ }
+
+ return predicates.length > 0 ? predicates.join(' OR ') : '0';
+}
+
function resolveDbPath(dbPath?: string): string {
if (dbPath) {
return dbPath;
@@ -71,31 +104,25 @@ function loadRows(dbPath: string): HermesRow[] {
return [];
}
+ const columns = tableColumns(db, 'sessions');
return db
.query(`
SELECT
id,
model,
- billing_provider,
+ ${selectColumn(columns, 'billing_provider')},
started_at,
- input_tokens,
- output_tokens,
- cache_read_tokens,
- cache_write_tokens,
- reasoning_tokens,
- estimated_cost_usd,
- actual_cost_usd
+ ${selectColumn(columns, 'input_tokens', '0')},
+ ${selectColumn(columns, 'output_tokens', '0')},
+ ${selectColumn(columns, 'cache_read_tokens', '0')},
+ ${selectColumn(columns, 'cache_write_tokens', '0')},
+ ${selectColumn(columns, 'reasoning_tokens', '0')},
+ ${selectColumn(columns, 'estimated_cost_usd')},
+ ${selectColumn(columns, 'actual_cost_usd')}
FROM sessions
WHERE model IS NOT NULL
AND TRIM(model) != ''
- AND (
- COALESCE(input_tokens, 0) > 0 OR
- COALESCE(output_tokens, 0) > 0 OR
- COALESCE(cache_read_tokens, 0) > 0 OR
- COALESCE(cache_write_tokens, 0) > 0 OR
- COALESCE(reasoning_tokens, 0) > 0 OR
- COALESCE(actual_cost_usd, estimated_cost_usd, 0) > 0
- )
+ AND (${signalPredicate(columns)})
`)
.all() as HermesRow[];
} catch {
@@ -118,7 +145,11 @@ function rowToRecord(row: HermesRow, range: DateRange): LocalUsageRecord | null
nonNegativeNumber(row.reasoning_tokens);
const cacheReadTokens = nonNegativeNumber(row.cache_read_tokens);
const cacheWriteTokens = nonNegativeNumber(row.cache_write_tokens);
- if (inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens === 0) {
+ const explicitCost = safeNumber(row.actual_cost_usd) ?? safeNumber(row.estimated_cost_usd) ?? undefined;
+ if (
+ inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens === 0 &&
+ !(explicitCost !== undefined && explicitCost > 0)
+ ) {
return null;
}
@@ -130,7 +161,7 @@ function rowToRecord(row: HermesRow, range: DateRange): LocalUsageRecord | null
outputTokens,
cacheReadTokens,
cacheWriteTokens,
- explicitCost: safeNumber(row.actual_cost_usd) ?? safeNumber(row.estimated_cost_usd) ?? undefined,
+ explicitCost,
sessionId: row.id,
projectId: stringValue(row.billing_provider) ?? undefined,
};
diff --git a/packages/registry/src/providers/index.ts b/packages/registry/src/providers/index.ts
index 0da9a8a..8b7d561 100644
--- a/packages/registry/src/providers/index.ts
+++ b/packages/registry/src/providers/index.ts
@@ -4,9 +4,21 @@ export { CursorProvider } from './cursor';
export { GeminiProvider } from './gemini';
export { CopilotProvider } from './copilot';
export { AmpProvider } from './amp';
+export { CodebuffProvider } from './codebuff';
+export { DroidProvider } from './droid';
export { QwenProvider } from './qwen';
export { RooCodeProvider, KiloCodeProvider } from './roo-kilo-code';
+export { KimiProvider } from './kimi';
+export { KiloProvider } from './kilo';
+export { MuxProvider } from './mux';
+export { CrushProvider } from './crush';
export { OpenClawProvider } from './openclaw';
export { HermesProvider } from './hermes';
+export { GooseProvider } from './goose';
+export { AntigravityProvider } from './antigravity';
+export { ZedProvider } from './zed';
+export { KiroProvider } from './kiro';
+export { TraeProvider } from './trae';
+export { SyntheticProvider } from './synthetic';
export { OpenCodeProvider } from './open-code';
export { PiProvider } from './pi';
diff --git a/packages/registry/src/providers/kilo.ts b/packages/registry/src/providers/kilo.ts
new file mode 100644
index 0000000..404a67f
--- /dev/null
+++ b/packages/registry/src/providers/kilo.ts
@@ -0,0 +1,95 @@
+import { existsSync } from 'node:fs';
+import { homedir } from 'node:os';
+import { join } from 'node:path';
+import { Database } from 'bun:sqlite';
+import type { DateRange, ProviderColors, ProviderData } from '@tokenleak/core';
+import type { IProvider } from '../provider';
+import { isInRange } from '../utils';
+import {
+ buildProviderData,
+ extractDate,
+ nonNegativeNumber,
+ safeNumber,
+ stringValue,
+ timestampToIso,
+ type LocalProviderMetadata,
+ type LocalUsageRecord,
+} from './local-usage';
+
+const PROVIDER_NAME = 'kilo';
+const DISPLAY_NAME = 'Kilo CLI';
+const DEFAULT_DB_PATH = join(homedir(), '.local', 'share', 'kilo', 'kilo.db');
+const COLORS: ProviderColors = {
+ primary: '#f59e0b',
+ secondary: '#fde68a',
+ gradient: ['#f59e0b', '#fde68a'],
+};
+const METADATA: LocalProviderMetadata = { provider: PROVIDER_NAME, displayName: DISPLAY_NAME, colors: COLORS };
+
+function resolveDbPath(dbPath?: string): string {
+ return dbPath ?? process.env['TOKENLEAK_KILO_DIR'] ?? DEFAULT_DB_PATH;
+}
+
+function loadRows(dbPath: string): Array> {
+ let db: InstanceType;
+ try {
+ db = new Database(dbPath, { readonly: true });
+ } catch {
+ return [];
+ }
+ try {
+ const tables = db.query("SELECT name FROM sqlite_master WHERE type='table'").all() as Array<{ name: string }>;
+ const table = tables.find((row) => ['usage', 'token_usage', 'messages'].includes(row.name))?.name;
+ if (!table) return [];
+ return db.query(`SELECT * FROM ${table}`).all() as Array>;
+ } catch {
+ return [];
+ } finally {
+ db.close();
+ }
+}
+
+function rowToRecord(row: Record, range: DateRange): LocalUsageRecord | null {
+ const timestamp = timestampToIso(row['timestamp'] ?? row['created_at']);
+ const date = timestamp ? extractDate(timestamp) : null;
+ if (!timestamp || !date || !isInRange(date, range)) return null;
+ const inputTokens = nonNegativeNumber(row['input_tokens'] ?? row['input']);
+ const outputTokens = nonNegativeNumber(row['output_tokens'] ?? row['output']) + nonNegativeNumber(row['reasoning_tokens']);
+ const cacheReadTokens = nonNegativeNumber(row['cache_read_tokens'] ?? row['cache_read']);
+ const cacheWriteTokens = nonNegativeNumber(row['cache_write_tokens'] ?? row['cache_write']);
+ if (inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens === 0) return null;
+ return {
+ date,
+ timestamp,
+ model: stringValue(row['model']) ?? stringValue(row['model_id']) ?? 'kilo-unknown',
+ inputTokens,
+ outputTokens,
+ cacheReadTokens,
+ cacheWriteTokens,
+ explicitCost: safeNumber(row['cost']) ?? safeNumber(row['cost_usd']) ?? undefined,
+ sessionId: stringValue(row['session_id']) ?? stringValue(row['sessionId']) ?? stringValue(row['id']) ?? undefined,
+ projectId: stringValue(row['provider']) ?? stringValue(row['provider_id']) ?? undefined,
+ };
+}
+
+export class KiloProvider implements IProvider {
+ readonly name = PROVIDER_NAME;
+ readonly displayName = DISPLAY_NAME;
+ readonly colors = COLORS;
+ private readonly dbPath: string;
+
+ constructor(dbPath?: string) {
+ this.dbPath = resolveDbPath(dbPath);
+ }
+
+ async isAvailable(): Promise {
+ return existsSync(this.dbPath);
+ }
+
+ async load(range: DateRange): Promise {
+ const records = loadRows(this.dbPath)
+ .map((row) => rowToRecord(row, range))
+ .filter((record): record is LocalUsageRecord => record !== null);
+ return buildProviderData(METADATA, records);
+ }
+}
diff --git a/packages/registry/src/providers/kimi.ts b/packages/registry/src/providers/kimi.ts
new file mode 100644
index 0000000..a52e9e6
--- /dev/null
+++ b/packages/registry/src/providers/kimi.ts
@@ -0,0 +1,118 @@
+import { existsSync, readFileSync } from 'node:fs';
+import { homedir } from 'node:os';
+import { dirname, join } from 'node:path';
+import type { DateRange, ProviderColors, ProviderData } from '@tokenleak/core';
+import type { IProvider } from '../provider';
+import { splitJsonlRecords } from '../parsers/jsonl-splitter';
+import { isInRange } from '../utils';
+import {
+ buildProviderData,
+ collectFiles,
+ extractDate,
+ nonNegativeNumber,
+ objectValue,
+ stringValue,
+ timestampToIso,
+ type LocalProviderMetadata,
+ type LocalUsageRecord,
+} from './local-usage';
+
+const PROVIDER_NAME = 'kimi';
+const DISPLAY_NAME = 'Kimi';
+const DEFAULT_BASE_DIR = join(homedir(), '.kimi');
+const COLORS: ProviderColors = {
+ primary: '#111827',
+ secondary: '#9ca3af',
+ gradient: ['#111827', '#9ca3af'],
+};
+const METADATA: LocalProviderMetadata = { provider: PROVIDER_NAME, displayName: DISPLAY_NAME, colors: COLORS };
+
+function resolveBaseDir(baseDir?: string): string {
+ return baseDir ?? process.env['TOKENLEAK_KIMI_DIR'] ?? DEFAULT_BASE_DIR;
+}
+
+function isKimiFile(_path: string, name: string): boolean {
+ return name === 'wire.jsonl';
+}
+
+function readModel(file: string): string {
+ let dir = dirname(file);
+ for (let i = 0; i < 4; i++) {
+ const config = join(dir, 'config.json');
+ try {
+ const model = stringValue(JSON.parse(readFileSync(config, 'utf-8'))?.model);
+ if (model) return model;
+ } catch {
+ // keep walking
+ }
+ dir = dirname(dir);
+ }
+ return 'kimi-for-coding';
+}
+
+async function parseKimiFile(file: string, range: DateRange): Promise {
+ const records: LocalUsageRecord[] = [];
+ const model = readModel(file);
+ const sessionId = dirname(file).split(/[\\/]/).at(-1) ?? 'unknown';
+
+ try {
+ for await (const record of splitJsonlRecords(file)) {
+ const entry = objectValue(record);
+ if (!entry || entry['type'] === 'metadata') continue;
+ const message = objectValue(entry['message']);
+ if (message?.['type'] !== 'StatusUpdate') continue;
+ const payload = objectValue(message['payload']);
+ const usage = objectValue(payload?.['token_usage']);
+ if (!usage) continue;
+
+ const timestamp = timestampToIso(entry['timestamp']);
+ const date = timestamp ? extractDate(timestamp) : null;
+ if (!timestamp || !date || !isInRange(date, range)) continue;
+
+ const inputTokens = nonNegativeNumber(usage['input_other']);
+ const outputTokens = nonNegativeNumber(usage['output']);
+ const cacheReadTokens = nonNegativeNumber(usage['input_cache_read']);
+ const cacheWriteTokens = nonNegativeNumber(usage['input_cache_creation']);
+ if (inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens === 0) continue;
+
+ records.push({
+ date,
+ timestamp,
+ model,
+ inputTokens,
+ outputTokens,
+ cacheReadTokens,
+ cacheWriteTokens,
+ sessionId,
+ projectId: dirname(dirname(file)).split(/[\\/]/).at(-1),
+ });
+ }
+ } catch {
+ return records;
+ }
+
+ return records;
+}
+
+export class KimiProvider implements IProvider {
+ readonly name = PROVIDER_NAME;
+ readonly displayName = DISPLAY_NAME;
+ readonly colors = COLORS;
+ private readonly baseDir: string;
+
+ constructor(baseDir?: string) {
+ this.baseDir = resolveBaseDir(baseDir);
+ }
+
+ async isAvailable(): Promise {
+ return existsSync(this.baseDir) && collectFiles(this.baseDir, isKimiFile).length > 0;
+ }
+
+ async load(range: DateRange): Promise {
+ const records: LocalUsageRecord[] = [];
+ for (const file of collectFiles(this.baseDir, isKimiFile)) {
+ records.push(...await parseKimiFile(file, range));
+ }
+ return buildProviderData(METADATA, records);
+ }
+}
diff --git a/packages/registry/src/providers/kiro.ts b/packages/registry/src/providers/kiro.ts
new file mode 100644
index 0000000..6d25401
--- /dev/null
+++ b/packages/registry/src/providers/kiro.ts
@@ -0,0 +1,139 @@
+import { existsSync, readFileSync } from 'node:fs';
+import { homedir } from 'node:os';
+import { join } from 'node:path';
+import { Database } from 'bun:sqlite';
+import type { DateRange, ProviderColors, ProviderData } from '@tokenleak/core';
+import type { IProvider } from '../provider';
+import { isInRange } from '../utils';
+import {
+ buildProviderData,
+ collectFiles,
+ extractDate,
+ nonNegativeNumber,
+ objectValue,
+ sessionIdFromFile,
+ stringValue,
+ timestampToIso,
+ type LocalProviderMetadata,
+ type LocalUsageRecord,
+} from './local-usage';
+
+const PROVIDER_NAME = 'kiro';
+const DISPLAY_NAME = 'Kiro';
+const COLORS: ProviderColors = {
+ primary: '#6366f1',
+ secondary: '#a5b4fc',
+ gradient: ['#6366f1', '#a5b4fc'],
+};
+const METADATA: LocalProviderMetadata = { provider: PROVIDER_NAME, displayName: DISPLAY_NAME, colors: COLORS };
+
+function defaultBaseDir(): string {
+ const override = process.env['TOKENLEAK_KIRO_DIR'];
+ if (override && !override.endsWith('.sqlite3') && !override.endsWith('.db')) return override;
+ return join(homedir(), '.kiro', 'sessions', 'cli');
+}
+
+function defaultDbPaths(): string[] {
+ const override = process.env['TOKENLEAK_KIRO_DIR'];
+ if (override && (override.endsWith('.sqlite3') || override.endsWith('.db'))) return [override];
+ return [
+ join(homedir(), '.local', 'share', 'kiro-cli', 'data.sqlite3'),
+ join(homedir(), 'Library', 'Application Support', 'kiro-cli', 'data.sqlite3'),
+ ];
+}
+
+function isKiroFile(_path: string, name: string): boolean {
+ return name.endsWith('.json');
+}
+
+function projectFromCwd(cwd: unknown): string | undefined {
+ return stringValue(cwd)?.split(/[\\/]/).filter(Boolean).at(-1);
+}
+
+function parseKiroSessionObject(value: Record, fallbackSessionId: string, range: DateRange): LocalUsageRecord[] {
+ const state = objectValue(value['session_state']);
+ const modelInfo = objectValue(objectValue(state?.['rts_model_state'])?.['model_info']);
+ const model = stringValue(modelInfo?.['model_id']) ?? 'kiro-unknown';
+ const sessionId = stringValue(value['session_id']) ?? fallbackSessionId;
+ const projectId = projectFromCwd(value['cwd']);
+ const metadata = objectValue(state?.['conversation_metadata']);
+ const turns = Array.isArray(metadata?.['user_turn_metadatas']) ? metadata['user_turn_metadatas'] as unknown[] : [];
+
+ return turns.flatMap((turnValue, index) => {
+ const turn = objectValue(turnValue);
+ if (!turn) return [];
+ const timestamp = timestampToIso(turn['end_timestamp']);
+ const date = timestamp ? extractDate(timestamp) : null;
+ if (!timestamp || !date || !isInRange(date, range)) return [];
+ const inputTokens = nonNegativeNumber(turn['input_token_count']);
+ const outputTokens = nonNegativeNumber(turn['output_token_count']);
+ if (inputTokens + outputTokens === 0) return [];
+ return [{
+ date,
+ timestamp,
+ model,
+ inputTokens,
+ outputTokens,
+ cacheReadTokens: 0,
+ cacheWriteTokens: 0,
+ sessionId: `${sessionId}:${index}`,
+ projectId,
+ }];
+ });
+}
+
+function parseKiroFile(file: string, range: DateRange): LocalUsageRecord[] {
+ try {
+ const value = objectValue(JSON.parse(readFileSync(file, 'utf-8')));
+ return value ? parseKiroSessionObject(value, sessionIdFromFile(file), range) : [];
+ } catch {
+ return [];
+ }
+}
+
+function parseKiroSqlite(dbPath: string, range: DateRange): LocalUsageRecord[] {
+ if (!existsSync(dbPath)) return [];
+ let db: InstanceType;
+ try {
+ db = new Database(dbPath, { readonly: true });
+ } catch {
+ return [];
+ }
+ try {
+ const rows = db.query('SELECT id, history FROM conversations_v2').all() as Array>;
+ return rows.flatMap((row) => {
+ const history = objectValue(JSON.parse(String(row['history'] ?? '{}')));
+ return history ? parseKiroSessionObject(history, stringValue(row['id']) ?? 'kiro-sqlite', range) : [];
+ });
+ } catch {
+ return [];
+ } finally {
+ db.close();
+ }
+}
+
+export class KiroProvider implements IProvider {
+ readonly name = PROVIDER_NAME;
+ readonly displayName = DISPLAY_NAME;
+ readonly colors = COLORS;
+ private readonly baseDir: string;
+ private readonly dbPaths: string[];
+
+ constructor(baseDir?: string, dbPath?: string) {
+ this.baseDir = baseDir ?? defaultBaseDir();
+ this.dbPaths = dbPath ? [dbPath] : defaultDbPaths();
+ }
+
+ async isAvailable(): Promise {
+ return (existsSync(this.baseDir) && collectFiles(this.baseDir, isKiroFile).length > 0) ||
+ this.dbPaths.some(existsSync);
+ }
+
+ async load(range: DateRange): Promise {
+ const records = collectFiles(this.baseDir, isKiroFile).flatMap((file) => parseKiroFile(file, range));
+ for (const dbPath of this.dbPaths) {
+ records.push(...parseKiroSqlite(dbPath, range));
+ }
+ return buildProviderData(METADATA, records);
+ }
+}
diff --git a/packages/registry/src/providers/mux.ts b/packages/registry/src/providers/mux.ts
new file mode 100644
index 0000000..57a732b
--- /dev/null
+++ b/packages/registry/src/providers/mux.ts
@@ -0,0 +1,114 @@
+import { existsSync, readFileSync } from 'node:fs';
+import { homedir } from 'node:os';
+import { dirname, join } from 'node:path';
+import type { DateRange, ProviderColors, ProviderData } from '@tokenleak/core';
+import type { IProvider } from '../provider';
+import { isInRange } from '../utils';
+import {
+ buildProviderData,
+ collectFiles,
+ extractDate,
+ nonNegativeNumber,
+ objectValue,
+ safeNumber,
+ timestampToIso,
+ type LocalProviderMetadata,
+ type LocalUsageRecord,
+} from './local-usage';
+
+const PROVIDER_NAME = 'mux';
+const DISPLAY_NAME = 'Mux';
+const DEFAULT_BASE_DIR = join(homedir(), '.mux', 'sessions');
+const COLORS: ProviderColors = {
+ primary: '#ec4899',
+ secondary: '#f9a8d4',
+ gradient: ['#ec4899', '#f9a8d4'],
+};
+const METADATA: LocalProviderMetadata = { provider: PROVIDER_NAME, displayName: DISPLAY_NAME, colors: COLORS };
+
+function resolveBaseDir(baseDir?: string): string {
+ return baseDir ?? process.env['TOKENLEAK_MUX_DIR'] ?? DEFAULT_BASE_DIR;
+}
+
+function isMuxFile(_path: string, name: string): boolean {
+ return name === 'session-usage.json';
+}
+
+function bucketTokens(bucket: unknown): number {
+ return nonNegativeNumber(objectValue(bucket)?.['tokens']);
+}
+
+function bucketCost(bucket: unknown): number {
+ return safeNumber(objectValue(bucket)?.['cost_usd']) ?? 0;
+}
+
+function stableCost(value: number): number {
+ return Math.round(value * 1_000_000_000_000) / 1_000_000_000_000;
+}
+
+function parseMuxFile(file: string, range: DateRange): LocalUsageRecord[] {
+ let root: Record;
+ try {
+ const obj = objectValue(JSON.parse(readFileSync(file, 'utf-8')));
+ if (!obj) return [];
+ root = obj;
+ } catch {
+ return [];
+ }
+ const timestamp = timestampToIso(objectValue(root['lastRequest'])?.['timestamp']);
+ const date = timestamp ? extractDate(timestamp) : null;
+ if (!timestamp || !date || !isInRange(date, range)) return [];
+ const byModel = objectValue(root['byModel']);
+ if (!byModel) return [];
+ const projectId = dirname(file).split(/[\\/]/).at(-1);
+
+ return Object.entries(byModel).flatMap(([modelKey, value]) => {
+ const entry = objectValue(value);
+ if (!entry) return [];
+ const [providerPart, modelPart] = modelKey.includes(':') ? modelKey.split(/:(.*)/s) : ['', modelKey];
+ const inputTokens = bucketTokens(entry['input']);
+ const outputTokens = bucketTokens(entry['output']) + bucketTokens(entry['reasoning']);
+ const cacheReadTokens = bucketTokens(entry['cached']);
+ const cacheWriteTokens = bucketTokens(entry['cacheCreate']);
+ const total = inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens;
+ if (total === 0) return [];
+ return [{
+ date,
+ timestamp,
+ model: modelPart || modelKey,
+ inputTokens,
+ outputTokens,
+ cacheReadTokens,
+ cacheWriteTokens,
+ explicitCost: stableCost(
+ bucketCost(entry['input']) +
+ bucketCost(entry['output']) +
+ bucketCost(entry['cached']) +
+ bucketCost(entry['cacheCreate']) +
+ bucketCost(entry['reasoning']),
+ ),
+ sessionId: projectId,
+ projectId,
+ directory: providerPart || undefined,
+ }];
+ });
+}
+
+export class MuxProvider implements IProvider {
+ readonly name = PROVIDER_NAME;
+ readonly displayName = DISPLAY_NAME;
+ readonly colors = COLORS;
+ private readonly baseDir: string;
+
+ constructor(baseDir?: string) {
+ this.baseDir = resolveBaseDir(baseDir);
+ }
+
+ async isAvailable(): Promise {
+ return existsSync(this.baseDir) && collectFiles(this.baseDir, isMuxFile).length > 0;
+ }
+
+ async load(range: DateRange): Promise {
+ return buildProviderData(METADATA, collectFiles(this.baseDir, isMuxFile).flatMap((file) => parseMuxFile(file, range)));
+ }
+}
diff --git a/packages/registry/src/providers/provider-parity.test.ts b/packages/registry/src/providers/provider-parity.test.ts
index ec66994..43c584f 100644
--- a/packages/registry/src/providers/provider-parity.test.ts
+++ b/packages/registry/src/providers/provider-parity.test.ts
@@ -4,13 +4,25 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { Database } from 'bun:sqlite';
import type { DateRange } from '@tokenleak/core';
+import { AntigravityProvider } from './antigravity';
import { AmpProvider } from './amp';
+import { CodebuffProvider } from './codebuff';
import { CopilotProvider } from './copilot';
+import { CrushProvider } from './crush';
+import { DroidProvider } from './droid';
import { GeminiProvider } from './gemini';
+import { GooseProvider } from './goose';
import { HermesProvider } from './hermes';
+import { KiloProvider } from './kilo';
+import { KimiProvider } from './kimi';
+import { KiroProvider } from './kiro';
import { KiloCodeProvider, RooCodeProvider } from './roo-kilo-code';
+import { MuxProvider } from './mux';
import { OpenClawProvider } from './openclaw';
import { QwenProvider } from './qwen';
+import { SyntheticProvider } from './synthetic';
+import { TraeProvider } from './trae';
+import { ZedProvider } from './zed';
const RANGE: DateRange = { since: '2026-02-01', until: '2026-02-28' };
@@ -353,4 +365,509 @@ describe('provider parity providers', () => {
expect(data.totalCost).toBe(0.08);
expect(data.events?.[0]?.projectId).toBe('anthropic');
});
+
+ it('loads Hermes SQLite rows when optional cost columns are absent', async () => {
+ const root = tempDir('hermes-minimal');
+ const dbPath = join(root, 'state.db');
+ const db = new Database(dbPath);
+ db.run(`
+ CREATE TABLE sessions (
+ id TEXT,
+ model TEXT,
+ started_at REAL,
+ input_tokens INTEGER,
+ output_tokens INTEGER
+ )
+ `);
+ db.run(`
+ INSERT INTO sessions VALUES (
+ 'hermes-minimal-session',
+ 'claude-sonnet-4',
+ 1770724800,
+ 10,
+ 3
+ )
+ `);
+ db.close();
+
+ const data = await new HermesProvider(dbPath).load(RANGE);
+
+ expect(data.provider).toBe('hermes');
+ expect(data.totalTokens).toBe(13);
+ expect(data.events?.[0]?.sessionId).toBe('hermes-minimal-session');
+ });
+
+ it('preserves Hermes provider-reported cost when token columns are absent', async () => {
+ const root = tempDir('hermes-cost-only');
+ const dbPath = join(root, 'state.db');
+ const db = new Database(dbPath);
+ db.run(`
+ CREATE TABLE sessions (
+ id TEXT,
+ model TEXT,
+ started_at REAL,
+ actual_cost_usd REAL
+ )
+ `);
+ db.run(`
+ INSERT INTO sessions VALUES (
+ 'hermes-cost-only-session',
+ 'claude-sonnet-4',
+ 1770724800,
+ 1.25
+ )
+ `);
+ db.close();
+
+ const data = await new HermesProvider(dbPath).load(RANGE);
+
+ expect(data.provider).toBe('hermes');
+ expect(data.totalTokens).toBe(0);
+ expect(data.totalCost).toBe(1.25);
+ expect(data.events?.[0]?.sessionId).toBe('hermes-cost-only-session');
+ });
+
+ it('loads Codebuff chat message usage from project chat files', async () => {
+ const root = tempDir('codebuff');
+ const chatDir = join(root, 'projects', 'repo-a', 'chats', '2026-02-10T12-00-00.000Z');
+ mkdirSync(chatDir, { recursive: true });
+ writeFileSync(join(chatDir, 'chat-messages.json'), JSON.stringify([
+ { variant: 'user', text: 'hello' },
+ {
+ id: 'assistant-1',
+ variant: 'assistant',
+ timestamp: '2026-02-10T12:00:00.000Z',
+ metadata: {
+ usage: {
+ model: 'claude-sonnet-4',
+ inputTokens: 11,
+ outputTokens: 7,
+ cacheReadInputTokens: 5,
+ cacheCreationInputTokens: 2,
+ credits: 0.04,
+ },
+ },
+ },
+ ]));
+
+ const data = await new CodebuffProvider(root).load(RANGE);
+
+ expect(data.provider).toBe('codebuff');
+ expect(data.totalTokens).toBe(25);
+ expect(data.totalCost).toBe(0.04);
+ expect(data.events?.[0]?.projectId).toBe('repo-a');
+ expect(data.events?.[0]?.sessionId).toContain('2026-02-10T12-00-00.000Z');
+ });
+
+ it('loads Droid settings token usage and JSONL model fallback', async () => {
+ const root = tempDir('droid');
+ mkdirSync(root, { recursive: true });
+ writeFileSync(join(root, 'session-a.settings.json'), JSON.stringify({
+ providerLock: 'anthropic',
+ providerLockTimestamp: '2026-02-10T12:00:00.000Z',
+ tokenUsage: {
+ inputTokens: 20,
+ outputTokens: 6,
+ cacheReadTokens: 4,
+ cacheCreationTokens: 3,
+ thinkingTokens: 2,
+ },
+ }));
+ writeFileSync(join(root, 'session-a.jsonl'), JSON.stringify({
+ type: 'system-reminder',
+ text: 'Model: Claude Sonnet 4 [Anthropic]',
+ }));
+
+ const data = await new DroidProvider(root).load(RANGE);
+
+ expect(data.provider).toBe('droid');
+ expect(data.totalTokens).toBe(35);
+ expect(data.daily[0]!.models[0]!.model).toBe('claude-sonnet-4');
+ expect(data.daily[0]!.outputTokens).toBe(8);
+ });
+
+ it('loads Kimi wire protocol StatusUpdate usage', async () => {
+ const root = tempDir('kimi');
+ const sessionDir = join(root, 'sessions', 'group-a', 'session-a');
+ mkdirSync(sessionDir, { recursive: true });
+ writeFileSync(join(root, 'config.json'), JSON.stringify({ model: 'kimi-k2' }));
+ writeFileSync(join(sessionDir, 'wire.jsonl'), [
+ JSON.stringify({ type: 'metadata' }),
+ JSON.stringify({
+ timestamp: 1770724800,
+ message: {
+ type: 'StatusUpdate',
+ payload: {
+ message_id: 'msg-1',
+ token_usage: {
+ input_other: 30,
+ output: 9,
+ input_cache_read: 6,
+ input_cache_creation: 3,
+ },
+ },
+ },
+ }),
+ ].join('\n'));
+
+ const data = await new KimiProvider(root).load(RANGE);
+
+ expect(data.provider).toBe('kimi');
+ expect(data.totalTokens).toBe(48);
+ expect(data.events?.[0]?.sessionId).toBe('session-a');
+ });
+
+ it('loads Kilo CLI SQLite usage rows', async () => {
+ const root = tempDir('kilo');
+ const dbPath = join(root, 'kilo.db');
+ const db = new Database(dbPath);
+ db.run(`
+ CREATE TABLE usage (
+ id TEXT,
+ session_id TEXT,
+ model TEXT,
+ provider TEXT,
+ timestamp INTEGER,
+ input_tokens INTEGER,
+ output_tokens INTEGER,
+ cache_read_tokens INTEGER,
+ cache_write_tokens INTEGER,
+ cost REAL
+ )
+ `);
+ db.run(`
+ INSERT INTO usage VALUES (
+ 'usage-1',
+ 'kilo-session',
+ 'gpt-5',
+ 'openai',
+ 1770724800000,
+ 14,
+ 5,
+ 4,
+ 2,
+ 0.06
+ )
+ `);
+ db.close();
+
+ const data = await new KiloProvider(dbPath).load(RANGE);
+
+ expect(data.provider).toBe('kilo');
+ expect(data.totalTokens).toBe(25);
+ expect(data.totalCost).toBe(0.06);
+ expect(data.events?.[0]?.sessionId).toBe('kilo-session');
+ });
+
+ it('loads Mux session usage JSON by model', async () => {
+ const root = tempDir('mux');
+ const sessionDir = join(root, 'sessions', 'workspace-a');
+ mkdirSync(sessionDir, { recursive: true });
+ writeFileSync(join(sessionDir, 'session-usage.json'), JSON.stringify({
+ byModel: {
+ 'anthropic:claude-sonnet-4': {
+ input: { tokens: 10, cost_usd: 0.01 },
+ output: { tokens: 4, cost_usd: 0.02 },
+ cached: { tokens: 3, cost_usd: 0.001 },
+ cacheCreate: { tokens: 2, cost_usd: 0.002 },
+ reasoning: { tokens: 1, cost_usd: 0.003 },
+ },
+ },
+ lastRequest: { timestamp: 1770724800000 },
+ }));
+
+ const data = await new MuxProvider(root).load(RANGE);
+
+ expect(data.provider).toBe('mux');
+ expect(data.totalTokens).toBe(20);
+ expect(data.totalCost).toBe(0.036);
+ expect(data.events?.[0]?.projectId).toBe('workspace-a');
+ });
+
+ it('loads Crush root session costs without fabricating tokens', async () => {
+ const dbPath = join(tempDir('crush'), 'crush.db');
+ const db = new Database(dbPath);
+ db.run(`
+ CREATE TABLE sessions (
+ id TEXT,
+ parent_session_id TEXT,
+ cost REAL,
+ created_at INTEGER,
+ updated_at INTEGER,
+ message_count INTEGER
+ )
+ `);
+ db.run(`
+ CREATE TABLE messages (
+ session_id TEXT,
+ role TEXT,
+ created_at INTEGER
+ )
+ `);
+ db.run("INSERT INTO sessions VALUES ('root', NULL, 0.5, 1770724800, 1770724900, 2)");
+ db.run("INSERT INTO messages VALUES ('root', 'assistant', 1770724800)");
+ db.close();
+
+ const data = await new CrushProvider([dbPath]).load(RANGE);
+
+ expect(data.provider).toBe('crush');
+ expect(data.totalTokens).toBe(0);
+ expect(data.totalCost).toBe(0.5);
+ expect(data.events?.[0]?.model).toBe('session-total');
+ });
+
+ it('loads Goose SQLite session totals', async () => {
+ const dbPath = join(tempDir('goose'), 'sessions.db');
+ const db = new Database(dbPath);
+ db.run(`
+ CREATE TABLE sessions (
+ id TEXT,
+ model_config_json TEXT,
+ provider_name TEXT,
+ created_at TEXT,
+ total_tokens INTEGER,
+ input_tokens INTEGER,
+ output_tokens INTEGER,
+ accumulated_total_tokens INTEGER,
+ accumulated_input_tokens INTEGER,
+ accumulated_output_tokens INTEGER
+ )
+ `);
+ db.run(`
+ INSERT INTO sessions VALUES (
+ 'goose-session',
+ '{"model_name":"claude-sonnet-4"}',
+ 'anthropic',
+ '2026-02-10T12:00:00.000Z',
+ 25,
+ 10,
+ 5,
+ 30,
+ 12,
+ 7
+ )
+ `);
+ db.close();
+
+ const data = await new GooseProvider(dbPath).load(RANGE);
+
+ expect(data.provider).toBe('goose');
+ expect(data.totalTokens).toBe(30);
+ expect(data.daily[0]!.outputTokens).toBe(18);
+ expect(data.events?.[0]?.sessionId).toBe('goose-session');
+ });
+
+ it('loads cached Antigravity JSONL usage rows', async () => {
+ const root = tempDir('antigravity');
+ mkdirSync(root, { recursive: true });
+ writeFileSync(join(root, 'session.jsonl'), [
+ JSON.stringify({ type: 'session_meta', sessionId: 'ag-session', modelId: 'claude-sonnet-4' }),
+ JSON.stringify({
+ type: 'usage',
+ sessionId: 'ag-session',
+ timestamp: 1770724800000,
+ input: 22,
+ output: 8,
+ cacheRead: 4,
+ cacheWrite: 2,
+ reasoning: 1,
+ responseId: 'resp-1',
+ }),
+ ].join('\n'));
+
+ const data = await new AntigravityProvider(root).load(RANGE);
+
+ expect(data.provider).toBe('antigravity');
+ expect(data.totalTokens).toBe(37);
+ expect(data.events?.[0]?.sessionId).toBe('ag-session');
+ });
+
+ it('loads hosted Zed Agent threads and ignores external providers', async () => {
+ const dbPath = join(tempDir('zed'), 'threads.db');
+ const db = new Database(dbPath);
+ db.run(`
+ CREATE TABLE threads (
+ id TEXT,
+ updated_at TEXT,
+ folder_paths TEXT,
+ folder_paths_order TEXT,
+ data_type TEXT,
+ data BLOB
+ )
+ `);
+ const hosted = JSON.stringify({
+ model: { provider: 'zed.dev', model: 'claude-sonnet-4' },
+ usage: { input_tokens: 10, output_tokens: 5, cache_read_tokens: 2, cache_write_tokens: 1 },
+ });
+ const external = JSON.stringify({
+ model: { provider: 'anthropic', model: 'claude-sonnet-4' },
+ usage: { input_tokens: 999, output_tokens: 999 },
+ });
+ db.run('INSERT INTO threads VALUES (?, ?, ?, ?, ?, ?)', [
+ 'zed-hosted',
+ '2026-02-10T12:00:00.000Z',
+ JSON.stringify(['/repo/zed']),
+ JSON.stringify([0]),
+ 'json',
+ Buffer.from(hosted),
+ ]);
+ db.run('INSERT INTO threads VALUES (?, ?, ?, ?, ?, ?)', [
+ 'zed-external',
+ '2026-02-10T12:00:00.000Z',
+ null,
+ null,
+ 'json',
+ Buffer.from(external),
+ ]);
+ db.close();
+
+ const data = await new ZedProvider(dbPath).load(RANGE);
+
+ expect(data.provider).toBe('zed');
+ expect(data.totalTokens).toBe(18);
+ expect(data.events?.[0]?.projectId).toBe('zed');
+ });
+
+ it('loads Zed threads when TOKENLEAK_ZED_DIR points at the data directory', async () => {
+ const previousEnv = process.env;
+ const root = tempDir('zed-env-dir');
+ const threadsDir = join(root, 'threads');
+ mkdirSync(threadsDir, { recursive: true });
+ const dbPath = join(threadsDir, 'threads.db');
+ const db = new Database(dbPath);
+ db.run(`
+ CREATE TABLE threads (
+ id TEXT,
+ updated_at TEXT,
+ folder_paths TEXT,
+ folder_paths_order TEXT,
+ data_type TEXT,
+ data BLOB
+ )
+ `);
+ db.run('INSERT INTO threads VALUES (?, ?, ?, ?, ?, ?)', [
+ 'zed-env-hosted',
+ '2026-02-10T12:00:00.000Z',
+ JSON.stringify(['/repo/zed-env']),
+ JSON.stringify([0]),
+ 'json',
+ Buffer.from(JSON.stringify({
+ model: { provider: 'zed.dev', model: 'claude-sonnet-4' },
+ usage: { input_tokens: 5, output_tokens: 4 },
+ })),
+ ]);
+ db.close();
+
+ try {
+ process.env = { ...process.env, TOKENLEAK_ZED_DIR: root };
+ const provider = new ZedProvider();
+ const data = await provider.load(RANGE);
+
+ expect(await provider.isAvailable()).toBe(true);
+ expect(data.provider).toBe('zed');
+ expect(data.totalTokens).toBe(9);
+ expect(data.events?.[0]?.sessionId).toBe('zed-env-hosted');
+ } finally {
+ process.env = previousEnv;
+ }
+ });
+
+ it('loads Kiro CLI file sessions with explicit turn token counts', async () => {
+ const root = tempDir('kiro');
+ mkdirSync(root, { recursive: true });
+ writeFileSync(join(root, 'session.json'), JSON.stringify({
+ session_id: 'kiro-session',
+ cwd: '/repo/kiro',
+ session_state: {
+ rts_model_state: {
+ model_info: { model_id: 'claude-sonnet-4', context_window_tokens: 200000 },
+ },
+ conversation_metadata: {
+ user_turn_metadatas: [
+ {
+ input_token_count: 44,
+ output_token_count: 11,
+ end_timestamp: 1770724800000,
+ total_request_count: 1,
+ },
+ ],
+ },
+ },
+ }));
+
+ const data = await new KiroProvider(root).load(RANGE);
+
+ expect(data.provider).toBe('kiro');
+ expect(data.totalTokens).toBe(55);
+ expect(data.events?.[0]?.projectId).toBe('kiro');
+ });
+
+ it('loads cached Trae usage API JSON', async () => {
+ const root = tempDir('trae');
+ mkdirSync(root, { recursive: true });
+ writeFileSync(join(root, 'usage.json'), JSON.stringify([
+ {
+ model_name: 'GPT-5.4',
+ session_id: 'trae-session',
+ usage_time: 1770724800,
+ dollar_float: 0.09,
+ extra_info: {
+ input_token: 21,
+ output_token: 8,
+ cache_read_token: 4,
+ cache_write_token: 2,
+ },
+ },
+ ]));
+
+ const data = await new TraeProvider(root).load(RANGE);
+
+ expect(data.provider).toBe('trae');
+ expect(data.totalTokens).toBe(35);
+ expect(data.totalCost).toBe(0.09);
+ expect(data.daily[0]!.models[0]!.model).toBe('gpt-5.4');
+ });
+
+ it('loads Synthetic Octofriend SQLite token rows', async () => {
+ const dbPath = join(tempDir('synthetic'), 'sqlite.db');
+ const db = new Database(dbPath);
+ db.run(`
+ CREATE TABLE messages (
+ id TEXT,
+ model TEXT,
+ input_tokens INTEGER,
+ output_tokens INTEGER,
+ cache_read_tokens INTEGER,
+ cache_write_tokens INTEGER,
+ reasoning_tokens INTEGER,
+ cost REAL,
+ timestamp INTEGER,
+ session_id TEXT,
+ provider TEXT
+ )
+ `);
+ db.run(`
+ INSERT INTO messages VALUES (
+ 'synthetic-message',
+ 'hf:deepseek-ai/DeepSeek-V3-0324',
+ 13,
+ 7,
+ 3,
+ 1,
+ 2,
+ 0.04,
+ 1770724800000,
+ 'synthetic-session',
+ 'synthetic'
+ )
+ `);
+ db.close();
+
+ const data = await new SyntheticProvider(dbPath).load(RANGE);
+
+ expect(data.provider).toBe('synthetic');
+ expect(data.totalTokens).toBe(26);
+ expect(data.totalCost).toBe(0.04);
+ expect(data.daily[0]!.models[0]!.model).toBe('deepseek-v3-0324');
+ });
});
diff --git a/packages/registry/src/providers/synthetic.ts b/packages/registry/src/providers/synthetic.ts
new file mode 100644
index 0000000..998c360
--- /dev/null
+++ b/packages/registry/src/providers/synthetic.ts
@@ -0,0 +1,109 @@
+import { existsSync } from 'node:fs';
+import { homedir } from 'node:os';
+import { join } from 'node:path';
+import { Database } from 'bun:sqlite';
+import type { DateRange, ProviderColors, ProviderData } from '@tokenleak/core';
+import type { IProvider } from '../provider';
+import { isInRange } from '../utils';
+import {
+ buildProviderData,
+ extractDate,
+ nonNegativeNumber,
+ safeNumber,
+ stringValue,
+ timestampToIso,
+ type LocalProviderMetadata,
+ type LocalUsageRecord,
+} from './local-usage';
+
+const PROVIDER_NAME = 'synthetic';
+const DISPLAY_NAME = 'Synthetic';
+const DEFAULT_DB_PATH = join(homedir(), '.local', 'share', 'octofriend', 'sqlite.db');
+const COLORS: ProviderColors = {
+ primary: '#10b981',
+ secondary: '#86efac',
+ gradient: ['#10b981', '#86efac'],
+};
+const METADATA: LocalProviderMetadata = { provider: PROVIDER_NAME, displayName: DISPLAY_NAME, colors: COLORS };
+
+function resolveDbPath(dbPath?: string): string {
+ return dbPath ?? process.env['TOKENLEAK_SYNTHETIC_DIR'] ?? DEFAULT_DB_PATH;
+}
+
+function normalizeSyntheticModel(model: string): string {
+ const lower = model.toLowerCase();
+ if (lower.startsWith('hf:')) {
+ return lower.split('/').at(-1) ?? lower.slice(3);
+ }
+ const modelsMarker = '/models/';
+ const index = lower.indexOf(modelsMarker);
+ return index >= 0 ? lower.slice(index + modelsMarker.length) : lower;
+}
+
+function loadRows(dbPath: string): Array> {
+ let db: InstanceType;
+ try {
+ db = new Database(dbPath, { readonly: true });
+ } catch {
+ return [];
+ }
+ try {
+ const tables = db.query("SELECT name FROM sqlite_master WHERE type='table'").all() as Array<{ name: string }>;
+ if (tables.some((row) => row.name === 'messages')) {
+ return db.query('SELECT * FROM messages').all() as Array>;
+ }
+ if (tables.some((row) => row.name === 'token_usage')) {
+ return db.query('SELECT * FROM token_usage').all() as Array>;
+ }
+ return [];
+ } catch {
+ return [];
+ } finally {
+ db.close();
+ }
+}
+
+function rowToRecord(row: Record, range: DateRange): LocalUsageRecord | null {
+ const timestamp = timestampToIso(row['timestamp'] ?? row['created_at']);
+ const date = timestamp ? extractDate(timestamp) : null;
+ if (!timestamp || !date || !isInRange(date, range)) return null;
+ const inputTokens = nonNegativeNumber(row['input_tokens'] ?? row['input']);
+ const outputTokens = nonNegativeNumber(row['output_tokens'] ?? row['output']) + nonNegativeNumber(row['reasoning_tokens']);
+ const cacheReadTokens = nonNegativeNumber(row['cache_read_tokens'] ?? row['cache_read']);
+ const cacheWriteTokens = nonNegativeNumber(row['cache_write_tokens'] ?? row['cache_write']);
+ if (inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens === 0) return null;
+ return {
+ date,
+ timestamp,
+ model: normalizeSyntheticModel(stringValue(row['model']) ?? stringValue(row['model_id']) ?? 'synthetic-unknown'),
+ inputTokens,
+ outputTokens,
+ cacheReadTokens,
+ cacheWriteTokens,
+ explicitCost: safeNumber(row['cost']) ?? safeNumber(row['cost_usd']) ?? undefined,
+ sessionId: stringValue(row['session_id']) ?? stringValue(row['sessionId']) ?? undefined,
+ projectId: stringValue(row['provider']) ?? 'synthetic',
+ };
+}
+
+export class SyntheticProvider implements IProvider {
+ readonly name = PROVIDER_NAME;
+ readonly displayName = DISPLAY_NAME;
+ readonly colors = COLORS;
+ private readonly dbPath: string;
+
+ constructor(dbPath?: string) {
+ this.dbPath = resolveDbPath(dbPath);
+ }
+
+ async isAvailable(): Promise {
+ return existsSync(this.dbPath);
+ }
+
+ async load(range: DateRange): Promise {
+ const records = loadRows(this.dbPath)
+ .map((row) => rowToRecord(row, range))
+ .filter((record): record is LocalUsageRecord => record !== null);
+ return buildProviderData(METADATA, records);
+ }
+}
diff --git a/packages/registry/src/providers/trae.ts b/packages/registry/src/providers/trae.ts
new file mode 100644
index 0000000..460d04c
--- /dev/null
+++ b/packages/registry/src/providers/trae.ts
@@ -0,0 +1,94 @@
+import { existsSync, readFileSync } from 'node:fs';
+import { homedir } from 'node:os';
+import { join } from 'node:path';
+import type { DateRange, ProviderColors, ProviderData } from '@tokenleak/core';
+import type { IProvider } from '../provider';
+import { isInRange } from '../utils';
+import {
+ buildProviderData,
+ collectFiles,
+ extractDate,
+ nonNegativeNumber,
+ safeNumber,
+ stringValue,
+ timestampToIso,
+ type LocalProviderMetadata,
+ type LocalUsageRecord,
+} from './local-usage';
+
+const PROVIDER_NAME = 'trae';
+const DISPLAY_NAME = 'Trae';
+const DEFAULT_BASE_DIR = join(homedir(), '.config', 'tokenleak', 'trae-cache', 'sessions');
+const COLORS: ProviderColors = {
+ primary: '#06b6d4',
+ secondary: '#67e8f9',
+ gradient: ['#06b6d4', '#67e8f9'],
+};
+const METADATA: LocalProviderMetadata = { provider: PROVIDER_NAME, displayName: DISPLAY_NAME, colors: COLORS };
+
+function resolveBaseDir(baseDir?: string): string {
+ return baseDir ?? process.env['TOKENLEAK_TRAE_DIR'] ?? DEFAULT_BASE_DIR;
+}
+
+function isTraeFile(_path: string, name: string): boolean {
+ return name.endsWith('.json');
+}
+
+function normalizeModel(name: string, mode?: string | null): string {
+ if (!name.trim()) return mode ? `trae-${mode.toLowerCase()}` : 'trae-unknown';
+ return name.toLowerCase().replace(/\s+/g, '-');
+}
+
+function parseTraeFile(file: string, range: DateRange): LocalUsageRecord[] {
+ let sessions: unknown[];
+ try {
+ const parsed = JSON.parse(readFileSync(file, 'utf-8'));
+ sessions = Array.isArray(parsed) ? parsed : [];
+ } catch {
+ return [];
+ }
+
+ return sessions.flatMap((sessionValue) => {
+ const session = typeof sessionValue === 'object' && sessionValue !== null ? sessionValue as Record : null;
+ if (!session) return [];
+ const extra = typeof session['extra_info'] === 'object' && session['extra_info'] !== null ? session['extra_info'] as Record : {};
+ const timestamp = timestampToIso(session['usage_time']);
+ const date = timestamp ? extractDate(timestamp) : null;
+ if (!timestamp || !date || !isInRange(date, range)) return [];
+ const inputTokens = nonNegativeNumber(extra['input_token']);
+ const outputTokens = nonNegativeNumber(extra['output_token']);
+ const cacheReadTokens = nonNegativeNumber(extra['cache_read_token']);
+ const cacheWriteTokens = nonNegativeNumber(extra['cache_write_token']);
+ if (inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens === 0) return [];
+ return [{
+ date,
+ timestamp,
+ model: normalizeModel(stringValue(session['model_name']) ?? '', stringValue(session['mode'])),
+ inputTokens,
+ outputTokens,
+ cacheReadTokens,
+ cacheWriteTokens,
+ explicitCost: safeNumber(session['dollar_float']) ?? undefined,
+ sessionId: stringValue(session['session_id']) ?? undefined,
+ }];
+ });
+}
+
+export class TraeProvider implements IProvider {
+ readonly name = PROVIDER_NAME;
+ readonly displayName = DISPLAY_NAME;
+ readonly colors = COLORS;
+ private readonly baseDir: string;
+
+ constructor(baseDir?: string) {
+ this.baseDir = resolveBaseDir(baseDir);
+ }
+
+ async isAvailable(): Promise {
+ return existsSync(this.baseDir) && collectFiles(this.baseDir, isTraeFile).length > 0;
+ }
+
+ async load(range: DateRange): Promise {
+ return buildProviderData(METADATA, collectFiles(this.baseDir, isTraeFile).flatMap((file) => parseTraeFile(file, range)));
+ }
+}
diff --git a/packages/registry/src/providers/zed.ts b/packages/registry/src/providers/zed.ts
new file mode 100644
index 0000000..9af5d97
--- /dev/null
+++ b/packages/registry/src/providers/zed.ts
@@ -0,0 +1,146 @@
+import { existsSync } from 'node:fs';
+import { homedir } from 'node:os';
+import { join } from 'node:path';
+import { Database } from 'bun:sqlite';
+import type { DateRange, ProviderColors, ProviderData } from '@tokenleak/core';
+import type { IProvider } from '../provider';
+import { isInRange } from '../utils';
+import {
+ buildProviderData,
+ extractDate,
+ nonNegativeNumber,
+ objectValue,
+ stringValue,
+ timestampToIso,
+ type LocalProviderMetadata,
+ type LocalUsageRecord,
+} from './local-usage';
+
+const PROVIDER_NAME = 'zed';
+const DISPLAY_NAME = 'Zed Agent';
+const ZED_HOSTED_PROVIDER = 'zed.dev';
+const COLORS: ProviderColors = {
+ primary: '#0891b2',
+ secondary: '#22d3ee',
+ gradient: ['#0891b2', '#22d3ee'],
+};
+const METADATA: LocalProviderMetadata = { provider: PROVIDER_NAME, displayName: DISPLAY_NAME, colors: COLORS };
+
+interface ZedRow {
+ id: string;
+ updated_at: string;
+ folder_paths?: string | null;
+ folder_paths_order?: string | null;
+ data_type: string;
+ data: Uint8Array | Buffer;
+}
+
+function defaultDbPaths(): string[] {
+ const override = process.env['TOKENLEAK_ZED_DIR'];
+ if (override) {
+ return override.endsWith('.db')
+ ? [override]
+ : [join(override, 'threads', 'threads.db'), join(override, 'threads.db')];
+ }
+ const paths = [
+ join(process.env['XDG_DATA_HOME'] ?? join(homedir(), '.local', 'share'), 'zed', 'threads', 'threads.db'),
+ join(homedir(), 'Library', 'Application Support', 'Zed', 'threads', 'threads.db'),
+ ];
+ if (process.env['LOCALAPPDATA']) {
+ paths.push(join(process.env['LOCALAPPDATA'], 'Zed', 'threads', 'threads.db'));
+ }
+ return paths;
+}
+
+function loadRows(dbPath: string): ZedRow[] {
+ let db: InstanceType;
+ try {
+ db = new Database(dbPath, { readonly: true });
+ } catch {
+ return [];
+ }
+ try {
+ return db.query('SELECT id, updated_at, folder_paths, folder_paths_order, data_type, data FROM threads').all() as ZedRow[];
+ } catch {
+ return [];
+ } finally {
+ db.close();
+ }
+}
+
+function decodeData(row: ZedRow): Record | null {
+ if (row.data_type.toLowerCase() !== 'json') return null;
+ try {
+ return objectValue(JSON.parse(Buffer.from(row.data).toString('utf-8')));
+ } catch {
+ return null;
+ }
+}
+
+function projectFromFolders(folderPaths?: string | null): string | undefined {
+ if (!folderPaths) return undefined;
+ try {
+ const folders = JSON.parse(folderPaths);
+ if (!Array.isArray(folders)) return undefined;
+ const first = stringValue(folders[0]);
+ return first?.split(/[\\/]/).filter(Boolean).at(-1);
+ } catch {
+ return undefined;
+ }
+}
+
+function usageFromThread(thread: Record): Record | null {
+ return objectValue(thread['usage']) ?? objectValue(thread['token_usage']) ?? objectValue(thread['usageData']);
+}
+
+function rowToRecord(row: ZedRow, range: DateRange): LocalUsageRecord | null {
+ const thread = decodeData(row);
+ if (!thread || thread['imported'] === true) return null;
+ const modelObj = objectValue(thread['model']);
+ if ((stringValue(modelObj?.['provider']) ?? '').toLowerCase() !== ZED_HOSTED_PROVIDER) return null;
+ const model = stringValue(modelObj?.['model']) ?? stringValue(modelObj?.['id']);
+ if (!model) return null;
+ const usage = usageFromThread(thread);
+ if (!usage) return null;
+ const timestamp = timestampToIso(thread['updated_at']) ?? timestampToIso(row.updated_at);
+ const date = timestamp ? extractDate(timestamp) : null;
+ if (!timestamp || !date || !isInRange(date, range)) return null;
+ const inputTokens = nonNegativeNumber(usage['input_tokens'] ?? usage['input']);
+ const outputTokens = nonNegativeNumber(usage['output_tokens'] ?? usage['output']) + nonNegativeNumber(usage['reasoning_tokens']);
+ const cacheReadTokens = nonNegativeNumber(usage['cache_read_tokens'] ?? usage['cacheRead']);
+ const cacheWriteTokens = nonNegativeNumber(usage['cache_write_tokens'] ?? usage['cacheWrite']);
+ if (inputTokens + outputTokens + cacheReadTokens + cacheWriteTokens === 0) return null;
+ return {
+ date,
+ timestamp,
+ model,
+ inputTokens,
+ outputTokens,
+ cacheReadTokens,
+ cacheWriteTokens,
+ sessionId: row.id,
+ projectId: projectFromFolders(row.folder_paths),
+ };
+}
+
+export class ZedProvider implements IProvider {
+ readonly name = PROVIDER_NAME;
+ readonly displayName = DISPLAY_NAME;
+ readonly colors = COLORS;
+ private readonly dbPaths: string[];
+
+ constructor(dbPath?: string) {
+ this.dbPaths = dbPath ? [dbPath] : defaultDbPaths();
+ }
+
+ async isAvailable(): Promise {
+ return this.dbPaths.some(existsSync);
+ }
+
+ async load(range: DateRange): Promise {
+ const records = this.dbPaths.flatMap((dbPath) => loadRows(dbPath))
+ .map((row) => rowToRecord(row, range))
+ .filter((record): record is LocalUsageRecord => record !== null);
+ return buildProviderData(METADATA, records);
+ }
+}
diff --git a/packages/renderers/src/live/wrapped-live-template.ts b/packages/renderers/src/live/wrapped-live-template.ts
index 52a7659..ddf7ba3 100644
--- a/packages/renderers/src/live/wrapped-live-template.ts
+++ b/packages/renderers/src/live/wrapped-live-template.ts
@@ -57,11 +57,23 @@ const PROVIDER_COLORS: Record = {
gemini: '#4285f4',
copilot: '#6e7781',
amp: '#ff5a1f',
+ codebuff: '#2563eb',
+ droid: '#f97316',
qwen: '#7c3aed',
'roo-code': '#0f766e',
'kilo-code': '#2563eb',
+ kimi: '#111827',
+ kilo: '#f59e0b',
+ mux: '#ec4899',
+ crush: '#ef4444',
openclaw: '#dc2626',
hermes: '#16a34a',
+ goose: '#0f766e',
+ antigravity: '#7c3aed',
+ zed: '#0891b2',
+ kiro: '#6366f1',
+ trae: '#06b6d4',
+ synthetic: '#10b981',
pi: '#5a4a70',
};
diff --git a/packages/renderers/src/svg/wrapped-single-page.ts b/packages/renderers/src/svg/wrapped-single-page.ts
index 3300c83..18fcf8f 100644
--- a/packages/renderers/src/svg/wrapped-single-page.ts
+++ b/packages/renderers/src/svg/wrapped-single-page.ts
@@ -80,6 +80,18 @@ const PROVIDER_COLORS: Record = {
codex: { dark: '#3a5070', light: '#4a6a90' },
google: { dark: '#6a2535', light: '#8a3548' },
cursor: { dark: '#7c5cbf', light: '#6a4aaa' },
+ codebuff: { dark: '#2563eb', light: '#1d4ed8' },
+ droid: { dark: '#f97316', light: '#c2410c' },
+ kimi: { dark: '#9ca3af', light: '#374151' },
+ kilo: { dark: '#f59e0b', light: '#b45309' },
+ mux: { dark: '#ec4899', light: '#be185d' },
+ crush: { dark: '#ef4444', light: '#b91c1c' },
+ goose: { dark: '#0f766e', light: '#0f766e' },
+ antigravity: { dark: '#7c3aed', light: '#6d28d9' },
+ zed: { dark: '#0891b2', light: '#0e7490' },
+ kiro: { dark: '#6366f1', light: '#4f46e5' },
+ trae: { dark: '#06b6d4', light: '#0891b2' },
+ synthetic: { dark: '#10b981', light: '#047857' },
pi: { dark: '#5a4a70', light: '#706088' },
};
diff --git a/packages/tui/src/lib/data.ts b/packages/tui/src/lib/data.ts
index 07d1564..384b3ea 100644
--- a/packages/tui/src/lib/data.ts
+++ b/packages/tui/src/lib/data.ts
@@ -54,11 +54,22 @@ import {
GeminiProvider,
CopilotProvider,
AmpProvider,
+ CodebuffProvider,
+ DroidProvider,
QwenProvider,
RooCodeProvider,
KiloCodeProvider,
+ KimiProvider,
+ KiloProvider,
+ MuxProvider,
+ CrushProvider,
OpenClawProvider,
HermesProvider,
+ GooseProvider,
+ AntigravityProvider,
+ ZedProvider,
+ KiroProvider,
+ TraeProvider,
OpenCodeProvider,
PiProvider,
MODEL_PRICING,
@@ -236,11 +247,22 @@ function createRegistry(): ProviderRegistry {
registry.register(new GeminiProvider());
registry.register(new CopilotProvider());
registry.register(new AmpProvider());
+ registry.register(new CodebuffProvider());
+ registry.register(new DroidProvider());
registry.register(new QwenProvider());
registry.register(new RooCodeProvider());
registry.register(new KiloCodeProvider());
+ registry.register(new KimiProvider());
+ registry.register(new KiloProvider());
+ registry.register(new MuxProvider());
+ registry.register(new CrushProvider());
registry.register(new OpenClawProvider());
registry.register(new HermesProvider());
+ registry.register(new GooseProvider());
+ registry.register(new AntigravityProvider());
+ registry.register(new ZedProvider());
+ registry.register(new KiroProvider());
+ registry.register(new TraeProvider());
registry.register(new OpenCodeProvider());
registry.register(new PiProvider());
return registry;
diff --git a/packages/tui/src/lib/theme.ts b/packages/tui/src/lib/theme.ts
index 16c1f4f..9e36570 100644
--- a/packages/tui/src/lib/theme.ts
+++ b/packages/tui/src/lib/theme.ts
@@ -25,11 +25,23 @@ export const PROVIDER_COLORS: Record = {
gemini: '#4285f4',
copilot: '#6e7781',
amp: '#ff5a1f',
+ codebuff: '#2563eb',
+ droid: '#f97316',
qwen: '#7c3aed',
'roo-code': '#0f766e',
'kilo-code': '#2563eb',
+ kimi: '#111827',
+ kilo: '#f59e0b',
+ mux: '#ec4899',
+ crush: '#ef4444',
openclaw: '#dc2626',
hermes: '#16a34a',
+ goose: '#0f766e',
+ antigravity: '#7c3aed',
+ zed: '#0891b2',
+ kiro: '#6366f1',
+ trae: '#06b6d4',
+ synthetic: '#10b981',
pi: '#06b6d4',
'open-code': '#ef4444',
};