chore: import upstream snapshot with attribution
@@ -0,0 +1 @@
|
||||
github: agentseal
|
||||
@@ -0,0 +1,18 @@
|
||||
## Summary
|
||||
|
||||
<!-- What does this PR do? 1-3 bullet points. -->
|
||||
|
||||
## Testing
|
||||
|
||||
- [ ] I have tested this locally against real data (not just unit tests)
|
||||
- [ ] `npm test` passes
|
||||
- [ ] `npm run build` succeeds
|
||||
|
||||
### For new providers only:
|
||||
|
||||
- [ ] I installed the tool and generated real sessions by using it
|
||||
- [ ] `npm run dev -- today` shows correct costs and session counts for this provider
|
||||
- [ ] `npm run dev -- models --provider <name>` shows correct model names and pricing
|
||||
- [ ] Screenshot or terminal output attached below proving it works with real data
|
||||
|
||||
<!-- Paste screenshot / terminal output here -->
|
||||
@@ -0,0 +1,43 @@
|
||||
name: Block Claude / Anthropic co-author trailers
|
||||
|
||||
# Rejects PRs that contain a `Co-authored-by: ... claude ...` or `... anthropic ...`
|
||||
# trailer in any of their commits. Contributors can still use AI tools to help
|
||||
# write code, but they must remove the co-author attribution before the PR is
|
||||
# eligible to merge, per the project's contributor guidelines.
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Scan PR commits for disallowed co-author trailers
|
||||
run: |
|
||||
BASE_SHA="${{ github.event.pull_request.base.sha }}"
|
||||
HEAD_SHA="${{ github.event.pull_request.head.sha }}"
|
||||
FOUND=0
|
||||
while IFS= read -r c; do
|
||||
[ -z "$c" ] && continue
|
||||
SUBJECT=$(git log -1 "$c" --format=%s)
|
||||
if git log -1 "$c" --format="%B" | grep -qiE 'co-authored-by:.*(claude|anthropic)'; then
|
||||
echo "::error::Commit $c ($SUBJECT) has a Claude/Anthropic co-author trailer. Please remove it before this PR can merge."
|
||||
FOUND=1
|
||||
fi
|
||||
done < <(git log --format=%H "$BASE_SHA".."$HEAD_SHA")
|
||||
if [ "$FOUND" != "0" ]; then
|
||||
echo ""
|
||||
echo "How to fix:"
|
||||
echo " 1. Run: git rebase -i $BASE_SHA"
|
||||
echo " 2. Mark each flagged commit as 'reword'"
|
||||
echo " 3. Delete the Co-authored-by line from the commit message"
|
||||
echo " 4. Save, then: git push --force-with-lease"
|
||||
exit 1
|
||||
fi
|
||||
echo "No disallowed co-author trailers found."
|
||||
@@ -0,0 +1,27 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
jobs:
|
||||
semgrep:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Install Semgrep
|
||||
run: pip install semgrep
|
||||
|
||||
- name: Run Semgrep bracket-assign guard
|
||||
run: |
|
||||
set -e
|
||||
semgrep --config .semgrep/rules/no-bracket-assign-hot-paths.yml \
|
||||
--strict --json \
|
||||
src/providers/ src/parser.ts > semgrep-out.json
|
||||
FINDINGS=$(jq '.results | length' semgrep-out.json)
|
||||
if [ "$FINDINGS" -gt 0 ]; then
|
||||
jq -r '.results[] | "::error file=\(.path),line=\(.start.line)::\(.extra.message)"' semgrep-out.json
|
||||
exit 1
|
||||
fi
|
||||
@@ -0,0 +1,23 @@
|
||||
name: firstlook
|
||||
on:
|
||||
pull_request:
|
||||
types: [opened, reopened, synchronize]
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
pr-number:
|
||||
description: 'PR number to scan (leave empty for all open PRs)'
|
||||
required: false
|
||||
type: string
|
||||
|
||||
jobs:
|
||||
assess:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
pull-requests: write
|
||||
contents: read
|
||||
steps:
|
||||
- uses: getagentseal/firstlook@main
|
||||
with:
|
||||
pr-number: ${{ inputs.pr-number }}
|
||||
skip-users: 'dependabot[bot],renovate[bot]'
|
||||
fail-on: 'unknown'
|
||||
@@ -0,0 +1,75 @@
|
||||
name: Release macOS Menubar
|
||||
|
||||
# Triggers on a `mac-v*` tag push (e.g. `git tag mac-v0.8.0 && git push origin mac-v0.8.0`),
|
||||
# or manually via the Actions tab. Builds a universal arm64+x86_64 bundle, ad-hoc signs it,
|
||||
# zips via `ditto`, and uploads the zip to the GitHub Release. The installer verifies
|
||||
# the checksum and bundle identity before replacing the local app.
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'mac-v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version label for the bundle (e.g. v0.8.0 or dev-preview)'
|
||||
required: true
|
||||
default: 'dev-preview'
|
||||
|
||||
permissions:
|
||||
contents: write # Needed to create the release + upload assets.
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: macos-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Resolve version label
|
||||
id: version
|
||||
run: |
|
||||
if [[ "${GITHUB_REF}" == refs/tags/mac-v* ]]; then
|
||||
echo "value=${GITHUB_REF#refs/tags/mac-}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "value=${{ github.event.inputs.version }}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- name: Show Swift toolchain
|
||||
run: swift --version
|
||||
|
||||
- name: Build + bundle + zip
|
||||
run: mac/Scripts/package-app.sh "${{ steps.version.outputs.value }}"
|
||||
|
||||
- name: Upload artifact (for manual runs)
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: CodeBurnMenubar-${{ steps.version.outputs.value }}
|
||||
path: |
|
||||
mac/.build/dist/CodeBurnMenubar-${{ steps.version.outputs.value }}.zip
|
||||
mac/.build/dist/CodeBurnMenubar-${{ steps.version.outputs.value }}.zip.sha256
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Create / update GitHub Release
|
||||
if: startsWith(github.ref, 'refs/tags/mac-v')
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
tag_name: ${{ github.ref_name }}
|
||||
name: Menubar ${{ steps.version.outputs.value }}
|
||||
body: |
|
||||
Install with:
|
||||
|
||||
```
|
||||
npm install -g codeburn
|
||||
codeburn menubar
|
||||
```
|
||||
|
||||
That command drops the app into `~/Applications`, records the persistent
|
||||
`codeburn` CLI path used by the menubar, verifies the downloaded checksum,
|
||||
clears quarantine after bundle verification, and launches it. If you download
|
||||
the zip from this page directly and macOS shows "cannot verify developer",
|
||||
right-click the app in Finder and pick Open to whitelist it once.
|
||||
files: |
|
||||
mac/.build/dist/CodeBurnMenubar-${{ steps.version.outputs.value }}.zip
|
||||
mac/.build/dist/CodeBurnMenubar-${{ steps.version.outputs.value }}.zip.sha256
|
||||
fail_on_unmatched_files: true
|
||||
@@ -0,0 +1,48 @@
|
||||
node_modules/
|
||||
dist/
|
||||
*.tgz
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Editor / IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# Planning artifacts (internal, not shipped)
|
||||
docs/superpowers/
|
||||
.claude/
|
||||
/CLAUDE.md
|
||||
|
||||
# Config / secrets
|
||||
.env
|
||||
.env.*
|
||||
*.pem
|
||||
*.key
|
||||
|
||||
# Debug / logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
|
||||
# Cache
|
||||
.cache/
|
||||
*.sqlite
|
||||
|
||||
# Build artifacts
|
||||
*.tsbuildinfo
|
||||
|
||||
# Local Discord brand / promo assets not yet ready to publish
|
||||
assets/discord-*.png
|
||||
|
||||
# Desktop app experiments
|
||||
desktop/
|
||||
|
||||
# Mac App Store app (private)
|
||||
appstore/
|
||||
|
||||
# WIP / not ready
|
||||
src/summit.ts
|
||||
@@ -0,0 +1,22 @@
|
||||
rules:
|
||||
- id: no-bracket-assign-on-literal-object-map
|
||||
languages: [typescript]
|
||||
severity: ERROR
|
||||
message: >
|
||||
Bracket-assign on a map created with `{}` allows prototype pollution when
|
||||
the key comes from external data. Initialize the map with
|
||||
`Object.create(null)` instead.
|
||||
patterns:
|
||||
- pattern-either:
|
||||
- pattern: $MAP[$KEY] = $MAP[$KEY] ?? $INIT
|
||||
- pattern: $MAP[$KEY] = $MAP[$KEY] || $INIT
|
||||
- pattern: $MAP[$KEY] ??= $INIT
|
||||
- pattern: |
|
||||
if (!$MAP[$KEY]) $MAP[$KEY] = $INIT
|
||||
- pattern-not-inside: |
|
||||
const $MAP = Object.create(null)
|
||||
...
|
||||
paths:
|
||||
include:
|
||||
- '/src/providers/*.ts'
|
||||
- '/src/parser.ts'
|
||||
@@ -0,0 +1,127 @@
|
||||
# Contributing to CodeBurn
|
||||
|
||||
Thanks for your interest. This document covers what you need to know to send a working pull request.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Node.js 22.20 or newer (`engines.node` in `package.json`).
|
||||
- npm 10 or newer (ships with recent Node).
|
||||
- macOS or Linux for full provider coverage. Windows works for most providers but Cursor / Antigravity development is easier on macOS.
|
||||
- Optional: Swift 6 toolchain if you are touching the macOS menubar (`mac/`).
|
||||
- Optional: GNOME 45 or newer if you are touching the GNOME extension (`gnome/`).
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
git clone https://github.com/getagentseal/codeburn
|
||||
cd codeburn
|
||||
npm install
|
||||
```
|
||||
|
||||
There is no separate build step required to run the dev CLI. `npm run dev` runs `tsx` against `src/cli.ts` directly.
|
||||
|
||||
## Common Commands
|
||||
|
||||
| Command | What it does |
|
||||
|---|---|
|
||||
| `npm test` | Runs the vitest suite (42 test files, 568 tests). |
|
||||
| `npm run dev -- status` | Runs the CLI in dev mode against your real data. |
|
||||
| `npm run build` | Bundles the litellm pricing snapshot, then runs `tsup` to produce `dist/cli.js`. |
|
||||
| `npm run bundle-litellm` | Refreshes `src/data/litellm-snapshot.json` from the upstream litellm repo. |
|
||||
|
||||
To test a specific suite, pass a path:
|
||||
|
||||
```bash
|
||||
npm test -- tests/providers/codex.test.ts
|
||||
```
|
||||
|
||||
## What to Read Before Editing
|
||||
|
||||
- `docs/architecture.md` for the high-level codebase map.
|
||||
- `docs/providers/<name>.md` for the provider you intend to change.
|
||||
- `RELEASING.md` if you are touching version bumps or the release pipeline.
|
||||
- `SECURITY.md` for the disclosure policy.
|
||||
|
||||
## Project Layout
|
||||
|
||||
```
|
||||
src/ CLI, parsers, optimize detectors, cache layers
|
||||
src/providers/ One file per AI tool integration
|
||||
src/data/ Bundled litellm pricing snapshot
|
||||
tests/ vitest specs
|
||||
mac/ Swift menubar app
|
||||
gnome/ GNOME shell extension
|
||||
scripts/ Build helpers (litellm bundle)
|
||||
```
|
||||
|
||||
See `docs/architecture.md` for a fuller map.
|
||||
|
||||
## Coding Conventions
|
||||
|
||||
- TypeScript strict mode is on. Do not introduce `any` without a comment explaining why.
|
||||
- Avoid bracket-assign (`obj[key] = value`) on parsed user input in hot paths inside `src/providers/` and `src/parser.ts`. There is a Semgrep rule (`.semgrep/rules/no-bracket-assign-hot-paths.yml`) enforced in CI that will fail your PR if you do. Use a `Map` or an explicit allowlist instead.
|
||||
- Provider parsers must be deterministic given the same input. If you read the system clock or the filesystem outside the documented session paths, add a fixture-based test.
|
||||
- New providers go through `src/providers/index.ts`. Lazy-load anything that pulls a heavy native dependency (sqlite, protobuf) so users without that provider are not slowed down.
|
||||
|
||||
## Tests
|
||||
|
||||
- Each new provider should ship with a fixture-based test under `tests/providers/`. The five providers without test files today (claude, gemini, goose, qwen, antigravity) are a known gap; new code should not add to that list.
|
||||
- Each new optimize detector in `src/optimize.ts` needs at least one positive and one negative case in `tests/optimize.test.ts`.
|
||||
- If your change affects the menubar JSON contract, update `tests/menubar-json.test.ts`.
|
||||
|
||||
## Commit Message Format
|
||||
|
||||
Short imperative subject, optional body. Examples from `git log`:
|
||||
|
||||
```
|
||||
Enhance GNOME extension with scrollable UI, dark mode, charts, and performance fixes
|
||||
Add table column headers, oneshot placeholder, currency picker dropdown
|
||||
```
|
||||
|
||||
### No AI Co-Author Trailers
|
||||
|
||||
The `.github/workflows/block-claude-coauthor.yml` workflow rejects any PR whose commits contain a `Co-authored-by: ... claude ...` or `... anthropic ...` trailer. You may use AI tools to help write code, but strip the co-author line before pushing.
|
||||
|
||||
If a flagged PR rejects on this check, the workflow prints the exact rebase command to fix it.
|
||||
|
||||
## Before You Start
|
||||
|
||||
**Comment on the issue first.** Before writing code for a feature or new provider, leave a comment on the relevant issue saying what you plan to do. Wait for a maintainer to confirm the approach. Unsolicited PRs that duplicate work already in progress or take an incompatible approach will be closed.
|
||||
|
||||
**One PR at a time.** We will not review a second PR from you until the first is merged or closed. This keeps the review queue manageable and ensures each contribution gets proper attention.
|
||||
|
||||
## Adding a New Provider
|
||||
|
||||
New providers have the highest bar because broken parsing silently produces wrong data for users. Before opening a PR:
|
||||
|
||||
1. **Install the tool and use it.** Generate real sessions by actually coding with the provider. We do this ourselves for every provider we ship.
|
||||
2. **Test against real data.** Run `npm run dev -- today` and `npm run dev -- models` with your real sessions and confirm the output looks correct — costs are non-zero, model names resolve, session counts match what you see in the tool.
|
||||
3. **Include proof in the PR.** Attach a screenshot or terminal output showing codeburn correctly parsing your real sessions. PRs for new providers without evidence of local testing will not be reviewed.
|
||||
4. **Do not rely on AI-generated guesses about storage paths or schemas.** Tools change their data formats between versions. The only way to know the current schema is to install the tool and inspect the actual files on disk.
|
||||
|
||||
PRs that add a provider based solely on online documentation or AI-generated code, without evidence of testing against real data, will be closed.
|
||||
|
||||
## Pull Requests
|
||||
|
||||
1. Fork or branch from `main`.
|
||||
2. Push your branch and open a PR against `main`.
|
||||
3. The `firstlook` workflow will auto-assess the PR. The `semgrep` CI workflow runs the hot-path bracket-assign guard. The `block-claude-coauthor` workflow scans commits.
|
||||
4. A maintainer reviews. For non-trivial changes, expect requests for tests.
|
||||
5. Squash-merge is the default. Keep the PR title short and accurate; the description carries the context.
|
||||
|
||||
## Reporting Bugs
|
||||
|
||||
File issues at https://github.com/getagentseal/codeburn/issues. Useful details:
|
||||
|
||||
- Output of `codeburn --version`.
|
||||
- Provider involved and rough size of your session history (`du -sh ~/.codex/sessions`, etc.).
|
||||
- Output of the failing command with `DEBUG=1` if applicable.
|
||||
- For parsing bugs: a redacted JSONL or SQLite snippet that reproduces the issue.
|
||||
|
||||
## Security Issues
|
||||
|
||||
Do not file security issues in the public tracker. See `SECURITY.md` for the disclosure process.
|
||||
|
||||
## License
|
||||
|
||||
CodeBurn is MIT-licensed. By contributing, you agree your contributions are licensed under the same terms.
|
||||
@@ -0,0 +1,21 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2026 AgentSeal
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
@@ -0,0 +1,686 @@
|
||||
<p align="center">
|
||||
<a href="https://claude.com/open-source-max"><img src="https://img.shields.io/badge/Claude_for_Open_Source-Recipient-da7756?style=for-the-badge&labelColor=1a1a1a" alt="Claude for Open Source Recipient" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/getagentseal/codeburn/main/assets/providers.png" alt="CodeBurn" width="420" />
|
||||
</p>
|
||||
|
||||
<p align="center"><strong>See where your AI spend goes.</strong></p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://www.npmjs.com/package/codeburn"><img src="https://img.shields.io/npm/v/codeburn.svg?color=F97316" alt="npm version" /></a>
|
||||
<a href="https://www.npmjs.com/package/codeburn"><img src="https://img.shields.io/npm/dt/codeburn.svg?color=F97316" alt="total downloads" /></a>
|
||||
<a href="https://github.com/getagentseal/codeburn/blob/main/LICENSE"><img src="https://img.shields.io/npm/l/codeburn.svg?color=F97316" alt="license" /></a>
|
||||
<a href="https://github.com/getagentseal/codeburn"><img src="https://img.shields.io/badge/node-%3E%3D22-F97316.svg" alt="node version" /></a>
|
||||
<a href="https://discord.gg/w2sw8mCqep"><img src="https://img.shields.io/badge/discord-join-F97316?logo=discord&logoColor=white" alt="Discord" /></a>
|
||||
<a href="https://x.com/_codeburn"><img src="https://img.shields.io/badge/%40__codeburn-F97316?logo=x&logoColor=white" alt="Follow @_codeburn on X" /></a>
|
||||
<a href="https://github.com/sponsors/iamtoruk"><img src="https://img.shields.io/badge/sponsor-♥-F97316?logo=github" alt="Sponsor" /></a>
|
||||
</p>
|
||||
|
||||
**CodeBurn is a free, open-source, local-first tool that tracks AI coding token usage and cost across 32 tools and agents (Claude Code, Cursor, Codex, Gemini, Grok and more), broken down by model, project, and task.**
|
||||
|
||||
You pay for Claude, Codex, Cursor, and a stack of other AI tools. The bill tells you the total. It never tells you that half of it went to conversation instead of code, or that an expensive model burned your budget on work a cheaper one would have one-shot.
|
||||
|
||||
CodeBurn does. It reads the session files your tools already write to disk and breaks down every token and dollar by **task, model, tool, and project**, across **32 AI tools**.
|
||||
|
||||
Everything runs locally. No wrapper, no proxy, no API keys, nothing leaves your machine. Pricing comes from [LiteLLM](https://github.com/BerriAI/litellm), refreshed daily.
|
||||
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/getagentseal/codeburn/main/assets/dashboard.jpg" alt="CodeBurn TUI dashboard" width="760" />
|
||||
</p>
|
||||
<p align="center"><em>A week across every tool you use, in one screen.</em></p>
|
||||
|
||||
<p align="center">
|
||||
<a href="#quick-start">Quick start</a> ·
|
||||
<a href="#find-and-fix-waste">Find waste</a> ·
|
||||
<a href="#apply-fixes-undo-anytime">Apply fixes</a> ·
|
||||
<a href="#guard-your-budget">Guard</a> ·
|
||||
<a href="#compare-models">Compare models</a> ·
|
||||
<a href="#track-what-shipped">Track what shipped</a> ·
|
||||
<a href="#codeburn-in-your-agent-mcp">MCP</a> ·
|
||||
<a href="#supported-tools">Supported tools</a> ·
|
||||
<a href="#commands">Commands</a> ·
|
||||
<a href="#features">Features</a> ·
|
||||
<a href="#how-it-reads-your-data">How it reads data</a>
|
||||
</p>
|
||||
|
||||
## Quick start
|
||||
|
||||
**Run it instantly**, no install needed:
|
||||
|
||||
```bash
|
||||
npx codeburn
|
||||
```
|
||||
|
||||
That opens the interactive dashboard (last 7 days by default). Arrow keys switch periods, `q` quits. That is the 30-second version. You now know where your AI budget goes.
|
||||
|
||||
**Install it** for a permanent `codeburn` command:
|
||||
|
||||
```bash
|
||||
npm install -g codeburn
|
||||
```
|
||||
|
||||
Also runs via `bunx codeburn` or `dx codeburn`, or `brew install codeburn` on macOS.
|
||||
|
||||
**Menu bar app** for macOS, with your spend always in the menu bar:
|
||||
|
||||
```bash
|
||||
codeburn menubar
|
||||
```
|
||||
|
||||
On Linux, a GNOME Shell extension gives the same panel view; see [Linux (GNOME)](#linux-gnome).
|
||||
|
||||
Requires **Node.js 22.13+** and at least one supported tool with session data on disk. For Cursor and OpenCode, `better-sqlite3` installs automatically.
|
||||
|
||||
## Your month at a glance
|
||||
|
||||
```bash
|
||||
codeburn overview # this month, clean tables
|
||||
codeburn overview --no-color # plain text, ready to paste
|
||||
codeburn overview --from 2026-06-01 --to 2026-06-15 # any date range
|
||||
codeburn overview -p all # all time
|
||||
codeburn overview --provider claude # one tool only
|
||||
```
|
||||
|
||||
`codeburn overview` prints a copy-pasteable summary of where your AI spend went: totals (cost, tokens, cache hit), a breakdown by tool and by top model, your highest-value days, top projects, a per-day table, and activity and tool usage. Pipe it anywhere (into `pbcopy`, a PR, Slack, or a tweet); color drops automatically when the output is not a terminal, or pass `--no-color`.
|
||||
|
||||
```text
|
||||
CodeBurn June 2026
|
||||
|
||||
Totals
|
||||
Cost $2,795.10
|
||||
Tokens 3.49B in 23.9M / out 20.2M / cache-w 72.5M / cache-r 3.38B
|
||||
Calls 14,755 sessions 753
|
||||
Cache hit 99.3%
|
||||
|
||||
By tool
|
||||
┌──────────┬───────────┬────────┬───────┐
|
||||
│ Tool │ Cost │ Tokens │ Share │
|
||||
├──────────┼───────────┼────────┼───────┤
|
||||
│ claude │ $2,662.37 │ 3.34B │ 95% │
|
||||
│ codex │ $119.12 │ 128.1M │ 4% │
|
||||
└──────────┴───────────┴────────┴───────┘
|
||||
|
||||
(plus Top models, Highest-value days, Top projects, a per-day table, By activity, and Tools)
|
||||
```
|
||||
|
||||
## Find and fix waste
|
||||
|
||||
```bash
|
||||
codeburn optimize # scan the last 30 days
|
||||
codeburn optimize -p today # today only
|
||||
codeburn optimize -p week # last 7 days
|
||||
codeburn optimize --provider claude # restrict to one provider
|
||||
codeburn optimize --format json # setup health + findings as JSON
|
||||
```
|
||||
|
||||
`codeburn optimize` scans your sessions and your `~/.claude/` setup for waste patterns:
|
||||
|
||||
- Files Claude re-reads across sessions (same content, same context, over and over)
|
||||
- Low Read:Edit ratio (editing without reading leads to retries and wasted tokens)
|
||||
- Wasted bash output (uncapped `BASH_MAX_OUTPUT_LENGTH`, trailing noise)
|
||||
- Unused MCP servers still paying their tool-schema overhead every session
|
||||
- Ghost agents, skills, and slash commands defined in `~/.claude/` but never invoked
|
||||
- Bloated `CLAUDE.md` files (with `@-import` expansion counted)
|
||||
- Cache creation overhead and junk directory reads
|
||||
- Context-heavy sessions where effective input/cache tokens swamp output
|
||||
- Possibly low-worth expensive sessions with no edit turns or repeated retries
|
||||
when no `git`/`gh` delivery command is observed
|
||||
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/getagentseal/codeburn/main/assets/optimize.jpg" alt="CodeBurn optimize" width="760" />
|
||||
</p>
|
||||
|
||||
Each finding shows the estimated token and dollar savings plus a ready-to-paste fix: a `CLAUDE.md` line, an environment variable, or a `mv` command to archive unused items. Findings are ranked by urgency (impact weighted against observed waste) and rolled up into an A to F setup health grade. Repeat runs classify each finding as new, improving, or resolved against a 48-hour recent window.
|
||||
|
||||
You can also open it inline from the dashboard: press `o` when a finding count appears in the status bar, `b` to return.
|
||||
|
||||
## Apply fixes, undo anytime
|
||||
|
||||
```bash
|
||||
codeburn optimize --apply # review and apply fixes interactively
|
||||
codeburn optimize --apply --dry-run # print the plan, change nothing
|
||||
codeburn optimize --apply --yes # apply every appliable fix without prompting
|
||||
codeburn act list # every change CodeBurn has made
|
||||
codeburn act undo --last # roll the most recent change back
|
||||
codeburn act report # realized vs estimated savings
|
||||
```
|
||||
|
||||
`codeburn optimize` finds the waste; `--apply` fixes the config-class findings for you: settings values, environment variables, archiving unused agents and skills. Every change is backed up and journaled before it lands. `codeburn act list` shows the history and `codeburn act undo <id>` restores the original files (it refuses if the files changed since being applied, unless you pass `--force`).
|
||||
|
||||
The loop closes on honesty: once an applied fix is at least 3 days old, `codeburn act report` compares its estimated savings against what your sessions actually did, and later `codeburn optimize` runs show that realized figure in the header. Estimates get checked against reality, not just claimed.
|
||||
|
||||
## Guard your budget
|
||||
|
||||
```bash
|
||||
codeburn guard install # hooks into this project's .claude/settings.json
|
||||
codeburn guard install --global # or into ~/.claude/settings.json
|
||||
codeburn guard status # caps, install locations, flagged projects
|
||||
codeburn guard uninstall # removes cleanly, leaves your own hooks alone
|
||||
```
|
||||
|
||||
Guard installs opt-in hooks into Claude Code that watch session cost while you work:
|
||||
|
||||
- **Soft cap** (default $5): a one-time in-session warning when a session passes it.
|
||||
- **Hard cap** (default $15): stops the session; `codeburn guard allow` lifts it for that session only.
|
||||
- **Checkpoint** (default $3): if a session ends past this with no edits and no commits, a nudge suggests starting fresh with a named deliverable.
|
||||
- **Session openers**: projects where optimize found waste get a one-line flag at session start.
|
||||
|
||||
Caps are edited in `~/.config/codeburn/guard.json` (set a value to `null` to disable it). Add `--statusline` to show session cost in the Claude Code status line. Installs go through the same journal as everything else, so `codeburn act undo` removes them too. Hooks fail open: a broken guard never blocks a session.
|
||||
|
||||
## Compare models
|
||||
|
||||
```bash
|
||||
codeburn compare # interactive model picker (default: last 6 months)
|
||||
codeburn compare -p week # last 7 days
|
||||
codeburn compare -p today # today only
|
||||
codeburn compare --provider claude # Claude Code sessions only
|
||||
```
|
||||
|
||||
Which model is actually better for *your* work? Press `c` in the dashboard, or run `codeburn compare`. Arrow keys switch periods, `b` to return.
|
||||
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/getagentseal/codeburn/main/assets/compare.jpg" alt="CodeBurn compare" width="760" />
|
||||
</p>
|
||||
|
||||
| Section | Metric | What it measures |
|
||||
|---------|--------|-----------------|
|
||||
| Performance | One-shot rate | Edits that succeed without retries |
|
||||
| Performance | Retry rate | Average retries per edit turn |
|
||||
| Performance | Self-correction | Turns where the model corrected its own mistake |
|
||||
| Efficiency | Cost per call | Average cost per API call |
|
||||
| Efficiency | Cost per edit | Average cost per edit turn |
|
||||
| Efficiency | Output tokens per call | Average output tokens per call |
|
||||
| Efficiency | Cache hit rate | Proportion of input from cache |
|
||||
|
||||
Also compares per-category one-shot rates, delegation rate, planning rate, average tools per turn, and fast mode usage.
|
||||
|
||||
## Track what shipped
|
||||
|
||||
```bash
|
||||
codeburn yield # last 7 days (default)
|
||||
codeburn yield -p today # today only
|
||||
codeburn yield -p 30days # last 30 days
|
||||
codeburn yield -p month # this calendar month
|
||||
codeburn yield --format json # productive/reverted/abandoned spend as JSON
|
||||
```
|
||||
|
||||
Did the spend actually ship? `codeburn yield` correlates AI sessions with git commits by timestamp:
|
||||
|
||||
| Category | Meaning |
|
||||
|----------|---------|
|
||||
| Productive | Commits from this session landed in main |
|
||||
| Reverted | Commits were later reverted |
|
||||
| Abandoned | No commits near session, or commits never merged |
|
||||
|
||||
Requires a git repository. Run from your project directory.
|
||||
|
||||
## Browser dashboard
|
||||
|
||||
```bash
|
||||
codeburn web # opens http://localhost:4747 in your browser
|
||||
codeburn web -p 30days # start on a different period
|
||||
codeburn web --port 8080 # pick a port (falls back to a free one if taken)
|
||||
codeburn web --no-open # start the server without opening a browser
|
||||
```
|
||||
|
||||
A local web dashboard with the same task, model, tool, and project breakdowns as the TUI, rendered with charts. Everything is read from disk on your machine and the server binds to localhost; nothing is uploaded.
|
||||
|
||||
### Combine usage across your devices
|
||||
|
||||
See one total across your laptop, desktop, and work machine on the same network. On each other device, share its usage:
|
||||
|
||||
```bash
|
||||
codeburn share --pair # opens a pairing window and prints a PIN
|
||||
```
|
||||
|
||||
Then add it once from your main device (the PIN authorizes the pairing):
|
||||
|
||||
```bash
|
||||
codeburn devices add # find nearby devices and pair, or: add <host> --pin <pin>
|
||||
codeburn devices # combined totals by machine
|
||||
codeburn devices rm <name> # forget a device
|
||||
```
|
||||
|
||||
Pairing is PIN-authorized and stays on your local network. You can also discover and pair devices straight from the browser dashboard.
|
||||
|
||||
## Menu bar
|
||||
|
||||
```bash
|
||||
codeburn menubar
|
||||
```
|
||||
|
||||
One command: downloads the latest `.app`, installs into `~/Applications`, and launches it. Re-run with `--force` to reinstall. Native Swift and SwiftUI app lives in `mac/` (see `mac/README.md` for build details).
|
||||
|
||||
<p align="center">
|
||||
<img src="https://raw.githubusercontent.com/getagentseal/codeburn/main/assets/menubar-0.9.11.png" alt="CodeBurn macOS menubar" width="440" />
|
||||
</p>
|
||||
|
||||
The menubar icon shows the spend period selected in Settings (Today by default; Week, Month, and 6 Months are also available). Non-today periods add a short suffix such as `$42 / mo` so the menu bar value stays clear. Click to open a popover with agent tabs, period switcher (Today, 7 Days, 30 Days, Month, All), Trend, Forecast, Pulse, Stats, and Plan insights, activity and model breakdowns, optimize findings, and CSV/JSON export. Refreshes every 30 seconds.
|
||||
|
||||
You can also set the menubar status period from Terminal:
|
||||
|
||||
```bash
|
||||
defaults write org.agentseal.codeburn-menubar CodeBurnMenubarPeriod -string month
|
||||
```
|
||||
|
||||
Allowed values are `today`, `week`, `month`, and `sixMonths`. Relaunch the app to apply external defaults changes.
|
||||
|
||||
**Compact mode** shrinks the menubar item to fit the text, dropping decimals (e.g. `$110` instead of `$110.20`):
|
||||
|
||||
```bash
|
||||
defaults write org.agentseal.codeburn-menubar CodeBurnMenubarCompact -bool true
|
||||
```
|
||||
|
||||
Relaunch the app to apply. To revert: `defaults delete org.agentseal.codeburn-menubar CodeBurnMenubarCompact`.
|
||||
|
||||
**Refresh cadence** is set in Settings under Usage Refresh. Auto (the default) refreshes every 30 seconds on AC power and backs off on battery, in Low Power Mode, and while the display sleeps; fixed 1, 5, or 15 minute cadences and a Manual mode (refresh only when you open the popover or click Refresh Now) are also available. From Terminal:
|
||||
|
||||
```bash
|
||||
defaults write org.agentseal.codeburn-menubar CodeBurnMenubarRefreshSeconds -int 300
|
||||
```
|
||||
|
||||
Seconds between refreshes: `60`, `300`, or `900`; `0` is Manual and `-1` is Auto. Takes effect on the next refresh tick, no relaunch needed.
|
||||
|
||||
### Linux (GNOME)
|
||||
|
||||
Linux gets the same ambient view through a GNOME Shell extension (GNOME 45+): spend in the top panel, period switcher, compact mode, and daily budget alerts. It lives in [`gnome/`](gnome/):
|
||||
|
||||
```bash
|
||||
git clone https://github.com/getagentseal/codeburn && cd codeburn/gnome
|
||||
./install.sh
|
||||
gnome-extensions enable codeburn@codeburn.dev
|
||||
```
|
||||
|
||||
See [gnome/README.md](gnome/README.md) for settings and development notes. On Windows, `codeburn web` is the always-on view for now.
|
||||
|
||||
## CodeBurn in your agent (MCP)
|
||||
|
||||
```bash
|
||||
claude mcp add codeburn -- npx -y codeburn mcp
|
||||
```
|
||||
|
||||
`codeburn mcp` runs a local MCP server over stdio, so Claude Code, Cursor, or any MCP client can ask "where did my tokens go this week?" or "how do I spend less?" mid-conversation. It exposes two tools:
|
||||
|
||||
| Tool | What it returns |
|
||||
|------|-----------------|
|
||||
| `get_usage` | Spend and usage with breakdowns by tool, model, project, and task (fast) |
|
||||
| `get_savings` | Cost reductions: waste findings, retry tax, routing waste (slower, deeper analysis) |
|
||||
|
||||
Everything is read from local disk, same as the CLI. Project names are pseudonymized by default; the agent only sees real names if it asks with `include_project_names: true`. For other MCP clients, configure a stdio server with command `npx` and args `-y codeburn mcp`.
|
||||
|
||||
## Supported tools
|
||||
|
||||
CodeBurn auto-detects which AI tools you use. Each logo links to its provider doc.
|
||||
|
||||
<p align="center">
|
||||
<a href="docs/providers/claude.md" title="Claude Code & Claude Desktop"><img src="assets/providers/claude.jpg" alt="Claude Code & Claude Desktop" height="34" /></a>
|
||||
<a href="docs/providers/cline.md" title="Cline"><img src="assets/providers/cline.svg" alt="Cline" height="34" /></a>
|
||||
<a href="docs/providers/codex.md" title="Codex (OpenAI)"><img src="assets/providers/codex.png" alt="Codex (OpenAI)" height="34" /></a>
|
||||
<a href="docs/providers/cursor.md" title="Cursor"><img src="assets/providers/cursor.jpg" alt="Cursor" height="34" /></a>
|
||||
<a href="docs/providers/cursor-agent.md" title="cursor-agent"><img src="assets/providers/cursor-agent.jpg" alt="cursor-agent" height="34" /></a>
|
||||
<a href="docs/providers/devin.md" title="Devin"><img src="assets/providers/devin.png" alt="Devin" height="34" /></a>
|
||||
<a href="docs/providers/forge.md" title="Forge"><img src="assets/providers/forge.png" alt="Forge" height="34" /></a>
|
||||
<a href="docs/providers/gemini.md" title="Gemini CLI"><img src="assets/providers/gemini.png" alt="Gemini CLI" height="34" /></a>
|
||||
<a href="docs/providers/mistral-vibe.md" title="Mistral Vibe"><img src="assets/providers/mistral-vibe.svg" alt="Mistral Vibe" height="34" /></a>
|
||||
<a href="docs/providers/copilot.md" title="GitHub Copilot"><img src="assets/providers/copilot.jpg" alt="GitHub Copilot" height="34" /></a>
|
||||
<a href="docs/providers/ibm-bob.md" title="IBM Bob"><img src="assets/providers/ibm-bob.svg" alt="IBM Bob" height="34" /></a>
|
||||
<a href="docs/providers/kiro.md" title="Kiro"><img src="assets/providers/kiro.png" alt="Kiro" height="34" /></a>
|
||||
<a href="docs/providers/opencode.md" title="OpenCode"><img src="assets/providers/opencode.png" alt="OpenCode" height="34" /></a>
|
||||
<a href="docs/providers/openclaw.md" title="OpenClaw"><img src="assets/providers/openclaw.jpg" alt="OpenClaw" height="34" /></a>
|
||||
<a href="docs/providers/pi.md" title="Pi"><img src="assets/providers/pi.png" alt="Pi" height="34" /></a>
|
||||
<a href="docs/providers/omp.md" title="OMP (Oh My Pi)"><img src="assets/providers/omp.svg" alt="OMP (Oh My Pi)" height="34" /></a>
|
||||
<a href="docs/providers/droid.md" title="Droid"><img src="assets/providers/droid.png" alt="Droid" height="34" /></a>
|
||||
<a href="docs/providers/roo-code.md" title="Roo Code"><img src="assets/providers/roo-code.png" alt="Roo Code" height="34" /></a>
|
||||
<a href="docs/providers/kilo-code.md" title="KiloCode"><img src="assets/providers/kilo-code.png" alt="KiloCode" height="34" /></a>
|
||||
<a href="docs/providers/qwen.md" title="Qwen"><img src="assets/providers/qwen.png" alt="Qwen" height="34" /></a>
|
||||
<a href="docs/providers/kimi.md" title="Kimi Code CLI"><img src="assets/providers/kimi.svg" alt="Kimi Code CLI" height="34" /></a>
|
||||
<a href="docs/providers/lingtai-tui.md" title="LingTai TUI">LingTai TUI</a>
|
||||
<a href="docs/providers/goose.md" title="Goose"><img src="assets/providers/goose.png" alt="Goose" height="34" /></a>
|
||||
<a href="docs/providers/antigravity.md" title="Antigravity"><img src="assets/providers/antigravity.png" alt="Antigravity" height="34" /></a>
|
||||
<a href="docs/providers/crush.md" title="Crush"><img src="assets/providers/crush.png" alt="Crush" height="34" /></a>
|
||||
<a href="docs/providers/warp.md" title="Warp"><img src="assets/providers/warp.jpg" alt="Warp" height="34" /></a>
|
||||
<a href="docs/providers/mux.md" title="Mux (coder)"><img src="assets/providers/mux.png" alt="Mux (coder)" height="34" /></a>
|
||||
<a href="docs/providers/vercel-gateway.md" title="Vercel AI Gateway"><img src="assets/providers/vercel-gateway.png" alt="Vercel AI Gateway" height="34" /></a>
|
||||
<a href="docs/providers/zerostack.md" title="Zerostack"><img src="assets/providers/zerostack.png" alt="Zerostack" height="34" /></a>
|
||||
<a href="docs/providers/grok.md" title="Grok Build"><img src="assets/providers/grok.png" alt="Grok Build" height="34" /></a>
|
||||
<a href="docs/providers/zcode.md" title="ZCode"><img src="assets/providers/zcode.jpg" alt="ZCode" height="34" /></a>
|
||||
<a href="docs/providers/zed.md" title="Zed"><img src="assets/providers/zed.jpg" alt="Zed" height="34" /></a>
|
||||
<a href="docs/providers/hermes.md" title="Hermes Agent"><img src="assets/providers/hermes.png" alt="Hermes Agent" height="34" /></a>
|
||||
</p>
|
||||
|
||||
If multiple providers have session data on disk, press `p` in the dashboard to toggle between them.
|
||||
|
||||
Each provider doc lists the exact data location, storage format, and known quirks. Linux and Windows paths are detected automatically. If a path has changed or is wrong, please [open an issue](https://github.com/getagentseal/codeburn/issues).
|
||||
|
||||
The `--provider` flag filters any command to a single provider: `codeburn report --provider claude`, `codeburn today --provider codex`, `codeburn export --provider cursor`. Works on all commands: `report`, `today`, `month`, `overview`, `status`, `export`, `web`, `optimize`, `compare`, `yield`.
|
||||
|
||||
Adding a new provider is a single file. See `src/providers/codex.ts` for an example.
|
||||
|
||||
## Commands
|
||||
|
||||
<details>
|
||||
<summary><strong>All commands and keyboard shortcuts</strong></summary>
|
||||
|
||||
Run `codeburn` for the dashboard, or use a subcommand below. Most commands also accept `--provider`, `--project` / `--exclude`, and a period flag (`-p today|week|30days|month|all`).
|
||||
|
||||
**Dashboard & reports**
|
||||
|
||||
| Command | What it does |
|
||||
|---------|--------------|
|
||||
| `codeburn` | Interactive dashboard, last 7 days (the default view) |
|
||||
| `codeburn today` | Today's usage |
|
||||
| `codeburn month` | This calendar month's usage |
|
||||
| `codeburn overview` | Plain-text monthly summary, copy-pasteable (`--no-color`, `--from`/`--to`) |
|
||||
| `codeburn report -p 30days` | Rolling 30-day window |
|
||||
| `codeburn report -p all` | Every recorded session |
|
||||
| `codeburn report --from 2026-04-01 --to 2026-04-10` | An exact date range |
|
||||
| `codeburn report --format json` | Full dashboard data as JSON, printed to stdout |
|
||||
| `codeburn report --refresh 60` | Auto-refresh every 60s (default 30s; `--refresh 0` disables) |
|
||||
|
||||
**Status & export**
|
||||
|
||||
| Command | What it does |
|
||||
|---------|--------------|
|
||||
| `codeburn status` | Compact one-liner: today + month totals |
|
||||
| `codeburn status --format json` | The same totals as JSON |
|
||||
| `codeburn export` | CSV covering today, 7 days, and 30 days |
|
||||
| `codeburn export -f json` | Export as JSON instead of CSV |
|
||||
|
||||
**Web & devices**
|
||||
|
||||
| Command | What it does |
|
||||
|---------|--------------|
|
||||
| `codeburn web` | Local browser dashboard with charts (http://localhost:4747) |
|
||||
| `codeburn share --pair` | Share this device's usage to your other devices (PIN pairing) |
|
||||
| `codeburn devices add` | Find and pair a nearby device |
|
||||
| `codeburn devices` | Combined usage totals across your paired devices |
|
||||
|
||||
**Analysis**
|
||||
|
||||
| Command | What it does |
|
||||
|---------|--------------|
|
||||
| `codeburn audit` | Per provider-model token source table: where every number comes from |
|
||||
| `codeburn context` | What fills a session's context window: interactive browser (Claude Code and Codex) |
|
||||
| `codeburn context <id> --json` | The same context tree, scriptable |
|
||||
| `codeburn optimize` | Scan for waste and print copy-paste fixes (last 30 days) |
|
||||
| `codeburn optimize -p week` | Scope the waste scan to the last 7 days |
|
||||
| `codeburn compare` | Side-by-side model comparison |
|
||||
| `codeburn yield` | Productive vs reverted/abandoned spend, correlated against git |
|
||||
| `codeburn yield -p 30days` | Yield analysis for the last 30 days |
|
||||
|
||||
**Fix & control**
|
||||
|
||||
| Command | What it does |
|
||||
|---------|--------------|
|
||||
| `codeburn optimize --apply` | Interactively apply config-class fixes (`--yes`, `--dry-run`, `--only <ids>`) |
|
||||
| `codeburn act list` | Every change CodeBurn has applied, newest first |
|
||||
| `codeburn act undo <id>` | Roll a change back (`--last` for the most recent, `--force` if files drifted) |
|
||||
| `codeburn act report` | Realized vs estimated savings for applied fixes |
|
||||
| `codeburn guard install` | Budget-cap hooks for Claude Code (`--global`, `--statusline`) |
|
||||
| `codeburn guard status` | Show caps, install locations, and flagged projects |
|
||||
| `codeburn guard allow` | Lift the hard cap for the current session |
|
||||
| `codeburn mcp` | MCP server (stdio) exposing usage and savings to AI agents |
|
||||
|
||||
**Models**
|
||||
|
||||
| Command | What it does |
|
||||
|---------|--------------|
|
||||
| `codeburn models` | Per-model token + cost table (last 30 days) |
|
||||
| `codeburn models --by-task` | Break each model into per-task-type rows |
|
||||
| `codeburn models --top 10` | Only the 10 most expensive models |
|
||||
| `codeburn models --format markdown` | Emit a paste-friendly markdown table |
|
||||
| `codeburn models --task feature` | Filter to feature-development work |
|
||||
| `codeburn models --provider claude` | Filter to a single provider |
|
||||
|
||||
Arrow keys switch between Today, 7 Days, 30 Days, Month, and 6 Months (use `--from` / `--to` for an exact historical window). Press `q` to quit, `1` `2` `3` `4` `5` as shortcuts, `c` to open model comparison, `o` to open optimize. The dashboard auto-refreshes every 30 seconds by default (`--refresh 0` to disable). It also shows average cost per session and the five most expensive sessions across all projects.
|
||||
|
||||
</details>
|
||||
|
||||
## Features
|
||||
|
||||
<details>
|
||||
<summary><strong>Pricing, task categories, plans, currency, filtering, and more</strong></summary>
|
||||
|
||||
### Pricing
|
||||
|
||||
Prices every API call using input, output, cache read, cache write, and web search token counts, with a fast mode multiplier for Claude. Prices are fetched from [LiteLLM](https://github.com/BerriAI/litellm) and cached locally for 24 hours at `~/.cache/codeburn/`. Hardcoded fallbacks for all Claude and GPT-5 models prevent fuzzy-matching mispricing.
|
||||
|
||||
### Task Categories
|
||||
|
||||
13 categories classified from tool usage patterns and user message keywords. No LLM calls, fully deterministic.
|
||||
|
||||
| Category | What triggers it |
|
||||
|---|---|
|
||||
| Coding | Edit, Write tools |
|
||||
| Debugging | Error/fix keywords + tool usage |
|
||||
| Feature Dev | "add", "create", "implement" keywords |
|
||||
| Refactoring | "refactor", "rename", "simplify" |
|
||||
| Testing | pytest, vitest, jest in Bash |
|
||||
| Exploration | Read, Grep, WebSearch without edits |
|
||||
| Planning | EnterPlanMode, TaskCreate tools |
|
||||
| Delegation | Agent tool spawns |
|
||||
| Git Ops | git push/commit/merge in Bash |
|
||||
| Build/Deploy | npm build, docker, pm2 |
|
||||
| Brainstorming | "brainstorm", "what if", "design" |
|
||||
| Conversation | No tools, pure text exchange |
|
||||
| General | Skill tool, uncategorized |
|
||||
|
||||
### Breakdowns
|
||||
|
||||
Daily cost chart, per-project, per-model (Opus, Sonnet, Haiku, GPT-5, GPT-4o, Gemini, Kiro, and more), per-activity with one-shot rate, core tools, shell commands, and MCP servers.
|
||||
|
||||
### One-Shot Rate
|
||||
|
||||
For categories that involve code edits, CodeBurn tracks file-aware retry cycles. A retry is when the same file is re-edited after a shell command in between (Edit foo.ts, Bash, Edit foo.ts). Editing different files across shell steps is not a retry. The one-shot column shows the percentage of edit turns that succeeded without retries. Coding at 90% means the AI got it right first try 9 out of 10 times. File-level tracking is available for Claude, Codex, and Goose; other providers fall back to tool-name-based detection.
|
||||
|
||||
### Plans
|
||||
|
||||
```bash
|
||||
codeburn plan set claude-max # $200/month
|
||||
codeburn plan set claude-pro # $20/month
|
||||
codeburn plan set cursor-pro # $20/month
|
||||
codeburn plan set custom --monthly-usd 200 --provider codex # ChatGPT Pro-style custom plan
|
||||
codeburn plan reset --provider codex # remove one provider plan
|
||||
codeburn plan set none # disable plan view
|
||||
codeburn plan # show configured plans
|
||||
codeburn plan reset # remove plan config
|
||||
```
|
||||
|
||||
Subscription tracking for Claude Pro, Claude Max, Cursor Pro, and custom provider plans. Plans are stored per provider, so you can track Claude and Codex/Cursor subscriptions at the same time; the dashboard shows one overage line per active provider plan. A legacy/custom `all` plan remains a single aggregate plan and is replaced when you add a provider-specific plan, avoiding double-counted overage rows. Existing single-plan config is still read as a fallback. Presets use publicly stated plan prices (as of April 2026); they do not model exact token allowances, because vendors do not publish precise consumer-plan limits.
|
||||
|
||||
### Currency
|
||||
|
||||
```bash
|
||||
codeburn currency GBP # set to British Pounds
|
||||
codeburn currency AUD # set to Australian Dollars
|
||||
codeburn currency JPY # set to Japanese Yen
|
||||
codeburn currency CNY # set to Chinese Yuan
|
||||
codeburn currency RON # set to Romanian Leu
|
||||
codeburn currency # show current setting
|
||||
codeburn currency --reset # back to USD
|
||||
```
|
||||
|
||||
Any [ISO 4217 currency code](https://en.wikipedia.org/wiki/ISO_4217#List_of_ISO_4217_currency_codes) is supported (162 currencies). Exchange rates fetched from [Frankfurter](https://www.frankfurter.app/) (European Central Bank data, free, no API key) and cached for 24 hours. Config stored at `~/.config/codeburn/config.json`. The currency setting applies everywhere: dashboard, status bar, menu bar, CSV/JSON exports, and JSON API output.
|
||||
|
||||
### Model Aliases
|
||||
|
||||
If you see `$0.00` for some models, the model name reported by your provider does not match any entry in the LiteLLM pricing data. This commonly happens when using a proxy that rewrites model names.
|
||||
|
||||
```bash
|
||||
codeburn model-alias "my-proxy-model" "claude-opus-4-6" # add alias
|
||||
codeburn model-alias --list # show configured aliases
|
||||
codeburn model-alias --remove "my-proxy-model" # remove alias
|
||||
```
|
||||
|
||||
Aliases are stored in `~/.config/codeburn/config.json` and applied at runtime before pricing lookup. The target name can be anything in the [LiteLLM model list](https://github.com/BerriAI/litellm/blob/main/model_prices_and_context_window.json) or a canonical name from the fallback table (e.g. `claude-sonnet-4-6`, `claude-opus-4-5`, `gpt-4o`). Built-in aliases ship for known proxy model name variants. User-configured aliases take precedence over built-ins.
|
||||
|
||||
### Local Models, Custom Prices, and Proxies
|
||||
|
||||
```bash
|
||||
codeburn price-override my-model --input 0.27 --output 1.10 # USD per 1M tokens
|
||||
codeburn model-savings "llama3.1:8b" gpt-4o # local model, counted as savings
|
||||
codeburn proxy-path ~/work/copilot-repo # subscription-covered project
|
||||
```
|
||||
|
||||
`price-override` sets exact rates for any model (input, output, cache read, cache creation), useful for private deployments or models LiteLLM prices wrong. `model-savings` maps a free local model to a paid baseline: the local calls stay $0, and the dashboard shows what the same tokens would have cost on the baseline. `proxy-path` marks a project routed through a subscription-backed proxy (e.g. Claude Code over GitHub Copilot), so its API-rate cost is reported as subscription-covered and your net out-of-pocket stays honest. All three support `--list` and `--remove`.
|
||||
|
||||
### Filtering
|
||||
|
||||
```bash
|
||||
codeburn report --project myapp # show only projects matching "myapp"
|
||||
codeburn report --exclude myapp # show everything except "myapp"
|
||||
codeburn report --exclude myapp --exclude tests # exclude multiple projects
|
||||
codeburn month --project api --project web # include multiple projects
|
||||
codeburn export --project inventory # export only "inventory" project data
|
||||
```
|
||||
|
||||
Filter by provider, project name (case-insensitive substring), or exact date range. The `--project` and `--exclude` flags work on all commands and can be combined with `--provider`.
|
||||
|
||||
```bash
|
||||
codeburn report --from 2026-04-01 --to 2026-04-10 # explicit window
|
||||
codeburn report --from 2026-04-01 # this date through today
|
||||
codeburn report --to 2026-04-10 # earliest data through this date
|
||||
```
|
||||
|
||||
Either flag alone is valid. Inverted or malformed dates exit with a clear error. In the TUI, the custom range sets the initial load only; pressing `1` through `5` switches back to predefined periods.
|
||||
|
||||
### JSON Output
|
||||
|
||||
`report`, `today`, and `month` support `--format json` to output the full dashboard data as structured JSON to stdout:
|
||||
|
||||
```bash
|
||||
codeburn report --format json # 7-day JSON report
|
||||
codeburn today --format json # today's data as JSON
|
||||
codeburn month --format json # this month as JSON
|
||||
codeburn report -p 30days --format json # 30-day window
|
||||
```
|
||||
|
||||
The JSON includes all dashboard panels: overview (cost, calls, sessions, cache hit %), daily breakdown, projects (with `avgCostPerSession`), models with token counts, activities with one-shot rates, core tools, MCP servers, and shell commands. Pipe to `jq` for filtering:
|
||||
|
||||
```bash
|
||||
codeburn report --format json | jq '.projects'
|
||||
codeburn today --format json | jq '.overview.cost'
|
||||
```
|
||||
|
||||
For lighter output, use `status --format json` (today and month totals only), `optimize --format json` (setup health, findings, and copy-paste fixes), `yield --format json` (productive/reverted/abandoned spend), or file exports (`export -f json`).
|
||||
|
||||
</details>
|
||||
|
||||
## Reading the dashboard
|
||||
|
||||
<details>
|
||||
<summary><strong>Signals and what they might mean</strong></summary>
|
||||
|
||||
CodeBurn surfaces the data, you read the story. A few patterns worth knowing:
|
||||
|
||||
| Signal you see | What it might mean |
|
||||
|---|---|
|
||||
| Cache hit < 80% | System prompt or context is not stable, or caching not enabled |
|
||||
| Lots of `Read` calls per session | Agent re-reading same files, missing context |
|
||||
| Low 1-shot rate (Coding 30%) | Agent struggling with edits, retry loops |
|
||||
| Opus 4.6 dominating cost on small turns | Overpowered model for simple tasks |
|
||||
| `dispatch_agent` / `task` heavy | Sub-agent fan-out, expected or excessive |
|
||||
| No MCP usage shown | Either you don't use MCP servers, or your config is broken |
|
||||
| Bash dominated by `git status`, `ls` | Agent exploring instead of executing |
|
||||
| Conversation category dominant | Agent talking instead of doing |
|
||||
|
||||
These are starting points, not verdicts. A 60% cache hit on a single experimental session is fine. A persistent 60% cache hit across weeks of work is a config issue.
|
||||
|
||||
</details>
|
||||
|
||||
## How it reads your data
|
||||
|
||||
<details>
|
||||
<summary><strong>Per-tool data locations and parsing</strong></summary>
|
||||
|
||||
| Provider | Data location | Notes |
|
||||
|----------|---------------|-------|
|
||||
| **Claude Code** | `~/.claude/projects/<sanitized-path>/<session-id>.jsonl` | Each assistant entry carries model name, token usage (input, output, cache read, cache write), `tool_use` blocks, and timestamps. |
|
||||
| **Claude (multiple config dirs)** | Set via `CLAUDE_CONFIG_DIRS` (e.g. `~/.claude-work:~/.claude-personal`) | Scans every listed directory and merges sessions into one row per project so totals reflect all your Claude usage. Use `:` on POSIX, `;` on Windows; overrides `CLAUDE_CONFIG_DIR`. Missing or unreadable directories are skipped. |
|
||||
| **Codex (OpenAI)** | `~/.codex/sessions/YYYY/MM/DD/rollout-*.jsonl` | Reads `token_count` events (per-call and cumulative usage) and `function_call` entries for tool tracking; attributes cost by project working directory. `codeburn report --provider codex` views Codex alone. |
|
||||
| **Cursor** | SQLite `state.vscdb` under `globalStorage`: macOS `~/Library/Application Support/Cursor/User/globalStorage/`, Linux `~/.config/Cursor/User/globalStorage/`, Windows `%APPDATA%/Cursor/User/globalStorage/`; results cached at `~/.cache/codeburn/cursor-results.json` | Input tokens come from Cursor's own per-conversation context meter (`composerData.promptTokenBreakdown`), credited once per conversation on a stable anchor; tool calls and shell commands are read from the agent stream (`agentKv`), and Composer house models are priced from Cursor's published rates. Output is a reply-text estimate and cache tokens are server-side only, so figures are marked estimated and undercount the Cursor admin console for long conversations. The cache auto-invalidates when the database changes; first run on a large database can take a minute. |
|
||||
| **OpenCode** | SQLite `~/.local/share/opencode/opencode*.db` (respects `XDG_DATA_HOME`) | Queries `session`, `message`, and `part` read-only and recalculates cost via LiteLLM (falling back to OpenCode's own cost field for unpriced models). Subtask sessions (`parent_id IS NOT NULL`) are excluded to avoid double counting; multiple channel databases are supported. |
|
||||
| **Gemini CLI** | `~/.gemini/tmp/<project>/chats/session-*.json` | One JSON file per session with real token counts (input, output, cached, thoughts) per message, so no estimation is needed. Input is reported inclusive of cached, so CodeBurn subtracts cached before pricing to avoid double charging. |
|
||||
| **Antigravity (CLI & IDE)** | Session files under `.gemini/` folders, plus the running language server | Pulls granular trajectory and pricing from the language server process. For the short-lived CLI, optionally install a status-line hook with `codeburn antigravity-hook install` so usage is captured between menubar refreshes. The IDE is detected via the `--app-data-dir antigravity-ide` flag on Windows. |
|
||||
| **GitHub Copilot** | `~/.copilot/session-state/` (legacy CLI); VS Code/VSCodium `workspaceStorage/*` chat sessions, `GitHub.copilot-chat/transcripts/`, and the `agent-traces.db` OpenTelemetry store; JetBrains IDEs (IntelliJ, PyCharm, …) under `~/.config/github-copilot/<ide>/<kind>/<storeId>/copilot-*-nitrite.db` | The OTel SQLite store is preferred when present (it carries real input/output/cache token counts). Other sources carry no explicit counts, so tokens are estimated from content length and the model is inferred from tool call ID prefixes. JetBrains sessions read from a Nitrite (H2 MVStore) `.db`; project comes from the plugin's `projectName` field (else the `.git` root of a referenced file). See [docs/providers/copilot.md](docs/providers/copilot.md). |
|
||||
| **Kiro** | `.chat` JSON files | Token counts are estimated from content length. The model is not exposed, so sessions are labeled `kiro-auto` and costed at Sonnet rates. |
|
||||
| **Mistral Vibe** | `~/.vibe/logs/session/` (or `$VIBE_HOME/logs/session/`); each folder has `meta.json` + `messages.jsonl` | Reads cumulative prompt/completion totals and model pricing from `meta.json`, then the first user prompt and tool calls from `messages.jsonl`. Emits one record per session (source data is cumulative, not per turn); subagent sessions under `agents/` are counted separately. |
|
||||
| **OpenClaw** | `~/.openclaw/agents/*.jsonl` (legacy `.clawdbot`, `.moltbot`, `.moldbot`) | Token usage comes from assistant message `usage` blocks; the model from `modelId` or `message.model`. |
|
||||
| **Warp** | `~/Library/Group Containers/2BBY89MBSN.dev.warp/Library/Application Support/dev.warp.Warp-Stable/warp.sqlite` (Preview fallback) | Reads `agent_conversations`, `ai_queries`, and `blocks`, emitting one call per finalized exchange. Exchange token share is estimated from prompt-size weighting normalized to conversation totals; `run_command` blocks attach to the nearest preceding exchange by timestamp. |
|
||||
| **Zed** | SQLite `~/Library/Application Support/Zed/threads/threads.db` (Linux `~/.local/share/zed/threads/`) | One row per agent thread; the blob is zstd-compressed JSON with per-request token usage (input, output, cache read, cache write) and the thread's model. Threads are topped up to the exact cumulative counter so totals match the store. Needs Node 22.15+ for built-in zstd. |
|
||||
| **Forge** | SQLite `~/.forge/.forge.db` | Queries `conversations` read-only and parses `context.messages`. Assistant usage entries provide prompt, completion, and cached counts; CodeBurn subtracts cached from prompt for input pricing, emits one call per assistant message, and extracts tool calls plus shell commands. |
|
||||
| **Pi / OMP** | `~/.pi/agent/sessions/<sanitized-cwd>/*.jsonl` (Pi), `~/.omp/agent/sessions/<sanitized-cwd>/*.jsonl` (OMP) | Each assistant message carries usage (input, output, cacheRead, cacheWrite) plus inline `toolCall` blocks. Tool names normalize to the standard set (`bash` → `Bash`, `dispatch_agent` → `Agent`); bash commands come from `toolCall.arguments.command`. |
|
||||
| **Codebuff** (formerly Manicode) | `~/.config/manicode/projects/<project>/chats/<chatId>/chat-messages.json` (honors `CODEBUFF_DATA_DIR`; walks `manicode-dev` / `manicode-staging`) | Bills in credits, so each completed assistant message is costed at the public rate of $0.01/credit via `msg.credits`. When an upstream provider's stashed RunState records token-level usage (`message.metadata.runState.sessionState.mainAgentState.messageHistory[*].providerOptions`), the real tokens and LiteLLM cost take precedence. Native tool names (`read_files`, `str_replace`, `run_terminal_command`, `spawn_agents`) normalize to `Read`, `Edit`, `Bash`, `Agent`. |
|
||||
| **Cline / Roo Code / KiloCode** | VS Code `globalStorage`: Cline at `saoudrizwan.claude-dev` and `~/.cline/data`; Roo Code and KiloCode across VS Code, VS Code Insiders, and VSCodium | Cline-family agents. CodeBurn reads `ui_messages.json` from each task directory, extracting token counts from `type: "say"` entries with `say: "api_req_started"`. |
|
||||
| **IBM Bob** | `User/globalStorage/ibm.bob-code/tasks/<task-id>/` (GA `IBM Bob` and preview `Bob-IDE` app folders) | Reads `ui_messages.json` for API request token/cost records and `api_conversation_history.json` for the selected model. |
|
||||
| **Kimi Code CLI** | `$KIMI_SHARE_DIR/sessions/<workdir-hash>/<session-id>/` or `~/.kimi/sessions/<workdir-hash>/<session-id>/` | Reads `wire.jsonl` `StatusUpdate.token_usage` records, mapping `input_other`, `input_cache_read`, `input_cache_creation`, and `output` into the standard token columns; includes subagents under each session's `subagents/` folder. |
|
||||
| **LingTai TUI** | `~/.lingtai/<agent>/logs/token_ledger.jsonl` plus project homes from `~/.lingtai-tui/registry.jsonl` (`<project>/.lingtai/<agent>/logs/token_ledger.jsonl`); honors `LINGTAI_HOME` / `LINGTAI_TUI_HOME` | Reads LingTai's append-only token ledger, mapping `input - cached` to fresh input, `cached` to cache reads, `output` to output, and `thinking` to reasoning. Nested daemon ledgers are skipped because parent ledgers already mirror daemon usage with `source`/`run_id` tags. |
|
||||
| **Vercel AI Gateway** | [Vercel AI Gateway reporting API](https://vercel.com/docs/ai-gateway/capabilities/custom-reporting) (cloud, not local logs) | Set `AI_GATEWAY_API_KEY` or `VERCEL_OIDC_TOKEN` (from `vercel env pull` / `vercel dev`); requires a Vercel plan with Custom Reporting. Without credentials it's skipped silently in the combined dashboard. |
|
||||
|
||||
CodeBurn deduplicates messages (by API message ID for Claude, by cumulative token cross-check for Codex, by conversation/timestamp for Cursor, by session ID for Gemini, by session+message ID for OpenCode, by responseId for Pi/OMP, by chat folder + message ID for Codebuff, by session+message ID for Kimi), filters by date range per entry, and classifies each turn.
|
||||
|
||||
</details>
|
||||
|
||||
## Environment Variables
|
||||
|
||||
<details>
|
||||
<summary><strong>Override data directories and paths</strong></summary>
|
||||
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `CLAUDE_CONFIG_DIR` | Override Claude Code data directory (default: `~/.claude`) |
|
||||
| `CLAUDE_CONFIG_DIRS` | OS-delimited list of Claude data directories to scan together (e.g. `~/.claude-work:~/.claude-personal`). Sessions merge into one row per project. Overrides `CLAUDE_CONFIG_DIR` when set. |
|
||||
| `CODEX_HOME` | Override Codex data directory (default: `~/.codex`) |
|
||||
| `CODEBUFF_DATA_DIR` | Override Codebuff data directory (default: `~/.config/manicode`) |
|
||||
| `FACTORY_DIR` | Override Droid data directory (default: `~/.factory`) |
|
||||
| `KIMI_SHARE_DIR` | Override Kimi Code CLI share directory (default: `~/.kimi`) |
|
||||
| `KIMI_MODEL_NAME` | Override Kimi model name when Kimi sessions do not record the model |
|
||||
| `LINGTAI_HOME` | Override LingTai data directory (default: `~/.lingtai`) |
|
||||
| `LINGTAI_TUI_HOME` | Alternate override for LingTai data directory; `LINGTAI_HOME` takes precedence |
|
||||
| `LINGTAI_TUI_GLOBAL_DIR` | Override LingTai TUI global directory used for project registry discovery (default: `~/.lingtai-tui`) |
|
||||
| `QWEN_DATA_DIR` | Override Qwen data directory (default: `~/.qwen/projects`) |
|
||||
| `VIBE_HOME` | Override Mistral Vibe home directory (default: `~/.vibe`) |
|
||||
| `WARP_DB_PATH` | Override Warp database path (default: Warp Stable, then Warp Preview) |
|
||||
|
||||
</details>
|
||||
|
||||
## Sponsoring CodeBurn
|
||||
|
||||
CodeBurn is free, runs entirely on your machine, and exists to cut your AI bill. If it has already saved you more than a sponsorship costs, consider sending a little of that back.
|
||||
|
||||
Keeping 30 integrations accurate is constant work. The tools underneath change every week: Cursor reshapes its database, Claude moves a config path, new models ship at new prices. Sponsorship keeps CodeBurn current with all of it, so the numbers you see are always the real ones.
|
||||
|
||||
Where your sponsorship goes:
|
||||
|
||||
- **Honest numbers.** New models and price changes mapped quickly, so your cost is the real cost, not a guess.
|
||||
- **More tools.** Every one of the 30 providers started as a single file. Sponsorship funds the next one.
|
||||
- **Fast fixes.** When a vendor breaks something, paid time is what gets it patched now instead of someday.
|
||||
|
||||
Sponsoring as a team or company? Your logo lands right here, in front of every developer who opens the repo. The first sponsor gets it to themselves until the next one shows up.
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/sponsors/iamtoruk"><img src="https://img.shields.io/badge/Sponsor_CodeBurn-♥-F97316?style=for-the-badge&logo=github&labelColor=1a1a1a" alt="Sponsor CodeBurn" /></a>
|
||||
</p>
|
||||
|
||||
## Star History
|
||||
|
||||
<a href="https://www.star-history.com/?repos=getagentseal%2Fcodeburn&type=date&legend=top-left">
|
||||
<picture>
|
||||
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=getagentseal/codeburn&type=date&theme=dark&legend=top-left" />
|
||||
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=getagentseal/codeburn&type=date&legend=top-left" />
|
||||
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=getagentseal/codeburn&type=date&legend=top-left" />
|
||||
</picture>
|
||||
</a>
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
CodeBurn is an AgentSeal open-source project and is not affiliated with CodeBurn Bt. or codeburn.hu.
|
||||
|
||||
## Credits
|
||||
|
||||
Pricing data from [LiteLLM](https://github.com/BerriAI/litellm). Exchange rates from [Frankfurter](https://www.frankfurter.app/).
|
||||
|
||||
Built by [AgentSeal](https://agentseal.org).
|
||||
@@ -0,0 +1,7 @@
|
||||
# WeHub 来源说明
|
||||
|
||||
- 原始项目:`getagentseal/codeburn`
|
||||
- 原始仓库:https://github.com/getagentseal/codeburn
|
||||
- 导入方式:上游默认分支的最新快照
|
||||
- 原作者、版权和许可证信息以原始仓库及本仓库 LICENSE 为准
|
||||
- 本文件仅用于记录来源,不代表 WeHub 是原项目作者
|
||||
@@ -0,0 +1,182 @@
|
||||
# Releasing CodeBurn
|
||||
|
||||
This document describes the actual steps a maintainer takes to cut a CLI or macOS menubar release. CLI releases are run by hand with `npm publish`; macOS menubar releases are automated by `.github/workflows/release-menubar.yml` when a `mac-v*` tag is pushed.
|
||||
|
||||
## Versioning
|
||||
|
||||
CodeBurn uses semantic versioning (major.minor.patch). The CLI and macOS menubar share the same version number for clarity.
|
||||
|
||||
## Before Every Release
|
||||
|
||||
Run the test suite to catch any regressions:
|
||||
|
||||
```bash
|
||||
npm test
|
||||
```
|
||||
|
||||
Verify that the build completes without errors:
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
## CLI Release Process
|
||||
|
||||
### 1. Update the Version
|
||||
|
||||
Edit `package.json` to bump the version number. Update both the `version` field at the top and the `package-lock.json` lockfile to match (npm handles this automatically):
|
||||
|
||||
```bash
|
||||
npm version <version>
|
||||
```
|
||||
|
||||
For example, `npm version 0.9.8` updates both files and creates a commit.
|
||||
|
||||
Alternatively, edit `package.json` by hand and run `npm install` to regenerate the lockfile with the new version.
|
||||
|
||||
### 2. Update the Changelog
|
||||
|
||||
Edit `CHANGELOG.md`. Move all changes from the "Unreleased" section into a new section with the version number and today's date:
|
||||
|
||||
```markdown
|
||||
## Unreleased
|
||||
|
||||
### ...
|
||||
|
||||
## 0.9.8 - 2026-05-10
|
||||
|
||||
### Added
|
||||
- Feature X
|
||||
|
||||
### Fixed
|
||||
- Bug Y
|
||||
```
|
||||
|
||||
Commit these changes:
|
||||
|
||||
```bash
|
||||
git add CHANGELOG.md package.json package-lock.json
|
||||
git commit -m "chore: bump to 0.9.8"
|
||||
```
|
||||
|
||||
### 3. Publish to npm
|
||||
|
||||
There is no GitHub Actions workflow for the CLI; the maintainer runs `npm publish` from a clean working tree:
|
||||
|
||||
```bash
|
||||
npm publish
|
||||
```
|
||||
|
||||
The `prepublishOnly` script in `package.json` runs `npm run build` first, which bundles the litellm pricing snapshot and then runs `tsup` to emit `dist/cli.js`.
|
||||
|
||||
If publishing for the first time on a new machine, run `npm login` first.
|
||||
|
||||
### 4. Tag the Release
|
||||
|
||||
After npm accepts the publish, tag the commit and push:
|
||||
|
||||
```bash
|
||||
git tag v0.9.8
|
||||
git push origin v0.9.8
|
||||
```
|
||||
|
||||
The tag is for human reference and to anchor the GitHub Release. No workflow runs on `v*` tags for the CLI today.
|
||||
|
||||
### 5. Verify npm Publication
|
||||
|
||||
```bash
|
||||
npm view codeburn version
|
||||
```
|
||||
|
||||
### 6. Create a GitHub Release
|
||||
|
||||
Use the GitHub CLI to create a release with notes from the changelog:
|
||||
|
||||
```bash
|
||||
gh release create v0.9.8 --title v0.9.8 --notes "$(sed -n '/^## 0.9.8/,/^## /p' CHANGELOG.md | head -n -1)"
|
||||
```
|
||||
|
||||
Or use the web interface to draft a release and copy the changelog section into the body.
|
||||
|
||||
## macOS Menubar Release Process
|
||||
|
||||
The macOS menubar is released separately with its own GitHub Release, but shares the same version number as the CLI.
|
||||
|
||||
### 1. Same Version Bump
|
||||
|
||||
Follow the same version bumping process as the CLI. Both `package.json` and `CHANGELOG.md` reflect the shared version.
|
||||
|
||||
### 2. Tag the macOS Release
|
||||
|
||||
After the CLI tag is published, create a separate tag for the menubar:
|
||||
|
||||
```bash
|
||||
git tag mac-v0.9.8
|
||||
git push origin mac-v0.9.8
|
||||
```
|
||||
|
||||
### 3. GitHub Actions Builds the Bundle
|
||||
|
||||
The `.github/workflows/release-menubar.yml` workflow automatically detects the `mac-v*` tag and:
|
||||
|
||||
1. Checks out the repo
|
||||
2. Runs `mac/Scripts/package-app.sh v0.9.8`
|
||||
3. Signs the app bundle (ad-hoc signing)
|
||||
4. Creates a zip file: `CodeBurnMenubar-v0.9.8.zip`
|
||||
5. Computes a SHA-256 checksum: `CodeBurnMenubar-v0.9.8.zip.sha256`
|
||||
6. Uploads both to a GitHub Release named "Menubar v0.9.8"
|
||||
|
||||
The script output on the build machine shows:
|
||||
|
||||
```
|
||||
✓ Built /path/mac/.build/dist/CodeBurnMenubar-v0.9.8.zip
|
||||
✓ Checksum /path/mac/.build/dist/CodeBurnMenubar-v0.9.8.zip.sha256
|
||||
<sha256-hash> CodeBurnMenubar-v0.9.8.zip
|
||||
```
|
||||
|
||||
No manual action is needed; the workflow handles everything.
|
||||
|
||||
### 4. Verify the Release
|
||||
|
||||
After the workflow completes, the GitHub Release page shows the zip and sha256 files. The installed CLI command `codeburn menubar --force` fetches the newest `mac-v*` menubar release that includes both assets, verifies the checksum and bundle identity, and installs it into `~/Applications`.
|
||||
|
||||
## Homebrew Core
|
||||
|
||||
CodeBurn is in homebrew-core. After publishing a new CLI version to npm, the homebrew-core formula is updated automatically by Homebrew's bot or can be bumped manually:
|
||||
|
||||
```bash
|
||||
brew bump-formula-pr codeburn --url "https://registry.npmjs.org/codeburn/-/codeburn-<VERSION>.tgz"
|
||||
```
|
||||
|
||||
Users install with `brew install codeburn` and upgrade with `brew upgrade codeburn`.
|
||||
|
||||
## Replacing Assets on an Existing Release
|
||||
|
||||
If a release is published with broken assets (e.g., a menubar zip with a build error), re-run the build and upload the fixed assets without creating a new tag.
|
||||
|
||||
Use `gh release upload` with the `--clobber` flag to overwrite existing files:
|
||||
|
||||
```bash
|
||||
# After re-running mac/Scripts/package-app.sh v0.9.8 to regenerate the zip and sha256
|
||||
gh release upload mac-v0.9.8 mac/.build/dist/CodeBurnMenubar-v0.9.8.zip --clobber
|
||||
gh release upload mac-v0.9.8 mac/.build/dist/CodeBurnMenubar-v0.9.8.zip.sha256 --clobber
|
||||
```
|
||||
|
||||
The GitHub Release page will now serve the fixed assets. The menubar installer selects the newest `mac-v*` release with `CodeBurnMenubar-v*.zip` plus its checksum, so users who run `codeburn menubar --force` after the replacement get the fixed version automatically.
|
||||
|
||||
## Rollback
|
||||
|
||||
If a released version has a critical bug, the fastest path is to fix the bug and cut a new patch release (e.g., 0.9.8 -> 0.9.9). Delete the broken tag locally and on GitHub if it has not yet been widely distributed:
|
||||
|
||||
```bash
|
||||
git tag -d v0.9.8
|
||||
git push origin --delete v0.9.8
|
||||
```
|
||||
|
||||
npm does not allow republishing to the same version. If you must unpublish from npm, use `npm unpublish codeburn@0.9.8 --force` (requires Owner role), but this is discouraged and all users who installed that version retain it.
|
||||
|
||||
For the menubar, tag a new mac-v0.9.9 and let the workflow build and upload it. Users will see the update pill in the menubar settings and upgrade automatically (or manually via `codeburn menubar --force`).
|
||||
|
||||
## Summary
|
||||
|
||||
The CLI release is manual: bump the version, update `CHANGELOG.md`, commit, run `npm publish`, then tag and create a GitHub Release. The macOS menubar release is automated: pushing a `mac-v*` tag fires `.github/workflows/release-menubar.yml`, which builds, signs, zips, and publishes the bundle. The homebrew-core formula is updated automatically or via `brew bump-formula-pr`.
|
||||
@@ -0,0 +1,21 @@
|
||||
# Security Policy
|
||||
|
||||
## Reporting a Vulnerability
|
||||
|
||||
Please report security vulnerabilities via [GitHub's private vulnerability reporting](https://github.com/getagentseal/codeburn/security/advisories/new).
|
||||
|
||||
Do not open a public issue for security vulnerabilities.
|
||||
|
||||
## Scope
|
||||
|
||||
Security reports are welcome for:
|
||||
|
||||
- The CLI (`src/`)
|
||||
- The menubar installer (`src/menubar-installer.ts`)
|
||||
- The macOS menubar app (`mac/`)
|
||||
- The desktop app (`desktop/`)
|
||||
- CI/CD workflows (`.github/workflows/`)
|
||||
|
||||
## Release Integrity
|
||||
|
||||
Menubar release assets include a `.sha256` checksum file. The installer verifies the checksum before extracting and launching the downloaded bundle.
|
||||
|
After Width: | Height: | Size: 381 KiB |
|
After Width: | Height: | Size: 497 KiB |
|
After Width: | Height: | Size: 226 KiB |
|
After Width: | Height: | Size: 326 KiB |
|
After Width: | Height: | Size: 300 KiB |
|
After Width: | Height: | Size: 760 KiB |
|
After Width: | Height: | Size: 490 KiB |
|
After Width: | Height: | Size: 231 KiB |
|
After Width: | Height: | Size: 602 KiB |
|
After Width: | Height: | Size: 900 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 12 KiB |
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Cline">
|
||||
<rect width="64" height="64" rx="14" fill="#0f2f2c"/>
|
||||
<path d="M45.5 42.2c-3.4 3.2-7.6 4.8-12.7 4.8-4.7 0-8.6-1.5-11.6-4.4-3-3-4.5-6.7-4.5-11.2s1.5-8.2 4.5-11.1c3-2.9 6.9-4.4 11.6-4.4 5.1 0 9.3 1.6 12.7 4.8l-5.2 5.8c-2-1.9-4.3-2.8-7-2.8-2.4 0-4.4.7-5.9 2.2-1.5 1.4-2.2 3.3-2.2 5.5 0 2.3.7 4.2 2.2 5.6 1.5 1.4 3.5 2.2 5.9 2.2 2.8 0 5.1-.9 7-2.8l5.2 5.8z" fill="#5eead4"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 473 B |
|
After Width: | Height: | Size: 4.0 KiB |
|
After Width: | Height: | Size: 46 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 8.3 KiB |
|
After Width: | Height: | Size: 67 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 18 KiB |
|
After Width: | Height: | Size: 32 KiB |
|
After Width: | Height: | Size: 5.2 KiB |
|
After Width: | Height: | Size: 4.5 KiB |
|
After Width: | Height: | Size: 26 KiB |
@@ -0,0 +1,6 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="IBM Bob">
|
||||
<rect width="64" height="64" rx="12" fill="#0F62FE"/>
|
||||
<path d="M14 19h36v5H14zm0 10h36v5H14zm0 10h36v5H14z" fill="#fff" opacity=".9"/>
|
||||
<circle cx="24" cy="32" r="4" fill="#0F62FE"/>
|
||||
<circle cx="40" cy="32" r="4" fill="#0F62FE"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 337 B |
|
After Width: | Height: | Size: 774 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Kimi">
|
||||
<rect width="64" height="64" rx="14" fill="#101820"/>
|
||||
<path d="M18 46V18h7v11.2L36 18h9L33 30.3 46.5 46h-9.2L25 31.4V46h-7z" fill="#C8F35A"/>
|
||||
<circle cx="48" cy="16" r="5" fill="#7EE787"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 292 B |
|
After Width: | Height: | Size: 1.2 KiB |
@@ -0,0 +1,12 @@
|
||||
<svg width="28" height="28" viewBox="0 0 28 28" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8.8147 5.35803H5.35791V8.46914H8.8147V5.35803Z" fill="black"/>
|
||||
<path d="M22.6419 5.35803H19.1851V8.46914H22.6419V5.35803Z" fill="black"/>
|
||||
<path d="M15.7283 15.7284H12.2715V18.8395H15.7283V15.7284Z" fill="black"/>
|
||||
<path d="M8.8147 15.7284H5.35791V18.8395H8.8147V15.7284Z" fill="black"/>
|
||||
<path d="M22.6419 15.7284H19.1851V18.8395H22.6419V15.7284Z" fill="black"/>
|
||||
<path d="M12.2715 8.81482H5.35791V11.9259H12.2715V8.81482Z" fill="black"/>
|
||||
<path d="M12.2718 19.1852H1.90137V22.2963H12.2718V19.1852Z" fill="black"/>
|
||||
<path d="M26.0989 19.1852H15.7285V22.2963H26.0989V19.1852Z" fill="black"/>
|
||||
<path d="M22.6419 12.2716H5.35791V15.3827H22.6419V12.2716Z" fill="black"/>
|
||||
<path d="M22.6421 8.81482H15.7285V11.9259H22.6421V8.81482Z" fill="black"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 849 B |
|
After Width: | Height: | Size: 9.2 KiB |
@@ -0,0 +1,16 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 90" width="120" height="90">
|
||||
<!-- Pi symbol with plugin connector -->
|
||||
<!-- Horizontal bar -->
|
||||
<rect x="10" y="8" width="100" height="12" rx="2" fill="#fafafa"/>
|
||||
<!-- Left leg -->
|
||||
<rect x="25" y="20" width="12" height="62" rx="2" fill="#fafafa"/>
|
||||
<!-- Right leg -->
|
||||
<rect x="75" y="20" width="12" height="45" rx="2" fill="#fafafa"/>
|
||||
<!-- Plugin connector -->
|
||||
<rect x="71" y="55" width="20" height="16" rx="3" fill="#f97316"/>
|
||||
<rect x="76" y="59" width="3" height="8" rx="1" fill="#0d0d0d"/>
|
||||
<rect x="82" y="59" width="3" height="8" rx="1" fill="#0d0d0d"/>
|
||||
<!-- Decorative dots -->
|
||||
<circle cx="18" cy="14" r="2" fill="#f97316" opacity="0.8"/>
|
||||
<circle cx="102" cy="14" r="2" fill="#f97316" opacity="0.8"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 795 B |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 420 B |
|
After Width: | Height: | Size: 316 B |
|
After Width: | Height: | Size: 16 KiB |
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 8.1 KiB |
|
After Width: | Height: | Size: 5.0 KiB |
|
After Width: | Height: | Size: 6.8 KiB |
|
After Width: | Height: | Size: 4.8 KiB |
|
After Width: | Height: | Size: 11 KiB |
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<link rel="icon" type="image/png" href="/codeburn-logo.png" />
|
||||
<title>CodeBurn - Local Dashboard</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "codeburn-dash",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.62.7",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.468.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"recharts": "^3.3.0",
|
||||
"tailwind-merge": "^3.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.0.13",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"tailwindcss": "^4.0.13",
|
||||
"typescript": "^5.7.2",
|
||||
"vite": "^6.0.5"
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 74 KiB |
@@ -0,0 +1,724 @@
|
||||
import { useEffect, useMemo, useState, type ReactNode } from 'react'
|
||||
import { useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
import {
|
||||
approvePairing,
|
||||
fetchDevices,
|
||||
PERIODS,
|
||||
shareStatus,
|
||||
startShare,
|
||||
stopShare,
|
||||
type DeviceUsage,
|
||||
type Payload,
|
||||
type Period,
|
||||
} from '@/lib/api'
|
||||
import { cn, fmtNum, fmtTokens, usd } from '@/lib/utils'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { MetricCard } from '@/components/MetricCard'
|
||||
import { BarList, type BarItem } from '@/components/BarList'
|
||||
import { DataTable } from '@/components/DataTable'
|
||||
import { UsageChart, DeviceUsageChart, type Unit } from '@/components/UsageChart'
|
||||
import { DeviceSearchModal } from '@/components/DeviceSearchModal'
|
||||
import { ContextExplorer } from '@/components/ContextExplorer'
|
||||
|
||||
const n = (v: number | undefined): number => v ?? 0
|
||||
|
||||
function Panel({ title, children }: { title: string; children: ReactNode }) {
|
||||
return (
|
||||
<Card className="px-5 py-4">
|
||||
<h2 className="mb-3.5 text-[11px] font-semibold uppercase tracking-[0.12em] text-heading">{title}</h2>
|
||||
{children}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function SideLink({ active, onClick, children }: { active: boolean; onClick: () => void; children: ReactNode }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'flex items-center gap-2 rounded-md px-2.5 py-1.5 text-left text-[13.5px] transition-colors max-md:min-h-9',
|
||||
active ? 'bg-interactive-secondary font-medium text-foreground' : 'font-light text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
<span className={cn('h-1.5 w-1.5 shrink-0 rounded-full', active ? 'bg-primary' : 'bg-transparent')} />
|
||||
<span className="truncate">{children}</span>
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
function Switch({ on }: { on: boolean }) {
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
'relative inline-flex h-4 w-7 shrink-0 items-center rounded-full transition-colors',
|
||||
on ? 'bg-primary' : 'bg-interactive-secondary ring-1 ring-inset ring-border',
|
||||
)}
|
||||
>
|
||||
<span className={cn('inline-block h-3 w-3 transform rounded-full bg-card shadow-sm transition-transform', on ? 'translate-x-3.5' : 'translate-x-0.5')} />
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
function Stat({ label: lbl, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<span className="text-tertiary-foreground">{lbl}</span>
|
||||
<span className="tabular-nums text-foreground">{value}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// One device's full dashboard. Remote devices arrive sanitized, so their
|
||||
// project and session detail is intentionally absent.
|
||||
function DeviceView({ payload, isRemote, unit }: { payload?: Payload; isRemote: boolean; unit: Unit }) {
|
||||
const c = payload?.current
|
||||
// Cache cards read the period-scoped `current` totals, matching Cost/Calls/
|
||||
// Tokens. `history.daily` is the 365-day backfill that feeds the trend chart
|
||||
// only; summing it here over-counted the cards for shorter periods (#583).
|
||||
const cacheWrite = c?.cacheWriteTokens ?? 0
|
||||
const cacheRead = c?.cacheReadTokens ?? 0
|
||||
const toolBars: BarItem[] = c
|
||||
? Object.entries(c.providers).filter(([, v]) => v > 0).sort((a, b) => b[1] - a[1]).map(([k, v]) => ({ name: k, value: v, display: usd(v) }))
|
||||
: []
|
||||
const modelBars: BarItem[] = c
|
||||
? c.topModels.filter((m) => m.cost > 0).slice(0, 8).map((m) => ({ name: m.name, value: m.cost, display: usd(m.cost) }))
|
||||
: []
|
||||
const activityBars: BarItem[] = c
|
||||
? c.topActivities.filter((a) => a.cost > 0).map((a) => ({ name: a.name, value: a.cost, display: usd(a.cost) }))
|
||||
: []
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="mb-3 overflow-hidden">
|
||||
<div className="flex items-end justify-between px-5 pt-4">
|
||||
<div>
|
||||
<div className="text-xs text-tertiary-foreground">
|
||||
{c ? `${fmtNum(c.calls)} calls · ${fmtNum(c.sessions)} sessions` : ' '}
|
||||
</div>
|
||||
<div className="mt-1 font-display text-4xl tracking-tight tabular-nums text-primary">
|
||||
{c ? (unit === 'tokens' ? fmtTokens(c.inputTokens + c.outputTokens) : usd(c.cost)) : <Skeleton className="h-10 w-36" />}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 h-64 px-2 pb-2">
|
||||
{!payload ? <Skeleton className="mx-3 mb-3 h-[228px]" /> : <UsageChart daily={payload.history.daily} unit={unit} />}
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="mb-3 grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
{c ? (
|
||||
<>
|
||||
<MetricCard label="Cost" value={usd(c.cost)} accent />
|
||||
<MetricCard
|
||||
label="Tokens"
|
||||
value={fmtTokens(c.inputTokens + c.outputTokens)}
|
||||
sub={`in ${fmtTokens(c.inputTokens)} / out ${fmtTokens(c.outputTokens)}`}
|
||||
/>
|
||||
<MetricCard label="Calls" value={fmtNum(c.calls)} />
|
||||
<MetricCard label="Sessions" value={fmtNum(c.sessions)} />
|
||||
<MetricCard label="Cache hit" value={`${(c.cacheHitPercent || 0).toFixed(1)}%`} />
|
||||
<MetricCard label="Cache write" value={fmtTokens(cacheWrite)} />
|
||||
<MetricCard label="Cache read" value={fmtTokens(cacheRead)} />
|
||||
<MetricCard label="One-shot" value={c.oneShotRate == null ? '—' : `${Math.round(c.oneShotRate * 100)}%`} />
|
||||
</>
|
||||
) : (
|
||||
Array.from({ length: 8 }).map((_, i) => <Skeleton key={i} className="h-20" />)
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-3 grid gap-3 lg:grid-cols-2">
|
||||
<Panel title="By tool">
|
||||
<BarList items={toolBars} total={c?.cost} />
|
||||
</Panel>
|
||||
<Panel title="Top models">
|
||||
<BarList items={modelBars} total={c?.cost} />
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<div className="mb-3 grid gap-3 lg:grid-cols-2">
|
||||
<Panel title="Top projects">
|
||||
{isRemote ? (
|
||||
<p className="py-6 text-center text-sm text-tertiary-foreground">
|
||||
Project and session detail stays on that device. Only totals are shared.
|
||||
</p>
|
||||
) : (
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'name', label: 'Project' },
|
||||
{ key: 'cost', label: 'Cost', num: true },
|
||||
{ key: 'sessions', label: 'Sessions', num: true },
|
||||
]}
|
||||
rows={(c?.topProjects ?? []).slice(0, 10).map((p) => ({
|
||||
name: p.name,
|
||||
cost: usd(p.cost),
|
||||
sessions: fmtNum(p.sessions),
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
</Panel>
|
||||
<Panel title="By activity">
|
||||
<BarList items={activityBars} total={c?.cost} />
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<div className="mb-3 grid gap-3 lg:grid-cols-2">
|
||||
<Panel title="Subagents">
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'name', label: 'Subagent' },
|
||||
{ key: 'calls', label: 'Calls', num: true },
|
||||
{ key: 'cost', label: 'Cost', num: true },
|
||||
]}
|
||||
rows={(c?.subagents ?? []).slice(0, 10).map((s) => ({ name: s.name, calls: fmtNum(s.calls), cost: usd(s.cost) }))}
|
||||
/>
|
||||
</Panel>
|
||||
<Panel title="Skills">
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'name', label: 'Skill' },
|
||||
{ key: 'turns', label: 'Turns', num: true },
|
||||
{ key: 'cost', label: 'Cost', num: true },
|
||||
]}
|
||||
rows={(c?.skills ?? []).slice(0, 10).map((s) => ({ name: s.name, turns: fmtNum(s.turns), cost: usd(s.cost) }))}
|
||||
/>
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<div className="mb-3 grid gap-3 lg:grid-cols-2">
|
||||
<Panel title="MCP servers">
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'name', label: 'Server' },
|
||||
{ key: 'calls', label: 'Calls', num: true },
|
||||
]}
|
||||
rows={(c?.mcpServers ?? []).slice(0, 10).map((m) => ({ name: m.name, calls: fmtNum(m.calls) }))}
|
||||
/>
|
||||
</Panel>
|
||||
<Panel title="Savings & waste">
|
||||
{c ? (
|
||||
<div className="flex flex-col gap-3 py-1">
|
||||
<Stat label="Local-model savings" value={usd(c.localModelSavings?.totalUSD)} />
|
||||
<Stat
|
||||
label={`Retry tax${c.retryTax?.retries ? ` (${fmtNum(c.retryTax.retries)} retries)` : ''}`}
|
||||
value={usd(c.retryTax?.totalUSD)}
|
||||
/>
|
||||
<Stat label="Routing waste (potential)" value={usd(c.routingWaste?.totalSavingsUSD)} />
|
||||
</div>
|
||||
) : (
|
||||
<Skeleton className="h-20" />
|
||||
)}
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<Panel title="Tools">
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'name', label: 'Tool' },
|
||||
{ key: 'calls', label: 'Calls', num: true },
|
||||
]}
|
||||
rows={(c?.tools ?? []).slice(0, 14).map((t) => ({ name: t.name, calls: fmtNum(t.calls) }))}
|
||||
/>
|
||||
</Panel>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// The "All devices" view: combined totals plus a per-device breakdown. Devices
|
||||
// are summed for display only; nothing is merged on the server.
|
||||
function CombinedView({ devices, unit }: { devices: DeviceUsage[]; unit: Unit }) {
|
||||
const rows = devices.map((d) => {
|
||||
const c = d.payload?.current
|
||||
return {
|
||||
name: d.name,
|
||||
local: d.local,
|
||||
cost: n(c?.cost),
|
||||
tokens: n(c?.inputTokens) + n(c?.outputTokens),
|
||||
calls: n(c?.calls),
|
||||
sessions: n(c?.sessions),
|
||||
error: d.error,
|
||||
}
|
||||
})
|
||||
const total = rows.reduce(
|
||||
(a, r) => ({ cost: a.cost + r.cost, tokens: a.tokens + r.tokens, calls: a.calls + r.calls, sessions: a.sessions + r.sessions }),
|
||||
{ cost: 0, tokens: 0, calls: 0, sessions: 0 },
|
||||
)
|
||||
const reachable = devices.filter((d) => d.payload).length
|
||||
|
||||
const providers = new Map<string, number>()
|
||||
const models = new Map<string, number>()
|
||||
const activities = new Map<string, number>()
|
||||
let inTok = 0
|
||||
let outTok = 0
|
||||
let cacheWrite = 0
|
||||
let cacheRead = 0
|
||||
for (const d of devices) {
|
||||
const c = d.payload?.current
|
||||
if (!c) continue
|
||||
inTok += c.inputTokens
|
||||
outTok += c.outputTokens
|
||||
// Period-scoped per device (was summing each device's 365-day backfill, #583).
|
||||
// `?? 0` mirrors DeviceView and guards the un-normalized bootstrap payload,
|
||||
// where an older peer may not carry these fields yet (avoids NaN).
|
||||
cacheWrite += c.cacheWriteTokens ?? 0
|
||||
cacheRead += c.cacheReadTokens ?? 0
|
||||
for (const [k, v] of Object.entries(c.providers)) providers.set(k, (providers.get(k) ?? 0) + v)
|
||||
for (const m of c.topModels) models.set(m.name, (models.get(m.name) ?? 0) + m.cost)
|
||||
for (const a of c.topActivities) activities.set(a.name, (activities.get(a.name) ?? 0) + a.cost)
|
||||
}
|
||||
const toolBars: BarItem[] = [...providers.entries()]
|
||||
.filter(([, v]) => v > 0)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([k, v]) => ({ name: k, value: v, display: usd(v) }))
|
||||
const modelBars: BarItem[] = [...models.entries()]
|
||||
.filter(([, v]) => v > 0)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.slice(0, 8)
|
||||
.map(([k, v]) => ({ name: k, value: v, display: usd(v) }))
|
||||
const taskBars: BarItem[] = [...activities.entries()]
|
||||
.filter(([, v]) => v > 0)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([k, v]) => ({ name: k, value: v, display: usd(v) }))
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card className="mb-3 overflow-hidden">
|
||||
<div className="flex items-end justify-between px-5 pt-4">
|
||||
<div>
|
||||
<div className="text-xs text-tertiary-foreground">{`${reachable} device${reachable === 1 ? '' : 's'} · ${fmtNum(total.calls)} calls`}</div>
|
||||
<div className="mt-1 font-display text-4xl tracking-tight tabular-nums text-primary">
|
||||
{unit === 'tokens' ? fmtTokens(total.tokens) : usd(total.cost)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 h-64 px-2 pb-2">
|
||||
<DeviceUsageChart devices={devices} unit={unit} />
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<div className="mb-3 grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<MetricCard label="Total cost" value={usd(total.cost)} accent />
|
||||
<MetricCard label="Tokens" value={fmtTokens(total.tokens)} sub={`in ${fmtTokens(inTok)} / out ${fmtTokens(outTok)}`} />
|
||||
<MetricCard label="Calls" value={fmtNum(total.calls)} />
|
||||
<MetricCard label="Sessions" value={fmtNum(total.sessions)} />
|
||||
<MetricCard label="Cache write" value={fmtTokens(cacheWrite)} />
|
||||
<MetricCard label="Cache read" value={fmtTokens(cacheRead)} />
|
||||
<MetricCard label="Devices" value={String(reachable)} />
|
||||
</div>
|
||||
|
||||
<Panel title="By device">
|
||||
<DataTable
|
||||
columns={[
|
||||
{ key: 'device', label: 'Device' },
|
||||
{ key: 'cost', label: 'Cost', num: true },
|
||||
{ key: 'tokens', label: 'Tokens', num: true },
|
||||
{ key: 'calls', label: 'Calls', num: true },
|
||||
{ key: 'sessions', label: 'Sessions', num: true },
|
||||
]}
|
||||
rows={rows.map((r) => ({
|
||||
device: r.name + (r.local ? ' · this Mac' : ''),
|
||||
cost: r.error ? <span className="text-tertiary-foreground">unreachable</span> : usd(r.cost),
|
||||
tokens: r.error ? '—' : fmtTokens(r.tokens),
|
||||
calls: r.error ? '—' : fmtNum(r.calls),
|
||||
sessions: r.error ? '—' : fmtNum(r.sessions),
|
||||
}))}
|
||||
/>
|
||||
</Panel>
|
||||
|
||||
<div className="mt-3 grid gap-3 lg:grid-cols-2">
|
||||
<Panel title="By task (all devices)">
|
||||
<BarList items={taskBars} total={total.cost} />
|
||||
</Panel>
|
||||
<Panel title="By tool (all devices)">
|
||||
<BarList items={toolBars} total={total.cost} />
|
||||
</Panel>
|
||||
</div>
|
||||
|
||||
<div className="mt-3">
|
||||
<Panel title="Top models (all devices)">
|
||||
<BarList items={modelBars} total={total.cost} />
|
||||
</Panel>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const [page, setPage] = useState<'usage' | 'context'>('usage')
|
||||
const [period, setPeriod] = useState<Period>('today')
|
||||
const [provider, setProvider] = useState('all')
|
||||
const [view, setView] = useState<string>('all')
|
||||
const [unit, setUnit] = useState<Unit>('cost')
|
||||
const [searchOpen, setSearchOpen] = useState(false)
|
||||
// Mobile only: the sidebar collapses to an off-canvas drawer below md.
|
||||
// On desktop this flag is inert (the max-md: transform classes don't apply).
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false)
|
||||
const [responded, setResponded] = useState<Set<string>>(new Set())
|
||||
|
||||
const qc = useQueryClient()
|
||||
|
||||
const { data, isError, error, refetch } = useQuery({
|
||||
queryKey: ['devices', period, provider],
|
||||
queryFn: () => fetchDevices(period, provider),
|
||||
initialData: () => (period === 'today' && provider === 'all' ? window.__CODEBURN_BOOTSTRAP__ : undefined),
|
||||
// Bootstrap paints instantly but is stale by definition, so refetch at once
|
||||
// (the default 30s staleTime would otherwise hide a live peer until then).
|
||||
initialDataUpdatedAt: 0,
|
||||
// When devices are paired, re-pull periodically so a device that briefly
|
||||
// dropped (asleep/network blip) reappears on its own instead of staying
|
||||
// gone until you switch tabs.
|
||||
refetchInterval: (q) => ((q.state.data?.devices?.some((d) => !d.local) ?? false) ? 20000 : false),
|
||||
})
|
||||
|
||||
const { data: shareInfo } = useQuery({
|
||||
queryKey: ['share'],
|
||||
queryFn: shareStatus,
|
||||
refetchInterval: (q) => (q.state.data?.sharing ? 2500 : 8000),
|
||||
})
|
||||
|
||||
const refreshShare = () => qc.invalidateQueries({ queryKey: ['share'] })
|
||||
const toggleShare = async () => {
|
||||
if (shareInfo?.sharing) await stopShare()
|
||||
else await startShare(shareInfo?.always ?? false)
|
||||
refreshShare()
|
||||
}
|
||||
const toggleAlways = async () => {
|
||||
await startShare(!(shareInfo?.always ?? false))
|
||||
refreshShare()
|
||||
}
|
||||
const respondPairing = async (id: string, approve: boolean) => {
|
||||
setResponded((s) => new Set(s).add(id)) // drop it from the prompt at once so it can't be double-clicked
|
||||
await approvePairing(id, approve)
|
||||
refreshShare()
|
||||
void refetch()
|
||||
}
|
||||
const pending = (shareInfo?.pending ?? []).filter((p) => !responded.has(p.id))
|
||||
|
||||
// Only show devices we could actually reach; an unreachable paired device is
|
||||
// hidden entirely rather than shown as an error row.
|
||||
const devices = (data?.devices ?? []).filter((d) => d.payload)
|
||||
const local = devices.find((d) => d.local)
|
||||
const multi = devices.some((d) => !d.local)
|
||||
const viewing = view === 'all' ? undefined : devices.find((d) => d.id === view)
|
||||
const primary = viewing ?? local
|
||||
const c0 = primary?.payload?.current
|
||||
|
||||
const providerOptions = useMemo(
|
||||
() =>
|
||||
c0
|
||||
? Object.entries(c0.providers)
|
||||
.filter(([, v]) => v > 0)
|
||||
.sort((a, b) => b[1] - a[1])
|
||||
.map(([k]) => k)
|
||||
: [],
|
||||
[c0],
|
||||
)
|
||||
|
||||
// If the device you're viewing drops off (slept/unreachable), fall back to
|
||||
// All devices instead of showing an empty panel with nothing selected.
|
||||
useEffect(() => {
|
||||
if (view !== 'all' && data && !devices.some((d) => d.id === view)) setView('all')
|
||||
}, [view, devices, data])
|
||||
|
||||
// If the selected provider isn't present on the current view, reset to all
|
||||
// (otherwise a healthy device shows empty under a filter it has no data for).
|
||||
useEffect(() => {
|
||||
if (provider !== 'all' && c0 && !providerOptions.includes(provider)) setProvider('all')
|
||||
}, [provider, providerOptions, c0])
|
||||
|
||||
const showCombined = multi && view === 'all'
|
||||
const viewTitle = showCombined ? 'All devices' : (primary ? primary.name + (primary.local ? ' · this Mac' : '') : 'Loading…')
|
||||
const label = local?.payload?.current?.label ?? ''
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-outer-background p-2.5 max-md:min-h-[100dvh]">
|
||||
<div className="flex h-[calc(100vh-20px)] flex-col gap-2.5 max-md:h-[calc(100dvh-20px)]">
|
||||
<header className="flex h-12 shrink-0 items-center gap-4 rounded-md border border-border bg-card px-5 shadow-[0_2px_8px_rgba(0,0,0,0.03)] max-md:gap-3 max-md:px-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSidebarOpen(true)}
|
||||
aria-label="Open menu"
|
||||
aria-expanded={sidebarOpen}
|
||||
aria-controls="dashboard-sidebar"
|
||||
className="-ml-0.5 flex h-9 w-9 shrink-0 items-center justify-center rounded-md text-foreground transition-colors hover:bg-interactive-secondary md:hidden"
|
||||
>
|
||||
<svg viewBox="0 0 16 16" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round">
|
||||
<path d="M2.5 4.5h11M2.5 8h11M2.5 11.5h11" />
|
||||
</svg>
|
||||
</button>
|
||||
<div className="flex items-center gap-2 max-md:shrink-0">
|
||||
<img src="/codeburn-logo.png" alt="CodeBurn" className="h-6 w-6" />
|
||||
<span className="text-lg font-semibold tracking-[-0.02em] text-foreground">
|
||||
Code<span className="text-[#e8553a]">Burn</span>
|
||||
</span>
|
||||
<span className="ml-1 text-[11px] font-light uppercase tracking-[0.14em] text-tertiary-foreground max-sm:hidden">usage</span>
|
||||
</div>
|
||||
|
||||
<div className="ml-6 flex rounded-md border border-border bg-interactive-secondary p-0.5 max-md:ml-2 max-md:shrink-0">
|
||||
{(['usage', 'context'] as const).map((pg) => (
|
||||
<button
|
||||
key={pg}
|
||||
type="button"
|
||||
onClick={() => setPage(pg)}
|
||||
className={cn(
|
||||
'rounded-[5px] px-3 py-1 text-xs font-medium transition-colors',
|
||||
page === pg ? 'bg-active-primary text-foreground shadow-sm' : 'text-tertiary-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{pg === 'usage' ? 'Usage' : 'Context'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2 max-md:min-w-0 max-md:overflow-x-auto max-md:[-ms-overflow-style:none] max-md:[scrollbar-width:none] max-md:[&::-webkit-scrollbar]:hidden">
|
||||
{page === 'usage' && (
|
||||
<>
|
||||
<div className="flex rounded-md border border-border bg-interactive-secondary p-0.5 max-md:shrink-0">
|
||||
{PERIODS.map((p) => (
|
||||
<button
|
||||
key={p.key}
|
||||
type="button"
|
||||
onClick={() => setPeriod(p.key)}
|
||||
className={cn(
|
||||
'rounded-[5px] px-3 py-1 text-xs font-medium transition-colors max-md:inline-flex max-md:min-h-9 max-md:items-center max-md:justify-center',
|
||||
period === p.key ? 'bg-active-primary text-foreground shadow-sm' : 'text-tertiary-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex rounded-md border border-border bg-interactive-secondary p-0.5 max-md:shrink-0">
|
||||
{(['cost', 'tokens'] as Unit[]).map((u) => (
|
||||
<button
|
||||
key={u}
|
||||
type="button"
|
||||
onClick={() => setUnit(u)}
|
||||
className={cn(
|
||||
'rounded-[5px] px-3 py-1 text-xs font-medium transition-colors max-md:inline-flex max-md:min-h-9 max-md:items-center max-md:justify-center',
|
||||
unit === u ? 'bg-active-primary text-foreground shadow-sm' : 'text-tertiary-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{u === 'cost' ? 'Cost' : 'Tokens'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<select
|
||||
value={provider}
|
||||
onChange={(e) => setProvider(e.target.value)}
|
||||
className="rounded-md border border-border bg-card px-3 py-1.5 text-xs text-foreground outline-none max-md:min-h-9 max-md:shrink-0"
|
||||
>
|
||||
<option value="all">All tools</option>
|
||||
{providerOptions.map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{p}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="flex min-h-0 flex-1 gap-2.5">
|
||||
{sidebarOpen && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close menu"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className="fixed inset-0 z-30 bg-black/40 md:hidden"
|
||||
/>
|
||||
)}
|
||||
<aside
|
||||
id="dashboard-sidebar"
|
||||
className={cn(
|
||||
'flex w-60 shrink-0 flex-col gap-5 overflow-y-auto rounded-md border border-border bg-card p-5',
|
||||
'max-md:fixed max-md:inset-y-0 max-md:left-0 max-md:z-40 max-md:rounded-none max-md:shadow-2xl max-md:transition-[transform,visibility] max-md:duration-200 max-md:ease-out',
|
||||
// Closed below md: slide off-canvas AND go visibility:hidden so its
|
||||
// links leave the tab order / a11y tree (not just visually hidden).
|
||||
sidebarOpen ? 'max-md:visible max-md:translate-x-0' : 'max-md:invisible max-md:-translate-x-full',
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close menu"
|
||||
onClick={() => setSidebarOpen(false)}
|
||||
className="absolute right-3 top-3 flex h-9 w-9 items-center justify-center rounded-md text-tertiary-foreground transition-colors hover:bg-interactive-secondary hover:text-foreground md:hidden"
|
||||
>
|
||||
<svg viewBox="0 0 16 16" width="15" height="15" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round">
|
||||
<path d="M4 4l8 8M12 4l-8 8" />
|
||||
</svg>
|
||||
</button>
|
||||
{page === 'usage' && (
|
||||
<>
|
||||
<div className="flex flex-col gap-1">
|
||||
<p className="mb-1 px-2.5 text-[11px] font-semibold uppercase tracking-[0.12em] text-heading">Devices</p>
|
||||
{multi && (
|
||||
<SideLink active={view === 'all'} onClick={() => { setView('all'); setSidebarOpen(false) }}>
|
||||
All devices
|
||||
</SideLink>
|
||||
)}
|
||||
{devices.map((d) => (
|
||||
<SideLink
|
||||
key={d.id}
|
||||
active={view === d.id || (!multi && view === 'all' && d.local)}
|
||||
onClick={() => { setView(d.id); setSidebarOpen(false) }}
|
||||
>
|
||||
{d.name}
|
||||
{d.local ? ' · this Mac' : ''}
|
||||
</SideLink>
|
||||
))}
|
||||
{devices.length === 0 && <p className="px-2.5 py-1 text-xs text-tertiary-foreground">Loading…</p>}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setSearchOpen(true); setSidebarOpen(false) }}
|
||||
className="flex items-center justify-center gap-2 rounded-md border border-border px-3 py-2 text-xs font-medium text-foreground transition-colors hover:bg-interactive-secondary max-md:min-h-9"
|
||||
>
|
||||
<svg viewBox="0 0 16 16" width="14" height="14" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round">
|
||||
<circle cx="7" cy="7" r="4.5" />
|
||||
<path d="M10.5 10.5L14 14" />
|
||||
</svg>
|
||||
Search local devices
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="border-t border-border pt-4">
|
||||
<p className="mb-2 px-2.5 text-[11px] font-semibold uppercase tracking-[0.12em] text-heading">Share</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void toggleShare()}
|
||||
className="flex w-full items-center justify-between rounded-md px-2.5 py-1.5 text-[13.5px] text-foreground transition-colors hover:bg-interactive-secondary max-md:min-h-9"
|
||||
>
|
||||
<span>Share this device</span>
|
||||
<Switch on={!!shareInfo?.sharing} />
|
||||
</button>
|
||||
{shareInfo?.sharing && (
|
||||
<div className="mt-1.5 px-2.5">
|
||||
<p className="text-[11px] leading-relaxed text-tertiary-foreground">
|
||||
Discoverable as “{shareInfo.name}” · {shareInfo.peers} paired
|
||||
</p>
|
||||
<label className="mt-2 flex cursor-pointer items-center gap-2 text-xs text-muted-foreground">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={shareInfo.always}
|
||||
onChange={() => void toggleAlways()}
|
||||
className="h-3.5 w-3.5 accent-[#1f8a5b]"
|
||||
/>
|
||||
Keep sharing always
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-auto border-t border-border pt-4">
|
||||
<p className="text-[11px] leading-relaxed text-tertiary-foreground">
|
||||
Local only. Nothing leaves your machine; only totals are shared between your devices.
|
||||
</p>
|
||||
<div className="mt-3 flex items-center gap-1">
|
||||
<a
|
||||
href="https://codeburn.app/"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title="codeburn.app"
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md text-tertiary-foreground transition-colors hover:bg-interactive-secondary hover:text-foreground"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" strokeWidth="1.6">
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M3 12h18" />
|
||||
<path d="M12 3c2.5 2.7 2.5 15.3 0 18M12 3c-2.5 2.7-2.5 15.3 0 18" />
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
href="https://discord.com/invite/w2sw8mCqep"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title="Discord"
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md text-tertiary-foreground transition-colors hover:bg-interactive-secondary hover:text-foreground"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" fill="currentColor">
|
||||
<path d="M20.317 4.369A19.79 19.79 0 0 0 16.558 3c-.2.36-.43.85-.59 1.23a18.27 18.27 0 0 0-5.93 0A12.6 12.6 0 0 0 9.44 3 19.7 19.7 0 0 0 5.68 4.37C2.9 8.46 2.14 12.45 2.52 16.38a19.9 19.9 0 0 0 6.07 3.08c.49-.67.93-1.38 1.3-2.13-.71-.27-1.4-.6-2.04-.99.17-.13.34-.26.5-.4 3.93 1.84 8.18 1.84 12.06 0 .17.14.33.27.5.4-.65.39-1.33.72-2.05.99.38.75.81 1.46 1.3 2.13a19.9 19.9 0 0 0 6.07-3.08c.45-4.55-.77-8.5-3.2-12.01zM9.69 14.5c-1.18 0-2.15-1.08-2.15-2.42 0-1.33.95-2.42 2.15-2.42 1.2 0 2.17 1.09 2.15 2.42 0 1.34-.95 2.42-2.15 2.42zm4.62 0c-1.18 0-2.15-1.08-2.15-2.42 0-1.33.95-2.42 2.15-2.42 1.2 0 2.17 1.09 2.15 2.42 0 1.34-.94 2.42-2.15 2.42z" />
|
||||
</svg>
|
||||
</a>
|
||||
<a
|
||||
href="https://x.com/_codeburn"
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title="X"
|
||||
className="flex h-7 w-7 items-center justify-center rounded-md text-tertiary-foreground transition-colors hover:bg-interactive-secondary hover:text-foreground"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="13" height="13" fill="currentColor">
|
||||
<path d="M18.244 2.25h3.308l-7.227 8.26 8.502 11.24h-6.65l-5.214-6.817L4.99 21.75H1.68l7.73-8.835L1.254 2.25H8.08l4.713 6.231zm-1.161 17.52h1.833L7.084 4.126H5.117z" />
|
||||
</svg>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
|
||||
<main className="min-w-0 flex-1 overflow-y-auto pr-0.5">
|
||||
<div className="mb-3 flex items-baseline justify-between">
|
||||
<h1 className="font-display text-xl tracking-tight text-foreground">{page === 'context' ? 'Context' : viewTitle}</h1>
|
||||
<span className="text-xs text-tertiary-foreground">{page === 'usage' ? label : ''}</span>
|
||||
</div>
|
||||
|
||||
{page === 'context' ? (
|
||||
<ContextExplorer />
|
||||
) : showCombined ? (
|
||||
<CombinedView devices={devices} unit={unit} />
|
||||
) : (
|
||||
<DeviceView payload={primary?.payload} isRemote={!!viewing && !viewing.local} unit={unit} />
|
||||
)}
|
||||
|
||||
{page === 'usage' && isError && (
|
||||
<div className="mt-4 text-sm text-tertiary-foreground">Failed to load: {String((error as Error)?.message)}</div>
|
||||
)}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{searchOpen && <DeviceSearchModal onClose={() => setSearchOpen(false)} onPaired={() => void refetch()} />}
|
||||
|
||||
{pending.length > 0 && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 p-4">
|
||||
<div className="w-full max-w-sm overflow-hidden rounded-lg border border-border bg-card shadow-[0_24px_60px_-20px_rgba(0,0,0,0.35)]">
|
||||
<div className="border-b border-border px-5 py-3.5">
|
||||
<h2 className="text-sm font-semibold text-foreground">Incoming pairing request</h2>
|
||||
</div>
|
||||
<div className="flex flex-col gap-3 px-5 py-4">
|
||||
{pending.map((p) => (
|
||||
<div key={p.id} className="rounded-md border border-border px-3.5 py-3">
|
||||
<p className="text-sm text-foreground">
|
||||
“{p.name}” wants to pair with this device.
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-tertiary-foreground">
|
||||
Confirm this code matches on that device: <span className="font-mono text-foreground">{p.code}</span>
|
||||
</p>
|
||||
<div className="mt-3 flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void respondPairing(p.id, true)}
|
||||
className="rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground transition-opacity hover:opacity-90"
|
||||
>
|
||||
Approve
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void respondPairing(p.id, false)}
|
||||
className="rounded-md border border-border px-3 py-1.5 text-xs text-tertiary-foreground transition-colors hover:text-foreground"
|
||||
>
|
||||
Deny
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
export type BarItem = { name: string; value: number; display: string }
|
||||
|
||||
export function BarList({ items, total }: { items: BarItem[]; total?: number }) {
|
||||
if (!items.length) return <div className="py-8 text-center text-sm text-tertiary-foreground">No data.</div>
|
||||
const max = Math.max(...items.map((i) => i.value), 1)
|
||||
return (
|
||||
<div className="flex flex-col gap-2.5">
|
||||
{items.map((it) => {
|
||||
const pct = Math.max(2, Math.round((it.value / max) * 100))
|
||||
const share = total ? Math.round((it.value / total) * 100) + '%' : ''
|
||||
return (
|
||||
<div key={it.name} className="grid grid-cols-[minmax(80px,130px)_1fr_auto] items-center gap-3 text-sm">
|
||||
<div className="truncate text-foreground">{it.name}</div>
|
||||
<div className="h-2 overflow-hidden rounded-full bg-interactive-secondary">
|
||||
<div
|
||||
className="h-full rounded-full"
|
||||
style={{ width: pct + '%', background: 'linear-gradient(90deg, var(--color-chart-1), var(--color-chart-4))' }}
|
||||
/>
|
||||
</div>
|
||||
<div className="min-w-[88px] text-right tabular-nums text-tertiary-foreground">
|
||||
<span className="font-medium text-foreground">{it.display}</span> {share}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import {
|
||||
fetchContextSessions,
|
||||
fetchContextTree,
|
||||
type ContextProvider,
|
||||
type ContextRow,
|
||||
type ContextSessionInfo,
|
||||
} from '@/lib/api'
|
||||
import { cn, fmtNum, fmtTokens, label } from '@/lib/utils'
|
||||
import { Card } from '@/components/ui/card'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
const PROVIDERS: Array<{ key: ContextProvider; label: string }> = [
|
||||
{ key: 'claude', label: 'Claude Code' },
|
||||
{ key: 'codex', label: 'Codex' },
|
||||
]
|
||||
|
||||
function ago(mtimeMs: number): string {
|
||||
const mins = Math.max(0, Math.round((Date.now() - mtimeMs) / 60_000))
|
||||
if (mins < 60) return `${mins}m ago`
|
||||
if (mins < 60 * 24) return `${Math.round(mins / 60)}h ago`
|
||||
return `${Math.round(mins / (60 * 24))}d ago`
|
||||
}
|
||||
|
||||
function TreeTable({ rows }: { rows: ContextRow[] }) {
|
||||
const max = Math.max(1, ...rows.filter((r) => !r.bold).map((r) => r.tokens))
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{rows.map((r, i) => (
|
||||
<div key={i} className={cn('relative flex items-center gap-3 rounded-sm px-2 py-[3px]', r.bold && i > 0 && 'mt-2')}>
|
||||
{!r.bold && (
|
||||
<span
|
||||
className="absolute inset-y-[3px] left-0 rounded-sm bg-primary/[0.07]"
|
||||
style={{ width: `${Math.max(1, (r.tokens / max) * 100)}%` }}
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
className={cn('relative min-w-0 flex-1 truncate text-[13px]', r.bold ? 'font-semibold text-foreground' : 'text-muted-foreground')}
|
||||
style={{ paddingLeft: r.depth * 16 }}
|
||||
>
|
||||
{r.label}
|
||||
</span>
|
||||
<span className="relative w-16 shrink-0 text-right text-xs tabular-nums text-tertiary-foreground">{fmtNum(r.count)}x</span>
|
||||
<span className={cn('relative w-20 shrink-0 text-right text-[13px] tabular-nums', r.bold ? 'font-semibold text-foreground' : 'text-foreground')}>
|
||||
{fmtTokens(r.tokens)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function Chip({ label: lbl, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div className="rounded-md border border-border bg-interactive-secondary px-3 py-2">
|
||||
<div className="text-[10px] font-semibold uppercase tracking-[0.1em] text-tertiary-foreground">{lbl}</div>
|
||||
<div className="mt-0.5 text-sm font-medium tabular-nums text-foreground">{value}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SessionDetails({ provider, id }: { provider: ContextProvider; id: string }) {
|
||||
const [scope, setScope] = useState<'effective' | 'full'>('effective')
|
||||
const { data, isLoading, isError, error } = useQuery({
|
||||
queryKey: ['context-tree', provider, id],
|
||||
queryFn: () => fetchContextTree(provider, id),
|
||||
staleTime: 60_000,
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 px-4 py-4">
|
||||
<Skeleton className="h-14" />
|
||||
<Skeleton className="h-40" />
|
||||
<p className="text-xs text-tertiary-foreground">Reading the whole transcript, large sessions take a few seconds…</p>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
if (isError || !data) {
|
||||
return <p className="px-4 py-4 text-sm text-tertiary-foreground">Failed to load: {String((error as Error)?.message ?? 'unknown')}</p>
|
||||
}
|
||||
|
||||
const view = scope === 'full' ? data.full : data.effective
|
||||
const rows = scope === 'full' ? data.fullRows : data.effectiveRows
|
||||
const window = data.reported?.window ?? null
|
||||
const pct = data.reported && window ? Math.min(100, Math.round((data.reported.context / window) * 100)) : null
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-3 px-4 py-4">
|
||||
<div className="grid grid-cols-2 gap-2 sm:grid-cols-4">
|
||||
<Chip label="Messages" value={fmtNum(view.messages)} />
|
||||
<Chip label="Est. tokens" value={fmtTokens(view.tokens)} />
|
||||
<Chip
|
||||
label="Context (exact)"
|
||||
value={data.reported ? (window ? `${fmtTokens(data.reported.context)} / ${fmtTokens(window)}` : fmtTokens(data.reported.context)) : '—'}
|
||||
/>
|
||||
<Chip label="Compactions" value={fmtNum(data.compactions)} />
|
||||
</div>
|
||||
|
||||
{pct !== null && (
|
||||
<div>
|
||||
<div className="mb-1 flex justify-between text-[11px] text-tertiary-foreground">
|
||||
<span>{label(data.model)} · live context window</span>
|
||||
<span className="tabular-nums">{pct}%</span>
|
||||
</div>
|
||||
<div className="h-1.5 overflow-hidden rounded-full bg-interactive-secondary">
|
||||
<div className={cn('h-full rounded-full', pct >= 80 ? 'bg-[#c8541f]' : 'bg-primary')} style={{ width: `${pct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex rounded-md border border-border bg-interactive-secondary p-0.5">
|
||||
{(['effective', 'full'] as const).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => setScope(s)}
|
||||
className={cn(
|
||||
'rounded-[5px] px-2.5 py-1 text-[11px] font-medium transition-colors',
|
||||
scope === s ? 'bg-active-primary text-foreground shadow-sm' : 'text-tertiary-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{s === 'effective' ? 'Live window' : 'Full history'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<span className="text-[11px] text-tertiary-foreground">token counts are estimates; “Context (exact)” comes from API usage</span>
|
||||
</div>
|
||||
|
||||
<TreeTable rows={rows} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SessionRow({ s, open, onToggle }: { s: ContextSessionInfo; open: boolean; onToggle: () => void }) {
|
||||
return (
|
||||
<div className={cn('border-t border-border first:border-t-0', open && 'bg-interactive-secondary/30')}>
|
||||
<button type="button" onClick={onToggle} className="flex w-full items-center gap-3 px-4 py-2.5 text-left transition-colors hover:bg-interactive-secondary/50">
|
||||
<svg
|
||||
viewBox="0 0 16 16"
|
||||
width="10"
|
||||
height="10"
|
||||
className={cn('shrink-0 text-tertiary-foreground transition-transform', open && 'rotate-90')}
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M6 3l5 5-5 5" />
|
||||
</svg>
|
||||
<span className="shrink-0 font-mono text-xs text-primary">{s.sessionId.slice(0, 8)}</span>
|
||||
<span className="min-w-0 flex-1 truncate text-[13px] text-foreground">
|
||||
{s.title || <span className="text-tertiary-foreground">untitled session</span>}
|
||||
</span>
|
||||
<span className="hidden shrink-0 text-xs text-tertiary-foreground sm:block">{s.project}</span>
|
||||
<span className="w-16 shrink-0 text-right text-xs tabular-nums text-tertiary-foreground">{ago(s.mtimeMs)}</span>
|
||||
<span className="w-16 shrink-0 text-right text-xs tabular-nums text-tertiary-foreground">{(s.sizeBytes / 1024 / 1024).toFixed(1)}MB</span>
|
||||
</button>
|
||||
{open && (
|
||||
<div className="border-t border-border">
|
||||
<SessionDetails provider={s.provider} id={s.sessionId} />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function ContextExplorer() {
|
||||
const [provider, setProvider] = useState<ContextProvider>('claude')
|
||||
const [openId, setOpenId] = useState<string | null>(null)
|
||||
|
||||
const { data, isLoading, isError, error } = useQuery({
|
||||
queryKey: ['context-sessions', provider],
|
||||
queryFn: () => fetchContextSessions(provider),
|
||||
staleTime: 30_000,
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-3 flex items-center gap-2">
|
||||
{PROVIDERS.map((p) => (
|
||||
<button
|
||||
key={p.key}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setProvider(p.key)
|
||||
setOpenId(null)
|
||||
}}
|
||||
className={cn(
|
||||
'rounded-md border px-3.5 py-1.5 text-xs font-medium transition-colors',
|
||||
provider === p.key
|
||||
? 'border-primary/40 bg-primary/10 text-foreground'
|
||||
: 'border-border bg-card text-tertiary-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{p.label}
|
||||
</button>
|
||||
))}
|
||||
<span className="ml-auto text-xs text-tertiary-foreground">what fills each session’s context window, block by block</span>
|
||||
</div>
|
||||
|
||||
<Card className="overflow-hidden">
|
||||
{isLoading && (
|
||||
<div className="flex flex-col gap-2 p-4">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-9" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{isError && <p className="px-4 py-6 text-sm text-tertiary-foreground">Failed to load sessions: {String((error as Error)?.message)}</p>}
|
||||
{data && data.length === 0 && <p className="px-4 py-6 text-sm text-tertiary-foreground">No sessions found for this provider.</p>}
|
||||
{data?.map((s) => (
|
||||
<SessionRow key={s.sessionId} s={s} open={openId === s.sessionId} onToggle={() => setOpenId(openId === s.sessionId ? null : s.sessionId)} />
|
||||
))}
|
||||
</Card>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export type Column = { key: string; label: string; num?: boolean }
|
||||
|
||||
export function DataTable({ columns, rows }: { columns: Column[]; rows: Array<Record<string, ReactNode>> }) {
|
||||
if (!rows.length) return <div className="py-8 text-center text-sm text-tertiary-foreground">No data.</div>
|
||||
return (
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
{columns.map((c) => (
|
||||
<th
|
||||
key={c.key}
|
||||
className={cn(
|
||||
'pb-2 text-[11px] font-medium uppercase tracking-wider text-tertiary-foreground',
|
||||
c.num ? 'text-right' : 'text-left',
|
||||
)}
|
||||
>
|
||||
{c.label}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rows.map((r, i) => (
|
||||
<tr key={i} className="border-t border-border">
|
||||
{columns.map((c) => (
|
||||
<td key={c.key} className={cn('py-2 tabular-nums', c.num ? 'text-right' : 'text-left')}>
|
||||
{r[c.key]}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { scanDevices, pairDevice, type DiscoveredDevice } from '@/lib/api'
|
||||
|
||||
export function DeviceSearchModal({ onClose, onPaired }: { onClose: () => void; onPaired: () => void }) {
|
||||
const [scanning, setScanning] = useState(true)
|
||||
const [found, setFound] = useState<DiscoveredDevice[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [pairing, setPairing] = useState<string | null>(null)
|
||||
const [status, setStatus] = useState<string | null>(null)
|
||||
|
||||
const scan = async () => {
|
||||
setScanning(true)
|
||||
setError(null)
|
||||
setStatus(null)
|
||||
try {
|
||||
setFound(await scanDevices())
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
} finally {
|
||||
setScanning(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void scan()
|
||||
}, [])
|
||||
|
||||
const connect = async (d: DiscoveredDevice) => {
|
||||
setPairing(d.fingerprint)
|
||||
setError(null)
|
||||
setStatus(`Confirm the code ${d.code} on "${d.name}", then approve there. Waiting...`)
|
||||
try {
|
||||
const r = await pairDevice(d)
|
||||
if (r.ok) {
|
||||
setStatus(`Connected to "${r.name ?? d.name}".`)
|
||||
onPaired()
|
||||
setTimeout(onClose, 700)
|
||||
} else {
|
||||
setError(r.error ?? 'Pairing failed')
|
||||
setStatus(null)
|
||||
setPairing(null)
|
||||
}
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : String(e))
|
||||
setStatus(null)
|
||||
setPairing(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/30 p-4" onClick={onClose}>
|
||||
<div
|
||||
className="w-full max-w-md overflow-hidden rounded-lg border border-border bg-card shadow-[0_24px_60px_-20px_rgba(0,0,0,0.35)]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-border px-5 py-3.5">
|
||||
<h2 className="text-sm font-semibold text-foreground">Search local devices</h2>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => void scan()}
|
||||
disabled={scanning}
|
||||
className="rounded-md border border-border px-2.5 py-1 text-xs text-tertiary-foreground transition-colors hover:text-foreground disabled:opacity-50"
|
||||
>
|
||||
Rescan
|
||||
</button>
|
||||
<button onClick={onClose} className="rounded-md px-2 py-1 text-tertiary-foreground hover:text-foreground" aria-label="Close">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-5 py-4">
|
||||
{scanning ? (
|
||||
<div className="flex items-center gap-3 py-6 text-sm text-tertiary-foreground">
|
||||
<span className="h-4 w-4 animate-spin rounded-full border-2 border-border border-t-primary" />
|
||||
Looking for devices on your network...
|
||||
</div>
|
||||
) : found.length === 0 ? (
|
||||
<p className="py-6 text-center text-sm text-tertiary-foreground">
|
||||
No devices found. On your other Mac run <span className="font-mono text-foreground">codeburn share</span> on the same Wi-Fi.
|
||||
</p>
|
||||
) : (
|
||||
<div className="flex flex-col gap-2">
|
||||
{found.map((d) => (
|
||||
<div key={d.fingerprint} className="flex items-center gap-3 rounded-md border border-border px-3.5 py-3">
|
||||
<div className="flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-interactive-secondary text-primary">
|
||||
<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" strokeWidth="1.6" strokeLinecap="round" strokeLinejoin="round">
|
||||
<rect x="3" y="4" width="18" height="12" rx="2" />
|
||||
<path d="M8 20h8M12 16v4" />
|
||||
</svg>
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-medium text-foreground">{d.name}</div>
|
||||
<div className="truncate font-mono text-xs text-tertiary-foreground">
|
||||
{d.host}:{d.port}
|
||||
</div>
|
||||
</div>
|
||||
{d.paired ? (
|
||||
<span className="rounded-full bg-primary/10 px-2.5 py-1 text-xs font-medium text-primary">Connected</span>
|
||||
) : pairing === d.fingerprint ? (
|
||||
<span className="font-mono text-xs text-tertiary-foreground">code {d.code}</span>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => void connect(d)}
|
||||
disabled={!!pairing}
|
||||
className="rounded-md bg-primary px-3 py-1.5 text-xs font-medium text-primary-foreground transition-opacity hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
Connect
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{status && <p className="mt-3 text-xs text-tertiary-foreground">{status}</p>}
|
||||
{error && <p className="mt-3 text-xs text-[#b5403a]">{error}</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
import { Card } from './ui/card'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function MetricCard({
|
||||
label,
|
||||
value,
|
||||
sub,
|
||||
accent,
|
||||
}: {
|
||||
label: string
|
||||
value: string
|
||||
sub?: string
|
||||
accent?: boolean
|
||||
}) {
|
||||
return (
|
||||
<Card className="px-4 py-3.5">
|
||||
<div className="text-[11px] uppercase tracking-wider text-tertiary-foreground">{label}</div>
|
||||
<div className={cn('mt-1.5 text-2xl font-semibold tabular-nums tracking-tight', accent && 'text-primary')}>
|
||||
{value}
|
||||
</div>
|
||||
{sub ? <div className="mt-1 text-xs text-tertiary-foreground">{sub}</div> : null}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { useMemo } from 'react'
|
||||
import { Bar, BarChart, CartesianGrid, ResponsiveContainer, Tooltip, XAxis, YAxis } from 'recharts'
|
||||
|
||||
import type { DailyEntry, DeviceUsage } from '@/lib/api'
|
||||
import { CHART_COLORS, compactUsd, fmtTokens, label, usd } from '@/lib/utils'
|
||||
|
||||
export type Unit = 'cost' | 'tokens'
|
||||
|
||||
const MONTHS = ['', 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec']
|
||||
function fmtDay(d: string): string {
|
||||
const [, m, day] = String(d).split('-')
|
||||
return m && day ? `${Number(day)} ${MONTHS[Number(m)]}` : d
|
||||
}
|
||||
|
||||
const TOP_N = 6
|
||||
|
||||
type Series = { key: string; label: string; color: string }
|
||||
|
||||
function makeTooltip(labels: Record<string, string>, fmt: (n: number) => string) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
return function ChartTooltip({ active, payload, label: lbl }: any) {
|
||||
if (!active || !payload?.length) return null
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const items = payload.filter((p: any) => p.value > 0).sort((a: any, b: any) => b.value - a.value)
|
||||
if (!items.length) return null
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const total = items.reduce((s: number, p: any) => s + p.value, 0)
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-popover px-3 py-2 text-xs shadow-xl ring-1 ring-black/5">
|
||||
<div className="mb-1.5 font-medium text-foreground">{fmtDay(String(lbl))}</div>
|
||||
<div className="flex flex-col gap-1">
|
||||
{/* eslint-disable-next-line @typescript-eslint/no-explicit-any */}
|
||||
{items.slice(0, 6).map((p: any) => (
|
||||
<div key={p.dataKey} className="flex items-center gap-2">
|
||||
<span className="h-2.5 w-2.5 shrink-0 rounded-sm" style={{ background: p.color }} />
|
||||
<span className="flex-1 truncate text-tertiary-foreground">{labels[String(p.dataKey)] ?? String(p.dataKey)}</span>
|
||||
<span className="tabular-nums text-muted-foreground">{fmt(p.value)}</span>
|
||||
</div>
|
||||
))}
|
||||
<div className="mt-1 flex items-center justify-between border-t border-border pt-1 text-foreground">
|
||||
<span>Total</span>
|
||||
<span className="font-semibold tabular-nums">{fmt(total)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
function StackedBars({
|
||||
rows,
|
||||
series,
|
||||
labels,
|
||||
unit,
|
||||
}: {
|
||||
rows: Array<Record<string, number | string>>
|
||||
series: Series[]
|
||||
labels: Record<string, string>
|
||||
unit: Unit
|
||||
}) {
|
||||
const fmt = unit === 'tokens' ? fmtTokens : usd
|
||||
const axisFmt = (v: number | string) => (unit === 'tokens' ? fmtTokens(Number(v)) : compactUsd(Number(v)))
|
||||
const Tip = makeTooltip(labels, fmt)
|
||||
return (
|
||||
<div className="relative h-full w-full [&_.recharts-bar-rectangle]:transition-opacity [&_.recharts-bar-rectangle]:duration-75 [&:has(.recharts-bar-rectangle:hover)_.recharts-bar-rectangle:not(:hover)]:opacity-40">
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={rows} margin={{ top: 8, right: 8, bottom: 0, left: -6 }} barCategoryGap="16%">
|
||||
<CartesianGrid vertical={false} strokeDasharray="2 2" stroke="var(--color-chart-grid-stroke)" />
|
||||
<XAxis
|
||||
dataKey="period"
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
tickMargin={8}
|
||||
interval="equidistantPreserveStart"
|
||||
tick={{ fontSize: 11, fill: 'var(--color-tertiary-foreground)' }}
|
||||
tickFormatter={fmtDay}
|
||||
/>
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={50}
|
||||
tick={{ fontSize: 11, fill: 'var(--color-tertiary-foreground)' }}
|
||||
tickFormatter={axisFmt}
|
||||
/>
|
||||
<Tooltip cursor={{ fill: 'rgba(0,0,0,0.04)' }} content={<Tip />} />
|
||||
{series.map((s, i) => (
|
||||
<Bar
|
||||
key={s.key}
|
||||
dataKey={s.key}
|
||||
stackId="a"
|
||||
fill={s.color}
|
||||
isAnimationActive={false}
|
||||
radius={i === series.length - 1 ? [3, 3, 0, 0] : undefined}
|
||||
/>
|
||||
))}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Spend (or tokens) per day, stacked by model (single device).
|
||||
export function UsageChart({ daily, unit = 'cost' }: { daily: DailyEntry[]; unit?: Unit }) {
|
||||
const { rows, series, labels } = useMemo(() => {
|
||||
const measure = (m: { cost: number; inputTokens: number; outputTokens: number }) =>
|
||||
unit === 'tokens' ? m.inputTokens + m.outputTokens : m.cost
|
||||
const totals = new Map<string, number>()
|
||||
for (const d of daily) for (const m of d.topModels) totals.set(m.name, (totals.get(m.name) ?? 0) + measure(m))
|
||||
const top = [...totals.entries()].sort((a, b) => b[1] - a[1]).slice(0, TOP_N).map(([k]) => k)
|
||||
const topSet = new Set(top)
|
||||
const hasOther = [...totals.keys()].some((k) => !topSet.has(k))
|
||||
const keys = hasOther ? [...top, 'Other'] : top
|
||||
const rowData = daily.map((d) => {
|
||||
const row: Record<string, number | string> = { period: d.date }
|
||||
for (const k of keys) row[k] = 0
|
||||
for (const m of d.topModels) {
|
||||
const key = topSet.has(m.name) ? m.name : 'Other'
|
||||
row[key] = (row[key] as number) + measure(m)
|
||||
}
|
||||
return row
|
||||
})
|
||||
const series: Series[] = keys.map((k, i) => ({ key: k, label: label(k), color: CHART_COLORS[i % CHART_COLORS.length]! }))
|
||||
const labels = Object.fromEntries(series.map((s) => [s.key, s.label]))
|
||||
return { rows: rowData, series, labels }
|
||||
}, [daily, unit])
|
||||
|
||||
return <StackedBars rows={rows} series={series} labels={labels} unit={unit} />
|
||||
}
|
||||
|
||||
// Spend (or tokens) per day, stacked by device (one color per device) for the All view.
|
||||
export function DeviceUsageChart({ devices, unit = 'cost' }: { devices: DeviceUsage[]; unit?: Unit }) {
|
||||
const { rows, series, labels } = useMemo(() => {
|
||||
const named = devices.filter((d) => d.payload)
|
||||
const dailyOf = (d: DeviceUsage) => d.payload?.history?.daily ?? []
|
||||
// Stable key + color per device (by unique id) so a device keeps its color
|
||||
// and its bars don't remount when another device drops/returns between
|
||||
// polls, and two devices sharing a hostname never collide.
|
||||
const keyOf = (d: DeviceUsage) => 'dev_' + d.id.replace(/[^a-zA-Z0-9]/g, '_')
|
||||
const colorOf = (id: string) => {
|
||||
let h = 0
|
||||
for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) | 0
|
||||
return CHART_COLORS[Math.abs(h) % CHART_COLORS.length]!
|
||||
}
|
||||
const dates = [...new Set(named.flatMap((d) => dailyOf(d).map((e) => e.date)))].sort((a, b) => a.localeCompare(b))
|
||||
const series: Series[] = named.map((d) => ({
|
||||
key: keyOf(d),
|
||||
label: d.name + (d.local ? ' (this Mac)' : ''),
|
||||
color: colorOf(d.id),
|
||||
}))
|
||||
const rowData = dates.map((date) => {
|
||||
const row: Record<string, number | string> = { period: date }
|
||||
named.forEach((d) => {
|
||||
const e = dailyOf(d).find((x) => x.date === date)
|
||||
row[keyOf(d)] = e ? (unit === 'tokens' ? e.inputTokens + e.outputTokens : e.cost) : 0
|
||||
})
|
||||
return row
|
||||
})
|
||||
const labels = Object.fromEntries(series.map((s) => [s.key, s.label]))
|
||||
return { rows: rowData, series, labels }
|
||||
}, [devices, unit])
|
||||
|
||||
return <StackedBars rows={rows} series={series} labels={labels} unit={unit} />
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import type { HTMLAttributes } from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function Card({ className, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||
return <div className={cn('rounded-lg border border-border bg-card', className)} {...props} />
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export function Skeleton({ className }: { className?: string }) {
|
||||
return <div className={cn('skeleton-shimmer rounded-md', className)} />
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
@import url("https://fonts.googleapis.com/css2?family=Geist:wght@100..900&family=Geist+Mono:wght@300..600&display=swap");
|
||||
@import "tailwindcss";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
/*
|
||||
* Archival warmth + forensic precision. Warm paper surfaces, ink text, a
|
||||
* forest-green accent, and a green -> gold -> terracotta ramp for stacked
|
||||
* charts. No pure white background, no pure black, no cold neutrals.
|
||||
*/
|
||||
:root {
|
||||
font-family: "Geist", "Geist Fallback", system-ui, sans-serif;
|
||||
--radius: 0.375rem;
|
||||
|
||||
--background: #f6f4ef;
|
||||
--outer-background: #e9e6de;
|
||||
--foreground: #16181d;
|
||||
--card: #ffffff;
|
||||
--card-foreground: #16181d;
|
||||
--popover: #ffffff;
|
||||
--popover-foreground: #16181d;
|
||||
--muted: #efece4;
|
||||
--muted-foreground: #5d626b;
|
||||
--tertiary-foreground: #8a857c;
|
||||
--heading: #2c5242;
|
||||
--border: rgba(23, 27, 32, 0.08);
|
||||
--input: rgba(23, 27, 32, 0.14);
|
||||
--interactive-secondary: rgba(23, 27, 32, 0.04);
|
||||
--interactive-secondary-hover: rgba(23, 27, 32, 0.08);
|
||||
--active-primary: #ffffff;
|
||||
--accent: #efece4;
|
||||
--accent-foreground: #16181d;
|
||||
--subtle: #8a857c;
|
||||
--primary: #1f8a5b;
|
||||
--primary-foreground: #ffffff;
|
||||
--ring: #1f8a5b;
|
||||
--positive: #1f8a5b;
|
||||
|
||||
--chart-1: #1f8a5b;
|
||||
--chart-2: #4fd394;
|
||||
--chart-3: #2c5242;
|
||||
--chart-4: #d99a3c;
|
||||
--chart-5: #c8541f;
|
||||
--chart-6: #2f5fd0;
|
||||
--chart-7: #7aa86f;
|
||||
--chart-8: #b5403a;
|
||||
--chart-9: #3f8f6b;
|
||||
--chart-10: #a98b4f;
|
||||
--chart-grid-stroke: rgba(23, 27, 32, 0.07);
|
||||
|
||||
color-scheme: light;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-outer-background: var(--outer-background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-tertiary-foreground: var(--tertiary-foreground);
|
||||
--color-heading: var(--heading);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-interactive-secondary: var(--interactive-secondary);
|
||||
--color-interactive-secondary-hover: var(--interactive-secondary-hover);
|
||||
--color-active-primary: var(--active-primary);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-subtle: var(--subtle);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-ring: var(--ring);
|
||||
--color-positive: var(--positive);
|
||||
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-6: var(--chart-6);
|
||||
--color-chart-7: var(--chart-7);
|
||||
--color-chart-8: var(--chart-8);
|
||||
--color-chart-9: var(--chart-9);
|
||||
--color-chart-10: var(--chart-10);
|
||||
--color-chart-grid-stroke: var(--chart-grid-stroke);
|
||||
|
||||
--font-display: "Alga", Georgia, "Times New Roman", serif;
|
||||
--font-mono: "Geist Mono", ui-monospace, "SF Mono", Menlo, monospace;
|
||||
|
||||
--radius-sm: calc(var(--radius) - 2px);
|
||||
--radius-md: var(--radius);
|
||||
--radius-lg: calc(var(--radius) + 2px);
|
||||
|
||||
--text-xs: 12px;
|
||||
--text-sm: 13px;
|
||||
--text-md: 15px;
|
||||
--text-lg: 17px;
|
||||
--text-xl: 20px;
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border;
|
||||
}
|
||||
body {
|
||||
@apply bg-outer-background text-foreground;
|
||||
letter-spacing: -0.006em;
|
||||
font-weight: 400;
|
||||
margin: 0;
|
||||
}
|
||||
::selection {
|
||||
background: var(--primary);
|
||||
color: var(--primary-foreground);
|
||||
}
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: color-mix(in oklch, var(--foreground) 16%, transparent) transparent;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes shimmer {
|
||||
0% { background-position: 200% 0; }
|
||||
100% { background-position: -200% 0; }
|
||||
}
|
||||
.skeleton-shimmer {
|
||||
background-color: var(--muted);
|
||||
background-image: linear-gradient(
|
||||
90deg,
|
||||
transparent 0%,
|
||||
color-mix(in oklch, var(--foreground) 7%, transparent) 50%,
|
||||
transparent 100%
|
||||
);
|
||||
background-size: 200% 100%;
|
||||
background-repeat: no-repeat;
|
||||
animation: shimmer 1.6s ease-in-out infinite;
|
||||
}
|
||||
@@ -0,0 +1,257 @@
|
||||
export type Period = 'today' | 'week' | '30days' | 'month' | 'all'
|
||||
|
||||
export type ModelDay = {
|
||||
name: string
|
||||
cost: number
|
||||
calls: number
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
}
|
||||
|
||||
export type DailyEntry = {
|
||||
date: string
|
||||
cost: number
|
||||
calls: number
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
cacheReadTokens: number
|
||||
cacheWriteTokens: number
|
||||
topModels: ModelDay[]
|
||||
}
|
||||
|
||||
export type Current = {
|
||||
label: string
|
||||
cost: number
|
||||
calls: number
|
||||
sessions: number
|
||||
oneShotRate: number | null
|
||||
inputTokens: number
|
||||
outputTokens: number
|
||||
cacheReadTokens: number
|
||||
cacheWriteTokens: number
|
||||
cacheHitPercent: number
|
||||
codexCredits: number
|
||||
topActivities: Array<{ name: string; cost: number; turns: number; oneShotRate: number | null }>
|
||||
topModels: Array<{ name: string; cost: number; calls: number; savingsUSD: number }>
|
||||
providers: Record<string, number>
|
||||
topProjects: Array<{ name: string; cost: number; sessions: number; avgCostPerSession: number }>
|
||||
tools: Array<{ name: string; calls: number }>
|
||||
subagents: Array<{ name: string; calls: number; cost: number }>
|
||||
skills: Array<{ name: string; turns: number; cost: number }>
|
||||
mcpServers: Array<{ name: string; calls: number }>
|
||||
modelEfficiency: Array<{ name: string; costPerEdit: number; oneShotRate: number }>
|
||||
localModelSavings: { totalUSD: number }
|
||||
retryTax: { totalUSD: number; retries: number }
|
||||
routingWaste: { totalSavingsUSD: number }
|
||||
}
|
||||
|
||||
export type Payload = {
|
||||
generated: string
|
||||
current: Current
|
||||
history: { daily: DailyEntry[] }
|
||||
}
|
||||
|
||||
export async function fetchUsage(period: Period, provider: string): Promise<Payload> {
|
||||
const res = await fetch(`/api/usage?period=${encodeURIComponent(period)}&provider=${encodeURIComponent(provider)}`)
|
||||
if (!res.ok) throw new Error(`Request failed (${res.status})`)
|
||||
return res.json() as Promise<Payload>
|
||||
}
|
||||
|
||||
export type DeviceUsage = {
|
||||
id: string
|
||||
name: string
|
||||
local: boolean
|
||||
payload?: Payload
|
||||
error?: string
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__CODEBURN_BOOTSTRAP__?: { devices: DeviceUsage[] }
|
||||
}
|
||||
}
|
||||
|
||||
// A device may run a different CodeBurn version and send a payload missing
|
||||
// fields we treat as required. Fill safe defaults at the boundary so the UI
|
||||
// can iterate them without crashing (the alternative is a white screen for an
|
||||
// innocent local user because a peer sent an old shape).
|
||||
function normalizePayload(p?: Payload): Payload | undefined {
|
||||
if (!p) return p
|
||||
const c = (p.current ?? {}) as Partial<Current>
|
||||
return {
|
||||
generated: p.generated,
|
||||
current: {
|
||||
label: c.label ?? '',
|
||||
cost: c.cost ?? 0,
|
||||
calls: c.calls ?? 0,
|
||||
sessions: c.sessions ?? 0,
|
||||
oneShotRate: c.oneShotRate ?? null,
|
||||
inputTokens: c.inputTokens ?? 0,
|
||||
outputTokens: c.outputTokens ?? 0,
|
||||
cacheReadTokens: c.cacheReadTokens ?? 0,
|
||||
cacheWriteTokens: c.cacheWriteTokens ?? 0,
|
||||
cacheHitPercent: c.cacheHitPercent ?? 0,
|
||||
codexCredits: c.codexCredits ?? 0,
|
||||
topActivities: c.topActivities ?? [],
|
||||
topModels: c.topModels ?? [],
|
||||
providers: c.providers ?? {},
|
||||
topProjects: c.topProjects ?? [],
|
||||
tools: c.tools ?? [],
|
||||
subagents: c.subagents ?? [],
|
||||
skills: c.skills ?? [],
|
||||
mcpServers: c.mcpServers ?? [],
|
||||
modelEfficiency: c.modelEfficiency ?? [],
|
||||
localModelSavings: c.localModelSavings ?? { totalUSD: 0 },
|
||||
retryTax: c.retryTax ?? { totalUSD: 0, retries: 0 },
|
||||
routingWaste: c.routingWaste ?? { totalSavingsUSD: 0 },
|
||||
},
|
||||
history: {
|
||||
daily: (p.history?.daily ?? []).map((d) => ({
|
||||
date: d.date,
|
||||
cost: d.cost ?? 0,
|
||||
calls: d.calls ?? 0,
|
||||
inputTokens: d.inputTokens ?? 0,
|
||||
outputTokens: d.outputTokens ?? 0,
|
||||
cacheReadTokens: d.cacheReadTokens ?? 0,
|
||||
cacheWriteTokens: d.cacheWriteTokens ?? 0,
|
||||
topModels: (d.topModels ?? []).map((m) => ({
|
||||
name: m.name,
|
||||
cost: m.cost ?? 0,
|
||||
calls: m.calls ?? 0,
|
||||
inputTokens: m.inputTokens ?? 0,
|
||||
outputTokens: m.outputTokens ?? 0,
|
||||
})),
|
||||
})),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export async function fetchDevices(period: Period, provider: string): Promise<{ devices: DeviceUsage[] }> {
|
||||
const res = await fetch(`/api/devices?period=${encodeURIComponent(period)}&provider=${encodeURIComponent(provider)}`)
|
||||
if (!res.ok) throw new Error(`Request failed (${res.status})`)
|
||||
const data = (await res.json()) as { devices: DeviceUsage[] }
|
||||
return { devices: (data.devices ?? []).map((d) => ({ ...d, payload: normalizePayload(d.payload) })) }
|
||||
}
|
||||
|
||||
export const PERIODS: Array<{ key: Period; label: string }> = [
|
||||
{ key: 'today', label: 'Today' },
|
||||
{ key: 'week', label: '7 days' },
|
||||
{ key: '30days', label: '30 days' },
|
||||
{ key: 'month', label: 'Month' },
|
||||
{ key: 'all', label: 'All' },
|
||||
]
|
||||
|
||||
export type DiscoveredDevice = {
|
||||
name: string
|
||||
host: string
|
||||
port: number
|
||||
fingerprint: string
|
||||
code: string
|
||||
paired: boolean
|
||||
}
|
||||
|
||||
export async function scanDevices(): Promise<DiscoveredDevice[]> {
|
||||
const res = await fetch('/api/devices/scan')
|
||||
if (!res.ok) throw new Error(`Scan failed (${res.status})`)
|
||||
const json = (await res.json()) as { found: DiscoveredDevice[] }
|
||||
return json.found
|
||||
}
|
||||
|
||||
export async function pairDevice(d: DiscoveredDevice): Promise<{ ok: boolean; name?: string; error?: string }> {
|
||||
const res = await fetch('/api/devices/pair', {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({ name: d.name, host: d.host, port: d.port, fingerprint: d.fingerprint }),
|
||||
})
|
||||
return res.json() as Promise<{ ok: boolean; name?: string; error?: string }>
|
||||
}
|
||||
|
||||
export type ContextProvider = 'claude' | 'codex'
|
||||
|
||||
export type ContextSessionInfo = {
|
||||
provider: ContextProvider
|
||||
sessionId: string
|
||||
project: string
|
||||
title: string
|
||||
mtimeMs: number
|
||||
sizeBytes: number
|
||||
}
|
||||
|
||||
export type BlockStat = { count: number; tokens: number }
|
||||
|
||||
export type ContextSnapshot = {
|
||||
messages: number
|
||||
tokens: number
|
||||
assistant: {
|
||||
count: number
|
||||
tokens: number
|
||||
text: BlockStat
|
||||
reasoning: BlockStat
|
||||
toolCall: BlockStat
|
||||
byTool: Array<{ tool: string; count: number; tokens: number }>
|
||||
}
|
||||
user: {
|
||||
count: number
|
||||
tokens: number
|
||||
text: BlockStat
|
||||
image: BlockStat
|
||||
compactSummary: BlockStat
|
||||
meta: BlockStat
|
||||
}
|
||||
toolResult: BlockStat
|
||||
system: BlockStat
|
||||
}
|
||||
|
||||
export type ContextRow = { depth: number; label: string; count: number; tokens: number; bold?: boolean }
|
||||
|
||||
export type ContextTree = {
|
||||
session: { sessionId: string; project: string; mtimeMs: number; sizeBytes: number }
|
||||
model: string
|
||||
compactions: number
|
||||
reported: { context: number; window: number | null } | null
|
||||
effective: ContextSnapshot
|
||||
full: ContextSnapshot
|
||||
effectiveRows: ContextRow[]
|
||||
fullRows: ContextRow[]
|
||||
}
|
||||
|
||||
export async function fetchContextSessions(provider: ContextProvider): Promise<ContextSessionInfo[]> {
|
||||
const res = await fetch(`/api/context/sessions?provider=${encodeURIComponent(provider)}`)
|
||||
if (!res.ok) throw new Error(`Request failed (${res.status})`)
|
||||
const json = (await res.json()) as { sessions: ContextSessionInfo[] }
|
||||
return json.sessions ?? []
|
||||
}
|
||||
|
||||
export async function fetchContextTree(provider: ContextProvider, id: string): Promise<ContextTree> {
|
||||
const res = await fetch(`/api/context/tree?provider=${encodeURIComponent(provider)}&id=${encodeURIComponent(id)}`)
|
||||
if (!res.ok) throw new Error(`Request failed (${res.status})`)
|
||||
return res.json() as Promise<ContextTree>
|
||||
}
|
||||
|
||||
export type PendingPairing = { id: string; name: string; code: string }
|
||||
export type ShareStatus = {
|
||||
sharing: boolean
|
||||
name: string
|
||||
port: number
|
||||
always: boolean
|
||||
peers: number
|
||||
pending: PendingPairing[]
|
||||
}
|
||||
|
||||
const postJson = (path: string, body: unknown) =>
|
||||
fetch(path, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(body) })
|
||||
|
||||
export async function shareStatus(): Promise<ShareStatus> {
|
||||
const res = await fetch('/api/share/status')
|
||||
if (!res.ok) throw new Error(`share status failed (${res.status})`)
|
||||
return res.json() as Promise<ShareStatus>
|
||||
}
|
||||
export async function startShare(always: boolean): Promise<ShareStatus> {
|
||||
return (await postJson('/api/share/start', { always })).json() as Promise<ShareStatus>
|
||||
}
|
||||
export async function stopShare(): Promise<ShareStatus> {
|
||||
return (await postJson('/api/share/stop', {})).json() as Promise<ShareStatus>
|
||||
}
|
||||
export async function approvePairing(id: string, approve: boolean): Promise<{ ok: boolean }> {
|
||||
return (await postJson('/api/share/approve', { id, approve })).json() as Promise<{ ok: boolean }>
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
import { clsx, type ClassValue } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]): string {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
export function usd(n: number | undefined | null): string {
|
||||
const v = n == null || !isFinite(n) ? 0 : n
|
||||
const sign = v < 0 ? '-' : ''
|
||||
const a = Math.abs(v)
|
||||
const s = a >= 1 || a === 0 ? a.toFixed(2) : a >= 0.01 ? a.toFixed(3) : a.toFixed(2)
|
||||
const [int, dec] = s.split('.')
|
||||
return sign + '$' + int!.replace(/\B(?=(\d{3})+(?!\d))/g, ',') + (dec ? '.' + dec : '')
|
||||
}
|
||||
|
||||
export function fmtTokens(n: number | undefined | null): string {
|
||||
const v = n == null || !isFinite(n) ? 0 : n
|
||||
if (v >= 1e9) return (v / 1e9).toFixed(2) + 'B'
|
||||
if (v >= 1e6) return (v / 1e6).toFixed(1) + 'M'
|
||||
if (v >= 1e3) return (v / 1e3).toFixed(1) + 'K'
|
||||
return String(Math.round(v))
|
||||
}
|
||||
|
||||
export function fmtNum(n: number | undefined | null): string {
|
||||
const v = n == null || !isFinite(n) ? 0 : n
|
||||
return v.toLocaleString()
|
||||
}
|
||||
|
||||
export function compactUsd(n: number): string {
|
||||
if (!isFinite(n)) return '$0'
|
||||
const sign = n < 0 ? '-' : ''
|
||||
const a = Math.abs(n)
|
||||
if (a >= 1e6) return sign + '$' + (a / 1e6).toFixed(1) + 'M'
|
||||
if (a >= 1e3) return sign + '$' + (a / 1e3).toFixed(a >= 1e4 ? 0 : 1) + 'k'
|
||||
return sign + '$' + Math.round(a)
|
||||
}
|
||||
|
||||
// Forest green -> gold -> terracotta ramp for stacked series (mirrors the
|
||||
// --chart-* tokens). Warm and on-brand, distinct enough to read when stacked.
|
||||
export const CHART_COLORS = [
|
||||
'#1f8a5b', '#4fd394', '#2c5242', '#d99a3c', '#c8541f',
|
||||
'#2f5fd0', '#7aa86f', '#b5403a', '#3f8f6b', '#a98b4f',
|
||||
]
|
||||
|
||||
const MODEL_LABELS: Record<string, string> = {
|
||||
'claude-opus-4-8': 'Opus 4.8',
|
||||
'claude-opus-4-6': 'Opus 4.6',
|
||||
'claude-opus-4-7': 'Opus 4.7',
|
||||
'claude-sonnet-4-6': 'Sonnet 4.6',
|
||||
'claude-sonnet-4-5': 'Sonnet 4.5',
|
||||
'claude-haiku-4-5-20251001': 'Haiku 4.5',
|
||||
'grok-build-0.1': 'Grok Build',
|
||||
'cursor-auto': 'Cursor',
|
||||
'composer-2.5': 'Composer 2.5',
|
||||
}
|
||||
|
||||
// Prettify a model id for chart legends. Display-name fields (current.topModels)
|
||||
// already arrive clean; history rows carry raw ids, so we map the common ones
|
||||
// and lightly clean the rest.
|
||||
export function label(key: string): string {
|
||||
if (MODEL_LABELS[key]) return MODEL_LABELS[key]
|
||||
if (key === 'Other' || key === 'unknown') return key
|
||||
return key
|
||||
.replace(/^gpt-/i, 'GPT-')
|
||||
.replace(/-(\d{8,})$/, '')
|
||||
.replace(/-/g, ' ')
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase())
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { Component, StrictMode, type ReactNode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
|
||||
import { App } from './App'
|
||||
import './index.css'
|
||||
|
||||
// Last-resort guard: a render error (e.g. an unexpected payload from a peer on
|
||||
// a different version) shows a recoverable message instead of a blank page.
|
||||
class ErrorBoundary extends Component<{ children: ReactNode }, { error: Error | null }> {
|
||||
state = { error: null as Error | null }
|
||||
static getDerivedStateFromError(error: Error) {
|
||||
return { error }
|
||||
}
|
||||
render() {
|
||||
if (this.state.error) {
|
||||
return (
|
||||
<div className="flex min-h-screen flex-col items-center justify-center gap-3 bg-outer-background p-8 text-center">
|
||||
<p className="text-sm text-foreground">Something went wrong rendering the dashboard.</p>
|
||||
<p className="max-w-md text-xs text-tertiary-foreground">{String(this.state.error.message)}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => location.reload()}
|
||||
className="rounded-md border border-border bg-card px-3 py-1.5 text-xs text-foreground hover:bg-interactive-secondary"
|
||||
>
|
||||
Reload
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return this.props.children
|
||||
}
|
||||
}
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: { queries: { refetchOnWindowFocus: false, staleTime: 30_000, retry: 1 } },
|
||||
})
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<ErrorBoundary>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<App />
|
||||
</QueryClientProvider>
|
||||
</ErrorBoundary>
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"skipLibCheck": true,
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"baseUrl": ".",
|
||||
"paths": { "@/*": ["./src/*"] }
|
||||
},
|
||||
"include": ["src", "vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import { fileURLToPath, URL } from 'node:url'
|
||||
|
||||
// base './' so the built assets load with relative URLs when the CLI serves
|
||||
// dist/dash from the local server root. Built output goes straight into the
|
||||
// package's dist/dash so `codeburn web` can serve it after `npm run build`.
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
base: './',
|
||||
resolve: {
|
||||
alias: { '@': fileURLToPath(new URL('./src', import.meta.url)) },
|
||||
},
|
||||
build: {
|
||||
outDir: '../dist/dash',
|
||||
emptyOutDir: true,
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
// During frontend dev, run `codeburn web` (CLI api on 4747) and `npm run dev`
|
||||
// here; this proxies the data calls to the CLI.
|
||||
proxy: { '/api': 'http://127.0.0.1:4747' },
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,189 @@
|
||||
# CodeBurn Architecture
|
||||
|
||||
A map of the codebase. Read this once before opening a non-trivial PR.
|
||||
|
||||
## Three Surfaces
|
||||
|
||||
CodeBurn is one Node.js CLI plus two GUI clients that shell out to it.
|
||||
|
||||
```
|
||||
+----------------------+ +-----------------+
|
||||
| mac/ (Swift) | ---> | |
|
||||
+----------------------+ | src/cli.ts |
|
||||
| gnome/ (JavaScript) | ---> | (the CLI) |
|
||||
+----------------------+ | |
|
||||
| status |
|
||||
| --format |
|
||||
| menubar-json |
|
||||
+-----------------+
|
||||
|
|
||||
v
|
||||
+----------------------------+
|
||||
| session files on disk |
|
||||
| (JSONL, SQLite, protobuf) |
|
||||
+----------------------------+
|
||||
```
|
||||
|
||||
The macOS menubar (`mac/`) and the GNOME extension (`gnome/`) both invoke `codeburn status --format menubar-json --period <p>` and parse the JSON. They do not share code with the CLI; they only depend on its output contract.
|
||||
|
||||
## CLI (`src/`)
|
||||
|
||||
`src/cli.ts` is the Commander.js entry point. The bin field in `package.json` points at `dist/cli.js`. Twelve commands are registered:
|
||||
|
||||
| Command | Line | Purpose |
|
||||
|---|---|---|
|
||||
| `report` | 274 | Default. Interactive Ink TUI dashboard. |
|
||||
| `status` | 358 | Compact text status, plus `--format menubar-json` for clients. |
|
||||
| `today` | 524 | Today-only view of `report`. |
|
||||
| `month` | 542 | Month-only view of `report`. |
|
||||
| `export` | 560 | CSV or JSON dump of usage data. |
|
||||
| `menubar` | 621 | Downloads and launches the macOS menubar bundle. |
|
||||
| `currency` | 636 | Sets display currency. |
|
||||
| `model-alias` | 687 | Maps an unknown model name to a known one for pricing. |
|
||||
| `plan` | 737 | Configures a subscription plan for overage tracking. |
|
||||
| `optimize` | 857 | Runs all 14 waste detectors. |
|
||||
| `compare` | 870 | Compares two models side by side. |
|
||||
| `yield` | 882 | Tracks which sessions shipped to main vs. were reverted (experimental). |
|
||||
|
||||
### Pipeline
|
||||
|
||||
```
|
||||
provider.discoverSessions()
|
||||
|
|
||||
v
|
||||
provider.createSessionParser(source, seenKeys)
|
||||
|
|
||||
v yields ParsedProviderCall (see src/providers/types.ts)
|
||||
|
|
||||
v
|
||||
src/parser.ts: parseAllSessions()
|
||||
|
|
||||
v aggregates into ProjectSummary[]
|
||||
|
|
||||
v
|
||||
src/daily-cache.ts: aggregate per day, persist
|
||||
|
|
||||
v
|
||||
output formatter (Ink TUI, JSON, or menubar-json)
|
||||
```
|
||||
|
||||
`src/parser.ts` is the central aggregator. Public exports: `parseAllSessions`, `filterProjectsByName`, `extractMcpInventory`. It owns the dedup `Set` (`seenKeys`) that is passed into every provider parser so a turn that surfaces in two providers (Claude logs vs. Cursor mirror, for instance) is counted once.
|
||||
|
||||
### Cache Layers
|
||||
|
||||
Three caches under `~/.cache/codeburn/` (override with `CODEBURN_CACHE_DIR`):
|
||||
|
||||
| File | Owner | Invalidation |
|
||||
|---|---|---|
|
||||
| `codex-results.json` | `src/codex-cache.ts` | `mtimeMs + sizeBytes` per Codex `.jsonl`. |
|
||||
| `cursor-results.json` | `src/cursor-cache.ts` | `mtimeMs + sizeBytes` of the Cursor SQLite db. |
|
||||
| `daily-cache.json` | `src/daily-cache.ts` | Tracks `lastComputedDate`; new days are backfilled, old days are reused. |
|
||||
|
||||
All three use atomic write (temp file + `rename`) and write with mode `0o600`. All three carry a numeric `version` field; bumping it forces a recompute next run.
|
||||
|
||||
### Optimize Detectors
|
||||
|
||||
`src/optimize.ts` exports 14 detectors. Each returns a `WasteFinding | null`. They are composed by `runOptimize()` which collects findings, ranks them by impact, and returns them with `WasteAction` objects (paste-to-CLAUDE.md, paste-to-session-opener, prompt-now, edit shell config).
|
||||
|
||||
| Detector | Line | What it catches |
|
||||
|---|---|---|
|
||||
| `detectJunkReads` | 428 | Reads into `node_modules`, `.git`, `dist`, etc. |
|
||||
| `detectDuplicateReads` | 477 | Re-reads of the same file in a session. |
|
||||
| `detectMcpToolCoverage` | 795 | MCP servers with many tools but low usage. |
|
||||
| `detectUnusedMcp` | 855 | MCP servers configured but never invoked. |
|
||||
| `detectBloatedClaudeMd` | 944 | `CLAUDE.md` files past a healthy size. |
|
||||
| `detectLowReadEditRatio` | 987 | Edit-heavy sessions with too few prior reads. |
|
||||
| `detectCacheBloat` | 1048 | High `cache_creation_input_tokens`. |
|
||||
| `detectGhostAgents` | 1124 | Defined but never-invoked Claude agents. |
|
||||
| `detectGhostSkills` | 1154 | Defined but never-invoked skills. |
|
||||
| `detectGhostCommands` | 1184 | Defined but never-invoked slash commands. |
|
||||
| `detectBashBloat` | 1228 | Shell output limit set above the recommended 15K chars. |
|
||||
| `detectLowWorthSessions` | 1405 | Sessions with cost but no edits or git delivery. |
|
||||
| `detectContextBloat` | 1512 | Input:output token ratio above 25:1. |
|
||||
| `detectSessionOutliers` | 1558 | Sessions costing more than 2x the project average. |
|
||||
|
||||
### Output Formats
|
||||
|
||||
| Command | `--format` choices | Default |
|
||||
|---|---|---|
|
||||
| `report`, `today`, `month` | `tui`, `json` | `tui` |
|
||||
| `status` | `terminal`, `menubar-json`, `json` | `terminal` |
|
||||
| `export` | `csv`, `json` | `csv` |
|
||||
| `plan` | `text`, `json` | `text` |
|
||||
|
||||
The macOS menubar and GNOME extension consume `menubar-json`. `src/menubar-json.ts` defines the contract; `tests/menubar-json.test.ts` pins it.
|
||||
|
||||
## Providers (`src/providers/`)
|
||||
|
||||
Every provider implements the `Provider` interface in `src/providers/types.ts`:
|
||||
|
||||
```ts
|
||||
type Provider = {
|
||||
name: string
|
||||
displayName: string
|
||||
modelDisplayName(model: string): string
|
||||
toolDisplayName(rawTool: string): string
|
||||
discoverSessions(): Promise<SessionSource[]>
|
||||
createSessionParser(source: SessionSource, seenKeys: Set<string>): SessionParser
|
||||
}
|
||||
```
|
||||
|
||||
`src/providers/index.ts` registers twenty-one providers across two tiers:
|
||||
|
||||
- **Eager**: `claude`, `cline`, `codex`, `copilot`, `droid`, `gemini`, `ibm-bob`, `kilo-code`, `kiro`, `kimi`, `openclaw`, `pi`, `omp`, `qwen`, `roo-code`. Imported at module load.
|
||||
- **Lazy**: `antigravity`, `goose`, `cursor`, `opencode`, `cursor-agent`, `crush`. Imported via dynamic `import()` so the heavy dependencies (SQLite, protobuf) do not touch users who do not have those tools installed.
|
||||
|
||||
Both lists hit the same `getAllProviders()` aggregator. A failed lazy import is silent and excludes that provider from the run.
|
||||
|
||||
`src/providers/vscode-cline-parser.ts` is a shared helper consumed by `cline`, `ibm-bob`, `kilo-code`, and `roo-code`. It is not registered as a provider on its own.
|
||||
|
||||
For the per-provider data location, storage format, parser quirks, and test coverage, see `docs/providers/`.
|
||||
|
||||
## macOS Menubar (`mac/`)
|
||||
|
||||
Swift package (`mac/Package.swift`), targets macOS 14, strict concurrency on. Layout under `mac/Sources/CodeBurnMenubar/`:
|
||||
|
||||
- `CodeBurnApp.swift` boots the SwiftUI `App` and the `NSStatusItem`.
|
||||
- `AppStore.swift` is the single source of truth for UI state.
|
||||
- `Data/` holds models, the CLI client, credential stores, and subscription services.
|
||||
- `DataClient.swift` spawns the CLI and decodes `MenubarPayload`. See file-level comment for why we never route through `/bin/zsh -c`.
|
||||
- `MenubarPayload.swift` mirrors the JSON the CLI emits; keep it in sync with `src/menubar-json.ts`.
|
||||
- `Security/CodeburnCLI.swift` resolves the CLI binary (env override `CODEBURN_BIN`, fallback `codeburn`), validates each argv entry against an allowlist regex, and augments PATH for Homebrew and npm-global installs. The Process is launched via `/usr/bin/env`, never via a shell.
|
||||
- `Theme/` holds color and typography constants and the dark/light state.
|
||||
- `Views/` are the SwiftUI components rendered inside `NSPopover`.
|
||||
|
||||
Tests live in `mac/Tests/CodeBurnMenubarTests/` (currently `CapacityEstimatorTests.swift`).
|
||||
|
||||
The build artifact is a zipped `.app` bundle produced by `mac/Scripts/package-app.sh`. See `RELEASING.md` for how the GitHub Actions workflow uses it.
|
||||
|
||||
## GNOME Extension (`gnome/`)
|
||||
|
||||
Plain JavaScript, no bundler. Targets GNOME Shell 45-50 (`metadata.json`).
|
||||
|
||||
- `extension.js` is the entry point. On `enable()` it constructs a `CodeBurnIndicator` and adds it to the panel.
|
||||
- `indicator.js` is the popover. It owns the period selector, the insight tabs, and the provider filter.
|
||||
- `dataClient.js` wraps `Gio.Subprocess` to call the CLI. It validates argv against the same allowlist pattern as the macOS client and augments PATH with `~/.local/bin`, `~/.npm-global/bin`, `~/.volta/bin`, `~/.bun/bin`, `~/.cargo/bin`, `~/.asdf/shims`, and a few others. Results are cached for 300 seconds.
|
||||
- `prefs.js` is the settings dialog backed by `schemas/org.gnome.shell.extensions.codeburn.gschema.xml`.
|
||||
- `install.sh` copies the extension into `~/.local/share/gnome-shell/extensions/`.
|
||||
|
||||
## Build (`scripts/`, `tsup.config.ts`)
|
||||
|
||||
`npm run build` is two steps:
|
||||
|
||||
1. `node scripts/bundle-litellm.mjs` fetches the latest litellm pricing JSON and writes `src/data/litellm-snapshot.json`. The bundle script keeps a manual override for MiniMax variants. Direct (un-prefixed) entries win over prefixed ones. The result is checked in so the build is reproducible.
|
||||
2. `tsup` reads `tsup.config.ts` and emits a single ESM bundle at `dist/cli.js` with a Node shebang banner. No source maps in publish builds; sourcemaps on for development.
|
||||
|
||||
The `prepublishOnly` hook in `package.json` runs `npm run build` so `npm publish` always ships fresh code.
|
||||
|
||||
## Tests
|
||||
|
||||
`npm test` runs vitest. Forty-two test files live under `tests/`:
|
||||
|
||||
- `tests/` root (27 files) covers CLI, parser, optimize, cache, format, models, plans.
|
||||
- `tests/security/` (1 file) covers prototype-pollution guards.
|
||||
- `tests/providers/` (15 files) covers per-provider parsing.
|
||||
- `tests/fixtures/` holds redacted real-world session data.
|
||||
|
||||
Five providers ship without dedicated test files today: `antigravity`, `claude`, `gemini`, `goose`, `qwen`. Closing this gap is a standing good-first-issue.
|
||||
|
||||
CI runs Semgrep against `.semgrep/rules/no-bracket-assign-hot-paths.yml` over `src/providers/` and `src/parser.ts` (`.github/workflows/ci.yml`). It does not run vitest in CI today; tests run locally before publish.
|
||||
@@ -0,0 +1,755 @@
|
||||
# CodeBurn MCP Server — Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add a `codeburn mcp` stdio MCP server exposing CodeBurn's usage/cost data to AI agents via two tools (`get_usage`, `get_savings`), each returning a markdown table plus typed structured JSON.
|
||||
|
||||
**Architecture:** Extract the existing `status --format menubar-json` aggregation into one reusable `buildMenubarPayloadForRange(periodInfo, opts)` (with an `optimize` boolean — the only expensive call, `scanAndDetect`, is skipped for `get_usage`). A long-lived in-process `McpServer` registers the two tools, injects the aggregator for testability, coalesces concurrent calls, hashes project names by default, and relies on the existing 180 s parser cache for warm reuse.
|
||||
|
||||
**Tech Stack:** TypeScript (ESM, `type: module`, node ≥ 22.13), commander, `@modelcontextprotocol/sdk@^1.29` (v1), zod, tsup, vitest.
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
- **Create `src/usage-aggregator.ts`** — owns `buildPeriodData` (moved from `main.ts`) and the extracted `buildMenubarPayloadForRange(periodInfo, opts): Promise<MenubarPayload>`. Single responsibility: turn a resolved date range + filters into a `MenubarPayload`.
|
||||
- **Create `src/mcp/redact.ts`** — `pseudonym()` + `redactProjectNames(payload, include)`. Privacy only.
|
||||
- **Create `src/mcp/tables.ts`** — markdown renderers (`renderSummaryTable`, `renderBreakdownTable`, `renderSavingsTable`). Presentation only.
|
||||
- **Create `src/mcp/server.ts`** — `createServer(deps)` (tool registration + handlers, aggregator injectable) and `startStdioServer(version)` (loadPricing + pre-warm + stdio transport).
|
||||
- **Modify `src/main.ts`** — import `buildPeriodData`/`buildMenubarPayloadForRange` from the aggregator; refactor the `status` menubar branch to call the aggregator; add `.command('mcp')`.
|
||||
- **Modify `package.json`** — add `@modelcontextprotocol/sdk` + `zod` deps.
|
||||
- **Modify `tsup.config.ts`** — `external: ['@modelcontextprotocol/sdk', 'zod']`.
|
||||
- **Create tests** — `tests/usage-aggregator.test.ts`, `tests/mcp-redact.test.ts`, `tests/mcp-tables.test.ts`, `tests/mcp-server.test.ts`.
|
||||
|
||||
Internal MCP period names map to CodeBurn's: `today→today`, `last_7_days→week`, `last_30_days→30days`, `month_to_date→month`, `last_6_months→all` (≈6 months).
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Add dependencies and externalize them in the bundle
|
||||
|
||||
**Files:**
|
||||
- Modify: `package.json` (dependencies)
|
||||
- Modify: `tsup.config.ts`
|
||||
|
||||
- [ ] **Step 1: Add runtime deps**
|
||||
|
||||
Run: `npm install @modelcontextprotocol/sdk@^1.29.0 zod@^3.25.0 --save-exact=false`
|
||||
Expected: both appear under `"dependencies"` in `package.json`; `node_modules/@modelcontextprotocol/sdk` and `node_modules/zod` exist.
|
||||
|
||||
- [ ] **Step 2: Externalize them so they are not inlined into `dist/main.js`**
|
||||
|
||||
Edit `tsup.config.ts` to:
|
||||
|
||||
```ts
|
||||
import { defineConfig } from 'tsup'
|
||||
|
||||
export default defineConfig({
|
||||
entry: ['src/main.ts'],
|
||||
format: ['esm'],
|
||||
target: 'node20',
|
||||
outDir: 'dist',
|
||||
clean: true,
|
||||
splitting: false,
|
||||
sourcemap: true,
|
||||
dts: false,
|
||||
external: ['@modelcontextprotocol/sdk', 'zod'],
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Build and confirm the SDK is external (not bundled)**
|
||||
|
||||
Run: `npm run build && node -e "const s=require('fs').readFileSync('dist/main.js','utf8'); if(/from'@modelcontextprotocol\/sdk|require\(.@modelcontextprotocol\/sdk./.test(s.replace(/\s/g,''))||!/McpServer/.test(s)){} console.log('built, bytes', s.length)"`
|
||||
Expected: build succeeds, prints `built, bytes <n>`. (The SDK isn't imported anywhere yet, so this just verifies the config builds.)
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add package.json package-lock.json tsup.config.ts
|
||||
git commit -m "build(mcp): add @modelcontextprotocol/sdk + zod, externalize in tsup"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Move `buildPeriodData` into a shared module
|
||||
|
||||
`buildPeriodData` is currently a private function in `main.ts:410`. The aggregator needs it, so move it to the new module and import it back into `main.ts`.
|
||||
|
||||
**Files:**
|
||||
- Create: `src/usage-aggregator.ts`
|
||||
- Modify: `src/main.ts:410` (remove local def), import section
|
||||
|
||||
- [ ] **Step 1: Create the module with `buildPeriodData` moved verbatim**
|
||||
|
||||
Create `src/usage-aggregator.ts`. Move the entire `function buildPeriodData(label: string, projects: ProjectSummary[]): PeriodData { ... }` body from `main.ts` into it, exported, with imports it needs:
|
||||
|
||||
```ts
|
||||
import type { ProjectSummary } from './types.js'
|
||||
import { type PeriodData } from './menubar-json.js'
|
||||
|
||||
export function buildPeriodData(label: string, projects: ProjectSummary[]): PeriodData {
|
||||
// ... exact body moved from main.ts:410 ...
|
||||
}
|
||||
```
|
||||
|
||||
(Copy the body unchanged. Add any imports the body references — e.g. `getShortModelName` from `./models.js` — until `npx tsc --noEmit` is clean.)
|
||||
|
||||
- [ ] **Step 2: Update `main.ts` to import it and delete the local copy**
|
||||
|
||||
Remove the `function buildPeriodData(...) {...}` at `main.ts:410`. Add to the import block near the top:
|
||||
|
||||
```ts
|
||||
import { buildPeriodData } from './usage-aggregator.js'
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Typecheck + run the full suite (parity guard)**
|
||||
|
||||
Run: `npx tsc --noEmit && npm test`
|
||||
Expected: typecheck clean; all existing tests pass (the `status` paths still use `buildPeriodData`, now imported).
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/usage-aggregator.ts src/main.ts
|
||||
git commit -m "refactor: move buildPeriodData into usage-aggregator module"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Extract `buildMenubarPayloadForRange`
|
||||
|
||||
Move the `status` menubar-json aggregation block (`main.ts:485–759`, everything after `periodInfo`/`now` are computed, ending at the `console.log`) into the aggregator as a function that **returns** the payload instead of printing it.
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/usage-aggregator.ts` (add the function)
|
||||
- Modify: `src/main.ts:476–761` (call it)
|
||||
- Test: existing `tests/cli-status-menubar.test.ts` is the parity guard
|
||||
|
||||
- [ ] **Step 1: Add the function signature + types to `src/usage-aggregator.ts`**
|
||||
|
||||
```ts
|
||||
import { homedir } from 'node:os'
|
||||
import type { ProjectSummary, DateRange } from './types.js'
|
||||
import { type PeriodData, type ProviderCost, type BreakdownArrays, type MenubarPayload, buildMenubarPayload } from './menubar-json.js'
|
||||
import { parseAllSessions, getAllProviders, filterProjectsByName, filterProjectsByDays } from './parser.js'
|
||||
import { aggregateProjectsIntoDays, buildPeriodDataFromDays } from './day-aggregator.js'
|
||||
import { aggregateModelEfficiency } from './model-efficiency.js'
|
||||
import { scanAndDetect } from './optimize.js'
|
||||
import { hydrateCache, loadDailyCache, getDaysInRange, toDateString, BACKFILL_DAYS, type DailyCache } from './daily-cache.js'
|
||||
|
||||
export type PeriodInfo = { range: DateRange; label: string }
|
||||
export type AggregateOpts = {
|
||||
provider?: string
|
||||
project?: string[]
|
||||
exclude?: string[]
|
||||
daysSelection?: { range: DateRange; label: string; days: Set<string> } | null
|
||||
optimize?: boolean
|
||||
}
|
||||
|
||||
export async function buildMenubarPayloadForRange(
|
||||
periodInfo: PeriodInfo,
|
||||
opts: AggregateOpts = {},
|
||||
): Promise<MenubarPayload> {
|
||||
const pf = opts.provider ?? 'all'
|
||||
const daysSelection = opts.daysSelection ?? null
|
||||
const fp = (p: ProjectSummary[]) => filterProjectsByName(p, opts.project ?? [], opts.exclude ?? [])
|
||||
// ... moved block from main.ts:485–757 (now/todayStart/... through breakdowns) ...
|
||||
const optimize = opts.optimize === false ? null : await scanAndDetect(scanProjects, scanRange)
|
||||
return buildMenubarPayload(currentData, providers, optimize, dailyHistory, retryTax, routingWaste, breakdowns)
|
||||
}
|
||||
```
|
||||
|
||||
Move lines `main.ts:485–757` (from `const now = new Date()` through the `breakdowns` IIFE) into the body unchanged. The block already references only the symbols imported above plus `homedir()`. `hydrateCache` is imported from `./daily-cache.js` (same module as `loadDailyCache`); if tsc reports a different source, follow the import it suggests.
|
||||
|
||||
- [ ] **Step 2: Precondition note — pricing**
|
||||
|
||||
The block assumes `loadPricing()` already ran. The `status` action calls it at `main.ts:473`; the MCP server will call it at startup (Task 6). Do **not** call `loadPricing()` inside the function.
|
||||
|
||||
- [ ] **Step 3: Refactor the `status` menubar branch to call it**
|
||||
|
||||
Replace `main.ts:485–760` (the inline block + `console.log` + `return`) with:
|
||||
|
||||
```ts
|
||||
const payload = await buildMenubarPayloadForRange(periodInfo, {
|
||||
provider: pf,
|
||||
project: opts.project,
|
||||
exclude: opts.exclude,
|
||||
daysSelection,
|
||||
optimize: opts.optimize !== false,
|
||||
})
|
||||
console.log(JSON.stringify(payload))
|
||||
return
|
||||
```
|
||||
|
||||
Keep `main.ts:477–484` (the `daysSelection`/`customRange`/`daySelection`/`periodInfo` resolution) — `periodInfo` and `daysSelection` are the inputs now passed in. Add `buildMenubarPayloadForRange` to the existing import from `./usage-aggregator.js`.
|
||||
|
||||
- [ ] **Step 4: Typecheck + parity test**
|
||||
|
||||
Run: `npx tsc --noEmit && npm test -- cli-status-menubar`
|
||||
Expected: clean typecheck; `tests/cli-status-menubar.test.ts` passes — i.e. `status --format menubar-json` output is unchanged (parity).
|
||||
|
||||
- [ ] **Step 5: Add a direct unit test for the aggregator**
|
||||
|
||||
Create `tests/usage-aggregator.test.ts`:
|
||||
|
||||
```ts
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { buildMenubarPayloadForRange } from '../src/usage-aggregator.js'
|
||||
import { getDateRange } from '../src/cli-date.js'
|
||||
|
||||
describe('buildMenubarPayloadForRange', () => {
|
||||
it('returns a zero payload with no data and skips optimize when optimize:false', async () => {
|
||||
const payload = await buildMenubarPayloadForRange(getDateRange('today'), { provider: 'all', optimize: false })
|
||||
expect(payload.current.cost).toBe(0)
|
||||
expect(payload.current.calls).toBe(0)
|
||||
expect(payload.optimize.findingCount).toBe(0)
|
||||
expect(Array.isArray(payload.current.topProjects)).toBe(true)
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
Run: `npm test -- usage-aggregator`
|
||||
Expected: PASS (uses the empty real environment; `scanAndDetect` not called because `optimize:false`).
|
||||
|
||||
- [ ] **Step 6: Commit**
|
||||
|
||||
```bash
|
||||
git add src/usage-aggregator.ts src/main.ts tests/usage-aggregator.test.ts
|
||||
git commit -m "refactor: extract buildMenubarPayloadForRange for reuse by MCP"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Project-name redaction
|
||||
|
||||
**Files:**
|
||||
- Create: `src/mcp/redact.ts`
|
||||
- Test: `tests/mcp-redact.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `tests/mcp-redact.test.ts`:
|
||||
|
||||
```ts
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { pseudonym, redactProjectNames } from '../src/mcp/redact.js'
|
||||
import type { MenubarPayload } from '../src/menubar-json.js'
|
||||
|
||||
function payload(): MenubarPayload {
|
||||
const base = {
|
||||
name: 'secret-client-repo', cost: 5, sessions: 2, avgCostPerSession: 2.5, sessionDetails: [],
|
||||
}
|
||||
return {
|
||||
generated: '', optimize: { findingCount: 0, savingsUSD: 0, topFindings: [] }, history: { daily: [] },
|
||||
current: {
|
||||
label: 'Today', cost: 5, calls: 10, sessions: 2, oneShotRate: null, inputTokens: 0, outputTokens: 0,
|
||||
cacheHitPercent: 0, topActivities: [], topModels: [], providers: {},
|
||||
topProjects: [base], modelEfficiency: [],
|
||||
topSessions: [{ project: 'secret-client-repo', cost: 5, calls: 10, date: '2026-06-01' }],
|
||||
retryTax: { totalUSD: 0, retries: 0, editTurns: 0, byModel: [] },
|
||||
routingWaste: { totalSavingsUSD: 0, baselineModel: '', baselineCostPerEdit: 0, byModel: [] },
|
||||
tools: [], skills: [], subagents: [], mcpServers: [],
|
||||
},
|
||||
} as MenubarPayload
|
||||
}
|
||||
|
||||
describe('redact', () => {
|
||||
it('pseudonym is stable and path-free', () => {
|
||||
expect(pseudonym('a')).toBe(pseudonym('a'))
|
||||
expect(pseudonym('secret-client-repo')).toMatch(/^project-[0-9a-f]{6}$/)
|
||||
})
|
||||
it('hashes project names by default, preserves numbers', () => {
|
||||
const out = redactProjectNames(payload(), false)
|
||||
expect(out.current.topProjects[0]!.name).toMatch(/^project-[0-9a-f]{6}$/)
|
||||
expect(out.current.topSessions[0]!.project).toMatch(/^project-[0-9a-f]{6}$/)
|
||||
expect(out.current.topProjects[0]!.cost).toBe(5)
|
||||
})
|
||||
it('keeps real names when include=true', () => {
|
||||
const out = redactProjectNames(payload(), true)
|
||||
expect(out.current.topProjects[0]!.name).toBe('secret-client-repo')
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify failure**
|
||||
|
||||
Run: `npm test -- mcp-redact`
|
||||
Expected: FAIL ("Cannot find module '../src/mcp/redact.js'").
|
||||
|
||||
- [ ] **Step 3: Implement**
|
||||
|
||||
Create `src/mcp/redact.ts`:
|
||||
|
||||
```ts
|
||||
import { createHash } from 'node:crypto'
|
||||
import type { MenubarPayload } from '../menubar-json.js'
|
||||
|
||||
export function pseudonym(name: string): string {
|
||||
return `project-${createHash('sha256').update(name).digest('hex').slice(0, 6)}`
|
||||
}
|
||||
|
||||
export function redactProjectNames(payload: MenubarPayload, includeNames: boolean): MenubarPayload {
|
||||
if (includeNames) return payload
|
||||
return {
|
||||
...payload,
|
||||
current: {
|
||||
...payload.current,
|
||||
topProjects: payload.current.topProjects.map(p => ({ ...p, name: pseudonym(p.name) })),
|
||||
topSessions: payload.current.topSessions.map(s => ({ ...s, project: pseudonym(s.project) })),
|
||||
},
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run to verify pass**
|
||||
|
||||
Run: `npm test -- mcp-redact`
|
||||
Expected: PASS (3 tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/mcp/redact.ts tests/mcp-redact.test.ts
|
||||
git commit -m "feat(mcp): hash project names by default with opt-in reveal"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Markdown table renderers
|
||||
|
||||
**Files:**
|
||||
- Create: `src/mcp/tables.ts`
|
||||
- Test: `tests/mcp-tables.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing test**
|
||||
|
||||
Create `tests/mcp-tables.test.ts`:
|
||||
|
||||
```ts
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { renderSummaryTable, renderBreakdownTable, renderSavingsTable } from '../src/mcp/tables.js'
|
||||
import type { MenubarPayload } from '../src/menubar-json.js'
|
||||
|
||||
function payload(): MenubarPayload {
|
||||
return {
|
||||
generated: '', optimize: { findingCount: 1, savingsUSD: 2.5, topFindings: [{ title: 'Trim system prompt', impact: 'high', savingsUSD: 2.5 }] }, history: { daily: [] },
|
||||
current: {
|
||||
label: 'Last 7 Days', cost: 12.5, calls: 100, sessions: 4, oneShotRate: 0.5, inputTokens: 1000, outputTokens: 500,
|
||||
cacheHitPercent: 80, topActivities: [{ name: 'feature', cost: 8, turns: 30, oneShotRate: 0.6 }],
|
||||
topModels: [{ name: 'Opus 4.8', cost: 10, calls: 60 }], providers: { 'claude code': 12.5 },
|
||||
topProjects: [{ name: 'project-abc123', cost: 12.5, sessions: 4, avgCostPerSession: 3.125, sessionDetails: [] }],
|
||||
modelEfficiency: [], topSessions: [],
|
||||
retryTax: { totalUSD: 1.2, retries: 4, editTurns: 20, byModel: [{ name: 'Opus 4.8', taxUSD: 1.2, retries: 4, retriesPerEdit: 0.2 }] },
|
||||
routingWaste: { totalSavingsUSD: 3, baselineModel: 'Haiku 4.5', baselineCostPerEdit: 0.01, byModel: [{ name: 'Opus 4.8', costPerEdit: 0.05, editTurns: 20, actualUSD: 1, counterfactualUSD: 0.2, savingsUSD: 0.8 }] },
|
||||
tools: [], skills: [], subagents: [], mcpServers: [],
|
||||
},
|
||||
} as MenubarPayload
|
||||
}
|
||||
|
||||
describe('tables', () => {
|
||||
it('summary shows headline cost and top models', () => {
|
||||
const t = renderSummaryTable(payload())
|
||||
expect(t).toContain('Last 7 Days')
|
||||
expect(t).toContain('Opus 4.8')
|
||||
expect(t).toContain('| Model | Cost | Calls |')
|
||||
})
|
||||
it('breakdown by provider lists providers', () => {
|
||||
expect(renderBreakdownTable(payload(), 'provider', 20)).toContain('claude code')
|
||||
})
|
||||
it('breakdown handles empty dimension gracefully', () => {
|
||||
const p = payload(); p.current.topActivities = []
|
||||
expect(renderBreakdownTable(p, 'task', 20)).toContain('no data')
|
||||
})
|
||||
it('savings shows retry tax and routing waste', () => {
|
||||
const t = renderSavingsTable(payload())
|
||||
expect(t).toContain('Retry tax')
|
||||
expect(t).toContain('Routing waste')
|
||||
expect(t).toContain('Trim system prompt')
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify failure**
|
||||
|
||||
Run: `npm test -- mcp-tables`
|
||||
Expected: FAIL ("Cannot find module '../src/mcp/tables.js'").
|
||||
|
||||
- [ ] **Step 3: Implement**
|
||||
|
||||
Create `src/mcp/tables.ts`:
|
||||
|
||||
```ts
|
||||
import { formatCost, formatTokens } from '../format.js'
|
||||
import type { MenubarPayload } from '../menubar-json.js'
|
||||
|
||||
export type BreakdownBy = 'project' | 'model' | 'task' | 'provider'
|
||||
|
||||
function mdTable(headers: string[], rows: string[][]): string {
|
||||
const head = `| ${headers.join(' | ')} |`
|
||||
const sep = `| ${headers.map(() => '---').join(' | ')} |`
|
||||
if (rows.length === 0) return `${head}\n${sep}\n| _(no data)_ |${' |'.repeat(headers.length - 1)}`
|
||||
return [head, sep, ...rows.map(r => `| ${r.join(' | ')} |`)].join('\n')
|
||||
}
|
||||
|
||||
const pct = (n: number) => `${Math.round(n)}%`
|
||||
const oneShot = (r: number | null) => (r === null ? 'n/a' : pct(r * 100))
|
||||
|
||||
export function renderSummaryTable(p: MenubarPayload): string {
|
||||
const c = p.current
|
||||
return [
|
||||
`**${c.label}** — ${formatCost(c.cost)} · ${c.calls} calls · ${c.sessions} sessions`,
|
||||
`cache hit ${pct(c.cacheHitPercent)} · one-shot ${oneShot(c.oneShotRate)} · in ${formatTokens(c.inputTokens)} / out ${formatTokens(c.outputTokens)}`,
|
||||
'',
|
||||
'_Top models_',
|
||||
mdTable(['Model', 'Cost', 'Calls'], c.topModels.slice(0, 5).map(m => [m.name, formatCost(m.cost), String(m.calls)])),
|
||||
'',
|
||||
'_Top projects_',
|
||||
mdTable(['Project', 'Cost', 'Sessions'], c.topProjects.slice(0, 5).map(x => [x.name, formatCost(x.cost), String(x.sessions)])),
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
export function renderBreakdownTable(p: MenubarPayload, by: BreakdownBy, limit: number): string {
|
||||
const c = p.current
|
||||
if (by === 'model') return mdTable(['Model', 'Cost', 'Calls'], c.topModels.slice(0, limit).map(m => [m.name, formatCost(m.cost), String(m.calls)]))
|
||||
if (by === 'project') return mdTable(['Project', 'Cost', 'Sessions'], c.topProjects.slice(0, limit).map(x => [x.name, formatCost(x.cost), String(x.sessions)]))
|
||||
if (by === 'task') return mdTable(['Task', 'Cost', 'Turns', 'One-shot'], c.topActivities.slice(0, limit).map(a => [a.name, formatCost(a.cost), String(a.turns), oneShot(a.oneShotRate)]))
|
||||
return mdTable(['Provider', 'Cost'], Object.entries(c.providers).sort(([, a], [, b]) => b - a).slice(0, limit).map(([name, cost]) => [name, formatCost(cost)]))
|
||||
}
|
||||
|
||||
export function renderSavingsTable(p: MenubarPayload): string {
|
||||
const c = p.current
|
||||
const findings = mdTable(['Finding', 'Impact', 'Saves'], p.optimize.topFindings.slice(0, 10).map(f => [f.title, f.impact, formatCost(f.savingsUSD)]))
|
||||
const retry = mdTable(['Model', 'Retry tax', 'Retries'], c.retryTax.byModel.map(m => [m.name, formatCost(m.taxUSD), String(m.retries)]))
|
||||
const routing = mdTable(['Model', 'Overpaid', 'vs baseline'], c.routingWaste.byModel.map(m => [m.name, formatCost(m.savingsUSD), c.routingWaste.baselineModel]))
|
||||
return [
|
||||
`**Savings — ${c.label}**`,
|
||||
`Optimize findings: ${p.optimize.findingCount} (≈ ${formatCost(p.optimize.savingsUSD)})`,
|
||||
findings, '',
|
||||
`_Retry tax_ — ${formatCost(c.retryTax.totalUSD)} on ${c.retryTax.retries} retries`,
|
||||
retry, '',
|
||||
`_Routing waste_ — ${formatCost(c.routingWaste.totalSavingsUSD)} vs ${c.routingWaste.baselineModel || 'n/a'}`,
|
||||
routing,
|
||||
].join('\n')
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run to verify pass**
|
||||
|
||||
Run: `npm test -- mcp-tables`
|
||||
Expected: PASS (4 tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/mcp/tables.ts tests/mcp-tables.test.ts
|
||||
git commit -m "feat(mcp): markdown table renderers for usage and savings"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: MCP server (tools, schemas, handlers, coalescing)
|
||||
|
||||
**Files:**
|
||||
- Create: `src/mcp/server.ts`
|
||||
- Test: `tests/mcp-server.test.ts`
|
||||
|
||||
- [ ] **Step 1: Write the failing test (in-memory transport, injected aggregator)**
|
||||
|
||||
Create `tests/mcp-server.test.ts`:
|
||||
|
||||
```ts
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
||||
import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js'
|
||||
import { createServer } from '../src/mcp/server.js'
|
||||
import type { MenubarPayload } from '../src/menubar-json.js'
|
||||
|
||||
function fakePayload(calls = 100): MenubarPayload {
|
||||
return {
|
||||
generated: '', optimize: { findingCount: 1, savingsUSD: 2, topFindings: [{ title: 'X', impact: 'high', savingsUSD: 2 }] }, history: { daily: [] },
|
||||
current: {
|
||||
label: 'Today', cost: 9, calls, sessions: 1, oneShotRate: 0.5, inputTokens: 10, outputTokens: 5, cacheHitPercent: 50,
|
||||
topActivities: [{ name: 'feature', cost: 9, turns: 5, oneShotRate: 0.5 }], topModels: [{ name: 'Opus 4.8', cost: 9, calls }],
|
||||
providers: { 'claude code': 9 }, topProjects: [{ name: 'real-repo', cost: 9, sessions: 1, avgCostPerSession: 9, sessionDetails: [] }],
|
||||
modelEfficiency: [], topSessions: [{ project: 'real-repo', cost: 9, calls, date: '2026-06-01' }],
|
||||
retryTax: { totalUSD: 1, retries: 2, editTurns: 5, byModel: [{ name: 'Opus 4.8', taxUSD: 1, retries: 2, retriesPerEdit: 0.4 }] },
|
||||
routingWaste: { totalSavingsUSD: 1, baselineModel: 'Haiku 4.5', baselineCostPerEdit: 0.01, byModel: [] },
|
||||
tools: [], skills: [], subagents: [], mcpServers: [],
|
||||
},
|
||||
} as MenubarPayload
|
||||
}
|
||||
|
||||
async function connect(aggregate: any) {
|
||||
const server = createServer({ version: 'test', aggregate })
|
||||
const [a, b] = InMemoryTransport.createLinkedPair()
|
||||
const client = new Client({ name: 'test', version: '1' })
|
||||
await Promise.all([server.connect(a), client.connect(b)])
|
||||
return client
|
||||
}
|
||||
|
||||
describe('mcp server', () => {
|
||||
it('exposes exactly two read-only tools', async () => {
|
||||
const client = await connect(async () => fakePayload())
|
||||
const { tools } = await client.listTools()
|
||||
expect(tools.map(t => t.name).sort()).toEqual(['get_savings', 'get_usage'])
|
||||
expect(tools.find(t => t.name === 'get_usage')!.annotations?.readOnlyHint).toBe(true)
|
||||
})
|
||||
it('get_usage hashes project names by default', async () => {
|
||||
const client = await connect(async () => fakePayload())
|
||||
const res: any = await client.callTool({ name: 'get_usage', arguments: { period: 'today', by: 'project' } })
|
||||
expect(JSON.stringify(res)).not.toContain('real-repo')
|
||||
expect(JSON.stringify(res)).toMatch(/project-[0-9a-f]{6}/)
|
||||
expect(res.isError).toBeFalsy()
|
||||
})
|
||||
it('get_usage reveals names when opted in', async () => {
|
||||
const client = await connect(async () => fakePayload())
|
||||
const res: any = await client.callTool({ name: 'get_usage', arguments: { period: 'today', by: 'project', include_project_names: true } })
|
||||
expect(JSON.stringify(res)).toContain('real-repo')
|
||||
})
|
||||
it('empty data returns a friendly message, not a zero table', async () => {
|
||||
const client = await connect(async () => fakePayload(0))
|
||||
const res: any = await client.callTool({ name: 'get_usage', arguments: { period: 'today' } })
|
||||
expect(res.content[0].text.toLowerCase()).toContain('no usage')
|
||||
})
|
||||
it('aggregator failure surfaces as isError', async () => {
|
||||
const client = await connect(async () => { throw new Error('boom') })
|
||||
const res: any = await client.callTool({ name: 'get_savings', arguments: {} })
|
||||
expect(res.isError).toBe(true)
|
||||
expect(res.content[0].text).toContain('boom')
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Run to verify failure**
|
||||
|
||||
Run: `npm test -- mcp-server`
|
||||
Expected: FAIL ("Cannot find module '../src/mcp/server.js'").
|
||||
|
||||
- [ ] **Step 3: Implement the server**
|
||||
|
||||
Create `src/mcp/server.ts`:
|
||||
|
||||
```ts
|
||||
import { z } from 'zod'
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
||||
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js'
|
||||
import { getDateRange } from '../cli-date.js'
|
||||
import { loadPricing } from '../models.js'
|
||||
import { buildMenubarPayloadForRange, type PeriodInfo } from '../usage-aggregator.js'
|
||||
import type { MenubarPayload } from '../menubar-json.js'
|
||||
import { redactProjectNames } from './redact.js'
|
||||
import { renderSummaryTable, renderBreakdownTable, renderSavingsTable, type BreakdownBy } from './tables.js'
|
||||
|
||||
const PERIOD = { today: 'today', last_7_days: 'week', last_30_days: '30days', month_to_date: 'month', last_6_months: 'all' } as const
|
||||
type McpPeriod = keyof typeof PERIOD
|
||||
const periodSchema = z.enum(['today', 'last_7_days', 'last_30_days', 'month_to_date', 'last_6_months'])
|
||||
|
||||
type Aggregate = (periodInfo: PeriodInfo, opts: { provider?: string; optimize?: boolean }) => Promise<MenubarPayload>
|
||||
|
||||
const INSTRUCTIONS =
|
||||
'CodeBurn exposes local AI-coding spend data. Use get_usage for spend/usage and breakdowns (fast); ' +
|
||||
'use get_savings to find cost reductions (slower — runs a deeper analysis). Project names are pseudonymized ' +
|
||||
'unless include_project_names is true. All data is read locally from this machine; last_6_months is the widest ' +
|
||||
'window. Numbers reflect the most recent scan and may lag the current session by up to a few minutes.'
|
||||
|
||||
export function createServer(deps: { version: string; aggregate?: Aggregate }): McpServer {
|
||||
const aggregate = deps.aggregate ?? buildMenubarPayloadForRange
|
||||
const inflight = new Map<string, Promise<MenubarPayload>>()
|
||||
|
||||
const getPayload = (period: McpPeriod, optimize: boolean): Promise<MenubarPayload> => {
|
||||
const key = `${optimize ? 'sav' : 'use'}:${period}`
|
||||
const existing = inflight.get(key)
|
||||
if (existing) return existing
|
||||
const { range, label } = getDateRange(PERIOD[period])
|
||||
const p = aggregate({ range, label }, { provider: 'all', optimize }).finally(() => inflight.delete(key))
|
||||
inflight.set(key, p)
|
||||
return p
|
||||
}
|
||||
|
||||
const server = new McpServer({ name: 'codeburn', version: deps.version }, { instructions: INSTRUCTIONS })
|
||||
|
||||
server.registerTool(
|
||||
'get_usage',
|
||||
{
|
||||
title: 'CodeBurn — usage & cost',
|
||||
description:
|
||||
'Show AI coding token spend and usage for a period. Omit `by` for a headline summary; set `by` to break ' +
|
||||
'it down by project, model, task, or provider (Claude Code / Cursor / Codex). Fast. Local to this machine.',
|
||||
inputSchema: {
|
||||
period: periodSchema.default('today'),
|
||||
by: z.enum(['project', 'model', 'task', 'provider']).optional(),
|
||||
limit: z.number().int().min(1).max(100).default(20),
|
||||
include_project_names: z.boolean().default(false),
|
||||
},
|
||||
outputSchema: {
|
||||
period: z.string(),
|
||||
empty: z.boolean(),
|
||||
totals: z.object({ costUSD: z.number(), calls: z.number(), sessions: z.number(), cacheHitPercent: z.number(), oneShotRate: z.number().nullable() }),
|
||||
breakdown: z.array(z.object({ name: z.string(), costUSD: z.number() })).nullable(),
|
||||
},
|
||||
annotations: { title: 'CodeBurn — usage & cost', readOnlyHint: true, openWorldHint: false, idempotentHint: true },
|
||||
},
|
||||
async ({ period, by, limit, include_project_names }): Promise<CallToolResult> => {
|
||||
try {
|
||||
const payload = redactProjectNames(await getPayload(period, false), include_project_names)
|
||||
const c = payload.current
|
||||
const totals = { costUSD: c.cost, calls: c.calls, sessions: c.sessions, cacheHitPercent: c.cacheHitPercent, oneShotRate: c.oneShotRate }
|
||||
if (c.calls === 0) {
|
||||
return { content: [{ type: 'text', text: `No usage recorded for ${c.label} yet — run some coding sessions and try again.` }], structuredContent: { period: c.label, empty: true, totals, breakdown: null } }
|
||||
}
|
||||
const text = by ? renderBreakdownTable(payload, by as BreakdownBy, limit) : renderSummaryTable(payload)
|
||||
const breakdown = by ? breakdownRows(payload, by as BreakdownBy, limit) : null
|
||||
return { content: [{ type: 'text', text }], structuredContent: { period: c.label, empty: false, totals, breakdown } }
|
||||
} catch (err) {
|
||||
return { content: [{ type: 'text', text: `codeburn: failed to read usage — ${err instanceof Error ? err.message : String(err)}` }], isError: true }
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
server.registerTool(
|
||||
'get_savings',
|
||||
{
|
||||
title: 'CodeBurn — savings opportunities',
|
||||
description:
|
||||
'Find ways to reduce AI coding cost for a period: optimization findings, retry tax (money spent re-doing ' +
|
||||
'work), and routing waste (what you would have saved on a cheaper model). Slower than get_usage.',
|
||||
inputSchema: { period: periodSchema.default('last_7_days'), include_project_names: z.boolean().default(false) },
|
||||
outputSchema: {
|
||||
period: z.string(),
|
||||
optimize: z.object({ findingCount: z.number(), savingsUSD: z.number(), topFindings: z.array(z.object({ title: z.string(), impact: z.string(), savingsUSD: z.number() })) }),
|
||||
retryTaxUSD: z.number(),
|
||||
routingWasteUSD: z.number(),
|
||||
},
|
||||
annotations: { title: 'CodeBurn — savings opportunities', readOnlyHint: true, openWorldHint: false, idempotentHint: true },
|
||||
},
|
||||
async ({ period, include_project_names }): Promise<CallToolResult> => {
|
||||
try {
|
||||
const payload = redactProjectNames(await getPayload(period, true), include_project_names)
|
||||
const c = payload.current
|
||||
return {
|
||||
content: [{ type: 'text', text: renderSavingsTable(payload) }],
|
||||
structuredContent: { period: c.label, optimize: payload.optimize, retryTaxUSD: c.retryTax.totalUSD, routingWasteUSD: c.routingWaste.totalSavingsUSD },
|
||||
}
|
||||
} catch (err) {
|
||||
return { content: [{ type: 'text', text: `codeburn: failed to compute savings — ${err instanceof Error ? err.message : String(err)}` }], isError: true }
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
return server
|
||||
}
|
||||
|
||||
function breakdownRows(p: MenubarPayload, by: BreakdownBy, limit: number): Array<{ name: string; costUSD: number }> {
|
||||
const c = p.current
|
||||
if (by === 'model') return c.topModels.slice(0, limit).map(m => ({ name: m.name, costUSD: m.cost }))
|
||||
if (by === 'project') return c.topProjects.slice(0, limit).map(x => ({ name: x.name, costUSD: x.cost }))
|
||||
if (by === 'task') return c.topActivities.slice(0, limit).map(a => ({ name: a.name, costUSD: a.cost }))
|
||||
return Object.entries(c.providers).sort(([, a], [, b]) => b - a).slice(0, limit).map(([name, cost]) => ({ name, costUSD: cost }))
|
||||
}
|
||||
|
||||
export async function startStdioServer(version: string): Promise<void> {
|
||||
await loadPricing()
|
||||
const server = createServer({ version })
|
||||
// Pre-warm the parser cache for the common case; ignore failures.
|
||||
void buildMenubarPayloadForRange(getDateRange('today'), { provider: 'all', optimize: false }).catch(() => {})
|
||||
await server.connect(new StdioServerTransport())
|
||||
}
|
||||
```
|
||||
|
||||
> If the installed SDK rejects the raw-shape `inputSchema`/`outputSchema` (object of zod validators), wrap each in `z.object({ ... })` — the in-memory test in Step 4 will surface this immediately. Likewise, if `InMemoryTransport`'s import path differs, it is exported from `@modelcontextprotocol/sdk/inMemory.js` in v1.
|
||||
|
||||
- [ ] **Step 4: Run to verify pass**
|
||||
|
||||
Run: `npm test -- mcp-server`
|
||||
Expected: PASS (5 tests).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/mcp/server.ts tests/mcp-server.test.ts
|
||||
git commit -m "feat(mcp): get_usage + get_savings tools with annotations, schemas, coalescing"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Wire the `codeburn mcp` command
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/main.ts` (add command + stdout guard)
|
||||
|
||||
- [ ] **Step 1: Add the command**
|
||||
|
||||
In `src/main.ts`, alongside the other `.command(...)` registrations (e.g. after the `status` block), add:
|
||||
|
||||
```ts
|
||||
program
|
||||
.command('mcp')
|
||||
.description('Run a Model Context Protocol server (stdio) exposing usage + savings to AI agents')
|
||||
.action(async () => {
|
||||
// stdout MUST carry only JSON-RPC; route stray logs to stderr.
|
||||
console.log = ((...args: unknown[]) => process.stderr.write(args.join(' ') + '\n')) as typeof console.log
|
||||
const { startStdioServer } = await import('./mcp/server.js')
|
||||
await startStdioServer(version)
|
||||
})
|
||||
```
|
||||
|
||||
(`version` is already in scope at `main.ts:30`.)
|
||||
|
||||
- [ ] **Step 2: Build**
|
||||
|
||||
Run: `npm run build`
|
||||
Expected: succeeds. `src/mcp/server.ts` is reachable from the `main.ts` import graph (via the dynamic import), so tsup bundles it; the `@modelcontextprotocol/sdk` and `zod` imports stay external (Task 1).
|
||||
|
||||
- [ ] **Step 3: Smoke-test the built server over stdio**
|
||||
|
||||
Run:
|
||||
```bash
|
||||
printf '%s\n' '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-06-18","capabilities":{},"clientInfo":{"name":"smoke","version":"1"}}}' '{"jsonrpc":"2.0","id":2,"method":"tools/list"}' | node dist/cli.js mcp 2>/dev/null | head -2
|
||||
```
|
||||
Expected: two JSON-RPC result lines on stdout; the second lists `get_usage` and `get_savings`. No non-JSON noise on stdout (warnings, if any, went to stderr).
|
||||
|
||||
- [ ] **Step 4: Verify the SDK is external in the bundle**
|
||||
|
||||
Run: `node -e "const s=require('fs').readFileSync('dist/main.js','utf8'); console.log('McpServer source inlined?', /class McpServer/.test(s))"`
|
||||
Expected: `McpServer source inlined? false` (it's imported from node_modules at runtime, not bundled).
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/main.ts
|
||||
git commit -m "feat(mcp): add 'codeburn mcp' stdio command with stdout guard"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Full suite + final verification
|
||||
|
||||
**Files:** none (verification)
|
||||
|
||||
- [ ] **Step 1: Run everything**
|
||||
|
||||
Run: `npx tsc --noEmit && npm test && npm run build`
|
||||
Expected: typecheck clean; all tests pass (incl. the pre-existing suite — parity preserved); build succeeds.
|
||||
|
||||
- [ ] **Step 2: Manual end-to-end against real data (optional but recommended)**
|
||||
|
||||
Run: `node dist/cli.js mcp` then, in another shell, point a local MCP client (or the smoke command from Task 7) at it and call `get_usage {"period":"today"}` and `get_savings {"period":"last_7_days"}`. Confirm tables render and project names are hashed.
|
||||
|
||||
- [ ] **Step 3: Commit any cleanup**
|
||||
|
||||
```bash
|
||||
git add -A && git commit -m "chore(mcp): final verification" || echo "nothing to commit"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deferred (not in v1 — see spec §10 and note below)
|
||||
|
||||
- **Per-provider graceful degradation (`degraded[]`).** The spec (§5/§8) called for `allSettled` per-provider isolation. That requires changing the shared `parseAllSessions` loop (`parser.ts:2133`), which every command uses — out of scope for v1 to avoid destabilizing the parser. v1 handles failures at the tool boundary (`isError: true` with the message). A malformed provider aborting a scan is a **pre-existing** behavior shared with the `status`/menubar path, not introduced here. *This is the one intentional deviation from the committed spec.*
|
||||
- HTTP/SSE transport, `compare_periods`, MCP prompts/resources, README + registry listings.
|
||||
|
||||
## Self-Review
|
||||
|
||||
- **Spec coverage:** in-process stdio server (Task 6/7) ✓; cheap vs optimize split via `optimize` flag (Task 3, refined — `scanAndDetect` is the only expensive call) ✓; 2 tools with annotations + outputSchema + isError (Task 6) ✓; hash-by-default redaction (Task 4) ✓; in-flight coalescing + pre-warm (Task 6) ✓; tsup external + pinned deps (Task 1) ✓; stdout guard (Task 7) ✓; period-enum rename + `last_6_months` semantics (Task 6 + instructions) ✓; empty-state message (Task 6) ✓; token discipline via `limit` + summarized history (Task 6, no daily array returned) ✓. Deviation: per-provider `degraded[]` deferred (documented above).
|
||||
- **Placeholders:** none — every code step has full source; the only "move verbatim" steps (Tasks 2–3) are mechanical relocations of existing, cited code, gated by the existing parity test.
|
||||
- **Type consistency:** `buildMenubarPayloadForRange(PeriodInfo, AggregateOpts)`, `redactProjectNames(MenubarPayload, boolean)`, `renderBreakdownTable(payload, BreakdownBy, limit)`, `createServer({version, aggregate})` — names/signatures consistent across tasks and tests.
|
||||
@@ -0,0 +1,125 @@
|
||||
# CodeBurn MCP Server — Design Spec
|
||||
|
||||
- **Date:** 2026-06-02
|
||||
- **Status:** Approved design (pre-implementation)
|
||||
- **Author:** brainstormed + validator/devil's-advocate hardened
|
||||
|
||||
## 1. Context & Goal
|
||||
|
||||
CodeBurn already aggregates rich AI-coding usage/cost data (by task, model, project, provider; retry tax; routing waste; optimize findings; 365-day history). This spec adds an **MCP server** that exposes that data to AI agents (Claude Code, Cursor, Claude Desktop) so an agent can answer "where did my tokens go?" and "how do I spend less?" mid-conversation.
|
||||
|
||||
**Why (and why not):** This is a **product / differentiation** play, not a downloads play. We measured that MCP is a niche *download* lever (`@ccusage/mcp` ≈ 1.1% of ccusage's downloads; competitor tokscale ships no MCP), and the real download lever is npx-first CLI positioning — tracked separately. The MCP's value is agent-facing utility on data competitors don't expose (retry tax, routing waste, one-shot rate, task attribution).
|
||||
|
||||
## 2. Decisions (from brainstorming)
|
||||
|
||||
1. **Use case:** unified — serves both live self-optimization and historical analysis from one tool set.
|
||||
2. **Output contract:** every tool returns a ready-to-display **markdown table** *and* the same data as **structured JSON**.
|
||||
3. **Architecture:** Approach A — a `codeburn mcp` subcommand on the existing CLI (no second package), reusing the existing aggregation; run as a **long-lived in-process stdio server**.
|
||||
4. **Tool surface:** **2 tools** — `get_usage` and `get_savings`. (`compare_periods` cut — no reusable backend, overlap-incoherent.)
|
||||
5. **Privacy:** project/session names **hashed by default** (`project-<6hex>`); real names only when `include_project_names: true`. Absolute paths never exposed.
|
||||
|
||||
## 3. Architecture
|
||||
|
||||
### Process model
|
||||
- `codeburn mcp` starts a **resident, in-process** MCP server over **stdio** (`StdioServerTransport`). Not exec-per-call — a resident process is required so the existing in-process session cache (180s TTL, `src/parser.ts`) amortizes across tool calls. Measured cost otherwise: `--period all` is ~17.6s **even warm** when the process exits between calls.
|
||||
- **First line of the `mcp` action:** `console.log = console.error`. The aggregation path is stdout-clean today (writes go to stderr), but the global `preAction` hook and `runOptimize` contain stdout `console.log`s; reassigning immunizes the JSON-RPC stream against any present or future stdout write. (Verified: `scanAndDetect`, `parseAllSessions`, providers, caches, `buildMenubarPayload` do not write to stdout.)
|
||||
- Pre-warm `today` usage on boot so the first interactive call is fast.
|
||||
|
||||
### Modules
|
||||
- **`src/usage-aggregator.ts`** *(new)* — extracted from the inline logic in the `status` handler (`src/main.ts` ~476–760):
|
||||
- `buildUsage(period, opts): Promise<MenubarPayload>` — **cheap path**, no optimize pass.
|
||||
- `buildSavings(period, opts): Promise<MenubarPayload>` — adds `scanAndDetect` (optimize findings + retry tax + routing waste).
|
||||
- `opts: { provider?: string; project?: string[]; exclude?: string[]; range?: ResolvedRange }`. The `provider` and project/range filters are **required** for parity — the `status` body forks on `isAllProviders` and resolves a range from `day/days/from/to` before `period`. `status --format menubar-json` is refactored to call these (one shared path).
|
||||
- **`src/mcp/server.ts`** *(new)* — builds `McpServer`, registers the 2 tools, connects `StdioServerTransport`, owns the in-flight coalescing map and the empty-state messages.
|
||||
- **`src/mcp/tables.ts`** *(new)* — compact markdown renderers per slice, reusing `format.ts` and `models-report.ts` where they already render tables.
|
||||
- **`src/mcp/redact.ts`** *(new)* — stable pseudonym hashing for project/session names; applied unless the caller passes `include_project_names: true`.
|
||||
- **`src/main.ts`** — add `.command('mcp')` that dynamic-`import()`s `./mcp/server.js` and starts it.
|
||||
|
||||
### Dependencies & build
|
||||
- Add to **`dependencies`**: `@modelcontextprotocol/sdk@^1.29.0` (v1 line; import paths `@modelcontextprotocol/sdk/server/mcp.js` and `/server/stdio.js`) and `zod@^3.25` (NOT transitive — it's a peer dep of the SDK and absent from the lockfile today).
|
||||
- Add to **`tsup.config.ts`**: `external: ['@modelcontextprotocol/sdk', 'zod']`. The current config bundles all deps (`splitting: false`, no `external`), so without this a dynamic import would inline the SDK (~MBs) into `dist/main.js`. Externalizing keeps `dist` small and makes the dynamic import a real lazy load from `node_modules`. (`files: ["dist"]` means externalized deps must be runtime `dependencies`.)
|
||||
- Pin the SDK major: a separately-named v2 package exists with different import paths; `^1.29.0` keeps the v1 paths valid.
|
||||
|
||||
## 4. Tool Surface
|
||||
|
||||
Period enum (LLM-clear names, mapped internally): `today→today`, `last_7_days→week`, `last_30_days→30days`, `month_to_date→month`, `last_6_months→all`. Documented: `last_6_months` is the maximum window (codeburn's "all" = ~6 months); history is summarized, not dumped.
|
||||
|
||||
Both tools carry annotations `{ readOnlyHint: true, openWorldHint: false, idempotentHint: true, title }`, a zod `inputSchema` and `outputSchema`, and return `{ content: [{ type:'text', text: <markdown table> }], structuredContent: <object matching outputSchema> }`.
|
||||
|
||||
### 4.1 `get_usage`
|
||||
- **title:** "CodeBurn — usage & cost"
|
||||
- **description (agent-facing):** "Show AI coding token spend and usage for a period. Omit `by` for a headline summary; set `by` to break it down by project, model, task, or provider. Fast (does not run the deeper savings analysis). Data is local to this machine and current as of the last scan."
|
||||
- **inputSchema:**
|
||||
- `period?: enum` (default `today`)
|
||||
- `by?: "project" | "model" | "task" | "provider"`
|
||||
- `limit?: number` (default 20, max 100) — row cap for breakdowns
|
||||
- `include_project_names?: boolean` (default `false`)
|
||||
- **Behavior:** no `by` → headline (cost, calls, sessions, input/output tokens, cache-hit %, one-shot rate) + top-N models/projects/tasks. With `by` → one ranked table for that dimension (`project→topProjects`, `model→topModels`, `task→topActivities`, `provider→providers` cost map). Uses `buildUsage` (cheap path).
|
||||
- **outputSchema:** the relevant subset of `MenubarPayload.current` (typed).
|
||||
|
||||
### 4.2 `get_savings`
|
||||
- **title:** "CodeBurn — savings opportunities"
|
||||
- **description (agent-facing):** "Find ways to reduce AI coding cost for a period: optimization findings, retry tax (money spent re-doing work), and routing waste (what you'd have saved on a cheaper model). Runs a deeper analysis, so it is slower than get_usage."
|
||||
- **inputSchema:** `period?: enum` (default `last_7_days` — never default to the slow `last_6_months`), `include_project_names?: boolean` (default `false`).
|
||||
- **Behavior:** runs `buildSavings`; returns optimize findings (title, impact, $ saved), retry tax (total + by model), routing waste (total savings, baseline model, by model).
|
||||
- **outputSchema:** `{ optimize, retryTax, routingWaste }` (typed).
|
||||
|
||||
### 4.3 Server `instructions`
|
||||
> "CodeBurn exposes local AI-coding spend data. Use `get_usage` for spend/usage and breakdowns (fast); use `get_savings` to find cost reductions (slower). Project names are pseudonymized unless you pass `include_project_names: true`. All data is read locally from this machine; `last_6_months` is the widest window. Numbers reflect the most recent scan and may lag the current in-flight session by a short interval."
|
||||
|
||||
## 5. Data Flow
|
||||
|
||||
```
|
||||
agent tool call
|
||||
→ zod inputSchema validation (invalid params → protocol error, auto)
|
||||
→ in-flight coalesce on {kind, period, opts-hash} (parallel callers share one scan)
|
||||
→ buildUsage / buildSavings(period, {provider:'all', ...})
|
||||
→ parseAllSessions with PER-PROVIDER allSettled isolation → degraded[]
|
||||
→ existing 180s in-process session cache amortizes
|
||||
→ redact project/session names unless include_project_names
|
||||
→ render markdown table + build structuredContent (validated vs outputSchema)
|
||||
→ return { content:[{type:'text',text}], structuredContent } (isError:true on failure)
|
||||
```
|
||||
|
||||
## 6. Privacy / Redaction
|
||||
- Name-bearing fields — `topProjects[].name`, `topSessions[].project`, `topProjects[].sessionDetails` — are replaced with stable pseudonyms `project-<6hex(sha256(name))>` unless `include_project_names: true`.
|
||||
- Absolute paths are never emitted (they aren't in the payload today; the MCP must not enrich with them).
|
||||
- Rationale: an MCP is an egress surface to possibly-remote/cloud agents; codeburn's brand is "data never leaves your machine." Hashing keeps breakdowns coherent (stable pseudonyms) without leaking identity; local users who want real names opt in per call.
|
||||
|
||||
## 7. Performance
|
||||
- **Two paths:** `get_usage` uses the cheap aggregation (`--no-optimize` seam already exists; ~3s `today`, ~5s `all`). `get_savings` runs the optimize pass (~+13s) — isolated to the one tool that needs it. Without this split, every tool paid the 13s tax.
|
||||
- **Resident process** + existing 180s session cache → warm calls are cheap.
|
||||
- **In-flight coalescing:** concurrent calls for the same `{kind, period, opts}` await a single scan (agents fire tools in parallel).
|
||||
- **Pre-warm** `today` usage on boot.
|
||||
- **Token discipline:** breakdowns capped at `limit` (default 20); history is summarized (totals + top-N), never the full 365-day array; tables are compact.
|
||||
|
||||
## 8. Error Handling
|
||||
- Invalid params → zod → MCP protocol error (auto).
|
||||
- No data for period (fresh install) → `isError: false` with a friendly "no usage recorded for <period> yet — run some coding sessions" message (not a zero-filled table).
|
||||
- One provider fails to parse → isolate via `allSettled`, return partial data, list skipped providers in `degraded[]` and a table footer note.
|
||||
- Aggregation throw / payload-shape mismatch → `isError: true` with a clear message (incl. version-skew hint); never hang the transport.
|
||||
|
||||
## 9. Testing
|
||||
- **Parity:** `buildUsage('today', {provider:'all'})` deep-equals current `status --format menubar-json --period today` (plus one more period). Guards the extraction.
|
||||
- **Per-tool:** fixture payload → expected markdown table (snapshot) + structuredContent keys; `by` each dimension; redaction on/off (no real names/paths leak when off; pseudonyms stable); empty-state message.
|
||||
- **MCP protocol smoke:** in-memory transport — `listTools` returns the 2 tools with annotations + outputSchema; call each; assert result shape (`content` + `structuredContent`) and `isError` paths; concurrent identical calls coalesce to one scan.
|
||||
- **Build:** assert SDK + zod are externalized (absent from `dist`) and declared in `dependencies`.
|
||||
|
||||
## 10. Out of Scope (v1 — YAGNI)
|
||||
- HTTP/SSE transport (stdio only).
|
||||
- `compare_periods` (agent can diff two `get_usage` calls; backend is net-new and overlap-incoherent).
|
||||
- Write/mutating tools, auth, multi-machine/remote data.
|
||||
- MCP **prompts** and **resources** primitives (tools-only v1; revisit if a "cost-review" prompt template proves useful).
|
||||
- README "MCP" section and MCP-registry listings (lobehub/Smithery/mcp.so) — follow-up tasks, not code.
|
||||
|
||||
## 11. Provenance — review findings incorporated
|
||||
Hardened by a validator + devil's-advocate pass:
|
||||
- **BLOCKER** in-process resident server (caches don't help exec-per-call) — §3, §7.
|
||||
- **BLOCKER** split cheap vs optimize path (optimize ≈ 70% of cost) — §7.
|
||||
- **BLOCKER** redact names by default (raw repo names + dated spend would egress) — §6.
|
||||
- **MAJOR** `buildUsage/buildSavings` signature carries provider/project/range for parity — §3.
|
||||
- **MAJOR** tsup `external` + deps in `dependencies`; pin SDK `^1.29.0`; add `zod` explicitly — §3.
|
||||
- **MAJOR** per-provider `allSettled` isolation + `degraded[]` — §5, §8.
|
||||
- **MAJOR** in-flight coalescing — §5, §7.
|
||||
- **MAJOR** drop `compare_periods`; rename period enums; default `today` — §4, §10.
|
||||
- **MINOR** `console.log→console.error` stdout guard; friendly empty-state; run compiled `dist` not `tsx`; validate payload shape; `last_6_months` is the real max — §3, §8, §4.
|
||||
@@ -0,0 +1,68 @@
|
||||
# Provider Docs
|
||||
|
||||
One file per provider integration. If you are fixing a bug or adding a feature scoped to a single provider, read the file for that provider first; it tells you which file to edit, where on disk the source data lives, and what edge cases the test suite already covers.
|
||||
|
||||
For the architectural picture, see `../architecture.md`.
|
||||
|
||||
## Provider Index
|
||||
|
||||
### Eager (always loaded)
|
||||
|
||||
| Provider | Storage | Source | Test |
|
||||
|---|---|---|---|
|
||||
| [Claude](claude.md) | JSONL (no parser) | `src/providers/claude.ts` | none (covered indirectly) |
|
||||
| [Cline](cline.md) | JSON | `src/providers/cline.ts` | `tests/providers/cline.test.ts` |
|
||||
| [Codex](codex.md) | JSONL | `src/providers/codex.ts` | `tests/providers/codex.test.ts` |
|
||||
| [Copilot](copilot.md) | JSONL + SQLite (OTel) + Nitrite .db (JetBrains) | `src/providers/copilot.ts` | `tests/providers/copilot.test.ts` |
|
||||
| [Devin](devin.md) | JSON + SQLite enrichment | `src/providers/devin.ts` | `tests/providers/devin.test.ts` |
|
||||
| [Droid](droid.md) | JSONL | `src/providers/droid.ts` | `tests/providers/droid.test.ts` |
|
||||
| [Gemini](gemini.md) | JSON / JSONL | `src/providers/gemini.ts` | none |
|
||||
| [Hermes Agent](hermes.md) | SQLite | `src/providers/hermes.ts` | `tests/providers/hermes.test.ts` |
|
||||
| [IBM Bob](ibm-bob.md) | JSON | `src/providers/ibm-bob.ts` | `tests/providers/ibm-bob.test.ts` |
|
||||
| [KiloCode](kilo-code.md) | JSON | `src/providers/kilo-code.ts` | `tests/providers/kilo-code.test.ts` |
|
||||
| [Kiro](kiro.md) | JSON | `src/providers/kiro.ts` | `tests/providers/kiro.test.ts` |
|
||||
| [Kimi](kimi.md) | JSONL | `src/providers/kimi.ts` | `tests/providers/kimi.test.ts` |
|
||||
| [LingTai TUI](lingtai-tui.md) | JSONL | `src/providers/lingtai-tui.ts` | `tests/providers/lingtai-tui.test.ts` |
|
||||
| [Mistral Vibe](mistral-vibe.md) | JSON / JSONL | `src/providers/mistral-vibe.ts` | `tests/providers/mistral-vibe.test.ts` |
|
||||
| [OpenClaw](openclaw.md) | JSONL | `src/providers/openclaw.ts` | `tests/providers/openclaw.test.ts` |
|
||||
| [Pi](pi.md) | JSONL | `src/providers/pi.ts` | `tests/providers/pi.test.ts` |
|
||||
| [OMP](omp.md) | JSONL | `src/providers/pi.ts` | `tests/providers/omp.test.ts` |
|
||||
| [Qwen](qwen.md) | JSONL | `src/providers/qwen.ts` | none |
|
||||
| [Roo Code](roo-code.md) | JSON | `src/providers/roo-code.ts` | `tests/providers/roo-code.test.ts` |
|
||||
| [Zerostack](zerostack.md) | JSON | `src/providers/zerostack.ts` | `tests/providers/zerostack.test.ts` |
|
||||
| [Grok Build](grok.md) | JSON/JSONL | `src/providers/grok.ts` | `tests/providers/grok.test.ts` |
|
||||
|
||||
### Lazy (loaded on first call)
|
||||
|
||||
| Provider | Storage | Source | Test |
|
||||
|---|---|---|---|
|
||||
| [Antigravity](antigravity.md) | protobuf over RPC | `src/providers/antigravity.ts` | none |
|
||||
| [Crush](crush.md) | SQLite (per-project) | `src/providers/crush.ts` | `tests/providers/crush.test.ts` |
|
||||
| [Forge](forge.md) | SQLite | `src/providers/forge.ts` | `tests/providers/forge.test.ts` |
|
||||
| [Cursor](cursor.md) | SQLite | `src/providers/cursor.ts` | `tests/providers/cursor.test.ts` |
|
||||
| [Cursor Agent](cursor-agent.md) | text / JSONL | `src/providers/cursor-agent.ts` | `tests/providers/cursor-agent.test.ts` |
|
||||
| [Goose](goose.md) | SQLite | `src/providers/goose.ts` | none |
|
||||
| [OpenCode](opencode.md) | SQLite | `src/providers/opencode.ts` | `tests/providers/opencode.test.ts` |
|
||||
| [Warp](warp.md) | SQLite | `src/providers/warp.ts` | `tests/providers/warp.test.ts` |
|
||||
| [Vercel AI Gateway](vercel-gateway.md) | REST API | `src/providers/vercel-gateway.ts` | `tests/providers/vercel-gateway.test.ts` |
|
||||
| [ZCode](zcode.md) | SQLite | `src/providers/zcode.ts` | `tests/providers/zcode.test.ts` |
|
||||
|
||||
### Shared
|
||||
|
||||
| Helper | Used by | Source |
|
||||
|---|---|---|
|
||||
| [vscode-cline-parser](vscode-cline-parser.md) | `cline`, `ibm-bob`, `kilo-code`, `roo-code` | `src/providers/vscode-cline-parser.ts` |
|
||||
|
||||
## File Format
|
||||
|
||||
Each provider doc has the same structure:
|
||||
|
||||
1. **One-line summary** of what the provider integrates.
|
||||
2. **Where it reads from** on disk (or over RPC).
|
||||
3. **Storage format** and validation rules.
|
||||
4. **Caching** (which cache layer, if any).
|
||||
5. **Deduplication key** so you understand cross-provider dedup.
|
||||
6. **Quirks** that have bitten us before.
|
||||
7. **When fixing a bug here** as a checklist.
|
||||
|
||||
If you add a new provider, copy `claude.md` as a template and fill in your provider's specifics. Update this index, and prefer adding a real test fixture under `tests/providers/`.
|
||||
@@ -0,0 +1,70 @@
|
||||
# Antigravity
|
||||
|
||||
Google Antigravity (CLI and IDE). CodeBurn discovers session files on disk and queries the local language-server RPC endpoint to parse them.
|
||||
|
||||
- **Source:** `src/providers/antigravity.ts`
|
||||
- **Loading:** lazy via `src/providers/index.ts`. Lazy because the protobuf dependency is heavy.
|
||||
- **Test:** focused helper coverage in `tests/providers/antigravity.test.ts`.
|
||||
|
||||
## Where it reads from
|
||||
|
||||
CodeBurn discovers Antigravity sessions from local directories on disk, then queries the live language-server process (if running) to fetch detailed trajectory generator metadata:
|
||||
|
||||
1. **Session Discovery:** It scans the following folders for `.pb` or `.db` files:
|
||||
- **Antigravity CLI:** `%USERPROFILE%\.gemini\antigravity-cli\conversations` (and `implicit`)
|
||||
- **Antigravity App/older path:** `%USERPROFILE%\.gemini\antigravity\conversations`
|
||||
- **Antigravity IDE (VSCode-based):** `%USERPROFILE%\.gemini\antigravity-ide\conversations` (and `implicit`)
|
||||
2. **Language Server RPC Query:** It locates the active language-server process via `ps` on POSIX or `Get-CimInstance Win32_Process` on Windows. It extracts the port and CSRF token from the process arguments, and queries the local HTTPS RPC endpoint `GetCascadeTrajectoryGeneratorMetadata` to parse the session.
|
||||
3. **Cache Fallback:** If the language server is not running, it falls back to the local results cache.
|
||||
|
||||
Antigravity exposes slightly different process flags across platforms:
|
||||
POSIX builds have used `--https_server_port` and `--csrf_token`; Windows
|
||||
builds can expose `--extension_server_port` and
|
||||
`--extension_server_csrf_token`. Both space-separated and `--flag=value`
|
||||
forms are supported. The parser identifies the target app type using the `--app-data-dir` flag (e.g., `antigravity`, `antigravity-cli`, or `antigravity-ide`).
|
||||
|
||||
For Antigravity CLI (`agy`), CodeBurn can also install an opt-in status line
|
||||
hook with `codeburn antigravity-hook install`. The hook records the CLI's
|
||||
sanitized `context_window.current_usage` payload while `agy` is still alive,
|
||||
without prompts or local working-directory paths. It also attempts a best-effort
|
||||
RPC snapshot for full response metadata. The installed command points at a
|
||||
persistent `codeburn` binary from PATH rather than a local build artifact, and
|
||||
running `codeburn antigravity-hook install` again repairs older CodeBurn-owned
|
||||
statusLine commands that used stale absolute paths. Remove it with `codeburn
|
||||
antigravity-hook uninstall`; if `--force` replaced an existing statusLine
|
||||
command, uninstall restores that previous command.
|
||||
|
||||
## Storage format
|
||||
|
||||
Protobuf. Cascade and response objects map to `ParsedProviderCall` directly.
|
||||
|
||||
## Caching
|
||||
|
||||
Custom file cache at `$CODEBURN_CACHE_DIR/antigravity-results.json` (defaults to `~/.cache/codeburn/`). The cache is also used as the data source when the RPC endpoint is unavailable, not just as an optimization. Bumping the cache version forces a recompute.
|
||||
|
||||
## Deduplication
|
||||
|
||||
Per `<cascadeId>:<responseId>` for RPC data. The status line fallback collapses
|
||||
repeated identical usage snapshots, ignores singleton intermediate snapshots
|
||||
when a later stabilized usage total is observed for the same conversation, and
|
||||
uses positive deltas for monotonic snapshots so cumulative counters are not
|
||||
double-counted.
|
||||
|
||||
## Quirks
|
||||
|
||||
- **Antigravity is the only provider that requires a live process.** A user who closes Antigravity loses the most-recent data until next launch (the cache covers older runs).
|
||||
- **Antigravity CLI has a shorter capture window than the desktop app.** `agy`
|
||||
exposes its language server only while the CLI session is active. The status
|
||||
line hook closes that gap for future sessions; older CLI `.pb` files still
|
||||
cannot be priced exactly unless an RPC snapshot was captured.
|
||||
- The 16 MB cap on RPC responses is necessary because individual cascades can balloon. Raising it risks OOM on the user's machine.
|
||||
- Token types are split across `inputTokens`, `responseOutputTokens`, and `thinkingOutputTokens`. Thinking is billed at output rate.
|
||||
|
||||
## When fixing a bug here
|
||||
|
||||
1. Reproducing the full provider path requires Antigravity running locally.
|
||||
The unit tests cover process flag parsing and wrapped/unwrapped RPC response
|
||||
extraction, but they do not stand up a live Antigravity RPC endpoint.
|
||||
2. Before any change, capture a sample protobuf response (anonymized) so future regressions can be tested against a recording.
|
||||
3. If the bug is "no data after Antigravity update", the protobuf schema may have shifted. The parser's response handling is the place to look.
|
||||
4. If the bug is "stale data", check whether the RPC is reachable; the cache fallback can mask connectivity issues.
|
||||
@@ -0,0 +1,54 @@
|
||||
# Claude
|
||||
|
||||
Anthropic Claude Code CLI and Claude Desktop's local agent mode.
|
||||
|
||||
- **Source:** `src/providers/claude.ts`
|
||||
- **Loading:** eager (`src/providers/index.ts:1`)
|
||||
- **Test:** none directly. Coverage comes from `tests/parser-claude-cwd.test.ts`, `tests/parser-filter.test.ts`, and `tests/parser-mcp-inventory.test.ts`, which exercise `src/parser.ts` end-to-end against fixture session files.
|
||||
|
||||
## Where it reads from
|
||||
|
||||
| Source | Path |
|
||||
|---|---|
|
||||
| Claude Code CLI | `$CLAUDE_CONFIG_DIR` if set, otherwise `~/.claude/projects/` |
|
||||
| Claude Desktop (macOS) | `~/Library/Application Support/Claude/local-agent-mode-sessions/` |
|
||||
| Claude Desktop (Windows) | `%APPDATA%/Claude/local-agent-mode-sessions/` |
|
||||
| Claude Desktop (Linux) | `~/.config/Claude/local-agent-mode-sessions/` |
|
||||
|
||||
For Desktop, `findDesktopProjectDirs` walks up to 8 levels deep looking for `projects/` subdirectories, skipping `node_modules` and `.git`.
|
||||
|
||||
## Storage format
|
||||
|
||||
JSONL, one event per line, per session file. Sessions live under `<project>/<sessionId>.jsonl`.
|
||||
|
||||
## Parser
|
||||
|
||||
`createSessionParser` returns an empty async generator (`claude.ts:101-105`). Claude is a special case: `src/parser.ts` reads Claude JSONL files directly with full turn grouping, dedup of streaming message IDs, and MCP tool inventory extraction. The provider object exists only so `discoverSessions` can return Claude session sources alongside the others.
|
||||
|
||||
## Pricing
|
||||
|
||||
Claude Code reports total cache-write tokens in `usage.cache_creation_input_tokens`.
|
||||
When available, it also splits those writes by duration in
|
||||
`usage.cache_creation.ephemeral_5m_input_tokens` and
|
||||
`usage.cache_creation.ephemeral_1h_input_tokens`. CodeBurn keeps the existing
|
||||
aggregate cache-write token total for reports, but prices the 1-hour portion at
|
||||
2x base input cost (1.6x the 5-minute cache-write rate exposed by LiteLLM).
|
||||
If the split fields are missing, the parser falls back to the legacy behavior
|
||||
and prices every cache write at the 5-minute rate.
|
||||
|
||||
## Caching
|
||||
|
||||
None at the provider level. The daily aggregation cache (`src/daily-cache.ts`) reuses prior computed days.
|
||||
|
||||
## Quirks
|
||||
|
||||
- The parser is in `src/parser.ts`, not in `src/providers/claude.ts`. Anything that changes Claude parsing belongs in `parser.ts`.
|
||||
- Streaming responses produce duplicate message IDs across resumed sessions; `parser.ts` strips them via the global `seenMsgIds` Set.
|
||||
- Model display names are mapped in `claude.ts:7-20`; add new versions there when Anthropic releases them.
|
||||
|
||||
## When fixing a bug here
|
||||
|
||||
1. Confirm whether the bug is in **discovery** (sessions not picked up) or **parsing** (sessions found but data wrong).
|
||||
2. Discovery bugs live in `claude.ts:78-99`. Verify the directory layout you expect actually matches what Claude writes today.
|
||||
3. Parsing bugs live in `src/parser.ts`. Look for `parseSessionFile`, `groupIntoTurns`, and `dedupeStreamingMessageIds`.
|
||||
4. Add a fixture under `tests/fixtures/` and a test under `tests/parser-claude-cwd.test.ts` (or a new file). Do not mock the filesystem.
|
||||
@@ -0,0 +1,50 @@
|
||||
# Cline
|
||||
|
||||
Cline VS Code extension and Cline home-data task storage.
|
||||
|
||||
- **Source:** `src/providers/cline.ts`
|
||||
- **Loading:** eager (`src/providers/index.ts:2`)
|
||||
- **Test:** `tests/providers/cline.test.ts`
|
||||
|
||||
## Where it reads from
|
||||
|
||||
Two task roots are scanned:
|
||||
|
||||
1. VS Code extension globalStorage for `saoudrizwan.claude-dev`.
|
||||
2. Cline's home-data root at `~/.cline/data`.
|
||||
|
||||
Both roots are expected to contain a `tasks/` child directory. Discovery is delegated to `discoverClineTasks` in `src/providers/vscode-cline-parser.ts`, so a task is only included when it has a `ui_messages.json` file.
|
||||
|
||||
## Storage format
|
||||
|
||||
Per-task directories with:
|
||||
|
||||
```
|
||||
tasks/<taskId>/
|
||||
ui_messages.json
|
||||
api_conversation_history.json
|
||||
task_metadata.json
|
||||
```
|
||||
|
||||
`ui_messages.json` provides the `api_req_started` usage entries. `api_conversation_history.json` is used for model extraction. See [`vscode-cline-parser`](vscode-cline-parser.md) for the full schema description.
|
||||
`task_metadata.json` is part of Cline's task layout but is not read by CodeBurn today.
|
||||
|
||||
## Caching
|
||||
|
||||
None at the provider level; delegates to the shared helper and normal parser/cache layers.
|
||||
|
||||
## Deduplication
|
||||
|
||||
Discovery deduplicates by task id across the two Cline roots so a migrated task is not scanned twice. If the same task id exists in multiple roots, the one with the newest `ui_messages.json` wins. Parsing still uses the shared per-call key: `<providerName>:<taskId>:<index>`.
|
||||
|
||||
## Quirks
|
||||
|
||||
- This provider is intentionally a thin wrapper over the shared Cline-family parser.
|
||||
- Cline can keep data in both VS Code globalStorage and `~/.cline/data`, depending on version and workflow.
|
||||
- If Cline changes the JSON shape, fix `vscode-cline-parser.ts` only if Roo Code and KiloCode still pass. Branch provider-specific parsing rather than duplicating the whole parser.
|
||||
|
||||
## When fixing a bug here
|
||||
|
||||
1. Reproduce with a minimal task directory containing `ui_messages.json` and `api_conversation_history.json`.
|
||||
2. Run `tests/providers/cline.test.ts`, plus `tests/providers/roo-code.test.ts` and `tests/providers/kilo-code.test.ts` if the shared parser changes.
|
||||
3. Keep the provider name `cline`; downstream filters and dedup keys depend on it.
|
||||
@@ -0,0 +1,50 @@
|
||||
# Codex
|
||||
|
||||
OpenAI Codex CLI.
|
||||
|
||||
- **Source:** `src/providers/codex.ts`
|
||||
- **Loading:** eager (`src/providers/index.ts:2`)
|
||||
- **Test:** `tests/providers/codex.test.ts` (374 lines)
|
||||
|
||||
## Where it reads from
|
||||
|
||||
`$CODEX_HOME` if set, otherwise `~/.codex`. Sessions are nested by date:
|
||||
|
||||
```
|
||||
~/.codex/sessions/<YYYY>/<MM>/<DD>/rollout-*.jsonl
|
||||
```
|
||||
|
||||
The discovery walk uses strict regex (`^\d{4}$`, `^\d{2}$`) on each path component.
|
||||
|
||||
## Storage format
|
||||
|
||||
JSONL. The first line must be a `session_meta` entry with `payload.originator` starting with `codex` (case-insensitive). Files that fail this check are silently skipped.
|
||||
|
||||
The first line read is capped at 1 MB (`FIRST_LINE_READ_CAP`). Codex CLI 0.128+ embeds the full system prompt in `session_meta`, which can run 20-27 KB; the cap leaves headroom while bounding memory if a corrupt file has no newline.
|
||||
|
||||
## Caching
|
||||
|
||||
`src/codex-cache.ts` writes `~/.cache/codeburn/codex-results.json` (or `$CODEBURN_CACHE_DIR/codex-results.json`). Each entry is keyed by absolute file path and validated against `mtimeMs + sizeBytes`. Cached entries are returned wholesale.
|
||||
|
||||
A session that yielded zero parseable lines does **not** write to the cache (`codex.ts:419`); this prevents a transient read failure from pinning an empty result against a fingerprint.
|
||||
|
||||
## Deduplication
|
||||
|
||||
`codex:<sessionId>:<timestamp>:<cumulativeTotal>` for accounted events, plus `codex:<sessionId>:<timestamp>:est<n>` for estimated events that fall back to char-counting.
|
||||
|
||||
## Quirks
|
||||
|
||||
- Codex CLI emits both `last_token_usage` (per turn) and `total_token_usage` (cumulative). The parser handles three modes:
|
||||
1. `last_token_usage` present: use it directly.
|
||||
2. Only cumulative: compute deltas against the prior turn.
|
||||
3. Neither: estimate from message text length (`CHARS_PER_TOKEN = 4`).
|
||||
- `prevCumulativeTotal` is initialized to `null`, not `0`. A session whose first event reports `total = 0` would otherwise be dropped as a "duplicate" of the initial state.
|
||||
- `prev*` token counters are advanced on **every** event, including ones that used `last_token_usage`. Earlier code only updated them on the fallback branch, which double-counted any session that mixed modes.
|
||||
- OpenAI counts cached tokens **inside** `input_tokens`. The parser subtracts them so the rest of the codebase can assume Anthropic semantics (cached are separate).
|
||||
|
||||
## When fixing a bug here
|
||||
|
||||
1. Reproduce against a real `rollout-*.jsonl` if you can. Drop a redacted copy under `tests/fixtures/codex/` and reference it from `tests/providers/codex.test.ts`.
|
||||
2. If the bug is "zero tokens reported", first check whether the file is being skipped by `isValidCodexSession`.
|
||||
3. If the bug is "tokens counted twice", look at `prevCumulativeTotal` and the prev-counter advancement.
|
||||
4. If you change the dedup key shape, run `tests/providers/codex.test.ts` and `tests/parser-filter.test.ts` together; cross-provider dedup happens via the global `seenKeys` Set.
|
||||
@@ -0,0 +1,192 @@
|
||||
# Copilot
|
||||
|
||||
GitHub Copilot Chat (CLI, VS Code core chat sessions, VS Code extension transcripts, and JetBrains IDE sessions).
|
||||
|
||||
- **Source:** `src/providers/copilot.ts`
|
||||
- **Loading:** eager (`src/providers/index.ts:3`)
|
||||
- **Test:** `tests/providers/copilot.test.ts`
|
||||
|
||||
## Where it reads from
|
||||
|
||||
Three JSONL locations plus an optional OpenTelemetry SQLite source (see below). OTel is
|
||||
preferred when present; chatSessions are only discovered when no OTel source is found.
|
||||
Other discovered sources are walked on every run; results merge and dedupe.
|
||||
|
||||
1. **Legacy CLI sessions:** `~/.copilot/session-state/`
|
||||
2. **VS Code core chat sessions:** `~/Library/Application Support/Code/User/workspaceStorage/<hash>/chatSessions/*.jsonl` plus `~/Library/Application Support/Code/User/globalStorage/emptyWindowChatSessions/*.jsonl` and equivalents on Windows / Linux
|
||||
3. **VS Code transcripts:** `~/Library/Application Support/Code/User/workspaceStorage/<hash>/GitHub.copilot-chat/transcripts/` and equivalents on Windows / Linux
|
||||
4. **OTel SQLite store:** VS Code Copilot Chat's `agent-traces.db` (see the OTel section). Preferred when present because it carries full input / output / cache token counts; legacy JSONL sources only record output tokens.
|
||||
5. **JetBrains IDE sessions:** `~/.config/github-copilot/<ide>/<kind>/<storeId>/copilot-*-nitrite.db` (see the JetBrains section). Covers IntelliJ IDEA, PyCharm, RubyMine, etc.
|
||||
|
||||
## Storage format
|
||||
|
||||
JSONL in the first three locations (schemas differ; the parser switches by source type / event shape), a SQLite DB for the OTel source, and a Nitrite (H2 MVStore) `.db` for the JetBrains source. VS Code core chat sessions use a delta journal: `kind:0` sets the root object, `kind:1` writes a value at path `k`, and `kind:2` appends items to an array path.
|
||||
|
||||
## OpenTelemetry (OTel) source
|
||||
|
||||
When VS Code Copilot Chat's `agent-traces.db` exists, the parser reads per-LLM-call token
|
||||
breakdowns (input, output, cache-read, cache-creation) from it, which the JSONL sources do
|
||||
not record. Discovery is skipped with `CODEBURN_COPILOT_DISABLE_OTEL=1`, and the DB path
|
||||
can be overridden with `CODEBURN_COPILOT_OTEL_DB`.
|
||||
|
||||
If OTel discovery finds at least one source, workspace `chatSessions/*.jsonl` and
|
||||
`emptyWindowChatSessions/*.jsonl` are skipped. Those journals can mirror the same Copilot
|
||||
turns under IDs that do not match OTel turn IDs, so CodeBurn prefers the richer OTel data
|
||||
instead of trying to dedupe across stores.
|
||||
|
||||
- **Requires Node 22+.** The OTel source uses the built-in `node:sqlite` module (the same
|
||||
backend as Cursor / OpenCode). On Node 20, or if the DB is missing / locked / corrupt /
|
||||
wrong-schema, OTel is skipped and the JSONL/transcript sources are used as a fallback.
|
||||
- **Durable cache (monotonic totals).** Copilot is marked `durableSources`: OTel-derived
|
||||
cache entries are never evicted when VS Code prunes old spans from the DB, so
|
||||
month-to-date totals do not drop as the DB rotates. Entries age out after 90 days.
|
||||
- **Upgrade note.** The first run after upgrading to the OTel version bumps the copilot
|
||||
parse version, which discards the prior copilot cache. Spans already pruned from the DB
|
||||
before the upgrade cannot be recovered, so monotonicity starts from the upgrade point,
|
||||
not retroactively.
|
||||
|
||||
## JetBrains IDEs (IntelliJ, PyCharm, …)
|
||||
|
||||
The JetBrains Copilot plugin does **not** write to any of the VS Code or CLI
|
||||
locations above. It persists chat/agent sessions under the shared GitHub Copilot
|
||||
config root, in one store directory per session store:
|
||||
|
||||
```
|
||||
~/.config/github-copilot/<ide>/<kind>/<storeId>/
|
||||
copilot-*-nitrite.db # Nitrite (H2 MVStore) — the session content
|
||||
blobs/
|
||||
```
|
||||
|
||||
`<ide>` is a per-IDE dir (`iu` for IntelliJ IDEA Ultimate, `intellij` for the
|
||||
community edition, `PyCharm2025.2`, …). `<kind>` ∈ `chat-agent-sessions`,
|
||||
`chat-sessions`, `chat-edit-sessions` (agent / ask / edit mode). The root follows
|
||||
XDG rules: `$XDG_CONFIG_HOME/github-copilot` when set, else
|
||||
`~/.config/github-copilot` (macOS / Linux) or `%LOCALAPPDATA%\github-copilot`
|
||||
(Windows).
|
||||
|
||||
**Storage: the Nitrite `.db`.** An H2 MVStore file (header
|
||||
`H:2,block:9,…format:3`) of Java-serialized Nitrite documents (`NtAgentSession`,
|
||||
`NtAgentTurn`). It is read as `latin1` (byte-offset-stable, lossless) and scanned
|
||||
— no Java deserializer, no new deps, and it is **not** SQLite so `node:sqlite` is
|
||||
not used. Each assistant reply is a `{"__first__":{"type":"Subgraph",…}}` blob.
|
||||
`extractResponseText` recovers the reply by unescaping one level at a time and,
|
||||
at the first depth where the record markers appear bare, reading the reply
|
||||
**structurally** (the payload is parsed as a delimited JSON-string literal, so a
|
||||
reply containing its own quotes is never truncated).
|
||||
|
||||
**Two turn shapes, both handled** (a blob is one or the other — verified across
|
||||
every observed store that they never coexist):
|
||||
|
||||
- **Ask mode** — the reply is a `Markdown` record's `text`.
|
||||
- **Agent / plan mode** (agent sessions, `/plan …`, e.g. in PyCharm) — the reply
|
||||
is the `reply` field of an `AgentRound` record; here the `Markdown` records
|
||||
hold the *user's* prompt instead. The mode is decided by the **presence** of an
|
||||
`AgentRound` record, and only its `reply` is read — so an agent turn with an
|
||||
empty reply (a failed turn or a pure tool-call round) is billed **$0** rather
|
||||
than falling back to the prompt. A multi-round blob contributes every non-empty
|
||||
round's reply.
|
||||
|
||||
Sidecar records that plan/agent mode also writes — `Thinking` (chain-of-thought),
|
||||
`PendingChanges` (proposed code diff, stored under `content` not `data`),
|
||||
`AskQuestion`, `Notification`, `SubTurn`, and file-read `text` results — are
|
||||
**not** billable assistant output and are deliberately skipped. User prompts are
|
||||
the simpler `{"<uuid>":{"type":"Value",…}}` value-maps.
|
||||
|
||||
**Old plugin format (≤1.5.x, e.g. 1.5.59-243).** Older plugins do not write
|
||||
per-turn `__first__`/Subgraph blobs at all — they store the whole session as ONE
|
||||
binary-framed outer Nitrite document of UUID-keyed `Value` entries, with the
|
||||
`AgentRound` records one escaping level deeper. When the Subgraph scan finds no
|
||||
turns but the raw file contains `AgentRound` text, a fallback locates that outer
|
||||
document (`extractJetBrainsDbTurns`), runs it through the same
|
||||
`extractResponseText` depth-unescape, and emits **one session-level call** per
|
||||
document (all rounds' replies joined). Cost and tokens are correct; only the
|
||||
per-turn call-count granularity is coarser than the new format — an accepted
|
||||
tradeoff for legacy data. The fallback is gated on the new-format scan yielding
|
||||
nothing, so current sessions are never affected or double-counted.
|
||||
|
||||
(Store dirs may also contain a legacy `00000000000.xd` Xodus log from older
|
||||
plugin versions. On every installation observed it is either empty or shadowed
|
||||
by the `.db`, so CodeBurn reads only the `.db`. If a real `.xd`-only session ever
|
||||
surfaces, add a reader with a captured fixture.)
|
||||
|
||||
- **No token accounting.** No store records token counts. Output tokens are
|
||||
**estimated** from the reply text via `estimateTokens` (`CHARS_PER_TOKEN = 4`,
|
||||
as for Cursor and legacy Copilot JSONL); input tokens are 0; every JetBrains
|
||||
call is marked `costIsEstimated: true`.
|
||||
- **Errored turns.** A failed generation ("Sorry, an error occurred …") is stored
|
||||
as an assistant blob with an error status and no reply text; it is detected and
|
||||
billed **$0** (not conflated with an empty success). In agent mode a failed turn
|
||||
has an empty `AgentRound` reply — the parser does not fall back to the prompt
|
||||
`Markdown`, so the user's words are never billed as the assistant's output.
|
||||
- **Per-turn model.** The model varies per turn within one `.db`. It is recovered
|
||||
from inside the assistant blob when present, else a store-wide default, else a
|
||||
generic Copilot bucket. Dotted Claude names are normalised to canonical ids
|
||||
(`claude-opus-4.5` → `claude-opus-4-5`); GPT/Gemini names kept verbatim.
|
||||
- **Duplicates.** The store keeps several byte-copies of each reply (original,
|
||||
lowercased, revisions); assistant turns are de-duplicated by reply content.
|
||||
- **One `.db` holds many chat tabs.** A single store `.db` contains multiple
|
||||
conversations, each with an internal GUID and an evolving title
|
||||
(`New Agent Session` → auto-name → final title). CodeBurn recovers the
|
||||
`GUID → title` map (`extractJetBrainsConversations`, keeping the latest
|
||||
non-default title), attributes each turn to the nearest preceding conversation
|
||||
GUID, and emits **one session per conversation** (not one per `.db`). Reply
|
||||
content is de-duplicated per conversation.
|
||||
- **Project.** Resolved in three tiers, most authoritative first:
|
||||
1. **`projectName` field (plugin 1.12+).** Recent plugins serialize the repo
|
||||
label directly on the session doc (`extractJetBrainsProjectName`) — the
|
||||
JetBrains analogue of the OTel source's `github.copilot.git.repository`.
|
||||
**Cross-kind join:** the billable turns live in `chat-agent-sessions`, but
|
||||
the `projectName` is usually written only into the sibling
|
||||
`chat-sessions` / `chat-edit-sessions` store. Discovery
|
||||
(`resolveJetBrainsProjectNames`) joins them by **store id** so the agent
|
||||
session inherits the label from whichever store recorded it. Read
|
||||
length-prefixed (Java `TC_STRING`) so an embedded quote/newline can't
|
||||
truncate it.
|
||||
2. **`.git` walk-up (older plugins / no `projectName`).** For each `file://`
|
||||
URI a chat referenced, walk UP the real filesystem to the nearest ancestor
|
||||
containing a `.git` and use that repo's basename (e.g. `pinot`).
|
||||
3. **`copilot-jetbrains`** bucket when neither signal exists (chat referenced
|
||||
no files and no `projectName` was recorded, or the repo no longer exists on
|
||||
disk).
|
||||
|
||||
The conversation **title** is a chat-thread name, NOT a project — it is the
|
||||
session label (`userMessage`) and deliberately kept out of `project` so it does
|
||||
not pollute the By-Project view. Note that `bg-agent-sessions/` (a newer kind
|
||||
dir holding `copilot-agent-snapshots.db` / `copilot-session-metadata.db`) is
|
||||
**not** scanned: those DBs carry file snapshots and metadata, not billable
|
||||
turns, and the same session's turns are already read from
|
||||
`chat-agent-sessions`.
|
||||
- **Override the root** with `CODEBURN_COPILOT_JETBRAINS_DIR`.
|
||||
|
||||
## Caching
|
||||
|
||||
None for the JSONL sources. The OTel source uses a durable cache (see above).
|
||||
|
||||
## Deduplication
|
||||
|
||||
Legacy JSONL and transcript sessions dedupe per `messageId`. Core chat sessions dedupe per `copilot-chatsession:<sessionId>:<requestId>`, and are not discovered when an OTel source is present. JetBrains `.db` turns dedupe per `copilot:jb:<conversationId>:<turnIndex>` (a per-conversation index, plus reply-content dedup within each conversation). These sources otherwise touch disjoint locations from the VS Code / CLI sources.
|
||||
|
||||
If a workspace hash contains at least one `chatSessions/*.jsonl` file, the provider skips that hash's legacy `GitHub.copilot-chat/transcripts/` directory. The core chat session journal is the modern token-bearing source for the same conversations, so reading both would inflate call counts.
|
||||
|
||||
## Model inference
|
||||
|
||||
Copilot does not always tag the model on each message. The parser infers it from the tool-call ID prefix:
|
||||
|
||||
| Prefix | Inferred model family |
|
||||
|---|---|
|
||||
| `toolu_bdrk_`, `toolu_vrtx_`, `tooluse_`, `toolu_` | Anthropic |
|
||||
| `call_` | OpenAI |
|
||||
|
||||
See `copilot.ts:176-213`.
|
||||
|
||||
## Quirks
|
||||
|
||||
- `toolRequests` can be missing or non-array on older sessions; the parser guards against that (`copilot.ts:126`, `:260`).
|
||||
- When `outputTokens` is missing the parser falls back to char-counting (`CHARS_PER_TOKEN = 4`, `copilot.ts:252-254`).
|
||||
- A single chat may be mirrored across both legacy and transcript paths if the user upgraded; the dedup `messageId` collision handles this.
|
||||
|
||||
## When fixing a bug here
|
||||
|
||||
1. Determine which schema reproduces the bug. The two parsers share little code on purpose; do not unify them unless you understand both formats.
|
||||
2. If the model is misidentified, look at the tool-call ID prefix list and consider whether a new prefix should be added.
|
||||
3. New fixtures go under `tests/fixtures/copilot/` and are referenced from `tests/providers/copilot.test.ts`.
|
||||
@@ -0,0 +1,87 @@
|
||||
# Crush
|
||||
|
||||
Charmbracelet's Crush TUI coding agent.
|
||||
|
||||
- **Source:** `src/providers/crush.ts`
|
||||
- **Loading:** lazy (`src/providers/index.ts`). Lazy because Crush ships per-project SQLite databases and we use `node:sqlite` to read them.
|
||||
- **Test:** `tests/providers/crush.test.ts` (10 tests, fixture-based)
|
||||
|
||||
## Where it reads from
|
||||
|
||||
Crush keeps a global registry that lists every project it has touched, and a separate SQLite database **per project**.
|
||||
|
||||
| File | Path |
|
||||
|---|---|
|
||||
| Registry (project list) | `$CRUSH_GLOBAL_DATA/projects.json`, otherwise `$XDG_DATA_HOME/crush/projects.json`, otherwise `~/.local/share/crush/projects.json` (Linux/macOS) or `%LOCALAPPDATA%/crush/projects.json` (Windows). |
|
||||
| Per-project db | `<project.path>/<project.data_dir>/crush.db` where `data_dir` defaults to `.crush`. |
|
||||
|
||||
The registry shape is an object keyed by project id (modern Crush) or an array (older builds and tokscale's sample fixtures). The parser accepts both.
|
||||
|
||||
## Storage format
|
||||
|
||||
SQLite. Schema verified against `charmbracelet/crush` v0.66.1 (`internal/db/migrations/20250424200609_initial.sql` plus subsequent additive migrations).
|
||||
|
||||
Two tables matter for codeburn:
|
||||
|
||||
```sql
|
||||
CREATE TABLE sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
parent_session_id TEXT,
|
||||
title TEXT NOT NULL,
|
||||
message_count INTEGER NOT NULL DEFAULT 0,
|
||||
prompt_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
completion_tokens INTEGER NOT NULL DEFAULT 0,
|
||||
cost REAL NOT NULL DEFAULT 0.0,
|
||||
updated_at INTEGER NOT NULL,
|
||||
created_at INTEGER NOT NULL,
|
||||
...
|
||||
);
|
||||
|
||||
CREATE TABLE messages (
|
||||
id TEXT PRIMARY KEY,
|
||||
session_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
parts TEXT NOT NULL DEFAULT '[]',
|
||||
model TEXT,
|
||||
created_at INTEGER NOT NULL,
|
||||
updated_at INTEGER NOT NULL,
|
||||
...
|
||||
);
|
||||
```
|
||||
|
||||
## Caching
|
||||
|
||||
None at the provider level.
|
||||
|
||||
## Deduplication
|
||||
|
||||
Per `crush:<sessionId>` (`crush.ts`).
|
||||
|
||||
## What we extract
|
||||
|
||||
| codeburn field | Crush source |
|
||||
|---|---|
|
||||
| `inputTokens` | `sessions.prompt_tokens` |
|
||||
| `outputTokens` | `sessions.completion_tokens` |
|
||||
| `costUSD` | `sessions.cost` (already in dollars) |
|
||||
| `model` | dominant value of `messages.model` for the session, picked by `GROUP BY model ORDER BY COUNT(*) DESC LIMIT 1`. Falls back to `unknown`. |
|
||||
| `timestamp` | `sessions.updated_at` if set, otherwise `created_at` |
|
||||
|
||||
Cache tokens, reasoning tokens, web-search counts, tools, and bash commands are all left as zero / empty. Crush does not record per-message token data, so per-turn attribution is not available.
|
||||
|
||||
## Quirks worth knowing
|
||||
|
||||
- **Timestamps are seconds, not milliseconds.** The Crush schema *comments* in the upstream migration claim millisecond timestamps, but every actual `INSERT`/`UPDATE` in `internal/db/sql/{sessions,messages}.sql` uses `strftime('%s', 'now')`, which returns Unix seconds. The parser multiplies by 1000 before constructing a `Date`. **Tokscale's parser (junhoyeo/tokscale#346) gets this wrong and is off by 1000x.** Confirmed against Crush v0.66.1.
|
||||
- **Cost is stored in dollars as a `REAL`.** No conversion needed.
|
||||
- **Child sessions are skipped.** Only rows with `parent_session_id IS NULL` are surfaced. Crush sub-agents inherit cost into the parent.
|
||||
- **Zero-spend rows are filtered.** Discovery skips sessions with `cost = 0 AND prompt_tokens = 0 AND completion_tokens = 0`.
|
||||
- **Optimize detectors that depend on tools (`detectJunkReads`, `detectDuplicateReads`, `detectLowReadEditRatio`) will not flag Crush sessions.** That is correct: Crush does not log per-tool calls in a way we can read today.
|
||||
- **`detectLowWorthSessions` may flag Crush sessions** because it looks for cost without edits. That is a known false positive; if it becomes noisy, we can branch the detector on provider.
|
||||
|
||||
## When fixing a bug here
|
||||
|
||||
1. Confirm the issue against a real Crush install (`brew install charmbracelet/tap/crush`) before assuming the schema has changed. Migrations in the last six months have only added columns to `sessions`/`messages`, never removed any of the ones we read.
|
||||
2. If the bug is "Crush sessions show timestamps from 1970-something", check whether someone "fixed" the seconds-vs-milliseconds handling by removing the `* 1000`. The schema comment is wrong; the data is in seconds.
|
||||
3. If the bug is "Crush model column shows `unknown`", the session has no messages with a non-null `model`. Some early Crush builds did not record provider on every message; add `LIKE` matching against `provider` if you want a stronger fallback.
|
||||
4. If the bug is "no sessions discovered", the registry path probably has not been verified for the user's setup. Print `getRegistryPath()` and have them confirm the file exists at that location.
|
||||
5. New fixtures go under the inline schema in `tests/providers/crush.test.ts`; keep the `CREATE TABLE` literal and synchronized with the upstream migration.
|
||||
@@ -0,0 +1,41 @@
|
||||
# Cursor Agent
|
||||
|
||||
Cursor's background agent transcripts (separate from the regular chat).
|
||||
|
||||
- **Source:** `src/providers/cursor-agent.ts`
|
||||
- **Loading:** lazy (`src/providers/index.ts:62-87`)
|
||||
- **Test:** `tests/providers/cursor-agent.test.ts` (243 lines)
|
||||
|
||||
## Where it reads from
|
||||
|
||||
`~/.cursor/projects/<projectId>/agent-transcripts/`. Inside each project, two layouts coexist:
|
||||
|
||||
1. **Legacy:** `*.txt` flat files.
|
||||
2. **Composer 2:** UUID-named subdirectories, each containing JSONL.
|
||||
|
||||
Subagents (delegated runs) live in `subagents/` subdirectories under the parent (`cursor-agent.ts:479-490`). They are picked up too.
|
||||
|
||||
## Storage format
|
||||
|
||||
- Legacy: free-form text transcripts. The parser does line-based heuristic parsing (`cursor-agent.ts:219-314`).
|
||||
- Composer 2: JSONL (`cursor-agent.ts:167-217`).
|
||||
|
||||
## Caching
|
||||
|
||||
None at the provider level. Conversation metadata is read from the same Cursor SQLite db (`state.vscdb`), specifically the `conversation_summaries` table (`cursor-agent.ts:46-50`). If the summary is missing, file mtime is used as the timestamp.
|
||||
|
||||
## Deduplication
|
||||
|
||||
Per `<provider>:<conversationId>:<turnIndex>` (`cursor-agent.ts:379`).
|
||||
|
||||
## Quirks
|
||||
|
||||
- A file with a UUID-shaped name is treated as the conversation ID directly (`cursor-agent.ts:142-143`); other names are derived from the parent directory.
|
||||
- Token counts are estimated from char count (`CHARS_PER_TOKEN = 4`, `cursor-agent.ts:35`, `:81-84`). The legacy text format never reports real tokens.
|
||||
- The text parser is regex-driven and brittle. It is easier to fix a Composer 2 (JSONL) bug than a legacy (text) bug.
|
||||
|
||||
## When fixing a bug here
|
||||
|
||||
1. Check which format the failing transcript uses before opening a fix.
|
||||
2. For text-format bugs, copy the redacted transcript verbatim into `tests/fixtures/cursor-agent/` so the regex change can be regression-tested.
|
||||
3. If the bug is "wrong project", look at `cursor-agent.ts:46-50` and whether a `conversation_summaries` row exists for the conversation.
|
||||
@@ -0,0 +1,50 @@
|
||||
# Cursor
|
||||
|
||||
Cursor IDE chat history.
|
||||
|
||||
- **Source:** `src/providers/cursor.ts`
|
||||
- **Loading:** lazy (`src/providers/index.ts:44-57`). The `node:sqlite` import is the heavy dependency that justifies lazy loading.
|
||||
- **Test:** `tests/providers/cursor.test.ts` (77 lines), `tests/providers/cursor-bubble-dedup.test.ts` (176 lines)
|
||||
|
||||
## Where it reads from
|
||||
|
||||
A single SQLite database per platform:
|
||||
|
||||
| Platform | Path |
|
||||
|---|---|
|
||||
| macOS | `~/Library/Application Support/Cursor/User/globalStorage/state.vscdb` |
|
||||
| Windows | `%APPDATA%/Cursor/User/globalStorage/state.vscdb` |
|
||||
| Linux | `~/.config/Cursor/User/globalStorage/state.vscdb` |
|
||||
|
||||
## Storage format
|
||||
|
||||
SQLite. Two parallel sources within the same db:
|
||||
|
||||
1. **Bubbles** (`cursor.ts:201-331`): per-message rows. The richer source.
|
||||
2. **agentKv** (`cursor.ts:350-460`): per-conversation key-value blobs. The fallback for older sessions.
|
||||
|
||||
The parser tries both and dedupes via `seenKeys`.
|
||||
|
||||
## Caching
|
||||
|
||||
`src/cursor-cache.ts` writes `~/.cache/codeburn/cursor-results.json` (override with `$CODEBURN_CACHE_DIR`). The fingerprint is `dbMtimeMs + dbSizeBytes` of `state.vscdb`. Atomic write via temp + rename.
|
||||
|
||||
## Deduplication
|
||||
|
||||
- Bubbles: per `bubbleId` (`cursor.ts:282`).
|
||||
- agentKv: per `requestId` (`cursor.ts:429`).
|
||||
|
||||
## Quirks
|
||||
|
||||
- **180-day lookback.** The bubbles query bounds itself to the trailing 180 days (`cursor.ts:205`). Older history is ignored. If a user reports "Cursor data missing", confirm the date range first.
|
||||
- **250 000 bubble cap.** Power users with massive history are capped to prevent unbounded memory. If you need to raise this, also raise the cache size budget.
|
||||
- **Per-conversation user-message queue.** The parser caches the user-message stream per conversation to avoid an O(n) shift on every turn (`cursor.ts:171-191`).
|
||||
- **agentKv has no per-message timestamp.** The DB file's mtime is used as the timestamp for every agentKv-derived call (`cursor.ts:358-363`). This is wrong but consistent.
|
||||
- **Cursor v3 reports zero token counts.** The parser falls back to char-counting (`CHARS_PER_TOKEN = 4`) for those rows (`cursor.ts:265-272`).
|
||||
|
||||
## When fixing a bug here
|
||||
|
||||
1. **Always reproduce against a fixture, not a real db.** SQLite over the live db is racy; the user might be using Cursor while you read.
|
||||
2. If the bug is "tokens are zero", check whether the row is a v3 zero-token bubble, in which case the char-fallback should kick in.
|
||||
3. If the bug is "duplicate counts", check both `bubbleId` dedup and the cross-provider `seenKeys` dedup.
|
||||
4. Cache poisoning is the most common failure mode after a Cursor schema change. Bump `CURSOR_CACHE_VERSION` in `src/cursor-cache.ts` so old caches are invalidated.
|
||||
@@ -0,0 +1,191 @@
|
||||
# Devin
|
||||
|
||||
Cognition Devin CLI local usage tracking.
|
||||
|
||||
- **Source:** `src/providers/devin.ts`
|
||||
- **Loading:** eager (`src/providers/index.ts`)
|
||||
- **Test:** `tests/providers/devin.test.ts`
|
||||
|
||||
## Where it reads from
|
||||
|
||||
Devin CLI data lives under:
|
||||
|
||||
```text
|
||||
~/.local/share/devin/cli/
|
||||
```
|
||||
|
||||
The MVP usage source is transcript JSON:
|
||||
|
||||
```text
|
||||
~/.local/share/devin/cli/transcripts/*.json
|
||||
```
|
||||
|
||||
The provider also reads:
|
||||
|
||||
```text
|
||||
~/.local/share/devin/cli/sessions.db
|
||||
```
|
||||
|
||||
`sessions.db` is enrichment only. It supplies project path/name, model fallback,
|
||||
timestamp fallback, and hidden-session filtering. It is not the source of usage
|
||||
or billing.
|
||||
|
||||
## Configuration
|
||||
|
||||
Devin reports spend in ACUs. CodeBurn reports provider cost through `costUSD`,
|
||||
so Devin stays disabled until a positive finite ACU-to-USD rate is configured:
|
||||
|
||||
```json
|
||||
{
|
||||
"devin": {
|
||||
"acuUsdRate": 2.25
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
The config file is:
|
||||
|
||||
```text
|
||||
~/.config/codeburn/config.json
|
||||
```
|
||||
|
||||
The macOS Settings window writes this value from the Devin tab. There is no
|
||||
environment-variable override and no default rate. Do not hardcode a universal
|
||||
ACU price; Devin ACU pricing is account/contract dependent.
|
||||
|
||||
When the rate is missing or invalid, `discoverSessions()` returns `[]` and the
|
||||
parser yields no calls. Devin remains registered as a provider, but it does not
|
||||
appear in CLI/UI results until configured.
|
||||
|
||||
## Storage format
|
||||
|
||||
Transcript root is a JSON object following the [ATIF-v1.7 trajectory schema][atif],
|
||||
with Devin-specific additions such as per-step `metadata` and `extra`. The
|
||||
parser does not validate `schema_version`; it only requires a parseable object
|
||||
with `steps[]`.
|
||||
|
||||
Core fields include `session_id`, `agent.model_name`, `agent.extra` (Devin
|
||||
backend/permission info), `final_metrics`, and `steps[]`.
|
||||
|
||||
Steps now support two metric sources. The parser checks `step.metrics` first
|
||||
(the standard ATIF location) and falls back to `step.metadata.metrics` (the
|
||||
legacy Devin location). Similarly, ACU cost is read from
|
||||
`step.metadata.committed_acu_cost` first, falling back to
|
||||
`step.extra.committed_acu_cost`.
|
||||
|
||||
Messages can be a plain string or an array of `ContentPart` objects (text or
|
||||
image), following the ATIF v1.6+ multimodal content model. The parser
|
||||
normalises both forms when extracting user messages.
|
||||
|
||||
Each counted step can provide:
|
||||
|
||||
- `step_id`
|
||||
- `metadata.committed_acu_cost` (or `extra.committed_acu_cost`)
|
||||
- `metrics.prompt_tokens` (or `metadata.metrics.input_tokens`)
|
||||
- `metrics.completion_tokens` (or `metadata.metrics.output_tokens`)
|
||||
- `metrics.extra.cache_creation_input_tokens` (or `metadata.metrics.cache_creation_tokens`)
|
||||
- `metrics.cached_tokens` (or `metadata.metrics.cache_read_tokens`)
|
||||
- `metadata.created_at`
|
||||
- `metadata.generation_model` (or `extra.generation_model`)
|
||||
- `metadata.request_id`
|
||||
- `tool_calls[].function_name`
|
||||
- `observation.results[]` (tool output; not parsed for usage)
|
||||
|
||||
User-input steps (`metadata.is_user_input === true`) are skipped. Non-user
|
||||
steps are included only if they have positive ACU usage or positive token usage.
|
||||
|
||||
## Pricing
|
||||
|
||||
ACU cost is per step, not cumulative. The provider reads
|
||||
`metadata.committed_acu_cost` first, falling back to
|
||||
`extra.committed_acu_cost`, then converts with:
|
||||
|
||||
```text
|
||||
costUSD = committed_acu_cost * devin.acuUsdRate
|
||||
```
|
||||
|
||||
Token-only steps are still included when they have positive token metrics, but
|
||||
their `costUSD` is `0` if `committed_acu_cost` is absent from both locations.
|
||||
|
||||
`src/parser.ts` preserves Devin's provider-supplied `costUSD` instead of
|
||||
re-pricing it through LiteLLM.
|
||||
|
||||
## sessions.db enrichment
|
||||
|
||||
The provider currently reads these columns from `sessions`:
|
||||
|
||||
| Column | Use |
|
||||
| ------------------- | ----------------------------------------------------------------------------------------------------------- |
|
||||
| `id` | join key with transcript `session_id` during parsing; discovery uses the transcript filename before `.json` |
|
||||
| `working_directory` | `projectPath` and derived project name |
|
||||
| `model` | model fallback |
|
||||
| `title` | project name fallback |
|
||||
| `created_at` | timestamp fallback |
|
||||
| `last_activity_at` | preferred session timestamp fallback |
|
||||
| `hidden` | skip hidden sessions |
|
||||
|
||||
`message_nodes`, `prompt_history`, and `tool_call_state` are not parsed yet.
|
||||
|
||||
## Timestamps
|
||||
|
||||
Step timestamps come from `metadata.created_at`, falling back to
|
||||
`sessions.last_activity_at`, then `sessions.created_at`.
|
||||
|
||||
Transcript step timestamps are passed through as ATIF string timestamps.
|
||||
Numeric normalization is only applied to `sessions.db` timestamps:
|
||||
|
||||
- less than `10_000_000_000`: seconds
|
||||
- otherwise: milliseconds
|
||||
|
||||
## Model Resolution
|
||||
|
||||
Model names resolve in this order:
|
||||
|
||||
1. `step.metadata.generation_model`
|
||||
2. `step.model_name`
|
||||
3. `transcript.agent.model_name`
|
||||
4. `sessions.model`
|
||||
5. `devin`
|
||||
|
||||
## Caching
|
||||
|
||||
No provider-level cache.
|
||||
|
||||
The normal session cache stores parsed provider calls, but Devin is always
|
||||
reparsed by `src/parser.ts` because `sessions.db` can change without the
|
||||
transcript JSON fingerprint changing.
|
||||
|
||||
## Deduplication
|
||||
|
||||
`devin:<sessionId>:<step.step_id>`
|
||||
|
||||
The provider name is part of the key via the `devin:` prefix.
|
||||
|
||||
## Quirks
|
||||
|
||||
- The transcript directory has usage; `sessions.db` is enrichment only.
|
||||
- `committed_acu_cost` is per-generation/per-step ACU usage. Never treat it as cumulative. It can appear in `metadata` (legacy) or `extra` (ATIF v1.7); the provider checks both.
|
||||
- Token metrics can live in `step.metrics` (standard ATIF) or `step.metadata.metrics` (legacy Devin). The provider checks `step.metrics` first, falling back to `metadata`.
|
||||
- Step messages can be a plain string or an array of `ContentPart` objects (text/image). The parser normalises both when extracting user messages.
|
||||
- There is no default ACU-to-USD rate. Missing config intentionally hides Devin.
|
||||
- Hidden sessions from `sessions.db` are skipped in discovery and parsing.
|
||||
- Tool names come directly from `tool_calls[].function_name`; the provider assumes valid ATIF tool-call records.
|
||||
- If SQLite is unavailable or `sessions.db` cannot be opened, the provider still parses transcripts without enrichment.
|
||||
|
||||
## When fixing a bug here
|
||||
|
||||
1. First check whether `~/.config/codeburn/config.json` contains a valid
|
||||
`devin.acuUsdRate`. Without it, no Devin sessions should appear.
|
||||
2. For usage total bugs, compare against (ACU cost can live in `metadata` or `extra`):
|
||||
|
||||
```bash
|
||||
jq '[.steps[] | select(.metadata.is_user_input != true) | (.metadata.committed_acu_cost // .extra.committed_acu_cost // 0)] | add' ~/.local/share/devin/cli/transcripts/<session>.json
|
||||
```
|
||||
|
||||
3. If project/model/timestamp metadata is wrong, inspect `sessions.db`, not the transcript.
|
||||
4. If a hidden session appears, check the `hidden` column. Discovery can only
|
||||
hide sessions whose transcript filename matches `sessions.id`; parsing uses
|
||||
the transcript `session_id` when present.
|
||||
5. Run `tests/providers/devin.test.ts` after parser changes. It covers ACU conversion, disabled-until-configured behavior, timestamp parsing, deduplication, hidden sessions, `sessions.db` enrichment, ATIF v1.7 multimodal messages, `step.metrics` vs `metadata.metrics` priority, and `extra.committed_acu_cost` fallback.
|
||||
|
||||
[atif]: https://github.com/harbor-framework/harbor/blob/main/rfcs/0001-trajectory-format.md
|
||||
@@ -0,0 +1,36 @@
|
||||
# Droid
|
||||
|
||||
Factory's Droid CLI.
|
||||
|
||||
- **Source:** `src/providers/droid.ts`
|
||||
- **Loading:** eager (`src/providers/index.ts:4`)
|
||||
- **Test:** `tests/providers/droid.test.ts` (148 lines)
|
||||
|
||||
## Where it reads from
|
||||
|
||||
`$FACTORY_DIR` if set, otherwise `~/.factory/sessions/<subdir>/*.jsonl`.
|
||||
|
||||
The parser ignores the `.factory/` directory itself (`droid.ts:293-296`); some installs nest it accidentally.
|
||||
|
||||
## Storage format
|
||||
|
||||
JSONL.
|
||||
|
||||
## Caching
|
||||
|
||||
None.
|
||||
|
||||
## Deduplication
|
||||
|
||||
Per `messageId` within a session (`droid.ts:253`).
|
||||
|
||||
## Quirks
|
||||
|
||||
- **Token totals are session-level only.** Droid does not report per-message tokens. The parser reads `settings.tokenUsage` once per session and **splits it evenly** across all assistant calls, with the remainder added to the last call (`droid.ts:223-251`). This is approximate but consistent.
|
||||
- Project name is derived from the session's `cwd`. If the cwd contains `projects/<name>`, that name is preferred over the basename (`droid.ts:299-319`).
|
||||
|
||||
## When fixing a bug here
|
||||
|
||||
1. If the bug is "tokens unevenly attributed", that is by design. The session-level total is the only signal Droid emits.
|
||||
2. If the bug is "no sessions found", confirm the user does not have `$FACTORY_DIR` pointing somewhere unexpected.
|
||||
3. New fixtures go under `tests/fixtures/droid/`.
|
||||
@@ -0,0 +1,37 @@
|
||||
# Forge
|
||||
|
||||
Forge agent CLI.
|
||||
|
||||
- **Source:** `src/providers/forge.ts`
|
||||
- **Loading:** lazy (`src/providers/index.ts:48`)
|
||||
- **Test:** `tests/providers/forge.test.ts`
|
||||
|
||||
## Where it reads from
|
||||
|
||||
`~/.forge/.forge.db` (`forge.ts:31`).
|
||||
|
||||
## Storage format
|
||||
|
||||
SQLite (`forge.ts:225-252`). CodeBurn reads the `conversations` table and parses JSON from `context.messages` (`forge.ts:154-171`).
|
||||
|
||||
## Caching
|
||||
|
||||
None.
|
||||
|
||||
## Deduplication
|
||||
|
||||
Per `<provider>:<conversation_id>:<tool_call_id-or-message-index>` (`forge.ts:193`).
|
||||
|
||||
## Quirks
|
||||
|
||||
- `workspace_id` is cast to text in SQL because Forge can store values larger than JavaScript's safe integer range (`forge.ts:35`, `forge.ts:155`, `forge.ts:238`).
|
||||
- Forge reports prompt tokens inclusive of cached tokens. CodeBurn subtracts cached tokens from prompt tokens before pricing (`forge.ts:185-188`).
|
||||
- One CodeBurn call is emitted per assistant message with usage; zero-token assistant messages are skipped (`forge.ts:183-190`).
|
||||
- Project names come from the conversation title, falling back to `workspace_id` (`forge.ts:244`).
|
||||
|
||||
## When fixing a bug here
|
||||
|
||||
1. If discovery returns no sessions, confirm the SQLite schema still has `conversations` with `conversation_id`, `workspace_id`, `context`, `created_at`, and `updated_at`.
|
||||
2. If Node SQLite throws on real data, check whether a numeric field needs `CAST(... AS TEXT)` like `workspace_id`.
|
||||
3. If costs look too high, verify cached tokens are still subtracted from prompt tokens before calling `calculateCost`.
|
||||
4. If duplicate rows appear, inspect whether `tool_calls[].call_id` is missing and the parser is falling back to message index.
|
||||
@@ -0,0 +1,35 @@
|
||||
# Gemini
|
||||
|
||||
Google Gemini CLI.
|
||||
|
||||
- **Source:** `src/providers/gemini.ts`
|
||||
- **Loading:** eager (`src/providers/index.ts:5`)
|
||||
- **Test:** none. Adding a fixture-based test is a known good first issue.
|
||||
|
||||
## Where it reads from
|
||||
|
||||
`~/.gemini/tmp/<project>/chats/session-*.json` and `session-*.jsonl` (`gemini.ts:218-252`).
|
||||
|
||||
## Storage format
|
||||
|
||||
Either a single JSON document per session or JSONL, depending on Gemini CLI version. The parser sniffs the first non-whitespace character to decide (`gemini.ts:197-206`).
|
||||
|
||||
## Caching
|
||||
|
||||
None.
|
||||
|
||||
## Deduplication
|
||||
|
||||
Per `sessionId` (`gemini.ts:72`). Gemini sessions are aggregated to a single call per session.
|
||||
|
||||
## Quirks
|
||||
|
||||
- **Cached tokens are a subset of input.** Gemini reports cached tokens included inside `promptTokenCount`. The parser subtracts them so callers see Anthropic semantics (cached are separate).
|
||||
- **Thoughts are billed at output rate** (`gemini.ts:125`).
|
||||
- Each session collapses to one `ParsedProviderCall`. If you need per-turn data, the upstream format does not support it without re-parsing the prompt history.
|
||||
|
||||
## When fixing a bug here
|
||||
|
||||
1. The lack of a test file is a hazard. **Add a fixture and a test before changing parsing logic** so future regressions are caught.
|
||||
2. If the bug involves a new Gemini version's schema, sniff with the same first-character heuristic; do not call `JSON.parse` on the whole file.
|
||||
3. If the bug is "Gemini sessions report less than expected", check whether the cached-token subtraction is over-correcting.
|
||||
@@ -0,0 +1,42 @@
|
||||
# Goose
|
||||
|
||||
Block's Goose CLI.
|
||||
|
||||
- **Source:** `src/providers/goose.ts`
|
||||
- **Loading:** lazy (`src/providers/index.ts:29-42`)
|
||||
- **Test:** none. Adding a fixture-based test is a known good first issue.
|
||||
|
||||
## Where it reads from
|
||||
|
||||
A SQLite database. Path resolution honors `XDG_DATA_HOME` and a `GOOSE_PATH_ROOT` override:
|
||||
|
||||
| Platform | Default path |
|
||||
|---|---|
|
||||
| macOS / Linux | `~/.local/share/goose/sessions/sessions.db` |
|
||||
| Windows | `%APPDATA%/Block/goose/sessions/sessions.db` |
|
||||
|
||||
See `goose.ts:52-62`.
|
||||
|
||||
## Storage format
|
||||
|
||||
SQLite.
|
||||
|
||||
## Caching
|
||||
|
||||
None.
|
||||
|
||||
## Deduplication
|
||||
|
||||
Per `sessionId` (`goose.ts:174`).
|
||||
|
||||
## Quirks
|
||||
|
||||
- Source paths are encoded as `<dbPath>:<sessionId>` so a single db can yield many session sources. The discovery code splits on the last colon (`goose.ts:148-150`).
|
||||
- Tool inventory comes from the `messages` table queried with `LIKE '%toolRequest%'` (`goose.ts:90`). This will miss tools whose payloads are encoded differently in a future Goose version.
|
||||
- Tokens are read directly from `accumulated_input_tokens` and `accumulated_output_tokens`. No estimation.
|
||||
|
||||
## When fixing a bug here
|
||||
|
||||
1. Add a fixture-based test before changing logic. `tests/providers/goose.test.ts` does not exist yet; create it and use a small SQLite file under `tests/fixtures/goose/`.
|
||||
2. If the bug is "no sessions", check `XDG_DATA_HOME` and `GOOSE_PATH_ROOT` first; users on non-default Linux setups will not match the default path.
|
||||
3. The `LIKE '%toolRequest%'` query is fragile. If Goose changes the message envelope, this is where it will break.
|
||||
@@ -0,0 +1,45 @@
|
||||
# Grok Build
|
||||
|
||||
Grok Build, xAI's coding CLI. Sessions use the `grok-build` model by default.
|
||||
|
||||
- **Source:** `src/providers/grok.ts`
|
||||
- **Loading:** eager (`src/providers/index.ts`)
|
||||
- **Test:** `tests/providers/grok.test.ts`
|
||||
|
||||
## Where it reads from
|
||||
|
||||
`$GROK_HOME/sessions/` (or `~/.grok/sessions/`), one directory per session:
|
||||
`sessions/<url-encoded-cwd>/<uuid>/`. The parser reads `summary.json`, `signals.json`, and `updates.jsonl` from each session directory.
|
||||
|
||||
## Storage format
|
||||
|
||||
JSON + JSONL. `summary.json` holds the session id, cwd, timestamps, and `current_model_id`. `signals.json` holds `modelsUsed`, `toolsUsed`, and `contextTokensUsed`. `updates.jsonl` is the ACP log: each streamed chunk carries `params._meta.totalTokens` (running context size) and `params._meta.promptId` (one per turn).
|
||||
|
||||
## Token model
|
||||
|
||||
**Estimated.** Grok does not log billable input/output tokens. It only records the running context fill (`totalTokens` per chunk, and `contextTokensUsed` in signals). The parser reconstructs a rough estimate from the per-turn `totalTokens` curve: input is the context entering each turn, output is the context growth during it. The result is flagged `costIsEstimated` and re-priced with `calculateCost`.
|
||||
|
||||
## Pricing
|
||||
|
||||
`grok-build` is aliased to `grok-build-0.1` in `src/models.ts`, so it prices off the bundled LiteLLM fallback. Note that xAI's published API rate and the LiteLLM fallback figure differ, so treat the cost as an estimate and verify against your xAI usage console.
|
||||
|
||||
## Caching
|
||||
|
||||
None.
|
||||
|
||||
## Deduplication
|
||||
|
||||
Per `grok:<session-dir>:<updated_at>:<id>`.
|
||||
|
||||
## Quirks
|
||||
|
||||
- **No cache or output/tool-token split.** Only context fill is available, so cache fields are `0` and the cost is an estimate (likely an upper bound, since re-sent context is cached server-side and not exposed in the session files).
|
||||
- **No bash-command capture.** Tool names come from `signals.toolsUsed`; per-command bash text is not extracted, so `bashCommands` is empty.
|
||||
- **Whole-session timestamp.** Spend is attributed to `updated_at`, since the context curve is cumulative.
|
||||
- **Subscription vs API.** Grok Build runs via either a metered xAI API account (tiered) or a SuperGrok subscription; the session files do not record which.
|
||||
|
||||
## When fixing a bug here
|
||||
|
||||
1. Discovery: check the `sessions/<cwd>/<uuid>/` walk and the `GROK_HOME` resolution.
|
||||
2. Token estimate: see `estimateTokens` (groups `updates.jsonl` by `promptId`).
|
||||
3. Add a fixture-format session under `tests/providers/grok.test.ts`; do not mock the filesystem.
|
||||
@@ -0,0 +1,67 @@
|
||||
# Hermes Agent
|
||||
|
||||
Hermes Agent CLI profiles.
|
||||
|
||||
- **Source:** `src/providers/hermes.ts`
|
||||
- **Loading:** eager (`src/providers/index.ts`)
|
||||
- **Test:** `tests/providers/hermes.test.ts`
|
||||
|
||||
## Where it reads from
|
||||
|
||||
| Source | Path |
|
||||
|---|---|
|
||||
| Default Hermes profile | `$HERMES_HOME/state.db` if set, otherwise `~/.hermes/state.db` |
|
||||
| Named Hermes profiles | `$HERMES_HOME/profiles/<profile>/state.db` |
|
||||
|
||||
## Storage format
|
||||
|
||||
SQLite. The provider reads Hermes' aggregate `sessions` token/cost counters and the matching `messages` rows for user prompt and tool-call context.
|
||||
|
||||
## Parser
|
||||
|
||||
Hermes stores durable token accounting at the session level, so CodeBurn emits one parsed call per Hermes session instead of one call per LLM API request. The call contains the aggregate session totals:
|
||||
|
||||
- input tokens
|
||||
- output tokens
|
||||
- cache-read tokens
|
||||
- cache-write tokens
|
||||
- reasoning tokens
|
||||
- actual or estimated cost when Hermes recorded one
|
||||
|
||||
If Hermes recorded no positive cost, CodeBurn falls back to its normal model pricing table.
|
||||
|
||||
## Project grouping
|
||||
|
||||
Discovery groups sessions by Hermes profile (`default`, `coder`, `analytics`, etc.). When a session message includes a clean `Current working directory: /path` line, parsing can attach that project path so CodeBurn can canonicalize worktrees. The parser deliberately ignores quoted or escaped prompt text that merely contains the phrase `Current working directory:`.
|
||||
|
||||
## Tool mapping
|
||||
|
||||
Hermes `tool_calls` are normalized to CodeBurn display names where possible:
|
||||
|
||||
- `terminal` -> `Bash`
|
||||
- `read_file` -> `Read`
|
||||
- `write_file` -> `Write`
|
||||
- `patch` -> `Edit`
|
||||
- `search_files` -> `Grep`
|
||||
- browser tools -> `Browser`
|
||||
- web tools -> `WebSearch` / `WebFetch`
|
||||
- skill tools -> `Skill`
|
||||
|
||||
Terminal command arguments are exposed as `bashCommands` for CodeBurn's command breakdowns.
|
||||
|
||||
## Caching
|
||||
|
||||
The shared session cache fingerprints Hermes state DB files. `HERMES_HOME` is included in the provider environment fingerprint so changing the runtime home invalidates stale cached results.
|
||||
|
||||
## Quirks
|
||||
|
||||
- The provider is aggregate-first because Hermes' stable accounting lives in `sessions`. Do not infer per-turn usage from message text.
|
||||
- Source paths are encoded as `<dbPath>#hermes-session=<sessionId>` so SQLite paths containing `:` remain safe.
|
||||
- SQLite schema checks are intentionally light: if the expected `sessions` or `messages` columns are absent, the DB is skipped.
|
||||
|
||||
## When fixing a bug here
|
||||
|
||||
1. Reproduce against a real Hermes `state.db` or a minimal SQLite fixture.
|
||||
2. Run `npm test -- tests/providers/hermes.test.ts --run`.
|
||||
3. For local smoke testing, use an isolated cache directory, for example:
|
||||
`CODEBURN_CACHE_DIR=/tmp/codeburn-hermes-cache node --import tsx -e "import { parseAllSessions } from './src/parser.ts'; console.log(await parseAllSessions(undefined, 'hermes'))"`.
|
||||
@@ -0,0 +1,55 @@
|
||||
# IBM Bob
|
||||
|
||||
IBM Bob IDE task history.
|
||||
|
||||
- **Source:** `src/providers/ibm-bob.ts`
|
||||
- **Loading:** eager (`src/providers/index.ts`)
|
||||
- **Test:** `tests/providers/ibm-bob.test.ts`
|
||||
|
||||
## Where It Reads From
|
||||
|
||||
IBM Bob stores IDE task history below `User/globalStorage/ibm.bob-code/tasks/` in the application data directory.
|
||||
|
||||
Default paths checked:
|
||||
|
||||
| Platform | Paths |
|
||||
|---|---|
|
||||
| macOS | `~/Library/Application Support/IBM Bob/User/globalStorage/ibm.bob-code/`, `~/Library/Application Support/Bob-IDE/User/globalStorage/ibm.bob-code/` |
|
||||
| Windows | `%APPDATA%/IBM Bob/User/globalStorage/ibm.bob-code/`, `%APPDATA%/Bob-IDE/User/globalStorage/ibm.bob-code/` |
|
||||
| Linux | `$XDG_CONFIG_HOME/IBM Bob/User/globalStorage/ibm.bob-code/`, `$XDG_CONFIG_HOME/Bob-IDE/User/globalStorage/ibm.bob-code/` with `~/.config` fallback |
|
||||
|
||||
The `Bob-IDE` paths cover the preview-era app name that some installs used before the GA `IBM Bob` directory.
|
||||
|
||||
## Storage Format
|
||||
|
||||
Each task is a directory under `tasks/<task-id>/` and must contain `ui_messages.json`.
|
||||
|
||||
CodeBurn parses the same Cline-family UI event format used by Roo Code and KiloCode:
|
||||
|
||||
- `ui_messages.json` entries with `type: "say"` and `say: "api_req_started"` contain serialized token/cost metrics.
|
||||
- `ui_messages.json` user text entries seed the turn's first user message.
|
||||
- `api_conversation_history.json` is optional and is used to extract the selected model from `<model>...</model>` environment details when present.
|
||||
- `task_metadata.json` may exist upstream, but CodeBurn does not need it for usage math today.
|
||||
|
||||
If no model tag is present, the parser uses `ibm-bob-auto`, which is priced through the same conservative Sonnet fallback used for Cline-family auto modes.
|
||||
|
||||
## Caching
|
||||
|
||||
None at the provider level.
|
||||
|
||||
## Deduplication
|
||||
|
||||
Per `<providerName>:<taskId>:<apiRequestIndex>` via `vscode-cline-parser.ts`.
|
||||
|
||||
## Quirks
|
||||
|
||||
- IBM Bob has shipped under both `IBM Bob` and `Bob-IDE` application data folder names.
|
||||
- This provider intentionally covers the IDE task-history format. Bob Shell's `~/.bob` checkpoint data is a separate storage surface and is not parsed until we have a stable usage schema fixture.
|
||||
- The shared Cline parser does not currently extract individual tool names from UI messages, so tool breakdowns are empty for IBM Bob just like Roo Code and KiloCode.
|
||||
|
||||
## When Fixing A Bug Here
|
||||
|
||||
1. Check whether the install uses `IBM Bob` or `Bob-IDE` as the application data directory.
|
||||
2. Confirm the task folder still contains `ui_messages.json` and `api_conversation_history.json`.
|
||||
3. If the UI message schema changed, add a focused fixture to `tests/providers/ibm-bob.test.ts`.
|
||||
4. If the change also affects Roo Code or KiloCode, update `src/providers/vscode-cline-parser.ts` and run all three provider test files.
|
||||
@@ -0,0 +1,34 @@
|
||||
# KiloCode
|
||||
|
||||
KiloCode VS Code extension.
|
||||
|
||||
- **Source:** `src/providers/kilo-code.ts`
|
||||
- **Loading:** eager (`src/providers/index.ts:6`)
|
||||
- **Test:** `tests/providers/kilo-code.test.ts` (62 lines)
|
||||
|
||||
## Where it reads from
|
||||
|
||||
VS Code extension globalStorage for `kilocode.kilo-code` (extension ID set at `kilo-code.ts:4`). The actual walk is delegated to `discoverClineTasks` in `src/providers/vscode-cline-parser.ts`.
|
||||
|
||||
## Storage format
|
||||
|
||||
Per-task directories with `ui_messages.json` and `api_conversation_history.json`. See [`vscode-cline-parser`](vscode-cline-parser.md) for the full schema description.
|
||||
|
||||
## Caching
|
||||
|
||||
None at the provider level; delegates to the shared helper.
|
||||
|
||||
## Deduplication
|
||||
|
||||
Delegated. Per `<providerName>:<taskId>:<index>` (handled in `vscode-cline-parser.ts:109`).
|
||||
|
||||
## Quirks
|
||||
|
||||
- This file is a thin wrapper. Almost every bug for KiloCode actually lives in `vscode-cline-parser.ts`.
|
||||
- The VS Code extension wrappers using the Cline-family parser differ **only** by extension ID.
|
||||
|
||||
## When fixing a bug here
|
||||
|
||||
1. If the bug is "Cline, KiloCode, and Roo Code all broken in the same way", fix it in `vscode-cline-parser.ts`.
|
||||
2. If the bug is "KiloCode broken, Roo Code fine", the difference is upstream (KiloCode's emitted JSON differs slightly). Reproduce with a fixture and consider whether the cline parser needs to branch on extension ID.
|
||||
3. Read [`vscode-cline-parser.md`](vscode-cline-parser.md) before editing.
|
||||
@@ -0,0 +1,62 @@
|
||||
# Kimi
|
||||
|
||||
Kimi Code CLI session parser.
|
||||
|
||||
- **Source:** `src/providers/kimi.ts`
|
||||
- **Loading:** eager (`src/providers/index.ts`)
|
||||
- **Test:** `tests/providers/kimi.test.ts`
|
||||
|
||||
## Where it reads from
|
||||
|
||||
`$KIMI_SHARE_DIR/sessions/` if set, otherwise `~/.kimi/sessions/`.
|
||||
|
||||
Kimi stores sessions by work-directory hash:
|
||||
|
||||
```text
|
||||
~/.kimi/
|
||||
kimi.json
|
||||
config.toml
|
||||
sessions/
|
||||
<workdir-md5>/
|
||||
<session-id>/
|
||||
context.jsonl
|
||||
wire.jsonl
|
||||
state.json
|
||||
subagents/
|
||||
<agent-id>/
|
||||
context.jsonl
|
||||
wire.jsonl
|
||||
```
|
||||
|
||||
`kimi.json` maps each work-directory hash back to the original working path. CodeBurn uses that to display the project basename; if the metadata file is missing, the hash directory name is used.
|
||||
|
||||
## Storage Format
|
||||
|
||||
CodeBurn reads `wire.jsonl`. Each data line is a persisted wire record:
|
||||
|
||||
```json
|
||||
{"timestamp":1776162403,"message":{"type":"StatusUpdate","payload":{"message_id":"msg-1","token_usage":{"input_other":100,"input_cache_read":25,"input_cache_creation":10,"output":40}}}}
|
||||
```
|
||||
|
||||
`TurnBegin` / `SteerInput` provide the user prompt, `ToolCall` / `ToolCallRequest` provide tool names and shell commands, and `StatusUpdate.token_usage` provides the billable token counts.
|
||||
|
||||
## Caching
|
||||
|
||||
None.
|
||||
|
||||
## Deduplication
|
||||
|
||||
Per `kimi:<session-id>:<message_id>`, falling back to the status-update line index if the message id is absent.
|
||||
|
||||
## Quirks
|
||||
|
||||
- Kimi's official `TokenUsage` separates `input_other`, `input_cache_read`, `input_cache_creation`, and `output`. CodeBurn maps those directly into input, cache read, cache write, and output.
|
||||
- The current Kimi wire schema does not persist the model on every usage update. CodeBurn uses `KIMI_MODEL_NAME` when set, then the active `~/.kimi/config.toml` default model, then `kimi-auto`.
|
||||
- `kimi-auto`, `kimi-code`, and `kimi-for-coding` are priced as `kimi-k2-thinking` so managed Kimi Code sessions do not show as `$0` when the exact backend model is hidden.
|
||||
- Subagent sessions are discovered from `subagents/<agent-id>/wire.jsonl` and parsed as separate Kimi sessions under the same project.
|
||||
|
||||
## When Fixing A Bug Here
|
||||
|
||||
1. Reproduce with a tiny `wire.jsonl` fixture in `tests/providers/kimi.test.ts`.
|
||||
2. If token totals look wrong, inspect `StatusUpdate.token_usage` first; `context.jsonl` only stores context checkpoints and cumulative counts, not per-step billing detail.
|
||||
3. If tools are missing, check whether Kimi emitted `ToolCall`, `ToolCallRequest`, or nested `SubagentEvent`; CodeBurn intentionally counts subagent wire files separately to avoid double-counting parent mirrors.
|
||||
@@ -0,0 +1,53 @@
|
||||
# Kiro
|
||||
|
||||
Kiro IDE chat history.
|
||||
|
||||
- **Source:** `src/providers/kiro.ts`
|
||||
- **Loading:** eager (`src/providers/index.ts:7`)
|
||||
- **Test:** `tests/providers/kiro.test.ts`
|
||||
|
||||
## Where it reads from
|
||||
|
||||
VS Code-style globalStorage at `kiro.kiroagent`:
|
||||
|
||||
| Platform | Path |
|
||||
|---|---|
|
||||
| macOS | `~/Library/Application Support/Kiro/User/globalStorage/kiro.kiroagent` |
|
||||
| Windows | `%APPDATA%/Kiro/User/globalStorage/kiro.kiroagent` |
|
||||
| Linux | `~/.config/Kiro/User/globalStorage/kiro.kiroagent` |
|
||||
|
||||
Sessions are under hash-named workspace subdirectories. Discovery keeps backward compatibility with legacy `.chat` files and also scans the post-February 2026 extensionless format:
|
||||
|
||||
- `<workspace-hash>/<execution-id>.chat` legacy session files
|
||||
- `<workspace-hash>/<session-hash>` extensionless session index files
|
||||
- `<workspace-hash>/<session-hash>/<execution-hash>` extensionless execution files inside session directories
|
||||
|
||||
## Storage format
|
||||
|
||||
Kiro has two known JSON formats:
|
||||
|
||||
- Legacy `.chat` files with `{ chat, metadata, executionId }`
|
||||
- Modern extensionless execution files with identifiers/timestamps at the top level plus conversation fields such as `messages`, `conversation`, `chat`, `transcript`, `entries`, `events`, or direct prompt/response fields
|
||||
|
||||
Session index files with `{ executions: [...] }` are discovered but skipped during parsing because they do not contain conversation content.
|
||||
|
||||
## Caching
|
||||
|
||||
None.
|
||||
|
||||
## Deduplication
|
||||
|
||||
Modern files deduplicate per session/execution pair. Legacy `.chat` files deduplicate per workflow/execution pair.
|
||||
|
||||
## Quirks
|
||||
|
||||
- **Workspace hash resolution** is non-trivial. The parser tries `workspace.json` first; if that fails, it base64-decodes the directory name to recover the workspace path.
|
||||
- **Model ID normalization.** Kiro stores models like `claude-1.2`; the parser rewrites the dot to a hyphen so they match `claude-1-2` in the pricing snapshot. Add new versions here when Kiro ships them.
|
||||
- **Tool name extraction accepts text and structured calls.** Kiro can embed tool calls inside message text as `<tool_use><name>...</name>` or expose structured `toolCalls` / `tool_calls` / `tools` entries.
|
||||
- Token counts are estimated via char count (`CHARS_PER_TOKEN = 4`).
|
||||
|
||||
## When fixing a bug here
|
||||
|
||||
1. If the bug is "wrong workspace", check the base64 fallback path. Some users name their workspaces with characters that are not valid base64.
|
||||
2. If the bug is "missing model in pricing", add the model to the normalization map and verify against `tests/providers/kiro.test.ts`.
|
||||
3. If the bug is "tools missing", check both text-envelope extraction and structured tool-call extraction. Kiro changes its envelope occasionally.
|
||||
@@ -0,0 +1,88 @@
|
||||
# LingTai TUI
|
||||
|
||||
LingTai TUI per-agent token ledger integration.
|
||||
|
||||
- **Source:** `src/providers/lingtai-tui.ts`
|
||||
- **Loading:** eager (`src/providers/index.ts`)
|
||||
- **Test:** `tests/providers/lingtai-tui.test.ts`
|
||||
|
||||
## Where it reads from
|
||||
|
||||
| Source | Path |
|
||||
|---|---|
|
||||
| Explicit LingTai homes | `$LINGTAI_HOME` or `$LINGTAI_TUI_HOME` if set; path-list values are supported |
|
||||
| Default LingTai home | `~/.lingtai` |
|
||||
| Project LingTai homes | `<project>/.lingtai` for projects registered in `~/.lingtai-tui/registry.jsonl` and `~/.lingtai-tui/brief/projects/*/meta.json` |
|
||||
| Current worktree home | `.lingtai` in the current directory or any parent directory |
|
||||
| Agent ledgers | `<lingtai-home>/<agent>/logs/token_ledger.jsonl` |
|
||||
|
||||
Daemon ledgers nested under `<agent>/daemons/...` are deliberately not discovered during normal scanning. LingTai mirrors daemon usage into the parent agent ledger with `source`, `em_id`, and `run_id` tags, so reading nested ledgers too would double count spend.
|
||||
|
||||
## Storage format
|
||||
|
||||
Append-only JSONL. Each valid ledger line may include:
|
||||
|
||||
- `source`
|
||||
- `em_id`
|
||||
- `run_id`
|
||||
- `ts`
|
||||
- `input`
|
||||
- `output`
|
||||
- `thinking`
|
||||
- `cached`
|
||||
- `model`
|
||||
- `endpoint`
|
||||
|
||||
Malformed lines and zero-token entries are skipped. Missing `model` falls back to the agent `.agent.json` `llm.model`, then `unknown`.
|
||||
|
||||
## Parser
|
||||
|
||||
CodeBurn emits one parsed call per ledger entry. LingTai records provider-normalized total input plus a separate `cached` counter, so the provider maps:
|
||||
|
||||
- `input - cached` -> fresh input tokens
|
||||
- `cached` -> cache-read tokens
|
||||
- `output` -> output tokens
|
||||
- `thinking` -> reasoning tokens
|
||||
|
||||
Costs are calculated from CodeBurn's normal model pricing table.
|
||||
|
||||
## Activity mapping
|
||||
|
||||
LingTai's token ledger is an accounting source, not full chat history, so it does not include the original user prompt or per-tool transcript. CodeBurn maps the ledger `source` field conservatively:
|
||||
|
||||
| LingTai `source` | CodeBurn activity |
|
||||
|---|---|
|
||||
| `main` and unknown sources | Conversation |
|
||||
| `tc_wake` and other task-coordinator wake sources | Delegation |
|
||||
| `daemon` | Delegation |
|
||||
| `summarize_apriori` | Planning |
|
||||
|
||||
This keeps the menubar and dashboard **By Activity** view from collapsing all LingTai usage into Conversation while avoiding invented feature/debug/refactor semantics that the ledger cannot prove.
|
||||
|
||||
## Project grouping
|
||||
|
||||
Discovery reads `<agent>/.agent.json` and groups by `nickname`, `agent_name`, `address`, then the directory name. Project-local homes are prefixed with the project directory name, for example `sample-project-Project Agent`, so same-named agents from different LingTai projects do not collapse together. The parsed call also carries the agent directory as `projectPath`.
|
||||
|
||||
## Caching
|
||||
|
||||
The shared session cache fingerprints each `token_ledger.jsonl`. `LINGTAI_HOME`, `LINGTAI_TUI_HOME`, and `LINGTAI_TUI_GLOBAL_DIR` are part of the provider environment fingerprint so changing homes invalidates stale cached results.
|
||||
|
||||
## Deduplication
|
||||
|
||||
Dedup keys include provider name, ledger path, line number, timestamp, model, endpoint, LingTai source tags, and token counts:
|
||||
|
||||
`lingtai-tui:<ledger-path>:<line>:<timestamp>:<model>:...`
|
||||
|
||||
The ledger is append-only, so line number is stable for normal operation.
|
||||
|
||||
## Quirks
|
||||
|
||||
- Tool calls are not reconstructed from chat history. The token ledger is the stable accounting source and does not include tool metadata.
|
||||
- Older ledger entries may not include `source`; those are labeled `main`.
|
||||
- `cached` is treated as cache read. LingTai does not expose a separate cache creation counter in the ledger.
|
||||
|
||||
## When fixing a bug here
|
||||
|
||||
1. Prefer a minimal redacted `token_ledger.jsonl` fixture over full `chat_history.jsonl`.
|
||||
2. Check whether a daemon entry is already mirrored into the parent ledger before adding new discovery paths.
|
||||
3. Run `npm test -- tests/providers/lingtai-tui.test.ts --run`.
|
||||