mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-21 06:24:32 +00:00
Merge remote-tracking branch 'origin/main' into pr1014-rebase
# Conflicts: # CHANGELOG.md
This commit is contained in:
commit
4dc53149bd
137 changed files with 21355 additions and 309 deletions
88
.github/workflows/build-windows-installer.yml
vendored
Normal file
88
.github/workflows/build-windows-installer.yml
vendored
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
name: Build Windows installer
|
||||
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
release_tag:
|
||||
description: Existing desktop-v* release to verify after manual asset upload
|
||||
required: false
|
||||
type: string
|
||||
pull_request:
|
||||
paths:
|
||||
- .github/workflows/build-windows-installer.yml
|
||||
- app/**
|
||||
- src/**
|
||||
- scripts/**
|
||||
- package.json
|
||||
- package-lock.json
|
||||
push:
|
||||
tags:
|
||||
- 'desktop-v*'
|
||||
release:
|
||||
types: [published]
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
nsis:
|
||||
if: ${{ github.event_name != 'release' && !(github.event_name == 'workflow_dispatch' && inputs.release_tag != '') }}
|
||||
runs-on: windows-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22.13.0
|
||||
cache: npm
|
||||
cache-dependency-path: |
|
||||
package-lock.json
|
||||
app/package-lock.json
|
||||
|
||||
- name: Install CLI dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Install desktop dependencies
|
||||
run: npm ci --prefix app
|
||||
|
||||
- name: Test installer verifier
|
||||
run: npm --prefix app test -- scripts/verify-windows-installer.test.ts
|
||||
|
||||
- name: Build NSIS installer
|
||||
run: npm --prefix app run package:win
|
||||
|
||||
- name: Verify installer manifest
|
||||
shell: pwsh
|
||||
run: |
|
||||
if ($env:GITHUB_REF_TYPE -eq 'tag') {
|
||||
node app/scripts/verify-windows-installer.mjs --tag $env:GITHUB_REF_NAME
|
||||
} else {
|
||||
node app/scripts/verify-windows-installer.mjs
|
||||
}
|
||||
|
||||
- name: Upload installer artifact
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: CodeBurn-Windows-Installer
|
||||
path: |
|
||||
app/release/CodeBurn-Setup-*.exe
|
||||
app/release/CodeBurn-Setup-*.exe.blockmap
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
|
||||
verify-release-assets:
|
||||
if: ${{ (github.event_name == 'release' && startsWith(github.event.release.tag_name, 'desktop-v')) || (github.event_name == 'workflow_dispatch' && inputs.release_tag != '') }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- name: Verify live desktop release assets
|
||||
env:
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
RELEASE_TAG: ${{ github.event.release.tag_name || inputs.release_tag }}
|
||||
run: |
|
||||
gh api "repos/${{ github.repository }}/releases/tags/$RELEASE_TAG" \
|
||||
--jq '[.assets[].name]' > "$RUNNER_TEMP/release-assets.json"
|
||||
node app/scripts/verify-windows-installer.mjs \
|
||||
--tag "$RELEASE_TAG" \
|
||||
--release-assets "$RUNNER_TEMP/release-assets.json"
|
||||
99
.github/workflows/release-menubar-windows.yml
vendored
Normal file
99
.github/workflows/release-menubar-windows.yml
vendored
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
name: Release Windows Menubar
|
||||
|
||||
# Triggers on a `windows-v*` tag push (e.g. `git tag windows-v0.9.20 && git push origin
|
||||
# windows-v0.9.20`), or manually via the Actions tab. Mirrors release-menubar.yml, which
|
||||
# does the same job for the macOS menubar under the `mac-v*` tags. The produced `.msi` is
|
||||
# unsigned; users see a SmartScreen prompt on first run until we add signing.
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- 'windows-v*'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
version:
|
||||
description: 'Version label for the bundle (e.g. v0.9.20 or dev-preview)'
|
||||
required: true
|
||||
default: 'dev-preview'
|
||||
|
||||
permissions:
|
||||
contents: write # Needed to create the release + upload assets.
|
||||
|
||||
jobs:
|
||||
build:
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
|
||||
- name: Resolve version label
|
||||
id: version
|
||||
shell: bash
|
||||
run: |
|
||||
if [[ "${GITHUB_REF}" == refs/tags/windows-v* ]]; then
|
||||
echo "value=${GITHUB_REF#refs/tags/windows-}" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "value=${{ github.event.inputs.version }}" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22.13.0
|
||||
cache: npm
|
||||
cache-dependency-path: windows/package-lock.json
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
targets: x86_64-pc-windows-msvc
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: windows/src-tauri
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: windows
|
||||
run: npm ci
|
||||
|
||||
- name: Build MSI bundle
|
||||
working-directory: windows
|
||||
run: npm run tauri build
|
||||
|
||||
- name: Collect artifacts
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mkdir -p release-artifacts
|
||||
find windows/src-tauri/target/release/bundle -type f -name '*.msi' \
|
||||
-exec cp -v {} release-artifacts/ \;
|
||||
(cd release-artifacts && for f in *.msi; do sha256sum "$f" > "$f.sha256"; done)
|
||||
ls -la release-artifacts
|
||||
|
||||
- name: Upload artifact (for manual runs)
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
uses: actions/upload-artifact@v6
|
||||
with:
|
||||
name: CodeBurnMenubar-Windows-${{ steps.version.outputs.value }}
|
||||
path: release-artifacts/*
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Create / update GitHub Release
|
||||
if: startsWith(github.ref, 'refs/tags/windows-v')
|
||||
uses: softprops/action-gh-release@v3
|
||||
with:
|
||||
tag_name: ${{ github.ref_name }}
|
||||
name: Windows Menubar ${{ steps.version.outputs.value }}
|
||||
body: |
|
||||
Download the `.msi` below and run it. The tray app reads everything through the
|
||||
CodeBurn CLI, so install that first:
|
||||
|
||||
```
|
||||
npm install -g codeburn
|
||||
```
|
||||
|
||||
Requires codeburn 0.9.9 or newer and the WebView2 Runtime (preinstalled on
|
||||
Windows 11 and recent Windows 10 updates; installed on demand otherwise).
|
||||
|
||||
The bundle is unsigned, so Windows SmartScreen warns on first run: click
|
||||
"More info", then "Run anyway". Signing is planned.
|
||||
files: release-artifacts/*
|
||||
fail_on_unmatched_files: true
|
||||
8
.github/workflows/tests.yml
vendored
8
.github/workflows/tests.yml
vendored
|
|
@ -9,12 +9,18 @@ jobs:
|
|||
test:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# Package floor, and the newest 22.x so paths gated on later node:zlib
|
||||
# features (zstd, 22.15+) get exercised.
|
||||
node-version: [22.13.0, 22]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 22.13.0
|
||||
node-version: ${{ matrix.node-version }}
|
||||
cache: npm
|
||||
- run: npm ci
|
||||
- name: Typecheck
|
||||
|
|
|
|||
83
.github/workflows/windows-menubar-ci.yml
vendored
Normal file
83
.github/workflows/windows-menubar-ci.yml
vendored
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
name: Windows Menubar CI
|
||||
|
||||
# The Windows menubar (windows/) is a Tauri app: a React frontend plus a Rust binary whose
|
||||
# interesting code is `#[cfg(windows)]` and therefore only ever compiled on a Windows runner.
|
||||
# ubuntu-latest is in the matrix because the same crate has to stay clean on the ksni/Linux
|
||||
# paths and because contributors develop it on non-Windows machines.
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- .github/workflows/windows-menubar-ci.yml
|
||||
- windows/**
|
||||
pull_request:
|
||||
paths:
|
||||
- .github/workflows/windows-menubar-ci.yml
|
||||
- windows/**
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
check:
|
||||
runs-on: ${{ matrix.os }}
|
||||
timeout-minutes: 30
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [windows-latest, ubuntu-latest]
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
# webkit2gtk + libayatana are what the Tauri and ksni crates link against; without
|
||||
# them the Linux leg cannot even typecheck the Rust side.
|
||||
- name: Install Linux system dependencies
|
||||
if: runner.os == 'Linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
libayatana-appindicator3-dev \
|
||||
librsvg2-dev \
|
||||
libssl-dev \
|
||||
libxdo-dev \
|
||||
libgtk-3-dev \
|
||||
build-essential
|
||||
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22.13.0
|
||||
cache: npm
|
||||
cache-dependency-path: windows/package-lock.json
|
||||
|
||||
- uses: dtolnay/rust-toolchain@stable
|
||||
with:
|
||||
components: clippy
|
||||
|
||||
- uses: Swatinem/rust-cache@v2
|
||||
with:
|
||||
workspaces: windows/src-tauri
|
||||
|
||||
- name: Install dependencies
|
||||
working-directory: windows
|
||||
run: npm ci
|
||||
|
||||
- name: Typecheck frontend
|
||||
working-directory: windows
|
||||
run: npx tsc --noEmit
|
||||
|
||||
- name: Clippy
|
||||
working-directory: windows/src-tauri
|
||||
run: cargo clippy --all-targets -- -D warnings
|
||||
|
||||
- name: Rust tests
|
||||
working-directory: windows/src-tauri
|
||||
run: cargo test
|
||||
|
||||
# Release-profile compile of the real Windows binary. `--no-bundle` skips the WiX
|
||||
# download and MSI packaging, which belong to the release workflow, not to every PR.
|
||||
- name: Release build smoke
|
||||
if: runner.os == 'Windows'
|
||||
working-directory: windows
|
||||
run: npm run tauri build -- --no-bundle
|
||||
19
CHANGELOG.md
19
CHANGELOG.md
|
|
@ -2,6 +2,19 @@
|
|||
|
||||
## Unreleased
|
||||
|
||||
### Added
|
||||
- **`codeburn models --unpriced`.** The dashboard warns about models that price at $0 and points at `codeburn model-alias`, but the list itself was hard to get out of the TUI. This filters the plain-stdout `models` report to exactly those rows, reusing `findUnpricedModels` so local, free, aliased and price-overridden models are treated the same way the warning treats them, and defaulting that mode's min-cost to 0 so $0 rows are not pre-filtered away. Thanks @kocaemre. (#969)
|
||||
- **`optimize` spots the same long block pasted at the start of many sessions.** The new `recurring-context` detector groups sessions by their opening block — normalized for whitespace and ANSI, hashed over the first 2 KB — and reports a block of at least 1.5 KB that opens 5 or more sessions, with the top three by tokens, their session counts and the project each is confined to. It is a habit, not an apply-able fix: CodeBurn will not move your own text into `CLAUDE.md` for you, so the finding asks Claude to give the block a permanent home (a `CLAUDE.md` rule, or a file read on demand) and hand back a one-line pointer to open sessions with instead. Savings count the repeats only, never the first paste, and are marked `estimated`: provider usage is counted per API call, where the pasted block is mixed in with the system prompt, tool schemas and `CLAUDE.md`, so the block is sized from its own bytes. Injected system reminders and slash-command wrappers are not pastes and are skipped, and neither is a prompt a program wrote — an SDK session or a subagent task — read from the entry's flags, or off the ends of the raw line when the entry is too large for the parser to keep them. The opening block comes from the session scan that already runs, so nothing extra is read from disk.
|
||||
- **Applied fixes get re-measured on every `optimize` run, and told plainly whether they worked.** After `codeburn optimize --apply`, every still-applied fix comes back in an `Applied fixes` section on subsequent `codeburn optimize` runs, carrying the verdict `act report` already computes from the same reconciliation: `worked` (at least 70% of its window-scaled estimate realized), `partial` (something, but under that), `no-effect` (no measured reduction, printed with the exact `codeburn act undo <id>` that puts it back), or `measuring` for anything younger than the 3-day measurement window. The numbers are measured — provider-counted usage over the post-apply window — not re-estimated. `--apply` now says when the re-measure will happen, `--format json` gains `appliedFixes[]` (add-only), and the same section appears in the dashboard TUI and the desktop app. New `codeburn optimize --auto-revert` undoes the fixes that measured no reduction at all through the same code path as `codeburn act undo`; it never touches `partial` or still-measuring fixes, and never auto-reverts a `CLAUDE.md` rule (it prints the undo command instead), matching the `--yes` guardrail.
|
||||
- **Optimize findings say what to do with them and where their number came from.** Every finding now carries a class and a basis, and every surface groups by it: `Fix now (apply-able)` for findings `codeburn optimize --apply` can write itself, `Habits` for the behavioural ones, `FYI` for informational ones whose cost may be justified. A finding only counts as apply-able when a plan can actually be built for that instance, so an `mcp-deferral-off` caused by Vertex policy or a shell-profile override is grouped as a habit rather than promising a fix that does not exist. Alongside it, each finding is marked `measured` (summed from provider-counted usage) or `estimated` (a schema-size or recovery-fraction model), with the split reported in the header as `N measured · M estimated` in place of the blanket "Estimates only." footer. Sessions whose cost the provider never reported are kept out of the `cost-outliers` peer comparison, and a provider that only ever estimates gets the finding marked `estimated` rather than dropped. `--format json` gains `class` and `basis` per finding plus `summary.measuredSavingsUSD` (existing fields unchanged), and the new `docs/optimize.md` covers what is scanned, exactly what `--apply` may write, and how to read the health grade.
|
||||
|
||||
### Added (Windows)
|
||||
- **`codeburn menubar` installs and launches the tray app on Windows.** The same command that installs the macOS menubar now does the Windows one, through the same pinned-release path: it resolves `windows-v<cliVersion>`, falls back to a scan of the newest `windows-v*` release carrying both assets when that tag has none, downloads the `.msi` with the same retry and backoff, and verifies its sha256 before anything executes it — a mismatch aborts without ever handing the file to the installer. It then runs `msiexec` out of `%SystemRoot%\System32` (never a bare name, so nothing dropped next to the CLI can impersonate it) with `/i <msi> /passive /norestart`, treats exit 3010 as installed-pending-restart and 1602 as a cancelled install rather than failures, and launches the exe named by the product's Uninstall registry key. An already-installed matching version skips the download and just launches; `--force` reinstalls.
|
||||
- **A menubar app for Windows.** `windows/` is a Tauri 2 tray app — Rust binary, React popover — that puts today's spend in the notification area and mirrors the macOS menubar screen for screen: agent tabs, period switcher, Trend, Forecast, Pulse, Stats and Plan insights, activity and model breakdowns, optimize findings, CSV/JSON export, launch at login, currency, and theme. Windows has no menubar title, so the number lives in a second tray icon rendered from the system font at the panel's native icon size (Settings can turn it off; the tooltip always carries it). It reads everything through the CLI like the macOS and GNOME clients do, and gates on **codeburn 0.9.9 or newer** — the first release accepting `status --format menubar-json --no-optimize` — showing a setup screen with the install command until it finds one. Refresh follows popover visibility the way the macOS app does: 60 s with optimize findings while open, 2 minutes for today's total while closed, and immediately on open when what you are looking at has gone stale. The Claude quota view never spends Claude's single-use refresh token; on a 401 it re-reads Claude Code's own credential file for a token it has already rotated, matching the macOS client. Ships as an unsigned `.msi` from the `windows-v*` tag, which `codeburn menubar` now installs for you. The same crate still builds and runs a tray on Linux, but that stays experimental and unreleased — `gnome/` is the supported Linux surface.
|
||||
|
||||
### Added (CLI)
|
||||
- **DeepSeek Harness (`dsh`) is now a supported provider.** Reads DeepSeek's open-source agent harness from `~/.dsh/sessions` (`DSH_HOME` relocates the root), both the default zstd logs and the uncompressed `session.jsonl` variant. A `.zstd` log is a concatenation of independent zstd frames, one per write batch, so it is decoded frame by frame behind a structural frame scan and a torn trailing frame from a crashed writer is ignored rather than failing the file (needs Node 22.15+ for `zlib` zstd; below that dsh is skipped with a notice instead of counted as $0). One call per `(turn, step)`, with the step's final `assistant/message` usage superseding the streamed `assistant/chunk` sample of the same call rather than adding to it, the model taken from the message that served the step, and reasoning tokens billed at the output rate. DSH records tokens but no cost, so calls are priced from the shared tables. The events a forked session replays from its parent are skipped, since codeburn already counts the parent's own log. The session format is pinned at version 0 upstream with no compatibility implied, so a log stamped with any other version is skipped with a notice instead of read under today's assumptions.
|
||||
|
||||
### Changed
|
||||
- **Codex rollouts parse across worker threads too, and the workload gate now takes bytes or files.** Codex is the bigger half of a real cold parse — a 4 GB rollout corpus against 1.8 GB of Claude sessions — and it was still decoding one file at a time. A whole-file rollout decode now runs on the same pool, against an empty dedup set, and comes back with the calls, the dedup keys it claimed, and the codex-cache entry it would have written; the parent installs all three in the serial loop's order, so `codex-results.json` and every payload come out byte-identical to a serial run. Cross-file state stays where it was: a forked rollout replaying its parent's token_count history collides on the parent's keys and is re-parsed in-process, and no worker ever touches the cache module's per-directory state. Files the Codex cache can serve exactly or resume into from a byte offset never reach a worker — they read a few KB and the resume state belongs to the parent. The workload gate is now pending BYTES alone (200 MB), not file count: 250 pending files holding under a megabyte between them spawned threads that made the run ~5% slower, while a few hundred huge rollouts were being turned away. The count takes `max(pendingFiles / 50, pendingBytes / 200 MB)`, and the per-thread memory budget is derived per parse as `clamp(256 MB, 2 × average pending file + 128 MB, 1 GB)` rather than a flat 256 MB — a 260 MB rollout peaks near 430 MB in its worker and scales linearly with the pool, so the flat figure over-subscribed exactly the workload this adds. The decision is per provider, and at most one pool is alive at a time.
|
||||
- **A large cold Claude parse now runs across worker threads.** Reading, decoding and line-parsing a session JSONL is per-file work that never touches anything shared, so it moves onto `worker_threads`; each worker ships its parsed turns back as a JSON string and the parent installs them in the exact order the serial loop would. Everything with cross-file state — the streaming-message dedup, canonical project paths, spawn links, PR correlation, progress saves — stays on the main thread, and a file whose message ids were already claimed by an earlier file (or whose worker failed) is simply re-parsed in-process, so the session cache and every payload are identical either way. On a 6 GB corpus a cold `status` drops from 27.5s to 14.8s with peak RSS up 2.27 GB → 2.52 GB. Threads only engage for a genuinely large cold parse: never with under 200 MB behind the pending whole-file re-parses, 2 or fewer cores, or under 4 GB of available memory — so warm and incremental runs are untouched and spawn nothing. Otherwise the count is `min(cores - 1, min(0.25 × available, 2 GB) / 256 MB, pendingFiles / 50)`, where available is `process.availableMemory()` (cgroup-aware in containers) rather than free memory, which on macOS reports free pages and would switch the feature on and off between runs. `CODEBURN_PARSE_WORKERS=0` forces the serial parse and `CODEBURN_PARSE_WORKERS=N` forces N (capped at the core count), both bypassing every gate; `CODEBURN_VERBOSE=1` prints the resolved count and why.
|
||||
|
|
@ -12,10 +25,16 @@
|
|||
- **One rule for every cache file.** `CODEBURN_CACHE_DIR` when set, otherwise `~/.cache/codeburn`. `XDG_CACHE_HOME` is no longer consulted; the sync ledger, the only file that ever honored it, is merged into the canonical location on first read and the legacy copy is retired, so nothing is re-uploaded after the move. (#972)
|
||||
|
||||
### Fixed (Desktop & Menubar)
|
||||
- **First launch no longer asks to control System Events.** The macOS menubar registered its login item by driving System Events over AppleScript, which made macOS put up an Automation consent dialog the first time the app ran. It now registers itself through `SMAppService.mainApp`, an in-process call that needs no Automation grant; there is no AppleScript fallback, so a failure logs and leaves the login item unset rather than bringing the prompt back. The same `codeburn.loginItemRegistered` guard still limits this to the first launch, so a login item you removed by hand stays removed. (#1026)
|
||||
- **The resident `codeburn serve` child.** The first real panel request is also the cache warm-up, so startup never runs an artificial warm-up query beside a duplicate one-shot child; each served command carries its own read-only option allowlist, and anything outside it falls back to a normal spawn; the child exits when its stdin closes, so it can never outlive the app. Requests whose response exceeds the 16 MiB frame limit still replace the child, but that deliberate kill no longer spends the resident's unexpected-death budget. (#972)
|
||||
|
||||
### Fixed
|
||||
- **The session chart legend now leads with a visible session disambiguator and title instead of the project path.** Every series in a monorepo shared the same project prefix, so the only thing separating them was a truncated hex fragment — and per-application cost attribution is the main reason to open that chart. `SessionSummary.title` is already parsed and already rendered in the Context tab; the legend now puts the short session id first, prefers the title, and falls back to the previous project-based label when a session never produced one. Titles come from transcripts, so they are stripped of ANSI and control characters and capped before they reach either the legend or the tooltip. (#997)
|
||||
- **The unpriced-models warning in the dashboard is now readable at every terminal width.** It lived in a fixed-width panel with an inline model list and a fix command, so it clipped mid-name at 80 columns and clipped *earlier* at 200, where the three-column layout narrows each panel - neither the affected models nor a runnable command survived. The panel line is now a pointer, `! N unpriced: codeburn models --unpriced` (shortened to `! N: codeburn models --unpriced` below 45 columns of panel), and the model list moves to that command's plain output, which is full width, copyable, and lists every model rather than the first two. The command's hint no longer reads as an unconditional instruction to alias: a subscription or flat-rate model is correctly $0, and mapping it onto another model's per-token rate would invent spend that was never billed. Provider-supplied model IDs are now stripped of terminal control characters in every human-readable report rather than only on the unpriced path, and `--unpriced` shows raw IDs instead of friendly names because `model-alias` keys on the raw ID. (#969)
|
||||
- **`codeburn models --unpriced --top N` returned nothing for a `--top N` smaller than the number of priced models.** `--top` is applied inside `aggregateModels`, before the unpriced filter, on rows sorted cost-first — and unpriced rows are $0 on both, so they sorted last and the slice removed exactly the rows the flag exists to show. A user with unpriced models was told they had none. The slice now runs after the filter — and after ranking, because unpriced rows tie at $0 on both keys, so slicing them in aggregate order kept whichever models happened to appear earliest in the transcript rather than the largest. The order now matches the one the unpriced-models warning shows. (#969)
|
||||
- **Old durable sources remain visible while they still exist.** The 90-day session-cache age-out now applies only after a durable source disappears from discovery, so an unchanged older Copilot source keeps reporting usage and reuses its persisted fingerprint instead of being reparsed and immediately discarded. (#987) On long-lived machines this makes previously dropped history reappear, so lifetime totals can jump once after upgrading.
|
||||
- **`optimize` no longer treats subagent transcripts as your sessions.** Claude Code writes each subagent's transcript to its own `subagents/agent-*.jsonl` file with `isSidechain: true` on every entry, and optimize counted each one as a user-started session. That inflated the session count in the header and fed the session-level detectors a population that fails their tests by construction: a sidechain is handed a large context and returns a short answer (context-heavy), and it never commits or opens a PR because its parent does (low-worth). Excluded from sidechains now: the header session count, the `low-worth-sessions`, `context-bloat`, `cost-outliers` and `capability-reliability` detectors, the coaching notes, the file-churn table, the median time-to-first-edit, the worst one-shot category, and the model-default recommendation - plus `duplicate-reads`, because a subagent starts on a fresh context and re-reading what its parent read is a necessary read, not a repeat. Everything else keeps the full population: `build-folder-reads` and `read-edit-ratio` still count calls made inside a sidechain, since reading `node_modules` or editing without reading is the same waste whoever does it and the `CLAUDE.md` rule they suggest binds subagents too, and so do the MCP, cache-bloat, ghost-command and configuration-overhead findings. Classification is sticky across the whole file, so calls that appear before the first marked entry are reclassified too, and `isSidechain` now survives the compact parser's 32 KB large-line path and warm-cache range rebuilds. Nothing is deleted from spend: sidechain tokens, calls and cost stay in every total and in `status`, and the optimize result cache keys on sidechain identity so a run cannot be served a pre-fix result. Absent markers still read as user-started, so no cache re-parse is needed. (#974)
|
||||
- **`optimize` no longer offers `claude mcp remove` for claude.ai connectors, and its MCP schema-cost estimate is per session.** A `claude_ai_*` namespace that no readable local MCP config claims is a claude.ai connector, managed through `/mcp` or claude.ai Settings rather than as a local MCP server (a local server that carries the prefix keeps its removal command and gains a same-name connector note); low-coverage findings now render them as a manual follow-up and build `--apply` plans only for exact local server names found in readable MCP config, so mixed findings remove only the local subset and the "apply-able" subtotal counts only that subset. The same change replaces the old global schema-cost cap with per-session, per-server proportional attribution — a more accurate model that lowers `mcp-low-coverage` estimates for everyone, connectors or not (on a large corpus roughly by half). (#975, #991)
|
||||
- **Bash command splitting was quadratic on long whitespace-heavy commands.** The separator regex retried its leading `\s*` from every offset; matching the separator alone and widening over whitespace by hand makes cold parse ~24% and warm ~40% faster on large corpora, output unchanged.
|
||||
- **Cold parse no longer retains full message bodies through cached previews.** `flatSlice` skipped its Buffer round-trip for strings already within the bound, but provider adapters pre-truncate user-message previews with `.slice(0, 500)` before the cache-site call — those pre-sliced views are still V8 SlicedStrings pinning their large parent, so the retention that OOM'd cold parses of large histories survived. The round-trip now always runs.
|
||||
- **Kiro sessions carry the real `projectPath`** (CLI meta.cwd, v2 `workspacePaths[0]`, workspace sessions' `workspaceDirectory`), so git-repo attribution can resolve them; previously they were attribution-blind. Bumps the kiro parse version, so the first run after upgrade re-parses kiro history once, and kiro sessions in linked git worktrees now group under the main repo.
|
||||
|
|
|
|||
54
README.md
54
README.md
|
|
@ -25,7 +25,7 @@
|
|||
<a href="https://github.com/sponsors/iamtoruk"><img src="https://img.shields.io/badge/sponsor-♥-F97316?logo=github" alt="Sponsor" /></a>
|
||||
</p>
|
||||
|
||||
<p align="center">If CodeBurn shows you something your bill never did, <a href="https://github.com/getagentseal/codeburn/stargazers">star the repo</a> so other developers find it, and consider <a href="https://github.com/sponsors/iamtoruk">sponsoring</a> to keep 40 integrations honest.</p>
|
||||
<p align="center">If CodeBurn shows you something your bill never did, <a href="https://github.com/getagentseal/codeburn/stargazers">star the repo</a> so other developers find it, and consider <a href="https://github.com/sponsors/iamtoruk">sponsoring</a> to keep 41 integrations honest.</p>
|
||||
|
||||
<table align="center">
|
||||
<tr>
|
||||
|
|
@ -52,20 +52,21 @@
|
|||
<code>npx codeburn</code>
|
||||
</td>
|
||||
<td align="center" width="50%">
|
||||
<strong>macOS Menubar</strong><br/>
|
||||
<strong>Menubar</strong><br/>
|
||||
<img src="https://raw.githubusercontent.com/getagentseal/codeburn/main/assets/menubar-app.jpg" alt="CodeBurn macOS menubar" /><br/>
|
||||
<code>codeburn menubar</code>
|
||||
<code>codeburn menubar</code><br/>
|
||||
<a href="https://github.com/getagentseal/codeburn/releases/tag/windows-v0.9.20"><img src="https://img.shields.io/badge/Windows-Tray_app_(.msi)-F97316?logo=windows&logoColor=white" alt="Download the CodeBurn Windows menubar" /></a>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
<p align="center"><em>Four surfaces, one source of truth: everything reads the session files already on your disk.</em></p>
|
||||
|
||||
**CodeBurn is a free, open-source, local-first tool that tracks AI coding token usage and cost across 40 tools and agents (Claude Code, Cursor, Codex, Gemini, Grok and more), broken down by model, project, and task.**
|
||||
**CodeBurn is a free, open-source, local-first tool that tracks AI coding token usage and cost across 41 tools and agents (Claude Code, Cursor, Codex, Gemini, Grok and more), broken down by model, project, and task.**
|
||||
|
||||
You pay for Claude, Codex, Cursor, and a stack of other AI tools. The bill tells you the total. It never tells you that half of it went to conversation instead of code, or that an expensive model burned your budget on work a cheaper one would have one-shot.
|
||||
|
||||
CodeBurn does. It reads the session files your tools already write to disk and breaks down every token and dollar by **task, model, tool, and project**, across **40 AI tools**.
|
||||
CodeBurn does. It reads the session files your tools already write to disk and breaks down every token and dollar by **task, model, tool, and project**, across **41 AI tools**.
|
||||
|
||||
Everything runs locally. No wrapper, no proxy, no API keys, nothing leaves your machine. Pricing comes from [LiteLLM](https://github.com/BerriAI/litellm), refreshed daily.
|
||||
|
||||
|
|
@ -107,7 +108,7 @@ Also runs via `bunx codeburn` or `pnpm dlx codeburn`, or `brew install codeburn`
|
|||
codeburn menubar
|
||||
```
|
||||
|
||||
On Linux, a GNOME Shell extension gives the same panel view; see [Linux (GNOME)](#linux-gnome).
|
||||
The same command installs the tray app on Windows; see [Windows](#windows). On Linux, a GNOME Shell extension gives it in the top panel; see [Linux (GNOME)](#linux-gnome).
|
||||
|
||||
Requires **Node.js 22.13+** and at least one supported tool with session data on disk. For Cursor and OpenCode, `better-sqlite3` installs automatically.
|
||||
|
||||
|
|
@ -156,6 +157,14 @@ codeburn optimize --format json # setup health + findings as JSON
|
|||
|
||||
`codeburn optimize` scans your sessions and your `~/.claude/` setup for waste patterns:
|
||||
|
||||
For Claude Code, the optimize session count, the per-session findings, coaching,
|
||||
and model-default recommendations use user-started (main) sessions. Subagent
|
||||
sidechain transcripts are excluded from that population because their delegated
|
||||
context and delivery behavior are structurally different, and so is the re-read
|
||||
finding, since a subagent starts on a fresh context. Findings about how Claude
|
||||
uses tools (junk reads, read:edit ratio) and every spend, MCP, and
|
||||
configuration-overhead finding keep counting them.
|
||||
|
||||
- Files Claude re-reads across sessions (same content, same context, over and over)
|
||||
- Low Read:Edit ratio (editing without reading leads to retries and wasted tokens)
|
||||
- Wasted bash output (uncapped `BASH_MAX_OUTPUT_LENGTH`, trailing noise)
|
||||
|
|
@ -167,6 +176,12 @@ codeburn optimize --format json # setup health + findings as JSON
|
|||
- Possibly low-worth expensive sessions with no edit turns or repeated retries
|
||||
when no `git`/`gh` delivery command is observed
|
||||
|
||||
Findings are grouped into three classes: **Fix now** (CodeBurn can apply it for you), **Habits**
|
||||
(you change how you drive the next session), and **FYI** (informational, the cost may be justified).
|
||||
Each one says whether its savings number is `measured` from provider-counted usage or `estimated`
|
||||
from a model. See [docs/optimize.md](docs/optimize.md) for what is scanned, what `--apply` may write,
|
||||
and how to read the health grade.
|
||||
|
||||
Each finding shows the estimated token and dollar savings plus a ready-to-paste fix: a `CLAUDE.md` line, an environment variable, or a `mv` command to archive unused items. Findings are ranked by urgency (impact weighted against observed waste) and rolled up into an A to F setup health grade. Repeat runs classify each finding as new, improving, or resolved against a 48-hour recent window.
|
||||
|
||||
You can also open it inline from the dashboard: press `o` when a finding count appears in the status bar, `b` to return.
|
||||
|
|
@ -180,11 +195,12 @@ codeburn optimize --apply --yes # apply every appliable fix without prompt
|
|||
codeburn act list # every change CodeBurn has made
|
||||
codeburn act undo --last # roll the most recent change back
|
||||
codeburn act report # realized vs estimated savings
|
||||
codeburn optimize --auto-revert # undo the applied fixes that measured no reduction
|
||||
```
|
||||
|
||||
`codeburn optimize` finds the waste; `--apply` fixes the config-class findings for you: settings values, environment variables, archiving unused agents and skills. Every change is backed up and journaled before it lands. `codeburn act list` shows the history and `codeburn act undo <id>` restores the original files (it refuses if the files changed since being applied, unless you pass `--force`).
|
||||
|
||||
The loop closes on honesty: once an applied fix is at least 3 days old, `codeburn act report` compares its estimated savings against what your sessions actually did, and later `codeburn optimize` runs show that realized figure in the header. Estimates get checked against reality, not just claimed.
|
||||
The loop closes on honesty: once an applied fix is at least 3 days old, `codeburn act report` compares its estimated savings against what your sessions actually did, and every later `codeburn optimize` run lists it under `Applied fixes` with a plain verdict — worked, under its estimate, or did not help, with the undo command for that last case. `--auto-revert` undoes the ones that did nothing (never `CLAUDE.md` rules). Estimates get checked against reality, not just claimed.
|
||||
|
||||
## Guard your budget
|
||||
|
||||
|
|
@ -281,6 +297,8 @@ Pairing is PIN-authorized and stays on your local network. You can also discover
|
|||
|
||||
## Menu bar
|
||||
|
||||
### macOS
|
||||
|
||||
```bash
|
||||
codeburn menubar
|
||||
```
|
||||
|
|
@ -321,6 +339,20 @@ defaults write org.agentseal.codeburn-menubar CodeBurnPreferredTerminal -string
|
|||
|
||||
Allowed values are `terminal` (macOS Terminal.app, the default) and `iterm2`. Anything else falls back to `terminal`. Only terminals that can script a command into a live window are offered; if the chosen app is missing or fails to accept the command, CodeBurn tries Terminal.app and then runs the command in the background, logging each step to Console.app. Takes effect on the next launch of a command, no relaunch needed.
|
||||
|
||||
### Windows
|
||||
|
||||
Windows gets the same ambient view from the system tray, from the same one command:
|
||||
|
||||
```powershell
|
||||
codeburn menubar
|
||||
```
|
||||
|
||||
It downloads the `.msi` for your CLI version, verifies its sha256, runs it through `msiexec /passive`, and launches the tray app. Re-run with `--force` to reinstall; an already-installed matching version is just launched. You can also download the `.msi` yourself from the [latest Windows Menubar release](https://github.com/getagentseal/codeburn/releases/tag/windows-v0.9.20).
|
||||
|
||||
Today's spend sits in the tray as a number beside the flame icon (turn it off in Settings, and the tooltip always carries it). Click for the same popover the macOS app shows: agent tabs, period switcher, Trend, Forecast, Pulse, Stats and Plan insights, activity and model breakdowns, optimize findings, and CSV/JSON export. Settings covers launch at login, the tray number, theme, and currency. It refreshes every 60 seconds while the popover is open and every 2 minutes while it is closed.
|
||||
|
||||
The tray app reads everything through the CLI, so install that first (`npm install -g codeburn`) — it needs **codeburn 0.9.9 or newer**, and shows a setup screen with the install command until it finds one. Source and build instructions are in [`windows/`](windows/) ([windows/DEVELOPMENT.md](windows/DEVELOPMENT.md)). The `.msi` is unsigned for now, so SmartScreen prompts on first run.
|
||||
|
||||
### Linux (GNOME)
|
||||
|
||||
Linux gets the same ambient view through a GNOME Shell extension (GNOME 45+): spend in the top panel, period switcher, compact mode, and daily budget alerts. It lives in [`gnome/`](gnome/):
|
||||
|
|
@ -331,7 +363,7 @@ git clone https://github.com/getagentseal/codeburn && cd codeburn/gnome
|
|||
gnome-extensions enable codeburn@codeburn.dev
|
||||
```
|
||||
|
||||
See [gnome/README.md](gnome/README.md) for settings and development notes. On Windows, `codeburn web` is the always-on view for now.
|
||||
See [gnome/README.md](gnome/README.md) for settings and development notes. The Tauri tray app in `windows/` also builds and runs on Linux, but it is experimental and unreleased there — the GNOME extension is the supported Linux surface.
|
||||
|
||||
## CodeBurn in your agent (MCP)
|
||||
|
||||
|
|
@ -484,6 +516,7 @@ Sync sends token counts, costs, models, and projects, never prompts or code. Thi
|
|||
| `codeburn models --by-task` | Break each model into per-task-type rows |
|
||||
| `codeburn models --by-agent` | Break each model into per-agent rows: which agent drove which model's spend (`(main)` covers non-agent sessions; `--min-cost 0` shows sub-cent agents) |
|
||||
| `codeburn models --top 10` | Only the 10 most expensive models |
|
||||
| `codeburn models --unpriced` | Only models with usage that currently price at $0 — the copyable form of the unpriced-models warning. Shows raw model IDs (not friendly names) so they can be pasted into `model-alias`; JSON keeps them exact |
|
||||
| `codeburn models --format markdown` | Emit a paste-friendly markdown table |
|
||||
| `codeburn models --task feature` | Filter to feature-development work |
|
||||
| `codeburn models --provider claude` | Filter to a single provider |
|
||||
|
|
@ -683,6 +716,7 @@ These are starting points, not verdicts. A 60% cache hit on a single experimenta
|
|||
| **Cline / Roo Code / KiloCode** | VS Code `globalStorage` across VS Code, VS Code Insiders, and VSCodium (Cline at `saoudrizwan.claude-dev`, plus `~/.cline/data`) | Cline-family agents. CodeBurn reads `ui_messages.json` from each task directory, extracting token counts from `type: "say"` entries with `say: "api_req_started"`. |
|
||||
| **Cline CLI** | `~/.cline/data/sessions/<session-id>/` (honors `CLINE_SESSION_DATA_DIR`, `CLINE_DATA_DIR`, `CLINE_DIR`) | The Cline command-line agent, whose layout is unrelated to the VS Code extension's. Reads `<session-id>.json` for session metadata and the rolled-up `usage`, and `<session-id>.messages.json` for the per-message `metrics` block (input, output, cacheRead, cacheWrite, cost) that becomes one call each. |
|
||||
| **CodeWhale** | `~/.codewhale/sessions/*.json` plus unmigrated legacy `~/.deepseek/sessions/*.json`; `$CODEWHALE_HOME/sessions` is an exact override | Emits one cumulative record per saved session. CodeWhale exposes only `total_tokens`, so CodeBurn preserves that aggregate in the input column rather than inventing an input/output split. Cost is the exact stored parent-session plus subagent USD total; model pricing is used only when the cost snapshot is absent. Tool blocks, shell commands, skills, and subagent types are retained. |
|
||||
| **DeepSeek Harness** (`dsh`) | `~/.dsh/sessions/--<slug>--/<session-id>/session.jsonl.zstd` (or `session.jsonl` when compression is off); `DSH_HOME` relocates the root | DeepSeek's open-source agent harness, unrelated to the CodeWhale desktop app. The `.zstd` log is a concatenation of independent zstd frames (one per write batch), decoded frame by frame; needs Node 22.15+. One call per `(turn, step)`, with usage from the step's `assistant/message` (the streamed `assistant/chunk` sample is a draft of the same call, never a second one). DSH records tokens but no cost, so calls are priced from the shared tables with reasoning billed at the output rate. |
|
||||
| **IBM Bob** | `User/globalStorage/ibm.bob-code/tasks/<task-id>/` (GA `IBM Bob` and preview `Bob-IDE` app folders) | Reads `ui_messages.json` for API request token/cost records and `api_conversation_history.json` for the selected model. |
|
||||
| **Kimi Code CLI** | `$KIMI_SHARE_DIR/sessions/<workdir-hash>/<session-id>/` or `~/.kimi/sessions/<workdir-hash>/<session-id>/` | Reads `wire.jsonl` `StatusUpdate.token_usage` records, mapping `input_other`, `input_cache_read`, `input_cache_creation`, and `output` into the standard token columns; includes subagents under each session's `subagents/` folder. |
|
||||
| **LingTai TUI** | `~/.lingtai/<agent>/logs/token_ledger.jsonl` plus project homes from `~/.lingtai-tui/registry.jsonl` (`<project>/.lingtai/<agent>/logs/token_ledger.jsonl`); honors `LINGTAI_HOME` / `LINGTAI_TUI_HOME` | Reads LingTai's append-only token ledger, mapping `input - cached` to fresh input, `cached` to cache reads, `output` to output, and `thinking` to reasoning. Nested daemon ledgers are skipped because parent ledgers already mirror daemon usage with `source`/`run_id` tags. |
|
||||
|
|
@ -722,12 +756,12 @@ CodeBurn deduplicates messages (by API message ID for Claude, by cumulative toke
|
|||
|
||||
CodeBurn is free, runs entirely on your machine, and exists to cut your AI bill. If it has already saved you more than a sponsorship costs, consider sending a little of that back.
|
||||
|
||||
Keeping 40 integrations accurate is constant work. The tools underneath change every week: Cursor reshapes its database, Claude moves a config path, new models ship at new prices. Sponsorship keeps CodeBurn current with all of it, so the numbers you see are always the real ones.
|
||||
Keeping 41 integrations accurate is constant work. The tools underneath change every week: Cursor reshapes its database, Claude moves a config path, new models ship at new prices. Sponsorship keeps CodeBurn current with all of it, so the numbers you see are always the real ones.
|
||||
|
||||
Where your sponsorship goes:
|
||||
|
||||
- **Honest numbers.** New models and price changes are mapped quickly, so your cost is the real cost, not a guess.
|
||||
- **More tools.** Every one of the 40 providers started as a single file. Sponsorship funds the next one.
|
||||
- **More tools.** Every one of the 41 providers started as a single file. Sponsorship funds the next one.
|
||||
- **Fast fixes.** When a vendor breaks something, paid time is what gets it patched now instead of someday.
|
||||
|
||||
Sponsoring as a team or company? Your logo lands right here, in front of every developer who opens the repo. The first sponsor gets it to themselves until the next one shows up.
|
||||
|
|
|
|||
|
|
@ -1,8 +1,10 @@
|
|||
# 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.
|
||||
This document describes the actual steps a maintainer takes to cut CLI, macOS menubar, and Electron desktop releases. 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.
|
||||
|
||||
The Electron desktop app (`app/`) has no CI automation yet, but it is released manually under `desktop-v<version>` tags: build the artifacts on a macOS host (see `app/DISTRIBUTION.md`) and `gh release upload desktop-v<version> … --clobber` them onto the release. See `app/DISTRIBUTION.md` for how to build and distribute it as an ad-hoc-signed, non-notarized macOS build (plus unsigned Windows and Linux builds).
|
||||
The Electron desktop app (`app/`) is released manually under `desktop-v<version>` tags. Build macOS and Linux artifacts as described in `app/DISTRIBUTION.md`; the tag also runs the read-only `Build Windows installer` workflow on `windows-latest`. Download its `CodeBurn-Windows-Installer` artifact and upload both the `.exe` and `.exe.blockmap` with the other platform assets. The workflow never publishes release assets.
|
||||
|
||||
Before announcing a desktop release, the release owner must confirm the live GitHub Release contains all four macOS `.dmg`/`.zip` files, the Linux `.AppImage`, `.deb`, and `.rpm`, and both Windows installer files. Publishing the Release runs the workflow's read-only live-asset verification job. If assets are uploaded after publication, rerun `Build Windows installer` with the `release_tag` input and require that verification job to pass. A failed or missing verification is a release blocker.
|
||||
|
||||
## Versioning
|
||||
|
||||
|
|
@ -197,4 +199,4 @@ For the menubar, tag a new mac-v0.9.9 and let the workflow build and upload it.
|
|||
|
||||
## Summary
|
||||
|
||||
The CLI release is manual: bump the version, update `CHANGELOG.md`, commit, run `npm publish`, then tag and create a GitHub Release. The macOS menubar release is automated: pushing a `mac-v*` tag fires `.github/workflows/release-menubar.yml`, which builds, signs, zips, and publishes the bundle. The homebrew-core formula is updated automatically or via `brew bump-formula-pr`.
|
||||
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 Electron desktop release is assembled manually under a `desktop-v*` tag, with the release-authoritative Windows NSIS installer built by the read-only `windows-latest` workflow. The homebrew-core formula is updated automatically or via `brew bump-formula-pr`.
|
||||
|
|
|
|||
51
THIRD_PARTY_NOTICES.md
Normal file
51
THIRD_PARTY_NOTICES.md
Normal file
|
|
@ -0,0 +1,51 @@
|
|||
# Third-party notices
|
||||
|
||||
CodeBurn is MIT licensed (see `LICENSE`). It also contains code derived from the
|
||||
projects below, which carry their own terms. Each notice is reproduced here as
|
||||
those terms require.
|
||||
|
||||
---
|
||||
|
||||
## @deepseek-ai/dsh-session-persistence-jsonl
|
||||
|
||||
`scanZstdFrames` in `src/providers/dsh.ts` is a transcription of the function of
|
||||
the same name in this package (`src/zstd.ts`), which is what lets CodeBurn read
|
||||
a DeepSeek Harness session log without depending on the harness itself. No other
|
||||
part of the package is used.
|
||||
|
||||
Upstream declares two different licenses for this package: the published npm
|
||||
package (0.0.1-rc.1) ships a BSD 3-Clause `LICENSE` and declares
|
||||
`"license": "BSD-3-Clause"`, while the monorepo source it is built from
|
||||
(`deepseek-ai/deepseek-harness`, `packages/session/session-persistence-jsonl`)
|
||||
declares MIT. The stricter of the two is reproduced below.
|
||||
|
||||
```
|
||||
BSD 3-Clause License
|
||||
|
||||
Copyright (c) 2026, DeepSeek
|
||||
|
||||
Redistribution and use in source and binary forms, with or without
|
||||
modification, are permitted provided that the following conditions are met:
|
||||
|
||||
1. Redistributions of source code must retain the above copyright notice, this
|
||||
list of conditions and the following disclaimer.
|
||||
|
||||
2. Redistributions in binary form must reproduce the above copyright notice,
|
||||
this list of conditions and the following disclaimer in the documentation
|
||||
and/or other materials provided with the distribution.
|
||||
|
||||
3. Neither the name of the copyright holder nor the names of its
|
||||
contributors may be used to endorse or promote products derived from
|
||||
this software without specific prior written permission.
|
||||
|
||||
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
||||
AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
||||
IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
|
||||
DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
|
||||
FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
|
||||
DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
|
||||
SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
|
||||
CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
|
||||
OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
|
||||
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
|
||||
```
|
||||
|
|
@ -3,10 +3,10 @@
|
|||
This document describes how to produce distributable macOS, Windows, and Linux
|
||||
builds of the Electron desktop app. The macOS build is ad-hoc-signed and
|
||||
**not notarized** (no paid Apple Developer account); the Windows and Linux
|
||||
builds are **unsigned**. There is no CI automation for any of this yet (unlike
|
||||
the CLI and menubar release processes in `../RELEASING.md`) — packaging is run
|
||||
by hand on a maintainer's machine. All three targets are produced by
|
||||
`electron-builder` and can be cross-built from a single macOS host.
|
||||
builds are **unsigned**. Windows NSIS packages are built and checked by the
|
||||
`Build Windows installer` GitHub Actions workflow; the other desktop packages
|
||||
are still produced by hand. All three targets are produced by
|
||||
`electron-builder`.
|
||||
|
||||
## The bundled CLI (no install prerequisite)
|
||||
|
||||
|
|
@ -93,8 +93,9 @@ self-contained bundle into `app/build/cli`; see "The bundled CLI" above), then
|
|||
`vite`), then `electron-builder --mac` (whose `afterPack` hook copies the
|
||||
staged CLI into the app). `package:win` and `package:linux` mirror it exactly,
|
||||
swapping the final flag for `electron-builder --win` and `electron-builder
|
||||
--linux`. All three can run on the same macOS host — electron-builder downloads
|
||||
the NSIS and AppImage tooling on first use.
|
||||
--linux`. Developers can run all three locally on the same macOS host —
|
||||
electron-builder downloads the NSIS and AppImage tooling on first use. Release
|
||||
Windows installers are built by the `windows-latest` workflow described below.
|
||||
|
||||
### Artifacts
|
||||
|
||||
|
|
@ -154,19 +155,20 @@ separate `electron-builder.yml`):
|
|||
|
||||
## Windows and Linux builds
|
||||
|
||||
Both are cross-built from the same macOS host used for the mac build — no
|
||||
Windows or Linux machine, and no `wine`, is required. electron-builder 26
|
||||
embeds the Windows executable's icon/version resources natively and downloads
|
||||
the NSIS and AppImage tooling on first run.
|
||||
Developers can cross-build both locally from the same macOS host used for the
|
||||
mac build — no Windows or Linux machine, and no `wine`, is required.
|
||||
Release-authoritative Windows NSIS installers are instead built by the `Build
|
||||
Windows installer` workflow on `windows-latest`. electron-builder 26 embeds the
|
||||
Windows executable's icon/version resources natively and downloads the NSIS and
|
||||
AppImage tooling on first run.
|
||||
|
||||
### Windows (`package:win`)
|
||||
|
||||
`electron-builder --win` produces a single artifact in `app/release/`:
|
||||
`electron-builder --win` produces a single installer in `app/release/`:
|
||||
|
||||
- **`CodeBurn Setup 0.9.15.exe`** — the NSIS installer (the version number
|
||||
tracks `package.json`; note the spaces in the filename). A `.exe.blockmap`
|
||||
is written alongside it (differential-update metadata, unused — no
|
||||
auto-updater yet).
|
||||
- **`CodeBurn-Setup-0.9.15.exe`** — the NSIS installer (the version number
|
||||
tracks `package.json`). A `.exe.blockmap` is written alongside it
|
||||
(differential-update metadata, unused — no auto-updater yet).
|
||||
|
||||
Config (`build.win` + `build.nsis`):
|
||||
|
||||
|
|
@ -236,8 +238,7 @@ taskbar/dock; it does not affect packaging or launch.
|
|||
|
||||
## Releases
|
||||
|
||||
There is no release CI for the desktop app yet (see the note at the top). When
|
||||
a maintainer cuts a desktop release by hand, the GitHub tag convention is:
|
||||
When a maintainer cuts a desktop release, the GitHub tag convention is:
|
||||
|
||||
```
|
||||
desktop-v<version> # e.g. desktop-v0.9.15
|
||||
|
|
@ -245,14 +246,31 @@ desktop-v<version> # e.g. desktop-v0.9.15
|
|||
|
||||
This mirrors the menubar's `mac-v<version>` convention (see `../RELEASING.md`)
|
||||
and keeps the desktop app's tags in their own namespace, separate from the CLI
|
||||
(`v<version>`) and the menubar (`mac-v<version>`). Upload all of the artifacts
|
||||
above — the four macOS `.dmg`/`.zip` files, `CodeBurn-Setup-<version>.exe`,
|
||||
and `CodeBurn-<version>.AppImage` — to the GitHub Release created at that
|
||||
tag. The website's download links **pin that tag** in their URLs, so the
|
||||
release name and the artifact filenames must match exactly. (The Windows
|
||||
installer uses an explicit `nsis.artifactName` of
|
||||
`CodeBurn-Setup-${version}.${ext}` — electron-builder's default contains
|
||||
spaces, which make ugly percent-encoded URLs.)
|
||||
(`v<version>`) and the menubar (`mac-v<version>`).
|
||||
|
||||
Pushing a `desktop-v<version>` tag runs the `Build Windows installer` workflow
|
||||
on `windows-latest`. The workflow requires the tag version, root package
|
||||
version, and app package version to agree, and it fails unless the build emits
|
||||
exactly one `CodeBurn-Setup-<version>.exe` and one matching
|
||||
`.exe.blockmap` at the top level of `app/release/`. It uploads those exact
|
||||
top-level filenames as the `CodeBurn-Windows-Installer`
|
||||
Actions artifact. The workflow has read-only repository permissions and does
|
||||
**not** publish release assets automatically. Artifacts are retained for 30 days.
|
||||
|
||||
Before publishing the GitHub Release, the release owner must download that
|
||||
workflow artifact and manually upload both Windows files along with the four
|
||||
macOS `.dmg`/`.zip` files, `CodeBurn-<version>.AppImage`,
|
||||
`codeburn-desktop_<version>_amd64.deb`, and
|
||||
`codeburn-desktop-<version>.x86_64.rpm`. Confirm the live release contains
|
||||
every required platform asset before announcing it. The
|
||||
website's download links **pin that tag** in their URLs, so a release with a
|
||||
missing installer is broken even when another Windows distribution channel is
|
||||
available. The Windows installer uses an explicit `nsis.artifactName` of
|
||||
`CodeBurn-Setup-${version}.${ext}`.
|
||||
|
||||
Publishing the Release triggers a read-only live-asset check. If the files are
|
||||
uploaded afterward, rerun the workflow manually with `release_tag` set to the
|
||||
existing `desktop-v<version>` tag and require the verification job to pass.
|
||||
|
||||
## Verifying a build
|
||||
|
||||
|
|
|
|||
|
|
@ -169,6 +169,7 @@
|
|||
"$HOME/.copilot",
|
||||
"$HOME/.cursor",
|
||||
"$HOME/.deepseek",
|
||||
"$HOME/.dsh/sessions",
|
||||
"$HOME/.factory",
|
||||
"$HOME/.forge",
|
||||
"$HOME/.gemini",
|
||||
|
|
|
|||
|
|
@ -138,7 +138,12 @@ function installDefaultMocks() {
|
|||
summary: {
|
||||
healthScore: 100, healthGrade: 'A', findingCount: 0, periodCostUSD: 0,
|
||||
sessions: 0, calls: 0, potentialSavingsTokens: 0, potentialSavingsCostUSD: 0,
|
||||
potentialSavingsPercent: 0, costRateUSD: 0,
|
||||
potentialSavingsPercent: 0, costRateUSD: 0, measuredSavingsUSD: 0,
|
||||
byClass: {
|
||||
fix: { tokensSaved: 0, savingsUSD: 0, count: 0 },
|
||||
nudge: { tokensSaved: 0, savingsUSD: 0, count: 0 },
|
||||
keep: { tokensSaved: 0, savingsUSD: 0, count: 0 },
|
||||
},
|
||||
},
|
||||
findings: [],
|
||||
})
|
||||
|
|
|
|||
|
|
@ -403,10 +403,12 @@ export type SpendFlow = {
|
|||
// ————— src/optimize.ts —————
|
||||
|
||||
export type WasteAction =
|
||||
| { type: 'paste'; label: string; text: string; destination?: 'claude-md' | 'session-opener' | 'prompt' | 'shell-config' }
|
||||
| { type: 'paste'; label: string; text: string; destination?: 'claude-md' | 'session-opener' | 'prompt' | 'shell-config' | 'manual' }
|
||||
| { type: 'command'; label: string; text: string }
|
||||
| { type: 'file-content'; label: string; path: string; content: string }
|
||||
|
||||
export type FindingClass = 'fix' | 'nudge' | 'keep'
|
||||
|
||||
export type OptimizeJsonReport = {
|
||||
period: { label: string; start: string | null; end: string | null }
|
||||
summary: {
|
||||
|
|
@ -420,6 +422,8 @@ export type OptimizeJsonReport = {
|
|||
potentialSavingsCostUSD: number
|
||||
potentialSavingsPercent: number | null
|
||||
costRateUSD: number
|
||||
measuredSavingsUSD: number
|
||||
byClass: Record<FindingClass, { tokensSaved: number; savingsUSD: number; count: number }>
|
||||
}
|
||||
findings: Array<{
|
||||
id: string
|
||||
|
|
@ -429,8 +433,21 @@ export type OptimizeJsonReport = {
|
|||
trend: 'active' | 'improving' | null
|
||||
tokensSaved: number
|
||||
estimatedSavingsUSD: number
|
||||
class: FindingClass
|
||||
basis: 'measured' | 'estimated'
|
||||
fix: WasteAction
|
||||
}>
|
||||
/** Still-applied fixes, re-measured on every run. Absent on older CLIs. */
|
||||
appliedFixes?: Array<{
|
||||
id: string
|
||||
kind: string
|
||||
findingId: string | null
|
||||
appliedAt: string
|
||||
verdict: 'worked' | 'partial' | 'no-effect' | 'pending'
|
||||
estimatedTokens: number
|
||||
realizedTokens: number
|
||||
undoCommand: string
|
||||
}>
|
||||
}
|
||||
|
||||
// ————— T1b: src/sharing/* (defined by the shared contract) —————
|
||||
|
|
|
|||
|
|
@ -48,23 +48,31 @@ function makeOptimizeReport(): OptimizeJsonReport {
|
|||
healthScore: 72, healthGrade: 'C', findingCount: 3, periodCostUSD: 612.48,
|
||||
sessions: 88, calls: 1220, potentialSavingsTokens: 184_000,
|
||||
potentialSavingsCostUSD: 94.4, potentialSavingsPercent: 15.4, costRateUSD: 0.0005,
|
||||
measuredSavingsUSD: 27.8,
|
||||
byClass: {
|
||||
fix: { tokensSaved: 18_200, savingsUSD: 9.1, count: 1 },
|
||||
nudge: { tokensSaved: 17_400, savingsUSD: 8.7, count: 1 },
|
||||
keep: { tokensSaved: 4_800, savingsUSD: 2.4, count: 1 },
|
||||
},
|
||||
},
|
||||
findings: [
|
||||
{
|
||||
id: 'cost-outliers', title: 'Opus is doing your small talk',
|
||||
id: 'unused-mcp', title: 'Opus is doing your small talk',
|
||||
explanation: 'Small conversational requests are running on an expensive model.',
|
||||
severity: 'high', trend: 'active', tokensSaved: 18_200, estimatedSavingsUSD: 9.1,
|
||||
class: 'fix', basis: 'estimated',
|
||||
fix: { type: 'paste', label: 'Paste into CLAUDE.md', text: 'Use Sonnet for routine questions.', destination: 'claude-md' },
|
||||
},
|
||||
{
|
||||
id: 'context-heavy-sessions', title: 'Cache hit is low in agentseal-dash',
|
||||
id: 'cost-outliers', title: 'Cache hit is low in agentseal-dash',
|
||||
explanation: 'Repeated context is not being served from cache.', severity: 'medium',
|
||||
trend: null, tokensSaved: 17_400, estimatedSavingsUSD: 8.7,
|
||||
trend: null, tokensSaved: 17_400, estimatedSavingsUSD: 8.7, class: 'nudge', basis: 'measured',
|
||||
fix: { type: 'command', label: 'Run this command', text: 'codeburn cache inspect' },
|
||||
},
|
||||
{
|
||||
id: 'warmup-heavy', title: 'Batch tiny requests', explanation: 'Many short sessions repeat setup work.',
|
||||
id: 'context-heavy-sessions', title: 'Batch tiny requests', explanation: 'Many short sessions repeat setup work.',
|
||||
severity: 'low', trend: 'improving', tokensSaved: 4_800, estimatedSavingsUSD: 2.4,
|
||||
class: 'keep', basis: 'measured',
|
||||
fix: { type: 'file-content', label: 'Create configuration', path: '~/.codeburn/config.json', content: '{"batch":true}' },
|
||||
},
|
||||
],
|
||||
|
|
@ -121,6 +129,46 @@ describe('Optimize', () => {
|
|||
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
|
||||
})
|
||||
|
||||
it('lists applied fixes with a glyph per verdict and the undo hint', async () => {
|
||||
const report = makeOptimizeReport()
|
||||
report.appliedFixes = [
|
||||
{ id: 'a1', kind: 'archive-skill', findingId: 'unused-skills', appliedAt: '2026-07-06T00:00:00.000Z', verdict: 'worked', estimatedTokens: 300_000, realizedTokens: 280_000, undoCommand: 'codeburn act undo a1' },
|
||||
{ id: 'b2', kind: 'defer-threshold', findingId: 'mcp-defer-threshold', appliedAt: '2026-07-07T00:00:00.000Z', verdict: 'partial', estimatedTokens: 600_000, realizedTokens: 420_000, undoCommand: 'codeburn act undo b2' },
|
||||
{ id: 'c3', kind: 'shell-config', findingId: 'bash-output-cap', appliedAt: '2026-07-05T00:00:00.000Z', verdict: 'no-effect', estimatedTokens: 41_000, realizedTokens: 0, undoCommand: 'codeburn act undo c3' },
|
||||
{ id: 'd4', kind: 'mcp-remove', findingId: null, appliedAt: '2026-07-09T00:00:00.000Z', verdict: 'pending', estimatedTokens: 0, realizedTokens: 0, undoCommand: 'codeburn act undo d4' },
|
||||
]
|
||||
getOptimizeReport.mockResolvedValue(report)
|
||||
render(<Optimize period="30days" provider="all" />)
|
||||
|
||||
await screen.findByText('Applied fixes')
|
||||
const rows = [...document.querySelectorAll('.opt-applied-row')]
|
||||
expect(rows.map(r => r.className.split(' ')[1])).toEqual([
|
||||
'opt-applied-worked', 'opt-applied-partial', 'opt-applied-no-effect', 'opt-applied-pending',
|
||||
])
|
||||
expect(rows[0]!.textContent).toContain('unused-skills')
|
||||
expect(rows[0]!.textContent).toContain('est. 300K \u2192 280K')
|
||||
expect(rows[3]!.textContent).toContain('mcp-remove')
|
||||
expect(screen.getByText('codeburn act undo c3')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('omits the applied-fixes list when nothing is applied', async () => {
|
||||
render(<Optimize period="30days" provider="all" />)
|
||||
await screen.findByText('Opus is doing your small talk')
|
||||
expect(document.querySelector('.opt-applied')).toBeNull()
|
||||
})
|
||||
|
||||
it('groups Waste findings under the fix / habits / FYI headers in order', async () => {
|
||||
render(<Optimize period="30days" provider="all" />)
|
||||
|
||||
await screen.findByText('Opus is doing your small talk')
|
||||
const groups = document.querySelectorAll('.opt-group')
|
||||
expect([...groups].map(g => g.textContent)).toEqual([
|
||||
'Fix now (apply-able) · 18.2K tokens · $9.10 · 1 finding',
|
||||
'Habits · 17.4K tokens · $8.70 · 1 finding',
|
||||
'FYI · 4.8K tokens · $2.40 · 1 finding',
|
||||
])
|
||||
})
|
||||
|
||||
it('renders tabs and actionable Waste findings with impact, savings, explanation, and copy-paste fix', async () => {
|
||||
render(<Optimize period="30days" provider="all" />)
|
||||
|
||||
|
|
@ -130,7 +178,7 @@ describe('Optimize', () => {
|
|||
expect(screen.getByText('Medium')).toHaveClass('opt-impact-medium')
|
||||
expect(screen.getByText('Low')).toHaveClass('opt-impact-low')
|
||||
expect(screen.getByText('$9.10')).toHaveClass('opt-finding-savings')
|
||||
expect(screen.getByText('18.2K tokens')).toBeInTheDocument()
|
||||
expect(screen.getByText('18.2K tokens · estimated')).toBeInTheDocument()
|
||||
expect(screen.getByRole('tab', { name: 'Waste $94.40' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('tab', { name: 'Reverts $107.00' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('tab', { name: 'Abandoned $65.40' })).toBeInTheDocument()
|
||||
|
|
@ -160,6 +208,32 @@ describe('Optimize', () => {
|
|||
expect(screen.getByText('{"batch":true}')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('renders and copies connector guidance as a manual action', async () => {
|
||||
const report = makeOptimizeReport()
|
||||
report.findings.push({
|
||||
id: 'mcp-low-coverage', title: 'Underused claude.ai connector',
|
||||
explanation: 'The connector loads unused tools.', severity: 'medium',
|
||||
trend: null, tokensSaved: 2_000, estimatedSavingsUSD: 1,
|
||||
// Connector-only: no appliable plan, so the finding is a nudge.
|
||||
class: 'nudge', basis: 'estimated',
|
||||
fix: {
|
||||
type: 'paste', destination: 'manual', label: 'Manage the connector where it loads:',
|
||||
text: 'Open /mcp and disable claude.ai Google Calendar.',
|
||||
},
|
||||
})
|
||||
report.summary.byClass.nudge = { tokensSaved: 19_400, savingsUSD: 9.7, count: 2 }
|
||||
getOptimizeReport.mockResolvedValue(report)
|
||||
render(<Optimize period="30days" provider="all" />)
|
||||
const row = await screen.findByRole('button', { name: /Underused claude.ai connector/ })
|
||||
fireEvent.click(row)
|
||||
|
||||
expect(screen.getByText('Manage the connector where it loads:')).toBeInTheDocument()
|
||||
expect(screen.getByText('Open /mcp and disable claude.ai Google Calendar.')).toBeInTheDocument()
|
||||
expect(row.parentElement?.querySelector('.opt-fix')).toHaveClass('opt-fix-paste')
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Copy' }))
|
||||
await waitFor(() => expect(writeText).toHaveBeenCalledWith('Open /mcp and disable claude.ai Google Calendar.'))
|
||||
})
|
||||
|
||||
it('switches to Reverts and Abandoned and shows only the matching yield details', async () => {
|
||||
render(<Optimize period="30days" provider="all" />)
|
||||
await screen.findByText('Opus is doing your small talk')
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import { StaleBanner } from '../components/StaleBanner'
|
|||
import { type Polled, usePolled } from '../hooks/usePolled'
|
||||
import { formatCompact, formatUsd } from '../lib/format'
|
||||
import { codeburn } from '../lib/ipc'
|
||||
import type { DateRange, MenubarPayload, OptimizeJsonReport, Period, SessionYieldJson, WasteAction, YieldJsonReport } from '../lib/types'
|
||||
import type { DateRange, FindingClass, MenubarPayload, OptimizeJsonReport, Period, SessionYieldJson, WasteAction, YieldJsonReport } from '../lib/types'
|
||||
|
||||
type OptimizeTab = 'waste' | 'reverts' | 'abandoned' | 'fixes'
|
||||
|
||||
|
|
@ -101,7 +101,51 @@ function WasteRows({ report }: { report: Polled<OptimizeJsonReport> }) {
|
|||
<div className="opt-summary">
|
||||
{report.data.summary.findingCount.toLocaleString('en-US')} findings · {formatUsd(report.data.summary.potentialSavingsCostUSD)} potential · health {report.data.summary.healthScore}/100
|
||||
</div>
|
||||
<ActionableFindingRows findings={report.data.findings} />
|
||||
<ActionableFindingRows findings={report.data.findings} byClass={report.data.summary.byClass} />
|
||||
<AppliedFixRows fixes={report.data.appliedFixes ?? []} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type AppliedFix = NonNullable<OptimizeJsonReport['appliedFixes']>[number]
|
||||
|
||||
const VERDICT_GLYPH: Record<AppliedFix['verdict'], string> = {
|
||||
worked: '\u2713',
|
||||
partial: '~',
|
||||
'no-effect': '\u2717',
|
||||
pending: '\u2026',
|
||||
}
|
||||
|
||||
const VERDICT_LABEL: Record<AppliedFix['verdict'], string> = {
|
||||
worked: 'worked',
|
||||
partial: 'under estimate',
|
||||
'no-effect': 'did not help',
|
||||
pending: 'measuring',
|
||||
}
|
||||
|
||||
// Closes the loop after `optimize --apply`: what each applied fix actually
|
||||
// measured, and for the ones that did nothing, how to put them back.
|
||||
function AppliedFixRows({ fixes }: { fixes: AppliedFix[] }) {
|
||||
if (!fixes.length) return null
|
||||
|
||||
return (
|
||||
<div className="opt-findings opt-applied">
|
||||
<div className="opt-group">Applied fixes</div>
|
||||
{fixes.map(fix => (
|
||||
<div className={`opt-applied-row opt-applied-${fix.verdict}`} key={fix.id}>
|
||||
<span className="opt-applied-glyph" aria-hidden="true">{VERDICT_GLYPH[fix.verdict]}</span>
|
||||
<b className="opt-finding-title">{fix.findingId ?? fix.kind}</b>
|
||||
<span className="opt-applied-verdict">{VERDICT_LABEL[fix.verdict]}</span>
|
||||
<span className="opt-finding-tokens">
|
||||
{fix.verdict === 'pending'
|
||||
? '\u2014'
|
||||
: `est. ${formatCompact(fix.estimatedTokens)} \u2192 ${formatCompact(fix.realizedTokens)}`}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{fixes.some(fix => fix.verdict === 'no-effect') && (
|
||||
<div className="opt-summary opt-applied-hint">Revert one that did not help: <code>{fixes.find(fix => fix.verdict === 'no-effect')!.undoCommand}</code></div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -114,11 +158,17 @@ const IMPACT_ICON: Record<'high' | 'medium' | 'low', string> = {
|
|||
low: '↓',
|
||||
}
|
||||
|
||||
const CLASS_HEADERS: Record<FindingClass, string> = {
|
||||
fix: 'Fix now (apply-able)',
|
||||
nudge: 'Habits',
|
||||
keep: 'FYI',
|
||||
}
|
||||
|
||||
function actionText(fix: WasteAction): string {
|
||||
return fix.type === 'file-content' ? fix.content : fix.text
|
||||
}
|
||||
|
||||
function ActionableFindingRows({ findings }: { findings: OptimizeFinding[] }) {
|
||||
function ActionableFindingRows({ findings, byClass }: { findings: OptimizeFinding[]; byClass: OptimizeJsonReport['summary']['byClass'] }) {
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||
const [copiedId, setCopiedId] = useState<string | null>(null)
|
||||
|
||||
|
|
@ -132,10 +182,18 @@ function ActionableFindingRows({ findings }: { findings: OptimizeFinding[] }) {
|
|||
|
||||
return (
|
||||
<div className="opt-findings">
|
||||
{findings.map(finding => {
|
||||
{findings.map((finding, i) => {
|
||||
const expanded = expandedId === finding.id
|
||||
// Findings arrive class-sorted from the CLI, so a header goes in
|
||||
// wherever the class changes.
|
||||
const showHeader = finding.class !== findings[i - 1]?.class
|
||||
return (
|
||||
<Fragment key={finding.id}>
|
||||
{showHeader && (
|
||||
<div className="opt-group">
|
||||
{CLASS_HEADERS[finding.class]} · {formatCompact(byClass[finding.class].tokensSaved)} tokens · {formatUsd(byClass[finding.class].savingsUSD)} · {byClass[finding.class].count} {byClass[finding.class].count === 1 ? 'finding' : 'findings'}
|
||||
</div>
|
||||
)}
|
||||
<button
|
||||
className="opt-finding opt-finding-toggle"
|
||||
type="button"
|
||||
|
|
@ -153,7 +211,7 @@ function ActionableFindingRows({ findings }: { findings: OptimizeFinding[] }) {
|
|||
)}
|
||||
</span>
|
||||
<span className="opt-finding-savings">{formatUsd(finding.estimatedSavingsUSD)}</span>
|
||||
<span className="opt-finding-tokens">{formatCompact(finding.tokensSaved)} tokens</span>
|
||||
<span className="opt-finding-tokens">{formatCompact(finding.tokensSaved)} tokens · {finding.basis}</span>
|
||||
<span className="opt-finding-chevron" aria-hidden="true">›</span>
|
||||
</button>
|
||||
{expanded && (
|
||||
|
|
|
|||
|
|
@ -640,6 +640,9 @@ td:first-child { font-size: var(--fs-body); font-weight: var(--fw-body); }
|
|||
.opt-waste { min-width: 0; }
|
||||
.opt-summary { padding: 0 0 10px; color: var(--mut); font-size: 11.5px; font-variant-numeric: tabular-nums; }
|
||||
.opt-findings { display: grid; min-width: 0; }
|
||||
.opt-group { padding: 11px 0 5px; color: var(--mut2); font-size: 10px; font-weight: 600; letter-spacing: 0.06em; text-transform: uppercase; }
|
||||
.opt-group:first-child { padding-top: 0; }
|
||||
.opt-group + .opt-finding { border-top: 0; }
|
||||
.opt-finding { display: grid; align-items: center; column-gap: 12px; min-height: 43px; border-top: 1px solid var(--line2); }
|
||||
.opt-finding:first-child { border-top: 0; }
|
||||
.opt-finding-legacy { grid-template-columns: 28px minmax(0, 1fr) 104px 86px; }
|
||||
|
|
@ -668,6 +671,15 @@ td:first-child { font-size: var(--fs-body); font-weight: var(--fw-body); }
|
|||
.opt-fix-code { max-width: 100%; overflow-x: auto; margin: 0; padding: 10px 11px; border: 1px solid var(--line); border-radius: 6px; background: var(--phead); color: var(--ink); font-family: var(--mono); font-size: 11px; line-height: 1.5; white-space: pre; }
|
||||
.opt-fix-command .opt-fix-code code::before { content: '$ '; color: var(--mut2); user-select: none; }
|
||||
.opt-copy { flex: 0 0 auto; padding: 4px 9px; border: 1px solid var(--line); border-radius: 6px; background: var(--panel); color: var(--mut); font: inherit; font-size: 10.5px; cursor: pointer; }
|
||||
.opt-applied { padding-top: 12px; }
|
||||
.opt-applied-row { display: grid; grid-template-columns: 16px minmax(0, 1fr) 110px 140px; align-items: center; column-gap: 12px; min-height: 34px; border-top: 1px solid var(--line2); }
|
||||
.opt-applied-glyph { color: var(--mut2); font-family: var(--mono); font-size: 12px; }
|
||||
.opt-applied-verdict { color: var(--mut); font-size: 10.5px; }
|
||||
.opt-applied-worked .opt-applied-glyph, .opt-applied-worked .opt-applied-verdict { color: var(--ok); }
|
||||
.opt-applied-partial .opt-applied-glyph, .opt-applied-partial .opt-applied-verdict { color: var(--warn); }
|
||||
.opt-applied-no-effect .opt-applied-glyph, .opt-applied-no-effect .opt-applied-verdict { color: var(--bad); }
|
||||
.opt-applied-hint { padding: 9px 0 0; }
|
||||
.opt-applied-hint code { font-family: var(--mono); }
|
||||
.opt-copy:hover, .opt-copy:focus-visible { border-color: var(--accent); color: var(--ink); outline: none; }
|
||||
.ov-analytics-row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px; align-items: stretch; }
|
||||
.ov-analytics-row > :only-child { grid-column: 1 / -1; }
|
||||
|
|
|
|||
101
app/scripts/verify-windows-installer.mjs
Normal file
101
app/scripts/verify-windows-installer.mjs
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { readFileSync, readdirSync } from 'node:fs'
|
||||
import { basename, join, resolve } from 'node:path'
|
||||
import { rootFromModuleUrl } from './windows-installer-paths.mjs'
|
||||
|
||||
function fail(message) {
|
||||
console.error(`Windows installer manifest invalid: ${message}`)
|
||||
process.exitCode = 1
|
||||
}
|
||||
|
||||
function option(name, fallback) {
|
||||
const index = process.argv.indexOf(name)
|
||||
if (index === -1) return fallback
|
||||
if (!process.argv[index + 1]) throw new Error(`${name} requires a value`)
|
||||
return process.argv[index + 1]
|
||||
}
|
||||
|
||||
function packageVersion(path) {
|
||||
return JSON.parse(readFileSync(path, 'utf8')).version
|
||||
}
|
||||
|
||||
function filesBelow(directory) {
|
||||
return readdirSync(directory, { withFileTypes: true })
|
||||
.filter(entry => entry.isFile())
|
||||
.map(entry => basename(entry.name))
|
||||
}
|
||||
|
||||
function releaseVersion(tag) {
|
||||
const match = /^desktop-v(.+)$/.exec(tag)
|
||||
if (!match) throw new Error(`${tag || '(missing tag)'} is not a desktop release tag`)
|
||||
return match[1]
|
||||
}
|
||||
|
||||
function verifyLiveRelease(tag, assetPath) {
|
||||
const version = releaseVersion(tag)
|
||||
const assets = JSON.parse(readFileSync(assetPath, 'utf8'))
|
||||
if (!Array.isArray(assets) || assets.some(asset => typeof asset !== 'string')) {
|
||||
throw new Error('release asset manifest must be a JSON array of names')
|
||||
}
|
||||
const required = [
|
||||
`CodeBurn-${version}-arm64.dmg`,
|
||||
`CodeBurn-${version}.dmg`,
|
||||
`CodeBurn-${version}-arm64-mac.zip`,
|
||||
`CodeBurn-${version}-mac.zip`,
|
||||
`CodeBurn-${version}.AppImage`,
|
||||
`codeburn-desktop_${version}_amd64.deb`,
|
||||
`codeburn-desktop-${version}.x86_64.rpm`,
|
||||
`CodeBurn-Setup-${version}.exe`,
|
||||
`CodeBurn-Setup-${version}.exe.blockmap`,
|
||||
]
|
||||
for (const expected of required) {
|
||||
const count = assets.filter(asset => asset === expected).length
|
||||
if (count === 0) fail(`live release is missing ${expected}`)
|
||||
if (count > 1) fail(`live release contains ${count} copies of ${expected}`)
|
||||
}
|
||||
if (!process.exitCode) console.log(`Live desktop release assets verified for ${version}`)
|
||||
}
|
||||
|
||||
try {
|
||||
const tag = option('--tag', '')
|
||||
const releaseAssets = option('--release-assets', '')
|
||||
if (releaseAssets) {
|
||||
verifyLiveRelease(tag, resolve(releaseAssets))
|
||||
} else {
|
||||
const root = resolve(option('--root', rootFromModuleUrl(import.meta.url)))
|
||||
const artifacts = resolve(option('--artifacts', join(root, 'app', 'release')))
|
||||
const rootVersion = packageVersion(join(root, 'package.json'))
|
||||
const appVersion = packageVersion(join(root, 'app', 'package.json'))
|
||||
|
||||
if (rootVersion !== appVersion) {
|
||||
fail(`root version ${rootVersion} does not match app version ${appVersion}`)
|
||||
}
|
||||
|
||||
if (tag && tag !== `desktop-v${appVersion}`) {
|
||||
fail(`${tag} does not match app version ${appVersion}`)
|
||||
}
|
||||
|
||||
const files = filesBelow(artifacts)
|
||||
const expectedArtifacts = [
|
||||
`CodeBurn-Setup-${appVersion}.exe`,
|
||||
`CodeBurn-Setup-${appVersion}.exe.blockmap`,
|
||||
]
|
||||
for (const expected of expectedArtifacts) {
|
||||
const count = files.filter(file => file === expected).length
|
||||
if (count !== 1) fail(`expected exactly one ${expected}, found ${count}`)
|
||||
}
|
||||
|
||||
const installerArtifacts = files.filter(file => /^CodeBurn-Setup-.*\.exe(?:\.blockmap)?$/.test(file))
|
||||
const unexpected = installerArtifacts.filter(file => !expectedArtifacts.includes(file))
|
||||
if (unexpected.length > 0) {
|
||||
fail(`unexpected Windows installer artifacts: ${unexpected.join(', ')}`)
|
||||
}
|
||||
|
||||
if (!process.exitCode) {
|
||||
console.log(`Windows installer manifest verified for ${appVersion}`)
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
fail(error instanceof Error ? error.message : String(error))
|
||||
}
|
||||
168
app/scripts/verify-windows-installer.test.ts
Normal file
168
app/scripts/verify-windows-installer.test.ts
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { rootFromModuleUrl } from './windows-installer-paths.mjs'
|
||||
|
||||
const verifier = new URL('./verify-windows-installer.mjs', import.meta.url)
|
||||
const verifierPath = fileURLToPath(verifier)
|
||||
|
||||
function fixture(options: {
|
||||
appVersion?: string
|
||||
rootVersion?: string
|
||||
files?: string[]
|
||||
tag?: string
|
||||
} = {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'codeburn-windows-manifest-'))
|
||||
const appDir = join(root, 'app')
|
||||
const releaseDir = join(appDir, 'release')
|
||||
mkdirSync(releaseDir, { recursive: true })
|
||||
|
||||
const appVersion = options.appVersion ?? '1.2.3'
|
||||
writeFileSync(join(root, 'package.json'), JSON.stringify({ version: options.rootVersion ?? appVersion }))
|
||||
writeFileSync(join(appDir, 'package.json'), JSON.stringify({ version: appVersion }))
|
||||
for (const file of options.files ?? [
|
||||
`CodeBurn-Setup-${appVersion}.exe`,
|
||||
`CodeBurn-Setup-${appVersion}.exe.blockmap`,
|
||||
]) {
|
||||
const path = join(releaseDir, file)
|
||||
mkdirSync(join(path, '..'), { recursive: true })
|
||||
writeFileSync(path, 'fixture')
|
||||
}
|
||||
|
||||
const args = [verifierPath, '--root', root, '--artifacts', releaseDir]
|
||||
if (options.tag) args.push('--tag', options.tag)
|
||||
return spawnSync(process.execPath, args, { encoding: 'utf8' })
|
||||
}
|
||||
|
||||
function releaseFixture(files: string[]) {
|
||||
const root = mkdtempSync(join(tmpdir(), 'codeburn-windows-release-'))
|
||||
const assets = join(root, 'assets.json')
|
||||
writeFileSync(assets, JSON.stringify(files))
|
||||
return spawnSync(process.execPath, [
|
||||
verifierPath,
|
||||
'--tag',
|
||||
'desktop-v1.2.3',
|
||||
'--release-assets',
|
||||
assets,
|
||||
], { encoding: 'utf8' })
|
||||
}
|
||||
|
||||
describe('Windows installer release manifest verifier', () => {
|
||||
it('converts a Windows module URL into a valid drive-letter repository root', () => {
|
||||
expect(rootFromModuleUrl(
|
||||
'file:///D:/a/codeburn/codeburn/app/scripts/verify-windows-installer.mjs',
|
||||
true,
|
||||
)).toBe('D:\\a\\codeburn\\codeburn')
|
||||
})
|
||||
|
||||
it('accepts one exact installer and blockmap for matching package versions and tag', () => {
|
||||
const result = fixture({ tag: 'desktop-v1.2.3' })
|
||||
|
||||
expect(result.status).toBe(0)
|
||||
expect(result.stdout).toContain('Windows installer manifest verified for 1.2.3')
|
||||
})
|
||||
|
||||
it('rejects a desktop tag that does not match the app version', () => {
|
||||
const result = fixture({ tag: 'desktop-v1.2.4' })
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain('desktop-v1.2.4 does not match app version 1.2.3')
|
||||
})
|
||||
|
||||
it('rejects divergent root and app versions', () => {
|
||||
const result = fixture({ rootVersion: '1.2.2' })
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain('root version 1.2.2 does not match app version 1.2.3')
|
||||
})
|
||||
|
||||
it('rejects a missing installer blockmap', () => {
|
||||
const result = fixture({ files: ['CodeBurn-Setup-1.2.3.exe'] })
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain('expected exactly one CodeBurn-Setup-1.2.3.exe.blockmap, found 0')
|
||||
})
|
||||
|
||||
it('requires installer artifacts at the documented top-level output', () => {
|
||||
const result = fixture({
|
||||
files: [
|
||||
'CodeBurn-Setup-1.2.3.exe.blockmap',
|
||||
'duplicate/CodeBurn-Setup-1.2.3.exe',
|
||||
],
|
||||
})
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain('expected exactly one CodeBurn-Setup-1.2.3.exe, found 0')
|
||||
})
|
||||
|
||||
it('rejects stale installer artifacts from another version', () => {
|
||||
const result = fixture({
|
||||
files: [
|
||||
'CodeBurn-Setup-1.2.3.exe',
|
||||
'CodeBurn-Setup-1.2.3.exe.blockmap',
|
||||
'CodeBurn-Setup-1.2.2.exe',
|
||||
'CodeBurn-Setup-1.2.2.exe.blockmap',
|
||||
],
|
||||
})
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain('unexpected Windows installer artifacts')
|
||||
})
|
||||
|
||||
it('accepts a complete live desktop release asset manifest', () => {
|
||||
const result = releaseFixture([
|
||||
'CodeBurn-1.2.3-arm64.dmg',
|
||||
'CodeBurn-1.2.3.dmg',
|
||||
'CodeBurn-1.2.3-arm64-mac.zip',
|
||||
'CodeBurn-1.2.3-mac.zip',
|
||||
'CodeBurn-1.2.3.AppImage',
|
||||
'codeburn-desktop_1.2.3_amd64.deb',
|
||||
'codeburn-desktop-1.2.3.x86_64.rpm',
|
||||
'CodeBurn-Setup-1.2.3.exe',
|
||||
'CodeBurn-Setup-1.2.3.exe.blockmap',
|
||||
])
|
||||
|
||||
expect(result.status).toBe(0)
|
||||
expect(result.stdout).toContain('Live desktop release assets verified for 1.2.3')
|
||||
})
|
||||
|
||||
it('rejects a live desktop release missing the Windows installer', () => {
|
||||
const result = releaseFixture([
|
||||
'CodeBurn-1.2.3-arm64.dmg',
|
||||
'CodeBurn-1.2.3.dmg',
|
||||
'CodeBurn-1.2.3-arm64-mac.zip',
|
||||
'CodeBurn-1.2.3-mac.zip',
|
||||
'CodeBurn-1.2.3.AppImage',
|
||||
'codeburn-desktop_1.2.3_amd64.deb',
|
||||
'codeburn-desktop-1.2.3.x86_64.rpm',
|
||||
'CodeBurn-Setup-1.2.3.exe.blockmap',
|
||||
])
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain('live release is missing CodeBurn-Setup-1.2.3.exe')
|
||||
})
|
||||
|
||||
it.each([
|
||||
'codeburn-desktop_1.2.3_amd64.deb',
|
||||
'codeburn-desktop-1.2.3.x86_64.rpm',
|
||||
])('rejects a live desktop release missing %s', missing => {
|
||||
const required = [
|
||||
'CodeBurn-1.2.3-arm64.dmg',
|
||||
'CodeBurn-1.2.3.dmg',
|
||||
'CodeBurn-1.2.3-arm64-mac.zip',
|
||||
'CodeBurn-1.2.3-mac.zip',
|
||||
'CodeBurn-1.2.3.AppImage',
|
||||
'codeburn-desktop_1.2.3_amd64.deb',
|
||||
'codeburn-desktop-1.2.3.x86_64.rpm',
|
||||
'CodeBurn-Setup-1.2.3.exe',
|
||||
'CodeBurn-Setup-1.2.3.exe.blockmap',
|
||||
]
|
||||
const result = releaseFixture(required.filter(asset => asset !== missing))
|
||||
|
||||
expect(result.status).toBe(1)
|
||||
expect(result.stderr).toContain(`live release is missing ${missing}`)
|
||||
})
|
||||
})
|
||||
1
app/scripts/windows-installer-paths.d.mts
Normal file
1
app/scripts/windows-installer-paths.d.mts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export function rootFromModuleUrl(moduleUrl: string | URL, windows?: boolean): string
|
||||
8
app/scripts/windows-installer-paths.mjs
Normal file
8
app/scripts/windows-installer-paths.mjs
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
import { posix, win32 } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
|
||||
export function rootFromModuleUrl(moduleUrl, windows = process.platform === 'win32') {
|
||||
const path = windows ? win32 : posix
|
||||
const scriptPath = fileURLToPath(moduleUrl, { windows })
|
||||
return path.resolve(path.dirname(scriptPath), '..', '..')
|
||||
}
|
||||
|
|
@ -4,27 +4,27 @@ 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.
|
||||
CodeBurn is one Node.js CLI plus three ambient 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) |
|
||||
+----------------------------+
|
||||
+---------------------------+ +-----------------+
|
||||
| mac/ (Swift) | ---> | |
|
||||
+---------------------------+ | src/cli.ts |
|
||||
| windows/ (Rust + React) | ---> | (the CLI) |
|
||||
+---------------------------+ | |
|
||||
| gnome/ (JavaScript) | ---> | 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.
|
||||
The macOS menubar (`mac/`), the Windows tray app (`windows/`), and the GNOME extension (`gnome/`) all 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/`)
|
||||
|
||||
|
|
@ -144,7 +144,7 @@ All three use atomic write (temp file + `rename`) and write with mode `0o600`. A
|
|||
|
||||
### 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).
|
||||
`src/optimize.ts` exports 20 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 |
|
||||
|---|---|---|
|
||||
|
|
@ -191,7 +191,7 @@ type Provider = {
|
|||
|
||||
`src/providers/index.ts` registers providers across two tiers:
|
||||
|
||||
- **Eager**: `claude`, `cline`, `codewhale`, `codebuff`, `codex`, `copilot`, `devin`, `droid`, `gemini`, `hermes`, `ibm-bob`, `kilo-code`, `kiro`, `kimi`, `lingtai-tui`, `mistral-vibe`, `mux`, `openclaw`, `open-design`, `pi`, `omp`, `qwen`, `roo-code`, `zerostack`, `grok`. Imported at module load.
|
||||
- **Eager**: `claude`, `cline`, `codewhale`, `codebuff`, `codex`, `copilot`, `devin`, `droid`, `dsh`, `gemini`, `hermes`, `ibm-bob`, `kilo-code`, `kiro`, `kimi`, `lingtai-tui`, `mistral-vibe`, `mux`, `openclaw`, `open-design`, `pi`, `omp`, `qwen`, `roo-code`, `zerostack`, `grok`. Imported at module load.
|
||||
- **Lazy**: `antigravity`, `forge`, `goose`, `cursor`, `opencode`, `cursor-agent`, `crush`, `warp`, `vercel-gateway`, `zcode`, `zed`. Imported via dynamic `import()` so the heavy dependencies (SQLite, protobuf, network clients) 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.
|
||||
|
|
@ -217,6 +217,20 @@ Tests live in `mac/Tests/CodeBurnMenubarTests/` (currently `CapacityEstimatorTes
|
|||
|
||||
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.
|
||||
|
||||
## Windows Menubar (`windows/`)
|
||||
|
||||
Tauri 2 app: a Rust binary (`windows/src-tauri/`) owning the tray and the process spawning, plus a React + TypeScript popover (`windows/src/`) rendered in a WebView2 window. Design tokens come from `windows/tokens.json`, the same file `mac/` reads at build time, so both products render as one.
|
||||
|
||||
- `src-tauri/src/lib.rs` builds the tray, positions the popover against the taskbar edge, and registers the `#[tauri::command]` surface the frontend calls.
|
||||
- `src-tauri/src/cli.rs` resolves and spawns the CLI. Only absolute `PATH` directories are searched (an empty entry from `;;` would otherwise resolve against the current directory), `CODEBURN_BIN` is allowlisted, and Windows system tools are spawned by absolute `%SystemRoot%\System32` path because `CreateProcess` searches the current directory first. `MIN_CLI_VERSION` gates the whole app; below it the popover shows a setup screen.
|
||||
- `src-tauri/src/plan.rs` ports the Claude quota view. Like the macOS `ClaudeCredentialStore`, it never spends Claude's single-use refresh token; on a 401 it re-reads Claude's own credential file for a token Claude Code has already rotated.
|
||||
- `src-tauri/src/tray_badge.rs` renders today's spend into a second tray icon, since Windows has no menubar title.
|
||||
- `src/App.tsx` owns the payload cache, the CLI gate, and the refresh cadence, which follows popover visibility the way `mac/`'s `RefreshCadence.swift` does.
|
||||
|
||||
`cargo test` covers the PATH filter and the version gate. `windows/DEVELOPMENT.md` has the build, security, and release details; CI is `.github/workflows/windows-menubar-ci.yml` and releases go out on `windows-v*` tags.
|
||||
|
||||
The Linux (ksni) paths in the same crate are kept compiling but are experimental and unreleased; `gnome/` is the shipping Linux surface.
|
||||
|
||||
## GNOME Extension (`gnome/`)
|
||||
|
||||
Plain JavaScript, no bundler. Targets GNOME Shell 45-50 (`metadata.json`).
|
||||
|
|
|
|||
139
docs/optimize.md
Normal file
139
docs/optimize.md
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
# optimize
|
||||
|
||||
`codeburn optimize` scans your Claude Code sessions and your `~/.claude/` setup, reports what is
|
||||
costing tokens without earning them, and grades the setup A to F.
|
||||
|
||||
## What it scans
|
||||
|
||||
- **Session transcripts** for the selected period: tool calls, per-call token usage, turn retries,
|
||||
per-session cost, and the block each session opens with. This is where re-reads, junk directory
|
||||
reads, low read:edit ratios, warmup overhead, retries, context pasted into session after session,
|
||||
and expensive or context-heavy sessions come from.
|
||||
- **Your configuration**: `~/.claude.json`, user and project `settings.json` / `settings.local.json`,
|
||||
`.mcp.json`, `CLAUDE.md` (including `@`-imports), and the `skills/`, `agents/`, `commands/`
|
||||
directories. This is where unused MCP servers, MCP deferral gaps, ghost skills/agents/commands,
|
||||
the bash output cap, and oversized `CLAUDE.md` files come from.
|
||||
|
||||
Nothing is written during a scan. Only `--apply` writes.
|
||||
|
||||
## The three classes
|
||||
|
||||
Every finding carries a `class`, and both the CLI and the apps group by it:
|
||||
|
||||
| Class | Header | Meaning |
|
||||
|---|---|---|
|
||||
| `fix` | Fix now (apply-able) | CodeBurn can make this change for you: `codeburn optimize --apply` |
|
||||
| `nudge` | Habits | Behavioural. Nothing to edit; the fix is how you drive the next session |
|
||||
| `keep` | FYI | Informational. The cost may well be justified; decide for yourself |
|
||||
|
||||
A finding is `fix` only when a plan can actually be built for that instance. The same detector can
|
||||
report a `fix` in one run and a `nudge` in another: `mcp-deferral-off` is appliable when the cause is
|
||||
an `ENABLE_TOOL_SEARCH` override in a settings file, but manual when the cause is Vertex AI policy,
|
||||
an outdated Claude Code, or an override that lives in your shell profile.
|
||||
|
||||
## What `--apply` may write
|
||||
|
||||
`--apply` builds a plan per finding, shows you the exact files it will touch, and asks before
|
||||
writing. `--dry-run` prints the plan and stops.
|
||||
|
||||
| Finding | File it edits |
|
||||
|---|---|
|
||||
| `unused-mcp`, `mcp-low-coverage` | `~/.claude.json`, project `.mcp.json` / `settings.json` (removes the server entry) |
|
||||
| `mcp-project-scope` | moves a global server entry into the keeper project's `.mcp.json` |
|
||||
| `mcp-deferral-off` | the settings file carrying the `ENABLE_TOOL_SEARCH` override |
|
||||
| `mcp-alwaysload-hygiene` | the config files carrying `"alwaysLoad": true` |
|
||||
| `mcp-defer-threshold` | the settings file carrying the `auto:N` threshold |
|
||||
| `unused-agents`, `unused-skills`, `unused-commands` | moves the files into `~/.claude/<kind>/.archived/` |
|
||||
| `bash-output-cap` | appends a marker block to `~/.zshrc` / `~/.bashrc` |
|
||||
| `read-edit-ratio`, `build-folder-reads` | appends a marker block to the current project's `CLAUDE.md` |
|
||||
|
||||
Every write is backed up and journaled first:
|
||||
|
||||
```bash
|
||||
codeburn act list # every change CodeBurn has made
|
||||
codeburn act undo <id> # restore the original files
|
||||
codeburn act undo --last
|
||||
```
|
||||
|
||||
Undo refuses if a file changed after the apply, unless you pass `--force`.
|
||||
|
||||
### The `--yes` CLAUDE.md guardrail
|
||||
|
||||
`--apply --yes` skips the prompt for every plan except `CLAUDE.md` rule blocks. Those land in the
|
||||
`CLAUDE.md` of whatever directory you happen to be in, so a blanket `--yes` from an unrelated
|
||||
directory would write advice into the wrong project. To apply one anyway, use the interactive picker
|
||||
or name it explicitly:
|
||||
|
||||
```bash
|
||||
codeburn optimize --apply --only read-edit-ratio
|
||||
```
|
||||
|
||||
## After you apply
|
||||
|
||||
Applying a fix is a claim, so CodeBurn checks it. Every `codeburn optimize` run re-measures the
|
||||
fixes still in place and prints them under `Applied fixes`, one line each:
|
||||
|
||||
| Line | Verdict | Meaning |
|
||||
|---|---|---|
|
||||
| `✓ unused-skills (7d ago): est. 300.0K -> measured 280.0K` | worked | at least 70% of the estimate showed up in your sessions |
|
||||
| `~ mcp-defer-threshold (5d ago): est. 600.0K -> measured 420.0K (-30% vs estimate)` | partial | it helped, but under its estimate |
|
||||
| `✗ bash-output-cap (6d ago): est. 41.0K -> measured 0 - did not help. Revert: codeburn act undo 3f2a1c04` | no-effect | no measured reduction at all |
|
||||
| `… mcp-remove (1d ago): measuring, check back after 3 days` | measuring | too young, or the change has not taken effect in a session yet |
|
||||
|
||||
The estimate shown is the at-apply estimate scaled to the measured window, so the two numbers are
|
||||
comparable. Both come from the same reconciliation `codeburn act report` prints — there is one set of
|
||||
numbers, not two — and they are **measured**: provider-counted usage over the post-apply window.
|
||||
Anything that cannot be measured (no baseline captured, a fix you reverted by hand, a
|
||||
correlation-only kind like `guard-install`) stays on the `measuring` line with the reason, never a
|
||||
claimed saving.
|
||||
|
||||
`--format json` carries the same list as `appliedFixes[]`, and the section appears in the dashboard
|
||||
TUI and the desktop app.
|
||||
|
||||
### `--auto-revert`
|
||||
|
||||
```bash
|
||||
codeburn optimize --auto-revert
|
||||
```
|
||||
|
||||
Off by default. It undoes exactly the fixes whose verdict is `no-effect`, through the same code path
|
||||
as `codeburn act undo` (backups restored, drift check applied, the revert journaled). It never
|
||||
touches a `partial` or still-measuring fix, and it never auto-reverts a `claude-md-rule` — those land
|
||||
in whatever project directory you were in, the same reason `--yes` skips them, so it prints the undo
|
||||
command and leaves the file alone.
|
||||
|
||||
## measured vs estimated
|
||||
|
||||
Each finding also carries a `basis`, printed next to its savings and summarised in the header as
|
||||
`N measured · M estimated`:
|
||||
|
||||
- **measured** — the token number is summed from provider-counted usage on your own calls. Today
|
||||
that is `context-heavy-sessions` and `cost-outliers`.
|
||||
- **estimated** — the token number comes from a model: a per-tool schema size, a per-line `CLAUDE.md`
|
||||
cost, an average read size, a recovery fraction applied to real turn tokens. A detector that mixes
|
||||
counted tokens with a model counts as estimated.
|
||||
|
||||
Sessions whose cost the provider never reported (Kiro, Cursor, some Cline sessions price from
|
||||
modelled token counts) are kept out of the `cost-outliers` peer comparison, so a modelled cost is
|
||||
never called an outlier against provider-reported ones. When a provider only ever estimates, the
|
||||
comparison falls back to those sessions and the finding reports itself as `estimated`.
|
||||
|
||||
In `--format json`, `summary.measuredSavingsUSD` is the share of `summary.potentialSavingsCostUSD`
|
||||
that comes from measured findings.
|
||||
|
||||
## Reading the health grade
|
||||
|
||||
Health starts at 100 and loses points per finding: 15 for a high-impact one, 7 for medium, 3 for low.
|
||||
The total penalty is capped at 80, so a long tail of small findings cannot sink the score to zero on
|
||||
its own. The grade is a band over that score:
|
||||
|
||||
| Grade | Score |
|
||||
|---|---|
|
||||
| A | 90-100 |
|
||||
| B | 75-89 |
|
||||
| C | 55-74 |
|
||||
| D | 30-54 |
|
||||
| F | below 30 |
|
||||
|
||||
The grade rates your setup, not your spending: an expensive month with a clean configuration still
|
||||
scores an A.
|
||||
|
|
@ -18,6 +18,7 @@ For the architectural picture, see `../architecture.md`.
|
|||
| [Copilot](copilot.md) | JSONL + SQLite (OTel) + Nitrite .db (JetBrains) | `src/providers/copilot.ts` | `tests/providers/copilot.test.ts` |
|
||||
| [Devin](devin.md) | JSON + SQLite enrichment | `src/providers/devin.ts` | `tests/providers/devin.test.ts` |
|
||||
| [Droid](droid.md) | JSONL | `src/providers/droid.ts` | `tests/providers/droid.test.ts` |
|
||||
| [DeepSeek Harness](dsh.md) | JSONL (zstd frames) | `src/providers/dsh.ts` | `tests/providers/dsh.test.ts` |
|
||||
| [Gemini](gemini.md) | JSON / JSONL | `src/providers/gemini.ts` | none |
|
||||
| [Hermes Agent](hermes.md) | SQLite | `src/providers/hermes.ts` | `tests/providers/hermes.test.ts` |
|
||||
| [IBM Bob](ibm-bob.md) | JSON | `src/providers/ibm-bob.ts` | `tests/providers/ibm-bob.test.ts` |
|
||||
|
|
|
|||
|
|
@ -39,7 +39,8 @@ instead of trying to dedupe across stores.
|
|||
wrong-schema, OTel is skipped and the JSONL/transcript sources are used as a fallback.
|
||||
- **Durable cache (monotonic totals).** Copilot is marked `durableSources`: OTel-derived
|
||||
cache entries are never evicted when VS Code prunes old spans from the DB, so
|
||||
month-to-date totals do not drop as the DB rotates. Entries age out after 90 days.
|
||||
month-to-date totals do not drop as the DB rotates. Orphaned entries age out after
|
||||
90 days; sources still present in discovery remain cached regardless of call age.
|
||||
- **Upgrade note.** The first run after upgrading to the OTel version bumps the copilot
|
||||
parse version, which discards the prior copilot cache. Spans already pruned from the DB
|
||||
before the upgrade cannot be recovered, so monotonicity starts from the upgrade point,
|
||||
|
|
|
|||
71
docs/providers/dsh.md
Normal file
71
docs/providers/dsh.md
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
# DeepSeek Harness (dsh)
|
||||
|
||||
DeepSeek's open-source agent harness (`dsh`, npm `@deepseek-ai/dsh`). Unrelated to the [CodeWhale](codewhale.md) provider, which reads the DeepSeek desktop app.
|
||||
|
||||
- **Source:** `src/providers/dsh.ts`
|
||||
- **Loading:** eager (`src/providers/index.ts`)
|
||||
- **Test:** `tests/providers/dsh.test.ts`
|
||||
|
||||
## Where it reads from
|
||||
|
||||
| Level | Env var | Default |
|
||||
|---|---|---|
|
||||
| sessions | — | `<root>/sessions` |
|
||||
| root | `DSH_HOME` | `~/.dsh` |
|
||||
|
||||
An empty `DSH_HOME` is treated as unset. `probeRoots()` reports the resolved sessions dir, so `codeburn doctor` distinguishes "dsh not installed" from "`DSH_HOME` pointing somewhere empty".
|
||||
|
||||
## Storage format
|
||||
|
||||
```
|
||||
sessions/--<slugified-cwd>--/<session-id>/
|
||||
session.jsonl.zstd default (compression: zstd)
|
||||
session.jsonl when compression: none
|
||||
```
|
||||
|
||||
Both variants are read; a session directory never holds both. The log is append-only JSONL whose first line is the session header:
|
||||
|
||||
```jsonc
|
||||
{ "type": "session", "version": 0, "id": "...", "createdAt": 1783352050748,
|
||||
"cwd": "/home/u/proj", "parentSession": "...", "seedLength": 3, "delegationDepth": 0 }
|
||||
```
|
||||
|
||||
`cwd` becomes `projectPath` / `workingDirectory` (git-repo attribution) and its last segment the project name.
|
||||
|
||||
Every later line is one event `{ type, seq, time, data }`. The parser reads:
|
||||
|
||||
| Event | Used for |
|
||||
|---|---|
|
||||
| `turn/start` | current turn number |
|
||||
| `user/message` | the turn's preview, when `data.source.kind === 'user'` |
|
||||
| `request/header` | `data.header.config.model` — the model for steps that follow |
|
||||
| `assistant/chunk` with `chunk.type === 'usage'` | streamed usage sample for `(turn, step)` |
|
||||
| `assistant/message` | final usage for `(turn, step)`, plus `data.message.source.model` |
|
||||
| `tool/call` | tool names, bash commands, skill names |
|
||||
|
||||
One parsed call per `(turn, step)` — one model call and the tools it requested. Dedup key: `dsh:<sessionId>:<turn>:<step>`.
|
||||
|
||||
`.zstd` logs are a concatenation of **independent** zstd frames, one per write batch, so they are decoded frame by frame behind a structural frame scan ported from `@deepseek-ai/dsh-session-persistence-jsonl`. Needs Node 22.15+ for `zlib.zstdDecompressSync`; below that dsh is skipped with a notice instead of counted as $0.
|
||||
|
||||
## Caching
|
||||
|
||||
None at the provider level; the log file is the cached source path and the normal parser/cache layers apply. Cache invalidates on `DSH_HOME` (`PROVIDER_ENV_VARS`) and on parser changes (`PROVIDER_PARSE_VERSIONS`).
|
||||
|
||||
## Quirks
|
||||
|
||||
- **DSH is a developer preview.** `SESSION_FORMAT_VERSION` is pinned at `0` with "no compatibility implied" upstream, and breaking changes are expected. The parser reads version `0` only and skips a log stamped with anything else, with a notice — reading a bumped format under today's assumptions would report confident wrong numbers. **A version bump upstream means this parser needs updating, not just relaxing the check.**
|
||||
- **The JSONL backend only.** DSH also ships an opt-in SQLite persistence backend (`@deepseek-ai/dsh-session-persistence-sqlite`); it is not the default and is not read.
|
||||
- **DSH records tokens, never dollars.** `usage` is `{ inputTokens, outputTokens, cacheReadTokens?, cacheWriteTokens?, reasoningTokens? }` with no cost field, so every call is priced from the shared tables. Reasoning bills at the output rate (same as Gemini and Hermes): `outputTokens + reasoningTokens` goes into `calculateCost`, while the two stay separate on the emitted call. Tokens are the provider's own exact counts, so `costIsEstimated` stays false.
|
||||
- **`assistant/message` usage wins over the `assistant/chunk` sample** for the same `(turn, step)` — the two are adjacent reports of one API call, not two calls. A late chunk never overwrites a final report, so the two are never summed.
|
||||
- **The model comes from the message, not the request.** `data.message.source.model` is what actually served the step; `request/header` only describes the request DSH was about to make, and is the fallback when a message names no model. The `provider` field there (`deepseek-official`) is the upstream LLM route, not the tool — the codeburn provider name is always `dsh`.
|
||||
- **A forked session's log replays its parent's events.** The header's `parentSession` + `seedLength` mark that prefix; codeburn parses the parent's own log as its own session, so events with `seq < seedLength` are skipped to avoid billing the same calls twice.
|
||||
- **`user/message` also carries agent-injected context** (runtime snapshots, skill bodies, file-change notices) under `source.kind: 'plugin'`. Only `kind: 'user'` messages become the preview.
|
||||
- **Delta chunks are packed.** Runs of streamed deltas are stored as `text-chunks` / `reasoning-chunks` / `tool-call-chunks` storage rows rather than one event per line. They carry no usage and no tool identity the `tool/call` event lacks, so they are ignored — as is any event type the parser does not know.
|
||||
- **A torn final zstd frame is ignored.** A crashed writer leaves an incomplete trailing frame; the complete frames before it parse normally. A structurally corrupt file is skipped whole with a notice rather than throwing.
|
||||
|
||||
## When fixing a bug here
|
||||
|
||||
1. Reproduce with a minimal session dir: `sessions/--proj--/<id>/session.jsonl` (uncompressed is easiest to hand-write).
|
||||
2. `tests/fixtures/dsh/bash-tool-turn.jsonl` is the upstream `examples/acp-agent/tests/snapshots/bash-tool-turn/session.jsonl` snapshot with its template placeholders filled in — refresh it from the DSH repo when the format moves.
|
||||
3. Run `tests/providers/dsh.test.ts`.
|
||||
4. `.zstd` fixtures must compress **each batch separately**; one `zstdCompressSync` over the whole file is a single-frame layout DSH never writes.
|
||||
|
|
@ -2,6 +2,7 @@ import Foundation
|
|||
import SwiftUI
|
||||
import AppKit
|
||||
import Observation
|
||||
import ServiceManagement
|
||||
|
||||
private let refreshIntervalSeconds: UInt64 = 30
|
||||
private let forceRefreshWatchdogSeconds: TimeInterval = 90
|
||||
|
|
@ -281,34 +282,19 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM
|
|||
let key = "codeburn.loginItemRegistered"
|
||||
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:\(appleScriptStringLiteral(appPath)), hidden:false}"
|
||||
|
||||
let process = Process()
|
||||
process.launchPath = "/usr/bin/osascript"
|
||||
process.arguments = ["-e", script]
|
||||
process.standardOutput = FileHandle.nullDevice
|
||||
process.standardError = FileHandle.nullDevice
|
||||
|
||||
// Registers in-process. The old path told System Events to make the login
|
||||
// item, which made macOS ask for Automation access on first launch (#1026).
|
||||
// No AppleScript fallback: a failure here must not bring that prompt back.
|
||||
do {
|
||||
try process.run()
|
||||
process.waitUntilExit()
|
||||
if process.terminationStatus == 0 {
|
||||
UserDefaults.standard.set(true, forKey: key)
|
||||
if SMAppService.mainApp.status != .enabled {
|
||||
try SMAppService.mainApp.register()
|
||||
}
|
||||
UserDefaults.standard.set(true, forKey: key)
|
||||
} catch {
|
||||
NSLog("CodeBurn: Login item registration failed: \(error)")
|
||||
NSLog("CodeBurn: login item registration failed: \(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
/// Anchors the shallow provider-root snapshot only after a complete usage
|
||||
/// refresh succeeds. It sits beside the cadence anchor so a failed fetch
|
||||
|
|
|
|||
|
|
@ -72,6 +72,8 @@ enum UsageDataChangeGuard {
|
|||
add(expand(environment["CODEWHALE_HOME"] ?? path(homeDirectory, ".codewhale"), homeDirectory: homeDirectory), scanFirstLevelDirectories: false)
|
||||
add(path(homeDirectory, ".deepseek", "sessions"), scanFirstLevelDirectories: false)
|
||||
add(path(homeDirectory, ".cline", "data"), scanFirstLevelDirectories: false)
|
||||
let dshHome = expand(environment["DSH_HOME"] ?? path(homeDirectory, ".dsh"), homeDirectory: homeDirectory)
|
||||
add(path(dshHome, "sessions"))
|
||||
add(expand(environment["CODEBUFF_DATA_DIR"] ?? path(xdgConfig, "manicode"), homeDirectory: homeDirectory), scanFirstLevelDirectories: false)
|
||||
let factoryHome = expand(environment["FACTORY_DIR"] ?? path(homeDirectory, ".factory"), homeDirectory: homeDirectory)
|
||||
add(path(factoryHome, "sessions"), scanFirstLevelDirectories: false)
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"THIRD_PARTY_NOTICES.md",
|
||||
"!dist/parse-worker.js.map"
|
||||
],
|
||||
"scripts": {
|
||||
|
|
@ -33,6 +34,7 @@
|
|||
"pi",
|
||||
"codebuff",
|
||||
"codewhale",
|
||||
"dsh",
|
||||
"ai-coding",
|
||||
"token-usage",
|
||||
"cost-tracking",
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { readFile } from 'node:fs/promises'
|
|||
import { join } from 'node:path'
|
||||
|
||||
import { aggregateModelStats, type ModelStats } from '../compare-stats.js'
|
||||
import { withUserStartedSessions } from '../session-population.js'
|
||||
import type { ProjectSummary } from '../types.js'
|
||||
import { sha256File } from './backup.js'
|
||||
import type { ActionPlan } from './types.js'
|
||||
|
|
@ -73,15 +74,16 @@ function isDebuggingHeavy(project: ProjectSummary): boolean {
|
|||
}
|
||||
|
||||
export function recommendModelDefault(project: ProjectSummary, opts: { now?: Date } = {}): ModelDefaultRecommendation | null {
|
||||
const behavioralProject = withUserStartedSessions(project)
|
||||
const now = opts.now ?? new Date()
|
||||
const stats = aggregateModelStats([project])
|
||||
const stats = aggregateModelStats([behavioralProject])
|
||||
.filter(s => s.model !== '<synthetic>' && s.editTurns >= MIN_EDIT_TURNS)
|
||||
.sort((a, b) => b.editTurns - a.editTurns || b.editCost - a.editCost)
|
||||
|
||||
const current = stats[0]
|
||||
if (!current) return null
|
||||
|
||||
const providers = providerByModel(project)
|
||||
const providers = providerByModel(behavioralProject)
|
||||
const provider = providers.get(current.model)
|
||||
if (!provider || !isRecent(current.lastSeen, now)) return null
|
||||
|
||||
|
|
@ -89,7 +91,7 @@ export function recommendModelDefault(project: ProjectSummary, opts: { now?: Dat
|
|||
const currentCost = costPerEdit(current)
|
||||
if (!Number.isFinite(currentCost) || currentCost <= 0) return null
|
||||
|
||||
const debuggingHeavy = isDebuggingHeavy(project)
|
||||
const debuggingHeavy = isDebuggingHeavy(behavioralProject)
|
||||
const tolerance = debuggingHeavy ? 0 : ONE_SHOT_TOLERANCE
|
||||
|
||||
const candidates = stats
|
||||
|
|
|
|||
|
|
@ -7,12 +7,17 @@ import { formatCost } from '../currency.js'
|
|||
import { formatTokens } from '../format.js'
|
||||
import { runAction } from './apply.js'
|
||||
import { shortId } from './journal.js'
|
||||
import { REPORT_MIN_AGE_DAYS } from './types.js'
|
||||
import { planFindings, type FindingPlan, type PlanContext } from './plans.js'
|
||||
|
||||
export type ApplyOptions = {
|
||||
yes?: boolean
|
||||
dryRun?: boolean
|
||||
only?: string
|
||||
// Mirrors `optimize --provider`. The scan below only reads Claude
|
||||
// transcripts, and this path does not just report findings, it plans and
|
||||
// applies them - a Codex-scoped run must never offer to edit ~/.claude.
|
||||
provider?: string
|
||||
actionsDir?: string
|
||||
ctx?: PlanContext
|
||||
// Test seams: crafted findings skip the session scan; streams default to
|
||||
|
|
@ -37,16 +42,47 @@ function changeLines(fp: FindingPlan): string[] {
|
|||
})
|
||||
}
|
||||
|
||||
function planTokensSaved(fp: FindingPlan): number {
|
||||
if (fp.plan?.mcpSavingsUncertain) return Number.NaN
|
||||
const byServer = fp.finding.applyTokensSavedByServer
|
||||
const affected = fp.plan?.affectedMcpServers
|
||||
if (byServer && affected) return affected.reduce((sum, server) => sum + (byServer[server] ?? 0), 0)
|
||||
return fp.finding.applyTokensSaved ?? fp.finding.tokensSaved
|
||||
}
|
||||
|
||||
function manualActionLines(fp: FindingPlan): string[] {
|
||||
if (fp.finding.manualFollowUp) {
|
||||
return [fp.finding.manualFollowUp.label, fp.finding.manualFollowUp.text]
|
||||
}
|
||||
const action = fp.finding.fix
|
||||
if (action.type === 'paste' && action.destination === 'manual') {
|
||||
return [action.label, action.text]
|
||||
}
|
||||
return []
|
||||
}
|
||||
|
||||
export function renderApplyList(appliable: FindingPlan[], manual: FindingPlan[], costRate: number): string {
|
||||
const lines: string[] = ['']
|
||||
lines.push(chalk.bold(' Appliable config-class fixes:'))
|
||||
appliable.forEach((fp, i) => {
|
||||
const f = fp.finding
|
||||
const savings = `~${formatTokens(f.tokensSaved)} tokens${costRate > 0 ? `, ~${formatCost(f.tokensSaved * costRate)}` : ''}`
|
||||
const actionTokensSaved = planTokensSaved(fp)
|
||||
const savings = Number.isFinite(actionTokensSaved)
|
||||
? `~${formatTokens(actionTokensSaved)} tokens${costRate > 0 ? `, ~${formatCost(actionTokensSaved * costRate)}` : ''}`
|
||||
: 'Savings not estimated'
|
||||
lines.push('')
|
||||
lines.push(` ${i + 1}. ${f.title} ${chalk.hex('#FFD700')(`(${savings})`)}`)
|
||||
if (fp.plan?.affectedMcpServers?.length) {
|
||||
const servers = fp.plan.affectedMcpServers.join(', ')
|
||||
lines.push(chalk.yellow(` Removes local MCP server${fp.plan.affectedMcpServers.length === 1 ? '' : 's'}: ${servers}`))
|
||||
}
|
||||
for (const line of changeLines(fp)) lines.push(chalk.dim(` ${line}`))
|
||||
for (const note of fp.notes) lines.push(chalk.yellow(` ! ${note}`))
|
||||
const manualLines = manualActionLines(fp)
|
||||
if (manualLines.length > 0) {
|
||||
lines.push(chalk.cyan(' Manual follow-up (not applied):'))
|
||||
for (const line of manualLines) lines.push(chalk.cyan(` ${line}`))
|
||||
}
|
||||
})
|
||||
if (manual.length > 0) {
|
||||
lines.push('')
|
||||
|
|
@ -54,6 +90,7 @@ export function renderApplyList(appliable: FindingPlan[], manual: FindingPlan[],
|
|||
for (const fp of manual) {
|
||||
lines.push(chalk.dim(` - ${fp.finding.title} [${fp.finding.id}] manual`))
|
||||
for (const note of fp.notes) lines.push(chalk.yellow(` ! ${note}`))
|
||||
for (const line of manualActionLines(fp)) lines.push(chalk.cyan(` ${line}`))
|
||||
}
|
||||
}
|
||||
lines.push('')
|
||||
|
|
@ -103,7 +140,7 @@ export async function runOptimizeApply(
|
|||
let costRate = opts.costRate ?? 0
|
||||
if (!findings) {
|
||||
errout.write(chalk.dim(' Analyzing your sessions...\n'))
|
||||
const scanned = await scanAndDetect(projects, dateRange)
|
||||
const scanned = await scanAndDetect(projects, dateRange, opts.provider)
|
||||
findings = scanned.findings
|
||||
costRate = scanned.costRate
|
||||
}
|
||||
|
|
@ -129,6 +166,7 @@ export async function runOptimizeApply(
|
|||
print(chalk.dim('\n No appliable config-class fixes for this period.'))
|
||||
for (const fp of manual) {
|
||||
for (const note of fp.notes) print(chalk.yellow(` ! ${fp.finding.id}: ${note}`))
|
||||
for (const line of manualActionLines(fp)) print(chalk.cyan(` ${line}`))
|
||||
}
|
||||
print()
|
||||
return
|
||||
|
|
@ -172,15 +210,25 @@ export async function runOptimizeApply(
|
|||
} catch { /* baseline is optional; apply proceeds without it */ }
|
||||
|
||||
print()
|
||||
let applied = 0
|
||||
for (const fp of selected) {
|
||||
try {
|
||||
const record = await runAction(fp.plan!, opts.actionsDir)
|
||||
applied++
|
||||
print(` Applied ${chalk.bold(shortId(record.id))} ${record.description}`)
|
||||
print(chalk.dim(` Undo anytime: codeburn act undo ${shortId(record.id)}`))
|
||||
const manualLines = manualActionLines(fp)
|
||||
if (manualLines.length > 0) {
|
||||
print(chalk.cyan(' Still requires manual action:'))
|
||||
for (const line of manualLines) print(chalk.cyan(` ${line}`))
|
||||
}
|
||||
} catch (e) {
|
||||
errout.write(chalk.red(` Failed to apply ${fp.finding.id}: ${e instanceof Error ? e.message : String(e)}`) + '\n')
|
||||
process.exitCode = 1
|
||||
}
|
||||
}
|
||||
if (applied > 0) {
|
||||
print(chalk.dim(` CodeBurn will re-measure these on your next optimize run after ${REPORT_MIN_AGE_DAYS} days.`))
|
||||
}
|
||||
print()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import {
|
|||
ALWAYSLOAD_STARTUP_CAP_SECONDS,
|
||||
ENABLE_TOOL_SEARCH_VAR,
|
||||
parseVersion,
|
||||
SHELL_PROFILE_SCOPE,
|
||||
versionPredates,
|
||||
} from '../optimize.js'
|
||||
import type { WasteFinding } from '../optimize.js'
|
||||
|
|
@ -275,12 +276,15 @@ function pathNoteAdder(pathNotes: Record<string, string>): (path: string, note:
|
|||
}
|
||||
|
||||
function buildMcpRemove(finding: WasteFinding, r: ResolvedPaths): BuiltPlan {
|
||||
const servers = finding.apply?.kind === 'mcp-remove' ? finding.apply.servers : []
|
||||
const servers = finding.apply?.kind === 'mcp-remove'
|
||||
? [...new Set(finding.apply.servers)]
|
||||
: []
|
||||
const searchPaths = [r.projectMcpJson, r.projectSettings, r.projectSettingsLocal, r.userClaudeJson]
|
||||
const docs = new ConfigDocs(r.homeDir)
|
||||
const skips: string[] = []
|
||||
const pathNotes: Record<string, string> = {}
|
||||
const addPathNote = pathNoteAdder(pathNotes)
|
||||
const affectedServers: string[] = []
|
||||
|
||||
for (const server of servers) {
|
||||
let removed = false
|
||||
|
|
@ -291,14 +295,24 @@ function buildMcpRemove(finding: WasteFinding, r: ResolvedPaths): BuiltPlan {
|
|||
if (res.removed) removed = true
|
||||
if (res.projectEntries.length > 0) addPathNote(path, projectRemovalNote(server, res.projectEntries, r.homeDir))
|
||||
}
|
||||
if (!removed) skips.push(`skipped ${server}: not found in editable config (plugin or managed config?)`)
|
||||
if (removed) affectedServers.push(server)
|
||||
else skips.push(`skipped ${server}: not found in editable config (plugin or managed config?)`)
|
||||
}
|
||||
|
||||
const changes = docs.changes()
|
||||
const notes = [...docs.errorNotes(), ...skips]
|
||||
const attribution = finding.applyTokensSavedByServer
|
||||
const partialWithoutAttribution = affectedServers.length < servers.length && !attribution
|
||||
const affectedMissingAttribution = attribution !== undefined
|
||||
&& affectedServers.some(server => !Object.hasOwn(attribution, server))
|
||||
const savingsUncertain = docs.errorNotes().length > 0
|
||||
|| partialWithoutAttribution
|
||||
|| affectedMissingAttribution
|
||||
if (changes.length === 0) return { plan: null, notes }
|
||||
const plan = mcpPlan('mcp-remove', finding.id, `Remove ${affectedServers.length === 1 ? 'an MCP server' : 'MCP servers'} from config`, changes, affectedServers)
|
||||
if (savingsUncertain) plan.mcpSavingsUncertain = true
|
||||
return {
|
||||
plan: mcpPlan('mcp-remove', finding.id, `Remove ${changes.length === 1 ? 'an MCP server' : 'MCP servers'} from config`, changes),
|
||||
plan,
|
||||
notes,
|
||||
...(Object.keys(pathNotes).length > 0 ? { pathNotes } : {}),
|
||||
}
|
||||
|
|
@ -371,8 +385,8 @@ function buildMcpProjectScope(finding: WasteFinding, r: ResolvedPaths): BuiltPla
|
|||
}
|
||||
}
|
||||
|
||||
function mcpPlan(kind: ActionKind, findingId: string, description: string, changes: PlannedChange[]): ActionPlan {
|
||||
return { kind, findingId, description, changes }
|
||||
function mcpPlan(kind: ActionKind, findingId: string, description: string, changes: PlannedChange[], affectedMcpServers?: string[]): ActionPlan {
|
||||
return { kind, findingId, description, changes, ...(affectedMcpServers ? { affectedMcpServers } : {}) }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -386,7 +400,6 @@ const NEXT_SESSION_NOTE = 'takes effect on the next session (this config is read
|
|||
|
||||
// findDeferralEnvSetting (src/optimize.ts) reports shell-profile hits with
|
||||
// exactly this scope string; the plan layer keys its refusal on it.
|
||||
const SHELL_PROFILE_SCOPE = 'shell profile'
|
||||
|
||||
const SHELL_TOOL_SEARCH_LINE = new RegExp(`^\\s*(?:export\\s+)?${ENABLE_TOOL_SEARCH_VAR}\\s*=.*$`, 'm')
|
||||
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
import { existsSync } from 'fs'
|
||||
import { dirname } from 'node:path'
|
||||
import type { DateRange, ProjectSummary, SessionSummary } from '../types.js'
|
||||
import type { ActionBaseline, ActionKind, ActionRecord } from './types.js'
|
||||
import type { ActionBaseline, ActionKind, ActionRecord, AppliedFix, AppliedVerdict } from './types.js'
|
||||
import { REPORT_MIN_AGE_DAYS, VERDICT_WORKED_RATIO } from './types.js'
|
||||
import type { FindingPlan } from './plans.js'
|
||||
import {
|
||||
AVG_TOKENS_PER_READ,
|
||||
|
|
@ -20,7 +21,8 @@ import {
|
|||
} from '../optimize.js'
|
||||
import { parseAllSessions } from '../parser.js'
|
||||
import { computeYield, type YieldSummary } from '../yield.js'
|
||||
import { defaultActionsDir, readRecords } from './journal.js'
|
||||
import { defaultActionsDir, readRecords, shortId } from './journal.js'
|
||||
import { undoAction } from './undo.js'
|
||||
import { renderTable } from '../text-table.js'
|
||||
import { formatTokens } from '../format.js'
|
||||
import { formatCost } from '../currency.js'
|
||||
|
|
@ -28,7 +30,6 @@ import { formatCost } from '../currency.js'
|
|||
const DAY_MS = 24 * 60 * 60 * 1000
|
||||
const WINDOW_CAP_DAYS = 30
|
||||
const BASELINE_WINDOW_DAYS = 14
|
||||
const REPORT_MIN_AGE_DAYS = 3
|
||||
const MIN_POST_WINDOW_SESSIONS = 20
|
||||
const VOLUME_SHIFT_FACTOR = 2
|
||||
|
||||
|
|
@ -59,6 +60,8 @@ const ARCHIVE_DEF_TOKENS: Partial<Record<ActionKind, number>> = {
|
|||
// 'pending' means the applied change has not taken effect in any post-apply
|
||||
// session yet (e.g. deferral before a client restart) - distinct from
|
||||
// 'reverted', which asserts the user undid it.
|
||||
export { REPORT_MIN_AGE_DAYS }
|
||||
|
||||
export type RealizedStatus = 'measured' | 'reverted' | 'not-measurable' | 'pending'
|
||||
|
||||
export type ActReportRow = {
|
||||
|
|
@ -102,6 +105,8 @@ export type ActReport = {
|
|||
// findingId -> earliest apply date of an active applied action; drives the
|
||||
// optimize "(previously applied ..., re-flagged)" title suffix.
|
||||
appliedByFinding: Record<string, string>
|
||||
// One entry per active applied action, including ones too young to measure.
|
||||
appliedFixes: AppliedFix[]
|
||||
}
|
||||
|
||||
export type ActReportOptions = {
|
||||
|
|
@ -470,6 +475,63 @@ function isSaneRecord(r: ActionRecord): boolean {
|
|||
return typeof r.at === 'string' && typeof r.status === 'string' && !Number.isNaN(new Date(r.at).getTime())
|
||||
}
|
||||
|
||||
// Turn the measured rows plus the still-young entries into one verdict per
|
||||
// active applied action. No second reconciliation: everything measurable comes
|
||||
// straight off the row `act report` already computed.
|
||||
function buildAppliedFixes(active: ActionRecord[], rows: ActReportRow[], now: Date): AppliedFix[] {
|
||||
const byId = new Map(rows.map(r => [r.id, r]))
|
||||
return active.map(rec => {
|
||||
const row = byId.get(rec.id)
|
||||
const base = {
|
||||
id: rec.id,
|
||||
kind: rec.kind,
|
||||
findingId: rec.findingId ?? null,
|
||||
appliedAt: rec.at,
|
||||
ageDays: ageDays(rec.at, now),
|
||||
undoCommand: `codeburn act undo ${shortId(rec.id)}`,
|
||||
}
|
||||
// No row means too young to measure; a row that is not a measured token
|
||||
// row (not-measurable, not yet in effect, reverted by the user, or a
|
||||
// correlation-only kind) has no reduction to judge either.
|
||||
if (!row) return { ...base, verdict: 'pending' as const, estimatedTokens: rec.baseline?.estimatedTokens ?? 0, realizedTokens: 0, note: '' }
|
||||
if (row.status !== 'measured' || !isTokenKind(row.kind)) {
|
||||
return { ...base, verdict: 'pending' as const, estimatedTokens: row.estimatedForWindow, realizedTokens: 0, note: row.note }
|
||||
}
|
||||
const estimatedTokens = row.estimatedForWindow
|
||||
const realizedTokens = row.realizedTokens
|
||||
const verdict: AppliedVerdict = realizedTokens <= 0
|
||||
? 'no-effect'
|
||||
: estimatedTokens <= 0 || realizedTokens >= estimatedTokens * VERDICT_WORKED_RATIO ? 'worked' : 'partial'
|
||||
return { ...base, verdict, estimatedTokens, realizedTokens, note: row.note }
|
||||
})
|
||||
}
|
||||
|
||||
// --auto-revert: undo the fixes that measured no reduction at all. CLAUDE.md
|
||||
// rules are never undone unattended, matching the --yes guardrail - the file
|
||||
// belongs to whatever project the user happened to be in.
|
||||
export async function autoRevertNoEffect(
|
||||
fixes: AppliedFix[], opts: { actionsDir?: string } = {},
|
||||
): Promise<{ lines: string[]; revertedIds: Set<string> }> {
|
||||
const lines: string[] = []
|
||||
const revertedIds = new Set<string>()
|
||||
for (const fix of fixes) {
|
||||
if (fix.verdict !== 'no-effect') continue
|
||||
const label = fix.findingId ?? fix.kind
|
||||
if (fix.kind === 'claude-md-rule') {
|
||||
lines.push(`Not auto-reverted: ${label} edits a CLAUDE.md. Revert: ${fix.undoCommand}`)
|
||||
continue
|
||||
}
|
||||
try {
|
||||
const record = await undoAction({ id: fix.id }, { actionsDir: opts.actionsDir })
|
||||
revertedIds.add(fix.id)
|
||||
lines.push(`Reverted ${shortId(record.id)}: ${record.description}`)
|
||||
} catch (err) {
|
||||
lines.push(`Could not revert ${label}: ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
}
|
||||
return { lines, revertedIds }
|
||||
}
|
||||
|
||||
export async function computeActReport(opts: ActReportOptions = {}): Promise<ActReport> {
|
||||
const now = opts.now ?? new Date()
|
||||
const rawRecords = await readRecords(opts.actionsDir ?? defaultActionsDir())
|
||||
|
|
@ -497,6 +559,7 @@ export async function computeActReport(opts: ActReportOptions = {}): Promise<Act
|
|||
observedDays: 0,
|
||||
malformedRecords,
|
||||
appliedByFinding,
|
||||
appliedFixes: buildAppliedFixes(active, [], now),
|
||||
}
|
||||
|
||||
const eligible = active.filter(r => ageDays(r.at, now) > REPORT_MIN_AGE_DAYS)
|
||||
|
|
@ -550,6 +613,7 @@ export async function computeActReport(opts: ActReportOptions = {}): Promise<Act
|
|||
observedDays,
|
||||
malformedRecords,
|
||||
appliedByFinding,
|
||||
appliedFixes: buildAppliedFixes(active, rows, now),
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -651,6 +715,7 @@ export function buildActReportJson(report: ActReport): unknown {
|
|||
activeActions: report.activeCount,
|
||||
observedDays: report.observedDays,
|
||||
},
|
||||
appliedFixes: report.appliedFixes,
|
||||
footer: HONEST_FOOTER,
|
||||
}
|
||||
}
|
||||
|
|
@ -666,7 +731,8 @@ type CaptureCtx = {
|
|||
now: Date
|
||||
}
|
||||
|
||||
function mcpServersFromApply(finding: WasteFinding): string[] {
|
||||
function mcpServersFromApply(finding: WasteFinding, affectedMcpServers?: string[]): string[] {
|
||||
if (affectedMcpServers) return affectedMcpServers
|
||||
if (finding.apply?.kind === 'mcp-remove') return finding.apply.servers
|
||||
if (finding.apply?.kind === 'mcp-project-scope') return finding.apply.servers.map(s => s.server)
|
||||
return []
|
||||
|
|
@ -684,24 +750,37 @@ function deferServers(finding: WasteFinding, ctx: CaptureCtx): string[] {
|
|||
return observedMcpServers(ctx.projects)
|
||||
}
|
||||
|
||||
export function captureBaseline(finding: WasteFinding, kind: ActionKind, ctx: CaptureCtx): ActionBaseline | undefined {
|
||||
export function captureBaseline(
|
||||
finding: WasteFinding,
|
||||
kind: ActionKind,
|
||||
ctx: CaptureCtx,
|
||||
affectedMcpServers?: string[],
|
||||
): ActionBaseline | undefined {
|
||||
const common = {
|
||||
windowDays: ctx.windowDays,
|
||||
capturedAt: ctx.now.toISOString(),
|
||||
estimatedTokens: Math.max(0, Math.round(finding.tokensSaved)),
|
||||
estimatedTokens: Math.max(0, Math.round(finding.applyTokensSaved ?? finding.tokensSaved)),
|
||||
}
|
||||
|
||||
if (MCP_KINDS.has(kind)) {
|
||||
const servers = mcpServersFromApply(finding)
|
||||
const servers = mcpServersFromApply(finding, affectedMcpServers)
|
||||
if (servers.length === 0) return undefined
|
||||
const covByServer = new Map(ctx.coverage.map(c => [c.server, c]))
|
||||
const metrics: Record<string, number> = {}
|
||||
for (const server of servers) {
|
||||
const cov = covByServer.get(server)
|
||||
const tools = cov && cov.toolsAvailable > 0 ? cov.toolsAvailable : TOOLS_PER_MCP_SERVER
|
||||
// Removal realizes only the unused schema that the low-coverage
|
||||
// detector estimated. If coverage is unavailable, omit the numeric
|
||||
// claim instead of inventing a five-tool baseline.
|
||||
const tools = finding.id === 'mcp-low-coverage'
|
||||
? cov?.unusedTools.length ?? 0
|
||||
: cov && cov.toolsAvailable > 0 ? cov.toolsAvailable : TOOLS_PER_MCP_SERVER
|
||||
metrics[server] = tools * TOKENS_PER_MCP_TOOL
|
||||
}
|
||||
return { ...common, sessions: countSessionsLoading(ctx.projects, servers), metrics }
|
||||
const estimatedTokens = finding.applyTokensSavedByServer
|
||||
? Math.round(servers.reduce((sum, server) => sum + (finding.applyTokensSavedByServer?.[server] ?? 0), 0))
|
||||
: common.estimatedTokens
|
||||
return { ...common, estimatedTokens, sessions: countSessionsLoading(ctx.projects, servers), metrics }
|
||||
}
|
||||
|
||||
if (DEFER_KINDS.has(kind)) {
|
||||
|
|
@ -750,7 +829,8 @@ export async function captureBaselinesForPlans(
|
|||
const projects = await loadProjects({ start, end: now })
|
||||
const ctx: CaptureCtx = { projects, coverage: aggregateMcpCoverage(projects), windowDays: BASELINE_WINDOW_DAYS, now }
|
||||
for (const fp of applicable) {
|
||||
const baseline = captureBaseline(fp.finding, fp.plan!.kind, ctx)
|
||||
if (fp.plan!.mcpSavingsUncertain) continue
|
||||
const baseline = captureBaseline(fp.finding, fp.plan!.kind, ctx, fp.plan!.affectedMcpServers)
|
||||
if (baseline) fp.plan!.baseline = baseline
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import { formatTokens } from '../format.js'
|
||||
|
||||
export type ActionKind =
|
||||
| 'mcp-remove' | 'mcp-project-scope'
|
||||
| 'defer-enable' | 'defer-alwaysload' | 'defer-threshold'
|
||||
|
|
@ -66,4 +68,68 @@ export type ActionPlan = {
|
|||
findingId?: string | null
|
||||
changes: PlannedChange[]
|
||||
baseline?: ActionBaseline
|
||||
// MCP plans only: exact server identities the generated file mutations own.
|
||||
// Preview and baseline capture must not claim skipped/managed targets.
|
||||
affectedMcpServers?: string[]
|
||||
// Relevant config scopes could not all be read, so removal may proceed
|
||||
// with warnings but savings/baseline claims must be suppressed.
|
||||
mcpSavingsUncertain?: boolean
|
||||
}
|
||||
|
||||
// Applied actions are re-measured on every `codeburn optimize` run: only fixes
|
||||
// at least this old have a post-apply window to measure against.
|
||||
export const REPORT_MIN_AGE_DAYS = 3
|
||||
// A fix counts as having worked once it realizes this share of its
|
||||
// window-scaled estimate; anything above zero but below it is partial.
|
||||
export const VERDICT_WORKED_RATIO = 0.7
|
||||
|
||||
// Per-applied-entry judgement shown by `codeburn optimize` after an --apply.
|
||||
// Computed in act/report.ts from the same rows `act report` prints - there is
|
||||
// one reconciliation, not two. Lives here so the optimize renderer can format
|
||||
// it without importing report.ts back into optimize.ts.
|
||||
export type AppliedVerdict = 'worked' | 'partial' | 'no-effect' | 'pending'
|
||||
|
||||
export type AppliedFix = {
|
||||
id: string
|
||||
kind: ActionKind
|
||||
findingId: string | null
|
||||
appliedAt: string
|
||||
ageDays: number
|
||||
verdict: AppliedVerdict
|
||||
// Window-scaled estimate, the same column `act report` compares against.
|
||||
estimatedTokens: number
|
||||
realizedTokens: number
|
||||
note: string
|
||||
undoCommand: string
|
||||
}
|
||||
|
||||
const VERDICT_GLYPH: Record<AppliedVerdict, string> = {
|
||||
worked: '\u2713',
|
||||
partial: '~',
|
||||
'no-effect': '\u2717',
|
||||
pending: '\u2026',
|
||||
}
|
||||
|
||||
export function appliedFixGlyph(fix: AppliedFix): string {
|
||||
return VERDICT_GLYPH[fix.verdict]
|
||||
}
|
||||
|
||||
// One plain line per applied fix: what it estimated, what it measured, and for
|
||||
// a fix that did nothing, how to put it back.
|
||||
export function formatAppliedFix(fix: AppliedFix): string {
|
||||
const age = Math.max(0, Math.floor(fix.ageDays))
|
||||
const head = `${fix.findingId ?? fix.kind} (${age}d ago)`
|
||||
if (fix.verdict === 'pending') {
|
||||
const why = fix.note || (age <= REPORT_MIN_AGE_DAYS
|
||||
? `measuring, check back after ${REPORT_MIN_AGE_DAYS} days`
|
||||
: 'measuring')
|
||||
return `${head}: ${why}`
|
||||
}
|
||||
const pair = `est. ${formatTokens(fix.estimatedTokens)} -> measured ${formatTokens(fix.realizedTokens)}`
|
||||
if (fix.verdict === 'worked') return `${head}: ${pair}`
|
||||
if (fix.verdict === 'partial') {
|
||||
const under = Math.round((1 - fix.realizedTokens / fix.estimatedTokens) * 100)
|
||||
return `${head}: ${pair} (-${under}% vs estimate)`
|
||||
}
|
||||
return `${head}: ${pair} - did not help. Revert: ${fix.undoCommand}`
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { getModelCosts, type ModelCosts } from './models.js'
|
||||
import { getModelCosts, sanitizeModelForDisplay, type ModelCosts } from './models.js'
|
||||
import { getProvider } from './providers/index.js'
|
||||
import { formatCost, formatTokens } from './format.js'
|
||||
import { renderTable, type TableColumn } from './text-table.js'
|
||||
|
|
@ -111,7 +111,9 @@ export async function aggregateAudit(projects: ProjectSummary[]): Promise<AuditR
|
|||
const p = await getProvider(name)
|
||||
const entry = {
|
||||
displayName: p?.displayName ?? name,
|
||||
formatModel: p ? (m: string) => p.modelDisplayName(m) : (m: string) => m,
|
||||
formatModel: p
|
||||
? (m: string) => sanitizeModelForDisplay(p.modelDisplayName(m))
|
||||
: sanitizeModelForDisplay,
|
||||
}
|
||||
providerCache.set(name, entry)
|
||||
return entry
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { homedir } from 'os'
|
||||
|
||||
import React, { useState, useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react'
|
||||
import React, { Fragment, useState, useCallback, useEffect, useLayoutEffect, useMemo, useRef } from 'react'
|
||||
import { render, Box, Text, measureElement, useInput, useApp, useWindowSize, type DOMElement } from 'ink'
|
||||
import { CATEGORY_LABELS, type DateRange, type ProjectSummary, type TaskCategory } from './types.js'
|
||||
import { formatCost, formatTokens, markEstimated, carriedCostNote } from './format.js'
|
||||
|
|
@ -10,7 +10,8 @@ import { findUnpricedModels, isExpectedFreeModel, loadPricing } from './models.j
|
|||
import { aggregateModelTotals } from './model-breakdown.js'
|
||||
import { buildDurablePeriod } from './usage-aggregator.js'
|
||||
import { getAllProviders } from './providers/index.js'
|
||||
import { scanAndDetect, type WasteFinding, type WasteAction, type OptimizeResult } from './optimize.js'
|
||||
import { classHeaderLine, classTotals, findingBasis, findingClass, scanAndDetect, type FindingClass, type WasteFinding, type WasteAction, type OptimizeResult } from './optimize.js'
|
||||
import { appliedFixGlyph, formatAppliedFix, type AppliedFix } from './act/types.js'
|
||||
import { aggregateFileChurn, buildCoachingNotes, computePricingCoverage, medianTimeToFirstEditMs, scanUserCorrections, worstOneShotCategory, type ReworkedFile } from './workflow-insights.js'
|
||||
import { estimateContextBudget, type ContextBudget } from './context-budget.js'
|
||||
import { dateKey } from './day-aggregator.js'
|
||||
|
|
@ -652,8 +653,10 @@ function ModelBreakdown({ projects, pw, bw }: { projects: ProjectSummary[]; pw:
|
|||
)
|
||||
})}
|
||||
{unpriced.length > 0 && (
|
||||
<Text color="yellow" wrap="truncate-end">
|
||||
{`! ${unpriced.length} model${unpriced.length === 1 ? '' : 's'} unpriced at $0, fix: codeburn model-alias (${unpriced.slice(0, 2).map(u => u.model).join(', ')}${unpriced.length > 2 ? ', ...' : ''})`}
|
||||
<Text color="yellow" wrap={pw <= 44 ? 'wrap' : 'truncate-end'}>
|
||||
{pw <= 44
|
||||
? `! ${unpriced.length}: codeburn models --unpriced`
|
||||
: `! ${unpriced.length} unpriced: codeburn models --unpriced`}
|
||||
</Text>
|
||||
)}
|
||||
{anyEstimated && (
|
||||
|
|
@ -1046,6 +1049,8 @@ function actionDestinationHeader(action: WasteAction): string {
|
|||
return '── Ask Claude in the current session '.padEnd(64, '─')
|
||||
case 'shell-config':
|
||||
return '── Add to your shell config '.padEnd(64, '─')
|
||||
case 'manual':
|
||||
return '── Manual action '.padEnd(64, '─')
|
||||
default:
|
||||
return '── Suggested action '.padEnd(64, '─')
|
||||
}
|
||||
|
|
@ -1079,7 +1084,7 @@ function FindingPanel({ index, finding, costRate, width }: { index: number; find
|
|||
{trendBadge && <Text color="#5BF5A0">{trendBadge}</Text>}
|
||||
</Text>
|
||||
<Text dimColor wrap="wrap">{finding.explanation}</Text>
|
||||
<Text color={GOLD}>Savings: ~{formatTokens(finding.tokensSaved)} tokens (~{formatCost(costSaved)})</Text>
|
||||
<Text color={GOLD}>Savings: ~{formatTokens(finding.tokensSaved)} tokens (~{formatCost(costSaved)})<Text dimColor> {findingBasis(finding)}</Text></Text>
|
||||
<Text> </Text>
|
||||
<FindingAction action={finding.fix} />
|
||||
</Box>
|
||||
|
|
@ -1094,7 +1099,14 @@ const GRADE_COLORS: Record<string, string> = { A: '#5BF5A0', B: '#5BF5A0', C: GO
|
|||
// off the alt-buffer top and the user couldn't see the StatusBar at all.
|
||||
const FINDINGS_WINDOW_SIZE = 3
|
||||
|
||||
function OptimizeView({ findings, costRate, projects, label, width, healthScore, healthGrade, cursor }: { findings: WasteFinding[]; costRate: number; projects: ProjectSummary[]; label: string; width: number; healthScore: number; healthGrade: string; cursor: number }) {
|
||||
const APPLIED_FIX_COLORS: Record<AppliedFix['verdict'], string> = {
|
||||
worked: '#5BF5A0',
|
||||
partial: GOLD,
|
||||
'no-effect': '#F55B5B',
|
||||
pending: DIM,
|
||||
}
|
||||
|
||||
function OptimizeView({ findings, costRate, projects, label, width, healthScore, healthGrade, cursor, appliedFixes = [] }: { findings: WasteFinding[]; costRate: number; projects: ProjectSummary[]; label: string; width: number; healthScore: number; healthGrade: string; cursor: number; appliedFixes?: AppliedFix[] }) {
|
||||
const periodCost = projects.reduce((s, p) => s + p.totalCostUSD, 0)
|
||||
const totalTokens = findings.reduce((s, f) => s + f.tokensSaved, 0)
|
||||
const totalCost = totalTokens * costRate
|
||||
|
|
@ -1105,6 +1117,7 @@ function OptimizeView({ findings, costRate, projects, label, width, healthScore,
|
|||
const start = total === 0 ? 0 : Math.min(cursor, Math.max(0, total - FINDINGS_WINDOW_SIZE))
|
||||
const end = Math.min(start + FINDINGS_WINDOW_SIZE, total)
|
||||
const visible = findings.slice(start, end)
|
||||
const totals = classTotals(findings, costRate)
|
||||
return (
|
||||
<Box flexDirection="column" width={width}>
|
||||
<Box flexDirection="column" borderStyle="round" borderColor={ORANGE} paddingX={1} width={width}>
|
||||
|
|
@ -1119,8 +1132,28 @@ function OptimizeView({ findings, costRate, projects, label, width, healthScore,
|
|||
<Text dimColor>Showing {start + 1}–{end} of {total} · j/k to scroll</Text>
|
||||
)}
|
||||
</Box>
|
||||
{visible.map((f, i) => <FindingPanel key={start + i} index={start + i + 1} finding={f} costRate={costRate} width={width} />)}
|
||||
<Box paddingX={1} width={width}><Text dimColor>Token estimates are approximate.</Text></Box>
|
||||
{visible.map((f, i) => {
|
||||
// Findings arrive class-sorted, so a header goes in wherever the class
|
||||
// changes (including the top of the window after paging).
|
||||
const cls = findingClass(f)
|
||||
const previous: FindingClass | null = i > 0 ? findingClass(visible[i - 1]!) : null
|
||||
return (
|
||||
<Fragment key={start + i}>
|
||||
{cls !== previous && <Box paddingX={1} width={width}><Text bold color={ORANGE} wrap="truncate-end">{classHeaderLine(cls, totals[cls], costRate)}</Text></Box>}
|
||||
<FindingPanel index={start + i + 1} finding={f} costRate={costRate} width={width} />
|
||||
</Fragment>
|
||||
)
|
||||
})}
|
||||
{appliedFixes.length > 0 && (
|
||||
<Box flexDirection="column" paddingX={1} width={width}>
|
||||
<Text bold color={ORANGE} wrap="truncate-end">Applied fixes</Text>
|
||||
{appliedFixes.map(fix => (
|
||||
<Text key={fix.id} color={APPLIED_FIX_COLORS[fix.verdict]} wrap="truncate-end">
|
||||
{appliedFixGlyph(fix)} {formatAppliedFix(fix)}
|
||||
</Text>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
|
@ -1302,6 +1335,7 @@ export function InteractiveDashboard({ initialProjects, initialDailyHistoryProje
|
|||
const [detectedProviders, setDetectedProviders] = useState<string[]>([])
|
||||
const [view, setView] = useState<View>('dashboard')
|
||||
const [optimizeResult, setOptimizeResult] = useState<OptimizeResult | null>(null)
|
||||
const [appliedFixes, setAppliedFixes] = useState<AppliedFix[]>([])
|
||||
const [optimizeLoading, setOptimizeLoading] = useState(false)
|
||||
const [projectBudgets, setProjectBudgets] = useState<Map<string, ContextBudget>>(new Map())
|
||||
const [planUsages, setPlanUsages] = useState<PlanUsage[]>(initialPlanUsages ?? [])
|
||||
|
|
@ -1460,14 +1494,20 @@ export function InteractiveDashboard({ initialProjects, initialDailyHistoryProje
|
|||
const generation = reloadGenerationRef.current
|
||||
setOptimizeLoading(true)
|
||||
try {
|
||||
const result = await scanAndDetect(projects, currentRange())
|
||||
const result = await scanAndDetect(projects, currentRange(), activeProvider)
|
||||
if (reloadGenerationRef.current === generation) setOptimizeResult(result)
|
||||
// Best effort: a bad journal never keeps the findings off screen.
|
||||
try {
|
||||
const { computeActReport } = await import('./act/report.js')
|
||||
const applied = await computeActReport()
|
||||
if (reloadGenerationRef.current === generation) setAppliedFixes(applied.appliedFixes)
|
||||
} catch { /* the applied section is optional */ }
|
||||
} catch (error) {
|
||||
console.error(error)
|
||||
} finally {
|
||||
if (reloadGenerationRef.current === generation) setOptimizeLoading(false)
|
||||
}
|
||||
}, [optimizeAvailable, projects, currentRange, optimizeLoading, optimizeResult])
|
||||
}, [optimizeAvailable, projects, currentRange, optimizeLoading, optimizeResult, activeProvider])
|
||||
|
||||
useEffect(() => {
|
||||
const refreshIntervalMs = getRefreshIntervalMs(refreshSeconds ?? 0)
|
||||
|
|
@ -1626,7 +1666,7 @@ export function InteractiveDashboard({ initialProjects, initialDailyHistoryProje
|
|||
{view === 'compare'
|
||||
? <CompareView projects={projects} onBack={() => setView('dashboard')} />
|
||||
: view === 'optimize' && optimizeResult
|
||||
? <OptimizeView findings={optimizeResult.findings} costRate={optimizeResult.costRate} projects={projects} label={headerLabel} width={dashWidth} healthScore={optimizeResult.healthScore} healthGrade={optimizeResult.healthGrade} cursor={findingsCursor} />
|
||||
? <OptimizeView findings={optimizeResult.findings} costRate={optimizeResult.costRate} projects={projects} label={headerLabel} width={dashWidth} healthScore={optimizeResult.healthScore} healthGrade={optimizeResult.healthGrade} cursor={findingsCursor} appliedFixes={appliedFixes} />
|
||||
: <DashboardContent projects={projects} period={period} columns={columns} maxContentWidth={maxContentWidth} activeProvider={activeProvider} budgets={projectBudgets} planUsages={planUsages} label={headerLabel} dayMode={isDayMode} dailyHistoryProjects={dailyHistoryProjects} dailyHistoryPageSize={dailyHistoryPageSize} scrollableDailyHistory={scrollableDailyHistory} dailyHistoryCursor={Math.min(dailyHistoryCursor, dailyHistoryMaxCursor)} durable={durable} />}
|
||||
{coachingNote && (
|
||||
<Box width={dashWidth} paddingX={1}>
|
||||
|
|
|
|||
104
src/main.ts
104
src/main.ts
|
|
@ -2,7 +2,7 @@ import { isAbsolute } from 'path'
|
|||
import { Command, Option } from 'commander'
|
||||
import { installMenubarApp } from './menubar-installer.js'
|
||||
import { exportCsv, exportJson, type PeriodExport } from './export.js'
|
||||
import { findUnpricedModels, loadPricing, setModelAliases, setPriceOverrides, setLocalModelSavings, setProxyPaths, normalizeProxyPath } from './models.js'
|
||||
import { findUnpricedModels, loadPricing, sanitizeModelForDisplay, setModelAliases, setPriceOverrides, setLocalModelSavings, setProxyPaths, normalizeProxyPath } from './models.js'
|
||||
import { parseAllSessions, filterProjectsByName, filterProjectsByDateRange, clearSessionCache, setInteractiveScanUI } from './parser.js'
|
||||
import { allProviderNames, getAllProviders } from './providers/index.js'
|
||||
import { getProvider } from './providers/index.js'
|
||||
|
|
@ -11,6 +11,7 @@ import { renderStatusBar } from './format.js'
|
|||
import { toDateString } from './daily-cache.js'
|
||||
import { dateKey } from './day-aggregator.js'
|
||||
import { CATEGORY_LABELS, type DateRange, type ProjectSummary, type TaskCategory } from './types.js'
|
||||
import type { AppliedFix } from './act/types.js'
|
||||
import { aggregateModelEfficiency } from './model-efficiency.js'
|
||||
import { buildPeriodData, buildMenubarPayloadForRange, buildDurablePeriod, type DurablePeriod } from './usage-aggregator.js'
|
||||
import { renderDashboard } from './dashboard.js'
|
||||
|
|
@ -1310,12 +1311,13 @@ program
|
|||
|
||||
program
|
||||
.command('menubar')
|
||||
.description('Install and launch the macOS menubar app (one command, no clone)')
|
||||
.option('--force', 'Reinstall even if an older copy is already in ~/Applications')
|
||||
.description('Install and launch the menubar app on macOS and Windows (one command, no clone)')
|
||||
.option('--force', 'Reinstall even if a copy is already installed')
|
||||
.action(async (opts: { force?: boolean }) => {
|
||||
try {
|
||||
const result = await installMenubarApp({ force: opts.force, cliVersion: version })
|
||||
console.log(`\n Ready. ${result.installedPath}\n`)
|
||||
// A cancelled Windows installer leaves nothing to point at.
|
||||
if (result.installedPath) console.log(`\n Ready. ${result.installedPath}\n`)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
console.error(`\n Menubar install failed: ${message}\n`)
|
||||
|
|
@ -1806,6 +1808,7 @@ program
|
|||
.option('--yes', 'With --apply: apply every appliable fix without prompting')
|
||||
.option('--dry-run', 'With --apply: print the plan and exit without changing anything')
|
||||
.option('--only <ids>', 'With --apply: restrict to a comma-separated list of finding ids')
|
||||
.option('--auto-revert', 'Undo applied fixes that measured no reduction (never CLAUDE.md rules)')
|
||||
.action(async (opts) => {
|
||||
assertProvider(opts.provider, 'optimize')
|
||||
const format = opts.json ? 'json' : opts.format
|
||||
|
|
@ -1831,28 +1834,35 @@ program
|
|||
const projects = await parseAllSessions(range, opts.provider)
|
||||
if (opts.apply) {
|
||||
const { runOptimizeApply } = await import('./act/optimize-apply.js')
|
||||
await runOptimizeApply(projects, range, { yes: opts.yes, dryRun: opts.dryRun, only: opts.only })
|
||||
await runOptimizeApply(projects, range, { yes: opts.yes, dryRun: opts.dryRun, only: opts.only, provider: opts.provider })
|
||||
return
|
||||
}
|
||||
assertFormat(format, ['text', 'json'], 'optimize')
|
||||
if (format === 'text') {
|
||||
// Surface realized savings from applied actions. Best effort: optimize
|
||||
// must never fail because of journal contents, so any error just drops
|
||||
// the header. computeActReport returns fast without scanning when the
|
||||
// journal has no eligible applied actions, so users who never opted in
|
||||
// see identical output.
|
||||
let appliedHeader: string | undefined
|
||||
let previouslyApplied: Record<string, string> | undefined
|
||||
try {
|
||||
const { computeActReport, buildOptimizeAppliedHeader } = await import('./act/report.js')
|
||||
const applied = await computeActReport()
|
||||
appliedHeader = buildOptimizeAppliedHeader(applied) ?? undefined
|
||||
previouslyApplied = applied.appliedByFinding
|
||||
} catch { /* the header is optional; never block the findings */ }
|
||||
await runOptimize(projects, label, range, { format, appliedHeader, previouslyApplied })
|
||||
} else {
|
||||
await runOptimize(projects, label, range, { format })
|
||||
}
|
||||
// Surface realized savings from applied actions, and re-measure every one
|
||||
// of them. Best effort: optimize must never fail because of journal
|
||||
// contents, so any error just drops the extras. computeActReport returns
|
||||
// fast without scanning when the journal has no applied actions, so users
|
||||
// who never opted in see identical output.
|
||||
let appliedHeader: string | undefined
|
||||
let previouslyApplied: Record<string, string> | undefined
|
||||
let appliedFixes: AppliedFix[] | undefined
|
||||
try {
|
||||
const { computeActReport, buildOptimizeAppliedHeader, autoRevertNoEffect } = await import('./act/report.js')
|
||||
const applied = await computeActReport()
|
||||
appliedHeader = buildOptimizeAppliedHeader(applied) ?? undefined
|
||||
previouslyApplied = applied.appliedByFinding
|
||||
appliedFixes = applied.appliedFixes
|
||||
if (opts.autoRevert) {
|
||||
const { lines, revertedIds } = await autoRevertNoEffect(appliedFixes)
|
||||
appliedFixes = appliedFixes.filter(f => !revertedIds.has(f.id))
|
||||
// JSON output must stay parseable, so the revert log goes to stderr there.
|
||||
for (const line of lines) {
|
||||
if (format === 'json') process.stderr.write(` ${line}\n`)
|
||||
else console.log(` ${line}`)
|
||||
}
|
||||
}
|
||||
} catch { /* the applied section is optional; never block the findings */ }
|
||||
await runOptimize(projects, label, range, { format, appliedHeader, previouslyApplied, appliedFixes, provider: opts.provider })
|
||||
})
|
||||
|
||||
program
|
||||
|
|
@ -2075,6 +2085,7 @@ program
|
|||
.option('--by-agent', 'One row per (provider, model, agent) instead of one row per (provider, model). Claude subagent transcripts only; other providers and main sessions bucket under "main"')
|
||||
.option('--top <n>', 'Show only the top N rows', (v: string) => parseInt(v, 10))
|
||||
.option('--min-cost <usd>', 'Hide rows below this cost threshold', (v: string) => parseFloat(v))
|
||||
.option('--unpriced', 'Show only models with usage that currently price at $0')
|
||||
.option('--no-totals', 'Suppress the footer totals row')
|
||||
.option('--format <format>', 'Output format: table, markdown, json, csv', 'table')
|
||||
.action(async (opts) => {
|
||||
|
|
@ -2099,27 +2110,60 @@ program
|
|||
}
|
||||
|
||||
const projects = await parseAllSessions(range, opts.provider)
|
||||
const rows = await aggregateModels(projects, {
|
||||
const topN = typeof opts.top === 'number' && Number.isFinite(opts.top) ? opts.top : undefined
|
||||
let rows = await aggregateModels(projects, {
|
||||
byTask: !!opts.byTask,
|
||||
byAgent: !!opts.byAgent,
|
||||
taskFilter: opts.task,
|
||||
topN: typeof opts.top === 'number' && Number.isFinite(opts.top) ? opts.top : undefined,
|
||||
minCost: typeof opts.minCost === 'number' && Number.isFinite(opts.minCost) ? opts.minCost : 0.01,
|
||||
// `aggregateModels` filters and slices before the unpriced filter. Its
|
||||
// rows are sorted cost-first, so a small --top would remove exactly the
|
||||
// rows `--unpriced` exists to show. Take the whole set here and slice
|
||||
// after filtering and ranking instead.
|
||||
topN: opts.unpriced ? undefined : topN,
|
||||
minCost: typeof opts.minCost === 'number' && Number.isFinite(opts.minCost) ? opts.minCost : (opts.unpriced ? 0 : 0.01),
|
||||
})
|
||||
if (opts.unpriced) {
|
||||
const unpriced = findUnpricedModels(rows.map(row => ({
|
||||
model: row.model,
|
||||
calls: row.calls,
|
||||
cost: row.costUSD,
|
||||
tokens: row.totalTokens,
|
||||
})))
|
||||
const unpricedRank = new Map<string, number>()
|
||||
for (const [rank, usage] of unpriced.entries()) {
|
||||
// Breakdown modes can emit several rows for one model. Keep the first
|
||||
// rank so all rows for that model stay together and N still counts rows.
|
||||
if (!unpricedRank.has(usage.model)) unpricedRank.set(usage.model, rank)
|
||||
}
|
||||
rows = rows
|
||||
.filter(row => unpricedRank.has(row.model))
|
||||
.sort((a, b) => (unpricedRank.get(a.model)! - unpricedRank.get(b.model)!))
|
||||
if (topN !== undefined) rows = rows.slice(0, topN)
|
||||
}
|
||||
|
||||
const fmt = (opts.format ?? 'table').toLowerCase()
|
||||
if (rows.length === 0 && (fmt === 'table' || fmt === 'markdown')) {
|
||||
process.stdout.write('No model usage found for the selected period.\n')
|
||||
process.stdout.write(opts.unpriced
|
||||
? 'No unpriced models found for the selected period.\n'
|
||||
: 'No model usage found for the selected period.\n')
|
||||
return
|
||||
}
|
||||
// The friendly name is useless for `model-alias`, which keys on the raw ID.
|
||||
// Sanitized because this bypasses the shared display path in models-report.
|
||||
const renderRows = opts.unpriced && fmt !== 'json'
|
||||
? rows.map(row => ({ ...row, modelDisplayName: sanitizeModelForDisplay(row.model) }))
|
||||
: rows
|
||||
if (fmt === 'json') {
|
||||
process.stdout.write(renderJson(rows) + '\n')
|
||||
} else if (fmt === 'csv') {
|
||||
process.stdout.write(renderCsv(rows, { byTask: !!opts.byTask, byAgent: !!opts.byAgent }) + '\n')
|
||||
process.stdout.write(renderCsv(renderRows, { byTask: !!opts.byTask, byAgent: !!opts.byAgent }) + '\n')
|
||||
} else if (fmt === 'markdown' || fmt === 'md') {
|
||||
process.stdout.write(renderMarkdown(rows, { byTask: !!opts.byTask, byAgent: !!opts.byAgent, showTotals: opts.totals !== false }) + '\n')
|
||||
process.stdout.write(renderMarkdown(renderRows, { byTask: !!opts.byTask, byAgent: !!opts.byAgent, showTotals: opts.totals !== false }) + '\n')
|
||||
} else if (fmt === 'table') {
|
||||
process.stdout.write(renderTable(rows, { byTask: !!opts.byTask, byAgent: !!opts.byAgent, showTotals: opts.totals !== false }) + '\n')
|
||||
process.stdout.write(renderTable(renderRows, { byTask: !!opts.byTask, byAgent: !!opts.byAgent, showTotals: opts.totals !== false }) + '\n')
|
||||
// Never advise aliasing unconditionally: a subscription or flat-rate model
|
||||
// is correctly $0, and mapping it onto another model's rate invents spend.
|
||||
if (opts.unpriced) process.stdout.write('If a model is billed per token, map it with: codeburn model-alias "<model>" <known-model>. Subscription or flat-rate models are correctly $0.\n')
|
||||
} else {
|
||||
process.stderr.write(`codeburn: unknown --format "${opts.format}". Choose table, markdown, json, or csv.\n`)
|
||||
process.exit(1)
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import { pipeline } from 'node:stream/promises'
|
|||
import { Readable } from 'node:stream'
|
||||
import { ProxyAgent, fetch as undiciFetch } from 'undici'
|
||||
|
||||
import { getCodeburnCacheDir } from './cache-dir.js'
|
||||
import {
|
||||
buildPersistentCodeburnLookupPath,
|
||||
resolvePersistentCodeburnPathFromWhichOutput,
|
||||
|
|
@ -22,6 +23,11 @@ const EXPECTED_BUNDLE_ID = 'org.agentseal.codeburn-menubar'
|
|||
const VERSIONED_ASSET_PATTERN = /^CodeBurnMenubar-v.+\.zip$/
|
||||
const APP_PROCESS_NAME = 'CodeBurnMenubar'
|
||||
const SUPPORTED_OS = 'darwin'
|
||||
/// The Windows tray app (windows/) ships as an .msi under its own `windows-v*` tag. GitHub
|
||||
/// rewrites the spaces in the bundle name to dots when it stores the asset, so both the asset
|
||||
/// name and its download URL carry `CodeBurn.Menubar_...`.
|
||||
const WINDOWS_PRODUCT_NAME = 'CodeBurn Menubar'
|
||||
const WINDOWS_ASSET_PATTERN = /^CodeBurn\.Menubar_.+_x64_en-US\.msi$/
|
||||
const MIN_MACOS_MAJOR = 14
|
||||
const PERSISTED_CLI_PATH = join(homedir(), 'Library', 'Application Support', 'CodeBurn', 'codeburn-cli-path.v1')
|
||||
const PERSISTENT_CLI_REQUIRED_MESSAGE =
|
||||
|
|
@ -31,8 +37,45 @@ export type InstallResult = { installedPath: string; launched: boolean }
|
|||
|
||||
export type ReleaseAsset = { name: string; browser_download_url: string }
|
||||
export type ReleaseResponse = { tag_name: string; assets: ReleaseAsset[] }
|
||||
/// `zip` is the platform's primary asset: the mac bundle zip, or the Windows .msi.
|
||||
export type ResolvedAssets = { release: ReleaseResponse; zip: ReleaseAsset; checksum: ReleaseAsset }
|
||||
export type InstallOptions = { force?: boolean; cliVersion?: string }
|
||||
export type InstallOptions = {
|
||||
force?: boolean
|
||||
cliVersion?: string
|
||||
platform?: string
|
||||
windows?: WindowsInstallHooks
|
||||
}
|
||||
|
||||
/// What differs per platform between the mac and Windows installs: which release tag holds the
|
||||
/// build, and which asset in it is the installable. Everything downstream - versioned URL first,
|
||||
/// release-API scan as fallback, retrying download, checksum verify - is shared.
|
||||
export type ReleaseSpec = {
|
||||
tagPrefix: string
|
||||
assetPattern: RegExp
|
||||
assetName: (version: string) => string
|
||||
missingAsset: (tag: string) => string
|
||||
noRelease: string
|
||||
}
|
||||
|
||||
const MAC_RELEASE: ReleaseSpec = {
|
||||
tagPrefix: 'mac-v',
|
||||
assetPattern: VERSIONED_ASSET_PATTERN,
|
||||
assetName: version => `CodeBurnMenubar-v${version}.zip`,
|
||||
missingAsset: tag =>
|
||||
`No ${APP_BUNDLE_NAME} versioned zip found in release ${tag}. ` +
|
||||
`Check https://github.com/getagentseal/codeburn/releases.`,
|
||||
noRelease: 'No mac-v* release with a CodeBurnMenubar-v*.zip and checksum was found.',
|
||||
}
|
||||
|
||||
export const WINDOWS_RELEASE: ReleaseSpec = {
|
||||
tagPrefix: 'windows-v',
|
||||
assetPattern: WINDOWS_ASSET_PATTERN,
|
||||
assetName: version => `CodeBurn.Menubar_${version}_x64_en-US.msi`,
|
||||
missingAsset: tag =>
|
||||
`No ${WINDOWS_PRODUCT_NAME} .msi found in release ${tag}. ` +
|
||||
`Check https://github.com/getagentseal/codeburn/releases.`,
|
||||
noRelease: 'No windows-v* release with a CodeBurn.Menubar_*.msi and checksum was found.',
|
||||
}
|
||||
type ProxyEnv = Partial<Record<'HTTPS_PROXY' | 'https_proxy' | 'HTTP_PROXY' | 'http_proxy' | 'NO_PROXY' | 'no_proxy', string>>
|
||||
type FetchOptions = Parameters<typeof undiciFetch>[1]
|
||||
type HeaderGetter = { get(name: string): string | null }
|
||||
|
|
@ -47,6 +90,10 @@ type FetchLikeResponse = {
|
|||
text(): Promise<string>
|
||||
}
|
||||
type FetchImpl = (url: string, options?: FetchOptions) => Promise<FetchLikeResponse>
|
||||
/// The release-API lookup reads JSON instead of streaming a body, so it takes its own narrow
|
||||
/// response shape rather than widening FetchLikeResponse for every asset download fake.
|
||||
export type ReleaseApiFetch = (url: string, options?: FetchOptions) =>
|
||||
Promise<{ ok: boolean; status: number; headers: HeaderGetter; json(): Promise<unknown> }>
|
||||
|
||||
/// Release-asset delivery (github.com -> Azure blob) occasionally returns a transient 5xx or
|
||||
/// drops the socket. Three attempts with a short exponential backoff (0.5s, then 1s) rides out
|
||||
|
|
@ -96,14 +143,9 @@ function fetchWithProxy(url: string, options: FetchOptions = {}) {
|
|||
return undiciFetch(url, dispatcher ? { ...options, dispatcher } : options)
|
||||
}
|
||||
|
||||
export function resolveMenubarReleaseAssets(release: ReleaseResponse): ResolvedAssets {
|
||||
const zip = release.assets.find(a => VERSIONED_ASSET_PATTERN.test(a.name))
|
||||
if (!zip) {
|
||||
throw new Error(
|
||||
`No ${APP_BUNDLE_NAME} versioned zip found in release ${release.tag_name}. ` +
|
||||
`Check https://github.com/getagentseal/codeburn/releases.`
|
||||
)
|
||||
}
|
||||
export function resolveMenubarReleaseAssets(release: ReleaseResponse, spec: ReleaseSpec = MAC_RELEASE): ResolvedAssets {
|
||||
const zip = release.assets.find(a => spec.assetPattern.test(a.name))
|
||||
if (!zip) throw new Error(spec.missingAsset(release.tag_name))
|
||||
const checksum = release.assets.find(a => a.name === `${zip.name}.sha256`)
|
||||
if (!checksum) {
|
||||
throw new Error(`Missing checksum asset ${zip.name}.sha256 in release ${release.tag_name}.`)
|
||||
|
|
@ -111,28 +153,28 @@ export function resolveMenubarReleaseAssets(release: ReleaseResponse): ResolvedA
|
|||
return { release, zip, checksum }
|
||||
}
|
||||
|
||||
export function resolveLatestMenubarReleaseAssets(releases: ReleaseResponse[]): ResolvedAssets {
|
||||
export function resolveLatestMenubarReleaseAssets(releases: ReleaseResponse[], spec: ReleaseSpec = MAC_RELEASE): ResolvedAssets {
|
||||
for (const release of releases) {
|
||||
if (!release.tag_name.startsWith('mac-v')) continue
|
||||
if (!release.tag_name.startsWith(spec.tagPrefix)) continue
|
||||
try {
|
||||
return resolveMenubarReleaseAssets(release)
|
||||
return resolveMenubarReleaseAssets(release, spec)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
}
|
||||
throw new Error('No mac-v* release with a CodeBurnMenubar-v*.zip and checksum was found.')
|
||||
throw new Error(spec.noRelease)
|
||||
}
|
||||
|
||||
function normalizeCliVersion(cliVersion: string): string {
|
||||
return cliVersion.trim().replace(/^v/, '')
|
||||
}
|
||||
|
||||
export function resolveVersionedMenubarReleaseAssets(cliVersion: string): ResolvedAssets {
|
||||
export function resolveVersionedMenubarReleaseAssets(cliVersion: string, spec: ReleaseSpec = MAC_RELEASE): ResolvedAssets {
|
||||
const version = normalizeCliVersion(cliVersion)
|
||||
if (!version) throw new Error('Cannot resolve CodeBurn Menubar release without a CLI version.')
|
||||
|
||||
const tagName = `mac-v${version}`
|
||||
const zipName = `CodeBurnMenubar-v${version}.zip`
|
||||
const tagName = `${spec.tagPrefix}${version}`
|
||||
const zipName = spec.assetName(version)
|
||||
const checksumName = `${zipName}.sha256`
|
||||
const releaseBase = `${RELEASE_DOWNLOAD_BASE}/${tagName}`
|
||||
const zip = { name: zipName, browser_download_url: `${releaseBase}/${zipName}` }
|
||||
|
|
@ -207,8 +249,8 @@ async function sysProductVersion(): Promise<string> {
|
|||
})
|
||||
}
|
||||
|
||||
async function fetchLatestReleaseAssets(): Promise<ResolvedAssets> {
|
||||
const response = await fetchWithProxy(RELEASE_API, {
|
||||
async function fetchLatestReleaseAssets(spec: ReleaseSpec = MAC_RELEASE, fetchImpl?: ReleaseApiFetch): Promise<ResolvedAssets> {
|
||||
const response = await (fetchImpl ?? fetchWithProxy)(RELEASE_API, {
|
||||
headers: {
|
||||
'User-Agent': 'codeburn-menubar-installer',
|
||||
Accept: 'application/vnd.github+json',
|
||||
|
|
@ -218,7 +260,7 @@ async function fetchLatestReleaseAssets(): Promise<ResolvedAssets> {
|
|||
throw new HttpStatusError(formatGitHubReleaseLookupError(response.status, response.headers), response.status)
|
||||
}
|
||||
const body = await response.json() as ReleaseResponse[]
|
||||
return resolveLatestMenubarReleaseAssets(body)
|
||||
return resolveLatestMenubarReleaseAssets(body, spec)
|
||||
}
|
||||
|
||||
/// 5xx means "GitHub/the CDN is unhappy right now" and is worth another attempt. 4xx is not:
|
||||
|
|
@ -473,7 +515,165 @@ async function killRunningApp(): Promise<void> {
|
|||
}
|
||||
}
|
||||
|
||||
/// Windows mirror of the mac install below: pin the release to the CLI's own version, fall back
|
||||
/// to the newest windows-v* release, verify the sha256 before anything executes the file, hand
|
||||
/// the .msi to msiexec, then launch what it installed.
|
||||
const WINDOWS_UNINSTALL_KEYS = [
|
||||
'HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
|
||||
'HKLM\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
|
||||
'HKCU\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall',
|
||||
]
|
||||
/// 3010 is "installed, reboot to finish"; 1602 is the user closing the UAC/installer prompt.
|
||||
const MSI_EXIT_REBOOT_REQUIRED = 3010
|
||||
const MSI_EXIT_USER_CANCEL = 1602
|
||||
|
||||
export type WindowsInstallHooks = {
|
||||
fetchOptions?: AssetFetchOptions
|
||||
apiFetch?: ReleaseApiFetch
|
||||
runInstaller?: (exe: string, args: string[]) => Promise<number>
|
||||
queryRegistry?: () => Promise<string>
|
||||
launch?: (exePath: string) => void
|
||||
log?: (message: string) => void
|
||||
stagingDir?: string
|
||||
env?: NodeJS.ProcessEnv
|
||||
}
|
||||
|
||||
export type InstalledWindowsMenubar = { version: string; exePath: string }
|
||||
|
||||
/// Windows' `CreateProcess` searches the current directory before `PATH`, so spawning `msiexec`
|
||||
/// or `reg` by bare name lets anything dropped next to the CLI impersonate a system tool. Same
|
||||
/// rule the tray app follows (windows/src-tauri/src/cli.rs: system32_path).
|
||||
export function resolveSystem32Path(exe: string, env: NodeJS.ProcessEnv = process.env): string {
|
||||
const root = env.SystemRoot
|
||||
const base = root && /^[a-zA-Z]:[\\/]/.test(root) ? root.replace(/[\\/]+$/, '') : 'C:\\Windows'
|
||||
return `${base}\\System32\\${exe}`
|
||||
}
|
||||
|
||||
/// Reads `reg query ... /s` output, which prints one blank-line separated block per subkey.
|
||||
export function parseInstalledWindowsMenubar(regOutput: string): InstalledWindowsMenubar | undefined {
|
||||
for (const block of regOutput.split(/\r?\n\s*\r?\n/)) {
|
||||
const values = new Map<string, string>()
|
||||
for (const line of block.split(/\r?\n/)) {
|
||||
const match = /^\s+(.+?)\s{4}REG_\w+\s{4}(.*)$/.exec(line)
|
||||
if (match) values.set(match[1]!.trim(), match[2]!.trim())
|
||||
}
|
||||
if (values.get('DisplayName') !== WINDOWS_PRODUCT_NAME) continue
|
||||
const location = values.get('InstallLocation')
|
||||
// DisplayIcon is `<exe>[,<index>]` and points at the installed binary when there is no
|
||||
// InstallLocation to join onto.
|
||||
const icon = values.get('DisplayIcon')?.split(',')[0]?.trim()
|
||||
const exePath = location
|
||||
? `${location.replace(/[\\/]+$/, '')}\\${WINDOWS_PRODUCT_NAME}.exe`
|
||||
: icon
|
||||
if (!exePath) continue
|
||||
return { version: values.get('DisplayVersion') ?? '', exePath }
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
async function queryWindowsUninstallRegistry(env: NodeJS.ProcessEnv): Promise<string> {
|
||||
const reg = resolveSystem32Path('reg.exe', env)
|
||||
// reg exits non-zero for a hive the machine does not have; an empty block is the right answer.
|
||||
const outputs = await Promise.all(
|
||||
WINDOWS_UNINSTALL_KEYS.map(key => captureCommand(reg, ['query', key, '/s']).catch(() => '')),
|
||||
)
|
||||
return outputs.join('\n\n')
|
||||
}
|
||||
|
||||
async function runMsiexec(exe: string, args: string[]): Promise<number> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn(exe, args, { stdio: 'inherit' })
|
||||
proc.on('error', reject)
|
||||
proc.on('close', code => resolve(code ?? 1))
|
||||
})
|
||||
}
|
||||
|
||||
function launchWindowsApp(exePath: string): void {
|
||||
const proc = spawn(exePath, [], { detached: true, stdio: 'ignore' })
|
||||
proc.on('error', err => console.error(`Could not launch ${exePath}: ${err.message}`))
|
||||
proc.unref()
|
||||
}
|
||||
|
||||
async function stageWindowsInstaller(
|
||||
assets: ResolvedAssets,
|
||||
stagingDir: string,
|
||||
hooks: WindowsInstallHooks,
|
||||
log: (message: string) => void,
|
||||
): Promise<string> {
|
||||
const { zip: msi, checksum } = assets
|
||||
const msiPath = join(stagingDir, msi.name)
|
||||
log(`Downloading ${msi.name}...`)
|
||||
await downloadToFile(msi.browser_download_url, msiPath, hooks.fetchOptions)
|
||||
log('Verifying checksum...')
|
||||
await verifyChecksum(msiPath, checksum.browser_download_url, hooks.fetchOptions)
|
||||
return msiPath
|
||||
}
|
||||
|
||||
async function installWindowsMenubarApp(options: InstallOptions): Promise<InstallResult> {
|
||||
const hooks = options.windows ?? {}
|
||||
const log = hooks.log ?? console.log
|
||||
const env = hooks.env ?? process.env
|
||||
const queryRegistry = hooks.queryRegistry ?? (() => queryWindowsUninstallRegistry(env))
|
||||
const launch = hooks.launch ?? launchWindowsApp
|
||||
const cliVersion = options.cliVersion ? normalizeCliVersion(options.cliVersion) : ''
|
||||
|
||||
const installed = parseInstalledWindowsMenubar(await queryRegistry())
|
||||
if (installed && !options.force && (!cliVersion || installed.version === cliVersion)) {
|
||||
launch(installed.exePath)
|
||||
log('Launched CodeBurn Menubar.')
|
||||
return { installedPath: installed.exePath, launched: true }
|
||||
}
|
||||
|
||||
let assets: ResolvedAssets
|
||||
if (cliVersion) {
|
||||
log(`Resolving CodeBurn Menubar v${cliVersion}...`)
|
||||
assets = resolveVersionedMenubarReleaseAssets(cliVersion, WINDOWS_RELEASE)
|
||||
} else {
|
||||
log('Looking up the latest CodeBurn Menubar release...')
|
||||
assets = await fetchLatestReleaseAssets(WINDOWS_RELEASE, hooks.apiFetch)
|
||||
}
|
||||
|
||||
const stagingDir = hooks.stagingDir ?? await (async () => {
|
||||
await mkdir(getCodeburnCacheDir(), { recursive: true })
|
||||
return mkdtemp(join(getCodeburnCacheDir(), 'menubar-'))
|
||||
})()
|
||||
try {
|
||||
let msiPath: string
|
||||
try {
|
||||
msiPath = await stageWindowsInstaller(assets, stagingDir, hooks, log)
|
||||
} catch (err) {
|
||||
if (!cliVersion || !isMissingDirectAssetError(err)) throw err
|
||||
log(`CodeBurn Menubar v${cliVersion} assets were not found. Looking up the latest CodeBurn Menubar release...`)
|
||||
assets = await fetchLatestReleaseAssets(WINDOWS_RELEASE, hooks.apiFetch)
|
||||
msiPath = await stageWindowsInstaller(assets, stagingDir, hooks, log)
|
||||
}
|
||||
|
||||
log('Installing...')
|
||||
const msiexec = resolveSystem32Path('msiexec.exe', env)
|
||||
const exitCode = await (hooks.runInstaller ?? runMsiexec)(msiexec, ['/i', msiPath, '/passive', '/norestart'])
|
||||
if (exitCode === MSI_EXIT_USER_CANCEL) {
|
||||
log('Installation was cancelled; nothing was installed.')
|
||||
return { installedPath: '', launched: false }
|
||||
}
|
||||
if (exitCode !== 0 && exitCode !== MSI_EXIT_REBOOT_REQUIRED) {
|
||||
throw new Error(`msiexec exited with ${exitCode} while installing ${assets.zip.name}.`)
|
||||
}
|
||||
if (exitCode === MSI_EXIT_REBOOT_REQUIRED) log('Windows wants a restart to finish the install.')
|
||||
|
||||
const nowInstalled = parseInstalledWindowsMenubar(await queryRegistry())
|
||||
if (!nowInstalled) {
|
||||
throw new Error('CodeBurn Menubar installed, but it was not found in the uninstall registry; start it from the Start menu.')
|
||||
}
|
||||
launch(nowInstalled.exePath)
|
||||
log('Launched CodeBurn Menubar.')
|
||||
return { installedPath: nowInstalled.exePath, launched: true }
|
||||
} finally {
|
||||
if (!hooks.stagingDir) await rm(stagingDir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
export async function installMenubarApp(options: InstallOptions = {}): Promise<InstallResult> {
|
||||
if ((options.platform ?? platform()) === 'win32') return installWindowsMenubarApp(options)
|
||||
await ensureSupportedPlatform()
|
||||
await persistCodeburnPath()
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import stripAnsi from 'strip-ansi'
|
|||
|
||||
import { codexCredits } from './codex-credits.js'
|
||||
import { formatCost, formatTokens } from './format.js'
|
||||
import { sanitizeModelForDisplay } from './models.js'
|
||||
import { getProvider } from './providers/index.js'
|
||||
import { CATEGORY_LABELS, type ProjectSummary, type TaskCategory } from './types.js'
|
||||
|
||||
|
|
@ -153,7 +154,9 @@ export async function aggregateModels(projects: ProjectSummary[], opts: Aggregat
|
|||
const p = await getProvider(name)
|
||||
const entry = {
|
||||
displayName: p?.displayName ?? name,
|
||||
formatModel: p ? (m: string) => p.modelDisplayName(m) : (m: string) => m,
|
||||
formatModel: p
|
||||
? (m: string) => sanitizeModelForDisplay(p.modelDisplayName(m))
|
||||
: sanitizeModelForDisplay,
|
||||
}
|
||||
providerCache.set(name, entry)
|
||||
return entry
|
||||
|
|
|
|||
|
|
@ -307,7 +307,7 @@ const BUILTIN_ALIASES: Record<string, string> = {
|
|||
// reports that quote literal slugs (e.g. forum.cursor.com/t/154933).
|
||||
'claude-4-sonnet': 'claude-sonnet-4',
|
||||
'claude-4-sonnet-1m': 'claude-sonnet-4',
|
||||
'claude-4-sonnet-thinking': 'claude-sonnet-4-5',
|
||||
'claude-4-sonnet-thinking': 'claude-sonnet-4',
|
||||
'claude-4.5-sonnet': 'claude-sonnet-4-5',
|
||||
'claude-4.5-sonnet-thinking': 'claude-sonnet-4-5',
|
||||
'claude-4.6-sonnet': 'claude-sonnet-4-6',
|
||||
|
|
@ -791,6 +791,11 @@ function shouldWarnAboutUnknownModel(name: string): boolean {
|
|||
return true
|
||||
}
|
||||
|
||||
/** Render provider-supplied model IDs without terminal control characters. */
|
||||
export function sanitizeModelForDisplay(model: string): string {
|
||||
return model.replace(/[\x00-\x1F\x7F-\x9F]/g, '?').slice(0, 200)
|
||||
}
|
||||
|
||||
export function calculateCost(
|
||||
model: string,
|
||||
inputTokens: number,
|
||||
|
|
@ -808,7 +813,7 @@ export function calculateCost(
|
|||
// Strip control characters and cap length: model names come from JSONL
|
||||
// payloads written by external tools, so a hostile or corrupt file
|
||||
// could embed terminal escape sequences here.
|
||||
const safeName = model.replace(/[\x00-\x1F\x7F-\x9F]/g, '?').slice(0, 200)
|
||||
const safeName = sanitizeModelForDisplay(model)
|
||||
const aliasHint = `Map it with: codeburn model-alias "${safeName}" <known-model>, or track local-model savings with: codeburn model-savings "${safeName}" <baseline-model>`
|
||||
process.stderr.write(
|
||||
`codeburn: no pricing data for model "${safeName}" — costs for this model will show $0. ` +
|
||||
|
|
|
|||
783
src/optimize.ts
783
src/optimize.ts
File diff suppressed because it is too large
Load diff
|
|
@ -557,7 +557,7 @@ function extractObjectFields(
|
|||
return captured
|
||||
}
|
||||
|
||||
const LARGE_ROOT_FIELDS = ['type', 'timestamp', 'sessionId', 'cwd', 'gitBranch', 'attachment', 'message'] as const
|
||||
const LARGE_ROOT_FIELDS = ['type', 'timestamp', 'sessionId', 'cwd', 'gitBranch', 'attachment', 'message', 'isSidechain'] as const
|
||||
const LARGE_ASSISTANT_MESSAGE_FIELDS = ['model', 'usage', 'id', 'content'] as const
|
||||
|
||||
function parseLargeJsonl(line: string | Buffer): JournalEntry | null {
|
||||
|
|
@ -571,6 +571,9 @@ function parseLargeJsonl(line: string | Buffer): JournalEntry | null {
|
|||
if (!type) return null
|
||||
|
||||
const entry: JournalEntry = { type }
|
||||
if (root['isSidechain']?.kind === 'scalar' && source.slice(root['isSidechain'].start, root['isSidechain'].end) === 'true') {
|
||||
entry.isSidechain = true
|
||||
}
|
||||
const timestamp = readJsonString(source, root['timestamp'])
|
||||
const sessionId = readJsonString(source, root['sessionId'])
|
||||
const cwd = readJsonString(source, root['cwd'])
|
||||
|
|
@ -2350,6 +2353,7 @@ async function scanProjectDirs(
|
|||
// on a resumed session) and derive the agent id from the `agent-<agentId>`
|
||||
// filename. A sidechain whose parent id was never captured stays standalone.
|
||||
if (cachedFile.isSidechain) {
|
||||
session.isSidechain = true
|
||||
if (cachedFile.parentSessionId) session.parentSessionId = cachedFile.parentSessionId
|
||||
session.agentId = sessionId.startsWith('agent-') ? sessionId.slice('agent-'.length) : sessionId
|
||||
}
|
||||
|
|
@ -3271,8 +3275,9 @@ async function parseProviderSources(
|
|||
}
|
||||
}
|
||||
|
||||
// 90-day age-out for durable providers: remove entries whose newest call is
|
||||
// older than 90 days so the cache doesn't grow unboundedly over time.
|
||||
// 90-day age-out for durable providers: prune only orphaned entries whose
|
||||
// newest call is older than 90 days. Still-discovered sources remain live
|
||||
// regardless of age and keep their persisted fingerprint for reuse.
|
||||
if (!readOnly && provider.durableSources) {
|
||||
const cutoffMs = Date.now() - 90 * 24 * 60 * 60 * 1000
|
||||
for (const [cachedPath, cachedFile] of Object.entries(section.files)) {
|
||||
|
|
@ -3281,7 +3286,7 @@ async function parseProviderSources(
|
|||
.map(c => new Date(c.timestamp).getTime())
|
||||
.filter(ts => !isNaN(ts))
|
||||
.reduce((max, ts) => Math.max(max, ts), 0)
|
||||
if (newestTs > 0 && newestTs < cutoffMs) {
|
||||
if (!allDiscoveredFiles.has(cachedPath) && newestTs > 0 && newestTs < cutoffMs) {
|
||||
delete section.files[cachedPath]
|
||||
markCacheDirty(diskCache, providerName, cachedPath)
|
||||
}
|
||||
|
|
@ -3574,6 +3579,7 @@ function carryLinkageFields(rebuilt: SessionSummary, original: SessionSummary):
|
|||
if (original.prLinks?.length) rebuilt.prLinks = original.prLinks
|
||||
if (original.prAttributionSource) rebuilt.prAttributionSource = original.prAttributionSource
|
||||
if (original.workingDirectory) rebuilt.workingDirectory = original.workingDirectory
|
||||
if (original.isSidechain) rebuilt.isSidechain = true
|
||||
// prRefsAtRangeStart is NOT copied here: a narrower slice needs it recomputed at
|
||||
// the new boundary (see recomputeRangeStartPrRefs), not the wide range's value.
|
||||
if (original.parentSessionId) rebuilt.parentSessionId = original.parentSessionId
|
||||
|
|
|
|||
591
src/providers/dsh.ts
Normal file
591
src/providers/dsh.ts
Normal file
|
|
@ -0,0 +1,591 @@
|
|||
import { open, readdir, readFile, stat } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { homedir } from 'os'
|
||||
import zlib from 'zlib'
|
||||
|
||||
import { MAX_SESSION_FILE_BYTES, readSessionFile } from '../fs-utils.js'
|
||||
import { calculateCost, getShortModelName } from '../models.js'
|
||||
import { extractBashCommands } from '../bash-utils.js'
|
||||
import type { ProbeRoot, Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js'
|
||||
|
||||
// DeepSeek Harness (dsh) stores one session per directory:
|
||||
// <DSH_HOME|~/.dsh>/sessions/<encoded-cwd>/session-<uuid>/session.jsonl.zstd
|
||||
// (or an uncompressed session.jsonl when compression=none). The .zstd file is
|
||||
// a concatenation of INDEPENDENT zstd frames — one per appended event batch —
|
||||
// so node:zlib's one-shot zstdDecompressSync (which decodes a single frame)
|
||||
// must be driven frame-by-frame behind a structural frame-boundary scan. The
|
||||
// scan below is a port of scanZstdFrames from the official
|
||||
// @deepseek-ai/dsh-session-persistence-jsonl package, which is third-party code
|
||||
// under its own license - see THIRD_PARTY_NOTICES.md.
|
||||
|
||||
// zstd landed in node:zlib in 22.15 / 23.8; the package floor is lower, so the
|
||||
// provider degrades with a notice instead of assuming the export exists.
|
||||
const zstdDecompress = (zlib as { zstdDecompressSync?: (buf: Buffer, opts?: { maxOutputLength?: number }) => Buffer }).zstdDecompressSync
|
||||
|
||||
const ZSTD_MAGIC = 0xfd2fb528
|
||||
|
||||
// SESSION_FORMAT_VERSION in @deepseek-ai/dsh-session. DSH refuses to load a log
|
||||
// stamped with any other version, and a bump means an event's meaning changed,
|
||||
// so a foreign version is skipped rather than read with today's assumptions.
|
||||
// A zstd frame's declared content size is attacker-controlled, so a few KB of
|
||||
// crafted input can expand to gigabytes. Every decode is capped: no single
|
||||
// frame may exceed this, and no file may decode to more than it would have been
|
||||
// allowed to occupy uncompressed (MAX_SESSION_FILE_BYTES). Overflow throws, and
|
||||
// the caller skips the WHOLE file rather than counting the frames it got to.
|
||||
const MAX_FRAME_DECODED_BYTES = 64 * 1024 * 1024
|
||||
|
||||
const SESSION_FORMAT_VERSION = 0
|
||||
|
||||
const MIN_REASONABLE_TIMESTAMP_MS = 1_000_000_000_000
|
||||
|
||||
// Discovery walks every session, so a per-file notice would repeat once per
|
||||
// log; each distinct message is worth saying exactly once.
|
||||
const noticed = new Set<string>()
|
||||
|
||||
function notice(message: string): void {
|
||||
if (noticed.has(message)) return
|
||||
noticed.add(message)
|
||||
process.stderr.write(message)
|
||||
}
|
||||
|
||||
type ZstdFrame = { start: number; end: number }
|
||||
|
||||
// Locate complete frames without decompressing their blocks. An EOF inside the
|
||||
// final frame (a torn append from a crashed writer) returns its start so the
|
||||
// caller can ignore the tail; invalid complete structure rejects.
|
||||
function scanZstdFrames(buffer: Buffer, maxFrames = Number.POSITIVE_INFINITY): { frames: ZstdFrame[]; tornStart?: number } {
|
||||
const frames: ZstdFrame[] = []
|
||||
let offset = 0
|
||||
while (offset < buffer.length) {
|
||||
const start = offset
|
||||
if (buffer.length - offset < 4) return { frames, tornStart: start }
|
||||
if (buffer.readUInt32LE(offset) !== ZSTD_MAGIC) {
|
||||
throw new Error(`invalid zstd frame magic at byte ${offset}`)
|
||||
}
|
||||
offset += 4
|
||||
if (offset === buffer.length) return { frames, tornStart: start }
|
||||
const descriptor = buffer.readUInt8(offset)!
|
||||
offset += 1
|
||||
if ((descriptor & 24) !== 0) throw new Error(`reserved frame-header bit at byte ${offset - 1}`)
|
||||
const contentSizeFlag = descriptor >>> 6
|
||||
const singleSegment = (descriptor & 32) !== 0
|
||||
const checksum = (descriptor & 4) !== 0
|
||||
const dictionaryFlag = descriptor & 3
|
||||
const dictionaryBytes = dictionaryFlag === 3 ? 4 : dictionaryFlag
|
||||
const contentSizeBytes = contentSizeFlag === 0 ? (singleSegment ? 1 : 0) : 1 << contentSizeFlag
|
||||
const remainingHeaderBytes = (singleSegment ? 0 : 1) + dictionaryBytes + contentSizeBytes
|
||||
if (buffer.length - offset < remainingHeaderBytes) return { frames, tornStart: start }
|
||||
offset += remainingHeaderBytes
|
||||
for (;;) {
|
||||
if (buffer.length - offset < 3) return { frames, tornStart: start }
|
||||
const blockHeader = buffer.readUIntLE(offset, 3)
|
||||
offset += 3
|
||||
const lastBlock = (blockHeader & 1) !== 0
|
||||
const blockType = (blockHeader >>> 1) & 3
|
||||
const blockSize = blockHeader >>> 3
|
||||
if (blockType === 3) throw new Error(`reserved block type at byte ${offset - 3}`)
|
||||
const payloadBytes = blockType === 1 ? 1 : blockSize
|
||||
if (buffer.length - offset < payloadBytes) return { frames, tornStart: start }
|
||||
offset += payloadBytes
|
||||
if (lastBlock) break
|
||||
}
|
||||
if (checksum) {
|
||||
if (buffer.length - offset < 4) return { frames, tornStart: start }
|
||||
offset += 4
|
||||
}
|
||||
frames.push({ start, end: offset })
|
||||
if (frames.length === maxFrames) return { frames }
|
||||
}
|
||||
return { frames }
|
||||
}
|
||||
|
||||
type DshUsage = {
|
||||
inputTokens?: number
|
||||
outputTokens?: number
|
||||
cacheReadTokens?: number
|
||||
cacheWriteTokens?: number
|
||||
reasoningTokens?: number
|
||||
}
|
||||
|
||||
type DshEvent = {
|
||||
type?: string
|
||||
seq?: number
|
||||
time?: number
|
||||
// Session header fields live at the top level of the first event.
|
||||
version?: number
|
||||
id?: string
|
||||
cwd?: string
|
||||
createdAt?: number
|
||||
parentSession?: string
|
||||
seedLength?: number
|
||||
data?: {
|
||||
turn?: number
|
||||
step?: number
|
||||
content?: Array<{ type?: string; text?: string }>
|
||||
// `user/message` carries the message author: a real prompt is
|
||||
// `{ kind: 'user' }`, agent-injected context is `{ kind: 'plugin' }`.
|
||||
source?: { kind?: string }
|
||||
header?: { config?: { model?: string; provider?: string } }
|
||||
message?: { source?: { kind?: string; model?: string; provider?: string } }
|
||||
chunk?: { type?: string; usage?: DshUsage }
|
||||
usage?: DshUsage
|
||||
name?: string
|
||||
arguments?: string
|
||||
}
|
||||
}
|
||||
|
||||
type StepBucket = {
|
||||
usage: DshUsage
|
||||
// A usage report from assistant/message is the final value for its
|
||||
// (turn, step) and replaces an earlier assistant/chunk sample (the two are
|
||||
// adjacent reports of the same API call, per dsh-token-meter's usage
|
||||
// projection). Time follows the winning report.
|
||||
final: boolean
|
||||
time?: number
|
||||
// Model that produced this step: the reporting assistant/message's own
|
||||
// `message.source` when it names one, else the most recent request/header
|
||||
// config (a header can change the model mid-turn between steps).
|
||||
model: string
|
||||
tools: string[]
|
||||
skills: string[]
|
||||
bashCommands: string[]
|
||||
}
|
||||
|
||||
const toolNameMap: Record<string, string> = {
|
||||
bash: 'Bash',
|
||||
pwsh: 'Bash',
|
||||
read: 'Read',
|
||||
write: 'Write',
|
||||
edit: 'Edit',
|
||||
str_replace_editor: 'Edit',
|
||||
glob: 'Glob',
|
||||
grep: 'Grep',
|
||||
todo_write: 'TodoWrite',
|
||||
todo: 'TodoWrite',
|
||||
web_search: 'WebSearch',
|
||||
skill: 'Skill',
|
||||
agent: 'Agent',
|
||||
ask_user_question: 'AskUserQuestion',
|
||||
}
|
||||
|
||||
function mapToolName(raw: string): string {
|
||||
return toolNameMap[raw] ?? raw
|
||||
}
|
||||
|
||||
// Usage fields are whatever the JSON held. A string or array would flow
|
||||
// straight into the global token totals and the persisted cache, where
|
||||
// `0 + [1, 2]` silently becomes "01,2". Same semantics as copilot.ts.
|
||||
function numberOrZero(raw: unknown): number {
|
||||
return typeof raw === 'number' && Number.isFinite(raw) && raw > 0 ? raw : 0
|
||||
}
|
||||
|
||||
// A log stamped with a version this parser was not written against is skipped
|
||||
// whole: a bump means an event's meaning changed, so reading it with today's
|
||||
// assumptions would report confident wrong numbers.
|
||||
function isReadableVersion(header: DshEvent): boolean {
|
||||
if (header.version === SESSION_FORMAT_VERSION) return true
|
||||
// Keyed on the version, not the path: a DSH upgrade makes EVERY session
|
||||
// unreadable at once, and one line per session log is noise, not a report.
|
||||
notice(`codeburn: skipping DSH sessions written in session format version ${String(header.version)}; upgrade codeburn.\n`)
|
||||
return false
|
||||
}
|
||||
|
||||
// DSH writes epoch milliseconds; promote a seconds-resolution value and reject
|
||||
// what stays implausible, matching the guard cline-cli.ts uses on the hazard.
|
||||
function isoTimestamp(value: number | undefined, fallback: string): string {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return fallback
|
||||
const ms = value < MIN_REASONABLE_TIMESTAMP_MS ? value * 1000 : value
|
||||
const date = new Date(ms)
|
||||
if (Number.isNaN(date.getTime()) || date.getTime() < MIN_REASONABLE_TIMESTAMP_MS) return fallback
|
||||
return date.toISOString()
|
||||
}
|
||||
|
||||
function getDshHome(override?: string): string {
|
||||
// An empty-string DSH_HOME is treated as unset.
|
||||
return override ?? (process.env['DSH_HOME'] || undefined) ?? join(homedir(), '.dsh')
|
||||
}
|
||||
|
||||
// DSH writes native-platform paths into the header (backslashes on Windows);
|
||||
// split on both separators so discovery is correct on any host.
|
||||
function projectFromCwd(cwd: string, fallback: string): string {
|
||||
const segments = cwd.split(/[\\/]/).filter(Boolean)
|
||||
return segments[segments.length - 1] ?? fallback
|
||||
}
|
||||
|
||||
// Decode every complete frame and yield its JSONL lines. A torn final frame is
|
||||
// ignored; a structurally corrupt file, or one that decodes past `budget`,
|
||||
// throws for the caller to report. Exported for the decode-budget test.
|
||||
export function* readZstdLines(
|
||||
buffer: Buffer,
|
||||
maxFrames = Number.POSITIVE_INFINITY,
|
||||
budget = MAX_SESSION_FILE_BYTES,
|
||||
): Generator<string> {
|
||||
const { frames } = scanZstdFrames(buffer, maxFrames)
|
||||
let remaining = budget
|
||||
for (const frame of frames) {
|
||||
if (remaining <= 0) throw new Error(`decodes past the ${budget}-byte cap`)
|
||||
// node throws ERR_BUFFER_TOO_LARGE without allocating past the cap, so the
|
||||
// per-frame limit doubles as the running budget for the frames after it.
|
||||
const decoded = zstdDecompress!(buffer.subarray(frame.start, frame.end), {
|
||||
maxOutputLength: Math.min(remaining, MAX_FRAME_DECODED_BYTES),
|
||||
})
|
||||
remaining -= decoded.length
|
||||
for (const line of decoded.toString('utf-8').split('\n')) {
|
||||
if (line.trim()) yield line
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function readEventLines(filePath: string): Promise<string[] | null> {
|
||||
if (filePath.endsWith('.zstd')) {
|
||||
if (!zstdDecompress) {
|
||||
notice('codeburn: DSH sessions need Node >= 22.15 (zstd support); skipping DSH usage.\n')
|
||||
return null
|
||||
}
|
||||
let buffer: Buffer
|
||||
try {
|
||||
// The whole log is buffered to scan its frames, so it needs the same
|
||||
// oversize guard readSessionFile applies to the uncompressed variant.
|
||||
const size = (await stat(filePath)).size
|
||||
if (size > MAX_SESSION_FILE_BYTES) {
|
||||
notice(`codeburn: skipped oversize DSH session log ${filePath} (${size} bytes)\n`)
|
||||
return null
|
||||
}
|
||||
buffer = await readFile(filePath)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
return [...readZstdLines(buffer)]
|
||||
} catch (err) {
|
||||
notice(`codeburn: skipped corrupt DSH session log ${filePath}: ${err instanceof Error ? err.message : err}\n`)
|
||||
return null
|
||||
}
|
||||
}
|
||||
const content = await readSessionFile(filePath)
|
||||
if (content === null) return null
|
||||
return content.split('\n').filter(l => l.trim())
|
||||
}
|
||||
|
||||
// Cheap discovery probe: decompress ONLY the first frame (the session header
|
||||
// batch) instead of the whole log. The header frame is tiny, so a bounded head
|
||||
// read almost always contains it; fall back to a full read when it does not.
|
||||
async function readSessionHeader(filePath: string): Promise<DshEvent | null> {
|
||||
const firstLine = async (): Promise<string | null> => {
|
||||
if (filePath.endsWith('.zstd')) {
|
||||
if (!zstdDecompress) return null
|
||||
let head: Buffer
|
||||
try {
|
||||
const handle = await open(filePath, 'r')
|
||||
try {
|
||||
const size = (await handle.stat()).size
|
||||
const length = Math.min(size, 256 * 1024)
|
||||
head = Buffer.alloc(length)
|
||||
await handle.read(head, 0, length, 0)
|
||||
} finally {
|
||||
await handle.close()
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
let { frames } = scanZstdFrames(head, 1)
|
||||
if (frames.length === 0) {
|
||||
// Head read did not cover one full frame; take the whole file. A fork's
|
||||
// first batch carries the whole inherited seed, so this is reachable on
|
||||
// a real log and needs the same oversize guard as the parse read.
|
||||
try {
|
||||
if ((await stat(filePath)).size > MAX_SESSION_FILE_BYTES) return null
|
||||
const full = await readFile(filePath)
|
||||
frames = scanZstdFrames(full, 1).frames
|
||||
if (frames.length === 0) return null
|
||||
head = full
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
const text = zstdDecompress(head.subarray(frames[0]!.start, frames[0]!.end), {
|
||||
maxOutputLength: MAX_FRAME_DECODED_BYTES,
|
||||
}).toString('utf-8')
|
||||
return text.split('\n').find(l => l.trim()) ?? null
|
||||
}
|
||||
const content = await readSessionFile(filePath)
|
||||
return content?.split('\n').find(l => l.trim()) ?? null
|
||||
}
|
||||
|
||||
try {
|
||||
const line = await firstLine()
|
||||
if (!line) return null
|
||||
const event = JSON.parse(line) as DshEvent
|
||||
if (event.type !== 'session') return null
|
||||
return isReadableVersion(event) ? event : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
async function discoverSessionsInDir(sessionsDir: string): Promise<SessionSource[]> {
|
||||
const sources: SessionSource[] = []
|
||||
|
||||
let projectDirs: string[]
|
||||
try {
|
||||
projectDirs = await readdir(sessionsDir)
|
||||
} catch {
|
||||
return sources
|
||||
}
|
||||
|
||||
for (const dirName of projectDirs) {
|
||||
const dirPath = join(sessionsDir, dirName)
|
||||
const dirStat = await stat(dirPath).catch(() => null)
|
||||
if (!dirStat?.isDirectory()) continue
|
||||
|
||||
let sessionDirs: string[]
|
||||
try {
|
||||
sessionDirs = await readdir(dirPath)
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
for (const sessionDir of sessionDirs) {
|
||||
const sessionPath = join(dirPath, sessionDir)
|
||||
const sessionStat = await stat(sessionPath).catch(() => null)
|
||||
if (!sessionStat?.isDirectory()) continue
|
||||
|
||||
// Compressed log first; the uncompressed variant exists when
|
||||
// compression=none. Never both for the same session.
|
||||
let filePath: string | null = null
|
||||
for (const name of ['session.jsonl.zstd', 'session.jsonl']) {
|
||||
const candidate = join(sessionPath, name)
|
||||
const fileStat = await stat(candidate).catch(() => null)
|
||||
if (fileStat?.isFile()) {
|
||||
filePath = candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
if (!filePath) continue
|
||||
|
||||
const header = await readSessionHeader(filePath)
|
||||
if (!header) continue
|
||||
|
||||
const cwd = typeof header.cwd === 'string' && header.cwd.trim() ? header.cwd : dirName
|
||||
sources.push({ path: filePath, project: projectFromCwd(cwd, dirName), provider: 'dsh' })
|
||||
}
|
||||
}
|
||||
|
||||
return sources
|
||||
}
|
||||
|
||||
function parseToolArguments(raw: string | undefined): Record<string, unknown> | null {
|
||||
if (!raw) return null
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as unknown
|
||||
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record<string, unknown> : null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function createParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
|
||||
return {
|
||||
async *parse(): AsyncGenerator<ParsedProviderCall> {
|
||||
const lines = await readEventLines(source.path)
|
||||
if (!lines) return
|
||||
|
||||
let sessionId = ''
|
||||
let cwd = ''
|
||||
let model = 'unknown'
|
||||
let currentTurn = 0
|
||||
let sessionStart = ''
|
||||
// Events a forked session inherited from its parent. They are a verbatim
|
||||
// copy of the parent's log, which codeburn parses as its own session, so
|
||||
// counting them here would bill the same calls twice.
|
||||
let seedLength = 0
|
||||
const userMessageByTurn = new Map<number, string>()
|
||||
const buckets = new Map<string, StepBucket>()
|
||||
|
||||
for (const line of lines) {
|
||||
let event: DshEvent
|
||||
try {
|
||||
event = JSON.parse(line) as DshEvent
|
||||
} catch {
|
||||
continue
|
||||
}
|
||||
|
||||
if (event.type === 'session') {
|
||||
if (!isReadableVersion(event)) return
|
||||
sessionId = event.id ?? sessionId
|
||||
cwd = event.cwd ?? cwd
|
||||
sessionStart = isoTimestamp(event.createdAt, sessionStart)
|
||||
if (typeof event.parentSession === 'string' && event.parentSession && typeof event.seedLength === 'number') {
|
||||
seedLength = event.seedLength
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
if (typeof event.seq === 'number' && event.seq < seedLength) continue
|
||||
|
||||
if (event.type === 'turn/start') {
|
||||
currentTurn = event.data?.turn ?? currentTurn
|
||||
continue
|
||||
}
|
||||
|
||||
if (event.type === 'request/header') {
|
||||
// Emitted at most once per request; steps after the last header
|
||||
// inherit its config as their model.
|
||||
const headerModel = event.data?.header?.config?.model
|
||||
if (typeof headerModel === 'string' && headerModel) model = headerModel
|
||||
continue
|
||||
}
|
||||
|
||||
if (event.type === 'user/message') {
|
||||
// Plugin-injected context (runtime snapshots, skill bodies, file-change
|
||||
// notices) rides the same event type as a typed prompt; only the latter
|
||||
// is a useful preview.
|
||||
if (event.data?.source?.kind !== 'user') continue
|
||||
if (userMessageByTurn.has(currentTurn)) continue
|
||||
const texts = (event.data?.content ?? [])
|
||||
.filter(c => c.type === 'text' && typeof c.text === 'string' && c.text)
|
||||
.map(c => c.text!)
|
||||
if (texts.length > 0) userMessageByTurn.set(currentTurn, texts.join(' ').slice(0, 500))
|
||||
continue
|
||||
}
|
||||
|
||||
if (event.type === 'tool/call') {
|
||||
const turn = event.data?.turn ?? currentTurn
|
||||
const step = event.data?.step ?? 0
|
||||
const rawName = event.data?.name
|
||||
if (!rawName) continue
|
||||
const key = `${turn}:${step}`
|
||||
let bucket = buckets.get(key)
|
||||
if (!bucket) {
|
||||
bucket = { usage: {}, final: false, model, tools: [], skills: [], bashCommands: [] }
|
||||
buckets.set(key, bucket)
|
||||
}
|
||||
bucket.tools.push(mapToolName(rawName))
|
||||
const args = parseToolArguments(event.data?.arguments)
|
||||
if ((rawName === 'bash' || rawName === 'pwsh') && typeof args?.['command'] === 'string') {
|
||||
bucket.bashCommands.push(...extractBashCommands(args['command']))
|
||||
}
|
||||
if (rawName === 'skill' && typeof args?.['name'] === 'string') {
|
||||
bucket.skills.push(args['name'])
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
let usage: DshUsage | undefined
|
||||
let isFinal = false
|
||||
// The model that actually served the call, when the message records it.
|
||||
// request/header only describes the request codeburn is about to see.
|
||||
let reportedModel = model
|
||||
if (event.type === 'assistant/chunk' && event.data?.chunk?.type === 'usage') {
|
||||
usage = event.data.chunk.usage
|
||||
} else if (event.type === 'assistant/message' && event.data?.usage) {
|
||||
usage = event.data.usage
|
||||
isFinal = true
|
||||
const messageModel = event.data.message?.source?.model
|
||||
if (typeof messageModel === 'string' && messageModel) reportedModel = messageModel
|
||||
} else {
|
||||
continue
|
||||
}
|
||||
if (!usage) continue
|
||||
|
||||
const turn = event.data?.turn ?? currentTurn
|
||||
const step = event.data?.step ?? 0
|
||||
const key = `${turn}:${step}`
|
||||
let bucket = buckets.get(key)
|
||||
if (!bucket) {
|
||||
bucket = { usage: {}, final: false, model, tools: [], skills: [], bashCommands: [] }
|
||||
buckets.set(key, bucket)
|
||||
}
|
||||
// A final report replaces an earlier sample; a late sample never
|
||||
// overwrites a final one. The model snapshot follows the winning
|
||||
// report (a header can change the model mid-turn between steps).
|
||||
if (isFinal || !bucket.final) {
|
||||
bucket.usage = usage
|
||||
bucket.final = isFinal
|
||||
bucket.time = event.time
|
||||
bucket.model = reportedModel
|
||||
}
|
||||
}
|
||||
|
||||
const sortedKeys = [...buckets.keys()].sort((a, b) => {
|
||||
const [ta, sa] = a.split(':').map(Number)
|
||||
const [tb, sb] = b.split(':').map(Number)
|
||||
return ta! - tb! || sa! - sb!
|
||||
})
|
||||
|
||||
for (const key of sortedKeys) {
|
||||
const bucket = buckets.get(key)!
|
||||
const input = numberOrZero(bucket.usage.inputTokens)
|
||||
const output = numberOrZero(bucket.usage.outputTokens)
|
||||
const cacheRead = numberOrZero(bucket.usage.cacheReadTokens)
|
||||
const cacheWrite = numberOrZero(bucket.usage.cacheWriteTokens)
|
||||
const reasoning = numberOrZero(bucket.usage.reasoningTokens)
|
||||
if (input + output + cacheRead + cacheWrite + reasoning === 0) continue
|
||||
|
||||
const dedupKey = `dsh:${sessionId || source.path}:${key}`
|
||||
if (seenKeys.has(dedupKey)) continue
|
||||
seenKeys.add(dedupKey)
|
||||
|
||||
// DSH bills reasoning tokens at the output rate (same as Gemini).
|
||||
const costUSD = calculateCost(bucket.model, input, output + reasoning, cacheWrite, cacheRead, 0)
|
||||
const [turn] = key.split(':').map(Number)
|
||||
|
||||
yield {
|
||||
provider: 'dsh',
|
||||
model: bucket.model,
|
||||
inputTokens: input,
|
||||
outputTokens: output,
|
||||
cacheCreationInputTokens: cacheWrite,
|
||||
cacheReadInputTokens: cacheRead,
|
||||
cachedInputTokens: cacheRead,
|
||||
reasoningTokens: reasoning,
|
||||
webSearchRequests: 0,
|
||||
costUSD,
|
||||
tools: [...new Set(bucket.tools)],
|
||||
bashCommands: bucket.bashCommands,
|
||||
skills: bucket.skills.length > 0 ? [...new Set(bucket.skills)] : undefined,
|
||||
timestamp: isoTimestamp(bucket.time, sessionStart),
|
||||
speed: 'standard',
|
||||
deduplicationKey: dedupKey,
|
||||
userMessage: userMessageByTurn.get(turn!) ?? '',
|
||||
sessionId: sessionId || source.path,
|
||||
project: cwd ? projectFromCwd(cwd, source.project) : source.project,
|
||||
projectPath: cwd || undefined,
|
||||
workingDirectory: cwd || undefined,
|
||||
}
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function createDshProvider(dshHomeOverride?: string): Provider {
|
||||
const dshHome = getDshHome(dshHomeOverride)
|
||||
const sessionsDir = join(dshHome, 'sessions')
|
||||
|
||||
return {
|
||||
name: 'dsh',
|
||||
displayName: 'DeepSeek Harness',
|
||||
|
||||
modelDisplayName(model: string): string {
|
||||
return getShortModelName(model)
|
||||
},
|
||||
|
||||
toolDisplayName(rawTool: string): string {
|
||||
return mapToolName(rawTool)
|
||||
},
|
||||
|
||||
async probeRoots(): Promise<ProbeRoot[]> {
|
||||
return [{ path: sessionsDir, label: 'sessions' }]
|
||||
},
|
||||
|
||||
async discoverSessions(): Promise<SessionSource[]> {
|
||||
return discoverSessionsInDir(sessionsDir)
|
||||
},
|
||||
|
||||
createSessionParser(source: SessionSource, seenKeys: Set<string>): SessionParser {
|
||||
return createParser(source, seenKeys)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export const dsh = createDshProvider()
|
||||
|
|
@ -7,6 +7,7 @@ import { codex } from './codex.js'
|
|||
import { copilot } from './copilot.js'
|
||||
import { droid } from './droid.js'
|
||||
import { devin } from './devin.js'
|
||||
import { dsh } from './dsh.js'
|
||||
import { gemini } from './gemini.js'
|
||||
import { hermes } from './hermes.js'
|
||||
import { ibmBob } from './ibm-bob.js'
|
||||
|
|
@ -192,7 +193,7 @@ async function loadZed(): Promise<Provider | null> {
|
|||
}
|
||||
}
|
||||
|
||||
const coreProviders: Provider[] = [claude, cline, clineCli, codewhale, codebuff, codex, copilot, devin, droid, gemini, hermes, ibmBob, kiloCode, kiro, kimi, kimicode, lingtaiTui, mistralVibe, mux, openclaw, openclaude, openDesign, pi, omp, qwen, quickdesk, rooCode, zerostack, grok]
|
||||
const coreProviders: Provider[] = [claude, cline, clineCli, codewhale, codebuff, codex, copilot, devin, droid, dsh, gemini, hermes, ibmBob, kiloCode, kiro, kimi, kimicode, lingtaiTui, mistralVibe, mux, openclaw, openclaude, openDesign, pi, omp, qwen, quickdesk, rooCode, zerostack, grok]
|
||||
|
||||
// Lazily loaded providers, listed by name so --provider validation works even
|
||||
// when an optional module fails to load. Must stay in sync with getAllProviders.
|
||||
|
|
|
|||
|
|
@ -100,7 +100,7 @@ export type CachedFile = {
|
|||
// is re-parsed only when the file changes (fingerprint differs). Carries no
|
||||
// turns, so it contributes no usage. (issue #441 follow-up)
|
||||
failed?: boolean
|
||||
// Rich-session-capture, Claude session-level (capture-only; no report yet).
|
||||
// Rich-session-capture, Claude session-level.
|
||||
// `title` is the LAST `ai-title` entry's text; `prLinks` accumulates every
|
||||
// `pr-link` entry's URL. `isSidechain` is true when any entry is a sidechain:
|
||||
// parentUuid references an intra-file entry uuid, not another session id, so it
|
||||
|
|
@ -216,6 +216,7 @@ export const PROVIDER_ENV_VARS: Record<string, string[]> = {
|
|||
hermes: ['HERMES_HOME'],
|
||||
'lingtai-tui': ['LINGTAI_HOME', 'LINGTAI_TUI_HOME', 'LINGTAI_TUI_GLOBAL_DIR'],
|
||||
droid: ['FACTORY_DIR'],
|
||||
dsh: ['DSH_HOME'],
|
||||
cursor: ['CODEBURN_CURSOR_MAX_BUBBLES'],
|
||||
// XDG_DATA_HOME is stale here (cursor-agent never reads it) but deliberately
|
||||
// kept: removing it would force a re-parse to fix nothing.
|
||||
|
|
@ -284,6 +285,10 @@ export const PROVIDER_PARSE_VERSIONS: Record<string, string> = {
|
|||
// input/cache rollup; this bump re-parses them so the missing tokens land.
|
||||
copilot: 'cli-shutdown-cost-v1-skills-source-provenance-v1',
|
||||
grok: 'estimated-cost-v1',
|
||||
// seed-aware-v1: the parser now skips the parent events a forked session
|
||||
// replays (double-counted before), takes the model from the reporting
|
||||
// assistant/message, and keeps agent-injected context out of the preview.
|
||||
dsh: 'seed-aware-v1',
|
||||
hermes: 'reasoning-output-accounting-v1-est-cost',
|
||||
'lingtai-tui': 'token-ledger-registry-activity-v3',
|
||||
'ibm-bob': 'worktree-project-grouping-v1',
|
||||
|
|
|
|||
34
src/session-population.ts
Normal file
34
src/session-population.ts
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
import type { ProjectSummary, SessionSummary } from './types.js'
|
||||
|
||||
/**
|
||||
* Sidechains are real usage, but they are not user-started work sessions.
|
||||
* Behavioral consumers should use this predicate or the projected project
|
||||
* view below; accounting and configuration consumers should use the originals.
|
||||
*/
|
||||
export function isUserStartedSession(session: SessionSummary): boolean {
|
||||
return session.isSidechain !== true
|
||||
}
|
||||
|
||||
export function withUserStartedSessions(project: ProjectSummary): ProjectSummary {
|
||||
const sessions = project.sessions.filter(isUserStartedSession)
|
||||
if (sessions.length === project.sessions.length) return project
|
||||
|
||||
const totalCostUSD = sessions.reduce((sum, session) => sum + session.totalCostUSD, 0)
|
||||
return {
|
||||
...project,
|
||||
sessions,
|
||||
totalCostUSD,
|
||||
totalSavingsUSD: sessions.reduce((sum, session) => sum + session.totalSavingsUSD, 0),
|
||||
totalEstimatedCostUSD: project.totalEstimatedCostUSD === undefined
|
||||
? undefined
|
||||
: sessions.reduce((sum, session) => sum + (session.totalEstimatedCostUSD ?? 0), 0),
|
||||
totalApiCalls: sessions.reduce((sum, session) => sum + session.apiCalls, 0),
|
||||
// Proxy coverage is project-scoped and applies to every retained session
|
||||
// whenever it applies to the source project.
|
||||
totalProxiedCostUSD: project.totalProxiedCostUSD > 0 ? totalCostUSD : 0,
|
||||
}
|
||||
}
|
||||
|
||||
export function userStartedProjects(projects: ProjectSummary[]): ProjectSummary[] {
|
||||
return projects.map(withUserStartedSessions)
|
||||
}
|
||||
|
|
@ -205,6 +205,11 @@ export type SessionSummary = {
|
|||
/// correlations performed after all saved sessions have been parsed.
|
||||
prAttributionSource?: 'transcript' | 'explicit-reference' | 'working-directory' | 'launcher-prompt'
|
||||
source?: SessionSourceMetadata
|
||||
/// Claude Code only: true when this record is a subagent (sidechain)
|
||||
/// transcript rather than a user-started parent session. Sidechain spend is
|
||||
/// real and remains in every cost/token/call aggregate; consumers that reason
|
||||
/// about human session populations may exclude it explicitly.
|
||||
isSidechain?: boolean
|
||||
// Claude Code only: agent type of a subagent transcript session
|
||||
// (`workflow-subagent`, `Explore`, `general-purpose`, …); undefined for
|
||||
// ordinary sessions. Drives the Claude-scoped agent-type breakdown.
|
||||
|
|
|
|||
|
|
@ -935,7 +935,7 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts:
|
|||
}
|
||||
})()
|
||||
|
||||
const optimize = opts.optimize === false ? null : await scanAndDetect(scanProjects, scanRange)
|
||||
const optimize = opts.optimize === false ? null : await scanAndDetect(scanProjects, scanRange, opts.provider)
|
||||
const granularRange = opts.daysSelection?.range ?? scanRange
|
||||
const granularHistory = opts.timeline === false ? undefined : buildGranularHistory(scanProjects, granularRange)
|
||||
return buildMenubarPayload(currentData, providers, optimize, dailyHistory, retryTax, routingWaste, breakdowns, claudeConfigs, granularHistory)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { homedir } from 'os'
|
||||
|
||||
import { EDIT_TOOLS } from './classifier.js'
|
||||
import { userStartedProjects } from './session-population.js'
|
||||
import { CATEGORY_LABELS, type ProjectSummary, type TaskCategory } from './types.js'
|
||||
|
||||
// User-side mirror of compare-stats.ts scanSelfCorrections (which scans the
|
||||
|
|
@ -44,7 +45,7 @@ export type UserCorrectionStats = {
|
|||
export function scanUserCorrections(projects: ProjectSummary[]): UserCorrectionStats {
|
||||
let corrections = 0
|
||||
let userTurns = 0
|
||||
for (const project of projects) {
|
||||
for (const project of userStartedProjects(projects)) {
|
||||
for (const session of project.sessions) {
|
||||
// A correction is a FOLLOW-UP by definition: the session's opening
|
||||
// prompt cannot be correcting this assistant, however correction-shaped
|
||||
|
|
@ -95,7 +96,7 @@ export function sessionTimeToFirstEditMs(session: ProjectSummary['sessions'][num
|
|||
|
||||
export function medianTimeToFirstEditMs(projects: ProjectSummary[]): number | null {
|
||||
const samples: number[] = []
|
||||
for (const project of projects) {
|
||||
for (const project of userStartedProjects(projects)) {
|
||||
for (const session of project.sessions) {
|
||||
const ms = sessionTimeToFirstEditMs(session)
|
||||
if (ms !== null) samples.push(ms)
|
||||
|
|
@ -140,7 +141,7 @@ export function aggregateFileChurn(projects: ProjectSummary[], limit = 15): Rewo
|
|||
type Acc = { path: string; sessions: Set<string>; edits: number }
|
||||
const byPath = new Map<string, Acc>()
|
||||
|
||||
for (const project of projects) {
|
||||
for (const project of userStartedProjects(projects)) {
|
||||
for (const session of project.sessions) {
|
||||
for (const turn of session.turns) {
|
||||
for (const call of turn.assistantCalls) {
|
||||
|
|
@ -191,7 +192,7 @@ export const MIN_ONE_SHOT_EDIT_TURNS = 5
|
|||
/// model-efficiency and the report's category one-shot figures.
|
||||
export function worstOneShotCategory(projects: ProjectSummary[], minEditTurns = MIN_ONE_SHOT_EDIT_TURNS): CategoryOneShot | null {
|
||||
const acc = new Map<string, { editTurns: number; oneShotTurns: number }>()
|
||||
for (const project of projects) {
|
||||
for (const project of userStartedProjects(projects)) {
|
||||
for (const session of project.sessions) {
|
||||
for (const [cat, d] of Object.entries(session.categoryBreakdown)) {
|
||||
const e = acc.get(cat) ?? { editTurns: 0, oneShotTurns: 0 }
|
||||
|
|
|
|||
|
|
@ -212,6 +212,13 @@ describe('model default recommendations', () => {
|
|||
|
||||
expect(recommendModelDefault(project, { now: NOW })).toBeNull()
|
||||
})
|
||||
|
||||
it('never recommends an actionable default from sidechain-only behavior', () => {
|
||||
const project = recommendationProject()
|
||||
project.sessions[0]!.isSidechain = true
|
||||
|
||||
expect(recommendModelDefault(project, { now: NOW })).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('model default apply plan', () => {
|
||||
|
|
|
|||
|
|
@ -5,13 +5,17 @@ import { join } from 'node:path'
|
|||
|
||||
import { journalPath } from '../src/act/journal.js'
|
||||
import {
|
||||
autoRevertNoEffect,
|
||||
buildActReportJson,
|
||||
buildOptimizeAppliedHeader,
|
||||
captureBaseline,
|
||||
captureBaselinesForPlans,
|
||||
computeActReport,
|
||||
renderActReport,
|
||||
} from '../src/act/report.js'
|
||||
import { formatAppliedFix, REPORT_MIN_AGE_DAYS } from '../src/act/types.js'
|
||||
import type { ActionRecord } from '../src/act/types.js'
|
||||
import type { FindingPlan } from '../src/act/plans.js'
|
||||
import type { WasteFinding } from '../src/optimize.js'
|
||||
import type { ClassifiedTurn, ProjectSummary } from '../src/types.js'
|
||||
|
||||
|
|
@ -762,3 +766,296 @@ describe('defer baseline capture', () => {
|
|||
expect(b).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('partial-action baseline capture', () => {
|
||||
it('persists the savings attributable to the local mutation, not the full mixed finding', () => {
|
||||
const finding: WasteFinding = {
|
||||
id: 'mcp-low-coverage',
|
||||
title: '2 MCP servers with low tool coverage',
|
||||
explanation: '',
|
||||
impact: 'medium',
|
||||
tokensSaved: 40_000,
|
||||
applyTokensSaved: 20_000,
|
||||
fix: { type: 'command', label: '', text: "claude mcp remove 'filesystem'" },
|
||||
apply: { kind: 'mcp-remove', servers: ['filesystem'] },
|
||||
}
|
||||
const sessions = sessionsAt(2, daysAgo(1), {
|
||||
mcpInventory: Array.from({ length: 20 }, (_, i) => `mcp__filesystem__t${i}`),
|
||||
})
|
||||
|
||||
const baseline = captureBaseline(finding, 'mcp-remove', {
|
||||
projects: [projectOf(sessions)],
|
||||
coverage: [{
|
||||
server: 'filesystem',
|
||||
toolsAvailable: 20,
|
||||
toolsInvoked: 3,
|
||||
unusedTools: Array.from({ length: 17 }, (_, i) => `mcp__filesystem__unused${i}`),
|
||||
invocations: 0,
|
||||
loadedSessions: 2,
|
||||
coverageRatio: 3 / 20,
|
||||
}],
|
||||
windowDays: 14,
|
||||
now: NOW,
|
||||
})
|
||||
|
||||
expect(baseline).toMatchObject({
|
||||
estimatedTokens: 20_000,
|
||||
sessions: 2,
|
||||
metrics: { filesystem: 6_800 },
|
||||
})
|
||||
expect(finding.tokensSaved).toBe(40_000)
|
||||
})
|
||||
|
||||
it('prices and measures only servers owned by the concrete mutation plan', () => {
|
||||
const finding: WasteFinding = {
|
||||
id: 'mcp-low-coverage',
|
||||
title: '2 MCP servers with low tool coverage',
|
||||
explanation: '',
|
||||
impact: 'medium',
|
||||
tokensSaved: 30_000,
|
||||
applyTokensSaved: 30_000,
|
||||
applyTokensSavedByServer: { filesystem: 10_000, managed: 20_000 },
|
||||
fix: { type: 'command', label: '', text: '' },
|
||||
apply: { kind: 'mcp-remove', servers: ['filesystem', 'managed'] },
|
||||
}
|
||||
const sessions = sessionsAt(2, daysAgo(1), {
|
||||
mcpInventory: [
|
||||
...Array.from({ length: 17 }, (_, i) => `mcp__filesystem__t${i}`),
|
||||
...Array.from({ length: 12 }, (_, i) => `mcp__managed__t${i}`),
|
||||
],
|
||||
})
|
||||
const coverage = [
|
||||
{
|
||||
server: 'filesystem', toolsAvailable: 20, toolsInvoked: 3,
|
||||
unusedTools: Array.from({ length: 17 }, (_, i) => `mcp__filesystem__t${i}`),
|
||||
invocations: 3, loadedSessions: 2, coverageRatio: 3 / 20,
|
||||
},
|
||||
{
|
||||
server: 'managed', toolsAvailable: 20, toolsInvoked: 8,
|
||||
unusedTools: Array.from({ length: 12 }, (_, i) => `mcp__managed__t${i}`),
|
||||
invocations: 8, loadedSessions: 2, coverageRatio: 8 / 20,
|
||||
},
|
||||
]
|
||||
|
||||
const baseline = captureBaseline(finding, 'mcp-remove', {
|
||||
projects: [projectOf(sessions)], coverage, windowDays: 14, now: NOW,
|
||||
}, ['filesystem'])
|
||||
|
||||
expect(baseline).toMatchObject({
|
||||
estimatedTokens: 10_000,
|
||||
sessions: 2,
|
||||
metrics: { filesystem: 6_800 },
|
||||
})
|
||||
expect(baseline!.metrics).not.toHaveProperty('managed')
|
||||
})
|
||||
|
||||
it('does not invent a low-coverage schema baseline when coverage is unavailable', () => {
|
||||
const finding: WasteFinding = {
|
||||
id: 'mcp-low-coverage',
|
||||
title: '1 MCP server with low tool coverage',
|
||||
explanation: '',
|
||||
impact: 'medium',
|
||||
tokensSaved: 10_000,
|
||||
applyTokensSavedByServer: { filesystem: 10_000 },
|
||||
fix: { type: 'command', label: '', text: '' },
|
||||
apply: { kind: 'mcp-remove', servers: ['filesystem'] },
|
||||
}
|
||||
|
||||
const baseline = captureBaseline(finding, 'mcp-remove', {
|
||||
projects: [projectOf(sessionsAt(2, daysAgo(1)))],
|
||||
coverage: [],
|
||||
windowDays: 14,
|
||||
now: NOW,
|
||||
}, ['filesystem'])
|
||||
|
||||
expect(baseline).toMatchObject({
|
||||
estimatedTokens: 10_000,
|
||||
metrics: { filesystem: 0 },
|
||||
})
|
||||
})
|
||||
|
||||
it('stamps a narrowed plan with only its concrete server baseline', async () => {
|
||||
const finding: WasteFinding = {
|
||||
id: 'mcp-low-coverage', title: '2 MCP servers', explanation: '', impact: 'medium',
|
||||
tokensSaved: 30_000, applyTokensSaved: 30_000,
|
||||
applyTokensSavedByServer: { filesystem: 10_000, managed: 20_000 },
|
||||
fix: { type: 'command', label: '', text: '' },
|
||||
apply: { kind: 'mcp-remove', servers: ['filesystem', 'managed'] },
|
||||
}
|
||||
const plan: FindingPlan = {
|
||||
finding,
|
||||
notes: [],
|
||||
plan: {
|
||||
kind: 'mcp-remove', description: 'Remove filesystem', changes: [],
|
||||
affectedMcpServers: ['filesystem'],
|
||||
},
|
||||
}
|
||||
const sessions = sessionsAt(2, daysAgo(1), {
|
||||
mcpInventory: [
|
||||
...Array.from({ length: 17 }, (_, i) => `mcp__filesystem__t${i}`),
|
||||
...Array.from({ length: 12 }, (_, i) => `mcp__managed__t${i}`),
|
||||
],
|
||||
})
|
||||
|
||||
await captureBaselinesForPlans([plan], {
|
||||
now: NOW,
|
||||
loadProjects: async () => [projectOf(sessions)],
|
||||
})
|
||||
|
||||
expect(plan.plan?.baseline).toMatchObject({
|
||||
estimatedTokens: 10_000,
|
||||
metrics: { filesystem: 6_800 },
|
||||
})
|
||||
expect(plan.plan?.baseline?.metrics).not.toHaveProperty('managed')
|
||||
})
|
||||
|
||||
it('does not stamp a numeric baseline onto an uncertain partial mutation', async () => {
|
||||
const finding: WasteFinding = {
|
||||
id: 'mcp-low-coverage', title: '1 MCP server', explanation: '', impact: 'medium',
|
||||
tokensSaved: 10_000, applyTokensSavedByServer: { filesystem: 10_000 },
|
||||
fix: { type: 'command', label: '', text: '' },
|
||||
apply: { kind: 'mcp-remove', servers: ['filesystem'] },
|
||||
}
|
||||
const plan: FindingPlan = {
|
||||
finding,
|
||||
notes: ['could not parse .mcp.json'],
|
||||
plan: {
|
||||
kind: 'mcp-remove', description: 'Remove filesystem', changes: [],
|
||||
affectedMcpServers: ['filesystem'], mcpSavingsUncertain: true,
|
||||
},
|
||||
}
|
||||
|
||||
await captureBaselinesForPlans([plan], {
|
||||
now: NOW,
|
||||
loadProjects: async () => [projectOf(sessionsAt(2, daysAgo(1)))],
|
||||
})
|
||||
|
||||
expect(plan.plan?.baseline).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('applied-fix verdicts', () => {
|
||||
const fixOf = async (records: ActionRecord[], projects: ProjectSummary[]) => {
|
||||
const actionsDir = await writeJournal(records)
|
||||
const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load(projects) })
|
||||
return { report, fixes: report.appliedFixes, actionsDir }
|
||||
}
|
||||
|
||||
it('calls a fix that realized its whole window estimate "worked"', async () => {
|
||||
const { fixes } = await fixOf([mcpRecord()], [projectOf(sessionsAt(20, daysAgo(5)))])
|
||||
expect(fixes).toHaveLength(1)
|
||||
expect(fixes[0]!.verdict).toBe('worked')
|
||||
expect(fixes[0]!.estimatedTokens).toBe(40_000)
|
||||
expect(fixes[0]!.realizedTokens).toBe(40_000)
|
||||
expect(fixes[0]!.undoCommand).toBe('codeburn act undo a1')
|
||||
})
|
||||
|
||||
it('holds the worked/partial boundary at the 70% ratio', async () => {
|
||||
const rec = mcpRecord({ kind: 'mcp-project-scope' })
|
||||
// 14 of 20 sessions saved = exactly 70% of the window estimate.
|
||||
const at70 = await fixOf(
|
||||
[rec],
|
||||
[projectOf([...sessionsAt(14, daysAgo(5)), ...sessionsAt(6, daysAgo(4), { mcpInventory: ['mcp__brave-search__search'] })])],
|
||||
)
|
||||
expect(at70.fixes[0]!.verdict).toBe('worked')
|
||||
|
||||
const below = await fixOf(
|
||||
[rec],
|
||||
[projectOf([...sessionsAt(13, daysAgo(5)), ...sessionsAt(7, daysAgo(4), { mcpInventory: ['mcp__brave-search__search'] })])],
|
||||
)
|
||||
expect(below.fixes[0]!.verdict).toBe('partial')
|
||||
expect(formatAppliedFix(below.fixes[0]!)).toContain('-35% vs estimate')
|
||||
})
|
||||
|
||||
it('calls a fix that realized nothing "no-effect" and offers the undo', async () => {
|
||||
const rec = mcpRecord({ kind: 'mcp-project-scope' })
|
||||
const stillLoading = sessionsAt(20, daysAgo(5), { mcpInventory: ['mcp__brave-search__search'] })
|
||||
const { fixes } = await fixOf([rec], [projectOf(stillLoading)])
|
||||
expect(fixes[0]!.verdict).toBe('no-effect')
|
||||
expect(fixes[0]!.realizedTokens).toBe(0)
|
||||
expect(formatAppliedFix(fixes[0]!)).toContain('did not help. Revert: codeburn act undo a1')
|
||||
})
|
||||
|
||||
it('treats an estimate of zero as worked only when something was realized', async () => {
|
||||
const zeroEstimate = { windowDays: 14, capturedAt: daysAgo(10), estimatedTokens: 0, sessions: 0, metrics: {} }
|
||||
const { fixes } = await fixOf(
|
||||
[mcpRecord({ baseline: { ...zeroEstimate, metrics: { 'brave-search': 2000 } } })],
|
||||
[projectOf(sessionsAt(20, daysAgo(5)))],
|
||||
)
|
||||
expect(fixes[0]!.estimatedTokens).toBe(40_000)
|
||||
expect(fixes[0]!.verdict).toBe('worked')
|
||||
})
|
||||
|
||||
it('leaves entries younger than the measurement window pending', async () => {
|
||||
const { fixes } = await fixOf([mcpRecord({ at: daysAgo(1) })], [projectOf(sessionsAt(20, daysAgo(1)))])
|
||||
expect(fixes[0]!.verdict).toBe('pending')
|
||||
expect(formatAppliedFix(fixes[0]!)).toBe(`unused-mcp (1d ago): measuring, check back after ${REPORT_MIN_AGE_DAYS} days`)
|
||||
})
|
||||
|
||||
it('keeps a user-reverted entry out of the verdicts and carries its note', async () => {
|
||||
const back = sessionsAt(20, daysAgo(5), { mcpInventory: ['mcp__brave-search__search'] })
|
||||
const { fixes } = await fixOf([mcpRecord()], [projectOf(back)])
|
||||
expect(fixes[0]!.verdict).toBe('pending')
|
||||
expect(fixes[0]!.note).toMatch(/reverted by user/)
|
||||
})
|
||||
|
||||
it('drops undone journal entries entirely', async () => {
|
||||
const rec = mcpRecord()
|
||||
const { fixes } = await fixOf(
|
||||
[rec, { ...rec, status: 'undone', undoneAt: daysAgo(2) }],
|
||||
[projectOf(sessionsAt(20, daysAgo(5)))],
|
||||
)
|
||||
expect(fixes).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('never judges a correlation-only kind, so --auto-revert can never touch it', async () => {
|
||||
const { fixes } = await fixOf([modelDefaultRecord()], [modelProject('app', '/tmp/app', 'candidate-model', 20, 19)])
|
||||
expect(fixes[0]!.verdict).toBe('pending')
|
||||
})
|
||||
})
|
||||
|
||||
describe('autoRevertNoEffect', () => {
|
||||
const noEffect = (over: Partial<ActionRecord> = {}) => ({
|
||||
...mcpRecord({ kind: 'mcp-project-scope', ...over }),
|
||||
})
|
||||
|
||||
it('undoes the no-effect entries and leaves the rest alone', async () => {
|
||||
const worked = noEffect({ id: 'keep1', findingId: 'kept' })
|
||||
const actionsDir = await writeJournal([noEffect(), worked])
|
||||
const stillLoading = sessionsAt(20, daysAgo(5), { mcpInventory: ['mcp__brave-search__search'] })
|
||||
const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([projectOf(stillLoading)]) })
|
||||
// Both are no-effect here; pin that only the ones we hand over get undone.
|
||||
const target = report.appliedFixes.filter(f => f.id === 'a1')
|
||||
const { lines, revertedIds } = await autoRevertNoEffect(target, { actionsDir })
|
||||
|
||||
expect([...revertedIds]).toEqual(['a1'])
|
||||
expect(lines).toEqual(['Reverted a1: Remove an MCP server from config'])
|
||||
const after = await computeActReport({ actionsDir, now: NOW, loadProjects: load([projectOf(stillLoading)]) })
|
||||
expect(after.appliedFixes.map(f => f.id)).toEqual(['keep1'])
|
||||
})
|
||||
|
||||
it('never auto-reverts a CLAUDE.md rule, it prints the undo command instead', async () => {
|
||||
const rec = mcpRecord({
|
||||
id: 'cm1',
|
||||
kind: 'claude-md-rule',
|
||||
findingId: 'read-edit-ratio',
|
||||
baseline: { windowDays: 14, capturedAt: daysAgo(10), estimatedTokens: 10_000, sessions: 20, metrics: { reads: 10, edits: 10 } },
|
||||
})
|
||||
const actionsDir = await writeJournal([rec])
|
||||
const sessions = sessionsAt(20, daysAgo(5), { toolBreakdown: { Edit: { calls: 10, tokens: 0 }, Read: { calls: 10, tokens: 0 } } as never })
|
||||
const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([projectOf(sessions)]) })
|
||||
|
||||
expect(report.appliedFixes[0]!.verdict).toBe('no-effect')
|
||||
const { lines, revertedIds } = await autoRevertNoEffect(report.appliedFixes, { actionsDir })
|
||||
expect(revertedIds.size).toBe(0)
|
||||
expect(lines).toEqual(['Not auto-reverted: read-edit-ratio edits a CLAUDE.md. Revert: codeburn act undo cm1'])
|
||||
})
|
||||
|
||||
it('ignores partial and pending entries', async () => {
|
||||
const actionsDir = await writeJournal([mcpRecord({ at: daysAgo(1) })])
|
||||
const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([projectOf(sessionsAt(20, daysAgo(1)))]) })
|
||||
const { lines, revertedIds } = await autoRevertNoEffect(report.appliedFixes, { actionsDir })
|
||||
expect(lines).toEqual([])
|
||||
expect(revertedIds.size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
|
|
|||
153
tests/cli-models-unpriced.test.ts
Normal file
153
tests/cli-models-unpriced.test.ts
Normal file
|
|
@ -0,0 +1,153 @@
|
|||
import { spawnSync } from 'node:child_process'
|
||||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
function runCli(args: string[], home: string, locale?: string) {
|
||||
return spawnSync(process.execPath, ['--import', 'tsx', 'src/cli.ts', ...args], {
|
||||
cwd: process.cwd(),
|
||||
env: {
|
||||
...process.env,
|
||||
HOME: home,
|
||||
USERPROFILE: home,
|
||||
CLAUDE_CONFIG_DIR: join(home, '.claude'),
|
||||
CODEBURN_CACHE_DIR: join(home, '.cache', 'codeburn'),
|
||||
TZ: 'UTC',
|
||||
...(locale ? { LANG: locale, LC_ALL: locale } : {}),
|
||||
},
|
||||
encoding: 'utf-8',
|
||||
timeout: 30_000,
|
||||
})
|
||||
}
|
||||
|
||||
function userLine(timestamp: string): string {
|
||||
return JSON.stringify({
|
||||
type: 'user', sessionId: 'unpriced-969', timestamp, cwd: '/tmp/unpriced-969',
|
||||
message: { role: 'user', content: 'inspect pricing coverage' },
|
||||
})
|
||||
}
|
||||
|
||||
function assistantLine(model: string, timestamp: string, messageId: string, input: number): string {
|
||||
return JSON.stringify({
|
||||
type: 'assistant', sessionId: 'unpriced-969', timestamp, cwd: '/tmp/unpriced-969',
|
||||
message: {
|
||||
id: messageId, type: 'message', role: 'assistant', model,
|
||||
content: [{ type: 'text', text: 'done' }],
|
||||
usage: {
|
||||
input_tokens: input, output_tokens: 100,
|
||||
cache_read_input_tokens: 0, cache_creation_input_tokens: 0,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
async function withFixture(lines: string[], run: (home: string) => void): Promise<void> {
|
||||
const home = await mkdtemp(join(tmpdir(), 'codeburn-models-unpriced-'))
|
||||
try {
|
||||
const projectDir = join(home, '.claude', 'projects', 'unpriced-969')
|
||||
await mkdir(projectDir, { recursive: true })
|
||||
await writeFile(join(projectDir, 'session.jsonl'), `${lines.join('\n')}\n`)
|
||||
run(home)
|
||||
} finally {
|
||||
await rm(home, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
const range = ['--from', '2026-05-20', '--to', '2026-05-20', '--provider', 'claude']
|
||||
|
||||
describe('codeburn models --unpriced public CLI', () => {
|
||||
it('filters before --top and returns the largest unpriced raw ID deterministically', async () => {
|
||||
await withFixture([
|
||||
userLine('2026-05-20T10:00:00.000Z'),
|
||||
assistantLine('acme/unknown-small-969', '2026-05-20T10:01:00.000Z', 'small', 1_000),
|
||||
assistantLine('claude-opus-4-6', '2026-05-20T10:02:00.000Z', 'priced', 20_000),
|
||||
assistantLine('acme/unknown-large-969', '2026-05-20T10:03:00.000Z', 'large', 9_000),
|
||||
], home => {
|
||||
const result = runCli(['models', '--unpriced', '--top', '1', '--format', 'json', ...range], home)
|
||||
expect(result.status, result.stderr).toBe(0)
|
||||
expect(JSON.parse(result.stdout)).toEqual([
|
||||
expect.objectContaining({ model: 'acme/unknown-large-969', totalTokens: 9_100 }),
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
it('orders tied unpriced rows identically across host locales', async () => {
|
||||
await withFixture([
|
||||
userLine('2026-05-20T10:00:00.000Z'),
|
||||
assistantLine('acme/z-unknown-969', '2026-05-20T10:01:00.000Z', 'z-model', 1_000),
|
||||
assistantLine('acme/ä-unknown-969', '2026-05-20T10:02:00.000Z', 'a-umlaut-model', 1_000),
|
||||
], home => {
|
||||
const args = ['models', '--unpriced', '--format', 'json', ...range]
|
||||
const english = runCli(args, home, 'en_US.UTF-8')
|
||||
const swedish = runCli(args, home, 'sv_SE.UTF-8')
|
||||
expect(english.status, english.stderr).toBe(0)
|
||||
expect(swedish.status, swedish.stderr).toBe(0)
|
||||
const models = (stdout: string) => (JSON.parse(stdout) as Array<{ model: string }>).map(row => row.model)
|
||||
expect(models(english.stdout)).toEqual(['acme/z-unknown-969', 'acme/ä-unknown-969'])
|
||||
expect(models(swedish.stdout)).toEqual(models(english.stdout))
|
||||
})
|
||||
})
|
||||
|
||||
it('honors an explicitly supplied finite --min-cost threshold', async () => {
|
||||
await withFixture([
|
||||
userLine('2026-05-20T10:00:00.000Z'),
|
||||
assistantLine('acme/unknown-zero-969', '2026-05-20T10:01:00.000Z', 'zero', 1_000),
|
||||
], home => {
|
||||
const result = runCli(['models', '--unpriced', '--min-cost', '0.01', '--format', 'json', ...range], home)
|
||||
expect(result.status, result.stderr).toBe(0)
|
||||
expect(JSON.parse(result.stdout)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
it('lists every unpriced model in table output with an actionable hint', async () => {
|
||||
await withFixture([
|
||||
userLine('2026-05-20T10:00:00.000Z'),
|
||||
assistantLine('acme/unknown-alpha-969', '2026-05-20T10:01:00.000Z', 'alpha', 1_000),
|
||||
assistantLine('acme/unknown-beta-969', '2026-05-20T10:02:00.000Z', 'beta', 2_000),
|
||||
assistantLine('claude-opus-4-6', '2026-05-20T10:03:00.000Z', 'priced', 3_000),
|
||||
], home => {
|
||||
const result = runCli(['models', '--unpriced', ...range], home)
|
||||
expect(result.status, result.stderr).toBe(0)
|
||||
expect(result.stdout).toContain('acme/unknown-alpha-969')
|
||||
expect(result.stdout).toContain('acme/unknown-beta-969')
|
||||
expect(result.stdout).not.toContain('claude-opus-4-6')
|
||||
expect(result.stdout).toContain('If a model is billed per token, map it with: codeburn model-alias "<model>" <known-model>')
|
||||
// #968: aliasing a subscription-billed model fabricates spend, so the
|
||||
// hint must never read as an unconditional instruction.
|
||||
expect(result.stdout).toContain('Subscription or flat-rate models are correctly $0.')
|
||||
expect(result.stdout).not.toContain('Fix: codeburn model-alias')
|
||||
})
|
||||
})
|
||||
|
||||
it('reports a clean period explicitly', async () => {
|
||||
const home = await mkdtemp(join(tmpdir(), 'codeburn-models-unpriced-empty-'))
|
||||
try {
|
||||
const result = runCli(['models', '--unpriced', ...range], home)
|
||||
expect(result.status, result.stderr).toBe(0)
|
||||
expect(result.stdout).toBe('No unpriced models found for the selected period.\n')
|
||||
} finally {
|
||||
await rm(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('sanitizes hostile IDs in human formats while JSON stays lossless', async () => {
|
||||
const hostile = `acme/alpha\u001b]0;forged\u0007click\u001b[31m\nforged-row-${'x'.repeat(300)}`
|
||||
await withFixture([
|
||||
userLine('2026-05-20T10:00:00.000Z'),
|
||||
assistantLine(hostile, '2026-05-20T10:01:00.000Z', 'hostile', 1_000),
|
||||
], home => {
|
||||
for (const format of ['table', 'markdown', 'csv']) {
|
||||
const result = runCli(['models', '--unpriced', '--format', format, ...range], home)
|
||||
expect(result.status, `${format}: ${result.stderr}`).toBe(0)
|
||||
expect(result.stdout).not.toMatch(/[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x9F]/)
|
||||
expect(result.stdout).not.toContain('\nforged-row-')
|
||||
expect(result.stdout).not.toContain('x'.repeat(201))
|
||||
}
|
||||
const json = runCli(['models', '--unpriced', '--format', 'json', ...range], home)
|
||||
expect(json.status, json.stderr).toBe(0)
|
||||
expect((JSON.parse(json.stdout) as Array<{ model: string }>)[0]?.model).toBe(hostile)
|
||||
})
|
||||
}, 15_000)
|
||||
})
|
||||
|
|
@ -395,6 +395,98 @@ describe('interactive terminal rendering', () => {
|
|||
expect(INTERACTIVE_RENDER_OPTIONS).toMatchObject({ alternateScreen: true })
|
||||
})
|
||||
|
||||
it.each([
|
||||
{ columns: 42, expected: '! 10: codeburn models --unpriced' },
|
||||
{ columns: 43, expected: '! 10: codeburn models --unpriced' },
|
||||
{ columns: 44, expected: '! 10: codeburn models --unpriced' },
|
||||
{ columns: 80, expected: '! 10 unpriced: codeburn models --unpriced' },
|
||||
])('shows an actionable unpriced-model command in a real $columns-column Ink frame', async ({ columns, expected }) => {
|
||||
const stdin = new PassThrough() as PassThrough & NodeJS.ReadStream
|
||||
const stdout = new PassThrough() as PassThrough & NodeJS.WriteStream
|
||||
stdin.isTTY = true
|
||||
stdin.setRawMode = () => stdin
|
||||
stdin.ref = () => stdin
|
||||
stdin.unref = () => stdin
|
||||
stdout.isTTY = true
|
||||
stdout.columns = columns
|
||||
stdout.rows = 100
|
||||
const frames: string[] = []
|
||||
stdout.on('data', chunk => frames.push(stripAnsi(String(chunk))))
|
||||
|
||||
const session = makeSession('unpriced-session', 0)
|
||||
for (let index = 0; index < 10; index++) {
|
||||
const model = `vendor-${index}/unknown-model-${index}-969`
|
||||
session.modelBreakdown[model] = {
|
||||
calls: 1,
|
||||
costUSD: 0,
|
||||
savingsUSD: 0,
|
||||
tokens: {
|
||||
inputTokens: 1_000,
|
||||
outputTokens: 100,
|
||||
cacheCreationInputTokens: 0,
|
||||
cacheReadInputTokens: 0,
|
||||
cachedInputTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
webSearchRequests: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const app = render(React.createElement(InteractiveDashboard, {
|
||||
initialProjects: [makeProject('unpriced-project', [session])],
|
||||
initialPeriod: 'today',
|
||||
initialProvider: 'all',
|
||||
refreshSeconds: 0,
|
||||
windowColumns: columns,
|
||||
}), { stdin, stdout, debug: true, interactive: true, patchConsole: false })
|
||||
onTestFinished(() => app.unmount())
|
||||
await app.waitUntilRenderFlush()
|
||||
|
||||
const frame = frames.filter(value => value.trim()).at(-1) ?? ''
|
||||
expect(frame).toContain(expected)
|
||||
})
|
||||
|
||||
it('labels claude.ai connector remediation as a manual action', async () => {
|
||||
const stdin = new PassThrough() as PassThrough & NodeJS.ReadStream
|
||||
const stdout = new PassThrough() as PassThrough & NodeJS.WriteStream
|
||||
stdin.isTTY = true
|
||||
stdin.setRawMode = () => stdin
|
||||
stdin.ref = () => stdin
|
||||
stdin.unref = () => stdin
|
||||
stdout.isTTY = true
|
||||
stdout.columns = 120
|
||||
stdout.rows = 50
|
||||
const frames: string[] = []
|
||||
stdout.on('data', chunk => frames.push(stripAnsi(String(chunk))))
|
||||
|
||||
const inventory = Array.from({ length: 20 }, (_, i) => `mcp__claude_ai_Google_Calendar__t${i}`)
|
||||
const sessions = ['connector-a', 'connector-b'].map((id, index) => {
|
||||
const session = makeSession(id, 91.337 + index)
|
||||
session.mcpInventory = inventory
|
||||
return session
|
||||
})
|
||||
const app = render(React.createElement(InteractiveDashboard, {
|
||||
initialProjects: [makeProject('connector-manual-action', sessions)],
|
||||
initialPeriod: 'today',
|
||||
initialProvider: 'all',
|
||||
refreshSeconds: 0,
|
||||
windowColumns: 120,
|
||||
}), { stdin, stdout, debug: true, interactive: true, patchConsole: false })
|
||||
onTestFinished(() => app.unmount())
|
||||
|
||||
await app.waitUntilRenderFlush()
|
||||
stdin.write('o')
|
||||
let frame = ''
|
||||
for (let i = 0; i < 100 && !frame.includes('Manual action'); i++) {
|
||||
await new Promise(resolve => setTimeout(resolve, 10))
|
||||
frame = frames.filter(value => value.trim()).at(-1) ?? ''
|
||||
}
|
||||
|
||||
expect(frame).toContain('Manual action')
|
||||
expect(frame).toContain('claude.ai Google Calendar')
|
||||
expect(frame).not.toContain('Ask Claude in the current session')
|
||||
})
|
||||
|
||||
it('leaves resize frame synchronization entirely to Ink', () => {
|
||||
const source = readFileSync(new URL('../src/dashboard.tsx', import.meta.url), 'utf8')
|
||||
expect(source).not.toContain('process.stdout.write(BSU)')
|
||||
|
|
@ -665,7 +757,12 @@ describe('InteractiveDashboard refresh', () => {
|
|||
})
|
||||
|
||||
it('keeps Optimize mounted without a loading frame when auto-refresh fires', async () => {
|
||||
vi.useFakeTimers()
|
||||
// The Optimize scan (`o`) does real fs I/O (readdir/stat) that only
|
||||
// resolves on a real event-loop turn. Leave setImmediate/nextTick/Date
|
||||
// real (Date stays real so the wait loop below can use a genuine
|
||||
// wall-clock deadline) and fake only what the 60s auto-refresh
|
||||
// interval needs.
|
||||
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] })
|
||||
const stdin = new PassThrough() as PassThrough & NodeJS.ReadStream
|
||||
const stdout = new PassThrough() as PassThrough & NodeJS.WriteStream
|
||||
stdin.isTTY = true
|
||||
|
|
@ -712,12 +809,20 @@ describe('InteractiveDashboard refresh', () => {
|
|||
expect(activityHeader.indexOf('turns') + 'turns'.length).toBe(activityRow.indexOf('12') + '12'.length)
|
||||
expect(activityHeader.indexOf('1-shot') + '1-shot'.length).toBe(activityRow.indexOf('50%') + '50%'.length)
|
||||
stdin.write('o')
|
||||
for (let i = 0; i < 20 && !frames.some(frame => frame.includes('Token estimates are approximate.')); i++) {
|
||||
// The scan does real fs work, so wait on real event-loop turns
|
||||
// (setImmediate is left un-faked above) rather than counting fake-timer
|
||||
// hops, bounded by a real wall-clock deadline.
|
||||
const realDeadline = Date.now() + 10_000
|
||||
while (!frames.some(frame => frame.includes('Savings: ~'))) {
|
||||
if (Date.now() > realDeadline) {
|
||||
throw new Error('Timed out waiting for the Optimize scan to render "Savings: ~"')
|
||||
}
|
||||
await new Promise(resolve => setImmediate(resolve))
|
||||
await vi.advanceTimersByTimeAsync(50)
|
||||
}
|
||||
const beforeRefresh = frames.filter(frame => frame.trim()).at(-1) ?? ''
|
||||
expect(beforeRefresh).toContain('CodeBurn Optimize')
|
||||
expect(beforeRefresh).toContain('Token estimates are approximate.')
|
||||
expect(beforeRefresh).toContain('CodeBurn Optimize')
|
||||
|
||||
frames.length = 0
|
||||
await vi.advanceTimersByTimeAsync(60_000)
|
||||
|
|
@ -726,10 +831,10 @@ describe('InteractiveDashboard refresh', () => {
|
|||
const frame = frames.filter(value => value.trim()).at(-1) ?? beforeRefresh
|
||||
expect(frame).toBe(beforeRefresh)
|
||||
expect(frame).toContain('CodeBurn Optimize')
|
||||
expect(frame).toContain('Token estimates are approximate.')
|
||||
expect(frame).toContain('CodeBurn Optimize')
|
||||
expect(frame).toContain('b back')
|
||||
expect(frame).not.toContain('Loading Today')
|
||||
expect(frame).not.toContain('Scanning Today')
|
||||
|
||||
})
|
||||
}, 30_000)
|
||||
})
|
||||
|
|
|
|||
35
tests/fixtures/dsh/bash-tool-turn.jsonl
vendored
Normal file
35
tests/fixtures/dsh/bash-tool-turn.jsonl
vendored
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
{"type":"session","version":0,"id":"e128dda9-ed11-4868-8266-0ef90d03c3d6","createdAt":1783352050748,"cwd":"/home/u/proj","delegationDepth":0}
|
||||
{"type":"agent/inbox/spliced","seq":0,"time":1785498771334,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo TERMINAL_OK. Then reply with the single word DONE and stop."}],"source":{"kind":"user"},"role":"user","id":"38694db6-921d-41fd-b1fb-3b0c40caf67c"}]}}
|
||||
{"type":"turn/start","seq":1,"time":1785821375023,"data":{"turn":1}}
|
||||
{"type":"agent/inbox/spliced","seq":2,"time":1785821375023,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}}
|
||||
{"type":"step/start","seq":3,"time":1783352050755,"data":{"turn":1,"step":1}}
|
||||
{"type":"user/message","seq":4,"time":1785498771360,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo TERMINAL_OK. Then reply with the single word DONE and stop."}],"source":{"kind":"user"},"role":"user","id":"38694db6-921d-41fd-b1fb-3b0c40caf67c"},"surfaceOp":"append"}
|
||||
{"type":"user/message","seq":5,"time":1785730424635,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"80474489-442a-4e98-beef-df6cd1e85870"},"surfaceOp":"append"}
|
||||
{"type":"session/title","seq":6,"time":1785730424635,"data":{"title":"Use the bash tool to","messageSeqs":[4],"source":{"kind":"fallback"}}}
|
||||
{"type":"request/header","seq":7,"time":1785498771361,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"you are dsh","tools":[]},"reason":"initial"}}
|
||||
{"type":"request/context","seq":8,"time":1785730424636,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}
|
||||
{"type":"assistant/chunk","seq":9,"time":1783352051590,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
|
||||
{"type":"reasoning-chunks","seq0":10,"time0":1783352051618,"data":{"turn":1,"step":1,"index":0,"dt":[0,1,0,0,26,30,0,0,1,0,27,1,0,0,0,86,1],"texts":["The"," user"," wants"," me"," to"," run"," a"," simple"," bash"," command"," and"," then"," reply"," with"," \"","D","ONE","\"."]}}
|
||||
{"type":"assistant/chunk","seq":28,"time":1783352051820,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}}
|
||||
{"type":"tool-call-chunks","seq0":29,"time0":1783352051820,"data":{"turn":1,"step":1,"index":1,"dt":[0,0,0,28,0,0,0,29,0,0,28,1,0,29,0,0,0,32,0,0,0,0,0,74,0,0,13,0,63,1],"id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","args":["","{","\"","command","\"",": ","\"","echo"," TER","MIN","AL","_OK","\"",", ","\"","description","\"",": ","\"","E","cho"," TER","MIN","AL","_OK"," to"," verify"," terminal"," access","\"","}"]}}
|
||||
{"type":"assistant/chunk","seq":60,"time":1783352052118,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run a simple bash command and then reply with \"DONE\"."}}}}
|
||||
{"type":"assistant/chunk","seq":61,"time":1783352052118,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","arguments":"{\"command\": \"echo TERMINAL_OK\", \"description\": \"Echo TERMINAL_OK to verify terminal access\"}"}}}}
|
||||
{"type":"assistant/chunk","seq":62,"time":1785498771373,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2877,"outputTokens":90,"cacheReadTokens":0,"reasoningTokens":18}}}}
|
||||
{"type":"assistant/chunk","seq":63,"time":1785730424645,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}}
|
||||
{"type":"assistant/message","seq":64,"time":1785730424645,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to run a simple bash command and then reply with \"DONE\"."},{"type":"tool-call","id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","arguments":"{\"command\": \"echo TERMINAL_OK\", \"description\": \"Echo TERMINAL_OK to verify terminal access\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"0a855246-fbf6-4f91-87b4-c6f1889effe7"},"usage":{"inputTokens":2877,"outputTokens":90,"cacheReadTokens":0,"reasoningTokens":18}},"sourceEventSeqs":[9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63],"surfaceOp":"append"}
|
||||
{"type":"tool/call","seq":65,"time":1785730424646,"data":{"turn":1,"step":1,"callId":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","arguments":"{\"command\": \"echo TERMINAL_OK\", \"description\": \"Echo TERMINAL_OK to verify terminal access\"}"}}
|
||||
{"type":"tool/result","seq":66,"time":1785730424665,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_fkbBRJsUrGKd1pWVc4Gn8233"},"content":[{"type":"tool-result","toolCallId":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","content":[{"type":"text","text":"TERMINAL_OK\n"}],"isError":false}],"role":"user","id":"908ca4f5-efbb-443b-9b07-acbf25edf954"}},"sourceEventSeqs":[65],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":67,"time":1785730424665,"data":{"turn":1,"step":1}}
|
||||
{"type":"step/start","seq":68,"time":1785730424676,"data":{"turn":1,"step":2}}
|
||||
{"type":"assistant/chunk","seq":69,"time":1783352052780,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
|
||||
{"type":"reasoning-chunks","seq0":70,"time0":1783352052809,"data":{"turn":1,"step":2,"index":0,"dt":[29,0,0,29,0,0,0,0,0,28,1,28,1,0,0,32,0,0,0,0,0],"texts":["The"," command"," ran"," successfully"," and"," output"," \"","TER","MIN","AL","_OK","\"."," I"," should"," now"," reply"," with"," just"," \"","D","ONE","\"."]}}
|
||||
{"type":"assistant/chunk","seq":92,"time":1783352052986,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}
|
||||
{"type":"assistant/chunk","seq":93,"time":1783352052986,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}}
|
||||
{"type":"assistant/chunk","seq":94,"time":1783352052986,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}}
|
||||
{"type":"assistant/chunk","seq":95,"time":1783352052986,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The command ran successfully and output \"TERMINAL_OK\". I should now reply with just \"DONE\"."}}}}
|
||||
{"type":"assistant/chunk","seq":96,"time":1783352052986,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}}
|
||||
{"type":"assistant/chunk","seq":97,"time":1785498771406,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":168,"outputTokens":25,"cacheReadTokens":2816,"reasoningTokens":22}}}}
|
||||
{"type":"assistant/chunk","seq":98,"time":1785730424681,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
|
||||
{"type":"assistant/message","seq":99,"time":1785730424681,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The command ran successfully and output \"TERMINAL_OK\". I should now reply with just \"DONE\"."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"aa705bf0-9b5b-4af3-9763-dbf93c98e4c4"},"usage":{"inputTokens":168,"outputTokens":25,"cacheReadTokens":2816,"reasoningTokens":22}},"sourceEventSeqs":[69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":100,"time":1785730424682,"data":{"turn":1,"step":2}}
|
||||
{"type":"turn/end","seq":101,"time":1785730424682,"data":{"turn":1,"reason":{"kind":"completed"}}}
|
||||
|
|
@ -1,10 +1,14 @@
|
|||
import { describe, it, expect } from 'vitest'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
|
||||
import {
|
||||
aggregateMcpCoverage,
|
||||
buildOptimizeJsonReport,
|
||||
classTotals,
|
||||
findingClass,
|
||||
detectMcpProfileAdvisor,
|
||||
detectMcpToolCoverage,
|
||||
estimateMcpSchemaCost,
|
||||
runOptimize,
|
||||
} from '../src/optimize.js'
|
||||
import type {
|
||||
ClassifiedTurn,
|
||||
|
|
@ -313,6 +317,23 @@ describe('estimateMcpSchemaCost', () => {
|
|||
expect(cost.cacheWriteTokens).toBe(24_000)
|
||||
})
|
||||
|
||||
it('does not count a duplicated server identifier twice', () => {
|
||||
const inventory = Array.from({ length: 20 }, (_, i) => `mcp__svc__t${i}`)
|
||||
const sessions = [makeSession({
|
||||
inventory,
|
||||
turns: [makeTurn([makeCall({ cacheCreation: 50_000 })])],
|
||||
})]
|
||||
|
||||
const cost = estimateMcpSchemaCost(
|
||||
{ svc: 20 },
|
||||
[project(sessions)],
|
||||
['svc', 'svc'],
|
||||
)
|
||||
|
||||
expect(cost.cacheWriteTokens).toBe(8_000)
|
||||
expect(cost.effectiveInputTokens).toBe(10_000)
|
||||
})
|
||||
|
||||
it('still works with the single-server signature (backward compat)', () => {
|
||||
const turns = [makeTurn([makeCall({ cacheCreation: 50_000 })])]
|
||||
const sessions = [makeSession({
|
||||
|
|
@ -333,6 +354,174 @@ describe('detectMcpToolCoverage', () => {
|
|||
expect(detectMcpToolCoverage([project([makeSession({})])])).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps claude.ai connector evidence but emits manual guidance instead of a local remove command', () => {
|
||||
const server = 'claude_ai_Netlify'
|
||||
const inventory = Array.from({ length: 20 }, (_, i) => `mcp__${server}__t${i}`)
|
||||
const turns = [makeTurn([makeCall({ cacheCreation: 50_000 })])]
|
||||
const sessions = [
|
||||
makeSession({ sessionId: 'a', inventory, turns }),
|
||||
makeSession({ sessionId: 'b', inventory, turns }),
|
||||
]
|
||||
|
||||
const finding = detectMcpToolCoverage([project(sessions)])
|
||||
|
||||
expect(finding).not.toBeNull()
|
||||
expect(finding!.tokensSaved).toBe(20_000)
|
||||
// Keep the transcript namespace as evidence, but name the connector the
|
||||
// way users actually see it in /mcp and claude.ai Settings.
|
||||
expect(finding!.explanation).toContain(server)
|
||||
expect(finding!.explanation).toContain('claude.ai Netlify')
|
||||
expect(finding!.explanation).toContain('/mcp')
|
||||
expect(finding!.explanation).toContain('claude.ai Settings > Connectors')
|
||||
expect(finding!.fix.type).toBe('paste')
|
||||
if (finding!.fix.type === 'paste') {
|
||||
expect(finding!.fix.destination).toBe('manual')
|
||||
expect(finding!.fix.text).toContain('/mcp')
|
||||
expect(finding!.fix.text).toContain('claude.ai Netlify')
|
||||
expect(finding!.fix.text).not.toContain(server)
|
||||
expect(finding!.fix.text).toContain('claude.ai Settings > Connectors')
|
||||
}
|
||||
expect(JSON.stringify(finding)).not.toContain('claude mcp remove')
|
||||
expect(finding!.apply).toBeUndefined()
|
||||
})
|
||||
|
||||
it('renders connector-only remediation as a manual action, never an Ask Claude prompt', async () => {
|
||||
const server = 'claude_ai_Google_Calendar'
|
||||
const inventory = Array.from({ length: 20 }, (_, i) => `mcp__${server}__t${i}`)
|
||||
const turns = [makeTurn([makeCall({ cacheCreation: 50_000 })])]
|
||||
const projects = [project([
|
||||
makeSession({ sessionId: 'a', inventory, turns }),
|
||||
makeSession({ sessionId: 'b', inventory, turns }),
|
||||
])]
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => undefined)
|
||||
|
||||
try {
|
||||
await runOptimize(projects, 'Test period')
|
||||
const output = log.mock.calls.map(args => args.join(' ')).join('\n')
|
||||
expect(output).toContain('Manual action')
|
||||
expect(output).toContain('claude.ai Google Calendar')
|
||||
expect(output).not.toContain('Ask Claude in the current session')
|
||||
} finally {
|
||||
log.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps the public optimize JSON envelope while marking connector guidance manual', () => {
|
||||
const server = 'claude_ai_Slack'
|
||||
const coverage = [{
|
||||
server,
|
||||
toolsAvailable: 20,
|
||||
toolsInvoked: 0,
|
||||
unusedTools: Array.from({ length: 20 }, (_, i) => `mcp__${server}__t${i}`),
|
||||
invocations: 0,
|
||||
loadedSessions: 2,
|
||||
coverageRatio: 0,
|
||||
}]
|
||||
const finding = detectMcpToolCoverage([], coverage)!
|
||||
|
||||
const report = buildOptimizeJsonReport([], 'Test period', {
|
||||
findings: [finding],
|
||||
costRate: 0,
|
||||
healthScore: 90,
|
||||
healthGrade: 'A',
|
||||
})
|
||||
|
||||
expect(report.findings[0]).toMatchObject({
|
||||
id: 'mcp-low-coverage',
|
||||
tokensSaved: 0,
|
||||
fix: {
|
||||
type: 'paste',
|
||||
destination: 'manual',
|
||||
text: expect.stringContaining('claude.ai Slack'),
|
||||
},
|
||||
})
|
||||
expect(report.findings[0]).not.toHaveProperty('apply')
|
||||
expect(report.findings[0]).not.toHaveProperty('applyTokensSaved')
|
||||
expect(report.findings[0]).not.toHaveProperty('applyTokensSavedByServer')
|
||||
expect(report.findings[0]).not.toHaveProperty('manualFollowUp')
|
||||
})
|
||||
|
||||
it('intersects globally unused tool identities with each session inventory', () => {
|
||||
const server = 'filesystem'
|
||||
const coverage = [{
|
||||
server,
|
||||
toolsAvailable: 20,
|
||||
toolsInvoked: 0,
|
||||
unusedTools: Array.from({ length: 20 }, (_, i) => `mcp__${server}__t${i}`),
|
||||
invocations: 0,
|
||||
loadedSessions: 2,
|
||||
coverageRatio: 0,
|
||||
}]
|
||||
const sessions = [5, 20].map((count, index) => makeSession({
|
||||
sessionId: `s${index}`,
|
||||
inventory: Array.from({ length: count }, (_, i) => `mcp__${server}__t${i}`),
|
||||
turns: [makeTurn([makeCall({ cacheCreation: 50_000 })])],
|
||||
}))
|
||||
|
||||
const finding = detectMcpToolCoverage([project(sessions)], coverage)
|
||||
|
||||
// 5*400 and 20*400, each at 1.25x cache-write pricing.
|
||||
expect(finding).toMatchObject({ tokensSaved: 12_500 })
|
||||
expect(finding!.applyTokensSavedByServer?.filesystem).toBe(12_500)
|
||||
})
|
||||
|
||||
it('conserves simultaneous cache-write and cache-read buckets with fractional shares', () => {
|
||||
const inventory = [
|
||||
...Array.from({ length: 15 }, (_, i) => `mcp__filesystem__t${i}`),
|
||||
...Array.from({ length: 11 }, (_, i) => `mcp__claude_ai_Slack__t${i}`),
|
||||
]
|
||||
const coverage: McpServerCoverage[] = [
|
||||
{
|
||||
server: 'filesystem', toolsAvailable: 15, toolsInvoked: 0,
|
||||
unusedTools: inventory.slice(0, 15), invocations: 0, loadedSessions: 2, coverageRatio: 0,
|
||||
},
|
||||
{
|
||||
server: 'claude_ai_Slack', toolsAvailable: 11, toolsInvoked: 0,
|
||||
unusedTools: inventory.slice(15), invocations: 0, loadedSessions: 2, coverageRatio: 0,
|
||||
},
|
||||
]
|
||||
// Duplicate inventory entries must not increase the schema share.
|
||||
const sessionInventory = [...inventory, inventory[0]!, inventory[15]!]
|
||||
const sessions = ['a', 'b'].map(sessionId => makeSession({
|
||||
sessionId,
|
||||
inventory: sessionInventory,
|
||||
turns: [makeTurn([makeCall({ cacheCreation: 5_001, cacheRead: 3_333 })])],
|
||||
}))
|
||||
|
||||
const finding = detectMcpToolCoverage([project(sessions)], coverage)!
|
||||
const total = 2 * (5_001 * 1.25 + 3_333 * 0.10)
|
||||
const local = total * (15 / 26)
|
||||
|
||||
expect(finding.tokensSaved).toBe(Math.round(total))
|
||||
expect(finding.applyTokensSaved).toBe(Math.round(local))
|
||||
expect(finding.applyTokensSavedByServer?.filesystem).toBeCloseTo(local, 8)
|
||||
})
|
||||
|
||||
it('pluralises manual guidance when only claude.ai connectors are flagged', () => {
|
||||
const coverage = ['claude_ai_Slack', 'claude_ai_Google_Calendar'].map(server => ({
|
||||
server,
|
||||
toolsAvailable: 20,
|
||||
toolsInvoked: 0,
|
||||
unusedTools: Array.from({ length: 20 }, (_, i) => `mcp__${server}__t${i}`),
|
||||
invocations: 0,
|
||||
loadedSessions: 2,
|
||||
coverageRatio: 0,
|
||||
}))
|
||||
|
||||
const finding = detectMcpToolCoverage([], coverage)
|
||||
|
||||
expect(finding).not.toBeNull()
|
||||
expect(finding!.fix).toMatchObject({
|
||||
type: 'paste',
|
||||
destination: 'manual',
|
||||
label: 'Manage the underused claude.ai connectors where they load:',
|
||||
})
|
||||
if (finding!.fix.type === 'paste') {
|
||||
expect(finding!.fix.text).toContain('manage them in claude.ai Settings > Connectors')
|
||||
}
|
||||
expect(finding!.apply).toBeUndefined()
|
||||
})
|
||||
|
||||
it('does not flag a server with healthy coverage', () => {
|
||||
const inventory = Array.from({ length: 20 }, (_, i) => `mcp__svc__t${i}`)
|
||||
const turns = [makeTurn(
|
||||
|
|
@ -379,9 +568,93 @@ describe('detectMcpToolCoverage', () => {
|
|||
expect(finding!.explanation).toContain('1/30')
|
||||
expect(finding!.fix.type).toBe('command')
|
||||
expect((finding!.fix as { text: string }).text).toContain("claude mcp remove 'hf'")
|
||||
expect(finding!.apply).toEqual({ kind: 'mcp-remove', servers: ['hf'] })
|
||||
expect(finding!.tokensSaved).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('keeps mixed connector guidance visible while making only the local server executable', () => {
|
||||
const inventory = ['filesystem', 'claude_ai_Slack'].flatMap(server =>
|
||||
Array.from({ length: 20 }, (_, i) => `mcp__${server}__t${i}`),
|
||||
)
|
||||
const sessions: SessionSummary[] = [
|
||||
makeSession({ sessionId: 'mixed-a', inventory, turns: [makeTurn([makeCall({ cacheCreation: 50_000 })])] }),
|
||||
makeSession({ sessionId: 'mixed-b', inventory, turns: [makeTurn([makeCall({ cacheCreation: 50_000 })])] }),
|
||||
]
|
||||
|
||||
const finding = detectMcpToolCoverage([project(sessions)])
|
||||
|
||||
expect(finding).not.toBeNull()
|
||||
// The finding describes both opportunities: 40 unused tool schemas across
|
||||
// two sessions = 40K effective tokens. The automatic mutation owns only
|
||||
// the 20 local schemas = 20K; the connector portion remains manual.
|
||||
expect(finding).toMatchObject({ tokensSaved: 40_000, applyTokensSaved: 20_000 })
|
||||
expect(finding!.explanation).toContain('claude_ai_Slack')
|
||||
expect(finding!.explanation).toContain('/mcp')
|
||||
expect(finding!.explanation).toContain('claude.ai Settings > Connectors')
|
||||
expect(finding!.fix).toEqual({
|
||||
type: 'command',
|
||||
label: 'Remove the underused local server, or trim its tools in your MCP config:',
|
||||
text: "claude mcp remove 'filesystem'",
|
||||
})
|
||||
expect(finding!.apply).toEqual({ kind: 'mcp-remove', servers: ['filesystem'] })
|
||||
})
|
||||
|
||||
it('attributes a capped mixed cache bucket proportionally to the local action', () => {
|
||||
const inventory = ['filesystem', 'claude_ai_Slack'].flatMap(server =>
|
||||
Array.from({ length: 20 }, (_, i) => `mcp__${server}__t${i}`),
|
||||
)
|
||||
const sessions = ['a', 'b'].map(sessionId => makeSession({
|
||||
sessionId,
|
||||
inventory,
|
||||
turns: [makeTurn([makeCall({ cacheCreation: 10_000 })])],
|
||||
}))
|
||||
|
||||
const finding = detectMcpToolCoverage([project(sessions)])
|
||||
|
||||
// Each call's 10K cache bucket is shared evenly by two 8K schemas.
|
||||
// Total: 2 * 10K * 1.25 = 25K. The local mutation owns half.
|
||||
expect(finding).toMatchObject({ tokensSaved: 25_000, applyTokensSaved: 12_500 })
|
||||
})
|
||||
|
||||
it('charges only the flagged servers actually loaded in each session', () => {
|
||||
const sessions = ['filesystem', 'claude_ai_Slack'].flatMap(server =>
|
||||
['a', 'b'].map(suffix => makeSession({
|
||||
sessionId: `${server}-${suffix}`,
|
||||
inventory: Array.from({ length: 20 }, (_, i) => `mcp__${server}__t${i}`),
|
||||
turns: [makeTurn([makeCall({ cacheCreation: 50_000 })])],
|
||||
})),
|
||||
)
|
||||
|
||||
const finding = detectMcpToolCoverage([project(sessions)])
|
||||
|
||||
// Four sessions each load one 8K schema. The combined finding must not
|
||||
// charge both schemas to every session merely because both are flagged.
|
||||
expect(finding).toMatchObject({ tokensSaved: 40_000, applyTokensSaved: 20_000 })
|
||||
})
|
||||
|
||||
it('disambiguates a claude.ai connector from a similarly named local server', () => {
|
||||
const sessions: SessionSummary[] = []
|
||||
for (const server of ['claude_ai_Netlify', 'netlify']) {
|
||||
const inventory = Array.from({ length: 20 }, (_, i) => `mcp__${server}__t${i}`)
|
||||
sessions.push(
|
||||
makeSession({ sessionId: `${server}-a`, inventory }),
|
||||
makeSession({ sessionId: `${server}-b`, inventory }),
|
||||
)
|
||||
}
|
||||
|
||||
const finding = detectMcpToolCoverage([project(sessions)])
|
||||
|
||||
expect(finding).not.toBeNull()
|
||||
expect(finding!.explanation).toContain('claude_ai_Netlify')
|
||||
expect(finding!.explanation).toContain('separate from any similarly named local MCP server')
|
||||
expect(finding!.fix.type).toBe('command')
|
||||
if (finding!.fix.type === 'command') {
|
||||
expect(finding!.fix.text).toBe("claude mcp remove 'netlify'")
|
||||
expect(finding!.fix.text).not.toContain('claude_ai_Netlify')
|
||||
}
|
||||
expect(finding!.apply).toEqual({ kind: 'mcp-remove', servers: ['netlify'] })
|
||||
})
|
||||
|
||||
it('escalates impact to high when token waste crosses the threshold', () => {
|
||||
const inventory = Array.from({ length: 60 }, (_, i) => `mcp__big__t${i}`)
|
||||
// 60 tools * 400 tokens = 24k schema. With many sessions and large
|
||||
|
|
@ -654,3 +927,128 @@ describe('detectMcpProfileAdvisor', () => {
|
|||
expect(detectMcpProfileAdvisor(projects, coverage)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Connector findings under the fix/nudge/keep classification (#1019)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('connector findings and finding class', () => {
|
||||
const inventoryFor = (servers: string[]) => servers.flatMap(server =>
|
||||
Array.from({ length: 20 }, (_, i) => `mcp__${server}__t${i}`),
|
||||
)
|
||||
const twoSessions = (servers: string[]) => ['a', 'b'].map(sessionId => makeSession({
|
||||
sessionId,
|
||||
inventory: inventoryFor(servers),
|
||||
turns: [makeTurn([makeCall({ cacheCreation: 50_000 })])],
|
||||
}))
|
||||
|
||||
it('classifies a connector-only finding as a nudge, since nothing is appliable', () => {
|
||||
const finding = detectMcpToolCoverage([project(twoSessions(['claude_ai_Gmail']))])
|
||||
|
||||
expect(finding).not.toBeNull()
|
||||
expect(finding!.apply).toBeUndefined()
|
||||
expect(findingClass(finding!)).toBe('nudge')
|
||||
expect(finding!.tokensSaved).toBeGreaterThan(0)
|
||||
// Never lands in the "apply-able" subtotal.
|
||||
expect(classTotals([finding!], 0.00002).fix).toEqual({ tokensSaved: 0, savingsUSD: 0, count: 0 })
|
||||
})
|
||||
|
||||
it('counts only the local subset of a mixed finding towards the apply-able subtotal', () => {
|
||||
const finding = detectMcpToolCoverage([project(twoSessions(['filesystem', 'claude_ai_Slack']))])
|
||||
|
||||
expect(finding).not.toBeNull()
|
||||
expect(findingClass(finding!)).toBe('fix')
|
||||
expect(finding).toMatchObject({ tokensSaved: 40_000, applyTokensSaved: 20_000 })
|
||||
expect(classTotals([finding!], 0.00002).fix).toEqual({ tokensSaved: 20_000, savingsUSD: 0.4, count: 1 })
|
||||
})
|
||||
|
||||
it("leaves a local-only finding's subtotal at its full estimate", () => {
|
||||
const finding = detectMcpToolCoverage([project(twoSessions(['filesystem']))])
|
||||
|
||||
expect(finding).not.toBeNull()
|
||||
expect(finding!.applyTokensSaved).toBeUndefined()
|
||||
expect(classTotals([finding!], 0.00002).fix.tokensSaved).toBe(finding!.tokensSaved)
|
||||
})
|
||||
|
||||
it('charges each session only for the local schemas it actually loaded', () => {
|
||||
// Same per-session scoping the connector split relies on, with no
|
||||
// connector in play: two flagged local servers in disjoint sessions are
|
||||
// charged one schema each, not both schemas everywhere.
|
||||
const sessions = ['filesystem', 'playwright'].flatMap(server =>
|
||||
['a', 'b'].map(suffix => makeSession({
|
||||
sessionId: `${server}-${suffix}`,
|
||||
inventory: inventoryFor([server]),
|
||||
turns: [makeTurn([makeCall({ cacheCreation: 50_000 })])],
|
||||
})),
|
||||
)
|
||||
|
||||
const finding = detectMcpToolCoverage([project(sessions)])
|
||||
|
||||
expect(finding).toMatchObject({ tokensSaved: 40_000 })
|
||||
expect(finding!.applyTokensSaved).toBeUndefined()
|
||||
expect(classTotals([finding!], 0.00002).fix.tokensSaved).toBe(40_000)
|
||||
})
|
||||
|
||||
it('treats a claude_ai_* name owned by local config as a local server', () => {
|
||||
const finding = detectMcpToolCoverage(
|
||||
[project(twoSessions(['claude_ai_homegrown']))],
|
||||
undefined,
|
||||
new Set(['claude_ai_homegrown']),
|
||||
)
|
||||
|
||||
expect(finding!.fix).toEqual({
|
||||
type: 'command',
|
||||
label: 'Remove the underused local server, or trim its tools in your MCP config:',
|
||||
text: "claude mcp remove 'claude_ai_homegrown'",
|
||||
})
|
||||
expect(finding!.apply).toEqual({ kind: 'mcp-remove', servers: ['claude_ai_homegrown'] })
|
||||
expect(findingClass(finding!)).toBe('fix')
|
||||
// Local config owns the name, so the finding must not claim it is a connector.
|
||||
expect(finding!.explanation).not.toContain('is a claude.ai connector namespace')
|
||||
// ...but the transcript cannot rule out a same-name connector.
|
||||
expect(finding!.explanation).toContain('If you also use a claude.ai connector named claude_ai_homegrown')
|
||||
expect(finding!.manualFollowUp?.label).toBe('Check for a same-name claude.ai connector:')
|
||||
// The whole estimate is appliable: nothing is reserved for a connector.
|
||||
expect(finding!.applyTokensSaved).toBeUndefined()
|
||||
expect(classTotals([finding!], 0.00002).fix.tokensSaved).toBe(finding!.tokensSaved)
|
||||
})
|
||||
|
||||
it('keeps a claude_ai_* name absent from local config a connector', () => {
|
||||
const finding = detectMcpToolCoverage(
|
||||
[project(twoSessions(['claude_ai_Gmail']))],
|
||||
undefined,
|
||||
new Set(['filesystem', 'playwright']),
|
||||
)
|
||||
|
||||
expect(finding!.fix.type).toBe('paste')
|
||||
expect(finding!.apply).toBeUndefined()
|
||||
expect(findingClass(finding!)).toBe('nudge')
|
||||
expect(finding!.explanation).toContain('is a claude.ai connector namespace')
|
||||
})
|
||||
|
||||
it('falls back to prefix-only when no local config could be read', () => {
|
||||
// Unreadable or absent config contributes no names, which leaves every
|
||||
// claude_ai_* namespace on the conservative connector path.
|
||||
const finding = detectMcpToolCoverage([project(twoSessions(['claude_ai_homegrown']))])
|
||||
|
||||
expect(finding!.fix.type).toBe('paste')
|
||||
expect(finding!.apply).toBeUndefined()
|
||||
expect(findingClass(finding!)).toBe('nudge')
|
||||
})
|
||||
|
||||
it('applies the local entry and notes the connector when a name collides', () => {
|
||||
const finding = detectMcpToolCoverage(
|
||||
[project(twoSessions(['filesystem', 'claude_ai_Slack']))],
|
||||
undefined,
|
||||
new Set(['filesystem', 'claude_ai_Slack']),
|
||||
)
|
||||
|
||||
// Both are local: the removal owns both entries and nothing is deferred.
|
||||
expect(finding!.apply).toEqual({ kind: 'mcp-remove', servers: ['filesystem', 'claude_ai_Slack'] })
|
||||
expect(finding!.applyTokensSaved).toBeUndefined()
|
||||
expect(classTotals([finding!], 0.00002).fix.tokensSaved).toBe(40_000)
|
||||
expect(finding!.explanation).not.toContain('is a claude.ai connector namespace')
|
||||
expect(finding!.manualFollowUp?.text)
|
||||
.toBe('If you also use a claude.ai connector named claude_ai_Slack, manage it with /mcp or in claude.ai Settings > Connectors.')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
299
tests/menubar-installer-windows.test.ts
Normal file
299
tests/menubar-installer-windows.test.ts
Normal file
|
|
@ -0,0 +1,299 @@
|
|||
import { createHash } from 'node:crypto'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
WINDOWS_RELEASE,
|
||||
installMenubarApp,
|
||||
parseInstalledWindowsMenubar,
|
||||
resolveLatestMenubarReleaseAssets,
|
||||
resolveSystem32Path,
|
||||
resolveVersionedMenubarReleaseAssets,
|
||||
type ReleaseResponse,
|
||||
} from '../src/menubar-installer.js'
|
||||
|
||||
function asset(name: string) {
|
||||
return { name, browser_download_url: `https://example.test/${name}` }
|
||||
}
|
||||
|
||||
const MSI_URL =
|
||||
'https://github.com/getagentseal/codeburn/releases/download/windows-v0.9.20/CodeBurn.Menubar_0.9.20_x64_en-US.msi'
|
||||
const MSI_BYTES = 'msi-bytes'
|
||||
|
||||
function sha256(text: string): string {
|
||||
return createHash('sha256').update(Buffer.from(text)).digest('hex')
|
||||
}
|
||||
|
||||
function httpResponse(status: number, body?: string) {
|
||||
return {
|
||||
ok: status >= 200 && status < 300,
|
||||
status,
|
||||
headers: { get: () => null },
|
||||
body: body === undefined ? null : new Response(body).body,
|
||||
text: async () => body ?? '',
|
||||
}
|
||||
}
|
||||
|
||||
/** reg query /s output, one blank-line separated block per subkey. */
|
||||
function regBlock(values: Record<string, string>, key = '{9c1e2f0a-0000-0000-0000-000000000001}'): string {
|
||||
const lines = Object.entries(values).map(([name, value]) => ` ${name} REG_SZ ${value}`)
|
||||
return [
|
||||
'HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{other}',
|
||||
' DisplayName REG_SZ Some Other App',
|
||||
'',
|
||||
`HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\${key}`,
|
||||
...lines,
|
||||
'',
|
||||
].join('\r\n')
|
||||
}
|
||||
|
||||
const INSTALLED_0_9_20 = regBlock({
|
||||
DisplayName: 'CodeBurn Menubar',
|
||||
DisplayVersion: '0.9.20',
|
||||
InstallLocation: 'C:\\Program Files\\CodeBurn Menubar\\',
|
||||
Publisher: 'AgentSeal',
|
||||
})
|
||||
|
||||
describe('windows release asset resolution', () => {
|
||||
it('builds direct release asset URLs from the CLI version', () => {
|
||||
const resolved = resolveVersionedMenubarReleaseAssets('0.9.20', WINDOWS_RELEASE)
|
||||
|
||||
expect(resolved.release.tag_name).toBe('windows-v0.9.20')
|
||||
expect(resolved.zip.name).toBe('CodeBurn.Menubar_0.9.20_x64_en-US.msi')
|
||||
expect(resolved.zip.browser_download_url).toBe(MSI_URL)
|
||||
expect(resolved.checksum.browser_download_url).toBe(`${MSI_URL}.sha256`)
|
||||
})
|
||||
|
||||
it('normalizes a leading v', () => {
|
||||
expect(resolveVersionedMenubarReleaseAssets('v0.9.20', WINDOWS_RELEASE).release.tag_name).toBe('windows-v0.9.20')
|
||||
})
|
||||
|
||||
it('scans for the newest windows-v release that has both assets', () => {
|
||||
const releases: ReleaseResponse[] = [
|
||||
{ tag_name: 'mac-v0.9.20', assets: [asset('CodeBurnMenubar-v0.9.20.zip'), asset('CodeBurnMenubar-v0.9.20.zip.sha256')] },
|
||||
{ tag_name: 'windows-v0.9.21', assets: [asset('CodeBurn.Menubar_0.9.21_x64_en-US.msi')] },
|
||||
{
|
||||
tag_name: 'windows-v0.9.20',
|
||||
assets: [asset('CodeBurn.Menubar_0.9.20_x64_en-US.msi'), asset('CodeBurn.Menubar_0.9.20_x64_en-US.msi.sha256')],
|
||||
},
|
||||
]
|
||||
|
||||
const resolved = resolveLatestMenubarReleaseAssets(releases, WINDOWS_RELEASE)
|
||||
|
||||
expect(resolved.release.tag_name).toBe('windows-v0.9.20')
|
||||
expect(resolved.zip.name).toBe('CodeBurn.Menubar_0.9.20_x64_en-US.msi')
|
||||
})
|
||||
|
||||
it('reports when no windows release carries both assets', () => {
|
||||
expect(() => resolveLatestMenubarReleaseAssets([{ tag_name: 'v0.9.20', assets: [] }], WINDOWS_RELEASE))
|
||||
.toThrow(/No windows-v\* release/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveSystem32Path', () => {
|
||||
it('uses an absolute SystemRoot', () => {
|
||||
expect(resolveSystem32Path('msiexec.exe', { SystemRoot: 'D:\\Windows' })).toBe('D:\\Windows\\System32\\msiexec.exe')
|
||||
})
|
||||
|
||||
it('falls back to the documented default when SystemRoot is missing or relative', () => {
|
||||
expect(resolveSystem32Path('reg.exe', {})).toBe('C:\\Windows\\System32\\reg.exe')
|
||||
expect(resolveSystem32Path('reg.exe', { SystemRoot: 'Windows' })).toBe('C:\\Windows\\System32\\reg.exe')
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseInstalledWindowsMenubar', () => {
|
||||
it('reads the version and joins the exe onto InstallLocation', () => {
|
||||
expect(parseInstalledWindowsMenubar(INSTALLED_0_9_20)).toEqual({
|
||||
version: '0.9.20',
|
||||
exePath: 'C:\\Program Files\\CodeBurn Menubar\\CodeBurn Menubar.exe',
|
||||
})
|
||||
})
|
||||
|
||||
it('falls back to DisplayIcon when there is no InstallLocation', () => {
|
||||
const output = regBlock({
|
||||
DisplayName: 'CodeBurn Menubar',
|
||||
DisplayVersion: '0.9.20',
|
||||
DisplayIcon: 'C:\\Program Files\\CodeBurn Menubar\\CodeBurn Menubar.exe,0',
|
||||
})
|
||||
|
||||
expect(parseInstalledWindowsMenubar(output)?.exePath).toBe('C:\\Program Files\\CodeBurn Menubar\\CodeBurn Menubar.exe')
|
||||
})
|
||||
|
||||
it('returns undefined when the product is not installed', () => {
|
||||
expect(parseInstalledWindowsMenubar(regBlock({ DisplayName: 'Something Else', DisplayVersion: '1.0' }))).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
describe('installMenubarApp on windows', () => {
|
||||
let sandbox: string
|
||||
let logs: string[]
|
||||
let launched: string[]
|
||||
let installerCalls: Array<{ exe: string; args: string[] }>
|
||||
|
||||
function hooks(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
stagingDir: sandbox,
|
||||
env: { SystemRoot: 'C:\\Windows' },
|
||||
log: (message: string) => { logs.push(message) },
|
||||
launch: (exePath: string) => { launched.push(exePath) },
|
||||
queryRegistry: async () => INSTALLED_0_9_20,
|
||||
runInstaller: async (exe: string, args: string[]) => { installerCalls.push({ exe, args }); return 0 },
|
||||
fetchOptions: {
|
||||
sleep: async () => {},
|
||||
log: (message: string) => { logs.push(message) },
|
||||
fetchImpl: async (url: string) => httpResponse(200, url.endsWith('.sha256')
|
||||
? `${sha256(MSI_BYTES)} CodeBurn.Menubar_0.9.20_x64_en-US.msi`
|
||||
: MSI_BYTES),
|
||||
},
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
sandbox = await mkdtemp(join(tmpdir(), 'menubar-windows-'))
|
||||
logs = []
|
||||
launched = []
|
||||
installerCalls = []
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(sandbox, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
it('skips the download and just launches when the pinned version is already installed', async () => {
|
||||
let fetches = 0
|
||||
const result = await installMenubarApp({
|
||||
platform: 'win32',
|
||||
cliVersion: '0.9.20',
|
||||
windows: hooks({ fetchOptions: { fetchImpl: async () => { fetches++; return httpResponse(500) } } }),
|
||||
})
|
||||
|
||||
expect(fetches).toBe(0)
|
||||
expect(installerCalls).toEqual([])
|
||||
expect(launched).toEqual(['C:\\Program Files\\CodeBurn Menubar\\CodeBurn Menubar.exe'])
|
||||
expect(result).toEqual({ installedPath: 'C:\\Program Files\\CodeBurn Menubar\\CodeBurn Menubar.exe', launched: true })
|
||||
})
|
||||
|
||||
it('downloads, verifies, runs msiexec from System32 and launches the installed app', async () => {
|
||||
let queries = 0
|
||||
const result = await installMenubarApp({
|
||||
platform: 'win32',
|
||||
cliVersion: '0.9.20',
|
||||
windows: hooks({
|
||||
queryRegistry: async () => (queries++ === 0 ? '' : INSTALLED_0_9_20),
|
||||
}),
|
||||
})
|
||||
|
||||
expect(installerCalls).toEqual([{
|
||||
exe: 'C:\\Windows\\System32\\msiexec.exe',
|
||||
args: ['/i', join(sandbox, 'CodeBurn.Menubar_0.9.20_x64_en-US.msi'), '/passive', '/norestart'],
|
||||
}])
|
||||
expect(launched).toEqual(['C:\\Program Files\\CodeBurn Menubar\\CodeBurn Menubar.exe'])
|
||||
expect(result.launched).toBe(true)
|
||||
expect(logs).toContain('Downloading CodeBurn.Menubar_0.9.20_x64_en-US.msi...')
|
||||
expect(logs).toContain('Verifying checksum...')
|
||||
expect(logs).toContain('Installing...')
|
||||
expect(logs).toContain('Launched CodeBurn Menubar.')
|
||||
})
|
||||
|
||||
it('reinstalls the same version when --force is passed', async () => {
|
||||
await installMenubarApp({ platform: 'win32', cliVersion: '0.9.20', force: true, windows: hooks() })
|
||||
|
||||
expect(installerCalls).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('aborts on a checksum mismatch without running the installer', async () => {
|
||||
await expect(installMenubarApp({
|
||||
platform: 'win32',
|
||||
cliVersion: '0.9.20',
|
||||
windows: hooks({
|
||||
queryRegistry: async () => '',
|
||||
fetchOptions: {
|
||||
sleep: async () => {},
|
||||
log: () => {},
|
||||
fetchImpl: async (url: string) =>
|
||||
httpResponse(200, url.endsWith('.sha256') ? `${sha256('other-bytes')} x.msi` : MSI_BYTES),
|
||||
},
|
||||
}),
|
||||
})).rejects.toThrow(/Checksum mismatch/)
|
||||
|
||||
expect(installerCalls).toEqual([])
|
||||
expect(launched).toEqual([])
|
||||
})
|
||||
|
||||
it('treats 3010 as installed and says a restart is pending', async () => {
|
||||
let queries = 0
|
||||
const result = await installMenubarApp({
|
||||
platform: 'win32',
|
||||
cliVersion: '0.9.20',
|
||||
windows: hooks({
|
||||
queryRegistry: async () => (queries++ === 0 ? '' : INSTALLED_0_9_20),
|
||||
runInstaller: async (exe: string, args: string[]) => { installerCalls.push({ exe, args }); return 3010 },
|
||||
}),
|
||||
})
|
||||
|
||||
expect(result.launched).toBe(true)
|
||||
expect(logs.some(line => line.includes('restart'))).toBe(true)
|
||||
})
|
||||
|
||||
it('treats 1602 as a cancelled install: no launch, no error', async () => {
|
||||
const result = await installMenubarApp({
|
||||
platform: 'win32',
|
||||
cliVersion: '0.9.20',
|
||||
windows: hooks({
|
||||
queryRegistry: async () => '',
|
||||
runInstaller: async () => 1602,
|
||||
}),
|
||||
})
|
||||
|
||||
expect(result).toEqual({ installedPath: '', launched: false })
|
||||
expect(launched).toEqual([])
|
||||
expect(logs.some(line => line.includes('cancelled'))).toBe(true)
|
||||
})
|
||||
|
||||
it('fails with the exit code for any other msiexec failure', async () => {
|
||||
await expect(installMenubarApp({
|
||||
platform: 'win32',
|
||||
cliVersion: '0.9.20',
|
||||
windows: hooks({ queryRegistry: async () => '', runInstaller: async () => 1603 }),
|
||||
})).rejects.toThrow(/msiexec exited with 1603/)
|
||||
|
||||
expect(launched).toEqual([])
|
||||
})
|
||||
|
||||
it('falls back to the release API when the pinned assets are missing', async () => {
|
||||
let queries = 0
|
||||
const requested: string[] = []
|
||||
const latest: ReleaseResponse[] = [{
|
||||
tag_name: 'windows-v0.9.19',
|
||||
assets: [
|
||||
{ name: 'CodeBurn.Menubar_0.9.19_x64_en-US.msi', browser_download_url: 'https://example.test/msi' },
|
||||
{ name: 'CodeBurn.Menubar_0.9.19_x64_en-US.msi.sha256', browser_download_url: 'https://example.test/msi.sha256' },
|
||||
],
|
||||
}]
|
||||
|
||||
const result = await installMenubarApp({
|
||||
platform: 'win32',
|
||||
cliVersion: '0.9.20',
|
||||
windows: hooks({
|
||||
queryRegistry: async () => (queries++ === 0 ? '' : INSTALLED_0_9_20),
|
||||
apiFetch: async () => ({ ok: true, status: 200, headers: { get: () => null }, json: async () => latest }),
|
||||
fetchOptions: {
|
||||
sleep: async () => {},
|
||||
log: () => {},
|
||||
fetchImpl: async (url: string) => {
|
||||
requested.push(url)
|
||||
if (url.startsWith(MSI_URL)) return httpResponse(404)
|
||||
return httpResponse(200, url.endsWith('.sha256') ? `${sha256(MSI_BYTES)} msi` : MSI_BYTES)
|
||||
},
|
||||
},
|
||||
}),
|
||||
})
|
||||
|
||||
expect(requested[0]).toBe(MSI_URL)
|
||||
expect(requested).toContain('https://example.test/msi')
|
||||
expect(installerCalls[0]?.args[1]).toBe(join(sandbox, 'CodeBurn.Menubar_0.9.19_x64_en-US.msi'))
|
||||
expect(result.launched).toBe(true)
|
||||
})
|
||||
})
|
||||
|
|
@ -1,6 +1,9 @@
|
|||
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { describe, it, expect, vi } from 'vitest'
|
||||
import chalk from 'chalk'
|
||||
import stripAnsi from 'strip-ansi'
|
||||
|
||||
|
|
@ -713,6 +716,114 @@ describe('renderCsv', () => {
|
|||
})
|
||||
|
||||
describe('models CLI breakdown flags', () => {
|
||||
vi.setConfig({ testTimeout: 30_000 })
|
||||
|
||||
it('filters the models report to unpriced rows', async () => {
|
||||
const home = await mkdtemp(join(tmpdir(), 'codeburn-models-unpriced-'))
|
||||
try {
|
||||
const projectDir = join(home, '.claude', 'projects', 'models-unpriced')
|
||||
await mkdir(projectDir, { recursive: true })
|
||||
await writeFile(join(projectDir, 'session.jsonl'), [
|
||||
JSON.stringify({
|
||||
type: 'user',
|
||||
sessionId: 'models-unpriced-session',
|
||||
timestamp: '2026-05-09T00:00:00.000Z',
|
||||
cwd: '/tmp/models-unpriced',
|
||||
message: { role: 'user', content: 'Use one priced and one unpriced model.' },
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'assistant',
|
||||
sessionId: 'models-unpriced-session',
|
||||
timestamp: '2026-05-09T00:01:00.000Z',
|
||||
cwd: '/tmp/models-unpriced',
|
||||
message: {
|
||||
id: 'priced',
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
model: 'claude-sonnet-4-6',
|
||||
content: [{ type: 'text', text: 'priced' }],
|
||||
usage: { input_tokens: 1000, output_tokens: 100, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 },
|
||||
},
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: 'assistant',
|
||||
sessionId: 'models-unpriced-session',
|
||||
timestamp: '2026-05-09T00:02:00.000Z',
|
||||
cwd: '/tmp/models-unpriced',
|
||||
message: {
|
||||
id: 'unpriced',
|
||||
type: 'message',
|
||||
role: 'assistant',
|
||||
model: 'zz-unpriced-frontier-model',
|
||||
content: [{ type: 'text', text: 'unpriced' }],
|
||||
usage: { input_tokens: 2000, output_tokens: 200, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 },
|
||||
},
|
||||
}),
|
||||
].join('\n') + '\n')
|
||||
|
||||
const res = spawnSync(
|
||||
process.execPath,
|
||||
['--import', 'tsx', 'src/cli.ts', 'models', '--unpriced', '--from', '2026-05-09', '--to', '2026-05-09', '--provider', 'claude', '--format', 'json'],
|
||||
{ cwd: process.cwd(), env: { ...process.env, HOME: home, CLAUDE_CONFIG_DIR: join(home, '.claude'), CODEBURN_CACHE_DIR: join(home, '.cache', 'codeburn'), TZ: 'UTC' }, encoding: 'utf-8', timeout: 30_000 },
|
||||
)
|
||||
|
||||
expect(res.status, `stdout: ${res.stdout}\nstderr: ${res.stderr}`).toBe(0)
|
||||
const rows = JSON.parse(res.stdout) as Array<{ model: string; calls: number }>
|
||||
expect(rows.map(row => row.model)).toEqual(['zz-unpriced-frontier-model'])
|
||||
expect(rows[0]?.calls).toBe(1)
|
||||
} finally {
|
||||
await rm(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
// Unpriced rows all sort at $0 in aggregateModels, so the old implementation
|
||||
// preserved transcript/Map order instead of findUnpricedModels' token order.
|
||||
it('keeps unpriced rows when --unpriced is combined with --top', async () => {
|
||||
const home = await mkdtemp(join(tmpdir(), 'codeburn-models-unpriced-top-'))
|
||||
try {
|
||||
const projectDir = join(home, '.claude', 'projects', 'models-unpriced-top')
|
||||
await mkdir(projectDir, { recursive: true })
|
||||
const assistant = (id: string, model: string, timestamp: string, input: number) => JSON.stringify({
|
||||
type: 'assistant',
|
||||
sessionId: 'models-unpriced-top-session',
|
||||
timestamp,
|
||||
cwd: '/tmp/models-unpriced-top',
|
||||
message: {
|
||||
id, type: 'message', role: 'assistant', model,
|
||||
content: [{ type: 'text', text: id }],
|
||||
usage: { input_tokens: input, output_tokens: 100, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 },
|
||||
},
|
||||
})
|
||||
await writeFile(join(projectDir, 'session.jsonl'), [
|
||||
JSON.stringify({
|
||||
type: 'user',
|
||||
sessionId: 'models-unpriced-top-session',
|
||||
timestamp: '2026-05-09T00:00:00.000Z',
|
||||
cwd: '/tmp/models-unpriced-top',
|
||||
message: { role: 'user', content: 'Three unpriced models arrive small-first.' },
|
||||
}),
|
||||
// Transcript order is deliberately different from token order:
|
||||
// 1.1k, 9.1k, 5.1k total tokens. The two largest must survive --top 2.
|
||||
assistant('small', 'zz-unpriced-small', '2026-05-09T00:01:00.000Z', 1000),
|
||||
assistant('largest', 'zz-unpriced-largest', '2026-05-09T00:02:00.000Z', 9000),
|
||||
assistant('middle', 'zz-unpriced-middle', '2026-05-09T00:03:00.000Z', 5000),
|
||||
].join('\n') + '\n')
|
||||
|
||||
const res = spawnSync(
|
||||
process.execPath,
|
||||
['--import', 'tsx', 'src/cli.ts', 'models', '--unpriced', '--top', '2', '--from', '2026-05-09', '--to', '2026-05-09', '--provider', 'claude', '--format', 'json'],
|
||||
{ cwd: process.cwd(), env: { ...process.env, HOME: home, CLAUDE_CONFIG_DIR: join(home, '.claude'), CODEBURN_CACHE_DIR: join(home, '.cache', 'codeburn'), TZ: 'UTC' }, encoding: 'utf-8', timeout: 30_000 },
|
||||
)
|
||||
|
||||
expect(res.status, `stdout: ${res.stdout}\nstderr: ${res.stderr}`).toBe(0)
|
||||
const rows = JSON.parse(res.stdout) as Array<{ model: string }>
|
||||
expect(rows).toHaveLength(2)
|
||||
expect(rows.map(row => row.model)).toEqual(['zz-unpriced-largest', 'zz-unpriced-middle'])
|
||||
} finally {
|
||||
await rm(home, { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
it('rejects --by-task and --by-agent together with a clear error and exit 1', () => {
|
||||
const res = spawnSync(
|
||||
process.execPath,
|
||||
|
|
|
|||
|
|
@ -505,7 +505,7 @@ describe('Cursor model variants resolve to pricing', () => {
|
|||
// Sonnet family
|
||||
['claude-4-sonnet', 'claude-sonnet-4'],
|
||||
['claude-4-sonnet-1m', 'claude-sonnet-4'],
|
||||
['claude-4-sonnet-thinking', 'claude-sonnet-4-5'],
|
||||
['claude-4-sonnet-thinking', 'claude-sonnet-4'],
|
||||
['claude-4.5-sonnet', 'claude-sonnet-4-5'],
|
||||
['claude-4.5-sonnet-thinking', 'claude-sonnet-4-5'],
|
||||
['claude-4.6-sonnet', 'claude-sonnet-4-6'],
|
||||
|
|
@ -558,6 +558,14 @@ describe('Cursor model variants resolve to pricing', () => {
|
|||
expect(costs!.outputCostPerToken).toBe(expected!.outputCostPerToken)
|
||||
})
|
||||
}
|
||||
|
||||
// Regression for #912: Cursor's unversioned `claude-4-sonnet-thinking`
|
||||
// slug is the thinking variant of Sonnet 4, not Sonnet 4.5. The two models
|
||||
// currently share a price, so the display name pins the canonical identity
|
||||
// independently of today's pricing coincidence.
|
||||
it('keeps claude-4-sonnet-thinking in the Sonnet 4 model family', () => {
|
||||
expect(getShortModelName('claude-4-sonnet-thinking')).toBe('Sonnet 4')
|
||||
})
|
||||
})
|
||||
|
||||
describe('Cursor house model pricing', () => {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { tmpdir } from 'node:os'
|
|||
import { join } from 'node:path'
|
||||
import { createHash } from 'node:crypto'
|
||||
import { PassThrough, Writable } from 'node:stream'
|
||||
import stripAnsi from 'strip-ansi'
|
||||
|
||||
import { planFor, planFindings, type PlanContext } from '../src/act/plans.js'
|
||||
import { renderApplyList, runOptimizeApply, type ApplyOptions } from '../src/act/optimize-apply.js'
|
||||
|
|
@ -12,6 +13,9 @@ import { runAction } from '../src/act/apply.js'
|
|||
import { undoAction } from '../src/act/undo.js'
|
||||
import { readRecords, shortId } from '../src/act/journal.js'
|
||||
import {
|
||||
FINDING_BASIS,
|
||||
FINDING_CLASS,
|
||||
findingClass,
|
||||
detectBloatedClaudeMd,
|
||||
detectDuplicateReads,
|
||||
detectJunkReads,
|
||||
|
|
@ -102,6 +106,169 @@ describe('mcp-remove plan', () => {
|
|||
await undoAction({ id: rec.id }, { actionsDir: fx.actionsDir })
|
||||
expect(await readFile(claudeJson, 'utf-8')).toBe(original)
|
||||
})
|
||||
|
||||
it('does not plan connector removal and removes only the local server from a mixed finding', async () => {
|
||||
const coverage = (server: string): McpServerCoverage => ({
|
||||
server,
|
||||
toolsAvailable: 20,
|
||||
toolsInvoked: 0,
|
||||
unusedTools: Array.from({ length: 20 }, (_, i) => `mcp__${server}__t${i}`),
|
||||
invocations: 0,
|
||||
loadedSessions: 2,
|
||||
coverageRatio: 0,
|
||||
})
|
||||
const connector = coverage('claude_ai_Netlify')
|
||||
|
||||
const connectorOnly = detectMcpToolCoverage([], [connector])!
|
||||
expect(connectorOnly.apply).toBeUndefined()
|
||||
expect(planFor(connectorOnly)).toBeNull()
|
||||
|
||||
const fx = await makeFixture()
|
||||
const claudeJson = join(fx.home, '.claude.json')
|
||||
await writeFile(claudeJson, JSON.stringify({
|
||||
mcpServers: {
|
||||
filesystem: { command: 'filesystem' },
|
||||
netlify: { command: 'local-netlify' },
|
||||
},
|
||||
}, null, 2) + '\n')
|
||||
|
||||
const mixed = detectMcpToolCoverage([], [connector, coverage('filesystem')])!
|
||||
expect(mixed.apply).toEqual({ kind: 'mcp-remove', servers: ['filesystem'] })
|
||||
const plan = planFor(mixed, { homeDir: fx.home, cwd: fx.project })
|
||||
expect(plan).not.toBeNull()
|
||||
|
||||
await runAction(plan!, fx.actionsDir)
|
||||
expect(JSON.parse(await readFile(claudeJson, 'utf-8')).mcpServers).toEqual({
|
||||
netlify: { command: 'local-netlify' },
|
||||
})
|
||||
})
|
||||
|
||||
it('previews only the savings attributable to a mixed finding local mutation', async () => {
|
||||
const fx = await makeFixture()
|
||||
await writeFile(join(fx.home, '.claude.json'), JSON.stringify({
|
||||
mcpServers: { filesystem: { command: 'filesystem' } },
|
||||
}, null, 2) + '\n')
|
||||
const finding: WasteFinding = {
|
||||
id: 'mcp-low-coverage',
|
||||
title: '2 MCP servers with low tool coverage',
|
||||
explanation: '',
|
||||
impact: 'medium',
|
||||
tokensSaved: 80_000,
|
||||
applyTokensSaved: 20_000,
|
||||
fix: { type: 'command', label: '', text: "claude mcp remove 'filesystem'" },
|
||||
apply: { kind: 'mcp-remove', servers: ['filesystem'] },
|
||||
}
|
||||
const plans = planFindings([finding], { homeDir: fx.home, cwd: fx.project })
|
||||
|
||||
const preview = stripAnsi(renderApplyList(plans, [], 0.000002))
|
||||
|
||||
expect(preview).toContain('(~20.0K tokens, ~$0.040)')
|
||||
expect(preview).not.toContain('~80.0K tokens')
|
||||
expect(preview).not.toContain('~$0.160')
|
||||
})
|
||||
|
||||
it('scopes targets and savings to local servers actually present in editable config', async () => {
|
||||
const fx = await makeFixture()
|
||||
await writeFile(join(fx.home, '.claude.json'), JSON.stringify({
|
||||
mcpServers: { filesystem: { command: 'filesystem' } },
|
||||
}, null, 2) + '\n')
|
||||
const finding: WasteFinding = {
|
||||
id: 'mcp-low-coverage',
|
||||
title: '3 MCP servers with low tool coverage',
|
||||
explanation: '',
|
||||
impact: 'medium',
|
||||
tokensSaved: 60_000,
|
||||
applyTokensSaved: 30_000,
|
||||
applyTokensSavedByServer: { filesystem: 10_000, managed: 20_000 },
|
||||
fix: { type: 'command', label: '', text: "claude mcp remove 'filesystem'\nclaude mcp remove 'managed'" },
|
||||
apply: { kind: 'mcp-remove', servers: ['filesystem', 'managed'] },
|
||||
}
|
||||
|
||||
const plans = planFindings([finding], { homeDir: fx.home, cwd: fx.project })
|
||||
const preview = stripAnsi(renderApplyList(plans.filter(p => p.plan), plans.filter(p => !p.plan), 0))
|
||||
|
||||
expect(plans[0]!.plan?.affectedMcpServers).toEqual(['filesystem'])
|
||||
expect(preview).toContain('Removes local MCP server: filesystem')
|
||||
expect(preview).toContain('~10.0K tokens')
|
||||
expect(preview).not.toContain('~30.0K tokens')
|
||||
expect(preview).toContain('skipped managed: not found in editable config')
|
||||
})
|
||||
|
||||
it('suppresses savings for a legacy partial plan without per-server attribution', async () => {
|
||||
const fx = await makeFixture()
|
||||
await writeFile(join(fx.home, '.claude.json'), JSON.stringify({
|
||||
mcpServers: { filesystem: { command: 'filesystem' } },
|
||||
}, null, 2) + '\n')
|
||||
const finding: WasteFinding = {
|
||||
id: 'mcp-low-coverage',
|
||||
title: '2 MCP servers with low tool coverage',
|
||||
explanation: '',
|
||||
impact: 'medium',
|
||||
tokensSaved: 30_000,
|
||||
applyTokensSaved: 30_000,
|
||||
fix: { type: 'command', label: '', text: '' },
|
||||
apply: { kind: 'mcp-remove', servers: ['filesystem', 'managed'] },
|
||||
}
|
||||
|
||||
const plans = planFindings([finding], { homeDir: fx.home, cwd: fx.project })
|
||||
const preview = stripAnsi(renderApplyList(plans.filter(p => p.plan), plans.filter(p => !p.plan), 0))
|
||||
|
||||
expect(plans[0]!.plan?.affectedMcpServers).toEqual(['filesystem'])
|
||||
expect(plans[0]!.plan?.mcpSavingsUncertain).toBe(true)
|
||||
expect(preview).toContain('Savings not estimated')
|
||||
expect(preview).not.toContain('~30.0K tokens')
|
||||
})
|
||||
|
||||
it('deduplicates repeated removal targets before planning and pricing them', async () => {
|
||||
const fx = await makeFixture()
|
||||
await writeFile(join(fx.home, '.claude.json'), JSON.stringify({
|
||||
mcpServers: { filesystem: { command: 'filesystem' } },
|
||||
}, null, 2) + '\n')
|
||||
const finding: WasteFinding = {
|
||||
id: 'mcp-low-coverage',
|
||||
title: '1 MCP server with low tool coverage',
|
||||
explanation: '',
|
||||
impact: 'medium',
|
||||
tokensSaved: 10_000,
|
||||
applyTokensSavedByServer: { filesystem: 10_000 },
|
||||
fix: { type: 'command', label: '', text: '' },
|
||||
apply: { kind: 'mcp-remove', servers: ['filesystem', 'filesystem'] },
|
||||
}
|
||||
|
||||
const plans = planFindings([finding], { homeDir: fx.home, cwd: fx.project })
|
||||
const preview = stripAnsi(renderApplyList(plans.filter(p => p.plan), [], 0))
|
||||
|
||||
expect(plans[0]!.plan?.affectedMcpServers).toEqual(['filesystem'])
|
||||
expect(preview).toContain('~10.0K tokens')
|
||||
expect(preview).not.toContain('~20.0K tokens')
|
||||
})
|
||||
|
||||
it('does not claim savings when another relevant config scope is unreadable', async () => {
|
||||
const fx = await makeFixture()
|
||||
await writeFile(join(fx.home, '.claude.json'), JSON.stringify({
|
||||
mcpServers: { filesystem: { command: 'filesystem' } },
|
||||
}, null, 2) + '\n')
|
||||
await writeFile(join(fx.project, '.mcp.json'), 'not json{{{')
|
||||
const finding: WasteFinding = {
|
||||
id: 'mcp-low-coverage',
|
||||
title: '1 MCP server with low tool coverage',
|
||||
explanation: '',
|
||||
impact: 'medium',
|
||||
tokensSaved: 10_000,
|
||||
applyTokensSavedByServer: { filesystem: 10_000 },
|
||||
fix: { type: 'command', label: '', text: "claude mcp remove 'filesystem'" },
|
||||
apply: { kind: 'mcp-remove', servers: ['filesystem'] },
|
||||
}
|
||||
|
||||
const plans = planFindings([finding], { homeDir: fx.home, cwd: fx.project })
|
||||
const preview = stripAnsi(renderApplyList(plans.filter(p => p.plan), plans.filter(p => !p.plan), 0))
|
||||
|
||||
expect(plans[0]!.plan?.affectedMcpServers).toEqual(['filesystem'])
|
||||
expect(plans[0]!.plan?.mcpSavingsUncertain).toBe(true)
|
||||
expect(preview).toContain('Savings not estimated')
|
||||
expect(preview).toContain('could not parse')
|
||||
expect(preview).not.toContain('~10.0K tokens')
|
||||
})
|
||||
})
|
||||
|
||||
describe('mcp-project-scope plan', () => {
|
||||
|
|
@ -418,6 +585,80 @@ async function threeFindingFixture(): Promise<{ fx: Fixture; findings: WasteFind
|
|||
}
|
||||
|
||||
describe('runOptimizeApply end-to-end', () => {
|
||||
it('prints connector-only manual guidance when there is nothing to apply', async () => {
|
||||
const fx = await makeFixture()
|
||||
const connector: McpServerCoverage = {
|
||||
server: 'claude_ai_Netlify',
|
||||
toolsAvailable: 20,
|
||||
toolsInvoked: 0,
|
||||
unusedTools: Array.from({ length: 20 }, (_, i) => `mcp__claude_ai_Netlify__t${i}`),
|
||||
invocations: 0,
|
||||
loadedSessions: 2,
|
||||
coverageRatio: 0,
|
||||
}
|
||||
const finding = detectMcpToolCoverage([], [connector])!
|
||||
const io = makeIo()
|
||||
|
||||
await runOptimizeApply([], undefined, applyOpts(fx, io, { findings: [finding], yes: true }))
|
||||
|
||||
expect(io.stdout()).toContain('No appliable config-class fixes')
|
||||
expect(io.stdout()).toContain('/mcp')
|
||||
expect(io.stdout()).toContain('claude.ai Netlify')
|
||||
expect(io.stdout()).not.toContain('claude mcp remove')
|
||||
})
|
||||
|
||||
it('names the exact local removal target and preserves connector follow-up in a mixed preview', async () => {
|
||||
const fx = await makeFixture()
|
||||
await writeFile(join(fx.home, '.claude.json'), JSON.stringify({
|
||||
mcpServers: { filesystem: { command: 'filesystem' }, netlify: { command: 'local-netlify' } },
|
||||
}, null, 2) + '\n')
|
||||
const coverage = (server: string): McpServerCoverage => ({
|
||||
server,
|
||||
toolsAvailable: 20,
|
||||
toolsInvoked: 0,
|
||||
unusedTools: Array.from({ length: 20 }, (_, i) => `mcp__${server}__t${i}`),
|
||||
invocations: 0,
|
||||
loadedSessions: 2,
|
||||
coverageRatio: 0,
|
||||
})
|
||||
const finding = detectMcpToolCoverage([], [coverage('filesystem'), coverage('claude_ai_Netlify')])!
|
||||
const io = makeIo()
|
||||
|
||||
await runOptimizeApply([], undefined, applyOpts(fx, io, { findings: [finding], dryRun: true }))
|
||||
|
||||
expect(io.stdout()).toContain('Removes local MCP server: filesystem')
|
||||
expect(io.stdout()).toContain('/mcp')
|
||||
expect(io.stdout()).toContain('claude.ai Netlify')
|
||||
expect(io.stdout()).not.toContain("claude mcp remove 'claude_ai_Netlify'")
|
||||
})
|
||||
|
||||
it('keeps mixed connector follow-up explicitly pending after applying the local fix', async () => {
|
||||
const fx = await makeFixture()
|
||||
await writeFile(join(fx.home, '.claude.json'), JSON.stringify({
|
||||
mcpServers: { filesystem: { command: 'filesystem' } },
|
||||
}, null, 2) + '\n')
|
||||
const coverage = (server: string): McpServerCoverage => ({
|
||||
server,
|
||||
toolsAvailable: 20,
|
||||
toolsInvoked: 0,
|
||||
unusedTools: Array.from({ length: 20 }, (_, i) => `mcp__${server}__t${i}`),
|
||||
invocations: 0,
|
||||
loadedSessions: 2,
|
||||
coverageRatio: 0,
|
||||
})
|
||||
const finding = detectMcpToolCoverage([], [coverage('filesystem'), coverage('claude_ai_Netlify')])!
|
||||
const io = makeIo()
|
||||
|
||||
await runOptimizeApply([], undefined, applyOpts(fx, io, { findings: [finding], yes: true }))
|
||||
|
||||
const out = io.stdout()
|
||||
expect(out).toContain('Manual follow-up (not applied):')
|
||||
expect(out).toContain('Still requires manual action:')
|
||||
expect(out).toContain('claude.ai Netlify')
|
||||
expect(await readRecords(fx.actionsDir)).toHaveLength(1)
|
||||
expect(JSON.parse(await readFile(join(fx.home, '.claude.json'), 'utf-8')).mcpServers).toEqual({})
|
||||
})
|
||||
|
||||
it('--yes applies every plan and prints journal short ids with the undo hint', async () => {
|
||||
const { fx, findings } = await threeFindingFixture()
|
||||
const io = makeIo()
|
||||
|
|
@ -430,11 +671,19 @@ describe('runOptimizeApply end-to-end', () => {
|
|||
expect(out).toContain(`Applied ${shortId(rec.id)}`)
|
||||
expect(out).toContain(`Undo anytime: codeburn act undo ${shortId(rec.id)}`)
|
||||
}
|
||||
expect(out).toContain('CodeBurn will re-measure these on your next optimize run after 3 days.')
|
||||
expect(JSON.parse(await readFile(join(fx.home, '.claude.json'), 'utf-8')).mcpServers).toEqual({})
|
||||
expect(existsSync(join(fx.home, '.claude', 'skills', '.archived', 'foo'))).toBe(true)
|
||||
expect(existsSync(join(fx.home, '.zshrc'))).toBe(true)
|
||||
})
|
||||
|
||||
it('does not promise a re-measure when nothing was applied', async () => {
|
||||
const { fx, findings } = await threeFindingFixture()
|
||||
const io = makeIo('q\n')
|
||||
await runOptimizeApply([], undefined, applyOpts(fx, io, { findings }))
|
||||
expect(io.stdout()).not.toContain('re-measure')
|
||||
})
|
||||
|
||||
it('interactive pick "2" applies only the second plan', async () => {
|
||||
const { fx, findings } = await threeFindingFixture()
|
||||
const io = makeIo('2\n')
|
||||
|
|
@ -653,3 +902,79 @@ describe('stale-plan detection', () => {
|
|||
expect(await readFile(p, 'utf-8')).toBe('overwritten')
|
||||
})
|
||||
})
|
||||
|
||||
describe('finding class', () => {
|
||||
it('covers every finding id with a class and a basis', () => {
|
||||
expect(Object.keys(FINDING_BASIS).sort()).toEqual(Object.keys(FINDING_CLASS).sort())
|
||||
})
|
||||
|
||||
it("classes a finding 'fix' exactly when a plan can be built for it", async () => {
|
||||
const fx = await makeFixture()
|
||||
const claudeJson = join(fx.home, '.claude.json')
|
||||
await writeFile(claudeJson, JSON.stringify({ mcpServers: { srv: { command: 's' } } }, null, 2) + '\n')
|
||||
const settings = join(fx.project, '.claude', 'settings.json')
|
||||
await mkdir(join(fx.project, '.claude'), { recursive: true })
|
||||
await writeFile(settings, JSON.stringify({ env: { ENABLE_TOOL_SEARCH: 'auto' } }, null, 2) + '\n')
|
||||
const mcpJson = join(fx.project, '.mcp.json')
|
||||
await writeFile(mcpJson, JSON.stringify({ mcpServers: { pinned: { command: 'x', alwaysLoad: true } } }, null, 2) + '\n')
|
||||
await mkdir(join(fx.home, '.claude', 'skills', 'ghost'), { recursive: true })
|
||||
await mkdir(join(fx.home, '.claude', 'agents'), { recursive: true })
|
||||
await mkdir(join(fx.home, '.claude', 'commands'), { recursive: true })
|
||||
await writeFile(join(fx.home, '.claude', 'agents', 'ghost.md'), 'x')
|
||||
await writeFile(join(fx.home, '.claude', 'commands', 'ghost.md'), 'x')
|
||||
|
||||
const CLAUDE_MD_FIX: WasteAction = { type: 'paste', destination: 'claude-md', label: '', text: 'rule' }
|
||||
const SHELL_FIX: WasteAction = { type: 'paste', destination: 'shell-config', label: '', text: 'export X=1' }
|
||||
const PROMPT_FIX: WasteAction = { type: 'paste', destination: 'prompt', label: '', text: 'ask' }
|
||||
const OPENER_FIX: WasteAction = { type: 'paste', destination: 'session-opener', label: '', text: 'o' }
|
||||
|
||||
// One representative finding per id, carrying the payload its plan
|
||||
// builder needs. Ids without a builder get a plain prompt fix.
|
||||
const representatives: Record<FindingId, WasteFinding> = {
|
||||
'read-edit-ratio': makeFinding('read-edit-ratio', CLAUDE_MD_FIX),
|
||||
'build-folder-reads': makeFinding('build-folder-reads', CLAUDE_MD_FIX),
|
||||
'redundant-rereads': makeFinding('redundant-rereads', PROMPT_FIX),
|
||||
'warmup-heavy': makeFinding('warmup-heavy', SHELL_FIX),
|
||||
'unused-mcp': makeFinding('unused-mcp', CMD_FIX, { kind: 'mcp-remove', servers: ['srv'] }),
|
||||
'mcp-low-coverage': makeFinding('mcp-low-coverage', CMD_FIX, { kind: 'mcp-remove', servers: ['srv'] }),
|
||||
'mcp-project-scope': makeFinding('mcp-project-scope', PROMPT_FIX, {
|
||||
kind: 'mcp-project-scope',
|
||||
servers: [{ server: 'srv', keepProjects: [fx.project], removeProjects: [] }],
|
||||
}),
|
||||
'mcp-deferral-off': makeFinding('mcp-deferral-off', CMD_FIX, {
|
||||
kind: 'defer-enable', cause: 'env-false', settingPath: settings, settingScope: 'project settings', value: 'false',
|
||||
}),
|
||||
'mcp-alwaysload-hygiene': makeFinding('mcp-alwaysload-hygiene', PROMPT_FIX, {
|
||||
kind: 'defer-alwaysload',
|
||||
servers: [{ server: 'pinned', paths: [mcpJson] }],
|
||||
}),
|
||||
'mcp-defer-threshold': makeFinding('mcp-defer-threshold', PROMPT_FIX, {
|
||||
kind: 'defer-threshold', settingPath: settings, settingScope: 'project settings',
|
||||
value: 'auto', recommendedPercent: 2, removeOverride: false,
|
||||
}),
|
||||
'retry-heavy-capabilities': makeFinding('retry-heavy-capabilities', PROMPT_FIX),
|
||||
'low-worth-sessions': makeFinding('low-worth-sessions', OPENER_FIX),
|
||||
'context-heavy-sessions': makeFinding('context-heavy-sessions', OPENER_FIX),
|
||||
'cost-outliers': makeFinding('cost-outliers', OPENER_FIX),
|
||||
'claude-md-too-long': makeFinding('claude-md-too-long', PROMPT_FIX),
|
||||
'bash-output-cap': makeFinding('bash-output-cap', SHELL_FIX),
|
||||
'unused-agents': makeFinding('unused-agents', CMD_FIX, { kind: 'archive', names: ['ghost'] }),
|
||||
'unused-skills': makeFinding('unused-skills', CMD_FIX, { kind: 'archive', names: ['ghost'] }),
|
||||
'unused-commands': makeFinding('unused-commands', CMD_FIX, { kind: 'archive', names: ['ghost'] }),
|
||||
'recurring-context': makeFinding('recurring-context', PROMPT_FIX),
|
||||
}
|
||||
|
||||
const planCtx: PlanContext = { homeDir: fx.home, cwd: fx.project, shell: '/bin/zsh', claudeVersion: () => '2.1.130' }
|
||||
for (const finding of Object.values(representatives)) {
|
||||
const hasPlan = planFor(finding, planCtx) !== null
|
||||
expect([finding.id, hasPlan]).toEqual([finding.id, findingClass(finding) === 'fix'])
|
||||
}
|
||||
})
|
||||
|
||||
it("drops to 'nudge' when the instance lacks the payload its plan needs", () => {
|
||||
const finding = makeFinding('mcp-deferral-off', { type: 'paste', destination: 'shell-config', label: '', text: 'x' })
|
||||
expect(FINDING_CLASS['mcp-deferral-off']).toBe('fix')
|
||||
expect(findingClass(finding)).toBe('nudge')
|
||||
expect(planFor(finding)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import { describe, it, expect, afterAll, beforeEach, vi } from 'vitest'
|
||||
import { describe, it, expect, afterAll, afterEach, beforeEach, vi } from 'vitest'
|
||||
import { Writable } from 'node:stream'
|
||||
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, utimesSync } from 'fs'
|
||||
import { tmpdir } from 'os'
|
||||
import { join } from 'path'
|
||||
|
|
@ -20,15 +21,24 @@ import {
|
|||
detectUnusedMcp,
|
||||
detectBashBloat,
|
||||
detectGhostCommands,
|
||||
detectDuplicateReads,
|
||||
detectJunkReads,
|
||||
detectLowReadEditRatio,
|
||||
loadMcpConfigs,
|
||||
localMcpServerNames,
|
||||
scanJsonlFile,
|
||||
scanAndDetect,
|
||||
detectRecurringContext,
|
||||
renderOptimize,
|
||||
type SessionOpener,
|
||||
type ToolCall,
|
||||
} from '../src/optimize.js'
|
||||
import {
|
||||
estimateContextBudget,
|
||||
discoverProjectCwd,
|
||||
} from '../src/context-budget.js'
|
||||
import type { ProjectSummary } from '../src/types.js'
|
||||
import { runOptimizeApply } from '../src/act/optimize-apply.js'
|
||||
|
||||
// ============================================================================
|
||||
// Helpers for filesystem fixtures
|
||||
|
|
@ -170,6 +180,32 @@ describe('loadMcpConfigs', () => {
|
|||
})
|
||||
})
|
||||
|
||||
describe('localMcpServerNames', () => {
|
||||
it('adds the ~/.claude.json top-level and per-project servers to the config names', () => {
|
||||
const root = makeFixtureRoot()
|
||||
const projectDir = join(root, 'myapp')
|
||||
mkdirSync(projectDir, { recursive: true })
|
||||
writeFile(join(projectDir, '.mcp.json'), JSON.stringify({ mcpServers: { fromMcpJson: {} } }))
|
||||
writeFile(join(FAKE_HOME_FOR_MOCK, '.claude.json'), JSON.stringify({
|
||||
mcpServers: { 'claude_ai_homegrown': {}, 'plugin:ctx:ctx': {} },
|
||||
projects: { [projectDir]: { mcpServers: { scoped: {} } } },
|
||||
}))
|
||||
|
||||
const names = localMcpServerNames([projectDir])
|
||||
|
||||
expect([...names].sort()).toEqual(['claude_ai_homegrown', 'fromMcpJson', 'plugin_ctx_ctx', 'scoped'])
|
||||
})
|
||||
|
||||
it('contributes no names when ~/.claude.json cannot be parsed', () => {
|
||||
const root = makeFixtureRoot()
|
||||
const projectDir = join(root, 'myapp')
|
||||
mkdirSync(projectDir, { recursive: true })
|
||||
writeFile(join(FAKE_HOME_FOR_MOCK, '.claude.json'), '{ not valid json')
|
||||
|
||||
expect(localMcpServerNames([projectDir]).size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('detectUnusedMcp', () => {
|
||||
it('flags servers configured but never called', () => {
|
||||
const root = makeFixtureRoot()
|
||||
|
|
@ -294,6 +330,78 @@ describe('scanJsonlFile', () => {
|
|||
expect(result.calls[0].name).toBe('Read')
|
||||
})
|
||||
|
||||
it('marks tool calls from sidechain transcript entries', async () => {
|
||||
const root = makeFixtureRoot()
|
||||
const filePath = join(root, 'agent-reviewer.jsonl')
|
||||
const now = new Date().toISOString()
|
||||
writeFile(filePath, JSON.stringify({
|
||||
type: 'assistant', isSidechain: true, timestamp: now,
|
||||
message: { content: [{ type: 'tool_use', name: 'Edit', input: { file_path: '/x/foo.ts' } }] },
|
||||
}))
|
||||
|
||||
const result = await scanJsonlFile(filePath, 'p1', undefined)
|
||||
|
||||
expect(result.calls).toHaveLength(1)
|
||||
expect(result.calls[0]!.isSidechain).toBe(true)
|
||||
})
|
||||
|
||||
it('classifies every tool call in a transcript when a later large entry marks it as sidechain', async () => {
|
||||
const root = makeFixtureRoot()
|
||||
const filePath = join(root, 'agent-reviewer.jsonl')
|
||||
const now = new Date().toISOString()
|
||||
const assistant = (name: string, isSidechain?: boolean, padding = '') => JSON.stringify({
|
||||
type: 'assistant',
|
||||
...(isSidechain === true ? { isSidechain: true } : {}),
|
||||
timestamp: now,
|
||||
cwd: '/x',
|
||||
padding,
|
||||
message: {
|
||||
model: 'claude-sonnet-4-5',
|
||||
usage: { cache_creation_input_tokens: 1 },
|
||||
content: [{ type: 'tool_use', name, input: { file_path: `/x/${name}.ts` } }],
|
||||
},
|
||||
})
|
||||
writeFile(filePath, [
|
||||
JSON.stringify({ type: 'user', timestamp: now, cwd: '/x', message: { content: 'delegate this' } }),
|
||||
assistant('Read'),
|
||||
assistant('Edit', true, 'x'.repeat(40_000)),
|
||||
assistant('Bash'),
|
||||
].join('\n'))
|
||||
|
||||
const result = await scanJsonlFile(filePath, 'p1', undefined)
|
||||
|
||||
expect(result.calls.map(call => [call.name, call.isSidechain])).toEqual([
|
||||
['Read', true],
|
||||
['Edit', true],
|
||||
['Bash', true],
|
||||
])
|
||||
expect(result.apiCalls).toHaveLength(3)
|
||||
expect(result.cwds).toHaveLength(4)
|
||||
expect(result.userMessages).toEqual(['delegate this'])
|
||||
})
|
||||
|
||||
it('keeps sidechain calls out of duplicate reads but in junk reads and the read:edit ratio', () => {
|
||||
const sidechain = { sessionId: 'agent-reviewer', project: 'p1', isSidechain: true }
|
||||
const editCalls = Array.from({ length: 10 }, (_, index) => ({
|
||||
name: 'Edit', input: { file_path: `/src/${index}.ts` }, ...sidechain,
|
||||
}))
|
||||
const junkReads = Array.from({ length: 6 }, () => ({
|
||||
name: 'Read', input: { file_path: '/app/node_modules/pkg/index.js' }, ...sidechain,
|
||||
}))
|
||||
const repeatReads = Array.from({ length: 6 }, () => ({
|
||||
name: 'Read', input: { file_path: '/app/src/a.ts' }, ...sidechain,
|
||||
}))
|
||||
|
||||
// A subagent editing without reading, or reading into node_modules, is the
|
||||
// same waste as the parent doing it, and the CLAUDE.md rule both suggest
|
||||
// binds subagents too - so the full call population feeds them.
|
||||
expect(detectLowReadEditRatio(editCalls)?.id).toBe('read-edit-ratio')
|
||||
expect(detectJunkReads(junkReads)?.id).toBe('build-folder-reads')
|
||||
// A re-read is only waste when the context already held the file; a
|
||||
// sidechain starts fresh and has to read it.
|
||||
expect(detectDuplicateReads(repeatReads)).toBeNull()
|
||||
})
|
||||
|
||||
it('skips malformed JSONL lines without crashing', async () => {
|
||||
const root = makeFixtureRoot()
|
||||
const filePath = join(root, 'session.jsonl')
|
||||
|
|
@ -359,6 +467,136 @@ describe('scanJsonlFile', () => {
|
|||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// detectRecurringContext
|
||||
// ============================================================================
|
||||
|
||||
describe('detectRecurringContext', () => {
|
||||
// ~2.2 KB, comfortably over the 1.5 KB floor.
|
||||
const BRIEF = 'PROJECT BRIEF\n' + 'Ship the invoice importer behind a flag. '.repeat(53)
|
||||
const TOKENS_PER_CHAR = 0.25
|
||||
|
||||
type Opener = { text: string; project?: string }
|
||||
|
||||
async function openersFor(sessions: Opener[]): Promise<SessionOpener[]> {
|
||||
const root = makeFixtureRoot()
|
||||
const now = new Date().toISOString()
|
||||
const openers: SessionOpener[] = []
|
||||
for (const [i, { text, project }] of sessions.entries()) {
|
||||
const filePath = join(root, `s${i}.jsonl`)
|
||||
writeFile(filePath, JSON.stringify({ type: 'user', timestamp: now, message: { content: text } }))
|
||||
const result = await scanJsonlFile(filePath, project ?? 'my-app', undefined)
|
||||
openers.push(...result.openers)
|
||||
}
|
||||
return openers
|
||||
}
|
||||
|
||||
const repeat = (n: number, text = BRIEF, project?: string): Opener[] =>
|
||||
Array.from({ length: n }, () => ({ text, project }))
|
||||
|
||||
it('flags a block that opens the session threshold worth of sessions', async () => {
|
||||
const finding = detectRecurringContext(await openersFor(repeat(5)))
|
||||
expect(finding).not.toBeNull()
|
||||
expect(finding!.id).toBe('recurring-context')
|
||||
expect(finding!.title).toBe(`Same ${(BRIEF.length / 1024).toFixed(1)} KB block pasted at the start of 5 sessions`)
|
||||
// Only the four repeats are recoverable; the first paste is the honest cost.
|
||||
expect(finding!.tokensSaved).toBe(Math.round(4 * BRIEF.length * TOKENS_PER_CHAR))
|
||||
expect(finding!.fix.type).toBe('paste')
|
||||
expect(finding!.fix.type === 'paste' && finding!.fix.destination).toBe('prompt')
|
||||
})
|
||||
|
||||
it('renders the finding with its preview and destination header', async () => {
|
||||
const finding = detectRecurringContext(await openersFor(repeat(5)))!
|
||||
const out = renderOptimize([finding], 0.00001, '30 Days', 10, 5, 100, 80, 'B', [], [])
|
||||
.replace(/\u001b\[[0-9;]*m/g, '')
|
||||
expect(out).toContain(finding.title)
|
||||
expect(out).toContain('PROJECT BRIEF Ship the invoice importer')
|
||||
expect(out).toContain('Ask Claude in the current session')
|
||||
expect(out).toContain('Move it into CLAUDE.md if it is a standing rule')
|
||||
})
|
||||
|
||||
it('stays quiet below the session threshold', async () => {
|
||||
expect(detectRecurringContext(await openersFor(repeat(4)))).toBeNull()
|
||||
})
|
||||
|
||||
it('ignores short openers however often they repeat', async () => {
|
||||
expect(detectRecurringContext(await openersFor(repeat(20, 'continue')))).toBeNull()
|
||||
expect(detectRecurringContext(await openersFor(repeat(20, 'yes')))).toBeNull()
|
||||
})
|
||||
|
||||
it('groups sessions whose block differs only in whitespace', async () => {
|
||||
const reflowed = BRIEF.replace(/ /g, ' ').replace(/\n/g, '\n\n')
|
||||
const finding = detectRecurringContext(await openersFor([...repeat(3), ...repeat(2, reflowed)]))
|
||||
expect(finding).not.toBeNull()
|
||||
expect(finding!.title).toContain('start of 5 sessions')
|
||||
})
|
||||
|
||||
it('ignores injected system reminders and slash-command wrappers', async () => {
|
||||
expect(detectRecurringContext(await openersFor(repeat(6, `<system-reminder>${BRIEF}</system-reminder>`)))).toBeNull()
|
||||
expect(detectRecurringContext(await openersFor(repeat(6, `<command-name>/brief</command-name>${BRIEF}`)))).toBeNull()
|
||||
})
|
||||
|
||||
it('skips prompts a program wrote: SDK sessions and subagent transcripts', async () => {
|
||||
const root = makeFixtureRoot()
|
||||
const now = new Date().toISOString()
|
||||
const openers: SessionOpener[] = []
|
||||
for (const [i, entry] of [{ promptSource: 'sdk' }, { isSidechain: true }].entries()) {
|
||||
for (let j = 0; j < 6; j++) {
|
||||
const filePath = join(root, `machine-${i}-${j}.jsonl`)
|
||||
writeFile(filePath, JSON.stringify({ type: 'user', timestamp: now, ...entry, message: { content: BRIEF } }))
|
||||
openers.push(...(await scanJsonlFile(filePath, 'my-app', undefined)).openers)
|
||||
}
|
||||
}
|
||||
expect(openers).toEqual([])
|
||||
expect(detectRecurringContext(openers)).toBeNull()
|
||||
})
|
||||
|
||||
// Over 32 KB the JSONL parser returns a reduced entry without the root
|
||||
// flags, so the markers have to be read off the raw line.
|
||||
it('skips machine-written prompts too large for the parser to keep flags on', async () => {
|
||||
const root = makeFixtureRoot()
|
||||
const now = new Date().toISOString()
|
||||
const huge = BRIEF + 'x'.repeat(40_000)
|
||||
const openers: SessionOpener[] = []
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const filePath = join(root, `huge-${i}.jsonl`)
|
||||
// Field order matters: the flags land past the head, behind the very
|
||||
// message that made the line large.
|
||||
writeFile(filePath, JSON.stringify({
|
||||
isSidechain: false, type: 'user', message: { content: huge }, timestamp: now, promptSource: 'sdk',
|
||||
}))
|
||||
openers.push(...(await scanJsonlFile(filePath, 'my-app', undefined)).openers)
|
||||
}
|
||||
expect(openers).toEqual([])
|
||||
})
|
||||
|
||||
it('only counts the first message of a session as its opener', async () => {
|
||||
const root = makeFixtureRoot()
|
||||
const now = new Date().toISOString()
|
||||
const openers: SessionOpener[] = []
|
||||
for (let i = 0; i < 6; i++) {
|
||||
const filePath = join(root, `late-${i}.jsonl`)
|
||||
writeFile(filePath, [
|
||||
JSON.stringify({ type: 'user', timestamp: now, message: { content: 'go on' } }),
|
||||
JSON.stringify({ type: 'user', timestamp: now, message: { content: BRIEF } }),
|
||||
].join('\n'))
|
||||
openers.push(...(await scanJsonlFile(filePath, 'my-app', undefined)).openers)
|
||||
}
|
||||
expect(detectRecurringContext(openers)).toBeNull()
|
||||
})
|
||||
|
||||
it('names the project when the block is confined to one, and counts them otherwise', async () => {
|
||||
const single = detectRecurringContext(await openersFor(repeat(5, BRIEF, '-Users-me-Projects-codeburn')))
|
||||
expect(single!.explanation).toContain('5 sessions in codeburn')
|
||||
|
||||
const spread = detectRecurringContext(await openersFor([
|
||||
...repeat(3, BRIEF, '-Users-me-Projects-codeburn'),
|
||||
...repeat(2, BRIEF, '-Users-me-Projects-dash'),
|
||||
]))
|
||||
expect(spread!.explanation).toContain('5 sessions in 2 projects')
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
// scanAndDetect (top-level integration)
|
||||
// ============================================================================
|
||||
|
|
@ -371,6 +609,94 @@ describe('scanAndDetect', () => {
|
|||
expect(result.healthGrade).toBe('A')
|
||||
expect(result.costRate).toBe(0)
|
||||
})
|
||||
|
||||
// The session scan only ever reads Claude Code transcripts, so under a
|
||||
// non-Claude --provider it used to report Claude-derived findings beside a
|
||||
// header scoped to the other provider - e.g. `optimize --provider codex`
|
||||
// printing a read/edit ratio counted from Claude sessions.
|
||||
describe('provider scoping', () => {
|
||||
// These fixtures live in the shared fake home, so they have to come back
|
||||
// out: later suites in this file assert on an otherwise empty ~/.claude.
|
||||
const CLAUDE_DIR = join(FAKE_HOME_FOR_MOCK, '.claude')
|
||||
afterEach(() => {
|
||||
for (const sub of ['projects', 'skills']) {
|
||||
rmSync(join(CLAUDE_DIR, sub), { recursive: true, force: true })
|
||||
}
|
||||
})
|
||||
|
||||
function claudeSessionWithEditHeavyTurns(): void {
|
||||
const projectDir = join(CLAUDE_DIR, 'projects', 'provider-scope')
|
||||
mkdirSync(projectDir, { recursive: true })
|
||||
const now = new Date().toISOString()
|
||||
const entry = (name: string, file: string) => JSON.stringify({
|
||||
type: 'assistant', timestamp: now,
|
||||
message: { content: [{ type: 'tool_use', name, input: { file_path: file } }] },
|
||||
})
|
||||
const lines = [entry('Read', '/src/a.ts')]
|
||||
for (let i = 0; i < 12; i++) lines.push(entry('Edit', `/src/f${i}.ts`))
|
||||
writeFileSync(join(projectDir, 'session.jsonl'), lines.join('\n'))
|
||||
}
|
||||
|
||||
// scanAndDetect memoises on (provider, range, project fingerprint) for 60s,
|
||||
// and the cache is module-level, so tests that differ only in what is on
|
||||
// disk would serve each other's results. `seed` moves the fingerprint so
|
||||
// each case scans for real.
|
||||
function projectFixture(seed: number): ProjectSummary {
|
||||
return {
|
||||
project: 'provider-scope',
|
||||
projectPath: '/tmp/provider-scope',
|
||||
sessions: [],
|
||||
totalCostUSD: 1,
|
||||
totalApiCalls: 13 + seed,
|
||||
} as unknown as ProjectSummary
|
||||
}
|
||||
|
||||
it('reports transcript-derived findings when scoped to claude', async () => {
|
||||
claudeSessionWithEditHeavyTurns()
|
||||
const result = await scanAndDetect([projectFixture(1)], undefined, 'claude')
|
||||
expect(result.findings.map(f => f.id)).toContain('read-edit-ratio')
|
||||
})
|
||||
|
||||
it('omits transcript-derived findings when scoped to another provider', async () => {
|
||||
claudeSessionWithEditHeavyTurns()
|
||||
mkdirSync(join(CLAUDE_DIR, 'skills', 'never-invoked'), { recursive: true })
|
||||
writeFileSync(join(CLAUDE_DIR, 'skills', 'never-invoked', 'SKILL.md'), '# skill\n')
|
||||
|
||||
const result = await scanAndDetect([projectFixture(2)], undefined, 'codex')
|
||||
const ids = result.findings.map(f => f.id)
|
||||
|
||||
expect(ids).not.toContain('read-edit-ratio')
|
||||
// An unmeasured skill must not be reported as an unused one: the scan
|
||||
// returns nothing under this filter, which is not evidence of disuse.
|
||||
expect(ids).not.toContain('unused-skills')
|
||||
})
|
||||
|
||||
// The apply path reaches scanAndDetect through its own entry point, so it
|
||||
// needs its own guard: `unused-skills` is appliable, and its plan moves
|
||||
// directories out of ~/.claude/skills. Reporting a Codex-labelled finding
|
||||
// is a wrong number; offering to archive every skill off one is a wrong
|
||||
// number with side effects.
|
||||
async function applyDryRun(provider: string): Promise<string> {
|
||||
const chunks: string[] = []
|
||||
const output = new Writable({ write(c, _e, cb) { chunks.push(String(c)); cb() } })
|
||||
const errorOutput = new Writable({ write(_c, _e, cb) { cb() } })
|
||||
await runOptimizeApply([projectFixture(3)], undefined, { provider, dryRun: true, output, errorOutput })
|
||||
return chunks.join('')
|
||||
}
|
||||
|
||||
it('plans no applies from Claude findings when scoped to another provider', async () => {
|
||||
claudeSessionWithEditHeavyTurns()
|
||||
mkdirSync(join(CLAUDE_DIR, 'skills', 'never-invoked'), { recursive: true })
|
||||
writeFileSync(join(CLAUDE_DIR, 'skills', 'never-invoked', 'SKILL.md'), '# skill\n')
|
||||
|
||||
const codex = await applyDryRun('codex')
|
||||
expect(codex).toContain('No appliable config-class fixes')
|
||||
expect(codex).not.toContain('never-invoked')
|
||||
|
||||
const claude = await applyDryRun('claude')
|
||||
expect(claude).toContain('never-invoked')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
// ============================================================================
|
||||
|
|
|
|||
341
tests/optimize-sidechains.test.ts
Normal file
341
tests/optimize-sidechains.test.ts
Normal file
|
|
@ -0,0 +1,341 @@
|
|||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('../src/providers/index.js', async (importOriginal) => {
|
||||
type ProvidersModule = typeof import('../src/providers/index.js')
|
||||
const actual = await importOriginal<ProvidersModule>()
|
||||
return {
|
||||
...actual,
|
||||
async discoverAllSessions() {
|
||||
return []
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
import {
|
||||
buildOptimizeJsonReport,
|
||||
cacheKey,
|
||||
computeInputCostRate,
|
||||
detectCapabilityReliability,
|
||||
detectSessionOutliers,
|
||||
findContextBloatCandidates,
|
||||
findLowWorthCandidates,
|
||||
runOptimize,
|
||||
scanAndDetect,
|
||||
type OptimizeResult,
|
||||
} from '../src/optimize.js'
|
||||
import type { ClassifiedTurn, ProjectSummary, SessionSummary } from '../src/types.js'
|
||||
|
||||
function behavioralTurn(
|
||||
model: string,
|
||||
index: number,
|
||||
options: { retries?: number; costUSD?: number; userMessage?: string } = {},
|
||||
): ClassifiedTurn {
|
||||
const timestamp = new Date(Date.parse('2026-08-01T10:00:00.000Z') + index * 1_000).toISOString()
|
||||
return {
|
||||
userMessage: options.userMessage ?? 'edit the code',
|
||||
timestamp,
|
||||
sessionId: 'agent-behavior',
|
||||
category: 'feature',
|
||||
retries: options.retries ?? 0,
|
||||
hasEdits: true,
|
||||
assistantCalls: [{
|
||||
provider: 'claude',
|
||||
model,
|
||||
usage: {
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
cacheCreationInputTokens: 0,
|
||||
cacheReadInputTokens: 0,
|
||||
cachedInputTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
webSearchRequests: 0,
|
||||
},
|
||||
costUSD: options.costUSD ?? 1,
|
||||
tools: ['Edit'],
|
||||
mcpTools: [],
|
||||
skills: [],
|
||||
subagentTypes: [],
|
||||
hasAgentSpawn: false,
|
||||
hasPlanMode: false,
|
||||
speed: 'standard',
|
||||
timestamp,
|
||||
bashCommands: [],
|
||||
deduplicationKey: `${model}-${index}`,
|
||||
}],
|
||||
}
|
||||
}
|
||||
|
||||
function session(
|
||||
sessionId: string,
|
||||
overrides: Partial<SessionSummary> = {},
|
||||
): SessionSummary {
|
||||
return {
|
||||
sessionId,
|
||||
project: 'app',
|
||||
firstTimestamp: '2026-08-01T10:00:00.000Z',
|
||||
lastTimestamp: '2026-08-01T10:30:00.000Z',
|
||||
totalCostUSD: 1,
|
||||
totalSavingsUSD: 0,
|
||||
totalInputTokens: 1_000,
|
||||
totalOutputTokens: 1_000,
|
||||
totalReasoningTokens: 0,
|
||||
totalCacheReadTokens: 0,
|
||||
totalCacheWriteTokens: 0,
|
||||
apiCalls: 1,
|
||||
turns: [],
|
||||
modelBreakdown: {},
|
||||
toolBreakdown: {},
|
||||
mcpBreakdown: {},
|
||||
bashBreakdown: {},
|
||||
categoryBreakdown: {} as SessionSummary['categoryBreakdown'],
|
||||
skillBreakdown: {},
|
||||
subagentBreakdown: {},
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function sidechain(
|
||||
sessionId: string,
|
||||
overrides: Partial<SessionSummary> = {},
|
||||
): SessionSummary {
|
||||
return session(sessionId, {
|
||||
isSidechain: true,
|
||||
parentSessionId: 'parent-session',
|
||||
agentId: sessionId.replace(/^agent-/, ''),
|
||||
...overrides,
|
||||
})
|
||||
}
|
||||
|
||||
function project(sessions: SessionSummary[]): ProjectSummary {
|
||||
return {
|
||||
project: 'app',
|
||||
projectPath: '/tmp/app',
|
||||
sessions,
|
||||
totalCostUSD: sessions.reduce((sum, item) => sum + item.totalCostUSD, 0),
|
||||
totalSavingsUSD: sessions.reduce((sum, item) => sum + item.totalSavingsUSD, 0),
|
||||
totalApiCalls: sessions.reduce((sum, item) => sum + item.apiCalls, 0),
|
||||
totalProxiedCostUSD: 0,
|
||||
}
|
||||
}
|
||||
|
||||
describe('optimize sidechain population (issue #974)', () => {
|
||||
it('does not recommend a model default from sidechain-only edit behavior', async () => {
|
||||
const sonnetTurns = Array.from({ length: 35 }, (_, index) =>
|
||||
behavioralTurn('claude-sonnet-4-20250514', index, {
|
||||
retries: index >= 32 ? 1 : 0,
|
||||
costUSD: 2,
|
||||
}))
|
||||
const haikuTurns = Array.from({ length: 32 }, (_, index) =>
|
||||
behavioralTurn('claude-haiku-3-5-20241022', index + 35, {
|
||||
retries: index >= 29 ? 1 : 0,
|
||||
costUSD: 0.9,
|
||||
}))
|
||||
const child = sidechain('agent-behavior', { turns: [...sonnetTurns, ...haikuTurns] })
|
||||
const projects = [project([child])]
|
||||
|
||||
const result = await scanAndDetect(projects, {
|
||||
start: new Date('2026-08-01T00:00:00.000Z'),
|
||||
end: new Date('2026-08-02T00:00:00.000Z'),
|
||||
})
|
||||
|
||||
expect(result.modelRecommendations).toEqual([])
|
||||
})
|
||||
|
||||
it('does not emit coaching from sidechain-only correction behavior', () => {
|
||||
const turns = Array.from({ length: 66 }, (_, index) =>
|
||||
behavioralTurn('claude-sonnet-4-20250514', index, {
|
||||
userMessage: index === 0 ? 'review the code' : 'you missed the edge case',
|
||||
}))
|
||||
const child = sidechain('agent-corrections', {
|
||||
totalCostUSD: 9,
|
||||
totalInputTokens: 6_600,
|
||||
totalOutputTokens: 3_300,
|
||||
apiCalls: 66,
|
||||
turns,
|
||||
})
|
||||
const projects = [project([child])]
|
||||
const result: OptimizeResult = {
|
||||
findings: [],
|
||||
costRate: computeInputCostRate(projects),
|
||||
healthScore: 100,
|
||||
healthGrade: 'A',
|
||||
modelRecommendations: [],
|
||||
}
|
||||
|
||||
const report = buildOptimizeJsonReport(projects, 'fixture', result)
|
||||
|
||||
expect(report.coachingNotes).toEqual([])
|
||||
expect(report.summary.periodCostUSD).toBe(9)
|
||||
expect(report.summary.calls).toBe(66)
|
||||
})
|
||||
|
||||
it('does not report retry-heavy capabilities from sidechain-only edits', () => {
|
||||
const turns = Array.from({ length: 5 }, (_, index) => {
|
||||
const item = behavioralTurn('claude-sonnet-4-20250514', index, {
|
||||
retries: index < 3 ? 1 : 0,
|
||||
})
|
||||
item.assistantCalls[0]!.skills = ['reviewer']
|
||||
return item
|
||||
})
|
||||
const child = sidechain('agent-capability', { turns })
|
||||
|
||||
expect(detectCapabilityReliability([project([child])])).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps sidechain spend out of the low-worth candidate population', () => {
|
||||
const parent = session('parent', {
|
||||
totalCostUSD: 4,
|
||||
turns: [],
|
||||
})
|
||||
const child = sidechain('agent-child', {
|
||||
totalCostUSD: 12,
|
||||
turns: [],
|
||||
})
|
||||
|
||||
expect(findLowWorthCandidates([project([parent, child])]).map(item => item.sessionId))
|
||||
.toEqual(['parent'])
|
||||
})
|
||||
|
||||
it('does not use a sidechain as a context-heavy candidate or growth baseline', () => {
|
||||
const baseline = session('parent-baseline', {
|
||||
firstTimestamp: '2026-08-01T10:00:00.000Z',
|
||||
totalInputTokens: 20_000,
|
||||
totalOutputTokens: 2_000,
|
||||
})
|
||||
const child = sidechain('agent-child', {
|
||||
firstTimestamp: '2026-08-02T10:00:00.000Z',
|
||||
totalInputTokens: 200_000,
|
||||
totalOutputTokens: 100,
|
||||
})
|
||||
const candidate = session('parent-candidate', {
|
||||
firstTimestamp: '2026-08-03T10:00:00.000Z',
|
||||
totalInputTokens: 100_000,
|
||||
totalOutputTokens: 2_000,
|
||||
})
|
||||
|
||||
const candidates = findContextBloatCandidates([project([baseline, child, candidate])])
|
||||
|
||||
expect(candidates.map(item => item.sessionId)).toEqual(['parent-candidate'])
|
||||
expect(candidates[0]!.growthRatio).toBe(5)
|
||||
})
|
||||
|
||||
it('does not let a sidechain satisfy the peer-sample minimum for cost outliers', () => {
|
||||
const sessions = [
|
||||
session('parent-cheap', { totalCostUSD: 1 }),
|
||||
session('parent-expensive', { totalCostUSD: 10 }),
|
||||
sidechain('agent-cheap', { totalCostUSD: 1 }),
|
||||
]
|
||||
|
||||
expect(detectSessionOutliers([project(sessions)])).toBeNull()
|
||||
})
|
||||
|
||||
it('never reports an expensive sidechain as a parent-session cost outlier', () => {
|
||||
const sessions = [
|
||||
session('parent-1', { totalCostUSD: 1 }),
|
||||
session('parent-2', { totalCostUSD: 1 }),
|
||||
session('parent-3', { totalCostUSD: 1 }),
|
||||
sidechain('agent-expensive', { totalCostUSD: 100 }),
|
||||
]
|
||||
|
||||
expect(detectSessionOutliers([project(sessions)])).toBeNull()
|
||||
})
|
||||
|
||||
it('counts only parent sessions while conserving sidechain cost, calls, and tokens', () => {
|
||||
const projects = [project([
|
||||
session('parent', {
|
||||
totalCostUSD: 3,
|
||||
totalInputTokens: 100,
|
||||
totalOutputTokens: 20,
|
||||
apiCalls: 2,
|
||||
}),
|
||||
sidechain('agent-child', {
|
||||
totalCostUSD: 7,
|
||||
totalInputTokens: 900,
|
||||
totalOutputTokens: 80,
|
||||
apiCalls: 4,
|
||||
}),
|
||||
])]
|
||||
const result: OptimizeResult = {
|
||||
findings: [],
|
||||
costRate: computeInputCostRate(projects),
|
||||
healthScore: 100,
|
||||
healthGrade: 'A',
|
||||
}
|
||||
|
||||
const report = buildOptimizeJsonReport(projects, 'fixture', result)
|
||||
|
||||
expect(report.summary.sessions).toBe(1)
|
||||
expect(report.summary.periodCostUSD).toBe(10)
|
||||
expect(report.summary.calls).toBe(6)
|
||||
// Input-cost calibration keeps all spend and all input/cache tokens:
|
||||
// ($10 * 0.7) / (100 + 900) tokens.
|
||||
expect(report.summary.costRateUSD).toBeCloseTo(0.007, 12)
|
||||
})
|
||||
|
||||
it('keeps sidechain spend out of every per-session finding in the optimize pipeline', async () => {
|
||||
const projects = [project([
|
||||
sidechain('agent-only', {
|
||||
totalCostUSD: 100,
|
||||
totalInputTokens: 1_000_000,
|
||||
totalOutputTokens: 100,
|
||||
}),
|
||||
])]
|
||||
|
||||
const result = await scanAndDetect(projects, {
|
||||
start: new Date('2026-08-01T00:00:00.000Z'),
|
||||
end: new Date('2026-08-02T00:00:00.000Z'),
|
||||
})
|
||||
|
||||
expect(result.findings.map(finding => finding.id)).not.toContain('low-worth-sessions')
|
||||
expect(result.findings.map(finding => finding.id)).not.toContain('context-heavy-sessions')
|
||||
expect(result.findings.map(finding => finding.id)).not.toContain('cost-outliers')
|
||||
})
|
||||
|
||||
it('uses the parent-session count in the text optimize headline', async () => {
|
||||
const projects = [project([
|
||||
session('parent', {
|
||||
totalCostUSD: 1,
|
||||
bashBreakdown: { 'git commit -m shipped': { calls: 1 } },
|
||||
}),
|
||||
sidechain('agent-child', {
|
||||
totalCostUSD: 2,
|
||||
bashBreakdown: { 'git commit -m irrelevant': { calls: 1 } },
|
||||
}),
|
||||
])]
|
||||
const log = vi.spyOn(console, 'log').mockImplementation(() => undefined)
|
||||
const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true)
|
||||
try {
|
||||
await runOptimize(projects, 'fixture', {
|
||||
start: new Date('2026-08-01T00:00:00.000Z'),
|
||||
end: new Date('2026-08-02T00:00:00.000Z'),
|
||||
})
|
||||
const output = String(log.mock.calls.at(-1)?.[0] ?? '')
|
||||
expect(output).toContain('1 session')
|
||||
expect(output).not.toContain('2 sessions')
|
||||
} finally {
|
||||
log.mockRestore()
|
||||
stderr.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('separates cached optimize results when only sidechain classification changes', () => {
|
||||
const range = {
|
||||
start: new Date('2026-08-01T00:00:00.000Z'),
|
||||
end: new Date('2026-08-02T00:00:00.000Z'),
|
||||
}
|
||||
const parentOnly = project([session('same')])
|
||||
const sidechainOnly = project([sidechain('same')])
|
||||
|
||||
expect(parentOnly.totalCostUSD).toBe(sidechainOnly.totalCostUSD)
|
||||
expect(parentOnly.totalApiCalls).toBe(sidechainOnly.totalApiCalls)
|
||||
expect(cacheKey([parentOnly], range)).not.toBe(cacheKey([sidechainOnly], range))
|
||||
|
||||
const firstSidechain = project([session('first'), sidechain('second')])
|
||||
const secondSidechain = project([sidechain('first'), session('second')])
|
||||
expect(firstSidechain.sessions.length).toBe(secondSidechain.sessions.length)
|
||||
expect(firstSidechain.totalCostUSD).toBe(secondSidechain.totalCostUSD)
|
||||
expect(firstSidechain.sessions.filter(item => item.isSidechain).length)
|
||||
.toBe(secondSidechain.sessions.filter(item => item.isSidechain).length)
|
||||
expect(cacheKey([firstSidechain], range)).not.toBe(cacheKey([secondSidechain], range))
|
||||
})
|
||||
})
|
||||
|
|
@ -26,12 +26,16 @@ import {
|
|||
computeHealth,
|
||||
computeTrend,
|
||||
buildOptimizeJsonReport,
|
||||
renderOptimize,
|
||||
findingBasis,
|
||||
type FindingId,
|
||||
type ToolCall,
|
||||
type ApiCallMeta,
|
||||
type WasteFinding,
|
||||
type OptimizeResult,
|
||||
} from '../src/optimize.js'
|
||||
import type { ProjectSummary } from '../src/types.js'
|
||||
import type { AppliedFix } from '../src/act/types.js'
|
||||
|
||||
function call(name: string, input: Record<string, unknown>, sessionId = 's1', project = 'p1'): ToolCall {
|
||||
return { name, input, sessionId, project }
|
||||
|
|
@ -1004,6 +1008,29 @@ describe('detectSessionOutliers', () => {
|
|||
expect(finding!.tokensSaved).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('keeps estimated-cost sessions out of the peer math', () => {
|
||||
const project = projectWithSessions([1, 1, 1, 10])
|
||||
// The expensive session is priced from modelled tokens, so it is not
|
||||
// comparable against the provider-reported peers and never gets flagged.
|
||||
project.sessions[3]!.totalEstimatedCostUSD = project.sessions[3]!.totalCostUSD
|
||||
expect(detectSessionOutliers([project])).toBeNull()
|
||||
})
|
||||
|
||||
it('falls back to estimated costs when nothing else is priced, and says so', () => {
|
||||
const project = projectWithSessions([1, 1, 1, 10])
|
||||
for (const s of project.sessions) s.totalEstimatedCostUSD = s.totalCostUSD
|
||||
const finding = detectSessionOutliers([project])
|
||||
expect(finding).not.toBeNull()
|
||||
expect(finding!.basis).toBe('estimated')
|
||||
expect(findingBasis(finding!)).toBe('estimated')
|
||||
})
|
||||
|
||||
it('reports measured basis when every peer cost is provider-reported', () => {
|
||||
const finding = detectSessionOutliers([projectWithSessions([1, 1, 1, 10])])
|
||||
expect(finding!.basis).toBeUndefined()
|
||||
expect(findingBasis(finding!)).toBe('measured')
|
||||
})
|
||||
|
||||
it('ignores tiny absolute-cost outliers', () => {
|
||||
expect(detectSessionOutliers([projectWithSessions([0.01, 0.01, 0.01, 0.2])])).toBeNull()
|
||||
})
|
||||
|
|
@ -1197,9 +1224,9 @@ describe('paste-fix destination tagging (issue #277)', () => {
|
|||
if (f.fix.type === 'paste') {
|
||||
expect(
|
||||
f.fix.destination,
|
||||
`finding "${f.title}" has paste fix without destination — pick one of: claude-md / session-opener / prompt / shell-config`
|
||||
`finding "${f.title}" has paste fix without destination — pick one of: claude-md / session-opener / prompt / shell-config / manual`
|
||||
).toBeDefined()
|
||||
expect(['claude-md', 'session-opener', 'prompt', 'shell-config'])
|
||||
expect(['claude-md', 'session-opener', 'prompt', 'shell-config', 'manual'])
|
||||
.toContain(f.fix.destination)
|
||||
}
|
||||
}
|
||||
|
|
@ -1240,6 +1267,7 @@ describe('buildOptimizeJsonReport', () => {
|
|||
healthGrade: 'C',
|
||||
findings: [
|
||||
{
|
||||
id: 'claude-md-too-long',
|
||||
title: 'Trim stale context',
|
||||
explanation: 'Old instructions are loaded every turn.',
|
||||
impact: 'medium',
|
||||
|
|
@ -1283,12 +1311,25 @@ describe('buildOptimizeJsonReport', () => {
|
|||
potentialSavingsPercent: 20,
|
||||
costRateUSD: 0.00002,
|
||||
})
|
||||
expect(report.summary.measuredSavingsUSD).toBe(0)
|
||||
expect(report.summary.byClass).toEqual({
|
||||
fix: { tokensSaved: 0, savingsUSD: 0, count: 0 },
|
||||
nudge: { tokensSaved: 50_000, savingsUSD: 1, count: 1 },
|
||||
keep: { tokensSaved: 0, savingsUSD: 0, count: 0 },
|
||||
})
|
||||
const classes = Object.values(report.summary.byClass)
|
||||
expect(classes.reduce((s, c) => s + c.tokensSaved, 0)).toBe(report.summary.potentialSavingsTokens)
|
||||
expect(classes.reduce((s, c) => s + c.savingsUSD, 0)).toBeCloseTo(report.summary.potentialSavingsCostUSD, 10)
|
||||
expect(classes.reduce((s, c) => s + c.count, 0)).toBe(report.summary.findingCount)
|
||||
expect(report.appliedFixes).toEqual([])
|
||||
expect(report.findings[0]).toMatchObject({
|
||||
title: 'Trim stale context',
|
||||
severity: 'medium',
|
||||
trend: 'active',
|
||||
tokensSaved: 50_000,
|
||||
estimatedSavingsUSD: 1,
|
||||
class: 'nudge',
|
||||
basis: 'estimated',
|
||||
fix: {
|
||||
type: 'paste',
|
||||
destination: 'claude-md',
|
||||
|
|
@ -1296,3 +1337,95 @@ describe('buildOptimizeJsonReport', () => {
|
|||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderOptimize grouping', () => {
|
||||
const plain = (s: string): string => s.replace(/\[[0-9;]*m/g, '')
|
||||
|
||||
function finding(id: FindingId, title: string): WasteFinding {
|
||||
return {
|
||||
id,
|
||||
title,
|
||||
explanation: 'why',
|
||||
impact: 'medium',
|
||||
tokensSaved: 1000,
|
||||
fix: { type: 'paste', destination: 'prompt', label: 'ask', text: 'ask' },
|
||||
}
|
||||
}
|
||||
|
||||
it('groups findings under fix / habits / FYI with continuous numbering and a basis split', () => {
|
||||
const findings = [
|
||||
finding('bash-output-cap', 'Cap bash output'),
|
||||
finding('claude-md-too-long', 'Trim CLAUDE.md'),
|
||||
finding('context-heavy-sessions', 'Context-heavy sessions'),
|
||||
]
|
||||
const out = plain(renderOptimize(findings, 0.00001, '7 Days', 10, 5, 100, 80, 'B', [], []))
|
||||
|
||||
const headers = [
|
||||
'Fix now (apply-able) · ~1.0K tokens (~$0.010) · 1 finding — codeburn optimize --apply',
|
||||
'Habits · ~1.0K tokens (~$0.010) · 1 finding',
|
||||
'FYI · ~1.0K tokens (~$0.010) · 1 finding',
|
||||
].map(h => out.indexOf(h))
|
||||
expect(headers.every(i => i >= 0)).toBe(true)
|
||||
expect(headers).toEqual([...headers].sort((a, b) => a - b))
|
||||
// Headline is the whole board; the apply-able slice is named separately.
|
||||
expect(out).toContain('Potential savings: ~3.0K tokens (~$0.030, ~0.3% of spend) — apply-able: ~$0.010')
|
||||
expect(out).toContain('1. Cap bash output')
|
||||
expect(out).toContain('2. Trim CLAUDE.md')
|
||||
expect(out).toContain('3. Context-heavy sessions')
|
||||
expect(out).toContain('1 measured · 2 estimated')
|
||||
expect(out).not.toContain('Estimates only.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderOptimize applied-fixes section', () => {
|
||||
const plain = (s: string): string => s.replace(/\[[0-9;]*m/g, '')
|
||||
|
||||
function fixture(over: Partial<AppliedFix>): AppliedFix {
|
||||
return {
|
||||
id: 'abcdef12',
|
||||
kind: 'mcp-remove',
|
||||
findingId: 'unused-mcp',
|
||||
appliedAt: '2026-05-01T00:00:00.000Z',
|
||||
ageDays: 4,
|
||||
verdict: 'worked',
|
||||
estimatedTokens: 300_000,
|
||||
realizedTokens: 280_000,
|
||||
note: '',
|
||||
undoCommand: 'codeburn act undo abcdef12',
|
||||
...over,
|
||||
}
|
||||
}
|
||||
|
||||
const findings: WasteFinding[] = [{
|
||||
id: 'bash-output-cap',
|
||||
title: 'Cap bash output',
|
||||
explanation: 'why',
|
||||
impact: 'medium',
|
||||
tokensSaved: 1000,
|
||||
fix: { type: 'paste', destination: 'prompt', label: 'ask', text: 'ask' },
|
||||
}]
|
||||
|
||||
const render = (appliedFixes: AppliedFix[], f = findings): string =>
|
||||
plain(renderOptimize(f, 0.00001, '7 Days', 10, 5, 100, 80, 'B', [], [], undefined, undefined, undefined, appliedFixes))
|
||||
|
||||
it('renders one line per verdict with its own glyph', () => {
|
||||
const out = render([
|
||||
fixture({}),
|
||||
fixture({ id: 'b', findingId: 'mcp-defer-threshold', verdict: 'partial', ageDays: 3, estimatedTokens: 600_000, realizedTokens: 420_000 }),
|
||||
fixture({ id: 'c', findingId: 'bash-output-cap', verdict: 'no-effect', ageDays: 5, estimatedTokens: 41_000, realizedTokens: 0, undoCommand: 'codeburn act undo cccccccc' }),
|
||||
fixture({ id: 'd', findingId: 'mcp-remove-linear', verdict: 'pending', ageDays: 1 }),
|
||||
])
|
||||
|
||||
expect(out).toContain('Applied fixes')
|
||||
expect(out).toContain('\u2713 unused-mcp (4d ago): est. 300.0K -> measured 280.0K')
|
||||
expect(out).toContain('~ mcp-defer-threshold (3d ago): est. 600.0K -> measured 420.0K (-30% vs estimate)')
|
||||
expect(out).toContain('\u2717 bash-output-cap (5d ago): est. 41.0K -> measured 0 - did not help. Revert: codeburn act undo cccccccc')
|
||||
expect(out).toContain('\u2026 mcp-remove-linear (1d ago): measuring, check back after 3 days')
|
||||
})
|
||||
|
||||
it('shows the section on a clean setup too, and omits it when nothing is applied', () => {
|
||||
expect(render([fixture({})], [])).toContain('Applied fixes')
|
||||
expect(render([])).not.toContain('Applied fixes')
|
||||
expect(render([], [])).not.toContain('Applied fixes')
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ function largeUserLine(): string {
|
|||
function largeAssistantLine(): string {
|
||||
return JSON.stringify({
|
||||
type: 'assistant',
|
||||
isSidechain: true,
|
||||
sessionId: 's1',
|
||||
timestamp: '2026-05-01T00:00:01Z',
|
||||
cwd: '/repo',
|
||||
|
|
@ -55,6 +56,7 @@ describe('large JSONL compact scanner', () => {
|
|||
|
||||
it('extracts capped tool inputs needed by optimize', () => {
|
||||
const parsed = parseJsonlLine(Buffer.from(largeAssistantLine()))
|
||||
expect(parsed?.isSidechain).toBe(true)
|
||||
const msg = parsed?.message
|
||||
expect(msg?.role).toBe('assistant')
|
||||
if (msg?.role !== 'assistant') return
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ function assistantLine(sessionId: string, timestamp: string, messageId: string,
|
|||
|
||||
function messageFirstLargeAssistantLine(sessionId: string, timestamp: string, messageId: string): string {
|
||||
const hugeText = 'y'.repeat(3_000_000)
|
||||
return `{"parentUuid":"u1","isSidechain":false,"message":{"model":"claude-sonnet-4-5","id":"${messageId}","type":"message","role":"assistant","content":[{"type":"text","text":"${hugeText}"},{"type":"tool_use","id":"tu-large","name":"Edit","input":{"file_path":"/tmp/x","old_string":"a","new_string":"b"}}],"usage":{"input_tokens":1000,"output_tokens":100,"cache_read_input_tokens":5000}},"uuid":"a1","timestamp":"${timestamp}","type":"assistant","sessionId":"${sessionId}","cwd":"/projects/app"}`
|
||||
return `{"parentUuid":"u1","isSidechain":true,"message":{"model":"claude-sonnet-4-5","id":"${messageId}","type":"message","role":"assistant","content":[{"type":"text","text":"${hugeText}"},{"type":"tool_use","id":"tu-large","name":"Edit","input":{"file_path":"/tmp/x","old_string":"a","new_string":"b"}}],"usage":{"input_tokens":1000,"output_tokens":100,"cache_read_input_tokens":5000}},"uuid":"a1","timestamp":"${timestamp}","type":"assistant","sessionId":"${sessionId}","cwd":"/projects/app"}`
|
||||
}
|
||||
|
||||
function attachmentLine(sessionId: string, timestamp: string): string {
|
||||
|
|
@ -227,6 +227,7 @@ describe('parseAllSessions with large Claude fixture', () => {
|
|||
expect(projects.length).toBeGreaterThan(0)
|
||||
|
||||
const sess = projects[0]!.sessions[0]!
|
||||
expect(sess.isSidechain).toBe(true)
|
||||
expect(sess.apiCalls).toBe(1)
|
||||
expect(sess.totalInputTokens).toBe(1000)
|
||||
expect(sess.totalOutputTokens).toBe(100)
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { join } from 'path'
|
|||
import { tmpdir } from 'os'
|
||||
|
||||
import { parseAllSessions, filterProjectsByDays, clearSessionCache } from '../src/parser.js'
|
||||
import { clearLoadCacheMemo } from '../src/session-cache.js'
|
||||
import { loadPricing } from '../src/models.js'
|
||||
import { aggregateByPr, prLinkedTotals } from '../src/sessions-report.js'
|
||||
|
||||
|
|
@ -64,8 +65,16 @@ describe('subagent fold across a date-range boundary', () => {
|
|||
const projects = await parseAllSessions(range, 'claude')
|
||||
|
||||
// The child session is present (its work is in range) as a standalone session.
|
||||
const childPresent = projects.some(p => p.sessions.some(s => s.sessionId === `agent-${AGENT}`))
|
||||
expect(childPresent).toBe(true)
|
||||
const child = projects.flatMap(p => p.sessions).find(s => s.sessionId === `agent-${AGENT}`)
|
||||
expect(child).toBeDefined()
|
||||
expect(child!.isSidechain).toBe(true)
|
||||
// Drop both in-process memo layers so the second parse reloads the persisted
|
||||
// session cache. The marker must survive that warm-disk path too, not only
|
||||
// the cold transcript parse.
|
||||
clearSessionCache()
|
||||
clearLoadCacheMemo()
|
||||
const warmProjects = await parseAllSessions(range, 'claude')
|
||||
expect(warmProjects.flatMap(p => p.sessions).find(s => s.sessionId === `agent-${AGENT}`)?.isSidechain).toBe(true)
|
||||
// The anchor parent (0 in-range turns) must NOT contaminate the sessions list;
|
||||
// it lives in subagentAnchors only.
|
||||
const anchorInSessions = projects.some(p => p.sessions.some(s => s.sessionId === PARENT))
|
||||
|
|
@ -101,6 +110,7 @@ describe('subagent fold across a date-range boundary', () => {
|
|||
const dayFiltered = filterProjectsByDays(projects, new Set(['2026-07-20']))
|
||||
expect(dayFiltered.some(p => p.sessions.some(s => s.sessionId === PARENT))).toBe(false) // parent no longer a session
|
||||
expect(dayFiltered.some(p => (p.subagentAnchors ?? []).some(s => s.sessionId === PARENT))).toBe(true) // kept as anchor
|
||||
expect(dayFiltered.flatMap(p => p.sessions).find(s => s.sessionId === `agent-${AGENT}`)?.isSidechain).toBe(true)
|
||||
|
||||
// The child's spend still folds to the PR through the anchor.
|
||||
const row = aggregateByPr(dayFiltered).find(r => r.url === PR)
|
||||
|
|
|
|||
|
|
@ -24,6 +24,7 @@ import type { SessionSource, SessionParser, ParsedProviderCall } from '../src/pr
|
|||
let _synthSources: SessionSource[] = []
|
||||
let _synthDurable = false
|
||||
let _synthYields: ParsedProviderCall[] = []
|
||||
let _synthParseCalls = 0
|
||||
let _synthOnParse: (() => void | Promise<void>) | null = null
|
||||
|
||||
vi.mock('../src/providers/index.js', async (importOriginal) => {
|
||||
|
|
@ -54,6 +55,7 @@ vi.mock('../src/providers/index.js', async (importOriginal) => {
|
|||
createSessionParser(_s: SessionSource, _k: Set<string>): SessionParser {
|
||||
return {
|
||||
async *parse(): AsyncGenerator<ParsedProviderCall> {
|
||||
_synthParseCalls++
|
||||
await _synthOnParse?.()
|
||||
for (const call of _synthYields) {
|
||||
// Respect seenKeys so that when multiple sources share the same
|
||||
|
|
@ -193,6 +195,7 @@ beforeEach(async () => {
|
|||
_synthSources = []
|
||||
_synthDurable = false
|
||||
_synthYields = []
|
||||
_synthParseCalls = 0
|
||||
_synthOnParse = null
|
||||
})
|
||||
|
||||
|
|
@ -360,7 +363,7 @@ describe('(d) non-durable provider evicts deleted sources', () => {
|
|||
// (e) 90-day age-out: orphan ≥ 91d old is pruned; ≤ 89d is retained
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
describe('(e) 90-day age-out for durable providers', () => {
|
||||
it('prunes an orphaned cache entry whose newest call is 91 days old', async () => {
|
||||
it('keeps a discovered 91-day source persisted until discovery removes it', async () => {
|
||||
const synthFile = join(tmpHome, 'synth-age.txt')
|
||||
await writeFile(synthFile, 'placeholder')
|
||||
|
||||
|
|
@ -380,15 +383,76 @@ describe('(e) 90-day age-out for durable providers', () => {
|
|||
userMessage: 'old', sessionId: 'synth-old',
|
||||
}]
|
||||
|
||||
// First parse: cached with 91d-old timestamp → immediately pruned by 90-day check
|
||||
// First refresh: a still-discovered durable source is live and persisted,
|
||||
// regardless of the age of its newest call.
|
||||
const proj1 = await parseAllSessions(undefined, 'test-synthetic')
|
||||
expect(totalOutput(proj1)).toBe(0) // pruned right away
|
||||
expect.soft(totalOutput(proj1)).toBe(8)
|
||||
expect.soft(_synthParseCalls).toBe(1)
|
||||
|
||||
// Confirm: entry is not in the persistent cache after first parse
|
||||
const cache1 = await loadCache()
|
||||
const persisted1 = cache1.providers['test-synthetic']?.files[synthFile]
|
||||
expect.soft(persisted1).toBeDefined()
|
||||
|
||||
// Second refresh: force the public seam through the persisted cache. The
|
||||
// unchanged fingerprint must serve the cached parse without invoking the
|
||||
// provider parser again.
|
||||
clearSessionCache()
|
||||
_synthSources = [] // no longer discovered
|
||||
const proj2 = await parseAllSessions(undefined, 'test-synthetic')
|
||||
expect(totalOutput(proj2)).toBe(0)
|
||||
expect.soft(totalOutput(proj2)).toBe(8)
|
||||
expect.soft(_synthParseCalls).toBe(1)
|
||||
|
||||
const cache2 = await loadCache()
|
||||
expect.soft(cache2.providers['test-synthetic']?.files[synthFile]?.fingerprint)
|
||||
.toEqual(persisted1?.fingerprint)
|
||||
|
||||
// Third refresh: once discovery removes the old source, it becomes an
|
||||
// orphan and the durable 90-day age-out prunes it from results and disk.
|
||||
clearSessionCache()
|
||||
_synthSources = []
|
||||
const proj3 = await parseAllSessions(undefined, 'test-synthetic')
|
||||
expect.soft(totalOutput(proj3)).toBe(0)
|
||||
|
||||
const cache3 = await loadCache()
|
||||
expect.soft(cache3.providers['test-synthetic']?.files[synthFile]).toBeUndefined()
|
||||
})
|
||||
|
||||
it('keeps a discovered 91-day source through a month-scoped refresh', async () => {
|
||||
const synthFile = join(tmpHome, 'synth-scoped.txt')
|
||||
await writeFile(synthFile, 'placeholder')
|
||||
|
||||
const ts91dAgo = new Date(Date.now() - 91 * 24 * 60 * 60 * 1000).toISOString()
|
||||
|
||||
_synthDurable = true
|
||||
_synthSources = [{ path: synthFile, project: 'test', provider: 'test-synthetic' }]
|
||||
_synthYields = [{
|
||||
provider: 'test-synthetic', model: 'gpt-4o',
|
||||
inputTokens: 10, outputTokens: 8,
|
||||
cacheCreationInputTokens: 0, cacheReadInputTokens: 0,
|
||||
cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0,
|
||||
costUSD: 0.002, tools: [], bashCommands: [],
|
||||
timestamp: ts91dAgo,
|
||||
speed: 'standard',
|
||||
deduplicationKey: 'synth-age-out-91d-scoped',
|
||||
userMessage: 'old', sessionId: 'synth-old-scoped',
|
||||
}]
|
||||
|
||||
expect.soft(totalOutput(await parseAllSessions(undefined, 'test-synthetic'))).toBe(8)
|
||||
|
||||
// A today-ranged refresh loads under a month scope that excludes the entry's
|
||||
// shard. Durable providers are never scoped, so the age-out still sees the
|
||||
// entry as discovered and the save must carry its month across intact.
|
||||
clearSessionCache()
|
||||
const today = new Date()
|
||||
const start = new Date(today); start.setHours(0, 0, 0, 0)
|
||||
const end = new Date(today); end.setHours(23, 59, 59, 999)
|
||||
expect.soft(totalOutput(await parseAllSessions({ start, end }, 'test-synthetic'))).toBe(0)
|
||||
|
||||
clearSessionCache()
|
||||
expect.soft(totalOutput(await parseAllSessions(undefined, 'test-synthetic'))).toBe(8)
|
||||
expect.soft(_synthParseCalls).toBe(1)
|
||||
|
||||
const cache = await loadCache()
|
||||
expect.soft(cache.providers['test-synthetic']?.files[synthFile]).toBeDefined()
|
||||
})
|
||||
|
||||
it('retains an orphaned cache entry whose newest call is 89 days old', async () => {
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ const FILE_PROVIDERS: Record<string, string[]> = {
|
|||
'codex.ts': ['codex'],
|
||||
'copilot.ts': ['copilot'],
|
||||
'droid.ts': ['droid'],
|
||||
'dsh.ts': ['dsh'],
|
||||
'hermes.ts': ['hermes'],
|
||||
'lingtai-tui.ts': ['lingtai-tui'],
|
||||
// Its only literal read is CODEBURN_CURSOR_MAX_BUBBLES (cursor.ts:692).
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ function fakeProvider(name: string, discover: Provider['discoverSessions']): Pro
|
|||
|
||||
describe('provider registry', () => {
|
||||
it('has core providers registered synchronously', () => {
|
||||
expect(providers.map(p => p.name)).toEqual(['claude', 'cline', 'cline-cli', 'codewhale', 'codebuff', 'codex', 'copilot', 'devin', 'droid', 'gemini', 'hermes', 'ibm-bob', 'kilo-code', 'kiro', 'kimi', 'kimicode', 'lingtai-tui', 'mistral-vibe', 'mux', 'openclaw', 'openclaude', 'open-design', 'pi', 'omp', 'qwen', 'quickdesk', 'roo-code', 'zerostack', 'grok'])
|
||||
expect(providers.map(p => p.name)).toEqual(['claude', 'cline', 'cline-cli', 'codewhale', 'codebuff', 'codex', 'copilot', 'devin', 'droid', 'dsh', 'gemini', 'hermes', 'ibm-bob', 'kilo-code', 'kiro', 'kimi', 'kimicode', 'lingtai-tui', 'mistral-vibe', 'mux', 'openclaw', 'openclaude', 'open-design', 'pi', 'omp', 'qwen', 'quickdesk', 'roo-code', 'zerostack', 'grok'])
|
||||
})
|
||||
|
||||
it('codebuff tool display names normalize codebuff-native names to canonical set', () => {
|
||||
|
|
|
|||
620
tests/providers/dsh.test.ts
Normal file
620
tests/providers/dsh.test.ts
Normal file
|
|
@ -0,0 +1,620 @@
|
|||
import { describe, it, expect, beforeEach, afterEach } from 'vitest'
|
||||
import { mkdtemp, mkdir, writeFile, readFile, rm, stat } from 'fs/promises'
|
||||
import { join } from 'path'
|
||||
import { homedir, tmpdir } from 'os'
|
||||
import zlib from 'zlib'
|
||||
|
||||
import { createDshProvider, readZstdLines } from '../../src/providers/dsh.js'
|
||||
import { calculateCost } from '../../src/models.js'
|
||||
import type { ParsedProviderCall } from '../../src/providers/types.js'
|
||||
|
||||
// DSH session logs are concatenations of INDEPENDENT zstd frames (one per
|
||||
// appended event batch), so fixtures must compress each batch separately —
|
||||
// a single zstdCompressSync over the whole file is a different (single-frame)
|
||||
// format than what DSH writes.
|
||||
|
||||
const zstdCompress = (zlib as { zstdCompressSync?: (buf: Buffer) => Buffer }).zstdCompressSync
|
||||
// node:zlib gained zstd in 22.15; the package floor (and CI's pinned Node) is
|
||||
// 22.13. Container-specific tests skip there; the rest fall back to plain jsonl
|
||||
// so the parsing semantics are still exercised.
|
||||
const itZstd = zstdCompress ? it : it.skip
|
||||
|
||||
let tmpDir: string
|
||||
|
||||
beforeEach(async () => {
|
||||
tmpDir = await mkdtemp(join(tmpdir(), 'dsh-test-'))
|
||||
})
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function sessionHeader(opts: { id?: string; cwd?: string } = {}) {
|
||||
return JSON.stringify({
|
||||
type: 'session',
|
||||
version: 0,
|
||||
id: opts.id ?? 'session-00000000-0000-0000-0000-000000000001',
|
||||
createdAt: 1786707336131,
|
||||
cwd: opts.cwd ?? 'C:\\Users\\test\\myproject',
|
||||
delegationDepth: 0,
|
||||
agentPreset: 'cordis',
|
||||
})
|
||||
}
|
||||
|
||||
function requestHeader(model: string, time = 1786707337000) {
|
||||
return JSON.stringify({
|
||||
type: 'request/header',
|
||||
seq: 10,
|
||||
time,
|
||||
data: { header: { config: { provider: 'deepseek-official', model, reasoningEffort: 'max', maxTokens: 256000 } } },
|
||||
})
|
||||
}
|
||||
|
||||
function turnStart(turn: number, time: number) {
|
||||
return JSON.stringify({ type: 'turn/start', seq: 1, time, data: { turn } })
|
||||
}
|
||||
|
||||
function userMessage(text: string, time: number) {
|
||||
return JSON.stringify({
|
||||
type: 'user/message',
|
||||
seq: 2,
|
||||
time,
|
||||
data: { content: [{ type: 'text', text }], source: { kind: 'user' }, role: 'user', id: 'msg-1' },
|
||||
})
|
||||
}
|
||||
|
||||
function chunkUsage(turn: number, step: number, usage: Record<string, number>, time: number) {
|
||||
return JSON.stringify({
|
||||
type: 'assistant/chunk',
|
||||
seq: 3,
|
||||
time,
|
||||
data: { turn, step, chunk: { type: 'usage', usage } },
|
||||
})
|
||||
}
|
||||
|
||||
function assistantMessage(turn: number, step: number, usage: Record<string, number> | undefined, time: number) {
|
||||
return JSON.stringify({
|
||||
type: 'assistant/message',
|
||||
seq: 4,
|
||||
time,
|
||||
data: {
|
||||
turn,
|
||||
step,
|
||||
message: { role: 'assistant', content: [{ type: 'text', text: 'done' }] },
|
||||
...(usage ? { usage } : {}),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function toolCall(turn: number, step: number, name: string, args: Record<string, unknown>, time: number) {
|
||||
return JSON.stringify({
|
||||
type: 'tool/call',
|
||||
seq: 5,
|
||||
time,
|
||||
data: { turn, step, callId: `call_${name}`, name, arguments: JSON.stringify(args) },
|
||||
})
|
||||
}
|
||||
|
||||
// Write one frame per batch of lines, matching DSH's append-per-batch layout.
|
||||
async function writeZstdSession(projectDirName: string, sessionDirName: string, batches: string[][]) {
|
||||
const dir = join(tmpDir, 'sessions', projectDirName, sessionDirName)
|
||||
await mkdir(dir, { recursive: true })
|
||||
if (!zstdCompress) {
|
||||
const filePath = join(dir, 'session.jsonl')
|
||||
await writeFile(filePath, batches.map(lines => lines.join('\n') + '\n').join(''))
|
||||
return filePath
|
||||
}
|
||||
const filePath = join(dir, 'session.jsonl.zstd')
|
||||
const frames = batches.map(lines => zstdCompress(Buffer.from(lines.join('\n') + '\n', 'utf-8')))
|
||||
await writeFile(filePath, Buffer.concat(frames))
|
||||
return filePath
|
||||
}
|
||||
|
||||
async function writePlainSession(projectDirName: string, sessionDirName: string, lines: string[]) {
|
||||
const dir = join(tmpDir, 'sessions', projectDirName, sessionDirName)
|
||||
await mkdir(dir, { recursive: true })
|
||||
const filePath = join(dir, 'session.jsonl')
|
||||
await writeFile(filePath, lines.join('\n') + '\n')
|
||||
return filePath
|
||||
}
|
||||
|
||||
async function parseAll(provider: ReturnType<typeof createDshProvider>, filePath: string): Promise<ParsedProviderCall[]> {
|
||||
const source = { path: filePath, project: 'myproject', provider: 'dsh' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, new Set()).parse()) {
|
||||
calls.push(call)
|
||||
}
|
||||
return calls
|
||||
}
|
||||
|
||||
describe('dsh provider - session discovery', () => {
|
||||
itZstd('discovers a multi-frame zstd session, project from the header cwd', async () => {
|
||||
await writeZstdSession('--C-Users-test-myproject--', 'session-abc', [
|
||||
[sessionHeader({ cwd: 'C:\\Users\\test\\myproject' })],
|
||||
[assistantMessage(1, 1, { inputTokens: 100, outputTokens: 10 }, 1786707340000)],
|
||||
])
|
||||
|
||||
const provider = createDshProvider(tmpDir)
|
||||
const sessions = await provider.discoverSessions()
|
||||
|
||||
expect(sessions).toHaveLength(1)
|
||||
expect(sessions[0]!.provider).toBe('dsh')
|
||||
expect(sessions[0]!.project).toBe('myproject')
|
||||
expect(sessions[0]!.path).toContain('session.jsonl.zstd')
|
||||
})
|
||||
|
||||
it('discovers the uncompressed session.jsonl variant (compression=none)', async () => {
|
||||
await writePlainSession('--home-u-proj--', 'session-plain', [
|
||||
sessionHeader({ cwd: '/home/u/proj' }),
|
||||
assistantMessage(1, 1, { inputTokens: 100, outputTokens: 10 }, 1786707340000),
|
||||
])
|
||||
|
||||
const provider = createDshProvider(tmpDir)
|
||||
const sessions = await provider.discoverSessions()
|
||||
|
||||
expect(sessions).toHaveLength(1)
|
||||
expect(sessions[0]!.path).toContain('session.jsonl')
|
||||
expect(sessions[0]!.path).not.toContain('zstd')
|
||||
expect(sessions[0]!.project).toBe('proj')
|
||||
})
|
||||
|
||||
it('returns empty for a non-existent home', async () => {
|
||||
const provider = createDshProvider('/nonexistent/dsh/home')
|
||||
expect(await provider.discoverSessions()).toEqual([])
|
||||
})
|
||||
|
||||
it('skips session dirs without a session log', async () => {
|
||||
await mkdir(join(tmpDir, 'sessions', '--x--', 'session-empty'), { recursive: true })
|
||||
const provider = createDshProvider(tmpDir)
|
||||
expect(await provider.discoverSessions()).toEqual([])
|
||||
})
|
||||
|
||||
it('DSH_HOME relocates discovery; an empty string is treated as unset', async () => {
|
||||
const home = join(tmpDir, 'dsh-home')
|
||||
await mkdir(join(home, 'sessions', '--x--', 'session-env'), { recursive: true })
|
||||
await writeFile(
|
||||
join(home, 'sessions', '--x--', 'session-env', 'session.jsonl'),
|
||||
sessionHeader({ cwd: '/x' }) + '\n',
|
||||
)
|
||||
|
||||
const saved = process.env['DSH_HOME']
|
||||
process.env['DSH_HOME'] = home
|
||||
try {
|
||||
const sessions = await createDshProvider().discoverSessions()
|
||||
expect(sessions).toHaveLength(1)
|
||||
} finally {
|
||||
if (saved === undefined) delete process.env['DSH_HOME']
|
||||
else process.env['DSH_HOME'] = saved
|
||||
}
|
||||
|
||||
process.env['DSH_HOME'] = ''
|
||||
try {
|
||||
const roots = await createDshProvider().probeRoots!()
|
||||
expect(roots).toEqual([{ path: join(homedir(), '.dsh', 'sessions'), label: 'sessions' }])
|
||||
} finally {
|
||||
if (saved === undefined) delete process.env['DSH_HOME']
|
||||
else process.env['DSH_HOME'] = saved
|
||||
}
|
||||
})
|
||||
|
||||
it('probeRoots reports the sessions dir under the factory root', async () => {
|
||||
expect(await createDshProvider('/tmp/dsh-a').probeRoots!()).toEqual([
|
||||
{ path: join('/tmp/dsh-a', 'sessions'), label: 'sessions' },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('dsh provider - parsing', () => {
|
||||
itZstd('decodes events spread across multiple independent zstd frames', async () => {
|
||||
const filePath = await writeZstdSession('--C-Users-test-myproject--', 'session-multi', [
|
||||
[sessionHeader({ id: 'session-multi', cwd: 'C:\\Users\\test\\myproject' })],
|
||||
[turnStart(1, 1786707339000), userMessage('build the thing', 1786707339100)],
|
||||
[chunkUsage(1, 1, { inputTokens: 500, outputTokens: 50 }, 1786707340000)],
|
||||
[chunkUsage(1, 2, { inputTokens: 800, outputTokens: 80 }, 1786707341000)],
|
||||
])
|
||||
|
||||
const calls = await parseAll(createDshProvider(tmpDir), filePath)
|
||||
expect(calls).toHaveLength(2)
|
||||
expect(calls[0]!.inputTokens).toBe(500)
|
||||
expect(calls[1]!.inputTokens).toBe(800)
|
||||
})
|
||||
|
||||
it('a final assistant/message usage REPLACES the earlier chunk sample for the same turn/step', async () => {
|
||||
const filePath = await writeZstdSession('--C-Users-test-myproject--', 'session-replace', [
|
||||
[sessionHeader({ id: 'session-replace' })],
|
||||
[turnStart(1, 1786707339000)],
|
||||
// Early sample, then the final report of the SAME API call: the totals
|
||||
// must come from the final report only, not the sum of both.
|
||||
[chunkUsage(1, 1, { inputTokens: 14900, outputTokens: 600, reasoningTokens: 500 }, 1786707340000)],
|
||||
[assistantMessage(1, 1, { inputTokens: 14981, outputTokens: 656, cacheReadTokens: 0, reasoningTokens: 609 }, 1786707340050)],
|
||||
])
|
||||
|
||||
const calls = await parseAll(createDshProvider(tmpDir), filePath)
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.inputTokens).toBe(14981)
|
||||
expect(calls[0]!.outputTokens).toBe(656)
|
||||
expect(calls[0]!.reasoningTokens).toBe(609)
|
||||
expect(calls[0]!.timestamp).toBe(new Date(1786707340050).toISOString())
|
||||
})
|
||||
|
||||
it('a chunk sample arriving after the final report does not overwrite it', async () => {
|
||||
const filePath = await writeZstdSession('--C-Users-test-myproject--', 'session-late', [
|
||||
[sessionHeader({ id: 'session-late' })],
|
||||
[assistantMessage(1, 1, { inputTokens: 100, outputTokens: 10 }, 1786707340050)],
|
||||
[chunkUsage(1, 1, { inputTokens: 999, outputTokens: 99 }, 1786707340100)],
|
||||
])
|
||||
|
||||
const calls = await parseAll(createDshProvider(tmpDir), filePath)
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.inputTokens).toBe(100)
|
||||
})
|
||||
|
||||
it('falls back to the chunk sample when no assistant/message usage arrives', async () => {
|
||||
const filePath = await writeZstdSession('--C-Users-test-myproject--', 'session-sample', [
|
||||
[sessionHeader({ id: 'session-sample' })],
|
||||
[chunkUsage(2, 3, { inputTokens: 42, outputTokens: 7 }, 1786707340000)],
|
||||
])
|
||||
|
||||
const calls = await parseAll(createDshProvider(tmpDir), filePath)
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.inputTokens).toBe(42)
|
||||
expect(calls[0]!.deduplicationKey).toBe('dsh:session-sample:2:3')
|
||||
})
|
||||
|
||||
it('steps inherit the model of the most recent request/header', async () => {
|
||||
const filePath = await writeZstdSession('--C-Users-test-myproject--', 'session-model', [
|
||||
[sessionHeader({ id: 'session-model' })],
|
||||
[requestHeader('deepseek-v4-pro', 1786707337000)],
|
||||
[assistantMessage(1, 1, { inputTokens: 100, outputTokens: 10 }, 1786707340000)],
|
||||
[assistantMessage(1, 2, { inputTokens: 200, outputTokens: 20 }, 1786707341000)],
|
||||
[requestHeader('deepseek-v4-flash', 1786707342000)],
|
||||
[assistantMessage(2, 1, { inputTokens: 300, outputTokens: 30 }, 1786707343000)],
|
||||
])
|
||||
|
||||
const calls = await parseAll(createDshProvider(tmpDir), filePath)
|
||||
expect(calls.map(c => c.model)).toEqual(['deepseek-v4-pro', 'deepseek-v4-pro', 'deepseek-v4-flash'])
|
||||
})
|
||||
|
||||
it('bills reasoning tokens at the output rate', async () => {
|
||||
const filePath = await writeZstdSession('--C-Users-test-myproject--', 'session-reason', [
|
||||
[sessionHeader({ id: 'session-reason' })],
|
||||
[requestHeader('deepseek-v4-pro')],
|
||||
[assistantMessage(1, 1, { inputTokens: 1000, outputTokens: 100, cacheWriteTokens: 50, cacheReadTokens: 500, reasoningTokens: 400 }, 1786707340000)],
|
||||
])
|
||||
|
||||
const calls = await parseAll(createDshProvider(tmpDir), filePath)
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.costUSD).toBeCloseTo(calculateCost('deepseek-v4-pro', 1000, 500, 50, 500, 0), 12)
|
||||
})
|
||||
|
||||
it('collects mapped tools, skill names and bash commands from tool/call events', async () => {
|
||||
const filePath = await writeZstdSession('--C-Users-test-myproject--', 'session-tools', [
|
||||
[sessionHeader({ id: 'session-tools' })],
|
||||
[
|
||||
toolCall(1, 1, 'read', { path: '/x/a.ts' }, 1786707339500),
|
||||
toolCall(1, 1, 'edit', { path: '/x/a.ts' }, 1786707339600),
|
||||
toolCall(1, 1, 'bash', { command: 'git status && bun test' }, 1786707339700),
|
||||
toolCall(1, 1, 'skill', { name: 'coding-agent-orchestration' }, 1786707339800),
|
||||
toolCall(1, 1, 'cordis_run', { id: 'j1' }, 1786707339900),
|
||||
chunkUsage(1, 1, { inputTokens: 100, outputTokens: 10 }, 1786707340000),
|
||||
],
|
||||
])
|
||||
|
||||
const calls = await parseAll(createDshProvider(tmpDir), filePath)
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.tools).toEqual(['Read', 'Edit', 'Bash', 'Skill', 'cordis_run'])
|
||||
expect(calls[0]!.bashCommands).toEqual(['git', 'bun'])
|
||||
expect(calls[0]!.skills).toEqual(['coding-agent-orchestration'])
|
||||
})
|
||||
|
||||
it('pairs the user message of the turn and carries session id and project', async () => {
|
||||
const filePath = await writeZstdSession('--C-Users-test-myproject--', 'session-ctx', [
|
||||
[sessionHeader({ id: 'session-ctx', cwd: 'C:\\Users\\test\\myproject' })],
|
||||
[turnStart(1, 1786707339000), userMessage('first question', 1786707339100)],
|
||||
[chunkUsage(1, 1, { inputTokens: 100, outputTokens: 10 }, 1786707340000)],
|
||||
[turnStart(2, 1786707350000), userMessage('second question', 1786707350100)],
|
||||
[chunkUsage(2, 1, { inputTokens: 200, outputTokens: 20 }, 1786707351000)],
|
||||
])
|
||||
|
||||
const calls = await parseAll(createDshProvider(tmpDir), filePath)
|
||||
expect(calls).toHaveLength(2)
|
||||
expect(calls[0]!.userMessage).toBe('first question')
|
||||
expect(calls[1]!.userMessage).toBe('second question')
|
||||
expect(calls[0]!.sessionId).toBe('session-ctx')
|
||||
expect(calls[0]!.project).toBe('myproject')
|
||||
expect(calls[0]!.projectPath).toBe('C:\\Users\\test\\myproject')
|
||||
})
|
||||
|
||||
it('parses the uncompressed session.jsonl variant', async () => {
|
||||
const filePath = await writePlainSession('--home-u-proj--', 'session-plain', [
|
||||
sessionHeader({ id: 'session-plain', cwd: '/home/u/proj' }),
|
||||
turnStart(1, 1786707339000),
|
||||
userMessage('hello', 1786707339100),
|
||||
chunkUsage(1, 1, { inputTokens: 123, outputTokens: 45 }, 1786707340000),
|
||||
])
|
||||
|
||||
const calls = await parseAll(createDshProvider(tmpDir), filePath)
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.inputTokens).toBe(123)
|
||||
expect(calls[0]!.outputTokens).toBe(45)
|
||||
})
|
||||
|
||||
it('skips buckets whose usage is all zero', async () => {
|
||||
const filePath = await writeZstdSession('--C-Users-test-myproject--', 'session-zero', [
|
||||
[sessionHeader({ id: 'session-zero' })],
|
||||
[assistantMessage(1, 1, { inputTokens: 0, outputTokens: 0 }, 1786707340000)],
|
||||
])
|
||||
|
||||
const calls = await parseAll(createDshProvider(tmpDir), filePath)
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
|
||||
itZstd('ignores a torn final frame appended by a crashed writer', async () => {
|
||||
const dir = join(tmpDir, 'sessions', '--C-Users-test-myproject--', 'session-torn')
|
||||
await mkdir(dir, { recursive: true })
|
||||
const filePath = join(dir, 'session.jsonl.zstd')
|
||||
const good = zstdCompress!(Buffer.from(
|
||||
sessionHeader({ id: 'session-torn' }) + '\n' +
|
||||
chunkUsage(1, 1, { inputTokens: 100, outputTokens: 10 }, 1786707340000) + '\n',
|
||||
))
|
||||
const torn = zstdCompress!(Buffer.from(chunkUsage(1, 2, { inputTokens: 1, outputTokens: 1 }, 1786707341000) + '\n'))
|
||||
await writeFile(filePath, Buffer.concat([good, torn.subarray(0, Math.floor(torn.length / 2))]))
|
||||
|
||||
const calls = await parseAll(createDshProvider(tmpDir), filePath)
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.inputTokens).toBe(100)
|
||||
})
|
||||
|
||||
it('deduplicates (turn, step) calls seen across multiple parses', async () => {
|
||||
const filePath = await writeZstdSession('--C-Users-test-myproject--', 'session-dedup', [
|
||||
[sessionHeader({ id: 'session-dedup' })],
|
||||
[chunkUsage(1, 1, { inputTokens: 100, outputTokens: 10 }, 1786707340000)],
|
||||
])
|
||||
|
||||
const provider = createDshProvider(tmpDir)
|
||||
const source = { path: filePath, project: 'myproject', provider: 'dsh' }
|
||||
const seenKeys = new Set<string>()
|
||||
|
||||
const firstRun: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, seenKeys).parse()) firstRun.push(call)
|
||||
const secondRun: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, seenKeys).parse()) secondRun.push(call)
|
||||
|
||||
expect(firstRun).toHaveLength(1)
|
||||
expect(secondRun).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('handles a missing session file gracefully', async () => {
|
||||
const provider = createDshProvider(tmpDir)
|
||||
const source = { path: join(tmpDir, 'nope', 'session.jsonl.zstd'), project: 'test', provider: 'dsh' }
|
||||
const calls: ParsedProviderCall[] = []
|
||||
for await (const call of provider.createSessionParser(source, new Set()).parse()) calls.push(call)
|
||||
expect(calls).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('dsh provider - display names', () => {
|
||||
const provider = createDshProvider('/tmp')
|
||||
|
||||
it('has correct name and displayName', () => {
|
||||
expect(provider.name).toBe('dsh')
|
||||
expect(provider.displayName).toBe('DeepSeek Harness')
|
||||
})
|
||||
|
||||
it('maps deepseek models to readable names and passes unknown ids through', () => {
|
||||
expect(provider.modelDisplayName('deepseek-v4-pro')).toBe('DeepSeek v4 Pro')
|
||||
expect(provider.modelDisplayName('some-future-model')).toBe('some-future-model')
|
||||
})
|
||||
|
||||
it('normalizes tool names, keeping unknown names raw', () => {
|
||||
expect(provider.toolDisplayName('bash')).toBe('Bash')
|
||||
expect(provider.toolDisplayName('pwsh')).toBe('Bash')
|
||||
expect(provider.toolDisplayName('todo_write')).toBe('TodoWrite')
|
||||
expect(provider.toolDisplayName('cordis_run')).toBe('cordis_run')
|
||||
})
|
||||
})
|
||||
|
||||
describe('dsh provider - real log fidelity', () => {
|
||||
// The upstream snapshot from deepseek-ai/deepseek-harness
|
||||
// (examples/acp-agent/tests/snapshots/bash-tool-turn/session.jsonl), with its
|
||||
// template placeholders filled in. It is the reference for every shape the
|
||||
// parser reads: packed `reasoning-chunks`/`tool-call-chunks` storage rows, a
|
||||
// plugin-injected user/message beside the typed one, and both the streamed
|
||||
// usage chunk and the final assistant/message usage for the same step.
|
||||
async function writeRealSession(): Promise<string> {
|
||||
const lines = (await readFile(join(import.meta.dirname, '../fixtures/dsh/bash-tool-turn.jsonl'), 'utf-8'))
|
||||
.split('\n').filter(l => l.trim())
|
||||
return writePlainSession('--home-u-proj--', 'e128dda9-ed11-4868-8266-0ef90d03c3d6', lines)
|
||||
}
|
||||
|
||||
it('parses the upstream snapshot: two steps, exact usage, model from the message source', async () => {
|
||||
const calls = await parseAll(createDshProvider(tmpDir), await writeRealSession())
|
||||
|
||||
expect(calls).toHaveLength(2)
|
||||
expect(calls.map(c => c.model)).toEqual(['deepseek-v4-flash', 'deepseek-v4-flash'])
|
||||
expect(calls[0]).toMatchObject({
|
||||
inputTokens: 2877,
|
||||
outputTokens: 90,
|
||||
cacheReadInputTokens: 0,
|
||||
reasoningTokens: 18,
|
||||
sessionId: 'e128dda9-ed11-4868-8266-0ef90d03c3d6',
|
||||
project: 'proj',
|
||||
projectPath: '/home/u/proj',
|
||||
workingDirectory: '/home/u/proj',
|
||||
})
|
||||
expect(calls[1]).toMatchObject({ inputTokens: 168, outputTokens: 25, cacheReadInputTokens: 2816, reasoningTokens: 22 })
|
||||
// Reasoning bills at the output rate, so it must not appear as input.
|
||||
expect(calls[0]!.costUSD).toBe(calculateCost('deepseek-v4-flash', 2877, 90 + 18, 0, 0, 0))
|
||||
expect(calls[0]!.costUSD).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('takes the typed prompt as the preview, not the plugin-injected context', async () => {
|
||||
const calls = await parseAll(createDshProvider(tmpDir), await writeRealSession())
|
||||
|
||||
expect(calls[0]!.userMessage).toBe('Use the bash tool to run exactly: echo TERMINAL_OK. Then reply with the single word DONE and stop.')
|
||||
expect(calls[0]!.userMessage).not.toContain('Current runtime context')
|
||||
})
|
||||
|
||||
it('reads the tool call through the packed chunk rows around it', async () => {
|
||||
const calls = await parseAll(createDshProvider(tmpDir), await writeRealSession())
|
||||
|
||||
expect(calls[0]!.tools).toEqual(['Bash'])
|
||||
expect(calls[0]!.bashCommands).toEqual(['echo'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('dsh provider - defensive reads', () => {
|
||||
it('skips a log stamped with an unsupported session format version', async () => {
|
||||
const filePath = await writePlainSession('--home-u-proj--', 'session-future', [
|
||||
JSON.stringify({ type: 'session', version: 1, id: 'session-future', createdAt: 1786707336131, cwd: '/home/u/proj', delegationDepth: 0 }),
|
||||
chunkUsage(1, 1, { inputTokens: 100, outputTokens: 10 }, 1786707340000),
|
||||
])
|
||||
|
||||
expect(await createDshProvider(tmpDir).discoverSessions()).toEqual([])
|
||||
expect(await parseAll(createDshProvider(tmpDir), filePath)).toEqual([])
|
||||
})
|
||||
|
||||
it('does not bill a forked session for the events it inherited from its parent', async () => {
|
||||
const filePath = await writePlainSession('--home-u-proj--', 'session-fork', [
|
||||
JSON.stringify({
|
||||
type: 'session', version: 0, id: 'session-fork', createdAt: 1786707336131,
|
||||
cwd: '/home/u/proj', parentSession: 'session-parent', seedLength: 3, delegationDepth: 0,
|
||||
}),
|
||||
// seq 0..2 are a verbatim copy of the parent's log, which codeburn parses
|
||||
// as its own session; only seq >= 3 is this session's own work.
|
||||
JSON.stringify({ type: 'turn/start', seq: 0, time: 1786707337000, data: { turn: 1 } }),
|
||||
JSON.stringify({ type: 'assistant/message', seq: 1, time: 1786707337100, data: { turn: 1, step: 1, message: { role: 'assistant', content: [] }, usage: { inputTokens: 9999, outputTokens: 999 } } }),
|
||||
JSON.stringify({ type: 'session/end-seed', seq: 2, time: 1786707337200, data: {} }),
|
||||
JSON.stringify({ type: 'turn/start', seq: 3, time: 1786707338000, data: { turn: 2 } }),
|
||||
JSON.stringify({ type: 'assistant/message', seq: 4, time: 1786707338100, data: { turn: 2, step: 1, message: { role: 'assistant', content: [] }, usage: { inputTokens: 100, outputTokens: 10 } } }),
|
||||
])
|
||||
|
||||
const calls = await parseAll(createDshProvider(tmpDir), filePath)
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.inputTokens).toBe(100)
|
||||
})
|
||||
|
||||
it('ignores unknown event types, packed chunk rows, and unparsable lines', async () => {
|
||||
const filePath = await writePlainSession('--home-u-proj--', 'session-noise', [
|
||||
sessionHeader({ id: 'session-noise', cwd: '/home/u/proj' }),
|
||||
JSON.stringify({ type: 'agent/inbox/spliced', seq: 0, time: 1786707337000, data: { target: 'next-turn' } }),
|
||||
JSON.stringify({ type: 'reasoning-chunks', seq0: 1, time0: 1786707337100, data: { turn: 1, step: 1, index: 0, dt: [0], texts: ['a', 'b'] } }),
|
||||
'{ not json at all',
|
||||
' ',
|
||||
chunkUsage(1, 1, { inputTokens: 100, outputTokens: 10 }, 1786707340000),
|
||||
])
|
||||
|
||||
const calls = await parseAll(createDshProvider(tmpDir), filePath)
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.inputTokens).toBe(100)
|
||||
})
|
||||
|
||||
it('falls back to the header createdAt when a usage event carries no usable time', async () => {
|
||||
const filePath = await writePlainSession('--home-u-proj--', 'session-notime', [
|
||||
JSON.stringify({ type: 'session', version: 0, id: 'session-notime', createdAt: 1786707336131, cwd: '/home/u/proj', delegationDepth: 0 }),
|
||||
JSON.stringify({ type: 'assistant/message', seq: 1, data: { turn: 1, step: 1, message: { role: 'assistant', content: [] }, usage: { inputTokens: 100, outputTokens: 10 } } }),
|
||||
])
|
||||
|
||||
const calls = await parseAll(createDshProvider(tmpDir), filePath)
|
||||
expect(calls).toHaveLength(1)
|
||||
expect(calls[0]!.timestamp).toBe(new Date(1786707336131).toISOString())
|
||||
})
|
||||
})
|
||||
|
||||
describe('dsh provider - real log, real container', () => {
|
||||
itZstd('reads the upstream snapshot out of multi-frame zstd with a torn tail identically to plain jsonl', async () => {
|
||||
const lines = (await readFile(join(import.meta.dirname, '../fixtures/dsh/bash-tool-turn.jsonl'), 'utf-8'))
|
||||
.split('\n').filter(l => l.trim())
|
||||
const plain = await parseAll(
|
||||
createDshProvider(tmpDir),
|
||||
await writePlainSession('--home-u-proj--', 'plain', lines),
|
||||
)
|
||||
|
||||
// Header batch, then three append batches — the layout DSH writes.
|
||||
const dir = join(tmpDir, 'sessions', '--home-u-proj--', 'framed')
|
||||
await mkdir(dir, { recursive: true })
|
||||
const filePath = join(dir, 'session.jsonl.zstd')
|
||||
const frames = [[lines[0]!], lines.slice(1, 10), lines.slice(10, 25), lines.slice(25)]
|
||||
.map(batch => zstdCompress!(Buffer.from(batch.join('\n') + '\n', 'utf-8')))
|
||||
// A crashed writer's half-written final batch, carrying usage that must not count.
|
||||
const torn = zstdCompress!(Buffer.from(assistantMessage(9, 9, { inputTokens: 123456, outputTokens: 1 }, 1785730424999) + '\n', 'utf-8'))
|
||||
await writeFile(filePath, Buffer.concat([...frames, torn.subarray(0, Math.floor(torn.length / 2))]))
|
||||
|
||||
const framed = await parseAll(createDshProvider(tmpDir), filePath)
|
||||
expect(framed.map(c => [c.inputTokens, c.outputTokens, c.reasoningTokens, c.model]))
|
||||
.toEqual(plain.map(c => [c.inputTokens, c.outputTokens, c.reasoningTokens, c.model]))
|
||||
expect(framed).toHaveLength(2)
|
||||
})
|
||||
})
|
||||
|
||||
describe('dsh provider - hostile input', () => {
|
||||
itZstd('skips a session whose frames decompress to far more than the file cap', async () => {
|
||||
const dir = join(tmpDir, 'sessions', '--home-u-proj--', 'session-bomb')
|
||||
await mkdir(dir, { recursive: true })
|
||||
const filePath = join(dir, 'session.jsonl.zstd')
|
||||
// 200 MB of zeros compresses to a few KB. Uncapped this decoded to ~916 MB
|
||||
// of RSS for a 16 KB file; the per-frame cap now rejects it without
|
||||
// allocating past the cap.
|
||||
const bomb = zstdCompress!(Buffer.alloc(200 * 1024 * 1024))
|
||||
const good = zstdCompress!(Buffer.from(
|
||||
sessionHeader({ id: 'session-bomb', cwd: '/home/u/proj' }) + '\n'
|
||||
+ chunkUsage(1, 1, { inputTokens: 100, outputTokens: 10 }, 1786707340000) + '\n',
|
||||
'utf-8',
|
||||
))
|
||||
await writeFile(filePath, Buffer.concat([good, bomb]))
|
||||
expect((await stat(filePath)).size).toBeLessThan(64 * 1024)
|
||||
|
||||
// The whole file is skipped: the frames read before the bomb are not
|
||||
// counted, so a crafted tail cannot poison a partial total.
|
||||
expect(await parseAll(createDshProvider(tmpDir), filePath)).toEqual([])
|
||||
})
|
||||
|
||||
itZstd('stops decoding once the frames exceed the running budget', async () => {
|
||||
const frame = zstdCompress!(Buffer.from('{"type":"turn/start","seq":0,"time":1,"data":{"turn":1}}\n', 'utf-8'))
|
||||
const buffer = Buffer.concat([frame, frame, frame])
|
||||
|
||||
expect([...readZstdLines(buffer, Number.POSITIVE_INFINITY, 4096)]).toHaveLength(3)
|
||||
// A budget under two frames' plaintext stops at the frame that overruns it.
|
||||
expect(() => [...readZstdLines(buffer, Number.POSITIVE_INFINITY, 60)]).toThrow()
|
||||
})
|
||||
|
||||
it('coerces non-numeric usage fields instead of poisoning the totals', async () => {
|
||||
const filePath = await writePlainSession('--home-u-proj--', 'session-poison', [
|
||||
sessionHeader({ id: 'session-poison', cwd: '/home/u/proj' }),
|
||||
JSON.stringify({
|
||||
type: 'assistant/message', seq: 1, time: 1786707340000,
|
||||
data: {
|
||||
turn: 1, step: 1, message: { role: 'assistant', content: [] },
|
||||
usage: { inputTokens: '999', outputTokens: [1, 2], reasoningTokens: 1e308 * 10, cacheReadTokens: -5, cacheWriteTokens: 7 },
|
||||
},
|
||||
}),
|
||||
])
|
||||
|
||||
const calls = await parseAll(createDshProvider(tmpDir), filePath)
|
||||
expect(calls).toHaveLength(1)
|
||||
// Only the one genuinely numeric field survives; every other shape is 0.
|
||||
expect(calls[0]).toMatchObject({
|
||||
inputTokens: 0,
|
||||
outputTokens: 0,
|
||||
reasoningTokens: 0,
|
||||
cacheReadInputTokens: 0,
|
||||
cacheCreationInputTokens: 7,
|
||||
})
|
||||
for (const value of [calls[0]!.inputTokens, calls[0]!.outputTokens, calls[0]!.costUSD]) {
|
||||
expect(typeof value).toBe('number')
|
||||
expect(Number.isFinite(value)).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('still skips a call whose usage is all non-numeric', async () => {
|
||||
const filePath = await writePlainSession('--home-u-proj--', 'session-poison-zero', [
|
||||
sessionHeader({ id: 'session-poison-zero', cwd: '/home/u/proj' }),
|
||||
JSON.stringify({
|
||||
type: 'assistant/message', seq: 1, time: 1786707340000,
|
||||
data: { turn: 1, step: 1, message: { role: 'assistant', content: [] }, usage: { inputTokens: '999', outputTokens: [1, 2] } },
|
||||
}),
|
||||
])
|
||||
|
||||
expect(await parseAll(createDshProvider(tmpDir), filePath)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
|
@ -353,4 +353,25 @@ describe('review-findings regressions', () => {
|
|||
// 95 local calls + 5 unpriced cloud calls: coverage must be 0, not 0.95.
|
||||
expect(computePricingCoverage(5, 5)).toBe(0)
|
||||
})
|
||||
|
||||
it('excludes sidechain-only work from every human workflow signal', () => {
|
||||
const editCall = call({
|
||||
tools: ['Edit'],
|
||||
timestamp: '2026-06-01T10:06:00Z',
|
||||
toolSequence: [[{ tool: 'Edit', file: '/home/u/app/src/a.ts' }]],
|
||||
})
|
||||
const sidechain = session('agent-reviewer', [
|
||||
turn({ userMessage: 'review the change', timestamp: '2026-06-01T10:00:00Z' }),
|
||||
turn({ userMessage: 'you missed the edge case', calls: [editCall], timestamp: '2026-06-01T10:06:00Z' }),
|
||||
turn({ userMessage: 'that is still wrong', timestamp: '2026-06-01T10:07:00Z' }),
|
||||
turn({ userMessage: 'revert that change', timestamp: '2026-06-01T10:08:00Z' }),
|
||||
], { feature: cat(10, 0) } as SessionSummary['categoryBreakdown'])
|
||||
sidechain.isSidechain = true
|
||||
const projects = [project([sidechain])]
|
||||
|
||||
expect(scanUserCorrections(projects)).toEqual({ corrections: 0, userTurns: 0, correctionRate: null })
|
||||
expect(medianTimeToFirstEditMs(projects)).toBeNull()
|
||||
expect(aggregateFileChurn(projects)).toEqual([])
|
||||
expect(worstOneShotCategory(projects)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -6,5 +6,8 @@ export default defineConfig({
|
|||
// session-discovery env vars (CLAUDE_CONFIG_DIRS, HOME, XDG_*, every
|
||||
// provider-specific *_HOME) don't bleed real local data into fixtures.
|
||||
setupFiles: ['./tests/setup/env-isolation.ts'],
|
||||
// Real-I/O tests (session parses, sqlite fixtures, worker pools) exceed the
|
||||
// 5s default under CI runner load; a hung test still fails at 30s.
|
||||
testTimeout: 30_000,
|
||||
},
|
||||
})
|
||||
|
|
|
|||
10
windows/.gitignore
vendored
Normal file
10
windows/.gitignore
vendored
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
node_modules/
|
||||
# dist/ is a Vite build output; we gitignore its contents but keep a tracked placeholder
|
||||
# index.html so Tauri's compile-time `frontendDist` validation passes on fresh clones before
|
||||
# anyone has run `npm run build`. Real build output replaces the placeholder locally.
|
||||
dist/*
|
||||
!dist/index.html
|
||||
src-tauri/target/
|
||||
src-tauri/gen/
|
||||
.DS_Store
|
||||
*.log
|
||||
188
windows/DEVELOPMENT.md
Normal file
188
windows/DEVELOPMENT.md
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
# CodeBurn Menubar (Windows)
|
||||
|
||||
Tauri 2.x tray app that surfaces CodeBurn in the Windows notification area. It is the Windows
|
||||
mirror of the native macOS menubar in `../mac/`, which stays the authoritative look and feel;
|
||||
this project mirrors its layout, colors, and data via the shared `tokens.json`.
|
||||
|
||||
Linux (ksni / AppIndicator) support is compiled and kept working for dev, but it is
|
||||
**experimental and unreleased** - Linux users should use the GNOME extension in `../gnome/`.
|
||||
The releases this repo cuts from here are Windows only.
|
||||
|
||||
Not everything crosses over: the spend badge is a second tray icon carrying the number as its
|
||||
bitmap, which only the Windows notification area provides. `tray_badge` is compiled out on
|
||||
Linux, the `set_tray_badge` command reports it as unsupported there, and the frontend hides
|
||||
the control behind `TRAY_BADGE_SUPPORTED` in `src/lib/platform.ts`. Anything else that is
|
||||
Windows-only must be cfg-gated the same way, or the ubuntu leg of CI fails on dead code.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
windows/
|
||||
├── src/ React + TypeScript popover UI (runs inside the Tauri webview)
|
||||
├── src-tauri/
|
||||
│ ├── src/
|
||||
│ │ ├── main.rs binary entry
|
||||
│ │ ├── lib.rs tray, window lifecycle, state wiring
|
||||
│ │ ├── cli.rs argv-validated spawn of the codeburn CLI
|
||||
│ │ ├── config.rs ~/.config/codeburn/config.json read/write under a lock
|
||||
│ │ ├── plan.rs Claude OAuth quota (port of mac/.../ClaudeSubscriptionService.swift)
|
||||
│ │ └── fx.rs Frankfurter fetch + 24h disk cache + [0.0001, 1e6] clamp
|
||||
│ ├── capabilities/ Tauri v2 permission manifests
|
||||
│ └── icons/ tray + bundle icons
|
||||
└── tokens.json shared design tokens (also consumed by mac/ at build time)
|
||||
```
|
||||
|
||||
## Prerequisites (Windows)
|
||||
|
||||
```powershell
|
||||
# Rust
|
||||
winget install Rustlang.Rustup
|
||||
rustup target add x86_64-pc-windows-msvc
|
||||
|
||||
# WebView2 Runtime
|
||||
winget install Microsoft.EdgeWebView2Runtime
|
||||
|
||||
# Microsoft C++ Build Tools (ships with Visual Studio Installer; pick "Desktop development with C++")
|
||||
```
|
||||
|
||||
## Prerequisites (macOS / Linux, dev only)
|
||||
|
||||
Tauri builds on macOS and Linux for inner-loop UI iteration. The shipping macOS product is the
|
||||
Swift app in `../mac/`, so we don't cut a Tauri Mac release.
|
||||
|
||||
```bash
|
||||
# macOS
|
||||
brew install rust node
|
||||
|
||||
# Ubuntu / Debian
|
||||
sudo apt update
|
||||
sudo apt install -y \
|
||||
build-essential curl wget file \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
libayatana-appindicator3-dev \
|
||||
librsvg2-dev \
|
||||
libssl-dev \
|
||||
libxdo-dev \
|
||||
libgtk-3-dev
|
||||
```
|
||||
|
||||
## Run the dev server
|
||||
|
||||
```bash
|
||||
cd windows
|
||||
npm install
|
||||
npm run tauri dev
|
||||
```
|
||||
|
||||
Under the hood this starts Vite on `localhost:1420`, builds `src-tauri/target/debug/codeburn-menubar`,
|
||||
and opens a window wired to the dev server with hot reload for the React code. The tray icon
|
||||
appears at the same time.
|
||||
|
||||
If the codeburn CLI isn't on PATH (dev builds from this monorepo), point the app at your local build:
|
||||
|
||||
```bash
|
||||
npm --prefix .. run build
|
||||
CODEBURN_BIN="node $(pwd)/../dist/cli.js" npm run tauri dev
|
||||
```
|
||||
|
||||
`CODEBURN_BIN` is validated against a strict allowlist (alphanumerics plus `._/-` and space;
|
||||
`\ : ( )` also allowed on Windows) before use; anything else falls back to auto-resolution.
|
||||
|
||||
Without `CODEBURN_BIN` the app looks for `codeburn` (`codeburn.cmd` / `codeburn.exe` on Windows)
|
||||
on the inherited `PATH`, then in the usual npm and node prefixes (`%APPDATA%\npm`,
|
||||
`%LOCALAPPDATA%\Programs\nodejs`, pnpm, Volta, scoop, `/opt/homebrew/bin`, `~/.npm-global/bin`),
|
||||
and finally on Windows in the live user and machine `PATH` read from the registry, so a CLI
|
||||
installed after the tray app was launched is still found. Only absolute directory entries are
|
||||
considered - empty or relative `PATH` entries are skipped so nothing is ever resolved out of the
|
||||
current working directory.
|
||||
|
||||
If nothing is found, or `codeburn --version` is older than `MIN_CLI_VERSION`
|
||||
(`src-tauri/src/cli.rs`), the popover shows a setup screen with the install command and a
|
||||
"Check again" button. That gate is probed once on mount, before the first payload fetch.
|
||||
|
||||
`MIN_CLI_VERSION` is **0.9.9**: the first release whose `codeburn status --format menubar-json`
|
||||
accepts `--no-optimize`, which the app's quiet background refreshes always pass. Every payload
|
||||
field the popover reads (`current.providers`, `current.cacheHitPercent`, `history.daily[].topModels`)
|
||||
also exists at that version.
|
||||
|
||||
## Refresh policy
|
||||
|
||||
Mirrors `mac/Sources/CodeBurnMenubar/RefreshCadence.swift`: each CLI fetch is a full Node
|
||||
process, so the cadence follows popover visibility.
|
||||
|
||||
- popover visible: 60 s tick, full fetch (optimize findings included)
|
||||
- popover hidden: 120 s tick, `today`/`all` only, `--no-optimize`
|
||||
- on show: immediate refresh when the visible key is older than 60 s
|
||||
|
||||
## Plan / quota
|
||||
|
||||
The Plan pill (visible on the Claude tab, or when Claude is the only detected provider) reads
|
||||
Claude Code's OAuth credentials from `~/.claude/.credentials.json`, calls
|
||||
`https://api.anthropic.com/api/oauth/usage`, and stores one snapshot per window under
|
||||
`~/.cache/codeburn/subscription-snapshots.json` (`CODEBURN_CACHE_DIR` override) so a freshly
|
||||
reset window can still show last cycle's final. This is the same file format the macOS app
|
||||
writes. Nothing is logged: the credential blob never leaves the Rust side.
|
||||
|
||||
On a 401 we do **not** call the token refresh endpoint. Claude's refresh token is single-use
|
||||
and rotates, so spending it would invalidate the token Claude Code itself is holding and break
|
||||
the user's login. Like `ClaudeCredentialStore.refreshAfter401` on macOS, we re-read Claude's
|
||||
own credential file for a token it has already rotated, and report a transient failure when
|
||||
there isn't one yet.
|
||||
|
||||
## Build a production package
|
||||
|
||||
```bash
|
||||
# Windows (.msi): run from a Windows host
|
||||
npm run tauri build
|
||||
|
||||
# Linux (experimental): produces .deb, .rpm, .AppImage under src-tauri/target/release/bundle/
|
||||
npm run tauri build
|
||||
```
|
||||
|
||||
## Security model
|
||||
|
||||
- **Process spawn**: every call into the codeburn CLI goes through `CodeburnCli::fetch_menubar_payload`,
|
||||
which builds argv explicitly and runs the binary directly (no `sh -c`). `CODEBURN_BIN` is
|
||||
allowlisted before use. Windows system tools (`reg.exe`, `cmd.exe`) are invoked by absolute
|
||||
path under `%SystemRoot%\System32` so `CreateProcess`'s current-directory search can never
|
||||
pick up a planted binary; `claude` is resolved from absolute `PATH` directories the same way.
|
||||
- **Pipes**: stdout is capped at 20 MB, stderr at 256 KB, total wall time at 60 s. A hung CLI
|
||||
cannot pin file descriptors or memory.
|
||||
- **Config writes**: `~/.config/codeburn/config.json` writes run under a POSIX `flock` on
|
||||
`~/.config/codeburn/.config.lock`. On Windows the same path uses a create-new lock file. Note
|
||||
that this lock is advisory *between instances of this app only* - the codeburn CLI does not
|
||||
take it - so it narrows, but does not eliminate, a concurrent-write race. A live holder keeps
|
||||
its file handle open and Windows will not unlink an open file, so the staleness sweep can only
|
||||
ever reclaim a lock whose owner is gone (after 30 s).
|
||||
- **Snapshot writes**: `subscription-snapshots.json` refuses a symlinked target and is written
|
||||
0600 on unix, mirroring `mac/Sources/CodeBurnMenubar/Security/SafeFile.swift`.
|
||||
- **Credentials**: the Plan view reads `~/.claude/.credentials.json` with a 64 KB cap and refuses
|
||||
symlinks; the access token is only ever sent to the Anthropic usage endpoint over TLS, and the
|
||||
refresh token is never read or sent at all.
|
||||
- **FX fetches**: Frankfurter response is parsed as JSON and the rate is clamped to
|
||||
`[0.0001, 1_000_000]` before it touches displayed numbers. Stale cache preferred over poisoned
|
||||
fresh data.
|
||||
- **CSP**: `connect-src` restricted to `self`, `ipc:`, and `https://api.frankfurter.app`. No
|
||||
inline scripts.
|
||||
|
||||
## CI and release tags
|
||||
|
||||
- `.github/workflows/windows-menubar-ci.yml` runs on any `windows/**` change: `tsc --noEmit`,
|
||||
`cargo clippy -D warnings` and `cargo test` on windows-latest + ubuntu-latest, plus a release
|
||||
build smoke on Windows.
|
||||
- `windows-v*` tag (e.g. `windows-v0.9.20`) triggers
|
||||
`.github/workflows/release-menubar-windows.yml`; publishes the `.msi` (plus its sha256) to
|
||||
a "Windows Menubar vX" release. Unsigned for now, so Windows SmartScreen prompts on first run
|
||||
until a signing cert is in place.
|
||||
- `codeburn menubar` installs from those assets (`src/menubar-installer.ts`): it pins the tag to
|
||||
the CLI's own version (`windows-v<cliVersion>`), falls back to a scan of the newest `windows-v*`
|
||||
release carrying both assets, verifies the sha256 before anything executes the file, then runs
|
||||
`%SystemRoot%\System32\msiexec.exe /i <msi> /passive /norestart` and launches the exe named by
|
||||
the product's Uninstall registry key. Renaming the bundle or the MSI asset breaks that lookup —
|
||||
`WINDOWS_RELEASE` and `WINDOWS_PRODUCT_NAME` in the installer have to move with it.
|
||||
|
||||
## Pending work
|
||||
|
||||
1. Code signing for the Windows `.msi` to remove the SmartScreen warning.
|
||||
2. Linux: decide whether to ship at all (the GNOME extension in `../gnome/` covers that
|
||||
surface today) or promote the ksni tray out of experimental.
|
||||
38
windows/Scripts/autoinstall/README.md
Normal file
38
windows/Scripts/autoinstall/README.md
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
# Unattended Ubuntu install for the CodeBurn dev VM
|
||||
|
||||
This directory contains a cloud-init `user-data` + `meta-data` pair that tells the Ubuntu 24.04 Server installer to configure itself without any user prompts. After it finishes, you reboot into GNOME and run the one-line provisioner.
|
||||
|
||||
Default credentials in `user-data`: **`codeburn` / `codeburn`**. Change them before using anywhere that matters.
|
||||
|
||||
## Build the CIDATA ISO (on your Mac)
|
||||
|
||||
```bash
|
||||
cd windows/Scripts/autoinstall
|
||||
hdiutil makehybrid -o codeburn-cidata.iso \
|
||||
-hfs -joliet -iso -default-volume-name CIDATA .
|
||||
```
|
||||
|
||||
That produces `codeburn-cidata.iso` (around 2 KB) with the two YAML files at the root, labelled `CIDATA`.
|
||||
|
||||
## Hook it into UTM
|
||||
|
||||
1. Create the VM as usual (Virtualize → Linux → Ubuntu Server arm64 ISO).
|
||||
2. Before first boot, open the VM's Settings → **Drives** → **New Drive** → pick **Removable** → **Import**, and select `codeburn-cidata.iso`.
|
||||
3. Boot. The Ubuntu installer auto-detects the CIDATA volume, reads the autoinstall config, and runs the install without prompts. Takes 15-20 minutes depending on disk speed.
|
||||
4. Reboot into the installed system, log in as `codeburn`, then:
|
||||
|
||||
```bash
|
||||
bash ~/provision.sh
|
||||
```
|
||||
|
||||
(The autoinstall drops the script to `~/provision.sh`. It installs Rust + Node + the codeburn CLI, clones the repo, and sets up the windows/ npm deps.)
|
||||
|
||||
5. `cd ~/codeburn/windows && npm run tauri dev`.
|
||||
|
||||
## Why not automate the provisioner run too
|
||||
|
||||
cloud-init's `late-commands` runs in the installer environment, which doesn't have a GNOME session for the tray icon to land in. We deliberately stop short of running `npm run tauri dev` from within autoinstall so the tray shows up on your first real login instead of a detached systemd unit.
|
||||
|
||||
## Skipping autoinstall
|
||||
|
||||
If you'd rather click through the Ubuntu installer normally, ignore this directory entirely. The `provision-linux.sh` script in the parent directory works the same way whether the OS was installed unattended or by hand.
|
||||
2
windows/Scripts/autoinstall/meta-data
Normal file
2
windows/Scripts/autoinstall/meta-data
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
instance-id: codeburn-linux-01
|
||||
local-hostname: codeburn-linux
|
||||
57
windows/Scripts/autoinstall/user-data
Normal file
57
windows/Scripts/autoinstall/user-data
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
#cloud-config
|
||||
# Ubuntu 24.04 LTS Server autoinstall configuration for a CodeBurn desktop dev VM. Mount this
|
||||
# file as a second virtual disk (CIDATA volume) alongside the Ubuntu Server ISO in UTM and
|
||||
# the installer runs unattended end to end. Default login: codeburn / codeburn. Change the
|
||||
# identity block before running in any environment that matters.
|
||||
autoinstall:
|
||||
version: 1
|
||||
|
||||
# Accept the EULA-style prompts without user input.
|
||||
refresh-installer:
|
||||
update: yes
|
||||
|
||||
locale: en_US.UTF-8
|
||||
keyboard:
|
||||
layout: us
|
||||
|
||||
# Wire up a default user. Password is `codeburn`; hash generated with `openssl passwd -6`.
|
||||
# Regenerate the hash if you care about the credentials outside of a throwaway VM.
|
||||
identity:
|
||||
hostname: codeburn-linux
|
||||
username: codeburn
|
||||
password: "$6$rounds=4096$JrKVZcJ2$F93p8IWyTlZR5p1Trmno/qCnhYI1BnbUUYdf6HsiD.XW4T0I3JtvzH40nWNy9Z1CcJ2X5C6RuzK0bj9WM3x/n."
|
||||
|
||||
ssh:
|
||||
install-server: yes
|
||||
allow-pw: yes
|
||||
|
||||
# Install GNOME + the build dependencies Tauri needs so the first login is already ready
|
||||
# to run `npm run tauri dev` without another apt round trip.
|
||||
packages:
|
||||
- ubuntu-desktop-minimal
|
||||
- build-essential
|
||||
- curl
|
||||
- wget
|
||||
- file
|
||||
- git
|
||||
- libwebkit2gtk-4.1-dev
|
||||
- libayatana-appindicator3-dev
|
||||
- librsvg2-dev
|
||||
- libssl-dev
|
||||
- libxdo-dev
|
||||
- libgtk-3-dev
|
||||
- pkg-config
|
||||
|
||||
# Run the provisioner as the new user on first boot. It installs Node + Rust, pulls the
|
||||
# repo, and runs `npm install` for the desktop app. After the script finishes, logging in
|
||||
# to GNOME and running `cd ~/codeburn/windows && npm run tauri dev` brings the tray up.
|
||||
late-commands:
|
||||
- curtin in-target --target=/target -- bash -lc '
|
||||
sudo -iu codeburn bash -lc "
|
||||
curl -fsSL https://raw.githubusercontent.com/getagentseal/codeburn/main/windows/Scripts/provision-linux.sh \
|
||||
-o /home/codeburn/provision.sh
|
||||
chmod +x /home/codeburn/provision.sh
|
||||
"
|
||||
'
|
||||
|
||||
shutdown: reboot
|
||||
89
windows/Scripts/provision-linux.sh
Executable file
89
windows/Scripts/provision-linux.sh
Executable file
|
|
@ -0,0 +1,89 @@
|
|||
#!/usr/bin/env bash
|
||||
# One-shot Ubuntu provisioning for the CodeBurn desktop (Tauri) dev environment.
|
||||
#
|
||||
# Usage inside a fresh Ubuntu 24.04 LTS Server VM (after `sudo apt install
|
||||
# ubuntu-desktop-minimal && sudo reboot`, and logging into GNOME):
|
||||
#
|
||||
# curl -fsSL https://raw.githubusercontent.com/getagentseal/codeburn/main/windows/Scripts/provision-linux.sh | bash
|
||||
#
|
||||
# Or if you cloned the repo manually: `bash windows/Scripts/provision-linux.sh`.
|
||||
#
|
||||
# Installs: build toolchain, webkit + appindicator headers, Node 20 LTS, Rust stable,
|
||||
# the codeburn npm CLI, and this repo. Leaves you one command away from `npm run tauri dev`.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
REPO_URL="https://github.com/getagentseal/codeburn.git"
|
||||
BRANCH="feat/tauri-menubar-win-linux"
|
||||
CHECKOUT="${HOME}/codeburn"
|
||||
|
||||
log() { printf '\033[1;34m▸\033[0m %s\n' "$*"; }
|
||||
fail() { printf '\033[1;31m✗\033[0m %s\n' "$*" >&2; exit 1; }
|
||||
|
||||
# 1. Platform sanity
|
||||
[[ "$(uname -s)" == "Linux" ]] || fail "Run me on Linux (detected: $(uname -s))."
|
||||
if ! command -v apt-get >/dev/null; then
|
||||
fail "Only apt-based distros supported by this provisioner (Ubuntu, Debian)."
|
||||
fi
|
||||
|
||||
log "apt update + system build deps"
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y \
|
||||
build-essential curl wget file git \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
libayatana-appindicator3-dev \
|
||||
librsvg2-dev \
|
||||
libssl-dev \
|
||||
libxdo-dev \
|
||||
libgtk-3-dev \
|
||||
pkg-config
|
||||
|
||||
# 2. Node 20 LTS via NodeSource if the distro version is too old. Tauri CLI needs >= 18.
|
||||
if ! command -v node >/dev/null || [[ "$(node -v | sed 's/v\([0-9]*\).*/\1/')" -lt 18 ]]; then
|
||||
log "installing Node 20 LTS"
|
||||
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
|
||||
sudo apt-get install -y nodejs
|
||||
fi
|
||||
|
||||
# 3. Rust via rustup if not present
|
||||
if ! command -v cargo >/dev/null; then
|
||||
log "installing Rust via rustup"
|
||||
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal
|
||||
# shellcheck disable=SC1091
|
||||
source "$HOME/.cargo/env"
|
||||
fi
|
||||
|
||||
# 4. codeburn CLI (the Tauri app shells out to this for data)
|
||||
if ! command -v codeburn >/dev/null; then
|
||||
log "installing codeburn CLI from npm"
|
||||
sudo npm install -g codeburn
|
||||
fi
|
||||
|
||||
# 5. Repo
|
||||
if [[ -d "${CHECKOUT}/.git" ]]; then
|
||||
log "updating existing checkout at ${CHECKOUT}"
|
||||
git -C "${CHECKOUT}" fetch origin
|
||||
git -C "${CHECKOUT}" checkout "${BRANCH}"
|
||||
git -C "${CHECKOUT}" pull --ff-only origin "${BRANCH}"
|
||||
else
|
||||
log "cloning ${REPO_URL} into ${CHECKOUT}"
|
||||
git clone --branch "${BRANCH}" "${REPO_URL}" "${CHECKOUT}"
|
||||
fi
|
||||
|
||||
# 6. npm deps for the desktop app
|
||||
log "npm install for windows/"
|
||||
(cd "${CHECKOUT}/windows" && npm install --no-audit --no-fund)
|
||||
|
||||
# 7. Summary + next step
|
||||
cat <<EOF
|
||||
|
||||
\033[1;32m✓\033[0m Provisioning complete.
|
||||
|
||||
Next:
|
||||
|
||||
cd ${CHECKOUT}/windows
|
||||
npm run tauri dev
|
||||
|
||||
A flame tray icon should appear in your panel. Click it for the popover. Hot reload is
|
||||
wired for the React code; Rust changes need a rebuild.
|
||||
EOF
|
||||
4
windows/dist/index.html
vendored
Normal file
4
windows/dist/index.html
vendored
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
<!doctype html><html><head><meta charset="utf-8"><title>CodeBurn</title></head><body>
|
||||
<noscript>This app requires JavaScript.</noscript>
|
||||
<p>Run <code>npm install && npm run tauri dev</code> from <code>windows/</code>.</p>
|
||||
</body></html>
|
||||
12
windows/index.html
Normal file
12
windows/index.html
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>CodeBurn</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
2069
windows/package-lock.json
generated
Normal file
2069
windows/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load diff
28
windows/package.json
Normal file
28
windows/package.json
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
{
|
||||
"name": "codeburn-menubar",
|
||||
"private": true,
|
||||
"version": "0.9.20",
|
||||
"description": "CodeBurn menubar (tray) app for Windows, with experimental Linux support. Shares design tokens with the native macOS app under mac/.",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc --noEmit && vite build",
|
||||
"preview": "vite preview",
|
||||
"tauri": "tauri"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2.0.0",
|
||||
"@tauri-apps/plugin-opener": "^2.0.0",
|
||||
"@tauri-apps/plugin-shell": "^2.0.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tauri-apps/cli": "^2.0.0",
|
||||
"@types/react": "^19.0.0",
|
||||
"@types/react-dom": "^19.0.0",
|
||||
"@vitejs/plugin-react": "^4.3.0",
|
||||
"typescript": "^5.6.0",
|
||||
"vite": "^6.0.0"
|
||||
}
|
||||
}
|
||||
6066
windows/src-tauri/Cargo.lock
generated
Normal file
6066
windows/src-tauri/Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
40
windows/src-tauri/Cargo.toml
Normal file
40
windows/src-tauri/Cargo.toml
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
[package]
|
||||
name = "codeburn-menubar"
|
||||
version = "0.9.20"
|
||||
description = "CodeBurn menubar (tray) app for Windows and Linux"
|
||||
authors = ["AgentSeal"]
|
||||
edition = "2021"
|
||||
rust-version = "1.80"
|
||||
|
||||
[lib]
|
||||
name = "codeburn_menubar_lib"
|
||||
crate-type = ["staticlib", "cdylib", "rlib"]
|
||||
|
||||
[build-dependencies]
|
||||
tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = ["tray-icon", "image-png"] }
|
||||
tauri-plugin-opener = "2"
|
||||
tauri-plugin-shell = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["process", "io-util", "rt-multi-thread", "macros", "time", "sync"] }
|
||||
thiserror = "1"
|
||||
anyhow = "1"
|
||||
dirs = "5"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
fontdue = "0.9"
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dependencies]
|
||||
windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_Graphics_Dwm", "Win32_UI_WindowsAndMessaging"] }
|
||||
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
ksni = "0.3"
|
||||
png = "0.17"
|
||||
|
||||
[features]
|
||||
default = ["custom-protocol"]
|
||||
# This feature is used for production builds or when a dev server is not specified. Don't
|
||||
# change it unless you know what you're doing.
|
||||
custom-protocol = ["tauri/custom-protocol"]
|
||||
3
windows/src-tauri/build.rs
Normal file
3
windows/src-tauri/build.rs
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
fn main() {
|
||||
tauri_build::build()
|
||||
}
|
||||
20
windows/src-tauri/capabilities/default.json
Normal file
20
windows/src-tauri/capabilities/default.json
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
{
|
||||
"$schema": "../gen/schemas/desktop-schema.json",
|
||||
"identifier": "default",
|
||||
"description": "Default permissions for the CodeBurn tray app window",
|
||||
"windows": ["popover"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"core:window:allow-close",
|
||||
"core:window:allow-hide",
|
||||
"core:window:allow-show",
|
||||
"core:window:allow-set-focus",
|
||||
"core:window:allow-set-position",
|
||||
"core:window:allow-set-size",
|
||||
"core:tray:default",
|
||||
"core:event:allow-listen",
|
||||
"core:event:allow-emit",
|
||||
"opener:default",
|
||||
"shell:default"
|
||||
]
|
||||
}
|
||||
BIN
windows/src-tauri/icons/128x128.png
Normal file
BIN
windows/src-tauri/icons/128x128.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
BIN
windows/src-tauri/icons/128x128@2x.png
Normal file
BIN
windows/src-tauri/icons/128x128@2x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 59 KiB |
BIN
windows/src-tauri/icons/32x32.png
Normal file
BIN
windows/src-tauri/icons/32x32.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
BIN
windows/src-tauri/icons/icon.ico
Normal file
BIN
windows/src-tauri/icons/icon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 86 KiB |
BIN
windows/src-tauri/icons/icon.png
Normal file
BIN
windows/src-tauri/icons/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 222 KiB |
BIN
windows/src-tauri/icons/tray.png
Normal file
BIN
windows/src-tauri/icons/tray.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.3 KiB |
89
windows/src-tauri/src/autostart.rs
Normal file
89
windows/src-tauri/src/autostart.rs
Normal file
|
|
@ -0,0 +1,89 @@
|
|||
//! Launch at login. Windows: a value under HKCU\...\CurrentVersion\Run pointing at this
|
||||
//! executable. Linux: an XDG autostart .desktop file. No extra crates; both are a few
|
||||
//! lines of `reg` / plain file IO.
|
||||
|
||||
use anyhow::{anyhow, Result};
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
use anyhow::Context;
|
||||
|
||||
#[cfg(any(target_os = "windows", target_os = "linux"))]
|
||||
const APP_NAME: &str = "CodeBurn";
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
const RUN_KEY: &str = r"HKCU\Software\Microsoft\Windows\CurrentVersion\Run";
|
||||
|
||||
/// Absolute `reg.exe` out of System32 -- see `cli::system_command`.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn reg(args: &[&str]) -> Result<std::process::Output> {
|
||||
crate::cli::system_command("reg.exe")
|
||||
.args(args)
|
||||
.output()
|
||||
.with_context(|| "failed to run reg.exe")
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn is_enabled() -> bool {
|
||||
reg(&["query", RUN_KEY, "/v", APP_NAME])
|
||||
.map(|out| out.status.success())
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn set_enabled(enabled: bool) -> Result<()> {
|
||||
if enabled {
|
||||
let exe = std::env::current_exe().with_context(|| "cannot resolve current exe")?;
|
||||
let value = format!("\"{}\"", exe.display());
|
||||
let out = reg(&["add", RUN_KEY, "/v", APP_NAME, "/t", "REG_SZ", "/d", &value, "/f"])?;
|
||||
if !out.status.success() {
|
||||
return Err(anyhow!(String::from_utf8_lossy(&out.stderr).trim().to_string()));
|
||||
}
|
||||
} else {
|
||||
let out = reg(&["delete", RUN_KEY, "/v", APP_NAME, "/f"])?;
|
||||
// Deleting a value that does not exist is the state we want anyway.
|
||||
if !out.status.success() && is_enabled() {
|
||||
return Err(anyhow!(String::from_utf8_lossy(&out.stderr).trim().to_string()));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn desktop_file() -> Option<std::path::PathBuf> {
|
||||
dirs::config_dir().map(|d| d.join("autostart").join("codeburn-menubar.desktop"))
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn is_enabled() -> bool {
|
||||
desktop_file().map(|p| p.is_file()).unwrap_or(false)
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn set_enabled(enabled: bool) -> Result<()> {
|
||||
let path = desktop_file().ok_or_else(|| anyhow!("no config dir"))?;
|
||||
if enabled {
|
||||
let exe = std::env::current_exe().with_context(|| "cannot resolve current exe")?;
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
std::fs::write(
|
||||
&path,
|
||||
format!(
|
||||
"[Desktop Entry]\nType=Application\nName={APP_NAME}\nExec=\"{}\"\nX-GNOME-Autostart-enabled=true\n",
|
||||
exe.display()
|
||||
),
|
||||
)?;
|
||||
} else if path.exists() {
|
||||
std::fs::remove_file(&path)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn is_enabled() -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
pub fn set_enabled(_enabled: bool) -> Result<()> {
|
||||
Err(anyhow!("launch at login is handled by the native macOS app"))
|
||||
}
|
||||
601
windows/src-tauri/src/cli.rs
Normal file
601
windows/src-tauri/src/cli.rs
Normal file
|
|
@ -0,0 +1,601 @@
|
|||
use std::env;
|
||||
use std::path::PathBuf;
|
||||
use std::process::Stdio;
|
||||
|
||||
use anyhow::{anyhow, bail, Context, Result};
|
||||
use serde::Serialize;
|
||||
use serde_json::Value;
|
||||
use tauri::AppHandle;
|
||||
use tokio::io::AsyncReadExt;
|
||||
use tokio::process::Command;
|
||||
use tokio::time::{timeout, Duration};
|
||||
|
||||
/// Hard bounds mirror the macOS CodeburnCLI / DataClient design. A malicious or stuck CLI
|
||||
/// cannot pin the Tauri process: stdout is capped, stderr is bounded, total wall time is
|
||||
/// 60s. A hostile CODEBURN_BIN is rejected before any shell-resembling path is taken.
|
||||
const MAX_PAYLOAD_BYTES: usize = 20 * 1024 * 1024;
|
||||
const MAX_STDERR_BYTES: usize = 256 * 1024;
|
||||
const FETCH_TIMEOUT_SECS: u64 = 60;
|
||||
const VERSION_TIMEOUT_SECS: u64 = 20;
|
||||
|
||||
/// Oldest CLI this app can talk to. 0.9.9 is the first release whose
|
||||
/// `status --format menubar-json` accepts `--no-optimize`, which every quiet background
|
||||
/// refresh passes; it also emits all the payload fields the popover reads
|
||||
/// (`current.providers`, `current.cacheHitPercent`, `history.daily[].topModels`). Older CLIs
|
||||
/// get the setup screen instead of a half-rendered popover or a stream of spawn failures.
|
||||
pub const MIN_CLI_VERSION: (u32, u32, u32) = (0, 9, 9);
|
||||
|
||||
#[cfg(windows)]
|
||||
const WINDOWS_CLI_NAMES: [&str; 2] = ["codeburn.cmd", "codeburn.exe"];
|
||||
|
||||
#[cfg(windows)]
|
||||
const CLAUDE_NAMES: [&str; 2] = ["claude.cmd", "claude.exe"];
|
||||
#[cfg(not(windows))]
|
||||
const CLAUDE_NAMES: [&str; 1] = ["claude"];
|
||||
|
||||
/// Alphanumerics plus `._/-` and space, with `\`, `:`, `(`, `)` also allowed on Windows
|
||||
/// so a user-supplied `CODEBURN_BIN` path like `C:\Users\...\codeburn.cmd` is accepted.
|
||||
/// None of these are shell metacharacters in a direct-argv spawn (we never invoke `sh -c`).
|
||||
fn is_safe_arg(value: &str) -> bool {
|
||||
!value.is_empty()
|
||||
&& value.chars().all(|c| {
|
||||
c.is_ascii_alphanumeric()
|
||||
|| matches!(c, '.' | '_' | '/' | '-' | ' ')
|
||||
|| (cfg!(windows) && matches!(c, '\\' | ':' | '(' | ')'))
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct CodeburnCli {
|
||||
program: String,
|
||||
extra_args: Vec<String>,
|
||||
}
|
||||
|
||||
/// What the setup screen needs to know about the CLI on this machine.
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct CliStatus {
|
||||
pub found: bool,
|
||||
pub program: String,
|
||||
pub version: Option<String>,
|
||||
pub min_version: String,
|
||||
pub compatible: bool,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
impl CodeburnCli {
|
||||
/// Honours `CODEBURN_BIN` only when every whitespace-delimited token passes the
|
||||
/// allowlist. Otherwise resolves `codeburn` from PATH and the usual npm locations.
|
||||
pub fn resolve() -> Self {
|
||||
let raw = env::var("CODEBURN_BIN").unwrap_or_default();
|
||||
if raw.is_empty() {
|
||||
return Self::default_program();
|
||||
}
|
||||
// A bare path (which may contain spaces, e.g. under Program Files) is used whole;
|
||||
// only otherwise is the value split into program + leading arguments.
|
||||
if is_safe_arg(&raw) && std::path::Path::new(&raw).is_file() {
|
||||
return CodeburnCli {
|
||||
program: raw,
|
||||
extra_args: vec![],
|
||||
};
|
||||
}
|
||||
let parts: Vec<String> = raw.split_whitespace().map(String::from).collect();
|
||||
if parts.iter().all(|p| is_safe_arg(p)) {
|
||||
if let Some((first, rest)) = parts.split_first() {
|
||||
return CodeburnCli {
|
||||
program: first.clone(),
|
||||
extra_args: rest.to_vec(),
|
||||
};
|
||||
}
|
||||
}
|
||||
eprintln!("codeburn-menubar: refusing unsafe CODEBURN_BIN; falling back to `codeburn`");
|
||||
Self::default_program()
|
||||
}
|
||||
|
||||
fn default_program() -> Self {
|
||||
CodeburnCli {
|
||||
program: locate_cli().unwrap_or_else(default_program_name),
|
||||
extra_args: vec![],
|
||||
}
|
||||
}
|
||||
|
||||
pub fn program(&self) -> &str {
|
||||
&self.program
|
||||
}
|
||||
|
||||
/// Runs `codeburn --version` and reports whether the CLI is present and new enough.
|
||||
pub async fn status(&self) -> CliStatus {
|
||||
let min_version = format!(
|
||||
"{}.{}.{}",
|
||||
MIN_CLI_VERSION.0, MIN_CLI_VERSION.1, MIN_CLI_VERSION.2
|
||||
);
|
||||
let mut status = CliStatus {
|
||||
found: false,
|
||||
program: self.program.clone(),
|
||||
version: None,
|
||||
min_version,
|
||||
compatible: false,
|
||||
error: None,
|
||||
};
|
||||
match self.run_capture(&["--version"], VERSION_TIMEOUT_SECS).await {
|
||||
Ok(out) => {
|
||||
let version = out.trim().to_string();
|
||||
status.found = true;
|
||||
status.compatible = parse_version(&version)
|
||||
.map(|v| v >= MIN_CLI_VERSION)
|
||||
.unwrap_or(false);
|
||||
status.version = Some(version);
|
||||
}
|
||||
Err(err) => {
|
||||
status.error = Some(err.to_string());
|
||||
}
|
||||
}
|
||||
status
|
||||
}
|
||||
|
||||
/// Spawns `codeburn status --format menubar-json --period X --provider Y` and decodes the
|
||||
/// output. Pipes are drained concurrently so a chatty stderr cannot deadlock stdout.
|
||||
pub async fn fetch_menubar_payload(
|
||||
&self,
|
||||
period: &str,
|
||||
provider: &str,
|
||||
include_optimize: bool,
|
||||
) -> Result<Value> {
|
||||
if !is_safe_arg(period) || !is_safe_arg(provider) {
|
||||
bail!("invalid period/provider argument");
|
||||
}
|
||||
|
||||
let mut args = vec![
|
||||
"status",
|
||||
"--format",
|
||||
"menubar-json",
|
||||
"--period",
|
||||
period,
|
||||
"--provider",
|
||||
provider,
|
||||
];
|
||||
if !include_optimize {
|
||||
args.push("--no-optimize");
|
||||
}
|
||||
|
||||
let stdout = self.run_capture(&args, FETCH_TIMEOUT_SECS).await?;
|
||||
let payload: Value =
|
||||
serde_json::from_str(&stdout).with_context(|| "CLI returned invalid JSON")?;
|
||||
Ok(payload)
|
||||
}
|
||||
|
||||
async fn run_capture(&self, args: &[&str], timeout_secs: u64) -> Result<String> {
|
||||
let mut full_args = self.extra_args.clone();
|
||||
full_args.extend(args.iter().map(|s| s.to_string()));
|
||||
|
||||
let mut cmd = Command::new(&self.program);
|
||||
cmd.args(&full_args)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::piped())
|
||||
.stderr(Stdio::piped())
|
||||
.kill_on_drop(true);
|
||||
#[cfg(windows)]
|
||||
{
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||
}
|
||||
let mut child = cmd.spawn().map_err(|err| {
|
||||
anyhow!(
|
||||
"CodeBurn CLI not found ({}). Install it with `npm install -g codeburn`.",
|
||||
spawn_error_summary(&self.program, &err)
|
||||
)
|
||||
})?;
|
||||
|
||||
let mut stdout = child.stdout.take().ok_or_else(|| anyhow!("no stdout"))?;
|
||||
let mut stderr = child.stderr.take().ok_or_else(|| anyhow!("no stderr"))?;
|
||||
|
||||
let stdout_task = tokio::spawn(async move {
|
||||
let mut buf = Vec::with_capacity(64 * 1024);
|
||||
let mut limited = (&mut stdout).take(MAX_PAYLOAD_BYTES as u64);
|
||||
limited.read_to_end(&mut buf).await.ok();
|
||||
buf
|
||||
});
|
||||
let stderr_task = tokio::spawn(async move {
|
||||
let mut buf = Vec::with_capacity(4 * 1024);
|
||||
let mut limited = (&mut stderr).take(MAX_STDERR_BYTES as u64);
|
||||
limited.read_to_end(&mut buf).await.ok();
|
||||
buf
|
||||
});
|
||||
|
||||
let status = timeout(Duration::from_secs(timeout_secs), child.wait())
|
||||
.await
|
||||
.map_err(|_| anyhow!("codeburn CLI timed out after {}s", timeout_secs))??;
|
||||
|
||||
let stdout_bytes = stdout_task.await.unwrap_or_default();
|
||||
let stderr_bytes = stderr_task.await.unwrap_or_default();
|
||||
|
||||
if !status.success() {
|
||||
let msg = String::from_utf8_lossy(&stderr_bytes);
|
||||
bail!("codeburn CLI exited {}: {}", status, msg.trim());
|
||||
}
|
||||
Ok(String::from_utf8_lossy(&stdout_bytes).into_owned())
|
||||
}
|
||||
}
|
||||
|
||||
fn spawn_error_summary(program: &str, err: &std::io::Error) -> String {
|
||||
match err.kind() {
|
||||
std::io::ErrorKind::NotFound => format!("{} is not on PATH", program),
|
||||
_ => format!("{}: {}", program, err),
|
||||
}
|
||||
}
|
||||
|
||||
fn default_program_name() -> String {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
"codeburn.cmd".to_string()
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
"codeburn".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Parses "0.7.3" or "codeburn 0.7.3" into a comparable tuple.
|
||||
pub fn parse_version(text: &str) -> Option<(u32, u32, u32)> {
|
||||
let token = text
|
||||
.split_whitespace()
|
||||
.find(|t| t.chars().next().map(|c| c.is_ascii_digit()).unwrap_or(false))?;
|
||||
let mut parts = token.split('.').map(|p| {
|
||||
p.chars()
|
||||
.take_while(|c| c.is_ascii_digit())
|
||||
.collect::<String>()
|
||||
.parse::<u32>()
|
||||
.ok()
|
||||
});
|
||||
Some((parts.next()??, parts.next()??, parts.next().flatten().unwrap_or(0)))
|
||||
}
|
||||
|
||||
/// Locates the CLI without relying on the inherited PATH being fresh. A tray app is often
|
||||
/// launched from Explorer or at login, before (or long after) `npm install -g codeburn`
|
||||
/// changed the user's PATH, so we also read the live PATH from the registry on Windows and
|
||||
/// probe the standard npm / node install prefixes.
|
||||
fn locate_cli() -> Option<String> {
|
||||
find_in_search_dirs(&candidate_names())
|
||||
}
|
||||
|
||||
/// Same search for Claude Code's own binary, so "Connect Claude" spawns an absolute path
|
||||
/// instead of letting the console shell resolve a bare `claude`.
|
||||
fn locate_claude() -> Option<String> {
|
||||
find_in_search_dirs(&CLAUDE_NAMES)
|
||||
}
|
||||
|
||||
fn find_in_search_dirs(names: &[&str]) -> Option<String> {
|
||||
let mut dirs: Vec<PathBuf> = Vec::new();
|
||||
if let Some(path) = env::var_os("PATH") {
|
||||
dirs.extend(env::split_paths(&path));
|
||||
}
|
||||
dirs.extend(extra_search_dirs());
|
||||
find_in_dirs(&dirs, names)
|
||||
}
|
||||
|
||||
/// The absolute-only filter is the security boundary, so it lives here where every search
|
||||
/// goes through it. `env::split_paths` yields an empty `PathBuf` for `;;` or a trailing `;`,
|
||||
/// and the registry PATH can hold relative entries too; `PathBuf::from("").join("codeburn.cmd")`
|
||||
/// resolves against the current directory, which for a tray app launched at login is
|
||||
/// whatever Explorer handed it. A binary planted there must never win.
|
||||
fn find_in_dirs(dirs: &[PathBuf], names: &[&str]) -> Option<String> {
|
||||
for dir in dirs.iter().filter(|d| d.is_absolute()) {
|
||||
for name in names {
|
||||
let candidate = dir.join(name);
|
||||
if candidate.is_file() {
|
||||
return Some(candidate.to_string_lossy().into_owned());
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
fn candidate_names() -> Vec<&'static str> {
|
||||
#[cfg(windows)]
|
||||
{
|
||||
WINDOWS_CLI_NAMES.to_vec()
|
||||
}
|
||||
#[cfg(not(windows))]
|
||||
{
|
||||
vec!["codeburn"]
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn extra_search_dirs() -> Vec<PathBuf> {
|
||||
let mut out = Vec::new();
|
||||
for var in ["APPDATA", "LOCALAPPDATA", "ProgramFiles", "ProgramFiles(x86)"] {
|
||||
if let Some(base) = env::var_os(var).map(PathBuf::from) {
|
||||
match var {
|
||||
"APPDATA" => out.push(base.join("npm")),
|
||||
"LOCALAPPDATA" => {
|
||||
out.push(base.join("Programs").join("nodejs"));
|
||||
out.push(base.join("pnpm"));
|
||||
out.push(base.join("Volta").join("bin"));
|
||||
out.push(base.join("fnm_multishells"));
|
||||
}
|
||||
_ => out.push(base.join("nodejs")),
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
out.push(home.join("scoop").join("shims"));
|
||||
out.push(home.join(".bun").join("bin"));
|
||||
}
|
||||
out.extend(registry_path_dirs());
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(not(windows))]
|
||||
fn extra_search_dirs() -> Vec<PathBuf> {
|
||||
let mut out = vec![
|
||||
PathBuf::from("/opt/homebrew/bin"),
|
||||
PathBuf::from("/usr/local/bin"),
|
||||
];
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
out.push(home.join(".npm-global").join("bin"));
|
||||
out.push(home.join(".local").join("bin"));
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Windows' `CreateProcess` searches the current directory before `PATH`, so spawning
|
||||
/// `reg` or `cmd` by bare name lets anything dropped next to the app impersonate a system
|
||||
/// tool -- and the tray badge re-runs `reg query` every refresh. Always spawn the real one
|
||||
/// out of `%SystemRoot%\System32`, falling back to the documented default when the
|
||||
/// environment variable is missing or relative.
|
||||
#[cfg(windows)]
|
||||
pub fn system32_path(exe: &str) -> PathBuf {
|
||||
let root = env::var_os("SystemRoot")
|
||||
.map(PathBuf::from)
|
||||
.filter(|p| p.is_absolute())
|
||||
.unwrap_or_else(|| PathBuf::from(r"C:\Windows"));
|
||||
root.join("System32").join(exe)
|
||||
}
|
||||
|
||||
/// `system32_path` plus the CREATE_NO_WINDOW flag every one of these callers wants.
|
||||
#[cfg(windows)]
|
||||
pub fn system_command(exe: &str) -> std::process::Command {
|
||||
use std::os::windows::process::CommandExt;
|
||||
const CREATE_NO_WINDOW: u32 = 0x08000000;
|
||||
let mut cmd = std::process::Command::new(system32_path(exe));
|
||||
cmd.creation_flags(CREATE_NO_WINDOW);
|
||||
cmd
|
||||
}
|
||||
|
||||
/// Reads the user and machine PATH values from the registry via `reg.exe` so a PATH edit
|
||||
/// made after this process started (npm install adds `%APPDATA%\npm`) is still honoured.
|
||||
#[cfg(windows)]
|
||||
fn registry_path_dirs() -> Vec<PathBuf> {
|
||||
let mut out = Vec::new();
|
||||
let keys = [
|
||||
r"HKCU\Environment",
|
||||
r"HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\Environment",
|
||||
];
|
||||
for key in keys {
|
||||
let output = system_command("reg.exe")
|
||||
.args(["query", key, "/v", "Path"])
|
||||
.output();
|
||||
let Ok(output) = output else { continue };
|
||||
let text = String::from_utf8_lossy(&output.stdout);
|
||||
for line in text.lines() {
|
||||
let trimmed = line.trim();
|
||||
if !trimmed.starts_with("Path") {
|
||||
continue;
|
||||
}
|
||||
let Some(idx) = trimmed.find("REG_") else { continue };
|
||||
let rest = &trimmed[idx..];
|
||||
let Some(space) = rest.find(char::is_whitespace) else { continue };
|
||||
let value = rest[space..].trim();
|
||||
for part in value.split(';') {
|
||||
let expanded = expand_env(part.trim());
|
||||
if !expanded.is_empty() {
|
||||
out.push(PathBuf::from(expanded));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(windows)]
|
||||
fn expand_env(value: &str) -> String {
|
||||
let mut result = String::with_capacity(value.len());
|
||||
let mut rest = value;
|
||||
while let Some(start) = rest.find('%') {
|
||||
result.push_str(&rest[..start]);
|
||||
let after = &rest[start + 1..];
|
||||
match after.find('%') {
|
||||
Some(end) => {
|
||||
let name = &after[..end];
|
||||
match env::var(name) {
|
||||
Ok(v) => result.push_str(&v),
|
||||
Err(_) => {
|
||||
result.push('%');
|
||||
result.push_str(name);
|
||||
result.push('%');
|
||||
}
|
||||
}
|
||||
rest = &after[end + 1..];
|
||||
}
|
||||
None => {
|
||||
result.push_str(&rest[start..]);
|
||||
rest = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
result.push_str(rest);
|
||||
result
|
||||
}
|
||||
|
||||
/// Runs a codeburn subcommand in the user's terminal emulator so they can see the output.
|
||||
/// Linux: tries `x-terminal-emulator`, `gnome-terminal`, `konsole`, then falls back to a
|
||||
/// detached headless spawn. Windows: opens a console via `cmd /C start`. Never
|
||||
/// interpolates through a shell -- argv throughout.
|
||||
pub fn spawn_in_terminal(app: &AppHandle, subcommand: &[&str]) -> Result<()> {
|
||||
let cli = CodeburnCli::resolve();
|
||||
spawn_program_in_terminal(app, &cli, subcommand)
|
||||
}
|
||||
|
||||
/// The Plan view's "Connect Claude" runs Claude Code's own login flow, not codeburn. The
|
||||
/// binary is located up front rather than handed to the console shell as a bare name, so
|
||||
/// the same absolute-directory rule that protects the codeburn lookup applies here too.
|
||||
pub fn spawn_claude_login(app: &AppHandle) -> Result<()> {
|
||||
let program = locate_claude().ok_or_else(|| {
|
||||
anyhow!("Claude Code was not found on this machine. Install it, then try again.")
|
||||
})?;
|
||||
let cli = CodeburnCli {
|
||||
program,
|
||||
extra_args: vec![],
|
||||
};
|
||||
spawn_program_in_terminal(app, &cli, &["login"])
|
||||
}
|
||||
|
||||
fn spawn_program_in_terminal(_app: &AppHandle, cli: &CodeburnCli, subcommand: &[&str]) -> Result<()> {
|
||||
if !subcommand.iter().all(|s| is_safe_arg(s)) {
|
||||
bail!("unsafe subcommand argument");
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let mut command_parts: Vec<String> = vec![cli.program.clone()];
|
||||
command_parts.extend(cli.extra_args.clone());
|
||||
command_parts.extend(subcommand.iter().map(|s| s.to_string()));
|
||||
// Terminal emulators take the command as one string that a shell then parses
|
||||
// (gnome-terminal explicitly hands it to `bash -lc`). `cli.program` reaches here
|
||||
// from PATH resolution, not only from the allowlisted CODEBURN_BIN, so re-check
|
||||
// every part before joining; anything a shell could reinterpret skips the terminal
|
||||
// and goes through the argv-only detached spawn below.
|
||||
if command_parts.iter().all(|p| is_safe_arg(p)) {
|
||||
let composite = command_parts.join(" ");
|
||||
let terminals: [&[&str]; 4] = [
|
||||
&["x-terminal-emulator", "-e"],
|
||||
&["gnome-terminal", "--", "bash", "-lc"],
|
||||
&["konsole", "-e"],
|
||||
&["xterm", "-e"],
|
||||
];
|
||||
for term in &terminals {
|
||||
let program = term[0];
|
||||
let extras = &term[1..];
|
||||
if which::which(program).is_ok() {
|
||||
let mut cmd = std::process::Command::new(program);
|
||||
cmd.args(extras);
|
||||
cmd.arg(&composite);
|
||||
cmd.spawn().with_context(|| format!("failed to launch {}", program))?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
// Fallback: run detached, output lost -- better than silently doing nothing.
|
||||
std::process::Command::new(&cli.program)
|
||||
.args(&cli.extra_args)
|
||||
.args(subcommand)
|
||||
.stdin(Stdio::null())
|
||||
.stdout(Stdio::null())
|
||||
.stderr(Stdio::null())
|
||||
.spawn()
|
||||
.with_context(|| "no terminal emulator found, detached spawn also failed")?;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
{
|
||||
// `start` treats the first quoted argument as the window title, so we pass an
|
||||
// explicit empty title. `/K` keeps the console open for non-interactive commands
|
||||
// (export) so the user can read where the file went; the TUI (report/optimize)
|
||||
// owns the window until the user quits it either way.
|
||||
// Only the unresolved default name is worth a second lookup; anything else is
|
||||
// either already absolute or a CODEBURN_BIN the user chose.
|
||||
let program = if cli.program == default_program_name() {
|
||||
locate_cli().unwrap_or_else(|| cli.program.clone())
|
||||
} else {
|
||||
cli.program.clone()
|
||||
};
|
||||
let cmd_exe = system32_path("cmd.exe");
|
||||
let mut cmd = system_command("cmd.exe");
|
||||
cmd.arg("/C").arg("start").arg("").arg(&cmd_exe).arg("/K").arg(&program);
|
||||
for a in &cli.extra_args {
|
||||
cmd.arg(a);
|
||||
}
|
||||
for a in subcommand {
|
||||
cmd.arg(a);
|
||||
}
|
||||
cmd.spawn().with_context(|| "failed to open cmd.exe")?;
|
||||
}
|
||||
|
||||
#[cfg(target_os = "macos")]
|
||||
{
|
||||
// macOS isn't our target for this app (Swift handles Mac), but keep dev-on-Mac working.
|
||||
std::process::Command::new(&cli.program)
|
||||
.args(&cli.extra_args)
|
||||
.args(subcommand)
|
||||
.spawn()
|
||||
.with_context(|| format!("failed to spawn {}", cli.program))?;
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Minimal dependency: we only use `which` inside spawn_in_terminal on Linux. Vendored here
|
||||
/// so the crate graph stays tiny. Gated so the unused-function warning doesn't fire on Mac
|
||||
/// or Windows builds.
|
||||
#[cfg(target_os = "linux")]
|
||||
mod which {
|
||||
use std::env;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub fn which(program: &str) -> Result<PathBuf, ()> {
|
||||
let path = env::var_os("PATH").ok_or(())?;
|
||||
let dirs: Vec<PathBuf> = env::split_paths(&path).collect();
|
||||
super::find_in_dirs(&dirs, &[program]).map(PathBuf::from).ok_or(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The `;;` / trailing-`;` case: an empty PATH entry must not turn into a
|
||||
/// current-directory lookup, which is how a planted binary would win at login.
|
||||
#[test]
|
||||
fn find_in_dirs_skips_empty_and_relative_entries() {
|
||||
let dir = std::env::temp_dir();
|
||||
let name = "codeburn-menubar-locate-probe";
|
||||
let planted = dir.join(name);
|
||||
std::fs::write(&planted, b"probe").unwrap();
|
||||
|
||||
// Empty and relative entries are ignored even though the file is reachable
|
||||
// through them once the process CWD is the temp dir.
|
||||
let unsafe_dirs = vec![PathBuf::from(""), PathBuf::from("."), PathBuf::from("..")];
|
||||
assert_eq!(find_in_dirs(&unsafe_dirs, &[name]), None);
|
||||
|
||||
// The same name behind an absolute entry is found.
|
||||
let found = find_in_dirs(std::slice::from_ref(&dir), &[name]).expect("absolute entry should match");
|
||||
assert!(PathBuf::from(&found).is_absolute());
|
||||
assert!(found.ends_with(name));
|
||||
|
||||
// An unsafe entry ahead of a good one cannot shadow it.
|
||||
let mixed = vec![PathBuf::from(""), dir.clone()];
|
||||
assert_eq!(find_in_dirs(&mixed, &[name]), Some(found));
|
||||
|
||||
std::fs::remove_file(&planted).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_version_reads_bare_and_prefixed_output() {
|
||||
assert_eq!(parse_version("0.9.9"), Some((0, 9, 9)));
|
||||
assert_eq!(parse_version("codeburn 0.9.20\n"), Some((0, 9, 20)));
|
||||
assert_eq!(parse_version("1.0"), Some((1, 0, 0)));
|
||||
assert_eq!(parse_version("0.10.0-beta.1"), Some((0, 10, 0)));
|
||||
assert_eq!(parse_version("no version here"), None);
|
||||
}
|
||||
|
||||
/// The gate is a plain tuple compare, so the only thing worth pinning is that the
|
||||
/// versions on either side of MIN_CLI_VERSION land on the right side of it.
|
||||
#[test]
|
||||
fn version_gate_rejects_only_older_clis() {
|
||||
assert_eq!(MIN_CLI_VERSION, (0, 9, 9));
|
||||
assert!(parse_version("0.9.8").unwrap() < MIN_CLI_VERSION);
|
||||
assert!(parse_version("0.9.9").unwrap() >= MIN_CLI_VERSION);
|
||||
assert!(parse_version("0.9.20").unwrap() >= MIN_CLI_VERSION);
|
||||
assert!(parse_version("0.10.0").unwrap() >= MIN_CLI_VERSION);
|
||||
}
|
||||
}
|
||||
183
windows/src-tauri/src/config.rs
Normal file
183
windows/src-tauri/src/config.rs
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Default, Clone, Serialize, Deserialize)]
|
||||
pub struct CurrencyConfig {
|
||||
#[serde(default, flatten)]
|
||||
extra: serde_json::Map<String, serde_json::Value>,
|
||||
}
|
||||
|
||||
fn codeburn_config_dir() -> PathBuf {
|
||||
dirs::home_dir()
|
||||
.map(|h| h.join(".config/codeburn"))
|
||||
.unwrap_or_else(|| PathBuf::from(".codeburn"))
|
||||
}
|
||||
|
||||
fn config_path() -> PathBuf {
|
||||
codeburn_config_dir().join("config.json")
|
||||
}
|
||||
|
||||
fn lock_path() -> PathBuf {
|
||||
codeburn_config_dir().join(".config.lock")
|
||||
}
|
||||
|
||||
impl CurrencyConfig {
|
||||
pub fn load_or_default() -> Self {
|
||||
match fs::read(config_path()) {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_default(),
|
||||
Err(_) => Self::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_currency(&mut self, code: &str, symbol: &str) -> Result<()> {
|
||||
fs::create_dir_all(codeburn_config_dir())
|
||||
.with_context(|| "failed to create ~/.config/codeburn")?;
|
||||
|
||||
#[cfg(unix)]
|
||||
let _lock = unix_lock::acquire()?;
|
||||
#[cfg(windows)]
|
||||
let _lock = windows_lock::acquire()?;
|
||||
|
||||
let mut disk: serde_json::Value = match fs::read(config_path()) {
|
||||
Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|_| serde_json::json!({})),
|
||||
Err(_) => serde_json::json!({}),
|
||||
};
|
||||
|
||||
if code == "USD" {
|
||||
if let Some(obj) = disk.as_object_mut() {
|
||||
obj.remove("currency");
|
||||
}
|
||||
} else if let Some(obj) = disk.as_object_mut() {
|
||||
obj.insert(
|
||||
"currency".into(),
|
||||
serde_json::json!({ "code": code, "symbol": symbol }),
|
||||
);
|
||||
}
|
||||
|
||||
let serialized = serde_json::to_vec_pretty(&disk)?;
|
||||
let tmp = config_path().with_extension("tmp");
|
||||
{
|
||||
let mut file = fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.open(&tmp)?;
|
||||
file.write_all(&serialized)?;
|
||||
file.flush()?;
|
||||
}
|
||||
fs::rename(&tmp, config_path())?;
|
||||
|
||||
*self = serde_json::from_value(disk).unwrap_or_default();
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
mod unix_lock {
|
||||
use std::fs;
|
||||
use std::os::fd::AsRawFd;
|
||||
use anyhow::{anyhow, Context, Result};
|
||||
|
||||
pub struct Guard {
|
||||
_file: fs::File,
|
||||
}
|
||||
|
||||
pub fn acquire() -> Result<Guard> {
|
||||
let file = fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.read(true)
|
||||
.write(true)
|
||||
// A lock file only ever needs to exist; its contents are irrelevant.
|
||||
.truncate(false)
|
||||
.open(super::lock_path())
|
||||
.with_context(|| "failed to open config lock")?;
|
||||
|
||||
let fd = file.as_raw_fd();
|
||||
let ret = unsafe { flock(fd, 2) };
|
||||
if ret != 0 {
|
||||
return Err(anyhow!("flock failed: {}", std::io::Error::last_os_error()));
|
||||
}
|
||||
Ok(Guard { _file: file })
|
||||
}
|
||||
|
||||
extern "C" {
|
||||
fn flock(fd: i32, operation: i32) -> i32;
|
||||
}
|
||||
}
|
||||
|
||||
/// Windows has no flock; a create-new lock file is the closest equivalent.
|
||||
///
|
||||
/// What this actually buys: mutual exclusion between writers that take this lock, which
|
||||
/// today means only other instances of this app. The codeburn CLI writes `config.json`
|
||||
/// without taking it, so a concurrent CLI write still races us -- the rename below keeps the
|
||||
/// file from ever being torn, but a simultaneous CLI edit can still be the one that wins.
|
||||
///
|
||||
/// A lock file left behind by a crash is treated as abandoned once it is older than
|
||||
/// STALE_LOCK_SECS (three orders of magnitude longer than the read-modify-rename it guards),
|
||||
/// so one crash cannot wedge currency changes forever. Upgrading to `LockFileEx`, which the
|
||||
/// OS releases on process death and needs no staleness heuristic, only becomes worth it if
|
||||
/// the CLI ever starts taking the lock too.
|
||||
#[cfg(windows)]
|
||||
mod windows_lock {
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::thread::sleep;
|
||||
use std::time::{Duration, SystemTime};
|
||||
use anyhow::{anyhow, Result};
|
||||
|
||||
const RETRY_INTERVAL: Duration = Duration::from_millis(40);
|
||||
const MAX_RETRIES: u32 = 50;
|
||||
const STALE_LOCK_SECS: u64 = 30;
|
||||
|
||||
pub struct Guard {
|
||||
path: PathBuf,
|
||||
/// Kept open for the lifetime of the guard: Windows will not unlink a file that is
|
||||
/// still open, so holding the handle is what stops the stale sweep below from ever
|
||||
/// deleting a lock whose owner is alive.
|
||||
file: Option<fs::File>,
|
||||
}
|
||||
|
||||
impl Drop for Guard {
|
||||
fn drop(&mut self) {
|
||||
self.file.take();
|
||||
let _ = fs::remove_file(&self.path);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn acquire() -> Result<Guard> {
|
||||
let path = super::lock_path();
|
||||
for _ in 0..MAX_RETRIES {
|
||||
match fs::OpenOptions::new().write(true).create_new(true).open(&path) {
|
||||
Ok(file) => {
|
||||
return Ok(Guard {
|
||||
path,
|
||||
file: Some(file),
|
||||
})
|
||||
}
|
||||
Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
|
||||
// Only proceed on a successful unlink: a live holder still has the file
|
||||
// open, so this fails for anything but an abandoned lock.
|
||||
if is_stale(&path) && fs::remove_file(&path).is_ok() {
|
||||
continue;
|
||||
}
|
||||
sleep(RETRY_INTERVAL);
|
||||
}
|
||||
Err(err) => return Err(anyhow!("failed to open config lock: {err}")),
|
||||
}
|
||||
}
|
||||
Err(anyhow!("config lock is held by another process"))
|
||||
}
|
||||
|
||||
fn is_stale(path: &PathBuf) -> bool {
|
||||
fs::metadata(path)
|
||||
.and_then(|m| m.modified())
|
||||
.ok()
|
||||
.and_then(|t| SystemTime::now().duration_since(t).ok())
|
||||
.map(|age| age.as_secs() > STALE_LOCK_SECS)
|
||||
.unwrap_or(false)
|
||||
}
|
||||
}
|
||||
152
windows/src-tauri/src/fx.rs
Normal file
152
windows/src-tauri/src/fx.rs
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
use std::collections::HashMap;
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Mutex;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
const FRANKFURTER_URL: &str = "https://api.frankfurter.app/latest?from=USD&to=";
|
||||
const CACHE_TTL_SECS: u64 = 24 * 3600;
|
||||
const FETCH_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
/// Defensive bounds on any fetched FX rate. Outside [0.0001, 1_000_000] the rate is either
|
||||
/// a parser bug or a tampered response; we refuse it so the UI never multiplies a NaN or
|
||||
/// wild value into displayed costs.
|
||||
const MIN_VALID_FX_RATE: f64 = 0.0001;
|
||||
const MAX_VALID_FX_RATE: f64 = 1_000_000.0;
|
||||
|
||||
/// Currency metadata the frontend renders against. `rate` is USD -> target; the UI
|
||||
/// multiplies each raw USD number by `rate` and prefixes `symbol` for display.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct CurrencyApplied {
|
||||
pub code: String,
|
||||
pub symbol: String,
|
||||
pub rate: f64,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct Entry {
|
||||
rate: f64,
|
||||
saved_at: u64,
|
||||
}
|
||||
|
||||
pub struct FxCache {
|
||||
entries: Mutex<HashMap<String, Entry>>,
|
||||
}
|
||||
|
||||
impl FxCache {
|
||||
pub fn new() -> Self {
|
||||
let entries = load_from_disk().unwrap_or_default();
|
||||
FxCache {
|
||||
entries: Mutex::new(entries),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns a cached-or-fresh rate. Tries cache first, then Frankfurter if stale. Any
|
||||
/// response that fails the sanity bounds is dropped and the cached (possibly stale)
|
||||
/// value is returned instead.
|
||||
pub async fn rate_for(&self, code: &str) -> Option<f64> {
|
||||
if code == "USD" {
|
||||
return Some(1.0);
|
||||
}
|
||||
|
||||
{
|
||||
let guard = self.entries.lock().ok()?;
|
||||
if let Some(entry) = guard.get(code) {
|
||||
if now_secs().saturating_sub(entry.saved_at) < CACHE_TTL_SECS {
|
||||
return Some(entry.rate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
match fetch_rate(code).await {
|
||||
Some(fresh) if is_valid(fresh) => {
|
||||
if let Ok(mut guard) = self.entries.lock() {
|
||||
guard.insert(
|
||||
code.to_string(),
|
||||
Entry {
|
||||
rate: fresh,
|
||||
saved_at: now_secs(),
|
||||
},
|
||||
);
|
||||
let _ = save_to_disk(&guard);
|
||||
}
|
||||
Some(fresh)
|
||||
}
|
||||
_ => {
|
||||
// Fetch failed or out-of-band; serve stale cached value if any.
|
||||
let guard = self.entries.lock().ok()?;
|
||||
guard.get(code).map(|e| e.rate)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn is_valid(rate: f64) -> bool {
|
||||
rate.is_finite() && (MIN_VALID_FX_RATE..=MAX_VALID_FX_RATE).contains(&rate)
|
||||
}
|
||||
|
||||
fn now_secs() -> u64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
fn cache_path() -> PathBuf {
|
||||
dirs::cache_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("."))
|
||||
.join("codeburn-menubar")
|
||||
.join("fx-rates.json")
|
||||
}
|
||||
|
||||
fn load_from_disk() -> Option<HashMap<String, Entry>> {
|
||||
let bytes = fs::read(cache_path()).ok()?;
|
||||
let parsed: HashMap<String, Entry> = serde_json::from_slice(&bytes).ok()?;
|
||||
Some(parsed.into_iter().filter(|(_, e)| is_valid(e.rate)).collect())
|
||||
}
|
||||
|
||||
fn save_to_disk(entries: &HashMap<String, Entry>) -> Option<()> {
|
||||
let path = cache_path();
|
||||
let parent = path.parent()?;
|
||||
fs::create_dir_all(parent).ok()?;
|
||||
let serialized = serde_json::to_vec(entries).ok()?;
|
||||
let tmp = path.with_extension("tmp");
|
||||
fs::write(&tmp, serialized).ok()?;
|
||||
fs::rename(&tmp, path).ok()?;
|
||||
Some(())
|
||||
}
|
||||
|
||||
async fn fetch_rate(code: &str) -> Option<f64> {
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(FETCH_TIMEOUT)
|
||||
.https_only(true)
|
||||
.build()
|
||||
.ok()?;
|
||||
let url = format!("{}{}", FRANKFURTER_URL, code);
|
||||
let response = client.get(&url).send().await.ok()?;
|
||||
if !response.status().is_success() {
|
||||
return None;
|
||||
}
|
||||
let body: serde_json::Value = response.json().await.ok()?;
|
||||
body.get("rates")?.get(code)?.as_f64()
|
||||
}
|
||||
|
||||
/// Prefers a handwritten glyph over whatever Intl returns for a given code, since some
|
||||
/// locales produce "US$" / "CA$" which reads as noise. Mirrors the Swift symbol override
|
||||
/// table so both apps display identical strings for the same code.
|
||||
pub fn symbol_for(code: &str) -> String {
|
||||
match code {
|
||||
"USD" | "CAD" | "AUD" | "NZD" | "HKD" | "SGD" | "MXN" => "$".into(),
|
||||
"EUR" => "\u{20AC}".into(),
|
||||
"GBP" => "\u{00A3}".into(),
|
||||
"JPY" | "CNY" => "\u{00A5}".into(),
|
||||
"KRW" => "\u{20A9}".into(),
|
||||
"INR" => "\u{20B9}".into(),
|
||||
"BRL" => "R$".into(),
|
||||
"CHF" => "CHF".into(),
|
||||
"SEK" | "DKK" => "kr".into(),
|
||||
"ZAR" => "R".into(),
|
||||
_ => code.into(),
|
||||
}
|
||||
}
|
||||
520
windows/src-tauri/src/lib.rs
Normal file
520
windows/src-tauri/src/lib.rs
Normal file
|
|
@ -0,0 +1,520 @@
|
|||
mod autostart;
|
||||
mod cli;
|
||||
mod config;
|
||||
mod fx;
|
||||
mod plan;
|
||||
/// The spend-in-the-tray badge is a second tray icon, which only the Tauri tray backend
|
||||
/// provides; Linux runs its own SNI tray (`tray_linux`) and has no equivalent, so the
|
||||
/// whole module is compiled out there rather than sitting unused.
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
mod tray_badge;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod tray_linux;
|
||||
|
||||
use std::sync::Mutex;
|
||||
use std::sync::atomic::{AtomicI64, Ordering};
|
||||
|
||||
static LAST_HIDDEN_MS: AtomicI64 = AtomicI64::new(0);
|
||||
|
||||
use tauri::{AppHandle, Emitter, Manager, WindowEvent};
|
||||
#[cfg(target_os = "linux")]
|
||||
use tauri::Listener;
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
use tauri::{
|
||||
menu::{Menu, MenuItem, PredefinedMenuItem},
|
||||
tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent},
|
||||
};
|
||||
|
||||
use crate::cli::CodeburnCli;
|
||||
use crate::config::CurrencyConfig;
|
||||
use crate::fx::FxCache;
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
const TRAY_ID: &str = "codeburn-tray";
|
||||
/// Second tray icon that carries today's spend as text, sitting next to the logo. The
|
||||
/// closest the Windows notification area gets to the macOS menubar title.
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
const BADGE_TRAY_ID: &str = "codeburn-badge";
|
||||
const POPOVER_LABEL: &str = "popover";
|
||||
|
||||
/// Shared application state. Wraps the CLI handle + currency config + FX cache so every
|
||||
/// Tauri command sees the same instances. Interior Mutex keeps things simple; the state is
|
||||
/// touched from the main thread (UI) and the Tokio runtime (CLI spawn, HTTP), both of
|
||||
/// which go through `#[tauri::command]` async functions that acquire the lock briefly.
|
||||
pub struct AppState {
|
||||
pub cli: Mutex<CodeburnCli>,
|
||||
pub config: Mutex<CurrencyConfig>,
|
||||
pub fx: FxCache,
|
||||
pub plan: plan::PlanClient,
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_shell::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.setup(|app| {
|
||||
app.manage(AppState {
|
||||
cli: Mutex::new(CodeburnCli::resolve()),
|
||||
config: Mutex::new(CurrencyConfig::load_or_default()),
|
||||
fx: FxCache::new(),
|
||||
plan: plan::PlanClient::new(),
|
||||
});
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
build_tray_tauri(app.handle())?;
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
init_tray_linux(app.handle().clone(), tray_linux::LinuxTrayHandle::empty());
|
||||
|
||||
if let Some(window) = app.get_webview_window(POPOVER_LABEL) {
|
||||
let _ = window.hide();
|
||||
#[cfg(target_os = "windows")]
|
||||
round_window_corners(&window);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
})
|
||||
.on_window_event(|window, event| {
|
||||
match event {
|
||||
WindowEvent::CloseRequested { api, .. } => {
|
||||
api.prevent_close();
|
||||
let _ = window.hide();
|
||||
mark_hidden(window.app_handle());
|
||||
}
|
||||
WindowEvent::Focused(false) => {
|
||||
let _ = window.hide();
|
||||
mark_hidden(window.app_handle());
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::fetch_payload,
|
||||
commands::cli_status,
|
||||
commands::set_currency,
|
||||
commands::open_terminal_command,
|
||||
commands::open_claude_login,
|
||||
commands::quit_app,
|
||||
commands::hide_popover,
|
||||
commands::set_tray_tooltip,
|
||||
commands::set_tray_badge,
|
||||
commands::app_version,
|
||||
commands::plan_usage,
|
||||
commands::launch_at_login,
|
||||
commands::set_launch_at_login,
|
||||
])
|
||||
.build(tauri::generate_context!())
|
||||
.expect("error while running tauri application")
|
||||
.run(|_app, event| {
|
||||
if let tauri::RunEvent::ExitRequested { api, .. } = event {
|
||||
api.prevent_exit();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn build_tray_tauri(app: &AppHandle) -> tauri::Result<()> {
|
||||
let Some(tray) = app.tray_by_id(TRAY_ID) else {
|
||||
return Ok(());
|
||||
};
|
||||
|
||||
let open = MenuItem::with_id(app, "open", "Open CodeBurn", true, None::<&str>)?;
|
||||
let refresh = MenuItem::with_id(app, "refresh", "Refresh", true, None::<&str>)?;
|
||||
let theme = MenuItem::with_id(app, "toggle_theme", "Toggle Dark/Light", true, None::<&str>)?;
|
||||
let report = MenuItem::with_id(app, "report", "Open Full Report", true, None::<&str>)?;
|
||||
let quit = MenuItem::with_id(app, "quit", "Quit CodeBurn", true, None::<&str>)?;
|
||||
let menu = Menu::with_items(
|
||||
app,
|
||||
&[
|
||||
&open,
|
||||
&refresh,
|
||||
&theme,
|
||||
&report,
|
||||
&PredefinedMenuItem::separator(app)?,
|
||||
&quit,
|
||||
],
|
||||
)?;
|
||||
|
||||
tray.set_menu(Some(menu.clone()))?;
|
||||
tray.set_show_menu_on_left_click(false)?;
|
||||
let _ = tray.set_tooltip(Some("CodeBurn"));
|
||||
tray.on_menu_event(on_tray_menu_event);
|
||||
tray.on_tray_icon_event(on_tray_icon_event);
|
||||
|
||||
// The badge icon starts fully transparent and hidden; the frontend shows it once it has
|
||||
// today's spend. Registering it right after the logo puts it beside the logo in the tray.
|
||||
let blank = tauri::image::Image::new_owned(
|
||||
vec![0u8; (BLANK_ICON_SIZE * BLANK_ICON_SIZE * 4) as usize],
|
||||
BLANK_ICON_SIZE,
|
||||
BLANK_ICON_SIZE,
|
||||
);
|
||||
TrayIconBuilder::with_id(BADGE_TRAY_ID)
|
||||
.icon(blank)
|
||||
.tooltip("CodeBurn")
|
||||
.menu(&menu)
|
||||
.show_menu_on_left_click(false)
|
||||
.on_menu_event(on_tray_menu_event)
|
||||
.on_tray_icon_event(on_tray_icon_event)
|
||||
.build(app)?
|
||||
.set_visible(false)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
const BLANK_ICON_SIZE: u32 = 16;
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn on_tray_menu_event(app: &AppHandle, event: tauri::menu::MenuEvent) {
|
||||
match event.id.as_ref() {
|
||||
"quit" => app.exit(0),
|
||||
"open" => show_popover(app, None),
|
||||
"refresh" => {
|
||||
if let Some(window) = app.get_webview_window(POPOVER_LABEL) {
|
||||
let _ = window.emit("codeburn://refresh", ());
|
||||
}
|
||||
}
|
||||
"toggle_theme" => {
|
||||
if let Some(window) = app.get_webview_window(POPOVER_LABEL) {
|
||||
let _ = window.emit("codeburn://toggle-theme", ());
|
||||
}
|
||||
}
|
||||
"report" => {
|
||||
let _ = cli::spawn_in_terminal(app, &["report"]);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn on_tray_icon_event(tray: &tauri::tray::TrayIcon, event: TrayIconEvent) {
|
||||
match event {
|
||||
TrayIconEvent::Click {
|
||||
button: MouseButton::Left,
|
||||
button_state: MouseButtonState::Up,
|
||||
position,
|
||||
..
|
||||
}
|
||||
| TrayIconEvent::DoubleClick {
|
||||
button: MouseButton::Left,
|
||||
position,
|
||||
..
|
||||
} => {
|
||||
toggle_popover(tray.app_handle(), Some((position.x as i32, position.y as i32)));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn init_tray_linux(app: AppHandle, handle: tray_linux::LinuxTrayHandle) {
|
||||
// Spawn the SNI tray on the Tokio runtime that Tauri already owns.
|
||||
let spawn_app = app.clone();
|
||||
let spawn_handle = handle.clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
if let Err(err) = tray_linux::spawn(spawn_app, spawn_handle).await {
|
||||
eprintln!("codeburn: failed to spawn Linux tray: {err}");
|
||||
}
|
||||
});
|
||||
|
||||
// Left-click on the tray: show popover anchored to the click coordinates.
|
||||
let activate_app = app.clone();
|
||||
app.listen_any("codeburn://tray-activate", move |event| {
|
||||
let anchor = parse_click(event.payload());
|
||||
toggle_popover(&activate_app, anchor);
|
||||
});
|
||||
|
||||
// Right-click / middle-click: same as left for now. Quit lives in the popover footer.
|
||||
let secondary_app = app.clone();
|
||||
app.listen_any("codeburn://tray-secondary", move |event| {
|
||||
let anchor = parse_click(event.payload());
|
||||
toggle_popover(&secondary_app, anchor);
|
||||
});
|
||||
}
|
||||
|
||||
#[cfg(target_os = "linux")]
|
||||
fn parse_click(payload: &str) -> Option<(i32, i32)> {
|
||||
let value: serde_json::Value = serde_json::from_str(payload).ok()?;
|
||||
let x = value.get("x")?.as_i64()? as i32;
|
||||
let y = value.get("y")?.as_i64()? as i32;
|
||||
Some((x, y))
|
||||
}
|
||||
|
||||
/// Undecorated windows are square by default; ask DWM for the Windows 11 rounded corner so
|
||||
/// the acrylic backdrop is clipped to the same shape as the popover card. Silently ignored
|
||||
/// on Windows 10, where the corners stay square.
|
||||
#[cfg(target_os = "windows")]
|
||||
fn round_window_corners(window: &tauri::WebviewWindow) {
|
||||
use windows_sys::Win32::Graphics::Dwm::{
|
||||
DwmSetWindowAttribute, DWMWA_WINDOW_CORNER_PREFERENCE, DWMWCP_ROUND,
|
||||
};
|
||||
let Ok(hwnd) = window.hwnd() else { return };
|
||||
let preference: u32 = DWMWCP_ROUND as u32;
|
||||
unsafe {
|
||||
DwmSetWindowAttribute(
|
||||
hwnd.0 as _,
|
||||
DWMWA_WINDOW_CORNER_PREFERENCE as u32,
|
||||
&preference as *const u32 as *const std::ffi::c_void,
|
||||
std::mem::size_of::<u32>() as u32,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A blur immediately followed by the tray click that caused it would re-open the popover;
|
||||
/// ignore show requests inside this window after a hide.
|
||||
const TOGGLE_DEBOUNCE_MS: i64 = 300;
|
||||
|
||||
fn now_ms() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis() as i64
|
||||
}
|
||||
|
||||
/// Every path that hides the popover goes through here, so the debounce stamp and the
|
||||
/// frontend's visibility signal can never drift apart. The frontend drops to its idle
|
||||
/// refresh cadence on `codeburn://hidden` and comes back on `codeburn://shown`.
|
||||
fn mark_hidden(app: &AppHandle) {
|
||||
LAST_HIDDEN_MS.store(now_ms(), Ordering::Relaxed);
|
||||
let _ = app.emit("codeburn://hidden", ());
|
||||
}
|
||||
|
||||
fn toggle_popover(app: &AppHandle, anchor: Option<(i32, i32)>) {
|
||||
let Some(window) = app.get_webview_window(POPOVER_LABEL) else {
|
||||
return;
|
||||
};
|
||||
if window.is_visible().unwrap_or(false) {
|
||||
let _ = window.hide();
|
||||
mark_hidden(app);
|
||||
return;
|
||||
}
|
||||
let last = LAST_HIDDEN_MS.load(Ordering::Relaxed);
|
||||
if now_ms() - last < TOGGLE_DEBOUNCE_MS {
|
||||
return;
|
||||
}
|
||||
show_popover(app, anchor);
|
||||
}
|
||||
|
||||
fn show_popover(app: &AppHandle, anchor: Option<(i32, i32)>) {
|
||||
let Some(window) = app.get_webview_window(POPOVER_LABEL) else {
|
||||
return;
|
||||
};
|
||||
// Position before showing so the first frame is already in place (no jump).
|
||||
position_popover(&window, anchor);
|
||||
let _ = window.show();
|
||||
let _ = window.unminimize();
|
||||
position_popover(&window, anchor);
|
||||
let _ = window.set_focus();
|
||||
let _ = window.emit("codeburn://shown", ());
|
||||
}
|
||||
|
||||
/// Places the popover against the taskbar / panel edge of the monitor that owns the click
|
||||
/// (or the cursor, when the request came from a menu). The work area already excludes the
|
||||
/// taskbar on Windows and panels on Linux, so we never need to guess their heights: the
|
||||
/// popover sits `MARGIN` inside the work area, horizontally centred on the anchor and
|
||||
/// clamped to the screen.
|
||||
fn position_popover(window: &tauri::WebviewWindow, anchor: Option<(i32, i32)>) {
|
||||
const POPOVER_WIDTH_LOGICAL: f64 = 360.0;
|
||||
const POPOVER_HEIGHT_LOGICAL: f64 = 660.0;
|
||||
const MARGIN_LOGICAL: f64 = 8.0;
|
||||
|
||||
let point = anchor
|
||||
.filter(|(x, y)| *x > 0 || *y > 0)
|
||||
.map(|(x, y)| (x as f64, y as f64))
|
||||
.or_else(|| window.cursor_position().ok().map(|p| (p.x, p.y)));
|
||||
|
||||
let monitor = point
|
||||
.and_then(|(x, y)| window.monitor_from_point(x, y).ok().flatten())
|
||||
.or_else(|| window.primary_monitor().ok().flatten());
|
||||
let Some(monitor) = monitor else {
|
||||
return;
|
||||
};
|
||||
|
||||
let scale = monitor.scale_factor();
|
||||
let pop_w = (POPOVER_WIDTH_LOGICAL * scale).round() as i32;
|
||||
let pop_h = (POPOVER_HEIGHT_LOGICAL * scale).round() as i32;
|
||||
let margin = (MARGIN_LOGICAL * scale).round() as i32;
|
||||
|
||||
let area = monitor.work_area();
|
||||
let area_x = area.position.x;
|
||||
let area_y = area.position.y;
|
||||
let area_w = area.size.width as i32;
|
||||
let area_h = area.size.height as i32;
|
||||
let screen = monitor.size();
|
||||
let screen_pos = monitor.position();
|
||||
|
||||
let (anchor_x, anchor_y) = point
|
||||
.map(|(x, y)| (x as i32, y as i32))
|
||||
.unwrap_or((area_x + area_w - pop_w / 2 - margin, area_y + area_h));
|
||||
|
||||
let min_x = area_x + margin;
|
||||
let max_x = (area_x + area_w - pop_w - margin).max(min_x);
|
||||
let x = (anchor_x - pop_w / 2).clamp(min_x, max_x);
|
||||
|
||||
// Which edge holds the taskbar? Whichever side the work area was trimmed on. If the
|
||||
// taskbar is at the top (or the anchor is in the top half with no bottom taskbar) the
|
||||
// popover drops down from the top edge; otherwise it rises from the bottom edge.
|
||||
let trimmed_top = area_y > screen_pos.y;
|
||||
let trimmed_bottom = (area_y + area_h) < (screen_pos.y + screen.height as i32);
|
||||
let anchor_in_top_half = anchor_y < screen_pos.y + (screen.height as i32) / 2;
|
||||
let open_downward = trimmed_top || (!trimmed_bottom && anchor_in_top_half);
|
||||
|
||||
let y = if open_downward {
|
||||
area_y + margin
|
||||
} else {
|
||||
(area_y + area_h - pop_h - margin).max(area_y + margin)
|
||||
};
|
||||
|
||||
let _ = window.set_position(tauri::PhysicalPosition::new(x, y));
|
||||
}
|
||||
|
||||
mod commands {
|
||||
use super::{AppState, POPOVER_LABEL};
|
||||
use serde_json::Value;
|
||||
use tauri::{AppHandle, Manager, State};
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn fetch_payload(
|
||||
period: String,
|
||||
provider: String,
|
||||
include_optimize: bool,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Value, String> {
|
||||
let cli = state.cli.lock().map_err(|e| e.to_string())?.clone();
|
||||
cli.fetch_menubar_payload(&period, &provider, include_optimize)
|
||||
.await
|
||||
.map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
/// Re-resolves the CLI each call so a freshly installed `codeburn` is picked up
|
||||
/// without restarting the tray app.
|
||||
#[tauri::command]
|
||||
pub async fn cli_status(state: State<'_, AppState>) -> Result<crate::cli::CliStatus, String> {
|
||||
let fresh = crate::cli::CodeburnCli::resolve();
|
||||
let status = fresh.status().await;
|
||||
if status.found {
|
||||
if let Ok(mut guard) = state.cli.lock() {
|
||||
*guard = fresh;
|
||||
}
|
||||
}
|
||||
Ok(status)
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn set_currency(
|
||||
code: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<crate::fx::CurrencyApplied, String> {
|
||||
let symbol = crate::fx::symbol_for(&code);
|
||||
let rate = state
|
||||
.fx
|
||||
.rate_for(&code)
|
||||
.await
|
||||
.ok_or_else(|| format!("Exchange rate for {code} is unavailable right now"))?;
|
||||
state
|
||||
.config
|
||||
.lock()
|
||||
.map_err(|e| e.to_string())?
|
||||
.set_currency(&code, &symbol)
|
||||
.map_err(|e| e.to_string())?;
|
||||
Ok(crate::fx::CurrencyApplied { code, symbol, rate })
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn open_terminal_command(app: AppHandle, args: Vec<String>) -> Result<(), String> {
|
||||
let args: Vec<&str> = args.iter().map(String::as_str).collect();
|
||||
crate::cli::spawn_in_terminal(&app, &args).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn open_claude_login(app: AppHandle) -> Result<(), String> {
|
||||
crate::cli::spawn_claude_login(&app).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn quit_app(app: AppHandle) {
|
||||
app.exit(0);
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn hide_popover(app: AppHandle) {
|
||||
if let Some(window) = app.get_webview_window(POPOVER_LABEL) {
|
||||
let _ = window.hide();
|
||||
super::mark_hidden(&app);
|
||||
}
|
||||
}
|
||||
|
||||
/// The tray cannot render text on Windows, so today's spend lives in the tooltip.
|
||||
#[tauri::command]
|
||||
pub fn set_tray_tooltip(app: AppHandle, text: String) {
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
for id in [super::TRAY_ID, super::BADGE_TRAY_ID] {
|
||||
if let Some(tray) = app.tray_by_id(id) {
|
||||
let _ = tray.set_tooltip(Some(text.as_str()));
|
||||
}
|
||||
}
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
let _ = (app, text);
|
||||
}
|
||||
}
|
||||
|
||||
/// `text` is a short spend string ("$87", "142", "1.2K"); `None` hides the badge icon.
|
||||
#[tauri::command]
|
||||
pub fn set_tray_badge(app: AppHandle, text: Option<String>) -> Result<(), String> {
|
||||
#[cfg(target_os = "linux")]
|
||||
{
|
||||
// Unreachable from the UI: the frontend hides the control wherever the badge is
|
||||
// unsupported (lib/platform.ts). Saying so beats reporting a success that never
|
||||
// happened.
|
||||
let _ = (app, text);
|
||||
Err("the tray spend badge needs a second tray icon, which the Linux SNI tray does not provide".to_string())
|
||||
}
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
{
|
||||
let Some(badge) = app.tray_by_id(super::BADGE_TRAY_ID) else {
|
||||
return Ok(());
|
||||
};
|
||||
match text.as_deref().map(str::trim).filter(|t| !t.is_empty()) {
|
||||
Some(t) => {
|
||||
let icon = crate::tray_badge::render(
|
||||
t,
|
||||
crate::tray_badge::small_icon_size(),
|
||||
crate::tray_badge::taskbar_is_dark(),
|
||||
);
|
||||
// Windows can only modify an icon that is currently shown, so show
|
||||
// first (re-adds the previous bitmap) and then swap the bitmap.
|
||||
badge.set_visible(true).map_err(|e| e.to_string())?;
|
||||
badge.set_icon(Some(icon)).map_err(|e| e.to_string())?;
|
||||
}
|
||||
None => {
|
||||
badge.set_visible(false).map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn app_version(app: AppHandle) -> String {
|
||||
app.package_info().version.to_string()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn launch_at_login() -> bool {
|
||||
crate::autostart::is_enabled()
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub fn set_launch_at_login(enabled: bool) -> Result<bool, String> {
|
||||
crate::autostart::set_enabled(enabled).map_err(|e| e.to_string())?;
|
||||
Ok(crate::autostart::is_enabled())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn plan_usage(state: State<'_, AppState>) -> Result<crate::plan::PlanUsage, String> {
|
||||
state.plan.fetch().await.map_err(|e| e.to_string())
|
||||
}
|
||||
}
|
||||
6
windows/src-tauri/src/main.rs
Normal file
6
windows/src-tauri/src/main.rs
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
// Stops an extra console window appearing on Windows in release builds.
|
||||
#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")]
|
||||
|
||||
fn main() {
|
||||
codeburn_menubar_lib::run()
|
||||
}
|
||||
479
windows/src-tauri/src/plan.rs
Normal file
479
windows/src-tauri/src/plan.rs
Normal file
|
|
@ -0,0 +1,479 @@
|
|||
//! Claude subscription usage (the "Plan" insight). Mirrors the macOS SubscriptionClient:
|
||||
//! read Claude Code's OAuth credentials, call the usage endpoint, adopt a token Claude Code
|
||||
//! has already rotated on 401 (never spending the shared refresh token), and keep a rolling
|
||||
//! snapshot file so a freshly reset window can still show last cycle's final.
|
||||
|
||||
use std::fs;
|
||||
use std::path::PathBuf;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use anyhow::{bail, Context, Result};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
const CREDENTIALS_RELATIVE_PATH: &str = ".claude/.credentials.json";
|
||||
const USAGE_URL: &str = "https://api.anthropic.com/api/oauth/usage";
|
||||
const BETA_HEADER: &str = "oauth-2025-04-20";
|
||||
const USER_AGENT: &str = "claude-code/2.1.0";
|
||||
const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
const MAX_CREDENTIAL_BYTES: u64 = 64 * 1024;
|
||||
const SNAPSHOT_FILENAME: &str = "subscription-snapshots.json";
|
||||
const SNAPSHOT_RETENTION: Duration = Duration::from_secs(30 * 24 * 3600);
|
||||
const WINDOW_KEYS: [(&str, &str); 4] = [
|
||||
("five_hour", "5-hour window"),
|
||||
("seven_day", "7-day total"),
|
||||
("seven_day_opus", "7-day Opus"),
|
||||
("seven_day_sonnet", "7-day Sonnet"),
|
||||
];
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct PlanWindow {
|
||||
pub key: String,
|
||||
pub label: String,
|
||||
/// 0..100
|
||||
pub percent: f64,
|
||||
/// RFC 3339 timestamp of the next reset, when the API supplied one.
|
||||
pub resets_at: Option<String>,
|
||||
/// Final percent reached in the immediately prior cycle, from the snapshot store.
|
||||
pub previous_final: Option<f64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
#[serde(tag = "state", rename_all = "snake_case")]
|
||||
pub enum PlanUsage {
|
||||
Ok {
|
||||
tier: String,
|
||||
raw_tier: Option<String>,
|
||||
windows: Vec<PlanWindow>,
|
||||
fetched_at: String,
|
||||
},
|
||||
NoCredentials,
|
||||
Failed {
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
pub struct PlanClient {
|
||||
snapshot_lock: Mutex<()>,
|
||||
}
|
||||
|
||||
impl PlanClient {
|
||||
pub fn new() -> Self {
|
||||
PlanClient {
|
||||
snapshot_lock: Mutex::new(()),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn fetch(&self) -> Result<PlanUsage> {
|
||||
let creds = match load_credentials() {
|
||||
Ok(Some(c)) => c,
|
||||
Ok(None) => return Ok(PlanUsage::NoCredentials),
|
||||
Err(err) => {
|
||||
return Ok(PlanUsage::Failed {
|
||||
message: err.to_string(),
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
let response = match fetch_usage(&creds.access_token).await {
|
||||
Ok(r) => r,
|
||||
// Parity with mac/Sources/CodeBurnMenubar/Data/ClaudeCredentialStore.swift
|
||||
// (`refreshAfter401`): Claude's refresh token is single-use and rotates, so
|
||||
// spending it here would invalidate the token Claude Code itself is holding and
|
||||
// break the user's `claude` login. Instead re-read Claude's own store for a
|
||||
// token it has already rotated; if there is nothing fresher yet, report a
|
||||
// transient failure and let the next refresh pick it up.
|
||||
Err(FetchError::Unauthorized) => {
|
||||
let rotated = load_credentials()
|
||||
.ok()
|
||||
.flatten()
|
||||
.map(|c| c.access_token)
|
||||
.filter(|t| *t != creds.access_token);
|
||||
let Some(token) = rotated else {
|
||||
return Ok(PlanUsage::Failed {
|
||||
message: "Claude is refreshing its session. This clears itself once Claude Code renews the token; run `claude login` if it persists.".into(),
|
||||
});
|
||||
};
|
||||
match fetch_usage(&token).await {
|
||||
Ok(r) => r,
|
||||
Err(err) => {
|
||||
return Ok(PlanUsage::Failed {
|
||||
message: err.to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
return Ok(PlanUsage::Failed {
|
||||
message: err.to_string(),
|
||||
})
|
||||
}
|
||||
};
|
||||
|
||||
let now = SystemTime::now();
|
||||
let mut windows = Vec::new();
|
||||
for (key, label) in WINDOW_KEYS {
|
||||
let Some(window) = response.window(key) else { continue };
|
||||
let Some(percent) = window.utilization else { continue };
|
||||
let resets_at = window.resets_at.clone().filter(|s| !s.is_empty());
|
||||
let previous_final = {
|
||||
let _guard = self.snapshot_lock.lock().await;
|
||||
if let Some(reset) = resets_at.as_deref() {
|
||||
record_snapshot(key, percent, reset, now);
|
||||
previous_window_final(key, reset)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
};
|
||||
windows.push(PlanWindow {
|
||||
key: key.to_string(),
|
||||
label: label.to_string(),
|
||||
percent: percent.clamp(0.0, 100.0),
|
||||
resets_at,
|
||||
previous_final,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(PlanUsage::Ok {
|
||||
tier: tier_display(creds.rate_limit_tier.as_deref()),
|
||||
raw_tier: creds.rate_limit_tier,
|
||||
windows,
|
||||
fetched_at: to_rfc3339(now),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// ---- credentials -----------------------------------------------------------------------
|
||||
|
||||
struct StoredCredentials {
|
||||
access_token: String,
|
||||
rate_limit_tier: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct CredentialsRoot {
|
||||
#[serde(rename = "claudeAiOauth")]
|
||||
claude_ai_oauth: Option<OAuthBlock>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct OAuthBlock {
|
||||
#[serde(rename = "accessToken")]
|
||||
access_token: Option<String>,
|
||||
#[serde(rename = "rateLimitTier")]
|
||||
rate_limit_tier: Option<String>,
|
||||
}
|
||||
|
||||
fn credentials_path() -> Option<PathBuf> {
|
||||
dirs::home_dir().map(|h| h.join(CREDENTIALS_RELATIVE_PATH))
|
||||
}
|
||||
|
||||
/// Ok(None) when the file does not exist (user never logged in); Err for malformed data.
|
||||
fn load_credentials() -> Result<Option<StoredCredentials>> {
|
||||
let Some(path) = credentials_path() else {
|
||||
return Ok(None);
|
||||
};
|
||||
let meta = match fs::symlink_metadata(&path) {
|
||||
Ok(m) => m,
|
||||
Err(_) => return Ok(None),
|
||||
};
|
||||
if meta.file_type().is_symlink() {
|
||||
bail!("credentials file is a symlink; refusing to read it");
|
||||
}
|
||||
if meta.len() > MAX_CREDENTIAL_BYTES {
|
||||
bail!("credentials file is unexpectedly large");
|
||||
}
|
||||
let bytes = fs::read(&path).with_context(|| "failed to read Claude credentials")?;
|
||||
let root: CredentialsRoot =
|
||||
serde_json::from_slice(&bytes).with_context(|| "Claude credentials are malformed")?;
|
||||
let Some(oauth) = root.claude_ai_oauth else {
|
||||
return Ok(None);
|
||||
};
|
||||
let token = oauth.access_token.unwrap_or_default().trim().to_string();
|
||||
if token.is_empty() {
|
||||
return Ok(None);
|
||||
}
|
||||
Ok(Some(StoredCredentials {
|
||||
access_token: token,
|
||||
rate_limit_tier: oauth.rate_limit_tier,
|
||||
}))
|
||||
}
|
||||
|
||||
fn tier_display(raw: Option<&str>) -> String {
|
||||
let Some(raw) = raw.map(|r| r.to_lowercase()) else {
|
||||
return "Subscription".into();
|
||||
};
|
||||
if raw.contains("max_20x") || raw.contains("max20x") || raw.contains("max-20x") {
|
||||
return "Max 20x".into();
|
||||
}
|
||||
if raw.contains("max_5x") || raw.contains("max5x") || raw.contains("max-5x") {
|
||||
return "Max 5x".into();
|
||||
}
|
||||
if raw.contains("max") {
|
||||
return "Max 5x".into();
|
||||
}
|
||||
if raw.contains("pro") {
|
||||
return "Pro".into();
|
||||
}
|
||||
if raw.contains("team") {
|
||||
return "Team".into();
|
||||
}
|
||||
if raw.contains("enterprise") {
|
||||
return "Enterprise".into();
|
||||
}
|
||||
"Subscription".into()
|
||||
}
|
||||
|
||||
// ---- HTTP ------------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct UsageResponse {
|
||||
five_hour: Option<Window>,
|
||||
seven_day: Option<Window>,
|
||||
seven_day_opus: Option<Window>,
|
||||
seven_day_sonnet: Option<Window>,
|
||||
}
|
||||
|
||||
impl UsageResponse {
|
||||
fn window(&self, key: &str) -> Option<&Window> {
|
||||
match key {
|
||||
"five_hour" => self.five_hour.as_ref(),
|
||||
"seven_day" => self.seven_day.as_ref(),
|
||||
"seven_day_opus" => self.seven_day_opus.as_ref(),
|
||||
"seven_day_sonnet" => self.seven_day_sonnet.as_ref(),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct Window {
|
||||
utilization: Option<f64>,
|
||||
resets_at: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
enum FetchError {
|
||||
#[error("Claude session is no longer authorized")]
|
||||
Unauthorized,
|
||||
#[error("Usage fetch failed ({0}){1}")]
|
||||
Http(u16, String),
|
||||
#[error("{0}")]
|
||||
Other(String),
|
||||
}
|
||||
|
||||
fn client() -> Result<reqwest::Client, FetchError> {
|
||||
reqwest::Client::builder()
|
||||
.timeout(REQUEST_TIMEOUT)
|
||||
.https_only(true)
|
||||
.build()
|
||||
.map_err(|e| FetchError::Other(e.to_string()))
|
||||
}
|
||||
|
||||
async fn fetch_usage(token: &str) -> Result<UsageResponse, FetchError> {
|
||||
let response = client()?
|
||||
.get(USAGE_URL)
|
||||
.bearer_auth(token)
|
||||
.header("Accept", "application/json")
|
||||
.header("anthropic-beta", BETA_HEADER)
|
||||
.header("User-Agent", USER_AGENT)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| FetchError::Other(e.to_string()))?;
|
||||
let status = response.status();
|
||||
if status.as_u16() == 401 {
|
||||
return Err(FetchError::Unauthorized);
|
||||
}
|
||||
if !status.is_success() {
|
||||
let body = response.text().await.unwrap_or_default();
|
||||
let detail = if body.is_empty() { String::new() } else { format!(": {}", truncate(&body, 200)) };
|
||||
return Err(FetchError::Http(status.as_u16(), detail));
|
||||
}
|
||||
response
|
||||
.json::<UsageResponse>()
|
||||
.await
|
||||
.map_err(|e| FetchError::Other(format!("Decode failed: {e}")))
|
||||
}
|
||||
|
||||
fn truncate(text: &str, max: usize) -> String {
|
||||
let mut out: String = text.chars().take(max).collect();
|
||||
if text.chars().count() > max {
|
||||
out.push_str("...");
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
// ---- snapshots -------------------------------------------------------------------------
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
struct Snapshot {
|
||||
#[serde(rename = "windowKey")]
|
||||
window_key: String,
|
||||
percent: f64,
|
||||
#[serde(rename = "resetsAt")]
|
||||
resets_at: String,
|
||||
#[serde(rename = "capturedAt")]
|
||||
captured_at: String,
|
||||
#[serde(rename = "effectiveTokens")]
|
||||
effective_tokens: Option<f64>,
|
||||
}
|
||||
|
||||
/// None when there is no cache dir and no home dir: writing the snapshot store into the
|
||||
/// process's current directory would scatter a file wherever the tray happened to be
|
||||
/// launched from, so we simply skip snapshots instead.
|
||||
fn snapshots_path() -> Option<PathBuf> {
|
||||
std::env::var_os("CODEBURN_CACHE_DIR")
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| dirs::home_dir().map(|h| h.join(".cache").join("codeburn")))
|
||||
.map(|dir| dir.join(SNAPSHOT_FILENAME))
|
||||
}
|
||||
|
||||
fn load_snapshots() -> Vec<Snapshot> {
|
||||
snapshots_path()
|
||||
.and_then(|p| fs::read(p).ok())
|
||||
.and_then(|bytes| serde_json::from_slice(&bytes).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Mirrors `mac/Sources/CodeBurnMenubar/Security/SafeFile.swift`: write to a temp file with
|
||||
/// owner-only permissions and rename over the target, and refuse a target that has been
|
||||
/// replaced by a symlink pointing somewhere else.
|
||||
fn save_snapshots(all: &[Snapshot]) {
|
||||
let Some(path) = snapshots_path() else { return };
|
||||
if let Ok(meta) = fs::symlink_metadata(&path) {
|
||||
if meta.file_type().is_symlink() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
if let Some(parent) = path.parent() {
|
||||
let _ = fs::create_dir_all(parent);
|
||||
}
|
||||
let Ok(bytes) = serde_json::to_vec_pretty(all) else { return };
|
||||
let tmp = path.with_extension("tmp");
|
||||
let mut options = fs::OpenOptions::new();
|
||||
options.write(true).create(true).truncate(true);
|
||||
#[cfg(unix)]
|
||||
{
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
options.mode(0o600);
|
||||
}
|
||||
let Ok(mut file) = options.open(&tmp) else { return };
|
||||
use std::io::Write;
|
||||
if file.write_all(&bytes).is_ok() && file.flush().is_ok() {
|
||||
drop(file);
|
||||
let _ = fs::rename(&tmp, &path);
|
||||
} else {
|
||||
drop(file);
|
||||
let _ = fs::remove_file(&tmp);
|
||||
}
|
||||
}
|
||||
|
||||
fn record_snapshot(window_key: &str, percent: f64, resets_at: &str, now: SystemTime) {
|
||||
let mut all = load_snapshots();
|
||||
match all
|
||||
.iter_mut()
|
||||
.find(|s| s.window_key == window_key && s.resets_at == resets_at)
|
||||
{
|
||||
Some(existing) => {
|
||||
if percent > existing.percent {
|
||||
existing.percent = percent;
|
||||
existing.captured_at = to_rfc3339(now);
|
||||
}
|
||||
}
|
||||
None => all.push(Snapshot {
|
||||
window_key: window_key.to_string(),
|
||||
percent,
|
||||
resets_at: resets_at.to_string(),
|
||||
captured_at: to_rfc3339(now),
|
||||
effective_tokens: None,
|
||||
}),
|
||||
}
|
||||
let cutoff = now.checked_sub(SNAPSHOT_RETENTION).unwrap_or(UNIX_EPOCH);
|
||||
all.retain(|s| parse_rfc3339(&s.captured_at).map(|t| t >= cutoff).unwrap_or(true));
|
||||
save_snapshots(&all);
|
||||
}
|
||||
|
||||
fn previous_window_final(window_key: &str, current_resets_at: &str) -> Option<f64> {
|
||||
let current = parse_rfc3339(current_resets_at)?;
|
||||
let all = load_snapshots();
|
||||
let priors: Vec<(SystemTime, f64)> = all
|
||||
.iter()
|
||||
.filter(|s| s.window_key == window_key)
|
||||
.filter_map(|s| parse_rfc3339(&s.resets_at).map(|t| (t, s.percent)))
|
||||
.filter(|(t, _)| *t < current)
|
||||
.collect();
|
||||
let latest = priors.iter().map(|(t, _)| *t).max()?;
|
||||
priors
|
||||
.iter()
|
||||
.filter(|(t, _)| *t == latest)
|
||||
.map(|(_, p)| *p)
|
||||
.fold(None, |acc: Option<f64>, p| Some(acc.map_or(p, |a| a.max(p))))
|
||||
}
|
||||
|
||||
// ---- time helpers (RFC 3339 without pulling in chrono) --------------------------------
|
||||
|
||||
fn to_rfc3339(t: SystemTime) -> String {
|
||||
let secs = t.duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) as i64;
|
||||
let (y, m, d, hh, mm, ss) = civil_from_unix(secs);
|
||||
format!("{y:04}-{m:02}-{d:02}T{hh:02}:{mm:02}:{ss:02}Z")
|
||||
}
|
||||
|
||||
/// Accepts `2026-08-18T10:00:00Z`, with optional fractional seconds and `+hh:mm` offsets.
|
||||
fn parse_rfc3339(text: &str) -> Option<SystemTime> {
|
||||
let bytes = text.as_bytes();
|
||||
if bytes.len() < 19 {
|
||||
return None;
|
||||
}
|
||||
let num = |a: usize, b: usize| text.get(a..b)?.parse::<i64>().ok();
|
||||
let (y, mo, d) = (num(0, 4)?, num(5, 7)?, num(8, 10)?);
|
||||
let (h, mi, s) = (num(11, 13)?, num(14, 16)?, num(17, 19)?);
|
||||
let mut rest = &text[19..];
|
||||
if rest.starts_with('.') {
|
||||
let end = rest[1..]
|
||||
.find(|c: char| !c.is_ascii_digit())
|
||||
.map(|i| i + 1)
|
||||
.unwrap_or(rest.len());
|
||||
rest = &rest[end..];
|
||||
}
|
||||
let offset_secs = match rest {
|
||||
"" | "Z" | "z" => 0,
|
||||
_ => {
|
||||
let sign = if rest.starts_with('-') { -1 } else { 1 };
|
||||
let oh = rest.get(1..3)?.parse::<i64>().ok()?;
|
||||
let om = rest.get(4..6)?.parse::<i64>().ok()?;
|
||||
sign * (oh * 3600 + om * 60)
|
||||
}
|
||||
};
|
||||
let unix = unix_from_civil(y, mo, d) + h * 3600 + mi * 60 + s - offset_secs;
|
||||
if unix < 0 {
|
||||
return None;
|
||||
}
|
||||
Some(UNIX_EPOCH + Duration::from_secs(unix as u64))
|
||||
}
|
||||
|
||||
fn unix_from_civil(y: i64, m: i64, d: i64) -> i64 {
|
||||
// Howard Hinnant's days_from_civil.
|
||||
let y = if m <= 2 { y - 1 } else { y };
|
||||
let era = if y >= 0 { y } else { y - 399 } / 400;
|
||||
let yoe = y - era * 400;
|
||||
let mp = (m + 9) % 12;
|
||||
let doy = (153 * mp + 2) / 5 + d - 1;
|
||||
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
||||
(era * 146_097 + doe - 719_468) * 86_400
|
||||
}
|
||||
|
||||
fn civil_from_unix(secs: i64) -> (i64, i64, i64, i64, i64, i64) {
|
||||
let days = secs.div_euclid(86_400);
|
||||
let rem = secs.rem_euclid(86_400);
|
||||
let z = days + 719_468;
|
||||
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
|
||||
let doe = z - era * 146_097;
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let y = if m <= 2 { y + 1 } else { y };
|
||||
(y, m, d, rem / 3600, (rem % 3600) / 60, rem % 60)
|
||||
}
|
||||
252
windows/src-tauri/src/tray_badge.rs
Normal file
252
windows/src-tauri/src/tray_badge.rs
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
//! Renders today's spend into the tray icon. Windows and most Linux panels cannot place a
|
||||
//! title next to a tray icon the way the macOS menubar does, so the number becomes the icon:
|
||||
//! a 4x7 pixel font drawn at the panel's native small-icon size, so it stays crisp instead
|
||||
//! of being a scaled-down bitmap.
|
||||
|
||||
use tauri::image::Image;
|
||||
|
||||
const GLYPH_HEIGHT: usize = 7;
|
||||
const BASE_ICON_SIZE: u32 = 16;
|
||||
const GLYPH_GAP: usize = 1;
|
||||
/// Brand accent on a light taskbar, the lighter ember on a dark one.
|
||||
const ACCENT_LIGHT_TASKBAR: [u8; 3] = [0xC9, 0x52, 0x1D];
|
||||
const ACCENT_DARK_TASKBAR: [u8; 3] = [0xF0, 0x8A, 0x55];
|
||||
|
||||
struct Glyph {
|
||||
width: usize,
|
||||
rows: [&'static str; GLYPH_HEIGHT],
|
||||
}
|
||||
|
||||
fn glyph(c: char) -> Option<Glyph> {
|
||||
let g = |width: usize, rows: [&'static str; GLYPH_HEIGHT]| Some(Glyph { width, rows });
|
||||
match c {
|
||||
'0' => g(4, [".##.", "#..#", "#..#", "#..#", "#..#", "#..#", ".##."]),
|
||||
'1' => g(3, [".#.", "##.", ".#.", ".#.", ".#.", ".#.", "###"]),
|
||||
'2' => g(4, [".##.", "#..#", "...#", "..#.", ".#..", "#...", "####"]),
|
||||
'3' => g(4, ["###.", "...#", "...#", ".##.", "...#", "...#", "###."]),
|
||||
'4' => g(4, ["#..#", "#..#", "#..#", "####", "...#", "...#", "...#"]),
|
||||
'5' => g(4, ["####", "#...", "#...", "###.", "...#", "...#", "###."]),
|
||||
'6' => g(4, [".##.", "#...", "#...", "###.", "#..#", "#..#", ".##."]),
|
||||
'7' => g(4, ["####", "...#", "..#.", "..#.", ".#..", ".#..", ".#.."]),
|
||||
'8' => g(4, [".##.", "#..#", "#..#", ".##.", "#..#", "#..#", ".##."]),
|
||||
'9' => g(4, [".##.", "#..#", "#..#", ".###", "...#", "...#", ".##."]),
|
||||
'$' => g(4, ["..#.", ".###", "#.#.", ".##.", ".#.#", "###.", "..#."]),
|
||||
'K' => g(4, ["#..#", "#.#.", "##..", "#...", "##..", "#.#.", "#..#"]),
|
||||
'M' => g(4, ["#..#", "####", "####", "#..#", "#..#", "#..#", "#..#"]),
|
||||
'.' => g(1, [".", ".", ".", ".", ".", ".", "#"]),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn text_width(text: &str) -> usize {
|
||||
let glyphs: Vec<Glyph> = text.chars().filter_map(glyph).collect();
|
||||
if glyphs.is_empty() {
|
||||
return 0;
|
||||
}
|
||||
glyphs.iter().map(|g| g.width).sum::<usize>() + GLYPH_GAP * (glyphs.len() - 1)
|
||||
}
|
||||
|
||||
/// Draws `text` centred in a `size` x `size` RGBA icon. Prefers anti-aliased bold system
|
||||
/// text (far more legible at 16px than 1px pixel strokes); falls back to the pixel font
|
||||
/// when no usable font file is present.
|
||||
pub fn render(text: &str, size: u32, dark_taskbar: bool) -> Image<'static> {
|
||||
let color = if dark_taskbar { ACCENT_DARK_TASKBAR } else { ACCENT_LIGHT_TASKBAR };
|
||||
if let Some(image) = render_with_font(text, size, color) {
|
||||
return image;
|
||||
}
|
||||
// The pixel font cannot shrink, so trim from the right until the string fits.
|
||||
let mut trimmed: String = text.to_string();
|
||||
while !fits(&trimmed) && trimmed.pop().is_some() {}
|
||||
render_pixel_font(&trimmed, size, dark_taskbar)
|
||||
}
|
||||
|
||||
/// Bold sans faces shipped with Windows, best first. Bahnschrift's condensed numerals fit
|
||||
/// four glyphs into 16px at a larger size than Segoe UI Bold does.
|
||||
#[cfg(target_os = "windows")]
|
||||
const FONT_CANDIDATES: [&str; 3] = [
|
||||
r"C:\Windows\Fonts\bahnschrift.ttf",
|
||||
r"C:\Windows\Fonts\segoeuib.ttf",
|
||||
r"C:\Windows\Fonts\arialbd.ttf",
|
||||
];
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
const FONT_CANDIDATES: [&str; 3] = [
|
||||
"/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",
|
||||
"/usr/share/fonts/TTF/DejaVuSans-Bold.ttf",
|
||||
"/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf",
|
||||
];
|
||||
|
||||
/// Largest and smallest text sizes tried, as a fraction of the icon size.
|
||||
const FONT_MAX_FRACTION: f32 = 0.95;
|
||||
const FONT_MIN_FRACTION: f32 = 0.5;
|
||||
const FONT_STEP_PX: f32 = 0.5;
|
||||
|
||||
/// Read and parsed once: the badge re-renders on every refresh and the font file does not
|
||||
/// change under a running session.
|
||||
fn font() -> Option<&'static fontdue::Font> {
|
||||
static FONT: std::sync::OnceLock<Option<fontdue::Font>> = std::sync::OnceLock::new();
|
||||
FONT.get_or_init(load_font).as_ref()
|
||||
}
|
||||
|
||||
fn load_font() -> Option<fontdue::Font> {
|
||||
for path in FONT_CANDIDATES {
|
||||
if let Ok(bytes) = std::fs::read(path) {
|
||||
if let Ok(font) = fontdue::Font::from_bytes(bytes, fontdue::FontSettings::default()) {
|
||||
return Some(font);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
struct Raster {
|
||||
metrics: fontdue::Metrics,
|
||||
bitmap: Vec<u8>,
|
||||
}
|
||||
|
||||
fn layout(font: &fontdue::Font, text: &str, px: f32) -> (Vec<Raster>, f32, i32, i32) {
|
||||
let glyphs: Vec<Raster> = text
|
||||
.chars()
|
||||
.map(|c| {
|
||||
let (metrics, bitmap) = font.rasterize(c, px);
|
||||
Raster { metrics, bitmap }
|
||||
})
|
||||
.collect();
|
||||
let width: f32 = glyphs.iter().map(|g| g.metrics.advance_width).sum();
|
||||
let top = glyphs.iter().map(|g| g.metrics.height as i32 + g.metrics.ymin).max().unwrap_or(0);
|
||||
let bottom = glyphs.iter().map(|g| g.metrics.ymin).min().unwrap_or(0);
|
||||
(glyphs, width, top, bottom)
|
||||
}
|
||||
|
||||
fn render_with_font(text: &str, size: u32, color: [u8; 3]) -> Option<Image<'static>> {
|
||||
let font = font()?;
|
||||
let limit = size as f32;
|
||||
let mut px = limit * FONT_MAX_FRACTION;
|
||||
let mut chosen = None;
|
||||
while px >= limit * FONT_MIN_FRACTION {
|
||||
let (glyphs, width, top, bottom) = layout(font, text, px);
|
||||
let height = (top - bottom) as f32;
|
||||
if width <= limit && height <= limit {
|
||||
chosen = Some((glyphs, width, top, bottom));
|
||||
break;
|
||||
}
|
||||
px -= FONT_STEP_PX;
|
||||
}
|
||||
let (glyphs, width, top, bottom) = chosen?;
|
||||
|
||||
let mut rgba = vec![0u8; (size * size * 4) as usize];
|
||||
let height = top - bottom;
|
||||
let x0 = ((limit - width) / 2.0).round();
|
||||
let baseline = ((size as i32 - height) / 2) + top;
|
||||
let mut pen = x0;
|
||||
for g in &glyphs {
|
||||
let gx = pen.round() as i32 + g.metrics.xmin;
|
||||
let gy = baseline - g.metrics.height as i32 - g.metrics.ymin;
|
||||
for row in 0..g.metrics.height {
|
||||
for col in 0..g.metrics.width {
|
||||
let alpha = g.bitmap[row * g.metrics.width + col];
|
||||
if alpha == 0 {
|
||||
continue;
|
||||
}
|
||||
let px_x = gx + col as i32;
|
||||
let px_y = gy + row as i32;
|
||||
if px_x < 0 || px_y < 0 || px_x >= size as i32 || px_y >= size as i32 {
|
||||
continue;
|
||||
}
|
||||
let i = ((px_y as u32 * size + px_x as u32) * 4) as usize;
|
||||
let existing = rgba[i + 3];
|
||||
let merged = existing.max(alpha);
|
||||
rgba[i] = color[0];
|
||||
rgba[i + 1] = color[1];
|
||||
rgba[i + 2] = color[2];
|
||||
rgba[i + 3] = merged;
|
||||
}
|
||||
}
|
||||
pen += g.metrics.advance_width;
|
||||
}
|
||||
Some(Image::new_owned(rgba, size, size))
|
||||
}
|
||||
|
||||
fn render_pixel_font(text: &str, size: u32, dark_taskbar: bool) -> Image<'static> {
|
||||
let size = size.max(BASE_ICON_SIZE);
|
||||
let scale = (size / BASE_ICON_SIZE).max(1) as usize;
|
||||
let mut rgba = vec![0u8; (size * size * 4) as usize];
|
||||
let color = if dark_taskbar { ACCENT_DARK_TASKBAR } else { ACCENT_LIGHT_TASKBAR };
|
||||
|
||||
let width = text_width(text) * scale;
|
||||
let height = GLYPH_HEIGHT * scale;
|
||||
let mut x = (size as usize).saturating_sub(width) / 2;
|
||||
let y0 = (size as usize).saturating_sub(height) / 2;
|
||||
|
||||
for g in text.chars().filter_map(glyph) {
|
||||
for (row, bits) in g.rows.iter().enumerate() {
|
||||
for (col, ch) in bits.chars().enumerate() {
|
||||
if ch != '#' {
|
||||
continue;
|
||||
}
|
||||
for dy in 0..scale {
|
||||
for dx in 0..scale {
|
||||
let px = x + col * scale + dx;
|
||||
let py = y0 + row * scale + dy;
|
||||
if px < size as usize && py < size as usize {
|
||||
let i = (py * size as usize + px) * 4;
|
||||
rgba[i] = color[0];
|
||||
rgba[i + 1] = color[1];
|
||||
rgba[i + 2] = color[2];
|
||||
rgba[i + 3] = 0xFF;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
x += (g.width + GLYPH_GAP) * scale;
|
||||
}
|
||||
|
||||
Image::new_owned(rgba, size, size)
|
||||
}
|
||||
|
||||
/// Whether the text fits the 16px pixel-font grid (the font path scales itself to fit).
|
||||
fn fits(text: &str) -> bool {
|
||||
text_width(text) <= BASE_ICON_SIZE as usize
|
||||
}
|
||||
|
||||
/// The panel's small-icon size in physical pixels (16 at 100%, 20 at 125%, 24 at 150%).
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn small_icon_size() -> u32 {
|
||||
use windows_sys::Win32::UI::WindowsAndMessaging::{GetSystemMetrics, SM_CXSMICON};
|
||||
let px = unsafe { GetSystemMetrics(SM_CXSMICON) };
|
||||
if px > 0 { px as u32 } else { BASE_ICON_SIZE }
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
pub fn small_icon_size() -> u32 {
|
||||
22
|
||||
}
|
||||
|
||||
/// Windows keeps the taskbar theme separate from the app theme; the number must contrast
|
||||
/// with the taskbar, not the popover.
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn taskbar_is_dark() -> bool {
|
||||
// Absolute `reg.exe` out of System32 -- this runs on every badge refresh, so a bare
|
||||
// name here would be the single most reliably triggered planted-binary path.
|
||||
let output = crate::cli::system_command("reg.exe")
|
||||
.args([
|
||||
"query",
|
||||
r"HKCU\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize",
|
||||
"/v",
|
||||
"SystemUsesLightTheme",
|
||||
])
|
||||
.output();
|
||||
match output {
|
||||
Ok(out) => {
|
||||
let text = String::from_utf8_lossy(&out.stdout);
|
||||
// "0x0" means the system (taskbar) uses the dark theme.
|
||||
text.lines().any(|l| l.contains("SystemUsesLightTheme") && l.trim_end().ends_with("0x0"))
|
||||
}
|
||||
Err(_) => true,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "windows"))]
|
||||
pub fn taskbar_is_dark() -> bool {
|
||||
true
|
||||
}
|
||||
139
windows/src-tauri/src/tray_linux.rs
Normal file
139
windows/src-tauri/src/tray_linux.rs
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use ksni::{Category, Icon, Status, ToolTip, Tray, TrayMethods};
|
||||
use tauri::{AppHandle, Emitter};
|
||||
|
||||
/// StatusNotifierItem-backed tray for Linux. Bypasses libappindicator so left-click
|
||||
/// fires `activate(x, y)` with real screen coordinates, which is what Tauri's Linux
|
||||
/// tray path cannot deliver. See tauri-apps/tauri#7283 for the upstream gap.
|
||||
///
|
||||
/// No menu() is exported. Exporting a menu causes most SNI hosts (notably
|
||||
/// gnome-shell-extension-appindicator) to swallow left-click as a menu-open and
|
||||
/// never fire Activate. Quit/Refresh/Open Full Report live in the popover footer.
|
||||
pub struct CodeburnTray {
|
||||
app: AppHandle,
|
||||
title: String,
|
||||
icon: Vec<Icon>,
|
||||
}
|
||||
|
||||
impl CodeburnTray {
|
||||
fn new(app: AppHandle, icon: Vec<Icon>) -> Self {
|
||||
Self {
|
||||
app,
|
||||
title: "CodeBurn".to_string(),
|
||||
icon,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Tray for CodeburnTray {
|
||||
fn id(&self) -> String {
|
||||
"org.agentseal.codeburn".to_string()
|
||||
}
|
||||
|
||||
fn title(&self) -> String {
|
||||
self.title.clone()
|
||||
}
|
||||
|
||||
fn category(&self) -> Category {
|
||||
Category::ApplicationStatus
|
||||
}
|
||||
|
||||
fn status(&self) -> Status {
|
||||
Status::Active
|
||||
}
|
||||
|
||||
fn icon_pixmap(&self) -> Vec<Icon> {
|
||||
self.icon.clone()
|
||||
}
|
||||
|
||||
fn tool_tip(&self) -> ToolTip {
|
||||
ToolTip {
|
||||
icon_name: String::new(),
|
||||
icon_pixmap: Vec::new(),
|
||||
title: "CodeBurn".to_string(),
|
||||
description: self.title.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn activate(&mut self, x: i32, y: i32) {
|
||||
let _ = self
|
||||
.app
|
||||
.emit("codeburn://tray-activate", TrayClick { x, y });
|
||||
}
|
||||
|
||||
fn secondary_activate(&mut self, x: i32, y: i32) {
|
||||
let _ = self
|
||||
.app
|
||||
.emit("codeburn://tray-secondary", TrayClick { x, y });
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, serde::Serialize)]
|
||||
struct TrayClick {
|
||||
x: i32,
|
||||
y: i32,
|
||||
}
|
||||
|
||||
/// Type-erased handle for the Linux tray so callers can push title updates without
|
||||
/// naming the `ksni::Handle<CodeburnTray>` generic parameter across module boundaries.
|
||||
#[derive(Clone)]
|
||||
pub struct LinuxTrayHandle {
|
||||
inner: Arc<Mutex<Option<ksni::Handle<CodeburnTray>>>>,
|
||||
}
|
||||
|
||||
impl LinuxTrayHandle {
|
||||
pub fn empty() -> Self {
|
||||
Self {
|
||||
inner: Arc::new(Mutex::new(None)),
|
||||
}
|
||||
}
|
||||
|
||||
fn set(&self, handle: ksni::Handle<CodeburnTray>) {
|
||||
if let Ok(mut guard) = self.inner.lock() {
|
||||
*guard = Some(handle);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
/// Decode the bundled tray.png into ARGB32 pixels that the SNI spec expects.
|
||||
/// Falls back to an empty icon list (host shows a broken-icon placeholder) if the
|
||||
/// asset can't be decoded. We'd rather render a blank icon than crash the tray.
|
||||
fn load_icon() -> Vec<Icon> {
|
||||
// Embedded at build time so the binary is self-contained.
|
||||
let bytes = include_bytes!("../icons/tray.png");
|
||||
let Ok(decoder) = png::Decoder::new(bytes.as_slice()).read_info().map_err(|_| ()) else {
|
||||
return Vec::new();
|
||||
};
|
||||
decode_png(decoder)
|
||||
}
|
||||
|
||||
fn decode_png(mut reader: png::Reader<&[u8]>) -> Vec<Icon> {
|
||||
let info = reader.info().clone();
|
||||
let width = info.width as i32;
|
||||
let height = info.height as i32;
|
||||
let mut buf = vec![0u8; reader.output_buffer_size()];
|
||||
if reader.next_frame(&mut buf).is_err() {
|
||||
return Vec::new();
|
||||
}
|
||||
// SNI expects ARGB32 in network byte order. PNG decoder gives RGBA8.
|
||||
let pixel_count = (width as usize) * (height as usize);
|
||||
let mut argb = Vec::with_capacity(pixel_count * 4);
|
||||
for chunk in buf.chunks_exact(4) {
|
||||
let (r, g, b, a) = (chunk[0], chunk[1], chunk[2], chunk[3]);
|
||||
argb.extend_from_slice(&[a, r, g, b]);
|
||||
}
|
||||
vec![Icon {
|
||||
width,
|
||||
height,
|
||||
data: argb,
|
||||
}]
|
||||
}
|
||||
|
||||
pub async fn spawn(app: AppHandle, handle_out: LinuxTrayHandle) -> anyhow::Result<()> {
|
||||
let tray = CodeburnTray::new(app, load_icon());
|
||||
let handle = tray.spawn().await?;
|
||||
handle_out.set(handle);
|
||||
Ok(())
|
||||
}
|
||||
64
windows/src-tauri/tauri.conf.json
Normal file
64
windows/src-tauri/tauri.conf.json
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "CodeBurn Menubar",
|
||||
"version": "0.9.20",
|
||||
"identifier": "org.agentseal.codeburn-menubar",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"frontendDist": "../dist"
|
||||
},
|
||||
"app": {
|
||||
"windows": [
|
||||
{
|
||||
"label": "popover",
|
||||
"title": "CodeBurn",
|
||||
"width": 360,
|
||||
"height": 660,
|
||||
"decorations": false,
|
||||
"transparent": true,
|
||||
"resizable": false,
|
||||
"alwaysOnTop": true,
|
||||
"skipTaskbar": true,
|
||||
"visible": false,
|
||||
"focus": false,
|
||||
"shadow": true,
|
||||
"windowEffects": {
|
||||
"effects": ["acrylic"],
|
||||
"state": "active"
|
||||
}
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; connect-src 'self' ipc: https://api.frankfurter.app"
|
||||
},
|
||||
"trayIcon": {
|
||||
"id": "codeburn-tray",
|
||||
"iconPath": "icons/tray.png",
|
||||
"iconAsTemplate": false,
|
||||
"tooltip": "CodeBurn"
|
||||
}
|
||||
},
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"category": "Utility",
|
||||
"shortDescription": "AI coding cost tracker",
|
||||
"longDescription": "Shows today's AI coding spend in your system tray. Popover breaks down cost by activity, model, and provider across Claude Code, Cursor, Codex, and more.",
|
||||
"publisher": "AgentSeal",
|
||||
"homepage": "https://github.com/getagentseal/codeburn",
|
||||
"targets": ["deb", "rpm", "appimage", "msi"],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
"icons/128x128@2x.png",
|
||||
"icons/icon.png",
|
||||
"icons/icon.ico"
|
||||
],
|
||||
"linux": {
|
||||
"deb": {
|
||||
"depends": ["libwebkit2gtk-4.1-0", "libayatana-appindicator3-1"]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
377
windows/src/App.tsx
Normal file
377
windows/src/App.tsx
Normal file
|
|
@ -0,0 +1,377 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { invoke } from '@tauri-apps/api/core'
|
||||
import { listen } from '@tauri-apps/api/event'
|
||||
|
||||
import type { MenubarPayload } from './lib/payload'
|
||||
import type { CurrencyState } from './lib/currency'
|
||||
import { USD, formatCurrency, trayBadgeText } from './lib/currency'
|
||||
import { PayloadCache } from './lib/cache'
|
||||
import { relativePast } from './lib/dates'
|
||||
import { applyTheme, currentTheme, readSetting, writeSetting } from './lib/settings'
|
||||
import { TRAY_BADGE_SUPPORTED } from './lib/platform'
|
||||
import { AgentTabStrip, detectedProviders } from './components/AgentTabStrip'
|
||||
import type { Provider } from './components/AgentTabStrip'
|
||||
import { ModelsSection } from './components/ModelsSection'
|
||||
import { InsightPills, INSIGHT_ORDER, isInsightMode, type InsightMode } from './components/InsightPills'
|
||||
import { TrendInsight } from './components/TrendInsight'
|
||||
import { ForecastInsight } from './components/ForecastInsight'
|
||||
import { PulseInsight } from './components/PulseInsight'
|
||||
import { StatsInsight } from './components/StatsInsight'
|
||||
import { PlanInsight } from './components/PlanInsight'
|
||||
import { FindingsSection } from './components/FindingsSection'
|
||||
import { ActivitySection } from './components/ActivitySection'
|
||||
import { LoadingOverlay } from './components/LoadingOverlay'
|
||||
import { EmptyProviderState } from './components/EmptyProviderState'
|
||||
import { NoDataState } from './components/NoDataState'
|
||||
import { SetupState, type CliStatus } from './components/SetupState'
|
||||
import { StarBanner } from './components/StarBanner'
|
||||
import { HeroSection } from './components/HeroSection'
|
||||
import { PeriodTabs, PERIOD_LABELS } from './components/PeriodTabs'
|
||||
import type { Period } from './components/PeriodTabs'
|
||||
import { FooterBar } from './components/FooterBar'
|
||||
import { ErrorToast } from './components/ErrorToast'
|
||||
import { SettingsPanel, type ThemeChoice } from './components/SettingsPanel'
|
||||
|
||||
const payloadCache = new PayloadCache<MenubarPayload>()
|
||||
|
||||
/// Background cadence, mirroring mac/Sources/CodeBurnMenubar/RefreshCadence.swift: every
|
||||
/// fetch is a full Node process, so the popover being closed has to cost less than it being
|
||||
/// open. Visible, a tick refreshes today/all plus the selected period/provider with optimize
|
||||
/// findings; hidden, a slower tick refreshes only today/all and skips optimize, since the
|
||||
/// tray badge and tooltip are the only things anyone can see. Entries younger than STALE_MS
|
||||
/// are left alone when the popover is re-opened.
|
||||
const REFRESH_ACTIVE_MS = 60_000
|
||||
const REFRESH_IDLE_MS = 120_000
|
||||
const STALE_MS = 60_000
|
||||
|
||||
type FetchOptions = {
|
||||
includeOptimize: boolean
|
||||
showOverlay: boolean
|
||||
}
|
||||
|
||||
export function App() {
|
||||
const [period, setPeriod] = useState<Period>('today')
|
||||
const [provider, setProvider] = useState<Provider>('all')
|
||||
const [payload, setPayload] = useState<MenubarPayload | null>(null)
|
||||
const [todayPayload, setTodayPayload] = useState<MenubarPayload | null>(null)
|
||||
const [currency, setCurrency] = useState<CurrencyState>(USD)
|
||||
const [overlay, setOverlay] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [insight, setInsight] = useState<InsightMode>(() => {
|
||||
const saved = readSetting('insight')
|
||||
return isInsightMode(saved) ? saved : 'trend'
|
||||
})
|
||||
const [cliStatus, setCliStatus] = useState<CliStatus | null>(null)
|
||||
const [cliChecking, setCliChecking] = useState(false)
|
||||
const [version, setVersion] = useState('')
|
||||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null)
|
||||
const [theme, setTheme] = useState(() => currentTheme())
|
||||
const [trayBadge, setTrayBadge] = useState(() => TRAY_BADGE_SUPPORTED && readSetting('trayBadge') !== 'off')
|
||||
const [showSettings, setShowSettings] = useState(false)
|
||||
// The window starts hidden and is shown by a tray click, which emits `codeburn://shown`.
|
||||
const [popoverVisible, setPopoverVisible] = useState(false)
|
||||
const [themeChoice, setThemeChoice] = useState<ThemeChoice>(() => {
|
||||
const saved = readSetting('theme')
|
||||
return saved === 'dark' || saved === 'light' ? saved : 'system'
|
||||
})
|
||||
|
||||
const selection = useRef({ period, provider })
|
||||
selection.current = { period, provider }
|
||||
|
||||
const fetchKey = useCallback(async (p: Period, prov: Provider, opts: FetchOptions) => {
|
||||
if (payloadCache.isInFlight(p, prov)) return
|
||||
payloadCache.markInFlight(p, prov)
|
||||
const isSelected = () => selection.current.period === p && selection.current.provider === prov
|
||||
if (opts.showOverlay && isSelected()) setOverlay(true)
|
||||
try {
|
||||
const json = await invoke<MenubarPayload>('fetch_payload', {
|
||||
period: p,
|
||||
provider: prov,
|
||||
includeOptimize: opts.includeOptimize,
|
||||
})
|
||||
// A quiet (no-optimize) refresh must not wipe findings a previous full fetch had.
|
||||
if (!opts.includeOptimize) {
|
||||
const previous = payloadCache.get(p, prov)
|
||||
if (previous) json.optimize = previous.optimize
|
||||
}
|
||||
payloadCache.set(p, prov, json)
|
||||
if (isSelected()) {
|
||||
setPayload(json)
|
||||
// "updated Xs ago" describes what the user is looking at, so only a fetch of the
|
||||
// visible key may stamp it - a background today/all tick must not.
|
||||
setLastUpdated(new Date())
|
||||
}
|
||||
if (p === 'today' && prov === 'all') setTodayPayload(json)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
if (message.includes('CLI not found')) {
|
||||
const status = await invoke<CliStatus>('cli_status').catch(() => null)
|
||||
if (status) setCliStatus(status)
|
||||
} else if (isSelected()) {
|
||||
setError(message)
|
||||
}
|
||||
} finally {
|
||||
payloadCache.clearInFlight(p, prov)
|
||||
if (isSelected()) setOverlay(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const refreshAll = useCallback(async (opts: FetchOptions) => {
|
||||
const { period: p, provider: prov } = selection.current
|
||||
if (!(p === 'today' && prov === 'all')) {
|
||||
fetchKey('today', 'all', { includeOptimize: false, showOverlay: false })
|
||||
}
|
||||
await fetchKey(p, prov, opts)
|
||||
}, [fetchKey])
|
||||
|
||||
/// The single source of truth for the CLI gate. Nothing else writes a "compatible"
|
||||
/// verdict: a payload that happens to parse does not prove the CLI is new enough, and a
|
||||
/// probe from the settings panel must not be able to invent one either.
|
||||
const checkCli = useCallback(async () => {
|
||||
setCliChecking(true)
|
||||
try {
|
||||
const status = await invoke<CliStatus>('cli_status')
|
||||
setCliStatus(status)
|
||||
if (status.found && status.compatible) {
|
||||
refreshAll({ includeOptimize: true, showOverlay: true })
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
} finally {
|
||||
setCliChecking(false)
|
||||
}
|
||||
}, [refreshAll])
|
||||
|
||||
const cliReady = cliStatus !== null && cliStatus.found && cliStatus.compatible
|
||||
|
||||
// Probe the gate before the first fetch: an old CLI emits a payload missing fields the
|
||||
// popover reads, which used to blank the whole window instead of showing the setup screen.
|
||||
useEffect(() => {
|
||||
invoke<string>('app_version').then(setVersion).catch(() => {})
|
||||
checkCli()
|
||||
// Startup only; checkCli is re-run from the setup screen and settings on demand.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
if (!cliReady) return
|
||||
const tick = popoverVisible
|
||||
? () => refreshAll({ includeOptimize: true, showOverlay: false })
|
||||
: () => fetchKey('today', 'all', { includeOptimize: false, showOverlay: false })
|
||||
const id = setInterval(tick, popoverVisible ? REFRESH_ACTIVE_MS : REFRESH_IDLE_MS)
|
||||
return () => clearInterval(id)
|
||||
}, [cliReady, popoverVisible, refreshAll, fetchKey])
|
||||
|
||||
useEffect(() => {
|
||||
const cached = payloadCache.get(period, provider)
|
||||
setPayload(cached)
|
||||
if (!cliReady) return
|
||||
if (!cached) {
|
||||
fetchKey(period, provider, { includeOptimize: true, showOverlay: true })
|
||||
} else if (payloadCache.age(period, provider) > STALE_MS) {
|
||||
fetchKey(period, provider, { includeOptimize: true, showOverlay: false })
|
||||
}
|
||||
}, [period, provider, cliReady, fetchKey])
|
||||
|
||||
useEffect(() => {
|
||||
const unlistenRefresh = listen('codeburn://refresh', () => refreshAll({ includeOptimize: true, showOverlay: true }))
|
||||
const unlistenShown = listen('codeburn://shown', () => {
|
||||
setPopoverVisible(true)
|
||||
const { period: p, provider: prov } = selection.current
|
||||
if (payloadCache.age(p, prov) > STALE_MS) refreshAll({ includeOptimize: true, showOverlay: false })
|
||||
})
|
||||
const unlistenHidden = listen('codeburn://hidden', () => setPopoverVisible(false))
|
||||
const unlistenTheme = listen('codeburn://toggle-theme', () => toggleTheme())
|
||||
return () => {
|
||||
unlistenRefresh.then(fn => fn())
|
||||
unlistenShown.then(fn => fn())
|
||||
unlistenHidden.then(fn => fn())
|
||||
unlistenTheme.then(fn => fn())
|
||||
}
|
||||
}, [refreshAll])
|
||||
|
||||
useEffect(() => {
|
||||
const saved = readSetting('theme')
|
||||
if (saved === 'dark' || saved === 'light') applyTheme(saved)
|
||||
setTheme(currentTheme())
|
||||
const media = window.matchMedia('(prefers-color-scheme: dark)')
|
||||
const onChange = () => setTheme(currentTheme())
|
||||
media.addEventListener('change', onChange)
|
||||
return () => media.removeEventListener('change', onChange)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') invoke('hide_popover').catch(() => {})
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [])
|
||||
|
||||
const todayCost = todayPayload?.current?.cost ?? null
|
||||
|
||||
useEffect(() => {
|
||||
if (todayCost === null) return
|
||||
const text = `CodeBurn · ${formatCurrency(todayCost, currency)} today`
|
||||
invoke('set_tray_tooltip', { text }).catch(() => {})
|
||||
}, [todayCost, currency])
|
||||
|
||||
useEffect(() => {
|
||||
if (!TRAY_BADGE_SUPPORTED) return
|
||||
const text = trayBadge && todayCost !== null ? trayBadgeText(todayCost, currency) : null
|
||||
invoke('set_tray_badge', { text }).catch(err => setError(`Tray badge: ${String(err)}`))
|
||||
}, [todayCost, currency, trayBadge])
|
||||
|
||||
|
||||
const chooseTheme = (choice: ThemeChoice) => {
|
||||
applyTheme(choice === 'system' ? null : choice)
|
||||
setThemeChoice(choice)
|
||||
setTheme(currentTheme())
|
||||
}
|
||||
|
||||
const toggleTheme = () => {
|
||||
chooseTheme(currentTheme() === 'dark' ? 'light' : 'dark')
|
||||
}
|
||||
|
||||
const setTrayBadgePref = (on: boolean) => {
|
||||
setTrayBadge(on)
|
||||
writeSetting('trayBadge', on ? 'on' : 'off')
|
||||
}
|
||||
|
||||
const applyCurrency = async (code: string) => {
|
||||
try {
|
||||
setCurrency(await invoke<CurrencyState>('set_currency', { code }))
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : String(err))
|
||||
}
|
||||
}
|
||||
|
||||
const openTerminal = (args: string[]) => {
|
||||
invoke('open_terminal_command', { args }).catch(err => setError(String(err)))
|
||||
}
|
||||
const connectClaude = () => {
|
||||
invoke('open_claude_login').catch(err => setError(String(err)))
|
||||
}
|
||||
|
||||
const selectInsight = (mode: InsightMode) => {
|
||||
setInsight(mode)
|
||||
writeSetting('insight', mode)
|
||||
}
|
||||
|
||||
const providers = detectedProviders(todayPayload)
|
||||
const planVisible = provider === 'claude' || (provider === 'all' && providers.length === 1 && providers[0] === 'claude')
|
||||
const visibleModes = useMemo(
|
||||
() => INSIGHT_ORDER.filter(m => m !== 'plan' || planVisible),
|
||||
[planVisible],
|
||||
)
|
||||
const activeInsight = visibleModes.includes(insight) ? insight : 'trend'
|
||||
|
||||
const cliBlocked = cliStatus !== null && (!cliStatus.found || !cliStatus.compatible)
|
||||
// The version gate above is what keeps these fields present; the optional reads are the
|
||||
// backstop that turns a surprising payload into an empty state rather than a blank window.
|
||||
const isFilteredEmpty = payload !== null && provider !== 'all'
|
||||
&& (payload.current?.cost ?? 0) <= 0 && (payload.current?.calls ?? 0) === 0
|
||||
const neverAnyData = payload !== null && provider === 'all'
|
||||
&& (payload.current?.calls ?? 0) === 0 && (payload.current?.sessions ?? 0) === 0
|
||||
&& (payload.history?.daily?.length ?? 0) === 0
|
||||
|
||||
const footnote = [version ? `CodeBurn v${version}` : 'CodeBurn', lastUpdated ? `updated ${relativePast(lastUpdated)}` : null]
|
||||
.filter(Boolean)
|
||||
.join(' · ')
|
||||
|
||||
return (
|
||||
<div className="popover">
|
||||
<header className="header">
|
||||
<div className="brand">
|
||||
<span className="brand-primary">Code</span>
|
||||
<span className="brand-accent">Burn</span>
|
||||
</div>
|
||||
<div className="subhead">AI Coding Cost Tracker</div>
|
||||
</header>
|
||||
|
||||
{!cliBlocked && !showSettings && (
|
||||
<AgentTabStrip selected={provider} onSelect={setProvider} payload={todayPayload} currency={currency} />
|
||||
)}
|
||||
|
||||
<div className="main-content">
|
||||
{showSettings ? (
|
||||
<SettingsPanel
|
||||
onBack={() => setShowSettings(false)}
|
||||
version={version}
|
||||
currency={currency}
|
||||
onCurrency={applyCurrency}
|
||||
themeChoice={themeChoice}
|
||||
onThemeChoice={chooseTheme}
|
||||
trayBadge={trayBadge}
|
||||
onTrayBadge={setTrayBadgePref}
|
||||
cliStatus={cliStatus}
|
||||
onCheckCli={checkCli}
|
||||
cliChecking={cliChecking}
|
||||
onQuit={() => invoke('quit_app').catch(() => {})}
|
||||
/>
|
||||
) : cliBlocked && cliStatus ? (
|
||||
<SetupState status={cliStatus} checking={cliChecking} onCheckAgain={checkCli} />
|
||||
) : (
|
||||
<>
|
||||
<HeroSection payload={payload} currency={currency} periodLabel={PERIOD_LABELS[period]} isToday={period === 'today'} />
|
||||
<PeriodTabs selected={period} onSelect={setPeriod} />
|
||||
|
||||
{isFilteredEmpty ? (
|
||||
<EmptyProviderState provider={provider} period={period} />
|
||||
) : neverAnyData ? (
|
||||
<NoDataState onRefresh={() => refreshAll({ includeOptimize: true, showOverlay: true })} />
|
||||
) : (
|
||||
<>
|
||||
<div className="insight-area">
|
||||
<InsightPills selected={activeInsight} onSelect={selectInsight} modes={visibleModes} />
|
||||
{activeInsight === 'plan' && (
|
||||
<PlanInsight payload={payload} currency={currency} onOpenTerminal={openTerminal} onConnectClaude={connectClaude} />
|
||||
)}
|
||||
{activeInsight === 'trend' && <TrendInsight days={payload?.history?.daily ?? []} currency={currency} />}
|
||||
{activeInsight === 'forecast' && <ForecastInsight days={payload?.history?.daily ?? []} currency={currency} />}
|
||||
{activeInsight === 'pulse' && payload && <PulseInsight payload={payload} currency={currency} />}
|
||||
{activeInsight === 'stats' && payload && <StatsInsight payload={payload} currency={currency} period={period} />}
|
||||
</div>
|
||||
{payload?.current && (
|
||||
<>
|
||||
<ActivitySection payload={payload} currency={currency} />
|
||||
<ModelsSection
|
||||
models={payload.current.topModels}
|
||||
inputTokens={payload.current.inputTokens}
|
||||
outputTokens={payload.current.outputTokens}
|
||||
cacheHitPercent={payload.current.cacheHitPercent}
|
||||
currency={currency}
|
||||
/>
|
||||
<FindingsSection payload={payload} currency={currency} onOpenTerminal={openTerminal} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{overlay && <LoadingOverlay periodLabel={PERIOD_LABELS[period]} />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<FooterBar
|
||||
currency={currency}
|
||||
onCurrency={applyCurrency}
|
||||
loading={overlay}
|
||||
onRefresh={() => refreshAll({ includeOptimize: true, showOverlay: true })}
|
||||
onExport={format => openTerminal(['export', '-f', format])}
|
||||
onOpenReport={() => openTerminal(['report'])}
|
||||
onToggleTheme={toggleTheme}
|
||||
onQuit={() => invoke('quit_app').catch(() => {})}
|
||||
themeLabel={theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'}
|
||||
trayBadge={trayBadge}
|
||||
onToggleTrayBadge={() => setTrayBadgePref(!trayBadge)}
|
||||
onOpenSettings={() => setShowSettings(s => !s)}
|
||||
settingsOpen={showSettings}
|
||||
footnote={footnote}
|
||||
/>
|
||||
|
||||
<StarBanner />
|
||||
|
||||
{error && <ErrorToast message={error} onDismiss={() => setError(null)} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue