Merge main into feat/codebuff-provider to resolve conflicts
18
.github/PULL_REQUEST_TEMPLATE.md
vendored
Normal file
|
|
@ -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 -->
|
||||
24
.github/workflows/release-menubar.yml
vendored
|
|
@ -2,8 +2,8 @@ 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. `npx codeburn menubar` clears
|
||||
# the download quarantine flag on install so Gatekeeper stays quiet.
|
||||
# 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:
|
||||
|
|
@ -45,7 +45,9 @@ jobs:
|
|||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: CodeBurnMenubar-${{ steps.version.outputs.value }}
|
||||
path: mac/.build/dist/CodeBurnMenubar-*.zip
|
||||
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
|
||||
|
|
@ -58,12 +60,16 @@ jobs:
|
|||
Install with:
|
||||
|
||||
```
|
||||
npx codeburn menubar
|
||||
npm install -g codeburn
|
||||
codeburn menubar
|
||||
```
|
||||
|
||||
That command drops the app into `~/Applications`, clears the download
|
||||
quarantine, 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-*.zip
|
||||
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
|
||||
|
|
|
|||
5
.gitignore
vendored
|
|
@ -16,7 +16,7 @@ Thumbs.db
|
|||
# Planning artifacts (internal, not shipped)
|
||||
docs/superpowers/
|
||||
.claude/
|
||||
CLAUDE.md
|
||||
/CLAUDE.md
|
||||
|
||||
# Config / secrets
|
||||
.env
|
||||
|
|
@ -40,3 +40,6 @@ assets/discord-*.png
|
|||
|
||||
# Desktop app experiments
|
||||
desktop/
|
||||
|
||||
# WIP / not ready
|
||||
src/summit.ts
|
||||
|
|
|
|||
267
CHANGELOG.md
|
|
@ -1,5 +1,272 @@
|
|||
# Changelog
|
||||
|
||||
## Unreleased
|
||||
|
||||
### Added (CLI)
|
||||
- **Mistral Vibe provider.** CodeBurn now reads Mistral Vibe session folders
|
||||
from `$VIBE_HOME/logs/session/` or `~/.vibe/logs/session/`, using
|
||||
`meta.json` for cumulative prompt/completion tokens, model pricing, and
|
||||
timestamps, and `messages.jsonl` for user prompts and tool calls. Subagent
|
||||
sessions under a parent session's `agents/` folder are tracked separately.
|
||||
Closes #283.
|
||||
- **Kimi Code CLI provider.** CodeBurn now reads Kimi session usage from
|
||||
`$KIMI_SHARE_DIR/sessions/` or `~/.kimi/sessions/`, including subagent
|
||||
`wire.jsonl` files. The parser consumes Kimi's official `StatusUpdate`
|
||||
token usage fields (`input_other`, `input_cache_read`,
|
||||
`input_cache_creation`, `output`), normalizes Kimi tool names such as
|
||||
`Shell`, `ReadFile`, and `WriteFile`, and maps hidden managed Kimi Code
|
||||
model aliases to priced Kimi K2 entries.
|
||||
|
||||
## 0.9.9 - 2026-05-15
|
||||
|
||||
### Added (CLI)
|
||||
- **IBM Bob provider.** Discovers IBM Bob IDE task history, reuses the
|
||||
Cline-family parser for token/cost records, extracts model tags and
|
||||
workspace-based project names from session data. Closes #248.
|
||||
|
||||
### Fixed (CLI)
|
||||
- **Reduced Claude parser OOM risk.** Large Claude JSONL sessions retained
|
||||
full entry objects (text, thinking blocks, tool results) in memory during
|
||||
parsing, causing V8 heap exhaustion on heavy usage months. Entries are now
|
||||
compacted immediately after JSON.parse, keeping only the fields needed for
|
||||
cost/token aggregation. This is a mitigation - very heavy users may still
|
||||
need the streaming parser refactor planned next.
|
||||
- **Eager daily-cache hydration caused OOM on most CLI commands.** Eight
|
||||
commands (report, today, month, export, optimize, compare, models, yield)
|
||||
called `hydrateCache()` which parses a 365-day backfill, even though only
|
||||
`status --format menubar-json` consumes the daily cache. Removed from all
|
||||
paths that parse their own date ranges via `parseAllSessions`.
|
||||
- **Session cache retained between status parses.** The `status --format json`
|
||||
path parsed today and month ranges without clearing the in-process session
|
||||
cache between them, keeping both result sets pinned. Cache is now cleared
|
||||
after each period is consumed.
|
||||
- **Claude 1-hour cache write pricing.** 1-hour cache writes are now priced
|
||||
at 2x base input (previously used the 5-minute 1.25x rate for all writes).
|
||||
Daily cache bumped to v6 so stale totals are recomputed. Closes #276.
|
||||
- **OpenCode MCP usage now counted.** OpenCode stores MCP tool calls as
|
||||
`<server>_<tool>` names, which the shared MCP pipeline did not recognize.
|
||||
The provider now normalizes these to the canonical `mcp__<server>__<tool>`
|
||||
form so MCP breakdowns and `optimize` work correctly. Closes #308.
|
||||
- **Antigravity Windows language-server discovery.** Antigravity detection now
|
||||
supports Windows process discovery, `--extension_server_port`,
|
||||
`--extension_server_csrf_token`, `--flag=value` syntax, and both wrapped and
|
||||
unwrapped Connect-RPC response shapes. Closes #249.
|
||||
- **Mangled project names in dashboard.** The By Project and Top Sessions
|
||||
panels decoded slugs by splitting on `-`, which broke directory names
|
||||
containing dashes or dots (e.g. `my-project` rendered as `my/project`).
|
||||
Now uses the real project path instead. Closes #320.
|
||||
- **Cursor undated bubble rows misattributed to Today.** Bubble rows without
|
||||
a `createdAt` timestamp were defaulting to the current date, inflating
|
||||
Today's spend. Now skipped at both the SQL and application level.
|
||||
- **Node version guard.** Running on Node < 22.13.0 now prints a clear
|
||||
upgrade message instead of crashing with a cryptic `node:sqlite` parse
|
||||
error. Closes #319.
|
||||
|
||||
### Fixed (macOS menubar)
|
||||
- **All-provider refresh OOM.** Refreshing with provider set to "All" could
|
||||
exhaust the V8 heap on accounts with heavy session history.
|
||||
- **Tab refresh recovery.** Switching tabs during a refresh no longer leaves
|
||||
the panel in a stale loading state.
|
||||
- **Stale cache recovery.** The menubar now detects and discards a corrupt or
|
||||
outdated on-disk cache instead of rendering zeroes until the next restart.
|
||||
- **Refresh timer hardening.** The 30-second auto-refresh timer is now
|
||||
cancelled on sleep/wake and restarted cleanly, preventing overlapping
|
||||
refreshes after lid-open.
|
||||
- **Version display.** The settings panel now shows the version without the
|
||||
`v` prefix for consistency with `codeburn --version`.
|
||||
|
||||
## 0.9.8 - 2026-05-10
|
||||
|
||||
### Added (CLI)
|
||||
- **Cline provider support.** CodeBurn now reads Cline task usage from both
|
||||
VS Code globalStorage (`saoudrizwan.claude-dev`) and Cline's
|
||||
`~/.cline/data` task root. It reuses the existing Cline-family parser for
|
||||
`ui_messages.json` usage entries, deduplicates migrated tasks by the newest
|
||||
`ui_messages.json`, and exposes Cline in CLI provider filters, docs, and the
|
||||
macOS menubar provider tabs. Closes #130.
|
||||
- **Multiple Claude config directories.** Set `CLAUDE_CONFIG_DIRS` to an
|
||||
OS-delimited list of paths (`:`-separated on POSIX, `;`-separated on
|
||||
Windows) to scan more than one Claude data directory in a single run.
|
||||
Sessions across every configured directory roll up into one project row
|
||||
per project, so a user with `~/.claude-work` and `~/.claude-personal`
|
||||
who works on the same repo from both accounts sees one combined row
|
||||
rather than two split rows. `~` is expanded; missing or unreadable
|
||||
directories in the list are skipped instead of aborting the scan; if
|
||||
every listed entry is unreadable a one-line hint is written to stderr
|
||||
so a misplaced delimiter does not silently produce zero rows.
|
||||
Precedence: `CLAUDE_CONFIG_DIRS` > `CLAUDE_CONFIG_DIR` > `~/.claude`.
|
||||
As part of this change `~` and `~/foo` are now also expanded in
|
||||
`CLAUDE_CONFIG_DIR` (previously the value was passed through verbatim,
|
||||
which only worked when the shell expanded `~` before exporting).
|
||||
Closes #208.
|
||||
- **`codeburn models` command.** Per-model breakdown across all providers,
|
||||
one row per (provider, model), sorted by cost. Each row carries Input,
|
||||
Output, Cache Write, Cache Read, Total, and Cost columns plus a Top Task
|
||||
cell showing the dominant task category and its cost share (e.g.
|
||||
`Coding (42%)`). Pass `--by-task` to explode each model into one row per
|
||||
task type, with provider/model cells blanked on subsequent rows of the
|
||||
same group and a horizontal divider between groups. Filters: `--period`
|
||||
(default `30days`), `--from/--to`, `--provider`, `--task`, `--top`,
|
||||
`--min-cost`, `--no-totals`. Output formats: `table` (Unicode box-drawn,
|
||||
default), `markdown` (GitHub-flavored, copy-paste friendly), `json`,
|
||||
`csv`. The table renderer auto-sizes every column to its content and
|
||||
drops cache columns first, then input/output, then top-task when the
|
||||
terminal is too narrow to fit the full set. Headers are cyan, totals row
|
||||
is yellow, provider name is dim. Inspired by tokscale's per-model table
|
||||
and ccusage's responsive cli-table3 layout, ported to plain Node with
|
||||
no new runtime dependency.
|
||||
- **Per-day one-shot data in `--format json`.** Each entry of `daily[]` now
|
||||
carries `turns`, `editTurns`, `oneShotTurns`, and `oneShotRate` (0-100,
|
||||
one decimal, `null` when no edit turns). Counts match the existing
|
||||
period-level `activities[]` rollup so a consumer can sum across days and
|
||||
reconcile. Closes #279.
|
||||
|
||||
### Fixed (CLI)
|
||||
- **Cursor sessions break down by project, not one row called "cursor".**
|
||||
Cursor's chat history sat under a single dashboard row labeled `cursor`
|
||||
because the provider had no way to attribute bubbles to a workspace.
|
||||
The fix walks `~/Library/Application Support/Cursor/User/workspaceStorage/*`
|
||||
for each workspace's `workspace.json` (folder URI) and
|
||||
`composer.composerData` (the composer ids opened in that workspace),
|
||||
then joins those composer ids against the global bubbles. Each
|
||||
workspace becomes its own project row, sanitized into the same slug
|
||||
shape Claude uses (e.g. `-Users-you-myproject`); composers that have
|
||||
no workspace mapping (multi-root workspaces, "no folder open"
|
||||
sessions, deleted workspaces) remain under a catch-all `cursor` row.
|
||||
As part of this the cursor parser now derives `sessionId` from the
|
||||
bubble row key (`bubbleId:<composerId>:<bubbleUuid>`) instead of the
|
||||
empty `conversationId` JSON field, which was always falling back to
|
||||
`'unknown'`. Cursor result cache version bumped to 3 to invalidate
|
||||
prior caches that recorded the old session id. Closes the per-project
|
||||
half of #196.
|
||||
- **Cursor cost shown for every model, not just Auto.** Cursor emits model
|
||||
names in a `claude-<dot-version>-<tier>` shape (`claude-4.6-sonnet`,
|
||||
`claude-4.5-opus`, `claude-4.5-opus-high-thinking`, etc.) plus its own
|
||||
`composer-1` house model, none of which match the canonical LiteLLM
|
||||
pricing keys (`claude-sonnet-4-6`, `claude-opus-4-5`). The alias map in
|
||||
`src/models.ts` filled some of these in v0.9.4 but missed the plain
|
||||
no-suffix forms (`claude-4.5-opus`, `claude-4.5-sonnet`,
|
||||
`claude-4.6-opus`), the haiku tier, the forward-looking 4.7 variant,
|
||||
and `composer-1`. The dashboard rendered $0 for sessions that used any
|
||||
unaliased model. Visible to users in #159 even after the v0.9.4 fix.
|
||||
Every Cursor variant in `src/providers/cursor.ts:modelDisplayNames`
|
||||
now has an alias and a regression test asserting non-zero pricing
|
||||
resolution. Closes #159.
|
||||
- **Activity classifier no longer mislabels feature work as debugging.**
|
||||
Messages like "add error handling", "create an issue tracker", or
|
||||
"implement the 404 page" used to land in the Debugging bucket because
|
||||
the classifier checked the debug-keyword regex (which matches `error`,
|
||||
`issue`, `404`) before the feature regex. Now the keyword that appears
|
||||
earliest in the user message wins, so "add" beats "error", "create"
|
||||
beats "issue", etc. A real bug report ("login is broken, traceback
|
||||
below") still classifies as debugging because the debug word leads.
|
||||
Fixes the activity-misattribution half of #196.
|
||||
|
||||
### Changed (CLI)
|
||||
- **`optimize` suggestions now declare their destination.** Every paste-style
|
||||
fix carries an explicit destination — `claude-md` (permanent project rule),
|
||||
`session-opener` (one-time paste at the start of a future session),
|
||||
`prompt` (one-time ask in the current chat), or `shell-config` (append to
|
||||
`~/.zshrc` / `~/.bashrc`). Output renders a clearly-labeled section header
|
||||
per destination so users no longer accidentally bake one-time session
|
||||
openers into their CLAUDE.md as permanent rules. Closes #277.
|
||||
|
||||
## 0.9.7 - 2026-05-07
|
||||
|
||||
### Added (CLI)
|
||||
- **MCP tool coverage detector.** New `optimize` finding flags MCP servers
|
||||
whose tool inventory is largely unused. Inventory is observed from the
|
||||
Claude `deferred_tools_delta` JSONL attachments (exact tool names per
|
||||
session) instead of guessed at five tools per server. Token-savings
|
||||
estimates are cache-aware: schema bytes pay full input price on the first
|
||||
cache-creation turn of a session, then carry at the cache-read discount
|
||||
on subsequent turns, capped per call so we never claim more overhead
|
||||
than the call's own cache buckets could contain. Threshold:
|
||||
>10 tools available, <20% coverage, observed in ≥2 sessions. Closes #2.
|
||||
- **Session cost outlier detector.** New `optimize` finding flags sessions costing more than 2x their peer-session average within the same project. Ignores sub-$1 outliers to avoid noise. Requires at least 3 sessions per project for a baseline.
|
||||
- **Context bloat detector.** New `optimize` finding flags sessions where
|
||||
effective input/cache tokens are large and disproportionate to output.
|
||||
Cache reads are discounted in the estimate to avoid overstating cheap cached
|
||||
context. The report highlights top sessions by imbalance, notes sharp
|
||||
growth from the previous project session (within a 7-day baseline window),
|
||||
and suggests starting fresh with only the current goal, relevant files,
|
||||
failing output, and constraints. Sessions flagged here are excluded from
|
||||
the cost-outlier finding so the same session is not listed twice.
|
||||
- **Worth-it score detector.** New `optimize` finding flags expensive sessions
|
||||
with weak delivery signals: no edit turns, repeated retries, or edit work
|
||||
that never landed in one shot, when no `git`/`gh` delivery command is
|
||||
observed. Framed as a conservative review candidate, not proof of waste.
|
||||
Sessions flagged here take priority and are excluded from both the
|
||||
context-bloat and cost-outlier findings so the same session is not listed
|
||||
more than once.
|
||||
- **Per-model efficiency metrics.** JSON report includes edit turns, one-shot rate, retries per edit, and cost per edit for each model.
|
||||
- **Custom date range export.** `codeburn export --from --to` exports a single custom period.
|
||||
- **Live Claude quota bar.** Menubar shows real-time quota usage inside the agent tab strip with OAuth refresh gate.
|
||||
|
||||
### Fixed (CLI)
|
||||
- **Invalid `--format` silently accepted.** All commands now reject unknown format values with a clear error and exit 1 instead of silently falling back to the default.
|
||||
- **Invalid `--period` silently accepted.** `getDateRange()` no longer falls back to "week" on unknown periods. All period-accepting commands reject invalid values.
|
||||
- **`status` help text.** Description said "today + week + month" but only today and month were shown. Fixed to match actual output.
|
||||
- **Windows Claude project paths.** Claude Code project rollups now prefer
|
||||
the canonical `cwd` stored in session JSONL files instead of reconstructing
|
||||
paths from lossy directory slugs, and group case/slash variants together.
|
||||
Closes #217.
|
||||
- **`all` period semantics unified between CLI and dashboard.** The dashboard treated `--period all` as all-time (epoch start) while the CLI bounded it to the last 6 months. Both now consistently mean "Last 6 months". Period helpers (`Period`, `PERIODS`, `PERIOD_LABELS`, `toPeriod`, `getDateRange`) consolidated into `cli-date.ts`. Use `--from` / `--to` for unbounded historical ranges.
|
||||
- **Popover anchor, tab strip flicker, and stale-data refresh.** Batch of UI regressions from the menubar hardening round.
|
||||
- **Validator hardenings.** Batch of edge-case fixes from the multi-agent bug hunt.
|
||||
- **Command injection in yield.** `yield` now uses `execFileSync` instead of `execSync` to prevent shell injection via crafted branch names.
|
||||
- **SHA-256 checksum verification.** Menubar installer verifies download integrity before replacing the running app.
|
||||
|
||||
### Fixed (macOS menubar)
|
||||
- **Stuck loading spinner.** The menubar ran `--optimize` on every 30-second background refresh. As sessions accumulated, optimize exceeded the 45-second timeout, and the loading overlay stayed forever with no fallback. Optimize is now stripped from all menubar fetches (use `codeburn optimize` in the CLI instead). On fetch failure with empty cache, the app retries without optimize so the spinner always clears.
|
||||
- **Stale data after overnight sleep.** Cache keys used the period enum (`.today`) not a calendar date, so data from yesterday persisted after midnight. Cache now tracks the current date and clears itself on day rollover. Wake-from-sleep additionally clears all cached entries before fetching fresh data.
|
||||
- **Refresh button appeared to do nothing.** Clicking refresh with stale cached data never showed the loading overlay because loading state only triggered on empty cache. Manual refresh and wake-from-sleep now explicitly request loading feedback.
|
||||
- **Update button stuck spinning forever.** `performUpdate()` only reset `isUpdating` on failure. On success the installer kills and relaunches the app, but if the process survives (pkill fails silently), the button stayed on "Updating..." permanently. Now always resets on termination and clears the update badge on success.
|
||||
|
||||
## 0.9.6 - 2026-05-03
|
||||
|
||||
### Added (CLI)
|
||||
- **Goose provider.** New provider for Block's Goose AI coding assistant.
|
||||
- **Antigravity provider.** New provider for Antigravity IDE sessions.
|
||||
- **Antigravity model aliases.** gemini-3-pro, flash-image, flash-lite, and community-contributed Gemini model IDs.
|
||||
- **GPT-5.5 display name** for Codex.
|
||||
- **Deno support.** `deno dx` added as a run method.
|
||||
|
||||
### Fixed (CLI)
|
||||
- **Streaming dedup.** Claude Code streams each `message.id` multiple times (start, intermediate, stop). The old keep-first strategy lost tool_use blocks and understated output tokens by ~6.3%. Now keeps last occurrence content with first occurrence timestamp for correct date bucketing.
|
||||
- **`$0.0000` display.** Near-zero costs showed four decimal places instead of `$0.00`. Fixes #205.
|
||||
- **ANSI escape stripping.** Shell commands containing ANSI color codes now cleaned across all providers.
|
||||
- **Antigravity dedup collision.** Fixed key collision in session dedup. Added Codex ChatGPT Plus token estimation.
|
||||
- **Codex large session validation.** Reads full first line for session meta validation; caps read size and handles torn writes.
|
||||
- **Codex fork dedup.** Deduplicates forked Codex sessions to avoid double-counting.
|
||||
- **Windows dashboard hang.** Fixed `ExperimentalWarning` and dashboard freeze on Windows.
|
||||
- **Hardcoded `$` in forecast.** Forecast comparison text now uses the configured currency symbol.
|
||||
|
||||
### Fixed (macOS menubar)
|
||||
- **Provider tabs showing $0.00 after idle.** CLI timeout increased from 20s to 45s for cold file-cache latency. Loading overlay now appears when the all-provider payload confirms a provider has spend but its dedicated data hasn't loaded yet.
|
||||
- **Refresh button blocked by in-flight requests.** Manual refresh now bypasses the in-flight guard so users can always re-fetch.
|
||||
- **Tab strip vs hero cost mismatch.** Tab strip prefers the provider-specific payload cost when available, staying in sync with the hero section.
|
||||
- **Ghost status item on macOS Tahoe.**
|
||||
|
||||
## 0.9.5 - 2026-05-01
|
||||
|
||||
### Added (CLI)
|
||||
- **Homebrew tap.** `brew tap getagentseal/codeburn && brew install codeburn`.
|
||||
- **GPT-5.3 and DeepSeek display names.** GPT-5.3, DeepSeek Coder, DeepSeek Coder Max, DeepSeek R1.
|
||||
|
||||
### Fixed (macOS menubar)
|
||||
- **Menubar refresh loop.** Was a single-fire Task that never repeated; now a proper while loop with 30s interval and `force: true`.
|
||||
- **Loading overlay flicker.** Counter-based `isLoading` so concurrent fetches don't toggle the overlay.
|
||||
- **Rapid tab switching race.** Previous fetch is cancelled when switching tabs; stale results are discarded via `Task.isCancelled`.
|
||||
- **Tab strip vs hero cost desync.** Provider-specific and all-provider data now fetched in parallel so costs arrive from the same snapshot.
|
||||
- **Stale menubar icon after wake.** `forceRefresh` now fetches today/all in parallel alongside the current selection.
|
||||
- **Accent color propagation.** `ThemeState` is now `@Observable`; removes `.id()` view hierarchy teardown hack.
|
||||
- **Currency flash on first switch.** Symbol and rate now apply atomically — no more wrong-symbol-with-old-rate flash.
|
||||
- **Export UI freeze.** Uses `terminationHandler` instead of `waitUntilExit`; HHmmss in filename prevents overwrite on double-export.
|
||||
- **CurrencyState concurrency.** Proper `@MainActor` isolation with `Sendable` conformance; `nonisolated` on pure static functions.
|
||||
- **Streak count.** Iterates calendar days instead of sparse history entries so gaps correctly break streaks.
|
||||
- **TrendBar chart flicker.** Stable date-based identity instead of UUID.
|
||||
|
||||
## 0.9.4 - 2026-04-29
|
||||
|
||||
### Added (CLI)
|
||||
|
|
|
|||
127
CONTRIBUTING.md
Normal file
|
|
@ -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.
|
||||
131
README.md
|
|
@ -13,7 +13,7 @@
|
|||
<a href="https://github.com/sponsors/iamtoruk"><img src="https://img.shields.io/badge/sponsor-♥-ea4aaa?logo=github" alt="Sponsor" /></a>
|
||||
</p>
|
||||
|
||||
CodeBurn tracks token usage, cost, and performance across **16 AI coding tools**. It breaks down spending by task type, model, tool, project, and provider so you can see exactly where your budget goes.
|
||||
CodeBurn tracks token usage, cost, and performance across **19 AI coding tools**. It breaks down spending by task type, model, tool, project, and provider so you can see exactly where your budget goes.
|
||||
|
||||
Everything runs locally. No wrapper, no proxy, no API keys. CodeBurn reads session data directly from disk and prices every call using [LiteLLM](https://github.com/BerriAI/litellm).
|
||||
|
||||
|
|
@ -48,11 +48,19 @@ Everything runs locally. No wrapper, no proxy, no API keys. CodeBurn reads sessi
|
|||
npm install -g codeburn
|
||||
```
|
||||
|
||||
Or with Homebrew:
|
||||
|
||||
```bash
|
||||
brew tap getagentseal/codeburn
|
||||
brew install codeburn
|
||||
```
|
||||
|
||||
Or run directly without installing:
|
||||
|
||||
```bash
|
||||
npx codeburn
|
||||
bunx codeburn
|
||||
dx codeburn
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
|
@ -75,32 +83,47 @@ codeburn optimize -p week # scope the scan to last 7 days
|
|||
codeburn compare # side-by-side model comparison
|
||||
codeburn yield # track productive vs reverted/abandoned spend
|
||||
codeburn yield -p 30days # yield analysis for last 30 days
|
||||
codeburn models # per-model token + cost table (last 30 days)
|
||||
codeburn models --by-task # explode each model into per-task-type rows
|
||||
codeburn models --top 10 # only the top 10 by cost
|
||||
codeburn models --format markdown # paste-friendly markdown table
|
||||
codeburn models --task feature # filter to feature-development work
|
||||
codeburn models --provider claude # filter to one provider
|
||||
```
|
||||
|
||||
Arrow keys switch between Today, 7 Days, 30 Days, Month, and All Time. 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.
|
||||
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.
|
||||
|
||||
## Supported Providers
|
||||
|
||||
| Provider | Data Location | Supported |
|
||||
|----------|--------------|-----------|
|
||||
| Claude Code | `~/.claude/projects/` | Yes |
|
||||
| Claude Desktop | `~/Library/Application Support/Claude/local-agent-mode-sessions/` | Yes |
|
||||
| Codex (OpenAI) | `~/.codex/sessions/` | Yes |
|
||||
| Cursor | `~/Library/Application Support/Cursor/User/globalStorage/state.vscdb` | Yes |
|
||||
| cursor-agent | `~/.cursor/projects/` | Yes |
|
||||
| Gemini CLI | `~/.gemini/tmp/<project>/chats/` | Yes |
|
||||
| GitHub Copilot | `~/.copilot/session-state/` + VS Code `workspaceStorage/` | Yes |
|
||||
| Kiro | `~/Library/Application Support/Kiro/User/globalStorage/kiro.kiroagent/` | Yes |
|
||||
| OpenCode | `~/.local/share/opencode/` (SQLite) | Yes |
|
||||
| OpenClaw | `~/.openclaw/agents/` (+ legacy `.clawdbot`, `.moltbot`, `.moldbot`) | Yes |
|
||||
| Pi | `~/.pi/agent/sessions/` | Yes |
|
||||
| OMP (Oh My Pi) | `~/.omp/agent/sessions/` | Yes |
|
||||
| Droid | `~/.factory/projects/` | Yes |
|
||||
| Roo Code | VS Code `globalStorage/rooveterinaryinc.roo-cline/tasks/` | Yes |
|
||||
| KiloCode | VS Code `globalStorage/kilocode.kilo-code/tasks/` | Yes |
|
||||
| Qwen | `~/.qwen/projects/<project>/chats/` | Yes |
|
||||
| | Provider | Supported | Doc |
|
||||
|---|----------|-----------|-----|
|
||||
| <img src="assets/providers/claude.jpg" width="28" /> | Claude Code | Yes | [claude.md](docs/providers/claude.md) |
|
||||
| <img src="assets/providers/claude.jpg" width="28" /> | Claude Desktop | Yes | [claude.md](docs/providers/claude.md) |
|
||||
| <img src="assets/providers/cline.svg" width="28" /> | Cline | Yes | [cline.md](docs/providers/cline.md) |
|
||||
| <img src="assets/providers/codex.png" width="28" /> | Codex (OpenAI) | Yes | [codex.md](docs/providers/codex.md) |
|
||||
| <img src="assets/providers/cursor.jpg" width="28" /> | Cursor | Yes | [cursor.md](docs/providers/cursor.md) |
|
||||
| <img src="assets/providers/cursor-agent.jpg" width="28" /> | cursor-agent | Yes | [cursor-agent.md](docs/providers/cursor-agent.md) |
|
||||
| <img src="assets/providers/gemini.png" width="28" /> | Gemini CLI | Yes | [gemini.md](docs/providers/gemini.md) |
|
||||
| <img src="assets/providers/mistral-vibe.svg" width="28" /> | Mistral Vibe | Yes | [mistral-vibe.md](docs/providers/mistral-vibe.md) |
|
||||
| <img src="assets/providers/copilot.jpg" width="28" /> | GitHub Copilot | Yes | [copilot.md](docs/providers/copilot.md) |
|
||||
| <img src="assets/providers/ibm-bob.svg" width="28" /> | IBM Bob | Yes | [ibm-bob.md](docs/providers/ibm-bob.md) |
|
||||
| <img src="assets/providers/kiro.png" width="28" /> | Kiro | Yes | [kiro.md](docs/providers/kiro.md) |
|
||||
| <img src="assets/providers/opencode.png" width="28" /> | OpenCode | Yes | [opencode.md](docs/providers/opencode.md) |
|
||||
| <img src="assets/providers/openclaw.jpg" width="28" /> | OpenClaw | Yes | [openclaw.md](docs/providers/openclaw.md) |
|
||||
| <img src="assets/providers/pi.png" width="28" /> | Pi | Yes | [pi.md](docs/providers/pi.md) |
|
||||
| <img src="assets/providers/omp.svg" width="28" /> | OMP (Oh My Pi) | Yes | [omp.md](docs/providers/omp.md) |
|
||||
| <img src="assets/providers/droid.png" width="28" /> | Droid | Yes | [droid.md](docs/providers/droid.md) |
|
||||
| <img src="assets/providers/roo-code.png" width="28" /> | Roo Code | Yes | [roo-code.md](docs/providers/roo-code.md) |
|
||||
| <img src="assets/providers/kilo-code.png" width="28" /> | KiloCode | Yes | [kilo-code.md](docs/providers/kilo-code.md) |
|
||||
| <img src="assets/providers/qwen.png" width="28" /> | Qwen | Yes | [qwen.md](docs/providers/qwen.md) |
|
||||
| <img src="assets/providers/kimi.svg" width="28" /> | Kimi Code CLI | Yes | [kimi.md](docs/providers/kimi.md) |
|
||||
| <img src="assets/providers/goose.png" width="28" /> | Goose | Yes | [goose.md](docs/providers/goose.md) |
|
||||
| <img src="assets/providers/antigravity.png" width="28" /> | Antigravity | Yes | [antigravity.md](docs/providers/antigravity.md) |
|
||||
| <img src="assets/providers/crush.png" width="28" /> | Crush | Yes | [crush.md](docs/providers/crush.md) |
|
||||
|
||||
Paths shown are for macOS. Linux and Windows equivalents are detected automatically. If a path has changed or is wrong, please [open an issue](https://github.com/getagentseal/codeburn/issues).
|
||||
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).
|
||||
|
||||
Provider logos are trademarks of their respective owners. The icon set was sourced from [tokscale](https://github.com/junhoyeo/tokscale) (MIT), official vendor assets, and simple provider identifiers, used under nominative fair use for the purpose of identifying supported tools.
|
||||
|
||||
CodeBurn auto-detects which AI coding tools you use. If multiple providers have session data on disk, press `p` in the dashboard to toggle between them.
|
||||
|
||||
|
|
@ -112,6 +135,8 @@ The `--provider` flag filters any command to a single provider: `codeburn report
|
|||
|
||||
**Gemini CLI** stores sessions as single JSON files. Each session embeds real token counts (input, output, cached, thoughts) per message, so no estimation is needed. Gemini reports input tokens inclusive of cached; CodeBurn subtracts cached from input before pricing to avoid double charging.
|
||||
|
||||
**Mistral Vibe** stores sessions as folders under `~/.vibe/logs/session/` (or `$VIBE_HOME/logs/session/`). CodeBurn reads cumulative prompt/completion totals and model pricing from `meta.json`, then reads `messages.jsonl` for the first user prompt and assistant tool calls. Subagent sessions under `agents/` are counted as separate Vibe sessions.
|
||||
|
||||
**Kiro** stores conversations as `.chat` JSON files. Token counts are estimated from content length. The underlying model is not exposed, so sessions are labeled `kiro-auto` and costed at Sonnet rates.
|
||||
|
||||
**GitHub Copilot** reads from both `~/.copilot/session-state/` (legacy CLI) and VS Code's `workspaceStorage/*/GitHub.copilot-chat/transcripts/`. The VS Code format has no explicit token counts; tokens are estimated from content length and the model is inferred from tool call ID prefixes.
|
||||
|
|
@ -120,6 +145,8 @@ The `--provider` flag filters any command to a single provider: `codeburn report
|
|||
|
||||
**Roo Code and KiloCode** are Cline-family VS Code extensions. CodeBurn reads `ui_messages.json` from each task directory and extracts token usage from `api_req_started` entries.
|
||||
|
||||
**Claude with multiple config directories.** If you run Claude Code under more than one account or profile (e.g. `~/.claude-work` and `~/.claude-personal`), point `CLAUDE_CONFIG_DIRS` at all of them at once: `CLAUDE_CONFIG_DIRS=~/.claude-work:~/.claude-personal codeburn`. Sessions across every directory are merged into one row per project so the totals reflect all your Claude usage in one place. Use `:` on POSIX, `;` on Windows. Missing or unreadable directories in the list are skipped.
|
||||
|
||||
Adding a new provider is a single file. See `src/providers/codex.ts` for an example.
|
||||
|
||||
## Features
|
||||
|
|
@ -178,6 +205,9 @@ Scans your sessions and your `~/.claude/` setup for waste patterns:
|
|||
- 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
|
||||
|
||||
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.
|
||||
|
||||
|
|
@ -186,7 +216,7 @@ You can also open it inline from the dashboard: press `o` when a finding count a
|
|||
### Compare
|
||||
|
||||
```bash
|
||||
codeburn compare # interactive model picker (default: all time)
|
||||
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
|
||||
|
|
@ -354,60 +384,39 @@ These are starting points, not verdicts. A 60% cache hit on a single experimenta
|
|||
|
||||
**Gemini CLI** stores sessions as single JSON files at `~/.gemini/tmp/<project>/chats/session-*.json`. Each session embeds real token counts (input, output, cached, thoughts) per message. Gemini reports input tokens inclusive of cached; CodeBurn subtracts cached from input before pricing to avoid double charging.
|
||||
|
||||
**Mistral Vibe** stores session folders at `~/.vibe/logs/session/`. Each folder contains `meta.json` with cumulative prompt/completion token totals, model pricing, timestamps, and working directory, plus `messages.jsonl` with user prompts and assistant tool calls. CodeBurn emits one record per Vibe session because the source data is cumulative, not per assistant turn.
|
||||
|
||||
**OpenClaw** stores agent sessions as JSONL at `~/.openclaw/agents/*.jsonl`. Also checks legacy paths `.clawdbot`, `.moltbot`, `.moldbot`. Token usage comes from assistant message `usage` blocks; model from `modelId` or `message.model` fields.
|
||||
|
||||
**Roo Code / KiloCode** are Cline-family VS Code extensions. CodeBurn reads `ui_messages.json` from each task directory in VS Code's `globalStorage`, filtering `type: "say"` entries with `say: "api_req_started"` to extract token counts.
|
||||
**Cline / Roo Code / KiloCode** are Cline-family coding agents. CodeBurn reads `ui_messages.json` from each task directory, filtering `type: "say"` entries with `say: "api_req_started"` to extract token counts. Cline scans both VS Code's `globalStorage/saoudrizwan.claude-dev` and `~/.cline/data`.
|
||||
|
||||
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), filters by date range per entry, and classifies each turn.
|
||||
**IBM Bob** stores IDE task history in `User/globalStorage/ibm.bob-code/tasks/<task-id>/` under the IBM Bob application data directory. CodeBurn reads `ui_messages.json` for API request token/cost records and `api_conversation_history.json` for the selected model, with support for both GA (`IBM Bob`) and preview (`Bob-IDE`) app data folders.
|
||||
|
||||
**Kimi Code CLI** stores session logs under `$KIMI_SHARE_DIR/sessions/<workdir-hash>/<session-id>/` or `~/.kimi/sessions/<workdir-hash>/<session-id>/`. CodeBurn reads `wire.jsonl` `StatusUpdate.token_usage` records, maps `input_other`, `input_cache_read`, `input_cache_creation`, and `output` into the standard token columns, and includes subagent sessions under each session's `subagents/` folder.
|
||||
|
||||
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.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
| 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 |
|
||||
| `QWEN_DATA_DIR` | Override Qwen data directory (default: `~/.qwen/projects`) |
|
||||
| `VIBE_HOME` | Override Mistral Vibe home directory (default: `~/.vibe`) |
|
||||
|
||||
## Project Structure
|
||||
## Sponsoring CodeBurn
|
||||
|
||||
```
|
||||
src/
|
||||
cli.ts Commander.js entry point
|
||||
dashboard.tsx Ink TUI (React for terminals)
|
||||
parser.ts JSONL reader, dedup, date filter, provider orchestration
|
||||
models.ts LiteLLM pricing, cost calculation
|
||||
classifier.ts 13-category task classifier
|
||||
compare-stats.ts Model comparison engine
|
||||
daily-cache.ts Persistent daily cache with migration
|
||||
day-aggregator.ts Daily aggregation from session data
|
||||
types.ts Type definitions
|
||||
format.ts Text rendering (status bar)
|
||||
menubar-json.ts Payload builder for the macOS menubar app
|
||||
export.ts CSV/JSON multi-period export
|
||||
config.ts Config file management (~/.config/codeburn/)
|
||||
currency.ts Currency conversion, exchange rates
|
||||
sqlite.ts SQLite adapter (lazy-loads better-sqlite3)
|
||||
optimize.ts Waste pattern detection engine
|
||||
providers/
|
||||
types.ts Provider interface definitions
|
||||
index.ts Provider registry
|
||||
claude.ts Claude Code session discovery
|
||||
codex.ts Codex session discovery and JSONL parsing
|
||||
copilot.ts GitHub Copilot session parsing
|
||||
cursor.ts Cursor SQLite parsing, language extraction
|
||||
cursor-agent.ts cursor-agent CLI session parsing
|
||||
droid.ts Droid session discovery
|
||||
gemini.ts Gemini CLI session JSON parsing
|
||||
kilo-code.ts KiloCode VS Code extension parsing
|
||||
kiro.ts Kiro .chat JSON session parsing
|
||||
openclaw.ts OpenClaw agent JSONL parsing
|
||||
opencode.ts OpenCode SQLite session parsing
|
||||
pi.ts Pi/OMP agent JSONL session parsing
|
||||
qwen.ts Qwen CLI JSONL session parsing
|
||||
roo-code.ts Roo Code VS Code extension parsing
|
||||
```
|
||||
If CodeBurn is useful to you or your team, consider sponsoring development.
|
||||
|
||||
Sponsorship helps support the time spent building and maintaining the project, the providers we add, and the bug-fix turnaround on issues like Cursor schema drift and Claude config-dir support.
|
||||
|
||||
[Sponsor on GitHub](https://github.com/sponsors/iamtoruk)
|
||||
|
||||
## Star History
|
||||
|
||||
|
|
|
|||
252
RELEASING.md
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
# 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 Tap Update
|
||||
|
||||
The Homebrew tap lives at `https://github.com/getagentseal/homebrew-codeburn`. A maintainer with access to that repository must manually update the formula.
|
||||
|
||||
### 1. Fetch the npm Tarball
|
||||
|
||||
When the CLI is published to npm, get its download URL and SHA-256 hash:
|
||||
|
||||
```bash
|
||||
npm view codeburn@0.9.8 dist.tarball
|
||||
npm view codeburn@0.9.8 dist.shasum
|
||||
```
|
||||
|
||||
This returns a URL like `https://registry.npmjs.org/codeburn/-/codeburn-0.9.8.tgz` and a SHA-256 hash.
|
||||
|
||||
Alternatively, compute the hash yourself:
|
||||
|
||||
```bash
|
||||
curl -sL https://registry.npmjs.org/codeburn/-/codeburn-0.9.8.tgz | shasum -a 256
|
||||
```
|
||||
|
||||
### 2. Update the Formula
|
||||
|
||||
Edit `Formula/codeburn.rb` in the homebrew-codeburn tap:
|
||||
|
||||
```ruby
|
||||
class Codeburn < Formula
|
||||
desc "See where your AI coding tokens go"
|
||||
homepage "https://github.com/getagentseal/codeburn"
|
||||
url "https://registry.npmjs.org/codeburn/-/codeburn-0.9.8.tgz"
|
||||
sha256 "<computed-hash>"
|
||||
license "MIT"
|
||||
|
||||
depends_on "node"
|
||||
|
||||
def install
|
||||
system "npm", "install", *Language::Node.std_npm_install_args(libexec)
|
||||
bin.install_symlink Dir[libexec/"bin/*"]
|
||||
end
|
||||
|
||||
test do
|
||||
system "#{bin}/codeburn", "--version"
|
||||
end
|
||||
end
|
||||
```
|
||||
|
||||
Update the `url` and `sha256` fields with the new version's values.
|
||||
|
||||
### 3. Test Locally
|
||||
|
||||
Before pushing, test the formula locally:
|
||||
|
||||
```bash
|
||||
brew install --build-from-source Formula/codeburn.rb
|
||||
codeburn --version
|
||||
```
|
||||
|
||||
### 4. Commit and Push
|
||||
|
||||
Commit the formula change:
|
||||
|
||||
```bash
|
||||
git add Formula/codeburn.rb
|
||||
git commit -m "codeburn: bump to 0.9.8"
|
||||
git push origin main
|
||||
```
|
||||
|
||||
Users can now install with:
|
||||
|
||||
```bash
|
||||
brew tap getagentseal/codeburn
|
||||
brew install codeburn
|
||||
```
|
||||
|
||||
Or upgrade an existing installation:
|
||||
|
||||
```bash
|
||||
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 formula at `getagentseal/homebrew-codeburn` is updated by hand after each CLI publish.
|
||||
21
SECURITY.md
Normal file
|
|
@ -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.
|
||||
BIN
assets/providers/antigravity.png
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
assets/providers/claude.jpg
Normal file
|
After Width: | Height: | Size: 12 KiB |
4
assets/providers/cline.svg
Normal file
|
|
@ -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 |
BIN
assets/providers/codex.png
Normal file
|
After Width: | Height: | Size: 4 KiB |
BIN
assets/providers/copilot.jpg
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
assets/providers/crush.png
Normal file
|
After Width: | Height: | Size: 49 KiB |
BIN
assets/providers/cursor-agent.jpg
Normal file
|
After Width: | Height: | Size: 8.3 KiB |
BIN
assets/providers/cursor.jpg
Normal file
|
After Width: | Height: | Size: 8.3 KiB |
BIN
assets/providers/droid.png
Normal file
|
After Width: | Height: | Size: 7.9 KiB |
BIN
assets/providers/gemini.png
Normal file
|
After Width: | Height: | Size: 32 KiB |
BIN
assets/providers/goose.png
Normal file
|
After Width: | Height: | Size: 5.2 KiB |
6
assets/providers/ibm-bob.svg
Normal file
|
|
@ -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 |
BIN
assets/providers/kilo-code.png
Normal file
|
After Width: | Height: | Size: 774 B |
5
assets/providers/kimi.svg
Normal file
|
|
@ -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 |
BIN
assets/providers/kiro.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
12
assets/providers/mistral-vibe.svg
Normal file
|
|
@ -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 |
16
assets/providers/omp.svg
Normal file
|
|
@ -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 |
BIN
assets/providers/openclaw.jpg
Normal file
|
After Width: | Height: | Size: 25 KiB |
BIN
assets/providers/opencode.png
Normal file
|
After Width: | Height: | Size: 420 B |
BIN
assets/providers/pi.png
Normal file
|
After Width: | Height: | Size: 316 B |
BIN
assets/providers/qwen.png
Normal file
|
After Width: | Height: | Size: 16 KiB |
BIN
assets/providers/roo-code.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
189
docs/architecture.md
Normal file
|
|
@ -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.
|
||||
59
docs/providers/README.md
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
# 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 | `src/providers/copilot.ts` | `tests/providers/copilot.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 |
|
||||
| [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` |
|
||||
| [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` |
|
||||
|
||||
### 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` |
|
||||
| [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` |
|
||||
|
||||
### 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/`.
|
||||
52
docs/providers/antigravity.md
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
# Antigravity
|
||||
|
||||
Google Antigravity. The only provider that does not read files off disk: it speaks to a local language-server RPC endpoint instead.
|
||||
|
||||
- **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
|
||||
|
||||
A local HTTPS RPC endpoint exposed by Antigravity's language server. The parser:
|
||||
|
||||
1. Locates the running language-server process via `ps` on POSIX or
|
||||
`Get-CimInstance Win32_Process` on Windows.
|
||||
2. Reads its port and CSRF token from process metadata.
|
||||
3. Calls `GetCascadeTrajectoryGeneratorMetadata` over HTTPS.
|
||||
4. Validates the response (capped at 16 MB).
|
||||
|
||||
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.
|
||||
|
||||
If the language server is not running, the parser falls back to the cached results file.
|
||||
|
||||
## 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>`.
|
||||
|
||||
## 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).
|
||||
- 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.
|
||||
54
docs/providers/claude.md
Normal file
|
|
@ -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.
|
||||
50
docs/providers/cline.md
Normal file
|
|
@ -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.
|
||||
50
docs/providers/codex.md
Normal file
|
|
@ -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.
|
||||
49
docs/providers/copilot.md
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
# Copilot
|
||||
|
||||
GitHub Copilot Chat (CLI and VS Code extension transcripts).
|
||||
|
||||
- **Source:** `src/providers/copilot.ts`
|
||||
- **Loading:** eager (`src/providers/index.ts:3`)
|
||||
- **Test:** `tests/providers/copilot.test.ts` (401 lines)
|
||||
|
||||
## Where it reads from
|
||||
|
||||
Two locations. Both are walked on every run; results merge.
|
||||
|
||||
1. **Legacy CLI sessions:** `~/.copilot/session-state/`
|
||||
2. **VS Code transcripts:** `~/Library/Application Support/Code/User/workspaceStorage/<hash>/GitHub.copilot-chat/transcripts/` and equivalents on Windows / Linux
|
||||
|
||||
## Storage format
|
||||
|
||||
JSONL in both locations, but the schemas differ. The parser switches by detecting which schema the first event uses (`copilot.ts:83-159` for legacy, `copilot.ts:215-293` for transcripts).
|
||||
|
||||
## Caching
|
||||
|
||||
None at the provider level.
|
||||
|
||||
## Deduplication
|
||||
|
||||
Per `messageId` in both formats (`copilot.ts:118` for legacy, `copilot.ts:245` for transcripts).
|
||||
|
||||
## 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`.
|
||||
87
docs/providers/crush.md
Normal file
|
|
@ -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.
|
||||
41
docs/providers/cursor-agent.md
Normal file
|
|
@ -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.
|
||||
50
docs/providers/cursor.md
Normal file
|
|
@ -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.
|
||||
36
docs/providers/droid.md
Normal file
|
|
@ -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/`.
|
||||
35
docs/providers/gemini.md
Normal file
|
|
@ -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.
|
||||
42
docs/providers/goose.md
Normal file
|
|
@ -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.
|
||||
55
docs/providers/ibm-bob.md
Normal file
|
|
@ -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.
|
||||
34
docs/providers/kilo-code.md
Normal file
|
|
@ -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.
|
||||
62
docs/providers/kimi.md
Normal file
|
|
@ -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.
|
||||
44
docs/providers/kiro.md
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
# Kiro
|
||||
|
||||
Kiro IDE chat history.
|
||||
|
||||
- **Source:** `src/providers/kiro.ts`
|
||||
- **Loading:** eager (`src/providers/index.ts:7`)
|
||||
- **Test:** `tests/providers/kiro.test.ts` (328 lines)
|
||||
|
||||
## 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 `.chat` files under hash-named subdirectories. Discovery is in `kiro.ts:215-247`; the path-resolution helpers it uses start at `kiro.ts:164`.
|
||||
|
||||
## Storage format
|
||||
|
||||
JSON `.chat` files (`kiro.ts:153`).
|
||||
|
||||
## Caching
|
||||
|
||||
None.
|
||||
|
||||
## Deduplication
|
||||
|
||||
Per `executionId` (`kiro.ts:104`).
|
||||
|
||||
## 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 (`kiro.ts:198-213`).
|
||||
- **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 (`kiro.ts:65-67`). Add new versions here when Kiro ships them.
|
||||
- **Tool name extraction is regex-driven.** Kiro embeds tool calls inside the message text as `<tool_use><name>...</name>` (`kiro.ts:69-78`). Brittle but unavoidable until Kiro emits structured tool data.
|
||||
- Token counts are estimated via char count (`CHARS_PER_TOKEN = 4`, `kiro.ts:9`, `:108-109`).
|
||||
|
||||
## 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 at `kiro.ts:65-67` and verify against `tests/providers/kiro.test.ts`.
|
||||
3. If the bug is "tools missing", look at the regex at `kiro.ts:69-78`. Kiro changes its envelope occasionally.
|
||||
41
docs/providers/mistral-vibe.md
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
# Mistral Vibe
|
||||
|
||||
Mistral Vibe CLI.
|
||||
|
||||
- **Source:** `src/providers/mistral-vibe.ts`
|
||||
- **Loading:** eager (`src/providers/index.ts`)
|
||||
- **Test:** `tests/providers/mistral-vibe.test.ts`
|
||||
|
||||
## Where it reads from
|
||||
|
||||
`$VIBE_HOME/logs/session/` when `VIBE_HOME` is set, otherwise `~/.vibe/logs/session/`.
|
||||
|
||||
## Storage format
|
||||
|
||||
Vibe 2.x stores each session as a directory:
|
||||
|
||||
- `meta.json` contains session metadata, cumulative token totals, active model config, model prices, timestamps, working directory, and available tools.
|
||||
- `messages.jsonl` contains non-system messages and assistant `tool_calls`.
|
||||
|
||||
Subagent traces are stored under a parent session's `agents/` folder with the same `meta.json` / `messages.jsonl` shape, so CodeBurn scans those one level down as separate sessions.
|
||||
|
||||
## Caching
|
||||
|
||||
None.
|
||||
|
||||
## Deduplication
|
||||
|
||||
Per `mistral-vibe:<session_id>`.
|
||||
|
||||
## Quirks
|
||||
|
||||
- **Usage is cumulative per session.** Vibe does not write per-assistant-message token usage into `messages.jsonl`; token counts come from `meta.json.stats.session_prompt_tokens` and `session_completion_tokens`. CodeBurn emits one usage record per Vibe session.
|
||||
- **Cost prefers Vibe's own model prices.** `meta.json.stats.input_price_per_million` and `output_price_per_million` are used first, with the active model config as a fallback. LiteLLM pricing is only used when Vibe provides no price data.
|
||||
- **Project names come from metadata.** Discovery uses `meta.json.environment.working_directory` and falls back to the session directory name if that field is missing.
|
||||
- **Tool calls come from messages.** Assistant `tool_calls[*].function.name` is normalized to the standard CodeBurn names (`bash` to `Bash`, `search_replace` to `Edit`, etc.). Bash commands are extracted from `function.arguments.command`.
|
||||
|
||||
## When fixing a bug here
|
||||
|
||||
1. Reproduce with a fixture that has both `meta.json` and `messages.jsonl`; both files are required for current Vibe sessions.
|
||||
2. If the bug is "wrong total", check `meta.json.stats` first. `messages.jsonl` is only for prompts and tool calls.
|
||||
3. If a future Vibe release adds per-turn usage, add tests before changing the one-record-per-session behavior so historical sessions continue to parse correctly.
|
||||
34
docs/providers/omp.md
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# OMP
|
||||
|
||||
OMP CLI. Same parser as Pi, different data directory.
|
||||
|
||||
- **Source:** `src/providers/pi.ts` (the `omp` export)
|
||||
- **Loading:** eager (`src/providers/index.ts:9`)
|
||||
- **Test:** `tests/providers/omp.test.ts` (225 lines)
|
||||
|
||||
## Where it reads from
|
||||
|
||||
`~/.omp/agent/sessions/` (`pi.ts:59-61`).
|
||||
|
||||
## Storage format
|
||||
|
||||
JSONL, identical schema to Pi.
|
||||
|
||||
## Caching
|
||||
|
||||
None.
|
||||
|
||||
## Deduplication
|
||||
|
||||
Identical to Pi: `<provider>:<path>:<responseId>` with timestamp / line-index fallbacks (`pi.ts:164`).
|
||||
|
||||
## Quirks
|
||||
|
||||
- OMP and Pi share the **same** `createParser` function. The provider object differs only in name, displayName, and the discovery directory.
|
||||
- If OMP and Pi diverge in a future release, do **not** copy-paste the parser. Add a discriminator to `createParser` and branch.
|
||||
|
||||
## When fixing a bug here
|
||||
|
||||
1. Check if the bug also reproduces against Pi. If yes, fix both with one change; the parser is shared.
|
||||
2. If the bug is OMP-specific, the right fix is usually to pass an option into `createParser` rather than to fork the file.
|
||||
3. Read [`pi.md`](pi.md) for the parser-level details.
|
||||
41
docs/providers/openclaw.md
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
# OpenClaw
|
||||
|
||||
OpenClaw, plus the older Clawdbot / Moltbot / Moldbot lineage.
|
||||
|
||||
- **Source:** `src/providers/openclaw.ts`
|
||||
- **Loading:** eager (`src/providers/index.ts:8`)
|
||||
- **Test:** `tests/providers/openclaw.test.ts` (192 lines)
|
||||
|
||||
## Where it reads from
|
||||
|
||||
Four directories, all checked on every run (`openclaw.ts:62-70`):
|
||||
|
||||
- `~/.openclaw/agents`
|
||||
- `~/.clawdbot/agents`
|
||||
- `~/.moltbot/agents`
|
||||
- `~/.moldbot/agents`
|
||||
|
||||
The legacy directories are kept for users who upgraded from older builds.
|
||||
|
||||
## Storage format
|
||||
|
||||
JSONL (`openclaw.ts:242`). Each agents directory has a `sessions.json` index file plus per-session `.jsonl` files. The parser reads the index when present and falls back to a directory scan if it is missing or stale (`openclaw.ts:220-247`).
|
||||
|
||||
## Caching
|
||||
|
||||
None.
|
||||
|
||||
## Deduplication
|
||||
|
||||
Per `<sessionId>:<dedupId>` (`openclaw.ts:169`).
|
||||
|
||||
## Quirks
|
||||
|
||||
- **Cost is preferred from the provider when reported.** OpenClaw emits `costUSD` in `message.usage`; the parser uses it directly when present (`openclaw.ts:174-177`) and only computes from tokens when it is missing.
|
||||
- Tokens are reported across `input`, `output`, `cacheRead`, and `cacheWrite`. Anthropic semantics throughout, no normalization needed.
|
||||
|
||||
## When fixing a bug here
|
||||
|
||||
1. If the bug is "session not found", check the four legacy dirs. A user might have a stray `~/.moltbot/` that the parser is reading instead of the real `~/.openclaw/`.
|
||||
2. If the bug is "wrong cost", confirm whether `costUSD` is present in the source data; the parser trusts it over its own calculation.
|
||||
3. The `sessions.json` index can drift when the user crashes mid-session. Make sure the directory-scan fallback triggers in those cases.
|
||||
40
docs/providers/opencode.md
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
# OpenCode
|
||||
|
||||
OpenCode (sst/opencode).
|
||||
|
||||
- **Source:** `src/providers/opencode.ts`
|
||||
- **Loading:** lazy (`src/providers/index.ts:59-75`)
|
||||
- **Test:** `tests/providers/opencode.test.ts` (676 lines, the largest provider test)
|
||||
|
||||
## Where it reads from
|
||||
|
||||
Default `~/.local/share/opencode/` or `$XDG_DATA_HOME/opencode/`. The discovery walk picks up `opencode*.db` files (`opencode.ts:71-88`).
|
||||
|
||||
## Storage format
|
||||
|
||||
SQLite.
|
||||
|
||||
## Caching
|
||||
|
||||
None.
|
||||
|
||||
## Deduplication
|
||||
|
||||
Per `<sessionId>:<messageId>`.
|
||||
|
||||
## Quirks
|
||||
|
||||
- **Schema validation is loud.** When a required table is missing, the parser logs an actionable warning telling the user which table is gone and what version of OpenCode it expects. This is the right behavior; do not silently swallow these.
|
||||
- Source paths are encoded as `<dbPath>:<sessionId>`.
|
||||
- Each message's `parts` are indexed; preserving the order matters for reasoning-token correctness.
|
||||
- Tokens are reported across `input`, `output`, `reasoning`, `cache.read`, and `cache.write`. Anthropic semantics.
|
||||
- External MCP tools are stored as `<server>_<tool>` names (for example
|
||||
`clickup_clickup_get_task`). The provider normalizes those to CodeBurn's
|
||||
canonical `mcp__<server>__<tool>` names before aggregation so shared MCP
|
||||
panels and `optimize` findings count OpenCode usage.
|
||||
|
||||
## When fixing a bug here
|
||||
|
||||
1. The 558-line test suite catches a lot. Run `npm test -- tests/providers/opencode.test.ts` before and after any change.
|
||||
2. If the bug is "missing table" warning, do not catch and silence it. Either upgrade the version expectation in the parser or document the breaking schema change.
|
||||
3. If the bug is "reasoning tokens off by one", check the parts index ordering.
|
||||
35
docs/providers/pi.md
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
# Pi
|
||||
|
||||
Pi agent CLI.
|
||||
|
||||
- **Source:** `src/providers/pi.ts`
|
||||
- **Loading:** eager (`src/providers/index.ts:9`)
|
||||
- **Test:** `tests/providers/pi.test.ts` (336 lines)
|
||||
|
||||
## Where it reads from
|
||||
|
||||
`~/.pi/agent/sessions/` (`pi.ts:55-57`).
|
||||
|
||||
## Storage format
|
||||
|
||||
JSONL (`pi.ts:98`).
|
||||
|
||||
## Caching
|
||||
|
||||
None.
|
||||
|
||||
## Deduplication
|
||||
|
||||
Per `<provider>:<path>:<responseId>` when a response ID is present, falling back to the entry timestamp, and finally to a line index (`pi.ts:164`).
|
||||
|
||||
## Quirks
|
||||
|
||||
- Undefined token fields in `message.usage` are coerced to `0` (`pi.ts:156-159`); never `undefined`.
|
||||
- The provider name is taken from `source.provider` (`pi.ts:182`), not hard-coded. This matters because `pi.ts` is the parser for **both** Pi and OMP; see [`omp.md`](omp.md).
|
||||
- Tool-call content type is extracted from the message envelope (`pi.ts:169-176`).
|
||||
|
||||
## When fixing a bug here
|
||||
|
||||
1. If you change parsing logic, also run `tests/providers/omp.test.ts` because OMP shares this code.
|
||||
2. If the bug is "tokens are NaN", look at the coercion at `pi.ts:156-159`. A regression on this is silent and easy to miss.
|
||||
3. If the bug is specific to the dedup behavior, decide which of the three fallback keys was used by adding a temporary log; the keys collide differently for old vs. new Pi versions.
|
||||
36
docs/providers/qwen.md
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
# Qwen
|
||||
|
||||
Qwen Code CLI.
|
||||
|
||||
- **Source:** `src/providers/qwen.ts`
|
||||
- **Loading:** eager (`src/providers/index.ts:10`)
|
||||
- **Test:** none. Adding a fixture-based test is a known good first issue.
|
||||
|
||||
## Where it reads from
|
||||
|
||||
`$QWEN_DATA_DIR` if set, otherwise `~/.qwen/projects/<project>/chats/*.jsonl` (`qwen.ts:52-54`).
|
||||
|
||||
## Storage format
|
||||
|
||||
JSONL.
|
||||
|
||||
## Caching
|
||||
|
||||
None.
|
||||
|
||||
## Deduplication
|
||||
|
||||
Per `<sessionId>:<uuid>` (`qwen.ts:110`).
|
||||
|
||||
## Quirks
|
||||
|
||||
- **Project name comes from the last path component** (`qwen.ts:56-59`), not from any in-file field. If a user puts the same project under two different paths, they will appear as two projects.
|
||||
- **Thought parts are filtered out** before token accounting (`qwen.ts:97`). Qwen reports `thoughtsTokenCount` separately from `candidatesTokenCount`; this parser counts both as output but does not double-count thoughts in the main message.
|
||||
- **Tool calls** are extracted from a fixed envelope shape (`qwen.ts:61-76`). If Qwen restructures its tool-call format in a future release, this is where it will break first.
|
||||
- Tokens come from `usageMetadata`: `promptTokenCount`, `candidatesTokenCount`, `thoughtsTokenCount`, `cachedContentTokenCount`.
|
||||
|
||||
## When fixing a bug here
|
||||
|
||||
1. Add a fixture and a test before changing logic. The lack of `tests/providers/qwen.test.ts` makes regressions invisible.
|
||||
2. If the bug is "tools missing", look at the function-call extraction loop at `qwen.ts:61-76`.
|
||||
3. If the bug is "duplicate counts", confirm `<sessionId>:<uuid>` actually uniquely identifies a turn in your reproducer; some Qwen builds repeat UUIDs across resumed sessions.
|
||||
34
docs/providers/roo-code.md
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
# Roo Code
|
||||
|
||||
Roo Code VS Code extension.
|
||||
|
||||
- **Source:** `src/providers/roo-code.ts`
|
||||
- **Loading:** eager (`src/providers/index.ts:11`)
|
||||
- **Test:** `tests/providers/roo-code.test.ts` (247 lines)
|
||||
|
||||
## Where it reads from
|
||||
|
||||
VS Code extension globalStorage for `rooveterinaryinc.roo-cline` (extension ID set at `roo-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 schema.
|
||||
|
||||
## Caching
|
||||
|
||||
None at the provider level; delegates to the shared helper.
|
||||
|
||||
## Deduplication
|
||||
|
||||
Delegated. Per `<providerName>:<taskId>:<index>` (in `vscode-cline-parser.ts:109`).
|
||||
|
||||
## Quirks
|
||||
|
||||
- Thin wrapper. Almost every Roo Code bug 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 also reproduces against Cline or KiloCode, fix it in `vscode-cline-parser.ts`.
|
||||
2. If the bug is Roo Code-specific, the difference is upstream JSON shape. 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.
|
||||
50
docs/providers/vscode-cline-parser.md
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
# vscode-cline-parser (Shared Helper)
|
||||
|
||||
Shared discovery and parsing for Cline and VS Code extensions descended from Cline.
|
||||
|
||||
- **Source:** `src/providers/vscode-cline-parser.ts`
|
||||
- **Loading:** not a provider; imported by `cline.ts`, `ibm-bob.ts`, `kilo-code.ts`, and `roo-code.ts`.
|
||||
- **Test:** none directly. Coverage comes from `tests/providers/cline.test.ts`, `tests/providers/ibm-bob.test.ts`, `tests/providers/kilo-code.test.ts`, and `tests/providers/roo-code.test.ts`.
|
||||
|
||||
## What it does
|
||||
|
||||
Two responsibilities:
|
||||
|
||||
1. `discoverClineTasks(extensionId)` walks a base directory's `tasks/` child and returns one source per task that has a `ui_messages.json` file (`vscode-cline-parser.ts:25-50`). Without an override directory it uses VS Code's `globalStorage/<extensionId>/` path.
|
||||
2. `discoverClineTasksInBaseDirs(baseDirs)` does the same for non-VS Code apps with compatible task storage, such as IBM Bob.
|
||||
3. `createClineParser` reads each task's `ui_messages.json` and `api_conversation_history.json`, extracts model, tools, and token counts, and yields `ParsedProviderCall` objects.
|
||||
|
||||
## Storage layout
|
||||
|
||||
Per task directory:
|
||||
|
||||
```
|
||||
<baseDir>/tasks/<taskId>/
|
||||
ui_messages.json # event stream
|
||||
api_conversation_history.json # full prompt history with model tags
|
||||
```
|
||||
|
||||
## Model resolution
|
||||
|
||||
The model is extracted from `api_conversation_history.json` by searching user message content blocks for a `<model>...</model>` tag. Falls back to the provider-supplied auto model (`cline-auto` by default) if no tag is found.
|
||||
|
||||
## Token extraction
|
||||
|
||||
From `api_req_started` entries inside `ui_messages.json`. Each such entry's `text` field is JSON-parsed; the parsed object holds `tokensIn`, `tokensOut`, `cacheReads`, `cacheWrites`, and (optionally) `cost`.
|
||||
|
||||
If `cost` is present, it is used directly. If not, `calculateCost` from `src/models.ts` computes it from tokens.
|
||||
|
||||
## Deduplication
|
||||
|
||||
Per `<providerName>:<taskId>:<index>` where `index` is the position of the `api_req_started` entry within `ui_messages.json`.
|
||||
|
||||
## Quirks
|
||||
|
||||
- Only the **first** user message is emitted as `userMessage` in the `ParsedProviderCall`. Subsequent user turns are accounted but not surfaced.
|
||||
- The model regex looks inside content blocks, not at top-level fields. Some Cline-derivative extensions emit the model elsewhere; if you add support for one, branch on extension ID rather than rewriting the regex.
|
||||
|
||||
## When fixing a bug here
|
||||
|
||||
1. A change here ripples to Cline, IBM Bob, KiloCode, and Roo Code. Run all four provider test files before opening a PR.
|
||||
2. If you find that one of the extensions emits a different shape, branch on the extension ID parameter that the discovery function already takes; do not duplicate the parser.
|
||||
3. If you add support for another Cline-family task store, register it as a thin wrapper file in the same shape as `cline.ts`, `ibm-bob.ts`, `kilo-code.ts`, and `roo-code.ts`.
|
||||
|
|
@ -1,266 +0,0 @@
|
|||
# Model Comparison Design
|
||||
|
||||
Compare two AI models side-by-side using normalized metrics derived from real
|
||||
usage data. Answers "is Opus 4.7 actually better than 4.6 for my workflow?"
|
||||
with hard numbers instead of vibes.
|
||||
|
||||
## Goals
|
||||
|
||||
1. Let users pick any two models and see a fair, normalized comparison
|
||||
2. Surface efficiency metrics that raw cost/token dashboards don't show
|
||||
(one-shot rate, retry rate, self-correction rate)
|
||||
3. Accessible from both the dashboard (press `c`) and standalone (`codeburn compare`)
|
||||
4. Screenshot-friendly terminal output
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Multi-model comparison (3+) -- v2
|
||||
- Time-frame filtering (`--period`) -- v2
|
||||
- Charts/graphs in the comparison view -- v2
|
||||
- Exporting comparison results to JSON/CSV -- v2
|
||||
- Statistical significance testing (show sample sizes, let the user judge)
|
||||
|
||||
---
|
||||
|
||||
## 1. Entry Points
|
||||
|
||||
### Standalone command
|
||||
|
||||
```
|
||||
codeburn compare [--provider <provider>] [--period <period>]
|
||||
```
|
||||
|
||||
Period defaults to `all` (6 months). Provider defaults to `all`. Both flags
|
||||
are accepted but optional. Launches the full-screen Ink TUI directly into the
|
||||
model selection screen.
|
||||
|
||||
### Dashboard shortcut
|
||||
|
||||
Press `c` in the dashboard to switch to the compare view. Same component, same
|
||||
flow. `Escape` returns to the dashboard (mirrors how `o` toggles optimize).
|
||||
|
||||
The status bar gains a `[c]ompare` hint next to the existing `[o]ptimize`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Data Pipeline
|
||||
|
||||
### Aggregation
|
||||
|
||||
Reuse `parseAllSessions` to get `ProjectSummary[]` for the selected
|
||||
period/provider. Then build per-model stats by iterating turns and calls:
|
||||
|
||||
```ts
|
||||
type ModelStats = {
|
||||
model: string
|
||||
calls: number
|
||||
cost: number
|
||||
outputTokens: number
|
||||
inputTokens: number
|
||||
cacheReadTokens: number
|
||||
cacheWriteTokens: number
|
||||
totalTurns: number
|
||||
editTurns: number
|
||||
oneShotTurns: number // edit turns with 0 retries
|
||||
retries: number // total retry count
|
||||
selfCorrections: number // turns matching apology/mistake patterns
|
||||
firstSeen: string // earliest timestamp (ISO)
|
||||
lastSeen: string // latest timestamp (ISO)
|
||||
}
|
||||
```
|
||||
|
||||
Turn-level metrics are attributed to the primary model (first call in the
|
||||
turn). This matches how the dashboard already attributes turns.
|
||||
|
||||
### Self-correction detection
|
||||
|
||||
Scan assistant message text for patterns that indicate the model acknowledged
|
||||
an error. These patterns were validated against real session data:
|
||||
|
||||
```ts
|
||||
const SELF_CORRECTION_PATTERNS = [
|
||||
/\bI('m| am) sorry\b/i,
|
||||
/\bmy mistake\b/i,
|
||||
/\bmy apolog/i,
|
||||
/\bI made (a |an )?(error|mistake)\b/i,
|
||||
/\bI was wrong\b/i,
|
||||
/\bmy bad\b/i,
|
||||
/\bI apologize\b/i,
|
||||
/\bsorry about that\b/i,
|
||||
/\bsorry for (the|that|this)\b/i,
|
||||
/\bI should have\b/i,
|
||||
/\bI shouldn't have\b/i,
|
||||
/\bI incorrectly\b/i,
|
||||
/\bI mistakenly\b/i,
|
||||
]
|
||||
```
|
||||
|
||||
This requires reading assistant message content from session JSONL files,
|
||||
which the current parser does not expose. The aggregation function will need
|
||||
to read raw JSONL entries for sessions that contain the selected models.
|
||||
|
||||
### Normalization
|
||||
|
||||
All comparison metrics are rates or per-call averages. Raw totals (cost, calls,
|
||||
days) are shown as context, never as comparison metrics.
|
||||
|
||||
---
|
||||
|
||||
## 3. Comparison Metrics
|
||||
|
||||
| Metric | Formula | Better |
|
||||
|---|---|---|
|
||||
| Cost / call | `cost / calls` | Lower |
|
||||
| Output tokens / call | `outputTokens / calls` | Lower |
|
||||
| Cache hit rate | `cacheRead / (input + cacheRead + cacheWrite) * 100` | Higher |
|
||||
| One-shot rate | `oneShotTurns / editTurns * 100` | Higher |
|
||||
| Retry rate | `retries / editTurns` | Lower |
|
||||
| Self-correction rate | `selfCorrections / totalTurns * 100` | Lower |
|
||||
|
||||
### Context row (not compared)
|
||||
|
||||
Displayed below the table to give sample-size context:
|
||||
|
||||
- Total calls
|
||||
- Total cost
|
||||
- Days of data (lastSeen - firstSeen)
|
||||
- Edit turns (denominator for one-shot/retry metrics)
|
||||
|
||||
---
|
||||
|
||||
## 4. UI Screens
|
||||
|
||||
### Model Selection Screen
|
||||
|
||||
```
|
||||
Model Comparison
|
||||
|
||||
Select two models to compare:
|
||||
|
||||
claude-opus-4-6 56,031 calls $5,272
|
||||
> claude-opus-4-7 3,592 calls $664 [selected]
|
||||
claude-sonnet-4-6 1,142 calls $25
|
||||
claude-haiku-4-5 323 calls $4
|
||||
gpt-5 113 calls $3 low data
|
||||
|
||||
[space] select [enter] compare [esc] back [q] quit
|
||||
```
|
||||
|
||||
- Arrow keys navigate, spacebar toggles selection (max 2)
|
||||
- Models sorted by cost descending (most-used first)
|
||||
- Models with < 20 calls show "low data" dim label
|
||||
- Enter is disabled until exactly 2 models are selected
|
||||
- Filter out `<synthetic>` model entries
|
||||
|
||||
### Loading Screen
|
||||
|
||||
```
|
||||
Comparing claude-opus-4-6 vs claude-opus-4-7...
|
||||
```
|
||||
|
||||
Simple spinner while aggregation runs. Should be fast (< 2 seconds) since
|
||||
session data is already parsed.
|
||||
|
||||
### Comparison Results Screen
|
||||
|
||||
```
|
||||
claude-opus-4-6 vs claude-opus-4-7
|
||||
|
||||
4.6 4.7
|
||||
Cost / call $0.094 $0.185 4.6 wins
|
||||
Output tok / call 227 800 4.6 wins
|
||||
Cache hit rate 98.4% 98.8% 4.7 wins
|
||||
One-shot rate 88.8% 74.5% 4.6 wins
|
||||
Retry rate 0.18 0.46 4.6 wins
|
||||
Self-correction 0.18% 0.25% 4.6 wins
|
||||
|
||||
── Context ──────────────────────────────────
|
||||
Calls 56,031 3,592
|
||||
Cost $5,272.13 $664.32
|
||||
Days of data 60 3
|
||||
Edit turns 1,577 102
|
||||
|
||||
[esc] back [q] quit
|
||||
```
|
||||
|
||||
- Winner column uses green text for the better model on each metric
|
||||
- Model names in the header are shortened for display (drop `claude-` prefix)
|
||||
- Context section is dimmed to visually separate it from the comparison
|
||||
- If a metric can't be computed (e.g., 0 edit turns), show `-` instead
|
||||
|
||||
---
|
||||
|
||||
## 5. File Structure
|
||||
|
||||
```
|
||||
src/compare.tsx -- Ink components: ModelSelector, ComparisonResults,
|
||||
CompareView (top-level), loading state
|
||||
src/compare-stats.ts -- aggregateModelStats(), computeComparison(),
|
||||
self-correction pattern matching, ModelStats type
|
||||
src/cli.ts -- new `compare` command registration
|
||||
src/dashboard.tsx -- add 'c' keybinding, CompareView integration
|
||||
```
|
||||
|
||||
### compare-stats.ts
|
||||
|
||||
Pure data module, no UI. Exports:
|
||||
|
||||
```ts
|
||||
function aggregateModelStats(projects: ProjectSummary[]): ModelStats[]
|
||||
function computeComparison(a: ModelStats, b: ModelStats): ComparisonRow[]
|
||||
```
|
||||
|
||||
The self-correction scanner needs raw assistant message text from JSONL files.
|
||||
Two options: (a) extend the parser to expose message text on turns during
|
||||
initial parse, or (b) have `compare-stats.ts` re-read JSONL files via provider
|
||||
session discovery (same mechanism the parser uses). Option (a) is cleaner but
|
||||
increases memory for all commands; option (b) is isolated to compare. The
|
||||
implementation plan should decide.
|
||||
|
||||
### compare.tsx
|
||||
|
||||
Three Ink components:
|
||||
|
||||
- `ModelSelector` -- arrow navigation, spacebar toggle, enter to confirm
|
||||
- `ComparisonResults` -- the formatted table with color-coded winners
|
||||
- `CompareView` -- orchestrates the flow (selection -> loading -> results)
|
||||
|
||||
Exported `renderCompare()` function for the standalone command, and
|
||||
`CompareView` component for embedding in the dashboard.
|
||||
|
||||
---
|
||||
|
||||
## 6. Dashboard Integration
|
||||
|
||||
### Status bar
|
||||
|
||||
Add `[c]ompare` to the status bar, after `[o]ptimize`:
|
||||
|
||||
```
|
||||
1-5 period arrows switch p provider o optimize c compare q quit
|
||||
```
|
||||
|
||||
### View state
|
||||
|
||||
Extend the existing `View` type:
|
||||
|
||||
```ts
|
||||
type View = 'dashboard' | 'optimize' | 'compare'
|
||||
```
|
||||
|
||||
Press `c` sets view to `'compare'`. Escape from compare returns to
|
||||
`'dashboard'`. The compare view receives the already-parsed `projects`
|
||||
from the dashboard state -- no re-parsing needed.
|
||||
|
||||
---
|
||||
|
||||
## 7. Edge Cases
|
||||
|
||||
- **Only one model in data**: Show message "Need at least 2 models to compare.
|
||||
Only found: claude-opus-4-6"
|
||||
- **Model with 0 edit turns**: Show `-` for one-shot rate and retry rate
|
||||
- **Model with < 20 calls**: Show "low data" warning on selection screen;
|
||||
allow selection but display a note on the results screen
|
||||
- **Self-correction scanner fails to read JSONL**: Gracefully degrade --
|
||||
show `-` for self-correction rate, don't block the rest of the comparison
|
||||
- **Both models have identical metrics**: Show "tie" instead of a winner
|
||||
70
gnome/README.md
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
# CodeBurn GNOME Extension
|
||||
|
||||
Monitor AI coding assistant token usage and costs from your GNOME desktop panel.
|
||||
|
||||
## Requirements
|
||||
|
||||
- GNOME Shell 45 or later
|
||||
- CodeBurn CLI installed (`npm i -g codeburn`)
|
||||
- `glib-compile-schemas` (usually part of `glib2-devel` or `libglib2.0-dev`)
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
cd gnome
|
||||
chmod +x install.sh
|
||||
./install.sh
|
||||
```
|
||||
|
||||
Then restart GNOME Shell:
|
||||
- **Wayland:** Log out and back in
|
||||
- **X11:** Press `Alt+F2`, type `r`, press Enter
|
||||
|
||||
Enable the extension:
|
||||
|
||||
```bash
|
||||
gnome-extensions enable codeburn@codeburn.dev
|
||||
```
|
||||
|
||||
## Configure
|
||||
|
||||
Open preferences:
|
||||
|
||||
```bash
|
||||
gnome-extensions prefs codeburn@codeburn.dev
|
||||
```
|
||||
|
||||
Or use the GNOME Extensions app.
|
||||
|
||||
### Settings
|
||||
|
||||
| Setting | Default | Description |
|
||||
|---------|---------|-------------|
|
||||
| Refresh Interval | 30s | How often to poll CodeBurn CLI |
|
||||
| Default Period | Today | Period shown on open |
|
||||
| Compact Mode | Off | Hide cost label, show icon only |
|
||||
| Budget Threshold | $0 | Daily budget alert (0 = disabled) |
|
||||
| Budget Alerts | Off | Show warning when budget exceeded |
|
||||
| CLI Path | (auto) | Custom path to `codeburn` binary |
|
||||
|
||||
## Uninstall
|
||||
|
||||
```bash
|
||||
gnome-extensions disable codeburn@codeburn.dev
|
||||
rm -r ~/.local/share/gnome-shell/extensions/codeburn@codeburn.dev
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
Test changes without installing:
|
||||
|
||||
```bash
|
||||
# Compile schemas locally
|
||||
glib-compile-schemas schemas/
|
||||
|
||||
# Symlink for development
|
||||
ln -sf "$(pwd)" ~/.local/share/gnome-shell/extensions/codeburn@codeburn.dev
|
||||
|
||||
# Watch logs
|
||||
journalctl -f -o cat /usr/bin/gnome-shell
|
||||
```
|
||||
161
gnome/dataClient.js
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
import GLib from 'gi://GLib';
|
||||
import Gio from 'gi://Gio';
|
||||
|
||||
const TIMEOUT_SECONDS = 15;
|
||||
const SAFE_ARG_RE = /^[A-Za-z0-9 ._/\-]+$/;
|
||||
|
||||
function buildAdditionalPaths() {
|
||||
const home = GLib.get_home_dir();
|
||||
return [
|
||||
'/usr/local/bin',
|
||||
`${home}/.local/bin`,
|
||||
`${home}/.npm-global/bin`,
|
||||
`${home}/.volta/bin`,
|
||||
`${home}/.bun/bin`,
|
||||
`${home}/.cargo/bin`,
|
||||
`${home}/.asdf/shims`,
|
||||
`${home}/.local/share/fnm/aliases/default/bin`,
|
||||
`${home}/.local/share/pnpm`,
|
||||
];
|
||||
}
|
||||
|
||||
export class DataClient {
|
||||
_cache = new Map();
|
||||
_inFlight = null;
|
||||
_codeburnPath;
|
||||
_augmentedPath;
|
||||
|
||||
constructor(codeburnPath) {
|
||||
this._codeburnPath = codeburnPath || '';
|
||||
this._augmentedPath = this._buildAugmentedPath();
|
||||
}
|
||||
|
||||
setCodeburnPath(path) {
|
||||
this._codeburnPath = path || '';
|
||||
}
|
||||
|
||||
cancelInFlight() {
|
||||
if (this._inFlight) {
|
||||
this._inFlight.cancellable.cancel();
|
||||
this._inFlight = null;
|
||||
}
|
||||
}
|
||||
|
||||
getCached(period, provider) {
|
||||
const key = `${period}:${provider}`;
|
||||
return this._cache.get(key) ?? null;
|
||||
}
|
||||
|
||||
async fetch(period, provider) {
|
||||
this.cancelInFlight();
|
||||
|
||||
const cancellable = new Gio.Cancellable();
|
||||
this._inFlight = { cancellable };
|
||||
|
||||
try {
|
||||
const payload = await this._spawn(period, provider, cancellable);
|
||||
const key = `${period}:${provider}`;
|
||||
this._cache.set(key, payload);
|
||||
return payload;
|
||||
} finally {
|
||||
if (this._inFlight?.cancellable === cancellable)
|
||||
this._inFlight = null;
|
||||
}
|
||||
}
|
||||
|
||||
_buildArgv(period, provider) {
|
||||
let base;
|
||||
if (this._codeburnPath && SAFE_ARG_RE.test(this._codeburnPath)) {
|
||||
base = this._codeburnPath.split(' ').filter(s => s.length > 0);
|
||||
} else {
|
||||
base = ['codeburn'];
|
||||
}
|
||||
|
||||
const args = [
|
||||
...base,
|
||||
'status',
|
||||
'--format', 'menubar-json',
|
||||
'--period', period,
|
||||
'--no-optimize',
|
||||
];
|
||||
|
||||
if (provider && provider !== 'all')
|
||||
args.push('--provider', provider);
|
||||
|
||||
return args;
|
||||
}
|
||||
|
||||
_buildAugmentedPath() {
|
||||
const currentPath = GLib.getenv('PATH') || '/usr/bin:/bin';
|
||||
const parts = currentPath.split(':');
|
||||
for (const extra of buildAdditionalPaths()) {
|
||||
if (!parts.includes(extra))
|
||||
parts.push(extra);
|
||||
}
|
||||
return parts.join(':');
|
||||
}
|
||||
|
||||
_spawn(period, provider, cancellable) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const argv = this._buildArgv(period, provider);
|
||||
let settled = false;
|
||||
|
||||
const settle = (fn, value) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
fn(value);
|
||||
};
|
||||
|
||||
let proc;
|
||||
try {
|
||||
const launcher = Gio.SubprocessLauncher.new(
|
||||
Gio.SubprocessFlags.STDOUT_PIPE | Gio.SubprocessFlags.STDERR_PIPE
|
||||
);
|
||||
launcher.setenv('PATH', this._augmentedPath, true);
|
||||
proc = launcher.spawnv(argv);
|
||||
} catch (e) {
|
||||
settle(reject, new Error(`CLI not found: ${e.message}`));
|
||||
return;
|
||||
}
|
||||
|
||||
let timeoutId = GLib.timeout_add_seconds(GLib.PRIORITY_DEFAULT, TIMEOUT_SECONDS, () => {
|
||||
timeoutId = 0;
|
||||
proc.force_exit();
|
||||
settle(reject, new Error('CLI timeout'));
|
||||
return GLib.SOURCE_REMOVE;
|
||||
});
|
||||
|
||||
proc.communicate_utf8_async(null, cancellable, (_proc, res) => {
|
||||
if (timeoutId) {
|
||||
GLib.Source.remove(timeoutId);
|
||||
timeoutId = 0;
|
||||
}
|
||||
|
||||
try {
|
||||
const [, stdout, stderr] = _proc.communicate_utf8_finish(res);
|
||||
|
||||
if (!_proc.get_successful()) {
|
||||
const msg = stderr?.trim() || 'CLI exited with error';
|
||||
settle(reject, new Error(msg));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!stdout || stdout.trim().length === 0) {
|
||||
settle(reject, new Error('CLI returned empty output'));
|
||||
return;
|
||||
}
|
||||
|
||||
const payload = JSON.parse(stdout);
|
||||
settle(resolve, payload);
|
||||
} catch (e) {
|
||||
settle(reject, e);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
destroy() {
|
||||
this.cancelInFlight();
|
||||
this._cache.clear();
|
||||
}
|
||||
}
|
||||
17
gnome/extension.js
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { Extension } from 'resource:///org/gnome/shell/extensions/extension.js';
|
||||
import * as Main from 'resource:///org/gnome/shell/ui/main.js';
|
||||
import { CodeBurnIndicator } from './indicator.js';
|
||||
|
||||
export default class CodeBurnExtension extends Extension {
|
||||
_indicator = null;
|
||||
|
||||
enable() {
|
||||
this._indicator = new CodeBurnIndicator(this);
|
||||
Main.panel.addToStatusArea('codeburn-indicator', this._indicator);
|
||||
}
|
||||
|
||||
disable() {
|
||||
this._indicator?.destroy();
|
||||
this._indicator = null;
|
||||
}
|
||||
}
|
||||
4
gnome/icons/codeburn-symbolic.svg
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
|
||||
<path fill="currentColor" d="M8 1C6.5 3.5 4 5 4 8c0 2.2 1.8 4 4 4s4-1.8 4-4c0-3-2.5-4.5-4-7zm0 9.5c-1 0-1.5-.7-1.5-1.5 0-1.2 1-2 1.5-3 .5 1 1.5 1.8 1.5 3 0 .8-.5 1.5-1.5 1.5z"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 310 B |
1004
gnome/indicator.js
Normal file
38
gnome/install.sh
Executable file
|
|
@ -0,0 +1,38 @@
|
|||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
UUID="codeburn@codeburn.dev"
|
||||
INSTALL_DIR="${HOME}/.local/share/gnome-shell/extensions/${UUID}"
|
||||
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||
|
||||
echo "Installing CodeBurn GNOME extension..."
|
||||
|
||||
# Compile GSettings schema
|
||||
echo "Compiling schemas..."
|
||||
glib-compile-schemas "${SCRIPT_DIR}/schemas/"
|
||||
|
||||
# Create install directory
|
||||
mkdir -p "${INSTALL_DIR}"
|
||||
|
||||
# Copy extension files
|
||||
cp "${SCRIPT_DIR}/metadata.json" "${INSTALL_DIR}/"
|
||||
cp "${SCRIPT_DIR}/extension.js" "${INSTALL_DIR}/"
|
||||
cp "${SCRIPT_DIR}/indicator.js" "${INSTALL_DIR}/"
|
||||
cp "${SCRIPT_DIR}/dataClient.js" "${INSTALL_DIR}/"
|
||||
cp "${SCRIPT_DIR}/prefs.js" "${INSTALL_DIR}/"
|
||||
cp "${SCRIPT_DIR}/stylesheet.css" "${INSTALL_DIR}/"
|
||||
|
||||
# Copy schemas
|
||||
mkdir -p "${INSTALL_DIR}/schemas"
|
||||
cp "${SCRIPT_DIR}/schemas/"* "${INSTALL_DIR}/schemas/"
|
||||
|
||||
# Copy icons
|
||||
mkdir -p "${INSTALL_DIR}/icons"
|
||||
cp "${SCRIPT_DIR}/icons/"* "${INSTALL_DIR}/icons/"
|
||||
|
||||
echo "Extension installed to ${INSTALL_DIR}"
|
||||
echo ""
|
||||
echo "Next steps:"
|
||||
echo " 1. Restart GNOME Shell (log out and back in on Wayland)"
|
||||
echo " 2. Enable: gnome-extensions enable ${UUID}"
|
||||
echo " 3. Configure: gnome-extensions prefs ${UUID}"
|
||||
8
gnome/metadata.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"name": "CodeBurn Monitor",
|
||||
"description": "Monitor AI coding assistant token usage and costs",
|
||||
"uuid": "codeburn@codeburn.dev",
|
||||
"shell-version": ["45", "46", "47", "48", "49", "50"],
|
||||
"url": "https://github.com/getagentseal/codeburn",
|
||||
"settings-schema": "org.gnome.shell.extensions.codeburn"
|
||||
}
|
||||
170
gnome/prefs.js
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
import Adw from 'gi://Adw';
|
||||
import Gtk from 'gi://Gtk';
|
||||
import Gio from 'gi://Gio';
|
||||
import { ExtensionPreferences } from 'resource:///org/gnome/Shell/Extensions/js/extensions/prefs.js';
|
||||
|
||||
const PROVIDERS = [
|
||||
{ id: 'claude', label: 'Claude' },
|
||||
{ id: 'codex', label: 'Codex' },
|
||||
{ id: 'copilot', label: 'Copilot' },
|
||||
{ id: 'cursor', label: 'Cursor' },
|
||||
{ id: 'droid', label: 'Droid' },
|
||||
{ id: 'gemini', label: 'Gemini' },
|
||||
{ id: 'goose', label: 'Goose' },
|
||||
{ id: 'kilo-code', label: 'Kilo Code' },
|
||||
{ id: 'kiro', label: 'Kiro' },
|
||||
{ id: 'kimi', label: 'Kimi' },
|
||||
{ id: 'openclaw', label: 'OpenClaw' },
|
||||
{ id: 'opencode', label: 'OpenCode' },
|
||||
{ id: 'pi', label: 'Pi' },
|
||||
{ id: 'qwen', label: 'Qwen' },
|
||||
{ id: 'roo-code', label: 'Roo Code' },
|
||||
{ id: 'antigravity', label: 'Antigravity' },
|
||||
];
|
||||
|
||||
const PERIODS = [
|
||||
{ id: 'today', label: 'Today' },
|
||||
{ id: 'week', label: '7 Days' },
|
||||
{ id: '30days', label: '30 Days' },
|
||||
{ id: 'month', label: 'Month' },
|
||||
{ id: 'all', label: '6 Months' },
|
||||
];
|
||||
|
||||
export default class CodeBurnPreferences extends ExtensionPreferences {
|
||||
fillPreferencesWindow(window) {
|
||||
const settings = this.getSettings();
|
||||
|
||||
const displayPage = new Adw.PreferencesPage({
|
||||
title: 'Display',
|
||||
icon_name: 'preferences-desktop-display-symbolic',
|
||||
});
|
||||
window.add(displayPage);
|
||||
|
||||
const displayGroup = new Adw.PreferencesGroup({
|
||||
title: 'Display',
|
||||
description: 'Configure how CodeBurn appears in the panel',
|
||||
});
|
||||
displayPage.add(displayGroup);
|
||||
|
||||
const refreshRow = new Adw.SpinRow({
|
||||
title: 'Refresh Interval',
|
||||
subtitle: 'Seconds between data refreshes',
|
||||
adjustment: new Gtk.Adjustment({
|
||||
lower: 5,
|
||||
upper: 300,
|
||||
step_increment: 5,
|
||||
page_increment: 30,
|
||||
value: settings.get_uint('refresh-interval'),
|
||||
}),
|
||||
});
|
||||
settings.bind('refresh-interval', refreshRow, 'value', Gio.SettingsBindFlags.DEFAULT);
|
||||
displayGroup.add(refreshRow);
|
||||
|
||||
const compactRow = new Adw.SwitchRow({
|
||||
title: 'Compact Mode',
|
||||
subtitle: 'Show only the icon, hide the cost label',
|
||||
});
|
||||
settings.bind('compact-mode', compactRow, 'active', Gio.SettingsBindFlags.DEFAULT);
|
||||
displayGroup.add(compactRow);
|
||||
|
||||
const darkModeRow = new Adw.SwitchRow({
|
||||
title: 'Force Dark Mode',
|
||||
subtitle: 'Always use dark theme for the popup',
|
||||
});
|
||||
settings.bind('force-dark-mode', darkModeRow, 'active', Gio.SettingsBindFlags.DEFAULT);
|
||||
displayGroup.add(darkModeRow);
|
||||
|
||||
const exactCostsRow = new Adw.SwitchRow({
|
||||
title: 'Show Exact Costs',
|
||||
subtitle: 'Show full values like $2,655.23 instead of $2.7k',
|
||||
});
|
||||
settings.bind('show-exact-costs', exactCostsRow, 'active', Gio.SettingsBindFlags.DEFAULT);
|
||||
displayGroup.add(exactCostsRow);
|
||||
|
||||
const periodModel = new Gtk.StringList();
|
||||
for (const p of PERIODS)
|
||||
periodModel.append(p.label);
|
||||
|
||||
const periodRow = new Adw.ComboRow({
|
||||
title: 'Default Period',
|
||||
subtitle: 'Time period shown when extension opens',
|
||||
model: periodModel,
|
||||
});
|
||||
const currentPeriod = settings.get_string('default-period');
|
||||
const periodIndex = PERIODS.findIndex(p => p.id === currentPeriod);
|
||||
periodRow.set_selected(periodIndex >= 0 ? periodIndex : 0);
|
||||
periodRow.connect('notify::selected', () => {
|
||||
const idx = periodRow.get_selected();
|
||||
if (idx >= 0 && idx < PERIODS.length)
|
||||
settings.set_string('default-period', PERIODS[idx].id);
|
||||
});
|
||||
displayGroup.add(periodRow);
|
||||
|
||||
const alertsGroup = new Adw.PreferencesGroup({
|
||||
title: 'Budget Alerts',
|
||||
description: 'Get warned when spending exceeds a threshold',
|
||||
});
|
||||
displayPage.add(alertsGroup);
|
||||
|
||||
const budgetEnabledRow = new Adw.SwitchRow({
|
||||
title: 'Enable Budget Alerts',
|
||||
subtitle: 'Show a warning when daily spending exceeds the threshold',
|
||||
});
|
||||
settings.bind('budget-alert-enabled', budgetEnabledRow, 'active', Gio.SettingsBindFlags.DEFAULT);
|
||||
alertsGroup.add(budgetEnabledRow);
|
||||
|
||||
const budgetRow = new Adw.SpinRow({
|
||||
title: 'Daily Budget (USD)',
|
||||
subtitle: 'Set to 0 to disable',
|
||||
adjustment: new Gtk.Adjustment({
|
||||
lower: 0,
|
||||
upper: 1000,
|
||||
step_increment: 1,
|
||||
page_increment: 10,
|
||||
value: settings.get_double('budget-threshold'),
|
||||
}),
|
||||
digits: 2,
|
||||
});
|
||||
settings.bind('budget-threshold', budgetRow, 'value', Gio.SettingsBindFlags.DEFAULT);
|
||||
alertsGroup.add(budgetRow);
|
||||
|
||||
const providersGroup = new Adw.PreferencesGroup({
|
||||
title: 'Providers',
|
||||
description: 'Toggle providers on/off for cost accounting',
|
||||
});
|
||||
displayPage.add(providersGroup);
|
||||
|
||||
const disabledProviders = settings.get_strv('disabled-providers');
|
||||
|
||||
for (const provider of PROVIDERS) {
|
||||
const row = new Adw.SwitchRow({
|
||||
title: provider.label,
|
||||
active: !disabledProviders.includes(provider.id),
|
||||
});
|
||||
row.connect('notify::active', () => {
|
||||
const current = settings.get_strv('disabled-providers');
|
||||
if (row.get_active()) {
|
||||
settings.set_strv('disabled-providers', current.filter(p => p !== provider.id));
|
||||
} else {
|
||||
if (!current.includes(provider.id))
|
||||
settings.set_strv('disabled-providers', [...current, provider.id]);
|
||||
}
|
||||
});
|
||||
providersGroup.add(row);
|
||||
}
|
||||
|
||||
const advancedGroup = new Adw.PreferencesGroup({
|
||||
title: 'Advanced',
|
||||
});
|
||||
displayPage.add(advancedGroup);
|
||||
|
||||
const pathRow = new Adw.EntryRow({
|
||||
title: 'CodeBurn CLI Path',
|
||||
text: settings.get_string('codeburn-path'),
|
||||
});
|
||||
pathRow.connect('changed', () => {
|
||||
settings.set_string('codeburn-path', pathRow.get_text());
|
||||
});
|
||||
advancedGroup.add(pathRow);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<schemalist gettext-domain="codeburn">
|
||||
<schema id="org.gnome.shell.extensions.codeburn"
|
||||
path="/org/gnome/shell/extensions/codeburn/">
|
||||
|
||||
<key name="refresh-interval" type="u">
|
||||
<default>30</default>
|
||||
<summary>Refresh interval</summary>
|
||||
<description>Seconds between automatic data refreshes</description>
|
||||
<range min="5" max="300"/>
|
||||
</key>
|
||||
|
||||
<key name="default-period" type="s">
|
||||
<default>'today'</default>
|
||||
<summary>Default time period</summary>
|
||||
<description>Period shown when extension opens (today, week, 30days, month, all)</description>
|
||||
</key>
|
||||
|
||||
<key name="budget-threshold" type="d">
|
||||
<default>0.0</default>
|
||||
<summary>Budget threshold</summary>
|
||||
<description>Daily budget threshold in USD. Set to 0 to disable.</description>
|
||||
</key>
|
||||
|
||||
<key name="budget-alert-enabled" type="b">
|
||||
<default>false</default>
|
||||
<summary>Enable budget alerts</summary>
|
||||
<description>Show warning when spending exceeds budget threshold</description>
|
||||
</key>
|
||||
|
||||
<key name="compact-mode" type="b">
|
||||
<default>false</default>
|
||||
<summary>Compact mode</summary>
|
||||
<description>Show only icon in panel, hide cost label</description>
|
||||
</key>
|
||||
|
||||
<key name="force-dark-mode" type="b">
|
||||
<default>false</default>
|
||||
<summary>Force dark mode</summary>
|
||||
<description>Always use dark theme for the popup, regardless of system theme</description>
|
||||
</key>
|
||||
|
||||
<key name="show-exact-costs" type="b">
|
||||
<default>false</default>
|
||||
<summary>Show exact costs</summary>
|
||||
<description>Show full decimal values instead of compact notation (e.g. $2,655.23 instead of $2.7k)</description>
|
||||
</key>
|
||||
|
||||
<key name="codeburn-path" type="s">
|
||||
<default>''</default>
|
||||
<summary>CodeBurn CLI path</summary>
|
||||
<description>Custom path to the codeburn executable. Leave empty to use PATH.</description>
|
||||
</key>
|
||||
|
||||
<key name="provider-filter" type="s">
|
||||
<default>'all'</default>
|
||||
<summary>Default provider filter</summary>
|
||||
<description>Default provider to filter by (all shows everything)</description>
|
||||
</key>
|
||||
|
||||
<key name="disabled-providers" type="as">
|
||||
<default>[]</default>
|
||||
<summary>Disabled providers</summary>
|
||||
<description>Providers excluded from cost accounting and display</description>
|
||||
</key>
|
||||
|
||||
</schema>
|
||||
</schemalist>
|
||||
610
gnome/stylesheet.css
Normal file
|
|
@ -0,0 +1,610 @@
|
|||
/* ---- panel button ---- */
|
||||
.codeburn-panel {
|
||||
spacing: 4px;
|
||||
}
|
||||
.codeburn-flame {
|
||||
font-size: 14px;
|
||||
}
|
||||
.codeburn-label {
|
||||
font-weight: 500;
|
||||
padding-left: 2px;
|
||||
padding-right: 2px;
|
||||
}
|
||||
|
||||
/* ---- popup host ---- */
|
||||
.codeburn-menu {
|
||||
padding: 0;
|
||||
}
|
||||
.codeburn-host {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
.codeburn-host:hover,
|
||||
.codeburn-host:focus,
|
||||
.codeburn-host:active,
|
||||
.codeburn-host:selected {
|
||||
background: transparent;
|
||||
}
|
||||
.codeburn-root {
|
||||
width: 340px;
|
||||
height: 540px;
|
||||
padding: 0;
|
||||
spacing: 0;
|
||||
}
|
||||
.codeburn-scroll {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
/* ---- brand header ---- */
|
||||
.codeburn-brand-header {
|
||||
padding: 14px 16px 10px 16px;
|
||||
spacing: 2px;
|
||||
}
|
||||
.codeburn-brand-row {
|
||||
spacing: 0;
|
||||
}
|
||||
.codeburn-brand-primary {
|
||||
font-weight: 700;
|
||||
font-size: 18px;
|
||||
}
|
||||
.codeburn-brand-accent {
|
||||
font-weight: 700;
|
||||
font-size: 18px;
|
||||
color: #ff8c42;
|
||||
}
|
||||
.codeburn-brand-subhead {
|
||||
font-size: 10.5px;
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
/* ---- tab rows ---- */
|
||||
.codeburn-tab-row {
|
||||
padding: 4px 10px 8px 10px;
|
||||
spacing: 4px;
|
||||
}
|
||||
.codeburn-period-row {
|
||||
padding-top: 0;
|
||||
padding-bottom: 10px;
|
||||
}
|
||||
.codeburn-tab,
|
||||
.codeburn-period {
|
||||
padding: 5px 6px;
|
||||
border-radius: 6px;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
background: transparent;
|
||||
border: none;
|
||||
opacity: 0.7;
|
||||
transition-duration: 80ms;
|
||||
}
|
||||
.codeburn-tab:hover,
|
||||
.codeburn-period:hover {
|
||||
background: rgba(255, 140, 66, 0.08);
|
||||
opacity: 1;
|
||||
}
|
||||
.codeburn-tab-active,
|
||||
.codeburn-period-active {
|
||||
background: rgba(255, 140, 66, 0.18);
|
||||
color: #ff8c42;
|
||||
opacity: 1;
|
||||
font-weight: 600;
|
||||
}
|
||||
.codeburn-agent-scroll {
|
||||
padding: 0;
|
||||
}
|
||||
.codeburn-agent-badge {
|
||||
padding: 3px 10px;
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 140, 66, 0.12);
|
||||
color: #ff8c42;
|
||||
font-size: 10.5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* ---- hero ---- */
|
||||
.codeburn-hero {
|
||||
padding: 4px 16px 10px 16px;
|
||||
spacing: 2px;
|
||||
}
|
||||
.codeburn-hero-top {
|
||||
spacing: 6px;
|
||||
}
|
||||
.codeburn-hero-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 3px;
|
||||
background-color: #ff8c42;
|
||||
margin-top: 7px;
|
||||
}
|
||||
.codeburn-hero-label {
|
||||
font-size: 11px;
|
||||
opacity: 0.65;
|
||||
font-weight: 500;
|
||||
}
|
||||
.codeburn-hero-amount {
|
||||
font-size: 28px;
|
||||
font-weight: 700;
|
||||
color: #ffd700;
|
||||
}
|
||||
.codeburn-hero-meta {
|
||||
font-size: 11px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* ---- activity section ---- */
|
||||
.codeburn-section-title {
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
opacity: 0.6;
|
||||
padding-bottom: 2px;
|
||||
}
|
||||
/* ---- table headers ---- */
|
||||
.codeburn-table-header {
|
||||
spacing: 6px;
|
||||
padding: 2px 0 4px 0;
|
||||
}
|
||||
.codeburn-th {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
opacity: 0.45;
|
||||
}
|
||||
.codeburn-th-cost {
|
||||
min-width: 64px;
|
||||
}
|
||||
.codeburn-th-turns {
|
||||
min-width: 40px;
|
||||
}
|
||||
.codeburn-th-calls {
|
||||
min-width: 50px;
|
||||
}
|
||||
|
||||
.codeburn-activity-rows {
|
||||
spacing: 0;
|
||||
}
|
||||
.codeburn-activity-row {
|
||||
spacing: 3px;
|
||||
padding: 6px 0;
|
||||
}
|
||||
.codeburn-activity-top {
|
||||
spacing: 6px;
|
||||
}
|
||||
.codeburn-activity-name {
|
||||
font-size: 11.5px;
|
||||
font-weight: 500;
|
||||
min-width: 120px;
|
||||
}
|
||||
.codeburn-activity-cost {
|
||||
font-size: 11.5px;
|
||||
font-family: monospace;
|
||||
font-weight: 600;
|
||||
color: #ffd700;
|
||||
min-width: 64px;
|
||||
}
|
||||
.codeburn-activity-turns {
|
||||
font-size: 10.5px;
|
||||
font-family: monospace;
|
||||
opacity: 0.6;
|
||||
min-width: 40px;
|
||||
}
|
||||
.codeburn-activity-oneshot {
|
||||
font-size: 10.5px;
|
||||
font-family: monospace;
|
||||
color: #4ec972;
|
||||
min-width: 40px;
|
||||
}
|
||||
.codeburn-bar-track {
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
width: 240px;
|
||||
}
|
||||
.codeburn-bar-fill {
|
||||
height: 4px;
|
||||
border-radius: 2px;
|
||||
background-color: #ff8c42;
|
||||
}
|
||||
.codeburn-empty {
|
||||
font-style: italic;
|
||||
opacity: 0.55;
|
||||
padding: 6px 0;
|
||||
}
|
||||
|
||||
/* ---- loading skeleton ---- */
|
||||
.codeburn-loading {
|
||||
padding: 10px 16px;
|
||||
spacing: 10px;
|
||||
}
|
||||
.codeburn-skeleton-bar {
|
||||
background-color: rgba(255, 140, 66, 0.15);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.codeburn-light .codeburn-skeleton-bar {
|
||||
background-color: rgba(200, 80, 30, 0.12);
|
||||
}
|
||||
|
||||
/* ---- findings CTA ---- */
|
||||
.codeburn-findings {
|
||||
margin: 2px 16px 10px 16px;
|
||||
padding: 9px 11px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 140, 66, 0.12);
|
||||
border: none;
|
||||
transition-duration: 120ms;
|
||||
}
|
||||
.codeburn-findings:hover {
|
||||
background: rgba(255, 140, 66, 0.2);
|
||||
}
|
||||
.codeburn-findings-inner {
|
||||
spacing: 8px;
|
||||
}
|
||||
.codeburn-findings-count {
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
color: #ff8c42;
|
||||
}
|
||||
.codeburn-findings-savings {
|
||||
font-size: 11.5px;
|
||||
font-weight: 500;
|
||||
color: #ff8c42;
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
/* ---- footer ---- */
|
||||
.codeburn-footer {
|
||||
padding: 10px 12px;
|
||||
spacing: 6px;
|
||||
}
|
||||
.codeburn-footer-btn {
|
||||
padding: 6px 10px;
|
||||
border-radius: 6px;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: none;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
transition-duration: 80ms;
|
||||
}
|
||||
.codeburn-footer-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
.codeburn-currency-box {
|
||||
spacing: 2px;
|
||||
}
|
||||
.codeburn-currency-btn {
|
||||
font-family: monospace;
|
||||
min-width: 62px;
|
||||
}
|
||||
.codeburn-currency-picker {
|
||||
background: rgba(30, 30, 30, 0.95);
|
||||
border-radius: 8px;
|
||||
padding: 4px;
|
||||
height: 180px;
|
||||
}
|
||||
.codeburn-currency-list {
|
||||
spacing: 1px;
|
||||
}
|
||||
.codeburn-currency-item {
|
||||
padding: 4px 10px;
|
||||
border-radius: 4px;
|
||||
font-size: 11px;
|
||||
font-family: monospace;
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
.codeburn-currency-item:hover {
|
||||
background: rgba(255, 140, 66, 0.12);
|
||||
}
|
||||
.codeburn-currency-item-active {
|
||||
background: rgba(255, 140, 66, 0.2);
|
||||
color: #ff8c42;
|
||||
font-weight: 600;
|
||||
}
|
||||
.codeburn-footer-cta {
|
||||
background: #c9521d;
|
||||
color: #ffffff;
|
||||
}
|
||||
.codeburn-footer-cta:hover {
|
||||
background: #ff8c42;
|
||||
}
|
||||
.codeburn-updated {
|
||||
font-size: 10px;
|
||||
opacity: 0.45;
|
||||
padding: 0 16px 10px 16px;
|
||||
}
|
||||
|
||||
/* ---- insight pills row ---- */
|
||||
.codeburn-insight-row {
|
||||
padding: 4px 10px 8px 10px;
|
||||
spacing: 4px;
|
||||
}
|
||||
.codeburn-insight-pill {
|
||||
padding: 4px 4px;
|
||||
border-radius: 6px;
|
||||
font-size: 10.5px;
|
||||
font-weight: 500;
|
||||
background: transparent;
|
||||
border: none;
|
||||
opacity: 0.65;
|
||||
transition-duration: 80ms;
|
||||
}
|
||||
.codeburn-insight-pill:hover {
|
||||
background: rgba(255, 140, 66, 0.08);
|
||||
opacity: 1;
|
||||
}
|
||||
.codeburn-insight-pill-active {
|
||||
background: rgba(255, 140, 66, 0.18);
|
||||
color: #ff8c42;
|
||||
opacity: 1;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* ---- token histogram chart ---- */
|
||||
.codeburn-chart {
|
||||
padding: 0 16px 10px 16px;
|
||||
spacing: 4px;
|
||||
}
|
||||
.codeburn-chart-header {
|
||||
spacing: 6px;
|
||||
}
|
||||
.codeburn-chart-label {
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
.codeburn-chart-total {
|
||||
font-family: monospace;
|
||||
font-size: 11px;
|
||||
opacity: 0.7;
|
||||
color: #ff8c42;
|
||||
}
|
||||
.codeburn-chart-bars {
|
||||
spacing: 2px;
|
||||
height: 52px;
|
||||
}
|
||||
.codeburn-chart-col {
|
||||
height: 52px;
|
||||
}
|
||||
.codeburn-chart-spacer {
|
||||
background: transparent;
|
||||
}
|
||||
.codeburn-chart-bar {
|
||||
background-color: #ff8c42;
|
||||
border-radius: 2px 2px 0 0;
|
||||
}
|
||||
.codeburn-chart-bar-hover {
|
||||
background-color: #ffa94d;
|
||||
}
|
||||
.codeburn-chart-total-hover {
|
||||
font-weight: 600;
|
||||
}
|
||||
.codeburn-divider {
|
||||
height: 1px;
|
||||
background-color: rgba(255, 255, 255, 0.08);
|
||||
margin: 4px 16px;
|
||||
}
|
||||
|
||||
/* ---- trend, pulse, stats, kv rows ---- */
|
||||
.codeburn-content {
|
||||
padding: 6px 16px 10px 16px;
|
||||
spacing: 6px;
|
||||
}
|
||||
.codeburn-trend-row,
|
||||
.codeburn-kv-row {
|
||||
padding: 4px 0;
|
||||
spacing: 8px;
|
||||
}
|
||||
.codeburn-trend-date,
|
||||
.codeburn-kv-label {
|
||||
font-size: 11.5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
.codeburn-trend-cost,
|
||||
.codeburn-kv-value {
|
||||
font-family: monospace;
|
||||
font-size: 11.5px;
|
||||
font-weight: 600;
|
||||
color: #ffd700;
|
||||
}
|
||||
.codeburn-trend-calls {
|
||||
font-size: 10.5px;
|
||||
opacity: 0.6;
|
||||
min-width: 62px;
|
||||
}
|
||||
|
||||
/* ---- pulse tiles ---- */
|
||||
.codeburn-pulse-row {
|
||||
spacing: 6px;
|
||||
padding: 4px 0;
|
||||
}
|
||||
.codeburn-pulse-tile {
|
||||
padding: 10px 8px;
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 140, 66, 0.08);
|
||||
spacing: 2px;
|
||||
}
|
||||
.codeburn-pulse-value {
|
||||
font-size: 16px;
|
||||
font-weight: 700;
|
||||
color: #ff8c42;
|
||||
font-family: monospace;
|
||||
}
|
||||
.codeburn-pulse-label {
|
||||
font-size: 10px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
/* ---- models rows ---- */
|
||||
.codeburn-models-rows {
|
||||
spacing: 0;
|
||||
padding-top: 4px;
|
||||
}
|
||||
.codeburn-model-row {
|
||||
spacing: 8px;
|
||||
padding: 6px 0;
|
||||
}
|
||||
.codeburn-model-name {
|
||||
font-size: 11.5px;
|
||||
min-width: 120px;
|
||||
}
|
||||
.codeburn-model-cost {
|
||||
font-family: monospace;
|
||||
font-size: 11.5px;
|
||||
color: #ffd700;
|
||||
min-width: 64px;
|
||||
}
|
||||
.codeburn-model-calls {
|
||||
font-family: monospace;
|
||||
font-size: 10.5px;
|
||||
opacity: 0.6;
|
||||
min-width: 50px;
|
||||
}
|
||||
|
||||
/* ---- settings gear button ---- */
|
||||
.codeburn-prefs-btn {
|
||||
padding: 6px 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* ---- budget warning ---- */
|
||||
.codeburn-budget-warning {
|
||||
color: #e5a50a;
|
||||
font-weight: bold;
|
||||
font-size: 11.5px;
|
||||
padding: 6px 16px;
|
||||
}
|
||||
|
||||
/* ---- dark theme ---- */
|
||||
.codeburn-dark {
|
||||
background-color: rgba(30, 30, 30, 0.98);
|
||||
color: #e0e0e0;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.codeburn-dark .codeburn-brand-primary {
|
||||
color: #ffffff;
|
||||
}
|
||||
.codeburn-dark .codeburn-brand-subhead {
|
||||
color: rgba(255, 255, 255, 0.55);
|
||||
}
|
||||
.codeburn-dark .codeburn-hero-label,
|
||||
.codeburn-dark .codeburn-hero-meta {
|
||||
color: rgba(255, 255, 255, 0.65);
|
||||
}
|
||||
.codeburn-dark .codeburn-section-title,
|
||||
.codeburn-dark .codeburn-th,
|
||||
.codeburn-dark .codeburn-chart-label {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
.codeburn-dark .codeburn-activity-name,
|
||||
.codeburn-dark .codeburn-model-name,
|
||||
.codeburn-dark .codeburn-trend-date,
|
||||
.codeburn-dark .codeburn-kv-label {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
.codeburn-dark .codeburn-activity-turns,
|
||||
.codeburn-dark .codeburn-model-calls,
|
||||
.codeburn-dark .codeburn-trend-calls {
|
||||
color: rgba(255, 255, 255, 0.5);
|
||||
}
|
||||
.codeburn-dark .codeburn-footer-btn {
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: #e0e0e0;
|
||||
}
|
||||
.codeburn-dark .codeburn-footer-btn:hover {
|
||||
background: rgba(255, 255, 255, 0.14);
|
||||
}
|
||||
.codeburn-dark .codeburn-currency-picker {
|
||||
background: rgba(20, 20, 20, 0.98);
|
||||
}
|
||||
.codeburn-dark .codeburn-currency-item {
|
||||
color: #e0e0e0;
|
||||
}
|
||||
.codeburn-dark .codeburn-tab,
|
||||
.codeburn-dark .codeburn-period,
|
||||
.codeburn-dark .codeburn-insight-pill {
|
||||
color: rgba(255, 255, 255, 0.7);
|
||||
}
|
||||
.codeburn-dark .codeburn-updated {
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
}
|
||||
|
||||
/* ---- light theme ---- */
|
||||
.codeburn-light {
|
||||
background-color: rgba(255, 255, 255, 0.98);
|
||||
color: #1a1a1a;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.codeburn-light .codeburn-brand-primary {
|
||||
color: #1a1a1a;
|
||||
}
|
||||
.codeburn-light .codeburn-brand-subhead {
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.codeburn-light .codeburn-hero-label,
|
||||
.codeburn-light .codeburn-hero-meta {
|
||||
color: rgba(0, 0, 0, 0.6);
|
||||
}
|
||||
.codeburn-light .codeburn-hero-amount {
|
||||
color: #c9521d;
|
||||
}
|
||||
.codeburn-light .codeburn-section-title,
|
||||
.codeburn-light .codeburn-th,
|
||||
.codeburn-light .codeburn-chart-label {
|
||||
color: rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
.codeburn-light .codeburn-activity-name,
|
||||
.codeburn-light .codeburn-model-name,
|
||||
.codeburn-light .codeburn-trend-date,
|
||||
.codeburn-light .codeburn-kv-label {
|
||||
color: #1a1a1a;
|
||||
}
|
||||
.codeburn-light .codeburn-activity-cost,
|
||||
.codeburn-light .codeburn-model-cost,
|
||||
.codeburn-light .codeburn-trend-cost,
|
||||
.codeburn-light .codeburn-kv-value {
|
||||
color: #c9521d;
|
||||
}
|
||||
.codeburn-light .codeburn-activity-turns,
|
||||
.codeburn-light .codeburn-model-calls,
|
||||
.codeburn-light .codeburn-trend-calls {
|
||||
color: rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.codeburn-light .codeburn-activity-oneshot {
|
||||
color: #1b7a35;
|
||||
}
|
||||
.codeburn-light .codeburn-bar-track {
|
||||
background-color: rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
.codeburn-light .codeburn-bar-fill {
|
||||
background-color: #c9521d;
|
||||
}
|
||||
.codeburn-light .codeburn-chart-bar {
|
||||
background-color: #c9521d;
|
||||
}
|
||||
.codeburn-light .codeburn-footer-btn {
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
color: #1a1a1a;
|
||||
}
|
||||
.codeburn-light .codeburn-footer-btn:hover {
|
||||
background: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.codeburn-light .codeburn-currency-picker {
|
||||
background: rgba(245, 245, 245, 0.98);
|
||||
}
|
||||
.codeburn-light .codeburn-currency-item {
|
||||
color: #1a1a1a;
|
||||
}
|
||||
.codeburn-light .codeburn-tab,
|
||||
.codeburn-light .codeburn-period,
|
||||
.codeburn-light .codeburn-insight-pill {
|
||||
color: rgba(0, 0, 0, 0.65);
|
||||
}
|
||||
.codeburn-light .codeburn-pulse-tile {
|
||||
background: rgba(255, 140, 66, 0.1);
|
||||
}
|
||||
.codeburn-light .codeburn-updated {
|
||||
color: rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
.codeburn-light .codeburn-divider {
|
||||
background-color: rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
|
@ -6,19 +6,17 @@ Native Swift + SwiftUI menubar app. The codeburn menubar surface.
|
|||
|
||||
- macOS 14+ (Sonoma)
|
||||
- Swift 6.0+ toolchain (bundled with Xcode 16 or standalone)
|
||||
- `codeburn` CLI installed globally (`npm install -g codeburn`) or available at a path you pass via `CODEBURN_BIN`
|
||||
- `codeburn` CLI installed globally (`npm install -g codeburn`)
|
||||
|
||||
## Install (end users)
|
||||
|
||||
One command:
|
||||
|
||||
```bash
|
||||
npx codeburn menubar
|
||||
codeburn menubar
|
||||
```
|
||||
|
||||
That's it. The command downloads the latest `.app` from GitHub Releases, drops it into `~/Applications`, clears Gatekeeper quarantine, and launches it. Re-running it upgrades in place with `--force`, or just launches the existing copy otherwise.
|
||||
|
||||
If you already have the CLI installed globally (`npm install -g codeburn`), `codeburn menubar` works the same way.
|
||||
That's it. The command records the persistent `codeburn` CLI path, downloads the latest `.app` from the newest `mac-v*` GitHub Release with a matching checksum, verifies it, drops it into `~/Applications`, clears Gatekeeper quarantine, and launches it. Re-running it upgrades in place with `--force`, or just launches the existing copy otherwise.
|
||||
|
||||
### Build from source
|
||||
|
||||
|
|
@ -39,7 +37,7 @@ cd mac
|
|||
swift build
|
||||
# Point the app at your dev CLI build instead of the globally installed `codeburn`:
|
||||
npm --prefix .. run build
|
||||
CODEBURN_BIN="node $(pwd)/../dist/cli.js" swift run
|
||||
CODEBURN_ALLOW_DEV_BIN=1 CODEBURN_BIN="node $(pwd)/../dist/cli.js" swift run
|
||||
```
|
||||
|
||||
The app registers itself as a menubar accessory (`LSUIElement = true` at runtime). No Dock icon.
|
||||
|
|
@ -48,7 +46,7 @@ The app registers itself as a menubar accessory (`LSUIElement = true` at runtime
|
|||
|
||||
On launch and every 60 seconds thereafter, the app spawns `codeburn status --format menubar-json --no-optimize` directly (argv, no shell) via `CodeburnCLI.makeProcess` and decodes the JSON into `MenubarPayload`. The manual refresh button in the footer invokes the same command without `--no-optimize`, which includes optimize findings but takes longer.
|
||||
|
||||
Override the binary via the `CODEBURN_BIN` environment variable (default: `codeburn` on PATH). The value is validated against a strict allowlist (alphanumerics plus `._/-` space) before use, so a malicious env var can't inject shell commands.
|
||||
Release installs record a persistent absolute CLI path in `~/Library/Application Support/CodeBurn/codeburn-cli-path.v1`, then fall back to Homebrew's common `codeburn` locations. For development only, set `CODEBURN_ALLOW_DEV_BIN=1` with `CODEBURN_BIN`; the value is validated against a strict allowlist before use, so a malicious env var can't inject shell commands.
|
||||
|
||||
## Project layout
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@
|
|||
set -euo pipefail
|
||||
|
||||
VERSION="${1:-dev}"
|
||||
ASSET_VERSION="${VERSION#mac-}"
|
||||
BUNDLE_VERSION="${ASSET_VERSION#v}"
|
||||
BUNDLE_NAME="CodeBurnMenubar.app"
|
||||
BUNDLE_ID="org.agentseal.codeburn-menubar"
|
||||
EXECUTABLE_NAME="CodeBurnMenubar"
|
||||
|
|
@ -66,9 +68,9 @@ cat > "${BUNDLE}/Contents/Info.plist" <<PLIST
|
|||
<key>CFBundlePackageType</key>
|
||||
<string>APPL</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>${VERSION}</string>
|
||||
<string>${BUNDLE_VERSION}</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>${VERSION}</string>
|
||||
<string>${BUNDLE_VERSION}</string>
|
||||
<key>LSMinimumSystemVersion</key>
|
||||
<string>${MIN_MACOS}</string>
|
||||
<key>LSUIElement</key>
|
||||
|
|
@ -85,19 +87,25 @@ cat > "${BUNDLE}/Contents/PkgInfo" <<'PKG'
|
|||
APPL????
|
||||
PKG
|
||||
|
||||
# Ad-hoc sign so macOS treats the bundle as internally consistent. This satisfies the
|
||||
# minimum bundle-validity checks on macOS 14+ and prevents a class of Gatekeeper edge
|
||||
# cases on managed Macs. A Developer ID signature (separate setup) would additionally
|
||||
# surface the publisher name in Finder; not required here.
|
||||
# Ad-hoc sign so macOS treats the bundle as internally consistent. Release
|
||||
# notarization can layer a Developer ID signature on top, but this local step
|
||||
# must still fail closed if signing or verification breaks.
|
||||
echo "▸ Ad-hoc signing..."
|
||||
codesign --force --sign - --timestamp=none --deep "${BUNDLE}" 2>/dev/null || true
|
||||
codesign --verify --deep --strict "${BUNDLE}" 2>/dev/null || echo " (signature verify skipped)"
|
||||
codesign --force --sign - --timestamp=none --deep "${BUNDLE}"
|
||||
codesign --verify --deep --strict "${BUNDLE}"
|
||||
|
||||
ZIP_NAME="CodeBurnMenubar-${VERSION}.zip"
|
||||
ZIP_NAME="CodeBurnMenubar-${ASSET_VERSION}.zip"
|
||||
ZIP_PATH="${DIST_DIR}/${ZIP_NAME}"
|
||||
echo "▸ Packaging ${ZIP_NAME}..."
|
||||
(cd "${DIST_DIR}" && /usr/bin/ditto -c -k --keepParent "${BUNDLE_NAME}" "${ZIP_NAME}")
|
||||
(cd "${DIST_DIR}" && COPYFILE_DISABLE=1 /usr/bin/ditto -c -k --norsrc --keepParent "${BUNDLE_NAME}" "${ZIP_NAME}")
|
||||
|
||||
CHECKSUM_NAME="${ZIP_NAME}.sha256"
|
||||
CHECKSUM_PATH="${DIST_DIR}/${CHECKSUM_NAME}"
|
||||
echo "▸ Computing SHA-256 checksum..."
|
||||
(cd "${DIST_DIR}" && shasum -a 256 "${ZIP_NAME}" > "${CHECKSUM_NAME}")
|
||||
|
||||
echo ""
|
||||
echo "✓ Built ${ZIP_PATH}"
|
||||
echo "✓ Checksum ${CHECKSUM_PATH}"
|
||||
cat "${CHECKSUM_PATH}"
|
||||
ls -la "${DIST_DIR}"
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import Foundation
|
|||
import Observation
|
||||
|
||||
private let cacheTTLSeconds: TimeInterval = 30
|
||||
private let interactiveRefreshResetSeconds: TimeInterval = 120
|
||||
|
||||
struct CachedPayload {
|
||||
let payload: MenubarPayload
|
||||
|
|
@ -25,14 +26,48 @@ final class AppStore {
|
|||
}
|
||||
var showingAccentPicker: Bool = false
|
||||
var currency: String = "USD"
|
||||
var isLoading: Bool = false
|
||||
var lastError: String?
|
||||
var isLoading: Bool { loadingCountsByKey.values.contains { $0 > 0 } }
|
||||
var isCurrentKeyLoading: Bool { loadingCountsByKey[currentKey, default: 0] > 0 }
|
||||
var hasAttemptedCurrentKeyLoad: Bool { attemptedKeys.contains(currentKey) }
|
||||
var lastError: String? { lastErrorByKey[currentKey] }
|
||||
private var loadingCountsByKey: [PayloadCacheKey: Int] = [:]
|
||||
private var loadingStartedAtByKey: [PayloadCacheKey: Date] = [:]
|
||||
private var attemptedKeys: Set<PayloadCacheKey> = []
|
||||
private var lastErrorByKey: [PayloadCacheKey: String] = [:]
|
||||
var subscription: SubscriptionUsage?
|
||||
var subscriptionError: String?
|
||||
var subscriptionLoadState: SubscriptionLoadState = .idle
|
||||
var subscriptionLoadState: SubscriptionLoadState = ClaudeCredentialStore.isBootstrapCompleted ? .loading : .notBootstrapped
|
||||
var capacityEstimates: [String: CapacityEstimate] = [:]
|
||||
|
||||
var codexUsage: CodexUsage?
|
||||
var codexError: String?
|
||||
var codexLoadState: SubscriptionLoadState = CodexCredentialStore.isBootstrapCompleted ? .loading : .notBootstrapped
|
||||
|
||||
/// Generation tokens for the in-flight refresh tasks. Incremented on every
|
||||
/// disconnect / reset so a fetch that started before the disconnect cannot
|
||||
/// resume after the await and re-populate the freshly-cleared state.
|
||||
private var claudeRefreshGen: Int = 0
|
||||
private var codexRefreshGen: Int = 0
|
||||
|
||||
private var cache: [PayloadCacheKey: CachedPayload] = [:]
|
||||
private var cacheDate: String = ""
|
||||
private var switchTask: Task<Void, Never>?
|
||||
private var payloadRefreshGeneration: UInt64 = 0
|
||||
/// Tracks the last successful fetch timestamp per key for stuck-loading
|
||||
/// diagnostics. NOT used for cache-freshness logic — `CachedPayload.fetchedAt`
|
||||
/// is authoritative there. This map persists across cache wipes (day
|
||||
/// rollover, etc.) so we can distinguish "fresh install, never fetched"
|
||||
/// from "cache was wiped 10 minutes ago and we still haven't refilled".
|
||||
private var lastSuccessByKey: [PayloadCacheKey: Date] = [:]
|
||||
|
||||
private func staleSecondsForKey(_ key: PayloadCacheKey) -> TimeInterval {
|
||||
guard let last = lastSuccessByKey[key] else { return .infinity }
|
||||
return Date().timeIntervalSince(last)
|
||||
}
|
||||
|
||||
private var todayAllKey: PayloadCacheKey {
|
||||
PayloadCacheKey(period: .today, provider: .all)
|
||||
}
|
||||
|
||||
private var currentKey: PayloadCacheKey {
|
||||
PayloadCacheKey(period: selectedPeriod, provider: selectedProvider)
|
||||
|
|
@ -45,7 +80,16 @@ final class AppStore {
|
|||
/// Today (across all providers) is pinned for the always-visible menubar icon, independent of
|
||||
/// the popover's selected period or provider.
|
||||
var todayPayload: MenubarPayload? {
|
||||
cache[PayloadCacheKey(period: .today, provider: .all)]?.payload
|
||||
cache[todayAllKey]?.payload
|
||||
}
|
||||
|
||||
var todayPayloadAgeSeconds: Int? {
|
||||
guard let cached = cache[todayAllKey] else { return nil }
|
||||
return Int(Date().timeIntervalSince(cached.fetchedAt))
|
||||
}
|
||||
|
||||
var needsStatusPayloadRefresh: Bool {
|
||||
cache[todayAllKey]?.isFresh != true
|
||||
}
|
||||
|
||||
/// All-provider payload for the selected period. Used by the tab strip to show
|
||||
|
|
@ -58,47 +102,262 @@ final class AppStore {
|
|||
cache[currentKey] != nil
|
||||
}
|
||||
|
||||
var hasStaleLoading: Bool {
|
||||
let now = Date()
|
||||
return loadingStartedAtByKey.values.contains {
|
||||
now.timeIntervalSince($0) > loadingWatchdogSeconds
|
||||
}
|
||||
}
|
||||
|
||||
var hasStaleInteractivePayload: Bool {
|
||||
staleInteractivePayloadAgeSeconds != nil
|
||||
}
|
||||
|
||||
var hasMissingInteractivePayloadWithoutAttempt: Bool {
|
||||
cache[currentKey] == nil && !isCurrentKeyLoading && !hasAttemptedCurrentKeyLoad
|
||||
}
|
||||
|
||||
var shouldResetInteractiveRefreshPipeline: Bool {
|
||||
hasStaleLoading || hasStaleInteractivePayload || hasMissingInteractivePayloadWithoutAttempt
|
||||
}
|
||||
|
||||
var staleInteractivePayloadAgeSeconds: Int? {
|
||||
let keys = Set([
|
||||
currentKey,
|
||||
todayAllKey,
|
||||
PayloadCacheKey(period: selectedPeriod, provider: .all),
|
||||
])
|
||||
let staleAges = keys.compactMap { key -> TimeInterval? in
|
||||
guard let cached = cache[key] else { return nil }
|
||||
let age = Date().timeIntervalSince(cached.fetchedAt)
|
||||
return age > interactiveRefreshResetSeconds ? age : nil
|
||||
}
|
||||
return staleAges.max().map(Int.init)
|
||||
}
|
||||
|
||||
var needsInteractivePayloadRefresh: Bool {
|
||||
let periodAllKey = PayloadCacheKey(period: selectedPeriod, provider: .all)
|
||||
return cache[currentKey]?.isFresh != true ||
|
||||
cache[todayAllKey]?.isFresh != true ||
|
||||
cache[periodAllKey]?.isFresh != true ||
|
||||
hasStaleLoading
|
||||
}
|
||||
|
||||
/// True if any cached payload reports at least one provider. Used to keep the
|
||||
/// AgentTabStrip visible across period/provider switches even when the current
|
||||
/// key's payload is briefly empty (e.g. immediately after a `switchTo` and
|
||||
/// before the new fetch lands).
|
||||
var hasAnyProvidersInCache: Bool {
|
||||
cache.values.contains { !$0.payload.current.providers.isEmpty }
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
func setCachedPayloadForTesting(_ payload: MenubarPayload, period: Period, provider: ProviderFilter, fetchedAt: Date) {
|
||||
cache[PayloadCacheKey(period: period, provider: provider)] = CachedPayload(payload: payload, fetchedAt: fetchedAt)
|
||||
}
|
||||
#endif
|
||||
|
||||
var findingsCount: Int {
|
||||
payload.optimize.findingCount
|
||||
}
|
||||
|
||||
/// Switch to a period. Always fetches fresh data so the user never sees stale numbers.
|
||||
func switchTo(period: Period) async {
|
||||
/// Switch to a period. Cancels any in-flight switch and fetches provider-specific +
|
||||
/// all-provider data in parallel so tab strip costs stay in sync with the hero.
|
||||
func switchTo(period: Period) {
|
||||
selectedPeriod = period
|
||||
await refresh(includeOptimize: true, force: true)
|
||||
startInteractiveSelectionRefresh()
|
||||
}
|
||||
|
||||
/// Switch to a provider filter. Always fetches fresh data so the user never sees stale numbers.
|
||||
func switchTo(provider: ProviderFilter) async {
|
||||
/// Switch to a provider filter. Cancels any in-flight switch so rapid tab tapping only
|
||||
/// runs the CLI for the final selection. Fetches provider-specific and all-provider data
|
||||
/// in parallel so the tab strip costs stay in sync with the hero.
|
||||
func switchTo(provider: ProviderFilter) {
|
||||
selectedProvider = provider
|
||||
await refresh(includeOptimize: true, force: true)
|
||||
startInteractiveSelectionRefresh()
|
||||
}
|
||||
|
||||
private func startInteractiveSelectionRefresh() {
|
||||
switchTask?.cancel()
|
||||
resetLoadingState()
|
||||
let period = selectedPeriod
|
||||
let provider = selectedProvider
|
||||
lastErrorByKey[PayloadCacheKey(period: period, provider: provider)] = nil
|
||||
switchTask = Task {
|
||||
if provider == .all {
|
||||
await refresh(includeOptimize: false, force: true, showLoading: true)
|
||||
} else {
|
||||
async let main: Void = refresh(includeOptimize: false, force: true, showLoading: true)
|
||||
async let all: Void = refreshQuietly(period: period)
|
||||
_ = await (main, all)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var inFlightKeys: Set<PayloadCacheKey> = []
|
||||
|
||||
/// Refresh the currently selected (period, provider) combination. Guards against concurrent
|
||||
/// fetches for the same key so a slow initial request can't overwrite a newer one that
|
||||
/// finished first (which would show stale numbers the user has already moved past).
|
||||
/// When `force` is false (background timer), skips the CLI call if the cache is still fresh.
|
||||
func refresh(includeOptimize: Bool, force: Bool = false) async {
|
||||
func resetLoadingState() {
|
||||
payloadRefreshGeneration &+= 1
|
||||
loadingCountsByKey.removeAll()
|
||||
loadingStartedAtByKey.removeAll()
|
||||
inFlightKeys.removeAll()
|
||||
}
|
||||
|
||||
func resetRefreshState(clearCache: Bool = false) {
|
||||
switchTask?.cancel()
|
||||
switchTask = nil
|
||||
resetLoadingState()
|
||||
attemptedKeys.removeAll()
|
||||
lastErrorByKey.removeAll()
|
||||
if clearCache {
|
||||
cache.removeAll()
|
||||
}
|
||||
}
|
||||
|
||||
private let loadingWatchdogSeconds: TimeInterval = 60
|
||||
|
||||
@discardableResult
|
||||
func clearStaleLoadingIfNeeded() -> Bool {
|
||||
let now = Date()
|
||||
let staleEntries = loadingStartedAtByKey.filter {
|
||||
now.timeIntervalSince($0.value) > loadingWatchdogSeconds
|
||||
}
|
||||
guard !staleEntries.isEmpty else { return false }
|
||||
|
||||
payloadRefreshGeneration &+= 1
|
||||
for (key, started) in staleEntries {
|
||||
NSLog("CodeBurn: loading stuck for %ds on %@/%@ — auto-clearing",
|
||||
Int(now.timeIntervalSince(started)), key.period.rawValue, key.provider.rawValue)
|
||||
loadingCountsByKey[key] = nil
|
||||
loadingStartedAtByKey[key] = nil
|
||||
inFlightKeys.remove(key)
|
||||
if cache[key] == nil {
|
||||
lastErrorByKey[key] = "Refresh took longer than expected. CodeBurn will keep retrying in the background."
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private func beginLoading(for key: PayloadCacheKey) {
|
||||
if loadingCountsByKey[key, default: 0] == 0 {
|
||||
loadingStartedAtByKey[key] = Date()
|
||||
}
|
||||
loadingCountsByKey[key, default: 0] += 1
|
||||
}
|
||||
|
||||
private func finishLoading(for key: PayloadCacheKey) {
|
||||
guard let count = loadingCountsByKey[key], count > 0 else { return }
|
||||
if count == 1 {
|
||||
loadingCountsByKey[key] = nil
|
||||
loadingStartedAtByKey[key] = nil
|
||||
} else {
|
||||
loadingCountsByKey[key] = count - 1
|
||||
}
|
||||
}
|
||||
|
||||
private func currentCacheDate() -> String {
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
return formatter.string(from: Date())
|
||||
}
|
||||
|
||||
private func invalidateStaleDayCache() {
|
||||
let today = currentCacheDate()
|
||||
if cacheDate != today {
|
||||
payloadRefreshGeneration &+= 1
|
||||
cache.removeAll()
|
||||
loadingCountsByKey.removeAll()
|
||||
loadingStartedAtByKey.removeAll()
|
||||
inFlightKeys.removeAll()
|
||||
attemptedKeys.removeAll()
|
||||
lastErrorByKey.removeAll()
|
||||
cacheDate = today
|
||||
NSLog("CodeBurn: reset menubar payload cache for new day %@", today)
|
||||
}
|
||||
}
|
||||
|
||||
func invalidateCache() {
|
||||
cache.removeAll()
|
||||
}
|
||||
|
||||
func refresh(includeOptimize: Bool, force: Bool = false, showLoading: Bool = false) async {
|
||||
invalidateStaleDayCache()
|
||||
let key = currentKey
|
||||
let cacheDateAtStart = cacheDate
|
||||
let generationAtStart = payloadRefreshGeneration
|
||||
if !force, cache[key]?.isFresh == true { return }
|
||||
guard !inFlightKeys.contains(key) else { return }
|
||||
if inFlightKeys.contains(key) { return }
|
||||
inFlightKeys.insert(key)
|
||||
if cache[key] == nil {
|
||||
isLoading = true
|
||||
attemptedKeys.insert(key)
|
||||
lastErrorByKey[key] = nil
|
||||
let didShowLoading = showLoading || cache[key] == nil
|
||||
if didShowLoading {
|
||||
beginLoading(for: key)
|
||||
}
|
||||
// Diagnostic anchor: if this key has been empty for a long time (the
|
||||
// popover would currently be showing "Loading..."), log how stale the
|
||||
// miss is so the next time a user reports a stuck-loading bug we have
|
||||
// a concrete data point — "no successful fetch for (today, claude)
|
||||
// in 14 minutes" beats squinting at unified-log noise. We deliberately
|
||||
// skip the first-attempt case (no prior success ever, finite check
|
||||
// below filters .infinity) — that's just the cold path, not a bug.
|
||||
let staleSeconds = staleSecondsForKey(key)
|
||||
if staleSeconds.isFinite, staleSeconds > 120 {
|
||||
NSLog("CodeBurn: refresh attempt for stale key \(key.period.rawValue)/\(key.provider.rawValue) — last success was \(Int(staleSeconds))s ago")
|
||||
}
|
||||
defer {
|
||||
inFlightKeys.remove(key)
|
||||
isLoading = false
|
||||
if didShowLoading {
|
||||
finishLoading(for: key)
|
||||
}
|
||||
}
|
||||
do {
|
||||
let fresh = try await DataClient.fetch(period: key.period, provider: key.provider, includeOptimize: includeOptimize)
|
||||
if generationAtStart != payloadRefreshGeneration {
|
||||
NSLog("CodeBurn: dropping fetch result for \(key.period.rawValue)/\(key.provider.rawValue) — refresh pipeline reset mid-fetch")
|
||||
return
|
||||
}
|
||||
if Task.isCancelled {
|
||||
// Distinguish cancellation (user switched tabs mid-fetch) from
|
||||
// the silent-no-result path. Without this log, a cancelled
|
||||
// fetch leaves cache empty + lastError nil and the user sees
|
||||
// perpetual loading with nothing in the diagnostics.
|
||||
NSLog("CodeBurn: fetch for \(key.period.rawValue)/\(key.provider.rawValue) cancelled before result was applied")
|
||||
return
|
||||
}
|
||||
// Day-rollover race guard: if the calendar date changed during the
|
||||
// fetch, this payload was computed against yesterday's date and
|
||||
// would pollute today's freshly-cleared cache. Drop it; the next
|
||||
// tick will refetch with today's data.
|
||||
if cacheDate != cacheDateAtStart || cacheDate != currentCacheDate() {
|
||||
invalidateStaleDayCache()
|
||||
NSLog("CodeBurn: dropping fetch result for \(key.period.rawValue)/\(key.provider.rawValue) — calendar rolled mid-fetch")
|
||||
return
|
||||
}
|
||||
cache[key] = CachedPayload(payload: fresh, fetchedAt: Date())
|
||||
lastError = nil
|
||||
lastSuccessByKey[key] = Date()
|
||||
lastErrorByKey[key] = nil
|
||||
} catch {
|
||||
lastError = String(describing: error)
|
||||
if Task.isCancelled { return }
|
||||
NSLog("CodeBurn: fetch failed for \(key.period.rawValue)/\(key.provider.rawValue): \(error)")
|
||||
if includeOptimize, cache[key] == nil {
|
||||
do {
|
||||
let fallback = try await DataClient.fetch(period: key.period, provider: key.provider, includeOptimize: false)
|
||||
guard !Task.isCancelled else { return }
|
||||
if generationAtStart != payloadRefreshGeneration { return }
|
||||
if cacheDate != cacheDateAtStart || cacheDate != currentCacheDate() {
|
||||
invalidateStaleDayCache()
|
||||
return
|
||||
}
|
||||
cache[key] = CachedPayload(payload: fallback, fetchedAt: Date())
|
||||
lastSuccessByKey[key] = Date()
|
||||
lastErrorByKey[key] = nil
|
||||
return
|
||||
} catch {
|
||||
if Task.isCancelled { return }
|
||||
NSLog("CodeBurn: fallback fetch also failed: \(error)")
|
||||
}
|
||||
}
|
||||
lastErrorByKey[key] = String(describing: error)
|
||||
}
|
||||
|
||||
let allKey = PayloadCacheKey(period: selectedPeriod, provider: .all)
|
||||
|
|
@ -110,37 +369,390 @@ final class AppStore {
|
|||
/// Background refresh for a period other than the visible one (e.g. keeping today fresh for the menubar badge).
|
||||
/// Does not toggle isLoading, so the popover's loading overlay is unaffected.
|
||||
/// Always uses the .all provider since the menubar badge shows total spend.
|
||||
func refreshQuietly(period: Period) async {
|
||||
func refreshQuietly(period: Period, force: Bool = false) async {
|
||||
invalidateStaleDayCache()
|
||||
let key = PayloadCacheKey(period: period, provider: .all)
|
||||
if !force, cache[key]?.isFresh == true { return }
|
||||
if inFlightKeys.contains(key) { return }
|
||||
inFlightKeys.insert(key)
|
||||
attemptedKeys.insert(key)
|
||||
let cacheDateAtStart = cacheDate
|
||||
let generationAtStart = payloadRefreshGeneration
|
||||
if period == .today, let age = todayPayloadAgeSeconds, age > 120 {
|
||||
NSLog("CodeBurn: refreshing stale today status payload after %ds", age)
|
||||
}
|
||||
defer {
|
||||
inFlightKeys.remove(key)
|
||||
}
|
||||
do {
|
||||
let fresh = try await DataClient.fetch(period: period, provider: .all, includeOptimize: true)
|
||||
cache[PayloadCacheKey(period: period, provider: .all)] = CachedPayload(payload: fresh, fetchedAt: Date())
|
||||
let fresh = try await DataClient.fetch(period: period, provider: .all, includeOptimize: false)
|
||||
if generationAtStart != payloadRefreshGeneration {
|
||||
NSLog("CodeBurn: dropping quiet fetch result for \(period.rawValue) — refresh pipeline reset mid-fetch")
|
||||
return
|
||||
}
|
||||
// Same day-rollover guard as refresh(): drop yesterday's payload if
|
||||
// the calendar rolled over during the fetch.
|
||||
if cacheDate != cacheDateAtStart || cacheDate != currentCacheDate() {
|
||||
invalidateStaleDayCache()
|
||||
return
|
||||
}
|
||||
cache[key] = CachedPayload(payload: fresh, fetchedAt: Date())
|
||||
lastSuccessByKey[key] = Date()
|
||||
lastErrorByKey[key] = nil
|
||||
} catch {
|
||||
NSLog("CodeBurn: quiet refresh failed for \(period.rawValue): \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Fetch Claude subscription usage. Sets subscription = nil on missing creds (API users / unauthenticated).
|
||||
/// Triggered lazily when the user opens the Plan pill, so the Keychain prompt only fires on intent.
|
||||
func refreshSubscription() async {
|
||||
subscriptionLoadState = .loading
|
||||
/// User-initiated. Reads Claude's source (this is what triggers the macOS keychain
|
||||
/// prompt for `Claude Code-credentials`). Once successful, subsequent background
|
||||
/// refreshes go through our own keychain item without prompting.
|
||||
func bootstrapSubscription() async {
|
||||
subscriptionLoadState = .bootstrapping
|
||||
do {
|
||||
let usage = try await SubscriptionClient.fetch()
|
||||
let usage = try await ClaudeSubscriptionService.bootstrap()
|
||||
subscription = usage
|
||||
subscriptionError = nil
|
||||
subscriptionLoadState = .loaded
|
||||
await captureSnapshots(for: usage)
|
||||
} catch SubscriptionError.noCredentials {
|
||||
subscription = nil
|
||||
subscriptionError = nil
|
||||
subscriptionLoadState = .noCredentials
|
||||
} catch let err as ClaudeSubscriptionService.FetchError {
|
||||
applyFetchError(err)
|
||||
} catch {
|
||||
subscription = nil
|
||||
subscriptionError = String(describing: error)
|
||||
subscriptionLoadState = .failed
|
||||
NSLog("CodeBurn: subscription fetch failed: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
/// Background refresh. No-op if the user has not yet connected. Never triggers
|
||||
/// a keychain prompt — uses our own keychain item exclusively.
|
||||
func refreshSubscription() async {
|
||||
_ = await refreshSubscriptionReportingSuccess()
|
||||
}
|
||||
|
||||
/// Same as `refreshSubscription` but returns whether the fetch produced a
|
||||
/// `.loaded` state, so the caller can anchor cadence timing on real success
|
||||
/// rather than every attempt.
|
||||
@discardableResult
|
||||
func refreshSubscriptionReportingSuccess() async -> Bool {
|
||||
guard ClaudeCredentialStore.isBootstrapCompleted else {
|
||||
if subscriptionLoadState != .notBootstrapped {
|
||||
subscriptionLoadState = .notBootstrapped
|
||||
}
|
||||
return false
|
||||
}
|
||||
let gen = claudeRefreshGen
|
||||
if subscription == nil { subscriptionLoadState = .loading }
|
||||
do {
|
||||
guard let usage = try await ClaudeSubscriptionService.refreshIfBootstrapped() else {
|
||||
return false
|
||||
}
|
||||
// Disconnect-during-fetch guard: if the user clicked Disconnect
|
||||
// while we were awaiting Anthropic, the generation token will
|
||||
// have advanced and we must drop this result instead of writing
|
||||
// it back over the freshly-cleared state.
|
||||
guard gen == claudeRefreshGen else { return false }
|
||||
subscription = usage
|
||||
subscriptionError = nil
|
||||
subscriptionLoadState = .loaded
|
||||
await captureSnapshots(for: usage)
|
||||
return true
|
||||
} catch let err as ClaudeSubscriptionService.FetchError {
|
||||
guard gen == claudeRefreshGen else { return false }
|
||||
applyFetchError(err)
|
||||
return false
|
||||
} catch {
|
||||
guard gen == claudeRefreshGen else { return false }
|
||||
subscriptionError = sanitizeForUI(String(describing: error))
|
||||
subscriptionLoadState = .failed
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/// User-initiated disconnect — clears our keychain item and bootstrap flag,
|
||||
/// plus all derived state so a reconnect (potentially under a different
|
||||
/// account or tier) starts clean. capacityEstimates and the snapshot store
|
||||
/// would otherwise contaminate "Based on last cycle" projections.
|
||||
func disconnectSubscription() {
|
||||
ClaudeSubscriptionService.disconnect()
|
||||
// Bump the generation token so any in-flight refreshSubscription that
|
||||
// resumes after this point detects the disconnect and discards its
|
||||
// result instead of re-populating the cleared state.
|
||||
claudeRefreshGen &+= 1
|
||||
subscription = nil
|
||||
subscriptionError = nil
|
||||
subscriptionLoadState = .notBootstrapped
|
||||
capacityEstimates = [:]
|
||||
Task.detached { await SubscriptionSnapshotStore.clearAll() }
|
||||
// Notify the AppDelegate to clear its cadence-loop anchor so the next
|
||||
// reconnect doesn't measure against a pre-disconnect timestamp.
|
||||
NotificationCenter.default.post(name: .codeBurnSubscriptionDisconnected, object: nil)
|
||||
}
|
||||
|
||||
// MARK: - Codex
|
||||
|
||||
func bootstrapCodex() async {
|
||||
codexLoadState = .bootstrapping
|
||||
do {
|
||||
let usage = try await CodexSubscriptionService.bootstrap()
|
||||
codexUsage = usage
|
||||
codexError = nil
|
||||
codexLoadState = .loaded
|
||||
} catch let err as CodexSubscriptionService.FetchError {
|
||||
applyCodexFetchError(err)
|
||||
} catch {
|
||||
codexError = sanitizeForUI(String(describing: error))
|
||||
codexLoadState = .failed
|
||||
}
|
||||
}
|
||||
|
||||
func refreshCodex() async {
|
||||
_ = await refreshCodexReportingSuccess()
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
func refreshCodexReportingSuccess() async -> Bool {
|
||||
guard CodexCredentialStore.isBootstrapCompleted else {
|
||||
if codexLoadState != .notBootstrapped { codexLoadState = .notBootstrapped }
|
||||
return false
|
||||
}
|
||||
let gen = codexRefreshGen
|
||||
if codexUsage == nil { codexLoadState = .loading }
|
||||
do {
|
||||
guard let usage = try await CodexSubscriptionService.refreshIfBootstrapped() else {
|
||||
return false
|
||||
}
|
||||
guard gen == codexRefreshGen else { return false }
|
||||
codexUsage = usage
|
||||
codexError = nil
|
||||
codexLoadState = .loaded
|
||||
return true
|
||||
} catch let err as CodexSubscriptionService.FetchError {
|
||||
guard gen == codexRefreshGen else { return false }
|
||||
applyCodexFetchError(err)
|
||||
return false
|
||||
} catch {
|
||||
guard gen == codexRefreshGen else { return false }
|
||||
codexError = sanitizeForUI(String(describing: error))
|
||||
codexLoadState = .failed
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func disconnectCodex() {
|
||||
CodexSubscriptionService.disconnect()
|
||||
codexRefreshGen &+= 1
|
||||
codexUsage = nil
|
||||
codexError = nil
|
||||
codexLoadState = .notBootstrapped
|
||||
NotificationCenter.default.post(name: .codeBurnSubscriptionDisconnected, object: nil)
|
||||
}
|
||||
|
||||
private func applyCodexFetchError(_ err: CodexSubscriptionService.FetchError) {
|
||||
let sanitized = sanitizeForUI(err.errorDescription)
|
||||
codexError = sanitized
|
||||
if err.isTerminal {
|
||||
codexLoadState = .terminalFailure(reason: sanitized)
|
||||
} else if let retryAt = err.rateLimitRetryAt {
|
||||
codexLoadState = .transientFailure(retryAt: retryAt)
|
||||
} else if case .notBootstrapped = err {
|
||||
codexLoadState = .notBootstrapped
|
||||
} else if case let .bootstrapFailed(storeErr) = err, case .bootstrapNoSource = storeErr {
|
||||
codexLoadState = .noCredentials
|
||||
} else {
|
||||
codexLoadState = .failed
|
||||
}
|
||||
}
|
||||
|
||||
private func applyFetchError(_ err: ClaudeSubscriptionService.FetchError) {
|
||||
let sanitized = sanitizeForUI(err.errorDescription)
|
||||
subscriptionError = sanitized
|
||||
if err.isTerminal {
|
||||
subscriptionLoadState = .terminalFailure(reason: sanitized)
|
||||
} else if let retryAt = err.rateLimitRetryAt {
|
||||
subscriptionLoadState = .transientFailure(retryAt: retryAt)
|
||||
} else if case .notBootstrapped = err {
|
||||
subscriptionLoadState = .notBootstrapped
|
||||
} else if case let .bootstrapFailed(storeErr) = err, case .bootstrapNoSource = storeErr {
|
||||
subscriptionLoadState = .noCredentials
|
||||
} else {
|
||||
subscriptionLoadState = .failed
|
||||
}
|
||||
}
|
||||
|
||||
/// Strip control characters and any token-shaped substrings from server-error
|
||||
/// strings before they land in NSLog or the UI. Anthropic / OpenAI error
|
||||
/// envelopes don't typically echo tokens, but we also surface this in
|
||||
/// unified-log paths readable by other local users via `log stream`.
|
||||
private func sanitizeForUI(_ s: String?) -> String? {
|
||||
guard let s, !s.isEmpty else { return nil }
|
||||
var cleaned = s.replacingOccurrences(of: "\u{0000}", with: "")
|
||||
// Token-shaped redaction. Apply to all known auth-token formats so
|
||||
// an error body that quotes the request/response token is masked.
|
||||
let patterns: [(pattern: String, replacement: String)] = [
|
||||
(#"sk-ant-[A-Za-z0-9_-]+"#, "sk-ant-***"),
|
||||
(#"sk-[A-Za-z0-9_-]{16,}"#, "sk-***"),
|
||||
(#"eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+"#, "eyJ***"),
|
||||
(#"(?i)Bearer\s+\S+"#, "Bearer ***"),
|
||||
]
|
||||
for entry in patterns {
|
||||
cleaned = cleaned.replacingOccurrences(of: entry.pattern, with: entry.replacement, options: .regularExpression)
|
||||
}
|
||||
// Cap length so a runaway server body cannot fill stderr.
|
||||
if cleaned.count > 240 { cleaned = String(cleaned.prefix(240)) + "…" }
|
||||
return cleaned
|
||||
}
|
||||
|
||||
/// Snapshot of live quota state for a given provider. Returns nil when the user
|
||||
/// has not connected yet — the bar slot stays empty so we never trigger a
|
||||
/// keychain prompt at startup. Once bootstrapped, the bar persists across all
|
||||
/// subsequent states (loading / stale / transient failure / terminal failure)
|
||||
/// so it doesn't flicker on every refresh tick.
|
||||
/// Aggregate quota status across all connected providers, used by the menu
|
||||
/// bar flame icon (color) and the popover warning row. Severity = worst
|
||||
/// observed across any provider's worst window. Warning providers are
|
||||
/// every connected provider at >= 70% utilization.
|
||||
struct AggregateQuotaStatus {
|
||||
let severity: QuotaSummary.Severity
|
||||
let warnings: [(name: String, percent: Double)] // sorted desc by percent
|
||||
}
|
||||
|
||||
var aggregateQuotaStatus: AggregateQuotaStatus {
|
||||
var providers: [(name: String, percent: Double)] = []
|
||||
if let usage = subscription, shouldIncludeCachedQuota(loadState: subscriptionLoadState) {
|
||||
let worst = [
|
||||
usage.fiveHourPercent,
|
||||
usage.sevenDayPercent,
|
||||
usage.sevenDayOpusPercent,
|
||||
usage.sevenDaySonnetPercent,
|
||||
].compactMap { $0 }.max() ?? 0
|
||||
if worst > 0 { providers.append(("Claude", worst)) }
|
||||
}
|
||||
if let usage = codexUsage, shouldIncludeCachedQuota(loadState: codexLoadState) {
|
||||
let worst = max(usage.primary?.usedPercent ?? 0, usage.secondary?.usedPercent ?? 0)
|
||||
if worst > 0 { providers.append(("Codex", worst)) }
|
||||
}
|
||||
let worst = providers.map(\.percent).max() ?? 0
|
||||
let severity = QuotaSummary.severity(for: worst / 100)
|
||||
let sorted = providers.sorted { $0.percent > $1.percent }
|
||||
let warnings = sorted.filter { $0.percent >= 70 }
|
||||
return AggregateQuotaStatus(severity: severity, warnings: warnings)
|
||||
}
|
||||
|
||||
private func shouldIncludeCachedQuota(loadState: SubscriptionLoadState) -> Bool {
|
||||
switch loadState {
|
||||
case .notBootstrapped, .bootstrapping, .noCredentials:
|
||||
return false
|
||||
case .loading, .loaded, .failed, .terminalFailure, .transientFailure:
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
func quotaSummary(for filter: ProviderFilter) -> QuotaSummary? {
|
||||
switch filter {
|
||||
case .claude: return claudeQuotaSummary(filter: filter)
|
||||
case .codex: return codexQuotaSummary(filter: filter)
|
||||
default: return nil
|
||||
}
|
||||
}
|
||||
|
||||
private func claudeQuotaSummary(filter: ProviderFilter) -> QuotaSummary? {
|
||||
if case .notBootstrapped = subscriptionLoadState { return nil }
|
||||
if case .bootstrapping = subscriptionLoadState { return nil }
|
||||
if case .noCredentials = subscriptionLoadState { return nil }
|
||||
|
||||
let connection: QuotaSummary.Connection = {
|
||||
switch subscriptionLoadState {
|
||||
case .notBootstrapped, .bootstrapping, .noCredentials: return .disconnected
|
||||
case .loading: return subscription == nil ? .loading : .stale
|
||||
case .loaded: return .connected
|
||||
case .failed: return subscription == nil ? .loading : .stale
|
||||
case let .terminalFailure(reason): return .terminalFailure(reason: reason)
|
||||
case .transientFailure: return .transientFailure
|
||||
}
|
||||
}()
|
||||
|
||||
var primary: QuotaSummary.Window?
|
||||
var details: [QuotaSummary.Window] = []
|
||||
if let usage = subscription {
|
||||
if let pct = usage.fiveHourPercent {
|
||||
details.append(.init(label: "5-hour", percent: pct / 100, resetsAt: usage.fiveHourResetsAt))
|
||||
}
|
||||
if let pct = usage.sevenDayPercent {
|
||||
let weekly = QuotaSummary.Window(label: "Weekly", percent: pct / 100, resetsAt: usage.sevenDayResetsAt)
|
||||
primary = weekly
|
||||
details.append(weekly)
|
||||
}
|
||||
if let pct = usage.sevenDayOpusPercent {
|
||||
details.append(.init(label: "Weekly · Opus", percent: pct / 100, resetsAt: usage.sevenDayOpusResetsAt))
|
||||
}
|
||||
if let pct = usage.sevenDaySonnetPercent {
|
||||
details.append(.init(label: "Weekly · Sonnet", percent: pct / 100, resetsAt: usage.sevenDaySonnetResetsAt))
|
||||
}
|
||||
}
|
||||
let plan = subscription?.tier.displayName
|
||||
return QuotaSummary(providerFilter: filter, connection: connection, primary: primary, details: details, planLabel: plan, footerLines: [])
|
||||
}
|
||||
|
||||
private func codexQuotaSummary(filter: ProviderFilter) -> QuotaSummary? {
|
||||
if case .notBootstrapped = codexLoadState { return nil }
|
||||
if case .bootstrapping = codexLoadState { return nil }
|
||||
if case .noCredentials = codexLoadState { return nil }
|
||||
|
||||
let connection: QuotaSummary.Connection = {
|
||||
switch codexLoadState {
|
||||
case .notBootstrapped, .bootstrapping, .noCredentials: return .disconnected
|
||||
case .loading: return codexUsage == nil ? .loading : .stale
|
||||
case .loaded: return .connected
|
||||
case .failed: return codexUsage == nil ? .loading : .stale
|
||||
case let .terminalFailure(reason): return .terminalFailure(reason: reason)
|
||||
case .transientFailure: return .transientFailure
|
||||
}
|
||||
}()
|
||||
|
||||
var primary: QuotaSummary.Window?
|
||||
var details: [QuotaSummary.Window] = []
|
||||
if let usage = codexUsage {
|
||||
if let w = usage.primary {
|
||||
let row = QuotaSummary.Window(label: w.windowLabel, percent: w.usedPercent / 100, resetsAt: w.resetsAt)
|
||||
primary = row
|
||||
details.append(row)
|
||||
}
|
||||
if let w = usage.secondary {
|
||||
let row = QuotaSummary.Window(label: w.windowLabel, percent: w.usedPercent / 100, resetsAt: w.resetsAt)
|
||||
// Some Codex plans (free / guest tiers) only return a secondary
|
||||
// window. Promote it to primary so the chip bar always has a
|
||||
// data source instead of rendering as an empty track.
|
||||
if primary == nil { primary = row }
|
||||
details.append(row)
|
||||
}
|
||||
// Surface per-model additional rate limits (e.g. "GPT-5.3-Codex-Spark")
|
||||
// only when the user has actually hit them. Skipping zero rows keeps
|
||||
// the popover compact for the common case where the user only uses
|
||||
// the main Codex window.
|
||||
for extra in usage.additionalLimits {
|
||||
if let p = extra.primary, p.usedPercent > 0 {
|
||||
details.append(.init(label: "\(extra.name) · \(p.windowLabel)", percent: p.usedPercent / 100, resetsAt: p.resetsAt))
|
||||
}
|
||||
if let s = extra.secondary, s.usedPercent > 0 {
|
||||
details.append(.init(label: "\(extra.name) · \(s.windowLabel)", percent: s.usedPercent / 100, resetsAt: s.resetsAt))
|
||||
}
|
||||
}
|
||||
}
|
||||
let plan = codexUsage?.plan.displayName
|
||||
var footerLines: [String] = []
|
||||
if let balance = codexUsage?.creditsBalance, balance > 0 {
|
||||
// Format as plain dollars; ChatGPT settles in USD regardless of
|
||||
// the user's display-currency preference.
|
||||
let formatter = NumberFormatter()
|
||||
formatter.numberStyle = .currency
|
||||
formatter.currencyCode = "USD"
|
||||
formatter.maximumFractionDigits = 2
|
||||
let formatted = formatter.string(from: NSNumber(value: balance)) ?? "$\(balance)"
|
||||
footerLines.append("Credits remaining · \(formatted)")
|
||||
}
|
||||
return QuotaSummary(providerFilter: filter, connection: connection, primary: primary, details: details, planLabel: plan, footerLines: footerLines)
|
||||
}
|
||||
|
||||
/// Persist one snapshot per window so we can answer "what did the prior cycle end at?"
|
||||
/// when the current window has just reset and projection from current data isn't meaningful.
|
||||
/// Also computes the effective_tokens consumed inside each 7-day window from local history,
|
||||
|
|
@ -174,7 +786,10 @@ final class AppStore {
|
|||
/// last 7 days of dailyHistory. Used as the "tokens consumed in 7-day window" reading paired
|
||||
/// with the API-reported percent for capacity estimation.
|
||||
private func effectiveTokensInLast7Days(history: [DailyHistoryEntry], asOf now: Date) -> Double {
|
||||
let cutoff = ISO8601DateFormatter().string(from: now.addingTimeInterval(-7 * 86400)).prefix(10)
|
||||
let f = DateFormatter()
|
||||
f.dateFormat = "yyyy-MM-dd"
|
||||
f.timeZone = .current
|
||||
let cutoff = f.string(from: now.addingTimeInterval(-7 * 86400))
|
||||
return history
|
||||
.filter { $0.date >= cutoff }
|
||||
.reduce(0.0) { $0 + $1.effectiveTokens }
|
||||
|
|
@ -227,12 +842,16 @@ enum SupportedCurrency: String, CaseIterable, Identifiable {
|
|||
enum ProviderFilter: String, CaseIterable, Identifiable {
|
||||
case all = "All"
|
||||
case claude = "Claude"
|
||||
case cline = "Cline"
|
||||
case codex = "Codex"
|
||||
case cursor = "Cursor"
|
||||
case cursorAgent = "Cursor Agent"
|
||||
case copilot = "Copilot"
|
||||
case droid = "Droid"
|
||||
case gemini = "Gemini"
|
||||
case ibmBob = "IBM Bob"
|
||||
case kiro = "Kiro"
|
||||
case kimi = "Kimi"
|
||||
case kiloCode = "KiloCode"
|
||||
case openclaw = "OpenClaw"
|
||||
case opencode = "OpenCode"
|
||||
|
|
@ -240,15 +859,23 @@ enum ProviderFilter: String, CaseIterable, Identifiable {
|
|||
case qwen = "Qwen"
|
||||
case omp = "OMP"
|
||||
case rooCode = "Roo Code"
|
||||
case crush = "Crush"
|
||||
case antigravity = "Antigravity"
|
||||
case goose = "Goose"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
var providerKeys: [String] {
|
||||
switch self {
|
||||
case .cursor: ["cursor", "cursor agent"]
|
||||
case .cursor: ["cursor"]
|
||||
case .cursorAgent: ["cursor-agent", "cursor agent"]
|
||||
case .cline: ["cline"]
|
||||
case .rooCode: ["roo-code", "roo code"]
|
||||
case .kiloCode: ["kilo-code", "kilocode"]
|
||||
case .ibmBob: ["ibm-bob", "ibm bob"]
|
||||
case .openclaw: ["openclaw"]
|
||||
case .antigravity: ["antigravity"]
|
||||
case .goose: ["goose"]
|
||||
default: [rawValue.lowercased()]
|
||||
}
|
||||
}
|
||||
|
|
@ -257,29 +884,43 @@ enum ProviderFilter: String, CaseIterable, Identifiable {
|
|||
switch self {
|
||||
case .all: "all"
|
||||
case .claude: "claude"
|
||||
case .cline: "cline"
|
||||
case .codex: "codex"
|
||||
case .cursor: "cursor"
|
||||
case .cursorAgent: "cursor-agent"
|
||||
case .copilot: "copilot"
|
||||
case .droid: "droid"
|
||||
case .gemini: "gemini"
|
||||
case .ibmBob: "ibm-bob"
|
||||
case .kiloCode: "kilo-code"
|
||||
case .kiro: "kiro"
|
||||
case .kimi: "kimi"
|
||||
case .openclaw: "openclaw"
|
||||
case .opencode: "opencode"
|
||||
case .pi: "pi"
|
||||
case .qwen: "qwen"
|
||||
case .omp: "omp"
|
||||
case .rooCode: "roo-code"
|
||||
case .crush: "crush"
|
||||
case .antigravity: "antigravity"
|
||||
case .goose: "goose"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
extension Notification.Name {
|
||||
static let codeBurnSubscriptionDisconnected = Notification.Name("com.codeburn.subscriptionDisconnected")
|
||||
}
|
||||
|
||||
enum SubscriptionLoadState: Sendable, Equatable {
|
||||
case idle // never tried, awaiting user intent
|
||||
case loading // fetch in progress
|
||||
case loaded // success; subscription is populated
|
||||
case noCredentials // tried; user has no Claude OAuth (API user / not logged in)
|
||||
case failed // tried; error occurred
|
||||
case notBootstrapped // no Keychain access yet — waiting for user to click Connect
|
||||
case bootstrapping // user clicked Connect; reading Claude's keychain (PROMPTS)
|
||||
case loading // background fetch in progress (subscription may already be populated)
|
||||
case loaded // success; subscription is populated
|
||||
case noCredentials // bootstrap tried; user has no Claude credentials at all
|
||||
case failed // generic non-recoverable failure
|
||||
case terminalFailure(reason: String?) // refresh-token invalid; user must reconnect
|
||||
case transientFailure(retryAt: Date?) // 429 / network blip; backing off automatically
|
||||
}
|
||||
|
||||
enum InsightMode: String, CaseIterable, Identifiable {
|
||||
|
|
@ -296,7 +937,7 @@ enum Period: String, CaseIterable, Identifiable {
|
|||
case sevenDays = "7 Days"
|
||||
case thirtyDays = "30 Days"
|
||||
case month = "Month"
|
||||
case all = "All"
|
||||
case all = "6 Months"
|
||||
|
||||
var id: String { rawValue }
|
||||
|
||||
|
|
@ -333,7 +974,7 @@ private let thousandsFormatter: NumberFormatter = {
|
|||
return f
|
||||
}()
|
||||
|
||||
extension Double {
|
||||
@MainActor extension Double {
|
||||
func asCurrency() -> String {
|
||||
let state = CurrencyState.shared
|
||||
let converted = self * state.rate
|
||||
|
|
|
|||
43
mac/Sources/CodeBurnMenubar/AppVersion.swift
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import Foundation
|
||||
|
||||
enum AppVersion {
|
||||
static var bundleShortVersion: String {
|
||||
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
|
||||
}
|
||||
|
||||
static var bundleBuildVersion: String {
|
||||
Bundle.main.infoDictionary?["CFBundleVersion"] as? String ?? ""
|
||||
}
|
||||
|
||||
static var normalizedBundleShortVersion: String {
|
||||
normalize(bundleShortVersion)
|
||||
}
|
||||
|
||||
static var normalizedBundleBuildVersion: String {
|
||||
normalize(bundleBuildVersion)
|
||||
}
|
||||
|
||||
static var displayBundleShortVersion: String {
|
||||
display(bundleShortVersion)
|
||||
}
|
||||
|
||||
static func normalize(_ version: String) -> String {
|
||||
let trimmed = version.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.lowercased().hasPrefix("mac-v") {
|
||||
return String(trimmed.dropFirst(5))
|
||||
}
|
||||
if trimmed.lowercased().hasPrefix("v") {
|
||||
return String(trimmed.dropFirst())
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
static func display(_ version: String) -> String {
|
||||
let normalized = normalize(version)
|
||||
guard !normalized.isEmpty else { return "v?" }
|
||||
if normalized == "?" || normalized == "dev" || normalized == "dev-preview" || normalized == "—" {
|
||||
return normalized
|
||||
}
|
||||
return "v\(normalized)"
|
||||
}
|
||||
}
|
||||
|
|
@ -3,8 +3,11 @@ import AppKit
|
|||
import Observation
|
||||
|
||||
private let refreshIntervalSeconds: UInt64 = 30
|
||||
private let nanosPerSecond: UInt64 = 1_000_000_000
|
||||
private let refreshIntervalNanos: UInt64 = refreshIntervalSeconds * nanosPerSecond
|
||||
private let forceRefreshWatchdogSeconds: TimeInterval = 90
|
||||
private let refreshLoopWatchdogSeconds: TimeInterval = 90
|
||||
private let statusPayloadRefreshWatchdogSeconds: TimeInterval = 60
|
||||
private let refreshRateLimitSeconds: TimeInterval = 5
|
||||
private let interactiveQuotaRefreshFloorSeconds: TimeInterval = 30
|
||||
private let statusItemWidth: CGFloat = NSStatusItem.variableLength
|
||||
private let popoverWidth: CGFloat = 360
|
||||
private let popoverHeight: CGFloat = 660
|
||||
|
|
@ -15,9 +18,12 @@ struct CodeBurnApp: App {
|
|||
@NSApplicationDelegateAdaptor(AppDelegate.self) var delegate
|
||||
|
||||
var body: some Scene {
|
||||
// SwiftUI App needs at least one scene. Settings is invisible by default.
|
||||
// The Settings scene gives us a real macOS Settings window with the
|
||||
// standard ⌘, shortcut and the menubar "Settings…" item. Provider tabs
|
||||
// (Claude today, Codex/Cursor/etc. in follow-ups) live inside SettingsView.
|
||||
Settings {
|
||||
EmptyView()
|
||||
SettingsView()
|
||||
.environment(delegate.store)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -26,49 +32,92 @@ struct CodeBurnApp: App {
|
|||
final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
|
||||
private var statusItem: NSStatusItem!
|
||||
private var popover: NSPopover!
|
||||
private let store = AppStore()
|
||||
fileprivate let store = AppStore()
|
||||
let updateChecker = UpdateChecker()
|
||||
/// Held for the lifetime of the app to opt out of App Nap and Automatic Termination.
|
||||
private var backgroundActivity: NSObjectProtocol?
|
||||
private var pendingRefreshWork: DispatchWorkItem?
|
||||
private var refreshTimer: DispatchSourceTimer?
|
||||
private var forceRefreshTask: Task<Void, Never>?
|
||||
private var forceRefreshStartedAt: Date?
|
||||
private var forceRefreshGeneration: UInt64 = 0
|
||||
private var statusPayloadRefreshTask: Task<Void, Never>?
|
||||
private var statusPayloadRefreshStartedAt: Date?
|
||||
private var statusPayloadRefreshGeneration: UInt64 = 0
|
||||
private var manualRefreshTask: Task<Void, Never>?
|
||||
private var manualRefreshGeneration: UInt64 = 0
|
||||
private var claudeQuotaRefreshTask: Task<Bool, Never>?
|
||||
private var codexQuotaRefreshTask: Task<Bool, Never>?
|
||||
private var refreshLoopHeartbeatAt: Date = .distantPast
|
||||
private var lastLaunchAgentHeartbeatAt: Date = .distantPast
|
||||
|
||||
func applicationWillFinishLaunching(_ notification: Notification) {
|
||||
// Set accessory policy before the app's focus chain forms. On macOS Tahoe
|
||||
// (26.x), setting it after didFinishLaunching causes ghost status items
|
||||
// because the policy gets baked into the initial focus chain.
|
||||
NSApp.setActivationPolicy(.accessory)
|
||||
}
|
||||
|
||||
private func observeSubscriptionDisconnect() {
|
||||
NotificationCenter.default.addObserver(
|
||||
forName: .codeBurnSubscriptionDisconnected,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.resetSubscriptionCadenceAnchor()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func applicationDidFinishLaunching(_ notification: Notification) {
|
||||
// On macOS Tahoe (26.x), accessory apps may fail to render their status item
|
||||
// if the activation policy is set before the status item is created. Starting
|
||||
// as a regular app and switching to accessory after setup works around this.
|
||||
NSApp.setActivationPolicy(.regular)
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
|
||||
ProcessInfo.processInfo.automaticTerminationSupportEnabled = false
|
||||
ProcessInfo.processInfo.disableSuddenTermination()
|
||||
backgroundActivity = ProcessInfo.processInfo.beginActivity(
|
||||
options: [.userInitiated, .automaticTerminationDisabled, .suddenTerminationDisabled],
|
||||
reason: "CodeBurn menubar polls AI coding cost every 15 seconds while idle in the background."
|
||||
options: [.automaticTerminationDisabled, .suddenTerminationDisabled],
|
||||
reason: "CodeBurn menubar background refresh"
|
||||
)
|
||||
|
||||
restorePersistedCurrency()
|
||||
setupStatusItem()
|
||||
setupPopover()
|
||||
|
||||
// Switch to accessory policy after status item is set up to hide from Dock
|
||||
DispatchQueue.main.async {
|
||||
NSApp.setActivationPolicy(.accessory)
|
||||
}
|
||||
observeStore()
|
||||
startRefreshLoop()
|
||||
setupWakeObservers()
|
||||
setupDistributedNotificationListener()
|
||||
installLaunchAgentIfNeeded()
|
||||
registerLoginItemIfNeeded()
|
||||
observeSubscriptionDisconnect()
|
||||
Task { await updateChecker.checkIfNeeded() }
|
||||
}
|
||||
|
||||
private func setupWakeObservers() {
|
||||
// Pause the refresh loop while the machine is asleep. Without this,
|
||||
// Task.sleep keeps a wakeup pending across the suspension and the
|
||||
// loop tick fires the same instant the wake notifications do,
|
||||
// producing 2-3 concurrent CLI spawns within ms of every wake.
|
||||
NSWorkspace.shared.notificationCenter.addObserver(
|
||||
forName: NSWorkspace.willSleepNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
self?.prepareRefreshPipelineForSleep()
|
||||
}
|
||||
}
|
||||
|
||||
// didWakeNotification + screensDidWakeNotification can both fire on
|
||||
// the same wake. forceRefreshTask squashes overlap; both notifications
|
||||
// still bypass the short manual-click rate limit so a just-before-sleep
|
||||
// refresh cannot block wake recovery.
|
||||
NSWorkspace.shared.notificationCenter.addObserver(
|
||||
forName: NSWorkspace.didWakeNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
Task { @MainActor in self?.forceRefresh() }
|
||||
Task { @MainActor in
|
||||
self?.recoverRefreshPipelineAfterInterruption(resetLoading: true, reason: "wake")
|
||||
}
|
||||
}
|
||||
|
||||
NSWorkspace.shared.notificationCenter.addObserver(
|
||||
|
|
@ -76,7 +125,9 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
|
|||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
Task { @MainActor in self?.forceRefresh() }
|
||||
Task { @MainActor in
|
||||
self?.recoverRefreshPipelineAfterInterruption(resetLoading: true, reason: "screen wake")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -86,7 +137,73 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
|
|||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
Task { @MainActor in self?.forceRefresh() }
|
||||
Task { @MainActor in
|
||||
self?.handleLaunchAgentHeartbeat()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func handleLaunchAgentHeartbeat() {
|
||||
let now = Date()
|
||||
guard now.timeIntervalSince(lastLaunchAgentHeartbeatAt) >= refreshRateLimitSeconds else { return }
|
||||
lastLaunchAgentHeartbeatAt = now
|
||||
let loopAge = now.timeIntervalSince(refreshLoopHeartbeatAt)
|
||||
guard refreshTimer == nil || loopAge > refreshLoopWatchdogSeconds else {
|
||||
_ = store.clearStaleLoadingIfNeeded()
|
||||
_ = clearStaleForceRefreshIfNeeded(now: now)
|
||||
_ = clearStaleStatusPayloadRefreshIfNeeded(now: now)
|
||||
return
|
||||
}
|
||||
if refreshTimer != nil {
|
||||
NSLog("CodeBurn: refresh loop stale for %ds after launch agent - restarting", Int(loopAge))
|
||||
}
|
||||
startRefreshLoop(forceQuotaOnStart: false)
|
||||
}
|
||||
|
||||
private func prepareRefreshPipelineForSleep() {
|
||||
forceRefreshTask?.cancel()
|
||||
forceRefreshTask = nil
|
||||
forceRefreshStartedAt = nil
|
||||
forceRefreshGeneration &+= 1
|
||||
manualRefreshTask?.cancel()
|
||||
manualRefreshTask = nil
|
||||
manualRefreshGeneration &+= 1
|
||||
statusPayloadRefreshTask?.cancel()
|
||||
statusPayloadRefreshTask = nil
|
||||
statusPayloadRefreshStartedAt = nil
|
||||
statusPayloadRefreshGeneration &+= 1
|
||||
store.resetLoadingState()
|
||||
stopRefreshTimer()
|
||||
refreshLoopHeartbeatAt = .distantPast
|
||||
lastRefreshTime = .distantPast
|
||||
}
|
||||
|
||||
private func recoverRefreshPipelineAfterInterruption(resetLoading: Bool, clearCache: Bool = false, reason: String) {
|
||||
if resetLoading {
|
||||
forceRefreshTask?.cancel()
|
||||
forceRefreshTask = nil
|
||||
forceRefreshStartedAt = nil
|
||||
forceRefreshGeneration &+= 1
|
||||
manualRefreshTask?.cancel()
|
||||
manualRefreshTask = nil
|
||||
manualRefreshGeneration &+= 1
|
||||
statusPayloadRefreshTask?.cancel()
|
||||
statusPayloadRefreshTask = nil
|
||||
statusPayloadRefreshStartedAt = nil
|
||||
statusPayloadRefreshGeneration &+= 1
|
||||
store.resetRefreshState(clearCache: clearCache)
|
||||
} else {
|
||||
_ = store.clearStaleLoadingIfNeeded()
|
||||
}
|
||||
let now = Date()
|
||||
let loopAge = now.timeIntervalSince(refreshLoopHeartbeatAt)
|
||||
if refreshTimer == nil || loopAge > refreshLoopWatchdogSeconds {
|
||||
if refreshTimer != nil {
|
||||
NSLog("CodeBurn: refresh loop stale for %ds after %@ - restarting", Int(loopAge), reason)
|
||||
}
|
||||
startRefreshLoop(forceQuotaOnStart: false)
|
||||
} else {
|
||||
runRefreshLoopTick(reason: reason, forcePayload: true, forceQuota: false)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -147,7 +264,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
|
|||
guard !UserDefaults.standard.bool(forKey: key) else { return }
|
||||
|
||||
let appPath = Bundle.main.bundlePath
|
||||
let script = "tell application \"System Events\" to make login item at end with properties {path:\"\(appPath)\", hidden:false}"
|
||||
let script = "tell application \"System Events\" to make login item at end with properties {path:\(appleScriptStringLiteral(appPath)), hidden:false}"
|
||||
|
||||
let process = Process()
|
||||
process.launchPath = "/usr/bin/osascript"
|
||||
|
|
@ -166,16 +283,115 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
|
|||
}
|
||||
}
|
||||
|
||||
private func appleScriptStringLiteral(_ value: String) -> String {
|
||||
var escaped = value.replacingOccurrences(of: "\\", with: "\\\\")
|
||||
escaped = escaped.replacingOccurrences(of: "\"", with: "\\\"")
|
||||
escaped = escaped.replacingOccurrences(of: "\r", with: "")
|
||||
escaped = escaped.replacingOccurrences(of: "\n", with: "")
|
||||
return "\"\(escaped)\""
|
||||
}
|
||||
|
||||
private var lastRefreshTime: Date = .distantPast
|
||||
|
||||
private func forceRefresh() {
|
||||
let now = Date()
|
||||
guard now.timeIntervalSince(lastRefreshTime) > 5 else { return }
|
||||
lastRefreshTime = now
|
||||
@discardableResult
|
||||
private func clearStaleForceRefreshIfNeeded(now: Date = Date()) -> Bool {
|
||||
if forceRefreshTask != nil {
|
||||
guard let started = forceRefreshStartedAt else {
|
||||
NSLog("CodeBurn: force refresh task had no start timestamp - clearing")
|
||||
forceRefreshTask?.cancel()
|
||||
forceRefreshTask = nil
|
||||
forceRefreshGeneration &+= 1
|
||||
store.resetLoadingState()
|
||||
return true
|
||||
}
|
||||
let elapsed = now.timeIntervalSince(started)
|
||||
guard elapsed > forceRefreshWatchdogSeconds else { return false }
|
||||
NSLog("CodeBurn: force refresh stuck for %ds - cancelling and restarting", Int(elapsed))
|
||||
forceRefreshTask?.cancel()
|
||||
forceRefreshTask = nil
|
||||
forceRefreshStartedAt = nil
|
||||
forceRefreshGeneration &+= 1
|
||||
store.resetLoadingState()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
Task {
|
||||
await store.refresh(includeOptimize: true, force: true)
|
||||
@discardableResult
|
||||
private func clearStaleStatusPayloadRefreshIfNeeded(now: Date = Date()) -> Bool {
|
||||
if statusPayloadRefreshTask != nil {
|
||||
guard let started = statusPayloadRefreshStartedAt else {
|
||||
NSLog("CodeBurn: today status refresh task had no start timestamp - clearing")
|
||||
statusPayloadRefreshTask?.cancel()
|
||||
statusPayloadRefreshTask = nil
|
||||
statusPayloadRefreshGeneration &+= 1
|
||||
return true
|
||||
}
|
||||
let elapsed = now.timeIntervalSince(started)
|
||||
guard elapsed > statusPayloadRefreshWatchdogSeconds else { return false }
|
||||
NSLog("CodeBurn: today status refresh stuck for %ds - cancelling", Int(elapsed))
|
||||
statusPayloadRefreshTask?.cancel()
|
||||
statusPayloadRefreshTask = nil
|
||||
statusPayloadRefreshStartedAt = nil
|
||||
statusPayloadRefreshGeneration &+= 1
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private func refreshTodayStatusPayloadIfNeeded(reason: String, force: Bool = false) {
|
||||
let now = Date()
|
||||
_ = clearStaleStatusPayloadRefreshIfNeeded(now: now)
|
||||
guard statusPayloadRefreshTask == nil else { return }
|
||||
guard force || store.needsStatusPayloadRefresh else { return }
|
||||
|
||||
if let age = store.todayPayloadAgeSeconds, age > 120 {
|
||||
NSLog("CodeBurn: today status payload stale for %ds on %@ refresh", age, reason)
|
||||
}
|
||||
|
||||
statusPayloadRefreshStartedAt = now
|
||||
statusPayloadRefreshGeneration &+= 1
|
||||
let generation = statusPayloadRefreshGeneration
|
||||
statusPayloadRefreshTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
await self.store.refreshQuietly(period: .today, force: true)
|
||||
self.refreshStatusButton()
|
||||
guard self.statusPayloadRefreshGeneration == generation, !Task.isCancelled else { return }
|
||||
self.statusPayloadRefreshTask = nil
|
||||
self.statusPayloadRefreshStartedAt = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func forceRefresh(bypassRateLimit: Bool = false, forceQuota: Bool = false) {
|
||||
let now = Date()
|
||||
_ = clearStaleForceRefreshIfNeeded(now: now)
|
||||
if forceRefreshTask != nil {
|
||||
refreshTodayStatusPayloadIfNeeded(reason: "blocked force refresh")
|
||||
}
|
||||
guard forceRefreshTask == nil else { return }
|
||||
if !bypassRateLimit {
|
||||
guard now.timeIntervalSince(lastRefreshTime) > refreshRateLimitSeconds else { return }
|
||||
}
|
||||
lastRefreshTime = now
|
||||
forceRefreshStartedAt = now
|
||||
forceRefreshGeneration &+= 1
|
||||
let generation = forceRefreshGeneration
|
||||
|
||||
forceRefreshTask = Task {
|
||||
async let main: Void = store.refresh(includeOptimize: false, force: true, showLoading: true)
|
||||
async let quotas: Bool = refreshLiveQuotaProgressIfDue(force: forceQuota)
|
||||
if store.selectedPeriod != .today || store.selectedProvider != .all {
|
||||
await store.refreshQuietly(period: .today)
|
||||
}
|
||||
_ = await main
|
||||
refreshStatusButton()
|
||||
await MainActor.run { [weak self] in
|
||||
guard let self, self.forceRefreshGeneration == generation else { return }
|
||||
self.forceRefreshTask = nil
|
||||
self.forceRefreshStartedAt = nil
|
||||
self.lastRefreshTime = Date()
|
||||
}
|
||||
_ = await quotas
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -201,21 +417,229 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
|
|||
}
|
||||
}
|
||||
|
||||
private func startRefreshLoop() {
|
||||
Task {
|
||||
await store.refresh(includeOptimize: true)
|
||||
refreshStatusButton()
|
||||
fileprivate var lastSubscriptionRefreshAt: Date?
|
||||
fileprivate var lastCodexRefreshAt: Date?
|
||||
|
||||
@discardableResult
|
||||
private func refreshLiveQuotaProgressIfDue(force: Bool = false) async -> Bool {
|
||||
let cadence = SubscriptionRefreshCadence.current
|
||||
if !force && cadence == .manual { return false }
|
||||
|
||||
let now = Date()
|
||||
let threshold = force ? 0 : TimeInterval(cadence.rawValue)
|
||||
let shouldRefreshClaude = force || now.timeIntervalSince(lastSubscriptionRefreshAt ?? .distantPast) >= threshold
|
||||
let shouldRefreshCodex = force || now.timeIntervalSince(lastCodexRefreshAt ?? .distantPast) >= threshold
|
||||
guard shouldRefreshClaude || shouldRefreshCodex else { return false }
|
||||
|
||||
switch (shouldRefreshClaude, shouldRefreshCodex) {
|
||||
case (true, true):
|
||||
async let claude = refreshClaudeQuotaSingleFlight()
|
||||
async let codex = refreshCodexQuotaSingleFlight()
|
||||
if await claude { lastSubscriptionRefreshAt = Date() }
|
||||
if await codex { lastCodexRefreshAt = Date() }
|
||||
case (true, false):
|
||||
if await refreshClaudeQuotaSingleFlight() {
|
||||
lastSubscriptionRefreshAt = Date()
|
||||
}
|
||||
case (false, true):
|
||||
if await refreshCodexQuotaSingleFlight() {
|
||||
lastCodexRefreshAt = Date()
|
||||
}
|
||||
case (false, false):
|
||||
break
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private func refreshClaudeQuotaSingleFlight() async -> Bool {
|
||||
if let task = claudeQuotaRefreshTask {
|
||||
return await task.value
|
||||
}
|
||||
let task = Task { [store] in
|
||||
await store.refreshSubscriptionReportingSuccess()
|
||||
}
|
||||
claudeQuotaRefreshTask = task
|
||||
let result = await task.value
|
||||
if claudeQuotaRefreshTask != nil {
|
||||
claudeQuotaRefreshTask = nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func refreshCodexQuotaSingleFlight() async -> Bool {
|
||||
if let task = codexQuotaRefreshTask {
|
||||
return await task.value
|
||||
}
|
||||
let task = Task { [store] in
|
||||
await store.refreshCodexReportingSuccess()
|
||||
}
|
||||
codexQuotaRefreshTask = task
|
||||
let result = await task.value
|
||||
if codexQuotaRefreshTask != nil {
|
||||
codexQuotaRefreshTask = nil
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
private func refreshLiveQuotaProgressForPopoverOpen() {
|
||||
let now = Date()
|
||||
let claudeElapsed = now.timeIntervalSince(lastSubscriptionRefreshAt ?? .distantPast)
|
||||
let codexElapsed = now.timeIntervalSince(lastCodexRefreshAt ?? .distantPast)
|
||||
guard claudeElapsed >= interactiveQuotaRefreshFloorSeconds ||
|
||||
codexElapsed >= interactiveQuotaRefreshFloorSeconds else { return }
|
||||
|
||||
Task { [weak self] in
|
||||
guard let self else { return }
|
||||
_ = await self.refreshLiveQuotaProgressIfDue(force: true)
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshPayloadForPopoverOpen() {
|
||||
guard store.needsInteractivePayloadRefresh else { return }
|
||||
let shouldResetPipeline = store.shouldResetInteractiveRefreshPipeline
|
||||
if shouldResetPipeline, let age = store.staleInteractivePayloadAgeSeconds {
|
||||
NSLog("CodeBurn: popover opened with %ds stale payload cache - resetting refresh pipeline", age)
|
||||
}
|
||||
recoverRefreshPipelineAfterInterruption(
|
||||
resetLoading: shouldResetPipeline,
|
||||
reason: "popover open"
|
||||
)
|
||||
}
|
||||
|
||||
private func stopRefreshTimer() {
|
||||
refreshTimer?.setEventHandler {}
|
||||
refreshTimer?.cancel()
|
||||
refreshTimer = nil
|
||||
}
|
||||
|
||||
private func runRefreshLoopTick(reason: String, forcePayload: Bool = false, forceQuota: Bool = false) {
|
||||
refreshLoopHeartbeatAt = Date()
|
||||
let hadForceRefreshInFlight = forceRefreshTask != nil
|
||||
let clearedStaleForceRefresh = clearStaleForceRefreshIfNeeded()
|
||||
let clearedStaleStatusRefresh = clearStaleStatusPayloadRefreshIfNeeded()
|
||||
let clearedStaleLoading = store.clearStaleLoadingIfNeeded()
|
||||
let statusPayloadStale = store.needsStatusPayloadRefresh
|
||||
let sinceLast = Date().timeIntervalSince(lastRefreshTime)
|
||||
let shouldForceRefresh = forcePayload ||
|
||||
clearedStaleForceRefresh ||
|
||||
clearedStaleLoading ||
|
||||
sinceLast >= TimeInterval(refreshIntervalSeconds)
|
||||
|
||||
if shouldForceRefresh {
|
||||
forceRefresh(bypassRateLimit: true, forceQuota: forceQuota)
|
||||
}
|
||||
|
||||
let forceRefreshWasBlocked = hadForceRefreshInFlight && forceRefreshTask != nil
|
||||
if statusPayloadStale && (!shouldForceRefresh || forceRefreshWasBlocked || clearedStaleStatusRefresh) {
|
||||
refreshTodayStatusPayloadIfNeeded(reason: reason, force: forcePayload)
|
||||
}
|
||||
}
|
||||
|
||||
private func startRefreshLoop(forceQuotaOnStart: Bool = false) {
|
||||
stopRefreshTimer()
|
||||
runRefreshLoopTick(reason: "start", forcePayload: true, forceQuota: forceQuotaOnStart)
|
||||
|
||||
let timer = DispatchSource.makeTimerSource(queue: .main)
|
||||
timer.schedule(
|
||||
deadline: .now() + .seconds(Int(refreshIntervalSeconds)),
|
||||
repeating: .seconds(Int(refreshIntervalSeconds)),
|
||||
leeway: .seconds(2)
|
||||
)
|
||||
timer.setEventHandler { [weak self] in
|
||||
Task { @MainActor [weak self] in
|
||||
self?.runRefreshLoopTick(reason: "timer")
|
||||
}
|
||||
}
|
||||
refreshTimer = timer
|
||||
refreshLoopHeartbeatAt = Date()
|
||||
timer.resume()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func refreshSubscriptionNow() {
|
||||
manualRefreshTask?.cancel()
|
||||
manualRefreshGeneration &+= 1
|
||||
let generation = manualRefreshGeneration
|
||||
forceRefreshTask?.cancel()
|
||||
forceRefreshTask = nil
|
||||
forceRefreshStartedAt = nil
|
||||
forceRefreshGeneration &+= 1
|
||||
statusPayloadRefreshTask?.cancel()
|
||||
statusPayloadRefreshTask = nil
|
||||
statusPayloadRefreshStartedAt = nil
|
||||
statusPayloadRefreshGeneration &+= 1
|
||||
pendingRefreshWork?.cancel()
|
||||
pendingRefreshWork = nil
|
||||
stopRefreshTimer()
|
||||
store.resetRefreshState(clearCache: true)
|
||||
lastRefreshTime = .distantPast
|
||||
refreshStatusButton()
|
||||
|
||||
manualRefreshTask = Task { [weak self] in
|
||||
guard let self else { return }
|
||||
// "Refresh Now" should refresh the menubar payload AND every
|
||||
// connected provider's live quota. The user's intent is "make
|
||||
// this match reality right now."
|
||||
let needsTodayTotal = self.store.selectedPeriod != .today || self.store.selectedProvider != .all
|
||||
async let payload: Void = self.store.refresh(includeOptimize: false, force: true, showLoading: true)
|
||||
async let quotas: Bool = self.refreshLiveQuotaProgressIfDue(force: true)
|
||||
if needsTodayTotal {
|
||||
await self.store.refreshQuietly(period: .today, force: true)
|
||||
}
|
||||
_ = await payload
|
||||
guard self.manualRefreshGeneration == generation, !Task.isCancelled else { return }
|
||||
self.lastRefreshTime = Date()
|
||||
self.refreshStatusButton()
|
||||
_ = await quotas
|
||||
guard self.manualRefreshGeneration == generation, !Task.isCancelled else { return }
|
||||
self.manualRefreshTask = nil
|
||||
if self.refreshTimer == nil {
|
||||
self.startRefreshLoop()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset the cadence anchor so the next loop tick re-evaluates from "now"
|
||||
/// rather than measuring against a timestamp from the previous connection.
|
||||
/// Triggered on disconnect of any provider — the cost of clearing both
|
||||
/// anchors is one extra refresh tick on the unaffected provider, far less
|
||||
/// disruptive than waiting a full cadence after a reconnect.
|
||||
@MainActor
|
||||
func resetSubscriptionCadenceAnchor() {
|
||||
lastSubscriptionRefreshAt = nil
|
||||
lastCodexRefreshAt = nil
|
||||
}
|
||||
|
||||
private func observeStore() {
|
||||
withObservationTracking {
|
||||
_ = store.payload
|
||||
_ = store.todayPayload
|
||||
// Read closure uses [weak self] so the implicit self capture from
|
||||
// accessing store.* doesn't pin self for the lifetime of an
|
||||
// unfired observation. withObservationTracking is one-shot per
|
||||
// call: once any read property changes, onChange fires and the
|
||||
// registration is consumed, then we re-arm. There is at most one
|
||||
// active subscription at a time.
|
||||
withObservationTracking { [weak self] in
|
||||
guard let self else { return }
|
||||
_ = self.store.payload
|
||||
_ = self.store.todayPayload
|
||||
// Track currency so the menubar title catches up immediately on
|
||||
// currency switch instead of waiting for the next 30s payload tick.
|
||||
_ = self.store.currency
|
||||
// Track the live-quota state too so the flame icon re-tints on
|
||||
// every subscription / codex usage update, not just every 30s.
|
||||
_ = self.store.subscription
|
||||
_ = self.store.subscriptionLoadState
|
||||
_ = self.store.codexUsage
|
||||
_ = self.store.codexLoadState
|
||||
} onChange: { [weak self] in
|
||||
Task { @MainActor in
|
||||
self?.refreshStatusButton()
|
||||
self?.observeStore()
|
||||
DispatchQueue.main.async {
|
||||
guard let self else { return }
|
||||
self.pendingRefreshWork?.cancel()
|
||||
let work = DispatchWorkItem { [weak self] in
|
||||
self?.refreshStatusButton()
|
||||
self?.observeStore()
|
||||
}
|
||||
self.pendingRefreshWork = work
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.05, execute: work)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -255,18 +679,46 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
|
|||
/// stubborn gap between icon and text on some macOS releases (the icon hugs the left edge
|
||||
/// of the status item, the title starts at its own baseline), so we inline both so they
|
||||
/// flow as one typographic unit with a single, controllable gap.
|
||||
private static func flameTint(for severity: QuotaSummary.Severity) -> NSColor? {
|
||||
switch severity {
|
||||
case .normal: return nil // template, auto-adapt
|
||||
case .warning: return NSColor.systemYellow // 70-90%
|
||||
case .critical: return NSColor.systemOrange // 90-100%
|
||||
case .danger: return NSColor.systemRed // 100%+
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshStatusButton() {
|
||||
guard let button = statusItem.button else { return }
|
||||
// Skip while the popover is anchored to this button. Rewriting the
|
||||
// attributedTitle changes the button's intrinsic width, which makes
|
||||
// macOS reflow the status item in the menubar and detaches the
|
||||
// anchored popover (it pops to a stale default position). The
|
||||
// popoverDidClose delegate calls back through here once the popover
|
||||
// is dismissed so the menubar cost catches up immediately on close.
|
||||
if popover != nil && popover.isShown { return }
|
||||
|
||||
// Clear any previously-set image so the attachment is the only glyph rendered.
|
||||
button.image = nil
|
||||
button.imagePosition = .noImage
|
||||
|
||||
let font = NSFont.monospacedDigitSystemFont(ofSize: menubarTitleFontSize, weight: .medium)
|
||||
let flameConfig = NSImage.SymbolConfiguration(pointSize: menubarTitleFontSize, weight: .medium)
|
||||
let baseConfig = NSImage.SymbolConfiguration(pointSize: menubarTitleFontSize, weight: .medium)
|
||||
// Tint the flame based on the worst-affected connected provider's quota.
|
||||
// Normal (<70%) keeps the template (auto white-on-dark / black-on-light);
|
||||
// warning/critical/danger override with a fixed palette color so the
|
||||
// user gets a glanceable signal even when the menu bar is busy.
|
||||
let aggregate = store.aggregateQuotaStatus
|
||||
let tint = Self.flameTint(for: aggregate.severity)
|
||||
let flameConfig: NSImage.SymbolConfiguration
|
||||
if let tint {
|
||||
flameConfig = baseConfig.applying(.init(paletteColors: [tint]))
|
||||
} else {
|
||||
flameConfig = baseConfig
|
||||
}
|
||||
let flame = NSImage(systemSymbolName: "flame.fill", accessibilityDescription: "CodeBurn")?
|
||||
.withSymbolConfiguration(flameConfig)
|
||||
flame?.isTemplate = true
|
||||
flame?.isTemplate = (tint == nil)
|
||||
|
||||
let attachment = NSTextAttachment()
|
||||
attachment.image = flame
|
||||
|
|
@ -322,14 +774,42 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
|
|||
if popover.isShown {
|
||||
popover.performClose(sender)
|
||||
} else {
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
// Do NOT call NSApp.activate(ignoringOtherApps:) here. On macOS
|
||||
// Tahoe an accessory app activating while a popover anchors to
|
||||
// its NSStatusItem can race with the system menu bar's auto-hide
|
||||
// logic and leave the user's apple-menu hidden until the popover
|
||||
// closes. The popover's window takes keyboard focus on its own
|
||||
// via makeKeyAndOrderFront, which is enough for keystrokes to
|
||||
// reach the SwiftUI content.
|
||||
popover.show(relativeTo: button.bounds, of: button, preferredEdge: .minY)
|
||||
popover.contentViewController?.view.window?.makeKey()
|
||||
if let window = popover.contentViewController?.view.window {
|
||||
// Pin the popover's window above the status-bar layer but tag
|
||||
// it as auxiliary so macOS Tahoe does not treat it as an
|
||||
// app-level focus event — that's what was hiding the system
|
||||
// menu bar (Terminal's apple-logo / Shell / Edit / View row)
|
||||
// every time the popover opened.
|
||||
window.level = .statusBar
|
||||
window.collectionBehavior.insert(.fullScreenAuxiliary)
|
||||
window.collectionBehavior.insert(.canJoinAllSpaces)
|
||||
window.makeKeyAndOrderFront(nil)
|
||||
}
|
||||
refreshPayloadForPopoverOpen()
|
||||
refreshLiveQuotaProgressForPopoverOpen()
|
||||
}
|
||||
}
|
||||
|
||||
private func showContextMenu(from button: NSStatusBarButton) {
|
||||
let menu = NSMenu()
|
||||
|
||||
let settingsItem = NSMenuItem(title: "Settings…", action: #selector(openSettings), keyEquivalent: ",")
|
||||
settingsItem.target = self
|
||||
menu.addItem(settingsItem)
|
||||
|
||||
let refreshNow = NSMenuItem(title: "Refresh Now", action: #selector(refreshNowAction), keyEquivalent: "r")
|
||||
refreshNow.target = self
|
||||
menu.addItem(refreshNow)
|
||||
|
||||
menu.addItem(.separator())
|
||||
let updateItem = NSMenuItem(title: "Check for Updates", action: #selector(checkForUpdates), keyEquivalent: "")
|
||||
updateItem.target = self
|
||||
menu.addItem(updateItem)
|
||||
|
|
@ -337,11 +817,48 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
|
|||
let quitItem = NSMenuItem(title: "Quit CodeBurn", action: #selector(quitApp), keyEquivalent: "q")
|
||||
quitItem.target = self
|
||||
menu.addItem(quitItem)
|
||||
|
||||
statusItem.menu = menu
|
||||
button.performClick(nil)
|
||||
statusItem.menu = nil
|
||||
}
|
||||
|
||||
private var settingsWindowController: NSWindowController?
|
||||
|
||||
@objc private func openSettings() {
|
||||
// Accessory-policy apps (no Dock icon, no main menu) don't get the
|
||||
// SwiftUI Settings scene wired into the responder chain reliably, so
|
||||
// the standard `showSettingsWindow:` selector silently no-ops. We host
|
||||
// the SwiftUI view in our own NSWindowController instead.
|
||||
if let controller = settingsWindowController {
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
controller.window?.makeKeyAndOrderFront(nil)
|
||||
return
|
||||
}
|
||||
|
||||
let hosting = NSHostingController(
|
||||
rootView: SettingsView().environment(store)
|
||||
)
|
||||
let window = NSWindow(
|
||||
contentRect: NSRect(x: 0, y: 0, width: 520, height: 380),
|
||||
styleMask: [.titled, .closable, .miniaturizable],
|
||||
backing: .buffered,
|
||||
defer: false
|
||||
)
|
||||
window.title = "CodeBurn Settings"
|
||||
window.contentViewController = hosting
|
||||
window.center()
|
||||
window.isReleasedWhenClosed = false
|
||||
let controller = NSWindowController(window: window)
|
||||
settingsWindowController = controller
|
||||
NSApp.activate(ignoringOtherApps: true)
|
||||
controller.showWindow(nil)
|
||||
}
|
||||
|
||||
@objc private func refreshNowAction() {
|
||||
refreshSubscriptionNow()
|
||||
}
|
||||
|
||||
private func codeburnAlertIcon() -> NSImage? {
|
||||
let config = NSImage.SymbolConfiguration(pointSize: 32, weight: .medium)
|
||||
guard let symbol = NSImage(systemSymbolName: "flame.fill", accessibilityDescription: "CodeBurn")?
|
||||
|
|
@ -363,14 +880,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
|
|||
await updateChecker.check()
|
||||
let alert = NSAlert()
|
||||
alert.icon = codeburnAlertIcon()
|
||||
if updateChecker.updateAvailable, let latest = updateChecker.latestVersion {
|
||||
if let error = updateChecker.updateError {
|
||||
alert.messageText = "Update Check Failed"
|
||||
alert.informativeText = error
|
||||
alert.alertStyle = .warning
|
||||
} else if updateChecker.updateAvailable, let latest = updateChecker.latestVersion {
|
||||
alert.messageText = "Update Available"
|
||||
alert.informativeText = "v\(latest) is available (you have v\(updateChecker.currentVersion)). Run:\n\ncodeburn menubar --force"
|
||||
alert.informativeText = "\(AppVersion.display(latest)) is available (you have \(AppVersion.display(updateChecker.currentVersion))). Run:\n\ncodeburn menubar --force"
|
||||
alert.alertStyle = .informational
|
||||
} else {
|
||||
alert.messageText = "Up to Date"
|
||||
alert.informativeText = "You're on the latest version (v\(updateChecker.currentVersion))."
|
||||
alert.informativeText = "You're on the latest version (\(AppVersion.display(updateChecker.currentVersion)))."
|
||||
alert.alertStyle = .informational
|
||||
}
|
||||
alert.alertStyle = .informational
|
||||
alert.addButton(withTitle: "OK")
|
||||
alert.runModal()
|
||||
}
|
||||
|
|
@ -385,4 +907,10 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate {
|
|||
func popoverShouldDetach(_ popover: NSPopover) -> Bool {
|
||||
false
|
||||
}
|
||||
|
||||
func popoverDidClose(_ notification: Notification) {
|
||||
// Catch up on any menubar title updates that were skipped while the
|
||||
// popover was anchored.
|
||||
refreshStatusButton()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,8 +10,8 @@ private let minValidFXRate: Double = 0.0001
|
|||
private let maxValidFXRate: Double = 1_000_000
|
||||
private let fxFetchTimeoutSeconds: TimeInterval = 10
|
||||
|
||||
@Observable
|
||||
final class CurrencyState: @unchecked Sendable {
|
||||
@MainActor @Observable
|
||||
final class CurrencyState: Sendable {
|
||||
static let shared = CurrencyState()
|
||||
|
||||
var code: String = "USD"
|
||||
|
|
@ -31,7 +31,7 @@ final class CurrencyState: @unchecked Sendable {
|
|||
}
|
||||
}
|
||||
|
||||
static func symbolForCode(_ code: String) -> String {
|
||||
nonisolated static func symbolForCode(_ code: String) -> String {
|
||||
// Some locales return "US$" for USD or "CA$" for CAD via NumberFormatter. Prefer the
|
||||
// plain glyph form everyone recognises.
|
||||
if let override = symbolOverrides[code] { return override }
|
||||
|
|
@ -42,7 +42,7 @@ final class CurrencyState: @unchecked Sendable {
|
|||
return formatter.currencySymbol ?? code
|
||||
}
|
||||
|
||||
private static let symbolOverrides: [String: String] = [
|
||||
nonisolated private static let symbolOverrides: [String: String] = [
|
||||
"USD": "$",
|
||||
"CAD": "$",
|
||||
"AUD": "$",
|
||||
|
|
|
|||
440
mac/Sources/CodeBurnMenubar/Data/ClaudeCredentialStore.swift
Normal file
|
|
@ -0,0 +1,440 @@
|
|||
import Foundation
|
||||
import Security
|
||||
|
||||
/// Owns the lifecycle of Claude OAuth credentials end-to-end. Replaces
|
||||
/// SubscriptionClient + SubscriptionRefreshGate with a model that mirrors
|
||||
/// CodexBar's proven pattern:
|
||||
///
|
||||
/// 1. **Bootstrap is user-initiated.** The first read of Claude's keychain
|
||||
/// entry — which triggers a macOS keychain prompt — only happens when
|
||||
/// the user clicks "Connect" in the Plan tab. The menubar does not
|
||||
/// touch Claude's keychain on launch.
|
||||
///
|
||||
/// 2. **We persist refreshed tokens.** When Anthropic returns a new access
|
||||
/// token (or a rotated refresh token) we write it back to our own keychain
|
||||
/// item. The next fetch uses it directly — one API call per cycle, not
|
||||
/// three. This was the root cause of "connect once, never updates": the
|
||||
/// previous code refreshed on every tick because the new token was
|
||||
/// thrown away.
|
||||
///
|
||||
/// 3. **Our own keychain item, not Claude's.** We bootstrap from Claude's
|
||||
/// entry once, then maintain `com.codeburn.menubar.claude.oauth.v1` in
|
||||
/// the user's keychain. Subsequent reads do not prompt because we own
|
||||
/// that item's ACL.
|
||||
///
|
||||
/// 4. **In-memory cache (5 min)** so back-to-back reads in the same refresh
|
||||
/// cycle don't even hit the keychain.
|
||||
enum ClaudeCredentialStore {
|
||||
private static let bootstrapCompletedKey = "codeburn.claude.bootstrapCompleted"
|
||||
private static let inMemoryTTL: TimeInterval = 5 * 60
|
||||
private static let proactiveRefreshMargin: TimeInterval = 5 * 60
|
||||
|
||||
private static let oauthClientID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
|
||||
private static let refreshURL = URL(string: "https://platform.claude.com/v1/oauth/token")!
|
||||
|
||||
private static let claudeKeychainService = "Claude Code-credentials"
|
||||
private static let credentialsRelativePath = ".claude/.credentials.json"
|
||||
private static let maxCredentialBytes = 64 * 1024
|
||||
|
||||
/// Legacy local cache file. New writes use the macOS Keychain; this path is
|
||||
/// read once for migration and then removed.
|
||||
private static let cacheFilename = "claude-credentials.v1.json"
|
||||
private static let ourKeychainService = "org.agentseal.codeburn.menubar.claude.oauth.v1"
|
||||
private static let ourKeychainAccount = "default"
|
||||
|
||||
private static let lock = NSLock()
|
||||
private nonisolated(unsafe) static var memoryCache: CachedRecord?
|
||||
|
||||
struct CachedRecord {
|
||||
let record: CredentialRecord
|
||||
let cachedAt: Date
|
||||
|
||||
var isFresh: Bool { Date().timeIntervalSince(cachedAt) < ClaudeCredentialStore.inMemoryTTL }
|
||||
}
|
||||
|
||||
struct CredentialRecord: Codable, Equatable {
|
||||
let accessToken: String
|
||||
let refreshToken: String?
|
||||
let expiresAt: Date?
|
||||
let rateLimitTier: String?
|
||||
}
|
||||
|
||||
enum StoreError: Error, LocalizedError {
|
||||
case bootstrapNoSource // neither file nor Claude keychain has credentials
|
||||
case bootstrapDecodeFailed
|
||||
case keychainWriteFailed(OSStatus)
|
||||
case keychainReadFailed(OSStatus)
|
||||
case refreshHTTPError(Int, String?)
|
||||
case refreshNetworkError(Error)
|
||||
case refreshDecodeFailed
|
||||
case noRefreshToken
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .bootstrapNoSource:
|
||||
return "No Claude credentials found. Sign in with `claude` first."
|
||||
case .bootstrapDecodeFailed:
|
||||
return "Claude credentials are malformed."
|
||||
case let .keychainWriteFailed(status):
|
||||
return "Could not write to keychain (status \(status))."
|
||||
case let .keychainReadFailed(status):
|
||||
return "Could not read from keychain (status \(status))."
|
||||
case let .refreshHTTPError(code, body):
|
||||
return "Token refresh failed (HTTP \(code))\(body.map { ": \($0)" } ?? "")"
|
||||
case let .refreshNetworkError(err):
|
||||
return "Token refresh network error: \(err.localizedDescription)"
|
||||
case .refreshDecodeFailed:
|
||||
return "Token refresh response was malformed."
|
||||
case .noRefreshToken:
|
||||
return "No refresh token available; reconnect required."
|
||||
}
|
||||
}
|
||||
|
||||
/// True when the failure means the user must re-authenticate (re-run
|
||||
/// `claude` or click Reconnect). Used by the UI to distinguish between
|
||||
/// "try again later" and "you must act".
|
||||
var isTerminal: Bool {
|
||||
if case let .refreshHTTPError(code, body) = self, code >= 400, code < 500 {
|
||||
let lower = body?.lowercased() ?? ""
|
||||
if lower.contains("invalid_grant") || lower.contains("invalid_client") || lower.contains("invalid_token") {
|
||||
return true
|
||||
}
|
||||
return true // 4xx other than rate-limiting is terminal too
|
||||
}
|
||||
if case .noRefreshToken = self { return true }
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Bootstrap state
|
||||
|
||||
/// True once the user has explicitly connected (clicked Connect in the Plan
|
||||
/// tab AND we successfully read their credentials). Persists across launches.
|
||||
static var isBootstrapCompleted: Bool {
|
||||
get { UserDefaults.standard.bool(forKey: bootstrapCompletedKey) }
|
||||
set { UserDefaults.standard.set(newValue, forKey: bootstrapCompletedKey) }
|
||||
}
|
||||
|
||||
/// Reset bootstrap state. Used when the user explicitly wants to disconnect
|
||||
/// or when the refresh token has been revoked terminally.
|
||||
static func resetBootstrap() {
|
||||
lock.withLock { memoryCache = nil }
|
||||
deleteOurCache()
|
||||
isBootstrapCompleted = false
|
||||
}
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
/// User-initiated entry point. Reads from Claude's source (PROMPTS for the
|
||||
/// keychain on first use), writes to our own keychain item, marks bootstrap
|
||||
/// as completed.
|
||||
@discardableResult
|
||||
static func bootstrap() throws -> CredentialRecord {
|
||||
let record = try readClaudeSource()
|
||||
try writeOurCache(record: record)
|
||||
isBootstrapCompleted = true
|
||||
cacheInMemory(record)
|
||||
return record
|
||||
}
|
||||
|
||||
/// Silent read for background refresh cycles. Reads only from our cache /
|
||||
/// keychain item — never prompts. Returns nil if not bootstrapped.
|
||||
static func currentRecord() throws -> CredentialRecord? {
|
||||
guard isBootstrapCompleted else { return nil }
|
||||
// Honour the in-memory TTL: a stale cached record can mask a token
|
||||
// that another process (e.g. claude /login again) has just rotated
|
||||
// on disk. Re-read the file when the cache passes the TTL.
|
||||
if let cached = lock.withLock({ memoryCache }), cached.isFresh {
|
||||
return cached.record
|
||||
}
|
||||
if let stored = try readOurCache() {
|
||||
cacheInMemory(stored)
|
||||
return stored
|
||||
}
|
||||
// Bootstrap flag is set but our cache file is missing — most likely
|
||||
// a fresh install resetting state, or the user manually deleted the
|
||||
// file. Force re-bootstrap on next user action.
|
||||
isBootstrapCompleted = false
|
||||
return nil
|
||||
}
|
||||
|
||||
/// Returns a token guaranteed to be either fresh or just-refreshed. If the
|
||||
/// current token expires within `proactiveRefreshMargin`, refreshes ahead
|
||||
/// of time and persists the new token.
|
||||
static func freshAccessToken() async throws -> String? {
|
||||
guard let record = try currentRecord() else { return nil }
|
||||
if let expiresAt = record.expiresAt, expiresAt.timeIntervalSinceNow < proactiveRefreshMargin {
|
||||
let updated = try await refreshAndPersist(record: record)
|
||||
return updated.accessToken
|
||||
}
|
||||
return record.accessToken
|
||||
}
|
||||
|
||||
/// Called after an explicit 401. Refreshes, persists, returns the new token.
|
||||
static func refreshAfter401() async throws -> String {
|
||||
guard let record = try currentRecord() else { throw StoreError.noRefreshToken }
|
||||
let updated = try await refreshAndPersist(record: record)
|
||||
return updated.accessToken
|
||||
}
|
||||
|
||||
static func subscriptionTier() throws -> String? {
|
||||
try currentRecord()?.rateLimitTier
|
||||
}
|
||||
|
||||
// MARK: - Bootstrap source
|
||||
|
||||
private static func readClaudeSource() throws -> CredentialRecord {
|
||||
if let fromFile = try? readClaudeFile() { return fromFile }
|
||||
if let fromKeychain = try readClaudeKeychain() { return fromKeychain }
|
||||
throw StoreError.bootstrapNoSource
|
||||
}
|
||||
|
||||
private static func readClaudeFile() throws -> CredentialRecord? {
|
||||
let url = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(credentialsRelativePath)
|
||||
guard FileManager.default.fileExists(atPath: url.path) else { return nil }
|
||||
let data = try SafeFile.read(from: url.path, maxBytes: maxCredentialBytes)
|
||||
return try parseClaudeBlob(data: sanitizeClaudeBlob(data))
|
||||
}
|
||||
|
||||
/// Reads Claude's keychain credentials. The CLI has historically written
|
||||
/// entries under different account names — older versions used "agentseal"
|
||||
/// (a hardcoded company-style identifier) while Claude Code 2.1.x writes
|
||||
/// under `$USER` (NSUserName()). After a user re-runs `/login`, both
|
||||
/// entries can coexist and `SecItemCopyMatching` with kSecMatchLimitOne
|
||||
/// often returns the older stale one. We try the user-keyed entry first
|
||||
/// (the modern format), then fall back to the unscoped query for older
|
||||
/// installations.
|
||||
private static func readClaudeKeychain() throws -> CredentialRecord? {
|
||||
if let record = try readClaudeKeychain(account: NSUserName()) {
|
||||
return record
|
||||
}
|
||||
return try readClaudeKeychain(account: nil)
|
||||
}
|
||||
|
||||
private static func readClaudeKeychain(account: String?) throws -> CredentialRecord? {
|
||||
var query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: claudeKeychainService,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||
kSecReturnData as String: true,
|
||||
]
|
||||
if let account { query[kSecAttrAccount as String] = account }
|
||||
var result: CFTypeRef?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
if status == errSecItemNotFound { return nil }
|
||||
guard status == errSecSuccess, let data = result as? Data else {
|
||||
throw StoreError.keychainReadFailed(status)
|
||||
}
|
||||
return try parseClaudeBlob(data: sanitizeClaudeBlob(data))
|
||||
}
|
||||
|
||||
/// Claude Code's keychain writer line-wraps long values (newline + leading
|
||||
/// spaces) mid-token, producing JSON with literal control chars inside string
|
||||
/// values. Strip those plus pretty-print indentation between fields so the
|
||||
/// JSON parser succeeds.
|
||||
private static func sanitizeClaudeBlob(_ data: Data) -> Data {
|
||||
guard var s = String(data: data, encoding: .utf8) else { return data }
|
||||
s = s.replacingOccurrences(of: "\r", with: "")
|
||||
if let regex = try? NSRegularExpression(pattern: "\\n[ \\t]*", options: []) {
|
||||
let range = NSRange(s.startIndex..<s.endIndex, in: s)
|
||||
s = regex.stringByReplacingMatches(in: s, options: [], range: range, withTemplate: "")
|
||||
}
|
||||
s = s.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return s.data(using: .utf8) ?? data
|
||||
}
|
||||
|
||||
private static func parseClaudeBlob(data: Data) throws -> CredentialRecord {
|
||||
struct Root: Decodable { let claudeAiOauth: OAuth? }
|
||||
struct OAuth: Decodable {
|
||||
let accessToken: String?
|
||||
let refreshToken: String?
|
||||
let expiresAt: Double?
|
||||
let rateLimitTier: String?
|
||||
}
|
||||
do {
|
||||
let root = try JSONDecoder().decode(Root.self, from: data)
|
||||
guard let oauth = root.claudeAiOauth,
|
||||
let token = oauth.accessToken?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!token.isEmpty
|
||||
else { throw StoreError.bootstrapDecodeFailed }
|
||||
return CredentialRecord(
|
||||
accessToken: token,
|
||||
refreshToken: oauth.refreshToken,
|
||||
expiresAt: oauth.expiresAt.map { Date(timeIntervalSince1970: $0 / 1000.0) },
|
||||
rateLimitTier: oauth.rateLimitTier
|
||||
)
|
||||
} catch {
|
||||
throw StoreError.bootstrapDecodeFailed
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Local cache file (no keychain involvement)
|
||||
|
||||
private static func cacheFileURL() -> URL {
|
||||
let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
|
||||
?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Application Support")
|
||||
return support
|
||||
.appendingPathComponent("CodeBurn", isDirectory: true)
|
||||
.appendingPathComponent(cacheFilename)
|
||||
}
|
||||
|
||||
private static func readOurCache() throws -> CredentialRecord? {
|
||||
if let record = try readOurKeychainCache() {
|
||||
return record
|
||||
}
|
||||
|
||||
let url = cacheFileURL()
|
||||
guard FileManager.default.fileExists(atPath: url.path) else { return nil }
|
||||
// Route through SafeFile.read so we lstat for symlinks before opening
|
||||
// and bound the read with maxCredentialBytes. Without this, an
|
||||
// attacker who can plant a symlink in ~/Library/Application Support/
|
||||
// CodeBurn/ between disconnect and reconnect could redirect our read
|
||||
// to /dev/zero (unbounded memory) or another file the user owns.
|
||||
let data = try SafeFile.read(from: url.path, maxBytes: maxCredentialBytes)
|
||||
guard let record = try? JSONDecoder().decode(CredentialRecord.self, from: data) else { return nil }
|
||||
try? writeOurKeychainCache(record: record)
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
return record
|
||||
}
|
||||
|
||||
private static func writeOurCache(record: CredentialRecord) throws {
|
||||
try writeOurKeychainCache(record: record)
|
||||
}
|
||||
|
||||
private static func readOurKeychainCache() throws -> CredentialRecord? {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: ourKeychainService,
|
||||
kSecAttrAccount as String: ourKeychainAccount,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||
kSecReturnData as String: true,
|
||||
]
|
||||
var result: CFTypeRef?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
if status == errSecItemNotFound { return nil }
|
||||
guard status == errSecSuccess, let data = result as? Data else {
|
||||
throw StoreError.keychainReadFailed(status)
|
||||
}
|
||||
return try? JSONDecoder().decode(CredentialRecord.self, from: data)
|
||||
}
|
||||
|
||||
private static func writeOurKeychainCache(record: CredentialRecord) throws {
|
||||
let url = cacheFileURL()
|
||||
let data = try JSONEncoder().encode(record)
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: ourKeychainService,
|
||||
kSecAttrAccount as String: ourKeychainAccount,
|
||||
]
|
||||
let attributes: [String: Any] = [
|
||||
kSecValueData as String: data,
|
||||
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
|
||||
]
|
||||
let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
|
||||
if status == errSecItemNotFound {
|
||||
var add = query
|
||||
add.merge(attributes) { _, new in new }
|
||||
let addStatus = SecItemAdd(add as CFDictionary, nil)
|
||||
guard addStatus == errSecSuccess else {
|
||||
throw StoreError.keychainWriteFailed(addStatus)
|
||||
}
|
||||
} else if status != errSecSuccess {
|
||||
throw StoreError.keychainWriteFailed(status)
|
||||
}
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
}
|
||||
|
||||
private static func deleteOurCache() {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: ourKeychainService,
|
||||
kSecAttrAccount as String: ourKeychainAccount,
|
||||
]
|
||||
SecItemDelete(query as CFDictionary)
|
||||
try? FileManager.default.removeItem(at: cacheFileURL())
|
||||
}
|
||||
|
||||
private static func cacheInMemory(_ record: CredentialRecord) {
|
||||
lock.withLock { memoryCache = CachedRecord(record: record, cachedAt: Date()) }
|
||||
}
|
||||
|
||||
// MARK: - Refresh
|
||||
|
||||
private static func refreshAndPersist(record: CredentialRecord) async throws -> CredentialRecord {
|
||||
guard let refreshToken = record.refreshToken, !refreshToken.isEmpty else {
|
||||
throw StoreError.noRefreshToken
|
||||
}
|
||||
|
||||
var request = URLRequest(url: refreshURL)
|
||||
request.httpMethod = "POST"
|
||||
request.timeoutInterval = 30
|
||||
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
var components = URLComponents()
|
||||
components.queryItems = [
|
||||
URLQueryItem(name: "grant_type", value: "refresh_token"),
|
||||
URLQueryItem(name: "refresh_token", value: refreshToken),
|
||||
URLQueryItem(name: "client_id", value: oauthClientID),
|
||||
]
|
||||
request.httpBody = (components.percentEncodedQuery ?? "").data(using: .utf8)
|
||||
|
||||
let data: Data
|
||||
let response: URLResponse
|
||||
do {
|
||||
(data, response) = try await URLSession.shared.data(for: request)
|
||||
} catch {
|
||||
throw StoreError.refreshNetworkError(error)
|
||||
}
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw StoreError.refreshHTTPError(-1, nil)
|
||||
}
|
||||
guard http.statusCode == 200 else {
|
||||
let body = String(data: data, encoding: .utf8)
|
||||
throw StoreError.refreshHTTPError(http.statusCode, body)
|
||||
}
|
||||
|
||||
struct RefreshResponse: Decodable {
|
||||
let accessToken: String
|
||||
let refreshToken: String?
|
||||
let expiresIn: Int?
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case accessToken = "access_token"
|
||||
case refreshToken = "refresh_token"
|
||||
case expiresIn = "expires_in"
|
||||
}
|
||||
}
|
||||
guard let decoded = try? JSONDecoder().decode(RefreshResponse.self, from: data) else {
|
||||
throw StoreError.refreshDecodeFailed
|
||||
}
|
||||
|
||||
// Anthropic may rotate the refresh token. If it did, the OLD one is
|
||||
// already invalid server-side — discarding the new one would lock
|
||||
// the user out permanently. So we cache the new record in memory
|
||||
// BEFORE attempting the keychain write, and if the write fails we
|
||||
// still return the new record (memory cache will serve subsequent
|
||||
// calls inside the 5-min TTL while we keep retrying the persist).
|
||||
let updated = CredentialRecord(
|
||||
accessToken: decoded.accessToken,
|
||||
refreshToken: decoded.refreshToken ?? record.refreshToken,
|
||||
expiresAt: decoded.expiresIn.map { Date().addingTimeInterval(TimeInterval($0)) } ?? record.expiresAt,
|
||||
rateLimitTier: record.rateLimitTier
|
||||
)
|
||||
cacheInMemory(updated)
|
||||
do {
|
||||
try writeOurCache(record: updated)
|
||||
} catch {
|
||||
// Best effort — surface to logs but do not abandon the rotated
|
||||
// token. Next refresh will retry persistence; UI will continue
|
||||
// working from the in-memory cache.
|
||||
NSLog("CodeBurn: cache write failed during refresh rotation: %@", String(describing: error))
|
||||
}
|
||||
return updated
|
||||
}
|
||||
}
|
||||
|
||||
private extension NSLock {
|
||||
func withLock<T>(_ body: () throws -> T) rethrows -> T {
|
||||
lock(); defer { unlock() }
|
||||
return try body()
|
||||
}
|
||||
}
|
||||
241
mac/Sources/CodeBurnMenubar/Data/ClaudeSubscriptionService.swift
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
import Foundation
|
||||
|
||||
/// Orchestrates "given a credential record, fetch live quota from Anthropic
|
||||
/// and surface a result the UI can render". All token persistence lives in
|
||||
/// `ClaudeCredentialStore`; the only state this service holds is the
|
||||
/// 429 backoff window for the usage endpoint.
|
||||
enum ClaudeSubscriptionService {
|
||||
private static let usageURL = URL(string: "https://api.anthropic.com/api/oauth/usage")!
|
||||
private static let betaHeader = "oauth-2025-04-20"
|
||||
private static let userAgent = "claude-code/2.1.0"
|
||||
private static let usageBlockedUntilKey = "codeburn.claude.usage.blockedUntil"
|
||||
|
||||
enum FetchError: Error, LocalizedError {
|
||||
case notBootstrapped
|
||||
case bootstrapFailed(ClaudeCredentialStore.StoreError)
|
||||
case rateLimited(retryAt: Date)
|
||||
case usageHTTPError(Int, String?)
|
||||
case usageDecodeFailed
|
||||
case network(Error)
|
||||
case credential(ClaudeCredentialStore.StoreError)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .notBootstrapped:
|
||||
return "Connect Claude in the Plan tab to start tracking quota."
|
||||
case let .bootstrapFailed(err):
|
||||
return err.errorDescription
|
||||
case let .rateLimited(retryAt):
|
||||
let f = RelativeDateTimeFormatter()
|
||||
f.unitsStyle = .short
|
||||
return "Anthropic rate-limited the quota endpoint. Retrying \(f.localizedString(for: retryAt, relativeTo: Date()))."
|
||||
case let .usageHTTPError(code, body):
|
||||
return "Quota fetch failed (HTTP \(code))\(body.map { ": \($0)" } ?? "")"
|
||||
case .usageDecodeFailed:
|
||||
return "Quota response was malformed."
|
||||
case let .network(err):
|
||||
return "Network error: \(err.localizedDescription)"
|
||||
case let .credential(err):
|
||||
return err.errorDescription
|
||||
}
|
||||
}
|
||||
|
||||
/// True when the user must take action (re-run claude/login or click
|
||||
/// Reconnect). Drives the red "Reconnect" UI path.
|
||||
var isTerminal: Bool {
|
||||
if case let .credential(err) = self { return err.isTerminal }
|
||||
if case let .bootstrapFailed(err) = self { return err.isTerminal }
|
||||
return false
|
||||
}
|
||||
|
||||
var rateLimitRetryAt: Date? {
|
||||
if case let .rateLimited(retryAt) = self { return retryAt }
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
/// User-initiated. Reads Claude's keychain (PROMPTS), copies to our keychain,
|
||||
/// then fetches usage. Idempotent — safe to call again to "reconnect".
|
||||
static func bootstrap() async throws -> SubscriptionUsage {
|
||||
// Honour the same 429 backoff that refreshIfBootstrapped respects.
|
||||
// Without this, a user spamming Reconnect during a sustained
|
||||
// rate-limit window hammers Anthropic on every click — exactly the
|
||||
// pattern that escalates the backoff.
|
||||
if let until = usageBlockedUntil(), until > Date() {
|
||||
throw FetchError.rateLimited(retryAt: until)
|
||||
}
|
||||
let record: ClaudeCredentialStore.CredentialRecord
|
||||
do {
|
||||
record = try ClaudeCredentialStore.bootstrap()
|
||||
} catch let err as ClaudeCredentialStore.StoreError {
|
||||
throw FetchError.bootstrapFailed(err)
|
||||
}
|
||||
return try await fetchWithRecord(initial: record)
|
||||
}
|
||||
|
||||
/// Background refresh. Never prompts. Returns nil if not yet bootstrapped.
|
||||
static func refreshIfBootstrapped() async throws -> SubscriptionUsage? {
|
||||
guard ClaudeCredentialStore.isBootstrapCompleted else {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Honour an outstanding rate-limit window — we recorded a 429 recently
|
||||
// and Anthropic told us when to come back.
|
||||
if let until = usageBlockedUntil(), until > Date() {
|
||||
throw FetchError.rateLimited(retryAt: until)
|
||||
}
|
||||
|
||||
do {
|
||||
let token = try await ClaudeCredentialStore.freshAccessToken()
|
||||
guard let token else { throw FetchError.notBootstrapped }
|
||||
return try await fetch(token: token, allowOne401Recovery: true)
|
||||
} catch let err as ClaudeCredentialStore.StoreError {
|
||||
throw FetchError.credential(err)
|
||||
} catch let err as FetchError {
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
/// Reset everything — used on user-initiated disconnect.
|
||||
static func disconnect() {
|
||||
ClaudeCredentialStore.resetBootstrap()
|
||||
clearUsageBlock()
|
||||
}
|
||||
|
||||
// MARK: - Internal
|
||||
|
||||
private static func fetchWithRecord(initial record: ClaudeCredentialStore.CredentialRecord) async throws -> SubscriptionUsage {
|
||||
do {
|
||||
return try await fetch(token: record.accessToken, allowOne401Recovery: true)
|
||||
} catch let err as FetchError {
|
||||
throw err
|
||||
} catch let err as ClaudeCredentialStore.StoreError {
|
||||
throw FetchError.credential(err)
|
||||
}
|
||||
}
|
||||
|
||||
private static func fetch(token: String, allowOne401Recovery: Bool) async throws -> SubscriptionUsage {
|
||||
var request = URLRequest(url: usageURL)
|
||||
request.httpMethod = "GET"
|
||||
request.timeoutInterval = 30
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
request.setValue(betaHeader, forHTTPHeaderField: "anthropic-beta")
|
||||
request.setValue(userAgent, forHTTPHeaderField: "User-Agent")
|
||||
|
||||
let data: Data
|
||||
let response: URLResponse
|
||||
do {
|
||||
(data, response) = try await URLSession.shared.data(for: request)
|
||||
} catch {
|
||||
throw FetchError.network(error)
|
||||
}
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw FetchError.usageHTTPError(-1, nil)
|
||||
}
|
||||
|
||||
switch http.statusCode {
|
||||
case 200:
|
||||
clearUsageBlock()
|
||||
do {
|
||||
let decoded = try JSONDecoder().decode(UsageResponse.self, from: data)
|
||||
let tier = try ClaudeCredentialStore.subscriptionTier()
|
||||
return mapResponse(decoded, rawTier: tier)
|
||||
} catch {
|
||||
throw FetchError.usageDecodeFailed
|
||||
}
|
||||
case 401:
|
||||
if allowOne401Recovery {
|
||||
let newToken = try await ClaudeCredentialStore.refreshAfter401()
|
||||
return try await fetch(token: newToken, allowOne401Recovery: false)
|
||||
}
|
||||
throw FetchError.usageHTTPError(401, String(data: data, encoding: .utf8))
|
||||
case 429:
|
||||
let body = String(data: data, encoding: .utf8)
|
||||
let retryAfter = parseRetryAfter(body: body)
|
||||
let until = recordUsageRateLimit(retryAfterSeconds: retryAfter)
|
||||
throw FetchError.rateLimited(retryAt: until)
|
||||
default:
|
||||
throw FetchError.usageHTTPError(http.statusCode, String(data: data, encoding: .utf8))
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - 429 backoff
|
||||
|
||||
private static func usageBlockedUntil() -> Date? {
|
||||
UserDefaults.standard.object(forKey: usageBlockedUntilKey) as? Date
|
||||
}
|
||||
|
||||
private static func clearUsageBlock() {
|
||||
UserDefaults.standard.removeObject(forKey: usageBlockedUntilKey)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
private static func recordUsageRateLimit(retryAfterSeconds: Int?) -> Date {
|
||||
let seconds = max(retryAfterSeconds ?? 300, 60)
|
||||
let until = Date().addingTimeInterval(TimeInterval(seconds))
|
||||
UserDefaults.standard.set(until, forKey: usageBlockedUntilKey)
|
||||
return until
|
||||
}
|
||||
|
||||
private static func parseRetryAfter(body: String?) -> Int? {
|
||||
guard let body, let data = body.data(using: .utf8) else { return nil }
|
||||
if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any] {
|
||||
if let n = json["retry_after"] as? Int { return n }
|
||||
if let s = json["retry_after"] as? String, let n = Int(s) { return n }
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MARK: - Response mapping
|
||||
|
||||
private struct UsageResponse: Decodable {
|
||||
let fiveHour: Window?
|
||||
let sevenDay: Window?
|
||||
let sevenDayOpus: Window?
|
||||
let sevenDaySonnet: Window?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case fiveHour = "five_hour"
|
||||
case sevenDay = "seven_day"
|
||||
case sevenDayOpus = "seven_day_opus"
|
||||
case sevenDaySonnet = "seven_day_sonnet"
|
||||
}
|
||||
}
|
||||
|
||||
private struct Window: Decodable {
|
||||
let utilization: Double?
|
||||
let resetsAt: String?
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case utilization
|
||||
case resetsAt = "resets_at"
|
||||
}
|
||||
}
|
||||
|
||||
private static func mapResponse(_ r: UsageResponse, rawTier: String?) -> SubscriptionUsage {
|
||||
SubscriptionUsage(
|
||||
tier: SubscriptionUsage.tier(from: rawTier),
|
||||
rawTier: rawTier,
|
||||
fiveHourPercent: r.fiveHour?.utilization,
|
||||
fiveHourResetsAt: parseDate(r.fiveHour?.resetsAt),
|
||||
sevenDayPercent: r.sevenDay?.utilization,
|
||||
sevenDayResetsAt: parseDate(r.sevenDay?.resetsAt),
|
||||
sevenDayOpusPercent: r.sevenDayOpus?.utilization,
|
||||
sevenDayOpusResetsAt: parseDate(r.sevenDayOpus?.resetsAt),
|
||||
sevenDaySonnetPercent: r.sevenDaySonnet?.utilization,
|
||||
sevenDaySonnetResetsAt: parseDate(r.sevenDaySonnet?.resetsAt),
|
||||
fetchedAt: Date()
|
||||
)
|
||||
}
|
||||
|
||||
private static func parseDate(_ s: String?) -> Date? {
|
||||
guard let s, !s.isEmpty else { return nil }
|
||||
let f = ISO8601DateFormatter()
|
||||
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
if let d = f.date(from: s) { return d }
|
||||
f.formatOptions = [.withInternetDateTime]
|
||||
return f.date(from: s)
|
||||
}
|
||||
}
|
||||
336
mac/Sources/CodeBurnMenubar/Data/CodexCredentialStore.swift
Normal file
|
|
@ -0,0 +1,336 @@
|
|||
import Foundation
|
||||
import Security
|
||||
|
||||
/// Owns the Codex (ChatGPT-mode) OAuth credential lifecycle. Mirrors
|
||||
/// ClaudeCredentialStore but reads from ~/.codex/auth.json — Codex CLI
|
||||
/// already stores its tokens as plaintext JSON in the home directory, so
|
||||
/// no keychain prompt is involved on bootstrap. After the user clicks
|
||||
/// Connect we cache a copy under ~/Library/Application Support/CodeBurn so
|
||||
/// we keep using rotated tokens after refresh.
|
||||
enum CodexCredentialStore {
|
||||
private static let bootstrapCompletedKey = "codeburn.codex.bootstrapCompleted"
|
||||
private static let inMemoryTTL: TimeInterval = 5 * 60
|
||||
private static let proactiveRefreshMargin: TimeInterval = 5 * 60
|
||||
|
||||
private static let oauthClientID = "app_EMoamEEZ73f0CkXaXp7hrann"
|
||||
private static let refreshURL = URL(string: "https://auth.openai.com/oauth/token")!
|
||||
private static let codexAuthPath = ".codex/auth.json"
|
||||
private static let maxCredentialBytes = 64 * 1024
|
||||
|
||||
private static let cacheFilename = "codex-credentials.v1.json"
|
||||
private static let ourKeychainService = "org.agentseal.codeburn.menubar.codex.oauth.v1"
|
||||
private static let ourKeychainAccount = "default"
|
||||
|
||||
private static let lock = NSLock()
|
||||
private nonisolated(unsafe) static var memoryCache: CachedRecord?
|
||||
|
||||
struct CachedRecord {
|
||||
let record: CredentialRecord
|
||||
let cachedAt: Date
|
||||
|
||||
var isFresh: Bool { Date().timeIntervalSince(cachedAt) < CodexCredentialStore.inMemoryTTL }
|
||||
}
|
||||
|
||||
struct CredentialRecord: Codable, Equatable {
|
||||
let accessToken: String
|
||||
let refreshToken: String
|
||||
let idToken: String?
|
||||
let accountId: String?
|
||||
let expiresAt: Date?
|
||||
}
|
||||
|
||||
enum StoreError: Error, LocalizedError {
|
||||
case bootstrapNoSource
|
||||
case bootstrapDecodeFailed
|
||||
case bootstrapNotChatGPT // user is on API-key mode; we need ChatGPT mode for quota
|
||||
case fileWriteFailed(String)
|
||||
case refreshHTTPError(Int, String?)
|
||||
case refreshNetworkError(Error)
|
||||
case refreshDecodeFailed
|
||||
case noRefreshToken
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .bootstrapNoSource:
|
||||
return "No Codex credentials found at ~/.codex/auth.json. Run `codex` to sign in."
|
||||
case .bootstrapDecodeFailed:
|
||||
return "Codex credentials are malformed."
|
||||
case .bootstrapNotChatGPT:
|
||||
return "Codex is in API-key mode; live quota tracking is only available for ChatGPT subscriptions."
|
||||
case let .fileWriteFailed(message):
|
||||
return "Could not write to local cache: \(message)"
|
||||
case let .refreshHTTPError(code, body):
|
||||
return "Codex token refresh failed (HTTP \(code))\(body.map { ": \($0)" } ?? "")"
|
||||
case let .refreshNetworkError(err):
|
||||
return "Codex token refresh network error: \(err.localizedDescription)"
|
||||
case .refreshDecodeFailed:
|
||||
return "Codex token refresh response was malformed."
|
||||
case .noRefreshToken:
|
||||
return "No refresh token available; reconnect required."
|
||||
}
|
||||
}
|
||||
|
||||
/// True when the user must take action: rerun `codex` to re-authenticate
|
||||
/// or switch from API-key to ChatGPT mode. Drives the red Reconnect path.
|
||||
var isTerminal: Bool {
|
||||
if case let .refreshHTTPError(code, body) = self, code >= 400, code < 500 {
|
||||
let lower = body?.lowercased() ?? ""
|
||||
if lower.contains("refresh_token_expired") ||
|
||||
lower.contains("refresh_token_reused") ||
|
||||
lower.contains("refresh_token_invalidated") ||
|
||||
lower.contains("invalid_grant")
|
||||
{
|
||||
return true
|
||||
}
|
||||
return true
|
||||
}
|
||||
switch self {
|
||||
case .noRefreshToken, .bootstrapNotChatGPT, .bootstrapNoSource: return true
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Bootstrap state
|
||||
|
||||
static var isBootstrapCompleted: Bool {
|
||||
get { UserDefaults.standard.bool(forKey: bootstrapCompletedKey) }
|
||||
set { UserDefaults.standard.set(newValue, forKey: bootstrapCompletedKey) }
|
||||
}
|
||||
|
||||
static func resetBootstrap() {
|
||||
lock.withLock { memoryCache = nil }
|
||||
deleteOurCache()
|
||||
isBootstrapCompleted = false
|
||||
}
|
||||
|
||||
// MARK: - Public API
|
||||
|
||||
@discardableResult
|
||||
static func bootstrap() throws -> CredentialRecord {
|
||||
let record = try readCodexAuth()
|
||||
try writeOurCache(record: record)
|
||||
isBootstrapCompleted = true
|
||||
cacheInMemory(record)
|
||||
return record
|
||||
}
|
||||
|
||||
static func currentRecord() throws -> CredentialRecord? {
|
||||
guard isBootstrapCompleted else { return nil }
|
||||
if let cached = lock.withLock({ memoryCache }), cached.isFresh {
|
||||
return cached.record
|
||||
}
|
||||
if let stored = try readOurCache() {
|
||||
cacheInMemory(stored)
|
||||
return stored
|
||||
}
|
||||
isBootstrapCompleted = false
|
||||
return nil
|
||||
}
|
||||
|
||||
static func freshAccessToken() async throws -> String? {
|
||||
guard let record = try currentRecord() else { return nil }
|
||||
if let expiresAt = record.expiresAt, expiresAt.timeIntervalSinceNow < proactiveRefreshMargin {
|
||||
let updated = try await refreshAndPersist(record: record)
|
||||
return updated.accessToken
|
||||
}
|
||||
return record.accessToken
|
||||
}
|
||||
|
||||
static func refreshAfter401() async throws -> String {
|
||||
guard let record = try currentRecord() else { throw StoreError.noRefreshToken }
|
||||
let updated = try await refreshAndPersist(record: record)
|
||||
return updated.accessToken
|
||||
}
|
||||
|
||||
// MARK: - Bootstrap source: ~/.codex/auth.json
|
||||
|
||||
private static func readCodexAuth() throws -> CredentialRecord {
|
||||
let url = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(codexAuthPath)
|
||||
guard FileManager.default.fileExists(atPath: url.path) else {
|
||||
throw StoreError.bootstrapNoSource
|
||||
}
|
||||
let data = try SafeFile.read(from: url.path, maxBytes: maxCredentialBytes)
|
||||
struct Root: Decodable {
|
||||
let auth_mode: String?
|
||||
let tokens: Tokens?
|
||||
}
|
||||
struct Tokens: Decodable {
|
||||
let access_token: String?
|
||||
let refresh_token: String?
|
||||
let id_token: String?
|
||||
let account_id: String?
|
||||
}
|
||||
do {
|
||||
let root = try JSONDecoder().decode(Root.self, from: data)
|
||||
// Live quota is only meaningful for ChatGPT-mode auth. API-key users
|
||||
// have a different billing surface (/v1/usage) which we do not yet
|
||||
// implement here.
|
||||
guard root.auth_mode == "chatgpt" else {
|
||||
throw StoreError.bootstrapNotChatGPT
|
||||
}
|
||||
guard let tokens = root.tokens,
|
||||
let access = tokens.access_token?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
let refresh = tokens.refresh_token?.trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!access.isEmpty, !refresh.isEmpty
|
||||
else {
|
||||
throw StoreError.bootstrapDecodeFailed
|
||||
}
|
||||
return CredentialRecord(
|
||||
accessToken: access,
|
||||
refreshToken: refresh,
|
||||
idToken: tokens.id_token,
|
||||
accountId: tokens.account_id,
|
||||
expiresAt: nil // Codex CLI does not record expiresAt in auth.json
|
||||
)
|
||||
} catch let err as StoreError {
|
||||
throw err
|
||||
} catch {
|
||||
throw StoreError.bootstrapDecodeFailed
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Local cache file
|
||||
|
||||
private static func cacheFileURL() -> URL {
|
||||
let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
|
||||
?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Application Support")
|
||||
return support
|
||||
.appendingPathComponent("CodeBurn", isDirectory: true)
|
||||
.appendingPathComponent(cacheFilename)
|
||||
}
|
||||
|
||||
private static func readOurCache() throws -> CredentialRecord? {
|
||||
if let record = try readOurKeychainCache() {
|
||||
return record
|
||||
}
|
||||
|
||||
let url = cacheFileURL()
|
||||
guard FileManager.default.fileExists(atPath: url.path) else { return nil }
|
||||
// Symlink-defense + size cap (same hardening as ClaudeCredentialStore).
|
||||
let data = try SafeFile.read(from: url.path, maxBytes: maxCredentialBytes)
|
||||
guard let record = try? JSONDecoder().decode(CredentialRecord.self, from: data) else { return nil }
|
||||
try? writeOurKeychainCache(record: record)
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
return record
|
||||
}
|
||||
|
||||
private static func writeOurCache(record: CredentialRecord) throws {
|
||||
try writeOurKeychainCache(record: record)
|
||||
}
|
||||
|
||||
private static func readOurKeychainCache() throws -> CredentialRecord? {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: ourKeychainService,
|
||||
kSecAttrAccount as String: ourKeychainAccount,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||
kSecReturnData as String: true,
|
||||
]
|
||||
var result: CFTypeRef?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
if status == errSecItemNotFound { return nil }
|
||||
guard status == errSecSuccess, let data = result as? Data else {
|
||||
throw StoreError.fileWriteFailed("keychain read failed with status \(status)")
|
||||
}
|
||||
return try? JSONDecoder().decode(CredentialRecord.self, from: data)
|
||||
}
|
||||
|
||||
private static func writeOurKeychainCache(record: CredentialRecord) throws {
|
||||
let url = cacheFileURL()
|
||||
let data = try JSONEncoder().encode(record)
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: ourKeychainService,
|
||||
kSecAttrAccount as String: ourKeychainAccount,
|
||||
]
|
||||
let attributes: [String: Any] = [
|
||||
kSecValueData as String: data,
|
||||
kSecAttrAccessible as String: kSecAttrAccessibleAfterFirstUnlockThisDeviceOnly,
|
||||
]
|
||||
let status = SecItemUpdate(query as CFDictionary, attributes as CFDictionary)
|
||||
if status == errSecItemNotFound {
|
||||
var add = query
|
||||
add.merge(attributes) { _, new in new }
|
||||
let addStatus = SecItemAdd(add as CFDictionary, nil)
|
||||
guard addStatus == errSecSuccess else {
|
||||
throw StoreError.fileWriteFailed("keychain write failed with status \(addStatus)")
|
||||
}
|
||||
} else if status != errSecSuccess {
|
||||
throw StoreError.fileWriteFailed("keychain update failed with status \(status)")
|
||||
}
|
||||
try? FileManager.default.removeItem(at: url)
|
||||
}
|
||||
|
||||
private static func deleteOurCache() {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: ourKeychainService,
|
||||
kSecAttrAccount as String: ourKeychainAccount,
|
||||
]
|
||||
SecItemDelete(query as CFDictionary)
|
||||
try? FileManager.default.removeItem(at: cacheFileURL())
|
||||
}
|
||||
|
||||
private static func cacheInMemory(_ record: CredentialRecord) {
|
||||
lock.withLock { memoryCache = CachedRecord(record: record, cachedAt: Date()) }
|
||||
}
|
||||
|
||||
// MARK: - Refresh
|
||||
|
||||
private static func refreshAndPersist(record: CredentialRecord) async throws -> CredentialRecord {
|
||||
guard !record.refreshToken.isEmpty else { throw StoreError.noRefreshToken }
|
||||
|
||||
var request = URLRequest(url: refreshURL)
|
||||
request.httpMethod = "POST"
|
||||
request.timeoutInterval = 30
|
||||
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
|
||||
let body: [String: String] = [
|
||||
"client_id": oauthClientID,
|
||||
"grant_type": "refresh_token",
|
||||
"refresh_token": record.refreshToken,
|
||||
"scope": "openid profile email",
|
||||
]
|
||||
request.httpBody = try JSONSerialization.data(withJSONObject: body)
|
||||
|
||||
let data: Data
|
||||
let response: URLResponse
|
||||
do {
|
||||
(data, response) = try await URLSession.shared.data(for: request)
|
||||
} catch {
|
||||
throw StoreError.refreshNetworkError(error)
|
||||
}
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw StoreError.refreshHTTPError(-1, nil)
|
||||
}
|
||||
guard http.statusCode == 200 else {
|
||||
let body = String(data: data, encoding: .utf8)
|
||||
throw StoreError.refreshHTTPError(http.statusCode, body)
|
||||
}
|
||||
|
||||
struct RefreshResponse: Decodable {
|
||||
let access_token: String
|
||||
let refresh_token: String?
|
||||
let id_token: String?
|
||||
let expires_in: Int?
|
||||
}
|
||||
guard let decoded = try? JSONDecoder().decode(RefreshResponse.self, from: data) else {
|
||||
throw StoreError.refreshDecodeFailed
|
||||
}
|
||||
|
||||
let updated = CredentialRecord(
|
||||
accessToken: decoded.access_token,
|
||||
refreshToken: decoded.refresh_token ?? record.refreshToken,
|
||||
idToken: decoded.id_token ?? record.idToken,
|
||||
accountId: record.accountId,
|
||||
expiresAt: decoded.expires_in.map { Date().addingTimeInterval(TimeInterval($0)) } ?? record.expiresAt
|
||||
)
|
||||
cacheInMemory(updated)
|
||||
do {
|
||||
try writeOurCache(record: updated)
|
||||
} catch {
|
||||
NSLog("CodeBurn: codex cache write failed during refresh rotation: %@", String(describing: error))
|
||||
}
|
||||
return updated
|
||||
}
|
||||
}
|
||||
243
mac/Sources/CodeBurnMenubar/Data/CodexSubscriptionService.swift
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
import Foundation
|
||||
|
||||
/// Mirror of ClaudeSubscriptionService for Codex (ChatGPT-mode). Hits
|
||||
/// /backend-api/wham/usage with the bearer token from CodexCredentialStore,
|
||||
/// applies an independent 429 backoff, and surfaces terminal vs transient
|
||||
/// failures to the UI.
|
||||
enum CodexSubscriptionService {
|
||||
private static let usageURL = URL(string: "https://chatgpt.com/backend-api/wham/usage")!
|
||||
private static let usageBlockedUntilKey = "codeburn.codex.usage.blockedUntil"
|
||||
|
||||
enum FetchError: Error, LocalizedError {
|
||||
case notBootstrapped
|
||||
case bootstrapFailed(CodexCredentialStore.StoreError)
|
||||
case rateLimited(retryAt: Date)
|
||||
case usageHTTPError(Int, String?)
|
||||
case usageDecodeFailed
|
||||
case network(Error)
|
||||
case credential(CodexCredentialStore.StoreError)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .notBootstrapped:
|
||||
return "Connect Codex in Settings to start tracking quota."
|
||||
case let .bootstrapFailed(err): return err.errorDescription
|
||||
case let .rateLimited(retryAt):
|
||||
let f = RelativeDateTimeFormatter()
|
||||
f.unitsStyle = .short
|
||||
return "ChatGPT rate-limited the quota endpoint. Retrying \(f.localizedString(for: retryAt, relativeTo: Date()))."
|
||||
case let .usageHTTPError(code, body):
|
||||
return "Codex quota fetch failed (HTTP \(code))\(body.map { ": \($0)" } ?? "")"
|
||||
case .usageDecodeFailed: return "Codex quota response was malformed."
|
||||
case let .network(err): return "Network error: \(err.localizedDescription)"
|
||||
case let .credential(err): return err.errorDescription
|
||||
}
|
||||
}
|
||||
|
||||
var isTerminal: Bool {
|
||||
if case let .credential(err) = self { return err.isTerminal }
|
||||
if case let .bootstrapFailed(err) = self { return err.isTerminal }
|
||||
return false
|
||||
}
|
||||
|
||||
var rateLimitRetryAt: Date? {
|
||||
if case let .rateLimited(retryAt) = self { return retryAt }
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
static func bootstrap() async throws -> CodexUsage {
|
||||
// Honour the same 429 backoff that refreshIfBootstrapped respects.
|
||||
// A user clicking Reconnect during a sustained ChatGPT rate-limit
|
||||
// window would otherwise re-hit /wham/usage on every click and keep
|
||||
// the backoff window pegged.
|
||||
if let until = usageBlockedUntil(), until > Date() {
|
||||
throw FetchError.rateLimited(retryAt: until)
|
||||
}
|
||||
let record: CodexCredentialStore.CredentialRecord
|
||||
do {
|
||||
record = try CodexCredentialStore.bootstrap()
|
||||
} catch let err as CodexCredentialStore.StoreError {
|
||||
throw FetchError.bootstrapFailed(err)
|
||||
}
|
||||
return try await fetchWithToken(record.accessToken, allowOne401Recovery: true)
|
||||
}
|
||||
|
||||
static func refreshIfBootstrapped() async throws -> CodexUsage? {
|
||||
guard CodexCredentialStore.isBootstrapCompleted else { return nil }
|
||||
if let until = usageBlockedUntil(), until > Date() {
|
||||
throw FetchError.rateLimited(retryAt: until)
|
||||
}
|
||||
do {
|
||||
let token = try await CodexCredentialStore.freshAccessToken()
|
||||
guard let token else { throw FetchError.notBootstrapped }
|
||||
return try await fetchWithToken(token, allowOne401Recovery: true)
|
||||
} catch let err as CodexCredentialStore.StoreError {
|
||||
throw FetchError.credential(err)
|
||||
}
|
||||
}
|
||||
|
||||
static func disconnect() {
|
||||
CodexCredentialStore.resetBootstrap()
|
||||
clearUsageBlock()
|
||||
}
|
||||
|
||||
private static func fetchWithToken(_ token: String, allowOne401Recovery: Bool) async throws -> CodexUsage {
|
||||
var request = URLRequest(url: usageURL)
|
||||
request.httpMethod = "GET"
|
||||
request.timeoutInterval = 30
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
request.setValue("CodeBurn", forHTTPHeaderField: "User-Agent")
|
||||
// chatgpt.com routes the rate_limit envelope per ChatGPT account. Without
|
||||
// this header the response often comes back as a guest-shape document
|
||||
// missing rate_limit entirely, which our decoder then fails on.
|
||||
if let accountId = try? CodexCredentialStore.currentRecord()?.accountId, !accountId.isEmpty {
|
||||
request.setValue(accountId, forHTTPHeaderField: "ChatGPT-Account-Id")
|
||||
}
|
||||
|
||||
let data: Data
|
||||
let response: URLResponse
|
||||
do {
|
||||
(data, response) = try await URLSession.shared.data(for: request)
|
||||
} catch {
|
||||
throw FetchError.network(error)
|
||||
}
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw FetchError.usageHTTPError(-1, nil)
|
||||
}
|
||||
|
||||
switch http.statusCode {
|
||||
case 200:
|
||||
clearUsageBlock()
|
||||
do {
|
||||
return try decodeUsage(data: data)
|
||||
} catch {
|
||||
// Do not log the response body — it's user-account data from
|
||||
// chatgpt.com and is readable by other local users via
|
||||
// `log stream`. The decode error type alone is enough to
|
||||
// bisect schema drift if needed.
|
||||
NSLog("CodeBurn: codex usage decode failed: %@", String(describing: error))
|
||||
throw FetchError.usageDecodeFailed
|
||||
}
|
||||
case 401:
|
||||
if allowOne401Recovery {
|
||||
let newToken = try await CodexCredentialStore.refreshAfter401()
|
||||
return try await fetchWithToken(newToken, allowOne401Recovery: false)
|
||||
}
|
||||
throw FetchError.usageHTTPError(401, String(data: data, encoding: .utf8))
|
||||
case 429:
|
||||
// Honour the RFC Retry-After header when present — ChatGPT's quota
|
||||
// endpoint sometimes sets it to a window shorter than our 5-min
|
||||
// floor, and ignoring it forced users to wait longer than the
|
||||
// server actually wanted.
|
||||
let retryAfter = parseRetryAfterHeader(http.value(forHTTPHeaderField: "Retry-After"))
|
||||
let until = recordUsageRateLimit(retryAfterSeconds: retryAfter)
|
||||
throw FetchError.rateLimited(retryAt: until)
|
||||
default:
|
||||
throw FetchError.usageHTTPError(http.statusCode, String(data: data, encoding: .utf8))
|
||||
}
|
||||
}
|
||||
|
||||
private struct UsageDTO: Decodable {
|
||||
let plan_type: String?
|
||||
let rate_limit: RateLimit?
|
||||
let additional_rate_limits: [AdditionalLimitDTO]?
|
||||
let credits: Credits?
|
||||
|
||||
struct RateLimit: Decodable {
|
||||
let primary_window: WindowDTO?
|
||||
let secondary_window: WindowDTO?
|
||||
}
|
||||
struct AdditionalLimitDTO: Decodable {
|
||||
let limit_name: String?
|
||||
let rate_limit: RateLimit?
|
||||
}
|
||||
struct WindowDTO: Decodable {
|
||||
let used_percent: Double?
|
||||
let reset_at: Int?
|
||||
let limit_window_seconds: Int?
|
||||
}
|
||||
// chatgpt.com sometimes serializes balance as a Double ("balance": 0.0)
|
||||
// and other times as a String ("balance": "0.00"). Mirror CodexBar's
|
||||
// resilient decode so a schema drift on either shape doesn't blow up
|
||||
// the whole quota fetch.
|
||||
struct Credits: Decodable {
|
||||
let balance: Double?
|
||||
enum CodingKeys: String, CodingKey { case balance }
|
||||
init(from decoder: Decoder) throws {
|
||||
let c = try decoder.container(keyedBy: CodingKeys.self)
|
||||
if let n = try? c.decode(Double.self, forKey: .balance) {
|
||||
balance = n
|
||||
} else if let s = try? c.decode(String.self, forKey: .balance), let n = Double(s) {
|
||||
balance = n
|
||||
} else {
|
||||
balance = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static func decodeUsage(data: Data) throws -> CodexUsage {
|
||||
let root = try JSONDecoder().decode(UsageDTO.self, from: data)
|
||||
let additional: [CodexUsage.AdditionalLimit] = (root.additional_rate_limits ?? []).compactMap { dto in
|
||||
guard let name = dto.limit_name, !name.isEmpty else { return nil }
|
||||
return CodexUsage.AdditionalLimit(
|
||||
name: name,
|
||||
primary: makeWindow(dto.rate_limit?.primary_window),
|
||||
secondary: makeWindow(dto.rate_limit?.secondary_window)
|
||||
)
|
||||
}
|
||||
return CodexUsage(
|
||||
plan: CodexUsage.planType(from: root.plan_type),
|
||||
primary: makeWindow(root.rate_limit?.primary_window),
|
||||
secondary: makeWindow(root.rate_limit?.secondary_window),
|
||||
additionalLimits: additional,
|
||||
creditsBalance: root.credits?.balance,
|
||||
fetchedAt: Date()
|
||||
)
|
||||
}
|
||||
|
||||
private static func makeWindow(_ dto: UsageDTO.WindowDTO?) -> CodexUsage.Window? {
|
||||
guard let dto, let used = dto.used_percent, let windowSeconds = dto.limit_window_seconds else {
|
||||
return nil
|
||||
}
|
||||
let resetsAt = dto.reset_at.map { Date(timeIntervalSince1970: TimeInterval($0)) }
|
||||
return CodexUsage.Window(usedPercent: used, resetsAt: resetsAt, limitWindowSeconds: windowSeconds)
|
||||
}
|
||||
|
||||
// MARK: - 429 backoff
|
||||
|
||||
private static func usageBlockedUntil() -> Date? {
|
||||
UserDefaults.standard.object(forKey: usageBlockedUntilKey) as? Date
|
||||
}
|
||||
|
||||
private static func clearUsageBlock() {
|
||||
UserDefaults.standard.removeObject(forKey: usageBlockedUntilKey)
|
||||
}
|
||||
|
||||
@discardableResult
|
||||
/// RFC 7231 says Retry-After is either a delta-seconds or an HTTP-date.
|
||||
/// chatgpt.com appears to send delta-seconds today; we still parse both
|
||||
/// shapes defensively so a future change to HTTP-date doesn't drop us
|
||||
/// onto the silent 5-minute floor.
|
||||
private static func parseRetryAfterHeader(_ value: String?) -> Int? {
|
||||
guard let value = value?.trimmingCharacters(in: .whitespaces), !value.isEmpty else { return nil }
|
||||
if let seconds = Int(value), seconds >= 0 { return seconds }
|
||||
let f = DateFormatter()
|
||||
f.locale = Locale(identifier: "en_US_POSIX")
|
||||
f.timeZone = TimeZone(secondsFromGMT: 0)
|
||||
f.dateFormat = "EEE, dd MMM yyyy HH:mm:ss zzz"
|
||||
if let date = f.date(from: value) {
|
||||
return max(0, Int(date.timeIntervalSinceNow))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
private static func recordUsageRateLimit(retryAfterSeconds: Int?) -> Date {
|
||||
let seconds = max(retryAfterSeconds ?? 300, 60)
|
||||
let until = Date().addingTimeInterval(TimeInterval(seconds))
|
||||
UserDefaults.standard.set(until, forKey: usageBlockedUntilKey)
|
||||
return until
|
||||
}
|
||||
}
|
||||
98
mac/Sources/CodeBurnMenubar/Data/CodexUsage.swift
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
import Foundation
|
||||
|
||||
/// Codex (ChatGPT-mode) live quota snapshot returned by /backend-api/wham/usage.
|
||||
/// Two windows are exposed: primary (typically the 5-hour rolling window) and
|
||||
/// secondary (typically the weekly window). Window size is dynamic per
|
||||
/// account — `limitWindowSeconds` tells us whether it's a 5-hour or 7-day
|
||||
/// boundary so we can label correctly.
|
||||
struct CodexUsage: Sendable, Equatable {
|
||||
enum PlanType: Sendable, Equatable {
|
||||
case guest, free, go, plus, pro, prolite, freeWorkspace, team
|
||||
case business, education, quorum, k12, enterprise, edu
|
||||
/// Captures any plan_type string OpenAI ships that we haven't enumerated
|
||||
/// yet, so the Settings/Plan UI can still show "Plan: <raw>" instead of
|
||||
/// a generic "Subscription" placeholder. Preserves forward compatibility
|
||||
/// without requiring a CodeBurn update for every new tier.
|
||||
case unknown(String)
|
||||
|
||||
var displayName: String {
|
||||
switch self {
|
||||
case .guest: "Guest"
|
||||
case .free: "Free"
|
||||
case .go: "Go"
|
||||
case .plus: "Plus"
|
||||
case .pro: "Pro"
|
||||
case .prolite: "Pro Lite"
|
||||
case .freeWorkspace: "Free Workspace"
|
||||
case .team: "Team"
|
||||
case .business: "Business"
|
||||
case .education: "Education"
|
||||
case .quorum: "Quorum"
|
||||
case .k12: "K-12"
|
||||
case .enterprise: "Enterprise"
|
||||
case .edu: "Edu"
|
||||
case let .unknown(raw): raw.isEmpty ? "Subscription" : raw.capitalized
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct Window: Sendable, Equatable {
|
||||
let usedPercent: Double // 0.0 ... 100.0
|
||||
let resetsAt: Date?
|
||||
let limitWindowSeconds: Int
|
||||
|
||||
/// Human label inferred from window size: 5h, 1d, 7d, etc.
|
||||
var windowLabel: String {
|
||||
switch limitWindowSeconds {
|
||||
case 0..<3600: return "Hourly"
|
||||
case 3600..<7200: return "Hour"
|
||||
case 18000..<19000: return "5-hour"
|
||||
case 86400..<87000: return "Daily"
|
||||
case 604800..<605000: return "Weekly"
|
||||
default:
|
||||
let hours = limitWindowSeconds / 3600
|
||||
if hours < 24 { return "\(hours)-hour" }
|
||||
return "\(hours / 24)-day"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Additional per-model / per-feature quotas exposed by ChatGPT alongside
|
||||
/// the main rate_limit (e.g. "GPT-5.3-Codex-Spark"). Each entry has its
|
||||
/// own primary/secondary windows. Only ones with non-zero utilization are
|
||||
/// surfaced in the popover so users on plans that don't touch these
|
||||
/// features don't see clutter.
|
||||
struct AdditionalLimit: Sendable, Equatable {
|
||||
let name: String
|
||||
let primary: Window?
|
||||
let secondary: Window?
|
||||
}
|
||||
|
||||
let plan: PlanType
|
||||
let primary: Window?
|
||||
let secondary: Window?
|
||||
let additionalLimits: [AdditionalLimit]
|
||||
let creditsBalance: Double?
|
||||
let fetchedAt: Date
|
||||
|
||||
static func planType(from raw: String?) -> PlanType {
|
||||
guard let raw = raw?.lowercased() else { return .unknown("") }
|
||||
switch raw {
|
||||
case "guest": return .guest
|
||||
case "free": return .free
|
||||
case "go": return .go
|
||||
case "plus": return .plus
|
||||
case "pro": return .pro
|
||||
case "prolite", "pro_lite", "pro-lite": return .prolite
|
||||
case "free_workspace": return .freeWorkspace
|
||||
case "team": return .team
|
||||
case "business": return .business
|
||||
case "education": return .education
|
||||
case "quorum": return .quorum
|
||||
case "k12": return .k12
|
||||
case "enterprise": return .enterprise
|
||||
case "edu": return .edu
|
||||
default: return .unknown(raw)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -6,7 +6,7 @@ import Foundation
|
|||
/// Pipe file descriptors pinned forever.
|
||||
private let maxPayloadBytes = 20 * 1024 * 1024
|
||||
private let maxStderrBytes = 256 * 1024
|
||||
private let spawnTimeoutSeconds: UInt64 = 20
|
||||
private let spawnTimeoutSeconds: UInt64 = 45
|
||||
|
||||
enum DataClientError: Error {
|
||||
case spawn(String)
|
||||
|
|
@ -61,21 +61,27 @@ struct DataClient {
|
|||
throw DataClientError.spawn(error.localizedDescription)
|
||||
}
|
||||
|
||||
// Drain both pipes concurrently so a large stderr can't deadlock stdout (the child
|
||||
// blocks on write once the pipe buffer fills). `drain` also enforces a byte cap.
|
||||
async let stdoutData = drain(outPipe.fileHandleForReading, limit: maxPayloadBytes)
|
||||
async let stderrData = drain(errPipe.fileHandleForReading, limit: maxStderrBytes)
|
||||
|
||||
// Wall-clock timeout: if the CLI hangs (parser stuck, disk stall), kill it.
|
||||
let timeoutTask = Task.detached(priority: .utility) {
|
||||
try? await Task.sleep(nanoseconds: spawnTimeoutSeconds * 1_000_000_000)
|
||||
if process.isRunning {
|
||||
process.terminate()
|
||||
NSLog("CodeBurn: CLI subprocess timed out after %llus for %@ — terminating",
|
||||
spawnTimeoutSeconds, subcommand.joined(separator: " "))
|
||||
terminateWithEscalation(process)
|
||||
}
|
||||
}
|
||||
defer { timeoutTask.cancel() }
|
||||
|
||||
let (out, err) = await (stdoutData, stderrData)
|
||||
let outHandle = outPipe.fileHandleForReading
|
||||
let errHandle = errPipe.fileHandleForReading
|
||||
let (out, err) = await withTaskCancellationHandler {
|
||||
async let stdoutData = drain(outHandle, limit: maxPayloadBytes)
|
||||
async let stderrData = drain(errHandle, limit: maxStderrBytes)
|
||||
return await (stdoutData, stderrData)
|
||||
} onCancel: {
|
||||
terminateWithEscalation(process)
|
||||
}
|
||||
try? outHandle.close()
|
||||
try? errHandle.close()
|
||||
process.waitUntilExit()
|
||||
|
||||
if out.count >= maxPayloadBytes {
|
||||
|
|
@ -86,22 +92,45 @@ struct DataClient {
|
|||
return ProcessResult(stdout: out, stderr: stderrString, exitCode: process.terminationStatus)
|
||||
}
|
||||
|
||||
/// Pulls bytes off a pipe until EOF or `limit`. Intentionally uses `availableData`, which
|
||||
/// returns empty on EOF -- no blocking once the child exits.
|
||||
private static func terminateWithEscalation(_ process: Process) {
|
||||
guard process.isRunning else { return }
|
||||
process.terminate()
|
||||
let pid = process.processIdentifier
|
||||
DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 0.5) {
|
||||
if process.isRunning { kill(pid, SIGKILL) }
|
||||
}
|
||||
}
|
||||
|
||||
private static func drain(_ handle: FileHandle, limit: Int) async -> Data {
|
||||
await Task.detached(priority: .utility) {
|
||||
var buffer = Data()
|
||||
while buffer.count < limit {
|
||||
let chunk = handle.availableData
|
||||
if chunk.isEmpty { break }
|
||||
let remaining = limit - buffer.count
|
||||
if chunk.count > remaining {
|
||||
buffer.append(chunk.prefix(remaining))
|
||||
break
|
||||
}
|
||||
buffer.append(chunk)
|
||||
let fd = handle.fileDescriptor
|
||||
let flags = Darwin.fcntl(fd, F_GETFL)
|
||||
if flags >= 0 {
|
||||
_ = Darwin.fcntl(fd, F_SETFL, flags | O_NONBLOCK)
|
||||
} else {
|
||||
NSLog("CodeBurn: fcntl F_GETFL failed on fd %d, drain may block", fd)
|
||||
}
|
||||
|
||||
var buffer = Data()
|
||||
var chunk = [UInt8](repeating: 0, count: 65_536)
|
||||
|
||||
while buffer.count < limit && !Task.isCancelled {
|
||||
let toRead = min(chunk.count, limit - buffer.count)
|
||||
let n = chunk.withUnsafeMutableBufferPointer { ptr in
|
||||
Darwin.read(fd, ptr.baseAddress!, toRead)
|
||||
}
|
||||
return buffer
|
||||
}.value
|
||||
if n > 0 {
|
||||
buffer.append(contentsOf: chunk.prefix(n))
|
||||
} else if n == 0 {
|
||||
break
|
||||
} else if errno == EAGAIN || errno == EWOULDBLOCK {
|
||||
try? await Task.sleep(nanoseconds: 5_000_000)
|
||||
} else if errno == EINTR {
|
||||
continue
|
||||
} else {
|
||||
NSLog("CodeBurn: drain read() failed on fd %d: errno %d", fd, errno)
|
||||
break
|
||||
}
|
||||
}
|
||||
return buffer
|
||||
}
|
||||
}
|
||||
|
|
|
|||
75
mac/Sources/CodeBurnMenubar/Data/QuotaSummary.swift
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
import Foundation
|
||||
|
||||
/// Per-provider live-quota snapshot consumed by the AgentTab progress bar
|
||||
/// and the hover-detail popover. Today only Claude has a real quota source
|
||||
/// (Anthropic /api/oauth/usage); future providers (Cursor, Copilot, etc.)
|
||||
/// will plug in by producing the same struct from their own auth path.
|
||||
struct QuotaSummary: Equatable {
|
||||
enum Connection: Equatable {
|
||||
case connected
|
||||
case disconnected // no credentials present
|
||||
case loading
|
||||
case stale // had data once, current fetch is in flight
|
||||
case transientFailure // backing off; show last-known data dimmed
|
||||
case terminalFailure(reason: String?) // user must reconnect
|
||||
}
|
||||
|
||||
let providerFilter: ProviderFilter
|
||||
let connection: Connection
|
||||
let primary: Window? // weekly utilization, the headline bar
|
||||
let details: [Window] // 5h, weekly, opus, sonnet — full hover card
|
||||
/// Display label for the user's plan (e.g. "Max 20x", "Pro Lite"). Shown
|
||||
/// in the top-right corner of the hover detail popover so users can
|
||||
/// confirm at a glance which subscription is feeding the bar.
|
||||
let planLabel: String?
|
||||
/// Optional footer rows that the popover renders below the window list.
|
||||
/// Used today only by Codex to surface the on-account credits balance,
|
||||
/// but kept generic so future providers can add provider-specific facts
|
||||
/// (e.g. "Anthropic incident in progress", "Cursor team seat").
|
||||
let footerLines: [String]
|
||||
|
||||
struct Window: Equatable {
|
||||
let label: String
|
||||
let percent: Double // 0..1
|
||||
let resetsAt: Date?
|
||||
}
|
||||
|
||||
/// Color band thresholds for the inline chip bar and aggregate menubar
|
||||
/// flame tint. Four tiers so the icon can step from "you're approaching
|
||||
/// your limit" (yellow) through "you're about to hit the wall" (orange)
|
||||
/// to "you're over" (red) — matches what the user expects from a warning
|
||||
/// indicator in the menu bar.
|
||||
static func severity(for percent: Double) -> Severity {
|
||||
if percent >= 1.0 { return .danger }
|
||||
if percent >= 0.9 { return .critical }
|
||||
if percent >= 0.7 { return .warning }
|
||||
return .normal
|
||||
}
|
||||
|
||||
enum Severity {
|
||||
case normal // <70%
|
||||
case warning // 70-90%
|
||||
case critical // 90-100%
|
||||
case danger // >=100%
|
||||
}
|
||||
}
|
||||
|
||||
extension QuotaSummary.Window {
|
||||
/// Human-readable countdown like "2h 11m" or "3d 14h" or "now".
|
||||
var resetsInLabel: String {
|
||||
guard let resetsAt else { return "" }
|
||||
let seconds = max(0, resetsAt.timeIntervalSinceNow)
|
||||
if seconds < 60 { return "now" }
|
||||
let minutes = Int(seconds / 60)
|
||||
let hours = minutes / 60
|
||||
let days = hours / 24
|
||||
if days > 0 { return "\(days)d \(hours % 24)h" }
|
||||
if hours > 0 { return "\(hours)h \(minutes % 60)m" }
|
||||
return "\(minutes)m"
|
||||
}
|
||||
|
||||
var percentLabel: String {
|
||||
let pct = Int((percent * 100).rounded())
|
||||
return "\(pct)%"
|
||||
}
|
||||
}
|
||||
|
|
@ -1,268 +0,0 @@
|
|||
import Foundation
|
||||
import Security
|
||||
|
||||
private let credentialsRelativePath = ".claude/.credentials.json"
|
||||
private let keychainService = "Claude Code-credentials"
|
||||
private let oauthClientID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
|
||||
private let refreshURL = URL(string: "https://platform.claude.com/v1/oauth/token")!
|
||||
private let usageURL = URL(string: "https://api.anthropic.com/api/oauth/usage")!
|
||||
private let betaHeader = "oauth-2025-04-20"
|
||||
private let userAgent = "claude-code/2.1.0"
|
||||
private let requestTimeout: TimeInterval = 30
|
||||
|
||||
private let maxCredentialBytes = 64 * 1024
|
||||
|
||||
enum SubscriptionError: Error, LocalizedError {
|
||||
case noCredentials
|
||||
case credentialsInvalid
|
||||
case refreshFailed(Int, String?)
|
||||
case usageFetchFailed(Int, String?)
|
||||
case decodeFailed(Error)
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case .noCredentials: "No Claude OAuth credentials found"
|
||||
case .credentialsInvalid: "Claude OAuth credentials malformed"
|
||||
case let .refreshFailed(code, body): "Token refresh failed (\(code))\(body.map { ": \($0)" } ?? "")"
|
||||
case let .usageFetchFailed(code, body): "Usage fetch failed (\(code))\(body.map { ": \($0)" } ?? "")"
|
||||
case let .decodeFailed(err): "Decode failed: \(err.localizedDescription)"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct SubscriptionClient {
|
||||
static func fetch() async throws -> SubscriptionUsage {
|
||||
let creds = try loadCredentials()
|
||||
|
||||
// Try the usage call with the existing token first. Only refresh on 401.
|
||||
do {
|
||||
let response = try await fetchUsage(token: creds.accessToken)
|
||||
return mapResponse(response, rawTier: creds.rateLimitTier)
|
||||
} catch SubscriptionError.usageFetchFailed(401, _) {
|
||||
guard let refreshToken = creds.refreshToken, !refreshToken.isEmpty else {
|
||||
throw SubscriptionError.usageFetchFailed(401, "no refresh token available")
|
||||
}
|
||||
let newToken = try await refreshAccessToken(refreshToken: refreshToken)
|
||||
let response = try await fetchUsage(token: newToken)
|
||||
return mapResponse(response, rawTier: creds.rateLimitTier)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Credentials
|
||||
|
||||
private static func loadCredentials() throws -> StoredCredentials {
|
||||
if let data = try readFileCredentials() {
|
||||
return try parseCredentials(data: sanitizeKeychainData(data))
|
||||
}
|
||||
if let creds = try readKeychainCredentials() {
|
||||
return creds
|
||||
}
|
||||
throw SubscriptionError.noCredentials
|
||||
}
|
||||
|
||||
private static func readFileCredentials() throws -> Data? {
|
||||
let url = FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent(credentialsRelativePath)
|
||||
guard FileManager.default.fileExists(atPath: url.path) else { return nil }
|
||||
// SafeFile refuses to follow symlinks and caps the read, so a 6 GB /dev/urandom
|
||||
// masquerading as the creds file can't blow up the app.
|
||||
return try SafeFile.read(from: url.path, maxBytes: maxCredentialBytes)
|
||||
}
|
||||
|
||||
private static func readKeychainCredentials() throws -> StoredCredentials? {
|
||||
let query: [String: Any] = [
|
||||
kSecClass as String: kSecClassGenericPassword,
|
||||
kSecAttrService as String: keychainService,
|
||||
kSecMatchLimit as String: kSecMatchLimitOne,
|
||||
kSecReturnData as String: true,
|
||||
]
|
||||
var result: CFTypeRef?
|
||||
let status = SecItemCopyMatching(query as CFDictionary, &result)
|
||||
if status == errSecItemNotFound { return nil }
|
||||
guard status == errSecSuccess, let data = result as? Data else {
|
||||
NSLog("CodeBurn: keychain query failed status=\(status)")
|
||||
return nil
|
||||
}
|
||||
return try parseCredentials(data: sanitizeKeychainData(data))
|
||||
}
|
||||
|
||||
/// Claude Code's keychain writer line-wraps long string values (newline + leading spaces)
|
||||
/// mid-token, producing JSON with literal control chars and stray spaces inside string
|
||||
/// values. Replace every newline (CR/LF) plus the run of spaces/tabs that follows it.
|
||||
/// Drops both the wrapping in tokens AND pretty-print indentation between fields (both
|
||||
/// produce valid, compact JSON afterward).
|
||||
private static func sanitizeKeychainData(_ data: Data) -> Data {
|
||||
guard var s = String(data: data, encoding: .utf8) else { return data }
|
||||
s = s.replacingOccurrences(of: "\r", with: "")
|
||||
let regex = try? NSRegularExpression(pattern: "\\n[ \\t]*", options: [])
|
||||
if let regex {
|
||||
let range = NSRange(s.startIndex..<s.endIndex, in: s)
|
||||
s = regex.stringByReplacingMatches(in: s, options: [], range: range, withTemplate: "")
|
||||
}
|
||||
s = s.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
return s.data(using: .utf8) ?? data
|
||||
}
|
||||
|
||||
/// Decodes the credential JSON blob. Never logs the blob contents or any slice of it --
|
||||
/// even a partial access token reaching Console.app is a leak, and the byte-window
|
||||
/// diagnostic that used to live here could overlap the `accessToken` field bytes.
|
||||
private static func parseCredentials(data: Data) throws -> StoredCredentials {
|
||||
do {
|
||||
let root = try JSONDecoder().decode(CredentialsRoot.self, from: data)
|
||||
guard let oauth = root.claudeAiOauth else { throw SubscriptionError.credentialsInvalid }
|
||||
let token = oauth.accessToken?.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
guard !token.isEmpty else { throw SubscriptionError.credentialsInvalid }
|
||||
let expiresAt = oauth.expiresAt.map { Date(timeIntervalSince1970: $0 / 1000.0) }
|
||||
return StoredCredentials(
|
||||
accessToken: token,
|
||||
refreshToken: oauth.refreshToken,
|
||||
expiresAt: expiresAt,
|
||||
rateLimitTier: oauth.rateLimitTier
|
||||
)
|
||||
} catch let err as SubscriptionError {
|
||||
throw err
|
||||
} catch {
|
||||
throw SubscriptionError.decodeFailed(error)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Refresh
|
||||
|
||||
private static func refreshAccessToken(refreshToken: String) async throws -> String {
|
||||
var request = URLRequest(url: refreshURL)
|
||||
request.httpMethod = "POST"
|
||||
request.timeoutInterval = requestTimeout
|
||||
request.setValue("application/x-www-form-urlencoded", forHTTPHeaderField: "Content-Type")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
var components = URLComponents()
|
||||
components.queryItems = [
|
||||
URLQueryItem(name: "grant_type", value: "refresh_token"),
|
||||
URLQueryItem(name: "refresh_token", value: refreshToken),
|
||||
URLQueryItem(name: "client_id", value: oauthClientID),
|
||||
]
|
||||
request.httpBody = (components.percentEncodedQuery ?? "").data(using: .utf8)
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw SubscriptionError.refreshFailed(-1, nil)
|
||||
}
|
||||
guard http.statusCode == 200 else {
|
||||
let body = String(data: data, encoding: .utf8)
|
||||
throw SubscriptionError.refreshFailed(http.statusCode, body)
|
||||
}
|
||||
do {
|
||||
let decoded = try JSONDecoder().decode(TokenRefreshResponse.self, from: data)
|
||||
return decoded.accessToken
|
||||
} catch {
|
||||
throw SubscriptionError.decodeFailed(error)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Usage fetch
|
||||
|
||||
private static func fetchUsage(token: String) async throws -> UsageResponse {
|
||||
var request = URLRequest(url: usageURL)
|
||||
request.httpMethod = "GET"
|
||||
request.timeoutInterval = requestTimeout
|
||||
request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization")
|
||||
request.setValue("application/json", forHTTPHeaderField: "Accept")
|
||||
request.setValue(betaHeader, forHTTPHeaderField: "anthropic-beta")
|
||||
request.setValue(userAgent, forHTTPHeaderField: "User-Agent")
|
||||
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse else {
|
||||
throw SubscriptionError.usageFetchFailed(-1, nil)
|
||||
}
|
||||
guard http.statusCode == 200 else {
|
||||
let body = String(data: data, encoding: .utf8)
|
||||
throw SubscriptionError.usageFetchFailed(http.statusCode, body)
|
||||
}
|
||||
do {
|
||||
return try JSONDecoder().decode(UsageResponse.self, from: data)
|
||||
} catch {
|
||||
throw SubscriptionError.decodeFailed(error)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Mapping
|
||||
|
||||
private static func mapResponse(_ r: UsageResponse, rawTier: String?) -> SubscriptionUsage {
|
||||
SubscriptionUsage(
|
||||
tier: SubscriptionUsage.tier(from: rawTier),
|
||||
rawTier: rawTier,
|
||||
fiveHourPercent: r.fiveHour?.utilization,
|
||||
fiveHourResetsAt: parseDate(r.fiveHour?.resetsAt),
|
||||
sevenDayPercent: r.sevenDay?.utilization,
|
||||
sevenDayResetsAt: parseDate(r.sevenDay?.resetsAt),
|
||||
sevenDayOpusPercent: r.sevenDayOpus?.utilization,
|
||||
sevenDayOpusResetsAt: parseDate(r.sevenDayOpus?.resetsAt),
|
||||
sevenDaySonnetPercent: r.sevenDaySonnet?.utilization,
|
||||
sevenDaySonnetResetsAt: parseDate(r.sevenDaySonnet?.resetsAt),
|
||||
fetchedAt: Date()
|
||||
)
|
||||
}
|
||||
|
||||
private static func parseDate(_ s: String?) -> Date? {
|
||||
guard let s, !s.isEmpty else { return nil }
|
||||
let f = ISO8601DateFormatter()
|
||||
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
|
||||
if let d = f.date(from: s) { return d }
|
||||
f.formatOptions = [.withInternetDateTime]
|
||||
return f.date(from: s)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Internal models
|
||||
|
||||
private struct StoredCredentials {
|
||||
let accessToken: String
|
||||
let refreshToken: String?
|
||||
let expiresAt: Date?
|
||||
let rateLimitTier: String?
|
||||
}
|
||||
|
||||
private struct CredentialsRoot: Decodable {
|
||||
let claudeAiOauth: OAuthBlock?
|
||||
}
|
||||
|
||||
private struct OAuthBlock: Decodable {
|
||||
let accessToken: String?
|
||||
let refreshToken: String?
|
||||
let expiresAt: Double?
|
||||
let rateLimitTier: String?
|
||||
}
|
||||
|
||||
private struct TokenRefreshResponse: Decodable {
|
||||
let accessToken: String
|
||||
let refreshToken: String?
|
||||
let expiresIn: Int?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case accessToken = "access_token"
|
||||
case refreshToken = "refresh_token"
|
||||
case expiresIn = "expires_in"
|
||||
}
|
||||
}
|
||||
|
||||
private struct UsageResponse: Decodable {
|
||||
let fiveHour: Window?
|
||||
let sevenDay: Window?
|
||||
let sevenDayOpus: Window?
|
||||
let sevenDaySonnet: Window?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case fiveHour = "five_hour"
|
||||
case sevenDay = "seven_day"
|
||||
case sevenDayOpus = "seven_day_opus"
|
||||
case sevenDaySonnet = "seven_day_sonnet"
|
||||
}
|
||||
}
|
||||
|
||||
private struct Window: Decodable {
|
||||
let utilization: Double?
|
||||
let resetsAt: String?
|
||||
|
||||
enum CodingKeys: String, CodingKey {
|
||||
case utilization
|
||||
case resetsAt = "resets_at"
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,42 @@
|
|||
import Foundation
|
||||
|
||||
/// User-configurable cadence for /api/oauth/usage polling. Mirrors CodexBar's
|
||||
/// "manual / 1m / 2m / 5m / 15m" preset set so users on tight rate-limit
|
||||
/// budgets can dial it down and power users can dial it up. Stored as the raw
|
||||
/// number of seconds in UserDefaults; `manual = 0` means "never auto-refresh".
|
||||
enum SubscriptionRefreshCadence: Int, CaseIterable, Identifiable {
|
||||
case manual = 0
|
||||
case oneMinute = 60
|
||||
case twoMinutes = 120
|
||||
case fiveMinutes = 300
|
||||
case fifteenMinutes = 900
|
||||
|
||||
var id: Int { rawValue }
|
||||
|
||||
var label: String {
|
||||
switch self {
|
||||
case .manual: return "Manual"
|
||||
case .oneMinute: return "1 minute"
|
||||
case .twoMinutes: return "2 minutes"
|
||||
case .fiveMinutes: return "5 minutes"
|
||||
case .fifteenMinutes: return "15 minutes"
|
||||
}
|
||||
}
|
||||
|
||||
static let defaultsKey = "codeburn.claude.refreshCadenceSeconds"
|
||||
static let `default`: SubscriptionRefreshCadence = .twoMinutes
|
||||
|
||||
static var current: SubscriptionRefreshCadence {
|
||||
get {
|
||||
// UserDefaults.integer returns 0 when the key is missing — that
|
||||
// happens to alias `manual`, which is wrong for a fresh install.
|
||||
// Probe with object(forKey:) so we can distinguish "never set"
|
||||
// from "set to manual" and seed the default on first run.
|
||||
if UserDefaults.standard.object(forKey: defaultsKey) == nil {
|
||||
return .default
|
||||
}
|
||||
return SubscriptionRefreshCadence(rawValue: UserDefaults.standard.integer(forKey: defaultsKey)) ?? .default
|
||||
}
|
||||
set { UserDefaults.standard.set(newValue.rawValue, forKey: defaultsKey) }
|
||||
}
|
||||
}
|
||||
|
|
@ -76,6 +76,13 @@ enum SubscriptionSnapshotStore {
|
|||
|
||||
/// Test seam: clear all snapshots.
|
||||
static func resetForTesting() async {
|
||||
await clearAll()
|
||||
}
|
||||
|
||||
/// Wipe all snapshots from disk. Called when the user disconnects so the
|
||||
/// "Based on last cycle" projections do not contaminate a reconnect under
|
||||
/// a different account or tier.
|
||||
static func clearAll() async {
|
||||
await SnapshotLock.shared.run {
|
||||
try? FileManager.default.removeItem(atPath: snapshotsPath())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,28 @@
|
|||
import Foundation
|
||||
import Observation
|
||||
|
||||
private let releasesAPI = "https://api.github.com/repos/getagentseal/codeburn/releases/latest"
|
||||
private let releasesAPI = "https://api.github.com/repos/getagentseal/codeburn/releases?per_page=20"
|
||||
private let checkIntervalSeconds: TimeInterval = 2 * 24 * 60 * 60
|
||||
private let lastCheckKey = "UpdateChecker.lastCheckDate"
|
||||
private let cachedVersionKey = "UpdateChecker.latestVersion"
|
||||
private let updateTimeoutSeconds: UInt64 = 120
|
||||
private let maxUpdateStderrBytes = 64 * 1024
|
||||
|
||||
private final class LockedDataBuffer: @unchecked Sendable {
|
||||
private let lock = NSLock()
|
||||
private var data = Data()
|
||||
|
||||
func append(_ chunk: Data, limit: Int) {
|
||||
lock.withLock {
|
||||
guard data.count < limit else { return }
|
||||
data.append(Data(chunk.prefix(limit - data.count)))
|
||||
}
|
||||
}
|
||||
|
||||
func snapshot() -> Data {
|
||||
lock.withLock { data }
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
|
|
@ -16,14 +34,14 @@ final class UpdateChecker {
|
|||
var updateAvailable: Bool {
|
||||
guard let latest = latestVersion else { return false }
|
||||
let current = currentVersion
|
||||
let normalizedLatest = latest.hasPrefix("v") ? String(latest.dropFirst()) : latest
|
||||
let normalizedCurrent = current.hasPrefix("v") ? String(current.dropFirst()) : current
|
||||
let normalizedLatest = AppVersion.normalize(latest)
|
||||
let normalizedCurrent = AppVersion.normalize(current)
|
||||
guard !normalizedCurrent.isEmpty && normalizedCurrent != "dev" else { return false }
|
||||
return normalizedLatest.compare(normalizedCurrent, options: .numeric) == .orderedDescending
|
||||
}
|
||||
|
||||
var currentVersion: String {
|
||||
Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? ""
|
||||
AppVersion.normalizedBundleShortVersion
|
||||
}
|
||||
|
||||
func checkIfNeeded() async {
|
||||
|
|
@ -37,19 +55,24 @@ final class UpdateChecker {
|
|||
}
|
||||
|
||||
func check() async {
|
||||
updateError = nil
|
||||
guard let url = URL(string: releasesAPI) else { return }
|
||||
var request = URLRequest(url: url)
|
||||
request.setValue("codeburn-menubar-updater", forHTTPHeaderField: "User-Agent")
|
||||
request.setValue("application/vnd.github+json", forHTTPHeaderField: "Accept")
|
||||
|
||||
do {
|
||||
let (data, _) = try await URLSession.shared.data(for: request)
|
||||
let release = try JSONDecoder().decode(GitHubRelease.self, from: data)
|
||||
guard let asset = release.assets.first(where: {
|
||||
$0.name.hasPrefix("CodeBurnMenubar-") && $0.name.hasSuffix(".zip")
|
||||
}) else { return }
|
||||
let (data, response) = try await URLSession.shared.data(for: request)
|
||||
guard let http = response as? HTTPURLResponse, http.statusCode == 200 else {
|
||||
let status = (response as? HTTPURLResponse)?.statusCode ?? -1
|
||||
throw UpdateCheckError.http(status)
|
||||
}
|
||||
let releases = try JSONDecoder().decode([GitHubRelease].self, from: data)
|
||||
guard let resolved = Self.resolveLatestMenubarRelease(in: releases) else {
|
||||
throw UpdateCheckError.missingMenubarAsset
|
||||
}
|
||||
|
||||
let version = asset.name
|
||||
let version = resolved.asset.name
|
||||
.replacingOccurrences(of: "CodeBurnMenubar-", with: "")
|
||||
.replacingOccurrences(of: ".zip", with: "")
|
||||
|
||||
|
|
@ -57,28 +80,58 @@ final class UpdateChecker {
|
|||
UserDefaults.standard.set(Date().timeIntervalSince1970, forKey: lastCheckKey)
|
||||
UserDefaults.standard.set(version, forKey: cachedVersionKey)
|
||||
} catch {
|
||||
updateError = "Update check failed: \(error.localizedDescription)"
|
||||
NSLog("CodeBurn: update check failed: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated static func resolveLatestMenubarRelease(in releases: [GitHubRelease]) -> (release: GitHubRelease, asset: GitHubAsset)? {
|
||||
for release in releases where release.tag_name.hasPrefix("mac-v") {
|
||||
guard let asset = release.assets.first(where: {
|
||||
$0.name.hasPrefix("CodeBurnMenubar-v") && $0.name.hasSuffix(".zip")
|
||||
}) else { continue }
|
||||
guard release.assets.contains(where: { $0.name == "\(asset.name).sha256" }) else { continue }
|
||||
return (release, asset)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func performUpdate() {
|
||||
isUpdating = true
|
||||
updateError = nil
|
||||
|
||||
let process = CodeburnCLI.makeProcess(subcommand: ["menubar", "--force"])
|
||||
let errPipe = Pipe()
|
||||
let errBuffer = LockedDataBuffer()
|
||||
process.standardOutput = FileHandle.nullDevice
|
||||
process.standardError = errPipe
|
||||
errPipe.fileHandleForReading.readabilityHandler = { handle in
|
||||
let chunk = handle.availableData
|
||||
guard !chunk.isEmpty else { return }
|
||||
errBuffer.append(chunk, limit: maxUpdateStderrBytes)
|
||||
}
|
||||
|
||||
let timeoutTask = Task.detached(priority: .utility) {
|
||||
try? await Task.sleep(nanoseconds: updateTimeoutSeconds * 1_000_000_000)
|
||||
if process.isRunning {
|
||||
NSLog("CodeBurn: update subprocess timed out after %llus - terminating", updateTimeoutSeconds)
|
||||
process.terminate()
|
||||
}
|
||||
}
|
||||
|
||||
process.terminationHandler = { [weak self] proc in
|
||||
let errData = errPipe.fileHandleForReading.readDataToEndOfFile()
|
||||
let stderr = String(data: errData, encoding: .utf8) ?? ""
|
||||
timeoutTask.cancel()
|
||||
errPipe.fileHandleForReading.readabilityHandler = nil
|
||||
let stderrData = errBuffer.snapshot()
|
||||
let stderr = Self.sanitizeForDisplay(String(data: stderrData, encoding: .utf8) ?? "")
|
||||
Task { @MainActor in
|
||||
guard let self else { return }
|
||||
self.isUpdating = false
|
||||
if proc.terminationStatus != 0 {
|
||||
self.isUpdating = false
|
||||
self.updateError = stderr.isEmpty ? "Update failed (exit \(proc.terminationStatus))" : stderr
|
||||
NSLog("CodeBurn: update failed (exit \(proc.terminationStatus)): \(stderr)")
|
||||
} else {
|
||||
self.latestVersion = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -91,14 +144,41 @@ final class UpdateChecker {
|
|||
NSLog("CodeBurn: update spawn failed: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
nonisolated private static func sanitizeForDisplay(_ value: String) -> String {
|
||||
var cleaned = value.replacingOccurrences(of: "\u{0000}", with: "")
|
||||
let patterns: [(String, String)] = [
|
||||
(#"sk-ant-[A-Za-z0-9_-]+"#, "sk-ant-***"),
|
||||
(#"sk-[A-Za-z0-9_-]{16,}"#, "sk-***"),
|
||||
(#"eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+"#, "eyJ***"),
|
||||
(#"(?i)Bearer\s+\S+"#, "Bearer ***"),
|
||||
]
|
||||
for (pattern, replacement) in patterns {
|
||||
cleaned = cleaned.replacingOccurrences(of: pattern, with: replacement, options: .regularExpression)
|
||||
}
|
||||
if cleaned.count > 1_000 { cleaned = String(cleaned.prefix(1_000)) + "..." }
|
||||
return cleaned.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
}
|
||||
}
|
||||
|
||||
private struct GitHubRelease: Decodable {
|
||||
enum UpdateCheckError: LocalizedError {
|
||||
case http(Int)
|
||||
case missingMenubarAsset
|
||||
|
||||
var errorDescription: String? {
|
||||
switch self {
|
||||
case let .http(status): "GitHub returned HTTP \(status)."
|
||||
case .missingMenubarAsset: "No mac-v release with a menubar zip and checksum was found."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct GitHubRelease: Decodable {
|
||||
let tag_name: String
|
||||
let assets: [GitHubAsset]
|
||||
}
|
||||
|
||||
private struct GitHubAsset: Decodable {
|
||||
struct GitHubAsset: Decodable {
|
||||
let name: String
|
||||
let browser_download_url: String
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,20 +13,50 @@ enum CodeburnCLI {
|
|||
/// PATH additions for GUI-launched apps, which otherwise get a minimal PATH that misses
|
||||
/// Homebrew and npm global installs.
|
||||
private static let additionalPathEntries = ["/opt/homebrew/bin", "/usr/local/bin"]
|
||||
private static let persistedPathFilename = "codeburn-cli-path.v1"
|
||||
|
||||
/// Returns the argv that launches the CLI. Dev override via `CODEBURN_BIN` is honoured only
|
||||
/// if every whitespace-delimited token passes `safeArgPattern`. Otherwise falls back to the
|
||||
/// plain `codeburn` name (resolved via PATH).
|
||||
static func baseArgv() -> [String] {
|
||||
guard let raw = ProcessInfo.processInfo.environment["CODEBURN_BIN"], !raw.isEmpty else {
|
||||
return ["codeburn"]
|
||||
if ProcessInfo.processInfo.environment["CODEBURN_ALLOW_DEV_BIN"] == "1",
|
||||
let raw = ProcessInfo.processInfo.environment["CODEBURN_BIN"],
|
||||
!raw.isEmpty
|
||||
{
|
||||
let parts = raw.split(separator: " ", omittingEmptySubsequences: true).map(String.init)
|
||||
guard parts.allSatisfy(isSafe) else {
|
||||
NSLog("CodeBurn: refusing unsafe CODEBURN_BIN; using installed codeburn")
|
||||
return installedArgv()
|
||||
}
|
||||
return parts
|
||||
}
|
||||
let parts = raw.split(separator: " ", omittingEmptySubsequences: true).map(String.init)
|
||||
guard parts.allSatisfy(isSafe) else {
|
||||
NSLog("CodeBurn: refusing unsafe CODEBURN_BIN; using default 'codeburn'")
|
||||
return ["codeburn"]
|
||||
|
||||
return installedArgv()
|
||||
}
|
||||
|
||||
private static func installedArgv() -> [String] {
|
||||
if let persisted = persistedCLIPath(), isSafe(persisted), FileManager.default.isExecutableFile(atPath: persisted) {
|
||||
return [persisted]
|
||||
}
|
||||
return parts
|
||||
for candidate in additionalPathEntries.map({ "\($0)/codeburn" }) {
|
||||
if FileManager.default.isExecutableFile(atPath: candidate) {
|
||||
return [candidate]
|
||||
}
|
||||
}
|
||||
return ["codeburn"]
|
||||
}
|
||||
|
||||
private static func persistedCLIPath() -> String? {
|
||||
let support = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first
|
||||
?? FileManager.default.homeDirectoryForCurrentUser.appendingPathComponent("Library/Application Support")
|
||||
let url = support
|
||||
.appendingPathComponent("CodeBurn", isDirectory: true)
|
||||
.appendingPathComponent(persistedPathFilename)
|
||||
guard let value = try? String(contentsOf: url, encoding: .utf8).trimmingCharacters(in: .whitespacesAndNewlines),
|
||||
!value.isEmpty,
|
||||
value.hasPrefix("/")
|
||||
else { return nil }
|
||||
return value
|
||||
}
|
||||
|
||||
/// Builds a `Process` that runs the CLI with the given subcommand args. Uses `/usr/bin/env`
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import SwiftUI
|
||||
import Observation
|
||||
|
||||
enum AccentPreset: String, CaseIterable, Identifiable {
|
||||
case ember = "Ember"
|
||||
|
|
@ -72,6 +73,7 @@ enum AccentPreset: String, CaseIterable, Identifiable {
|
|||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class ThemeState {
|
||||
static let shared = ThemeState()
|
||||
|
||||
|
|
|
|||
|
|
@ -1,28 +1,111 @@
|
|||
import AppKit
|
||||
import SwiftUI
|
||||
|
||||
/// Shared state read by the NSEvent local monitor closure. The closure
|
||||
/// snapshots its captured environment at install time, so SwiftUI @State
|
||||
/// can't be used directly — a reference-type holder keeps the latest hover
|
||||
/// status visible to the monitor across SwiftUI updates.
|
||||
@MainActor
|
||||
final class AgentTabStripScrollState {
|
||||
static let shared = AgentTabStripScrollState()
|
||||
var isStripHovered: Bool = false
|
||||
}
|
||||
|
||||
struct AgentTabStrip: View {
|
||||
@Environment(AppStore.self) private var store
|
||||
@State private var stripViewportWidth: CGFloat = 0
|
||||
@State private var stripContentWidth: CGFloat = 0
|
||||
@State private var scrollWheelMonitor: Any?
|
||||
|
||||
var body: some View {
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 5) {
|
||||
ForEach(visibleFilters) { filter in
|
||||
Button {
|
||||
Task { await store.switchTo(provider: filter) }
|
||||
} label: {
|
||||
AgentTab(
|
||||
filter: filter,
|
||||
cost: cost(for: filter),
|
||||
isActive: store.selectedProvider == filter
|
||||
GeometryReader { viewportGeo in
|
||||
ScrollViewReader { proxy in
|
||||
HStack(spacing: 4) {
|
||||
if isOverflowing {
|
||||
Button {
|
||||
selectAdjacentProvider(direction: -1, proxy: proxy)
|
||||
} label: {
|
||||
Image(systemName: "chevron.left")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.frame(width: 18, height: 18)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(canMoveBackward ? Color.primary : Color.secondary.opacity(0.35))
|
||||
.disabled(!canMoveBackward)
|
||||
.help("Show previous providers")
|
||||
}
|
||||
|
||||
ScrollView(.horizontal, showsIndicators: false) {
|
||||
HStack(spacing: 5) {
|
||||
ForEach(visibleFilters) { filter in
|
||||
AgentTab(
|
||||
filter: filter,
|
||||
cost: cost(for: filter),
|
||||
isActive: store.selectedProvider == filter,
|
||||
quota: store.quotaSummary(for: filter)
|
||||
) {
|
||||
store.switchTo(provider: filter)
|
||||
withAnimation(.easeInOut(duration: 0.18)) {
|
||||
proxy.scrollTo(filter.id, anchor: .center)
|
||||
}
|
||||
}
|
||||
.id(filter.id)
|
||||
}
|
||||
}
|
||||
.background(
|
||||
GeometryReader { contentGeo in
|
||||
Color.clear
|
||||
.onAppear {
|
||||
stripContentWidth = contentGeo.size.width
|
||||
}
|
||||
.onChange(of: contentGeo.size.width) { _, newWidth in
|
||||
stripContentWidth = newWidth
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 4)
|
||||
.onHover { hovering in
|
||||
AgentTabStripScrollState.shared.isStripHovered = hovering
|
||||
}
|
||||
|
||||
if isOverflowing {
|
||||
Button {
|
||||
selectAdjacentProvider(direction: 1, proxy: proxy)
|
||||
} label: {
|
||||
Image(systemName: "chevron.right")
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.frame(width: 18, height: 18)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
.foregroundStyle(canMoveForward ? Color.primary : Color.secondary.opacity(0.35))
|
||||
.disabled(!canMoveForward)
|
||||
.help("Show next providers")
|
||||
}
|
||||
}
|
||||
.onAppear {
|
||||
stripViewportWidth = viewportGeo.size.width
|
||||
installScrollWheelMonitorIfNeeded()
|
||||
withAnimation(.easeInOut(duration: 0.18)) {
|
||||
proxy.scrollTo(store.selectedProvider.id, anchor: .center)
|
||||
}
|
||||
}
|
||||
.onChange(of: viewportGeo.size.width) { _, newWidth in
|
||||
stripViewportWidth = newWidth
|
||||
}
|
||||
.onChange(of: store.selectedProvider) { _, newProvider in
|
||||
withAnimation(.easeInOut(duration: 0.18)) {
|
||||
proxy.scrollTo(newProvider.id, anchor: .center)
|
||||
}
|
||||
}
|
||||
.onDisappear {
|
||||
removeScrollWheelMonitorIfNeeded()
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 12)
|
||||
.padding(.top, 8)
|
||||
.padding(.bottom, 4)
|
||||
}
|
||||
.frame(height: 38)
|
||||
}
|
||||
|
||||
private var todayAll: MenubarPayload {
|
||||
|
|
@ -46,6 +129,9 @@ struct AgentTabStrip: View {
|
|||
private func cost(for filter: ProviderFilter) -> Double? {
|
||||
let data = periodAll
|
||||
if filter == .all { return data.current.cost }
|
||||
if filter == store.selectedProvider, store.hasCachedData {
|
||||
return store.payload.current.cost
|
||||
}
|
||||
let providers = Dictionary(
|
||||
data.current.providers.map { ($0.key.lowercased(), $0.value) },
|
||||
uniquingKeysWith: +
|
||||
|
|
@ -54,23 +140,100 @@ struct AgentTabStrip: View {
|
|||
sum + (providers[key] ?? 0)
|
||||
}
|
||||
}
|
||||
|
||||
private var currentFilterIndex: Int {
|
||||
visibleFilters.firstIndex(of: store.selectedProvider) ?? 0
|
||||
}
|
||||
|
||||
private var canMoveBackward: Bool { currentFilterIndex > 0 }
|
||||
private var canMoveForward: Bool { currentFilterIndex < visibleFilters.count - 1 }
|
||||
private var isOverflowing: Bool { stripContentWidth > (stripViewportWidth - 30) }
|
||||
|
||||
private func selectAdjacentProvider(direction: Int, proxy: ScrollViewProxy) {
|
||||
guard !visibleFilters.isEmpty else { return }
|
||||
let targetIndex = min(max(currentFilterIndex + direction, 0), visibleFilters.count - 1)
|
||||
let target = visibleFilters[targetIndex]
|
||||
store.switchTo(provider: target)
|
||||
withAnimation(.easeInOut(duration: 0.18)) {
|
||||
proxy.scrollTo(target.id, anchor: .center)
|
||||
}
|
||||
}
|
||||
|
||||
/// Standard mouse wheels emit vertical-only scroll deltas, which a horizontal
|
||||
/// `ScrollView` ignores. While the cursor is over the strip we transpose
|
||||
/// vertical-axis scroll fields onto the horizontal axis so the underlying
|
||||
/// NSScrollView receives a real horizontal delta. Trackpad events (precise
|
||||
/// deltas, with native horizontal component) are passed through untouched
|
||||
/// so vertical scrolling elsewhere in the popover is unaffected.
|
||||
private func installScrollWheelMonitorIfNeeded() {
|
||||
guard scrollWheelMonitor == nil else { return }
|
||||
scrollWheelMonitor = NSEvent.addLocalMonitorForEvents(matching: .scrollWheel) { event in
|
||||
guard AgentTabStripScrollState.shared.isStripHovered,
|
||||
!event.hasPreciseScrollingDeltas,
|
||||
abs(event.scrollingDeltaX) < 0.001,
|
||||
abs(event.scrollingDeltaY) > 0,
|
||||
let cg = event.cgEvent?.copy() else {
|
||||
return event
|
||||
}
|
||||
let lineDeltaY = cg.getIntegerValueField(.scrollWheelEventDeltaAxis1)
|
||||
let pointDeltaY = cg.getDoubleValueField(.scrollWheelEventPointDeltaAxis1)
|
||||
let fixedDeltaY = cg.getDoubleValueField(.scrollWheelEventFixedPtDeltaAxis1)
|
||||
cg.setIntegerValueField(.scrollWheelEventDeltaAxis1, value: 0)
|
||||
cg.setDoubleValueField(.scrollWheelEventPointDeltaAxis1, value: 0)
|
||||
cg.setDoubleValueField(.scrollWheelEventFixedPtDeltaAxis1, value: 0)
|
||||
cg.setIntegerValueField(.scrollWheelEventDeltaAxis2, value: lineDeltaY)
|
||||
cg.setDoubleValueField(.scrollWheelEventPointDeltaAxis2, value: pointDeltaY)
|
||||
cg.setDoubleValueField(.scrollWheelEventFixedPtDeltaAxis2, value: fixedDeltaY)
|
||||
return NSEvent(cgEvent: cg) ?? event
|
||||
}
|
||||
}
|
||||
|
||||
private func removeScrollWheelMonitorIfNeeded() {
|
||||
if let monitor = scrollWheelMonitor {
|
||||
NSEvent.removeMonitor(monitor)
|
||||
scrollWheelMonitor = nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct AgentTab: View {
|
||||
let filter: ProviderFilter
|
||||
let cost: Double?
|
||||
let isActive: Bool
|
||||
let quota: QuotaSummary?
|
||||
let onTap: () -> Void
|
||||
|
||||
@State private var hoverPopoverShown = false
|
||||
@State private var hoverEnterTask: DispatchWorkItem?
|
||||
@State private var hoverExitTask: DispatchWorkItem?
|
||||
@State private var clickDismissed = false
|
||||
|
||||
/// Providers whose AgentTab chip reserves a 3pt bar slot underneath the
|
||||
/// label, even when not yet connected. Driven by which providers we
|
||||
/// actually implement live-quota fetching for in AppStore.quotaSummary.
|
||||
static func providerSupportsQuota(_ filter: ProviderFilter) -> Bool {
|
||||
switch filter {
|
||||
case .claude, .codex: return true
|
||||
default: return false
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 5) {
|
||||
Text(filter.rawValue)
|
||||
.font(.system(size: 11.5, weight: .medium))
|
||||
.tracking(-0.05)
|
||||
if let cost, cost > 0 {
|
||||
Text(cost.asCompactCurrency())
|
||||
.font(.codeMono(size: 10.5, weight: .medium))
|
||||
.foregroundStyle(isActive ? AnyShapeStyle(.white.opacity(0.8)) : AnyShapeStyle(.secondary))
|
||||
.tracking(-0.2)
|
||||
VStack(spacing: 3) {
|
||||
HStack(spacing: 5) {
|
||||
Text(filter.rawValue)
|
||||
.font(.system(size: 11.5, weight: .medium))
|
||||
.tracking(-0.05)
|
||||
if let cost, cost > 0 {
|
||||
Text(cost.asCompactCurrency())
|
||||
.font(.codeMono(size: 10.5, weight: .medium))
|
||||
.foregroundStyle(isActive ? AnyShapeStyle(.white.opacity(0.8)) : AnyShapeStyle(.secondary))
|
||||
.tracking(-0.2)
|
||||
}
|
||||
}
|
||||
if quota != nil {
|
||||
AgentTabQuotaBar(quota: quota, isActive: isActive)
|
||||
.frame(height: 3)
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 10)
|
||||
|
|
@ -81,6 +244,233 @@ private struct AgentTab: View {
|
|||
)
|
||||
.foregroundStyle(isActive ? AnyShapeStyle(.white) : AnyShapeStyle(.secondary))
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
hoverPopoverShown = false
|
||||
hoverEnterTask?.cancel()
|
||||
clickDismissed = true
|
||||
onTap()
|
||||
}
|
||||
.onHover { hovering in
|
||||
hoverEnterTask?.cancel()
|
||||
hoverExitTask?.cancel()
|
||||
if !hovering {
|
||||
clickDismissed = false
|
||||
let task = DispatchWorkItem { hoverPopoverShown = false }
|
||||
hoverExitTask = task
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.15, execute: task)
|
||||
} else if !clickDismissed, quota != nil {
|
||||
let task = DispatchWorkItem { hoverPopoverShown = true }
|
||||
hoverEnterTask = task
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + 0.25, execute: task)
|
||||
}
|
||||
}
|
||||
.popover(isPresented: $hoverPopoverShown) {
|
||||
if let quota {
|
||||
QuotaDetailPopover(quota: quota)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Thin progress bar drawn inside an AgentTab chip when that provider has a live quota
|
||||
/// source. Width matches the chip; color shifts green → amber → red at 70% / 90%.
|
||||
private struct AgentTabQuotaBar: View {
|
||||
let quota: QuotaSummary?
|
||||
let isActive: Bool
|
||||
|
||||
var body: some View {
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .leading) {
|
||||
Capsule()
|
||||
.fill(trackColor)
|
||||
if let percent = filledFraction {
|
||||
Capsule()
|
||||
.fill(barColor)
|
||||
.frame(width: max(2, geo.size.width * CGFloat(percent)))
|
||||
.animation(.easeOut(duration: 0.25), value: percent)
|
||||
}
|
||||
if case .terminalFailure = quota?.connection {
|
||||
// Hatched/red strip to telegraph "broken; reconnect needed".
|
||||
Capsule()
|
||||
.fill(Color.red.opacity(0.7))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var filledFraction: Double? {
|
||||
guard let pct = quota?.primary?.percent else { return nil }
|
||||
return min(max(pct, 0), 1)
|
||||
}
|
||||
|
||||
private var barColor: Color {
|
||||
guard let pct = quota?.primary?.percent else { return .clear }
|
||||
switch QuotaSummary.severity(for: pct) {
|
||||
case .normal: return isActive ? Color.white : Color.green.opacity(0.85)
|
||||
case .warning: return Color.yellow
|
||||
case .critical: return Color.orange
|
||||
case .danger: return Color.red
|
||||
}
|
||||
}
|
||||
|
||||
private var trackColor: Color {
|
||||
isActive ? Color.white.opacity(0.20) : Color.secondary.opacity(0.18)
|
||||
}
|
||||
}
|
||||
|
||||
private struct QuotaDetailPopover: View {
|
||||
let quota: QuotaSummary
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 8) {
|
||||
switch quota.connection {
|
||||
case .terminalFailure(let reason):
|
||||
terminalFailureCard(reason: reason)
|
||||
case .disconnected:
|
||||
Text(disconnectedMessage)
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(.secondary)
|
||||
case .loading where quota.details.isEmpty:
|
||||
Text("Loading…")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(.secondary)
|
||||
default:
|
||||
rowsCard
|
||||
}
|
||||
}
|
||||
.padding(12)
|
||||
.frame(width: 260)
|
||||
}
|
||||
|
||||
private var disconnectedMessage: String {
|
||||
switch quota.providerFilter {
|
||||
case .codex: return "Sign in with `codex` (ChatGPT mode) to track quota."
|
||||
case .claude: return "Sign in to Claude Code to track quota."
|
||||
default: return "Sign in to track quota."
|
||||
}
|
||||
}
|
||||
|
||||
private var rowsCard: some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack(spacing: 6) {
|
||||
Text("\(quota.providerFilter.rawValue) usage")
|
||||
.font(.system(size: 11, weight: .semibold))
|
||||
if case .stale = quota.connection {
|
||||
Text("stale")
|
||||
.font(.system(size: 9.5))
|
||||
.foregroundStyle(.secondary)
|
||||
} else if case .transientFailure = quota.connection {
|
||||
Text("retrying")
|
||||
.font(.system(size: 9.5))
|
||||
.foregroundStyle(.orange)
|
||||
}
|
||||
Spacer()
|
||||
if let plan = quota.planLabel, !plan.isEmpty {
|
||||
Text(plan)
|
||||
.font(.system(size: 9.5, weight: .medium))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(1)
|
||||
.truncationMode(.tail)
|
||||
.padding(.horizontal, 6)
|
||||
.padding(.vertical, 2)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 4)
|
||||
.fill(Color.secondary.opacity(0.12))
|
||||
)
|
||||
// Size to content. Plan names are bounded short strings
|
||||
// ("Max 20x", "Pro Lite", "Free Workspace"); a forced
|
||||
// maxWidth was making short labels look stretched.
|
||||
.fixedSize(horizontal: true, vertical: false)
|
||||
}
|
||||
}
|
||||
ForEach(Array(quota.details.enumerated()), id: \.offset) { _, w in
|
||||
QuotaDetailRow(window: w)
|
||||
}
|
||||
if !quota.footerLines.isEmpty {
|
||||
Divider()
|
||||
.padding(.top, 2)
|
||||
ForEach(Array(quota.footerLines.enumerated()), id: \.offset) { _, line in
|
||||
Text(line)
|
||||
.font(.system(size: 10.5))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func terminalFailureCard(reason: String?) -> some View {
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
Text(reconnectTitle)
|
||||
.font(.system(size: 11.5, weight: .semibold))
|
||||
.foregroundStyle(.red)
|
||||
Text(reason ?? defaultReconnectReason)
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
Text(reconnectInstruction)
|
||||
.font(.system(size: 10.5))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
|
||||
private var reconnectTitle: String {
|
||||
switch quota.providerFilter {
|
||||
case .codex: return "Reconnect Codex"
|
||||
default: return "Reconnect Claude"
|
||||
}
|
||||
}
|
||||
|
||||
private var defaultReconnectReason: String {
|
||||
switch quota.providerFilter {
|
||||
case .codex: return "Refresh token rejected by OpenAI."
|
||||
default: return "Refresh token rejected by Anthropic."
|
||||
}
|
||||
}
|
||||
|
||||
private var reconnectInstruction: String {
|
||||
switch quota.providerFilter {
|
||||
case .codex: return "Run `codex login` in your terminal, then click Reconnect."
|
||||
default: return "Open Claude Code in your terminal and type `/login`, then click Reconnect."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct QuotaDetailRow: View {
|
||||
let window: QuotaSummary.Window
|
||||
|
||||
var body: some View {
|
||||
HStack(spacing: 8) {
|
||||
Text(window.label)
|
||||
.font(.system(size: 10.5))
|
||||
.frame(width: 92, alignment: .leading)
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .leading) {
|
||||
Capsule().fill(Color.secondary.opacity(0.18))
|
||||
Capsule()
|
||||
.fill(barColor)
|
||||
.frame(width: max(2, geo.size.width * CGFloat(min(max(window.percent, 0), 1))))
|
||||
}
|
||||
}
|
||||
.frame(height: 4)
|
||||
Text(window.percentLabel)
|
||||
.font(.codeMono(size: 10.5, weight: .medium))
|
||||
.frame(width: 36, alignment: .trailing)
|
||||
if !window.resetsInLabel.isEmpty {
|
||||
Text(window.resetsInLabel)
|
||||
.font(.codeMono(size: 10))
|
||||
.foregroundStyle(.secondary)
|
||||
.frame(width: 50, alignment: .trailing)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var barColor: Color {
|
||||
switch QuotaSummary.severity(for: window.percent) {
|
||||
case .normal: return Color.green.opacity(0.85)
|
||||
case .warning: return Color.yellow
|
||||
case .critical: return Color.orange
|
||||
case .danger: return Color.red
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -89,19 +479,26 @@ extension ProviderFilter {
|
|||
switch self {
|
||||
case .all: return Theme.brandAccent
|
||||
case .claude: return Theme.categoricalClaude
|
||||
case .cline: return Color(red: 0x23/255.0, green: 0x8A/255.0, blue: 0x7E/255.0)
|
||||
case .codex: return Theme.categoricalCodex
|
||||
case .cursor: return Theme.categoricalCursor
|
||||
case .cursorAgent: return Color(red: 0x4E/255.0, green: 0xC9/255.0, blue: 0xB0/255.0)
|
||||
case .copilot: return Color(red: 0x6D/255.0, green: 0x8F/255.0, blue: 0xA6/255.0)
|
||||
case .droid: return Color(red: 0x7C/255.0, green: 0x3A/255.0, blue: 0xED/255.0)
|
||||
case .gemini: return Color(red: 0x44/255.0, green: 0x85/255.0, blue: 0xF4/255.0)
|
||||
case .ibmBob: return Color(red: 0x0F/255.0, green: 0x62/255.0, blue: 0xFE/255.0)
|
||||
case .kiloCode: return Color(red: 0x00/255.0, green: 0x96/255.0, blue: 0x88/255.0)
|
||||
case .kiro: return Color(red: 0x4A/255.0, green: 0x9E/255.0, blue: 0xC4/255.0)
|
||||
case .kimi: return Color(red: 0xA4/255.0, green: 0xC6/255.0, blue: 0x39/255.0)
|
||||
case .openclaw: return Color(red: 0xDA/255.0, green: 0x70/255.0, blue: 0x56/255.0)
|
||||
case .opencode: return Color(red: 0x5B/255.0, green: 0x83/255.0, blue: 0x5B/255.0)
|
||||
case .pi: return Color(red: 0xB2/255.0, green: 0x6B/255.0, blue: 0x3D/255.0)
|
||||
case .qwen: return Color(red: 0x61/255.0, green: 0x5E/255.0, blue: 0xEB/255.0)
|
||||
case .omp: return Color(red: 0x8B/255.0, green: 0x5C/255.0, blue: 0xB0/255.0)
|
||||
case .rooCode: return Color(red: 0x4C/255.0, green: 0xAF/255.0, blue: 0x50/255.0)
|
||||
case .crush: return Color(red: 0xE0/255.0, green: 0x6C/255.0, blue: 0x9F/255.0)
|
||||
case .antigravity: return Color(red: 0xFF/255.0, green: 0x7A/255.0, blue: 0x45/255.0)
|
||||
case .goose: return Color(red: 0xB7/255.0, green: 0x8D/255.0, blue: 0x52/255.0)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -213,11 +213,11 @@ private struct HistoryStats {
|
|||
|
||||
private func computeHistoryStats(history: [DailyHistoryEntry]) -> HistoryStats {
|
||||
var calendar = Calendar(identifier: .gregorian)
|
||||
calendar.timeZone = TimeZone(identifier: "UTC")!
|
||||
calendar.timeZone = .current
|
||||
let formatter: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.dateFormat = "yyyy-MM-dd"
|
||||
f.timeZone = TimeZone(identifier: "UTC")
|
||||
f.timeZone = .current
|
||||
return f
|
||||
}()
|
||||
let now = Date()
|
||||
|
|
|
|||
|
|
@ -5,6 +5,36 @@ private let trendBarWidth: CGFloat = 13
|
|||
private let trendBarGap: CGFloat = 4
|
||||
private let trendChartHeight: CGFloat = 90
|
||||
|
||||
// Cached formatters and a calendar to avoid allocating fresh ones on every
|
||||
// SwiftUI body re-eval. Hover scrubbing on the trend bars triggers many
|
||||
// re-evals per second; a fresh DateFormatter / Calendar each time was a
|
||||
// measurable hot spot.
|
||||
private let yyyymmdd: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.dateFormat = "yyyy-MM-dd"
|
||||
f.timeZone = .current
|
||||
return f
|
||||
}()
|
||||
|
||||
private let prettyDayFormat: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.dateFormat = "EEE MMM d"
|
||||
return f
|
||||
}()
|
||||
|
||||
private let mmmDayFormat: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.dateFormat = "MMM d"
|
||||
f.timeZone = .current
|
||||
return f
|
||||
}()
|
||||
|
||||
private let gregorianCalendar: Calendar = {
|
||||
var c = Calendar(identifier: .gregorian)
|
||||
c.timeZone = .current
|
||||
return c
|
||||
}()
|
||||
|
||||
/// Three switchable insight visualizations: Calendar (this month), Forecast (burn rate),
|
||||
/// Pulse (efficiency KPIs). Pills at top toggle between them.
|
||||
struct HeatmapSection: View {
|
||||
|
|
@ -25,10 +55,14 @@ struct HeatmapSection: View {
|
|||
}
|
||||
|
||||
private var visibleModes: [InsightMode] {
|
||||
// Plan sources from Claude's OAuth usage endpoint, so it only makes sense when the
|
||||
// Claude provider tab is selected. Hidden on All/Cursor/Codex/etc.
|
||||
// Plan sources from a provider's OAuth usage endpoint. Currently
|
||||
// implemented for Claude (Anthropic) and Codex (ChatGPT). Hidden on
|
||||
// All / Cursor / Droid / Gemini / Copilot until those providers ship
|
||||
// their own quota data sources.
|
||||
InsightMode.allCases.filter { mode in
|
||||
if mode == .plan { return store.selectedProvider == .claude }
|
||||
if mode == .plan {
|
||||
return store.selectedProvider == .claude || store.selectedProvider == .codex
|
||||
}
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
|
@ -42,7 +76,12 @@ struct HeatmapSection: View {
|
|||
@ViewBuilder
|
||||
private var content: some View {
|
||||
switch store.selectedInsight {
|
||||
case .plan: PlanInsight(usage: store.subscription)
|
||||
case .plan:
|
||||
if store.selectedProvider == .codex {
|
||||
CodexPlanInsight()
|
||||
} else {
|
||||
PlanInsight(usage: store.subscription)
|
||||
}
|
||||
case .trend: TrendInsight(days: store.payload.history.daily)
|
||||
case .forecast: ForecastInsight(days: store.payload.history.daily)
|
||||
case .pulse: PulseInsight(payload: store.payload)
|
||||
|
|
@ -342,13 +381,8 @@ private struct BarTooltipCard: View {
|
|||
}
|
||||
|
||||
private func prettyDate(_ ymd: String) -> String {
|
||||
let parser = DateFormatter()
|
||||
parser.dateFormat = "yyyy-MM-dd"
|
||||
parser.timeZone = .current
|
||||
guard let date = parser.date(from: ymd) else { return ymd }
|
||||
let display = DateFormatter()
|
||||
display.dateFormat = "EEE MMM d"
|
||||
return display.string(from: date)
|
||||
guard let date = yyyymmdd.date(from: ymd) else { return ymd }
|
||||
return prettyDayFormat.string(from: date)
|
||||
}
|
||||
|
||||
private struct MiniStat: View {
|
||||
|
|
@ -370,7 +404,7 @@ private struct MiniStat: View {
|
|||
}
|
||||
|
||||
private struct TrendBar: Identifiable {
|
||||
let id = UUID()
|
||||
var id: String { date }
|
||||
let date: String
|
||||
let cost: Double
|
||||
let inputTokens: Double
|
||||
|
|
@ -391,14 +425,8 @@ private struct TrendStats {
|
|||
}
|
||||
|
||||
private func buildTrendBars(from days: [DailyHistoryEntry]) -> [TrendBar] {
|
||||
var calendar = Calendar(identifier: .gregorian)
|
||||
calendar.timeZone = .current
|
||||
let formatter: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.dateFormat = "yyyy-MM-dd"
|
||||
f.timeZone = .current
|
||||
return f
|
||||
}()
|
||||
let calendar = gregorianCalendar
|
||||
let formatter = yyyymmdd
|
||||
let entryByDate = Dictionary(days.map { ($0.date, $0) }, uniquingKeysWith: { _, new in new })
|
||||
let today = calendar.startOfDay(for: Date())
|
||||
let todayKey = formatter.string(from: today)
|
||||
|
|
@ -426,14 +454,8 @@ private func computeTrendStats(bars: [TrendBar], allDays: [DailyHistoryEntry]) -
|
|||
let avg = bars.isEmpty ? 0 : total / Double(bars.count)
|
||||
let peak = bars.filter { $0.cost > 0 }.max(by: { $0.cost < $1.cost })
|
||||
|
||||
var calendar = Calendar(identifier: .gregorian)
|
||||
calendar.timeZone = .current
|
||||
let formatter: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.dateFormat = "yyyy-MM-dd"
|
||||
f.timeZone = .current
|
||||
return f
|
||||
}()
|
||||
let calendar = gregorianCalendar
|
||||
let formatter = yyyymmdd
|
||||
let today = calendar.startOfDay(for: Date())
|
||||
let priorWindowStart = calendar.date(byAdding: .day, value: -(2 * trendDays - 1), to: today)
|
||||
let thisWindowStart = calendar.date(byAdding: .day, value: -(trendDays - 1), to: today)
|
||||
|
|
@ -515,7 +537,7 @@ private struct ForecastInsight: View {
|
|||
guard previous > 0 else { return "no prior month" }
|
||||
let diff = ((projection - previous) / previous) * 100
|
||||
let sign = diff >= 0 ? "+" : ""
|
||||
return "\(sign)\(String(format: "%.0f", diff))% vs last month ($\(String(format: "%.0f", previous)))"
|
||||
return "\(sign)\(String(format: "%.0f", diff))% vs last month (\(previous.asCompactCurrency()))"
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -546,14 +568,8 @@ private struct ForecastStats {
|
|||
}
|
||||
|
||||
private func computeForecast(days: [DailyHistoryEntry]) -> ForecastStats {
|
||||
var calendar = Calendar(identifier: .gregorian)
|
||||
calendar.timeZone = .current
|
||||
let formatter: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.dateFormat = "yyyy-MM-dd"
|
||||
f.timeZone = .current
|
||||
return f
|
||||
}()
|
||||
let calendar = gregorianCalendar
|
||||
let formatter = yyyymmdd
|
||||
let now = Date()
|
||||
let comps = calendar.dateComponents([.year, .month, .day], from: now)
|
||||
guard
|
||||
|
|
@ -793,24 +809,13 @@ private struct AllStats {
|
|||
let historyDayCount: Int
|
||||
}
|
||||
|
||||
private func computeAllStats(payload: MenubarPayload) -> AllStats {
|
||||
@MainActor private func computeAllStats(payload: MenubarPayload) -> AllStats {
|
||||
let history = payload.history.daily
|
||||
let favoriteModel = payload.current.topModels.first?.name ?? "—"
|
||||
|
||||
var calendar = Calendar(identifier: .gregorian)
|
||||
calendar.timeZone = .current
|
||||
let formatter: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.dateFormat = "yyyy-MM-dd"
|
||||
f.timeZone = .current
|
||||
return f
|
||||
}()
|
||||
let displayFormatter: DateFormatter = {
|
||||
let f = DateFormatter()
|
||||
f.dateFormat = "MMM d"
|
||||
f.timeZone = .current
|
||||
return f
|
||||
}()
|
||||
let calendar = gregorianCalendar
|
||||
let formatter = yyyymmdd
|
||||
let displayFormatter = mmmDayFormat
|
||||
|
||||
let now = Date()
|
||||
let today = calendar.startOfDay(for: now)
|
||||
|
|
@ -848,13 +853,21 @@ private func computeAllStats(payload: MenubarPayload) -> AllStats {
|
|||
|
||||
var longestStreak = 0
|
||||
var running = 0
|
||||
let sortedDates = history.map(\.date).sorted()
|
||||
for date in sortedDates {
|
||||
if (costByDate[date] ?? 0) > 0 {
|
||||
running += 1
|
||||
longestStreak = max(longestStreak, running)
|
||||
} else {
|
||||
running = 0
|
||||
if let firstDate = history.map(\.date).min(),
|
||||
let lastDate = history.map(\.date).max(),
|
||||
let start = formatter.date(from: firstDate),
|
||||
let end = formatter.date(from: lastDate) {
|
||||
var cursor = start
|
||||
while cursor <= end {
|
||||
let key = formatter.string(from: cursor)
|
||||
if (costByDate[key] ?? 0) > 0 {
|
||||
running += 1
|
||||
longestStreak = max(longestStreak, running)
|
||||
} else {
|
||||
running = 0
|
||||
}
|
||||
guard let next = calendar.date(byAdding: .day, value: 1, to: cursor) else { break }
|
||||
cursor = next
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -887,28 +900,36 @@ private struct PlanInsight: View {
|
|||
var body: some View {
|
||||
Group {
|
||||
switch store.subscriptionLoadState {
|
||||
case .idle:
|
||||
PlanIdleView()
|
||||
case .loading:
|
||||
case .notBootstrapped:
|
||||
PlanConnectView { Task { await store.bootstrapSubscription() } }
|
||||
case .bootstrapping:
|
||||
PlanLoadingView()
|
||||
case .loading:
|
||||
if let usage {
|
||||
loadedBody(usage: usage)
|
||||
} else {
|
||||
PlanLoadingView()
|
||||
}
|
||||
case .noCredentials:
|
||||
PlanNoCredentialsView()
|
||||
case .failed:
|
||||
PlanFailedView(error: store.subscriptionError)
|
||||
case .transientFailure:
|
||||
if let usage {
|
||||
loadedBody(usage: usage)
|
||||
} else {
|
||||
PlanFailedView(error: store.subscriptionError ?? "Anthropic temporarily unreachable — retrying.")
|
||||
}
|
||||
case let .terminalFailure(reason):
|
||||
PlanReconnectView(reason: reason) { Task { await store.bootstrapSubscription() } }
|
||||
case .loaded:
|
||||
if let usage {
|
||||
loadedBody(usage: usage)
|
||||
} else {
|
||||
PlanNoCredentialsView()
|
||||
PlanLoadingView()
|
||||
}
|
||||
}
|
||||
}
|
||||
.task {
|
||||
// Lazy-trigger fetch the first time Plan is opened.
|
||||
if store.subscriptionLoadState == .idle {
|
||||
await store.refreshSubscription()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
|
|
@ -1006,26 +1027,6 @@ private struct PlanInsight: View {
|
|||
|
||||
// MARK: - Plan empty/loading/failure states
|
||||
|
||||
private struct PlanIdleView: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 8) {
|
||||
Image(systemName: "person.crop.circle.dashed")
|
||||
.font(.system(size: 22))
|
||||
.foregroundStyle(.tertiary)
|
||||
Text("Loading your plan...")
|
||||
.font(.system(size: 11.5, weight: .medium))
|
||||
.foregroundStyle(.secondary)
|
||||
Text("macOS may ask permission to read your Claude Code credentials.")
|
||||
.font(.system(size: 10))
|
||||
.foregroundStyle(.tertiary)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: 260)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 16)
|
||||
}
|
||||
}
|
||||
|
||||
private struct PlanLoadingView: View {
|
||||
var body: some View {
|
||||
VStack(spacing: 8) {
|
||||
|
|
@ -1043,27 +1044,27 @@ private struct PlanNoCredentialsView: View {
|
|||
@Environment(AppStore.self) private var store
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 8) {
|
||||
VStack(spacing: 10) {
|
||||
Image(systemName: "key.slash")
|
||||
.font(.system(size: 20))
|
||||
.font(.system(size: 24))
|
||||
.foregroundStyle(.tertiary)
|
||||
Text("No Claude subscription connected")
|
||||
Text("No Claude credentials found")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(.primary)
|
||||
Text("Sign in with Claude Code, then click Retry.")
|
||||
Text("Sign in with Claude Code first: open `claude` in your terminal and type `/login`. Then click Try Again.")
|
||||
.font(.system(size: 10.5))
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: 260)
|
||||
Button("Retry") {
|
||||
Task { await store.refreshSubscription() }
|
||||
.frame(maxWidth: 280)
|
||||
Button("Try Again") {
|
||||
Task { await store.bootstrapSubscription() }
|
||||
}
|
||||
.controlSize(.small)
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(Theme.brandAccent)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 14)
|
||||
.padding(.vertical, 16)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -1099,6 +1100,175 @@ private struct PlanFailedView: View {
|
|||
}
|
||||
}
|
||||
|
||||
/// Shown the very first time a user opens the Plan tab. Clicking Connect is the
|
||||
/// only path to triggering the macOS keychain prompt for Claude Code credentials —
|
||||
/// the menubar app does not touch the keychain at startup.
|
||||
private struct PlanConnectView: View {
|
||||
let onConnect: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 10) {
|
||||
Image(systemName: "link.circle")
|
||||
.font(.system(size: 26))
|
||||
.foregroundStyle(Theme.brandAccent)
|
||||
Text("Connect Claude subscription")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(.primary)
|
||||
Text("CodeBurn will read your Claude Code credentials once. macOS will ask permission. After that, the live quota bar shows next to the Claude tab and updates automatically.")
|
||||
.font(.system(size: 10.5))
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: 280)
|
||||
Button("Connect", action: onConnect)
|
||||
.controlSize(.small)
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(Theme.brandAccent)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 18)
|
||||
}
|
||||
}
|
||||
|
||||
/// Shown when the refresh token has been invalidated (typically because the user
|
||||
/// re-authenticated on another device). Clicking the button re-runs bootstrap,
|
||||
/// which reads Claude's credentials source again and writes a fresh copy to our
|
||||
/// own keychain item.
|
||||
private struct PlanReconnectView: View {
|
||||
let reason: String?
|
||||
let onReconnect: () -> Void
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 10) {
|
||||
Image(systemName: "arrow.triangle.2.circlepath.circle")
|
||||
.font(.system(size: 24))
|
||||
.foregroundStyle(.red)
|
||||
Text("Reconnect Claude")
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
.foregroundStyle(.primary)
|
||||
Text(reason ?? "Your Claude session has expired. Open Claude Code in your terminal and type `/login`, then click Reconnect.")
|
||||
.font(.system(size: 10.5))
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: 280)
|
||||
.lineLimit(3)
|
||||
Button("Reconnect", action: onReconnect)
|
||||
.controlSize(.small)
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(.red)
|
||||
}
|
||||
.frame(maxWidth: .infinity)
|
||||
.padding(.vertical, 16)
|
||||
}
|
||||
}
|
||||
|
||||
/// Plan tab for Codex. Mirrors PlanInsight's layout but reads from
|
||||
/// store.codexUsage / store.codexLoadState. We deliberately skip the
|
||||
/// "On pace at reset" projection here — that math is fed by local
|
||||
/// per-message Claude spend extrapolated against the API quota windows;
|
||||
/// our local Codex spend isn't an apples-to-apples signal for the
|
||||
/// ChatGPT-subscription rate windows reported by wham/usage. Add when
|
||||
/// we wire a comparable extrapolator.
|
||||
private struct CodexPlanInsight: View {
|
||||
@Environment(AppStore.self) private var store
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
switch store.codexLoadState {
|
||||
case .notBootstrapped:
|
||||
PlanConnectView { Task { await store.bootstrapCodex() } }
|
||||
case .bootstrapping:
|
||||
PlanLoadingView()
|
||||
case .loading:
|
||||
if let usage = store.codexUsage {
|
||||
loadedBody(usage: usage)
|
||||
} else {
|
||||
PlanLoadingView()
|
||||
}
|
||||
case .noCredentials:
|
||||
PlanNoCredentialsView()
|
||||
case .failed:
|
||||
PlanFailedView(error: store.codexError)
|
||||
case .transientFailure:
|
||||
if let usage = store.codexUsage {
|
||||
loadedBody(usage: usage)
|
||||
} else {
|
||||
PlanFailedView(error: store.codexError ?? "ChatGPT temporarily unreachable — retrying.")
|
||||
}
|
||||
case let .terminalFailure(reason):
|
||||
PlanReconnectView(reason: reason) { Task { await store.bootstrapCodex() } }
|
||||
case .loaded:
|
||||
if let usage = store.codexUsage {
|
||||
loadedBody(usage: usage)
|
||||
} else {
|
||||
PlanLoadingView()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private func loadedBody(usage: CodexUsage) -> some View {
|
||||
VStack(alignment: .leading, spacing: 10) {
|
||||
HStack(alignment: .firstTextBaseline) {
|
||||
Text(usage.plan.displayName)
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.foregroundStyle(.primary)
|
||||
Spacer()
|
||||
if let resetsAt = (usage.primary ?? usage.secondary)?.resetsAt {
|
||||
Text("Resets \(relativeReset(resetsAt))")
|
||||
.font(.system(size: 10.5))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
}
|
||||
if let primary = usage.primary {
|
||||
UtilizationRow(
|
||||
label: "\(primary.windowLabel) window",
|
||||
percent: primary.usedPercent,
|
||||
resetsAt: primary.resetsAt,
|
||||
projection: nil
|
||||
)
|
||||
}
|
||||
if let secondary = usage.secondary {
|
||||
UtilizationRow(
|
||||
label: "\(secondary.windowLabel) window",
|
||||
percent: secondary.usedPercent,
|
||||
resetsAt: secondary.resetsAt,
|
||||
projection: nil
|
||||
)
|
||||
}
|
||||
// Surface non-zero per-model rate limits (Codex Spark, etc.) so
|
||||
// power users see them; idle ones stay collapsed.
|
||||
ForEach(Array(usage.additionalLimits.enumerated()), id: \.offset) { _, limit in
|
||||
if let p = limit.primary, p.usedPercent > 0 {
|
||||
UtilizationRow(
|
||||
label: "\(limit.name) · \(p.windowLabel)",
|
||||
percent: p.usedPercent,
|
||||
resetsAt: p.resetsAt,
|
||||
projection: nil
|
||||
)
|
||||
}
|
||||
if let s = limit.secondary, s.usedPercent > 0 {
|
||||
UtilizationRow(
|
||||
label: "\(limit.name) · \(s.windowLabel)",
|
||||
percent: s.usedPercent,
|
||||
resetsAt: s.resetsAt,
|
||||
projection: nil
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.top, 4)
|
||||
.padding(.bottom, 8)
|
||||
}
|
||||
|
||||
private func relativeReset(_ date: Date) -> String {
|
||||
let f = RelativeDateTimeFormatter()
|
||||
f.unitsStyle = .short
|
||||
return f.localizedString(for: date, relativeTo: Date())
|
||||
}
|
||||
}
|
||||
|
||||
private struct WindowProjection {
|
||||
enum Source { case linear, historicalBaseline }
|
||||
let percent: Double
|
||||
|
|
|
|||
|
|
@ -41,9 +41,30 @@ struct MenuBarContent: View {
|
|||
}
|
||||
}
|
||||
|
||||
if store.isLoading {
|
||||
BurnLoadingOverlay(periodLabel: store.selectedPeriod.rawValue)
|
||||
// Overlay fires only on cold cache for the current key. This
|
||||
// avoids the 1-frame `$0.00` flash on first-time period/provider
|
||||
// switches. When the fetch fails (CLI subprocess timeout, parse
|
||||
// error, etc.), surface a retry card instead of leaving the
|
||||
// user stuck on a perpetual "Loading..." spinner.
|
||||
if !store.hasCachedData {
|
||||
if store.isCurrentKeyLoading || !store.hasAttemptedCurrentKeyLoad {
|
||||
BurnLoadingOverlay(periodLabel: store.selectedPeriod.rawValue)
|
||||
.transition(.opacity)
|
||||
} else if let err = store.lastError {
|
||||
FetchErrorOverlay(
|
||||
error: err,
|
||||
periodLabel: store.selectedPeriod.rawValue,
|
||||
retry: { Task { await store.refresh(includeOptimize: false, force: true, showLoading: true) } }
|
||||
)
|
||||
.transition(.opacity)
|
||||
} else {
|
||||
FetchErrorOverlay(
|
||||
error: "The last refresh stopped before returning data. CodeBurn will keep retrying, or you can retry now.",
|
||||
periodLabel: store.selectedPeriod.rawValue,
|
||||
retry: { Task { await store.refresh(includeOptimize: false, force: true, showLoading: true) } }
|
||||
)
|
||||
.transition(.opacity)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(height: 520)
|
||||
|
|
@ -55,20 +76,34 @@ struct MenuBarContent: View {
|
|||
|
||||
StarBanner()
|
||||
}
|
||||
.id(store.accentPreset)
|
||||
}
|
||||
|
||||
/// True when a specific provider tab is selected and that provider has no spend in the
|
||||
/// currently selected period. The .all tab is exempt -- it always shows aggregated data.
|
||||
private var isFilteredEmpty: Bool {
|
||||
guard store.selectedProvider != .all else { return false }
|
||||
return store.payload.current.cost <= 0 && store.payload.current.calls == 0
|
||||
if store.payload.current.cost > 0 || store.payload.current.calls > 0 { return false }
|
||||
if providerHasCostInAllPayload { return false }
|
||||
return true
|
||||
}
|
||||
|
||||
private var providerHasCostInAllPayload: Bool {
|
||||
guard let allPayload = store.periodAllPayload else { return false }
|
||||
let providers = Dictionary(
|
||||
allPayload.current.providers.map { ($0.key.lowercased(), $0.value) },
|
||||
uniquingKeysWith: +
|
||||
)
|
||||
return store.selectedProvider.providerKeys.contains { key in
|
||||
(providers[key] ?? 0) > 0
|
||||
}
|
||||
}
|
||||
|
||||
/// Show the tab row whenever the CLI detected at least one AI coding tool installed
|
||||
/// on this machine. Hidden only when nothing is detected, which means there's
|
||||
/// nothing to filter by anyway.
|
||||
private var showAgentTabs: Bool {
|
||||
// Sticky: once any cached payload has reported providers, keep the tab strip
|
||||
// visible. Without this, the strip disappears for one frame on a period
|
||||
// switch when the new key's payload is still empty.
|
||||
if store.hasAnyProvidersInCache { return true }
|
||||
let payload = store.todayPayload ?? store.payload
|
||||
return !payload.current.providers.isEmpty
|
||||
}
|
||||
|
|
@ -99,11 +134,54 @@ private struct EmptyProviderState: View {
|
|||
case .sevenDays: "the last 7 days"
|
||||
case .thirtyDays: "the last 30 days"
|
||||
case .month: "this month"
|
||||
case .all: "all time"
|
||||
case .all: "the last 6 months"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Shown when a fetch failed and the cache is still empty for this key. The
|
||||
/// user previously sat on the "Loading…" spinner forever — the popover had
|
||||
/// no path to recover beyond the next 30s tick (which would just re-fail).
|
||||
/// Now they see what broke and can retry directly.
|
||||
private struct FetchErrorOverlay: View {
|
||||
let error: String
|
||||
let periodLabel: String
|
||||
let retry: () -> Void
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
Rectangle().fill(.ultraThinMaterial)
|
||||
VStack(spacing: 12) {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.system(size: 28))
|
||||
.foregroundStyle(Theme.brandAccent)
|
||||
Text("Couldn't load \(periodLabel)")
|
||||
.font(.system(size: 12.5, weight: .semibold))
|
||||
.foregroundStyle(.primary)
|
||||
Text(displayError)
|
||||
.font(.system(size: 10.5))
|
||||
.foregroundStyle(.secondary)
|
||||
.multilineTextAlignment(.center)
|
||||
.frame(maxWidth: 280)
|
||||
.lineLimit(3)
|
||||
Button("Retry", action: retry)
|
||||
.buttonStyle(.borderedProminent)
|
||||
.tint(Theme.brandAccent)
|
||||
.controlSize(.small)
|
||||
}
|
||||
.padding(.horizontal, 20)
|
||||
}
|
||||
}
|
||||
|
||||
/// Strip the leading subprocess noise that creeps into NSError descriptions
|
||||
/// so the visible message is the actual cause, not the framework wrapper.
|
||||
private var displayError: String {
|
||||
let trimmed = error.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
if trimmed.count <= 240 { return trimmed }
|
||||
return String(trimmed.prefix(240)) + "…"
|
||||
}
|
||||
}
|
||||
|
||||
/// Translucent overlay that blurs whatever's behind it (the previous tab/period content)
|
||||
/// and centers an animated burning flame -- the brand mark filling up bottom-to-top in
|
||||
/// yellow→orange→red, looping.
|
||||
|
|
@ -185,24 +263,31 @@ private struct BurnFlame: View {
|
|||
|
||||
private struct Header: View {
|
||||
@Environment(UpdateChecker.self) private var updateChecker
|
||||
@Environment(AppStore.self) private var store
|
||||
var body: some View {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
(
|
||||
Text("Code").foregroundStyle(.primary)
|
||||
+ Text("Burn").foregroundStyle(Theme.brandEmber)
|
||||
)
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.tracking(-0.15)
|
||||
Text("AI Coding Cost Tracker")
|
||||
.font(.system(size: 10.5))
|
||||
.foregroundStyle(.secondary)
|
||||
VStack(alignment: .leading, spacing: 6) {
|
||||
HStack {
|
||||
VStack(alignment: .leading, spacing: 1) {
|
||||
(
|
||||
Text("Code").foregroundStyle(.primary)
|
||||
+ Text("Burn").foregroundStyle(Theme.brandEmber)
|
||||
)
|
||||
.font(.system(size: 13, weight: .semibold))
|
||||
.tracking(-0.15)
|
||||
Text("AI Coding Cost Tracker")
|
||||
.font(.system(size: 10.5))
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
Spacer()
|
||||
if updateChecker.updateAvailable || updateChecker.updateError != nil {
|
||||
UpdateBadge()
|
||||
}
|
||||
AccentPicker()
|
||||
}
|
||||
Spacer()
|
||||
if updateChecker.updateAvailable {
|
||||
UpdateBadge()
|
||||
}
|
||||
AccentPicker()
|
||||
// Compact warning row when any connected provider crosses 70%.
|
||||
// Lists all warning providers with their worst-window percent so
|
||||
// the user knows whether to slow down on Claude, Codex, or both.
|
||||
QuotaWarningRow(status: store.aggregateQuotaStatus)
|
||||
}
|
||||
.padding(.horizontal, 14)
|
||||
.padding(.top, 10)
|
||||
|
|
@ -210,6 +295,61 @@ private struct Header: View {
|
|||
}
|
||||
}
|
||||
|
||||
private struct QuotaWarningRow: View {
|
||||
let status: AppStore.AggregateQuotaStatus
|
||||
|
||||
var body: some View {
|
||||
if !status.warnings.isEmpty {
|
||||
HStack(spacing: 6) {
|
||||
Image(systemName: severityIcon)
|
||||
.font(.system(size: 10, weight: .semibold))
|
||||
.foregroundStyle(severityColor)
|
||||
Text(message)
|
||||
.font(.system(size: 10.5, weight: .medium))
|
||||
.foregroundStyle(severityColor)
|
||||
Spacer(minLength: 0)
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
.padding(.vertical, 5)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 5)
|
||||
.fill(severityColor.opacity(0.12))
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private var message: String {
|
||||
let parts = status.warnings.map { "\($0.name) \(Int($0.percent.rounded()))%" }
|
||||
if parts.count == 1 {
|
||||
// Reads "Claude over limit (105%)" when any provider exceeds the
|
||||
// quota cap, instead of the awkward "Claude 105% of quota used".
|
||||
if case .danger = status.severity {
|
||||
return "\(status.warnings[0].name) over limit (\(Int(status.warnings[0].percent.rounded()))%)"
|
||||
}
|
||||
return "\(parts[0]) of quota used"
|
||||
}
|
||||
return parts.joined(separator: " · ")
|
||||
}
|
||||
|
||||
private var severityColor: Color {
|
||||
switch status.severity {
|
||||
case .normal: return .secondary
|
||||
case .warning: return .yellow
|
||||
case .critical: return .orange
|
||||
case .danger: return .red
|
||||
}
|
||||
}
|
||||
|
||||
private var severityIcon: String {
|
||||
switch status.severity {
|
||||
case .normal: return "info.circle"
|
||||
case .warning: return "exclamationmark.circle"
|
||||
case .critical: return "exclamationmark.triangle"
|
||||
case .danger: return "octagon"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private struct AccentPicker: View {
|
||||
@Environment(AppStore.self) private var store
|
||||
|
||||
|
|
@ -269,18 +409,25 @@ private struct UpdateBadge: View {
|
|||
|
||||
var body: some View {
|
||||
Button {
|
||||
updateChecker.performUpdate()
|
||||
if updateChecker.updateAvailable {
|
||||
updateChecker.performUpdate()
|
||||
} else {
|
||||
Task { await updateChecker.check() }
|
||||
}
|
||||
} label: {
|
||||
HStack(spacing: 4) {
|
||||
if updateChecker.isUpdating {
|
||||
ProgressView()
|
||||
.controlSize(.mini)
|
||||
.scaleEffect(0.7)
|
||||
} else if updateChecker.updateError != nil {
|
||||
Image(systemName: "exclamationmark.triangle.fill")
|
||||
.font(.system(size: 10))
|
||||
} else {
|
||||
Image(systemName: "arrow.down.circle.fill")
|
||||
.font(.system(size: 10))
|
||||
}
|
||||
Text(updateChecker.isUpdating ? "Updating..." : "Update")
|
||||
Text(updateChecker.isUpdating ? "Updating..." : (updateChecker.updateError == nil ? "Update" : "Failed"))
|
||||
.font(.system(size: 10, weight: .medium))
|
||||
}
|
||||
.padding(.horizontal, 8)
|
||||
|
|
@ -290,6 +437,7 @@ private struct UpdateBadge: View {
|
|||
.tint(Theme.brandAccent)
|
||||
.controlSize(.mini)
|
||||
.disabled(updateChecker.isUpdating)
|
||||
.help(updateChecker.updateError ?? "Install the latest menubar build")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -397,7 +545,7 @@ struct FooterBar: View {
|
|||
.fixedSize()
|
||||
|
||||
Button {
|
||||
Task { await store.refresh(includeOptimize: true, force: true) }
|
||||
refreshNow()
|
||||
} label: {
|
||||
Image(systemName: store.isLoading ? "arrow.triangle.2.circlepath" : "arrow.clockwise")
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
|
|
@ -422,7 +570,7 @@ struct FooterBar: View {
|
|||
|
||||
Spacer()
|
||||
|
||||
Text("v\(Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "?")")
|
||||
Text(AppVersion.displayBundleShortVersion)
|
||||
.font(.system(size: 10, weight: .regular, design: .monospaced))
|
||||
.foregroundStyle(.tertiary)
|
||||
|
||||
|
|
@ -443,6 +591,14 @@ struct FooterBar: View {
|
|||
TerminalLauncher.open(subcommand: ["report"])
|
||||
}
|
||||
|
||||
private func refreshNow() {
|
||||
if let delegate = NSApp.delegate as? AppDelegate {
|
||||
delegate.refreshSubscriptionNow()
|
||||
} else {
|
||||
Task { await store.refresh(includeOptimize: false, force: true, showLoading: true) }
|
||||
}
|
||||
}
|
||||
|
||||
private enum ExportFormat {
|
||||
case csv, json
|
||||
var cliName: String { self == .csv ? "csv" : "json" }
|
||||
|
|
@ -457,7 +613,7 @@ struct FooterBar: View {
|
|||
Task {
|
||||
let downloads = (NSHomeDirectory() as NSString).appendingPathComponent("Downloads")
|
||||
let formatter = DateFormatter()
|
||||
formatter.dateFormat = "yyyy-MM-dd"
|
||||
formatter.dateFormat = "yyyy-MM-dd-HHmmss"
|
||||
let base = "codeburn-\(formatter.string(from: Date()))"
|
||||
let outputPath = (downloads as NSString).appendingPathComponent(base + format.suffix)
|
||||
|
||||
|
|
@ -466,13 +622,17 @@ struct FooterBar: View {
|
|||
])
|
||||
|
||||
do {
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
if process.terminationStatus == 0 {
|
||||
NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: outputPath)])
|
||||
} else {
|
||||
NSLog("CodeBurn: \(format.cliName.uppercased()) export exited with status \(process.terminationStatus)")
|
||||
let fmt = format
|
||||
process.terminationHandler = { proc in
|
||||
Task { @MainActor in
|
||||
if proc.terminationStatus == 0 {
|
||||
NSWorkspace.shared.activateFileViewerSelecting([URL(fileURLWithPath: outputPath)])
|
||||
} else {
|
||||
NSLog("CodeBurn: \(fmt.cliName.uppercased()) export exited with status \(proc.terminationStatus)")
|
||||
}
|
||||
}
|
||||
}
|
||||
try process.run()
|
||||
} catch {
|
||||
NSLog("CodeBurn: \(format.cliName.uppercased()) export failed: \(error)")
|
||||
}
|
||||
|
|
@ -483,21 +643,18 @@ struct FooterBar: View {
|
|||
/// thread right away so the UI redraws the next frame, then fetches a fresh rate in the
|
||||
/// background. CLI config is persisted so other codeburn commands stay in sync.
|
||||
private func applyCurrency(code: String) {
|
||||
store.currency = code
|
||||
let symbol = CurrencyState.symbolForCode(code)
|
||||
|
||||
Task {
|
||||
let cached = await FXRateCache.shared.cachedRate(for: code)
|
||||
await MainActor.run {
|
||||
if let cached {
|
||||
store.currency = code
|
||||
CurrencyState.shared.apply(code: code, rate: cached, symbol: symbol)
|
||||
}
|
||||
|
||||
let fresh = await FXRateCache.shared.rate(for: code)
|
||||
if let fresh, fresh != cached {
|
||||
await MainActor.run {
|
||||
CurrencyState.shared.apply(code: code, rate: fresh, symbol: symbol)
|
||||
}
|
||||
}
|
||||
store.currency = code
|
||||
CurrencyState.shared.apply(code: code, rate: fresh ?? cached, symbol: symbol)
|
||||
}
|
||||
|
||||
CLICurrencyConfig.persist(code: code)
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ struct PeriodSegmentedControl: View {
|
|||
HStack(spacing: 1) {
|
||||
ForEach(Period.allCases) { period in
|
||||
Button {
|
||||
Task { await store.switchTo(period: period) }
|
||||
store.switchTo(period: period)
|
||||
} label: {
|
||||
Text(period.rawValue)
|
||||
.font(.system(size: 11, weight: .medium))
|
||||
|
|
|
|||
365
mac/Sources/CodeBurnMenubar/Views/SettingsView.swift
Normal file
|
|
@ -0,0 +1,365 @@
|
|||
import SwiftUI
|
||||
|
||||
/// macOS-standard tabbed Settings window. New per-provider sections (Codex,
|
||||
/// Cursor, Copilot, etc.) plug in as additional tabs. Each tab owns its own
|
||||
/// concerns; this top-level view only hosts the TabView shell.
|
||||
struct SettingsView: View {
|
||||
@Environment(AppStore.self) private var store
|
||||
|
||||
var body: some View {
|
||||
TabView {
|
||||
GeneralSettingsTab()
|
||||
.tabItem { Label("General", systemImage: "gearshape") }
|
||||
|
||||
ClaudeSettingsTab()
|
||||
.tabItem { Label("Claude", systemImage: "brain") }
|
||||
|
||||
CodexSettingsTab()
|
||||
.tabItem { Label("Codex", systemImage: "chevron.left.forwardslash.chevron.right") }
|
||||
|
||||
AboutSettingsTab()
|
||||
.tabItem { Label("About", systemImage: "info.circle") }
|
||||
}
|
||||
.frame(width: 520, height: 400)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - General
|
||||
|
||||
private struct GeneralSettingsTab: View {
|
||||
@Environment(AppStore.self) private var store
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("Display") {
|
||||
Picker("Currency", selection: Binding(
|
||||
get: { store.currency },
|
||||
set: { applyCurrency(code: $0) }
|
||||
)) {
|
||||
ForEach(["USD", "EUR", "GBP", "INR", "JPY", "AUD", "CAD"], id: \.self) { code in
|
||||
Text(code).tag(code)
|
||||
}
|
||||
}
|
||||
Picker("Accent", selection: Binding(
|
||||
get: { store.accentPreset },
|
||||
set: { store.accentPreset = $0 }
|
||||
)) {
|
||||
ForEach(AccentPreset.allCases) { preset in
|
||||
Text(preset.rawValue).tag(preset)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.padding()
|
||||
}
|
||||
|
||||
private func applyCurrency(code: String) {
|
||||
let symbol = CurrencyState.symbolForCode(code)
|
||||
Task {
|
||||
let cached = await FXRateCache.shared.cachedRate(for: code)
|
||||
if let cached {
|
||||
store.currency = code
|
||||
CurrencyState.shared.apply(code: code, rate: cached, symbol: symbol)
|
||||
}
|
||||
let fresh = await FXRateCache.shared.rate(for: code)
|
||||
store.currency = code
|
||||
CurrencyState.shared.apply(code: code, rate: fresh ?? cached, symbol: symbol)
|
||||
}
|
||||
CLICurrencyConfig.persist(code: code)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Claude
|
||||
|
||||
private struct ClaudeSettingsTab: View {
|
||||
@Environment(AppStore.self) private var store
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("Connection") {
|
||||
ClaudeConnectionRow()
|
||||
}
|
||||
Section("Quota Refresh") {
|
||||
Picker("Update every", selection: Binding(
|
||||
get: { SubscriptionRefreshCadence.current },
|
||||
set: { SubscriptionRefreshCadence.current = $0 }
|
||||
)) {
|
||||
ForEach(SubscriptionRefreshCadence.allCases) { cadence in
|
||||
Text(cadence.label).tag(cadence)
|
||||
}
|
||||
}
|
||||
.pickerStyle(.menu)
|
||||
Text("Anthropic rate-limits this endpoint per account. 2 minutes is plenty for the 5-hour and weekly windows; pick Manual if you only want updates on demand.")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(.secondary)
|
||||
Button("Refresh Now") {
|
||||
Task { await store.refreshSubscription() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
private struct ClaudeConnectionRow: View {
|
||||
@Environment(AppStore.self) private var store
|
||||
@State private var showDisconnectConfirm = false
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: stateIcon)
|
||||
.font(.system(size: 18))
|
||||
.foregroundStyle(stateTint)
|
||||
.frame(width: 22)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(stateTitle)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
Text(stateDetail)
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
Spacer()
|
||||
actionButton
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
private var stateIcon: String {
|
||||
switch store.subscriptionLoadState {
|
||||
case .loaded: return "checkmark.circle.fill"
|
||||
case .terminalFailure: return "exclamationmark.triangle.fill"
|
||||
case .transientFailure: return "clock.arrow.circlepath"
|
||||
case .bootstrapping, .loading: return "ellipsis.circle"
|
||||
case .notBootstrapped, .noCredentials: return "link.circle"
|
||||
case .failed: return "xmark.circle"
|
||||
}
|
||||
}
|
||||
|
||||
private var stateTint: Color {
|
||||
switch store.subscriptionLoadState {
|
||||
case .loaded: return .green
|
||||
case .terminalFailure, .failed: return .red
|
||||
case .transientFailure: return .orange
|
||||
default: return .secondary
|
||||
}
|
||||
}
|
||||
|
||||
private var stateTitle: String {
|
||||
switch store.subscriptionLoadState {
|
||||
case .loaded: return "Connected"
|
||||
case let .terminalFailure(reason): return reason ?? "Reconnect required"
|
||||
case .transientFailure: return "Backing off"
|
||||
case .bootstrapping: return "Connecting…"
|
||||
case .loading: return "Refreshing…"
|
||||
case .notBootstrapped, .noCredentials: return "Not connected"
|
||||
case .failed: return "Couldn't load plan data"
|
||||
}
|
||||
}
|
||||
|
||||
private var stateDetail: String {
|
||||
switch store.subscriptionLoadState {
|
||||
case .loaded:
|
||||
if let tier = store.subscription?.tier.displayName {
|
||||
return "Plan: \(tier)"
|
||||
}
|
||||
return "Live quota tracked from Anthropic."
|
||||
case .terminalFailure: return "Open Claude Code in your terminal and type `/login`, then click Reconnect."
|
||||
case .transientFailure: return store.subscriptionError ?? "Anthropic rate-limited; auto-retrying."
|
||||
case .bootstrapping: return "macOS may ask permission to read your credentials."
|
||||
case .loading: return "Background refresh in progress."
|
||||
case .notBootstrapped, .noCredentials: return "Click Connect to read your Claude Code credentials and start tracking quota."
|
||||
case .failed: return store.subscriptionError ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var actionButton: some View {
|
||||
switch store.subscriptionLoadState {
|
||||
case .loaded, .transientFailure, .loading:
|
||||
Button("Disconnect") { showDisconnectConfirm = true }
|
||||
.confirmationDialog(
|
||||
"Disconnect Claude?",
|
||||
isPresented: $showDisconnectConfirm
|
||||
) {
|
||||
Button("Disconnect", role: .destructive) {
|
||||
store.disconnectSubscription()
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("CodeBurn will stop tracking quota and delete its local copy of your Claude credentials. Your Claude Code keychain entry is untouched — Claude Code keeps working.")
|
||||
}
|
||||
case .terminalFailure, .noCredentials, .failed:
|
||||
Button("Reconnect") { Task { await store.bootstrapSubscription() } }
|
||||
.buttonStyle(.borderedProminent)
|
||||
case .notBootstrapped:
|
||||
Button("Connect") { Task { await store.bootstrapSubscription() } }
|
||||
.buttonStyle(.borderedProminent)
|
||||
case .bootstrapping:
|
||||
ProgressView().controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Codex
|
||||
|
||||
private struct CodexSettingsTab: View {
|
||||
@Environment(AppStore.self) private var store
|
||||
|
||||
var body: some View {
|
||||
Form {
|
||||
Section("Connection") {
|
||||
CodexConnectionRow()
|
||||
}
|
||||
Section {
|
||||
Text("Codex live-quota tracking reads `~/.codex/auth.json` once on Connect, then keeps a local copy under Application Support so subsequent quota fetches don't re-read the original. Only ChatGPT-mode auth (Plus / Pro / Team / Business) is supported — API-key users are billed per request and have a different reporting surface.")
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(.secondary)
|
||||
} header: {
|
||||
Text("How it works")
|
||||
}
|
||||
}
|
||||
.formStyle(.grouped)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
||||
private struct CodexConnectionRow: View {
|
||||
@Environment(AppStore.self) private var store
|
||||
@State private var showDisconnectConfirm = false
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: stateIcon)
|
||||
.font(.system(size: 18))
|
||||
.foregroundStyle(stateTint)
|
||||
.frame(width: 22)
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text(stateTitle)
|
||||
.font(.system(size: 12, weight: .semibold))
|
||||
Text(stateDetail)
|
||||
.font(.system(size: 11))
|
||||
.foregroundStyle(.secondary)
|
||||
.lineLimit(2)
|
||||
}
|
||||
Spacer()
|
||||
actionButton
|
||||
}
|
||||
.padding(.vertical, 4)
|
||||
}
|
||||
|
||||
private var stateIcon: String {
|
||||
switch store.codexLoadState {
|
||||
case .loaded: return "checkmark.circle.fill"
|
||||
case .terminalFailure: return "exclamationmark.triangle.fill"
|
||||
case .transientFailure: return "clock.arrow.circlepath"
|
||||
case .bootstrapping, .loading: return "ellipsis.circle"
|
||||
case .notBootstrapped, .noCredentials: return "link.circle"
|
||||
case .failed: return "xmark.circle"
|
||||
}
|
||||
}
|
||||
|
||||
private var stateTint: Color {
|
||||
switch store.codexLoadState {
|
||||
case .loaded: return .green
|
||||
case .terminalFailure, .failed: return .red
|
||||
case .transientFailure: return .orange
|
||||
default: return .secondary
|
||||
}
|
||||
}
|
||||
|
||||
private var stateTitle: String {
|
||||
switch store.codexLoadState {
|
||||
case .loaded: return "Connected"
|
||||
case let .terminalFailure(reason): return reason ?? "Reconnect required"
|
||||
case .transientFailure: return "Backing off"
|
||||
case .bootstrapping: return "Connecting…"
|
||||
case .loading: return "Refreshing…"
|
||||
case .notBootstrapped, .noCredentials: return "Not connected"
|
||||
case .failed: return "Couldn't load Codex quota"
|
||||
}
|
||||
}
|
||||
|
||||
private var stateDetail: String {
|
||||
switch store.codexLoadState {
|
||||
case .loaded:
|
||||
if let plan = store.codexUsage?.plan.displayName {
|
||||
return "Plan: \(plan)"
|
||||
}
|
||||
return "Live quota tracked from chatgpt.com."
|
||||
case .terminalFailure:
|
||||
// Be specific about the cause: the message we already surface in
|
||||
// codexError will say "API-key mode" if that's the situation, so
|
||||
// the generic "run codex login" hint covers both cases.
|
||||
if let err = store.codexError, err.lowercased().contains("api-key") {
|
||||
return "Codex is in API-key mode. Run `codex login` and choose a ChatGPT plan to enable quota tracking."
|
||||
}
|
||||
return "Run `codex login` in your terminal to sign in again, then click Reconnect."
|
||||
case .transientFailure: return store.codexError ?? "ChatGPT rate-limited; auto-retrying."
|
||||
case .bootstrapping: return "Reading ~/.codex/auth.json."
|
||||
case .loading: return "Background refresh in progress."
|
||||
case .notBootstrapped, .noCredentials:
|
||||
return "Click Connect to read your Codex CLI credentials. If Connect fails, run `codex login` in your terminal first to create ~/.codex/auth.json."
|
||||
case .failed: return store.codexError ?? ""
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var actionButton: some View {
|
||||
switch store.codexLoadState {
|
||||
case .loaded, .transientFailure, .loading:
|
||||
Button("Disconnect") { showDisconnectConfirm = true }
|
||||
.confirmationDialog(
|
||||
"Disconnect Codex?",
|
||||
isPresented: $showDisconnectConfirm
|
||||
) {
|
||||
Button("Disconnect", role: .destructive) {
|
||||
store.disconnectCodex()
|
||||
}
|
||||
Button("Cancel", role: .cancel) {}
|
||||
} message: {
|
||||
Text("CodeBurn will stop tracking quota and delete its local copy of your Codex credentials. Your ~/.codex/auth.json is untouched — Codex CLI keeps working.")
|
||||
}
|
||||
case .terminalFailure, .noCredentials, .failed:
|
||||
Button("Reconnect") { Task { await store.bootstrapCodex() } }
|
||||
.buttonStyle(.borderedProminent)
|
||||
case .notBootstrapped:
|
||||
Button("Connect") { Task { await store.bootstrapCodex() } }
|
||||
.buttonStyle(.borderedProminent)
|
||||
case .bootstrapping:
|
||||
ProgressView().controlSize(.small)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - About
|
||||
|
||||
private struct AboutSettingsTab: View {
|
||||
private let appVersion: String = AppVersion.normalizedBundleShortVersion
|
||||
private let buildVersion: String = AppVersion.normalizedBundleBuildVersion
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 14) {
|
||||
Image(systemName: "flame.fill")
|
||||
.font(.system(size: 40))
|
||||
.foregroundStyle(Theme.brandAccent)
|
||||
Text("CodeBurn")
|
||||
.font(.system(size: 18, weight: .semibold))
|
||||
Text("AI Coding Cost Tracker")
|
||||
.font(.system(size: 12))
|
||||
.foregroundStyle(.secondary)
|
||||
Text("Version \(appVersion) (\(buildVersion))")
|
||||
.font(.codeMono(size: 11))
|
||||
.foregroundStyle(.secondary)
|
||||
HStack(spacing: 10) {
|
||||
Link("GitHub", destination: URL(string: "https://github.com/getagentseal/codeburn")!)
|
||||
Link("Issues", destination: URL(string: "https://github.com/getagentseal/codeburn/issues")!)
|
||||
}
|
||||
.font(.system(size: 12))
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.padding()
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
import Foundation
|
||||
import Testing
|
||||
@testable import CodeBurnMenubar
|
||||
|
||||
private func menubarPayload(cost: Double) -> MenubarPayload {
|
||||
MenubarPayload(
|
||||
generated: "test",
|
||||
current: CurrentBlock(
|
||||
label: "Today",
|
||||
cost: cost,
|
||||
calls: 1,
|
||||
sessions: 1,
|
||||
oneShotRate: nil,
|
||||
inputTokens: 1,
|
||||
outputTokens: 1,
|
||||
cacheHitPercent: 0,
|
||||
topActivities: [],
|
||||
topModels: [],
|
||||
providers: ["claude": cost]
|
||||
),
|
||||
optimize: OptimizeBlock(findingCount: 0, savingsUSD: 0, topFindings: []),
|
||||
history: HistoryBlock(daily: [])
|
||||
)
|
||||
}
|
||||
|
||||
@Suite("AppStore refresh recovery")
|
||||
@MainActor
|
||||
struct AppStoreRefreshRecoveryTests {
|
||||
@Test("stale visible payload triggers hard recovery without clearing cache")
|
||||
func stalePayloadTriggersHardRecoveryWithoutClearingCache() {
|
||||
let store = AppStore()
|
||||
store.setCachedPayloadForTesting(
|
||||
menubarPayload(cost: 92.33),
|
||||
period: .today,
|
||||
provider: .all,
|
||||
fetchedAt: Date().addingTimeInterval(-180)
|
||||
)
|
||||
|
||||
#expect(store.todayPayload?.current.cost == 92.33)
|
||||
#expect(store.needsInteractivePayloadRefresh)
|
||||
#expect(store.needsStatusPayloadRefresh)
|
||||
#expect(store.hasStaleInteractivePayload)
|
||||
#expect(store.shouldResetInteractiveRefreshPipeline)
|
||||
|
||||
store.resetRefreshState(clearCache: false)
|
||||
|
||||
#expect(store.todayPayload?.current.cost == 92.33)
|
||||
}
|
||||
|
||||
@Test("fresh visible payload does not trigger hard recovery")
|
||||
func freshPayloadDoesNotTriggerHardRecovery() {
|
||||
let store = AppStore()
|
||||
store.setCachedPayloadForTesting(
|
||||
menubarPayload(cost: 164.06),
|
||||
period: .today,
|
||||
provider: .all,
|
||||
fetchedAt: Date()
|
||||
)
|
||||
|
||||
#expect(!store.needsInteractivePayloadRefresh)
|
||||
#expect(!store.needsStatusPayloadRefresh)
|
||||
#expect(!store.hasStaleInteractivePayload)
|
||||
#expect(!store.shouldResetInteractiveRefreshPipeline)
|
||||
}
|
||||
|
||||
@Test("missing today status payload needs status refresh")
|
||||
func missingTodayStatusPayloadNeedsStatusRefresh() {
|
||||
let store = AppStore()
|
||||
|
||||
#expect(store.todayPayload == nil)
|
||||
#expect(store.needsStatusPayloadRefresh)
|
||||
}
|
||||
|
||||
@Test("missing unattempted payload triggers hard recovery")
|
||||
func missingUnattemptedPayloadTriggersHardRecovery() {
|
||||
let store = AppStore()
|
||||
|
||||
#expect(!store.hasCachedData)
|
||||
#expect(!store.hasAttemptedCurrentKeyLoad)
|
||||
#expect(store.needsInteractivePayloadRefresh)
|
||||
#expect(store.hasMissingInteractivePayloadWithoutAttempt)
|
||||
#expect(store.shouldResetInteractiveRefreshPipeline)
|
||||
}
|
||||
}
|
||||
19
mac/Tests/CodeBurnMenubarTests/AppVersionTests.swift
Normal file
|
|
@ -0,0 +1,19 @@
|
|||
import Testing
|
||||
@testable import CodeBurnMenubar
|
||||
|
||||
@Suite("AppVersion")
|
||||
struct AppVersionTests {
|
||||
@Test("display avoids duplicate v prefix")
|
||||
func displayAvoidsDuplicatePrefix() {
|
||||
#expect(AppVersion.display("0.9.8") == "v0.9.8")
|
||||
#expect(AppVersion.display("v0.9.8") == "v0.9.8")
|
||||
#expect(AppVersion.display("mac-v0.9.8") == "v0.9.8")
|
||||
}
|
||||
|
||||
@Test("bundle metadata stores unprefixed semver")
|
||||
func normalizeBundleVersion() {
|
||||
#expect(AppVersion.normalize("v0.9.8") == "0.9.8")
|
||||
#expect(AppVersion.normalize("mac-v0.9.8") == "0.9.8")
|
||||
#expect(AppVersion.normalize("dev") == "dev")
|
||||
}
|
||||
}
|
||||
39
mac/Tests/CodeBurnMenubarTests/UpdateCheckerTests.swift
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
import Testing
|
||||
@testable import CodeBurnMenubar
|
||||
|
||||
@Suite("UpdateChecker")
|
||||
struct UpdateCheckerTests {
|
||||
@Test("selects newest mac release with zip and checksum")
|
||||
func selectsNewestMacReleaseWithChecksum() {
|
||||
let releases = [
|
||||
GitHubRelease(
|
||||
tag_name: "v0.9.9",
|
||||
assets: [GitHubAsset(name: "codeburn-0.9.9.tgz", browser_download_url: "https://example.test/cli")]
|
||||
),
|
||||
GitHubRelease(
|
||||
tag_name: "mac-v0.9.8",
|
||||
assets: [
|
||||
GitHubAsset(name: "CodeBurnMenubar-v0.9.8.zip", browser_download_url: "https://example.test/app"),
|
||||
GitHubAsset(name: "CodeBurnMenubar-v0.9.8.zip.sha256", browser_download_url: "https://example.test/app.sha256"),
|
||||
]
|
||||
),
|
||||
]
|
||||
|
||||
let resolved = UpdateChecker.resolveLatestMenubarRelease(in: releases)
|
||||
|
||||
#expect(resolved?.release.tag_name == "mac-v0.9.8")
|
||||
#expect(resolved?.asset.name == "CodeBurnMenubar-v0.9.8.zip")
|
||||
}
|
||||
|
||||
@Test("ignores mac release missing checksum")
|
||||
func ignoresMacReleaseMissingChecksum() {
|
||||
let releases = [
|
||||
GitHubRelease(
|
||||
tag_name: "mac-v0.9.8",
|
||||
assets: [GitHubAsset(name: "CodeBurnMenubar-v0.9.8.zip", browser_download_url: "https://example.test/app")]
|
||||
),
|
||||
]
|
||||
|
||||
#expect(UpdateChecker.resolveLatestMenubarRelease(in: releases) == nil)
|
||||
}
|
||||
}
|
||||
9
package-lock.json
generated
|
|
@ -1,18 +1,19 @@
|
|||
{
|
||||
"name": "codeburn",
|
||||
"version": "0.9.4",
|
||||
"version": "0.9.9",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "codeburn",
|
||||
"version": "0.9.4",
|
||||
"version": "0.9.9",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"chalk": "^5.4.1",
|
||||
"commander": "^13.1.0",
|
||||
"ink": "^7.0.0",
|
||||
"react": "^19.2.5"
|
||||
"react": "^19.2.5",
|
||||
"strip-ansi": "^7.2.0"
|
||||
},
|
||||
"bin": {
|
||||
"codeburn": "dist/cli.js"
|
||||
|
|
@ -26,7 +27,7 @@
|
|||
"vitest": "^3.1.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
"node": ">=22.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@alcalzone/ansi-tokenize": {
|
||||
|
|
|
|||
11
package.json
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "codeburn",
|
||||
"version": "0.9.4",
|
||||
"version": "0.9.9",
|
||||
"description": "See where your AI coding tokens go - by task, tool, model, and project",
|
||||
"type": "module",
|
||||
"main": "./dist/cli.js",
|
||||
|
|
@ -12,7 +12,7 @@
|
|||
],
|
||||
"scripts": {
|
||||
"bundle-litellm": "node scripts/bundle-litellm.mjs",
|
||||
"build": "node scripts/bundle-litellm.mjs && tsup",
|
||||
"build": "node scripts/bundle-litellm.mjs && tsup && node -e \"const fs=require('fs'); fs.copyFileSync('src/cli.ts','dist/cli.js'); fs.chmodSync('dist/cli.js',0o755)\"",
|
||||
"dev": "tsx src/cli.ts",
|
||||
"test": "vitest",
|
||||
"prepublishOnly": "npm run build"
|
||||
|
|
@ -21,6 +21,8 @@
|
|||
"claude-code",
|
||||
"cursor",
|
||||
"codex",
|
||||
"kimi",
|
||||
"ibm-bob",
|
||||
"opencode",
|
||||
"pi",
|
||||
"codebuff",
|
||||
|
|
@ -31,7 +33,7 @@
|
|||
"developer-tools"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=22"
|
||||
"node": ">=22.13.0"
|
||||
},
|
||||
"author": "AgentSeal <hello@agentseal.org>",
|
||||
"license": "MIT",
|
||||
|
|
@ -47,7 +49,8 @@
|
|||
"chalk": "^5.4.1",
|
||||
"commander": "^13.1.0",
|
||||
"ink": "^7.0.0",
|
||||
"react": "^19.2.5"
|
||||
"react": "^19.2.5",
|
||||
"strip-ansi": "^7.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^22.19.17",
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
import { basename } from 'path'
|
||||
import stripAnsi from 'strip-ansi'
|
||||
|
||||
function stripQuotedStrings(command: string): string {
|
||||
return command.replace(/"[^"]*"|'[^']*'/g, match => ' '.repeat(match.length))
|
||||
}
|
||||
|
||||
export function extractBashCommands(command: string): string[] {
|
||||
if (!command || !command.trim()) return []
|
||||
export function extractBashCommands(rawCommand: string): string[] {
|
||||
if (!rawCommand || !rawCommand.trim()) return []
|
||||
|
||||
const command = stripAnsi(rawCommand)
|
||||
const stripped = stripQuotedStrings(command)
|
||||
|
||||
const separatorRegex = /\s*(?:&&|;|\|)\s*/g
|
||||
|
|
|
|||
|
|
@ -53,6 +53,10 @@ function getAllTools(turn: ParsedTurn): string[] {
|
|||
return turn.assistantCalls.flatMap(c => c.tools)
|
||||
}
|
||||
|
||||
function getAllSkills(turn: ParsedTurn): string[] {
|
||||
return turn.assistantCalls.flatMap(c => c.skills ?? [])
|
||||
}
|
||||
|
||||
function classifyByToolPattern(turn: ParsedTurn): TaskCategory | null {
|
||||
const tools = getAllTools(turn)
|
||||
if (tools.length === 0) return null
|
||||
|
|
@ -89,12 +93,38 @@ function classifyByToolPattern(turn: ParsedTurn): TaskCategory | null {
|
|||
return null
|
||||
}
|
||||
|
||||
/// Picks the category whose keyword pattern matches earliest in the message.
|
||||
/// On a tie (same start index) the candidate listed first in `candidates` wins,
|
||||
/// so callers control tie-break priority by ordering. Returns null when no
|
||||
/// pattern matches. The first-match heuristic fixes the long-standing problem
|
||||
/// where "add error handling" was tagged Debugging because the DEBUG regex was
|
||||
/// checked before FEATURE; now FEATURE wins because "add" appears before
|
||||
/// "error". Issue #196.
|
||||
function firstMatchingCategory(
|
||||
text: string,
|
||||
candidates: ReadonlyArray<{ regex: RegExp; category: TaskCategory }>,
|
||||
): TaskCategory | null {
|
||||
let best: { index: number; order: number; category: TaskCategory } | null = null
|
||||
for (let i = 0; i < candidates.length; i++) {
|
||||
const c = candidates[i]!
|
||||
const m = c.regex.exec(text)
|
||||
if (!m) continue
|
||||
if (!best || m.index < best.index || (m.index === best.index && i < best.order)) {
|
||||
best = { index: m.index, order: i, category: c.category }
|
||||
}
|
||||
}
|
||||
return best?.category ?? null
|
||||
}
|
||||
|
||||
function refineByKeywords(category: TaskCategory, userMessage: string): TaskCategory {
|
||||
if (category === 'coding') {
|
||||
if (DEBUG_KEYWORDS.test(userMessage)) return 'debugging'
|
||||
if (REFACTOR_KEYWORDS.test(userMessage)) return 'refactoring'
|
||||
if (FEATURE_KEYWORDS.test(userMessage)) return 'feature'
|
||||
return 'coding'
|
||||
// Tie-break order (when two keywords match at the same index): refactoring
|
||||
// first because its words are the most specific, then feature, then debug.
|
||||
return firstMatchingCategory(userMessage, [
|
||||
{ regex: REFACTOR_KEYWORDS, category: 'refactoring' },
|
||||
{ regex: FEATURE_KEYWORDS, category: 'feature' },
|
||||
{ regex: DEBUG_KEYWORDS, category: 'debugging' },
|
||||
]) ?? 'coding'
|
||||
}
|
||||
|
||||
if (category === 'exploration') {
|
||||
|
|
@ -109,8 +139,14 @@ function refineByKeywords(category: TaskCategory, userMessage: string): TaskCate
|
|||
function classifyConversation(userMessage: string): TaskCategory {
|
||||
if (BRAINSTORM_KEYWORDS.test(userMessage)) return 'brainstorming'
|
||||
if (RESEARCH_KEYWORDS.test(userMessage)) return 'exploration'
|
||||
if (DEBUG_KEYWORDS.test(userMessage)) return 'debugging'
|
||||
if (FEATURE_KEYWORDS.test(userMessage)) return 'feature'
|
||||
// Same first-match-wins logic as refineByKeywords so a chat-only message
|
||||
// starting with a feature verb does not flip to debugging because of an
|
||||
// incidental "error" or "fix" word later in the same sentence.
|
||||
const debugOrFeature = firstMatchingCategory(userMessage, [
|
||||
{ regex: FEATURE_KEYWORDS, category: 'feature' },
|
||||
{ regex: DEBUG_KEYWORDS, category: 'debugging' },
|
||||
])
|
||||
if (debugOrFeature) return debugOrFeature
|
||||
if (FILE_PATTERNS.test(userMessage)) return 'coding'
|
||||
if (SCRIPT_PATTERNS.test(userMessage)) return 'coding'
|
||||
if (URL_PATTERN.test(userMessage)) return 'exploration'
|
||||
|
|
@ -159,5 +195,12 @@ export function classifyTurn(turn: ParsedTurn): ClassifiedTurn {
|
|||
}
|
||||
}
|
||||
|
||||
return { ...turn, category, retries: countRetries(turn), hasEdits: turnHasEdits(turn) }
|
||||
const result: ClassifiedTurn = { ...turn, category, retries: countRetries(turn), hasEdits: turnHasEdits(turn) }
|
||||
|
||||
if (category === 'general') {
|
||||
const skills = getAllSkills(turn)
|
||||
if (skills.length > 0) result.subCategory = skills[0]
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
|
|
|||
115
src/cli-date.ts
|
|
@ -1,4 +1,5 @@
|
|||
import type { DateRange } from './types.js'
|
||||
import { toDateString } from './daily-cache.js'
|
||||
|
||||
const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/
|
||||
|
||||
|
|
@ -7,19 +8,68 @@ const END_OF_DAY_MINUTES = 59
|
|||
const END_OF_DAY_SECONDS = 59
|
||||
const END_OF_DAY_MS = 999
|
||||
|
||||
// "All Time" is intentionally bounded to the last 6 months. Older data is
|
||||
// rarely actionable for a cost tracker, and capping the range keeps the parse
|
||||
// path bounded so providers like Codex/Cursor with sparse multi-year history
|
||||
// still load in seconds. Users who need an unbounded window can use
|
||||
// `--from` / `--to`.
|
||||
const ALL_TIME_MONTHS = 6
|
||||
|
||||
export type Period = 'today' | 'week' | '30days' | 'month' | 'all'
|
||||
|
||||
export const PERIODS: Period[] = ['today', 'week', '30days', 'month', 'all']
|
||||
|
||||
// Short labels suitable for the dashboard tab strip. Long-form labels for
|
||||
// header text come from `getDateRange().label`.
|
||||
export const PERIOD_LABELS: Record<Period, string> = {
|
||||
today: 'Today',
|
||||
week: '7 Days',
|
||||
'30days': '30 Days',
|
||||
month: 'This Month',
|
||||
all: '6 Months',
|
||||
}
|
||||
|
||||
const VALID_PERIODS: ReadonlyArray<Period> = ['today', 'week', '30days', 'month', 'all']
|
||||
|
||||
export function toPeriod(s: string): Period {
|
||||
if ((VALID_PERIODS as readonly string[]).includes(s)) return s as Period
|
||||
// Fail loudly instead of silently coercing to 'week'. Previously a typo
|
||||
// like `-p mounth` produced a quiet 7-day report and the user thought
|
||||
// they were viewing the month.
|
||||
process.stderr.write(
|
||||
`codeburn: unknown period "${s}". Valid values: ${VALID_PERIODS.join(', ')}.\n`
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
function parseLocalDate(s: string): Date {
|
||||
if (!ISO_DATE_RE.test(s)) {
|
||||
throw new Error(`Invalid date format "${s}": expected YYYY-MM-DD`)
|
||||
}
|
||||
const [y, m, d] = s.split('-').map(Number) as [number, number, number]
|
||||
return new Date(y, m - 1, d)
|
||||
const date = new Date(y, m - 1, d)
|
||||
// JS Date silently rolls overflow forward (Feb 31 → Mar 3). That makes a
|
||||
// typo like `--from 2026-02-31 --to 2026-03-15` quietly drop sessions
|
||||
// dated Feb 28 - Mar 2. Reject overflow so the user gets a loud error
|
||||
// instead of an off-by-N-days date range.
|
||||
if (date.getFullYear() !== y || date.getMonth() !== m - 1 || date.getDate() !== d) {
|
||||
throw new Error(`Invalid date "${s}": ${m}/${d}/${y} is not a real calendar date`)
|
||||
}
|
||||
return date
|
||||
}
|
||||
|
||||
export function parseDateRangeFlags(from: string | undefined, to: string | undefined): DateRange | null {
|
||||
if (from === undefined && to === undefined) return null
|
||||
|
||||
const now = new Date()
|
||||
const start = from !== undefined ? parseLocalDate(from) : new Date(0)
|
||||
// When --from is omitted, default to 6 months back (the same window the
|
||||
// dashboard's "all" period uses) instead of epoch. Previously a bare
|
||||
// `--to 2026-01-01` opened a 55-year scan from 1970 which is rarely what
|
||||
// the user meant and is expensive on machines with many session files.
|
||||
const ALL_TIME_FALLBACK_MS = 6 * 31 * 24 * 60 * 60 * 1000
|
||||
const start = from !== undefined
|
||||
? parseLocalDate(from)
|
||||
: new Date(now.getTime() - ALL_TIME_FALLBACK_MS)
|
||||
|
||||
const endDate = to !== undefined ? parseLocalDate(to) : new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
const end = new Date(
|
||||
|
|
@ -37,3 +87,64 @@ export function parseDateRangeFlags(from: string | undefined, to: string | undef
|
|||
}
|
||||
return { start, end }
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the date range and a human-readable label for a named period.
|
||||
*
|
||||
* Accepts a string (rather than the strict `Period` type) because the CLI
|
||||
* surfaces a few extra inputs not exposed in the dashboard tab strip
|
||||
* (e.g. `'yesterday'`). Unknown values fall back to `'week'`.
|
||||
*
|
||||
* Note: `'all'` is bounded to the last 6 months. Use `--from`/`--to` for
|
||||
* an unbounded historical window.
|
||||
*/
|
||||
export function getDateRange(period: string): { range: DateRange; label: string } {
|
||||
const now = new Date()
|
||||
const end = new Date(
|
||||
now.getFullYear(),
|
||||
now.getMonth(),
|
||||
now.getDate(),
|
||||
END_OF_DAY_HOURS,
|
||||
END_OF_DAY_MINUTES,
|
||||
END_OF_DAY_SECONDS,
|
||||
END_OF_DAY_MS,
|
||||
)
|
||||
|
||||
switch (period) {
|
||||
case 'today': {
|
||||
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate())
|
||||
return { range: { start, end }, label: `Today (${toDateString(start)})` }
|
||||
}
|
||||
case 'yesterday': {
|
||||
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1)
|
||||
const yesterdayEnd = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 1, END_OF_DAY_HOURS, END_OF_DAY_MINUTES, END_OF_DAY_SECONDS, END_OF_DAY_MS)
|
||||
return { range: { start, end: yesterdayEnd }, label: `Yesterday (${toDateString(start)})` }
|
||||
}
|
||||
case 'week': {
|
||||
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 7)
|
||||
return { range: { start, end }, label: 'Last 7 Days' }
|
||||
}
|
||||
case 'month': {
|
||||
const start = new Date(now.getFullYear(), now.getMonth(), 1)
|
||||
return { range: { start, end }, label: `${now.toLocaleString('default', { month: 'long' })} ${now.getFullYear()}` }
|
||||
}
|
||||
case '30days': {
|
||||
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - 30)
|
||||
return { range: { start, end }, label: 'Last 30 Days' }
|
||||
}
|
||||
case 'all': {
|
||||
const start = new Date(now.getFullYear(), now.getMonth() - ALL_TIME_MONTHS, 1)
|
||||
return { range: { start, end }, label: 'Last 6 months' }
|
||||
}
|
||||
default: {
|
||||
process.stderr.write(
|
||||
`codeburn: unknown period "${period}". Valid values: today, week, 30days, month, all.\n`
|
||||
)
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function formatDateRangeLabel(from: string | undefined, to: string | undefined): string {
|
||||
return `${from ?? 'all'} to ${to ?? 'today'}`
|
||||
}
|
||||
|
|
|
|||