diff --git a/.github/workflows/release-menubar-windows.yml b/.github/workflows/release-menubar-windows.yml
new file mode 100644
index 00000000..e69c5e66
--- /dev/null
+++ b/.github/workflows/release-menubar-windows.yml
@@ -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
diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index d81667d5..58fad55e 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -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
diff --git a/.github/workflows/windows-menubar-ci.yml b/.github/workflows/windows-menubar-ci.yml
new file mode 100644
index 00000000..9a2e0298
--- /dev/null
+++ b/.github/workflows/windows-menubar-ci.yml
@@ -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
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 477c7f4f..c444ad37 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,37 @@
## Unreleased
+### Added
+- **`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 ` 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`, 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 /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.
+- **A warm launch rewrites only the month that changed, and a ranged query reads only the months it can report on.** Per-provider shards still meant one appended session republished that provider's entire history — 95 MB for Claude on a 6 GB corpus. Each provider's shard is now split again by the UTC month of the cached session's FIRST turn, a bucket that never moves as a session grows, so an append rewrites one month. Every shard records the newest month it holds, which lets `--period today/week` skip the shards that cannot contribute a turn to the range; the skipped months stay on disk untouched across the save, and providers whose cache is the only surviving record (durable) or whose parse fingerprint moved are always read in full. Remaining shards are read concurrently. Existing v8 and v7 caches are re-laid-out losslessly on first load and the old layout removed once the new one is published: nothing re-parses.
+- **A warm launch rewrites only the provider that changed.** The session cache was a single blob, so any provider appending a few KB republished the whole thing — 147 MB of stringify + fsync on a 6 GB corpus, ~18% of a warm run. It is now a version-suffixed directory holding one shard per provider plus a small envelope, written per provider and published by a single envelope rename. An existing v7 cache is re-laid-out losslessly on first load and the old file removed once the new layout is on disk: nothing re-parses. One unreadable shard now costs that provider a re-parse instead of discarding every provider's history, and partial saves during a cold parse are triggered every 2000 files rather than every 5 seconds, so a slow cold parse no longer rewrites the growing cache on a wall clock.
+- **An appended Codex rollout parses only its tail.** Rollout files are append-only and the active ones run to hundreds of MB, but the Codex result cache keyed on mtime + size alone, so any growth re-read the file from byte 0. Each entry now records a restart point at the last task boundary — byte offset plus the state the single-pass decode carries across it — and a grown file with the same inode resumes there, producing output identical to a full re-parse. An entry without a usable restart point simply re-parses in full once and gains one.
+- **A date-ranged report classifies only the turns it keeps.** Every cached turn went through the turn classifier — category, retries, edit detection, and a full reconstruction of its API calls — before the date slice discarded most of them, so a week view paid to classify all of history to keep a few percent of it. The keep/drop decision is now taken on the raw cached turn and only the survivors are classified, still from their complete call list, with the branch and pull-request carries still walking the full ordered turn list. Output is byte-identical.
+- **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
+- **`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.
+
## 0.9.20 - 2026-08-10
### Added
diff --git a/README.md b/README.md
index 2159e1da..d587b3ef 100644
--- a/README.md
+++ b/README.md
@@ -25,7 +25,7 @@
-
If CodeBurn shows you something your bill never did, star the repo so other developers find it, and consider sponsoring to keep 40 integrations honest.
+
If CodeBurn shows you something your bill never did, star the repo so other developers find it, and consider sponsoring to keep 41 integrations honest.
+ )}
{expanded && (
diff --git a/app/renderer/styles/plain.css b/app/renderer/styles/plain.css
index f82172b9..6d7a86c4 100644
--- a/app/renderer/styles/plain.css
+++ b/app/renderer/styles/plain.css
@@ -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; }
diff --git a/app/scripts/stage-cli.mjs b/app/scripts/stage-cli.mjs
index 69085678..2d4381f9 100644
--- a/app/scripts/stage-cli.mjs
+++ b/app/scripts/stage-cli.mjs
@@ -11,6 +11,7 @@
// build/cli/package.json (root package.json: {version}, type:module)
// build/cli/dist/cli.js (Node-version-guard launcher → ./main.js)
// build/cli/dist/main.js (the bundle)
+// build/cli/dist/parse-worker.js (the parse worker thread's own entry)
// build/cli/node_modules/ (production dependency closure)
//
// The production closure is copied out of the already-installed root
@@ -29,7 +30,7 @@ const dist = join(root, 'dist')
const rootModules = join(root, 'node_modules')
const stage = join(appDir, 'build', 'cli')
-for (const f of ['cli.js', 'main.js']) {
+for (const f of ['cli.js', 'main.js', 'parse-worker.js']) {
if (!existsSync(join(dist, f))) {
throw new Error(`stage-cli: ${join(dist, f)} is missing — build the root CLI first`)
}
@@ -41,6 +42,10 @@ mkdirSync(join(stage, 'dist'), { recursive: true })
copyFileSync(join(root, 'package.json'), join(stage, 'package.json'))
copyFileSync(join(dist, 'cli.js'), join(stage, 'dist', 'cli.js'))
copyFileSync(join(dist, 'main.js'), join(stage, 'dist', 'main.js'))
+// The cold-parse worker pool resolves this as a sibling of the bundle it runs
+// from, so it has to be staged alongside main.js or a packaged app silently
+// loses every parse thread.
+copyFileSync(join(dist, 'parse-worker.js'), join(stage, 'dist', 'parse-worker.js'))
// Desktop-app launch shim (the app spawns this, not cli.js). The packaged app
// runs the CLI with Electron's own binary as Node (ELECTRON_RUN_AS_NODE=1).
diff --git a/docs/architecture.md b/docs/architecture.md
index 088e46f8..3b949bb4 100644
--- a/docs/architecture.md
+++ b/docs/architecture.md
@@ -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
` 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
` and parse the JSON. They do not share code with the CLI; they only depend on its output contract.
## CLI (`src/`)
@@ -69,6 +69,67 @@ output formatter (Ink TUI, JSON, or menubar-json)
`src/parser.ts` is the central aggregator. Public exports: `parseAllSessions`, `filterProjectsByName`, `extractMcpInventory`. It owns the dedup `Set` (`seenKeys`) that is passed into every provider parser so a turn that surfaces in two providers (Claude logs vs. Cursor mirror, for instance) is counted once.
+### Parallel Cold Parse
+
+A cold parse spends most of its time on work that is per-file and pure: reading a
+session JSONL or a Codex rollout, decoding it, and turning each line into a
+journal entry. `src/parse-workers.ts` moves that onto `worker_threads` when the
+pending workload is big enough to pay for them. Each worker runs the same
+per-file function the serial path runs — `parseClaudeFileFull` for a Claude
+session, `parseCodexFileFull` for a Codex rollout — against an empty dedup set,
+and ships the result back as a JSON string together with every dedup key it
+claimed. The parent installs results in the same order the serial loop would, and
+everything with cross-file state (the dedup sets, canonical project paths, spawn
+links, PR correlation, the Codex result cache) stays on the main thread. A file
+whose keys were already claimed by an earlier file, or whose worker failed, is
+re-parsed in-process — so the output is identical to the serial path either way.
+That overlap check is what makes a forked Codex rollout safe: it replays its
+parent's token_count history under the parent's key namespace, collides, and is
+re-parsed against the real dedup set.
+
+A Codex worker never touches `src/codex-cache.ts`: it returns the cache entry it
+would have written and the parent writes it, in install order, so
+`flushCodexCache` publishes exactly what a serial parse would. Only whole-file
+parses go off-thread; the append/incremental paths (a Claude append, a Codex
+byte-offset resume) are untouched and stay in-process. The decision is made per
+provider — the Claude scan and the provider loop run one after the other, so at
+most one pool is alive — and the pool is terminated when its scan ends, so the
+resident `serve` child never accumulates threads.
+
+The pool is off by default for anything that is not a large cold parse:
+
+| Gate | Serial when |
+|---|---|
+| Pending bytes | under 200 MB behind the pending whole-file parses |
+| Cores | `availableParallelism() <= 2` |
+| Memory | under 4 GB available |
+
+Otherwise the worker count is
+`min(cores - 1, min(0.25 * available, 2 GB) / perWorker, max(pendingFiles / 50, pendingBytes / 200 MB))`.
+Files and bytes each earn threads on their own, so a few hundred multi-hundred-MB
+Codex rollouts parallelize as well as a few thousand small Claude transcripts. The
+gate is bytes only, deliberately: 250 pending files holding under a megabyte
+between them spawn threads that make the run ~5% slower, and a file count only
+starts paying for itself around 400.
+
+`perWorker` is the per-thread memory budget, derived per parse as
+`clamp(256 MB, 2 x (pendingBytes / pendingFiles) + 128 MB, 1 GB)`. A flat figure
+was wrong in both directions: small Claude transcripts peak well under 256 MB,
+while a 260 MB Codex rollout peaks near 430 MB in its worker and scales linearly
+with the pool. The budget also covers the parent, which buffers up to `pool.size`
+finished results while it installs one.
+
+"Available" is `process.availableMemory()`, falling back to `os.totalmem()`. It is
+deliberately not `os.freemem()`: on macOS that counts free pages rather than
+available memory and reads as a few hundred MB on an idle 128 GB machine, so a
+gate built on it switches the feature on and off between runs. On Linux outside a
+memory-limited cgroup, `availableMemory()` reports free memory and can still
+under-report on a busy host — which fails safe, to fewer threads or none.
+
+`CODEBURN_PARSE_WORKERS` overrides the decision and skips every gate above:
+`0` forces the serial parse, `N` forces N workers (capped at the core count).
+`CODEBURN_VERBOSE=1` prints the resolved worker count and the reason for it.
+
### Cache Layers
Three caches under `~/.cache/codeburn/` (override with `CODEBURN_CACHE_DIR`):
@@ -83,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 |
|---|---|---|
@@ -130,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.
@@ -156,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`).
diff --git a/docs/optimize.md b/docs/optimize.md
new file mode 100644
index 00000000..6cd23bbd
--- /dev/null
+++ b/docs/optimize.md
@@ -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//.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 # 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.
diff --git a/docs/providers/README.md b/docs/providers/README.md
index 971ae2b4..938d5425 100644
--- a/docs/providers/README.md
+++ b/docs/providers/README.md
@@ -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` |
diff --git a/docs/providers/dsh.md b/docs/providers/dsh.md
new file mode 100644
index 00000000..d3bb81ac
--- /dev/null
+++ b/docs/providers/dsh.md
@@ -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 | — | `/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/----//
+ 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:::`.
+
+`.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--//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.
diff --git a/docs/providers/kiro.md b/docs/providers/kiro.md
index 0252e901..d6e10b32 100644
--- a/docs/providers/kiro.md
+++ b/docs/providers/kiro.md
@@ -59,6 +59,7 @@ The stores are disjoint (v2 sessions use `sess_`-prefixed IDs in a separate dire
- Token counts are estimated via char count (`CHARS_PER_TOKEN = 4`).
- **Credits are the cost source; tokens stay estimated.** Kiro bills in credits ($20/mo for 1,000; overage $0.04/credit). CLI (`metering_usage`), v1 executions (`usageSummary[].usage`), and v2 (`usage_summary.promptTurnSummaries[].usage`) turns record real credits, converted to USD at `USD_PER_KIRO_CREDIT = 0.04` (the public overage rate — the same never-understate approach as Codebuff). Turns without credit data fall back to token-estimated cost (`costIsEstimated: true`); legacy `.chat` and workspace-session records carry no usage data, so they are always token-estimated. Note: an earlier CLI implementation summed credit values directly as dollars, overstating cost 25×. Token *counts* remain char-estimated everywhere (input undercounts: only visible transcript text is seen, not the full resent context; v2's `session_metadata.contextUsage.usagePercentage` × context window is a better input proxy if ever needed). v2 does keep the real `modelId`, so unlike the v1 execution-file path it is not mislabeled `kiro-auto`.
- **Cost is frozen at parse time.** Kiro is on the `costUSD` pass-through allowlist in `providerCallToCachedCall` (alongside mistral-vibe, devin, hermes, …), so its credit-based cost survives the session cache instead of being re-priced from estimated tokens — token re-pricing understated/overstated real kiro spend by up to 16× per model. The tradeoff, shared with all allowlisted providers: `codeburn price-override` and `model-alias` do not affect kiro dollar amounts (token *counts* are unaffected). Historical caches from before this change re-parse via the `CACHE_VERSION` bump to 5.
+- **`projectPath` for git attribution.** The parser now records the session's working directory as `projectPath` (CLI `meta.cwd`, v2 `workspacePaths[0]`, workspace sessions' `workspaceDirectory`), which sync attribution needs to resolve the git repo. The `project-path-v1` parse-version bump re-parses cached kiro history once; sessions in linked git worktrees now group under the main repo.
## When fixing a bug here
diff --git a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift
index e6366baa..b0400d6e 100644
--- a/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift
+++ b/mac/Sources/CodeBurnMenubar/CodeBurnApp.swift
@@ -2,6 +2,7 @@ import Foundation
import SwiftUI
import AppKit
import Observation
+import ServiceManagement
private let refreshIntervalSeconds: UInt64 = 30
private let forceRefreshWatchdogSeconds: TimeInterval = 90
@@ -127,9 +128,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSPopoverDelegate, NSM
// interaction (popover open, wake) refreshes immediately.
restorePersistedCurrency()
- // Resident serve child: payload fetches answer from a warm CLI once
- // its warm-up completes; until then (and on any failure) fetches keep
- // the spawn path. See ServeConnection.
+ // Start the resident CLI early without an artificial query. The first
+ // real status refresh becomes its only cold warm-up. See ServeConnection.
Task { await ServeConnection.shared.ensureStarted() }
// #868 experiment: restore only the activation half of the #147 fix.
// Packaged builds ship LSUIElement=true, so the policy is .accessory
@@ -282,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
diff --git a/mac/Sources/CodeBurnMenubar/CurrencyState.swift b/mac/Sources/CodeBurnMenubar/CurrencyState.swift
index def6cf32..1c9f3d12 100644
--- a/mac/Sources/CodeBurnMenubar/CurrencyState.swift
+++ b/mac/Sources/CodeBurnMenubar/CurrencyState.swift
@@ -77,11 +77,7 @@ actor FXRateCache {
private var loaded = false
private var cacheFilePath: String {
- let base = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
- return base
- .appendingPathComponent("codeburn-mac", isDirectory: true)
- .appendingPathComponent("fx-rates.json")
- .path
+ return (CodeBurnCacheDirectory.resolve() as NSString).appendingPathComponent("fx-rates.json")
}
private func loadIfNeeded() {
diff --git a/mac/Sources/CodeBurnMenubar/Data/CodeBurnCacheDirectory.swift b/mac/Sources/CodeBurnMenubar/Data/CodeBurnCacheDirectory.swift
new file mode 100644
index 00000000..d5e31b82
--- /dev/null
+++ b/mac/Sources/CodeBurnMenubar/Data/CodeBurnCacheDirectory.swift
@@ -0,0 +1,18 @@
+import Foundation
+
+/// Resolves the on-disk directory shared by the CLI, desktop app and menubar.
+enum CodeBurnCacheDirectory {
+ static func resolve(
+ environment: [String: String] = ProcessInfo.processInfo.environment,
+ homeDirectory: URL = FileManager.default.homeDirectoryForCurrentUser
+ ) -> String {
+ if let override = environment["CODEBURN_CACHE_DIR"],
+ !override.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
+ return override
+ }
+ return homeDirectory
+ .appendingPathComponent(".cache", isDirectory: true)
+ .appendingPathComponent("codeburn", isDirectory: true)
+ .path
+ }
+}
diff --git a/mac/Sources/CodeBurnMenubar/Data/DataClient.swift b/mac/Sources/CodeBurnMenubar/Data/DataClient.swift
index cafe0449..73159eed 100644
--- a/mac/Sources/CodeBurnMenubar/Data/DataClient.swift
+++ b/mac/Sources/CodeBurnMenubar/Data/DataClient.swift
@@ -123,21 +123,68 @@ struct DataClient {
subcommand: [String],
qualityOfService: QualityOfService = .userInitiated
) async throws -> ProcessResult {
- // Serve fast path: a warm resident `codeburn serve` child answers the
- // status payload without a spawn (no node boot, no session-cache
- // reload). Any serve failure falls back to the spawn path below, so
- // this is strictly an optimization; it also takes no spawn slot.
+ try await runCLI(
+ subcommand: subcommand,
+ serveRequest: { args in
+ try await ServeConnection.shared.request(args: args)
+ },
+ spawnFallback: {
+ await spawnLimiter.acquire()
+ defer { Task { await spawnLimiter.release() } }
+ let process = CodeburnCLI.makeProcess(
+ subcommand: subcommand,
+ qualityOfService: qualityOfService
+ )
+ return try await runProcess(
+ process,
+ timeoutSeconds: spawnTimeoutSeconds,
+ label: subcommand.joined(separator: " ")
+ )
+ }
+ )
+ }
+
+ /// Internal seam for behavior-shaped lifecycle tests. Production supplies
+ /// the shared resident and globally limited one-shot closures above.
+ static func runCLI(
+ subcommand: [String],
+ serveRequest: ([String]) async throws -> Data,
+ spawnFallback: () async throws -> ProcessResult
+ ) async throws -> ProcessResult {
+ // Serve path: the first real status payload warms the resident child,
+ // then later payloads reuse it (no node boot or session-cache reload).
+ // Transport/protocol failures fall back to the spawn path below, so
+ // the resident remains an optimization. Resource-policy failures stay
+ // terminal and cannot bypass the resident output ceiling.
if ServeConnection.isEligible(subcommand) {
- if let stdout = try? await ServeConnection.shared.requestIfWarm(args: subcommand) {
+ do {
+ let stdout = try await serveRequest(subcommand)
return ProcessResult(stdout: stdout, stderr: "", exitCode: 0)
+ } catch let error as CancellationError {
+ // Cancellation is control flow from the refresh owner. Starting
+ // a fallback process here would turn cancelled work into a new
+ // expensive cold parse and delay task teardown.
+ throw error
+ } catch {
+ if let terminalError = terminalServeError(error) {
+ throw terminalError
+ }
+ // Resident serve is only an optimization. Protocol, child, and
+ // timeout failures retain the established one-shot fallback,
+ // unless a sibling teardown raced this task's cancellation.
+ try Task.checkCancellation()
}
}
- await spawnLimiter.acquire()
- defer { Task { await spawnLimiter.release() } }
- let process = CodeburnCLI.makeProcess(subcommand: subcommand, qualityOfService: qualityOfService)
- return try await runProcess(process,
- timeoutSeconds: spawnTimeoutSeconds,
- label: subcommand.joined(separator: " "))
+ return try await spawnFallback()
+ }
+
+ /// Some resident failures are terminal resource-policy decisions, not
+ /// transport failures. Retrying those through the one-shot path would redo
+ /// the cold scan and could bypass the resident's stricter output ceiling.
+ static func terminalServeError(_ error: Error) -> DataClientError? {
+ guard let failure = error as? ServeConnection.ServeRequestFailed,
+ failure.reason == .outputTooLarge else { return nil }
+ return .outputTooLarge
}
/// Runs an already-configured process to completion, draining its output and
diff --git a/mac/Sources/CodeBurnMenubar/Data/MenubarStatusCache.swift b/mac/Sources/CodeBurnMenubar/Data/MenubarStatusCache.swift
index ef5f217a..72a8dfb7 100644
--- a/mac/Sources/CodeBurnMenubar/Data/MenubarStatusCache.swift
+++ b/mac/Sources/CodeBurnMenubar/Data/MenubarStatusCache.swift
@@ -9,8 +9,9 @@ struct MenubarStatusCache {
/// Default location under `~/.cache/codeburn/`.
static func standard() -> MenubarStatusCache {
- let home = FileManager.default.homeDirectoryForCurrentUser.path
- return MenubarStatusCache(statusPath: "\(home)/.cache/codeburn/menubar-status.json")
+ let cacheDir = CodeBurnCacheDirectory.resolve()
+ let path = (cacheDir as NSString).appendingPathComponent("menubar-status.json")
+ return MenubarStatusCache(statusPath: path)
}
struct BadgeRead {
diff --git a/mac/Sources/CodeBurnMenubar/Data/ServeConnection.swift b/mac/Sources/CodeBurnMenubar/Data/ServeConnection.swift
index 0eb13578..4d1f879c 100644
--- a/mac/Sources/CodeBurnMenubar/Data/ServeConnection.swift
+++ b/mac/Sources/CodeBurnMenubar/Data/ServeConnection.swift
@@ -1,3 +1,4 @@
+import Darwin
import Foundation
/// A resident `codeburn serve --stdio` child, held so payload fetches skip the
@@ -6,53 +7,118 @@ import Foundation
/// replies are `{id, ok, output}`. Mirrors the desktop app's client contract:
///
/// - Only `status` payload queries route here; anything else spawns as before.
-/// - Requests route through serve only once the child is READY and WARM (one
-/// completed query), so cold start behaves exactly as today.
-/// - Any failure falls back to the spawn path for that call; three child
-/// deaths disable serve for this app run.
+/// - The first real status request is also the warm-up. It may be written
+/// before the child announces READY; the pipe buffers it until serve reads
+/// stdin, avoiding a second one-shot process that parses the same cache.
+/// - Transport/protocol failures fall back to the spawn path for that call;
+/// resource-policy failures remain terminal. Three child deaths disable
+/// serve for this app run.
/// - The child's stdin closing (app quit, even SIGKILL) ends the server loop
/// on the CLI side, so no orphan survives the menubar.
actor ServeConnection {
static let shared = ServeConnection()
+ typealias ProcessFactory = ([String], QualityOfService) -> Process
+ typealias TimeoutSleep = @Sendable (UInt64) async throws -> Void
+
+ private struct QueuedRequest {
+ let token: Int
+ let args: [String]
+ let continuation: CheckedContinuation
+ }
+
+ private struct ActiveRequest {
+ let token: Int
+ let id: Int
+ let args: [String]
+ let child: Process
+ }
+
private var process: Process?
private var stdinHandle: FileHandle?
private var nextId = 1
+ private var nextRequestToken = 1
+ private var queuedRequests: [QueuedRequest] = []
+ private var activeRequest: ActiveRequest?
private var pending: [Int: CheckedContinuation] = [:]
- private var ready = false
- private var warm = false
+ private var requestTimeouts: [Int: Task] = [:]
+ private var timeoutOwners: [Int: Process] = [:]
+ private var responseBytes: [Int: Int] = [:]
private var deaths = 0
private var buffer = Data()
+ private var receivedTerminalResponse = false
+ private var outputTasks: [ObjectIdentifier: Task] = [:]
+ private var terminationTasks: [ObjectIdentifier: Task] = [:]
+ private let makeProcess: ProcessFactory
+ private let timeoutSleep: TimeoutSleep
+ private let terminationGraceSleep: TimeoutSleep
+ private let responseLimitBytes: Int
private static let maxDeaths = 3
- private static let requestTimeoutSeconds: UInt64 = 60
+ static let maxResponseBytes = 16 * 1024 * 1024
+ private static let stdoutReadChunkBytes = 64 * 1024
+ private static let terminationGraceNanoseconds: UInt64 = 1_000_000_000
+ private static let coldRequestTimeoutNanoseconds: UInt64 = 10 * 60 * 1_000_000_000
+ private static let warmRequestTimeoutNanoseconds: UInt64 = 60 * 1_000_000_000
struct ServeUnavailable: Error {}
- struct ServeRequestFailed: Error { let message: String }
+ enum FailureReason: Sendable, Equatable {
+ case generic
+ case outputTooLarge
+ }
+ struct ServeRequestFailed: Error, Sendable {
+ let message: String
+ let reason: FailureReason
+
+ init(message: String, reason: FailureReason = .generic) {
+ self.message = message
+ self.reason = reason
+ }
+ }
+
+ init(
+ makeProcess: @escaping ProcessFactory = CodeburnCLI.makeProcess,
+ timeoutSleep: @escaping TimeoutSleep = { nanoseconds in
+ try await Task.sleep(nanoseconds: nanoseconds)
+ },
+ terminationGraceSleep: @escaping TimeoutSleep = { nanoseconds in
+ try await Task.sleep(nanoseconds: nanoseconds)
+ },
+ responseLimitBytes: Int = ServeConnection.maxResponseBytes
+ ) {
+ self.makeProcess = makeProcess
+ self.timeoutSleep = timeoutSleep
+ self.terminationGraceSleep = terminationGraceSleep
+ precondition(responseLimitBytes > 0)
+ self.responseLimitBytes = responseLimitBytes
+ }
static func isEligible(_ subcommand: [String]) -> Bool {
subcommand.first == "status"
}
- /// Kick the child off (idempotent). Called from app startup; fetches keep
- /// spawning until the warm-up completes.
+ /// Kick the child off (idempotent). Called from app startup and again by
+ /// the first request in case the startup task has not run yet.
func ensureStarted() {
guard process == nil, deaths < Self.maxDeaths else { return }
- let child = CodeburnCLI.makeProcess(subcommand: ["serve", "--stdio"], qualityOfService: .utility)
+ // This single resident serves both background and user-visible status
+ // requests. Its cold hydration replaces the old interactive one-shot,
+ // so keep the child at the same user-initiated QoS as visible fetches.
+ let child = makeProcess(["serve", "--stdio"], .userInitiated)
let stdinPipe = Pipe()
+ let stdinWriter = stdinPipe.fileHandleForWriting
+ // Suppress SIGPIPE only for this connection's write end. A process-wide
+ // SIG_IGN leaks into unrelated libraries and children; F_SETNOSIGPIPE
+ // keeps a closed child stdin on the normal throwable EPIPE path.
+ guard Darwin.fcntl(stdinWriter.fileDescriptor, F_SETNOSIGPIPE, 1) == 0 else {
+ deaths = Self.maxDeaths
+ return
+ }
let stdoutPipe = Pipe()
+ let stdoutReader = stdoutPipe.fileHandleForReading
child.standardInput = stdinPipe
child.standardOutput = stdoutPipe
child.standardError = FileHandle.nullDevice
- stdoutPipe.fileHandleForReading.readabilityHandler = { handle in
- let data = handle.availableData
- guard !data.isEmpty else { return }
- Task { await ServeConnection.shared.consume(data) }
- }
- child.terminationHandler = { _ in
- stdoutPipe.fileHandleForReading.readabilityHandler = nil
- Task { await ServeConnection.shared.childDied() }
- }
do {
try child.run()
} catch {
@@ -60,112 +126,404 @@ actor ServeConnection {
return
}
process = child
- stdinHandle = stdinPipe.fileHandleForWriting
- Task {
- // Warm-up: one cheap query makes the child parse the session cache
- // once; every later payload answers from the warm in-memory copy.
- _ = try? await self.send(args: ["status", "--format", "menubar-json", "--period", "today", "--no-optimize"])
- await self.markWarm()
+ stdinHandle = stdinWriter
+ let generation = ObjectIdentifier(child)
+ // One blocking reader owns this generation's stdout. It never reads a
+ // second bounded chunk until the actor has consumed the first, giving
+ // the 16 MiB protocol limit real backpressure instead of accumulating
+ // an unbounded callback/AsyncStream backlog. EOF is observed only after
+ // the pipe's final bytes, so child death cannot overtake a split reply.
+ outputTasks[generation] = Task.detached { [weak self] in
+ var bytes = [UInt8](repeating: 0, count: Self.stdoutReadChunkBytes)
+ while !Task.isCancelled {
+ let count = Darwin.read(stdoutReader.fileDescriptor, &bytes, bytes.count)
+ if count > 0 {
+ guard let self else { break }
+ await self.consume(Data(bytes[0.. Data {
- guard ready, warm, process != nil else { throw ServeUnavailable() }
- return try await send(args: args)
+ /// Send the first real payload through the resident child. A request does
+ /// not need to wait for the READY frame: stdin is safe to write as soon as
+ /// Process.run() succeeds, and serve serializes it after initialization.
+ func request(args: [String]) async throws -> Data {
+ try Task.checkCancellation()
+ ensureStarted()
+ guard process != nil else { throw ServeUnavailable() }
+ let token = nextRequestToken
+ nextRequestToken += 1
+ let response = try await withTaskCancellationHandler {
+ try await withCheckedThrowingContinuation { continuation in
+ queuedRequests.append(QueuedRequest(
+ token: token,
+ args: args,
+ continuation: continuation
+ ))
+ startNextRequestIfPossible()
+ }
+ } onCancel: {
+ Task { await self.cancelRequest(token: token) }
+ }
+ try Task.checkCancellation()
+ return response
}
func shutdown() {
deaths = Self.maxDeaths
process?.terminate()
- failAllPending()
+ for task in terminationTasks.values { task.cancel() }
+ terminationTasks.removeAll()
+ cancelAllTimeouts()
+ failAllRequests()
process = nil
stdinHandle = nil
+ buffer = Data()
+ receivedTerminalResponse = false
}
// MARK: - internals
- private func markWarm() {
- if process != nil { warm = true }
- }
+ private func startNextRequestIfPossible() {
+ guard activeRequest == nil, !queuedRequests.isEmpty else { return }
+ ensureStarted()
+ guard let stdinHandle, let child = process else {
+ failQueuedRequests(error: ServeUnavailable())
+ return
+ }
+ // A Process can report not-running just before its termination callback
+ // reaches the ordered event stream. Keep the request queued for that
+ // event instead of writing to a generation which is already exiting.
+ guard child.isRunning else { return }
- private func send(args: [String]) async throws -> Data {
- guard let stdinHandle, let child = process else { throw ServeUnavailable() }
+ let request = queuedRequests.removeFirst()
let id = nextId
nextId += 1
- let request: [String: Any] = ["id": id, "args": args]
- let line = try JSONSerialization.data(withJSONObject: request)
- return try await withThrowingTaskGroup(of: Data.self) { group in
- group.addTask {
- try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in
- Task { await self.registerPending(id: id, continuation: continuation) }
- do {
- try stdinHandle.write(contentsOf: line + Data("\n".utf8))
- } catch {
- Task { await self.rejectPending(id: id, error: ServeRequestFailed(message: "stdin write failed")) }
- }
- }
- }
- group.addTask {
- try await Task.sleep(nanoseconds: Self.requestTimeoutSeconds * 1_000_000_000)
- // A hung request would block the serialized queue behind it:
- // kill the child so everything falls back to spawns.
- await self.rejectPending(id: id, error: ServeRequestFailed(message: "serve timeout"))
- child.terminate()
- throw ServeRequestFailed(message: "serve timeout")
- }
- let result = try await group.next()!
- group.cancelAll()
- return result
+ let line: Data
+ do {
+ line = try JSONSerialization.data(withJSONObject: ["id": id, "args": request.args])
+ } catch {
+ request.continuation.resume(throwing: error)
+ startNextRequestIfPossible()
+ return
+ }
+
+ // The previous response can resume its caller just before EOF reaches
+ // this actor. Avoid admitting a successor to an already-reaped child;
+ // the reader's ordered EOF path will start it on a replacement.
+ guard child.isRunning else {
+ queuedRequests.insert(request, at: 0)
+ outputStreamEnded(for: child)
+ return
+ }
+
+ // Select and arm the timeout only when this request becomes the sole
+ // protocol request in flight. A queued request must not spend its own
+ // budget while its predecessor is still hydrating or draining.
+ let timeoutNanoseconds = receivedTerminalResponse
+ ? Self.warmRequestTimeoutNanoseconds
+ : Self.coldRequestTimeoutNanoseconds
+ activeRequest = ActiveRequest(
+ token: request.token,
+ id: id,
+ args: request.args,
+ child: child
+ )
+ pending[id] = request.continuation
+ responseBytes[id] = 0
+ do {
+ try stdinHandle.write(contentsOf: line + Data("\n".utf8))
+ armTimeout(id: id, child: child, nanoseconds: timeoutNanoseconds)
+ } catch {
+ // The previous terminal frame can resume its caller just before
+ // EOF detaches that generation. Preserve this never-admitted
+ // request and retry it on the replacement instead of surfacing a
+ // transient EPIPE to the UI.
+ pending.removeValue(forKey: id)
+ responseBytes.removeValue(forKey: id)
+ activeRequest = nil
+ queuedRequests.insert(request, at: 0)
+ outputStreamEnded(for: child)
}
}
- private func registerPending(id: Int, continuation: CheckedContinuation) {
- pending[id] = continuation
+ private func cancelRequest(token: Int) {
+ if let index = queuedRequests.firstIndex(where: { $0.token == token }) {
+ let request = queuedRequests.remove(at: index)
+ request.continuation.resume(throwing: CancellationError())
+ return
+ }
+ guard let activeRequest, activeRequest.token == token,
+ let continuation = pending.removeValue(forKey: activeRequest.id) else { return }
+ continuation.resume(throwing: CancellationError())
+ // Caller cancellation abandons only this response. The serialized serve
+ // child may still be doing the expensive first hydration, and killing it
+ // here lets tab switches and UI watchdogs restart that work indefinitely.
+ // Its independent request timeout remains armed: a command that never
+ // returns is still reaped, so it cannot wedge every later serialized call.
}
- private func rejectPending(id: Int, error: Error) {
+ private func armTimeout(id: Int, child: Process, nanoseconds: UInt64) {
+ let sleep = timeoutSleep
+ timeoutOwners[id] = child
+ requestTimeouts[id] = Task.detached { [weak self] in
+ do {
+ try await sleep(nanoseconds)
+ } catch {
+ return
+ }
+ await self?.requestTimedOut(id: id)
+ }
+ }
+
+ private func requestTimedOut(id: Int) {
+ guard let child = timeoutOwners.removeValue(forKey: id) else { return }
+ requestTimeouts.removeValue(forKey: id)
+ responseBytes.removeValue(forKey: id)
if let continuation = pending.removeValue(forKey: id) {
- continuation.resume(throwing: error)
+ continuation.resume(throwing: ServeRequestFailed(message: "serve timeout"))
}
- }
-
- private func consume(_ data: Data) {
- buffer.append(data)
- while let newline = buffer.firstIndex(of: UInt8(ascii: "\n")) {
- let lineData = buffer.subdata(in: buffer.startIndex.. Bool {
+ guard let current = responseBytes[id],
+ count <= responseLimitBytes - current else {
+ outputOverflowed(child)
+ return false
+ }
+ responseBytes[id] = current + count
+ return true
+ }
+
+ private func outputOverflowed(_ child: Process) {
+ guard process === child else { return }
+ // Detach this exact generation before terminating it. Its eventual exit
+ // and any already-scheduled stdout callbacks are then stale and cannot
+ // consume a second death or corrupt a replacement generation.
+ process = nil
+ stdinHandle = nil
+ buffer = Data()
+ receivedTerminalResponse = false
+ deaths += 1
+ cancelTimeouts(ownedBy: child)
+ failAllRequests(error: ServeRequestFailed(
+ message: "serve output exceeded \(responseLimitBytes) bytes",
+ reason: .outputTooLarge
+ ))
+ if child.isRunning { child.terminate() }
+ }
+
+ private func childDied(_ child: Process) {
+ guard process === child else { return }
+ process = nil
+ stdinHandle = nil
+ buffer.removeAll()
+ receivedTerminalResponse = false
+ deaths += 1
+ cancelTimeouts(ownedBy: child)
+ if let activeRequest, activeRequest.child === child {
+ if let continuation = pending.removeValue(forKey: activeRequest.id) {
+ // Only read-only status requests enter this connection. If a
+ // generation exits after admission but before its terminal
+ // reply, retain the waiter and retry on the replacement rather
+ // than racing it into a one-shot fallback. A timed-out or
+ // cancelled waiter is already absent and is never retried.
+ queuedRequests.insert(QueuedRequest(
+ token: activeRequest.token,
+ args: activeRequest.args,
+ continuation: continuation
+ ), at: 0)
+ }
+ self.activeRequest = nil
+ }
+ // Requests which were never written survive an ordinary child crash.
+ // They begin on a replacement only after this ordered death event.
+ startNextRequestIfPossible()
+ }
+
+ private func failAllRequests(
+ error: Error = ServeRequestFailed(message: "serve exited")
+ ) {
for (_, continuation) in pending {
- continuation.resume(throwing: ServeRequestFailed(message: "serve exited"))
+ continuation.resume(throwing: error)
}
pending.removeAll()
+ activeRequest = nil
+ failQueuedRequests(error: error)
+ }
+
+ private func failQueuedRequests(error: Error) {
+ let requests = queuedRequests
+ queuedRequests.removeAll()
+ for request in requests {
+ request.continuation.resume(throwing: error)
+ }
}
}
diff --git a/mac/Sources/CodeBurnMenubar/Data/SubscriptionSnapshotStore.swift b/mac/Sources/CodeBurnMenubar/Data/SubscriptionSnapshotStore.swift
index 9357ee94..2c99dd06 100644
--- a/mac/Sources/CodeBurnMenubar/Data/SubscriptionSnapshotStore.swift
+++ b/mac/Sources/CodeBurnMenubar/Data/SubscriptionSnapshotStore.swift
@@ -14,13 +14,8 @@ struct SubscriptionSnapshot: Codable, Sendable {
private let snapshotFilename = "subscription-snapshots.json"
private let pruneOlderThanSeconds: TimeInterval = 30 * 24 * 3600
-private func snapshotsCacheDir() -> String {
- return ProcessInfo.processInfo.environment["CODEBURN_CACHE_DIR"]
- ?? (NSHomeDirectory() as NSString).appendingPathComponent(".cache/codeburn")
-}
-
private func snapshotsPath() -> String {
- return (snapshotsCacheDir() as NSString).appendingPathComponent(snapshotFilename)
+ return (CodeBurnCacheDirectory.resolve() as NSString).appendingPathComponent(snapshotFilename)
}
private actor SnapshotLock {
diff --git a/mac/Sources/CodeBurnMenubar/Data/UsageDataChangeGuard.swift b/mac/Sources/CodeBurnMenubar/Data/UsageDataChangeGuard.swift
index d2a48c36..e28eed9c 100644
--- a/mac/Sources/CodeBurnMenubar/Data/UsageDataChangeGuard.swift
+++ b/mac/Sources/CodeBurnMenubar/Data/UsageDataChangeGuard.swift
@@ -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)
diff --git a/mac/Sources/CodeBurnMenubar/Security/SafeFile.swift b/mac/Sources/CodeBurnMenubar/Security/SafeFile.swift
index 3d6bda57..3b3dea29 100644
--- a/mac/Sources/CodeBurnMenubar/Security/SafeFile.swift
+++ b/mac/Sources/CodeBurnMenubar/Security/SafeFile.swift
@@ -2,7 +2,7 @@ import Foundation
/// Symlink-safe file I/O with atomic writes and optional cross-process flock.
///
-/// Every cache file we touch (`~/Library/Caches/codeburn-mac/fx-rates.json`,
+/// Every cache file we touch (`~/.cache/codeburn/fx-rates.json`,
/// `~/.cache/codeburn/subscription-snapshots.json`, `~/.config/codeburn/config.json`) is a
/// legitimate target for a local-symlink attack: if an attacker plants a symlink from one of
/// those paths to, say, `~/.ssh/config`, a naive `Data.write(to:)` blindly follows the link and
diff --git a/mac/Tests/CodeBurnMenubarTests/CodeBurnCacheDirectoryTests.swift b/mac/Tests/CodeBurnMenubarTests/CodeBurnCacheDirectoryTests.swift
new file mode 100644
index 00000000..cb217ed5
--- /dev/null
+++ b/mac/Tests/CodeBurnMenubarTests/CodeBurnCacheDirectoryTests.swift
@@ -0,0 +1,36 @@
+import Foundation
+import Testing
+@testable import CodeBurnMenubar
+
+@Suite("CodeBurnCacheDirectory")
+struct CodeBurnCacheDirectoryTests {
+ @Test("honors CODEBURN_CACHE_DIR override")
+ func honorsOverride() {
+ let resolved = CodeBurnCacheDirectory.resolve(
+ environment: ["CODEBURN_CACHE_DIR": "/tmp/codeburn-shared-cache"],
+ homeDirectory: URL(fileURLWithPath: "/Users/test")
+ )
+
+ #expect(resolved == "/tmp/codeburn-shared-cache")
+ }
+
+ @Test("falls back to the user's standard cache directory")
+ func fallsBackToStandardDirectory() {
+ let resolved = CodeBurnCacheDirectory.resolve(
+ environment: [:],
+ homeDirectory: URL(fileURLWithPath: "/Users/test", isDirectory: true)
+ )
+
+ #expect(resolved == "/Users/test/.cache/codeburn")
+ }
+
+ @Test("ignores an empty cache override")
+ func ignoresEmptyOverride() {
+ let resolved = CodeBurnCacheDirectory.resolve(
+ environment: ["CODEBURN_CACHE_DIR": " \n"],
+ homeDirectory: URL(fileURLWithPath: "/Users/test", isDirectory: true)
+ )
+
+ #expect(resolved == "/Users/test/.cache/codeburn")
+ }
+}
diff --git a/mac/Tests/CodeBurnMenubarTests/ServeConnectionTests.swift b/mac/Tests/CodeBurnMenubarTests/ServeConnectionTests.swift
new file mode 100644
index 00000000..a5c7588e
--- /dev/null
+++ b/mac/Tests/CodeBurnMenubarTests/ServeConnectionTests.swift
@@ -0,0 +1,1161 @@
+import Darwin
+import Foundation
+import Testing
+@testable import CodeBurnMenubar
+
+private let ignoredSIGPIPEHandlerBits = unsafeBitCast(SIG_IGN, to: UInt.self)
+private let coldTimeoutNanoseconds: UInt64 = 10 * 60 * 1_000_000_000
+private let warmTimeoutNanoseconds: UInt64 = 60 * 1_000_000_000
+private let terminationGraceNanoseconds: UInt64 = 1_000_000_000
+
+private func currentSIGPIPEHandlerBits() -> UInt {
+ var action = sigaction()
+ _ = sigaction(SIGPIPE, nil, &action)
+ return unsafeBitCast(action.__sigaction_u.__sa_handler, to: UInt.self)
+}
+
+private actor TimeoutRecorder {
+ private var values: [UInt64] = []
+
+ func recordAndSleep(_ nanoseconds: UInt64) async throws {
+ values.append(nanoseconds)
+ // Cold timers stay pending until the fake child replies and the
+ // connection cancels them. The warm timer returns immediately to exercise
+ // the timeout path without a real one-minute wait.
+ if nanoseconds == warmTimeoutNanoseconds { return }
+ try await Task.sleep(nanoseconds: 5 * 1_000_000_000)
+ }
+
+ func recordAndWait(_ nanoseconds: UInt64) async throws {
+ values.append(nanoseconds)
+ // This recorder verifies timeout selection without firing the timeout.
+ // The response must deterministically win, then cancel this sleeper.
+ try await Task.sleep(nanoseconds: 5 * 1_000_000_000)
+ }
+
+ func snapshot() -> [UInt64] { values }
+}
+
+private actor FallbackRecorder {
+ private var calls = 0
+
+ func record() { calls += 1 }
+ func snapshot() -> Int { calls }
+}
+
+/// A cancellation-aware timeout clock that tests can advance explicitly. This
+/// keeps the regression independent of the production ten-minute cold budget.
+private actor ManualTimeoutClock {
+ private struct Waiter {
+ let nanoseconds: UInt64
+ let continuation: CheckedContinuation
+ }
+
+ private var nextToken = 0
+ private var waiters: [Int: Waiter] = [:]
+ private var recorded: [UInt64] = []
+
+ func sleep(_ nanoseconds: UInt64) async throws {
+ let token = nextToken
+ nextToken += 1
+ recorded.append(nanoseconds)
+ try await withTaskCancellationHandler {
+ try await withCheckedThrowingContinuation { (continuation: CheckedContinuation) in
+ if Task.isCancelled {
+ continuation.resume(throwing: CancellationError())
+ } else {
+ waiters[token] = Waiter(nanoseconds: nanoseconds, continuation: continuation)
+ }
+ }
+ } onCancel: {
+ Task { await self.cancel(token) }
+ }
+ }
+
+ func snapshot() -> [UInt64] {
+ waiters.keys.sorted().compactMap { waiters[$0]?.nanoseconds }
+ }
+
+ func history() -> [UInt64] { recorded }
+
+ func fireOldest() {
+ guard let token = waiters.keys.min(), let waiter = waiters.removeValue(forKey: token) else { return }
+ waiter.continuation.resume()
+ }
+
+ private func cancel(_ token: Int) {
+ guard let waiter = waiters.removeValue(forKey: token) else { return }
+ waiter.continuation.resume(throwing: CancellationError())
+ }
+}
+
+private final class QualityOfServiceRecorder: @unchecked Sendable {
+ private let lock = NSLock()
+ private var values: [QualityOfService] = []
+
+ func record(_ value: QualityOfService) {
+ lock.lock()
+ values.append(value)
+ lock.unlock()
+ }
+
+ func snapshot() -> [QualityOfService] {
+ lock.lock()
+ defer { lock.unlock() }
+ return values
+ }
+}
+
+private final class ProcessQueue: @unchecked Sendable {
+ private let lock = NSLock()
+ private var processes: [Process]
+
+ init(_ processes: [Process]) {
+ self.processes = processes
+ }
+
+ func take(qualityOfService: QualityOfService) -> Process {
+ lock.lock()
+ let child = processes.removeFirst()
+ lock.unlock()
+ child.qualityOfService = qualityOfService
+ return child
+ }
+
+ var remainingCount: Int {
+ lock.lock()
+ defer { lock.unlock() }
+ return processes.count
+ }
+}
+
+@Suite("ServeConnection", .serialized)
+struct ServeConnectionTests {
+ @Test("the resident child starts at user-initiated QoS")
+ func residentChildUsesInteractiveQoS() async {
+ let recorder = QualityOfServiceRecorder()
+ let connection = ServeConnection { _, qualityOfService in
+ recorder.record(qualityOfService)
+ let child = Process()
+ child.executableURL = URL(fileURLWithPath: "/bin/sh")
+ child.arguments = ["-c", "while IFS= read -r line; do :; done"]
+ child.qualityOfService = qualityOfService
+ return child
+ }
+
+ await connection.ensureStarted()
+
+ #expect(recorder.snapshot() == [.userInitiated])
+ await connection.shutdown()
+ }
+
+ @Test("cancelling a hung request returns promptly")
+ func cancellationUnblocksPendingContinuation() async throws {
+ let dir = NSTemporaryDirectory() + "serve-connection-cancel-test-" + UUID().uuidString
+ try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
+ defer { try? FileManager.default.removeItem(atPath: dir) }
+ let requestMarker = dir + "/request-read"
+
+ let connection = ServeConnection { _, qualityOfService in
+ let child = Process()
+ child.executableURL = URL(fileURLWithPath: "/bin/sh")
+ child.arguments = ["-c", "IFS= read -r line; : > \"$1\"; sleep 1", "serve-fixture", requestMarker]
+ child.qualityOfService = qualityOfService
+ return child
+ }
+
+ let request = Task {
+ try await connection.request(args: ["status", "--format", "menubar-json"])
+ }
+ for _ in 0..<200 where !FileManager.default.fileExists(atPath: requestMarker) {
+ try await Task.sleep(nanoseconds: 10_000_000)
+ }
+ #expect(FileManager.default.fileExists(atPath: requestMarker))
+
+ let clock = ContinuousClock()
+ let started = clock.now
+ request.cancel()
+ do {
+ _ = try await request.value
+ #expect(Bool(false), "cancelled request unexpectedly succeeded")
+ } catch {
+ #expect(error is CancellationError)
+ }
+ let elapsed = started.duration(to: clock.now)
+ #expect(elapsed < .milliseconds(500))
+ await connection.shutdown()
+ }
+
+ @Test("a request queued during cancelled hydration completes on the same child")
+ func cancellationKeepsQueuedRequestOnResidentChild() async throws {
+ let dir = NSTemporaryDirectory() + "serve-connection-cancel-overlap-test-" + UUID().uuidString
+ try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
+ defer { try? FileManager.default.removeItem(atPath: dir) }
+ let pidsFile = dir + "/pids"
+ let eventsFile = dir + "/events"
+ let releaseMarker = dir + "/release-first"
+ let recorder = TimeoutRecorder()
+
+ let connection = ServeConnection(
+ makeProcess: { _, qualityOfService in
+ let child = Process()
+ child.executableURL = URL(fileURLWithPath: "/bin/sh")
+ child.arguments = ["-c", """
+ printf '%s\n' "$$" >> "$1"
+ IFS= read -r first
+ first_id=$(printf '%s' "$first" | sed -E 's/.*"id":([0-9]+).*/\\1/')
+ printf 'first-read\n' >> "$2"
+ while [ ! -f "$3" ]; do sleep 0.01; done
+ printf '{"id":%s,"ok":true,"output":"late-%s"}\n' "$first_id" "$first_id"
+ printf 'late-first\n' >> "$2"
+ IFS= read -r second
+ second_id=$(printf '%s' "$second" | sed -E 's/.*"id":([0-9]+).*/\\1/')
+ printf 'second-read\n' >> "$2"
+ printf '{"id":%s,"ok":true,"output":"live-%s"}\n' "$second_id" "$second_id"
+ printf 'second-replied\n' >> "$2"
+ """, "serve-fixture", pidsFile, eventsFile, releaseMarker]
+ child.qualityOfService = qualityOfService
+ return child
+ },
+ timeoutSleep: { nanoseconds in
+ try await recorder.recordAndWait(nanoseconds)
+ }
+ )
+
+ let first = Task {
+ try await connection.request(args: ["status", "--request", "first"])
+ }
+ for _ in 0..<200 {
+ let events = (try? String(contentsOfFile: eventsFile, encoding: .utf8)) ?? ""
+ if events.contains("first-read\n") { break }
+ try await Task.sleep(nanoseconds: 10_000_000)
+ }
+ #expect(try String(contentsOfFile: eventsFile, encoding: .utf8) == "first-read\n")
+
+ first.cancel()
+ do {
+ _ = try await first.value
+ #expect(Bool(false), "cancelled request unexpectedly succeeded")
+ } catch {
+ #expect(error is CancellationError)
+ }
+
+ // Submit the next request while the child is still blocked hydrating
+ // the cancelled first one. It stays client-side queued: neither its
+ // stdin line nor its own timeout may begin yet.
+ let second = Task {
+ try await connection.request(args: ["status", "--request", "second"])
+ }
+ try await Task.sleep(nanoseconds: 100_000_000)
+ #expect(await recorder.snapshot() == [coldTimeoutNanoseconds])
+ #expect(try String(contentsOfFile: eventsFile, encoding: .utf8) == "first-read\n")
+
+ _ = FileManager.default.createFile(atPath: releaseMarker, contents: Data())
+ let secondPayload = try await second.value
+
+ #expect(String(decoding: secondPayload, as: UTF8.self) == "live-2")
+ #expect(await recorder.snapshot() == [coldTimeoutNanoseconds, warmTimeoutNanoseconds])
+ let pids = try String(contentsOfFile: pidsFile, encoding: .utf8)
+ .split(separator: "\n")
+ #expect(pids.count == 1)
+ let events = try String(contentsOfFile: eventsFile, encoding: .utf8)
+ .split(separator: "\n")
+ #expect(events == ["first-read", "late-first", "second-read", "second-replied"])
+ await connection.shutdown()
+ }
+
+ @Test("a cancelled never-returning request retains a timeout owner and cannot wedge later work")
+ func cancelledHungRequestIsEventuallyReaped() async throws {
+ let dir = NSTemporaryDirectory() + "serve-connection-cancel-timeout-test-" + UUID().uuidString
+ try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
+ defer { try? FileManager.default.removeItem(atPath: dir) }
+ let firstReadMarker = dir + "/first-read"
+ let pidsFile = dir + "/pids"
+ let clock = ManualTimeoutClock()
+ let graceClock = ManualTimeoutClock()
+
+ let stuckChild = Process()
+ stuckChild.executableURL = URL(fileURLWithPath: "/bin/sh")
+ stuckChild.arguments = ["-c", """
+ trap '' TERM
+ printf '%s\n' "$$" >> "$1"
+ IFS= read -r line
+ : > "$2"
+ while :; do :; done
+ """, "serve-fixture", pidsFile, firstReadMarker]
+
+ let replacement = Process()
+ replacement.executableURL = URL(fileURLWithPath: "/bin/sh")
+ replacement.arguments = ["-c", """
+ printf '%s\n' "$$" >> "$1"
+ while IFS= read -r line; do
+ id=$(printf '%s' "$line" | sed -E 's/.*"id":([0-9]+).*/\\1/')
+ printf '{"id":%s,"ok":true,"output":"replacement-%s"}\n' "$id" "$id"
+ done
+ """, "serve-fixture", pidsFile]
+
+ let children = ProcessQueue([stuckChild, replacement])
+ let connection = ServeConnection(
+ makeProcess: { _, qualityOfService in
+ children.take(qualityOfService: qualityOfService)
+ },
+ timeoutSleep: { nanoseconds in
+ try await clock.sleep(nanoseconds)
+ },
+ terminationGraceSleep: { nanoseconds in
+ try await graceClock.sleep(nanoseconds)
+ }
+ )
+ defer {
+ if stuckChild.isRunning { _ = Darwin.kill(stuckChild.processIdentifier, SIGKILL) }
+ }
+
+ let abandoned = Task {
+ try await connection.request(args: ["status", "--request", "stuck"])
+ }
+ for _ in 0..<200 where !FileManager.default.fileExists(atPath: firstReadMarker) {
+ try await Task.sleep(nanoseconds: 10_000_000)
+ }
+ #expect(FileManager.default.fileExists(atPath: firstReadMarker))
+ #expect(await clock.snapshot() == [coldTimeoutNanoseconds])
+
+ abandoned.cancel()
+ do {
+ _ = try await abandoned.value
+ #expect(Bool(false), "cancelled request unexpectedly succeeded")
+ } catch {
+ #expect(error is CancellationError)
+ }
+
+ // The caller is gone, but the independently-owned cold timeout must
+ // remain armed. This assertion is the red-before regression: the old
+ // task-group race cancelled the only timeout along with the caller.
+ #expect(await clock.snapshot() == [coldTimeoutNanoseconds])
+ let successor = Task {
+ try await connection.request(args: ["status", "--request", "after-cancel"])
+ }
+ try await Task.sleep(nanoseconds: 100_000_000)
+ #expect(await clock.snapshot() == [coldTimeoutNanoseconds])
+ #expect(children.remainingCount == 1)
+
+ await clock.fireOldest()
+ for _ in 0..<200 where children.remainingCount > 0 {
+ try await Task.sleep(nanoseconds: 10_000_000)
+ }
+ let replacementStartedBeforeOldEOF = children.remainingCount == 0
+ #expect(replacementStartedBeforeOldEOF)
+ // Keep the red-before run finite: the old implementation waits for EOF
+ // forever because this fixture deliberately ignores SIGTERM.
+ if !replacementStartedBeforeOldEOF {
+ _ = Darwin.kill(stuckChild.processIdentifier, SIGKILL)
+ for _ in 0..<200 where children.remainingCount > 0 {
+ try await Task.sleep(nanoseconds: 10_000_000)
+ }
+ }
+
+ // The retired child ignores SIGTERM, yet its stale stdout remains open.
+ // The queued successor must already run on a replacement; it cannot wait
+ // for either old-generation EOF or the force-kill grace period.
+ let payload = try await successor.value
+ #expect(String(decoding: payload, as: UTF8.self) == "replacement-2")
+ #expect(await clock.snapshot().isEmpty)
+ #expect(await clock.history() == [coldTimeoutNanoseconds, coldTimeoutNanoseconds])
+ #expect(await graceClock.snapshot() == [terminationGraceNanoseconds])
+ #expect(stuckChild.isRunning)
+ #expect(try String(contentsOfFile: pidsFile, encoding: .utf8).split(separator: "\n").count == 2)
+
+ await graceClock.fireOldest()
+ for _ in 0..<200 where stuckChild.isRunning {
+ try await Task.sleep(nanoseconds: 10_000_000)
+ }
+ #expect(!stuckChild.isRunning)
+ #expect(stuckChild.terminationReason == .uncaughtSignal)
+ #expect(stuckChild.terminationStatus == SIGKILL)
+ await connection.shutdown()
+ }
+
+ @Test("shutdown during the termination grace force-kills the SIGTERM-ignoring generation")
+ func shutdownDuringGraceKillsStubbornChild() async throws {
+ let dir = NSTemporaryDirectory() + "serve-connection-shutdown-grace-test-" + UUID().uuidString
+ try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
+ defer { try? FileManager.default.removeItem(atPath: dir) }
+ let firstReadMarker = dir + "/first-read"
+ let clock = ManualTimeoutClock()
+ let graceClock = ManualTimeoutClock()
+
+ let stuckChild = Process()
+ stuckChild.executableURL = URL(fileURLWithPath: "/bin/sh")
+ stuckChild.arguments = ["-c", """
+ trap '' TERM
+ IFS= read -r line
+ : > "$1"
+ while :; do :; done
+ """, "serve-fixture", firstReadMarker]
+
+ let children = ProcessQueue([stuckChild])
+ let connection = ServeConnection(
+ makeProcess: { _, qualityOfService in
+ children.take(qualityOfService: qualityOfService)
+ },
+ timeoutSleep: { nanoseconds in
+ try await clock.sleep(nanoseconds)
+ },
+ terminationGraceSleep: { nanoseconds in
+ try await graceClock.sleep(nanoseconds)
+ }
+ )
+ defer {
+ if stuckChild.isRunning { _ = Darwin.kill(stuckChild.processIdentifier, SIGKILL) }
+ }
+
+ let request = Task {
+ try await connection.request(args: ["status", "--request", "stuck"])
+ }
+ for _ in 0..<200 where !FileManager.default.fileExists(atPath: firstReadMarker) {
+ try await Task.sleep(nanoseconds: 10_000_000)
+ }
+ #expect(FileManager.default.fileExists(atPath: firstReadMarker))
+ #expect(await clock.snapshot() == [coldTimeoutNanoseconds])
+
+ // Time out the request: the generation is retired and SIGTERM'd, and the
+ // SIGKILL escalation parks on the injected grace clock.
+ await clock.fireOldest()
+ do {
+ _ = try await request.value
+ #expect(Bool(false), "timed-out request unexpectedly succeeded")
+ } catch let error as ServeConnection.ServeRequestFailed {
+ #expect(error.message == "serve timeout")
+ }
+ for _ in 0..<200 where await graceClock.snapshot().isEmpty {
+ try await Task.sleep(nanoseconds: 10_000_000)
+ }
+ #expect(await graceClock.snapshot() == [terminationGraceNanoseconds])
+ #expect(stuckChild.isRunning) // SIGTERM ignored; escalation still pending
+
+ // Shutdown must not merely cancel the escalation. The retired generation
+ // is already detached from `process`, so nothing else will reap it; the
+ // grace task's cancellation path has to SIGKILL it or it outlives the app.
+ await connection.shutdown()
+ for _ in 0..<200 where stuckChild.isRunning {
+ try await Task.sleep(nanoseconds: 10_000_000)
+ }
+ #expect(!stuckChild.isRunning)
+ #expect(stuckChild.terminationReason == .uncaughtSignal)
+ #expect(stuckChild.terminationStatus == SIGKILL)
+ }
+
+ @Test("timed-out generations consume one death each and stop at the resident budget")
+ func timeoutDeathBudgetIsExact() async throws {
+ let dir = NSTemporaryDirectory() + "serve-connection-timeout-budget-test-" + UUID().uuidString
+ try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
+ defer { try? FileManager.default.removeItem(atPath: dir) }
+ let readsFile = dir + "/reads"
+ let clock = ManualTimeoutClock()
+ let processes = (0..<3).map { _ in
+ let child = Process()
+ child.executableURL = URL(fileURLWithPath: "/bin/sh")
+ child.arguments = ["-c", """
+ trap '' TERM
+ IFS= read -r line
+ printf r >> "$1"
+ while :; do :; done
+ """, "serve-fixture", readsFile]
+ return child
+ }
+ let children = ProcessQueue(processes)
+ let connection = ServeConnection(
+ makeProcess: { _, qualityOfService in
+ children.take(qualityOfService: qualityOfService)
+ },
+ timeoutSleep: { nanoseconds in
+ try await clock.sleep(nanoseconds)
+ },
+ terminationGraceSleep: { _ in }
+ )
+ defer {
+ for child in processes where child.isRunning {
+ _ = Darwin.kill(child.processIdentifier, SIGKILL)
+ }
+ }
+
+ for attempt in 0..<3 {
+ let request = Task {
+ try await connection.request(args: ["status", "--attempt", String(attempt)])
+ }
+ for _ in 0..<200 {
+ let reads = (try? String(contentsOfFile: readsFile, encoding: .utf8).count) ?? 0
+ if reads == attempt + 1, await clock.snapshot().count == 1 { break }
+ try await Task.sleep(nanoseconds: 10_000_000)
+ }
+ #expect((try? String(contentsOfFile: readsFile, encoding: .utf8).count) == attempt + 1)
+ await clock.fireOldest()
+ do {
+ _ = try await request.value
+ Issue.record("timeout \(attempt) unexpectedly succeeded")
+ } catch let error as ServeConnection.ServeRequestFailed {
+ #expect(error.message == "serve timeout")
+ }
+ }
+
+ #expect(children.remainingCount == 0)
+ do {
+ _ = try await connection.request(args: ["status", "--after-budget"])
+ Issue.record("resident restarted after three timed-out generations")
+ } catch {
+ #expect(error is ServeConnection.ServeUnavailable)
+ }
+ for _ in 0..<200 where processes.contains(where: \.isRunning) {
+ try await Task.sleep(nanoseconds: 10_000_000)
+ }
+ #expect(processes.allSatisfy { !$0.isRunning })
+ #expect(processes.allSatisfy {
+ $0.terminationReason == .uncaughtSignal && $0.terminationStatus == SIGKILL
+ })
+ await connection.shutdown()
+ }
+
+ @Test("external cancellations keep one child and safely discard late replies")
+ func cancellationsKeepResidentChildAlive() async throws {
+ let dir = NSTemporaryDirectory() + "serve-connection-cancel-reuse-test-" + UUID().uuidString
+ try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
+ defer { try? FileManager.default.removeItem(atPath: dir) }
+ let pidsFile = dir + "/pids"
+ let requestsFile = dir + "/requests"
+ let lateRepliesFile = dir + "/late-replies"
+
+ let connection = ServeConnection { _, qualityOfService in
+ let child = Process()
+ child.executableURL = URL(fileURLWithPath: "/bin/sh")
+ child.arguments = ["-c", """
+ printf '%s\n' "$$" >> "$1"
+ while IFS= read -r line; do
+ printf r >> "$2"
+ id=$(printf '%s' "$line" | sed -E 's/.*"id":([0-9]+).*/\\1/')
+ if [ "$id" -le 3 ]; then
+ sleep 0.05
+ printf '{"id":%s,"ok":true,"output":"late-%s"}\n' "$id" "$id"
+ printf l >> "$3"
+ else
+ printf '{"id":%s,"ok":true,"output":"live-%s"}\n' "$id" "$id"
+ fi
+ done
+ """, "serve-fixture", pidsFile, requestsFile, lateRepliesFile]
+ child.qualityOfService = qualityOfService
+ return child
+ }
+
+ for attempt in 0..<3 {
+ let request = Task {
+ try await connection.request(args: ["status", "--attempt", String(attempt)])
+ }
+ for _ in 0..<200 {
+ let reads = (try? String(contentsOfFile: requestsFile, encoding: .utf8).count) ?? 0
+ if reads >= attempt + 1 { break }
+ try await Task.sleep(nanoseconds: 10_000_000)
+ }
+ request.cancel()
+ do {
+ _ = try await request.value
+ #expect(Bool(false), "cancelled request unexpectedly succeeded")
+ } catch {
+ #expect(error is CancellationError)
+ }
+
+ // The fake child deliberately emits the now-orphaned response after
+ // cancellation. It must be ignored without double-resuming anything,
+ // and the same resident child must remain available for the next id.
+ for _ in 0..<200 {
+ let replies = (try? String(contentsOfFile: lateRepliesFile, encoding: .utf8).count) ?? 0
+ if replies >= attempt + 1 { break }
+ try await Task.sleep(nanoseconds: 10_000_000)
+ }
+ let replies = (try? String(contentsOfFile: lateRepliesFile, encoding: .utf8).count) ?? 0
+ #expect(replies == attempt + 1)
+ }
+
+ let finalPayload = try await connection.request(args: ["status", "--attempt", "final"])
+ #expect(String(decoding: finalPayload, as: UTF8.self) == "live-4")
+ let pids = try String(contentsOfFile: pidsFile, encoding: .utf8)
+ .split(separator: "\n")
+ #expect(pids.count == 1)
+ #expect(try String(contentsOfFile: requestsFile, encoding: .utf8) == "rrrr")
+ #expect(try String(contentsOfFile: lateRepliesFile, encoding: .utf8) == "lll")
+ await connection.shutdown()
+ }
+
+ @Test("cancelling a queued request never writes it or arms its timeout")
+ func queuedCancellationNeverReachesChild() async throws {
+ let dir = NSTemporaryDirectory() + "serve-connection-queued-cancel-test-" + UUID().uuidString
+ try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
+ defer { try? FileManager.default.removeItem(atPath: dir) }
+ let requestsFile = dir + "/requests"
+ let releaseMarker = dir + "/release"
+ let recorder = TimeoutRecorder()
+
+ let connection = ServeConnection(
+ makeProcess: { _, qualityOfService in
+ let child = Process()
+ child.executableURL = URL(fileURLWithPath: "/bin/sh")
+ child.arguments = ["-c", """
+ count=0
+ while IFS= read -r line; do
+ count=$((count + 1))
+ printf '%s\n' "$line" >> "$1"
+ if [ "$count" -eq 1 ]; then
+ while [ ! -f "$2" ]; do sleep 0.01; done
+ fi
+ id=$(printf '%s' "$line" | sed -E 's/.*"id":([0-9]+).*/\\1/')
+ printf '{"id":%s,"ok":true,"output":"served-%s"}\n' "$id" "$id"
+ done
+ """, "serve-fixture", requestsFile, releaseMarker]
+ child.qualityOfService = qualityOfService
+ return child
+ },
+ timeoutSleep: { nanoseconds in
+ try await recorder.recordAndWait(nanoseconds)
+ }
+ )
+
+ let first = Task { try await connection.request(args: ["status", "first"]) }
+ for _ in 0..<200 where !(FileManager.default.fileExists(atPath: requestsFile)) {
+ try await Task.sleep(nanoseconds: 10_000_000)
+ }
+ let cancelled = Task { try await connection.request(args: ["status", "cancelled"]) }
+ let third = Task { try await connection.request(args: ["status", "third"]) }
+ try await Task.sleep(nanoseconds: 100_000_000)
+ cancelled.cancel()
+ do {
+ _ = try await cancelled.value
+ Issue.record("queued cancellation unexpectedly succeeded")
+ } catch {
+ #expect(error is CancellationError)
+ }
+ #expect(await recorder.snapshot() == [coldTimeoutNanoseconds])
+
+ _ = FileManager.default.createFile(atPath: releaseMarker, contents: Data())
+ #expect(String(decoding: try await first.value, as: UTF8.self) == "served-1")
+ #expect(String(decoding: try await third.value, as: UTF8.self) == "served-2")
+ let requests = try String(contentsOfFile: requestsFile, encoding: .utf8)
+ #expect(requests.contains("first"))
+ #expect(requests.contains("third"))
+ #expect(!requests.contains("cancelled"))
+ #expect(await recorder.snapshot() == [coldTimeoutNanoseconds, warmTimeoutNanoseconds])
+ await connection.shutdown()
+ }
+
+ @Test("shutdown fails the active request and every client-side queued request")
+ func shutdownDrainsClientQueue() async throws {
+ let dir = NSTemporaryDirectory() + "serve-connection-shutdown-queue-test-" + UUID().uuidString
+ try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
+ defer { try? FileManager.default.removeItem(atPath: dir) }
+ let requestMarker = dir + "/request-read"
+ let recorder = TimeoutRecorder()
+ let connection = ServeConnection(
+ makeProcess: { _, qualityOfService in
+ let child = Process()
+ child.executableURL = URL(fileURLWithPath: "/bin/sh")
+ child.arguments = ["-c", "IFS= read -r line; : > \"$1\"; sleep 5", "serve-fixture", requestMarker]
+ child.qualityOfService = qualityOfService
+ return child
+ },
+ timeoutSleep: { nanoseconds in
+ try await recorder.recordAndWait(nanoseconds)
+ }
+ )
+
+ let active = Task { try await connection.request(args: ["status", "active"]) }
+ for _ in 0..<200 where !FileManager.default.fileExists(atPath: requestMarker) {
+ try await Task.sleep(nanoseconds: 10_000_000)
+ }
+ let queued = Task { try await connection.request(args: ["status", "queued"]) }
+ try await Task.sleep(nanoseconds: 100_000_000)
+ #expect(await recorder.snapshot() == [coldTimeoutNanoseconds])
+ await connection.shutdown()
+
+ for request in [active, queued] {
+ do {
+ _ = try await request.value
+ Issue.record("shutdown request unexpectedly succeeded")
+ } catch {
+ #expect(error is ServeConnection.ServeRequestFailed)
+ }
+ }
+ }
+
+ @Test("late stdout from a replaced child cannot corrupt or warm its replacement")
+ func staleGenerationStdoutIsDiscarded() async throws {
+ let oldChild = Process()
+ oldChild.executableURL = URL(fileURLWithPath: "/bin/sh")
+ oldChild.arguments = ["-c", "IFS= read -r line; sleep 0.1; exit 1"]
+
+ let newChild = Process()
+ newChild.executableURL = URL(fileURLWithPath: "/bin/sh")
+ newChild.arguments = ["-c", """
+ while IFS= read -r line; do
+ id=$(printf '%s' "$line" | sed -E 's/.*"id":([0-9]+).*/\\1/')
+ printf '{"id":%s,"ok":true,"output":"new-%s"}\n' "$id" "$id"
+ done
+ """]
+
+ let children = ProcessQueue([oldChild, newChild])
+ let recorder = TimeoutRecorder()
+ let connection = ServeConnection(
+ makeProcess: { _, qualityOfService in
+ children.take(qualityOfService: qualityOfService)
+ },
+ timeoutSleep: { nanoseconds in
+ try await recorder.recordAndWait(nanoseconds)
+ }
+ )
+
+ // The admitted read survives the old generation's crash and retries
+ // on the replacement. Request id 1 belonged to the old child; the
+ // replacement receives id 2.
+ let retried = try await connection.request(args: ["status", "--generation", "old"])
+ #expect(String(decoding: retried, as: UTF8.self) == "new-2")
+
+ // Model both harmful trailing shapes after the replacement owns the
+ // connection: a complete terminal would incorrectly select the warm
+ // timeout, while a fragment would corrupt the replacement's first line.
+ await connection.consume(
+ Data("{\"id\":1,\"ok\":true,\"output\":\"late-old\"}\n".utf8),
+ from: oldChild
+ )
+ await connection.consume(Data("{\"id\":1".utf8), from: oldChild)
+
+ let payload = try await connection.request(args: ["status", "--generation", "new"])
+
+ #expect(String(decoding: payload, as: UTF8.self) == "new-3")
+ #expect(await recorder.snapshot() == [
+ coldTimeoutNanoseconds,
+ coldTimeoutNanoseconds,
+ warmTimeoutNanoseconds,
+ ])
+ #expect(children.remainingCount == 0)
+ await connection.shutdown()
+ }
+
+ @Test("queued requests arm their warm timeout only after cold hydration finishes")
+ func coldAndWarmTimeoutSelection() async throws {
+ let dir = NSTemporaryDirectory() + "serve-connection-timeout-test-" + UUID().uuidString
+ try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
+ defer { try? FileManager.default.removeItem(atPath: dir) }
+ let releaseMarker = dir + "/release-cold-responses"
+ let recorder = TimeoutRecorder()
+
+ let connection = ServeConnection(
+ makeProcess: { _, qualityOfService in
+ let child = Process()
+ child.executableURL = URL(fileURLWithPath: "/bin/sh")
+ child.arguments = ["-c", """
+ IFS= read -r first
+ while [ ! -f "$1" ]; do sleep 0.01; done
+ for slot in first second third; do
+ if [ "$slot" = first ]; then line="$first"; else IFS= read -r line; fi
+ id=$(printf '%s' "$line" | sed -E 's/.*"id":([0-9]+).*/\\1/')
+ printf '{"id":%s,"ok":true,"output":"served"}\\n' "$id"
+ done
+ """, "serve-fixture", releaseMarker]
+ child.qualityOfService = qualityOfService
+ return child
+ },
+ timeoutSleep: { nanoseconds in
+ try await recorder.recordAndWait(nanoseconds)
+ }
+ )
+
+ let first = Task { try await connection.request(args: ["status", "--request", "one"]) }
+ let second = Task { try await connection.request(args: ["status", "--request", "two"]) }
+ try await Task.sleep(nanoseconds: 100_000_000)
+ #expect(await recorder.snapshot() == [coldTimeoutNanoseconds])
+
+ _ = FileManager.default.createFile(atPath: releaseMarker, contents: Data())
+ let firstPayload = try await first.value
+ let secondPayload = try await second.value
+ #expect(String(decoding: firstPayload, as: UTF8.self) == "served")
+ #expect(String(decoding: secondPayload, as: UTF8.self) == "served")
+
+ let thirdPayload = try await connection.request(args: ["status", "--request", "three"])
+ #expect(String(decoding: thirdPayload, as: UTF8.self) == "served")
+ let allSelections = await recorder.snapshot()
+ #expect(allSelections == [
+ coldTimeoutNanoseconds,
+ warmTimeoutNanoseconds,
+ warmTimeoutNanoseconds,
+ ])
+ await connection.shutdown()
+ }
+
+ @Test("a failed terminal response does not mark the resident child warm")
+ func failedTerminalResponseKeepsColdTimeout() async throws {
+ let recorder = TimeoutRecorder()
+ let connection = ServeConnection(
+ makeProcess: { _, qualityOfService in
+ let child = Process()
+ child.executableURL = URL(fileURLWithPath: "/bin/sh")
+ child.arguments = ["-c", """
+ count=0
+ while IFS= read -r line; do
+ count=$((count + 1))
+ id=$(printf '%s' "$line" | sed -E 's/.*"id":([0-9]+).*/\\1/')
+ if [ "$count" -eq 1 ]; then
+ printf '{"id":%s,"ok":false,"error":"cold failure"}\\n' "$id"
+ else
+ printf '{"id":%s,"ok":true,"output":"served-%s"}\\n' "$id" "$count"
+ fi
+ done
+ """]
+ child.qualityOfService = qualityOfService
+ return child
+ },
+ timeoutSleep: { nanoseconds in
+ try await recorder.recordAndWait(nanoseconds)
+ }
+ )
+
+ do {
+ _ = try await connection.request(args: ["status", "--request", "failed"])
+ #expect(Bool(false), "failed response unexpectedly succeeded")
+ } catch {
+ #expect(error is ServeConnection.ServeRequestFailed)
+ }
+
+ let second = try await connection.request(args: ["status", "--request", "cold-success"])
+ let third = try await connection.request(args: ["status", "--request", "warm-success"])
+
+ #expect(String(decoding: second, as: UTF8.self) == "served-2")
+ #expect(String(decoding: third, as: UTF8.self) == "served-3")
+ #expect(await recorder.snapshot() == [
+ coldTimeoutNanoseconds,
+ coldTimeoutNanoseconds,
+ warmTimeoutNanoseconds,
+ ])
+ await connection.shutdown()
+ }
+
+ @Test("an actual stdout flood is bounded and the next generation stays healthy")
+ func oversizedFrameTerminatesOnlyItsGeneration() async throws {
+ let oldChild = Process()
+ oldChild.executableURL = URL(fileURLWithPath: "/bin/sh")
+ oldChild.arguments = ["-c", """
+ IFS= read -r line
+ dd if=/dev/zero bs=1024 count=1 2>/dev/null | tr '\\0' x
+ sleep 5
+ """]
+
+ let replacement = Process()
+ replacement.executableURL = URL(fileURLWithPath: "/bin/sh")
+ replacement.arguments = ["-c", """
+ IFS= read -r line
+ id=$(printf '%s' "$line" | sed -E 's/.*"id":([0-9]+).*/\\1/')
+ printf '{"id":%s,"ok":true,"output":"replacement"}\\n' "$id"
+ """]
+
+ let children = ProcessQueue([oldChild, replacement])
+ let recorder = TimeoutRecorder()
+ let connection = ServeConnection(
+ makeProcess: { _, qualityOfService in
+ children.take(qualityOfService: qualityOfService)
+ },
+ timeoutSleep: { nanoseconds in
+ try await recorder.recordAndWait(nanoseconds)
+ },
+ responseLimitBytes: 128
+ )
+
+ do {
+ _ = try await connection.request(args: ["status", "--oversized"])
+ #expect(Bool(false), "oversized resident frame unexpectedly succeeded")
+ } catch let error as ServeConnection.ServeRequestFailed {
+ #expect(error.reason == .outputTooLarge)
+ }
+
+ await connection.ensureStarted()
+ let payload = try await connection.request(args: ["status", "--replacement"])
+ #expect(String(decoding: payload, as: UTF8.self) == "replacement")
+ #expect(children.remainingCount == 0)
+ await connection.shutdown()
+ }
+
+ @Test("an unterminated frame and cumulative progress cannot bypass the resident limit")
+ func partialAndCumulativeFramesAreBounded() async throws {
+ for mode in ["partial", "progress"] {
+ let child = Process()
+ child.executableURL = URL(fileURLWithPath: "/bin/sh")
+ child.arguments = ["-c", "IFS= read -r line; sleep 5"]
+ let recorder = TimeoutRecorder()
+ let connection = ServeConnection(
+ makeProcess: { _, qualityOfService in
+ child.qualityOfService = qualityOfService
+ return child
+ },
+ timeoutSleep: { nanoseconds in
+ try await recorder.recordAndWait(nanoseconds)
+ },
+ responseLimitBytes: 128
+ )
+ let request = Task { try await connection.request(args: ["status", "--mode", mode]) }
+ for _ in 0..<200 {
+ if await recorder.snapshot().count == 1 { break }
+ try await Task.sleep(nanoseconds: 10_000_000)
+ }
+
+ if mode == "partial" {
+ await connection.consume(Data(repeating: UInt8(ascii: "x"), count: 129), from: child)
+ } else {
+ let progress = String(repeating: "p", count: 70)
+ let frame = Data("{\"id\":1,\"progress\":\"\(progress)\"}\n".utf8)
+ #expect(frame.count < 128)
+ await connection.consume(frame, from: child)
+ await connection.consume(frame, from: child)
+ }
+
+ do {
+ _ = try await request.value
+ #expect(Bool(false), "\(mode) overflow unexpectedly succeeded")
+ } catch let error as ServeConnection.ServeRequestFailed {
+ #expect(error.reason == .outputTooLarge)
+ }
+ await connection.shutdown()
+ }
+ }
+
+ @Test("a cancelled request keeps its cumulative progress bound until the child finishes")
+ func cancelledRequestStillBoundsOrphanProgress() async throws {
+ let oldChild = Process()
+ oldChild.executableURL = URL(fileURLWithPath: "/bin/sh")
+ oldChild.arguments = ["-c", "IFS= read -r line; sleep 5"]
+
+ let replacement = Process()
+ replacement.executableURL = URL(fileURLWithPath: "/bin/sh")
+ replacement.arguments = ["-c", """
+ IFS= read -r line
+ id=$(printf '%s' "$line" | sed -E 's/.*"id":([0-9]+).*/\\1/')
+ printf '{"id":%s,"ok":true,"output":"healthy"}\\n' "$id"
+ """]
+
+ let children = ProcessQueue([oldChild, replacement])
+ let recorder = TimeoutRecorder()
+ let connection = ServeConnection(
+ makeProcess: { _, qualityOfService in
+ children.take(qualityOfService: qualityOfService)
+ },
+ timeoutSleep: { nanoseconds in
+ try await recorder.recordAndWait(nanoseconds)
+ },
+ responseLimitBytes: 128
+ )
+
+ let abandoned = Task { try await connection.request(args: ["status", "--abandoned"]) }
+ for _ in 0..<200 {
+ if await recorder.snapshot().count == 1 { break }
+ try await Task.sleep(nanoseconds: 10_000_000)
+ }
+ abandoned.cancel()
+ do {
+ _ = try await abandoned.value
+ Issue.record("cancelled request unexpectedly succeeded")
+ } catch {
+ #expect(error is CancellationError)
+ }
+
+ let progress = String(repeating: "p", count: 70)
+ let frame = Data("{\"id\":1,\"progress\":\"\(progress)\"}\n".utf8)
+ await connection.consume(frame, from: oldChild)
+ await connection.consume(frame, from: oldChild)
+
+ await connection.ensureStarted()
+ #expect(children.remainingCount == 0)
+ let payload = try await connection.request(args: ["status", "--replacement"])
+ #expect(String(decoding: payload, as: UTF8.self) == "healthy")
+ await connection.shutdown()
+ }
+
+ @Test("each overflow consumes exactly one resident death")
+ func overflowDeathBudgetIsExact() async throws {
+ let processes = (0..<3).map { _ in
+ let child = Process()
+ child.executableURL = URL(fileURLWithPath: "/bin/sh")
+ child.arguments = ["-c", "IFS= read -r line; sleep 5"]
+ return child
+ }
+ let children = ProcessQueue(processes)
+ let recorder = TimeoutRecorder()
+ let connection = ServeConnection(
+ makeProcess: { _, qualityOfService in
+ children.take(qualityOfService: qualityOfService)
+ },
+ timeoutSleep: { nanoseconds in
+ try await recorder.recordAndWait(nanoseconds)
+ },
+ responseLimitBytes: 64
+ )
+
+ for attempt in 0..<3 {
+ let request = Task { try await connection.request(args: ["status", "--attempt", "\(attempt)"]) }
+ for _ in 0..<200 {
+ if await recorder.snapshot().count == attempt + 1 { break }
+ try await Task.sleep(nanoseconds: 10_000_000)
+ }
+ await connection.consume(Data(repeating: UInt8(ascii: "x"), count: 65), from: processes[attempt])
+ do {
+ _ = try await request.value
+ Issue.record("overflow \(attempt) unexpectedly succeeded")
+ } catch let error as ServeConnection.ServeRequestFailed {
+ #expect(error.reason == .outputTooLarge)
+ }
+ }
+
+ #expect(children.remainingCount == 0)
+ do {
+ _ = try await connection.request(args: ["status", "--after-budget"])
+ Issue.record("resident restarted after exhausting its death budget")
+ } catch {
+ #expect(error is ServeConnection.ServeUnavailable)
+ }
+ await connection.shutdown()
+ }
+
+ @Test("output overflow is not eligible for a one-shot fallback")
+ func outputOverflowIsTerminalForDataClient() async {
+ let overflow = ServeConnection.ServeRequestFailed(
+ message: "too large",
+ reason: .outputTooLarge
+ )
+ let fallback = FallbackRecorder()
+ do {
+ _ = try await DataClient.runCLI(
+ subcommand: ["status", "--format", "menubar-json"],
+ serveRequest: { _ in throw overflow },
+ spawnFallback: {
+ await fallback.record()
+ return DataClient.ProcessResult(stdout: Data(), stderr: "", exitCode: 0)
+ }
+ )
+ Issue.record("output overflow unexpectedly fell back or succeeded")
+ } catch DataClientError.outputTooLarge {
+ // Expected: the one-shot closure must remain untouched.
+ } catch {
+ Issue.record("unexpected terminal error: \(error)")
+ }
+ #expect(await fallback.snapshot() == 0)
+
+ let ordinary = ServeConnection.ServeRequestFailed(message: "serve exited")
+ do {
+ let result = try await DataClient.runCLI(
+ subcommand: ["status", "--format", "menubar-json"],
+ serveRequest: { _ in throw ordinary },
+ spawnFallback: {
+ await fallback.record()
+ return DataClient.ProcessResult(stdout: Data("fallback".utf8), stderr: "", exitCode: 0)
+ }
+ )
+ #expect(String(decoding: result.stdout, as: UTF8.self) == "fallback")
+ } catch {
+ Issue.record("ordinary serve failure did not use fallback: \(error)")
+ }
+ #expect(await fallback.snapshot() == 1)
+ }
+
+ @Test("the first real request is the only cold-start query")
+ func firstRequestIsTheWarmup() async throws {
+ let dir = NSTemporaryDirectory() + "serve-connection-test-" + UUID().uuidString
+ try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
+ defer { try? FileManager.default.removeItem(atPath: dir) }
+ let requestLog = dir + "/requests.log"
+
+ let connection = ServeConnection { _, qualityOfService in
+ let child = Process()
+ child.executableURL = URL(fileURLWithPath: "/bin/sh")
+ child.arguments = ["-c", """
+ while IFS= read -r line; do
+ printf 'request\\n' >> "$1"
+ id=$(printf '%s' "$line" | sed -E 's/.*\"id\":([0-9]+).*/\\1/')
+ printf '{\"id\":%s,\"progress\":\"scanning\"}\\n' "$id"
+ printf '{\"id\":%s,\"ok\":true,\"output\":\"served\"}\\n' "$id"
+ # Emit READY after the terminal response. The client must
+ # register and complete the first real request without it.
+ printf '{\"ready\":true,\"pid\":1}\\n'
+ done
+ """, "serve-fixture", requestLog]
+ child.qualityOfService = qualityOfService
+ return child
+ }
+
+ await connection.ensureStarted()
+ let payload = try await connection.request(args: ["status", "--format", "menubar-json"])
+
+ #expect(String(decoding: payload, as: UTF8.self) == "served")
+ let requests = try String(contentsOfFile: requestLog, encoding: .utf8)
+ .split(separator: "\n")
+ #expect(requests.count == 1)
+ await connection.shutdown()
+ }
+
+ @Test("split terminal bytes are drained before child death and the next generation stays clean")
+ func finalStdoutDrainPrecedesTermination() async throws {
+ let first = Process()
+ first.executableURL = URL(fileURLWithPath: "/bin/sh")
+ first.arguments = ["-c", """
+ IFS= read -r line
+ id=$(printf '%s' "$line" | sed -E 's/.*"id":([0-9]+).*/\\1/')
+ printf '{"id":%s,"ok":true,' "$id"
+ printf '"output":"final-drain"}\n'
+ """]
+
+ let replacement = Process()
+ replacement.executableURL = URL(fileURLWithPath: "/bin/sh")
+ replacement.arguments = ["-c", """
+ while IFS= read -r line; do
+ id=$(printf '%s' "$line" | sed -E 's/.*"id":([0-9]+).*/\\1/')
+ printf '{"id":%s,"ok":true,"output":"replacement"}\n' "$id"
+ done
+ """]
+
+ let children = ProcessQueue([first, replacement])
+ let connection = ServeConnection { _, qualityOfService in
+ children.take(qualityOfService: qualityOfService)
+ }
+
+ let drained = try await connection.request(args: ["status", "drain"])
+ #expect(String(decoding: drained, as: UTF8.self) == "final-drain")
+ let next = try await connection.request(args: ["status", "next"])
+ #expect(String(decoding: next, as: UTF8.self) == "replacement")
+ #expect(children.remainingCount == 0)
+ await connection.shutdown()
+ }
+
+ @Test("a child that closes stdin fails the request without terminating the app")
+ func closedChildStdinDoesNotRaiseSIGPIPE() async throws {
+ let dir = NSTemporaryDirectory() + "serve-connection-sigpipe-test-" + UUID().uuidString
+ try FileManager.default.createDirectory(atPath: dir, withIntermediateDirectories: true)
+ defer { try? FileManager.default.removeItem(atPath: dir) }
+ let closedMarker = dir + "/stdin-closed"
+ let sigpipeHandlerBefore = currentSIGPIPEHandlerBits()
+
+ let connection = ServeConnection { _, qualityOfService in
+ let child = Process()
+ child.executableURL = URL(fileURLWithPath: "/bin/sh")
+ child.arguments = ["-c", "exec 0<&-; : > \"$1\"; sleep 2", "serve-fixture", closedMarker]
+ child.qualityOfService = qualityOfService
+ return child
+ }
+
+ await connection.ensureStarted()
+ #expect(currentSIGPIPEHandlerBits() == sigpipeHandlerBefore)
+ #expect(currentSIGPIPEHandlerBits() != ignoredSIGPIPEHandlerBits)
+ for _ in 0..<200 where !FileManager.default.fileExists(atPath: closedMarker) {
+ try await Task.sleep(nanoseconds: 10_000_000)
+ }
+ #expect(FileManager.default.fileExists(atPath: closedMarker))
+
+ var requestFailed = false
+ do {
+ _ = try await connection.request(args: ["status", "--format", "menubar-json"])
+ } catch {
+ requestFailed = true
+ }
+ #expect(requestFailed)
+ await connection.shutdown()
+ }
+}
diff --git a/package.json b/package.json
index 0137c62b..c1288196 100644
--- a/package.json
+++ b/package.json
@@ -8,7 +8,9 @@
"codeburn": "dist/cli.js"
},
"files": [
- "dist"
+ "dist",
+ "THIRD_PARTY_NOTICES.md",
+ "!dist/parse-worker.js.map"
],
"scripts": {
"bundle-litellm": "node scripts/bundle-litellm.mjs",
@@ -32,6 +34,7 @@
"pi",
"codebuff",
"codewhale",
+ "dsh",
"ai-coding",
"token-usage",
"cost-tracking",
diff --git a/src/act/optimize-apply.ts b/src/act/optimize-apply.ts
index 5b90685a..235645a2 100644
--- a/src/act/optimize-apply.ts
+++ b/src/act/optimize-apply.ts
@@ -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()
}
diff --git a/src/act/plans.ts b/src/act/plans.ts
index b3ee4e39..9c02a1dd 100644
--- a/src/act/plans.ts
+++ b/src/act/plans.ts
@@ -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): (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 = {}
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')
diff --git a/src/act/report.ts b/src/act/report.ts
index 30c88120..ed921f83 100644
--- a/src/act/report.ts
+++ b/src/act/report.ts
@@ -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> = {
// '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
+ // 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 }> {
+ const lines: string[] = []
+ const revertedIds = new Set()
+ 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 {
const now = opts.now ?? new Date()
const rawRecords = await readRecords(opts.actionsDir ?? defaultActionsDir())
@@ -497,6 +559,7 @@ export async function computeActReport(opts: ActReportOptions = {}): Promise ageDays(r.at, now) > REPORT_MIN_AGE_DAYS)
@@ -550,6 +613,7 @@ export async function computeActReport(opts: ActReportOptions = {}): Promise 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 = {}
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
}
}
diff --git a/src/act/types.ts b/src/act/types.ts
index 4142abe4..6ca0c1c4 100644
--- a/src/act/types.ts
+++ b/src/act/types.ts
@@ -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 = {
+ 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}`
}
diff --git a/src/antigravity-statusline.ts b/src/antigravity-statusline.ts
index 15f49093..27b24067 100644
--- a/src/antigravity-statusline.ts
+++ b/src/antigravity-statusline.ts
@@ -3,6 +3,7 @@ import { randomBytes } from 'crypto'
import { dirname, join } from 'path'
import { homedir } from 'os'
+import { getCodeburnCacheDir } from './cache-dir.js'
import {
recordAntigravityStatusLinePayload,
snapshotAntigravityStatusLinePayload,
@@ -54,12 +55,8 @@ function settingsPath(): string {
?? join(homedir(), '.gemini', 'antigravity-cli', 'settings.json')
}
-function codeburnCacheDir(): string {
- return process.env['CODEBURN_CACHE_DIR'] ?? join(homedir(), '.cache', 'codeburn')
-}
-
function previousStatusLinePath(): string {
- return join(codeburnCacheDir(), 'antigravity-statusline-previous.json')
+ return join(getCodeburnCacheDir(), 'antigravity-statusline-previous.json')
}
async function readSettings(): Promise {
diff --git a/src/bash-utils.ts b/src/bash-utils.ts
index b6388a95..55fc50aa 100644
--- a/src/bash-utils.ts
+++ b/src/bash-utils.ts
@@ -1,6 +1,8 @@
import { basename } from 'path'
import stripAnsi from 'strip-ansi'
+const WHITESPACE = /\s/
+
function stripQuotedStrings(command: string): string {
return command.replace(/"[^"]*"|'[^']*'/g, match => ' '.repeat(match.length))
}
@@ -19,12 +21,22 @@ export function extractBashCommands(rawCommand: string): string[] {
const command = stripAnsi(rawCommand)
const stripped = stripQuotedStrings(command)
- const separatorRegex = /\s*(?:&&|;|\|)\s*/g
+ // Match the separator alone, then widen over surrounding whitespace by hand.
+ // /\s*(?:&&|;|\|)\s*/ retried its leading \s* from every offset, quadratic on
+ // long whitespace-heavy commands. Widening is required (not cosmetic): stripQuotedStrings
+ // blanks quoted text, and segments are sliced from the original string.
+ const separatorRegex = /(?:&&|;|\|)/g
const separators: Array<{ start: number; end: number }> = []
let match: RegExpExecArray | null
while ((match = separatorRegex.exec(stripped)) !== null) {
- separators.push({ start: match.index, end: match.index + match[0].length })
+ let start = match.index
+ while (start > 0 && WHITESPACE.test(stripped[start - 1]!)) start--
+ let end = match.index + match[0].length
+ while (end < stripped.length && WHITESPACE.test(stripped[end]!)) end++
+ const prevEnd = separators[separators.length - 1]?.end ?? 0
+ separators.push({ start: Math.max(start, prevEnd), end })
+ separatorRegex.lastIndex = end
}
const ranges: Array<[number, number]> = []
@@ -93,7 +105,7 @@ const GIT_READ_SUBCOMMANDS = new Set([
export function isReadShapedBashCommand(rawCommand: string): boolean {
if (!rawCommand || !rawCommand.trim()) return false
const stripped = stripQuotedStrings(stripAnsi(rawCommand))
- const segments = stripped.split(/\s*(?:&&|;|\|)\s*/)
+ const segments = stripped.split(/(?:&&|;|\|)/)
let sawCommand = false
for (const segment of segments) {
const trimmed = segment.trim()
diff --git a/src/cache-dir.ts b/src/cache-dir.ts
new file mode 100644
index 00000000..a202be05
--- /dev/null
+++ b/src/cache-dir.ts
@@ -0,0 +1,13 @@
+import { homedir } from 'os'
+import { join } from 'path'
+
+/**
+ * Resolve CodeBurn's shared cache directory at call time.
+ *
+ * Reading the environment on every call matters for embedded consumers and
+ * tests that change CODEBURN_CACHE_DIR after importing the CLI modules.
+ */
+export function getCodeburnCacheDir(): string {
+ const override = process.env['CODEBURN_CACHE_DIR']
+ return override?.trim() ? override : join(homedir(), '.cache', 'codeburn')
+}
diff --git a/src/cache-refresh-lock.ts b/src/cache-refresh-lock.ts
index 58faf281..f467886f 100644
--- a/src/cache-refresh-lock.ts
+++ b/src/cache-refresh-lock.ts
@@ -1,9 +1,10 @@
import { createHash, randomBytes } from 'crypto'
import { existsSync } from 'fs'
import { mkdir, open, readFile, stat, unlink, utimes, writeFile } from 'fs/promises'
-import { homedir } from 'os'
import { join } from 'path'
+import { getCodeburnCacheDir } from './cache-dir.js'
+
const LOCK_FILE = 'session-refresh.lock'
const TAKEOVER_FILE = `${LOCK_FILE}.takeover`
const DEFAULT_HEARTBEAT_MS = 10_000
@@ -46,10 +47,6 @@ const defaultClock: RefreshLockClock = {
wallNow: () => Date.now(),
}
-function defaultCacheDir(): string {
- return process.env['CODEBURN_CACHE_DIR'] ?? join(homedir(), '.cache', 'codeburn')
-}
-
function delay(ms: number): Promise {
return new Promise(resolve => { setTimeout(resolve, ms) })
}
@@ -197,7 +194,7 @@ export async function acquireCacheRefreshLock(options: RefreshLockOptions = {}):
leaveSingleFlight()
}
- const cacheDir = options.cacheDir ?? defaultCacheDir()
+ const cacheDir = options.cacheDir ?? getCodeburnCacheDir()
const clock = options.clock ?? defaultClock
const heartbeatMs = options.heartbeatMs ?? DEFAULT_HEARTBEAT_MS
const staleMs = options.staleMs ?? DEFAULT_STALE_MS
diff --git a/src/codex-cache.ts b/src/codex-cache.ts
index 6146e8e9..ca3f306a 100644
--- a/src/codex-cache.ts
+++ b/src/codex-cache.ts
@@ -1,9 +1,10 @@
import { readFile, mkdir, stat, open, rename, unlink } from 'fs/promises'
import { existsSync } from 'fs'
import { randomBytes } from 'crypto'
-import { join } from 'path'
-import { homedir } from 'os'
+import { join, resolve } from 'path'
+import { AsyncLocalStorage } from 'node:async_hooks'
+import { getCodeburnCacheDir } from './cache-dir.js'
import type { ParsedProviderCall } from './providers/types.js'
// v4: attribute MCP calls emitted as event_msg/mcp_tool_call_end (issue #478).
@@ -14,45 +15,85 @@ import type { ParsedProviderCall } from './providers/types.js'
// v6/v7: rich-session-capture — per-call locAdded/locRemoved/editFailed from
// patch_apply_end. Sessions cached under v5 lack these fields; re-parse to add.
// v8: persist native MCP timing and compact invocation attribution.
+// Deliberately NOT bumped for the resume fields (dev/ino + resumeOffset/
+// resumeState): they are additive and absence-safe in both directions, so a
+// bump would only throw away a warm multi-hundred-MB cache to gain nothing. An
+// entry without them simply re-parses in full once and gains them.
const CODEX_CACHE_VERSION = 8
const CACHE_FILE = 'codex-results.json'
-type FileFingerprint = { mtimeMs: number; sizeBytes: number }
+export type CodexFileFingerprint = { dev: number; ino: number; mtimeMs: number; sizeBytes: number }
+type FileFingerprint = CodexFileFingerprint
type FileEntry = {
+ // Absent on entries written before the resume support landed.
+ dev?: number
+ ino?: number
mtimeMs: number
sizeBytes: number
project: string
calls: ParsedProviderCall[]
+ /** Byte offset of a complete-line boundary the parser can restart from. */
+ resumeOffset?: number
+ /** Opaque parser state captured at `resumeOffset` (shape owned by the Codex parser). */
+ resumeState?: unknown
+ /** How many of `calls` were decoded before `resumeOffset`. */
+ resumeCallCount?: number
}
+/** An exact fingerprint match, or an append the parser can resume into. */
+export type CodexCacheHit =
+ | { kind: 'exact'; calls: ParsedProviderCall[] }
+ | { kind: 'resume'; calls: ParsedProviderCall[]; offset: number; state: unknown; callCount: number }
+
type ResultCache = {
version: number
files: Record
}
-function getCacheDir(): string {
- return process.env['CODEBURN_CACHE_DIR'] ?? join(homedir(), '.cache', 'codeburn')
+const cacheDirContext = new AsyncLocalStorage()
+
+function currentCacheDir(): string {
+ return cacheDirContext.getStore() ?? resolve(getCodeburnCacheDir())
}
-function getCachePath(): string {
- return join(getCacheDir(), CACHE_FILE)
+// A parse can cross many async boundaries before the Codex provider publishes
+// its incremental cache. Embedded hosts are allowed to change the process env
+// between calls, so pin the call-time directory for the whole transaction
+// instead of re-reading CODEBURN_CACHE_DIR at each cache operation.
+export function withCodexCacheDirectory(cacheDir: string, operation: () => T): T {
+ return cacheDirContext.run(resolve(cacheDir), operation)
}
-let memCache: ResultCache | null = null
+function getCachePath(cacheDir: string): string {
+ return join(cacheDir, CACHE_FILE)
+}
-async function loadCache(): Promise {
- if (memCache) return memCache
+// Embedded consumers can change CODEBURN_CACHE_DIR without reloading this
+// module. Keep each directory's in-memory state separate so a warm cache (or an
+// unflushed update) from A can never be read from or written into B.
+const memCaches = new Map()
+
+// Dropped by the resident RSS guard. Every write is published by
+// flushCodexCache() in the parse's finally, so the next load re-reads disk.
+export function clearCodexMemCaches(): void {
+ memCaches.clear()
+}
+
+async function loadCache(cacheDir: string): Promise {
+ const inMemory = memCaches.get(cacheDir)
+ if (inMemory) return inMemory
try {
- const raw = await readFile(getCachePath(), 'utf-8')
+ const raw = await readFile(getCachePath(cacheDir), 'utf-8')
const cache = JSON.parse(raw) as ResultCache
if (cache.version === CODEX_CACHE_VERSION && cache.files && typeof cache.files === 'object') {
- memCache = cache
+ memCaches.set(cacheDir, cache)
return cache
}
} catch {}
- memCache = { version: CODEX_CACHE_VERSION, files: {} }
- return memCache
+ const empty = { version: CODEX_CACHE_VERSION, files: {} }
+ memCaches.set(cacheDir, empty)
+ return empty
}
function getEntry(cache: ResultCache, filePath: string, fp: FileFingerprint): FileEntry | null {
@@ -64,14 +105,51 @@ function getEntry(cache: ResultCache, filePath: string, fp: FileFingerprint): Fi
return null
}
+// A grown file is only assumed to be an APPEND if the recorded boundary still
+// falls right after a newline. A same-inode rewrite (truncate + refill, or an
+// in-place edit) that happens to end up larger would otherwise resume into the
+// middle of an unrelated line. Reading one byte is cheaper than being wrong.
+async function endsLineAt(filePath: string, offset: number): Promise {
+ if (offset === 0) return true
+ try {
+ const handle = await open(filePath, 'r')
+ try {
+ const buf = Buffer.alloc(1)
+ const { bytesRead } = await handle.read(buf, 0, 1, offset - 1)
+ return bytesRead === 1 && buf[0] === 0x0a
+ } finally {
+ await handle.close()
+ }
+ } catch {
+ return false
+ }
+}
+
export async function readCachedCodexResults(
filePath: string,
-): Promise {
+): Promise {
try {
const s = await stat(filePath)
- const cache = await loadCache()
- const entry = getEntry(cache, filePath, { mtimeMs: s.mtimeMs, sizeBytes: s.size })
- return entry?.calls ?? null
+ const cache = await loadCache(currentCacheDir())
+ const fp = { dev: s.dev, ino: s.ino, mtimeMs: s.mtimeMs, sizeBytes: s.size }
+ const entry = getEntry(cache, filePath, fp)
+ if (entry) return { kind: 'exact', calls: entry.calls }
+ // Rollouts are append-only: the same inode, grown past a boundary we
+ // recorded, can be picked up from that boundary instead of re-read whole.
+ const stale = cache.files[filePath]
+ if (
+ stale
+ && stale.dev === fp.dev
+ && stale.ino === fp.ino
+ && stale.resumeOffset !== undefined
+ && stale.resumeState !== undefined
+ && stale.resumeCallCount !== undefined
+ && fp.sizeBytes > stale.sizeBytes
+ && stale.resumeOffset <= fp.sizeBytes
+ && await endsLineAt(filePath, stale.resumeOffset)
+ ) {
+ return { kind: 'resume', calls: stale.calls, offset: stale.resumeOffset, state: stale.resumeState, callCount: stale.resumeCallCount }
+ }
} catch {}
return null
}
@@ -81,8 +159,8 @@ export async function getCachedCodexProject(
): Promise {
try {
const s = await stat(filePath)
- const cache = await loadCache()
- const entry = getEntry(cache, filePath, { mtimeMs: s.mtimeMs, sizeBytes: s.size })
+ const cache = await loadCache(currentCacheDir())
+ const entry = getEntry(cache, filePath, { dev: s.dev, ino: s.ino, mtimeMs: s.mtimeMs, sizeBytes: s.size })
return entry?.project ?? null
} catch {}
return null
@@ -93,7 +171,7 @@ export async function fingerprintFile(
): Promise {
try {
const s = await stat(filePath)
- return { mtimeMs: s.mtimeMs, sizeBytes: s.size }
+ return { dev: s.dev, ino: s.ino, mtimeMs: s.mtimeMs, sizeBytes: s.size }
} catch {
return null
}
@@ -104,19 +182,25 @@ export async function writeCachedCodexResults(
project: string,
calls: ParsedProviderCall[],
fingerprint: FileFingerprint,
+ resume?: { offset: number; state: unknown; callCount: number },
): Promise {
try {
- const cache = await loadCache()
+ const cache = await loadCache(currentCacheDir())
cache.files[filePath] = {
+ dev: fingerprint.dev,
+ ino: fingerprint.ino,
mtimeMs: fingerprint.mtimeMs,
sizeBytes: fingerprint.sizeBytes,
project,
calls,
+ ...(resume ? { resumeOffset: resume.offset, resumeState: resume.state, resumeCallCount: resume.callCount } : {}),
}
} catch {}
}
export async function flushCodexCache(): Promise {
+ const cacheDir = currentCacheDir()
+ const memCache = memCaches.get(cacheDir)
if (!memCache) return
try {
// Evict entries for files that no longer exist on disk
@@ -129,9 +213,8 @@ export async function flushCodexCache(): Promise {
}
}
- const dir = getCacheDir()
- if (!existsSync(dir)) await mkdir(dir, { recursive: true })
- const finalPath = getCachePath()
+ if (!existsSync(cacheDir)) await mkdir(cacheDir, { recursive: true })
+ const finalPath = getCachePath(cacheDir)
const tempPath = `${finalPath}.${randomBytes(8).toString('hex')}.tmp`
const payload = JSON.stringify(memCache)
const handle = await open(tempPath, 'w', 0o600)
diff --git a/src/content-utils.ts b/src/content-utils.ts
index 5ca31ae9..0c41afb6 100644
--- a/src/content-utils.ts
+++ b/src/content-utils.ts
@@ -24,3 +24,35 @@ export function normalizeContentBlocks {
@@ -111,7 +104,7 @@ async function loadCachedRate(code: string): Promise {
}
async function cacheRate(code: string, rate: number): Promise {
- await mkdir(getCacheDir(), { recursive: true })
+ await mkdir(getCodeburnCacheDir(), { recursive: true })
await writeFile(getRateCachePath(), JSON.stringify({ timestamp: Date.now(), code, rate }))
}
@@ -138,7 +131,13 @@ async function getExchangeRate(code: string): Promise {
export async function loadCurrency(): Promise {
const config = await readConfig()
- if (!config.currency) return
+ if (!config.currency) {
+ // A long-lived `serve` process may previously have loaded a non-USD
+ // currency. Removing the config entry is the USD reset contract, so reset
+ // the module state as well as letting the output memo invalidate.
+ active = USD
+ return
+ }
const code = config.currency.code.toUpperCase()
const rate = await getExchangeRate(code)
diff --git a/src/cursor-cache.ts b/src/cursor-cache.ts
index 28a7820f..d48ca33c 100644
--- a/src/cursor-cache.ts
+++ b/src/cursor-cache.ts
@@ -1,8 +1,8 @@
import { readFile, writeFile, mkdir, rename, stat, unlink } from 'fs/promises'
import { join } from 'path'
-import { homedir } from 'os'
import { randomBytes } from 'crypto'
+import { getCodeburnCacheDir } from './cache-dir.js'
import type { ParsedProviderCall } from './providers/types.js'
// Bumped to 3 for the workspace-aware breakdown change: the cursor parser
@@ -31,12 +31,8 @@ type ResultCache = {
const CACHE_FILE = 'cursor-results.json'
-function getCacheDir(): string {
- return join(homedir(), '.cache', 'codeburn')
-}
-
function getCachePath(): string {
- return join(getCacheDir(), CACHE_FILE)
+ return join(getCodeburnCacheDir(), CACHE_FILE)
}
async function getDbFingerprint(dbPath: string): Promise<{ mtimeMs: number; size: number } | null> {
@@ -86,7 +82,7 @@ export async function writeCachedResults(
const fp = await getDbFingerprint(dbPath)
if (!fp) return
- const dir = getCacheDir()
+ const dir = getCodeburnCacheDir()
await mkdir(dir, { recursive: true }).catch(() => {})
const cache: ResultCache = {
version: CURSOR_CACHE_VERSION,
diff --git a/src/daily-cache.ts b/src/daily-cache.ts
index 7abb445c..76e787f0 100644
--- a/src/daily-cache.ts
+++ b/src/daily-cache.ts
@@ -1,8 +1,9 @@
import { randomBytes } from 'crypto'
import { existsSync } from 'fs'
import { mkdir, open, readdir, readFile, rename, stat, unlink } from 'fs/promises'
-import { homedir } from 'os'
import { join } from 'path'
+
+import { getCodeburnCacheDir } from './cache-dir.js'
import type { DateRange, ProjectSummary } from './types.js'
// Bumped to 17: copilot CLI sessions were misclassified as VS Code transcripts
@@ -176,10 +177,6 @@ export type DailyCache = {
watermarkTrusted?: boolean
}
-function getCacheDir(): string {
- return process.env['CODEBURN_CACHE_DIR'] ?? join(homedir(), '.cache', 'codeburn')
-}
-
/** IANA name of the current local timezone (respects the TZ env var). Days are
* bucketed by local midnight, so this tags the cache for TZ-change invalidation. */
export function currentTzKey(): string {
@@ -187,7 +184,7 @@ export function currentTzKey(): string {
}
function getCachePath(): string {
- return join(getCacheDir(), DAILY_CACHE_FILENAME)
+ return join(getCodeburnCacheDir(), DAILY_CACHE_FILENAME)
}
/** Absolute path of the active (version-suffixed) daily cache file. */
@@ -379,7 +376,7 @@ function isAdoptableCache(parsed: unknown): parsed is AdoptableCache {
/// bump lossless: the new version starts from the union of everything every
/// previous version ever recorded, then re-derives what sources still support.
async function adoptOlderDailyCaches(): Promise {
- const dir = getCacheDir()
+ const dir = getCodeburnCacheDir()
let names: string[] = []
try {
names = await readdir(dir)
@@ -449,7 +446,7 @@ async function adoptOlderDailyCaches(): Promise {
}
export async function saveDailyCache(cache: DailyCache): Promise {
- const dir = getCacheDir()
+ const dir = getCodeburnCacheDir()
if (!existsSync(dir)) await mkdir(dir, { recursive: true })
const finalPath = getCachePath()
const tempPath = `${finalPath}.${randomBytes(8).toString('hex')}.tmp`
diff --git a/src/dashboard.tsx b/src/dashboard.tsx
index b66b41cf..90a21988 100644
--- a/src/dashboard.tsx
+++ b/src/dashboard.tsx
@@ -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'
@@ -1046,6 +1047,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 +1082,7 @@ function FindingPanel({ index, finding, costRate, width }: { index: number; find
{trendBadge && {trendBadge}}
{finding.explanation}
- Savings: ~{formatTokens(finding.tokensSaved)} tokens (~{formatCost(costSaved)})
+ Savings: ~{formatTokens(finding.tokensSaved)} tokens (~{formatCost(costSaved)}) {findingBasis(finding)}
@@ -1094,7 +1097,14 @@ const GRADE_COLORS: Record = { 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 = {
+ 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 +1115,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 (
@@ -1119,8 +1130,28 @@ function OptimizeView({ findings, costRate, projects, label, width, healthScore,
Showing {start + 1}–{end} of {total} · j/k to scroll
)}
- {visible.map((f, i) => )}
- Token estimates are approximate.
+ {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 (
+
+ {cls !== previous && {classHeaderLine(cls, totals[cls], costRate)}}
+
+
+ )
+ })}
+ {appliedFixes.length > 0 && (
+
+ Applied fixes
+ {appliedFixes.map(fix => (
+
+ {appliedFixGlyph(fix)} {formatAppliedFix(fix)}
+
+ ))}
+
+ )}
)
}
@@ -1302,6 +1333,7 @@ export function InteractiveDashboard({ initialProjects, initialDailyHistoryProje
const [detectedProviders, setDetectedProviders] = useState([])
const [view, setView] = useState('dashboard')
const [optimizeResult, setOptimizeResult] = useState(null)
+ const [appliedFixes, setAppliedFixes] = useState([])
const [optimizeLoading, setOptimizeLoading] = useState(false)
const [projectBudgets, setProjectBudgets] = useState