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 @@ Sponsor

-

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.

@@ -52,20 +52,21 @@ npx codeburn
- macOS Menubar
+ Menubar
CodeBurn macOS menubar
- codeburn menubar + codeburn menubar
+ Download the CodeBurn Windows menubar

Four surfaces, one source of truth: everything reads the session files already on your disk.

-**CodeBurn is a free, open-source, local-first tool that tracks AI coding token usage and cost across 40 tools and agents (Claude Code, Cursor, Codex, Gemini, Grok and more), broken down by model, project, and task.** +**CodeBurn is a free, open-source, local-first tool that tracks AI coding token usage and cost across 41 tools and agents (Claude Code, Cursor, Codex, Gemini, Grok and more), broken down by model, project, and task.** You pay for Claude, Codex, Cursor, and a stack of other AI tools. The bill tells you the total. It never tells you that half of it went to conversation instead of code, or that an expensive model burned your budget on work a cheaper one would have one-shot. -CodeBurn does. It reads the session files your tools already write to disk and breaks down every token and dollar by **task, model, tool, and project**, across **40 AI tools**. +CodeBurn does. It reads the session files your tools already write to disk and breaks down every token and dollar by **task, model, tool, and project**, across **41 AI tools**. Everything runs locally. No wrapper, no proxy, no API keys, nothing leaves your machine. Pricing comes from [LiteLLM](https://github.com/BerriAI/litellm), refreshed daily. @@ -107,7 +108,7 @@ Also runs via `bunx codeburn` or `pnpm dlx codeburn`, or `brew install codeburn` codeburn menubar ``` -On Linux, a GNOME Shell extension gives the same panel view; see [Linux (GNOME)](#linux-gnome). +The same command installs the tray app on Windows; see [Windows](#windows). On Linux, a GNOME Shell extension gives it in the top panel; see [Linux (GNOME)](#linux-gnome). Requires **Node.js 22.13+** and at least one supported tool with session data on disk. For Cursor and OpenCode, `better-sqlite3` installs automatically. @@ -167,6 +168,12 @@ codeburn optimize --format json # setup health + findings as JSON - Possibly low-worth expensive sessions with no edit turns or repeated retries when no `git`/`gh` delivery command is observed +Findings are grouped into three classes: **Fix now** (CodeBurn can apply it for you), **Habits** +(you change how you drive the next session), and **FYI** (informational, the cost may be justified). +Each one says whether its savings number is `measured` from provider-counted usage or `estimated` +from a model. See [docs/optimize.md](docs/optimize.md) for what is scanned, what `--apply` may write, +and how to read the health grade. + Each finding shows the estimated token and dollar savings plus a ready-to-paste fix: a `CLAUDE.md` line, an environment variable, or a `mv` command to archive unused items. Findings are ranked by urgency (impact weighted against observed waste) and rolled up into an A to F setup health grade. Repeat runs classify each finding as new, improving, or resolved against a 48-hour recent window. You can also open it inline from the dashboard: press `o` when a finding count appears in the status bar, `b` to return. @@ -180,11 +187,12 @@ codeburn optimize --apply --yes # apply every appliable fix without prompt codeburn act list # every change CodeBurn has made codeburn act undo --last # roll the most recent change back codeburn act report # realized vs estimated savings +codeburn optimize --auto-revert # undo the applied fixes that measured no reduction ``` `codeburn optimize` finds the waste; `--apply` fixes the config-class findings for you: settings values, environment variables, archiving unused agents and skills. Every change is backed up and journaled before it lands. `codeburn act list` shows the history and `codeburn act undo ` restores the original files (it refuses if the files changed since being applied, unless you pass `--force`). -The loop closes on honesty: once an applied fix is at least 3 days old, `codeburn act report` compares its estimated savings against what your sessions actually did, and later `codeburn optimize` runs show that realized figure in the header. Estimates get checked against reality, not just claimed. +The loop closes on honesty: once an applied fix is at least 3 days old, `codeburn act report` compares its estimated savings against what your sessions actually did, and every later `codeburn optimize` run lists it under `Applied fixes` with a plain verdict — worked, under its estimate, or did not help, with the undo command for that last case. `--auto-revert` undoes the ones that did nothing (never `CLAUDE.md` rules). Estimates get checked against reality, not just claimed. ## Guard your budget @@ -281,6 +289,8 @@ Pairing is PIN-authorized and stays on your local network. You can also discover ## Menu bar +### macOS + ```bash codeburn menubar ``` @@ -321,6 +331,20 @@ defaults write org.agentseal.codeburn-menubar CodeBurnPreferredTerminal -string Allowed values are `terminal` (macOS Terminal.app, the default) and `iterm2`. Anything else falls back to `terminal`. Only terminals that can script a command into a live window are offered; if the chosen app is missing or fails to accept the command, CodeBurn tries Terminal.app and then runs the command in the background, logging each step to Console.app. Takes effect on the next launch of a command, no relaunch needed. +### Windows + +Windows gets the same ambient view from the system tray, from the same one command: + +```powershell +codeburn menubar +``` + +It downloads the `.msi` for your CLI version, verifies its sha256, runs it through `msiexec /passive`, and launches the tray app. Re-run with `--force` to reinstall; an already-installed matching version is just launched. You can also download the `.msi` yourself from the [latest Windows Menubar release](https://github.com/getagentseal/codeburn/releases/tag/windows-v0.9.20). + +Today's spend sits in the tray as a number beside the flame icon (turn it off in Settings, and the tooltip always carries it). Click for the same popover the macOS app shows: agent tabs, period switcher, Trend, Forecast, Pulse, Stats and Plan insights, activity and model breakdowns, optimize findings, and CSV/JSON export. Settings covers launch at login, the tray number, theme, and currency. It refreshes every 60 seconds while the popover is open and every 2 minutes while it is closed. + +The tray app reads everything through the CLI, so install that first (`npm install -g codeburn`) — it needs **codeburn 0.9.9 or newer**, and shows a setup screen with the install command until it finds one. Source and build instructions are in [`windows/`](windows/) ([windows/DEVELOPMENT.md](windows/DEVELOPMENT.md)). The `.msi` is unsigned for now, so SmartScreen prompts on first run. + ### Linux (GNOME) Linux gets the same ambient view through a GNOME Shell extension (GNOME 45+): spend in the top panel, period switcher, compact mode, and daily budget alerts. It lives in [`gnome/`](gnome/): @@ -331,7 +355,7 @@ git clone https://github.com/getagentseal/codeburn && cd codeburn/gnome gnome-extensions enable codeburn@codeburn.dev ``` -See [gnome/README.md](gnome/README.md) for settings and development notes. On Windows, `codeburn web` is the always-on view for now. +See [gnome/README.md](gnome/README.md) for settings and development notes. The Tauri tray app in `windows/` also builds and runs on Linux, but it is experimental and unreleased there — the GNOME extension is the supported Linux surface. ## CodeBurn in your agent (MCP) @@ -683,6 +707,7 @@ These are starting points, not verdicts. A 60% cache hit on a single experimenta | **Cline / Roo Code / KiloCode** | VS Code `globalStorage` across VS Code, VS Code Insiders, and VSCodium (Cline at `saoudrizwan.claude-dev`, plus `~/.cline/data`) | Cline-family agents. CodeBurn reads `ui_messages.json` from each task directory, extracting token counts from `type: "say"` entries with `say: "api_req_started"`. | | **Cline CLI** | `~/.cline/data/sessions//` (honors `CLINE_SESSION_DATA_DIR`, `CLINE_DATA_DIR`, `CLINE_DIR`) | The Cline command-line agent, whose layout is unrelated to the VS Code extension's. Reads `.json` for session metadata and the rolled-up `usage`, and `.messages.json` for the per-message `metrics` block (input, output, cacheRead, cacheWrite, cost) that becomes one call each. | | **CodeWhale** | `~/.codewhale/sessions/*.json` plus unmigrated legacy `~/.deepseek/sessions/*.json`; `$CODEWHALE_HOME/sessions` is an exact override | Emits one cumulative record per saved session. CodeWhale exposes only `total_tokens`, so CodeBurn preserves that aggregate in the input column rather than inventing an input/output split. Cost is the exact stored parent-session plus subagent USD total; model pricing is used only when the cost snapshot is absent. Tool blocks, shell commands, skills, and subagent types are retained. | +| **DeepSeek Harness** (`dsh`) | `~/.dsh/sessions/----//session.jsonl.zstd` (or `session.jsonl` when compression is off); `DSH_HOME` relocates the root | DeepSeek's open-source agent harness, unrelated to the CodeWhale desktop app. The `.zstd` log is a concatenation of independent zstd frames (one per write batch), decoded frame by frame; needs Node 22.15+. One call per `(turn, step)`, with usage from the step's `assistant/message` (the streamed `assistant/chunk` sample is a draft of the same call, never a second one). DSH records tokens but no cost, so calls are priced from the shared tables with reasoning billed at the output rate. | | **IBM Bob** | `User/globalStorage/ibm.bob-code/tasks//` (GA `IBM Bob` and preview `Bob-IDE` app folders) | Reads `ui_messages.json` for API request token/cost records and `api_conversation_history.json` for the selected model. | | **Kimi Code CLI** | `$KIMI_SHARE_DIR/sessions///` or `~/.kimi/sessions///` | Reads `wire.jsonl` `StatusUpdate.token_usage` records, mapping `input_other`, `input_cache_read`, `input_cache_creation`, and `output` into the standard token columns; includes subagents under each session's `subagents/` folder. | | **LingTai TUI** | `~/.lingtai//logs/token_ledger.jsonl` plus project homes from `~/.lingtai-tui/registry.jsonl` (`/.lingtai//logs/token_ledger.jsonl`); honors `LINGTAI_HOME` / `LINGTAI_TUI_HOME` | Reads LingTai's append-only token ledger, mapping `input - cached` to fresh input, `cached` to cache reads, `output` to output, and `thinking` to reasoning. Nested daemon ledgers are skipped because parent ledgers already mirror daemon usage with `source`/`run_id` tags. | @@ -722,12 +747,12 @@ CodeBurn deduplicates messages (by API message ID for Claude, by cumulative toke CodeBurn is free, runs entirely on your machine, and exists to cut your AI bill. If it has already saved you more than a sponsorship costs, consider sending a little of that back. -Keeping 40 integrations accurate is constant work. The tools underneath change every week: Cursor reshapes its database, Claude moves a config path, new models ship at new prices. Sponsorship keeps CodeBurn current with all of it, so the numbers you see are always the real ones. +Keeping 41 integrations accurate is constant work. The tools underneath change every week: Cursor reshapes its database, Claude moves a config path, new models ship at new prices. Sponsorship keeps CodeBurn current with all of it, so the numbers you see are always the real ones. Where your sponsorship goes: - **Honest numbers.** New models and price changes are mapped quickly, so your cost is the real cost, not a guess. -- **More tools.** Every one of the 40 providers started as a single file. Sponsorship funds the next one. +- **More tools.** Every one of the 41 providers started as a single file. Sponsorship funds the next one. - **Fast fixes.** When a vendor breaks something, paid time is what gets it patched now instead of someday. Sponsoring as a team or company? Your logo lands right here, in front of every developer who opens the repo. The first sponsor gets it to themselves until the next one shows up. diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md new file mode 100644 index 00000000..c1ffcb8e --- /dev/null +++ b/THIRD_PARTY_NOTICES.md @@ -0,0 +1,51 @@ +# Third-party notices + +CodeBurn is MIT licensed (see `LICENSE`). It also contains code derived from the +projects below, which carry their own terms. Each notice is reproduced here as +those terms require. + +--- + +## @deepseek-ai/dsh-session-persistence-jsonl + +`scanZstdFrames` in `src/providers/dsh.ts` is a transcription of the function of +the same name in this package (`src/zstd.ts`), which is what lets CodeBurn read +a DeepSeek Harness session log without depending on the harness itself. No other +part of the package is used. + +Upstream declares two different licenses for this package: the published npm +package (0.0.1-rc.1) ships a BSD 3-Clause `LICENSE` and declares +`"license": "BSD-3-Clause"`, while the monorepo source it is built from +(`deepseek-ai/deepseek-harness`, `packages/session/session-persistence-jsonl`) +declares MIT. The stricter of the two is reproduced below. + +``` +BSD 3-Clause License + +Copyright (c) 2026, DeepSeek + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +``` diff --git a/app/electron/cli.test.ts b/app/electron/cli.test.ts index e95bc93d..0fb11504 100644 --- a/app/electron/cli.test.ts +++ b/app/electron/cli.test.ts @@ -1,10 +1,10 @@ // @vitest-environment node -import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { describe, it, expect, beforeEach, afterEach } from 'vitest' import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, chmodSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join, isAbsolute, relative, win32, posix } from 'node:path' -import { spawnCli, spawnCliAction, spawnEnvFor, spawnSpecFor, killAll, CliError, nodeManagerDirs, notFoundStage, resolveCodeburnPath, resolveTarget } from './cli' +import { spawnCli, spawnCliAction, spawnEnvFor, spawnSpecFor, startServe, killAll, shutdownAll, CliError, nodeManagerDirs, notFoundStage, resolveCodeburnPath, resolveTarget } from './cli' let dir: string const originalBin = process.env.CODEBURN_BIN @@ -23,6 +23,61 @@ function fakeBin(name: string, body: string): string { return p } +function readMaybe(path: string): string { + try { return readFileSync(path, 'utf8') } catch { return '' } +} + +async function waitFor(condition: () => boolean, timeoutMs = 3_000): Promise { + const deadline = Date.now() + timeoutMs + while (!condition()) { + if (Date.now() > deadline) throw new Error('waitFor timed out') + await new Promise(resolve => setTimeout(resolve, 10)) + } +} + +/** A protocol-faithful fake CLI whose serve child accepts requests before its + * delayed ready frame. Files expose process starts and heavy request executions + * without relying on timing or private ServeClient internals. */ +function fakeResidentBin(): { + startsFile: string + heavyFile: string + oneShotsFile: string + actionsFile: string + serveEnvFile: string +} { + const startsFile = join(dir, 'serve-starts') + const heavyFile = join(dir, 'heavy-requests') + const oneShotsFile = join(dir, 'one-shot-reads') + const actionsFile = join(dir, 'actions') + const serveEnvFile = join(dir, 'serve-progress-env') + fakeBin( + 'resident.js', + `const fs = require('node:fs'); const readline = require('node:readline'); + const command = process.argv[2]; + if (command === 'serve') { + fs.appendFileSync(${JSON.stringify(startsFile)}, 's'); + const generation = fs.readFileSync(${JSON.stringify(startsFile)}, 'utf8').length; + fs.writeFileSync(${JSON.stringify(serveEnvFile)}, process.env.CODEBURN_PROGRESS || ''); + const rl = readline.createInterface({ input: process.stdin }); + rl.on('line', line => { + const request = JSON.parse(line); + fs.appendFileSync(${JSON.stringify(heavyFile)}, 'h'); + const progress = 'CODEBURN_PROGRESS ' + JSON.stringify({ kind: 'provider', provider: 'claude', state: 'start', generation }) + '\\n'; + process.stdout.write(JSON.stringify({ id: request.id, progress }) + '\\n'); + process.stdout.write(JSON.stringify({ id: request.id, ok: true, output: JSON.stringify({ via: 'serve', generation, args: request.args }) }) + '\\n'); + }); + setTimeout(() => process.stdout.write(JSON.stringify({ ready: true, pid: process.pid }) + '\\n'), 100); + } else if (command === 'currency') { + fs.appendFileSync(${JSON.stringify(actionsFile)}, 'a'); + process.stdout.write('currency updated'); + } else { + fs.appendFileSync(${JSON.stringify(oneShotsFile)}, 'o'); + process.stdout.write(JSON.stringify({ via: 'spawn', command })); + }`, + ) + return { startsFile, heavyFile, oneShotsFile, actionsFile, serveEnvFile } +} + /** Writes the repo CLI under this test's isolated dev-root override. */ function fakeDevRepoCli(): string { const repoRoot = join(dir, 'dev-repo') @@ -39,6 +94,7 @@ beforeEach(() => { }) afterEach(() => { + killAll() if (originalBin === undefined) delete process.env.CODEBURN_BIN else process.env.CODEBURN_BIN = originalBin if (originalPathDirs === undefined) delete process.env.CODEBURN_PATH_DIRS @@ -357,19 +413,19 @@ describe('spawnCli coalescing (read-only)', () => { expect(readFileSync(countFile, 'utf8')).toBe('x') // exactly one spawn }) - it('spawns again once the 5s result cache has expired', async () => { - vi.useFakeTimers({ toFake: ['Date'] }) - try { - const countFile = join(dir, 'spawns') - fakeBin('counter-ttl.js', `require('fs').appendFileSync(${JSON.stringify(countFile)},'x'); process.stdout.write(JSON.stringify({ok:1}))`) - vi.setSystemTime(0) - await spawnCli(['status']) - vi.setSystemTime(6_000) - await spawnCli(['status']) - expect(readFileSync(countFile, 'utf8')).toBe('xx') // cache expired → new spawn - } finally { - vi.useRealTimers() - } + it('reflects an external config change on the next same-argv read', async () => { + const configFile = join(dir, 'external-config') + const countFile = join(dir, 'spawns') + writeFileSync(configFile, 'before') + fakeBin( + 'external-config.js', + `const fs = require('node:fs'); fs.appendFileSync(${JSON.stringify(countFile)}, 'x'); process.stdout.write(JSON.stringify({ value: fs.readFileSync(${JSON.stringify(configFile)}, 'utf8') }))`, + ) + + await expect(spawnCli(['model-alias', '--list'])).resolves.toEqual({ value: 'before' }) + writeFileSync(configFile, 'after') + await expect(spawnCli(['model-alias', '--list'])).resolves.toEqual({ value: 'after' }) + expect(readFileSync(countFile, 'utf8')).toBe('xx') }) it('never coalesces config-mutating action calls', async () => { @@ -379,14 +435,433 @@ describe('spawnCli coalescing (read-only)', () => { expect(readFileSync(countFile, 'utf8')).toBe('xx') // two independent spawns }) - it('flushes the read cache when an action completes, so post-action refetches are fresh', async () => { + it('runs a fresh read after a config-mutating action', async () => { const countFile = join(dir, 'spawns') fakeBin('mixed.js', `require('fs').appendFileSync(${JSON.stringify(countFile)},'x'); process.stdout.write(JSON.stringify({ok:1}))`) - await spawnCli(['model-alias', '--list']) // primes the 5s cache - await spawnCliAction(['model-alias', 'a', 'b']) // config change → cache flush - await spawnCli(['model-alias', '--list']) // must NOT serve the pre-action cache + await spawnCli(['model-alias', '--list']) + await spawnCliAction(['model-alias', 'a', 'b']) + await spawnCli(['model-alias', '--list']) expect(readFileSync(countFile, 'utf8')).toBe('xxx') }) + + it('fences old in-flight reads across a mutation without deleting the new flight', async () => { + const configFile = join(dir, 'generation-config') + const startsFile = join(dir, 'generation-read-starts') + const releaseDir = join(dir, 'generation-release') + mkdirSync(releaseDir) + writeFileSync(configFile, 'old') + fakeBin( + 'generation-fence.js', + `const fs = require('node:fs'); const path = require('node:path'); + if (process.argv[3] === '--list') { + const value = fs.readFileSync(${JSON.stringify(configFile)}, 'utf8'); + fs.appendFileSync(${JSON.stringify(startsFile)}, 'r'); + const generation = fs.readFileSync(${JSON.stringify(startsFile)}, 'utf8').length; + const release = path.join(${JSON.stringify(releaseDir)}, String(generation)); + const timer = setInterval(() => { + if (!fs.existsSync(release)) return; + clearInterval(timer); + process.stdout.write(JSON.stringify({ value, generation })); + }, 5); + } else { + fs.writeFileSync(${JSON.stringify(configFile)}, 'new'); + process.stdout.write('updated'); + }`, + ) + + const oldRead = spawnCli(['model-alias', '--list']) + await waitFor(() => readMaybe(startsFile) === 'r') + await expect(spawnCliAction(['model-alias', 'alias', 'model'])) + .resolves.toMatchObject({ ok: true }) + + const newRead = spawnCli(['model-alias', '--list']) + await waitFor(() => readMaybe(startsFile) === 'rr') + writeFileSync(join(releaseDir, '1'), '') + await expect(oldRead).resolves.toEqual({ value: 'old', generation: 1 }) + + // Settling the superseded flight must not remove the current generation's + // entry: this identical call still shares read #2 instead of spawning #3. + const coalescedNewRead = spawnCli(['model-alias', '--list']) + await new Promise(resolve => setTimeout(resolve, 100)) + expect(readMaybe(startsFile)).toBe('rr') + + writeFileSync(join(releaseDir, '2'), '') + await expect(Promise.all([newRead, coalescedNewRead])).resolves.toEqual([ + { value: 'new', generation: 2 }, + { value: 'new', generation: 2 }, + ]) + }) +}) + +describe('resident serve single-flight', () => { + it('startServe is idempotent and creates only one resident child', async () => { + const files = fakeResidentBin() + startServe() + startServe() + + const result = await spawnCli(['status', '--double-start'], { timeoutMs: 5_000 }) as { generation: number } + + expect(result.generation).toBe(1) + expect(readMaybe(files.startsFile)).toBe('s') + expect(readMaybe(files.heavyFile)).toBe('h') + }) + + it('lazily starts a new resident after an unexpected death and one-shot fallback', async () => { + const startsFile = join(dir, 'serve-starts') + const oneShotsFile = join(dir, 'one-shot-reads') + fakeBin( + 'dies-once-resident.js', + `const fs = require('node:fs'); const readline = require('node:readline'); + const command = process.argv[2]; + if (command === 'serve') { + fs.appendFileSync(${JSON.stringify(startsFile)}, 's'); + const generation = fs.readFileSync(${JSON.stringify(startsFile)}, 'utf8').length; + const rl = readline.createInterface({ input: process.stdin }); + rl.once('line', line => { + const request = JSON.parse(line); + if (generation === 1) process.exit(1); + process.stdout.write(JSON.stringify({ id: request.id, ok: true, output: JSON.stringify({ via: 'serve', generation }) }) + '\\n'); + }); + } else { + fs.appendFileSync(${JSON.stringify(oneShotsFile)}, 'o'); + process.stdout.write(JSON.stringify({ via: 'spawn' })); + }`, + ) + startServe() + + await expect(spawnCli(['status', '--first'], { timeoutMs: 5_000 })) + .resolves.toEqual({ via: 'spawn' }) + await expect(spawnCli(['models', '--second'], { timeoutMs: 5_000 })) + .resolves.toEqual({ via: 'serve', generation: 2 }) + + expect(readMaybe(startsFile)).toBe('ss') + expect(readMaybe(oneShotsFile)).toBe('o') + }) + + it('gives the first resident status request the power-user cold timeout floor', async () => { + fakeBin( + 'slow-cold-resident.js', + `const readline = require('node:readline'); + if (process.argv[2] === 'serve') { + const rl = readline.createInterface({ input: process.stdin }); + rl.on('line', line => { + const request = JSON.parse(line); + setTimeout(() => process.stdout.write(JSON.stringify({ id: request.id, ok: true, output: JSON.stringify({ via: 'serve' }) }) + '\\n'), 80); + }); + } else { + process.stdout.write(JSON.stringify({ via: 'spawn' })); + }`, + ) + startServe() + + await expect(spawnCli(['status', '--cold-floor'], { timeoutMs: 20 })) + .resolves.toEqual({ via: 'serve' }) + }) + + it('starts a queued resident timeout only after the request ahead settles', async () => { + fakeBin( + 'serial-resident.js', + `const readline = require('node:readline'); + if (process.argv[2] === 'serve') { + const rl = readline.createInterface({ input: process.stdin }); + (async () => { + for await (const line of rl) { + const request = JSON.parse(line); + if (request.args.includes('--slow')) await new Promise(resolve => setTimeout(resolve, 400)); + process.stdout.write(JSON.stringify({ id: request.id, ok: true, output: JSON.stringify({ via: 'serve', args: request.args }) }) + '\\n'); + } + })(); + } else { + process.stdout.write(JSON.stringify({ via: 'spawn' })); + }`, + ) + startServe() + await expect(spawnCli(['status', '--warm'], { timeoutMs: 5_000 })) + .resolves.toMatchObject({ via: 'serve' }) + + const slow = spawnCli(['sessions', '--slow'], { timeoutMs: 1_000 }) + const queued = spawnCli(['models', '--queued'], { timeoutMs: 200 }) + const [slowResult, queuedResult] = await Promise.all([slow, queued]) + + expect(slowResult).toMatchObject({ via: 'serve' }) + expect(queuedResult).toMatchObject({ via: 'serve' }) + }) + + it('uses the first real request as the only heavy execution, even before ready', async () => { + const files = fakeResidentBin() + startServe() + + const result = await spawnCli(['status', '--format', 'menubar-json'], { + timeoutMs: 5_000, + extraEnv: { CODEBURN_PROGRESS: '1' }, + }) as { via: string; generation: number } + + expect(result).toMatchObject({ via: 'serve', generation: 1 }) + expect(readMaybe(files.startsFile)).toBe('s') + expect(readMaybe(files.heavyFile)).toBe('h') + expect(readMaybe(files.oneShotsFile)).toBe('') + expect(readMaybe(files.serveEnvFile)).toBe('1') + }) + + it('forwards serve progress frames through the read onStderr callback', async () => { + fakeResidentBin() + startServe() + const chunks: string[] = [] + + await spawnCli(['status'], { + timeoutMs: 5_000, + extraEnv: { CODEBURN_PROGRESS: '1' }, + onStderr: chunk => { chunks.push(chunk) }, + }) + + expect(chunks.join('')).toBe('CODEBURN_PROGRESS {"kind":"provider","provider":"claude","state":"start","generation":1}\n') + }) + + it('rejects and terminates a resident that emits an oversized valid JSON frame', async () => { + const startsFile = join(dir, 'oversized-frame-starts') + const oneShotsFile = join(dir, 'oversized-frame-one-shots') + fakeBin( + 'oversized-frame-resident.js', + `const fs = require('node:fs'); const readline = require('node:readline'); + if (process.argv[2] === 'serve') { + fs.appendFileSync(${JSON.stringify(startsFile)}, 's'); + const generation = fs.readFileSync(${JSON.stringify(startsFile)}, 'utf8').length; + const rl = readline.createInterface({ input: process.stdin }); + rl.once('line', line => { + const request = JSON.parse(line); + const output = generation === 1 + ? JSON.stringify({ value: 'x'.repeat(16 * 1024 * 1024 + 1024) }) + : JSON.stringify({ generation }); + process.stdout.write(JSON.stringify({ id: request.id, ok: true, output }) + '\\n'); + }); + setInterval(() => {}, 1000); + } else { + fs.appendFileSync(${JSON.stringify(oneShotsFile)}, 'o'); + process.stdout.write('{}'); + }`, + ) + startServe() + + await expect(spawnCli(['status', '--oversized-frame'], { timeoutMs: 5_000 })) + .rejects.toMatchObject({ kind: 'too-large' } satisfies Partial) + await expect(spawnCli(['status', '--after-oversized-frame'], { timeoutMs: 5_000 })) + .resolves.toEqual({ generation: 2 }) + expect(readMaybe(startsFile)).toBe('ss') + expect(readMaybe(oneShotsFile)).toBe('') + }) + + it('keeps serve enabled after more overflows than the resident death budget', async () => { + const startsFile = join(dir, 'overflow-budget-starts') + const oneShotsFile = join(dir, 'overflow-budget-one-shots') + fakeBin( + 'always-oversized-resident.js', + `const fs = require('node:fs'); const readline = require('node:readline'); + if (process.argv[2] === 'serve') { + fs.appendFileSync(${JSON.stringify(startsFile)}, 's'); + const rl = readline.createInterface({ input: process.stdin }); + rl.once('line', line => { + const request = JSON.parse(line); + const output = JSON.stringify({ value: 'x'.repeat(16 * 1024 * 1024 + 1024) }); + process.stdout.write(JSON.stringify({ id: request.id, ok: true, output }) + '\\n'); + }); + setInterval(() => {}, 1000); + } else { + fs.appendFileSync(${JSON.stringify(oneShotsFile)}, 'o'); + process.stdout.write('{}'); + }`, + ) + startServe() + + for (let attempt = 0; attempt < 4; attempt += 1) { + await expect(spawnCli(['status', '--overflow', String(attempt)], { timeoutMs: 5_000 })) + .rejects.toMatchObject({ kind: 'too-large' } satisfies Partial) + } + + // An overflow kill is deliberate, so it never spends the unexpected-death + // budget: the fourth request still reaches a resident, not a one-shot. + expect(readMaybe(startsFile)).toBe('ssss') + expect(readMaybe(oneShotsFile)).toBe('') + }) + + it('rejects and terminates a resident whose protocol line never terminates', async () => { + const startsFile = join(dir, 'unterminated-line-starts') + const oneShotsFile = join(dir, 'unterminated-line-one-shots') + fakeBin( + 'unterminated-line-resident.js', + `const fs = require('node:fs'); const readline = require('node:readline'); + if (process.argv[2] === 'serve') { + fs.appendFileSync(${JSON.stringify(startsFile)}, 's'); + const rl = readline.createInterface({ input: process.stdin }); + rl.once('line', () => process.stdout.write('x'.repeat(16 * 1024 * 1024 + 1024))); + setInterval(() => {}, 1000); + } else { + fs.appendFileSync(${JSON.stringify(oneShotsFile)}, 'o'); + process.stdout.write('{}'); + }`, + ) + startServe() + + await expect(spawnCli(['status', '--unterminated-line'], { timeoutMs: 5_000 })) + .rejects.toMatchObject({ kind: 'too-large' } satisfies Partial) + expect(readMaybe(startsFile)).toBe('s') + expect(readMaybe(oneShotsFile)).toBe('') + }) + + it('keeps requests with any non-progress env override on the one-shot path', async () => { + const files = fakeResidentBin() + startServe() + + const result = await spawnCli(['status'], { + timeoutMs: 5_000, + extraEnv: { CODEBURN_PROGRESS: '1', CODEBURN_TEST_MODE: 'isolated' }, + }) as { via: string } + + expect(result.via).toBe('spawn') + expect(readMaybe(files.heavyFile)).toBe('') + expect(readMaybe(files.oneShotsFile)).toBe('o') + }) + + it('treats empty and undefined-only env overrides as serve-compatible', async () => { + const files = fakeResidentBin() + startServe() + + const empty = await spawnCli(['status', '--empty-env'], { + timeoutMs: 5_000, + extraEnv: {}, + }) as { via: string } + const undefinedOnly = await spawnCli(['models', '--undefined-env'], { + timeoutMs: 5_000, + extraEnv: { CODEBURN_PROGRESS: undefined }, + }) as { via: string } + + expect(empty.via).toBe('serve') + expect(undefinedOnly.via).toBe('serve') + expect(readMaybe(files.heavyFile)).toBe('hh') + expect(readMaybe(files.oneShotsFile)).toBe('') + }) + + it('restarts the resident child after a successful config mutation', async () => { + const files = fakeResidentBin() + startServe() + + const before = await spawnCli(['status'], { timeoutMs: 5_000 }) as { generation: number } + const action = await spawnCliAction(['currency', 'EUR'], { timeoutMs: 5_000 }) + const after = await spawnCli(['status'], { timeoutMs: 5_000 }) as { generation: number } + + expect(action).toMatchObject({ ok: true, stdout: 'currency updated', code: 0 }) + expect(before.generation).toBe(1) + expect(after.generation).toBe(2) + expect(readMaybe(files.startsFile)).toBe('ss') + expect(readMaybe(files.heavyFile)).toBe('hh') + expect(readMaybe(files.actionsFile)).toBe('a') + }) + + it('preserves the unexpected-death budget across mutation restarts', async () => { + const startsFile = join(dir, 'serve-starts') + const oneShotsFile = join(dir, 'one-shot-reads') + fakeBin( + 'crashing-resident.js', + `const fs = require('node:fs'); const readline = require('node:readline'); + const command = process.argv[2]; + if (command === 'serve') { + fs.appendFileSync(${JSON.stringify(startsFile)}, 's'); + const rl = readline.createInterface({ input: process.stdin }); + rl.once('line', () => process.exit(1)); + } else if (command === 'currency') { + process.stdout.write('currency updated'); + } else { + fs.appendFileSync(${JSON.stringify(oneShotsFile)}, 'o'); + process.stdout.write(JSON.stringify({ via: 'spawn' })); + }`, + ) + startServe() + + for (let attempt = 0; attempt < 3; attempt += 1) { + await expect(spawnCli(['status', '--attempt', String(attempt)], { timeoutMs: 5_000 })) + .resolves.toEqual({ via: 'spawn' }) + await expect(spawnCliAction(['currency', attempt % 2 === 0 ? 'EUR' : 'USD'], { timeoutMs: 5_000 })) + .resolves.toMatchObject({ ok: true }) + } + + // A mutation may replace a healthy child, but it must not erase real crash + // history and resurrect serve after the third unexpected death. + expect(readMaybe(startsFile)).toBe('sss') + await expect(spawnCli(['status', '--after-budget'], { timeoutMs: 5_000 })) + .resolves.toEqual({ via: 'spawn' }) + expect(readMaybe(startsFile)).toBe('sss') + expect(readMaybe(oneShotsFile)).toBe('oooo') + }) + + it('stops lazy crash recovery after three consecutive resident deaths', async () => { + const startsFile = join(dir, 'serve-starts') + const oneShotsFile = join(dir, 'one-shot-reads') + fakeBin( + 'always-crashing-resident.js', + `const fs = require('node:fs'); const readline = require('node:readline'); + if (process.argv[2] === 'serve') { + fs.appendFileSync(${JSON.stringify(startsFile)}, 's'); + const rl = readline.createInterface({ input: process.stdin }); + rl.once('line', () => process.exit(1)); + } else { + fs.appendFileSync(${JSON.stringify(oneShotsFile)}, 'o'); + process.stdout.write(JSON.stringify({ via: 'spawn' })); + }`, + ) + startServe() + + for (let attempt = 0; attempt < 4; attempt += 1) { + await expect(spawnCli(['status', '--lazy-crash', String(attempt)], { timeoutMs: 5_000 })) + .resolves.toEqual({ via: 'spawn' }) + } + + expect(readMaybe(startsFile)).toBe('sss') + expect(readMaybe(oneShotsFile)).toBe('oooo') + }) + + it('does not spawn a one-shot fallback after killAll destroys serve', async () => { + const requestSeenFile = join(dir, 'request-seen') + const oneShotsFile = join(dir, 'one-shot-reads') + fakeBin( + 'shutdown-resident.js', + `const fs = require('node:fs'); const readline = require('node:readline'); + if (process.argv[2] === 'serve') { + const rl = readline.createInterface({ input: process.stdin }); + rl.once('line', () => { fs.writeFileSync(${JSON.stringify(requestSeenFile)}, '1'); }); + } else { + fs.appendFileSync(${JSON.stringify(oneShotsFile)}, 'o'); + process.stdout.write('{}'); + }`, + ) + startServe() + const pending = spawnCli(['status', '--shutdown'], { timeoutMs: 60_000 }) + for (let attempt = 0; attempt < 400 && !readMaybe(requestSeenFile); attempt += 1) { + await new Promise(resolve => setTimeout(resolve, 5)) + } + const requestSeen = readMaybe(requestSeenFile) + killAll() + + expect(requestSeen).toBe('1') + await expect(pending).rejects.toMatchObject({ kind: 'nonzero' }) + await new Promise(resolve => setTimeout(resolve, 25)) + expect(readMaybe(oneShotsFile)).toBe('') + }) + + it('keeps the warm resident child after a successful export', async () => { + const files = fakeResidentBin() + startServe() + + const before = await spawnCli(['status'], { timeoutMs: 5_000 }) as { generation: number } + const action = await spawnCliAction(['export', '-f', 'json', '-o', join(dir, 'usage.json')], { timeoutMs: 5_000 }) + // A different panel query proves which resident generation handled the + // next served read without relying on same-request coalescing. + const after = await spawnCli(['models', '--format', 'json'], { timeoutMs: 5_000 }) as { generation: number } + + expect(action.ok).toBe(true) + expect(before.generation).toBe(1) + expect(after.generation).toBe(1) + expect(readMaybe(files.startsFile)).toBe('s') + expect(readMaybe(files.heavyFile)).toBe('hh') + }) }) describe('killAll', () => { @@ -398,6 +873,43 @@ describe('killAll', () => { killAll() await expect(pending).rejects.toMatchObject({ kind: 'nonzero' }) }) + + it('terminal shutdown rejects new read and action races without spawning', async () => { + const startsFile = join(dir, 'starts') + fakeBin( + 'shutdown-guard.js', + `require('node:fs').appendFileSync(${JSON.stringify(startsFile)}, 'x'); process.stdout.write('{}')`, + ) + + shutdownAll() + startServe() + + await expect(spawnCli(['status', '--after-shutdown'])) + .rejects.toMatchObject({ kind: 'nonzero' }) + await expect(spawnCliAction(['currency', 'EUR'])) + .resolves.toMatchObject({ ok: false, code: null }) + expect(readMaybe(startsFile)).toBe('') + }) + + it('terminal shutdown cancels read and action slots admitted before their spawn microtask', async () => { + const startsFile = join(dir, 'starts-after-admission') + fakeBin( + 'shutdown-after-admission.js', + `require('node:fs').appendFileSync(${JSON.stringify(startsFile)}, process.argv[2] + '\\n'); if (process.argv[2] === 'status') process.stdout.write('{}'); else process.stdout.write('updated')`, + ) + + // Both calls synchronously acquire the two free scheduler slots. Their + // actual spawn resumes in a microtask, which is exactly the before-quit race. + const read = spawnCli(['status', '--admitted']) + const action = spawnCliAction(['currency', 'EUR']) + shutdownAll() + + await Promise.all([ + expect(read).rejects.toMatchObject({ kind: 'nonzero' }), + expect(action).resolves.toMatchObject({ ok: false, code: null }), + ]) + expect(readMaybe(startsFile)).toBe('') + }) }) describe('spawnCli concurrency scheduler', () => { @@ -525,6 +1037,52 @@ describe('spawnCli concurrency scheduler', () => { await delay(50) expect(startedList(startedFile)).not.toContain('sessions') // never spawned }) + + it('limits six simultaneous resident-failure fallbacks to two one-shot children', async () => { + const startedFile = join(dir, 'fallback-started') + const activeDir = join(dir, 'fallback-active'); mkdirSync(activeDir) + const activeCountsFile = join(dir, 'fallback-active-counts') + const releaseDir = join(dir, 'fallback-release'); mkdirSync(releaseDir) + fakeBin( + 'failing-resident-with-blocked-fallbacks.js', + `const fs = require('node:fs'); const path = require('node:path'); const readline = require('node:readline'); + if (process.argv[2] === 'serve') { + const rl = readline.createInterface({ input: process.stdin }); + rl.on('line', line => { + const request = JSON.parse(line); + process.stdout.write(JSON.stringify({ id: request.id, ok: false, error: 'resident failed' }) + '\\n'); + }); + } else { + const id = process.argv[3]; + fs.appendFileSync(${JSON.stringify(startedFile)}, id + '\\n'); + const activeFile = path.join(${JSON.stringify(activeDir)}, String(process.pid)); + fs.writeFileSync(activeFile, ''); + fs.appendFileSync(${JSON.stringify(activeCountsFile)}, fs.readdirSync(${JSON.stringify(activeDir)}).length + '\\n'); + const releaseFile = path.join(${JSON.stringify(releaseDir)}, id); + const timer = setInterval(() => { + if (!fs.existsSync(releaseFile)) return; + clearInterval(timer); + fs.unlinkSync(activeFile); + process.stdout.write(JSON.stringify({ via: 'spawn', id })); + }, 5); + }`, + ) + startServe() + + const requests = Array.from({ length: 6 }, (_, index) => + spawnCli(['status', `fallback-${index}`], { timeoutMs: 5_000 }), + ) + await waitUntil(() => startedList(startedFile).length >= 2) + await delay(150) + const admittedBeforeRelease = startedList(startedFile) + + for (let index = 0; index < 6; index += 1) release(releaseDir, `fallback-${index}`) + await Promise.all(requests) + + const activeCounts = startedList(activeCountsFile).map(Number) + expect(admittedBeforeRelease).toHaveLength(2) + expect(Math.max(...activeCounts)).toBeLessThanOrEqual(2) + }) }) describe('spawnCliAction', () => { diff --git a/app/electron/cli.ts b/app/electron/cli.ts index ad63a8ec..2d449a43 100644 --- a/app/electron/cli.ts +++ b/app/electron/cli.ts @@ -54,11 +54,12 @@ export class CliError extends Error { } const DEFAULT_TIMEOUT_MS = 45_000 +// The first status query may hydrate a power-user cache from scratch. Every +// resident request admitted before that succeeds shares this floor so a later +// short request cannot kill the child while it waits behind the cold scan. +export const DESKTOP_COLD_TIMEOUT_MS = 10 * 60_000 // A runaway CLI (or a compromised binary) must not exhaust main-process memory. const MAX_OUTPUT_BYTES = 16 * 1024 * 1024 -// Same-cadence pollers fire near-identical read spawns; share one child and hold -// its result briefly so six overview hooks don't launch six processes at once. -const COALESCE_TTL_MS = 5_000 // A cold-cache CLI spawn costs seconds at ~120% CPU; letting every poll + // prefetch launch at once saturates the machine. Cap how many children run // concurrently — the rest queue and drain as slots free (interactive first). @@ -67,7 +68,10 @@ const MAX_CONCURRENT_CLI = 2 // Every live child so `before-quit` can reap them (Electron does not on macOS). const activeChildren = new Set() const readInflight = new Map>() -const readCache = new Map() +// Successful mutations advance the epoch before their promise resolves. A read +// begun against older config may still settle for its original caller, but can +// never be reused by the post-mutation refetch or delete that newer flight. +let readGeneration = 0 // Concurrency scheduler. `running` counts spawned (not queued) children; waiters // hold the slot-grant resolver for a queued spawn. Two queues so interactive @@ -76,6 +80,7 @@ type SlotWaiter = { resolve: () => void; reject: (err: unknown) => void } let running = 0 const interactiveQueue: SlotWaiter[] = [] const backgroundQueue: SlotWaiter[] = [] +let shuttingDown = false /** Grant free slots to queued waiters, interactive first, up to the cap. */ function pumpSlots(): void { @@ -101,9 +106,8 @@ function releaseSlot(): void { pumpSlots() } -/** SIGKILL every in-flight child and cancel anything still queued for a slot. - * Wired to Electron's `before-quit`. */ -export function killAll(): void { +/** Reap every child and cancel anything still queued for a slot. */ +function reapAll(): void { serveClient?.destroy() serveClient = null for (const child of activeChildren) child.kill('SIGKILL') @@ -117,6 +121,19 @@ export function killAll(): void { for (const waiter of waiting) waiter.reject(new CliError('nonzero', 'codeburn cancelled')) } +/** Test/dev cleanup that permits a later fresh start in this same process. */ +export function killAll(): void { + shuttingDown = false + reapAll() +} + +/** Terminal app shutdown: reap current work and reject any IPC race that arrives + * while Electron is still flushing telemetry before the final quit pass. */ +export function shutdownAll(): void { + shuttingDown = true + reapAll() +} + // Homebrew + common Node version managers, mirroring mac/CodeburnCLI.swift so a // GUI-launched app (minimal PATH) still finds a globally-installed `codeburn`. export function nodeManagerDirs(): string[] { @@ -378,6 +395,25 @@ function runCli(spec: SpawnSpec, cmdLabel: string, timeoutMs: number, onStderr?: }) } +/** Run a one-shot read under the global child cap. A slot grant resumes on a + * microtask, so terminal shutdown must be checked again immediately before the + * synchronous spawn call. */ +async function runScheduledCli( + spec: SpawnSpec, + cmdLabel: string, + timeoutMs: number, + priority: SpawnPriority, + onStderr?: (chunk: string) => void, +): Promise { + await acquireSlot(priority) + try { + if (shuttingDown) throw new CliError('nonzero', 'codeburn is shutting down') + return await runCli(spec, cmdLabel, timeoutMs, onStderr) + } finally { + releaseSlot() + } +} + /** * Spawn `codeburn ` with plain argv (never a shell), collect stdout, and * decode it as JSON. Rejects with a structured {@link CliError}: @@ -387,8 +423,9 @@ function runCli(spec: SpawnSpec, cmdLabel: string, timeoutMs: number, onStderr?: * timeout the process was killed after `timeoutMs` * too-large stdout+stderr exceeded {@link MAX_OUTPUT_BYTES} * - * Read-only, so concurrent identical calls share one child and a 5s result cache - * absorbs same-cadence pollers. Never use this for config-mutating commands. + * Read-only, so concurrent identical calls share one child. Settled results are + * never cached here because config can also change outside the desktop app. + * Never use this for config-mutating commands. */ // ── Resident serve child ──────────────────────────────────────────────── // The heavy read queries (one per panel) each pay seconds of CLI startup on @@ -397,75 +434,141 @@ function runCli(spec: SpawnSpec, cmdLabel: string, timeoutMs: number, onStderr?: // stdio and the cache stays parsed in the child. Routing rules keep this // strictly an optimization: // - only SERVE_ROUTED commands (the app's JSON panel queries) are eligible; -// - requests route through serve only once the child is READY AND WARM, so -// the cold-start path keeps its spawn (with its stderr progress events); +// - 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; +// - progress frames from serve are forwarded through the same onStderr hook +// used by a one-shot cold start; // - any serve failure falls back to a normal spawn for that call; // - three child deaths permanently disable serve for this app run. const SERVE_ROUTED = new Set(['status', 'models', 'sessions', 'compare', 'yield', 'spend', 'optimize', 'audit']) -const SERVE_REQUEST_TIMEOUT_MS = 60_000 const SERVE_MAX_RESTARTS = 3 class ServeClient { private child: ReturnType | null = null - private pending = new Map void; reject: (e: Error) => void; timer: NodeJS.Timeout }>() + private pending = new Map void + reject: (e: Error) => void + timer: NodeJS.Timeout + warmsServe: boolean + decodedBytes: number + onStderr?: (chunk: string) => void + }>() private nextId = 1 - private ready = false - private warm = false private deaths = 0 private buffer = '' + private bufferBytes = 0 + private warmed = false + private destroyed = false + private requestTail: Promise = Promise.resolve() constructor(private readonly spec: SpawnSpec) {} - isWarmAndReady(): boolean { return this.ready && this.warm && this.child !== null } + isRunning(): boolean { return this.child !== null } disabled(): boolean { return this.deaths >= SERVE_MAX_RESTARTS } + isDestroyed(): boolean { return this.destroyed } start(): void { - if (this.child || this.disabled()) return + if (this.child || this.disabled() || this.destroyed) return const child = spawn(this.spec.bin, [...this.spec.args], { shell: false, stdio: ['pipe', 'pipe', 'ignore'], env: this.spec.env }) this.child = child child.stdout!.setEncoding('utf8') - child.stdout!.on('data', (chunk: string) => this.onData(chunk)) - const onGone = () => this.onDeath() + child.stdout!.on('data', (chunk: string) => { + // A replaced child's stream can drain after its exit callback. Never let + // those stale bytes repopulate the shared line buffer for the new child. + if (this.child === child) this.onData(child, chunk) + }) + const onGone = () => this.onDeath(child) child.on('exit', onGone) child.on('error', onGone) - // Background warm-up: one cheap query makes the child parse the session - // cache once; every later panel fetch reuses the in-memory copy. - void this.request(['status', '--format', 'menubar-json', '--period', 'today'], SERVE_REQUEST_TIMEOUT_MS) - .then(() => { this.warm = true }) - .catch(() => { /* warm-up failure just leaves routing on the spawn path */ }) } - private onData(chunk: string): void { + private onData(child: ReturnType, chunk: string): void { this.buffer += chunk + this.bufferBytes += Buffer.byteLength(chunk) let idx: number while ((idx = this.buffer.indexOf('\n')) >= 0) { - const line = this.buffer.slice(0, idx).trim() + const rawLine = this.buffer.slice(0, idx) this.buffer = this.buffer.slice(idx + 1) + const rawLineBytes = Buffer.byteLength(rawLine) + this.bufferBytes = Math.max(0, this.bufferBytes - rawLineBytes - 1) + if (rawLineBytes > MAX_OUTPUT_BYTES) { + this.terminateForOverflow(child) + return + } + const line = rawLine.trim() if (!line) continue - let msg: { id?: number; ready?: boolean; ok?: boolean; refused?: boolean; output?: string; error?: string } + let msg: { id?: number; ready?: boolean; progress?: string; ok?: boolean; refused?: boolean; output?: string; error?: string } try { msg = JSON.parse(line) } catch { continue } - if (msg.ready) { this.ready = true; continue } + if (msg.ready) continue if (typeof msg.id !== 'number') continue const waiter = this.pending.get(msg.id) if (!waiter) continue + if (typeof msg.progress === 'string') { + if (!this.consumeDecodedOutput(child, waiter, msg.progress)) return + if (waiter.onStderr) { + try { waiter.onStderr(msg.progress) } catch { /* progress consumers never own the request */ } + } + continue + } + const terminalOutput = typeof msg.output === 'string' ? msg.output : typeof msg.error === 'string' ? msg.error : '' + if (!this.consumeDecodedOutput(child, waiter, terminalOutput)) return this.pending.delete(msg.id) clearTimeout(waiter.timer) if (msg.ok && typeof msg.output === 'string') { + if (waiter.warmsServe) this.warmed = true try { waiter.resolve(JSON.parse(msg.output)) } catch { waiter.reject(new CliError('bad-json', 'codeburn produced output that was not valid JSON')) } } else { waiter.reject(new CliError('nonzero', msg.error ?? 'serve request failed')) } } + // Complete lines are bounded above before parsing. Bound the partial frame + // too, otherwise a child that never emits '\n' can grow this buffer forever. + if (this.bufferBytes > MAX_OUTPUT_BYTES) this.terminateForOverflow(child) } - private onDeath(): void { - const child = this.child + private consumeDecodedOutput( + child: ReturnType, + waiter: { decodedBytes: number }, + output: string, + ): boolean { + waiter.decodedBytes += Buffer.byteLength(output) + if (waiter.decodedBytes <= MAX_OUTPUT_BYTES) return true + this.terminateForOverflow(child) + return false + } + + private terminateForOverflow(child: ReturnType): void { + if (this.child !== child) return + const error = new CliError('too-large', `codeburn serve produced more than ${MAX_OUTPUT_BYTES} bytes`) + // Detach synchronously before SIGKILL. A new request may start the next + // generation immediately; the old child's eventual exit must not reject it. this.child = null - this.ready = false - this.warm = false - this.deaths += 1 - if (child) activeChildren.delete(child as never) + this.buffer = '' + this.bufferBytes = 0 + this.warmed = false + // Deliberate termination, not a crash: it must not spend the unexpected-death + // budget, or three oversized payloads would disable serve for the app run. + activeChildren.delete(child as never) + for (const [, waiter] of this.pending) { + clearTimeout(waiter.timer) + waiter.reject(error) + } + this.pending.clear() + child.kill('SIGKILL') + } + + private onDeath(child: ReturnType, countsTowardBudget = true): void { + // Both `error` and `exit` can fire for one child, and destroy() performs the + // same cleanup synchronously. Only the currently-owned child may transition + // this client or reject its pending requests. + if (this.child !== child) return + this.child = null + this.buffer = '' + this.bufferBytes = 0 + this.warmed = false + if (countsTowardBudget) this.deaths += 1 + activeChildren.delete(child as never) for (const [, waiter] of this.pending) { clearTimeout(waiter.timer) waiter.reject(new CliError('nonzero', 'codeburn serve exited')) @@ -473,10 +576,32 @@ class ServeClient { this.pending.clear() } - request(args: string[], timeoutMs: number): Promise { + restartAfterMutation(): void { + const child = this.child + if (child) { + // This is an intentional replacement, not a crash. Detach first so the + // later exit event cannot consume the unexpected-death budget. + this.onDeath(child, false) + child.kill('SIGKILL') + } + this.start() + } + + request(args: string[], timeoutMs: number, onStderr?: (chunk: string) => void): Promise { + // The stdio server is deliberately serial. Mirror that contract client-side + // so queued calls do not start their timers while a cold request is still + // hydrating the cache in front of them. + const run = () => this.requestNow(args, timeoutMs, onStderr) + const result = this.requestTail.then(run, run) + this.requestTail = result.then(() => undefined, () => undefined) + return result + } + + private requestNow(args: string[], timeoutMs: number, onStderr?: (chunk: string) => void): Promise { const child = this.child if (!child?.stdin) return Promise.reject(new CliError('nonzero', 'serve not running')) const id = this.nextId++ + const effectiveTimeoutMs = this.warmed ? timeoutMs : Math.max(timeoutMs, DESKTOP_COLD_TIMEOUT_MS) return new Promise((resolve, reject) => { const timer = setTimeout(() => { // A hung request would block the serialized queue behind it; kill the @@ -484,8 +609,15 @@ class ServeClient { this.pending.delete(id) reject(new CliError('timeout', 'codeburn serve timed out')) child.kill('SIGKILL') - }, timeoutMs) - this.pending.set(id, { resolve, reject, timer }) + }, effectiveTimeoutMs) + this.pending.set(id, { + resolve, + reject, + timer, + warmsServe: args[0] === 'status', + decodedBytes: 0, + ...(onStderr ? { onStderr } : {}), + }) child.stdin!.write(JSON.stringify({ id, args }) + '\n', (err) => { if (err) { this.pending.delete(id) @@ -497,68 +629,113 @@ class ServeClient { } destroy(): void { + this.destroyed = true this.deaths = SERVE_MAX_RESTARTS - this.child?.kill('SIGKILL') - this.onDeath() + const child = this.child + if (!child) return + this.onDeath(child, false) + child.kill('SIGKILL') } } let serveClient: ServeClient | null = null -/** Start the resident serve child and its warm-up query. Called once from app - * startup (never from the spawn path, so unit tests of the scheduler and the - * cold-start flow are byte-identical without it). Safe to call repeatedly. */ -export function startServeWarmup(): void { +/** Start the resident serve child without issuing a query. The first real panel + * request is accepted immediately (even before the ready frame) and performs + * the one cold-cache hydration while streaming progress back to the splash. */ +export function startServe(): void { + if (shuttingDown) return const target = resolveTarget() if (!target) return if (serveClient?.disabled()) return - if (!serveClient) serveClient = new ServeClient(spawnSpecFor(target, ['serve', '--stdio'])) + if (!serveClient) { + const spec = spawnSpecFor(target, ['serve', '--stdio']) + spec.env = { ...spec.env, CODEBURN_PROGRESS: '1' } + serveClient = new ServeClient(spec) + } serveClient.start() } +function restartServeAfterMutation(): void { + // CLI-only consumers never started serve, so do not create a surprise daemon + // for them. In Electron, replace the resident child immediately so its parser + // and output memos cannot survive a successful config mutation. Reusing the + // client preserves its app-lifetime budget of unexpected child deaths. + if (!serveClient) return + serveClient.restartAfterMutation() +} + +function actionInvalidatesServe(args: string[]): boolean { + // Export only writes the caller-selected artifact. Every other current + // Electron action changes config or device state, and future actions restart + // by default until they are explicitly proven state-preserving. + return args[0] !== 'export' +} + +function isServeCompatibleEnv(extraEnv?: NodeJS.ProcessEnv): boolean { + if (!extraEnv) return true + const entries = Object.entries(extraEnv).filter(([, value]) => value !== undefined) + if (entries.length === 0) return true + return entries.length === 1 && entries[0]![0] === 'CODEBURN_PROGRESS' && entries[0]![1] === '1' +} + export function spawnCli( args: string[], opts: { timeoutMs?: number; onStderr?: (chunk: string) => void; extraEnv?: NodeJS.ProcessEnv; priority?: SpawnPriority } = {}, ): Promise { + if (shuttingDown) return Promise.reject(new CliError('nonzero', 'codeburn is shutting down')) const target = resolveTarget() if (!target) return Promise.reject(new CliError('not-found', 'codeburn CLI not found', notFoundStage())) const spec = spawnSpecFor(target, args) if (opts.extraEnv) spec.env = { ...spec.env, ...opts.extraEnv } - const key = JSON.stringify([spec.bin, ...spec.args]) - const cached = readCache.get(key) - if (cached && Date.now() - cached.at < COALESCE_TTL_MS) return Promise.resolve(cached.value) + const generation = readGeneration + const key = JSON.stringify([generation, spec.bin, ...spec.args]) const existing = readInflight.get(key) // A same-cadence re-poll during a slow cold warmup coalesces onto the one // in-flight child (which already carries onStderr); no second cold parse. - // Coalesce/cache hits settle here, BEFORE queueing, so they never hold a slot. + // Coalesced calls settle here, BEFORE queueing, so they never hold a slot. if (existing) return existing - // Serve fast-path: warm resident child answers the panel query without a - // spawn. The child is started once at app startup (startServeWarmup); until - // it is warm, every call keeps the plain spawn path. - if (SERVE_ROUTED.has(args[0] ?? '') && !opts.extraEnv) { + const priority = opts.priority ?? 'interactive' + + // Serve fast-path: the child is started once at app startup. It accepts the + // first real query before its ready frame, making that request the single + // cache warm-up. CODEBURN_PROGRESS is compatible because startServe sets it + // on the resident child; any other per-call env needs an isolated one-shot. + if (SERVE_ROUTED.has(args[0] ?? '') && isServeCompatibleEnv(opts.extraEnv)) { const serve = serveClient - if (serve?.isWarmAndReady()) { - const flight = serve.request(args, opts.timeoutMs ?? DEFAULT_TIMEOUT_MS) - .catch(() => runCli(spec, args[0] ?? '', opts.timeoutMs ?? DEFAULT_TIMEOUT_MS, opts.onStderr)) - .then(value => { readCache.set(key, { at: Date.now(), value }); return value }) + // Recover lazily from an unexpected child death. start() is synchronous and + // idempotent, and the client's lifetime death budget prevents an endlessly + // crashing binary from being respawned on every poll. + if (serve && !serve.isRunning() && !serve.disabled()) serve.start() + if (serve?.isRunning()) { + const flight = serve.request(args, opts.timeoutMs ?? DEFAULT_TIMEOUT_MS, opts.onStderr) + .catch(err => { + // App shutdown is terminal: never turn rejected resident requests + // into brand-new one-shot children after killAll() has reaped them. + if (serve.isDestroyed() || (err instanceof CliError && err.kind === 'too-large')) throw err + return runScheduledCli( + spec, + args[0] ?? '', + opts.timeoutMs ?? DEFAULT_TIMEOUT_MS, + priority, + opts.onStderr, + ) + }) .finally(() => { readInflight.delete(key) }) readInflight.set(key, flight) return flight } } - const priority = opts.priority ?? 'interactive' - const flight = (async () => { - await acquireSlot(priority) - try { - return await runCli(spec, args[0] ?? '', opts.timeoutMs ?? DEFAULT_TIMEOUT_MS, opts.onStderr) - } finally { - releaseSlot() - } - })() - .then(value => { readCache.set(key, { at: Date.now(), value }); return value }) + const flight = runScheduledCli( + spec, + args[0] ?? '', + opts.timeoutMs ?? DEFAULT_TIMEOUT_MS, + priority, + opts.onStderr, + ) .finally(() => { readInflight.delete(key) }) readInflight.set(key, flight) return flight @@ -568,6 +745,7 @@ export function spawnCli( * Mutations count as interactive, so they take a run slot ahead of any queued * background warm — a Settings save is never stuck behind speculative prefetch. */ export function spawnCliAction(args: string[], opts: { timeoutMs?: number } = {}): Promise { + if (shuttingDown) return Promise.resolve({ ok: false, stdout: '', stderr: 'codeburn is shutting down', code: null }) const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS const target = resolveTarget() if (!target) return Promise.resolve({ ok: false, stdout: '', stderr: 'codeburn CLI not found', code: null }) @@ -580,6 +758,7 @@ export function spawnCliAction(args: string[], opts: { timeoutMs?: number } = {} return { ok: false, stdout: '', stderr: 'codeburn cancelled', code: null } } try { + if (shuttingDown) return { ok: false, stdout: '', stderr: 'codeburn is shutting down', code: null } return await runAction(spec, args, timeoutMs) } finally { releaseSlot() @@ -600,9 +779,13 @@ function runAction(spec: SpawnSpec, args: string[], timeoutMs: number): Promise< settled = true clearTimeout(timer) activeChildren.delete(child) - // The action may have changed config the read cache still reflects; a - // Settings refetch fires immediately after, so serve it fresh data. - readCache.clear() + if (result.ok && actionInvalidatesServe(args)) { + // Fence coalescing before the action promise resolves. An immediate + // same-argv refetch belongs to the new config generation even while an + // older read is still running. + readGeneration += 1 + restartServeAfterMutation() + } resolve(result) } diff --git a/app/electron/main.ts b/app/electron/main.ts index 7d2c2bb4..d3676474 100644 --- a/app/electron/main.ts +++ b/app/electron/main.ts @@ -1,7 +1,7 @@ import { app, BrowserWindow, dialog, ipcMain, Menu, nativeTheme, shell, type MenuItemConstructorOptions } from 'electron' import path from 'node:path' -import { CliError, killAll, resolveCodeburnPath, spawnCli, spawnCliAction, startServeWarmup, type ActionResult, type SpawnPriority } from './cli' +import { CliError, DESKTOP_COLD_TIMEOUT_MS, resolveCodeburnPath, shutdownAll, spawnCli, spawnCliAction, startServe, type ActionResult, type SpawnPriority } from './cli' import { getQuota, sanitizeError } from './quota' import { Telemetry } from './telemetry' import { createUpdateChecker, type UpdateChecker, type UpdateStatus } from './updates' @@ -77,7 +77,7 @@ export type Envelope = { ok: true; value: T } | { ok: false; error: // slowness. Give the first (cold) overview a long window; revert to the default // once it succeeds. Sections gate their own first poll on this one resolving so // the cold hydration runs ONCE, not once per section in parallel. -const WARMUP_TIMEOUT_MS = 10 * 60_000 +const WARMUP_TIMEOUT_MS = DESKTOP_COLD_TIMEOUT_MS // Wire marker for CLI scan-progress lines (src/parser.ts: PROGRESS_LINE_PREFIX). const PROGRESS_LINE_PREFIX = 'CODEBURN_PROGRESS ' // IPC channel carrying cold-start scan-progress events to the splash. @@ -564,15 +564,15 @@ function bootstrap(): void { app.on('before-quit', createBeforeQuitHandler({ getTelemetry: () => telemetryInstance, - killAll, + killAll: shutdownAll, quit: () => app.quit(), })) void app.whenReady().then(() => { - // Start the resident serve child early so its warm-up (one cache parse) - // finishes during the first panels' cold spawns; every fetch after that - // answers from the warm child in milliseconds. - startServeWarmup() + // Start the resident child early, but issue no artificial warm-up query: + // the first real overview request is the single cache hydration and streams + // its progress through serve. Every later panel reuses that parsed cache. + startServe() // Consent-gated anonymous telemetry (desktop only). Nothing transmits until // the onboarding consent screen is completed and the toggle is on; EU/EEA/ // UK/CH installs default the toggle off. Dev builds never send. diff --git a/app/package.json b/app/package.json index efe4fde8..ab5a29b4 100644 --- a/app/package.json +++ b/app/package.json @@ -169,6 +169,7 @@ "$HOME/.copilot", "$HOME/.cursor", "$HOME/.deepseek", + "$HOME/.dsh/sessions", "$HOME/.factory", "$HOME/.forge", "$HOME/.gemini", diff --git a/app/renderer/App.test.tsx b/app/renderer/App.test.tsx index dbf5e8ff..d29b4c43 100644 --- a/app/renderer/App.test.tsx +++ b/app/renderer/App.test.tsx @@ -138,7 +138,12 @@ function installDefaultMocks() { summary: { healthScore: 100, healthGrade: 'A', findingCount: 0, periodCostUSD: 0, sessions: 0, calls: 0, potentialSavingsTokens: 0, potentialSavingsCostUSD: 0, - potentialSavingsPercent: 0, costRateUSD: 0, + potentialSavingsPercent: 0, costRateUSD: 0, measuredSavingsUSD: 0, + byClass: { + fix: { tokensSaved: 0, savingsUSD: 0, count: 0 }, + nudge: { tokensSaved: 0, savingsUSD: 0, count: 0 }, + keep: { tokensSaved: 0, savingsUSD: 0, count: 0 }, + }, }, findings: [], }) diff --git a/app/renderer/lib/types.ts b/app/renderer/lib/types.ts index 25394087..fa05bc6d 100644 --- a/app/renderer/lib/types.ts +++ b/app/renderer/lib/types.ts @@ -403,10 +403,12 @@ export type SpendFlow = { // ————— src/optimize.ts ————— export type WasteAction = - | { type: 'paste'; label: string; text: string; destination?: 'claude-md' | 'session-opener' | 'prompt' | 'shell-config' } + | { type: 'paste'; label: string; text: string; destination?: 'claude-md' | 'session-opener' | 'prompt' | 'shell-config' | 'manual' } | { type: 'command'; label: string; text: string } | { type: 'file-content'; label: string; path: string; content: string } +export type FindingClass = 'fix' | 'nudge' | 'keep' + export type OptimizeJsonReport = { period: { label: string; start: string | null; end: string | null } summary: { @@ -420,6 +422,8 @@ export type OptimizeJsonReport = { potentialSavingsCostUSD: number potentialSavingsPercent: number | null costRateUSD: number + measuredSavingsUSD: number + byClass: Record } findings: Array<{ id: string @@ -429,8 +433,21 @@ export type OptimizeJsonReport = { trend: 'active' | 'improving' | null tokensSaved: number estimatedSavingsUSD: number + class: FindingClass + basis: 'measured' | 'estimated' fix: WasteAction }> + /** Still-applied fixes, re-measured on every run. Absent on older CLIs. */ + appliedFixes?: Array<{ + id: string + kind: string + findingId: string | null + appliedAt: string + verdict: 'worked' | 'partial' | 'no-effect' | 'pending' + estimatedTokens: number + realizedTokens: number + undoCommand: string + }> } // ————— T1b: src/sharing/* (defined by the shared contract) ————— diff --git a/app/renderer/sections/Optimize.test.tsx b/app/renderer/sections/Optimize.test.tsx index 5219afaf..70013522 100644 --- a/app/renderer/sections/Optimize.test.tsx +++ b/app/renderer/sections/Optimize.test.tsx @@ -48,23 +48,31 @@ function makeOptimizeReport(): OptimizeJsonReport { healthScore: 72, healthGrade: 'C', findingCount: 3, periodCostUSD: 612.48, sessions: 88, calls: 1220, potentialSavingsTokens: 184_000, potentialSavingsCostUSD: 94.4, potentialSavingsPercent: 15.4, costRateUSD: 0.0005, + measuredSavingsUSD: 27.8, + byClass: { + fix: { tokensSaved: 18_200, savingsUSD: 9.1, count: 1 }, + nudge: { tokensSaved: 17_400, savingsUSD: 8.7, count: 1 }, + keep: { tokensSaved: 4_800, savingsUSD: 2.4, count: 1 }, + }, }, findings: [ { - id: 'cost-outliers', title: 'Opus is doing your small talk', + id: 'unused-mcp', title: 'Opus is doing your small talk', explanation: 'Small conversational requests are running on an expensive model.', severity: 'high', trend: 'active', tokensSaved: 18_200, estimatedSavingsUSD: 9.1, + class: 'fix', basis: 'estimated', fix: { type: 'paste', label: 'Paste into CLAUDE.md', text: 'Use Sonnet for routine questions.', destination: 'claude-md' }, }, { - id: 'context-heavy-sessions', title: 'Cache hit is low in agentseal-dash', + id: 'cost-outliers', title: 'Cache hit is low in agentseal-dash', explanation: 'Repeated context is not being served from cache.', severity: 'medium', - trend: null, tokensSaved: 17_400, estimatedSavingsUSD: 8.7, + trend: null, tokensSaved: 17_400, estimatedSavingsUSD: 8.7, class: 'nudge', basis: 'measured', fix: { type: 'command', label: 'Run this command', text: 'codeburn cache inspect' }, }, { - id: 'warmup-heavy', title: 'Batch tiny requests', explanation: 'Many short sessions repeat setup work.', + id: 'context-heavy-sessions', title: 'Batch tiny requests', explanation: 'Many short sessions repeat setup work.', severity: 'low', trend: 'improving', tokensSaved: 4_800, estimatedSavingsUSD: 2.4, + class: 'keep', basis: 'measured', fix: { type: 'file-content', label: 'Create configuration', path: '~/.codeburn/config.json', content: '{"batch":true}' }, }, ], @@ -121,6 +129,46 @@ describe('Optimize', () => { Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } }) }) + it('lists applied fixes with a glyph per verdict and the undo hint', async () => { + const report = makeOptimizeReport() + report.appliedFixes = [ + { id: 'a1', kind: 'archive-skill', findingId: 'unused-skills', appliedAt: '2026-07-06T00:00:00.000Z', verdict: 'worked', estimatedTokens: 300_000, realizedTokens: 280_000, undoCommand: 'codeburn act undo a1' }, + { id: 'b2', kind: 'defer-threshold', findingId: 'mcp-defer-threshold', appliedAt: '2026-07-07T00:00:00.000Z', verdict: 'partial', estimatedTokens: 600_000, realizedTokens: 420_000, undoCommand: 'codeburn act undo b2' }, + { id: 'c3', kind: 'shell-config', findingId: 'bash-output-cap', appliedAt: '2026-07-05T00:00:00.000Z', verdict: 'no-effect', estimatedTokens: 41_000, realizedTokens: 0, undoCommand: 'codeburn act undo c3' }, + { id: 'd4', kind: 'mcp-remove', findingId: null, appliedAt: '2026-07-09T00:00:00.000Z', verdict: 'pending', estimatedTokens: 0, realizedTokens: 0, undoCommand: 'codeburn act undo d4' }, + ] + getOptimizeReport.mockResolvedValue(report) + render() + + await screen.findByText('Applied fixes') + const rows = [...document.querySelectorAll('.opt-applied-row')] + expect(rows.map(r => r.className.split(' ')[1])).toEqual([ + 'opt-applied-worked', 'opt-applied-partial', 'opt-applied-no-effect', 'opt-applied-pending', + ]) + expect(rows[0]!.textContent).toContain('unused-skills') + expect(rows[0]!.textContent).toContain('est. 300K \u2192 280K') + expect(rows[3]!.textContent).toContain('mcp-remove') + expect(screen.getByText('codeburn act undo c3')).toBeTruthy() + }) + + it('omits the applied-fixes list when nothing is applied', async () => { + render() + await screen.findByText('Opus is doing your small talk') + expect(document.querySelector('.opt-applied')).toBeNull() + }) + + it('groups Waste findings under the fix / habits / FYI headers in order', async () => { + render() + + await screen.findByText('Opus is doing your small talk') + const groups = document.querySelectorAll('.opt-group') + expect([...groups].map(g => g.textContent)).toEqual([ + 'Fix now (apply-able) · 18.2K tokens · $9.10 · 1 finding', + 'Habits · 17.4K tokens · $8.70 · 1 finding', + 'FYI · 4.8K tokens · $2.40 · 1 finding', + ]) + }) + it('renders tabs and actionable Waste findings with impact, savings, explanation, and copy-paste fix', async () => { render() @@ -130,7 +178,7 @@ describe('Optimize', () => { expect(screen.getByText('Medium')).toHaveClass('opt-impact-medium') expect(screen.getByText('Low')).toHaveClass('opt-impact-low') expect(screen.getByText('$9.10')).toHaveClass('opt-finding-savings') - expect(screen.getByText('18.2K tokens')).toBeInTheDocument() + expect(screen.getByText('18.2K tokens · estimated')).toBeInTheDocument() expect(screen.getByRole('tab', { name: 'Waste $94.40' })).toBeInTheDocument() expect(screen.getByRole('tab', { name: 'Reverts $107.00' })).toBeInTheDocument() expect(screen.getByRole('tab', { name: 'Abandoned $65.40' })).toBeInTheDocument() @@ -160,6 +208,32 @@ describe('Optimize', () => { expect(screen.getByText('{"batch":true}')).toBeInTheDocument() }) + it('renders and copies connector guidance as a manual action', async () => { + const report = makeOptimizeReport() + report.findings.push({ + id: 'mcp-low-coverage', title: 'Underused claude.ai connector', + explanation: 'The connector loads unused tools.', severity: 'medium', + trend: null, tokensSaved: 2_000, estimatedSavingsUSD: 1, + // Connector-only: no appliable plan, so the finding is a nudge. + class: 'nudge', basis: 'estimated', + fix: { + type: 'paste', destination: 'manual', label: 'Manage the connector where it loads:', + text: 'Open /mcp and disable claude.ai Google Calendar.', + }, + }) + report.summary.byClass.nudge = { tokensSaved: 19_400, savingsUSD: 9.7, count: 2 } + getOptimizeReport.mockResolvedValue(report) + render() + const row = await screen.findByRole('button', { name: /Underused claude.ai connector/ }) + fireEvent.click(row) + + expect(screen.getByText('Manage the connector where it loads:')).toBeInTheDocument() + expect(screen.getByText('Open /mcp and disable claude.ai Google Calendar.')).toBeInTheDocument() + expect(row.parentElement?.querySelector('.opt-fix')).toHaveClass('opt-fix-paste') + fireEvent.click(screen.getByRole('button', { name: 'Copy' })) + await waitFor(() => expect(writeText).toHaveBeenCalledWith('Open /mcp and disable claude.ai Google Calendar.')) + }) + it('switches to Reverts and Abandoned and shows only the matching yield details', async () => { render() await screen.findByText('Opus is doing your small talk') diff --git a/app/renderer/sections/Optimize.tsx b/app/renderer/sections/Optimize.tsx index 03d8674b..ac9350c2 100644 --- a/app/renderer/sections/Optimize.tsx +++ b/app/renderer/sections/Optimize.tsx @@ -9,7 +9,7 @@ import { StaleBanner } from '../components/StaleBanner' import { type Polled, usePolled } from '../hooks/usePolled' import { formatCompact, formatUsd } from '../lib/format' import { codeburn } from '../lib/ipc' -import type { DateRange, MenubarPayload, OptimizeJsonReport, Period, SessionYieldJson, WasteAction, YieldJsonReport } from '../lib/types' +import type { DateRange, FindingClass, MenubarPayload, OptimizeJsonReport, Period, SessionYieldJson, WasteAction, YieldJsonReport } from '../lib/types' type OptimizeTab = 'waste' | 'reverts' | 'abandoned' | 'fixes' @@ -101,7 +101,51 @@ function WasteRows({ report }: { report: Polled }) {
{report.data.summary.findingCount.toLocaleString('en-US')} findings · {formatUsd(report.data.summary.potentialSavingsCostUSD)} potential · health {report.data.summary.healthScore}/100
- + + + + ) +} + +type AppliedFix = NonNullable[number] + +const VERDICT_GLYPH: Record = { + worked: '\u2713', + partial: '~', + 'no-effect': '\u2717', + pending: '\u2026', +} + +const VERDICT_LABEL: Record = { + worked: 'worked', + partial: 'under estimate', + 'no-effect': 'did not help', + pending: 'measuring', +} + +// Closes the loop after `optimize --apply`: what each applied fix actually +// measured, and for the ones that did nothing, how to put them back. +function AppliedFixRows({ fixes }: { fixes: AppliedFix[] }) { + if (!fixes.length) return null + + return ( +
+
Applied fixes
+ {fixes.map(fix => ( +
+ + {fix.findingId ?? fix.kind} + {VERDICT_LABEL[fix.verdict]} + + {fix.verdict === 'pending' + ? '\u2014' + : `est. ${formatCompact(fix.estimatedTokens)} \u2192 ${formatCompact(fix.realizedTokens)}`} + +
+ ))} + {fixes.some(fix => fix.verdict === 'no-effect') && ( +
Revert one that did not help: {fixes.find(fix => fix.verdict === 'no-effect')!.undoCommand}
+ )}
) } @@ -114,11 +158,17 @@ const IMPACT_ICON: Record<'high' | 'medium' | 'low', string> = { low: '↓', } +const CLASS_HEADERS: Record = { + fix: 'Fix now (apply-able)', + nudge: 'Habits', + keep: 'FYI', +} + function actionText(fix: WasteAction): string { return fix.type === 'file-content' ? fix.content : fix.text } -function ActionableFindingRows({ findings }: { findings: OptimizeFinding[] }) { +function ActionableFindingRows({ findings, byClass }: { findings: OptimizeFinding[]; byClass: OptimizeJsonReport['summary']['byClass'] }) { const [expandedId, setExpandedId] = useState(null) const [copiedId, setCopiedId] = useState(null) @@ -132,10 +182,18 @@ function ActionableFindingRows({ findings }: { findings: OptimizeFinding[] }) { return (
- {findings.map(finding => { + {findings.map((finding, i) => { const expanded = expandedId === finding.id + // Findings arrive class-sorted from the CLI, so a header goes in + // wherever the class changes. + const showHeader = finding.class !== findings[i - 1]?.class return ( + {showHeader && ( +
+ {CLASS_HEADERS[finding.class]} · {formatCompact(byClass[finding.class].tokensSaved)} tokens · {formatUsd(byClass[finding.class].savingsUSD)} · {byClass[finding.class].count} {byClass[finding.class].count === 1 ? 'finding' : 'findings'} +
+ )} {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>(new Map()) const [planUsages, setPlanUsages] = useState(initialPlanUsages ?? []) @@ -1460,14 +1492,20 @@ export function InteractiveDashboard({ initialProjects, initialDailyHistoryProje const generation = reloadGenerationRef.current setOptimizeLoading(true) try { - const result = await scanAndDetect(projects, currentRange()) + const result = await scanAndDetect(projects, currentRange(), activeProvider) if (reloadGenerationRef.current === generation) setOptimizeResult(result) + // Best effort: a bad journal never keeps the findings off screen. + try { + const { computeActReport } = await import('./act/report.js') + const applied = await computeActReport() + if (reloadGenerationRef.current === generation) setAppliedFixes(applied.appliedFixes) + } catch { /* the applied section is optional */ } } catch (error) { console.error(error) } finally { if (reloadGenerationRef.current === generation) setOptimizeLoading(false) } - }, [optimizeAvailable, projects, currentRange, optimizeLoading, optimizeResult]) + }, [optimizeAvailable, projects, currentRange, optimizeLoading, optimizeResult, activeProvider]) useEffect(() => { const refreshIntervalMs = getRefreshIntervalMs(refreshSeconds ?? 0) @@ -1626,7 +1664,7 @@ export function InteractiveDashboard({ initialProjects, initialDailyHistoryProje {view === 'compare' ? setView('dashboard')} /> : view === 'optimize' && optimizeResult - ? + ? : } {coachingNote && ( diff --git a/src/main.ts b/src/main.ts index d201920b..3ed919d8 100644 --- a/src/main.ts +++ b/src/main.ts @@ -11,6 +11,7 @@ import { renderStatusBar } from './format.js' import { toDateString } from './daily-cache.js' import { dateKey } from './day-aggregator.js' import { CATEGORY_LABELS, type DateRange, type ProjectSummary, type TaskCategory } from './types.js' +import type { AppliedFix } from './act/types.js' import { aggregateModelEfficiency } from './model-efficiency.js' import { buildPeriodData, buildMenubarPayloadForRange, buildDurablePeriod, type DurablePeriod } from './usage-aggregator.js' import { renderDashboard } from './dashboard.js' @@ -1310,12 +1311,13 @@ program program .command('menubar') - .description('Install and launch the macOS menubar app (one command, no clone)') - .option('--force', 'Reinstall even if an older copy is already in ~/Applications') + .description('Install and launch the menubar app on macOS and Windows (one command, no clone)') + .option('--force', 'Reinstall even if a copy is already installed') .action(async (opts: { force?: boolean }) => { try { const result = await installMenubarApp({ force: opts.force, cliVersion: version }) - console.log(`\n Ready. ${result.installedPath}\n`) + // A cancelled Windows installer leaves nothing to point at. + if (result.installedPath) console.log(`\n Ready. ${result.installedPath}\n`) } catch (err) { const message = err instanceof Error ? err.message : String(err) console.error(`\n Menubar install failed: ${message}\n`) @@ -1806,6 +1808,7 @@ program .option('--yes', 'With --apply: apply every appliable fix without prompting') .option('--dry-run', 'With --apply: print the plan and exit without changing anything') .option('--only ', 'With --apply: restrict to a comma-separated list of finding ids') + .option('--auto-revert', 'Undo applied fixes that measured no reduction (never CLAUDE.md rules)') .action(async (opts) => { assertProvider(opts.provider, 'optimize') const format = opts.json ? 'json' : opts.format @@ -1831,28 +1834,35 @@ program const projects = await parseAllSessions(range, opts.provider) if (opts.apply) { const { runOptimizeApply } = await import('./act/optimize-apply.js') - await runOptimizeApply(projects, range, { yes: opts.yes, dryRun: opts.dryRun, only: opts.only }) + await runOptimizeApply(projects, range, { yes: opts.yes, dryRun: opts.dryRun, only: opts.only, provider: opts.provider }) return } assertFormat(format, ['text', 'json'], 'optimize') - if (format === 'text') { - // Surface realized savings from applied actions. Best effort: optimize - // must never fail because of journal contents, so any error just drops - // the header. computeActReport returns fast without scanning when the - // journal has no eligible applied actions, so users who never opted in - // see identical output. - let appliedHeader: string | undefined - let previouslyApplied: Record | undefined - try { - const { computeActReport, buildOptimizeAppliedHeader } = await import('./act/report.js') - const applied = await computeActReport() - appliedHeader = buildOptimizeAppliedHeader(applied) ?? undefined - previouslyApplied = applied.appliedByFinding - } catch { /* the header is optional; never block the findings */ } - await runOptimize(projects, label, range, { format, appliedHeader, previouslyApplied }) - } else { - await runOptimize(projects, label, range, { format }) - } + // Surface realized savings from applied actions, and re-measure every one + // of them. Best effort: optimize must never fail because of journal + // contents, so any error just drops the extras. computeActReport returns + // fast without scanning when the journal has no applied actions, so users + // who never opted in see identical output. + let appliedHeader: string | undefined + let previouslyApplied: Record | undefined + let appliedFixes: AppliedFix[] | undefined + try { + const { computeActReport, buildOptimizeAppliedHeader, autoRevertNoEffect } = await import('./act/report.js') + const applied = await computeActReport() + appliedHeader = buildOptimizeAppliedHeader(applied) ?? undefined + previouslyApplied = applied.appliedByFinding + appliedFixes = applied.appliedFixes + if (opts.autoRevert) { + const { lines, revertedIds } = await autoRevertNoEffect(appliedFixes) + appliedFixes = appliedFixes.filter(f => !revertedIds.has(f.id)) + // JSON output must stay parseable, so the revert log goes to stderr there. + for (const line of lines) { + if (format === 'json') process.stderr.write(` ${line}\n`) + else console.log(` ${line}`) + } + } + } catch { /* the applied section is optional; never block the findings */ } + await runOptimize(projects, label, range, { format, appliedHeader, previouslyApplied, appliedFixes, provider: opts.provider }) }) program @@ -2075,6 +2085,7 @@ program .option('--by-agent', 'One row per (provider, model, agent) instead of one row per (provider, model). Claude subagent transcripts only; other providers and main sessions bucket under "main"') .option('--top ', 'Show only the top N rows', (v: string) => parseInt(v, 10)) .option('--min-cost ', 'Hide rows below this cost threshold', (v: string) => parseFloat(v)) + .option('--unpriced', 'Show only models with usage that currently price at $0') .option('--no-totals', 'Suppress the footer totals row') .option('--format ', 'Output format: table, markdown, json, csv', 'table') .action(async (opts) => { @@ -2099,13 +2110,21 @@ program } const projects = await parseAllSessions(range, opts.provider) - const rows = await aggregateModels(projects, { + let rows = await aggregateModels(projects, { byTask: !!opts.byTask, byAgent: !!opts.byAgent, taskFilter: opts.task, topN: typeof opts.top === 'number' && Number.isFinite(opts.top) ? opts.top : undefined, - minCost: typeof opts.minCost === 'number' && Number.isFinite(opts.minCost) ? opts.minCost : 0.01, + minCost: typeof opts.minCost === 'number' && Number.isFinite(opts.minCost) ? opts.minCost : (opts.unpriced ? 0 : 0.01), }) + if (opts.unpriced) { + rows = rows.filter(row => findUnpricedModels([{ + model: row.model, + calls: row.calls, + cost: row.costUSD, + tokens: row.totalTokens, + }]).length > 0) + } const fmt = (opts.format ?? 'table').toLowerCase() if (rows.length === 0 && (fmt === 'table' || fmt === 'markdown')) { diff --git a/src/menubar-installer.ts b/src/menubar-installer.ts index b43b136f..62494349 100644 --- a/src/menubar-installer.ts +++ b/src/menubar-installer.ts @@ -8,6 +8,7 @@ import { pipeline } from 'node:stream/promises' import { Readable } from 'node:stream' import { ProxyAgent, fetch as undiciFetch } from 'undici' +import { getCodeburnCacheDir } from './cache-dir.js' import { buildPersistentCodeburnLookupPath, resolvePersistentCodeburnPathFromWhichOutput, @@ -22,6 +23,11 @@ const EXPECTED_BUNDLE_ID = 'org.agentseal.codeburn-menubar' const VERSIONED_ASSET_PATTERN = /^CodeBurnMenubar-v.+\.zip$/ const APP_PROCESS_NAME = 'CodeBurnMenubar' const SUPPORTED_OS = 'darwin' +/// The Windows tray app (windows/) ships as an .msi under its own `windows-v*` tag. GitHub +/// rewrites the spaces in the bundle name to dots when it stores the asset, so both the asset +/// name and its download URL carry `CodeBurn.Menubar_...`. +const WINDOWS_PRODUCT_NAME = 'CodeBurn Menubar' +const WINDOWS_ASSET_PATTERN = /^CodeBurn\.Menubar_.+_x64_en-US\.msi$/ const MIN_MACOS_MAJOR = 14 const PERSISTED_CLI_PATH = join(homedir(), 'Library', 'Application Support', 'CodeBurn', 'codeburn-cli-path.v1') const PERSISTENT_CLI_REQUIRED_MESSAGE = @@ -31,8 +37,45 @@ export type InstallResult = { installedPath: string; launched: boolean } export type ReleaseAsset = { name: string; browser_download_url: string } export type ReleaseResponse = { tag_name: string; assets: ReleaseAsset[] } +/// `zip` is the platform's primary asset: the mac bundle zip, or the Windows .msi. export type ResolvedAssets = { release: ReleaseResponse; zip: ReleaseAsset; checksum: ReleaseAsset } -export type InstallOptions = { force?: boolean; cliVersion?: string } +export type InstallOptions = { + force?: boolean + cliVersion?: string + platform?: string + windows?: WindowsInstallHooks +} + +/// What differs per platform between the mac and Windows installs: which release tag holds the +/// build, and which asset in it is the installable. Everything downstream - versioned URL first, +/// release-API scan as fallback, retrying download, checksum verify - is shared. +export type ReleaseSpec = { + tagPrefix: string + assetPattern: RegExp + assetName: (version: string) => string + missingAsset: (tag: string) => string + noRelease: string +} + +const MAC_RELEASE: ReleaseSpec = { + tagPrefix: 'mac-v', + assetPattern: VERSIONED_ASSET_PATTERN, + assetName: version => `CodeBurnMenubar-v${version}.zip`, + missingAsset: tag => + `No ${APP_BUNDLE_NAME} versioned zip found in release ${tag}. ` + + `Check https://github.com/getagentseal/codeburn/releases.`, + noRelease: 'No mac-v* release with a CodeBurnMenubar-v*.zip and checksum was found.', +} + +export const WINDOWS_RELEASE: ReleaseSpec = { + tagPrefix: 'windows-v', + assetPattern: WINDOWS_ASSET_PATTERN, + assetName: version => `CodeBurn.Menubar_${version}_x64_en-US.msi`, + missingAsset: tag => + `No ${WINDOWS_PRODUCT_NAME} .msi found in release ${tag}. ` + + `Check https://github.com/getagentseal/codeburn/releases.`, + noRelease: 'No windows-v* release with a CodeBurn.Menubar_*.msi and checksum was found.', +} type ProxyEnv = Partial> type FetchOptions = Parameters[1] type HeaderGetter = { get(name: string): string | null } @@ -47,6 +90,10 @@ type FetchLikeResponse = { text(): Promise } type FetchImpl = (url: string, options?: FetchOptions) => Promise +/// The release-API lookup reads JSON instead of streaming a body, so it takes its own narrow +/// response shape rather than widening FetchLikeResponse for every asset download fake. +export type ReleaseApiFetch = (url: string, options?: FetchOptions) => + Promise<{ ok: boolean; status: number; headers: HeaderGetter; json(): Promise }> /// Release-asset delivery (github.com -> Azure blob) occasionally returns a transient 5xx or /// drops the socket. Three attempts with a short exponential backoff (0.5s, then 1s) rides out @@ -96,14 +143,9 @@ function fetchWithProxy(url: string, options: FetchOptions = {}) { return undiciFetch(url, dispatcher ? { ...options, dispatcher } : options) } -export function resolveMenubarReleaseAssets(release: ReleaseResponse): ResolvedAssets { - const zip = release.assets.find(a => VERSIONED_ASSET_PATTERN.test(a.name)) - if (!zip) { - throw new Error( - `No ${APP_BUNDLE_NAME} versioned zip found in release ${release.tag_name}. ` + - `Check https://github.com/getagentseal/codeburn/releases.` - ) - } +export function resolveMenubarReleaseAssets(release: ReleaseResponse, spec: ReleaseSpec = MAC_RELEASE): ResolvedAssets { + const zip = release.assets.find(a => spec.assetPattern.test(a.name)) + if (!zip) throw new Error(spec.missingAsset(release.tag_name)) const checksum = release.assets.find(a => a.name === `${zip.name}.sha256`) if (!checksum) { throw new Error(`Missing checksum asset ${zip.name}.sha256 in release ${release.tag_name}.`) @@ -111,28 +153,28 @@ export function resolveMenubarReleaseAssets(release: ReleaseResponse): ResolvedA return { release, zip, checksum } } -export function resolveLatestMenubarReleaseAssets(releases: ReleaseResponse[]): ResolvedAssets { +export function resolveLatestMenubarReleaseAssets(releases: ReleaseResponse[], spec: ReleaseSpec = MAC_RELEASE): ResolvedAssets { for (const release of releases) { - if (!release.tag_name.startsWith('mac-v')) continue + if (!release.tag_name.startsWith(spec.tagPrefix)) continue try { - return resolveMenubarReleaseAssets(release) + return resolveMenubarReleaseAssets(release, spec) } catch { continue } } - throw new Error('No mac-v* release with a CodeBurnMenubar-v*.zip and checksum was found.') + throw new Error(spec.noRelease) } function normalizeCliVersion(cliVersion: string): string { return cliVersion.trim().replace(/^v/, '') } -export function resolveVersionedMenubarReleaseAssets(cliVersion: string): ResolvedAssets { +export function resolveVersionedMenubarReleaseAssets(cliVersion: string, spec: ReleaseSpec = MAC_RELEASE): ResolvedAssets { const version = normalizeCliVersion(cliVersion) if (!version) throw new Error('Cannot resolve CodeBurn Menubar release without a CLI version.') - const tagName = `mac-v${version}` - const zipName = `CodeBurnMenubar-v${version}.zip` + const tagName = `${spec.tagPrefix}${version}` + const zipName = spec.assetName(version) const checksumName = `${zipName}.sha256` const releaseBase = `${RELEASE_DOWNLOAD_BASE}/${tagName}` const zip = { name: zipName, browser_download_url: `${releaseBase}/${zipName}` } @@ -207,8 +249,8 @@ async function sysProductVersion(): Promise { }) } -async function fetchLatestReleaseAssets(): Promise { - const response = await fetchWithProxy(RELEASE_API, { +async function fetchLatestReleaseAssets(spec: ReleaseSpec = MAC_RELEASE, fetchImpl?: ReleaseApiFetch): Promise { + const response = await (fetchImpl ?? fetchWithProxy)(RELEASE_API, { headers: { 'User-Agent': 'codeburn-menubar-installer', Accept: 'application/vnd.github+json', @@ -218,7 +260,7 @@ async function fetchLatestReleaseAssets(): Promise { throw new HttpStatusError(formatGitHubReleaseLookupError(response.status, response.headers), response.status) } const body = await response.json() as ReleaseResponse[] - return resolveLatestMenubarReleaseAssets(body) + return resolveLatestMenubarReleaseAssets(body, spec) } /// 5xx means "GitHub/the CDN is unhappy right now" and is worth another attempt. 4xx is not: @@ -473,7 +515,165 @@ async function killRunningApp(): Promise { } } +/// Windows mirror of the mac install below: pin the release to the CLI's own version, fall back +/// to the newest windows-v* release, verify the sha256 before anything executes the file, hand +/// the .msi to msiexec, then launch what it installed. +const WINDOWS_UNINSTALL_KEYS = [ + 'HKLM\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall', + 'HKLM\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall', + 'HKCU\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall', +] +/// 3010 is "installed, reboot to finish"; 1602 is the user closing the UAC/installer prompt. +const MSI_EXIT_REBOOT_REQUIRED = 3010 +const MSI_EXIT_USER_CANCEL = 1602 + +export type WindowsInstallHooks = { + fetchOptions?: AssetFetchOptions + apiFetch?: ReleaseApiFetch + runInstaller?: (exe: string, args: string[]) => Promise + queryRegistry?: () => Promise + launch?: (exePath: string) => void + log?: (message: string) => void + stagingDir?: string + env?: NodeJS.ProcessEnv +} + +export type InstalledWindowsMenubar = { version: string; exePath: string } + +/// Windows' `CreateProcess` searches the current directory before `PATH`, so spawning `msiexec` +/// or `reg` by bare name lets anything dropped next to the CLI impersonate a system tool. Same +/// rule the tray app follows (windows/src-tauri/src/cli.rs: system32_path). +export function resolveSystem32Path(exe: string, env: NodeJS.ProcessEnv = process.env): string { + const root = env.SystemRoot + const base = root && /^[a-zA-Z]:[\\/]/.test(root) ? root.replace(/[\\/]+$/, '') : 'C:\\Windows' + return `${base}\\System32\\${exe}` +} + +/// Reads `reg query ... /s` output, which prints one blank-line separated block per subkey. +export function parseInstalledWindowsMenubar(regOutput: string): InstalledWindowsMenubar | undefined { + for (const block of regOutput.split(/\r?\n\s*\r?\n/)) { + const values = new Map() + for (const line of block.split(/\r?\n/)) { + const match = /^\s+(.+?)\s{4}REG_\w+\s{4}(.*)$/.exec(line) + if (match) values.set(match[1]!.trim(), match[2]!.trim()) + } + if (values.get('DisplayName') !== WINDOWS_PRODUCT_NAME) continue + const location = values.get('InstallLocation') + // DisplayIcon is `[,]` and points at the installed binary when there is no + // InstallLocation to join onto. + const icon = values.get('DisplayIcon')?.split(',')[0]?.trim() + const exePath = location + ? `${location.replace(/[\\/]+$/, '')}\\${WINDOWS_PRODUCT_NAME}.exe` + : icon + if (!exePath) continue + return { version: values.get('DisplayVersion') ?? '', exePath } + } + return undefined +} + +async function queryWindowsUninstallRegistry(env: NodeJS.ProcessEnv): Promise { + const reg = resolveSystem32Path('reg.exe', env) + // reg exits non-zero for a hive the machine does not have; an empty block is the right answer. + const outputs = await Promise.all( + WINDOWS_UNINSTALL_KEYS.map(key => captureCommand(reg, ['query', key, '/s']).catch(() => '')), + ) + return outputs.join('\n\n') +} + +async function runMsiexec(exe: string, args: string[]): Promise { + return new Promise((resolve, reject) => { + const proc = spawn(exe, args, { stdio: 'inherit' }) + proc.on('error', reject) + proc.on('close', code => resolve(code ?? 1)) + }) +} + +function launchWindowsApp(exePath: string): void { + const proc = spawn(exePath, [], { detached: true, stdio: 'ignore' }) + proc.on('error', err => console.error(`Could not launch ${exePath}: ${err.message}`)) + proc.unref() +} + +async function stageWindowsInstaller( + assets: ResolvedAssets, + stagingDir: string, + hooks: WindowsInstallHooks, + log: (message: string) => void, +): Promise { + const { zip: msi, checksum } = assets + const msiPath = join(stagingDir, msi.name) + log(`Downloading ${msi.name}...`) + await downloadToFile(msi.browser_download_url, msiPath, hooks.fetchOptions) + log('Verifying checksum...') + await verifyChecksum(msiPath, checksum.browser_download_url, hooks.fetchOptions) + return msiPath +} + +async function installWindowsMenubarApp(options: InstallOptions): Promise { + const hooks = options.windows ?? {} + const log = hooks.log ?? console.log + const env = hooks.env ?? process.env + const queryRegistry = hooks.queryRegistry ?? (() => queryWindowsUninstallRegistry(env)) + const launch = hooks.launch ?? launchWindowsApp + const cliVersion = options.cliVersion ? normalizeCliVersion(options.cliVersion) : '' + + const installed = parseInstalledWindowsMenubar(await queryRegistry()) + if (installed && !options.force && (!cliVersion || installed.version === cliVersion)) { + launch(installed.exePath) + log('Launched CodeBurn Menubar.') + return { installedPath: installed.exePath, launched: true } + } + + let assets: ResolvedAssets + if (cliVersion) { + log(`Resolving CodeBurn Menubar v${cliVersion}...`) + assets = resolveVersionedMenubarReleaseAssets(cliVersion, WINDOWS_RELEASE) + } else { + log('Looking up the latest CodeBurn Menubar release...') + assets = await fetchLatestReleaseAssets(WINDOWS_RELEASE, hooks.apiFetch) + } + + const stagingDir = hooks.stagingDir ?? await (async () => { + await mkdir(getCodeburnCacheDir(), { recursive: true }) + return mkdtemp(join(getCodeburnCacheDir(), 'menubar-')) + })() + try { + let msiPath: string + try { + msiPath = await stageWindowsInstaller(assets, stagingDir, hooks, log) + } catch (err) { + if (!cliVersion || !isMissingDirectAssetError(err)) throw err + log(`CodeBurn Menubar v${cliVersion} assets were not found. Looking up the latest CodeBurn Menubar release...`) + assets = await fetchLatestReleaseAssets(WINDOWS_RELEASE, hooks.apiFetch) + msiPath = await stageWindowsInstaller(assets, stagingDir, hooks, log) + } + + log('Installing...') + const msiexec = resolveSystem32Path('msiexec.exe', env) + const exitCode = await (hooks.runInstaller ?? runMsiexec)(msiexec, ['/i', msiPath, '/passive', '/norestart']) + if (exitCode === MSI_EXIT_USER_CANCEL) { + log('Installation was cancelled; nothing was installed.') + return { installedPath: '', launched: false } + } + if (exitCode !== 0 && exitCode !== MSI_EXIT_REBOOT_REQUIRED) { + throw new Error(`msiexec exited with ${exitCode} while installing ${assets.zip.name}.`) + } + if (exitCode === MSI_EXIT_REBOOT_REQUIRED) log('Windows wants a restart to finish the install.') + + const nowInstalled = parseInstalledWindowsMenubar(await queryRegistry()) + if (!nowInstalled) { + throw new Error('CodeBurn Menubar installed, but it was not found in the uninstall registry; start it from the Start menu.') + } + launch(nowInstalled.exePath) + log('Launched CodeBurn Menubar.') + return { installedPath: nowInstalled.exePath, launched: true } + } finally { + if (!hooks.stagingDir) await rm(stagingDir, { recursive: true, force: true }) + } +} + export async function installMenubarApp(options: InstallOptions = {}): Promise { + if ((options.platform ?? platform()) === 'win32') return installWindowsMenubarApp(options) await ensureSupportedPlatform() await persistCodeburnPath() diff --git a/src/models.ts b/src/models.ts index f1d9ad0b..0ad8fccc 100644 --- a/src/models.ts +++ b/src/models.ts @@ -1,8 +1,9 @@ import { readFile, writeFile, mkdir } from 'fs/promises' import { join } from 'path' -import { homedir } from 'os' -import snapshotData from './data/litellm-snapshot.json' -import fallbackData from './data/pricing-fallback.json' + +import { getCodeburnCacheDir } from './cache-dir.js' +import snapshotData from './data/litellm-snapshot.json' with { type: 'json' } +import fallbackData from './data/pricing-fallback.json' with { type: 'json' } import { fetchWithTimeout } from './fetch-utils.js' export type ModelCosts = { @@ -143,13 +144,8 @@ function getLowercasePricingIndex(): Map { return lowercasePricingIndex } -function getCacheDir(): string { - if (process.env['CODEBURN_CACHE_DIR']) return process.env['CODEBURN_CACHE_DIR'] - return join(homedir(), '.cache', 'codeburn') -} - function getCachePath(): string { - return join(getCacheDir(), 'litellm-pricing.json') + return join(getCodeburnCacheDir(), 'litellm-pricing.json') } /// Clamp a per-token rate to a sane non-negative value. Defense in depth @@ -202,7 +198,7 @@ async function fetchAndCachePricing(): Promise> { if (stripped !== name && !pricing.has(stripped)) pricing.set(stripped, costs) } - await mkdir(getCacheDir(), { recursive: true }) + await mkdir(getCodeburnCacheDir(), { recursive: true }) await writeFile(getCachePath(), JSON.stringify({ timestamp: Date.now(), data: Object.fromEntries(pricing), @@ -311,7 +307,7 @@ const BUILTIN_ALIASES: Record = { // reports that quote literal slugs (e.g. forum.cursor.com/t/154933). 'claude-4-sonnet': 'claude-sonnet-4', 'claude-4-sonnet-1m': 'claude-sonnet-4', - 'claude-4-sonnet-thinking': 'claude-sonnet-4-5', + 'claude-4-sonnet-thinking': 'claude-sonnet-4', 'claude-4.5-sonnet': 'claude-sonnet-4-5', 'claude-4.5-sonnet-thinking': 'claude-sonnet-4-5', 'claude-4.6-sonnet': 'claude-sonnet-4-6', @@ -999,3 +995,33 @@ export function getShortModelName(model: string): string { } return canonical } + +// Pricing is process-global state assembled at CLI startup from the cached +// LiteLLM snapshot plus user config. A parse worker thread starts with none of +// it, and re-running loadPricing() there would mean N more disk reads (or, on a +// cold pricing cache, N network fetches). Ship the resolved state across +// instead, so every thread prices a call exactly as the main thread would. +export type PricingSnapshot = { + pricing: Map + aliases: Record + priceOverrides: Record + localModelSavings: Record +} + +export function snapshotPricingState(): PricingSnapshot { + return { + pricing: pricingCache, + aliases: userAliases, + priceOverrides: userPriceOverridesConfig, + localModelSavings: userLocalModelSavings, + } +} + +export function restorePricingState(snapshot: PricingSnapshot): void { + pricingCache = snapshot.pricing + sortedPricingKeys = null + lowercasePricingIndex = null + setModelAliases(snapshot.aliases) + setPriceOverrides(snapshot.priceOverrides) + setLocalModelSavings(snapshot.localModelSavings) +} diff --git a/src/optimize.ts b/src/optimize.ts index 63d49330..7984800a 100644 --- a/src/optimize.ts +++ b/src/optimize.ts @@ -1,5 +1,7 @@ import chalk from 'chalk' +import stripAnsi from 'strip-ansi' import { isReadShapedBashCommand } from './bash-utils.js' +import { createHash } from 'crypto' import { readdir, stat } from 'fs/promises' import { existsSync, statSync } from 'fs' import { basename, join } from 'path' @@ -12,6 +14,7 @@ import type { DateRange, ProjectSummary, SessionSummary } from './types.js' import { formatCost } from './currency.js' import { formatTokens } from './format.js' import { recommendModelDefault, type ModelDefaultRecommendation } from './act/model-defaults.js' +import { appliedFixGlyph, formatAppliedFix, type AppliedFix } from './act/types.js' import { aggregateFileChurn, buildCoachingNotes, scanUserCorrections, medianTimeToFirstEditMs, worstOneShotCategory, type ReworkedFile } from './workflow-insights.js' // ============================================================================ @@ -168,6 +171,17 @@ const DEFER_THRESHOLD_DEFAULT_PERCENT = 10 const DEFER_THRESHOLD_MAX_PERCENT = 100 const DEFER_THRESHOLD_MIN_TOKENS_PER_SESSION = 5_000 const DEFER_THRESHOLD_MEDIUM_IMPACT_TOKENS = 200_000 +// "recurring-context": the same block opening session after session. 1.5 KB +// is roughly 400 tokens at BASH_TOKENS_PER_CHAR — below that a repeated +// opener costs too little to be worth a habit change, and five sessions is +// where "I keep pasting this" stops looking like coincidence. +const RECURRING_CONTEXT_MIN_CHARS = 1_500 +const RECURRING_CONTEXT_MIN_SESSIONS = 5 +const RECURRING_CONTEXT_NORMALIZE_SLACK = 4 +const RECURRING_CONTEXT_PREVIEW = 3 +const RECURRING_CONTEXT_PREVIEW_CHARS = 80 +const RECURRING_CONTEXT_MEDIUM_IMPACT_TOKENS = 50_000 +const RECURRING_CONTEXT_HIGH_IMPACT_TOKENS = 200_000 // ============================================================================ // Scoring constants @@ -232,6 +246,7 @@ export type PasteDestination = | 'session-opener' // one-time paste at the start of a NEW session | 'prompt' // one-time ask in the current Claude conversation | 'shell-config' // append to ~/.zshrc / ~/.bashrc + | 'manual' // instructions the user carries out directly export type WasteAction = | { type: 'paste'; label: string; text: string; destination?: PasteDestination } @@ -263,6 +278,154 @@ export type FindingId = | 'unused-agents' | 'unused-skills' | 'unused-commands' + | 'recurring-context' + +/// How a finding is meant to be acted on: +/// - `fix` CodeBurn can write the change itself (`codeburn optimize --apply`) +/// - `nudge` behavioural, the user changes a habit +/// - `keep` informational; the cost may well be justified +export type FindingClass = 'fix' | 'nudge' | 'keep' + +/// Where a finding's `tokensSaved` number comes from: +/// - `measured` summed from provider-counted usage on the parsed calls +/// - `estimated` a schema/heuristic model (per-tool sizes, recovery fractions) +/// A detector that mixes the two counts as `estimated`. +export type FindingBasis = 'measured' | 'estimated' + +/// Static class per finding id. `fix` entries are exactly the ids `buildPlan` +/// (src/act/plans.ts) routes to a plan builder; tests assert the two lists +/// stay equal. Instances that lack the payload their builder needs fall back +/// to `nudge` via `findingClass`. +export const FINDING_CLASS: Record = { + 'read-edit-ratio': 'fix', // CLAUDE.md rule block + 'build-folder-reads': 'fix', // CLAUDE.md rule block + 'redundant-rereads': 'nudge', + 'warmup-heavy': 'nudge', + 'unused-mcp': 'fix', + 'mcp-low-coverage': 'fix', + 'mcp-project-scope': 'fix', + 'mcp-deferral-off': 'fix', + 'mcp-alwaysload-hygiene': 'fix', + 'mcp-defer-threshold': 'fix', + 'retry-heavy-capabilities': 'nudge', + 'low-worth-sessions': 'nudge', + 'context-heavy-sessions': 'keep', // context-heavy work is often load-bearing + 'cost-outliers': 'nudge', + 'claude-md-too-long': 'nudge', // trimming is a judgement call, not a rule block + 'bash-output-cap': 'fix', + 'unused-agents': 'fix', + 'unused-skills': 'fix', + 'unused-commands': 'fix', + 'recurring-context': 'nudge', +} + +/// Ids whose plan is built from the `apply` payload: without it the plan +/// builder returns null, so the finding is only a nudge. +const CLASS_NEEDS_APPLY: ReadonlySet = new Set([ + 'unused-mcp', + 'mcp-low-coverage', + 'mcp-project-scope', + 'mcp-deferral-off', + 'mcp-alwaysload-hygiene', + 'mcp-defer-threshold', + 'unused-agents', + 'unused-skills', + 'unused-commands', +]) + +/// Static basis per finding id. Only the two session-level detectors sum +/// provider-counted tokens end to end; everything else multiplies a modelled +/// per-unit size or a recovery fraction. +export const FINDING_BASIS: Record = { + 'read-edit-ratio': 'estimated', // reads x AVG_TOKENS_PER_READ + 'build-folder-reads': 'estimated', // reads x AVG_TOKENS_PER_READ + 'redundant-rereads': 'estimated', // reads x AVG_TOKENS_PER_READ + 'warmup-heavy': 'estimated', // observed median minus a modelled baseline + 'unused-mcp': 'estimated', // tools x TOKENS_PER_MCP_TOOL x sessions + 'mcp-low-coverage': 'estimated', // schema-size model, only capped by observed cache tokens + 'mcp-project-scope': 'estimated', // same schema-size model + 'mcp-deferral-off': 'estimated', // schema-size model x affected sessions + 'mcp-alwaysload-hygiene': 'estimated', // tools x TOKENS_PER_MCP_TOOL x loaded sessions + 'mcp-defer-threshold': 'estimated', // definition-size model x sessions + 'retry-heavy-capabilities': 'estimated', // real turn tokens x recovery fraction + 'low-worth-sessions': 'estimated', // real session tokens x recovery fraction + 'context-heavy-sessions': 'measured', // counted input/cache tokens above the target ratio + 'cost-outliers': 'measured', // counted session tokens above the peer average + 'claude-md-too-long': 'estimated', // lines x CLAUDEMD_TOKENS_PER_LINE + 'bash-output-cap': 'estimated', // chars x BASH_TOKENS_PER_CHAR + 'unused-agents': 'estimated', // count x TOKENS_PER_AGENT_DEF + 'unused-skills': 'estimated', // count x TOKENS_PER_SKILL_DEF + 'unused-commands': 'estimated', // count x TOKENS_PER_COMMAND_DEF + // Provider usage is per API call: the first turn's input tokens mix the + // system prompt, tool schemas and CLAUDE.md in with the pasted block, so + // nothing counted isolates the block. Its size is modelled from its bytes. + 'recurring-context': 'estimated', // block chars x BASH_TOKENS_PER_CHAR x repeats +} + +/// Scope label for a setting that lives in ~/.zshrc / ~/.bashrc. The MCP +/// deferral plans (defer-enable, defer-threshold) refuse to rewrite an +/// override found there and report it instead; bash-output-cap does append +/// its own marker block to the shell rc. +export const SHELL_PROFILE_SCOPE = 'shell profile' + +export function findingClass(f: WasteFinding): FindingClass { + const base = FINDING_CLASS[f.id] + if (base !== 'fix') return base + if (CLASS_NEEDS_APPLY.has(f.id) && !f.apply) return 'nudge' + const apply = f.apply + if ((apply?.kind === 'defer-enable' || apply?.kind === 'defer-threshold') && apply.settingScope === SHELL_PROFILE_SCOPE) { + return 'nudge' + } + // Of the deferral causes only these two have a plan; the rest are manual + // advice (Vertex policy, an outdated Claude Code, an unverified proxy). + if (apply?.kind === 'defer-enable' && apply.cause !== 'env-false' && apply.cause !== 'proxy-verified') return 'nudge' + return 'fix' +} + +export function findingBasis(f: WasteFinding): FindingBasis { + return f.basis ?? FINDING_BASIS[f.id] +} + +const CLASS_ORDER: Record = { fix: 0, nudge: 1, keep: 2 } + +export const CLASS_HEADERS: Record = { + fix: 'Fix now (apply-able)', + nudge: 'Habits', + keep: 'FYI', +} + +export type ClassTotals = { tokensSaved: number; savingsUSD: number; count: number } + +export function classTotals(findings: WasteFinding[], costRate: number): Record { + const totals: Record = { + fix: { tokensSaved: 0, savingsUSD: 0, count: 0 }, + nudge: { tokensSaved: 0, savingsUSD: 0, count: 0 }, + keep: { tokensSaved: 0, savingsUSD: 0, count: 0 }, + } + for (const f of findings) { + const cls = findingClass(f) + // A `fix` whose plan owns only part of its estimate (a mixed local + + // claude.ai connector MCP finding) contributes only the apply-able + // subset, so this subtotal and the "apply-able" headline never promise + // what `--apply` cannot recover. The finding keeps the whole + // opportunity in its own `tokensSaved`, so the fix subtotal can be + // smaller than the findings listed under it. + const tokens = cls === 'fix' ? f.applyTokensSaved ?? f.tokensSaved : f.tokensSaved + const t = totals[cls] + t.tokensSaved += tokens + t.savingsUSD += tokens * costRate + t.count++ + } + return totals +} + +/// Group header with its own subtotal, shared by the CLI and the TUI so the +/// two never drift apart. +export function classHeaderLine(cls: FindingClass, totals: ClassTotals, costRate: number): string { + const cost = costRate > 0 ? ` (~${formatCost(totals.savingsUSD)})` : '' + const suffix = cls === 'fix' ? ' — codeburn optimize --apply' : '' + return `${CLASS_HEADERS[cls]} · ~${formatTokens(totals.tokensSaved)} tokens${cost} · ${totals.count} finding${totals.count === 1 ? '' : 's'}${suffix}` +} // Cause taxonomy for defer-enable plans (mcp-deferral-off findings). // 'proxy-verified' is never produced by the detector today: it is reserved @@ -300,9 +463,24 @@ export type WasteFinding = { explanation: string impact: Impact tokensSaved: number + /// Savings attributable to the automatic mutation when it covers only a + /// subset of the finding. Omitted when `tokensSaved` already describes the + /// whole apply action (or when the finding is manual-only). + applyTokensSaved?: number + /// Per-server shares from the same capped cost pass as `tokensSaved`. + /// Internal apply/report consumers use this to price only targets that a + /// concrete mutation plan can actually edit; JSON output remains stable. + applyTokensSavedByServer?: Record + /// Additional by-hand action retained when `fix` is an executable local + /// command (for example, connector guidance beside a local MCP removal). + /// Internal apply UI metadata; the stable optimize JSON mapper omits it. + manualFollowUp?: { label: string; text: string } fix: WasteAction trend?: Trend apply?: FindingApply + /// Set only when a detector's basis varies per run (see detectSessionOutliers); + /// otherwise `FINDING_BASIS[id]` applies. Read through `findingBasis`. + basis?: FindingBasis } export type OptimizeResult = { @@ -330,6 +508,12 @@ export type OptimizeJsonReport = { potentialSavingsCostUSD: number potentialSavingsPercent: number | null costRateUSD: number + /// Portion of `potentialSavingsCostUSD` coming from `measured`-basis + /// findings. The total keeps its old meaning: measured plus estimated. + measuredSavingsUSD: number + /// Per-class subtotals; the three counts and token sums add up to + /// `findingCount` and `potentialSavingsTokens`. + byClass: Record } findings: Array<{ id: FindingId @@ -339,6 +523,8 @@ export type OptimizeJsonReport = { trend: Trend | null tokensSaved: number estimatedSavingsUSD: number + class: FindingClass + basis: FindingBasis fix: WasteAction }> /// Files most reworked by edit-family calls, relative to project root (top 15). @@ -346,6 +532,8 @@ export type OptimizeJsonReport = { /// 1-3 templated one-liners keyed on the strongest workflow signals. coachingNotes: string[] modelRecommendations?: Array + /// One entry per still-applied fix, re-measured on every run (see act/report.ts). + appliedFixes: Array> } export type ToolCall = { @@ -362,11 +550,22 @@ export type ApiCallMeta = { recent?: boolean } +/// One session's opening paste. `hash` groups sessions that open with the +/// same block; `chars` is the block's length, a floor for a block long +/// enough that the parser capped its text. +export type SessionOpener = { + hash: string + chars: number + project: string + preview: string +} + type ScanData = { toolCalls: ToolCall[] projectCwds: Set apiCalls: ApiCallMeta[] userMessages: string[] + openers: SessionOpener[] } // ============================================================================ @@ -453,6 +652,53 @@ type ScanFileResult = { cwds: string[] apiCalls: ApiCallMeta[] userMessages: string[] + openers: SessionOpener[] +} + +/// Whitespace-insensitive so the same block reflowed by a different paste +/// still groups, and ANSI-free so terminal output pasted twice matches. +function normalizeOpener(text: string): string { + return stripAnsi(text).replace(/\s+/g, ' ').trim() +} + +const MACHINE_PROMPT_HEAD_BYTES = 2048 +const MACHINE_PROMPT_PATTERN = /"promptSource"\s*:\s*"sdk"|"isSidechain"\s*:\s*true/ + +/// True when a program wrote this prompt rather than a person pasting it: an +/// SDK caller, or a parent agent writing a subagent's task. Either repeats by +/// design and has no home in CLAUDE.md. A user entry over the parser's +/// large-line threshold — routine for generated prompts — comes back without +/// its root flags, so those are read off the raw line instead: the ends of it, +/// since the fields sit either side of the message that made the line large. +function isMachineWrittenPrompt(entry: Record, line: string | Buffer): boolean { + if (entry['promptSource'] === 'sdk' || entry['isSidechain'] === true) return true + const edge = (start: number, end: number): string => + typeof line === 'string' ? line.slice(start, end) : line.subarray(start, end).toString('utf-8') + const head = edge(0, MACHINE_PROMPT_HEAD_BYTES) + const tail = edge(Math.max(MACHINE_PROMPT_HEAD_BYTES, line.length - MACHINE_PROMPT_HEAD_BYTES), line.length) + return MACHINE_PROMPT_PATTERN.test(head) || MACHINE_PROMPT_PATTERN.test(tail) +} + +/// A session's opening block, or null when it is too small to matter or is +/// not a paste at all: system reminders carry CLAUDE.md and hook output, +/// slash command wrappers refer to a file that already exists. `chars` is a +/// floor: the parser caps the text of a very large user entry, so a huge +/// block is sized at that cap rather than its true length. +function toSessionOpener(text: string, project: string): SessionOpener | null { + if (text.length < RECURRING_CONTEXT_MIN_CHARS) return null + const head = text.trimStart() + if (head.startsWith('') || head.startsWith('') || head.startsWith('')) return null + // Normalizing before the cap is what lets a re-flowed paste hash the same; + // the pre-slice keeps the work per session bounded however long the block + // is, with slack for whitespace that expands under re-flow. + const normalized = normalizeOpener(text.slice(0, OPTIMIZE_TEXT_CAP * RECURRING_CONTEXT_NORMALIZE_SLACK)) + .slice(0, OPTIMIZE_TEXT_CAP) + return { + hash: createHash('sha1').update(normalized).digest('hex'), + chars: text.length, + project, + preview: normalized.slice(0, RECURRING_CONTEXT_PREVIEW_CHARS), + } } function inRange(timestamp: string | undefined, range: DateRange | undefined): boolean { @@ -477,8 +723,12 @@ export async function scanJsonlFile( const cwds: string[] = [] const apiCalls: ApiCallMeta[] = [] const userMessages: string[] = [] + const openers: SessionOpener[] = [] const sessionId = basename(filePath, '.jsonl') let lastVersion = '' + // The opening block is the first user message carrying text; anything + // later in the session is not what the user opens with. + let sawUserText = false const skipThreshold = dateRange ? new Date(dateRange.start.getTime() - 86_400_000).toISOString() @@ -508,6 +758,11 @@ export async function scanJsonlFile( const msgContent = msg?.content if (typeof msgContent === 'string') { userMessages.push(msgContent.slice(0, OPTIMIZE_TEXT_CAP)) + if (!sawUserText) { + sawUserText = true + const opener = isMachineWrittenPrompt(entry, line) ? null : toSessionOpener(msgContent, project) + if (opener) openers.push(opener) + } } else if (Array.isArray(msgContent)) { let remaining = OPTIMIZE_TEXT_CAP for (const block of msgContent) { @@ -516,6 +771,11 @@ export async function scanJsonlFile( const text = block.text.slice(0, remaining) userMessages.push(text) remaining -= text.length + if (!sawUserText) { + sawUserText = true + const opener = isMachineWrittenPrompt(entry, line) ? null : toSessionOpener(block.text, project) + if (opener) openers.push(opener) + } } } } @@ -548,15 +808,27 @@ export async function scanJsonlFile( } } - return { calls, cwds, apiCalls, userMessages } + return { calls, cwds, apiCalls, userMessages, openers } } -async function scanSessions(dateRange?: DateRange): Promise { +// The session scan reads Claude Code transcripts only, so a `--provider` that +// excludes Claude leaves nothing for it to do. Callers must also skip the +// detectors it feeds (see `claudeOnly` in scanAndDetect) — the empty scan +// returned here is an absence of measurement, not a measurement of absence. +export function providerCoversClaude(provider?: string): boolean { + return !provider || provider === 'all' || provider === 'claude' +} + +async function scanSessions(dateRange?: DateRange, provider?: string): Promise { + if (!providerCoversClaude(provider)) { + return { toolCalls: [], projectCwds: new Set(), apiCalls: [], userMessages: [], openers: [] } + } const sources = await discoverAllSessions('claude') const allCalls: ToolCall[] = [] const allCwds = new Set() const allApiCalls: ApiCallMeta[] = [] const allUserMessages: string[] = [] + const allOpeners: SessionOpener[] = [] const tasks: Array<{ file: string; project: string }> = [] for (const source of sources) { @@ -568,14 +840,15 @@ async function scanSessions(dateRange?: DateRange): Promise { } await runWithConcurrency(tasks, FILE_READ_CONCURRENCY, async ({ file, project }) => { - const { calls, cwds, apiCalls, userMessages } = await scanJsonlFile(file, project, dateRange) + const { calls, cwds, apiCalls, userMessages, openers } = await scanJsonlFile(file, project, dateRange) allCalls.push(...calls) for (const cwd of cwds) allCwds.add(cwd) allApiCalls.push(...apiCalls) allUserMessages.push(...userMessages) + allOpeners.push(...openers) }) - return { toolCalls: allCalls, projectCwds: allCwds, apiCalls: allApiCalls, userMessages: allUserMessages } + return { toolCalls: allCalls, projectCwds: allCwds, apiCalls: allApiCalls, userMessages: allUserMessages, openers: allOpeners } } // ============================================================================ @@ -650,6 +923,27 @@ export function loadMcpConfigs(projectCwds: Iterable, homeDir = homedir( return servers } +/// Server names owned by readable local MCP config, normalized the way +/// transcript namespaces are (":" -> "_"). `loadMcpConfigs` covers +/// settings.json and .mcp.json; `~/.claude.json` adds the top-level and +/// per-project `mcpServers` containers the remove plan also edits. +/// +/// A `claude_ai_*` namespace listed here is a local server that happens to +/// carry the connector prefix, not a claude.ai connector. Config we cannot +/// read simply contributes no names, which leaves those namespaces on the +/// conservative connector path. +export function localMcpServerNames(projectCwds: Iterable, homeDir = homedir()): Set { + const names = new Set(loadMcpConfigs(projectCwds, homeDir).keys()) + const userJson = readJsonFile(join(homeDir, '.claude.json')) + const projects = (userJson?.['projects'] ?? {}) as Record + const containers = [userJson?.['mcpServers'], ...Object.values(projects).map(entry => entry?.mcpServers)] + for (const container of containers) { + if (!container || typeof container !== 'object') continue + for (const name of Object.keys(container)) names.add(name.replace(/:/g, '_')) + } + return names +} + // ============================================================================ // Detectors // ============================================================================ @@ -789,6 +1083,12 @@ type McpSchemaCostEstimate = { effectiveInputTokens: number } +type McpSchemaCostAttribution = McpSchemaCostEstimate & { + byServer: Record +} + +type McpUnusedToolsByServer = Record + /** * Aggregate MCP inventory and invocations across the projects in scope. * @@ -964,49 +1264,86 @@ export function estimateMcpSchemaCost( counts = unusedToolCounts } - const totalUnusedSchemaTokens = servers.reduce( - (s, srv) => s + (counts[srv] ?? 0) * TOKENS_PER_MCP_TOOL, - 0, - ) - if (totalUnusedSchemaTokens === 0) { - return { cacheWriteTokens: 0, cacheReadTokens: 0, effectiveInputTokens: 0 } + const attributed = estimateMcpSchemaCostAttributed(counts, projects, servers) + return { + cacheWriteTokens: attributed.cacheWriteTokens, + cacheReadTokens: attributed.cacheReadTokens, + effectiveInputTokens: attributed.effectiveInputTokens, + } +} + +function estimateMcpSchemaCostAttributed( + unusedToolsByServer: McpUnusedToolsByServer, + projects: ProjectSummary[], + servers: string[], +): McpSchemaCostAttribution { + servers = [...new Set(servers)] + const byServer: Record = {} + for (const server of servers) { + byServer[server] = { cacheWriteTokens: 0, cacheReadTokens: 0, effectiveInputTokens: 0 } } - const serverSet = new Set(servers) - let cacheWriteTokens = 0 - let cacheReadTokens = 0 + const addBucket = ( + loaded: Array<{ server: string; schemaTokens: number }>, + bucket: number, + key: 'cacheWriteTokens' | 'cacheReadTokens', + ): void => { + if (bucket <= 0) return + const totalSchemaTokens = loaded.reduce((sum, entry) => sum + entry.schemaTokens, 0) + if (totalSchemaTokens <= 0) return + const charged = Math.min(totalSchemaTokens, bucket) + for (const entry of loaded) { + byServer[entry.server]![key] += charged * (entry.schemaTokens / totalSchemaTokens) + } + } for (const project of projects) { for (const session of project.sessions) { - // A session counts only if its observed inventory included at least - // one of the flagged servers — same invariant `aggregateMcpCoverage` - // uses for `loadedSessions`. - let loaded = false - for (const fqn of session.mcpInventory ?? []) { - const seg = fqn.split('__')[1] - if (seg && serverSet.has(seg)) { loaded = true; break } + const inventory = new Set(session.mcpInventory ?? []) + const inventoryCounts = new Map() + for (const fqn of inventory) { + const parts = fqn.split('__') + if (parts[0] !== 'mcp' || !parts[1] || parts.length < 3) continue + inventoryCounts.set(parts[1], (inventoryCounts.get(parts[1]) ?? 0) + 1) } - if (!loaded) continue + + const loaded: Array<{ server: string; schemaTokens: number }> = [] + for (const server of servers) { + const unused = unusedToolsByServer[server] + const toolCount = typeof unused === 'number' + ? Math.min(unused, inventoryCounts.get(server) ?? 0) + : [...new Set(unused ?? [])].reduce((count, fqn) => count + (inventory.has(fqn) ? 1 : 0), 0) + if (toolCount > 0) loaded.push({ server, schemaTokens: toolCount * TOKENS_PER_MCP_TOOL }) + } + if (loaded.length === 0) continue for (const turn of session.turns) { for (const call of turn.assistantCalls) { - // Both buckets can be non-zero on the same call (cache rebuild - // alongside a partial read), so account for them independently. - // The cap is applied to the combined unused-schema budget so - // multiple flagged servers cannot all claim the same call. - if (call.usage.cacheCreationInputTokens > 0) { - cacheWriteTokens += Math.min(totalUnusedSchemaTokens, call.usage.cacheCreationInputTokens) - } - if (call.usage.cacheReadInputTokens > 0) { - cacheReadTokens += Math.min(totalUnusedSchemaTokens, call.usage.cacheReadInputTokens) - } + // A cache bucket is shared by every flagged schema loaded on this + // call. Charge it once, then attribute the capped amount in + // proportion to each server's unused schema. This conserves the + // combined total and makes any local-only subset additive. + addBucket(loaded, call.usage.cacheCreationInputTokens, 'cacheWriteTokens') + addBucket(loaded, call.usage.cacheReadInputTokens, 'cacheReadTokens') } } } } - const effectiveInputTokens = cacheWriteTokens * CACHE_WRITE_MULTIPLIER + cacheReadTokens * CACHE_READ_DISCOUNT - return { cacheWriteTokens, cacheReadTokens, effectiveInputTokens } + let cacheWriteTokens = 0 + let cacheReadTokens = 0 + for (const estimate of Object.values(byServer)) { + estimate.effectiveInputTokens = estimate.cacheWriteTokens * CACHE_WRITE_MULTIPLIER + + estimate.cacheReadTokens * CACHE_READ_DISCOUNT + cacheWriteTokens += estimate.cacheWriteTokens + cacheReadTokens += estimate.cacheReadTokens + } + return { + cacheWriteTokens, + cacheReadTokens, + effectiveInputTokens: cacheWriteTokens * CACHE_WRITE_MULTIPLIER + cacheReadTokens * CACHE_READ_DISCOUNT, + byServer, + } } /** @@ -1026,6 +1363,7 @@ export function estimateMcpSchemaCost( export function detectMcpToolCoverage( projects: ProjectSummary[], coverage = aggregateMcpCoverage(projects), + localServerNames: ReadonlySet = new Set(), ): WasteFinding | null { if (coverage.length === 0) return null @@ -1040,30 +1378,102 @@ export function detectMcpToolCoverage( const lines: string[] = [] const removeCommands: string[] = [] - const unusedCountsByServer: Record = {} + const unusedToolsByServer: Record = {} const flaggedServers: string[] = [] + const localServers: string[] = [] + const connectorServers: string[] = [] + // Local, but named like a connector: the transcript cannot tell the two + // apart, so the removal targets the config entry and the guidance warns + // about a possible same-name connector instead of asserting one. + const ambiguousServers: string[] = [] for (const c of flagged) { - unusedCountsByServer[c.server] = c.toolsAvailable - c.toolsInvoked + unusedToolsByServer[c.server] = c.unusedTools flaggedServers.push(c.server) const pct = Math.round(c.coverageRatio * 100) lines.push( `${c.server}: ${c.toolsInvoked}/${c.toolsAvailable} tools used (${pct}% coverage) across ${c.loadedSessions} session${c.loadedSessions === 1 ? '' : 's'}`, ) - removeCommands.push(`claude mcp remove '${c.server}'`) + if (c.server.startsWith('claude_ai_') && !localServerNames.has(c.server)) { + connectorServers.push(c.server) + } else { + if (c.server.startsWith('claude_ai_')) ambiguousServers.push(c.server) + localServers.push(c.server) + removeCommands.push(`claude mcp remove '${c.server}'`) + } } // Single combined cost pass: caps each call's contribution at the // total unused-schema budget across all flagged servers, so two // flagged servers cannot independently claim the same call's cache // bucket and overstate `tokensSaved`. - const cost = estimateMcpSchemaCost(unusedCountsByServer, projects, flaggedServers) + const cost = estimateMcpSchemaCostAttributed(unusedToolsByServer, projects, flaggedServers) const tokensSaved = Math.round(cost.effectiveInputTokens) + const applyTokensSavedByServer = Object.fromEntries(localServers.map(server => [ + server, + cost.byServer[server]?.effectiveInputTokens ?? 0, + ])) + const localTokensSaved = Object.values(applyTokensSavedByServer).reduce((sum, value) => sum + value, 0) + const applyTokensSaved = localServers.length > 0 && connectorServers.length > 0 + ? Math.round(localTokensSaved) + : undefined const impact: Impact = tokensSaved >= MCP_COVERAGE_HIGH_IMPACT_TOKENS ? 'high' : flagged.length >= UNUSED_MCP_HIGH_THRESHOLD ? 'high' : 'medium' + // `claude_ai_*` is Claude Code's transcript namespace for server-side + // claude.ai connectors, which are not local mcpServers entries, so + // `claude mcp remove` and the file-editing apply plan cannot own them -- + // unless readable local config claims the exact name (`ambiguousServers`). + // Coverage is aggregate here; project-level config attribution is deliberately + // out of scope, hence the instruction to inspect /mcp per affected project. + const one = connectorServers.length === 1 + const connectorLabels = connectorServers.map(server => + `claude.ai ${server.slice('claude_ai_'.length).replaceAll('_', ' ')}`, + ) + const connectorEvidence = connectorServers.map((server, index) => + `${connectorLabels[index]} (${server})`, + ) + const connectorGuidance = connectorServers.length > 0 + ? ` ${connectorEvidence.join(', ')} ${one ? 'is a claude.ai connector namespace' : 'are claude.ai connector namespaces'}, separate from any similarly named local MCP server. Transcript inventory is aggregated across the selected projects; use /mcp in each project where ${one ? 'it loads' : 'they load'}, or manage ${one ? 'it' : 'them'} in claude.ai Settings > Connectors.` + : '' + const oneAmbiguous = ambiguousServers.length === 1 + const ambiguousNote = ambiguousServers.length > 0 + ? `If you also use ${oneAmbiguous ? 'a claude.ai connector' : 'claude.ai connectors'} named ${ambiguousServers.join(', ')}, manage ${oneAmbiguous ? 'it' : 'them'} with /mcp or in claude.ai Settings > Connectors.` + : '' + const ambiguousGuidance = ambiguousServers.length > 0 + ? ` ${ambiguousServers.join(', ')} ${oneAmbiguous ? 'is a local MCP config entry whose name matches' : 'are local MCP config entries whose names match'} the claude.ai connector namespace, so the removal below edits local config only. ${ambiguousNote}` + : '' + const connectorText = [ + connectorServers.length > 0 + ? `Open /mcp in each affected project and disable ${connectorLabels.join(', ')}, or manage ${one ? 'it' : 'them'} in claude.ai Settings > Connectors.` + : '', + ambiguousNote, + ].filter(Boolean).join(' ') + const connectorAction = connectorText + ? { + label: connectorServers.length === 0 + ? 'Check for a same-name claude.ai connector:' + : one ? 'Manage the underused claude.ai connector where it loads:' + : 'Manage the underused claude.ai connectors where they load:', + text: connectorText, + } + : undefined + const fix: WasteAction = localServers.length > 0 + ? { + type: 'command', + label: localServers.length === 1 + ? 'Remove the underused local server, or trim its tools in your MCP config:' + : 'Remove underused local servers, or trim their tools in your MCP config:', + text: removeCommands.join('\n'), + } + : { + type: 'paste', + destination: 'manual', + label: connectorAction!.label, + text: connectorAction!.text, + } return { id: 'mcp-low-coverage', @@ -1071,17 +1481,16 @@ export function detectMcpToolCoverage( explanation: `Schema for unused tools is loaded into the system prompt every session and ` + `carried in the cached prefix on every turn. ` + - `${lines.join('; ')}.`, + `${lines.join('; ')}.${connectorGuidance}${ambiguousGuidance}`, impact, tokensSaved, - fix: { - type: 'command', - label: flagged.length === 1 - ? 'Remove the underused server, or trim its tools in your MCP config:' - : 'Remove underused servers, or trim their tools in your MCP config:', - text: removeCommands.join('\n'), - }, - apply: { kind: 'mcp-remove', servers: flaggedServers }, + ...(applyTokensSaved !== undefined ? { applyTokensSaved } : {}), + ...(localServers.length > 0 ? { applyTokensSavedByServer } : {}), + ...(localServers.length > 0 && connectorAction ? { manualFollowUp: connectorAction } : {}), + fix, + ...(localServers.length > 0 + ? { apply: { kind: 'mcp-remove' as const, servers: localServers } } + : {}), } } @@ -1705,7 +2114,7 @@ export function findDeferralEnvSetting( const content = readSessionFileSync(path) if (content === null) continue const match = content.match(linePattern) - if (match) return { value: match[1]!, scope: 'shell profile', path } + if (match) return { value: match[1]!, scope: SHELL_PROFILE_SCOPE, path } } return null } @@ -2477,6 +2886,60 @@ export function detectBashBloat(): WasteFinding | null { } } +/// The same long block opening many sessions: a spec, a repo dump, a standing +/// brief. Every repeat is input tokens for context that could live in +/// CLAUDE.md or in a file read on demand. The first paste is the honest cost +/// of saying it once, so only the repeats count as savings. +export function detectRecurringContext(openers: SessionOpener[]): WasteFinding | null { + type Group = { sessions: number; chars: number; preview: string; projects: Set } + const groups = new Map() + for (const o of openers) { + const g = groups.get(o.hash) + if (!g) { + groups.set(o.hash, { sessions: 1, chars: o.chars, preview: o.preview, projects: new Set([o.project]) }) + continue + } + g.sessions++ + // Only the hashed prefix is known to match, so size the block by the + // smallest occurrence rather than claiming the longest. + g.chars = Math.min(g.chars, o.chars) + g.projects.add(o.project) + } + + const repeated = [...groups.values()] + .filter(g => g.sessions >= RECURRING_CONTEXT_MIN_SESSIONS) + .map(g => ({ ...g, tokens: Math.round((g.sessions - 1) * g.chars * BASH_TOKENS_PER_CHAR) })) + .sort((a, b) => b.tokens - a.tokens) + if (repeated.length === 0) return null + + const tokensSaved = repeated.reduce((sum, g) => sum + g.tokens, 0) + const top = repeated[0] + const preview = repeated.slice(0, RECURRING_CONTEXT_PREVIEW) + const list = preview + .map(g => { + const where = g.projects.size === 1 + ? [...g.projects][0].split('-').filter(Boolean).pop() ?? [...g.projects][0] + : `${g.projects.size} projects` + return `"${g.preview}..." — ${g.sessions} sessions in ${where}, ~${formatTokens(g.tokens)} tokens` + }) + .join('; ') + const extra = repeated.length > preview.length ? `; +${repeated.length - preview.length} more` : '' + + return { + id: 'recurring-context', + title: `Same ${(top.chars / 1024).toFixed(1)} KB block pasted at the start of ${top.sessions} sessions`, + explanation: `These sessions open with a block you have pasted before, so you pay input tokens for the same context every time: ${list}${extra}. Standing rules belong in CLAUDE.md; reference material belongs in a file Claude reads on demand. Only the repeats are counted, not the first paste.`, + impact: tokensSaved >= RECURRING_CONTEXT_HIGH_IMPACT_TOKENS ? 'high' : tokensSaved >= RECURRING_CONTEXT_MEDIUM_IMPACT_TOKENS ? 'medium' : 'low', + tokensSaved, + fix: { + type: 'paste', + destination: 'prompt', + label: 'Ask Claude to give this block a permanent home:', + text: `I open many sessions by pasting this block:\n"${top.preview}..."\nMove it into CLAUDE.md if it is a standing rule, or into a file you read on demand if it is reference material, then tell me the one-line pointer to start sessions with instead.`, + }, + } +} + function sessionTokenTotal(session: ProjectSummary['sessions'][number]): number { return session.totalInputTokens + session.totalOutputTokens @@ -2803,9 +3266,17 @@ export function detectSessionOutliers(projects: ProjectSummary[], excludedSessio } const outliers: Outlier[] = [] + // Modelled costs (Kiro, Cursor, some Cline sessions) are not comparable + // against provider-reported ones, so they leave the peer math. Providers + // that only ever estimate would lose the finding entirely, so those fall + // back to the full set and the finding reports itself as estimated. + let usedEstimatedCosts = false for (const project of projects) { - const sessions = project.sessions.filter(s => s.totalCostUSD > 0) + const costed = project.sessions.filter(s => s.totalCostUSD > 0) + const exact = costed.filter(s => (s.totalEstimatedCostUSD ?? 0) === 0) + const sessions = exact.length >= MIN_SESSIONS_FOR_OUTLIER ? exact : costed + const fellBack = sessions.length > exact.length if (sessions.length < MIN_SESSIONS_FOR_OUTLIER) continue const totalCost = sessions.reduce((sum, s) => sum + s.totalCostUSD, 0) @@ -2824,6 +3295,7 @@ export function detectSessionOutliers(projects: ProjectSummary[], excludedSessio // "tighter constraint" advice here. if (excludedSessionIds?.has(session.sessionId)) continue + if (fellBack) usedEstimatedCosts = true outliers.push({ project: project.project, sessionId: session.sessionId, @@ -2853,6 +3325,7 @@ export function detectSessionOutliers(projects: ProjectSummary[], excludedSessio explanation: `Sessions costing more than ${SESSION_OUTLIER_MULTIPLIER}x their peer-session average in the same project: ${list}${extra}. These usually come from broad prompts, runaway loops, or context-heavy work that should be split into smaller sessions.`, impact: outliers.length >= 3 || totalExcessCost >= 10 ? 'high' : 'medium', tokensSaved, + ...(usedEstimatedCosts ? { basis: 'estimated' as const } : {}), fix: { type: 'paste', destination: 'session-opener', @@ -2977,7 +3450,7 @@ export function computeInputCostRate(projects: ProjectSummary[]): number { type CacheEntry = { data: OptimizeResult; ts: number } const resultCache = new Map() -export function cacheKey(projects: ProjectSummary[], dateRange: DateRange | undefined): string { +export function cacheKey(projects: ProjectSummary[], dateRange: DateRange | undefined, provider?: string): string { const dr = dateRange ? `${dateRange.start.getTime()}-${dateRange.end.getTime()}` : 'all' // Fingerprint enough of the dataset that two materially different inputs // cannot collide onto one cached OptimizeResult. Project count + api-call @@ -2994,23 +3467,27 @@ export function cacheKey(projects: ProjectSummary[], dateRange: DateRange | unde } // Costs scaled to whole micro-dollars so float jitter cannot thrash the key. const fingerprint = `${projects.length}:${calls}:${Math.round(cost * 1e6)}:${Math.round(savings * 1e6)}:${Math.round(proxied * 1e6)}` - return `${dr}:${fingerprint}` + // The provider decides whether the Claude session scan runs at all, so two + // filters that happen to share a project fingerprint must not share a result. + return `${provider ?? 'all'}:${dr}:${fingerprint}` } export async function scanAndDetect( projects: ProjectSummary[], dateRange?: DateRange, + provider?: string, ): Promise { if (projects.length === 0) { return { findings: [], costRate: 0, healthScore: 100, healthGrade: 'A', modelRecommendations: [] } } - const key = cacheKey(projects, dateRange) + const key = cacheKey(projects, dateRange, provider) const cached = resultCache.get(key) if (cached && Date.now() - cached.ts < RESULT_CACHE_TTL_MS) return cached.data const costRate = computeInputCostRate(projects) - const { toolCalls, projectCwds, apiCalls, userMessages } = await scanSessions(dateRange) + const scanCoversClaude = providerCoversClaude(provider) + const { toolCalls, projectCwds, apiCalls, userMessages, openers } = await scanSessions(dateRange, provider) const mcpCoverage = aggregateMcpCoverage(projects) const findings: WasteFinding[] = [] @@ -3025,38 +3502,51 @@ export async function scanAndDetect( ) const firstSessionIds = findYoungProjectFirstSessionIds(projects) const outlierExclusions = new Set([...lowWorthSessionIds, ...contextBloatVisibleIds, ...firstSessionIds]) + // Detectors fed by the session scan or by `~/.claude` config only mean + // anything when the run covers Claude. Under a different `--provider` they + // must be skipped rather than handed an empty scan: emptiness reads as + // "never invoked", so every skill, agent and command would be reported as + // unused when it was simply not measured. + const claudeOnly = (detect: () => WasteFinding | null): (() => WasteFinding | null) => + scanCoversClaude ? detect : () => null const syncDetectors: Array<() => WasteFinding | null> = [ - () => detectCacheBloat(apiCalls, projects, dateRange), - () => detectLowReadEditRatio(toolCalls), - () => detectJunkReads(toolCalls, dateRange), - () => detectDuplicateReads(toolCalls, dateRange), - () => detectUnusedMcp(toolCalls, projects, projectCwds, mcpCoverage), - () => detectMcpToolCoverage(projects, mcpCoverage), + claudeOnly(() => detectCacheBloat(apiCalls, projects, dateRange)), + claudeOnly(() => detectLowReadEditRatio(toolCalls)), + claudeOnly(() => detectJunkReads(toolCalls, dateRange)), + claudeOnly(() => detectDuplicateReads(toolCalls, dateRange)), + claudeOnly(() => detectUnusedMcp(toolCalls, projects, projectCwds, mcpCoverage)), + () => detectMcpToolCoverage(projects, mcpCoverage, localMcpServerNames(projectCwds)), () => detectMcpProfileAdvisor(projects, mcpCoverage), // mcp-deferral-gaps family (#614): detection only, no apply plans yet. - () => detectMcpDeferralOff(toolCalls, projects, projectCwds, apiCalls), - () => detectMcpAlwaysLoadHygiene(projects, projectCwds, apiCalls, mcpCoverage), - () => detectMcpDeferThreshold(projects, projectCwds), + claudeOnly(() => detectMcpDeferralOff(toolCalls, projects, projectCwds, apiCalls)), + claudeOnly(() => detectMcpAlwaysLoadHygiene(projects, projectCwds, apiCalls, mcpCoverage)), + claudeOnly(() => detectMcpDeferThreshold(projects, projectCwds)), () => detectCapabilityReliability(projects), () => detectLowWorthSessions(projects), () => detectContextBloat(projects, lowWorthSessionIds), () => detectSessionOutliers(projects, outlierExclusions), - () => detectBloatedClaudeMd(projectCwds), - () => detectBashBloat(), + claudeOnly(() => detectBloatedClaudeMd(projectCwds)), + claudeOnly(() => detectBashBloat()), + claudeOnly(() => detectRecurringContext(openers)), ] for (const detect of syncDetectors) { const finding = detect() if (finding) findings.push(finding) } - const ghostResults = await Promise.all([ - detectGhostAgents(toolCalls), - detectGhostSkills(toolCalls), - detectGhostCommands(userMessages), - ]) + const ghostResults = scanCoversClaude + ? await Promise.all([ + detectGhostAgents(toolCalls), + detectGhostSkills(toolCalls), + detectGhostCommands(userMessages), + ]) + : [] for (const f of ghostResults) if (f) findings.push(f) + // Urgency first, then class: every surface lists the apply-able fixes + // before the habit nudges, and orders by urgency inside each group. findings.sort((a, b) => urgencyScore(b) - urgencyScore(a)) + findings.sort((a, b) => CLASS_ORDER[findingClass(a)] - CLASS_ORDER[findingClass(b)]) const { score, grade } = computeHealth(findings) const modelRecommendations: ModelDefaultRecommendation[] = [] @@ -3117,6 +3607,7 @@ function renderActionHeader(action: WasteAction): string { case 'session-opener': return fillTo('One-time session opener (do NOT add to CLAUDE.md)') case 'prompt': return fillTo('Ask Claude in the current session') case 'shell-config': return fillTo('Add to your shell config') + case 'manual': return fillTo('Manual action') default: return fillTo('Suggested action') } } @@ -3140,7 +3631,7 @@ function renderFinding(n: number, f: WasteFinding, costRate: number): string[] { lines.push('') lines.push(wrap(f.explanation, PANEL_WIDTH - 4, ' ')) lines.push('') - lines.push(chalk.hex(GOLD)(` Potential savings: ${savings}`)) + lines.push(chalk.hex(GOLD)(` Potential savings: ${savings}`) + chalk.dim(` ${findingBasis(f)}`)) lines.push('') // Destination header — issue #277. Tells the user where each suggestion @@ -3183,7 +3674,26 @@ function renderWorkflowSection(reworkedFiles: ReworkedFile[], coachingNotes: str return lines } -function renderOptimize( +const APPLIED_FIX_COLORS: Record = { + worked: GREEN, + partial: GOLD, + 'no-effect': RED, + pending: DIM, +} + +// Closes the loop after --apply: every still-applied fix gets its measured +// verdict back here, on every run. +function renderAppliedFixes(appliedFixes: AppliedFix[]): string[] { + if (appliedFixes.length === 0) return [] + const lines = [chalk.bold.hex(ORANGE)(' Applied fixes'), ''] + for (const fix of appliedFixes) { + lines.push(chalk.hex(APPLIED_FIX_COLORS[fix.verdict])(` ${appliedFixGlyph(fix)} ${formatAppliedFix(fix)}`)) + } + lines.push('') + return lines +} + +export function renderOptimize( findings: WasteFinding[], costRate: number, periodLabel: string, @@ -3197,6 +3707,7 @@ function renderOptimize( appliedHeader?: string, previouslyApplied?: Record, modelRecommendations?: ModelDefaultRecommendation[], + appliedFixes: AppliedFix[] = [], ): string { const lines: string[] = [] lines.push('') @@ -3204,12 +3715,16 @@ function renderOptimize( lines.push(chalk.hex(DIM)(' ' + SEP.repeat(PANEL_WIDTH))) const issueSuffix = findings.length > 0 ? `, ${findings.length} issue${findings.length > 1 ? 's' : ''}` : '' + const measured = findings.filter(f => findingBasis(f) === 'measured').length lines.push(' ' + [ `${sessionCount} sessions`, `${callCount.toLocaleString()} calls`, chalk.hex(GOLD)(formatCost(periodCost)), `Health: ${chalk.bold.hex(GRADE_COLORS[healthGrade])(healthGrade)}${chalk.dim(` (${healthScore}/100${issueSuffix})`)}`, ].join(chalk.hex(DIM)(' '))) + if (findings.length > 0) { + lines.push(chalk.dim(` ${measured} measured · ${findings.length - measured} estimated`)) + } if (appliedHeader) lines.push(' ' + chalk.hex(GREEN)(appliedHeader)) lines.push('') @@ -3220,6 +3735,7 @@ function renderOptimize( lines.push(chalk.dim(' token waste: junk directory reads, duplicate file reads, unused')) lines.push(chalk.dim(' agents/skills/MCP servers, bloated CLAUDE.md, and more.')) lines.push('') + lines.push(...renderAppliedFixes(appliedFixes)) lines.push(...renderWorkflowSection(reworkedFiles, coachingNotes)) return lines.join('\n') } @@ -3229,21 +3745,33 @@ function renderOptimize( const pctRaw = periodCost > 0 ? (totalCost / periodCost) * 100 : 0 const pct = pctRaw >= 1 ? pctRaw.toFixed(0) : pctRaw.toFixed(1) + const totals = classTotals(findings, costRate) const costText = costRate > 0 ? ` (~${formatCost(totalCost)}, ~${pct}% of spend)` : '' - lines.push(chalk.hex(GREEN)(` Potential savings: ~${formatTokens(totalTokens)} tokens${costText}`)) + // The headline is the whole board; name the apply-able slice separately so + // it never reads as "what CodeBurn can fix for you". + const applyable = costRate > 0 && totals.fix.count > 0 ? ` — apply-able: ~${formatCost(totals.fix.savingsUSD)}` : '' + lines.push(chalk.hex(GREEN)(` Potential savings: ~${formatTokens(totalTokens)} tokens${costText}${applyable}`)) lines.push('') - for (let i = 0; i < findings.length; i++) { - const f = findings[i]! - const appliedOn = previouslyApplied?.[f.id] - const shown = appliedOn ? { ...f, title: `${f.title} (previously applied ${appliedOn}, re-flagged)` } : f - lines.push(...renderFinding(i + 1, shown, costRate)) + // One block per class, in fix -> nudge -> keep order; numbering runs + // continuously across the blocks so `--only` picks stay unambiguous. + let n = 0 + for (const cls of ['fix', 'nudge', 'keep'] as const) { + const group = findings.filter(f => findingClass(f) === cls) + if (group.length === 0) continue + lines.push(chalk.bold.hex(ORANGE)(` ${classHeaderLine(cls, totals[cls], costRate)}`)) + lines.push('') + for (const f of group) { + const appliedOn = previouslyApplied?.[f.id] + const shown = appliedOn ? { ...f, title: `${f.title} (previously applied ${appliedOn}, re-flagged)` } : f + lines.push(...renderFinding(++n, shown, costRate)) + } } lines.push(chalk.hex(DIM)(' ' + SEP.repeat(PANEL_WIDTH))) - lines.push(chalk.dim(' Estimates only.')) lines.push('') + lines.push(...renderAppliedFixes(appliedFixes)) lines.push(...renderWorkflowSection(reworkedFiles, coachingNotes)) if (modelRecommendations && modelRecommendations.length > 0) { @@ -3281,7 +3809,7 @@ export async function runOptimize( projects: ProjectSummary[], periodLabel: string, dateRange?: DateRange, - opts: { format?: 'text' | 'json'; appliedHeader?: string; previouslyApplied?: Record } = {}, + opts: { format?: 'text' | 'json'; appliedHeader?: string; previouslyApplied?: Record; appliedFixes?: AppliedFix[]; provider?: string } = {}, ): Promise { const format = opts.format ?? 'text' if (projects.length === 0 && format === 'text') { @@ -3293,19 +3821,19 @@ export async function runOptimize( process.stderr.write(chalk.dim(' Analyzing your sessions...\n')) } - const result = await scanAndDetect(projects, dateRange) + const result = await scanAndDetect(projects, dateRange, opts.provider) const { findings, costRate, healthScore, healthGrade } = result const sessions = projects.flatMap(p => p.sessions) const periodCost = projects.reduce((s, p) => s + p.totalCostUSD, 0) const callCount = projects.reduce((s, p) => s + p.totalApiCalls, 0) if (format === 'json') { - console.log(JSON.stringify(buildOptimizeJsonReport(projects, periodLabel, result, dateRange), null, 2)) + console.log(JSON.stringify(buildOptimizeJsonReport(projects, periodLabel, result, dateRange, opts.appliedFixes), null, 2)) return } const { topReworkedFiles, coachingNotes } = buildWorkflowReport(projects) - const output = renderOptimize(findings, costRate, periodLabel, periodCost, sessions.length, callCount, healthScore, healthGrade, topReworkedFiles, coachingNotes, opts.appliedHeader, opts.previouslyApplied, result.modelRecommendations) + const output = renderOptimize(findings, costRate, periodLabel, periodCost, sessions.length, callCount, healthScore, healthGrade, topReworkedFiles, coachingNotes, opts.appliedHeader, opts.previouslyApplied, result.modelRecommendations, opts.appliedFixes) console.log(output) } @@ -3314,6 +3842,7 @@ export function buildOptimizeJsonReport( periodLabel: string, result: OptimizeResult, dateRange?: DateRange, + appliedFixes: AppliedFix[] = [], ): OptimizeJsonReport { const sessions = projects.flatMap(p => p.sessions) const periodCostUSD = projects.reduce((s, p) => s + p.totalCostUSD, 0) @@ -3341,6 +3870,10 @@ export function buildOptimizeJsonReport( potentialSavingsCostUSD, potentialSavingsPercent, costRateUSD: result.costRate, + measuredSavingsUSD: result.findings + .filter(f => findingBasis(f) === 'measured') + .reduce((s, f) => s + f.tokensSaved * result.costRate, 0), + byClass: classTotals(result.findings, result.costRate), }, findings: result.findings.map(f => ({ id: f.id, @@ -3350,9 +3883,21 @@ export function buildOptimizeJsonReport( trend: f.trend ?? null, tokensSaved: f.tokensSaved, estimatedSavingsUSD: f.tokensSaved * result.costRate, + class: findingClass(f), + basis: findingBasis(f), fix: f.fix, })), ...buildWorkflowReport(projects), modelRecommendations: result.modelRecommendations, + appliedFixes: appliedFixes.map(f => ({ + id: f.id, + kind: f.kind, + findingId: f.findingId, + appliedAt: f.appliedAt, + verdict: f.verdict, + estimatedTokens: f.estimatedTokens, + realizedTokens: f.realizedTokens, + undoCommand: f.undoCommand, + })), } } diff --git a/src/parse-worker.ts b/src/parse-worker.ts new file mode 100644 index 00000000..df3d9cbf --- /dev/null +++ b/src/parse-worker.ts @@ -0,0 +1,34 @@ +import { parentPort, workerData } from 'worker_threads' +import { restorePricingState, type PricingSnapshot } from './models.js' +import type { ParseJob } from './parse-workers.js' +import { parseClaudeFileFull } from './parser.js' +import { parseCodexFileFull } from './providers/codex.js' + +const port = parentPort +if (!port) throw new Error('parse-worker must be started as a worker thread') + +restorePricingState((workerData as { pricing: PricingSnapshot }).pricing) + +// The parsed turns go back as a JSON string rather than as a live object graph: +// structured-cloning a whole corpus of turns costs more than the parallel parse +// saves, while a string is a single copy the parent re-parses at memcpy speed. +// `msgIds` / `keys` is every dedup key this file claimed; the parent uses it to +// prove no earlier file already owned one before installing the result. A Codex +// job also carries back the cache entry it would have written, because the cache +// module's per-directory state belongs to the parent, not to a thread. +port.on('message', (msg: ParseJob) => { + void (async () => { + try { + const seen = new Set() + if (msg.kind === 'codex') { + const parsed = await parseCodexFileFull(msg.source, seen) + port.postMessage({ json: JSON.stringify({ ...parsed, keys: [...seen], path: msg.source.path }) }) + return + } + const parsed = await parseClaudeFileFull(msg.filePath, seen) + port.postMessage({ json: parsed === null ? null : JSON.stringify({ ...parsed, msgIds: [...seen], path: msg.filePath }) }) + } catch (err) { + port.postMessage({ error: err instanceof Error ? err.message : String(err) }) + } + })() +}) diff --git a/src/parse-workers.ts b/src/parse-workers.ts new file mode 100644 index 00000000..25639687 --- /dev/null +++ b/src/parse-workers.ts @@ -0,0 +1,245 @@ +import { availableParallelism, totalmem } from 'os' +import { Worker } from 'worker_threads' +import { snapshotPricingState } from './models.js' +import type { ClaudeFileParse } from './parser.js' +import type { SessionSource } from './providers/types.js' + +// A worker holds one file's entries plus its serialized result, and the parent +// buffers up to `pool.size` finished results while it installs one — both are in +// this budget. A flat 256 MB was measured wrong on Codex: a 260 MB rollout peaks +// near 430 MB per worker and scales linearly with the pool. So derive it from the +// average pending file instead, floored at the small-transcript figure and capped +// at 1 GB. Going over the budget is what turns a parallel parse into a swapping one. +const MIN_PER_WORKER_RSS_BYTES = 256 * 1024 * 1024 +const MAX_PER_WORKER_RSS_BYTES = 1024 * 1024 * 1024 +const PER_WORKER_RSS_OVERHEAD_BYTES = 128 * 1024 * 1024 +const MEMORY_BUDGET_CAP_BYTES = 2 * 1024 * 1024 * 1024 +const MIN_AVAILABLE_BYTES = 4 * 1024 * 1024 * 1024 +const MIN_FILES_PER_WORKER = 50 +const MIN_BYTES_PER_WORKER = 200 * 1024 * 1024 +// Below this a parse is warm/incremental and the thread startup + result transfer +// costs more than the parallelism buys. Bytes, not file count: 250 pending files +// holding under a megabyte between them spawn threads that make the run ~5% +// SLOWER, and the file count only starts paying for itself around 400. +const MIN_PENDING_BYTES = 200 * 1024 * 1024 + +export type ParseWorkerDecision = { workers: number; reason: string } + +export type SystemCapacity = { cores: number; availableBytes: number } + +// `process.availableMemory()` respects a container's cgroup / rlimit, which is the +// case this gate exists for; where it is absent it falls back to total RAM. Free +// memory is deliberately NOT used: on macOS `os.freemem()` counts free pages, not +// available memory, and reads as a few hundred MB on an idle 128 GB machine — a +// gate built on it turns the feature on and off at random. +function currentSystemCapacity(): SystemCapacity { + return { + cores: availableParallelism(), + availableBytes: typeof process.availableMemory === 'function' ? process.availableMemory() : totalmem(), + } +} + +/// Decide how many parse worker threads a pending workload earns. Returning 0 +/// means "parse serially" — the only behaviour before this existed, and still +/// the behaviour for every warm run, every small corpus, and every low-spec box. +export function decideParseWorkers( + pending: { files: number; bytes: number }, + sys: SystemCapacity = currentSystemCapacity(), + env: NodeJS.ProcessEnv = process.env, +): ParseWorkerDecision { + // Every reason carries the full decision input, so a support log line explains + // itself without a second run. + const inputs = `${sys.cores} cores, ${Math.round(sys.availableBytes / 1e9 * 10) / 10} GB available, ${pending.files} pending files / ${Math.round(pending.bytes / 1e6)} MB` + + const override = env['CODEBURN_PARSE_WORKERS'] + if (override !== undefined && override !== '') { + const n = Number(override) + if (!Number.isFinite(n) || n < 0) return { workers: 0, reason: `invalid CODEBURN_PARSE_WORKERS=${override}` } + const capped = Math.min(Math.floor(n), sys.cores) + return { workers: capped, reason: `${capped === 0 ? 'forced serial' : 'forced'} by CODEBURN_PARSE_WORKERS=${override}; ${inputs}` } + } + + // Workload gates first, so a warm run's log line says "warm", not whatever the + // machine happened to look like at that moment. + if (pending.bytes < MIN_PENDING_BYTES) return { workers: 0, reason: `below ${Math.round(MIN_PENDING_BYTES / 1e6)} MB pending; ${inputs}` } + if (sys.cores <= 2) return { workers: 0, reason: `too few cores; ${inputs}` } + if (sys.availableBytes < MIN_AVAILABLE_BYTES) return { workers: 0, reason: `below ${Math.round(MIN_AVAILABLE_BYTES / 1e9)} GB available memory; ${inputs}` } + + const memoryBudget = Math.min(0.25 * sys.availableBytes, MEMORY_BUDGET_CAP_BYTES) + const perWorker = Math.min( + MAX_PER_WORKER_RSS_BYTES, + Math.max(MIN_PER_WORKER_RSS_BYTES, 2 * (pending.bytes / Math.max(1, pending.files)) + PER_WORKER_RSS_OVERHEAD_BYTES), + ) + // Files and bytes each earn threads on their own: a few hundred huge rollouts + // are as parallelisable as a few thousand small transcripts, and gating the + // count on files alone would hand a 6 GB / 60-file workload a single thread. + const workers = Math.min( + sys.cores - 1, + Math.floor(memoryBudget / perWorker), + Math.max( + Math.floor(pending.files / MIN_FILES_PER_WORKER), + Math.floor(pending.bytes / MIN_BYTES_PER_WORKER), + ), + ) + return { workers, reason: inputs } +} + +// In dist the entry is the bundled sibling of this module and a worker can load +// it directly. Running from source (tsx, vitest) the entry is TypeScript, and a +// worker thread inherits none of the parent's loader hooks — so register tsx's +// inside the thread before importing. tsx is a devDependency, which is exactly +// the only situation where the entry can be a .ts file at all. +function workerBootstrap(entryUrl: string): { source: string | URL; eval: boolean } { + if (!entryUrl.endsWith('.ts')) return { source: new URL(entryUrl), eval: false } + return { + eval: true, + // Chained, not awaited: the eval scope is CommonJS, and a top-level await of + // the entry re-enters it as a require(esm) cycle. + source: ` +process.noDeprecation = true +import('tsx/esm/api').then(tsx => { tsx.register(); return import(${JSON.stringify(entryUrl)}) }) +`, + } +} + +function workerEntryUrl(): string { + const ext = import.meta.url.endsWith('.ts') ? '.ts' : '.js' + return new URL(`./parse-worker${ext}`, import.meta.url).href +} + +/// One whole-file parse for a worker to run. Both kinds carry exactly what the +/// serial per-file parse takes, so the worker can run that same function. +export type ParseJob = + | { kind: 'claude'; filePath: string } + | { kind: 'codex'; source: SessionSource } + +export type ParseWorkerResult = + | { ok: true; parsed: T | null } + | { ok: false; error: string } + +export type ClaudeWorkerParse = ClaudeFileParse & { msgIds: string[]; path: string } + +type Task = { job: ParseJob; resolve: (r: ParseWorkerResult) => void } + +type WorkerMessage = { json?: string | null; error?: string } + +export class ParseWorkerPool { + private readonly workers: Worker[] = [] + private readonly idle: Worker[] = [] + private readonly inflight = new Map() + private readonly queue: Task[] = [] + private closed = false + + constructor(size: number) { + const boot = workerBootstrap(workerEntryUrl()) + const workerData = { pricing: snapshotPricingState() } + try { + for (let i = 0; i < size; i++) { + const worker = new Worker(boot.source, { eval: boot.eval, workerData }) + worker.on('message', (msg: WorkerMessage) => this.settle(worker, msg)) + worker.on('error', (err: Error) => this.settle(worker, { error: err.message }, true)) + worker.on('exit', () => this.drop(worker)) + this.workers.push(worker) + this.idle.push(worker) + } + } catch (err) { + for (const w of this.workers) void w.terminate() + this.workers.length = 0 + this.idle.length = 0 + throw err + } + } + + get size(): number { + return this.workers.length + } + + /// Parse one file off-thread. Never rejects: a worker-side failure (or a dead + /// pool) comes back as `ok: false` so the caller can fall back to an in-process + /// parse and never lose a file to a crashed thread. + submit(job: ParseJob): Promise> { + return new Promise>((resolve) => { + if (this.closed || this.workers.length === 0) { + resolve({ ok: false, error: 'parse worker pool unavailable' }) + return + } + this.queue.push({ job, resolve: resolve as (r: ParseWorkerResult) => void }) + this.pump() + }) + } + + async close(): Promise { + this.closed = true + const pending = [...this.queue] + this.queue.length = 0 + for (const task of pending) task.resolve({ ok: false, error: 'parse worker pool closed' }) + await Promise.all(this.workers.map(w => w.terminate())) + this.workers.length = 0 + this.idle.length = 0 + this.inflight.clear() + } + + private pump(): void { + while (this.queue.length > 0 && this.idle.length > 0) { + const worker = this.idle.pop()! + const task = this.queue.shift()! + this.inflight.set(worker, task) + worker.postMessage(task.job) + } + } + + private settle(worker: Worker, msg: WorkerMessage, fatal = false): void { + const task = this.inflight.get(worker) + this.inflight.delete(worker) + if (task) { + if (msg.error !== undefined) task.resolve({ ok: false, error: msg.error }) + else task.resolve({ ok: true, parsed: msg.json == null ? null : JSON.parse(msg.json) }) + } + if (fatal) return + if (!this.closed) { + this.idle.push(worker) + this.pump() + } + } + + // A thread that died takes its queue slot with it; the remaining files are + // handed back for a serial parse rather than being lost. + private drop(worker: Worker): void { + const i = this.workers.indexOf(worker) + if (i >= 0) this.workers.splice(i, 1) + const j = this.idle.indexOf(worker) + if (j >= 0) this.idle.splice(j, 1) + const task = this.inflight.get(worker) + if (task) { + this.inflight.delete(worker) + task.resolve({ ok: false, error: 'parse worker exited' }) + } + if (this.workers.length === 0) { + const pending = [...this.queue] + this.queue.length = 0 + for (const t of pending) t.resolve({ ok: false, error: 'all parse workers exited' }) + } + } +} + +/// Yield results for `jobs` in the SAME order they were given, no matter which +/// worker finishes first. Keeps exactly `pool.size` files in flight, so at most +/// that many parsed results are buffered while the caller installs one. +export async function* parseFilesInOrder( + pool: ParseWorkerPool, + jobs: readonly ParseJob[], +): AsyncGenerator, void, void> { + const inflight: Array>> = [] + let next = 0 + const fill = (): void => { + while (inflight.length < Math.max(1, pool.size) && next < jobs.length) { + inflight.push(pool.submit(jobs[next++]!)) + } + } + fill() + for (let i = 0; i < jobs.length; i++) { + const result = await inflight.shift()! + fill() + yield result + } +} diff --git a/src/parser.ts b/src/parser.ts index 712295b0..7eec184c 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -4,12 +4,13 @@ import { basename, dirname, join, resolve, sep } from 'path' import { readSessionLines } from './fs-utils.js' import { calculateCost, calculateLocalModelSavings, getShortModelName, isProxiedPath, getProxyPathsConfigHash, getModelAliasesConfigHash, getPriceOverridesConfigHash, getLocalModelSavingsConfigHash } from './models.js' import { resolveSubagentAttribution, sessionIdentity } from './sessions-report.js' -import { normalizeContentBlocks } from './content-utils.js' +import { normalizeContentBlocks, flatSlice, flatString } from './content-utils.js' import { discoverAllSessions, getProvider } from './providers/index.js' -import { flushCodexCache } from './codex-cache.js' +import { flushCodexCache, readCachedCodexResults, withCodexCacheDirectory, writeCachedCodexResults } from './codex-cache.js' import { antigravityCascadeIdFromPath, flushAntigravityCache, shouldReparseAntigravitySource } from './providers/antigravity.js' -import { getDesktopSessionsDirs } from './providers/claude.js' +import { getClaudeConfigDirs, getDesktopSessionsDirs } from './providers/claude.js' import { isSqliteBusyError } from './sqlite.js' +import { getCodeburnCacheDir } from './cache-dir.js' import { type CachedCall, type CachedFile, @@ -22,11 +23,16 @@ import { DURABLE_PROVIDER_NAMES, fingerprintFile, isCacheComplete, + isCacheDirty, loadCache, + markCacheDirty, + monthScopeForRange, reconcileFile, saveCache, } from './session-cache.js' import { acquireCacheRefreshLock, type RefreshLockHandle } from './cache-refresh-lock.js' +import { decideParseWorkers, parseFilesInOrder, ParseWorkerPool, type ClaudeWorkerParse, type ParseJob } from './parse-workers.js' +import type { CodexFullParse } from './providers/codex.js' import { dateKey } from './day-aggregator.js' import type { ParsedProviderCall, SessionSource } from './providers/types.js' import type { @@ -91,7 +97,27 @@ function isCoworkSession(cwd: string, filePath: string): boolean { }) } +// Memoizes resolveCanonicalProjectPath: every ParsedProviderCall with a +// projectPath pays the .git-marker directory walk (one lstat per ancestor +// level), and a session's calls all share one cwd — without this cache a +// cold parse re-walks the same few directories thousands of times +// (measured ~+5% cold-parse time for a large kiro store). Filesystem facts +// can go stale in a long-lived process (a dir converted to a worktree +// mid-run), so the cache is cleared with the session cache. +// Stores the Promise, not the resolved value: callers within the same +// Promise.all batch would otherwise all miss the cache and each re-walk the +// filesystem before the first walk's result lands. +const canonicalPathCache = new Map>() + async function resolveCanonicalProjectPath(cwd: string): Promise<{ path: string; isWorktree: boolean }> { + const cached = canonicalPathCache.get(cwd) + if (cached) return cached + const result = resolveCanonicalProjectPathUncached(cwd) + canonicalPathCache.set(cwd, result) + return result +} + +async function resolveCanonicalProjectPathUncached(cwd: string): Promise<{ path: string; isWorktree: boolean }> { const trimmed = cwd.trim() if (!trimmed) return { path: cwd, isWorktree: false } @@ -1261,7 +1287,7 @@ export function collectToolResultMeta(entry: JournalEntry, map: Map)['aiTitle'] - if (typeof t === 'string' && t.trim()) meta.title = t.trim().slice(0, 200) + if (typeof t === 'string' && t.trim()) meta.title = flatString(t.trim().slice(0, 200)) } else if (entry.type === 'pr-link') { const url = (entry as Record)['prUrl'] if (typeof url === 'string' && url && !meta.prLinks.includes(url)) meta.prLinks.push(url) @@ -1900,7 +1926,7 @@ export async function readAgentType(filePath: string): Promise !f.append).map(f => f.filePath) + const pendingBytes = changedFiles.reduce((n, f) => f.append ? n : n + f.info.fp.sizeBytes, 0) + const decision = decideParseWorkers({ files: fullReparsePaths.length, bytes: pendingBytes }) + if (process.env['CODEBURN_VERBOSE'] === '1') { + process.stderr.write(`codeburn: claude parse workers=${decision.workers} (${decision.reason})\n`) + } + // A pool that cannot even start (worker entry missing from an odd packaging, + // thread limit reached) must degrade to the serial parse, not fail the run. + let pool: ParseWorkerPool | null = null + if (decision.workers > 0) { try { - if (append) { - // Append-only growth: parse ONLY the bytes past the cached resume offset - // and merge with the cached turns, rather than re-reading the file from 0. - // On a studio machine where live agents constantly append to session - // JSONL, this is the dominant warm-run cost. The merged result is - // byte-for-byte identical to a full re-parse (see mergeBoundaryCalls). - const tracker = { lastCompleteLineOffset: append.readFromOffset } - const toolResultMeta = new Map() - const sessionMeta = emptySessionMeta() - const newEntries = await parseClaudeEntries(filePath, tracker, append.readFromOffset, { toolResultMeta, sessionMeta }) - const cached = append.cached - - // Straddle guard: a streamed assistant message id that first appeared in - // the committed prefix can be restated inside the appended region - // (image-heavy turns stream one id across several records over seconds). - // The appended region is grouped before this file's cached keys join - // seenMsgIds, so the restated id would count twice; suppressing it - // instead would freeze the stale first emission. Neither matches a full - // re-parse, so on any id overlap the shortcut is abandoned and the file - // re-parses from byte 0 (rare: ~0.3% of real files). - const cachedIds = new Set(cached.turns.flatMap(t => t.calls.map(c => c.deduplicationKey))) - const straddles = newEntries !== null && newEntries.some(e => { - const id = getMessageId(e) - return id !== null && cachedIds.has(id) - }) - if (!straddles) { - const newTurns = newEntries - ? parsedTurnsToCachedTurns(groupIntoTurns(dedupeStreamingMessageIds(newEntries), seenMsgIds, toolResultMeta)) - : [] - - const mergedTurns: CachedTurn[] = cached.turns.map(t => ({ ...t, calls: [...t.calls] })) - if (newTurns.length > 0) { - let startIdx = 0 - // A first new turn with no leading user message is a continuation of - // the last cached turn — merge its calls in (a full re-parse would put - // them in that same turn), then append the remaining new turns. - if (!newTurns[0]!.userMessage.trim() && mergedTurns.length > 0) { - const last = mergedTurns[mergedTurns.length - 1]! - last.calls = mergeBoundaryCalls(last.calls, newTurns[0]!.calls) - // A PR referenced in the appended continuation belongs to this same - // turn: union its refs in so the shortcut matches a full re-parse. - const refs = Array.from(new Set([...(last.prRefs ?? []), ...(newTurns[0]!.prRefs ?? [])])).sort() - if (refs.length > 0) last.prRefs = refs - // A subagent spawned in the appended continuation belongs to this - // same turn: union its spawn ids in for the same reason. - const spawnIds = Array.from(new Set([...(last.spawnToolUseIds ?? []), ...(newTurns[0]!.spawnToolUseIds ?? [])])) - if (spawnIds.length > 0) last.spawnToolUseIds = spawnIds - startIdx = 1 - } - for (let i = startIdx; i < newTurns.length; i++) mergedTurns.push(newTurns[i]!) - } - - // The cached region's dedup keys were not added to seenMsgIds (only - // unchanged files pre-seed it), so add them now — a full re-parse would - // have, and later files dedup cross-file against them. - for (const t of cached.turns) for (const c of t.calls) seenMsgIds.add(c.deduplicationKey) - - // First-cwd wins, and the first cwd lives in the cached region whenever - // one was resolved there; only re-derive if the cached region had none. - let canonicalCwd = cached.canonicalCwd - let canonicalProjectName = cached.canonicalProjectName - let workingDirectory = cached.workingDirectory - if (canonicalCwd === undefined && newEntries) { - const cwd = extractCanonicalCwd(newEntries) - workingDirectory = workingDirectory ?? cwd - const canonical = (cwd && !isCoworkSession(cwd, filePath)) ? await resolveCanonicalProjectPath(cwd) : undefined - canonicalCwd = canonical?.path - canonicalProjectName = canonical?.isWorktree ? projectNameFromPath(canonical.path, info.dirName) : undefined - } - - // Inventory is a sorted set union; cached (older entries) ∪ new = full. - const mcpInventory = newEntries - ? Array.from(new Set([...cached.mcpInventory, ...extractMcpInventory(newEntries)])).sort() - : cached.mcpInventory - - // Session meta merges across the append boundary: title is last-wins - // (prefer the newly-parsed tail), PR links union, isSidechain is sticky. - // parentSessionId is sticky (cached-first, it is the earliest region); - // agentSpawnLinks union (cached-first, first-seen spawn id per agent wins). - const mergedTitle = sessionMeta.title ?? cached.title - const mergedPrLinks = Array.from(new Set([...(cached.prLinks ?? []), ...sessionMeta.prLinks])) - const mergedSidechain = cached.isSidechain === true || sessionMeta.isSidechain - const mergedParentSessionId = cached.parentSessionId ?? sessionMeta.parentSessionId - const mergedSpawnLinks = { ...sessionMeta.agentSpawnLinks, ...cached.agentSpawnLinks } - const mergedAmbiguousIds = Array.from(new Set([...(cached.ambiguousSpawnAgentIds ?? []), ...sessionMeta.ambiguousSpawnAgentIds])) - - section.files[filePath] = { - fingerprint: info.fp, - lastCompleteLineOffset: tracker.lastCompleteLineOffset, - canonicalCwd, - ...(workingDirectory ? { workingDirectory } : {}), - canonicalProjectName, - mcpInventory, - turns: mergedTurns, - agentType: cached.agentType, - ...(mergedTitle ? { title: mergedTitle } : {}), - ...(mergedPrLinks.length > 0 ? { prLinks: mergedPrLinks } : {}), - ...(mergedSidechain ? { isSidechain: true } : {}), - ...(mergedParentSessionId ? { parentSessionId: mergedParentSessionId } : {}), - ...(Object.keys(mergedSpawnLinks).length > 0 ? { agentSpawnLinks: mergedSpawnLinks } : {}), - ...(mergedAmbiguousIds.length > 0 ? { ambiguousSpawnAgentIds: mergedAmbiguousIds } : {}), - } - ;(diskCache as { _dirty?: boolean })._dirty = true - filesDone++ - await parseProgress.tick(filesDone) - if (filesDone % 50 === 0 || filesDone === progressTotal) { - emitScanProgress({ kind: 'tick', provider: 'claude', done: filesDone, total: progressTotal }) - } - if (onFileParsed) await onFileParsed() - continue - } - // Straddled: fall through to the full re-parse below. - } - - const tracker = { lastCompleteLineOffset: 0 } - const toolResultMeta = new Map() - const sessionMeta = emptySessionMeta() - const entries = await parseClaudeEntries(filePath, tracker, undefined, { toolResultMeta, sessionMeta }) - if (!entries) { filesDone++; await parseProgress.tick(filesDone); continue } - - const turns = groupIntoTurns(dedupeStreamingMessageIds(entries), seenMsgIds, toolResultMeta) - const cwd = extractCanonicalCwd(entries) - const canonical = (cwd && !isCoworkSession(cwd, filePath)) ? await resolveCanonicalProjectPath(cwd) : undefined - section.files[filePath] = { - fingerprint: info.fp, - lastCompleteLineOffset: tracker.lastCompleteLineOffset, - canonicalCwd: canonical?.path, - ...(cwd ? { workingDirectory: cwd } : {}), - canonicalProjectName: canonical?.isWorktree ? projectNameFromPath(canonical.path, info.dirName) : undefined, - mcpInventory: extractMcpInventory(entries), - turns: parsedTurnsToCachedTurns(turns), - agentType: await readAgentType(filePath), - ...(sessionMeta.title ? { title: sessionMeta.title } : {}), - ...(sessionMeta.prLinks.length > 0 ? { prLinks: sessionMeta.prLinks } : {}), - ...(sessionMeta.isSidechain ? { isSidechain: true } : {}), - ...(sessionMeta.parentSessionId ? { parentSessionId: sessionMeta.parentSessionId } : {}), - ...(Object.keys(sessionMeta.agentSpawnLinks).length > 0 ? { agentSpawnLinks: sessionMeta.agentSpawnLinks } : {}), - ...(sessionMeta.ambiguousSpawnAgentIds.length > 0 ? { ambiguousSpawnAgentIds: sessionMeta.ambiguousSpawnAgentIds } : {}), - } - ;(diskCache as { _dirty?: boolean })._dirty = true + pool = new ParseWorkerPool(decision.workers) } catch (err) { - // A single malformed Claude session file must not abort the whole run — that - // would empty the daily-cache backfill and wipe the trend/history (issue #441, - // same isolation the provider path already has). Record a failure marker keyed - // by the current fingerprint so it isn't re-read and re-thrown every run; it - // re-parses only if the file changes. - section.files[filePath] = { fingerprint: info.fp, mcpInventory: [], turns: [], failed: true } - ;(diskCache as { _dirty?: boolean })._dirty = true - warnProviderParseFailure('claude', filePath, err) + process.stderr.write(`codeburn: parse workers unavailable, parsing serially (${err instanceof Error ? err.message : String(err)})\n`) } - filesDone++ - await parseProgress.tick(filesDone) - // Machine-readable tick for the app splash (throttled to ~every 50 files so - // a large cold run doesn't flood stderr), plus a partial-progress save. - if (filesDone % 50 === 0 || filesDone === progressTotal) { - emitScanProgress({ kind: 'tick', provider: 'claude', done: filesDone, total: progressTotal }) + } + const offThread = pool + ? parseFilesInOrder(pool, fullReparsePaths.map(filePath => ({ kind: 'claude', filePath }))) + : null + // Files whose worker result had to be thrown away because an earlier file had + // already claimed one of its message ids. Expected to stay near zero; a large + // count means the corpus is full of resumed sessions and the pool is doing + // double work. + let workerDiscards = 0 + + const installClaudeFile = async (filePath: string, info: FileInfo, parsed: ClaudeFileParse): Promise => { + const cwd = parsed.workingDirectory + const canonical = (cwd && !isCoworkSession(cwd, filePath)) ? await resolveCanonicalProjectPath(cwd) : undefined + section.files[filePath] = { + fingerprint: info.fp, + lastCompleteLineOffset: parsed.lastCompleteLineOffset, + canonicalCwd: canonical?.path, + ...(cwd ? { workingDirectory: cwd } : {}), + canonicalProjectName: canonical?.isWorktree ? projectNameFromPath(canonical.path, info.dirName) : undefined, + mcpInventory: parsed.mcpInventory, + turns: parsed.turns, + agentType: parsed.agentType, + ...(parsed.title ? { title: parsed.title } : {}), + ...(parsed.prLinks?.length ? { prLinks: parsed.prLinks } : {}), + ...(parsed.isSidechain ? { isSidechain: true } : {}), + ...(parsed.parentSessionId ? { parentSessionId: parsed.parentSessionId } : {}), + ...(Object.keys(parsed.agentSpawnLinks ?? {}).length > 0 ? { agentSpawnLinks: parsed.agentSpawnLinks } : {}), + ...(parsed.ambiguousSpawnAgentIds?.length ? { ambiguousSpawnAgentIds: parsed.ambiguousSpawnAgentIds } : {}), } - if (onFileParsed) await onFileParsed() + markCacheDirty(diskCache, 'claude', filePath) + } + + try { + for (const { filePath, info, append } of changedFiles) { + // Marked here, not after the re-parse: an unreadable file `continue`s out + // below, and the deletion would otherwise live only in memory. + delete section.files[filePath] + markCacheDirty(diskCache, 'claude', filePath) + + // Off-thread results arrive in this order (parseFilesInOrder), so the Nth + // full re-parse here is the Nth yielded result — appends never consume one, + // in either the shortcut or the straddled-fallthrough case. A worker parses + // against an EMPTY dedup set, so an EMPTY id intersection is the proof that a + // serial parse would have dropped nothing either — that, and only that, makes + // the result installable. On any overlap the WHOLE file is discarded and + // re-parsed in-process. Never patch the overlapping turns out of a worker + // result instead: a drop is not local to its own turn, because + // parsedTurnsToCachedTurns delta-encodes gitBranch across turns, so removing + // one turn changes whether a LATER turn carries a gitBranch key. + // Deliberately OUTSIDE the per-file try below: the pairing is positional, and + // a misalignment would install one session's turns under another's path — a + // wrong number nobody would ever notice, so it fails the run instead of being + // caught as a parse failure. + let parsed: ClaudeFileParse | null | undefined + if (offThread && !append) { + const result = (await offThread.next()).value + if (result?.ok && result.parsed) { + if (result.parsed.path !== filePath) { + throw new Error(`claude parse worker result out of order: got ${result.parsed.path}, expected ${filePath}`) + } + if (result.parsed.msgIds.some(id => seenMsgIds.has(id))) { + workerDiscards++ + parsed = undefined + } else { + for (const id of result.parsed.msgIds) seenMsgIds.add(id) + parsed = result.parsed + } + } else if (result?.ok) { + parsed = null + } + } + + try { + if (append) { + // Append-only growth: parse ONLY the bytes past the cached resume offset + // and merge with the cached turns, rather than re-reading the file from 0. + // On a studio machine where live agents constantly append to session + // JSONL, this is the dominant warm-run cost. The merged result is + // byte-for-byte identical to a full re-parse (see mergeBoundaryCalls). + const tracker = { lastCompleteLineOffset: append.readFromOffset } + const toolResultMeta = new Map() + const sessionMeta = emptySessionMeta() + const newEntries = await parseClaudeEntries(filePath, tracker, append.readFromOffset, { toolResultMeta, sessionMeta }) + const cached = append.cached + + // Straddle guard: a streamed assistant message id that first appeared in + // the committed prefix can be restated inside the appended region + // (image-heavy turns stream one id across several records over seconds). + // The appended region is grouped before this file's cached keys join + // seenMsgIds, so the restated id would count twice; suppressing it + // instead would freeze the stale first emission. Neither matches a full + // re-parse, so on any id overlap the shortcut is abandoned and the file + // re-parses from byte 0 (rare: ~0.3% of real files). + const cachedIds = new Set(cached.turns.flatMap(t => t.calls.map(c => c.deduplicationKey))) + const straddles = newEntries !== null && newEntries.some(e => { + const id = getMessageId(e) + return id !== null && cachedIds.has(id) + }) + if (!straddles) { + const newTurns = newEntries + ? parsedTurnsToCachedTurns(groupIntoTurns(dedupeStreamingMessageIds(newEntries), seenMsgIds, toolResultMeta)) + : [] + + const mergedTurns: CachedTurn[] = cached.turns.map(t => ({ ...t, calls: [...t.calls] })) + if (newTurns.length > 0) { + let startIdx = 0 + // A first new turn with no leading user message is a continuation of + // the last cached turn — merge its calls in (a full re-parse would put + // them in that same turn), then append the remaining new turns. + if (!newTurns[0]!.userMessage.trim() && mergedTurns.length > 0) { + const last = mergedTurns[mergedTurns.length - 1]! + last.calls = mergeBoundaryCalls(last.calls, newTurns[0]!.calls) + // A PR referenced in the appended continuation belongs to this same + // turn: union its refs in so the shortcut matches a full re-parse. + const refs = Array.from(new Set([...(last.prRefs ?? []), ...(newTurns[0]!.prRefs ?? [])])).sort() + if (refs.length > 0) last.prRefs = refs + // A subagent spawned in the appended continuation belongs to this + // same turn: union its spawn ids in for the same reason. + const spawnIds = Array.from(new Set([...(last.spawnToolUseIds ?? []), ...(newTurns[0]!.spawnToolUseIds ?? [])])) + if (spawnIds.length > 0) last.spawnToolUseIds = spawnIds + startIdx = 1 + } + for (let i = startIdx; i < newTurns.length; i++) mergedTurns.push(newTurns[i]!) + } + + // The cached region's dedup keys were not added to seenMsgIds (only + // unchanged files pre-seed it), so add them now — a full re-parse would + // have, and later files dedup cross-file against them. + for (const t of cached.turns) for (const c of t.calls) seenMsgIds.add(c.deduplicationKey) + + // First-cwd wins, and the first cwd lives in the cached region whenever + // one was resolved there; only re-derive if the cached region had none. + let canonicalCwd = cached.canonicalCwd + let canonicalProjectName = cached.canonicalProjectName + let workingDirectory = cached.workingDirectory + if (canonicalCwd === undefined && newEntries) { + const cwd = extractCanonicalCwd(newEntries) + workingDirectory = workingDirectory ?? cwd + const canonical = (cwd && !isCoworkSession(cwd, filePath)) ? await resolveCanonicalProjectPath(cwd) : undefined + canonicalCwd = canonical?.path + canonicalProjectName = canonical?.isWorktree ? projectNameFromPath(canonical.path, info.dirName) : undefined + } + + // Inventory is a sorted set union; cached (older entries) ∪ new = full. + const mcpInventory = newEntries + ? Array.from(new Set([...cached.mcpInventory, ...extractMcpInventory(newEntries)])).sort() + : cached.mcpInventory + + // Session meta merges across the append boundary: title is last-wins + // (prefer the newly-parsed tail), PR links union, isSidechain is sticky. + // parentSessionId is sticky (cached-first, it is the earliest region); + // agentSpawnLinks union (cached-first, first-seen spawn id per agent wins). + const mergedTitle = sessionMeta.title ?? cached.title + const mergedPrLinks = Array.from(new Set([...(cached.prLinks ?? []), ...sessionMeta.prLinks])) + const mergedSidechain = cached.isSidechain === true || sessionMeta.isSidechain + const mergedParentSessionId = cached.parentSessionId ?? sessionMeta.parentSessionId + const mergedSpawnLinks = { ...sessionMeta.agentSpawnLinks, ...cached.agentSpawnLinks } + const mergedAmbiguousIds = Array.from(new Set([...(cached.ambiguousSpawnAgentIds ?? []), ...sessionMeta.ambiguousSpawnAgentIds])) + + section.files[filePath] = { + fingerprint: info.fp, + lastCompleteLineOffset: tracker.lastCompleteLineOffset, + canonicalCwd, + ...(workingDirectory ? { workingDirectory } : {}), + canonicalProjectName, + mcpInventory, + turns: mergedTurns, + agentType: cached.agentType, + ...(mergedTitle ? { title: mergedTitle } : {}), + ...(mergedPrLinks.length > 0 ? { prLinks: mergedPrLinks } : {}), + ...(mergedSidechain ? { isSidechain: true } : {}), + ...(mergedParentSessionId ? { parentSessionId: mergedParentSessionId } : {}), + ...(Object.keys(mergedSpawnLinks).length > 0 ? { agentSpawnLinks: mergedSpawnLinks } : {}), + ...(mergedAmbiguousIds.length > 0 ? { ambiguousSpawnAgentIds: mergedAmbiguousIds } : {}), + } + markCacheDirty(diskCache, 'claude', filePath) + filesDone++ + await parseProgress.tick(filesDone) + if (filesDone % 50 === 0 || filesDone === progressTotal) { + emitScanProgress({ kind: 'tick', provider: 'claude', done: filesDone, total: progressTotal }) + } + if (onFileParsed) await onFileParsed() + continue + } + // Straddled: fall through to the full re-parse below. + } + + if (parsed === undefined) parsed = await parseClaudeFileFull(filePath, seenMsgIds) + if (!parsed) { filesDone++; await parseProgress.tick(filesDone); continue } + + await installClaudeFile(filePath, info, parsed) + } catch (err) { + // A single malformed Claude session file must not abort the whole run — that + // would empty the daily-cache backfill and wipe the trend/history (issue #441, + // same isolation the provider path already has). Record a failure marker keyed + // by the current fingerprint so it isn't re-read and re-thrown every run; it + // re-parses only if the file changes. + section.files[filePath] = { fingerprint: info.fp, mcpInventory: [], turns: [], failed: true } + markCacheDirty(diskCache, 'claude', filePath) + warnProviderParseFailure('claude', filePath, err) + } + filesDone++ + await parseProgress.tick(filesDone) + // Machine-readable tick for the app splash (throttled to ~every 50 files so + // a large cold run doesn't flood stderr), plus a partial-progress save. + if (filesDone % 50 === 0 || filesDone === progressTotal) { + emitScanProgress({ kind: 'tick', provider: 'claude', done: filesDone, total: progressTotal }) + } + if (onFileParsed) await onFileParsed() + } + } finally { + await pool?.close() + } + if (pool && process.env['CODEBURN_VERBOSE'] === '1') { + process.stderr.write(`codeburn: claude parse workers done, ${workerDiscards}/${fullReparsePaths.length} results re-parsed in-process on id overlap\n`) } parseProgress.finish() @@ -2160,7 +2257,7 @@ async function scanProjectDirs( // but they carry attributable PR spend (surfaced above as a legacy split). if (section.files[cachedPath]?.prLinks?.length) continue delete section.files[cachedPath] - ;(diskCache as { _dirty?: boolean })._dirty = true + markCacheDirty(diskCache, 'claude', cachedPath) } } @@ -2187,7 +2284,13 @@ async function scanProjectDirs( let carriedPrRefs: string[] | undefined let prRefsAtRangeStart: string[] | undefined let frozePrRefs = !dateRange - let classifiedTurns = cachedFile.turns.map(turn => { + // The keep/drop decision is taken on the RAW turn, before classifying it: + // `cachedTurnToClassified` maps `calls` 1:1 onto `assistantCalls`, so a turn + // with no call in range is dropped whole by the slicer below and classifying + // it is pure waste (on a week view that is nearly all of history). The + // carries above still run over the FULL ordered turn list. + const classifiedTurns: ClassifiedTurn[] = [] + for (const turn of cachedFile.turns) { if (turn.gitBranch) carriedBranch = turn.gitBranch if (dateRange && !frozePrRefs) { const firstTs = turn.calls[0]?.timestamp @@ -2197,10 +2300,16 @@ async function scanProjectDirs( } } if (turn.prRefs?.length) carriedPrRefs = turn.prRefs - return cachedTurnToClassified(turn, carriedBranch) - }) - // Captured from the FULL turn list, before the date slice below can drop the - // turn a branch was first seen on. Lets the by-branch report keep this + if (dateRange && !callsInRange(turn.calls, dateRange)) continue + const classified = cachedTurnToClassified(turn, carriedBranch) + // Slice rather than drop: a turn spanning local midnight would otherwise + // lose every call that lands in the requested day (issue #852). Only + // `assistantCalls`/`timestamp` are touched — see classifiedTurnSlicedToRange. + const sliced = dateRange ? classifiedTurnSlicedToRange(classified, dateRange) : classified + if (sliced) classifiedTurns.push(sliced) + } + // Captured from the FULL turn list, which the date slice above can strip of + // the turn a branch was first seen on. Lets the by-branch report keep this // session's in-range unbranched spend as `null` instead of discarding it. const everHadBranch = carriedBranch !== undefined @@ -2210,16 +2319,6 @@ async function scanProjectDirs( // sessions that both spawned subagents and referenced a PR. const spawnPrSets = cachedFile.prLinks?.length ? buildSpawnPrSets(cachedFile.turns) : {} - if (dateRange) { - // Slice rather than drop: a turn spanning local midnight would otherwise - // lose every call that lands in the requested day (issue #852). Only - // `assistantCalls`/`timestamp` are touched — see classifiedTurnSlicedToRange. - classifiedTurns = classifiedTurns.flatMap(turn => { - const sliced = classifiedTurnSlicedToRange(turn, dateRange) - return sliced ? [sliced] : [] - }) - } - // A PR-linked parent that spawned subagents is kept even when its OWN turns all // fall out of range, as a 0-cost fold ANCHOR: an in-range child (an async agent // that outlived the parent's last in-range turn) still needs the parent's @@ -2453,7 +2552,7 @@ function parsedTurnToCachedTurn(turn: ParsedTurn): CachedTurn { return { timestamp: turn.timestamp, sessionId: turn.sessionId, - userMessage: turn.userMessage.slice(0, 2000), + userMessage: flatSlice(turn.userMessage, 2000), calls: turn.assistantCalls.map(apiCallToCachedCall), // Stored per-turn directly (already sorted/deduped in groupIntoTurns), unlike // gitBranch's change-detection dedup, so each turn's refs are self-contained. @@ -2484,7 +2583,7 @@ function providerCallToCachedTurn(call: ParsedProviderCall): CachedTurn { return { timestamp: call.timestamp, sessionId: call.sessionId, - userMessage: call.userMessage.slice(0, 2000), + userMessage: flatSlice(call.userMessage, 2000), calls: [providerCallToCachedCall(call)], ...(prRefs.length ? { prRefs } : {}), } @@ -2507,7 +2606,7 @@ function providerCallsToCachedTurns(calls: ParsedProviderCall[]): CachedTurn[] { turn = { timestamp: call.timestamp, sessionId: call.sessionId, - userMessage: call.userMessage.slice(0, 2000), + userMessage: flatSlice(call.userMessage, 2000), calls: [], ...(prRefs.length ? { prRefs } : {}), } @@ -2647,6 +2746,54 @@ async function parseClaudeEntries( return entries } +// Everything a cold Claude re-parse does for ONE file: read + decode + line-parse +// the JSONL, group it into turns, shape it for the cache. Depends on nothing +// process-wide except `seenMsgIds`, so a worker thread can run it against a fresh +// empty set and the parent can install the result verbatim once it has confirmed +// none of those ids were already claimed by an earlier file. Canonical-path +// resolution deliberately stays with the caller: it walks the filesystem behind a +// process-global memo. +export type ClaudeFileParse = { + lastCompleteLineOffset: number + workingDirectory?: string + mcpInventory: string[] + turns: CachedTurn[] + agentType?: string + title?: string + prLinks?: string[] + isSidechain?: boolean + parentSessionId?: string + agentSpawnLinks?: Record + ambiguousSpawnAgentIds?: string[] +} + +export async function parseClaudeFileFull( + filePath: string, + seenMsgIds: Set, +): Promise { + const tracker = { lastCompleteLineOffset: 0 } + const toolResultMeta = new Map() + const sessionMeta = emptySessionMeta() + const entries = await parseClaudeEntries(filePath, tracker, undefined, { toolResultMeta, sessionMeta }) + if (!entries) return null + + const turns = groupIntoTurns(dedupeStreamingMessageIds(entries), seenMsgIds, toolResultMeta) + const cwd = extractCanonicalCwd(entries) + return { + lastCompleteLineOffset: tracker.lastCompleteLineOffset, + ...(cwd ? { workingDirectory: cwd } : {}), + mcpInventory: extractMcpInventory(entries), + turns: parsedTurnsToCachedTurns(turns), + agentType: await readAgentType(filePath), + ...(sessionMeta.title ? { title: sessionMeta.title } : {}), + ...(sessionMeta.prLinks.length > 0 ? { prLinks: sessionMeta.prLinks } : {}), + ...(sessionMeta.isSidechain ? { isSidechain: true } : {}), + ...(sessionMeta.parentSessionId ? { parentSessionId: sessionMeta.parentSessionId } : {}), + ...(Object.keys(sessionMeta.agentSpawnLinks).length > 0 ? { agentSpawnLinks: sessionMeta.agentSpawnLinks } : {}), + ...(sessionMeta.ambiguousSpawnAgentIds.length > 0 ? { ambiguousSpawnAgentIds: sessionMeta.ambiguousSpawnAgentIds } : {}), + } +} + function getOrCreateProviderSection(cache: SessionCache, provider: string): ProviderSection { const envFp = computeEnvFingerprint(provider) const existing = cache.providers[provider] @@ -2665,6 +2812,7 @@ function getOrCreateProviderSection(cache: SessionCache, provider: string): Prov } } cache.providers[provider] = section + markCacheDirty(cache, provider) return section } @@ -2782,10 +2930,15 @@ export function emitScanProgress(event: ScanProgressEvent): void { try { process.stderr.write(`${PROGRESS_LINE_PREFIX}${JSON.stringify(event)}\n`) } catch { /* stderr closed */ } } -// Minimum spacing between partial-progress saves during a cold parse. Low enough +// Files parsed between partial-progress saves during a cold parse. Low enough // that an interrupted long run loses little work, high enough that repeated -// full-cache writes never dominate a fast warm run. -const PROGRESS_SAVE_THROTTLE_MS = 5000 +// cache writes never dominate the parse. +const PROGRESS_SAVE_FILE_INTERVAL = 2000 +// Only the claude scan reports per file; every other provider calls saveProgress +// once, at its own boundary. Without a time floor the counter would never reach +// the interval during a long non-claude phase and progress saves would simply +// stop happening there. +const PROGRESS_SAVE_MAX_INTERVAL_MS = 30_000 export function createScanProgress(label: string, total: number) { const show = !interactiveScanUI && total > 20 && process.stderr.isTTY === true @@ -2832,8 +2985,8 @@ function turnSlicedToRange(turn: CachedTurn, dateRange: DateRange): CachedTurn | return { ...turn, calls: inRangeCalls, timestamp: inRangeCalls[0]!.timestamp } } -// Same slice, applied post-classification (scanProjectDirs classifies every -// turn from its FULL call list up front, before date filtering — see the +// Same slice, applied post-classification (scanProjectDirs classifies each +// surviving turn from its FULL call list, before date filtering — see the // carriedBranch/carriedPrRefs comments in scanProjectDirs — so this only // trims `assistantCalls` and re-anchors `timestamp`; `category`/`subCategory`/ // `retries`/`hasEdits` stay exactly as classified from the complete turn. @@ -2869,6 +3022,10 @@ async function parseProviderSources( ): Promise { const provider = await getProvider(providerName) if (!provider) return [] + // The environment is a call-time input. Capture Antigravity's cache target + // for this whole parse transaction so a host changing CODEBURN_CACHE_DIR + // before the final flush cannot redirect A's dirty state into (or past) B. + const antigravityCacheDir = providerName === 'antigravity' ? getCodeburnCacheDir() : undefined const section = getOrCreateProviderSection(diskCache, providerName) const allDiscoveredFiles = new Set() @@ -2933,6 +3090,45 @@ async function parseProviderSources( } } + // Codex rollouts are the bulk of a cold parse (multi-GB against Claude's + // hundreds of MB), so whole-file decodes go to worker threads. A file the + // codex cache can serve exactly, or resume into from a byte offset, stays + // in-process: it reads a few KB, and it is the codex cache's own per-directory + // state that a thread must never own. The eligible list is built with the same + // filters (and in the same order) the parse loop applies, so the Nth result + // parseFilesInOrder yields is the Nth file that reaches the worker branch. + // The decision is per provider rather than pooled across Claude+Codex because + // the two scans run one after the other — at most one pool is ever alive — and + // a per-provider count is what the verbose line can honestly report. + const workerJobs: ParseJob[] = [] + const workerPaths = new Set() + let workerDiscards = 0 + let pendingBytes = 0 + if (providerName === 'codex' && !readOnly) { + for (const { source, fp } of changedSources) { + if (dateRange && fp.mtimeMs < dateRange.start.getTime()) continue + if (await readCachedCodexResults(source.path)) continue + workerJobs.push({ kind: 'codex', source }) + workerPaths.add(source.path) + pendingBytes += fp.sizeBytes + } + } + const decision = workerJobs.length > 0 + ? decideParseWorkers({ files: workerJobs.length, bytes: pendingBytes }) + : { workers: 0, reason: 'no full parses pending' } + if (providerName === 'codex' && !readOnly && process.env['CODEBURN_VERBOSE'] === '1') { + process.stderr.write(`codeburn: codex parse workers=${decision.workers} (${decision.reason})\n`) + } + let pool: ParseWorkerPool | null = null + if (decision.workers > 0) { + try { + pool = new ParseWorkerPool(decision.workers) + } catch (err) { + process.stderr.write(`codeburn: parse workers unavailable, parsing serially (${err instanceof Error ? err.message : String(err)})\n`) + } + } + const offThread = pool ? parseFilesInOrder(pool, workerJobs) : null + // Parse changed files, update cache let didParse = false // Track which paths have already been cleared this pass so that subsequent @@ -2952,15 +3148,47 @@ async function parseProviderSources( // that pruned-away data is preserved for monotonic monthly totals. if (!provider.durableSources && !clearedPaths.has(source.path)) { delete section.files[source.path] + markCacheDirty(diskCache, providerName, source.path) clearedPaths.add(source.path) } - const parser = provider.createSessionParser(source, parserDedup, dateRange) + // Off-thread results arrive in this order, so the Nth eligible file here is + // the Nth yielded result. A worker decodes against an EMPTY dedup set, so an + // EMPTY key intersection is the proof that a serial parse would have dropped + // nothing either — that, and only that, makes the result installable. On any + // overlap (a forked rollout replaying its parent's token_count history is + // exactly this) the WHOLE file is discarded and re-parsed in-process against + // the real dedup set. Deliberately OUTSIDE the per-file try below: the + // pairing is positional, and a misalignment would install one rollout's + // calls under another's path — a wrong number nobody would ever notice, so + // it fails the run instead of being caught as a parse failure. + let providerCalls: ParsedProviderCall[] | undefined + if (offThread && workerPaths.has(source.path)) { + const result = (await offThread.next()).value + if (result?.ok && result.parsed) { + if (result.parsed.path !== source.path) { + throw new Error(`codex parse worker result out of order: got ${result.parsed.path}, expected ${source.path}`) + } + if (result.parsed.keys.some(k => parserDedup.has(k))) { + workerDiscards++ + } else { + for (const k of result.parsed.keys) parserDedup.add(k) + providerCalls = result.parsed.calls + // The worker never touches the codex cache; publish its entry here, + // in install order, so flushCodexCache writes what serial would. + const write = result.parsed.write + if (write) await writeCachedCodexResults(source.path, write.project, providerCalls, write.fingerprint, write.resume) + } + } + } try { - const providerCalls: ParsedProviderCall[] = [] - for await (const call of parser.parse()) { - providerCalls.push(call) + if (!providerCalls) { + const parser = provider.createSessionParser(source, parserDedup, dateRange) + providerCalls = [] + for await (const call of parser.parse()) { + providerCalls.push(call) + } } const canonicalCalls = await Promise.all(providerCalls.map(canonicalizeProviderCallProject)) const turns = providerCallsToCachedTurns(canonicalCalls) @@ -2997,7 +3225,7 @@ async function parseProviderSources( } } didParse = true - ;(diskCache as { _dirty?: boolean })._dirty = true + markCacheDirty(diskCache, providerName, source.path) } catch (err) { if (isSqliteBusyError(err)) { warnProviderReadFailureOnce(providerName, err) @@ -3010,16 +3238,20 @@ async function parseProviderSources( // on every refresh; it re-parses only if it changes. Empty turns => no // usage contributed. section.files[source.path] = { fingerprint: fp, mcpInventory: [], turns: [], failed: true } - ;(diskCache as { _dirty?: boolean })._dirty = true + markCacheDirty(diskCache, providerName, source.path) warnProviderParseFailure(providerName, source.path, err) continue } } } finally { + await pool?.close() + if (pool && process.env['CODEBURN_VERBOSE'] === '1') { + process.stderr.write(`codeburn: codex parse workers done, ${workerDiscards}/${workerJobs.length} results re-parsed in-process on id overlap\n`) + } if (didParse && providerName === 'codex') await flushCodexCache() if (didParse && providerName === 'antigravity') { const liveIds = new Set(sources.map(s => antigravityCascadeIdFromPath(s.path))) - await flushAntigravityCache(liveIds) + await flushAntigravityCache(liveIds, antigravityCacheDir) } } @@ -3027,14 +3259,14 @@ async function parseProviderSources( // parseAllSessions can fast-check without a getProvider() round-trip. if (!readOnly && provider.durableSources && !section.durable) { section.durable = true - ;(diskCache as { _dirty?: boolean })._dirty = true + markCacheDirty(diskCache, providerName) } if (!readOnly && sources.length > 0 && !provider.durableSources) { for (const cachedPath of Object.keys(section.files)) { if (!allDiscoveredFiles.has(cachedPath)) { delete section.files[cachedPath] - ;(diskCache as { _dirty?: boolean })._dirty = true + markCacheDirty(diskCache, providerName, cachedPath) } } } @@ -3051,7 +3283,7 @@ async function parseProviderSources( .reduce((max, ts) => Math.max(max, ts), 0) if (newestTs > 0 && newestTs < cutoffMs) { delete section.files[cachedPath] - ;(diskCache as { _dirty?: boolean })._dirty = true + markCacheDirty(diskCache, providerName, cachedPath) } } } @@ -3190,7 +3422,15 @@ async function parseProviderSources( const CACHE_TTL_MS = 180_000 const MAX_CACHE_ENTRIES = 10 -const sessionCache = new Map() +type SessionCacheEntry = { + data: ProjectSummary[] + createdAt: number + validatedFrom: number + startMs?: number + endMs?: number + sig?: string +} +const sessionCache = new Map() // Burst reuse for a resident process (codeburn serve). Every payload command // anchors its range end at its own `new Date()`, so two panel fetches issued @@ -3207,15 +3447,16 @@ function parseBurstWindowMs(): number { // A resident process (codeburn serve) can install a validator that answers // "has any watched session root changed since this timestamp?" — typically -// backed by fs.watch over every provider's probeRoots(). While the validator -// reports clean, a previous parse stays reusable well past the burst window, -// bounded by a hard cap so a missed filesystem event self-heals instead of -// pinning stale data forever. Null (the default everywhere but serve) keeps -// reuse strictly inside the burst window. -let parseReuseValidator: ((sinceTs: number) => boolean) | null = null +// backed by fs.watch over every provider's probeRoots(). Clean extends reuse +// to the hard cap, dirty rejects every memo, and unknown (watcher coverage is +// unavailable or began too late) falls back to the ordinary exact TTL / short +// burst rather than disabling caching. Null keeps those ordinary semantics. +export type ParseReuseValidation = 'clean' | 'dirty' | 'unknown' +type ParseReuseValidator = (sinceTs: number) => ParseReuseValidation +let parseReuseValidator: ParseReuseValidator | null = null const VALIDATED_REUSE_CAP_MS = 5 * 60 * 1000 -export function setParseReuseValidator(validator: ((sinceTs: number) => boolean) | null): void { +export function setParseReuseValidator(validator: ParseReuseValidator | null): void { parseReuseValidator = validator } @@ -3227,9 +3468,13 @@ function burstReuse(dateRange: DateRange, sig: string): ProjectSummary[] | null const endMs = dateRange.end.getTime() for (const entry of sessionCache.values()) { if (entry.sig !== sig || entry.startMs !== startMs || entry.endMs === undefined) continue - const age = now - entry.ts + const validation = parseReuseValidator?.(entry.validatedFrom) ?? 'unknown' + // A dirty event during the producing parse must not be hidden even by the + // short burst. Unknown coverage, however, retains that bounded fallback. + if (validation === 'dirty') continue + const age = now - entry.createdAt const insideBurst = age <= windowMs - const validatedClean = parseReuseValidator !== null && age <= VALIDATED_REUSE_CAP_MS && parseReuseValidator(entry.ts) + const validatedClean = validation === 'clean' && age <= VALIDATED_REUSE_CAP_MS if (!insideBurst && !validatedClean) continue if (endMs < entry.endMs || endMs - entry.endMs > Math.max(windowMs, validatedClean ? VALIDATED_REUSE_CAP_MS : 0)) continue return filterProjectsByDateRange(entry.data, dateRange) @@ -3237,34 +3482,36 @@ function burstReuse(dateRange: DateRange, sig: string): ProjectSummary[] | null return null } -function cacheKey(dateRange?: DateRange, providerFilter?: string): string { +function cacheKey(dateRange: DateRange | undefined, providerFilter: string | undefined, claudeDiscoveryRoots: readonly string[]): string { const s = dateRange ? `${dateRange.start.getTime()}:${dateRange.end.getTime()}` : 'none' - // Include the Claude config-dir env so a config change in a long-lived - // process (menubar / GNOME extension / test workers) does not return - // stale data keyed under a previous configuration. - const claudeEnv = (process.env['CLAUDE_CONFIG_DIRS'] ?? '') + '|' + (process.env['CLAUDE_CONFIG_DIR'] ?? '') + // Key on the effective roots, not only their env inputs: GUI consumers can + // change config.json claudeConfigDirs while a resident serve process stays + // alive. Normalized roots also collapse syntactically different inputs that + // discover the same directories. + const claudeRoots = JSON.stringify(claudeDiscoveryRoots) // Proxy attribution (totalProxiedCostUSD) is computed live from proxyPaths and // then cached, so the key must change when that config changes. // Pricing-affecting config participates so a memoized parse (exact-key or // burst-reused in a resident serve process) can never present costs priced // under aliases/overrides/savings the user has since changed. - return `${s}:${providerFilter ?? 'all'}:${claudeEnv}:${getProxyPathsConfigHash()}:${getModelAliasesConfigHash()}:${getPriceOverridesConfigHash()}:${getLocalModelSavingsConfigHash()}` + return `${s}:${providerFilter ?? 'all'}:${claudeRoots}:${getProxyPathsConfigHash()}:${getModelAliasesConfigHash()}:${getPriceOverridesConfigHash()}:${getLocalModelSavingsConfigHash()}` } export function clearSessionCache(): void { sessionCache.clear() + canonicalPathCache.clear() } -function cachePut(key: string, data: ProjectSummary[]) { +function cachePut(key: string, data: ProjectSummary[], parseStartedAt: number) { const now = Date.now() for (const [k, v] of sessionCache) { - if (now - v.ts > CACHE_TTL_MS) sessionCache.delete(k) + if (now - v.createdAt > CACHE_TTL_MS) sessionCache.delete(k) } if (sessionCache.size >= MAX_CACHE_ENTRIES) { - const oldest = [...sessionCache.entries()].sort((a, b) => a[1].ts - b[1].ts)[0] + const oldest = [...sessionCache.entries()].sort((a, b) => a[1].createdAt - b[1].createdAt)[0] if (oldest) sessionCache.delete(oldest[0]) } - sessionCache.set(key, { data, ts: now, ...(putMeta ?? {}) }) + sessionCache.set(key, { data, createdAt: now, validatedFrom: parseStartedAt, ...(putMeta ?? {}) }) putMeta = null } @@ -3707,19 +3954,50 @@ export function isSessionHydrationComplete(): boolean { // chart (gapStart = lastComputedDate + 1 never looks back at them). let readOnlyServedStale = false -export async function parseAllSessions(dateRange?: DateRange, providerFilter?: string): Promise { - const key = cacheKey(dateRange, providerFilter) +export function parseAllSessions(dateRange?: DateRange, providerFilter?: string): Promise { + // Capture synchronously, before the first await. AsyncLocalStorage keeps all + // Codex cache reads, dirty writes, and the final flush on this call-time + // directory even if an embedding host changes the process env mid-parse. + const codexCacheDir = getCodeburnCacheDir() + return withCodexCacheDirectory(codexCacheDir, () => parseAllSessionsInCacheScope(dateRange, providerFilter)) +} + +async function parseAllSessionsInCacheScope(dateRange?: DateRange, providerFilter?: string): Promise { + // Anchor freshness before any config, cache, or session input is read. A + // watched-root event that lands while this parse is in flight must remain + // newer than the resulting memo instead of being blessed retroactively. + const parseStartedAt = Date.now() + const claudeDiscoveryRoots = await getClaudeConfigDirs() + const key = cacheKey(dateRange, providerFilter, claudeDiscoveryRoots) const cached = sessionCache.get(key) - if (cached && Date.now() - cached.ts < CACHE_TTL_MS) return cached.data + if (cached) { + const age = Date.now() - cached.createdAt + const validation = parseReuseValidator?.(cached.validatedFrom) ?? 'unknown' + if ( + validation !== 'dirty' + && (age < CACHE_TTL_MS || (validation === 'clean' && age <= VALIDATED_REUSE_CAP_MS)) + ) return cached.data + } // The signature is the key minus the range: what must match for a burst // reuse (provider, config env, proxy hash) regardless of the now-anchor. - const burstSig = cacheKey(undefined, providerFilter) + const burstSig = cacheKey(undefined, providerFilter, claudeDiscoveryRoots) if (dateRange) { const reused = burstReuse(dateRange, burstSig) if (reused) return reused } - let diskCache = await loadCache() + // Load only the month shards a query over `dateRange` can possibly report + // on. Sessions whose every turn falls outside the range are dropped from the + // report anyway, so skipping their shards changes nothing except the bytes + // read — and a save writes only dirty months, leaving the skipped ones on + // disk untouched (see saveCache). Cross-file dedup is weakened, not broken: + // the pre-seed of `seenMsgIds` / `seenKeys` only covers loaded files, so a key + // that a skipped file also holds is no longer suppressed. Totals are + // unaffected (a suppressed duplicate contributes nothing either way), but for + // a proxied key emitted under two providers the attribution can land on a + // different provider than a full load would pick. + const loadScope = dateRange ? monthScopeForRange(dateRange.start, dateRange.end) : undefined + let diskCache = await loadCache(loadScope) await cleanupOrphanedTempFiles() // Cold-hydration coordination (advisory, cross-process). Engages whenever the @@ -3732,10 +4010,10 @@ export async function parseAllSessions(dateRange?: DateRange, providerFilter?: s // doubt it proceeds unlocked. if (!isCacheComplete(diskCache)) { const hydration = await beginColdHydration(true) - if (hydration.waited) diskCache = await loadCache() + if (hydration.waited) diskCache = await loadCache(loadScope) const isCold = !isCacheComplete(diskCache) try { - return await runParse(key, diskCache, dateRange, providerFilter, { isCold }) + return await runParse(key, diskCache, dateRange, providerFilter, { isCold, burstSig, parseStartedAt }) } finally { await hydration.release() } @@ -3747,20 +4025,20 @@ export async function parseAllSessions(dateRange?: DateRange, providerFilter?: s const priorSnapshot = diskCache const refresh = await acquireCacheRefreshLock() if (refresh.outcome === 'timed-out' || refresh.outcome === 'unavailable') { - return runParse(key, priorSnapshot, dateRange, providerFilter, { readOnly: true }) + return runParse(key, priorSnapshot, dateRange, providerFilter, { readOnly: true, burstSig, parseStartedAt }) } if (refresh.outcome === 'completed-by-other') { - return runParse(key, await loadCache(), dateRange, providerFilter, { readOnly: true }) + return runParse(key, await loadCache(loadScope), dateRange, providerFilter, { readOnly: true, burstSig, parseStartedAt }) } try { // Reload only after ownership is canonical; this closes the lost-update // window between the pre-gate read and the holder's completed publication. - diskCache = await loadCache() - return await runParse(key, diskCache, dateRange, providerFilter, { refreshLock: refresh.handle }) + diskCache = await loadCache(loadScope) + return await runParse(key, diskCache, dateRange, providerFilter, { refreshLock: refresh.handle, burstSig, parseStartedAt }) } catch (err) { if (!(err instanceof RefreshFenceLostError) && !(err instanceof RefreshPublicationUnavailableError)) throw err - return runParse(key, await loadCache(), dateRange, providerFilter, { readOnly: true }) + return runParse(key, await loadCache(loadScope), dateRange, providerFilter, { readOnly: true, burstSig, parseStartedAt }) } finally { await refresh.handle.release() } @@ -3773,14 +4051,16 @@ type RunParseOptions = { isCold?: boolean readOnly?: boolean refreshLock?: RefreshLockHandle + burstSig: string + parseStartedAt: number } async function runParse( key: string, diskCache: SessionCache, - dateRange?: DateRange, - providerFilter?: string, - options: RunParseOptions = {}, + dateRange: DateRange | undefined, + providerFilter: string | undefined, + options: RunParseOptions, ): Promise { const { isCold = false, readOnly = false, refreshLock } = options readOnlyServedStale = false @@ -3798,15 +4078,22 @@ async function runParse( providerGroups.set(source.provider, existing) } - // Cold-run robustness: persist partial progress during a long parse (throttled) - // so a run interrupted before the single end-of-parse save still leaves a warm - // cache behind. saveCache is atomic (temp + rename) and clears `_dirty`, so this + // Cold-run robustness: persist partial progress during a long parse so a run + // interrupted before the single end-of-parse save still leaves a warm cache + // behind. Triggered by files parsed rather than elapsed time: the cost of a + // save scales with the corpus, not the clock, so a wall-clock throttle made a + // slow cold parse rewrite the whole (growing) cache every few seconds. At this + // interval a ~18k-file cold parse saves under a dozen times. saveCache is + // atomic (temp + rename) and writes only the dirty provider shards, so this // never races the final save below. + let filesSinceSave = 0 let lastSaveAt = Date.now() const saveProgress = async (): Promise => { if (!isCold || readOnly) return - if (!(diskCache as { _dirty?: boolean })._dirty) return - if (Date.now() - lastSaveAt < PROGRESS_SAVE_THROTTLE_MS) return + if (!isCacheDirty(diskCache)) return + filesSinceSave++ + if (filesSinceSave < PROGRESS_SAVE_FILE_INTERVAL && Date.now() - lastSaveAt < PROGRESS_SAVE_MAX_INTERVAL_MS) return + filesSinceSave = 0 lastSaveAt = Date.now() try { await saveCache(diskCache) } catch { /* best-effort partial save */ } } @@ -3890,7 +4177,7 @@ async function runParse( // partial saves keep `complete: false` and the next launch resumes cold. const wasComplete = isCacheComplete(diskCache) if (!readOnly && !wasComplete) diskCache.complete = true - if (!readOnly && ((diskCache as { _dirty?: boolean })._dirty || !wasComplete)) { + if (!readOnly && (isCacheDirty(diskCache) || !wasComplete)) { try { const published = await saveCache(diskCache, refreshLock?.verifyStillOwner) if (!published) throw new RefreshFenceLostError() @@ -3942,7 +4229,7 @@ async function runParse( const result = Array.from(mergedMap.values()).sort((a, b) => b.totalCostUSD - a.totalCostUSD) correlateCrossProviderPrSessions(result) - if (dateRange) setCachePutMeta({ startMs: dateRange.start.getTime(), endMs: dateRange.end.getTime(), sig: cacheKey(undefined, providerFilter) }) - cachePut(key, result) + if (dateRange) setCachePutMeta({ startMs: dateRange.start.getTime(), endMs: dateRange.end.getTime(), sig: options.burstSig }) + cachePut(key, result, options.parseStartedAt) return result } diff --git a/src/providers/antigravity.ts b/src/providers/antigravity.ts index 31449567..fad0f39d 100644 --- a/src/providers/antigravity.ts +++ b/src/providers/antigravity.ts @@ -1,11 +1,12 @@ import { readdir, readFile, mkdir, stat, open, rename, unlink } from 'fs/promises' import { execFile } from 'child_process' import { randomBytes } from 'crypto' -import { basename, join } from 'path' +import { basename, join, resolve } from 'path' import { homedir } from 'os' import { fileURLToPath } from 'url' import https from 'https' +import { getCodeburnCacheDir } from '../cache-dir.js' import { calculateCost } from '../models.js' import { isSqliteAvailable, isSqliteBusyError, openDatabase } from '../sqlite.js' import type { ProbeRoot, Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js' @@ -161,8 +162,17 @@ type AntigravityGenMetadataRow = { const cachedServers = new Map() const cachedModelMaps = new Map() -let memCache: AntigravityCache | null = null -let cacheDirty = false +type AntigravityCacheState = { cache: AntigravityCache; dirty: boolean } +const cacheStates = new Map() + +// Dropped by the resident RSS guard. A dirty state holds cascades not yet on +// disk, so it stays resident until its own flush publishes it. +export function clearAntigravityCacheStates(): void { + for (const [dir, state] of cacheStates) { + if (!state.dirty) cacheStates.delete(dir) + } +} + let httpsAgent: https.Agent | undefined const protoTextDecoder = new TextDecoder('utf-8', { fatal: false }) @@ -175,16 +185,16 @@ function getAgent(): https.Agent { return httpsAgent } -function getCacheDir(): string { - return process.env['CODEBURN_CACHE_DIR'] ?? join(homedir(), '.cache', 'codeburn') +function currentCacheDir(): string { + return resolve(getCodeburnCacheDir()) } -function getCachePath(): string { - return join(getCacheDir(), 'antigravity-results.json') +function getCachePath(cacheDir: string): string { + return join(cacheDir, 'antigravity-results.json') } export function getAntigravityStatusLineEventsPath(): string { - return join(getCacheDir(), 'antigravity-statusline.jsonl') + return join(getCodeburnCacheDir(), 'antigravity-statusline.jsonl') } function execFileText(command: string, args: string[], timeout = 3000): Promise { @@ -323,22 +333,30 @@ export function extractAntigravityGeneratorMetadata(resp: unknown): GeneratorMet return Array.isArray(metadata) ? metadata : [] } -async function loadCache(): Promise { - if (memCache) return memCache +async function loadCache(cacheDir: string): Promise { + const inMemory = cacheStates.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 AntigravityCache if (cache.version === CACHE_VERSION && cache.cascades && typeof cache.cascades === 'object') { - memCache = cache - return cache + const state = { cache, dirty: false } + cacheStates.set(cacheDir, state) + return state } } catch { /* no cache or invalid */ } - memCache = { version: CACHE_VERSION, cascades: {} } - return memCache + const state: AntigravityCacheState = { + cache: { version: CACHE_VERSION, cascades: {} }, + dirty: false, + } + cacheStates.set(cacheDir, state) + return state } -async function flushCache(liveCascadeIds?: Set): Promise { - if (!memCache) return +async function flushCache(liveCascadeIds?: Set, cacheDir = currentCacheDir()): Promise { + const state = cacheStates.get(cacheDir) + if (!state) return + const memCache = state.cache // If the caller supplied liveCascadeIds, we must run the eviction step // even when no cascade was added or updated this run; otherwise deleted // .pb files would persist in the cache forever once it stops getting @@ -348,16 +366,14 @@ async function flushCache(liveCascadeIds?: Set): Promise { for (const id of Object.keys(memCache.cascades)) { if (!liveCascadeIds.has(id)) { delete memCache.cascades[id] - cacheDirty = true + state.dirty = true } } } - if (!cacheDirty) return + if (!state.dirty) return try { - - const dir = getCacheDir() - await mkdir(dir, { recursive: true }) - const finalPath = getCachePath() + await mkdir(cacheDir, { recursive: true }) + const finalPath = getCachePath(cacheDir) const tempPath = `${finalPath}.${randomBytes(8).toString('hex')}.tmp` const handle = await open(tempPath, 'w', 0o600) try { @@ -371,7 +387,7 @@ async function flushCache(liveCascadeIds?: Set): Promise { } catch { try { await unlink(tempPath) } catch { /* cleanup */ } } - cacheDirty = false + state.dirty = false } catch { /* best-effort */ } } @@ -1009,7 +1025,7 @@ export async function recordAntigravityStatusLinePayload(input: unknown): Promis if (!event) return false const path = getAntigravityStatusLineEventsPath() - await mkdir(getCacheDir(), { recursive: true, mode: 0o700 }) + await mkdir(getCodeburnCacheDir(), { recursive: true, mode: 0o700 }) const fd = await open(path, 'a', 0o600) try { await fd.appendFile(`${JSON.stringify(event)}\n`, { encoding: 'utf-8' }) @@ -1173,7 +1189,9 @@ export async function snapshotAntigravityStatusLinePayload(input: unknown): Prom const s = await stat(source.path).catch(() => null) if (!s) return false - const cache = await loadCache() + const cacheDir = currentCacheDir() + const state = await loadCache(cacheDir) + const cache = state.cache const cached = cache.cascades[cascadeId] if (cached && cached.mtimeMs === s.mtimeMs && cached.sizeBytes === s.size && cached.calls.length > 0) { return true @@ -1195,8 +1213,8 @@ export async function snapshotAntigravityStatusLinePayload(input: unknown): Prom sizeBytes: s.size, calls: snapshotCalls, } - cacheDirty = true - await flushCache() + state.dirty = true + await flushCache(undefined, cacheDir) return cache.cascades[cascadeId]!.calls.length > 0 } catch { return false @@ -1300,7 +1318,8 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars } const cascadeId = antigravityCascadeIdFromPath(source.path) - const cache = await loadCache() + const state = await loadCache(currentCacheDir()) + const cache = state.cache const s = await stat(source.path).catch(() => null) if (!s) return @@ -1331,7 +1350,7 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars sizeBytes: s.size, calls: sqliteResults, } - cacheDirty = true + state.dirty = true for (const call of sqliteResults) { if (seenKeys.has(call.deduplicationKey)) continue @@ -1384,7 +1403,7 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars sizeBytes: s.size, calls: results, } - cacheDirty = true + state.dirty = true for (const call of results) { if (seenKeys.has(call.deduplicationKey)) continue @@ -1445,8 +1464,8 @@ export function createAntigravityProvider(): Provider { } } -export async function flushAntigravityCache(liveCascadeIds?: Set): Promise { - await flushCache(liveCascadeIds) +export async function flushAntigravityCache(liveCascadeIds?: Set, cacheDir?: string): Promise { + await flushCache(liveCascadeIds, cacheDir ? resolve(cacheDir) : currentCacheDir()) } export const antigravity = createAntigravityProvider() diff --git a/src/providers/codex.ts b/src/providers/codex.ts index 7191c6fe..3328a3ef 100644 --- a/src/providers/codex.ts +++ b/src/providers/codex.ts @@ -6,7 +6,7 @@ import { homedir } from 'os' import { readSessionLines } from '../fs-utils.js' import { calculateCost } from '../models.js' -import { readCachedCodexResults, writeCachedCodexResults, getCachedCodexProject, fingerprintFile } from '../codex-cache.js' +import { readCachedCodexResults, writeCachedCodexResults, getCachedCodexProject, fingerprintFile, type CodexFileFingerprint } from '../codex-cache.js' import { normalizeContentBlocks } from '../content-utils.js' import { estimateTokensFromChars } from '../token-estimate.js' import type { ToolCall } from '../types.js' @@ -569,52 +569,131 @@ function resolveModel(info: CodexEntry['payload'], sessionModel?: string): strin return firstModelString(info?.model, info?.info?.model, info?.info?.model_name, sessionModel) ?? 'gpt-5' } -function createParser(source: SessionSource, seenKeys: Set): SessionParser { +// Everything the single-pass decode carries across a `task_started` boundary. +// A rollout is append-only, so recording this at such a boundary (where the +// previous task has been flushed to `results` and the per-task accumulators are +// empty) lets a later run restart there and produce byte-identical output +// instead of re-reading the whole file. Every field the loop below reads from a +// PRIOR line must appear here, or a resumed decode silently diverges. +type CodexResumeState = { + sessionModel?: string + sessionId: string + sessionCwd?: string + forkedFromId: string + forkCutoff: string + prevCumulativeTotal: number | null + prevInput: number + prevCached: number + prevOutput: number + prevReasoning: number + pendingTools: string[] + pendingToolSequence: ToolCall[][] + pendingUserMessage: string + pendingOutputChars: number + pendingLocAdded: number + pendingLocRemoved: number + pendingEditFailed: number + estCounter: number + turnCounter: number + currentTurnId: string + taskStartedAt?: number +} + +// The state comes back off our own JSON cache; a truncated or hand-edited file +// must fall back to a full re-parse rather than decode against nonsense. +function isResumeState(value: unknown): value is CodexResumeState { + if (!value || typeof value !== 'object') return false + const v = value as Record + return typeof v['sessionId'] === 'string' + && typeof v['forkedFromId'] === 'string' + && typeof v['forkCutoff'] === 'string' + && (v['prevCumulativeTotal'] === null || typeof v['prevCumulativeTotal'] === 'number') + && typeof v['prevInput'] === 'number' + && typeof v['prevCached'] === 'number' + && typeof v['prevOutput'] === 'number' + && typeof v['prevReasoning'] === 'number' + && Array.isArray(v['pendingTools']) + && Array.isArray(v['pendingToolSequence']) + && typeof v['pendingUserMessage'] === 'string' + && typeof v['pendingOutputChars'] === 'number' + && typeof v['pendingLocAdded'] === 'number' + && typeof v['pendingLocRemoved'] === 'number' + && typeof v['pendingEditFailed'] === 'number' + && typeof v['estCounter'] === 'number' + && typeof v['turnCounter'] === 'number' + && typeof v['currentTurnId'] === 'string' +} + +/** What the serial path would have written to the codex cache for one file. */ +export type CodexCacheWrite = { + project: string + fingerprint: CodexFileFingerprint + resume?: { offset: number; state: unknown; callCount: number } +} + +// When `capture` is passed the parse is a whole-file decode that never touches +// the codex cache: no hit lookup (so no resume), and the entry it would have +// written comes back through `capture` for the caller to install. That is what +// lets a worker thread run this exact decode without owning the cache module's +// per-directory state. +function createParser(source: SessionSource, seenKeys: Set, capture?: { write?: CodexCacheWrite }): SessionParser { return { async *parse(): AsyncGenerator { - const cached = await readCachedCodexResults(source.path) - if (cached) { - for (const call of cached) { + const hit = capture ? null : await readCachedCodexResults(source.path) + if (hit?.kind === 'exact') { + for (const call of hit.calls) { if (seenKeys.has(call.deduplicationKey)) continue seenKeys.add(call.deduplicationKey) yield call } return } + const resume = hit && isResumeState(hit.state) + ? { offset: hit.offset, state: hit.state, calls: hit.calls.slice(0, hit.callCount) } + : null const fp = await fingerprintFile(source.path) if (!fp) return - let sessionModel: string | undefined - let sessionId = '' - let sessionCwd: string | undefined - let forkedFromId = '' - let forkCutoff = '' + let sessionModel: string | undefined = resume?.state.sessionModel + let sessionId = resume?.state.sessionId ?? '' + let sessionCwd: string | undefined = resume?.state.sessionCwd + let forkedFromId = resume?.state.forkedFromId ?? '' + let forkCutoff = resume?.state.forkCutoff ?? '' // Null sentinel rather than `0` so the FIRST event is never confused // with a duplicate. A session that only emits last_token_usage (no // total_token_usage) reports cumulativeTotal=0 on every event; with a // 0-initialized prev, the first event would have matched and been // dropped. Once we've observed any event, we record its cumulative // total and dedup on equality regardless of whether it is zero. - let prevCumulativeTotal: number | null = null - let prevInput = 0 - let prevCached = 0 - let prevOutput = 0 - let prevReasoning = 0 - let pendingTools: string[] = [] - let pendingToolSequence: ToolCall[][] = [] - let pendingUserMessage = '' - let pendingOutputChars = 0 + let prevCumulativeTotal: number | null = resume?.state.prevCumulativeTotal ?? null + let prevInput = resume?.state.prevInput ?? 0 + let prevCached = resume?.state.prevCached ?? 0 + let prevOutput = resume?.state.prevOutput ?? 0 + let prevReasoning = resume?.state.prevReasoning ?? 0 + let pendingTools: string[] = resume ? [...resume.state.pendingTools] : [] + let pendingToolSequence: ToolCall[][] = resume ? [...resume.state.pendingToolSequence] : [] + let pendingUserMessage = resume?.state.pendingUserMessage ?? '' + let pendingOutputChars = resume?.state.pendingOutputChars ?? 0 // Rich-session-capture: edit LOC deltas and failed-patch count accumulated // across a turn's patch_apply_end events, flushed onto the turn's call. - let pendingLocAdded = 0 - let pendingLocRemoved = 0 - let pendingEditFailed = 0 - let estCounter = 0 - let turnCounter = 0 - let currentTurnId = `${sessionId}:t0` + let pendingLocAdded = resume?.state.pendingLocAdded ?? 0 + let pendingLocRemoved = resume?.state.pendingLocRemoved ?? 0 + let pendingEditFailed = resume?.state.pendingEditFailed ?? 0 + let estCounter = resume?.state.estCounter ?? 0 + let turnCounter = resume?.state.turnCounter ?? 0 + let currentTurnId = resume?.state.currentTurnId ?? `${sessionId}:t0` let sawAnyLine = false const results: ParsedProviderCall[] = [] + // Calls already decoded before the resume boundary. They pass through the + // same cross-provider dedup a full decode would have applied to them. + if (resume) { + for (const call of resume.calls) { + if (seenKeys.has(call.deduplicationKey)) continue + seenKeys.add(call.deduplicationKey) + results.push(call) + } + } // Calls decoded since the last task_started, held back so task_complete can // stamp active/toolWait timing before they are appended to results. Emitting // a task only once its timing is known keeps single-pass and split/resume @@ -623,15 +702,25 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars let pendingTaskCalls: ParsedProviderCall[] = [] let taskGeneratedTokens = 0 let taskToolIntervals: Array<[number, number]> = [] - let taskStartedAt: number | undefined + let taskStartedAt: number | undefined = resume?.state.taskStartedAt const openToolStarts = new Map() + // Resume point for the NEXT run, refreshed at every task boundary. + const tracker = { lastCompleteLineOffset: resume?.offset ?? 0 } + let resumeOffset = resume?.offset ?? 0 + let resumeState: CodexResumeState | null = resume?.state ?? null + let resumeCallCount = results.length + // Stream the session file line by line. Heavy Codex sessions can exceed // 250 MB on disk; reading the entire file into a string would either hit // the readSessionFile cap or push V8 toward its 512 MB string limit // after split('\n'). readSessionLines streams raw buffers and hands // huge lines to the compact parser without full string conversion. - for await (const rawLine of readSessionLines(source.path, undefined, { largeLineAsBuffer: true })) { + for await (const rawLine of readSessionLines(source.path, undefined, { + largeLineAsBuffer: true, + byteOffsetTracker: tracker, + ...(resume ? { startByteOffset: resume.offset } : {}), + })) { sawAnyLine = true const entry = parseCodexLine(rawLine) if (!entry) continue @@ -684,6 +773,33 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars const startedAt = entry.timestamp ? Date.parse(entry.timestamp) : NaN taskStartedAt = Number.isFinite(startedAt) ? startedAt : undefined openToolStarts.clear() + // Everything decoded so far is now in `results` and the per-task + // accumulators are empty: a clean restart point for an appended tail. + resumeOffset = tracker.lastCompleteLineOffset + resumeCallCount = results.length + resumeState = { + ...(sessionModel !== undefined ? { sessionModel } : {}), + sessionId, + ...(sessionCwd !== undefined ? { sessionCwd } : {}), + forkedFromId, + forkCutoff, + prevCumulativeTotal, + prevInput, + prevCached, + prevOutput, + prevReasoning, + pendingTools: [...pendingTools], + pendingToolSequence: [...pendingToolSequence], + pendingUserMessage, + pendingOutputChars, + pendingLocAdded, + pendingLocRemoved, + pendingEditFailed, + estCounter, + turnCounter, + currentTurnId, + ...(taskStartedAt !== undefined ? { taskStartedAt } : {}), + } continue } @@ -997,13 +1113,23 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars // If the stream yielded nothing the file was unreadable, oversized, or // empty. Skip cache write so a transient failure can't pin an empty - // result set against a fingerprint that would otherwise be re-parsed. - if (!sawAnyLine) return + // result set against a fingerprint that would otherwise be re-parsed. On a + // resume the earlier calls are still valid output, so serve them - but + // still leave the cache entry alone. + if (!sawAnyLine) { + if (resume) for (const call of results) yield call + return + } // Flush the final task, which has no following task_started to trigger it. results.push(...pendingTaskCalls) - await writeCachedCodexResults(source.path, source.project, results, fp) + const resumeWrite = resumeState ? { offset: resumeOffset, state: resumeState, callCount: resumeCallCount } : undefined + if (capture) { + capture.write = { project: source.project, fingerprint: fp, ...(resumeWrite ? { resume: resumeWrite } : {}) } + } else { + await writeCachedCodexResults(source.path, source.project, results, fp, resumeWrite) + } for (const call of results) { yield call @@ -1012,6 +1138,20 @@ function createParser(source: SessionSource, seenKeys: Set): SessionPars } } +export type CodexFullParse = { calls: ParsedProviderCall[]; write?: CodexCacheWrite } + +/// Decode one rollout end to end, exactly as the serial path does for a file +/// with no cache entry, without reading or writing the codex cache. `seenKeys` +/// is the dedup set the decode runs against — pass an empty one off-thread and +/// let the caller prove no earlier file claimed any of the keys before +/// installing the result. +export async function parseCodexFileFull(source: SessionSource, seenKeys: Set): Promise { + const capture: { write?: CodexCacheWrite } = {} + const calls: ParsedProviderCall[] = [] + for await (const call of createParser(source, seenKeys, capture).parse()) calls.push(call) + return { calls, ...(capture.write ? { write: capture.write } : {}) } +} + export function createCodexProvider(codexDir?: string): Provider { const dir = getCodexDir(codexDir) diff --git a/src/providers/dsh.ts b/src/providers/dsh.ts new file mode 100644 index 00000000..d82664db --- /dev/null +++ b/src/providers/dsh.ts @@ -0,0 +1,591 @@ +import { open, readdir, readFile, stat } from 'fs/promises' +import { join } from 'path' +import { homedir } from 'os' +import zlib from 'zlib' + +import { MAX_SESSION_FILE_BYTES, readSessionFile } from '../fs-utils.js' +import { calculateCost, getShortModelName } from '../models.js' +import { extractBashCommands } from '../bash-utils.js' +import type { ProbeRoot, Provider, SessionSource, SessionParser, ParsedProviderCall } from './types.js' + +// DeepSeek Harness (dsh) stores one session per directory: +// /sessions//session-/session.jsonl.zstd +// (or an uncompressed session.jsonl when compression=none). The .zstd file is +// a concatenation of INDEPENDENT zstd frames — one per appended event batch — +// so node:zlib's one-shot zstdDecompressSync (which decodes a single frame) +// must be driven frame-by-frame behind a structural frame-boundary scan. The +// scan below is a port of scanZstdFrames from the official +// @deepseek-ai/dsh-session-persistence-jsonl package, which is third-party code +// under its own license - see THIRD_PARTY_NOTICES.md. + +// zstd landed in node:zlib in 22.15 / 23.8; the package floor is lower, so the +// provider degrades with a notice instead of assuming the export exists. +const zstdDecompress = (zlib as { zstdDecompressSync?: (buf: Buffer, opts?: { maxOutputLength?: number }) => Buffer }).zstdDecompressSync + +const ZSTD_MAGIC = 0xfd2fb528 + +// SESSION_FORMAT_VERSION in @deepseek-ai/dsh-session. DSH refuses to load a log +// stamped with any other version, and a bump means an event's meaning changed, +// so a foreign version is skipped rather than read with today's assumptions. +// A zstd frame's declared content size is attacker-controlled, so a few KB of +// crafted input can expand to gigabytes. Every decode is capped: no single +// frame may exceed this, and no file may decode to more than it would have been +// allowed to occupy uncompressed (MAX_SESSION_FILE_BYTES). Overflow throws, and +// the caller skips the WHOLE file rather than counting the frames it got to. +const MAX_FRAME_DECODED_BYTES = 64 * 1024 * 1024 + +const SESSION_FORMAT_VERSION = 0 + +const MIN_REASONABLE_TIMESTAMP_MS = 1_000_000_000_000 + +// Discovery walks every session, so a per-file notice would repeat once per +// log; each distinct message is worth saying exactly once. +const noticed = new Set() + +function notice(message: string): void { + if (noticed.has(message)) return + noticed.add(message) + process.stderr.write(message) +} + +type ZstdFrame = { start: number; end: number } + +// Locate complete frames without decompressing their blocks. An EOF inside the +// final frame (a torn append from a crashed writer) returns its start so the +// caller can ignore the tail; invalid complete structure rejects. +function scanZstdFrames(buffer: Buffer, maxFrames = Number.POSITIVE_INFINITY): { frames: ZstdFrame[]; tornStart?: number } { + const frames: ZstdFrame[] = [] + let offset = 0 + while (offset < buffer.length) { + const start = offset + if (buffer.length - offset < 4) return { frames, tornStart: start } + if (buffer.readUInt32LE(offset) !== ZSTD_MAGIC) { + throw new Error(`invalid zstd frame magic at byte ${offset}`) + } + offset += 4 + if (offset === buffer.length) return { frames, tornStart: start } + const descriptor = buffer.readUInt8(offset)! + offset += 1 + if ((descriptor & 24) !== 0) throw new Error(`reserved frame-header bit at byte ${offset - 1}`) + const contentSizeFlag = descriptor >>> 6 + const singleSegment = (descriptor & 32) !== 0 + const checksum = (descriptor & 4) !== 0 + const dictionaryFlag = descriptor & 3 + const dictionaryBytes = dictionaryFlag === 3 ? 4 : dictionaryFlag + const contentSizeBytes = contentSizeFlag === 0 ? (singleSegment ? 1 : 0) : 1 << contentSizeFlag + const remainingHeaderBytes = (singleSegment ? 0 : 1) + dictionaryBytes + contentSizeBytes + if (buffer.length - offset < remainingHeaderBytes) return { frames, tornStart: start } + offset += remainingHeaderBytes + for (;;) { + if (buffer.length - offset < 3) return { frames, tornStart: start } + const blockHeader = buffer.readUIntLE(offset, 3) + offset += 3 + const lastBlock = (blockHeader & 1) !== 0 + const blockType = (blockHeader >>> 1) & 3 + const blockSize = blockHeader >>> 3 + if (blockType === 3) throw new Error(`reserved block type at byte ${offset - 3}`) + const payloadBytes = blockType === 1 ? 1 : blockSize + if (buffer.length - offset < payloadBytes) return { frames, tornStart: start } + offset += payloadBytes + if (lastBlock) break + } + if (checksum) { + if (buffer.length - offset < 4) return { frames, tornStart: start } + offset += 4 + } + frames.push({ start, end: offset }) + if (frames.length === maxFrames) return { frames } + } + return { frames } +} + +type DshUsage = { + inputTokens?: number + outputTokens?: number + cacheReadTokens?: number + cacheWriteTokens?: number + reasoningTokens?: number +} + +type DshEvent = { + type?: string + seq?: number + time?: number + // Session header fields live at the top level of the first event. + version?: number + id?: string + cwd?: string + createdAt?: number + parentSession?: string + seedLength?: number + data?: { + turn?: number + step?: number + content?: Array<{ type?: string; text?: string }> + // `user/message` carries the message author: a real prompt is + // `{ kind: 'user' }`, agent-injected context is `{ kind: 'plugin' }`. + source?: { kind?: string } + header?: { config?: { model?: string; provider?: string } } + message?: { source?: { kind?: string; model?: string; provider?: string } } + chunk?: { type?: string; usage?: DshUsage } + usage?: DshUsage + name?: string + arguments?: string + } +} + +type StepBucket = { + usage: DshUsage + // A usage report from assistant/message is the final value for its + // (turn, step) and replaces an earlier assistant/chunk sample (the two are + // adjacent reports of the same API call, per dsh-token-meter's usage + // projection). Time follows the winning report. + final: boolean + time?: number + // Model that produced this step: the reporting assistant/message's own + // `message.source` when it names one, else the most recent request/header + // config (a header can change the model mid-turn between steps). + model: string + tools: string[] + skills: string[] + bashCommands: string[] +} + +const toolNameMap: Record = { + bash: 'Bash', + pwsh: 'Bash', + read: 'Read', + write: 'Write', + edit: 'Edit', + str_replace_editor: 'Edit', + glob: 'Glob', + grep: 'Grep', + todo_write: 'TodoWrite', + todo: 'TodoWrite', + web_search: 'WebSearch', + skill: 'Skill', + agent: 'Agent', + ask_user_question: 'AskUserQuestion', +} + +function mapToolName(raw: string): string { + return toolNameMap[raw] ?? raw +} + +// Usage fields are whatever the JSON held. A string or array would flow +// straight into the global token totals and the persisted cache, where +// `0 + [1, 2]` silently becomes "01,2". Same semantics as copilot.ts. +function numberOrZero(raw: unknown): number { + return typeof raw === 'number' && Number.isFinite(raw) && raw > 0 ? raw : 0 +} + +// A log stamped with a version this parser was not written against is skipped +// whole: a bump means an event's meaning changed, so reading it with today's +// assumptions would report confident wrong numbers. +function isReadableVersion(header: DshEvent): boolean { + if (header.version === SESSION_FORMAT_VERSION) return true + // Keyed on the version, not the path: a DSH upgrade makes EVERY session + // unreadable at once, and one line per session log is noise, not a report. + notice(`codeburn: skipping DSH sessions written in session format version ${String(header.version)}; upgrade codeburn.\n`) + return false +} + +// DSH writes epoch milliseconds; promote a seconds-resolution value and reject +// what stays implausible, matching the guard cline-cli.ts uses on the hazard. +function isoTimestamp(value: number | undefined, fallback: string): string { + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) return fallback + const ms = value < MIN_REASONABLE_TIMESTAMP_MS ? value * 1000 : value + const date = new Date(ms) + if (Number.isNaN(date.getTime()) || date.getTime() < MIN_REASONABLE_TIMESTAMP_MS) return fallback + return date.toISOString() +} + +function getDshHome(override?: string): string { + // An empty-string DSH_HOME is treated as unset. + return override ?? (process.env['DSH_HOME'] || undefined) ?? join(homedir(), '.dsh') +} + +// DSH writes native-platform paths into the header (backslashes on Windows); +// split on both separators so discovery is correct on any host. +function projectFromCwd(cwd: string, fallback: string): string { + const segments = cwd.split(/[\\/]/).filter(Boolean) + return segments[segments.length - 1] ?? fallback +} + +// Decode every complete frame and yield its JSONL lines. A torn final frame is +// ignored; a structurally corrupt file, or one that decodes past `budget`, +// throws for the caller to report. Exported for the decode-budget test. +export function* readZstdLines( + buffer: Buffer, + maxFrames = Number.POSITIVE_INFINITY, + budget = MAX_SESSION_FILE_BYTES, +): Generator { + const { frames } = scanZstdFrames(buffer, maxFrames) + let remaining = budget + for (const frame of frames) { + if (remaining <= 0) throw new Error(`decodes past the ${budget}-byte cap`) + // node throws ERR_BUFFER_TOO_LARGE without allocating past the cap, so the + // per-frame limit doubles as the running budget for the frames after it. + const decoded = zstdDecompress!(buffer.subarray(frame.start, frame.end), { + maxOutputLength: Math.min(remaining, MAX_FRAME_DECODED_BYTES), + }) + remaining -= decoded.length + for (const line of decoded.toString('utf-8').split('\n')) { + if (line.trim()) yield line + } + } +} + +async function readEventLines(filePath: string): Promise { + if (filePath.endsWith('.zstd')) { + if (!zstdDecompress) { + notice('codeburn: DSH sessions need Node >= 22.15 (zstd support); skipping DSH usage.\n') + return null + } + let buffer: Buffer + try { + // The whole log is buffered to scan its frames, so it needs the same + // oversize guard readSessionFile applies to the uncompressed variant. + const size = (await stat(filePath)).size + if (size > MAX_SESSION_FILE_BYTES) { + notice(`codeburn: skipped oversize DSH session log ${filePath} (${size} bytes)\n`) + return null + } + buffer = await readFile(filePath) + } catch { + return null + } + try { + return [...readZstdLines(buffer)] + } catch (err) { + notice(`codeburn: skipped corrupt DSH session log ${filePath}: ${err instanceof Error ? err.message : err}\n`) + return null + } + } + const content = await readSessionFile(filePath) + if (content === null) return null + return content.split('\n').filter(l => l.trim()) +} + +// Cheap discovery probe: decompress ONLY the first frame (the session header +// batch) instead of the whole log. The header frame is tiny, so a bounded head +// read almost always contains it; fall back to a full read when it does not. +async function readSessionHeader(filePath: string): Promise { + const firstLine = async (): Promise => { + if (filePath.endsWith('.zstd')) { + if (!zstdDecompress) return null + let head: Buffer + try { + const handle = await open(filePath, 'r') + try { + const size = (await handle.stat()).size + const length = Math.min(size, 256 * 1024) + head = Buffer.alloc(length) + await handle.read(head, 0, length, 0) + } finally { + await handle.close() + } + } catch { + return null + } + let { frames } = scanZstdFrames(head, 1) + if (frames.length === 0) { + // Head read did not cover one full frame; take the whole file. A fork's + // first batch carries the whole inherited seed, so this is reachable on + // a real log and needs the same oversize guard as the parse read. + try { + if ((await stat(filePath)).size > MAX_SESSION_FILE_BYTES) return null + const full = await readFile(filePath) + frames = scanZstdFrames(full, 1).frames + if (frames.length === 0) return null + head = full + } catch { + return null + } + } + const text = zstdDecompress(head.subarray(frames[0]!.start, frames[0]!.end), { + maxOutputLength: MAX_FRAME_DECODED_BYTES, + }).toString('utf-8') + return text.split('\n').find(l => l.trim()) ?? null + } + const content = await readSessionFile(filePath) + return content?.split('\n').find(l => l.trim()) ?? null + } + + try { + const line = await firstLine() + if (!line) return null + const event = JSON.parse(line) as DshEvent + if (event.type !== 'session') return null + return isReadableVersion(event) ? event : null + } catch { + return null + } +} + +async function discoverSessionsInDir(sessionsDir: string): Promise { + const sources: SessionSource[] = [] + + let projectDirs: string[] + try { + projectDirs = await readdir(sessionsDir) + } catch { + return sources + } + + for (const dirName of projectDirs) { + const dirPath = join(sessionsDir, dirName) + const dirStat = await stat(dirPath).catch(() => null) + if (!dirStat?.isDirectory()) continue + + let sessionDirs: string[] + try { + sessionDirs = await readdir(dirPath) + } catch { + continue + } + + for (const sessionDir of sessionDirs) { + const sessionPath = join(dirPath, sessionDir) + const sessionStat = await stat(sessionPath).catch(() => null) + if (!sessionStat?.isDirectory()) continue + + // Compressed log first; the uncompressed variant exists when + // compression=none. Never both for the same session. + let filePath: string | null = null + for (const name of ['session.jsonl.zstd', 'session.jsonl']) { + const candidate = join(sessionPath, name) + const fileStat = await stat(candidate).catch(() => null) + if (fileStat?.isFile()) { + filePath = candidate + break + } + } + if (!filePath) continue + + const header = await readSessionHeader(filePath) + if (!header) continue + + const cwd = typeof header.cwd === 'string' && header.cwd.trim() ? header.cwd : dirName + sources.push({ path: filePath, project: projectFromCwd(cwd, dirName), provider: 'dsh' }) + } + } + + return sources +} + +function parseToolArguments(raw: string | undefined): Record | null { + if (!raw) return null + try { + const parsed = JSON.parse(raw) as unknown + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed as Record : null + } catch { + return null + } +} + +function createParser(source: SessionSource, seenKeys: Set): SessionParser { + return { + async *parse(): AsyncGenerator { + const lines = await readEventLines(source.path) + if (!lines) return + + let sessionId = '' + let cwd = '' + let model = 'unknown' + let currentTurn = 0 + let sessionStart = '' + // Events a forked session inherited from its parent. They are a verbatim + // copy of the parent's log, which codeburn parses as its own session, so + // counting them here would bill the same calls twice. + let seedLength = 0 + const userMessageByTurn = new Map() + const buckets = new Map() + + for (const line of lines) { + let event: DshEvent + try { + event = JSON.parse(line) as DshEvent + } catch { + continue + } + + if (event.type === 'session') { + if (!isReadableVersion(event)) return + sessionId = event.id ?? sessionId + cwd = event.cwd ?? cwd + sessionStart = isoTimestamp(event.createdAt, sessionStart) + if (typeof event.parentSession === 'string' && event.parentSession && typeof event.seedLength === 'number') { + seedLength = event.seedLength + } + continue + } + + if (typeof event.seq === 'number' && event.seq < seedLength) continue + + if (event.type === 'turn/start') { + currentTurn = event.data?.turn ?? currentTurn + continue + } + + if (event.type === 'request/header') { + // Emitted at most once per request; steps after the last header + // inherit its config as their model. + const headerModel = event.data?.header?.config?.model + if (typeof headerModel === 'string' && headerModel) model = headerModel + continue + } + + if (event.type === 'user/message') { + // Plugin-injected context (runtime snapshots, skill bodies, file-change + // notices) rides the same event type as a typed prompt; only the latter + // is a useful preview. + if (event.data?.source?.kind !== 'user') continue + if (userMessageByTurn.has(currentTurn)) continue + const texts = (event.data?.content ?? []) + .filter(c => c.type === 'text' && typeof c.text === 'string' && c.text) + .map(c => c.text!) + if (texts.length > 0) userMessageByTurn.set(currentTurn, texts.join(' ').slice(0, 500)) + continue + } + + if (event.type === 'tool/call') { + const turn = event.data?.turn ?? currentTurn + const step = event.data?.step ?? 0 + const rawName = event.data?.name + if (!rawName) continue + const key = `${turn}:${step}` + let bucket = buckets.get(key) + if (!bucket) { + bucket = { usage: {}, final: false, model, tools: [], skills: [], bashCommands: [] } + buckets.set(key, bucket) + } + bucket.tools.push(mapToolName(rawName)) + const args = parseToolArguments(event.data?.arguments) + if ((rawName === 'bash' || rawName === 'pwsh') && typeof args?.['command'] === 'string') { + bucket.bashCommands.push(...extractBashCommands(args['command'])) + } + if (rawName === 'skill' && typeof args?.['name'] === 'string') { + bucket.skills.push(args['name']) + } + continue + } + + let usage: DshUsage | undefined + let isFinal = false + // The model that actually served the call, when the message records it. + // request/header only describes the request codeburn is about to see. + let reportedModel = model + if (event.type === 'assistant/chunk' && event.data?.chunk?.type === 'usage') { + usage = event.data.chunk.usage + } else if (event.type === 'assistant/message' && event.data?.usage) { + usage = event.data.usage + isFinal = true + const messageModel = event.data.message?.source?.model + if (typeof messageModel === 'string' && messageModel) reportedModel = messageModel + } else { + continue + } + if (!usage) continue + + const turn = event.data?.turn ?? currentTurn + const step = event.data?.step ?? 0 + const key = `${turn}:${step}` + let bucket = buckets.get(key) + if (!bucket) { + bucket = { usage: {}, final: false, model, tools: [], skills: [], bashCommands: [] } + buckets.set(key, bucket) + } + // A final report replaces an earlier sample; a late sample never + // overwrites a final one. The model snapshot follows the winning + // report (a header can change the model mid-turn between steps). + if (isFinal || !bucket.final) { + bucket.usage = usage + bucket.final = isFinal + bucket.time = event.time + bucket.model = reportedModel + } + } + + const sortedKeys = [...buckets.keys()].sort((a, b) => { + const [ta, sa] = a.split(':').map(Number) + const [tb, sb] = b.split(':').map(Number) + return ta! - tb! || sa! - sb! + }) + + for (const key of sortedKeys) { + const bucket = buckets.get(key)! + const input = numberOrZero(bucket.usage.inputTokens) + const output = numberOrZero(bucket.usage.outputTokens) + const cacheRead = numberOrZero(bucket.usage.cacheReadTokens) + const cacheWrite = numberOrZero(bucket.usage.cacheWriteTokens) + const reasoning = numberOrZero(bucket.usage.reasoningTokens) + if (input + output + cacheRead + cacheWrite + reasoning === 0) continue + + const dedupKey = `dsh:${sessionId || source.path}:${key}` + if (seenKeys.has(dedupKey)) continue + seenKeys.add(dedupKey) + + // DSH bills reasoning tokens at the output rate (same as Gemini). + const costUSD = calculateCost(bucket.model, input, output + reasoning, cacheWrite, cacheRead, 0) + const [turn] = key.split(':').map(Number) + + yield { + provider: 'dsh', + model: bucket.model, + inputTokens: input, + outputTokens: output, + cacheCreationInputTokens: cacheWrite, + cacheReadInputTokens: cacheRead, + cachedInputTokens: cacheRead, + reasoningTokens: reasoning, + webSearchRequests: 0, + costUSD, + tools: [...new Set(bucket.tools)], + bashCommands: bucket.bashCommands, + skills: bucket.skills.length > 0 ? [...new Set(bucket.skills)] : undefined, + timestamp: isoTimestamp(bucket.time, sessionStart), + speed: 'standard', + deduplicationKey: dedupKey, + userMessage: userMessageByTurn.get(turn!) ?? '', + sessionId: sessionId || source.path, + project: cwd ? projectFromCwd(cwd, source.project) : source.project, + projectPath: cwd || undefined, + workingDirectory: cwd || undefined, + } + } + }, + } +} + +export function createDshProvider(dshHomeOverride?: string): Provider { + const dshHome = getDshHome(dshHomeOverride) + const sessionsDir = join(dshHome, 'sessions') + + return { + name: 'dsh', + displayName: 'DeepSeek Harness', + + modelDisplayName(model: string): string { + return getShortModelName(model) + }, + + toolDisplayName(rawTool: string): string { + return mapToolName(rawTool) + }, + + async probeRoots(): Promise { + return [{ path: sessionsDir, label: 'sessions' }] + }, + + async discoverSessions(): Promise { + return discoverSessionsInDir(sessionsDir) + }, + + createSessionParser(source: SessionSource, seenKeys: Set): SessionParser { + return createParser(source, seenKeys) + }, + } +} + +export const dsh = createDshProvider() diff --git a/src/providers/index.ts b/src/providers/index.ts index bf035f06..70af252c 100644 --- a/src/providers/index.ts +++ b/src/providers/index.ts @@ -7,6 +7,7 @@ import { codex } from './codex.js' import { copilot } from './copilot.js' import { droid } from './droid.js' import { devin } from './devin.js' +import { dsh } from './dsh.js' import { gemini } from './gemini.js' import { hermes } from './hermes.js' import { ibmBob } from './ibm-bob.js' @@ -192,7 +193,7 @@ async function loadZed(): Promise { } } -const coreProviders: Provider[] = [claude, cline, clineCli, codewhale, codebuff, codex, copilot, devin, droid, gemini, hermes, ibmBob, kiloCode, kiro, kimi, kimicode, lingtaiTui, mistralVibe, mux, openclaw, openclaude, openDesign, pi, omp, qwen, quickdesk, rooCode, zerostack, grok] +const coreProviders: Provider[] = [claude, cline, clineCli, codewhale, codebuff, codex, copilot, devin, droid, dsh, gemini, hermes, ibmBob, kiloCode, kiro, kimi, kimicode, lingtaiTui, mistralVibe, mux, openclaw, openclaude, openDesign, pi, omp, qwen, quickdesk, rooCode, zerostack, grok] // Lazily loaded providers, listed by name so --provider validation works even // when an optional module fails to load. Must stay in sync with getAllProviders. diff --git a/src/providers/kiro.ts b/src/providers/kiro.ts index 6723f723..4e0308d9 100644 --- a/src/providers/kiro.ts +++ b/src/providers/kiro.ts @@ -5,6 +5,7 @@ import { basename, dirname, extname, join } from 'path' import { homedir } from 'os' import { readSessionFile } from '../fs-utils.js' +import { flatSlice, flatString } from '../content-utils.js' import { calculateCost } from '../models.js' import { estimateTokensFromChars } from '../token-estimate.js' import type { ToolCall } from '../types.js' @@ -98,7 +99,10 @@ function extractToolNames(content: string): string[] { let match while ((match = regex.exec(content)) !== null) { const name = match[1]!.trim() - tools.push(toolNameMap[name] ?? name) + // flatString: regex match groups are V8 SlicedStrings that retain the + // ENTIRE subject string — storing them in the session cache would pin + // every scanned assistant-content buffer. Mapped names are flat literals. + tools.push(toolNameMap[name] ?? flatString(name)) } return tools } @@ -217,7 +221,7 @@ function parseChatFile(data: KiroChatFile, sessionId: string, project: string, s if (msg.role === 'human') { if (msg.content.startsWith('')) continue inputChars += msg.content.length - pendingUserMessage = msg.content.slice(0, 500) + pendingUserMessage = flatSlice(msg.content, 500) } if (msg.role === 'bot') { const msgTools = extractToolNames(msg.content) @@ -296,7 +300,7 @@ function parseModernExecution(data: KiroModernExecution, sourcePath: string, see if (directInput) { inputChars += directInput.length - pendingUserMessage = directInput.slice(0, 500) + pendingUserMessage = flatSlice(directInput, 500) } if (directOutput) { @@ -328,7 +332,7 @@ function parseModernExecution(data: KiroModernExecution, sourcePath: string, see if (role === 'human' || role === 'user') { if (!text) continue inputChars += text.length - pendingUserMessage = text.slice(0, 500) + pendingUserMessage = flatSlice(text, 500) } else if (role === 'bot' || role === 'assistant' || role === 'ai' || role === 'model') { if (text) outputChars += text.length if (text || tools.length > 0) hasOutputActivity = true @@ -506,6 +510,7 @@ function parseCliSession(meta: KiroCliSessionMeta, entries: KiroCliEntry[], seen userMessage: pendingUserMessage, sessionId, project, + ...(meta.cwd ? { projectPath: meta.cwd } : {}), }) turnIndex++ } @@ -526,7 +531,7 @@ function parseCliSession(meta: KiroCliSessionMeta, entries: KiroCliEntry[], seen for (const item of content) { const rec = asRecord(item) if (rec && rec['kind'] === 'text' && typeof rec['data'] === 'string') { - pendingUserMessage = (rec['data'] as string).slice(0, 500) + pendingUserMessage = flatSlice(rec['data'] as string, 500) inputChars += (rec['data'] as string).length } } @@ -605,7 +610,7 @@ async function parseWorkspaceSession(record: Record, source: Se const text = extractText(msg['content']) if (role === 'user' && text) { inputChars += text.length - pendingUserMessage = text.slice(0, 500) + pendingUserMessage = flatSlice(text, 500) } else if (role === 'assistant' && !execBacked && text && text !== 'On it.') { // An item carrying an executionId is execution-backed: its content is // counted from the execution file, so counting it here would double-count. @@ -662,6 +667,9 @@ async function parseWorkspaceSession(record: Record, source: Se deduplicationKey: dedupKey, userMessage: pendingUserMessage, sessionId, + ...(typeof record['workspaceDirectory'] === 'string' && record['workspaceDirectory'] + ? { projectPath: record['workspaceDirectory'] as string } + : {}), }) return results @@ -774,6 +782,7 @@ async function parseV2Session(source: SessionSource, seenKeys: Set): Pro userMessage: turnUserMessage, sessionId, project: source.project, + ...(meta.workspacePaths?.[0] ? { projectPath: meta.workspacePaths[0] } : {}), }) } } @@ -794,7 +803,7 @@ async function parseV2Session(source: SessionSource, seenKeys: Set): Pro // for the upcoming turn_start. if (inTurn) flushTurn() const text = typeof payload['content'] === 'string' ? payload['content'] as string : extractText(payload['content']) - pendingUserMessage = text.slice(0, 500) + pendingUserMessage = flatSlice(text, 500) pendingUserChars = text.length } else if (type === 'turn_start') { if (inTurn) flushTurn() diff --git a/src/serve.ts b/src/serve.ts index dc80e391..20ec0a6c 100644 --- a/src/serve.ts +++ b/src/serve.ts @@ -1,8 +1,11 @@ import { watch, type FSWatcher } from 'fs' -import { stat } from 'fs/promises' +import { readFile, stat } from 'fs/promises' +import { createHash } from 'crypto' import { createInterface } from 'readline' import type { Command } from 'commander' +import { getConfigFilePath } from './config.js' +import type { ParseReuseValidation } from './parser.js' // --------------------------------------------------------------------------- // codeburn serve --stdio: a resident query server for the desktop app. @@ -28,16 +31,77 @@ import type { Command } from 'commander' // already guards between processes. // --------------------------------------------------------------------------- -// First-token allowlist of the app's heavy read queries. Deliberately absent: -// every config mutation (currency, model-alias set, budget, price-override, -// proxy-path, plan), export (writes files), share/devices (network + pairing -// state), menubar/web/mcp/guard/sync/act (process management or writes). // Past this resident-set size the serve loop drops its in-memory memos and // re-parses on the next request. 3GB leaves generous room for the largest // observed corpora while bounding a pathological one. const SERVE_MAX_RSS_BYTES = 3 * 1024 * 1024 * 1024 -const SERVE_COMMANDS = new Set(['status', 'overview', 'models', 'sessions', 'compare', 'yield', 'spend', 'optimize', 'audit']) +type OutputMemoEntry = { + createdAt: number + validatedFrom: number + output: string + configFingerprint: string +} + +// Kept as a small seam so the ordering contract can be tested without relying +// on filesystem watcher scheduling: an event arriving while a parse is in +// flight must be newer than the memo produced by that parse. +export function createOutputMemoEntry( + parseStartedAt: number, + parseCompletedAt: number, + output: string, + configFingerprint: string, +): OutputMemoEntry { + return { createdAt: parseCompletedAt, validatedFrom: parseStartedAt, output, configFingerprint } +} + +type ServeOptionKind = 'flag' | 'value' + +// This is intentionally a positive, command-specific option schema rather +// than a shared denylist. If a command later gains a write-capable option it +// remains a normal one-shot CLI action until it is explicitly reviewed here. +// The entries mirror the Commander definitions in main.ts. In particular, +// optimize omits its apply-only surface (--apply, --yes, --dry-run, --only). +const SERVE_OPTIONS: Readonly>>> = { + status: { + '--format': 'value', '--scope': 'value', '--provider': 'value', '--project': 'value', + '--exclude': 'value', '--period': 'value', '--day': 'value', '--from': 'value', + '--to': 'value', '--days': 'value', '--no-optimize': 'flag', '--no-timeline': 'flag', + '--claude-config-source': 'value', + }, + overview: { + '-p': 'value', '--period': 'value', '--from': 'value', '--to': 'value', + '--provider': 'value', '--project': 'value', '--exclude': 'value', '--no-color': 'flag', + }, + models: { + '-p': 'value', '--period': 'value', '--from': 'value', '--to': 'value', + '--provider': 'value', '--task': 'value', '--by-task': 'flag', '--by-agent': 'flag', + '--top': 'value', '--min-cost': 'value', '--no-totals': 'flag', '--format': 'value', + }, + sessions: { + '-p': 'value', '--period': 'value', '--from': 'value', '--to': 'value', + '--provider': 'value', '--format': 'value', '--by-pr': 'flag', '--no-pager': 'flag', + }, + compare: { + '-p': 'value', '--period': 'value', '--provider': 'value', '--format': 'value', + '--model-a': 'value', '--model-b': 'value', + }, + yield: { + '-p': 'value', '--period': 'value', '--provider': 'value', '--format': 'value', + }, + spend: { + '-p': 'value', '--period': 'value', '--from': 'value', '--to': 'value', + '--provider': 'value', '--format': 'value', + }, + optimize: { + '-p': 'value', '--period': 'value', '--from': 'value', '--to': 'value', + '--provider': 'value', '--format': 'value', '--json': 'flag', + }, + audit: { + '-p': 'value', '--period': 'value', '--from': 'value', '--to': 'value', + '--provider': 'value', '--format': 'value', + }, +} type ServeRequest = { id: string | number; args: string[] } @@ -50,30 +114,73 @@ function isServeRequest(value: unknown): value is ServeRequest { function allowed(args: string[]): boolean { const first = args[0] - if (!first || !SERVE_COMMANDS.has(first)) return false - // No request may smuggle a second positional that turns a read into - // something else; the allowed commands take flags only. - return args.slice(1).every((a, i, all) => a.startsWith('-') || (i > 0 && all[i - 1]!.startsWith('--'))) + if (!first) return false + const options = SERVE_OPTIONS[first] + if (!options) return false + + // Served commands have no positional arguments. Long options may use the + // standard --name=value form; otherwise every value must immediately + // follow an option declared as value-bearing in that command's schema. + for (let i = 1; i < args.length; i++) { + const token = args[i]! + const separator = token.startsWith('--') ? token.indexOf('=') : -1 + const option = separator >= 0 ? token.slice(0, separator) : token + const inlineValue = separator >= 0 + const kind = options[option] + if (!kind) return false + if (kind === 'flag') { + if (inlineValue) return false + continue + } + if (inlineValue) continue + const value = args[++i] + if (value === undefined || value.startsWith('-')) return false + } + return true } class ExitSignal extends Error { constructor(public readonly code: number) { super(`exit ${code}`) } } -/// Run one argv through a fresh program, capturing everything the command -/// writes to stdout. process.exit inside a handler is converted to a thrown -/// ExitSignal so a failing request can never take the server down. -async function runCaptured(buildProgram: () => Command, args: string[]): Promise<{ output: string; code: number }> { +function chunkToString(chunk: unknown, encoding: unknown): string { + if (typeof chunk === 'string') return chunk + if (chunk instanceof Uint8Array) { + return Buffer.from(chunk).toString(typeof encoding === 'string' ? encoding as BufferEncoding : 'utf8') + } + return String(chunk) +} + +function finishWrite(rest: unknown[]): void { + const callback = rest[rest.length - 1] + if (typeof callback === 'function') (callback as () => void)() +} + +/// Run one argv through a fresh program, capturing command stdout for the +/// final response and forwarding command stderr as progress. process.exit +/// inside a handler is converted to a thrown ExitSignal so a failing request +/// can never take the server down. +async function runCaptured( + buildProgram: () => Command, + args: string[], + onProgress: (progress: string) => void, +): Promise<{ output: string; code: number }> { const chunks: string[] = [] const originalWrite = process.stdout.write.bind(process.stdout) + const originalErrorWrite = process.stderr.write.bind(process.stderr) const originalExit = process.exit.bind(process) process.stdout.write = ((chunk: unknown, ...rest: unknown[]) => { - chunks.push(typeof chunk === 'string' ? chunk : String(chunk)) - const last = rest[rest.length - 1] - if (typeof last === 'function') (last as () => void)() + chunks.push(chunkToString(chunk, rest[0])) + finishWrite(rest) return true }) as typeof process.stdout.write + process.stderr.write = ((chunk: unknown, ...rest: unknown[]) => { + const progress = chunkToString(chunk, rest[0]) + if (progress) onProgress(progress) + finishWrite(rest) + return true + }) as typeof process.stderr.write process.exit = ((code?: number) => { throw new ExitSignal(code ?? 0) }) as typeof process.exit try { @@ -86,19 +193,57 @@ async function runCaptured(buildProgram: () => Command, args: string[]): Promise throw err } finally { process.stdout.write = originalWrite + process.stderr.write = originalErrorWrite process.exit = originalExit } } +/// A cheap per-request fingerprint for the configuration that affects query +/// rendering and aggregation. Hashing the small config file tracks effective +/// content rather than filesystem churn: a byte-identical rewrite keeps the +/// memo hot, while any real change invalidates immediately. A missing config +/// is a stable state; every other read failure fails closed (no memo reuse). +async function getConfigFingerprint(): Promise { + const path = getConfigFilePath() + try { + const content = await readFile(path) + const digest = createHash('sha256').update(content).digest('hex') + return `${path}\u0000sha256:${digest}` + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') return `${path}\u0000missing` + return null + } +} + /// Watch every provider's probe roots (the same paths codeburn doctor reports /// as "where discovery looks") so the parse-reuse validator can answer "did /// any session data change since T?" without a stat sweep. macOS fs.watch -/// rides FSEvents and supports recursive directory watches; a root that fails -/// to watch is simply not covered, which only shortens reuse (the burst -/// window and the hard cap still apply), never staleness. -async function startRootWatchers(): Promise<{ startedAt: number; lastEventAt: () => number; close: () => void }> { +/// rides FSEvents and supports recursive directory watches. A probe failure or +/// a watch failure for an existing root disables event-driven reuse for this +/// generation; a root absent at setup is rechecked by the parser's hard cap. +type RootWatcherState = { + startedAt: number + lastEventAt: () => number + healthy: () => boolean + close: () => void +} + +export function classifyRootReuse( + sinceTs: number, + state: { startedAt: number; lastEventAt: number; healthy: boolean }, +): ParseReuseValidation { + // A known event is conclusive even if watcher coverage degraded afterward. + // Unknown means only that no dirty evidence exists and cleanliness cannot be + // established for the whole interval. + if (state.lastEventAt >= sinceTs) return 'dirty' + if (!state.healthy || sinceTs < state.startedAt) return 'unknown' + return 'clean' +} + +async function startRootWatchers(): Promise { let lastEventAt = 0 - const startedAt = Date.now() + let healthy = true + let closed = false const watchers: FSWatcher[] = [] try { const { getAllProviders } = await import('./providers/index.js') @@ -108,21 +253,54 @@ async function startRootWatchers(): Promise<{ startedAt: number; lastEventAt: () if (!provider.probeRoots) continue try { for (const root of await provider.probeRoots()) roots.add(root.path) - } catch { /* a failing probe just goes unwatched */ } + } catch { + // An unknown probe result could hide an existing input root, so no + // global all-roots-quiet claim is safe for this watcher generation. + healthy = false + } } for (const root of roots) { + let info: Awaited> + try { + info = await stat(root) + } catch (err) { + // An absent discovery root contains no sessions at arm time. If it is + // created later there is no child watcher to see that creation, so the + // parser's hard reuse cap remains the eventual revalidation backstop. + // Other stat failures mean an existing input could be uncovered. + if ((err as NodeJS.ErrnoException).code !== 'ENOENT') healthy = false + continue + } try { - const info = await stat(root) const watcher = watch(root, { recursive: info.isDirectory() }, () => { lastEventAt = Date.now() }) - watcher.on('error', () => { /* dropped watcher = shorter reuse, never staleness */ }) + watcher.on('error', () => { healthy = false }) watchers.push(watcher) - } catch { /* nonexistent root: nothing to watch */ } + } catch { + // stat proved this input exists, so failing to arm it invalidates the + // global quiet predicate even when other roots remain watched. + healthy = false + } } - } catch { /* watcherless serve still works via the burst window */ } + } catch { + // Discovery itself failed. Existing watchers are still closed normally, + // but they cannot validate reuse for an incomplete root set. + healthy = false + } + if (watchers.length === 0) return null + + // Coverage begins only after at least one watcher has been successfully + // armed. A parse performed while provider probing/stat/watch setup was in + // flight must not be blessed retroactively as watched. + const startedAt = Date.now() return { startedAt, lastEventAt: () => lastEventAt, - close: () => { for (const w of watchers) w.close() }, + healthy: () => healthy && !closed, + close: () => { + if (closed) return + closed = true + for (const w of watchers) w.close() + }, } } @@ -136,27 +314,49 @@ export async function runStdioServe(buildProgram: () => Command): Promise // parse stays valid past the burst window (capped in parser.ts, so a missed // filesystem event self-heals within minutes). This is what turns a warm // no-change fetch into a no-op instead of a stat sweep. - let rootsQuietSince: ((sinceTs: number) => boolean) | null = null - void startRootWatchers().then(async (w) => { + let rootReuseValidation: ((sinceTs: number) => ParseReuseValidation) | null = null + // Mutable object properties keep cleanup visible to TypeScript even though + // setup assigns them from an asynchronous continuation. + const watcherLifecycle: { + state: RootWatcherState | null + resetValidator: (() => void) | null + } = { state: null, resetValidator: null } + const watcherSetup = startRootWatchers().then(async (w) => { + watcherLifecycle.state = w + if (!w) return const { setParseReuseValidator } = await import('./parser.js') // Clean means: the watchers were already armed when the parse happened, // and no filesystem event has landed since. lastEventAt of 0 is a quiet // system (clean for anything parsed after arming), not an unknown. - const quiet = (sinceTs: number): boolean => sinceTs >= w.startedAt && w.lastEventAt() < sinceTs - rootsQuietSince = quiet - setParseReuseValidator(quiet) - }).catch(() => { /* watcherless serve still works via the burst window */ }) + const validate = (sinceTs: number): ParseReuseValidation => classifyRootReuse(sinceTs, { + startedAt: w.startedAt, + lastEventAt: w.lastEventAt(), + healthy: w.healthy(), + }) + rootReuseValidation = validate + setParseReuseValidator(validate) + watcherLifecycle.resetValidator = () => setParseReuseValidator(null) + }).catch(() => { + watcherLifecycle.state?.close() + watcherLifecycle.state = null + }) // Output-level memo: an identical panel query while the roots are quiet // returns the previous stdout verbatim - the aggregation work is skipped - // too, not just the parse. Invalidation is the same event-or-cap rule the - // parse reuse uses. + // too, not just the parse. Session data uses the same event-or-cap rule as + // parse reuse; config.json is fingerprinted on every request because it can + // change rendering without touching a provider root. const OUTPUT_MEMO_CAP_MS = 5 * 60 * 1000 - const outputMemo = new Map() + const outputMemo = new Map() + let observedConfigFingerprint: string | null | undefined if (process.stdin.isTTY) { process.stderr.write('codeburn serve speaks JSON over stdio and exists for the desktop app to hold warm.\nNothing interactive happens here; press Ctrl+C to exit.\n') } - const write = (value: unknown): void => { process.stdout.write(JSON.stringify(value) + '\n') } + // Keep the protocol transport anchored to the real stdout. runCaptured() + // temporarily replaces process.stdout.write to collect command output; a + // dynamic lookup here would swallow progress frames into the final payload. + const protocolWrite = process.stdout.write.bind(process.stdout) + const write = (value: unknown): void => { protocolWrite(JSON.stringify(value) + '\n') } write({ ready: true, pid: process.pid }) // Strict serialization: each request chains on the previous one. @@ -183,18 +383,39 @@ export async function runStdioServe(buildProgram: () => Command): Promise write({ id: request.id, ok: false, refused: true, error: 'command not served' }) return } + const configFingerprint = await getConfigFingerprint() + if (observedConfigFingerprint !== undefined && configFingerprint !== observedConfigFingerprint) { + outputMemo.clear() + } + observedConfigFingerprint = configFingerprint + // A permission or transient read failure must shorten reuse, never make + // an old result look current. + if (configFingerprint === null) outputMemo.clear() + const memoKey = request.args.join('\u0000') const memoHit = outputMemo.get(memoKey) - if (memoHit && Date.now() - memoHit.at < OUTPUT_MEMO_CAP_MS && rootsQuietSince?.(memoHit.at)) { + if ( + configFingerprint !== null + && memoHit?.configFingerprint === configFingerprint + && Date.now() - memoHit.createdAt < OUTPUT_MEMO_CAP_MS + && rootReuseValidation?.(memoHit.validatedFrom) === 'clean' + ) { write({ id: request.id, ok: true, output: memoHit.output }) return } try { - const { output, code } = await runCaptured(buildProgram, request.args) + const parseStartedAt = Date.now() + const { output, code } = await runCaptured( + buildProgram, + request.args, + progress => write({ id: request.id, progress }), + ) if (code === 0) { - outputMemo.set(memoKey, { at: Date.now(), output }) + if (configFingerprint !== null) { + outputMemo.set(memoKey, createOutputMemoEntry(parseStartedAt, Date.now(), output, configFingerprint)) + } if (outputMemo.size > 32) { - const oldest = [...outputMemo.entries()].sort((a, b) => a[1].at - b[1].at)[0] + const oldest = [...outputMemo.entries()].sort((a, b) => a[1].createdAt - b[1].createdAt)[0] if (oldest) outputMemo.delete(oldest[0]) } write({ id: request.id, ok: true, output }) @@ -212,16 +433,35 @@ export async function runStdioServe(buildProgram: () => Command): Promise if (process.memoryUsage().rss > SERVE_MAX_RSS_BYTES) { const { clearSessionCache } = await import('./parser.js') const { clearLoadCacheMemo } = await import('./session-cache.js') + const { clearCodexMemCaches } = await import('./codex-cache.js') + const { clearAntigravityCacheStates } = await import('./providers/antigravity.js') clearSessionCache() clearLoadCacheMemo() + clearCodexMemCaches() + clearAntigravityCacheStates() if (typeof globalThis.gc === 'function') globalThis.gc() } }) }) - // The app owns this process: stdin closing means the app is gone. - await new Promise((resolve) => { - rl.on('close', resolve) - process.stdin.on('end', resolve) + // The app owns this process: stdin closing (or failing) means the app is + // gone. Always release FSEvents handles and the module-global validator; + // otherwise an existing Claude root keeps a naturally closed child alive. + const transportClosed = new Promise((resolve) => { + rl.once('close', resolve) + process.stdin.once('end', resolve) + process.stdin.once('error', resolve) }) + try { + await transportClosed + } finally { + rl.close() + await watcherSetup + rootReuseValidation = null + try { + watcherLifecycle.resetValidator?.() + } finally { + watcherLifecycle.state?.close() + } + } } diff --git a/src/session-cache.ts b/src/session-cache.ts index 2759520f..a1305ae1 100644 --- a/src/session-cache.ts +++ b/src/session-cache.ts @@ -1,9 +1,9 @@ -import { readFile, stat, open, rename, unlink, readdir, mkdir } from 'fs/promises' +import { readFile, stat, open, rename, unlink, readdir, mkdir, rm } from 'fs/promises' import { existsSync, readFileSync, unlinkSync } from 'fs' import { createHash, randomBytes } from 'crypto' import { join } from 'path' -import { homedir } from 'os' +import { getCodeburnCacheDir } from './cache-dir.js' import type { ToolCall } from './types.js' // ── Types ────────────────────────────────────────────────────────────── @@ -157,19 +157,37 @@ export type SessionCache = { // INVARIANT: a version bump must extend `PRIOR_CACHE_VERSIONS` (the adoption path // below) to EVERY prior version that can still exist on disk, or expired-PR // history from the immediately preceding build silently vanishes. -export const CACHE_VERSION = 7 +// v8: on-disk layout only - the single blob became a directory of per-provider +// shards plus a small envelope, so a launch that only touched one provider +// rewrites just that provider's file. The turn shape is unchanged, so a v7 file +// migrates losslessly (migrateSingleFileCache) rather than re-parsing. +// v9: on-disk layout only - a provider's shard split further by the UTC month of +// each cached file, so one appended session rewrites one month instead of the +// provider's whole (100MB-scale) history, and a ranged query loads only the +// months it can possibly report on. Turn shape unchanged, so v8 and v7 both +// migrate losslessly. +export const CACHE_VERSION = 9 -// The cache filename is version-suffixed so different binaries (e.g. an old -// launchd menubar on a prior release and a newer desktop app) each own a -// distinct file and can never clobber each other's incompatible schema. Bumping -// CACHE_VERSION automatically mints a fresh filename, superseding the migration -// dance the legacy unversioned file used to need. -const CACHE_FILE = `session-cache.v${CACHE_VERSION}.json` +// The cache directory is version-suffixed for the same reason the file used to +// be: different binaries (an old launchd menubar, a newer desktop app) each own +// a distinct layout and can never clobber each other's incompatible schema. +const CACHE_DIR_NAME = `session-cache.v${CACHE_VERSION}` +// The v8 shard directory, read once by the lossless v8 -> v9 re-layout. +const PRIOR_SHARD_DIR_NAME = 'session-cache.v8' +// Written LAST on every save: it names the shard file of every provider-month, so +// the rename that publishes it is the single point at which a save becomes visible. +const ENVELOPE_FILE = 'envelope.json' // The pre-versioning filename. Never written or deleted anymore — old binaries // still own it. On first load we adopt-copy it once (see loadCache) when the // versioned file is absent and the legacy file's version matches ours. const LEGACY_CACHE_FILE = 'session-cache.json' const TEMP_FILE_MAX_AGE_MS = 5 * 60 * 1000 +// A shard the published envelope does not name is either superseded garbage or +// a CONCURRENT writer's shard that its envelope has not published yet. The +// second case is why this guard is an order of magnitude above the temp-file +// one: sweeping a live save's shard out from under it would publish an envelope +// naming a file that no longer exists. No save takes an hour. +const UNREFERENCED_SHARD_MAX_AGE_MS = 60 * 60 * 1000 // Env vars that change what a provider discovers or how its sessions parse. // computeEnvFingerprint hashes exactly these to decide when a provider's cache @@ -198,6 +216,7 @@ export const PROVIDER_ENV_VARS: Record = { hermes: ['HERMES_HOME'], 'lingtai-tui': ['LINGTAI_HOME', 'LINGTAI_TUI_HOME', 'LINGTAI_TUI_GLOBAL_DIR'], droid: ['FACTORY_DIR'], + dsh: ['DSH_HOME'], cursor: ['CODEBURN_CURSOR_MAX_BUBBLES'], // XDG_DATA_HOME is stale here (cursor-agent never reads it) but deliberately // kept: removing it would force a re-parse to fix nothing. @@ -266,10 +285,19 @@ export const PROVIDER_PARSE_VERSIONS: Record = { // input/cache rollup; this bump re-parses them so the missing tokens land. copilot: 'cli-shutdown-cost-v1-skills-source-provenance-v1', grok: 'estimated-cost-v1', + // seed-aware-v1: the parser now skips the parent events a forked session + // replays (double-counted before), takes the model from the reporting + // assistant/message, and keeps agent-injected context out of the preview. + dsh: 'seed-aware-v1', hermes: 'reasoning-output-accounting-v1-est-cost', 'lingtai-tui': 'token-ledger-registry-activity-v3', 'ibm-bob': 'worktree-project-grouping-v1', - kiro: 'ide-parsing-v1-est-cost', + // project-path-v1: the parser now records the session's full working + // directory as projectPath (CLI meta.cwd, v2 workspacePaths[0], workspace + // sessions' workspaceDirectory), which sync attribution needs to resolve + // the git repo. Cached entries from before the bump lack projectPath and + // would serve attribution-blind sessions forever without a re-parse. + kiro: 'ide-parsing-v1-est-cost-project-path-v1', opencode: 'session-model-v1', quickdesk: 'emf-sqlite-v2-est-cost', kimicode: 'wire-usage-v1-est-cost', @@ -279,23 +307,142 @@ export const PROVIDER_PARSE_VERSIONS: Record = { antigravity: 'worktree-project-grouping-v5', } -// ── Cache Dir ────────────────────────────────────────────────────────── - -function getCacheDir(): string { - return process.env['CODEBURN_CACHE_DIR'] ?? join(homedir(), '.cache', 'codeburn') -} - -function getCachePath(): string { - return join(getCacheDir(), CACHE_FILE) -} - function getLegacyCachePath(): string { - return join(getCacheDir(), LEGACY_CACHE_FILE) + return join(getCodeburnCacheDir(), LEGACY_CACHE_FILE) } -/** Absolute path of the active (version-suffixed) session cache file. */ -export function sessionCachePath(): string { - return getCachePath() +/** Absolute path of the active (version-suffixed) session cache directory. */ +export function sessionCacheDir(): string { + return join(getCodeburnCacheDir(), CACHE_DIR_NAME) +} + +// `until` is the UTC month of the newest turn any file in the shard holds. The +// shard's own key is the month of the OLDEST (a file is bucketed by its first +// turn), so the pair bounds every turn the shard can contribute and a ranged +// load can skip the shard outright when the two do not overlap the query. +type ShardRef = { name: string; until: string } +type EnvelopeProvider = { + envFingerprint: string + durable?: boolean + /** month (`YYYY-MM`, or `0000-00` for turn-less files) -> shard */ + shards: Record +} +type CacheEnvelope = { + version: number + complete?: boolean + nonce: string + providers: Record +} + +// Files with no turns (failure markers, empty sessions) have no month to bucket +// by. They live in one always-loaded bucket, which is also what makes the only +// possible re-bucketing safe: a file leaves this bucket the first time it gains +// a turn, and the bucket it leaves is guaranteed to be in memory. +const UNDATED_BUCKET = '0000-00' +// Sentinel inside `dirtyBuckets`: every bucket of the provider is dirty. +const ALL_BUCKETS = '*' + +function monthKey(timestamp: string | undefined): string | null { + if (!timestamp) return null + const ms = Date.parse(timestamp) + if (Number.isNaN(ms)) return null + const d = new Date(ms) + return `${d.getUTCFullYear()}-${String(d.getUTCMonth() + 1).padStart(2, '0')}` +} + +/** The UTC month span a cached file covers: `bucket` is its OLDEST turn's month + * (the shard it lives in), `until` its NEWEST (how far forward the shard can + * contribute). Both scan every turn rather than reading turns[0]/turns[-1]: + * several providers emit turns out of chronological order (cursor composers by + * ROWID, goose/crush/copilot by a DESC ordering), and a `until < bucket` span + * is empty, which makes the shard unreachable at EVERY scope. */ +export function cacheFileSpan(file: CachedFile): { bucket: string; until: string } { + let bucket: string | null = null + let until: string | null = null + for (const turn of file.turns) { + const month = monthKey(turn.timestamp) + if (month === null) continue + if (bucket === null || month < bucket) bucket = month + if (until === null || month > until) until = month + } + return bucket === null ? { bucket: UNDATED_BUCKET, until: UNDATED_BUCKET } : { bucket, until: until! } +} + +/** The shard bucket a cached file belongs to. Derived from the file's own turns, + * so an APPEND never moves it: appending can only extend `until`. */ +export function cacheBucketMonth(file: CachedFile): string { + return cacheFileSpan(file).bucket +} + +// Save bookkeeping, held beside the cache rather than on it so it never lands in +// a shard's JSON or in a caller's deep-equality. +type CacheState = { + dirty: boolean + /** provider -> dirty months (or `ALL_BUCKETS`). */ + dirtyBuckets: Map> + /** provider -> the shard refs the last load/save published. */ + shards: Map> + /** provider -> months held in memory; `null` when the whole provider loaded. */ + loaded: Map | null> + /** provider -> the envFingerprint the published envelope recorded. */ + fingerprints: Map + /** `provider\0path` -> the bucket the entry was loaded/saved under, so a + * delete or a re-bucketing can dirty the bucket it is leaving. */ + bucketOf: Map + /** The load scope this cache was read under, for the cross-request memo. */ + scope: string +} +const cacheStates = new WeakMap() + +function stateOf(cache: SessionCache): CacheState { + let state = cacheStates.get(cache) + if (!state) { + state = { + dirty: false, + dirtyBuckets: new Map(), + shards: new Map(), + loaded: new Map(), + fingerprints: new Map(), + bucketOf: new Map(), + scope: 'all', + } + cacheStates.set(cache, state) + } + return state +} + +function markBucketDirty(state: CacheState, provider: string, bucket: string): void { + state.dirty = true + let buckets = state.dirtyBuckets.get(provider) + if (!buckets) { buckets = new Set(); state.dirtyBuckets.set(provider, buckets) } + buckets.add(bucket) +} + +function isBucketDirty(state: CacheState, provider: string, bucket: string): boolean { + const buckets = state.dirtyBuckets.get(provider) + return buckets !== undefined && (buckets.has(ALL_BUCKETS) || buckets.has(bucket)) +} + +/** Record that `provider`'s section changed, so the next save rewrites the + * affected shards. Pass `filePath` whenever the change is scoped to one cached + * file — both the bucket it was last saved in and the bucket it is in now are + * marked, so a delete, a rewrite and a re-bucketing are all covered whichever + * order the caller mutates and marks in. Omitting it dirties every bucket. */ +export function markCacheDirty(cache: SessionCache, provider: string, filePath?: string): void { + const state = stateOf(cache) + if (filePath === undefined) { markBucketDirty(state, provider, ALL_BUCKETS); return } + const prior = state.bucketOf.get(`${provider}\0${filePath}`) + if (prior !== undefined) markBucketDirty(state, provider, prior) + const file = cache.providers[provider]?.files[filePath] + if (file) markBucketDirty(state, provider, cacheBucketMonth(file)) + // A path with neither a prior bucket nor a live entry (deleted before this + // process ever saw it) still has to move `dirty`, or the save is skipped. + state.dirty = true +} + +/** True when any provider section changed since the last save. */ +export function isCacheDirty(cache: SessionCache): boolean { + return stateOf(cache).dirty } // ── Env Fingerprint ──────────────────────────────────────────────────── @@ -440,6 +587,12 @@ function validateCachedFile(f: unknown): f is CachedFile { && (o['turns'] as unknown[]).every(validateTurn) } +// A shard's payload: the provider's `files` map, restricted to one month. +function validateFiles(v: unknown): v is Record { + if (!v || typeof v !== 'object' || Array.isArray(v)) return false + return Object.values(v as Record).every(validateCachedFile) +} + function validateProviderSection(s: unknown): s is ProviderSection { if (!s || typeof s !== 'object') return false const o = s as Record @@ -448,10 +601,11 @@ function validateProviderSection(s: unknown): s is ProviderSection { return Object.values(o['files'] as Record).every(validateCachedFile) } -function validateCache(raw: unknown): raw is SessionCache { +// Full validation of a single-file (pre-v8) cache blob at `version`. +function validateCache(raw: unknown, version: number): raw is SessionCache { if (!raw || typeof raw !== 'object') return false const o = raw as Record - if (o['version'] !== CACHE_VERSION) return false + if (o['version'] !== version) return false if (!o['providers'] || typeof o['providers'] !== 'object' || Array.isArray(o['providers'])) return false return Object.values(o['providers'] as Record).every(validateProviderSection) } @@ -464,7 +618,7 @@ function validateCache(raw: unknown): raw is SessionCache { // CACHE_VERSION bump MUST extend this list to every prior version that can still // exist on disk, or that history silently vanishes. (v5 was missed on the 5->6 // bump; v6 on the 6->7 bump; both are listed here.) -const PRIOR_CACHE_VERSIONS = [6, 5] as const +const PRIOR_CACHE_VERSIONS = [7, 6, 5] as const function priorCacheFile(version: number): string { return `session-cache.v${version}.json` @@ -490,7 +644,7 @@ function isCacheEnvelope(raw: unknown, version: number): raw is { version: numbe // sources. The daily cache (durable cost history) is not touched. async function adoptPriorCache(version: number): Promise { try { - const raw = await readFile(join(getCacheDir(), priorCacheFile(version)), 'utf-8') + const raw = await readFile(join(getCodeburnCacheDir(), priorCacheFile(version)), 'utf-8') const parsed = JSON.parse(raw) if (!isCacheEnvelope(parsed, version)) return null const migrated: SessionCache = { version: CACHE_VERSION, providers: {}, complete: false } @@ -540,54 +694,239 @@ async function adoptNewestPriorCache(): Promise { return merged } -// In-process memo of the parsed cache, keyed by the file identity that last -// produced it. On a 100MB+ corpus the JSON.parse of the session cache is -// seconds of work per load; a resident process (codeburn serve) pays it once -// and revalidates with a stat() per request. A rewrite by ANOTHER process -// moves mtime/size and forces a reload, so cross-process freshness is -// preserved; saveCache updates the memo write-through so the object handed -// out stays the canonical one after a refresh. -let cacheMemo: { path: string; mtimeMs: number; size: number; cache: SessionCache } | null = null +// In-process memo of the parsed cache, keyed by the envelope nonce that last +// produced it. On a 100MB+ corpus the JSON.parse of the shards is seconds of +// work per load; a resident process (codeburn serve) pays it once and +// revalidates by re-reading the (tiny) envelope per request. A save by ANOTHER +// process mints a new nonce and forces a reload, so cross-process freshness is +// preserved; saveCache updates the memo write-through so the object handed out +// stays the canonical one after a refresh. +let cacheMemo: { dir: string; nonce: string; scope: string; cache: SessionCache } | null = null export function clearLoadCacheMemo(): void { cacheMemo = null } -export async function loadCache(): Promise { - const path = getCachePath() +/** Months (UTC `YYYY-MM`, inclusive) a query can possibly report on. The load + * widens this by one month BELOW `fromMonth` and none above (see + * shardInScope): every cross-range carry in the report reads BACKWARDS from the + * first in-range turn, never forwards, so there is nothing above the range to + * reach for. */ +export type CacheLoadScope = { fromMonth: string; toMonth: string } + +export function monthScopeForRange(start: Date, end: Date): CacheLoadScope { + return { fromMonth: monthKey(start.toISOString())!, toMonth: monthKey(end.toISOString())! } +} + +function previousMonth(month: string): string { + const [y, m] = month.split('-').map(Number) as [number, number] + return m === 1 ? `${y - 1}-12` : `${y}-${String(m - 1).padStart(2, '0')}` +} + +// A shard is in scope when its [bucket .. until] span overlaps the query. One +// extra month of slack BELOW the range (and none above — every carry reads +// backwards) covers the cross-range carries that read turns from before the +// window: the pre-range PR set / git branch a session carries into its first +// in-range turn (both resolved from the same file, so they only need the file +// loaded at all), and the out-of-range subagent-spawn ANCHOR whose in-range +// child folds into it. LIMITATION: an anchor whose last turn is two or more +// months before its child's is not loaded, so that child attributes without the +// parent's PR set. One month of slack is the deliberate ceiling; widening it +// gives back the read savings the scope exists for. +// The undated bucket has no span and is always loaded. +function shardInScope(bucket: string, until: string, scope: CacheLoadScope): boolean { + if (bucket === UNDATED_BUCKET) return true + return bucket <= scope.toMonth && until >= previousMonth(scope.fromMonth) +} + +function isShardRef(v: unknown): v is ShardRef { + if (!v || typeof v !== 'object') return false + const o = v as Record + return typeof o['name'] === 'string' && typeof o['until'] === 'string' +} + +function isEnvelope(raw: unknown): raw is CacheEnvelope { + if (!raw || typeof raw !== 'object') return false + const o = raw as Record + if (o['version'] !== CACHE_VERSION || typeof o['nonce'] !== 'string') return false + const providers = o['providers'] + if (!providers || typeof providers !== 'object' || Array.isArray(providers)) return false + return Object.values(providers as Record).every(p => { + if (!p || typeof p !== 'object') return false + const e = p as Record + if (typeof e['envFingerprint'] !== 'string') return false + if (!e['shards'] || typeof e['shards'] !== 'object' || Array.isArray(e['shards'])) return false + return Object.values(e['shards'] as Record).every(isShardRef) + }) +} + +async function readEnvelope(dir: string): Promise { try { - const info = await stat(path) - if (cacheMemo && cacheMemo.path === path && cacheMemo.mtimeMs === info.mtimeMs && cacheMemo.size === info.size) { - return cacheMemo.cache - } - const raw = await readFile(path, 'utf-8') - const parsed = JSON.parse(raw) - if (!validateCache(parsed)) return afterMissingVersionedCache() - cacheMemo = { path, mtimeMs: info.mtimeMs, size: info.size, cache: parsed } - return parsed + const parsed = JSON.parse(await readFile(join(dir, ENVELOPE_FILE), 'utf-8')) + return isEnvelope(parsed) ? parsed : null } catch { - return afterMissingVersionedCache() + return null } } -// The current versioned file is absent/unreadable. Prefer adopting the newest -// prior versioned file's expired-source PR orphans (v6 before v5); failing that, -// fall back to the legacy unversioned file. Either way the versioned file is -// minted on the next save. -async function afterMissingVersionedCache(): Promise { +// A shard that is missing or malformed costs exactly the provider-months it +// held, not the provider and never the whole cache: those files re-parse while +// every other month keeps serving. +async function loadShard(path: string): Promise | null> { + try { + const parsed = JSON.parse(await readFile(path, 'utf-8')) + return validateFiles(parsed) ? parsed : null + } catch { + return null + } +} + +/** + * Read the cache. With a `scope`, only the shards whose months can contribute a + * turn to that range are read — everything else stays on disk and is carried + * across the next save untouched (see saveCache). Durable providers and any + * provider whose recorded fingerprint no longer matches are always read in + * full: the first because its cache is the only surviving record of pruned + * usage, the second because a fingerprint change discards the whole section and + * must see every entry it is discarding. + */ +export async function loadCache(scope?: CacheLoadScope): Promise { + const dir = sessionCacheDir() + const envelope = await readEnvelope(dir) + if (!envelope) return afterMissingShardCache() + const scopeKey = scope ? `${scope.fromMonth}..${scope.toMonth}` : 'all' + if (cacheMemo && cacheMemo.dir === dir && cacheMemo.nonce === envelope.nonce + && (cacheMemo.scope === 'all' || cacheMemo.scope === scopeKey)) return cacheMemo.cache + + const cache: SessionCache = { version: CACHE_VERSION, providers: {}, complete: envelope.complete === true } + const state = stateOf(cache) + const reads: Promise[] = [] + for (const [provider, meta] of Object.entries(envelope.providers)) { + const section: ProviderSection = { + envFingerprint: meta.envFingerprint, + files: {}, + ...(meta.durable ? { durable: true } : {}), + } + // Recorded even when every shard is skipped or unreadable: the section is + // what tells the next save which provider these carried-forward shard refs + // belong to, and what stops the reconcile from re-parsing under a + // fingerprint the envelope already agrees with. + cache.providers[provider] = section + const full = !scope || meta.durable === true || meta.envFingerprint !== computeEnvFingerprint(provider) + const loaded: Set | null = full ? null : new Set() + // Shards are read concurrently but merged in envelope order, so the result + // never depends on which read finished first. A path that somehow ended up + // in two shards resolves to the FRESHEST fingerprint and dirties both + // buckets, so the next save prunes the loser instead of letting it linger. + const pending: { bucket: string; files: Promise | null> }[] = [] + for (const [bucket, ref] of Object.entries(meta.shards)) { + if (loaded && !shardInScope(bucket, ref.until, scope!)) continue + loaded?.add(bucket) + pending.push({ bucket, files: loadShard(join(dir, ref.name)) }) + } + reads.push((async () => { + for (const { bucket, files: read } of pending) { + const files = await read + // Unreadable: the bucket counts as loaded-and-empty and is marked + // dirty, so the re-parsed files replace it instead of the stale shard + // being carried forward forever. + if (!files) { markBucketDirty(state, provider, bucket); continue } + for (const [path, file] of Object.entries(files)) { + const key = `${provider}\0${path}` + const seenIn = state.bucketOf.get(key) + if (seenIn !== undefined) { + markBucketDirty(state, provider, seenIn) + markBucketDirty(state, provider, bucket) + if (section.files[path]!.fingerprint.mtimeMs >= file.fingerprint.mtimeMs) continue + } + state.bucketOf.set(key, bucket) + section.files[path] = file + } + } + })()) + state.loaded.set(provider, loaded) + state.shards.set(provider, meta.shards) + state.fingerprints.set(provider, meta.envFingerprint) + } + await Promise.all(reads) + state.scope = scopeKey + cacheMemo = { dir, nonce: envelope.nonce, scope: scopeKey, cache } + return cache +} + +// The shard directory is absent/unreadable. Prefer a LOSSLESS re-layout of the +// newest prior layout that is present (v8 provider shards, then the v7 single +// file — both hold the current turn shape, so nothing re-parses); failing that, +// adopt the prior versions' expired-source PR orphans, then the legacy +// unversioned file. Either way the shard directory is minted on the next save. +async function afterMissingShardCache(): Promise { + const relaid = await migrateProviderShardCache() ?? await migrateSingleFileCache() + if (relaid) return relaid const prior = await adoptNewestPriorCache() if (prior) return prior - // validateCache requires version === CACHE_VERSION, so a different-version - // legacy file is ignored (left intact). We copy it into the versioned file once - // via saveCache; the legacy file is never modified. + // validateCache requires the version to match, so a different-version legacy + // file is ignored (left intact). We copy it into the shard layout once via + // saveCache; the legacy file is never modified. return adoptLegacyCache() } +// One-time, lossless re-layout of the v8 per-provider shard directory: v9 +// changed the on-disk LAYOUT only, so every entry moves across verbatim (just +// re-bucketed by month in memory) and nothing re-parses. The v8 directory is +// removed only once the v9 save has published. +async function migrateProviderShardCache(): Promise { + const dir = join(getCodeburnCacheDir(), PRIOR_SHARD_DIR_NAME) + let envelope: { complete?: boolean; shards: Record } + try { + const parsed = JSON.parse(await readFile(join(dir, ENVELOPE_FILE), 'utf-8')) as Record + if (parsed['version'] !== 8 || !parsed['shards'] || typeof parsed['shards'] !== 'object') return null + envelope = parsed as { complete?: boolean; shards: Record } + } catch { + return null + } + const cache: SessionCache = { version: CACHE_VERSION, providers: {}, complete: envelope.complete === true } + await Promise.all(Object.entries(envelope.shards).map(async ([provider, name]) => { + try { + const parsed = JSON.parse(await readFile(join(dir, name), 'utf-8')) + if (validateProviderSection(parsed)) cache.providers[provider] = parsed + } catch { /* one unreadable v8 shard costs that provider, as it already did */ } + })) + return publishRelaidCache(cache, () => rm(dir, { recursive: true, force: true })) +} + +// One-time, lossless re-layout of the v7 single-file cache. v7 never wrote a +// shard directory, so it is migrated straight to v9 without minting a v8 in +// between. +async function migrateSingleFileCache(): Promise { + const v7Path = join(getCodeburnCacheDir(), priorCacheFile(7)) + let parsed: unknown + try { + parsed = JSON.parse(await readFile(v7Path, 'utf-8')) + } catch { + return null + } + if (!validateCache(parsed, 7)) return null + return publishRelaidCache( + { version: CACHE_VERSION, providers: parsed.providers, complete: parsed.complete === true }, + () => unlink(v7Path), + ) +} + +// Every section is marked dirty so the save writes each month's shard; the old +// layout is retired only once that save has published. +async function publishRelaidCache(cache: SessionCache, retire: () => Promise): Promise { + for (const provider of Object.keys(cache.providers)) markCacheDirty(cache, provider) + const published = await saveCache(cache).catch(() => false) + if (published) await retryCacheFileMutation(async () => { await retire() }) + return cache +} + async function adoptLegacyCache(): Promise { try { const raw = await readFile(getLegacyCachePath(), 'utf-8') const parsed = JSON.parse(raw) - if (!validateCache(parsed)) return emptyCache() + if (!validateCache(parsed, CACHE_VERSION)) return emptyCache() + for (const provider of Object.keys(parsed.providers)) markCacheDirty(parsed, provider) await saveCache(parsed).catch(() => {}) return parsed } catch { @@ -595,15 +934,19 @@ async function adoptLegacyCache(): Promise { } } -export async function saveCache(cache: SessionCache, verifyStillOwner?: () => Promise): Promise { - const dir = getCacheDir() - if (!existsSync(dir)) await mkdir(dir, { recursive: true }) +// Shard filenames carry a fresh nonce on every write, so a save never overwrites +// the file the currently-published envelope points at: readers keep seeing a +// consistent set until the envelope rename publishes the new one, and a writer +// that loses the ownership fence leaves the canonical shards untouched. +function shardFileName(provider: string, bucket: string): string { + return `${provider.replace(/[^A-Za-z0-9_-]/g, '_')}.${bucket}.${randomBytes(8).toString('hex')}.json` +} - const finalPath = getCachePath() +// The temp name carries a nonce: two processes writing the SAME final path +// (the envelope, every save) would otherwise share one temp file and interleave +// their writes into a torn or foreign payload. +async function writeFileAtomic(finalPath: string, payload: string): Promise { const tempPath = `${finalPath}.${randomBytes(8).toString('hex')}.tmp` - delete (cache as { _dirty?: boolean })._dirty - const payload = JSON.stringify(cache) - const handle = await open(tempPath, 'w', 0o600) try { await handle.writeFile(payload, { encoding: 'utf-8' }) @@ -611,44 +954,275 @@ export async function saveCache(cache: SessionCache, verifyStillOwner?: () => Pr } finally { await handle.close() } - try { - // The warm refresh transaction passes an ownership fence. It must be the - // final operation before publication so a displaced writer cannot replace - // the canonical cache with its stale snapshot. - if (verifyStillOwner && !await verifyStillOwner()) { - await retryCacheFileMutation(() => unlink(tempPath)) - return false - } - let renamed = false for (let attempt = 0; attempt < 3; attempt++) { try { await rename(tempPath, finalPath) - renamed = true - break + return } catch (err) { const code = (err as NodeJS.ErrnoException).code if ((code !== 'EPERM' && code !== 'EBUSY') || attempt === 2) throw err await new Promise(resolve => { setTimeout(resolve, 10 * (attempt + 1)) }) } } - if (!renamed) throw new Error('session cache rename failed') - // Write-through: the object just published IS the freshest state; capture - // the post-rename file identity so the next loadCache in this process - // reuses it instead of re-parsing what it just wrote. - try { - const info = await stat(finalPath) - cacheMemo = { path: finalPath, mtimeMs: info.mtimeMs, size: info.size, cache } - } catch { - cacheMemo = null - } - return true } catch (err) { await retryCacheFileMutation(() => unlink(tempPath)) throw err } } +function bucketFiles(section: ProviderSection): { groups: Map>; until: Map } { + const groups = new Map>() + const until = new Map() + for (const [path, file] of Object.entries(section.files)) { + const span = cacheFileSpan(file) + let group = groups.get(span.bucket) + if (!group) { group = {}; groups.set(span.bucket, group) } + group[path] = file + const seen = until.get(span.bucket) + if (seen === undefined || span.until > seen) until.set(span.bucket, span.until) + } + return { groups, until } +} + +function untilMonth(files: Record): string { + let until = UNDATED_BUCKET + for (const file of Object.values(files)) { + const month = cacheFileSpan(file).until + if (month > until) until = month + } + return until +} + +// What a save has decided about one provider, carried across the ownership +// fence so every shard READ that a save needs happens as late as possible (see +// the phase-two comment in saveCache). +type ProviderPlan = { + section: ProviderSection + groups: Map> + loaded: Set | null + priorRefs: Record + reset: boolean + /** Paths that may ALSO still sit in a shard this run never loaded. */ + moved: Set + /** Buckets whose payload has to be merged with the published shard first. */ + deferred: string[] + /** bucket -> the shard name the merge was built from, for the retry below. */ + mergedFrom: Map + refs: Record +} + +export async function saveCache(cache: SessionCache, verifyStillOwner?: () => Promise): Promise { + const dir = sessionCacheDir() + if (!existsSync(dir)) await mkdir(dir, { recursive: true, mode: 0o700 }) + + const state = stateOf(cache) + const written = new Set() + const plans = new Map() + + const writeShard = async (provider: string, bucket: string, files: Record): Promise => { + const name = shardFileName(provider, bucket) + await writeFileAtomic(join(dir, name), JSON.stringify(files)) + written.add(name) + return { name, until: untilMonth(files) } + } + + // Overlay this run's entries for `bucket` onto the published shard `from`, + // minus any path that has since moved to another month. + const mergeShard = async (provider: string, plan: ProviderPlan, bucket: string, from: string | undefined): Promise => { + const files = plan.groups.get(bucket)! + const onDisk = from ? await loadShard(join(dir, from)) : null + if (!onDisk) return writeShard(provider, bucket, files) + for (const path of plan.moved) delete onDisk[path] + return writeShard(provider, bucket, { ...onDisk, ...files }) + } + + try { + // ── Phase one: everything that can be written from memory alone ────── + for (const [provider, section] of Object.entries(cache.providers)) { + const priorRefs = state.shards.get(provider) ?? {} + const loaded = state.loaded.get(provider) ?? null + // A fingerprint change discards the section outright (see + // getOrCreateProviderSection), so the months it did not load must be + // dropped rather than carried — they hold entries under the old + // fingerprint. loadCache never scopes such a provider, so `loaded` is + // null here in practice; the guard is what makes that safe to rely on. + const priorFingerprint = state.fingerprints.get(provider) + const reset = priorFingerprint !== undefined && priorFingerprint !== section.envFingerprint + const { groups } = bucketFiles(section) + const plan: ProviderPlan = { section, groups, loaded, priorRefs, reset, moved: new Set(), deferred: [], mergedFrom: new Map(), refs: {} } + plans.set(provider, plan) + + // An entry whose bucket this run never loaded may ALSO still exist, under + // an older month, in a shard we are about to carry across verbatim — a + // re-parse that shifted the file's oldest turn, or (the common #441 path) + // a parse failure that left a turn-less marker with no month at all. Left + // alone, the path would live in two shards at once and a later load could + // resolve to the stale copy. Both cases are rare, so the prune they + // trigger below reads shards it otherwise would not. + if (loaded) { + for (const [path, file] of Object.entries(section.files)) { + if (state.bucketOf.has(`${provider}\0${path}`)) continue + const bucket = cacheFileSpan(file).bucket + if (!loaded.has(bucket) || bucket === UNDATED_BUCKET) plan.moved.add(path) + } + } + + for (const [bucket, files] of groups) { + const prior = priorRefs[bucket] + // `priorRefs` is this process's snapshot from its last load or save. + // ANOTHER process may have republished that shard since, unlinking the + // file we are about to name — so reuse is conditional on the file still + // being there, and a vanished one is rewritten from memory. + if (prior && !isBucketDirty(state, provider, bucket) && existsSync(join(dir, prior.name))) { + plan.refs[bucket] = prior + continue + } + // Dirty but never loaded: memory holds only the entries this run wrote + // into the bucket, so the published shard's other entries have to be + // merged back in or the save would drop them. Deferred to phase two so + // the read happens against the CURRENT shard, not a stale name. + if (loaded && !loaded.has(bucket) && prior) { plan.deferred.push(bucket); continue } + plan.refs[bucket] = await writeShard(provider, bucket, files) + } + } + + // The warm refresh transaction passes an ownership fence. It must be the + // final operation before publication so a displaced writer cannot replace + // the canonical cache with its stale snapshot. Shards written above are + // unreferenced until the envelope names them, so a lost fence publishes + // nothing. + if (verifyStillOwner && !await verifyStillOwner()) { + for (const name of written) await retryCacheFileMutation(() => unlink(join(dir, name))) + return false + } + + // ── Phase two: everything that has to read the published shards ────── + // Re-read the envelope first. Between our load and now, another process may + // have republished any month we are carrying or merging into; adopting its + // CURRENT name is what keeps a carried orphan (an expired transcript's PR + // spend, unrecoverable by any re-parse) from being dropped just because the + // name we remembered was retired. It also shrinks the read-modify-write + // window for a merge down to the publish itself. That window is not zero: + // two processes merging into the same unloaded month can still interleave, + // and the loser's entries are re-derived on the next parse rather than lost + // for good — a full lock here would cost every save the contention. + const live = await readEnvelope(dir) + for (const [provider, plan] of plans) { + const liveShards = live?.providers[provider]?.shards ?? {} + const currentName = (bucket: string): string | undefined => { + const name = liveShards[bucket]?.name ?? plan.priorRefs[bucket]?.name + return name && existsSync(join(dir, name)) ? name : undefined + } + + for (const bucket of plan.deferred) { + plan.refs[bucket] = await mergeShard(provider, plan, bucket, currentName(bucket)) + plan.mergedFrom.set(bucket, currentName(bucket)) + } + + // Months this run never loaded keep their published shard. This is the + // invariant that makes a scoped load safe to save from. A month another + // process published while we held a partial view is adopted for the same + // reason: dropping it would delete history we never even saw. + if (!plan.loaded || plan.reset) continue + const carried = new Set([...Object.keys(plan.priorRefs), ...Object.keys(liveShards)]) + for (const bucket of carried) { + if (plan.refs[bucket] || plan.groups.has(bucket) || plan.loaded.has(bucket)) continue + const name = currentName(bucket) + if (!name) continue + const ref = { name, until: (liveShards[bucket] ?? plan.priorRefs[bucket])!.until } + if (plan.moved.size === 0) { plan.refs[bucket] = ref; continue } + // A path that moved into another month must not survive here too. + const onDisk = await loadShard(join(dir, name)) + if (!onDisk || !Object.keys(onDisk).some(p => plan.moved.has(p))) { plan.refs[bucket] = ref; continue } + for (const path of plan.moved) delete onDisk[path] + if (Object.keys(onDisk).length > 0) plan.refs[bucket] = await writeShard(provider, bucket, onDisk) + } + } + + // One optimistic retry: if another process republished a month we merged + // into while we were reading it, our shard was built on a superseded + // pre-image and would drop that process's entries. Redoing the merge from + // the current shard narrows the read-modify-write window from a shard read + // down to the envelope publish below. It does not close it — a save that + // loses the remaining race has its entries re-derived by the next parse + // (the reconcile sees no cache entry and re-reads the file), never silently + // dropped for good. A lock here would tax every save for a rare interleave. + const settled = await readEnvelope(dir) + for (const [provider, plan] of plans) { + for (const [bucket, mergedFrom] of plan.mergedFrom) { + const now = settled?.providers[provider]?.shards[bucket]?.name + if (!now || now === mergedFrom || !existsSync(join(dir, now))) continue + plan.refs[bucket] = await mergeShard(provider, plan, bucket, now) + } + } + + // Last look before publishing: a concurrent save may have unlinked a shard + // in the moment since. An envelope must never name a file that is already + // gone — that reads back as a corrupt month and drops its history. + const providers: Record = {} + for (const [provider, plan] of plans) { + const shards: Record = {} + for (const [bucket, ref] of Object.entries(plan.refs)) { + if (written.has(ref.name) || existsSync(join(dir, ref.name))) { shards[bucket] = ref; continue } + const files = plan.groups.get(bucket) + // A carried month whose file vanished and whose content was never in + // memory cannot be rewritten; dropping the reference is the only honest + // option, and the sweep retires the name. + if (files) shards[bucket] = await writeShard(provider, bucket, files) + } + providers[provider] = { + envFingerprint: plan.section.envFingerprint, + ...(plan.section.durable ? { durable: true } : {}), + shards, + } + } + + const envelope: CacheEnvelope = { + version: CACHE_VERSION, + complete: cache.complete === true, + nonce: randomBytes(8).toString('hex'), + providers, + } + await writeFileAtomic(join(dir, ENVELOPE_FILE), JSON.stringify(envelope)) + + // Shards the new envelope no longer references are garbage; a reader that + // already opened one keeps reading it, and any failure here is swept later + // by cleanupOrphanedTempFiles. + const retired: string[] = [] + for (const [provider, priorRefs] of state.shards) { + const kept = providers[provider]?.shards ?? {} + for (const [bucket, ref] of Object.entries(priorRefs)) { + if (kept[bucket]?.name !== ref.name) retired.push(ref.name) + } + } + + state.dirty = false + state.dirtyBuckets.clear() + state.shards.clear() + state.fingerprints.clear() + state.bucketOf.clear() + // `loaded` deliberately survives: a merged-and-rewritten shard is complete + // on disk but still partial in memory, so the next save has to merge again. + for (const [provider, meta] of Object.entries(providers)) { + state.shards.set(provider, meta.shards) + state.fingerprints.set(provider, meta.envFingerprint) + for (const [path, file] of Object.entries(cache.providers[provider]!.files)) { + state.bucketOf.set(`${provider}\0${path}`, cacheFileSpan(file).bucket) + } + } + // Write-through: the object just published IS the freshest state, so the + // next loadCache in this process reuses it instead of re-parsing. Its scope + // is whatever was loaded, not `all` — a save never widens what is in memory. + cacheMemo = { dir, nonce: envelope.nonce, scope: state.scope, cache } + for (const name of retired) await retryCacheFileMutation(() => unlink(join(dir, name))) + return true + } catch (err) { + for (const name of written) await retryCacheFileMutation(() => unlink(join(dir, name))) + throw err + } +} + async function retryCacheFileMutation(operation: () => Promise): Promise { for (let attempt = 0; attempt < 3; attempt++) { try { @@ -799,26 +1373,58 @@ export function mergeCallByDedupKey( // ── Temp Cleanup ─────────────────────────────────────────────────────── +async function unlinkIfOlderThan(path: string, maxAgeMs: number, now: number): Promise { + try { + const s = await stat(path) + if (now - s.mtimeMs > maxAgeMs) await unlink(path) + } catch {} +} + +// Sweeps our own shard directory: interrupted temp writes, plus shards the +// published envelope no longer references. Also retires the single-file layout's +// leftover temps in the parent directory, which nothing writes anymore. export async function cleanupOrphanedTempFiles(): Promise { - const dir = getCacheDir() + const now = Date.now() + const parent = getCodeburnCacheDir() + + // `session-cache.v.json..tmp` from a pre-v8 binary interrupted + // mid-write. Age-guarded, so an old binary's in-flight write is left alone. + try { + for (const entry of await readdir(parent)) { + if (!/^session-cache\.v\d+\.json\..*\.tmp$/.test(entry)) continue + await unlinkIfOlderThan(join(parent, entry), TEMP_FILE_MAX_AGE_MS, now) + } + } catch {} + + const dir = sessionCacheDir() if (!existsSync(dir)) return - try { - const entries = await readdir(dir) - const now = Date.now() + const referenced = new Set([ENVELOPE_FILE]) + const envelope = await readEnvelope(dir) + if (envelope) { + for (const meta of Object.values(envelope.providers)) { + for (const ref of Object.values(meta.shards)) referenced.add(ref.name) + } + // A published v9 envelope means the re-layout completed. Its retirement of + // the old layout is a separate, unsynchronised step, so a crash in between + // leaves 100MB+ of superseded cache behind forever. Age-guarded for the + // same reason the shard sweep is: an OLD binary may still be writing there. + await unlinkIfOlderThan(join(getCodeburnCacheDir(), priorCacheFile(7)), UNREFERENCED_SHARD_MAX_AGE_MS, now) + const v8Dir = join(getCodeburnCacheDir(), PRIOR_SHARD_DIR_NAME) + try { + const s = await stat(join(v8Dir, ENVELOPE_FILE)) + if (now - s.mtimeMs > UNREFERENCED_SHARD_MAX_AGE_MS) await rm(v8Dir, { recursive: true, force: true }) + } catch {} + } - // Only our own (versioned) temp files. Legacy `session-cache.json.*.tmp` - // temps belong to old binaries mid-write and must not be touched. - const prefix = `${CACHE_FILE}.` - for (const entry of entries) { - if (!entry.startsWith(prefix) || !entry.endsWith('.tmp')) continue - try { - const fullPath = join(dir, entry) - const s = await stat(fullPath) - if (now - s.mtimeMs > TEMP_FILE_MAX_AGE_MS) { - await unlink(fullPath) - } - } catch {} + try { + for (const entry of await readdir(dir)) { + if (entry.endsWith('.tmp')) { + await unlinkIfOlderThan(join(dir, entry), TEMP_FILE_MAX_AGE_MS, now) + continue + } + if (!envelope || referenced.has(entry)) continue + await unlinkIfOlderThan(join(dir, entry), UNREFERENCED_SHARD_MAX_AGE_MS, now) } } catch {} } @@ -844,7 +1450,7 @@ export type HydrationHandle = { waited: boolean; release: () => Promise } const NOOP_HANDLE: HydrationHandle = { waited: false, release: async () => {} } function lockPath(): string { - return join(getCacheDir(), HYDRATION_LOCK_FILE) + return join(getCodeburnCacheDir(), HYDRATION_LOCK_FILE) } // Our own pid never counts as a foreign holder: a same-process lock is either @@ -867,7 +1473,7 @@ async function readLockRecord(): Promise { async function writeOurLock(): Promise { try { - const dir = getCacheDir() + const dir = getCodeburnCacheDir() if (!existsSync(dir)) await mkdir(dir, { recursive: true }) const handle = await open(lockPath(), 'wx', 0o600) try { await handle.writeFile(JSON.stringify({ pid: process.pid, at: Date.now() }), { encoding: 'utf-8' }) } diff --git a/src/sync/ledger.ts b/src/sync/ledger.ts index 99ef8560..8e81a63d 100644 --- a/src/sync/ledger.ts +++ b/src/sync/ledger.ts @@ -6,8 +6,8 @@ */ import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync, renameSync } from 'fs' -import { join } from 'path' -import { homedir } from 'os' +import { join, resolve } from 'path' +import { getCodeburnCacheDir } from '../cache-dir.js' export interface LedgerEntry { key: string // deduplicationKey @@ -16,34 +16,69 @@ export interface LedgerEntry { const SIX_MONTHS_MS = 180 * 24 * 60 * 60 * 1000 -function cacheDir(): string { - // Honor XDG_CACHE_HOME — the ledger is reconstructible state, not config - const xdg = process.env.XDG_CACHE_HOME - const base = xdg && xdg.trim() ? xdg : join(homedir(), '.cache') - return join(base, 'codeburn') +function ledgerCacheDir(): string { + return getCodeburnCacheDir() } function ledgerPath(): string { - return join(cacheDir(), 'sync-ledger.json') + return join(ledgerCacheDir(), 'sync-ledger.json') } -export function readLedger(): LedgerEntry[] { - const path = ledgerPath() - if (!existsSync(path)) return [] +// Before the shared cache resolver existed, sync alone wrote beneath +// XDG_CACHE_HOME. Treat that location as a one-time migration source only; +// CODEBURN_CACHE_DIR (when non-empty) is authoritative and must never import +// from an unrelated XDG tree. +function legacyXdgLedgerPath(): string | null { + if (process.env.CODEBURN_CACHE_DIR?.trim()) return null + const xdg = process.env.XDG_CACHE_HOME + if (!xdg?.trim()) return null + const legacy = join(xdg, 'codeburn', 'sync-ledger.json') + return resolve(legacy) === resolve(ledgerPath()) ? null : legacy +} + +function readLedgerFile(path: string): LedgerEntry[] | null { try { - const raw = readFileSync(path, 'utf-8') - const entries = JSON.parse(raw) as unknown - if (!Array.isArray(entries)) return [] + const entries = JSON.parse(readFileSync(path, 'utf-8')) as unknown + if (!Array.isArray(entries)) return null return entries.filter( (e): e is LedgerEntry => typeof e === 'object' && e !== null && typeof e.key === 'string' ) } catch { - return [] + return null } } +export function readLedger(): LedgerEntry[] { + const path = ledgerPath() + const legacyPath = legacyXdgLedgerPath() + const canonicalEntries = existsSync(path) ? readLedgerFile(path) : null + if (!legacyPath || !existsSync(legacyPath)) return canonicalEntries ?? [] + const legacyEntries = readLedgerFile(legacyPath) + if (!legacyEntries) return canonicalEntries ?? [] + + // Canonical wins for duplicate keys, but retain every key that exists only + // in the historical ledger so an upgrade cannot re-upload old calls. + const merged = [...(canonicalEntries ?? [])] + const keys = new Set(merged.map(entry => entry.key)) + for (const entry of legacyEntries) { + if (keys.has(entry.key)) continue + keys.add(entry.key) + merged.push(entry) + } + + // Publish the canonical copy before retiring the legacy source. If the + // write fails, keep and return the old ledger so deduplication still works. + try { + writeLedger(merged) + try { unlinkSync(legacyPath) } catch { /* canonical copy already wins */ } + } catch { + return merged + } + return merged +} + export function writeLedger(entries: LedgerEntry[]): void { - const dir = cacheDir() + const dir = ledgerCacheDir() mkdirSync(dir, { recursive: true }) // Atomic write: a crash mid-write must not corrupt the ledger — a corrupt // ledger reads as empty and the next push re-sends the whole window. @@ -77,11 +112,34 @@ export function ledgerKeySet(): Set { return new Set(readLedger().map(e => e.key)) } -/** Clear the ledger (for sync reset). Returns the number of entries removed. */ -export function clearLedger(): number { - const path = ledgerPath() - if (!existsSync(path)) return 0 - const count = readLedger().length - unlinkSync(path) - return count +function isMissingFileError(error: unknown): boolean { + return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT' +} + +/** Clear every eligible ledger (for sync reset). Returns the number of unique + * entries removed. This deliberately bypasses readLedger(): reset must delete + * canonical and legacy files independently, never migrate one into the other. */ +export function clearLedger(): number { + const canonicalPath = ledgerPath() + const legacyPath = legacyXdgLedgerPath() + const targets = [canonicalPath, ...(legacyPath ? [legacyPath] : [])].map(path => ({ + path, + entries: readLedgerFile(path) ?? [], + })) + const removedKeys = new Set() + let deletionError: unknown + + // Attempt every target even if one unlink fails. A retry then has only the + // actual remainder to remove, while ENOENT is the idempotent success case. + for (const target of targets) { + try { + unlinkSync(target.path) + for (const entry of target.entries) removedKeys.add(entry.key) + } catch (error) { + if (!isMissingFileError(error) && deletionError === undefined) deletionError = error + } + } + + if (deletionError !== undefined) throw deletionError + return removedKeys.size } diff --git a/src/usage-aggregator.ts b/src/usage-aggregator.ts index 82aeb732..4350413b 100644 --- a/src/usage-aggregator.ts +++ b/src/usage-aggregator.ts @@ -935,7 +935,7 @@ export async function buildMenubarPayloadForRange(periodInfo: PeriodInfo, opts: } })() - const optimize = opts.optimize === false ? null : await scanAndDetect(scanProjects, scanRange) + const optimize = opts.optimize === false ? null : await scanAndDetect(scanProjects, scanRange, opts.provider) const granularRange = opts.daysSelection?.range ?? scanRange const granularHistory = opts.timeline === false ? undefined : buildGranularHistory(scanProjects, granularRange) return buildMenubarPayload(currentData, providers, optimize, dailyHistory, retryTax, routingWaste, breakdowns, claudeConfigs, granularHistory) diff --git a/tests/act-report.test.ts b/tests/act-report.test.ts index 80834d0d..8b81be3f 100644 --- a/tests/act-report.test.ts +++ b/tests/act-report.test.ts @@ -5,13 +5,17 @@ import { join } from 'node:path' import { journalPath } from '../src/act/journal.js' import { + autoRevertNoEffect, buildActReportJson, buildOptimizeAppliedHeader, captureBaseline, + captureBaselinesForPlans, computeActReport, renderActReport, } from '../src/act/report.js' +import { formatAppliedFix, REPORT_MIN_AGE_DAYS } from '../src/act/types.js' import type { ActionRecord } from '../src/act/types.js' +import type { FindingPlan } from '../src/act/plans.js' import type { WasteFinding } from '../src/optimize.js' import type { ClassifiedTurn, ProjectSummary } from '../src/types.js' @@ -762,3 +766,296 @@ describe('defer baseline capture', () => { expect(b).toBeUndefined() }) }) + +describe('partial-action baseline capture', () => { + it('persists the savings attributable to the local mutation, not the full mixed finding', () => { + const finding: WasteFinding = { + id: 'mcp-low-coverage', + title: '2 MCP servers with low tool coverage', + explanation: '', + impact: 'medium', + tokensSaved: 40_000, + applyTokensSaved: 20_000, + fix: { type: 'command', label: '', text: "claude mcp remove 'filesystem'" }, + apply: { kind: 'mcp-remove', servers: ['filesystem'] }, + } + const sessions = sessionsAt(2, daysAgo(1), { + mcpInventory: Array.from({ length: 20 }, (_, i) => `mcp__filesystem__t${i}`), + }) + + const baseline = captureBaseline(finding, 'mcp-remove', { + projects: [projectOf(sessions)], + coverage: [{ + server: 'filesystem', + toolsAvailable: 20, + toolsInvoked: 3, + unusedTools: Array.from({ length: 17 }, (_, i) => `mcp__filesystem__unused${i}`), + invocations: 0, + loadedSessions: 2, + coverageRatio: 3 / 20, + }], + windowDays: 14, + now: NOW, + }) + + expect(baseline).toMatchObject({ + estimatedTokens: 20_000, + sessions: 2, + metrics: { filesystem: 6_800 }, + }) + expect(finding.tokensSaved).toBe(40_000) + }) + + it('prices and measures only servers owned by the concrete mutation plan', () => { + const finding: WasteFinding = { + id: 'mcp-low-coverage', + title: '2 MCP servers with low tool coverage', + explanation: '', + impact: 'medium', + tokensSaved: 30_000, + applyTokensSaved: 30_000, + applyTokensSavedByServer: { filesystem: 10_000, managed: 20_000 }, + fix: { type: 'command', label: '', text: '' }, + apply: { kind: 'mcp-remove', servers: ['filesystem', 'managed'] }, + } + const sessions = sessionsAt(2, daysAgo(1), { + mcpInventory: [ + ...Array.from({ length: 17 }, (_, i) => `mcp__filesystem__t${i}`), + ...Array.from({ length: 12 }, (_, i) => `mcp__managed__t${i}`), + ], + }) + const coverage = [ + { + server: 'filesystem', toolsAvailable: 20, toolsInvoked: 3, + unusedTools: Array.from({ length: 17 }, (_, i) => `mcp__filesystem__t${i}`), + invocations: 3, loadedSessions: 2, coverageRatio: 3 / 20, + }, + { + server: 'managed', toolsAvailable: 20, toolsInvoked: 8, + unusedTools: Array.from({ length: 12 }, (_, i) => `mcp__managed__t${i}`), + invocations: 8, loadedSessions: 2, coverageRatio: 8 / 20, + }, + ] + + const baseline = captureBaseline(finding, 'mcp-remove', { + projects: [projectOf(sessions)], coverage, windowDays: 14, now: NOW, + }, ['filesystem']) + + expect(baseline).toMatchObject({ + estimatedTokens: 10_000, + sessions: 2, + metrics: { filesystem: 6_800 }, + }) + expect(baseline!.metrics).not.toHaveProperty('managed') + }) + + it('does not invent a low-coverage schema baseline when coverage is unavailable', () => { + const finding: WasteFinding = { + id: 'mcp-low-coverage', + title: '1 MCP server with low tool coverage', + explanation: '', + impact: 'medium', + tokensSaved: 10_000, + applyTokensSavedByServer: { filesystem: 10_000 }, + fix: { type: 'command', label: '', text: '' }, + apply: { kind: 'mcp-remove', servers: ['filesystem'] }, + } + + const baseline = captureBaseline(finding, 'mcp-remove', { + projects: [projectOf(sessionsAt(2, daysAgo(1)))], + coverage: [], + windowDays: 14, + now: NOW, + }, ['filesystem']) + + expect(baseline).toMatchObject({ + estimatedTokens: 10_000, + metrics: { filesystem: 0 }, + }) + }) + + it('stamps a narrowed plan with only its concrete server baseline', async () => { + const finding: WasteFinding = { + id: 'mcp-low-coverage', title: '2 MCP servers', explanation: '', impact: 'medium', + tokensSaved: 30_000, applyTokensSaved: 30_000, + applyTokensSavedByServer: { filesystem: 10_000, managed: 20_000 }, + fix: { type: 'command', label: '', text: '' }, + apply: { kind: 'mcp-remove', servers: ['filesystem', 'managed'] }, + } + const plan: FindingPlan = { + finding, + notes: [], + plan: { + kind: 'mcp-remove', description: 'Remove filesystem', changes: [], + affectedMcpServers: ['filesystem'], + }, + } + const sessions = sessionsAt(2, daysAgo(1), { + mcpInventory: [ + ...Array.from({ length: 17 }, (_, i) => `mcp__filesystem__t${i}`), + ...Array.from({ length: 12 }, (_, i) => `mcp__managed__t${i}`), + ], + }) + + await captureBaselinesForPlans([plan], { + now: NOW, + loadProjects: async () => [projectOf(sessions)], + }) + + expect(plan.plan?.baseline).toMatchObject({ + estimatedTokens: 10_000, + metrics: { filesystem: 6_800 }, + }) + expect(plan.plan?.baseline?.metrics).not.toHaveProperty('managed') + }) + + it('does not stamp a numeric baseline onto an uncertain partial mutation', async () => { + const finding: WasteFinding = { + id: 'mcp-low-coverage', title: '1 MCP server', explanation: '', impact: 'medium', + tokensSaved: 10_000, applyTokensSavedByServer: { filesystem: 10_000 }, + fix: { type: 'command', label: '', text: '' }, + apply: { kind: 'mcp-remove', servers: ['filesystem'] }, + } + const plan: FindingPlan = { + finding, + notes: ['could not parse .mcp.json'], + plan: { + kind: 'mcp-remove', description: 'Remove filesystem', changes: [], + affectedMcpServers: ['filesystem'], mcpSavingsUncertain: true, + }, + } + + await captureBaselinesForPlans([plan], { + now: NOW, + loadProjects: async () => [projectOf(sessionsAt(2, daysAgo(1)))], + }) + + expect(plan.plan?.baseline).toBeUndefined() + }) +}) + +describe('applied-fix verdicts', () => { + const fixOf = async (records: ActionRecord[], projects: ProjectSummary[]) => { + const actionsDir = await writeJournal(records) + const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load(projects) }) + return { report, fixes: report.appliedFixes, actionsDir } + } + + it('calls a fix that realized its whole window estimate "worked"', async () => { + const { fixes } = await fixOf([mcpRecord()], [projectOf(sessionsAt(20, daysAgo(5)))]) + expect(fixes).toHaveLength(1) + expect(fixes[0]!.verdict).toBe('worked') + expect(fixes[0]!.estimatedTokens).toBe(40_000) + expect(fixes[0]!.realizedTokens).toBe(40_000) + expect(fixes[0]!.undoCommand).toBe('codeburn act undo a1') + }) + + it('holds the worked/partial boundary at the 70% ratio', async () => { + const rec = mcpRecord({ kind: 'mcp-project-scope' }) + // 14 of 20 sessions saved = exactly 70% of the window estimate. + const at70 = await fixOf( + [rec], + [projectOf([...sessionsAt(14, daysAgo(5)), ...sessionsAt(6, daysAgo(4), { mcpInventory: ['mcp__brave-search__search'] })])], + ) + expect(at70.fixes[0]!.verdict).toBe('worked') + + const below = await fixOf( + [rec], + [projectOf([...sessionsAt(13, daysAgo(5)), ...sessionsAt(7, daysAgo(4), { mcpInventory: ['mcp__brave-search__search'] })])], + ) + expect(below.fixes[0]!.verdict).toBe('partial') + expect(formatAppliedFix(below.fixes[0]!)).toContain('-35% vs estimate') + }) + + it('calls a fix that realized nothing "no-effect" and offers the undo', async () => { + const rec = mcpRecord({ kind: 'mcp-project-scope' }) + const stillLoading = sessionsAt(20, daysAgo(5), { mcpInventory: ['mcp__brave-search__search'] }) + const { fixes } = await fixOf([rec], [projectOf(stillLoading)]) + expect(fixes[0]!.verdict).toBe('no-effect') + expect(fixes[0]!.realizedTokens).toBe(0) + expect(formatAppliedFix(fixes[0]!)).toContain('did not help. Revert: codeburn act undo a1') + }) + + it('treats an estimate of zero as worked only when something was realized', async () => { + const zeroEstimate = { windowDays: 14, capturedAt: daysAgo(10), estimatedTokens: 0, sessions: 0, metrics: {} } + const { fixes } = await fixOf( + [mcpRecord({ baseline: { ...zeroEstimate, metrics: { 'brave-search': 2000 } } })], + [projectOf(sessionsAt(20, daysAgo(5)))], + ) + expect(fixes[0]!.estimatedTokens).toBe(40_000) + expect(fixes[0]!.verdict).toBe('worked') + }) + + it('leaves entries younger than the measurement window pending', async () => { + const { fixes } = await fixOf([mcpRecord({ at: daysAgo(1) })], [projectOf(sessionsAt(20, daysAgo(1)))]) + expect(fixes[0]!.verdict).toBe('pending') + expect(formatAppliedFix(fixes[0]!)).toBe(`unused-mcp (1d ago): measuring, check back after ${REPORT_MIN_AGE_DAYS} days`) + }) + + it('keeps a user-reverted entry out of the verdicts and carries its note', async () => { + const back = sessionsAt(20, daysAgo(5), { mcpInventory: ['mcp__brave-search__search'] }) + const { fixes } = await fixOf([mcpRecord()], [projectOf(back)]) + expect(fixes[0]!.verdict).toBe('pending') + expect(fixes[0]!.note).toMatch(/reverted by user/) + }) + + it('drops undone journal entries entirely', async () => { + const rec = mcpRecord() + const { fixes } = await fixOf( + [rec, { ...rec, status: 'undone', undoneAt: daysAgo(2) }], + [projectOf(sessionsAt(20, daysAgo(5)))], + ) + expect(fixes).toHaveLength(0) + }) + + it('never judges a correlation-only kind, so --auto-revert can never touch it', async () => { + const { fixes } = await fixOf([modelDefaultRecord()], [modelProject('app', '/tmp/app', 'candidate-model', 20, 19)]) + expect(fixes[0]!.verdict).toBe('pending') + }) +}) + +describe('autoRevertNoEffect', () => { + const noEffect = (over: Partial = {}) => ({ + ...mcpRecord({ kind: 'mcp-project-scope', ...over }), + }) + + it('undoes the no-effect entries and leaves the rest alone', async () => { + const worked = noEffect({ id: 'keep1', findingId: 'kept' }) + const actionsDir = await writeJournal([noEffect(), worked]) + const stillLoading = sessionsAt(20, daysAgo(5), { mcpInventory: ['mcp__brave-search__search'] }) + const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([projectOf(stillLoading)]) }) + // Both are no-effect here; pin that only the ones we hand over get undone. + const target = report.appliedFixes.filter(f => f.id === 'a1') + const { lines, revertedIds } = await autoRevertNoEffect(target, { actionsDir }) + + expect([...revertedIds]).toEqual(['a1']) + expect(lines).toEqual(['Reverted a1: Remove an MCP server from config']) + const after = await computeActReport({ actionsDir, now: NOW, loadProjects: load([projectOf(stillLoading)]) }) + expect(after.appliedFixes.map(f => f.id)).toEqual(['keep1']) + }) + + it('never auto-reverts a CLAUDE.md rule, it prints the undo command instead', async () => { + const rec = mcpRecord({ + id: 'cm1', + kind: 'claude-md-rule', + findingId: 'read-edit-ratio', + baseline: { windowDays: 14, capturedAt: daysAgo(10), estimatedTokens: 10_000, sessions: 20, metrics: { reads: 10, edits: 10 } }, + }) + const actionsDir = await writeJournal([rec]) + const sessions = sessionsAt(20, daysAgo(5), { toolBreakdown: { Edit: { calls: 10, tokens: 0 }, Read: { calls: 10, tokens: 0 } } as never }) + const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([projectOf(sessions)]) }) + + expect(report.appliedFixes[0]!.verdict).toBe('no-effect') + const { lines, revertedIds } = await autoRevertNoEffect(report.appliedFixes, { actionsDir }) + expect(revertedIds.size).toBe(0) + expect(lines).toEqual(['Not auto-reverted: read-edit-ratio edits a CLAUDE.md. Revert: codeburn act undo cm1']) + }) + + it('ignores partial and pending entries', async () => { + const actionsDir = await writeJournal([mcpRecord({ at: daysAgo(1) })]) + const report = await computeActReport({ actionsDir, now: NOW, loadProjects: load([projectOf(sessionsAt(20, daysAgo(1)))]) }) + const { lines, revertedIds } = await autoRevertNoEffect(report.appliedFixes, { actionsDir }) + expect(lines).toEqual([]) + expect(revertedIds.size).toBe(0) + }) +}) diff --git a/tests/bash-commands.test.ts b/tests/bash-commands.test.ts index 949d50ff..69f5e870 100644 --- a/tests/bash-commands.test.ts +++ b/tests/bash-commands.test.ts @@ -1,4 +1,6 @@ import { describe, it, expect } from 'vitest' +import { basename } from 'path' +import stripAnsi from 'strip-ansi' import { extractBashCommands, isReadShapedBashCommand } from '../src/bash-utils.js' import { BASH_TOOLS } from '../src/classifier.js' @@ -118,6 +120,157 @@ describe('BASH_TOOLS', () => { it('rejects unknown tools', () => { expect(BASH_TOOLS.has('Read')).toBe(false) }) }) +// Regression coverage for the quadratic -> linear separator-matching rewrite. +// The old regex (/\s*(?:&&|;|\|)\s*/g, and the equivalent split form) is kept +// here verbatim as a reference so new/old output can be diffed on tricky inputs. +describe('separator regex fix: parity with pre-fix implementation', () => { + function stripQuotedStringsRef(command: string): string { + return command.replace(/"[^"]*"|'[^']*'/g, match => ' '.repeat(match.length)) + } + + const COMMAND_PREFIXES_REF = new Set([ + 'sudo', 'doas', + 'npx', 'bunx', + 'time', + 'nice', 'nohup', 'stdbuf', + 'rtk', + ]) + + const READ_ONLY_BASH_REF = new Set([ + 'rg', 'grep', 'egrep', 'fgrep', 'ag', + 'cat', 'head', 'tail', 'less', 'more', + 'ls', 'find', 'fd', 'tree', + 'wc', 'stat', 'file', 'du', 'df', + 'which', 'type', 'pwd', 'printenv', 'env', + 'readlink', 'realpath', 'basename', 'dirname', + 'jq', 'diff', + ]) + + const GIT_READ_SUBCOMMANDS_REF = new Set([ + 'log', 'diff', 'status', 'show', 'blame', 'grep', + 'shortlog', 'describe', 'rev-parse', 'ls-files', + ]) + + function extractBashCommandsOld(rawCommand: string): string[] { + if (!rawCommand || !rawCommand.trim()) return [] + + const command = stripAnsi(rawCommand) + const stripped = stripQuotedStringsRef(command) + + const separatorRegex = /\s*(?:&&|;|\|)\s*/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 }) + } + + const ranges: Array<[number, number]> = [] + let cursor = 0 + for (const sep of separators) { + ranges.push([cursor, sep.start]) + cursor = sep.end + } + ranges.push([cursor, command.length]) + + const commands: string[] = [] + for (const [start, end] of ranges) { + const segment = command.slice(start, end).trim() + if (!segment) continue + + const tokens = segment.split(/\s+/) + let i = 0 + while (i < tokens.length) { + if (/^\w+=/.test(tokens[i]!)) { i++; continue } + const next = tokens[i + 1] + if ( + next !== undefined && + COMMAND_PREFIXES_REF.has(basename(tokens[i]!)) && + !next.startsWith('-') && + !/["']/.test(next) + ) { i++; continue } + break + } + const base = i < tokens.length ? basename(tokens[i]!) : '' + + if (base && base !== 'cd' && base !== 'true' && base !== 'false') { + commands.push(base) + } + } + + return commands + } + + function isReadShapedBashCommandOld(rawCommand: string): boolean { + if (!rawCommand || !rawCommand.trim()) return false + const stripped = stripQuotedStringsRef(stripAnsi(rawCommand)) + const segments = stripped.split(/\s*(?:&&|;|\|)\s*/) + let sawCommand = false + for (const segment of segments) { + const trimmed = segment.trim() + if (!trimmed) continue + const tokens = trimmed.split(/\s+/) + let i = 0 + while (i < tokens.length && (/^\w+=/.test(tokens[i]!) || COMMAND_PREFIXES_REF.has(basename(tokens[i]!)))) i++ + const base = i < tokens.length ? basename(tokens[i]!) : '' + if (!base) continue + sawCommand = true + if (base === 'git') { + const sub = tokens[i + 1] + if (!sub || !GIT_READ_SUBCOMMANDS_REF.has(sub)) return false + continue + } + if (!READ_ONLY_BASH_REF.has(base)) return false + } + return sawCommand + } + + function buildWhitespaceHeavyCommand(): string { + const parts: string[] = [] + for (let i = 0; i < 10; i++) parts.push('git' + ' '.repeat(2000) + 'status') + return parts.join(' && ') + } + + const TRICKY_INPUTS: string[] = [ + 'echo "a && b" && ls', + "foo 'x;y';bar", + 'cat < { + for (const input of TRICKY_INPUTS) { + expect(extractBashCommands(input)).toEqual(extractBashCommandsOld(input)) + } + }) + + it('isReadShapedBashCommand matches the old implementation across tricky separator inputs', () => { + for (const input of TRICKY_INPUTS) { + expect(isReadShapedBashCommand(input)).toBe(isReadShapedBashCommandOld(input)) + } + }) + + it('runs the whitespace-heavy command in well under 50ms (old form is quadratic)', () => { + const big = buildWhitespaceHeavyCommand() + const t0 = Date.now() + extractBashCommands(big) + expect(Date.now() - t0).toBeLessThan(50) + }) +}) + describe('isReadShapedBashCommand (#941)', () => { it('accepts single read commands and read-only git subcommands', () => { expect(isReadShapedBashCommand('rg -n "x" src/')).toBe(true) diff --git a/tests/cache-dir.test.ts b/tests/cache-dir.test.ts new file mode 100644 index 00000000..eaef6c74 --- /dev/null +++ b/tests/cache-dir.test.ts @@ -0,0 +1,25 @@ +import { afterEach, describe, expect, it } from 'vitest' +import { join } from 'path' +import { homedir } from 'os' +import { getCodeburnCacheDir } from '../src/cache-dir.js' + +describe('getCodeburnCacheDir', () => { + const original = process.env['CODEBURN_CACHE_DIR'] + + afterEach(() => { + if (original === undefined) delete process.env['CODEBURN_CACHE_DIR'] + else process.env['CODEBURN_CACHE_DIR'] = original + }) + + it('resolves an explicit override at call time', () => { + process.env['CODEBURN_CACHE_DIR'] = '/tmp/codeburn-one' + expect(getCodeburnCacheDir()).toBe('/tmp/codeburn-one') + process.env['CODEBURN_CACHE_DIR'] = '/tmp/codeburn-two' + expect(getCodeburnCacheDir()).toBe('/tmp/codeburn-two') + }) + + it.each(['', ' ', '\n\t'])('treats a blank override as absent (%j)', value => { + process.env['CODEBURN_CACHE_DIR'] = value + expect(getCodeburnCacheDir()).toBe(join(homedir(), '.cache', 'codeburn')) + }) +}) diff --git a/tests/cache-directory-switch.test.ts b/tests/cache-directory-switch.test.ts new file mode 100644 index 00000000..175cd828 --- /dev/null +++ b/tests/cache-directory-switch.test.ts @@ -0,0 +1,277 @@ +import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'fs/promises' +import { existsSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' + +import { + clearCodexMemCaches, + fingerprintFile, + flushCodexCache, + readCachedCodexResults, + writeCachedCodexResults, +} from '../src/codex-cache.js' +import { + clearAntigravityCacheStates, + createAntigravityProvider, + flushAntigravityCache, +} from '../src/providers/antigravity.js' +import type { ParsedProviderCall } from '../src/providers/types.js' + +const originalCacheDir = process.env['CODEBURN_CACHE_DIR'] +const originalHome = process.env['HOME'] +const originalCodexHome = process.env['CODEX_HOME'] +let root: string + +function call(provider: string, marker: string): ParsedProviderCall { + return { + provider, + model: marker, + inputTokens: 1, + outputTokens: 1, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + costUSD: 0, + tools: [], + bashCommands: [], + timestamp: '2026-08-12T00:00:00.000Z', + speed: 'standard', + deduplicationKey: `${provider}:${marker}`, + userMessage: '', + sessionId: marker, + } +} + +async function seedAntigravityCache( + cacheDir: string, + sourcePath: string, + marker: string, +): Promise { + const sourceStat = await stat(sourcePath) + await mkdir(cacheDir, { recursive: true }) + await writeFile(join(cacheDir, 'antigravity-results.json'), JSON.stringify({ + version: 5, + cascades: { + shared: { + mtimeMs: sourceStat.mtimeMs, + sizeBytes: sourceStat.size, + calls: [call('antigravity', marker)], + }, + }, + })) +} + +async function readAntigravityModel(sourcePath: string): Promise { + const parser = createAntigravityProvider().createSessionParser({ + path: sourcePath, + project: 'fixture', + provider: 'antigravity', + }, new Set()) + for await (const parsed of parser.parse()) return parsed.model + return undefined +} + +beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'codeburn-cache-switch-')) +}) + +afterEach(async () => { + if (originalCacheDir === undefined) delete process.env['CODEBURN_CACHE_DIR'] + else process.env['CODEBURN_CACHE_DIR'] = originalCacheDir + if (originalHome === undefined) delete process.env['HOME'] + else process.env['HOME'] = originalHome + if (originalCodexHome === undefined) delete process.env['CODEX_HOME'] + else process.env['CODEX_HOME'] = originalCodexHome + await rm(root, { recursive: true, force: true }) +}) + +describe('call-time CODEBURN_CACHE_DIR isolation', () => { + it('keeps Codex reads and writes keyed by the active cache directory', async () => { + const sourcePath = join(root, 'rollout.jsonl') + const cacheA = join(root, 'cache-a') + const cacheB = join(root, 'cache-b') + await writeFile(sourcePath, '{}\n') + const fingerprint = await fingerprintFile(sourcePath) + expect(fingerprint).not.toBeNull() + + process.env['CODEBURN_CACHE_DIR'] = cacheA + await writeCachedCodexResults(sourcePath, 'project-a', [call('codex', 'from-a')], fingerprint!) + await flushCodexCache() + + process.env['CODEBURN_CACHE_DIR'] = cacheB + expect(await readCachedCodexResults(sourcePath)).toBeNull() + await writeCachedCodexResults(sourcePath, 'project-b', [call('codex', 'from-b')], fingerprint!) + await flushCodexCache() + + const diskB = JSON.parse(await readFile(join(cacheB, 'codex-results.json'), 'utf8')) + expect(diskB.files[sourcePath].calls.map((entry: ParsedProviderCall) => entry.model)).toEqual(['from-b']) + + process.env['CODEBURN_CACHE_DIR'] = cacheA + expect((await readCachedCodexResults(sourcePath))?.calls.map(entry => entry.model)).toEqual(['from-a']) + }) + + it('does not flush dirty Codex state from A into B', async () => { + const sourceA = join(root, 'a.jsonl') + const sourceB = join(root, 'b.jsonl') + const cacheA = join(root, 'cache-a-dirty') + const cacheB = join(root, 'cache-b-dirty') + await writeFile(sourceA, 'a\n') + await writeFile(sourceB, 'b\n') + + process.env['CODEBURN_CACHE_DIR'] = cacheA + await writeCachedCodexResults(sourceA, 'project-a', [call('codex', 'dirty-a')], (await fingerprintFile(sourceA))!) + + process.env['CODEBURN_CACHE_DIR'] = cacheB + await writeCachedCodexResults(sourceB, 'project-b', [call('codex', 'dirty-b')], (await fingerprintFile(sourceB))!) + await flushCodexCache() + + const diskB = JSON.parse(await readFile(join(cacheB, 'codex-results.json'), 'utf8')) + expect(Object.keys(diskB.files)).toEqual([sourceB]) + + process.env['CODEBURN_CACHE_DIR'] = cacheA + await flushCodexCache() + const diskA = JSON.parse(await readFile(join(cacheA, 'codex-results.json'), 'utf8')) + expect(Object.keys(diskA.files)).toEqual([sourceA]) + }) + + it('pins Codex reads, dirty writes, and flushes to the parse call-time directory', async () => { + const home = join(root, 'parse-home') + const codexHome = join(root, 'parse-codex-home') + const sessionDir = join(codexHome, 'sessions', '2026', '08', '12') + const cacheA = join(root, 'parse-cache-a') + const cacheB = join(root, 'parse-cache-b') + await mkdir(sessionDir, { recursive: true }) + await mkdir(home, { recursive: true }) + const sourcePath = join(sessionDir, 'rollout-cache-dir-switch.jsonl') + await writeFile(sourcePath, [ + JSON.stringify({ + type: 'session_meta', + timestamp: '2026-08-12T10:00:00.000Z', + payload: { + cwd: '/Users/test/cache-dir-transaction', + originator: 'codex-cli', + session_id: 'cache-dir-transaction', + model: 'gpt-5.3-codex', + }, + }), + JSON.stringify({ + type: 'event_msg', + timestamp: '2026-08-12T10:01:00.000Z', + payload: { + type: 'token_count', + info: { + model: 'gpt-5.3-codex', + last_token_usage: { + input_tokens: 10, + cached_input_tokens: 0, + output_tokens: 5, + reasoning_output_tokens: 0, + total_tokens: 15, + }, + total_token_usage: { + input_tokens: 10, + cached_input_tokens: 0, + output_tokens: 5, + reasoning_output_tokens: 0, + total_tokens: 15, + }, + }, + }, + }), + ].join('\n') + '\n') + + process.env['HOME'] = home + process.env['CODEX_HOME'] = codexHome + process.env['CODEBURN_CACHE_DIR'] = cacheA + const { clearSessionCache, parseAllSessions } = await import('../src/parser.js') + clearSessionCache() + + // parseAllSessions reaches its first await before any Codex cache access. + // Switching the host env immediately after invocation deterministically + // exercises every later read/write/flush under the captured A transaction. + const parsing = parseAllSessions(undefined, 'codex') + process.env['CODEBURN_CACHE_DIR'] = cacheB + const projects = await parsing + + expect(projects.some(project => project.sessions.some(session => + session.turns.some(turn => turn.assistantCalls.some(entry => entry.provider === 'codex')) + ))).toBe(true) + expect(existsSync(join(cacheA, 'codex-results.json'))).toBe(true) + expect(existsSync(join(cacheB, 'codex-results.json'))).toBe(false) + const diskA = JSON.parse(await readFile(join(cacheA, 'codex-results.json'), 'utf8')) + expect(diskA.files[sourcePath].calls).toHaveLength(1) + clearSessionCache() + }) + + it('loads Antigravity cache entries from the active directory after A to B', async () => { + const sourcePath = join(root, 'shared.pb') + const cacheA = join(root, 'agy-cache-a') + const cacheB = join(root, 'agy-cache-b') + await writeFile(sourcePath, 'fixture') + await seedAntigravityCache(cacheA, sourcePath, 'from-a') + await seedAntigravityCache(cacheB, sourcePath, 'from-b') + + process.env['CODEBURN_CACHE_DIR'] = cacheA + expect(await readAntigravityModel(sourcePath)).toBe('from-a') + + process.env['CODEBURN_CACHE_DIR'] = cacheB + expect(await readAntigravityModel(sourcePath)).toBe('from-b') + }) + + it('does not flush dirty Antigravity state from A into B', async () => { + const sourcePath = join(root, 'shared.pb') + const cacheA = join(root, 'agy-cache-a-dirty') + const cacheB = join(root, 'agy-cache-b-dirty') + await writeFile(sourcePath, 'fixture') + await seedAntigravityCache(cacheA, sourcePath, 'from-a') + await seedAntigravityCache(cacheB, sourcePath, 'from-b') + + process.env['CODEBURN_CACHE_DIR'] = cacheA + expect(await readAntigravityModel(sourcePath)).toBe('from-a') + + // The provider parse transaction captures A. Even if the host changes its + // call-time env before the deferred flush, eviction/publication stays on A. + process.env['CODEBURN_CACHE_DIR'] = cacheB + await flushAntigravityCache(new Set(), cacheA) + + expect(existsSync(join(cacheB, 'antigravity-results.json'))).toBe(true) + const diskB = JSON.parse(await readFile(join(cacheB, 'antigravity-results.json'), 'utf8')) + expect(diskB.cascades.shared.calls[0].model).toBe('from-b') + const diskA = JSON.parse(await readFile(join(cacheA, 'antigravity-results.json'), 'utf8')) + expect(diskA.cascades).toEqual({}) + }) + + it('drops clean per-directory memos when the resident RSS guard clears them', async () => { + const codexSource = join(root, 'guard.jsonl') + const antigravitySource = join(root, 'shared.pb') + const cacheDir = join(root, 'guard-cache') + await writeFile(codexSource, '{}\n') + await writeFile(antigravitySource, 'fixture') + await seedAntigravityCache(cacheDir, antigravitySource, 'before') + + process.env['CODEBURN_CACHE_DIR'] = cacheDir + await writeCachedCodexResults(codexSource, 'project', [call('codex', 'before')], (await fingerprintFile(codexSource))!) + await flushCodexCache() + expect(await readAntigravityModel(antigravitySource)).toBe('before') + + // Another process republishes both cache files. Without the clear, the + // resident keeps serving its warm copies. + await seedAntigravityCache(cacheDir, antigravitySource, 'after') + const codexDisk = JSON.parse(await readFile(join(cacheDir, 'codex-results.json'), 'utf8')) + codexDisk.files[codexSource].calls[0].model = 'after' + await writeFile(join(cacheDir, 'codex-results.json'), JSON.stringify(codexDisk)) + + expect((await readCachedCodexResults(codexSource))?.calls.map(entry => entry.model)).toEqual(['before']) + expect(await readAntigravityModel(antigravitySource)).toBe('before') + + clearCodexMemCaches() + clearAntigravityCacheStates() + + expect((await readCachedCodexResults(codexSource))?.calls.map(entry => entry.model)).toEqual(['after']) + expect(await readAntigravityModel(antigravitySource)).toBe('after') + }) +}) diff --git a/tests/cache-persistence-coldstart.test.ts b/tests/cache-persistence-coldstart.test.ts index c18b677f..7ab1d893 100644 --- a/tests/cache-persistence-coldstart.test.ts +++ b/tests/cache-persistence-coldstart.test.ts @@ -4,7 +4,8 @@ import { join } from 'path' import { tmpdir } from 'os' import { parseAllSessions, clearSessionCache } from '../src/parser.js' -import { CACHE_VERSION, sessionCachePath } from '../src/session-cache.js' +import { CACHE_VERSION } from '../src/session-cache.js' +import { readCacheOnDisk } from './fixtures/session-cache-io.js' let tmpDir: string let cacheDir: string @@ -48,7 +49,7 @@ describe('cold-start cache persistence', () => { const projects = await parseAllSessions() expect(projects.length).toBeGreaterThan(0) - const raw = JSON.parse(await readFile(sessionCachePath(), 'utf-8')) + const raw = await readCacheOnDisk() expect(raw.version).toBe(CACHE_VERSION) const claudeFiles = Object.keys(raw.providers?.claude?.files ?? {}) expect(claudeFiles.length).toBeGreaterThan(0) diff --git a/tests/cache-refresh-lock.test.ts b/tests/cache-refresh-lock.test.ts index cdabaeec..b59c6834 100644 --- a/tests/cache-refresh-lock.test.ts +++ b/tests/cache-refresh-lock.test.ts @@ -8,7 +8,7 @@ import { type RefreshLockClock, } from '../src/cache-refresh-lock.js' import { clearSessionCache, parseAllSessions } from '../src/parser.js' -import { emptyCache, loadCache, saveCache, sessionCachePath } from '../src/session-cache.js' +import { emptyCache, loadCache, saveCache, sessionCacheDir } from '../src/session-cache.js' const dirs: string[] = [] @@ -202,7 +202,7 @@ describe('warm session-cache refresh lock', () => { await result.handle.release() expect(JSON.parse(await readFile(lockPath(dir), 'utf-8')).token).toBe('successor') - expect(sessionCachePath()).toContain(dir) + expect(sessionCacheDir()).toContain(dir) }) // retry shields environmental fd/CPU starvation in a saturated full-suite diff --git a/tests/codex-cache-invalidation.test.ts b/tests/codex-cache-invalidation.test.ts index e4e53111..35b80f7f 100644 --- a/tests/codex-cache-invalidation.test.ts +++ b/tests/codex-cache-invalidation.test.ts @@ -13,7 +13,7 @@ import { createHash } from 'crypto' import { join } from 'path' import { clearSessionCache, parseAllSessions } from '../src/parser.js' -import { sessionCachePath } from '../src/session-cache.js' +import { readCacheOnDisk, writeCacheOnDisk } from './fixtures/session-cache-io.js' const testRoot = vi.hoisted(() => { const root = `${process.env['TMPDIR'] || '/tmp'}/codex-stale-repro-${process.pid}-${Date.now()}` @@ -75,8 +75,7 @@ describe('codex parser change invalidates stale session-cache (#478/#513)', () = // release: pre-fix envFingerprint, unchanged file fingerprint, cached // turns lack the mcp__ tool. Also reset codex-results.json to v4 so the // provider (if it runs at all) must genuinely re-parse. - const cachePath = sessionCachePath() - const cache = JSON.parse(await readFile(cachePath, 'utf8')) + const cache = await readCacheOnDisk() as any cache.providers.codex.envFingerprint = preFixFingerprint() for (const f of Object.values(cache.providers.codex.files) as any[]) { for (const turn of f.turns) { @@ -88,7 +87,7 @@ describe('codex parser change invalidates stale session-cache (#478/#513)', () = } } } - await writeFile(cachePath, JSON.stringify(cache)) + await writeCacheOnDisk(cache) const codexCachePath = join(CACHE_DIR, 'codex-results.json') const codexCache = JSON.parse(await readFile(codexCachePath, 'utf8')) codexCache.version = 4 diff --git a/tests/dashboard.test.ts b/tests/dashboard.test.ts index 9879c8a1..8f87ae6a 100644 --- a/tests/dashboard.test.ts +++ b/tests/dashboard.test.ts @@ -395,6 +395,47 @@ describe('interactive terminal rendering', () => { expect(INTERACTIVE_RENDER_OPTIONS).toMatchObject({ alternateScreen: true }) }) + it('labels claude.ai connector remediation as a manual action', async () => { + const stdin = new PassThrough() as PassThrough & NodeJS.ReadStream + const stdout = new PassThrough() as PassThrough & NodeJS.WriteStream + stdin.isTTY = true + stdin.setRawMode = () => stdin + stdin.ref = () => stdin + stdin.unref = () => stdin + stdout.isTTY = true + stdout.columns = 120 + stdout.rows = 50 + const frames: string[] = [] + stdout.on('data', chunk => frames.push(stripAnsi(String(chunk)))) + + const inventory = Array.from({ length: 20 }, (_, i) => `mcp__claude_ai_Google_Calendar__t${i}`) + const sessions = ['connector-a', 'connector-b'].map((id, index) => { + const session = makeSession(id, 91.337 + index) + session.mcpInventory = inventory + return session + }) + const app = render(React.createElement(InteractiveDashboard, { + initialProjects: [makeProject('connector-manual-action', sessions)], + initialPeriod: 'today', + initialProvider: 'all', + refreshSeconds: 0, + windowColumns: 120, + }), { stdin, stdout, debug: true, interactive: true, patchConsole: false }) + onTestFinished(() => app.unmount()) + + await app.waitUntilRenderFlush() + stdin.write('o') + let frame = '' + for (let i = 0; i < 100 && !frame.includes('Manual action'); i++) { + await new Promise(resolve => setTimeout(resolve, 10)) + frame = frames.filter(value => value.trim()).at(-1) ?? '' + } + + expect(frame).toContain('Manual action') + expect(frame).toContain('claude.ai Google Calendar') + expect(frame).not.toContain('Ask Claude in the current session') + }) + it('leaves resize frame synchronization entirely to Ink', () => { const source = readFileSync(new URL('../src/dashboard.tsx', import.meta.url), 'utf8') expect(source).not.toContain('process.stdout.write(BSU)') @@ -665,7 +706,12 @@ describe('InteractiveDashboard refresh', () => { }) it('keeps Optimize mounted without a loading frame when auto-refresh fires', async () => { - vi.useFakeTimers() + // The Optimize scan (`o`) does real fs I/O (readdir/stat) that only + // resolves on a real event-loop turn. Leave setImmediate/nextTick/Date + // real (Date stays real so the wait loop below can use a genuine + // wall-clock deadline) and fake only what the 60s auto-refresh + // interval needs. + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'setInterval', 'clearInterval'] }) const stdin = new PassThrough() as PassThrough & NodeJS.ReadStream const stdout = new PassThrough() as PassThrough & NodeJS.WriteStream stdin.isTTY = true @@ -712,12 +758,20 @@ describe('InteractiveDashboard refresh', () => { expect(activityHeader.indexOf('turns') + 'turns'.length).toBe(activityRow.indexOf('12') + '12'.length) expect(activityHeader.indexOf('1-shot') + '1-shot'.length).toBe(activityRow.indexOf('50%') + '50%'.length) stdin.write('o') - for (let i = 0; i < 20 && !frames.some(frame => frame.includes('Token estimates are approximate.')); i++) { + // The scan does real fs work, so wait on real event-loop turns + // (setImmediate is left un-faked above) rather than counting fake-timer + // hops, bounded by a real wall-clock deadline. + const realDeadline = Date.now() + 10_000 + while (!frames.some(frame => frame.includes('Savings: ~'))) { + if (Date.now() > realDeadline) { + throw new Error('Timed out waiting for the Optimize scan to render "Savings: ~"') + } + await new Promise(resolve => setImmediate(resolve)) await vi.advanceTimersByTimeAsync(50) } const beforeRefresh = frames.filter(frame => frame.trim()).at(-1) ?? '' expect(beforeRefresh).toContain('CodeBurn Optimize') - expect(beforeRefresh).toContain('Token estimates are approximate.') + expect(beforeRefresh).toContain('CodeBurn Optimize') frames.length = 0 await vi.advanceTimersByTimeAsync(60_000) @@ -726,10 +780,10 @@ describe('InteractiveDashboard refresh', () => { const frame = frames.filter(value => value.trim()).at(-1) ?? beforeRefresh expect(frame).toBe(beforeRefresh) expect(frame).toContain('CodeBurn Optimize') - expect(frame).toContain('Token estimates are approximate.') + expect(frame).toContain('CodeBurn Optimize') expect(frame).toContain('b back') expect(frame).not.toContain('Loading Today') expect(frame).not.toContain('Scanning Today') - }) + }, 30_000) }) diff --git a/tests/fixtures/cache-refresh-worker.ts b/tests/fixtures/cache-refresh-worker.ts index a2807df1..1a24c0c4 100644 --- a/tests/fixtures/cache-refresh-worker.ts +++ b/tests/fixtures/cache-refresh-worker.ts @@ -3,7 +3,7 @@ import { mkdir, readFile, writeFile } from 'fs/promises' import { join } from 'path' import { acquireCacheRefreshLock } from '../../src/cache-refresh-lock.js' -import { loadCache, saveCache } from '../../src/session-cache.js' +import { loadCache, markCacheDirty, saveCache } from '../../src/session-cache.js' const [cacheDir, barrierDir, id, sourcePath, bypass = 'false'] = process.argv.slice(2) if (!cacheDir || !barrierDir || !id || !sourcePath) throw new Error('missing worker argument') @@ -33,7 +33,7 @@ try { mcpInventory: [], turns: [], } - ;(cache as { _dirty?: boolean })._dirty = true + markCacheDirty(cache, 'regression') await writeFile(join(barrierDir, `${id}.parsed`), '') await waitFor(`${id}.save`) const published = await saveCache(cache, refresh?.handle.verifyStillOwner) diff --git a/tests/fixtures/dsh/bash-tool-turn.jsonl b/tests/fixtures/dsh/bash-tool-turn.jsonl new file mode 100644 index 00000000..add85175 --- /dev/null +++ b/tests/fixtures/dsh/bash-tool-turn.jsonl @@ -0,0 +1,35 @@ +{"type":"session","version":0,"id":"e128dda9-ed11-4868-8266-0ef90d03c3d6","createdAt":1783352050748,"cwd":"/home/u/proj","delegationDepth":0} +{"type":"agent/inbox/spliced","seq":0,"time":1785498771334,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo TERMINAL_OK. Then reply with the single word DONE and stop."}],"source":{"kind":"user"},"role":"user","id":"38694db6-921d-41fd-b1fb-3b0c40caf67c"}]}} +{"type":"turn/start","seq":1,"time":1785821375023,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785821375023,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1783352050755,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1785498771360,"data":{"content":[{"type":"text","text":"Use the bash tool to run exactly: echo TERMINAL_OK. Then reply with the single word DONE and stop."}],"source":{"kind":"user"},"role":"user","id":"38694db6-921d-41fd-b1fb-3b0c40caf67c"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730424635,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"80474489-442a-4e98-beef-df6cd1e85870"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1785730424635,"data":{"title":"Use the bash tool to","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1785498771361,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"you are dsh","tools":[]},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1785730424636,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":9,"time":1783352051590,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":10,"time0":1783352051618,"data":{"turn":1,"step":1,"index":0,"dt":[0,1,0,0,26,30,0,0,1,0,27,1,0,0,0,86,1],"texts":["The"," user"," wants"," me"," to"," run"," a"," simple"," bash"," command"," and"," then"," reply"," with"," \"","D","ONE","\"."]}} +{"type":"assistant/chunk","seq":28,"time":1783352051820,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"tool-call-chunks","seq0":29,"time0":1783352051820,"data":{"turn":1,"step":1,"index":1,"dt":[0,0,0,28,0,0,0,29,0,0,28,1,0,29,0,0,0,32,0,0,0,0,0,74,0,0,13,0,63,1],"id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","args":["","{","\"","command","\"",": ","\"","echo"," TER","MIN","AL","_OK","\"",", ","\"","description","\"",": ","\"","E","cho"," TER","MIN","AL","_OK"," to"," verify"," terminal"," access","\"","}"]}} +{"type":"assistant/chunk","seq":60,"time":1783352052118,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to run a simple bash command and then reply with \"DONE\"."}}}} +{"type":"assistant/chunk","seq":61,"time":1783352052118,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","arguments":"{\"command\": \"echo TERMINAL_OK\", \"description\": \"Echo TERMINAL_OK to verify terminal access\"}"}}}} +{"type":"assistant/chunk","seq":62,"time":1785498771373,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":2877,"outputTokens":90,"cacheReadTokens":0,"reasoningTokens":18}}}} +{"type":"assistant/chunk","seq":63,"time":1785730424645,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":64,"time":1785730424645,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to run a simple bash command and then reply with \"DONE\"."},{"type":"tool-call","id":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","arguments":"{\"command\": \"echo TERMINAL_OK\", \"description\": \"Echo TERMINAL_OK to verify terminal access\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"0a855246-fbf6-4f91-87b4-c6f1889effe7"},"usage":{"inputTokens":2877,"outputTokens":90,"cacheReadTokens":0,"reasoningTokens":18}},"sourceEventSeqs":[9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38,39,40,41,42,43,44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63],"surfaceOp":"append"} +{"type":"tool/call","seq":65,"time":1785730424646,"data":{"turn":1,"step":1,"callId":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","name":"bash","arguments":"{\"command\": \"echo TERMINAL_OK\", \"description\": \"Echo TERMINAL_OK to verify terminal access\"}"}} +{"type":"tool/result","seq":66,"time":1785730424665,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_fkbBRJsUrGKd1pWVc4Gn8233"},"content":[{"type":"tool-result","toolCallId":"call_00_fkbBRJsUrGKd1pWVc4Gn8233","content":[{"type":"text","text":"TERMINAL_OK\n"}],"isError":false}],"role":"user","id":"908ca4f5-efbb-443b-9b07-acbf25edf954"}},"sourceEventSeqs":[65],"surfaceOp":"append"} +{"type":"step/end","seq":67,"time":1785730424665,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":68,"time":1785730424676,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":69,"time":1783352052780,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":70,"time0":1783352052809,"data":{"turn":1,"step":2,"index":0,"dt":[29,0,0,29,0,0,0,0,0,28,1,28,1,0,0,32,0,0,0,0,0],"texts":["The"," command"," ran"," successfully"," and"," output"," \"","TER","MIN","AL","_OK","\"."," I"," should"," now"," reply"," with"," just"," \"","D","ONE","\"."]}} +{"type":"assistant/chunk","seq":92,"time":1783352052986,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":93,"time":1783352052986,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"D"}}} +{"type":"assistant/chunk","seq":94,"time":1783352052986,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":1,"text":"ONE"}}} +{"type":"assistant/chunk","seq":95,"time":1783352052986,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The command ran successfully and output \"TERMINAL_OK\". I should now reply with just \"DONE\"."}}}} +{"type":"assistant/chunk","seq":96,"time":1783352052986,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":97,"time":1785498771406,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":168,"outputTokens":25,"cacheReadTokens":2816,"reasoningTokens":22}}}} +{"type":"assistant/chunk","seq":98,"time":1785730424681,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":99,"time":1785730424681,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The command ran successfully and output \"TERMINAL_OK\". I should now reply with just \"DONE\"."},{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"aa705bf0-9b5b-4af3-9763-dbf93c98e4c4"},"usage":{"inputTokens":168,"outputTokens":25,"cacheReadTokens":2816,"reasoningTokens":22}},"sourceEventSeqs":[69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95,96,97,98],"surfaceOp":"append"} +{"type":"step/end","seq":100,"time":1785730424682,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":101,"time":1785730424682,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/tests/fixtures/session-cache-io.ts b/tests/fixtures/session-cache-io.ts new file mode 100644 index 00000000..445a769b --- /dev/null +++ b/tests/fixtures/session-cache-io.ts @@ -0,0 +1,35 @@ +// Test-side IO for the sharded session cache: the on-disk form is a directory +// (envelope + one shard per provider), so tests read and write it through the +// real load/save path instead of touching a single JSON file. +import { readFile, readdir } from 'fs/promises' +import { join } from 'path' + +import { + clearLoadCacheMemo, + loadCache, + markCacheDirty, + saveCache, + sessionCacheDir, + type SessionCache, +} from '../../src/session-cache.js' + +/** The cache exactly as it is on disk, bypassing the in-process memo. */ +export async function readCacheOnDisk(): Promise { + clearLoadCacheMemo() + return loadCache() +} + +/** Publish `cache`, rewriting every provider's shard. */ +export async function writeCacheOnDisk(cache: SessionCache): Promise { + for (const provider of Object.keys(cache.providers)) markCacheDirty(cache, provider) + await saveCache(cache) + clearLoadCacheMemo() +} + +/** Byte-level snapshot of the whole cache directory (names + contents). */ +export async function cacheDirSnapshot(): Promise { + const dir = sessionCacheDir() + const names = (await readdir(dir)).sort() + const parts = await Promise.all(names.map(async name => `${name}:${await readFile(join(dir, name), 'utf-8')}`)) + return parts.join('\n') +} diff --git a/tests/flat-slice.test.ts b/tests/flat-slice.test.ts new file mode 100644 index 00000000..8e997ba4 --- /dev/null +++ b/tests/flat-slice.test.ts @@ -0,0 +1,117 @@ +/** + * Tests for flatSlice — the SlicedString-retention fix. + * + * Background: `String.prototype.slice` returns a V8 SlicedString that + * retains its entire parent string. Storing short slices of large session + * strings (100KB+ agent prompts) in the long-lived session cache pinned + * gigabytes of parent buffers during cold parses, OOMing the default heap + * (issue observed at ~5.5GB peak for 3.2GB of kiro session files; ~300MB + * after flattening). + */ + +import { describe, it, expect } from 'vitest' + +import { flatSlice, flatString } from '../src/content-utils.js' + +describe('flatSlice', () => { + it('returns the prefix for strings over the bound', () => { + const big = 'x'.repeat(10_000) + const out = flatSlice(big, 500) + expect(out.length).toBe(500) + expect(out).toBe(big.slice(0, 500)) + }) + + it('returns the string itself when within the bound', () => { + const small = 'hello world' + expect(flatSlice(small, 500)).toBe(small) + }) + + it('handles multi-byte characters without corruption', () => { + // Emoji + CJK near the boundary — Buffer round-trip must not produce + // invalid UTF-8 replacement chars for chars fully inside the slice. + const s = '🐾'.repeat(300) // each emoji is 2 UTF-16 code units + const out = flatSlice(s, 500) + expect(out).toBe(s.slice(0, 500)) + }) + + it('preserves a lone surrogate at a mid-pair cut', () => { + // A cut landing between the high and low surrogate of a pair leaves a + // lone surrogate. utf16le round-trips code units byte-for-byte, so the + // lone surrogate survives intact (unlike utf-8, which would replace it + // with U+FFFD). + const s = 'ab' + '🐾'.repeat(300) // odd offset puts every emoji across even boundaries + const out = flatSlice(s, 501) // cuts mid-pair + expect(out.length).toBe(501) + expect(out.slice(0, 500)).toBe(s.slice(0, 500)) // content before the cut intact + expect(out.charCodeAt(500)).toBe(s.charCodeAt(500)) // lone surrogate preserved + }) + + it('does not retain the parent of an already-sliced view', () => { + // The bug this early-return removal fixes: provider adapters pre-truncate + // with .slice(0, 500) before the cache-site flatSlice call, so a naive + // "already within bound" early return would skip flattening and leave + // the SlicedString pinning its 100KB parent. + const before = process.memoryUsage().heapUsed + const kept: string[] = [] + for (let i = 0; i < 1000; i++) { + const parent = (i % 10).toString().repeat(100_000) + i + const preSliced = parent.slice(0, 500) + kept.push(flatSlice(preSliced, 2000)) + } + if (typeof global.gc === 'function') global.gc() + const after = process.memoryUsage().heapUsed + const growthMB = (after - before) / 1048576 + expect(kept.length).toBe(1000) + expect(growthMB).toBeLessThan(50) + }) + + it('does not retain the parent string (heap growth stays bounded)', () => { + // Property test for the retention fix: keep 1000 short prefixes of + // 1000 distinct 100KB strings. With plain .slice() each prefix pins its + // 100KB parent (~200MB in UTF-16 total). With flatSlice, retained data + // is ~1000 × 500 chars ≈ 1MB. Assert heap growth is far below the + // retention scenario. Threshold is generous (50MB) to be CI-safe while + // still failing decisively if retention returns (>190MB). When the test + // runner exposes gc (vitest under --expose-gc), force a collection so + // transient parent garbage doesn't inflate the measurement. + const before = process.memoryUsage().heapUsed + const kept: string[] = [] + for (let i = 0; i < 1000; i++) { + // Distinct content so V8 cannot intern/share the parents. + const parent = (i % 10).toString().repeat(100_000) + kept.push(flatSlice(parent + i, 500)) + } + if (typeof global.gc === 'function') global.gc() + const after = process.memoryUsage().heapUsed + const growthMB = (after - before) / 1048576 + expect(kept.length).toBe(1000) + expect(growthMB).toBeLessThan(50) + }) +}) + +describe('flatString', () => { + it('returns an equal string for any input', () => { + expect(flatString('')).toBe('') + expect(flatString('hello')).toBe('hello') + expect(flatString('🐾 multi-byte ✓')).toBe('🐾 multi-byte ✓') + }) + + it('does not retain the parent of a regex match group', () => { + // match[1] is a SlicedString retaining the entire subject. flatString + // must break that link: keep 1000 short match groups of distinct 100KB + // subjects and assert bounded heap growth (same thresholds as the + // flatSlice retention test). + const before = process.memoryUsage().heapUsed + const kept: string[] = [] + for (let i = 0; i < 1000; i++) { + const subject = `tool_${i}` + (i % 10).toString().repeat(100_000) + const m = /([^<]+)<\/name>/.exec(subject) + kept.push(flatString(m![1]!)) + } + if (typeof global.gc === 'function') global.gc() + const after = process.memoryUsage().heapUsed + const growthMB = (after - before) / 1048576 + expect(kept.length).toBe(1000) + expect(growthMB).toBeLessThan(50) + }) +}) diff --git a/tests/hydration-interrupt-convergence.test.ts b/tests/hydration-interrupt-convergence.test.ts index 7ad68a09..e3cb761e 100644 --- a/tests/hydration-interrupt-convergence.test.ts +++ b/tests/hydration-interrupt-convergence.test.ts @@ -6,7 +6,7 @@ import { afterEach, beforeAll, beforeEach, describe, expect, it } from 'vitest' import { loadPricing, setLocalModelSavings, setModelAliases } from '../src/models.js' import { buildMenubarPayloadForRange } from '../src/usage-aggregator.js' import { clearSessionCache } from '../src/parser.js' -import { sessionCachePath } from '../src/session-cache.js' +import { readCacheOnDisk, writeCacheOnDisk } from './fixtures/session-cache-io.js' import { dailyCachePath } from '../src/daily-cache.js' import type { DateRange } from '../src/types.js' @@ -91,9 +91,9 @@ describe('interrupted hydration converges to the uninterrupted result', () => { // (a) Session cache: present but NOT marked complete — an interrupted cold // start's throttled partial save. - const sessionRaw = JSON.parse(await readFile(sessionCachePath(), 'utf-8')) + const sessionRaw = await readCacheOnDisk() sessionRaw.complete = false - await writeFile(sessionCachePath(), JSON.stringify(sessionRaw), 'utf-8') + await writeCacheOnDisk(sessionRaw) // (b) Daily cache: frozen with the older days dropped but `lastComputedDate` // advanced to yesterday and NO completeness marker — the exact freeze that @@ -118,7 +118,7 @@ describe('interrupted hydration converges to the uninterrupted result', () => { expect(healed.current.calls).toBe(reference.current.calls) // And the on-disk markers are now durably complete, so the next launch is warm. - expect(JSON.parse(await readFile(sessionCachePath(), 'utf-8')).complete).toBe(true) + expect((await readCacheOnDisk()).complete).toBe(true) expect(JSON.parse(await readFile(dailyCachePath(), 'utf-8')).complete).toBe(true) }) }) diff --git a/tests/kiro-cache-invalidation.test.ts b/tests/kiro-cache-invalidation.test.ts index 0046dd7a..93bc9c1b 100644 --- a/tests/kiro-cache-invalidation.test.ts +++ b/tests/kiro-cache-invalidation.test.ts @@ -25,9 +25,9 @@ import { CACHE_VERSION, computeEnvFingerprint, fingerprintFile, - sessionCachePath, type SessionCache, } from '../src/session-cache.js' +import { writeCacheOnDisk } from './fixtures/session-cache-io.js' // The kiro provider singleton captures homedir() when its module is first // imported, so HOME must point at the test root before ../src/parser.js is @@ -99,7 +99,7 @@ async function seedCache(execPath: string, envFingerprint: string): Promise { + const root = `${process.env['TMPDIR'] || '/tmp'}/kiro-projpath-${process.pid}-${Date.now()}` + process.env['HOME'] = `${root}/home` + process.env['USERPROFILE'] = `${root}/home` + return root +}) + +const HOME = join(testRoot, 'home') +const CACHE_DIR = join(testRoot, 'cache') +const KIRO_SESSIONS = join(HOME, '.kiro', 'sessions') +const CLI_DIR = join(KIRO_SESSIONS, 'cli') + +const CLI_CWD = '/local/home/testuser/workplace/my-project' +const V2_WORKSPACE = '/local/home/testuser/workplace/ide-project' + +beforeEach(() => { + process.env['HOME'] = HOME + process.env['USERPROFILE'] = HOME + process.env['CODEBURN_CACHE_DIR'] = CACHE_DIR + delete process.env['KIRO_HOME'] + clearSessionCache() +}) + +afterAll(async () => { + await rm(testRoot, { recursive: true, force: true }) +}) + +/** Write a minimal kiro CLI session: .jsonl entries + companion .json meta. */ +async function seedCliSession(id: string, cwd: string): Promise { + await mkdir(CLI_DIR, { recursive: true }) + const jsonlPath = join(CLI_DIR, `${id}.jsonl`) + const entries = [ + { kind: 'Prompt', data: { content: [{ kind: 'text', data: 'add a feature' }] } }, + { kind: 'AssistantMessage', data: { content: [{ kind: 'text', data: 'Done — added the feature and tests.' }] } }, + ] + await writeFile(jsonlPath, entries.map(e => JSON.stringify(e)).join('\n')) + await writeFile(join(CLI_DIR, `${id}.json`), JSON.stringify({ + session_id: id, + cwd, + created_at: '2026-08-01T10:00:00Z', + updated_at: '2026-08-01T10:05:00Z', + session_state: { + rts_model_state: { model_info: { model_id: 'auto' } }, + conversation_metadata: { + user_turn_metadatas: [ + { end_timestamp: '2026-08-01T10:05:00Z', metering_usage: [] }, + ], + }, + }, + })) + return jsonlPath +} + +/** Write a minimal v2 IDE session: sessions//sess_/{session.json,messages.jsonl}. */ +async function seedV2Session(id: string, workspacePath: string): Promise { + const sessDir = join(KIRO_SESSIONS, 'f'.repeat(32), `sess_${id}`) + await mkdir(sessDir, { recursive: true }) + await writeFile(join(sessDir, 'session.json'), JSON.stringify({ + id, + modelId: 'auto', + workspacePaths: [workspacePath], + createdAt: '2026-08-01T11:00:00Z', + })) + const events = [ + { timestamp: '2026-08-01T11:00:00Z', payload: { type: 'user', content: 'fix the bug' } }, + { timestamp: '2026-08-01T11:00:01Z', payload: { type: 'turn_start', executionId: 'x1' } }, + { timestamp: '2026-08-01T11:00:05Z', payload: { type: 'assistant', content: 'Fixed the bug in handler.ts by checking null first.' } }, + { timestamp: '2026-08-01T11:00:06Z', payload: { type: 'turn_end', executionId: 'x1' } }, + ] + await writeFile(join(sessDir, 'messages.jsonl'), events.map(e => JSON.stringify(e)).join('\n')) +} + +function kiroAgentDir(): string { + if (process.platform === 'darwin') { + return join(HOME, 'Library', 'Application Support', 'Kiro', 'User', 'globalStorage', 'kiro.kiroagent') + } + if (process.platform === 'win32') { + return join(HOME, 'AppData', 'Roaming', 'Kiro', 'User', 'globalStorage', 'kiro.kiroagent') + } + return join(HOME, '.config', 'Kiro', 'User', 'globalStorage', 'kiro.kiroagent') +} + +/** Write a minimal IDE workspace-session: + * /workspace-sessions//.json */ +async function seedWorkspaceSession(id: string, workspaceDirectory: string): Promise { + const encoded = Buffer.from(workspaceDirectory, 'utf-8').toString('base64').replace(/=/g, '_') + const dir = join(kiroAgentDir(), 'workspace-sessions', encoded) + await mkdir(dir, { recursive: true }) + await writeFile(join(dir, `${id}.json`), JSON.stringify({ + sessionId: id, + selectedModel: 'auto', + workspaceDirectory, + history: [ + { message: { role: 'user', content: 'refactor the config loader' } }, + { message: { role: 'assistant', content: 'Refactored the loader into three small functions with tests.' } }, + ], + })) +} + +async function kiroCalls() { + const projects = await parseAllSessions(undefined, 'kiro') + return projects.flatMap(p => p.sessions.map(s => ({ project: p.project, projectPath: p.projectPath, session: s }))) +} + +describe('kiro projectPath emission', () => { + it('CLI session: projectPath is the full meta.cwd, project the basename', async () => { + await seedCliSession('cli-001', CLI_CWD) + const rows = await kiroCalls() + const row = rows.find(r => r.project === 'my-project') + expect(row).toBeDefined() + expect(row!.projectPath).toBe(CLI_CWD) + }) + + it('v2 IDE session: projectPath is workspacePaths[0]', async () => { + await seedV2Session('v2-001', V2_WORKSPACE) + const rows = await kiroCalls() + const row = rows.find(r => r.project === 'ide-project') + expect(row).toBeDefined() + expect(row!.projectPath).toBe(V2_WORKSPACE) + }) + + it('workspace session: projectPath is workspaceDirectory', async () => { + const WS_DIR = '/local/home/testuser/workplace/ws-project' + await seedWorkspaceSession('ws-001', WS_DIR) + const rows = await kiroCalls() + const row = rows.find(r => r.project === 'ws-project') + expect(row).toBeDefined() + expect(row!.projectPath).toBe(WS_DIR) + }) +}) + +describe('kiro projectPath cache invalidation (project-path-v1 bump)', () => { + // The fingerprint a cache written by the PREVIOUS release carries: same env + // vars, but the parser version before the project-path-v1 bump. + function preBumpFingerprint(): string { + const parts = [`KIRO_HOME=${process.env['KIRO_HOME'] ?? ''}`, 'parser=ide-parsing-v1-est-cost'] + return createHash('sha256').update(parts.join('\0')).digest('hex').slice(0, 16) + } + + it('the bump changed the env fingerprint', () => { + expect(computeEnvFingerprint('kiro')).not.toBe(preBumpFingerprint()) + }) + + it('a pre-bump cache entry (no projectPath) is re-parsed and gains projectPath', async () => { + const jsonlPath = await seedCliSession('cli-002', CLI_CWD) + + // Seed a cache exactly as the pre-bump release would have left it: + // correct file fingerprint, pre-bump env fingerprint, turns WITHOUT + // projectPath on the cached calls. + const fp = await fingerprintFile(jsonlPath) + if (!fp) throw new Error('failed to fingerprint seeded session file') + const cache: SessionCache = { + version: CACHE_VERSION, + providers: { + kiro: { + envFingerprint: preBumpFingerprint(), + files: { + [jsonlPath]: { fingerprint: fp, mcpInventory: [], turns: [] }, + }, + }, + }, + } + await mkdir(CACHE_DIR, { recursive: true }) + await writeCacheOnDisk(cache) + clearSessionCache() + + const rows = await kiroCalls() + const row = rows.find(r => r.project === 'my-project') + expect(row).toBeDefined() + expect(row!.projectPath).toBe(CLI_CWD) + }) +}) diff --git a/tests/mcp-coverage.test.ts b/tests/mcp-coverage.test.ts index a19ddc4d..e173f008 100644 --- a/tests/mcp-coverage.test.ts +++ b/tests/mcp-coverage.test.ts @@ -1,10 +1,14 @@ -import { describe, it, expect } from 'vitest' +import { describe, it, expect, vi } from 'vitest' import { aggregateMcpCoverage, + buildOptimizeJsonReport, + classTotals, + findingClass, detectMcpProfileAdvisor, detectMcpToolCoverage, estimateMcpSchemaCost, + runOptimize, } from '../src/optimize.js' import type { ClassifiedTurn, @@ -313,6 +317,23 @@ describe('estimateMcpSchemaCost', () => { expect(cost.cacheWriteTokens).toBe(24_000) }) + it('does not count a duplicated server identifier twice', () => { + const inventory = Array.from({ length: 20 }, (_, i) => `mcp__svc__t${i}`) + const sessions = [makeSession({ + inventory, + turns: [makeTurn([makeCall({ cacheCreation: 50_000 })])], + })] + + const cost = estimateMcpSchemaCost( + { svc: 20 }, + [project(sessions)], + ['svc', 'svc'], + ) + + expect(cost.cacheWriteTokens).toBe(8_000) + expect(cost.effectiveInputTokens).toBe(10_000) + }) + it('still works with the single-server signature (backward compat)', () => { const turns = [makeTurn([makeCall({ cacheCreation: 50_000 })])] const sessions = [makeSession({ @@ -333,6 +354,174 @@ describe('detectMcpToolCoverage', () => { expect(detectMcpToolCoverage([project([makeSession({})])])).toBeNull() }) + it('keeps claude.ai connector evidence but emits manual guidance instead of a local remove command', () => { + const server = 'claude_ai_Netlify' + const inventory = Array.from({ length: 20 }, (_, i) => `mcp__${server}__t${i}`) + const turns = [makeTurn([makeCall({ cacheCreation: 50_000 })])] + const sessions = [ + makeSession({ sessionId: 'a', inventory, turns }), + makeSession({ sessionId: 'b', inventory, turns }), + ] + + const finding = detectMcpToolCoverage([project(sessions)]) + + expect(finding).not.toBeNull() + expect(finding!.tokensSaved).toBe(20_000) + // Keep the transcript namespace as evidence, but name the connector the + // way users actually see it in /mcp and claude.ai Settings. + expect(finding!.explanation).toContain(server) + expect(finding!.explanation).toContain('claude.ai Netlify') + expect(finding!.explanation).toContain('/mcp') + expect(finding!.explanation).toContain('claude.ai Settings > Connectors') + expect(finding!.fix.type).toBe('paste') + if (finding!.fix.type === 'paste') { + expect(finding!.fix.destination).toBe('manual') + expect(finding!.fix.text).toContain('/mcp') + expect(finding!.fix.text).toContain('claude.ai Netlify') + expect(finding!.fix.text).not.toContain(server) + expect(finding!.fix.text).toContain('claude.ai Settings > Connectors') + } + expect(JSON.stringify(finding)).not.toContain('claude mcp remove') + expect(finding!.apply).toBeUndefined() + }) + + it('renders connector-only remediation as a manual action, never an Ask Claude prompt', async () => { + const server = 'claude_ai_Google_Calendar' + const inventory = Array.from({ length: 20 }, (_, i) => `mcp__${server}__t${i}`) + const turns = [makeTurn([makeCall({ cacheCreation: 50_000 })])] + const projects = [project([ + makeSession({ sessionId: 'a', inventory, turns }), + makeSession({ sessionId: 'b', inventory, turns }), + ])] + const log = vi.spyOn(console, 'log').mockImplementation(() => undefined) + + try { + await runOptimize(projects, 'Test period') + const output = log.mock.calls.map(args => args.join(' ')).join('\n') + expect(output).toContain('Manual action') + expect(output).toContain('claude.ai Google Calendar') + expect(output).not.toContain('Ask Claude in the current session') + } finally { + log.mockRestore() + } + }) + + it('keeps the public optimize JSON envelope while marking connector guidance manual', () => { + const server = 'claude_ai_Slack' + const coverage = [{ + server, + toolsAvailable: 20, + toolsInvoked: 0, + unusedTools: Array.from({ length: 20 }, (_, i) => `mcp__${server}__t${i}`), + invocations: 0, + loadedSessions: 2, + coverageRatio: 0, + }] + const finding = detectMcpToolCoverage([], coverage)! + + const report = buildOptimizeJsonReport([], 'Test period', { + findings: [finding], + costRate: 0, + healthScore: 90, + healthGrade: 'A', + }) + + expect(report.findings[0]).toMatchObject({ + id: 'mcp-low-coverage', + tokensSaved: 0, + fix: { + type: 'paste', + destination: 'manual', + text: expect.stringContaining('claude.ai Slack'), + }, + }) + expect(report.findings[0]).not.toHaveProperty('apply') + expect(report.findings[0]).not.toHaveProperty('applyTokensSaved') + expect(report.findings[0]).not.toHaveProperty('applyTokensSavedByServer') + expect(report.findings[0]).not.toHaveProperty('manualFollowUp') + }) + + it('intersects globally unused tool identities with each session inventory', () => { + const server = 'filesystem' + const coverage = [{ + server, + toolsAvailable: 20, + toolsInvoked: 0, + unusedTools: Array.from({ length: 20 }, (_, i) => `mcp__${server}__t${i}`), + invocations: 0, + loadedSessions: 2, + coverageRatio: 0, + }] + const sessions = [5, 20].map((count, index) => makeSession({ + sessionId: `s${index}`, + inventory: Array.from({ length: count }, (_, i) => `mcp__${server}__t${i}`), + turns: [makeTurn([makeCall({ cacheCreation: 50_000 })])], + })) + + const finding = detectMcpToolCoverage([project(sessions)], coverage) + + // 5*400 and 20*400, each at 1.25x cache-write pricing. + expect(finding).toMatchObject({ tokensSaved: 12_500 }) + expect(finding!.applyTokensSavedByServer?.filesystem).toBe(12_500) + }) + + it('conserves simultaneous cache-write and cache-read buckets with fractional shares', () => { + const inventory = [ + ...Array.from({ length: 15 }, (_, i) => `mcp__filesystem__t${i}`), + ...Array.from({ length: 11 }, (_, i) => `mcp__claude_ai_Slack__t${i}`), + ] + const coverage: McpServerCoverage[] = [ + { + server: 'filesystem', toolsAvailable: 15, toolsInvoked: 0, + unusedTools: inventory.slice(0, 15), invocations: 0, loadedSessions: 2, coverageRatio: 0, + }, + { + server: 'claude_ai_Slack', toolsAvailable: 11, toolsInvoked: 0, + unusedTools: inventory.slice(15), invocations: 0, loadedSessions: 2, coverageRatio: 0, + }, + ] + // Duplicate inventory entries must not increase the schema share. + const sessionInventory = [...inventory, inventory[0]!, inventory[15]!] + const sessions = ['a', 'b'].map(sessionId => makeSession({ + sessionId, + inventory: sessionInventory, + turns: [makeTurn([makeCall({ cacheCreation: 5_001, cacheRead: 3_333 })])], + })) + + const finding = detectMcpToolCoverage([project(sessions)], coverage)! + const total = 2 * (5_001 * 1.25 + 3_333 * 0.10) + const local = total * (15 / 26) + + expect(finding.tokensSaved).toBe(Math.round(total)) + expect(finding.applyTokensSaved).toBe(Math.round(local)) + expect(finding.applyTokensSavedByServer?.filesystem).toBeCloseTo(local, 8) + }) + + it('pluralises manual guidance when only claude.ai connectors are flagged', () => { + const coverage = ['claude_ai_Slack', 'claude_ai_Google_Calendar'].map(server => ({ + server, + toolsAvailable: 20, + toolsInvoked: 0, + unusedTools: Array.from({ length: 20 }, (_, i) => `mcp__${server}__t${i}`), + invocations: 0, + loadedSessions: 2, + coverageRatio: 0, + })) + + const finding = detectMcpToolCoverage([], coverage) + + expect(finding).not.toBeNull() + expect(finding!.fix).toMatchObject({ + type: 'paste', + destination: 'manual', + label: 'Manage the underused claude.ai connectors where they load:', + }) + if (finding!.fix.type === 'paste') { + expect(finding!.fix.text).toContain('manage them in claude.ai Settings > Connectors') + } + expect(finding!.apply).toBeUndefined() + }) + it('does not flag a server with healthy coverage', () => { const inventory = Array.from({ length: 20 }, (_, i) => `mcp__svc__t${i}`) const turns = [makeTurn( @@ -379,9 +568,93 @@ describe('detectMcpToolCoverage', () => { expect(finding!.explanation).toContain('1/30') expect(finding!.fix.type).toBe('command') expect((finding!.fix as { text: string }).text).toContain("claude mcp remove 'hf'") + expect(finding!.apply).toEqual({ kind: 'mcp-remove', servers: ['hf'] }) expect(finding!.tokensSaved).toBeGreaterThan(0) }) + it('keeps mixed connector guidance visible while making only the local server executable', () => { + const inventory = ['filesystem', 'claude_ai_Slack'].flatMap(server => + Array.from({ length: 20 }, (_, i) => `mcp__${server}__t${i}`), + ) + const sessions: SessionSummary[] = [ + makeSession({ sessionId: 'mixed-a', inventory, turns: [makeTurn([makeCall({ cacheCreation: 50_000 })])] }), + makeSession({ sessionId: 'mixed-b', inventory, turns: [makeTurn([makeCall({ cacheCreation: 50_000 })])] }), + ] + + const finding = detectMcpToolCoverage([project(sessions)]) + + expect(finding).not.toBeNull() + // The finding describes both opportunities: 40 unused tool schemas across + // two sessions = 40K effective tokens. The automatic mutation owns only + // the 20 local schemas = 20K; the connector portion remains manual. + expect(finding).toMatchObject({ tokensSaved: 40_000, applyTokensSaved: 20_000 }) + expect(finding!.explanation).toContain('claude_ai_Slack') + expect(finding!.explanation).toContain('/mcp') + expect(finding!.explanation).toContain('claude.ai Settings > Connectors') + expect(finding!.fix).toEqual({ + type: 'command', + label: 'Remove the underused local server, or trim its tools in your MCP config:', + text: "claude mcp remove 'filesystem'", + }) + expect(finding!.apply).toEqual({ kind: 'mcp-remove', servers: ['filesystem'] }) + }) + + it('attributes a capped mixed cache bucket proportionally to the local action', () => { + const inventory = ['filesystem', 'claude_ai_Slack'].flatMap(server => + Array.from({ length: 20 }, (_, i) => `mcp__${server}__t${i}`), + ) + const sessions = ['a', 'b'].map(sessionId => makeSession({ + sessionId, + inventory, + turns: [makeTurn([makeCall({ cacheCreation: 10_000 })])], + })) + + const finding = detectMcpToolCoverage([project(sessions)]) + + // Each call's 10K cache bucket is shared evenly by two 8K schemas. + // Total: 2 * 10K * 1.25 = 25K. The local mutation owns half. + expect(finding).toMatchObject({ tokensSaved: 25_000, applyTokensSaved: 12_500 }) + }) + + it('charges only the flagged servers actually loaded in each session', () => { + const sessions = ['filesystem', 'claude_ai_Slack'].flatMap(server => + ['a', 'b'].map(suffix => makeSession({ + sessionId: `${server}-${suffix}`, + inventory: Array.from({ length: 20 }, (_, i) => `mcp__${server}__t${i}`), + turns: [makeTurn([makeCall({ cacheCreation: 50_000 })])], + })), + ) + + const finding = detectMcpToolCoverage([project(sessions)]) + + // Four sessions each load one 8K schema. The combined finding must not + // charge both schemas to every session merely because both are flagged. + expect(finding).toMatchObject({ tokensSaved: 40_000, applyTokensSaved: 20_000 }) + }) + + it('disambiguates a claude.ai connector from a similarly named local server', () => { + const sessions: SessionSummary[] = [] + for (const server of ['claude_ai_Netlify', 'netlify']) { + const inventory = Array.from({ length: 20 }, (_, i) => `mcp__${server}__t${i}`) + sessions.push( + makeSession({ sessionId: `${server}-a`, inventory }), + makeSession({ sessionId: `${server}-b`, inventory }), + ) + } + + const finding = detectMcpToolCoverage([project(sessions)]) + + expect(finding).not.toBeNull() + expect(finding!.explanation).toContain('claude_ai_Netlify') + expect(finding!.explanation).toContain('separate from any similarly named local MCP server') + expect(finding!.fix.type).toBe('command') + if (finding!.fix.type === 'command') { + expect(finding!.fix.text).toBe("claude mcp remove 'netlify'") + expect(finding!.fix.text).not.toContain('claude_ai_Netlify') + } + expect(finding!.apply).toEqual({ kind: 'mcp-remove', servers: ['netlify'] }) + }) + it('escalates impact to high when token waste crosses the threshold', () => { const inventory = Array.from({ length: 60 }, (_, i) => `mcp__big__t${i}`) // 60 tools * 400 tokens = 24k schema. With many sessions and large @@ -654,3 +927,128 @@ describe('detectMcpProfileAdvisor', () => { expect(detectMcpProfileAdvisor(projects, coverage)).toBeNull() }) }) + +// --------------------------------------------------------------------------- +// Connector findings under the fix/nudge/keep classification (#1019) +// --------------------------------------------------------------------------- + +describe('connector findings and finding class', () => { + const inventoryFor = (servers: string[]) => servers.flatMap(server => + Array.from({ length: 20 }, (_, i) => `mcp__${server}__t${i}`), + ) + const twoSessions = (servers: string[]) => ['a', 'b'].map(sessionId => makeSession({ + sessionId, + inventory: inventoryFor(servers), + turns: [makeTurn([makeCall({ cacheCreation: 50_000 })])], + })) + + it('classifies a connector-only finding as a nudge, since nothing is appliable', () => { + const finding = detectMcpToolCoverage([project(twoSessions(['claude_ai_Gmail']))]) + + expect(finding).not.toBeNull() + expect(finding!.apply).toBeUndefined() + expect(findingClass(finding!)).toBe('nudge') + expect(finding!.tokensSaved).toBeGreaterThan(0) + // Never lands in the "apply-able" subtotal. + expect(classTotals([finding!], 0.00002).fix).toEqual({ tokensSaved: 0, savingsUSD: 0, count: 0 }) + }) + + it('counts only the local subset of a mixed finding towards the apply-able subtotal', () => { + const finding = detectMcpToolCoverage([project(twoSessions(['filesystem', 'claude_ai_Slack']))]) + + expect(finding).not.toBeNull() + expect(findingClass(finding!)).toBe('fix') + expect(finding).toMatchObject({ tokensSaved: 40_000, applyTokensSaved: 20_000 }) + expect(classTotals([finding!], 0.00002).fix).toEqual({ tokensSaved: 20_000, savingsUSD: 0.4, count: 1 }) + }) + + it("leaves a local-only finding's subtotal at its full estimate", () => { + const finding = detectMcpToolCoverage([project(twoSessions(['filesystem']))]) + + expect(finding).not.toBeNull() + expect(finding!.applyTokensSaved).toBeUndefined() + expect(classTotals([finding!], 0.00002).fix.tokensSaved).toBe(finding!.tokensSaved) + }) + + it('charges each session only for the local schemas it actually loaded', () => { + // Same per-session scoping the connector split relies on, with no + // connector in play: two flagged local servers in disjoint sessions are + // charged one schema each, not both schemas everywhere. + const sessions = ['filesystem', 'playwright'].flatMap(server => + ['a', 'b'].map(suffix => makeSession({ + sessionId: `${server}-${suffix}`, + inventory: inventoryFor([server]), + turns: [makeTurn([makeCall({ cacheCreation: 50_000 })])], + })), + ) + + const finding = detectMcpToolCoverage([project(sessions)]) + + expect(finding).toMatchObject({ tokensSaved: 40_000 }) + expect(finding!.applyTokensSaved).toBeUndefined() + expect(classTotals([finding!], 0.00002).fix.tokensSaved).toBe(40_000) + }) + + it('treats a claude_ai_* name owned by local config as a local server', () => { + const finding = detectMcpToolCoverage( + [project(twoSessions(['claude_ai_homegrown']))], + undefined, + new Set(['claude_ai_homegrown']), + ) + + expect(finding!.fix).toEqual({ + type: 'command', + label: 'Remove the underused local server, or trim its tools in your MCP config:', + text: "claude mcp remove 'claude_ai_homegrown'", + }) + expect(finding!.apply).toEqual({ kind: 'mcp-remove', servers: ['claude_ai_homegrown'] }) + expect(findingClass(finding!)).toBe('fix') + // Local config owns the name, so the finding must not claim it is a connector. + expect(finding!.explanation).not.toContain('is a claude.ai connector namespace') + // ...but the transcript cannot rule out a same-name connector. + expect(finding!.explanation).toContain('If you also use a claude.ai connector named claude_ai_homegrown') + expect(finding!.manualFollowUp?.label).toBe('Check for a same-name claude.ai connector:') + // The whole estimate is appliable: nothing is reserved for a connector. + expect(finding!.applyTokensSaved).toBeUndefined() + expect(classTotals([finding!], 0.00002).fix.tokensSaved).toBe(finding!.tokensSaved) + }) + + it('keeps a claude_ai_* name absent from local config a connector', () => { + const finding = detectMcpToolCoverage( + [project(twoSessions(['claude_ai_Gmail']))], + undefined, + new Set(['filesystem', 'playwright']), + ) + + expect(finding!.fix.type).toBe('paste') + expect(finding!.apply).toBeUndefined() + expect(findingClass(finding!)).toBe('nudge') + expect(finding!.explanation).toContain('is a claude.ai connector namespace') + }) + + it('falls back to prefix-only when no local config could be read', () => { + // Unreadable or absent config contributes no names, which leaves every + // claude_ai_* namespace on the conservative connector path. + const finding = detectMcpToolCoverage([project(twoSessions(['claude_ai_homegrown']))]) + + expect(finding!.fix.type).toBe('paste') + expect(finding!.apply).toBeUndefined() + expect(findingClass(finding!)).toBe('nudge') + }) + + it('applies the local entry and notes the connector when a name collides', () => { + const finding = detectMcpToolCoverage( + [project(twoSessions(['filesystem', 'claude_ai_Slack']))], + undefined, + new Set(['filesystem', 'claude_ai_Slack']), + ) + + // Both are local: the removal owns both entries and nothing is deferred. + expect(finding!.apply).toEqual({ kind: 'mcp-remove', servers: ['filesystem', 'claude_ai_Slack'] }) + expect(finding!.applyTokensSaved).toBeUndefined() + expect(classTotals([finding!], 0.00002).fix.tokensSaved).toBe(40_000) + expect(finding!.explanation).not.toContain('is a claude.ai connector namespace') + expect(finding!.manualFollowUp?.text) + .toBe('If you also use a claude.ai connector named claude_ai_Slack, manage it with /mcp or in claude.ai Settings > Connectors.') + }) +}) diff --git a/tests/menubar-installer-windows.test.ts b/tests/menubar-installer-windows.test.ts new file mode 100644 index 00000000..0ac4c55e --- /dev/null +++ b/tests/menubar-installer-windows.test.ts @@ -0,0 +1,299 @@ +import { createHash } from 'node:crypto' +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { + WINDOWS_RELEASE, + installMenubarApp, + parseInstalledWindowsMenubar, + resolveLatestMenubarReleaseAssets, + resolveSystem32Path, + resolveVersionedMenubarReleaseAssets, + type ReleaseResponse, +} from '../src/menubar-installer.js' + +function asset(name: string) { + return { name, browser_download_url: `https://example.test/${name}` } +} + +const MSI_URL = + 'https://github.com/getagentseal/codeburn/releases/download/windows-v0.9.20/CodeBurn.Menubar_0.9.20_x64_en-US.msi' +const MSI_BYTES = 'msi-bytes' + +function sha256(text: string): string { + return createHash('sha256').update(Buffer.from(text)).digest('hex') +} + +function httpResponse(status: number, body?: string) { + return { + ok: status >= 200 && status < 300, + status, + headers: { get: () => null }, + body: body === undefined ? null : new Response(body).body, + text: async () => body ?? '', + } +} + +/** reg query /s output, one blank-line separated block per subkey. */ +function regBlock(values: Record, key = '{9c1e2f0a-0000-0000-0000-000000000001}'): string { + const lines = Object.entries(values).map(([name, value]) => ` ${name} REG_SZ ${value}`) + return [ + 'HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{other}', + ' DisplayName REG_SZ Some Other App', + '', + `HKEY_LOCAL_MACHINE\\SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\${key}`, + ...lines, + '', + ].join('\r\n') +} + +const INSTALLED_0_9_20 = regBlock({ + DisplayName: 'CodeBurn Menubar', + DisplayVersion: '0.9.20', + InstallLocation: 'C:\\Program Files\\CodeBurn Menubar\\', + Publisher: 'AgentSeal', +}) + +describe('windows release asset resolution', () => { + it('builds direct release asset URLs from the CLI version', () => { + const resolved = resolveVersionedMenubarReleaseAssets('0.9.20', WINDOWS_RELEASE) + + expect(resolved.release.tag_name).toBe('windows-v0.9.20') + expect(resolved.zip.name).toBe('CodeBurn.Menubar_0.9.20_x64_en-US.msi') + expect(resolved.zip.browser_download_url).toBe(MSI_URL) + expect(resolved.checksum.browser_download_url).toBe(`${MSI_URL}.sha256`) + }) + + it('normalizes a leading v', () => { + expect(resolveVersionedMenubarReleaseAssets('v0.9.20', WINDOWS_RELEASE).release.tag_name).toBe('windows-v0.9.20') + }) + + it('scans for the newest windows-v release that has both assets', () => { + const releases: ReleaseResponse[] = [ + { tag_name: 'mac-v0.9.20', assets: [asset('CodeBurnMenubar-v0.9.20.zip'), asset('CodeBurnMenubar-v0.9.20.zip.sha256')] }, + { tag_name: 'windows-v0.9.21', assets: [asset('CodeBurn.Menubar_0.9.21_x64_en-US.msi')] }, + { + tag_name: 'windows-v0.9.20', + assets: [asset('CodeBurn.Menubar_0.9.20_x64_en-US.msi'), asset('CodeBurn.Menubar_0.9.20_x64_en-US.msi.sha256')], + }, + ] + + const resolved = resolveLatestMenubarReleaseAssets(releases, WINDOWS_RELEASE) + + expect(resolved.release.tag_name).toBe('windows-v0.9.20') + expect(resolved.zip.name).toBe('CodeBurn.Menubar_0.9.20_x64_en-US.msi') + }) + + it('reports when no windows release carries both assets', () => { + expect(() => resolveLatestMenubarReleaseAssets([{ tag_name: 'v0.9.20', assets: [] }], WINDOWS_RELEASE)) + .toThrow(/No windows-v\* release/) + }) +}) + +describe('resolveSystem32Path', () => { + it('uses an absolute SystemRoot', () => { + expect(resolveSystem32Path('msiexec.exe', { SystemRoot: 'D:\\Windows' })).toBe('D:\\Windows\\System32\\msiexec.exe') + }) + + it('falls back to the documented default when SystemRoot is missing or relative', () => { + expect(resolveSystem32Path('reg.exe', {})).toBe('C:\\Windows\\System32\\reg.exe') + expect(resolveSystem32Path('reg.exe', { SystemRoot: 'Windows' })).toBe('C:\\Windows\\System32\\reg.exe') + }) +}) + +describe('parseInstalledWindowsMenubar', () => { + it('reads the version and joins the exe onto InstallLocation', () => { + expect(parseInstalledWindowsMenubar(INSTALLED_0_9_20)).toEqual({ + version: '0.9.20', + exePath: 'C:\\Program Files\\CodeBurn Menubar\\CodeBurn Menubar.exe', + }) + }) + + it('falls back to DisplayIcon when there is no InstallLocation', () => { + const output = regBlock({ + DisplayName: 'CodeBurn Menubar', + DisplayVersion: '0.9.20', + DisplayIcon: 'C:\\Program Files\\CodeBurn Menubar\\CodeBurn Menubar.exe,0', + }) + + expect(parseInstalledWindowsMenubar(output)?.exePath).toBe('C:\\Program Files\\CodeBurn Menubar\\CodeBurn Menubar.exe') + }) + + it('returns undefined when the product is not installed', () => { + expect(parseInstalledWindowsMenubar(regBlock({ DisplayName: 'Something Else', DisplayVersion: '1.0' }))).toBeUndefined() + }) +}) + +describe('installMenubarApp on windows', () => { + let sandbox: string + let logs: string[] + let launched: string[] + let installerCalls: Array<{ exe: string; args: string[] }> + + function hooks(overrides: Record = {}) { + return { + stagingDir: sandbox, + env: { SystemRoot: 'C:\\Windows' }, + log: (message: string) => { logs.push(message) }, + launch: (exePath: string) => { launched.push(exePath) }, + queryRegistry: async () => INSTALLED_0_9_20, + runInstaller: async (exe: string, args: string[]) => { installerCalls.push({ exe, args }); return 0 }, + fetchOptions: { + sleep: async () => {}, + log: (message: string) => { logs.push(message) }, + fetchImpl: async (url: string) => httpResponse(200, url.endsWith('.sha256') + ? `${sha256(MSI_BYTES)} CodeBurn.Menubar_0.9.20_x64_en-US.msi` + : MSI_BYTES), + }, + ...overrides, + } + } + + beforeEach(async () => { + sandbox = await mkdtemp(join(tmpdir(), 'menubar-windows-')) + logs = [] + launched = [] + installerCalls = [] + }) + + afterEach(async () => { + await rm(sandbox, { recursive: true, force: true }) + }) + + it('skips the download and just launches when the pinned version is already installed', async () => { + let fetches = 0 + const result = await installMenubarApp({ + platform: 'win32', + cliVersion: '0.9.20', + windows: hooks({ fetchOptions: { fetchImpl: async () => { fetches++; return httpResponse(500) } } }), + }) + + expect(fetches).toBe(0) + expect(installerCalls).toEqual([]) + expect(launched).toEqual(['C:\\Program Files\\CodeBurn Menubar\\CodeBurn Menubar.exe']) + expect(result).toEqual({ installedPath: 'C:\\Program Files\\CodeBurn Menubar\\CodeBurn Menubar.exe', launched: true }) + }) + + it('downloads, verifies, runs msiexec from System32 and launches the installed app', async () => { + let queries = 0 + const result = await installMenubarApp({ + platform: 'win32', + cliVersion: '0.9.20', + windows: hooks({ + queryRegistry: async () => (queries++ === 0 ? '' : INSTALLED_0_9_20), + }), + }) + + expect(installerCalls).toEqual([{ + exe: 'C:\\Windows\\System32\\msiexec.exe', + args: ['/i', join(sandbox, 'CodeBurn.Menubar_0.9.20_x64_en-US.msi'), '/passive', '/norestart'], + }]) + expect(launched).toEqual(['C:\\Program Files\\CodeBurn Menubar\\CodeBurn Menubar.exe']) + expect(result.launched).toBe(true) + expect(logs).toContain('Downloading CodeBurn.Menubar_0.9.20_x64_en-US.msi...') + expect(logs).toContain('Verifying checksum...') + expect(logs).toContain('Installing...') + expect(logs).toContain('Launched CodeBurn Menubar.') + }) + + it('reinstalls the same version when --force is passed', async () => { + await installMenubarApp({ platform: 'win32', cliVersion: '0.9.20', force: true, windows: hooks() }) + + expect(installerCalls).toHaveLength(1) + }) + + it('aborts on a checksum mismatch without running the installer', async () => { + await expect(installMenubarApp({ + platform: 'win32', + cliVersion: '0.9.20', + windows: hooks({ + queryRegistry: async () => '', + fetchOptions: { + sleep: async () => {}, + log: () => {}, + fetchImpl: async (url: string) => + httpResponse(200, url.endsWith('.sha256') ? `${sha256('other-bytes')} x.msi` : MSI_BYTES), + }, + }), + })).rejects.toThrow(/Checksum mismatch/) + + expect(installerCalls).toEqual([]) + expect(launched).toEqual([]) + }) + + it('treats 3010 as installed and says a restart is pending', async () => { + let queries = 0 + const result = await installMenubarApp({ + platform: 'win32', + cliVersion: '0.9.20', + windows: hooks({ + queryRegistry: async () => (queries++ === 0 ? '' : INSTALLED_0_9_20), + runInstaller: async (exe: string, args: string[]) => { installerCalls.push({ exe, args }); return 3010 }, + }), + }) + + expect(result.launched).toBe(true) + expect(logs.some(line => line.includes('restart'))).toBe(true) + }) + + it('treats 1602 as a cancelled install: no launch, no error', async () => { + const result = await installMenubarApp({ + platform: 'win32', + cliVersion: '0.9.20', + windows: hooks({ + queryRegistry: async () => '', + runInstaller: async () => 1602, + }), + }) + + expect(result).toEqual({ installedPath: '', launched: false }) + expect(launched).toEqual([]) + expect(logs.some(line => line.includes('cancelled'))).toBe(true) + }) + + it('fails with the exit code for any other msiexec failure', async () => { + await expect(installMenubarApp({ + platform: 'win32', + cliVersion: '0.9.20', + windows: hooks({ queryRegistry: async () => '', runInstaller: async () => 1603 }), + })).rejects.toThrow(/msiexec exited with 1603/) + + expect(launched).toEqual([]) + }) + + it('falls back to the release API when the pinned assets are missing', async () => { + let queries = 0 + const requested: string[] = [] + const latest: ReleaseResponse[] = [{ + tag_name: 'windows-v0.9.19', + assets: [ + { name: 'CodeBurn.Menubar_0.9.19_x64_en-US.msi', browser_download_url: 'https://example.test/msi' }, + { name: 'CodeBurn.Menubar_0.9.19_x64_en-US.msi.sha256', browser_download_url: 'https://example.test/msi.sha256' }, + ], + }] + + const result = await installMenubarApp({ + platform: 'win32', + cliVersion: '0.9.20', + windows: hooks({ + queryRegistry: async () => (queries++ === 0 ? '' : INSTALLED_0_9_20), + apiFetch: async () => ({ ok: true, status: 200, headers: { get: () => null }, json: async () => latest }), + fetchOptions: { + sleep: async () => {}, + log: () => {}, + fetchImpl: async (url: string) => { + requested.push(url) + if (url.startsWith(MSI_URL)) return httpResponse(404) + return httpResponse(200, url.endsWith('.sha256') ? `${sha256(MSI_BYTES)} msi` : MSI_BYTES) + }, + }, + }), + }) + + expect(requested[0]).toBe(MSI_URL) + expect(requested).toContain('https://example.test/msi') + expect(installerCalls[0]?.args[1]).toBe(join(sandbox, 'CodeBurn.Menubar_0.9.19_x64_en-US.msi')) + expect(result.launched).toBe(true) + }) +}) diff --git a/tests/models-report.test.ts b/tests/models-report.test.ts index 33317fd8..8808ca58 100644 --- a/tests/models-report.test.ts +++ b/tests/models-report.test.ts @@ -1,6 +1,9 @@ +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' import { spawnSync } from 'node:child_process' -import { describe, it, expect } from 'vitest' +import { describe, it, expect, vi } from 'vitest' import chalk from 'chalk' import stripAnsi from 'strip-ansi' @@ -713,6 +716,66 @@ describe('renderCsv', () => { }) describe('models CLI breakdown flags', () => { + vi.setConfig({ testTimeout: 30_000 }) + + it('filters the models report to unpriced rows', async () => { + const home = await mkdtemp(join(tmpdir(), 'codeburn-models-unpriced-')) + try { + const projectDir = join(home, '.claude', 'projects', 'models-unpriced') + await mkdir(projectDir, { recursive: true }) + await writeFile(join(projectDir, 'session.jsonl'), [ + JSON.stringify({ + type: 'user', + sessionId: 'models-unpriced-session', + timestamp: '2026-05-09T00:00:00.000Z', + cwd: '/tmp/models-unpriced', + message: { role: 'user', content: 'Use one priced and one unpriced model.' }, + }), + JSON.stringify({ + type: 'assistant', + sessionId: 'models-unpriced-session', + timestamp: '2026-05-09T00:01:00.000Z', + cwd: '/tmp/models-unpriced', + message: { + id: 'priced', + type: 'message', + role: 'assistant', + model: 'claude-sonnet-4-6', + content: [{ type: 'text', text: 'priced' }], + usage: { input_tokens: 1000, output_tokens: 100, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 }, + }, + }), + JSON.stringify({ + type: 'assistant', + sessionId: 'models-unpriced-session', + timestamp: '2026-05-09T00:02:00.000Z', + cwd: '/tmp/models-unpriced', + message: { + id: 'unpriced', + type: 'message', + role: 'assistant', + model: 'zz-unpriced-frontier-model', + content: [{ type: 'text', text: 'unpriced' }], + usage: { input_tokens: 2000, output_tokens: 200, cache_read_input_tokens: 0, cache_creation_input_tokens: 0 }, + }, + }), + ].join('\n') + '\n') + + const res = spawnSync( + process.execPath, + ['--import', 'tsx', 'src/cli.ts', 'models', '--unpriced', '--from', '2026-05-09', '--to', '2026-05-09', '--provider', 'claude', '--format', 'json'], + { cwd: process.cwd(), env: { ...process.env, HOME: home, CLAUDE_CONFIG_DIR: join(home, '.claude'), CODEBURN_CACHE_DIR: join(home, '.cache', 'codeburn'), TZ: 'UTC' }, encoding: 'utf-8', timeout: 30_000 }, + ) + + expect(res.status, `stdout: ${res.stdout}\nstderr: ${res.stderr}`).toBe(0) + const rows = JSON.parse(res.stdout) as Array<{ model: string; calls: number }> + expect(rows.map(row => row.model)).toEqual(['zz-unpriced-frontier-model']) + expect(rows[0]?.calls).toBe(1) + } finally { + await rm(home, { recursive: true, force: true }) + } + }) + it('rejects --by-task and --by-agent together with a clear error and exit 1', () => { const res = spawnSync( process.execPath, diff --git a/tests/models.test.ts b/tests/models.test.ts index e8910e37..3e2b1655 100644 --- a/tests/models.test.ts +++ b/tests/models.test.ts @@ -505,7 +505,7 @@ describe('Cursor model variants resolve to pricing', () => { // Sonnet family ['claude-4-sonnet', 'claude-sonnet-4'], ['claude-4-sonnet-1m', 'claude-sonnet-4'], - ['claude-4-sonnet-thinking', 'claude-sonnet-4-5'], + ['claude-4-sonnet-thinking', 'claude-sonnet-4'], ['claude-4.5-sonnet', 'claude-sonnet-4-5'], ['claude-4.5-sonnet-thinking', 'claude-sonnet-4-5'], ['claude-4.6-sonnet', 'claude-sonnet-4-6'], @@ -558,6 +558,14 @@ describe('Cursor model variants resolve to pricing', () => { expect(costs!.outputCostPerToken).toBe(expected!.outputCostPerToken) }) } + + // Regression for #912: Cursor's unversioned `claude-4-sonnet-thinking` + // slug is the thinking variant of Sonnet 4, not Sonnet 4.5. The two models + // currently share a price, so the display name pins the canonical identity + // independently of today's pricing coincidence. + it('keeps claude-4-sonnet-thinking in the Sonnet 4 model family', () => { + expect(getShortModelName('claude-4-sonnet-thinking')).toBe('Sonnet 4') + }) }) describe('Cursor house model pricing', () => { diff --git a/tests/optimize-apply.test.ts b/tests/optimize-apply.test.ts index 2b582284..ab93a2fe 100644 --- a/tests/optimize-apply.test.ts +++ b/tests/optimize-apply.test.ts @@ -5,6 +5,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { createHash } from 'node:crypto' import { PassThrough, Writable } from 'node:stream' +import stripAnsi from 'strip-ansi' import { planFor, planFindings, type PlanContext } from '../src/act/plans.js' import { renderApplyList, runOptimizeApply, type ApplyOptions } from '../src/act/optimize-apply.js' @@ -12,6 +13,9 @@ import { runAction } from '../src/act/apply.js' import { undoAction } from '../src/act/undo.js' import { readRecords, shortId } from '../src/act/journal.js' import { + FINDING_BASIS, + FINDING_CLASS, + findingClass, detectBloatedClaudeMd, detectDuplicateReads, detectJunkReads, @@ -102,6 +106,169 @@ describe('mcp-remove plan', () => { await undoAction({ id: rec.id }, { actionsDir: fx.actionsDir }) expect(await readFile(claudeJson, 'utf-8')).toBe(original) }) + + it('does not plan connector removal and removes only the local server from a mixed finding', async () => { + const coverage = (server: string): McpServerCoverage => ({ + server, + toolsAvailable: 20, + toolsInvoked: 0, + unusedTools: Array.from({ length: 20 }, (_, i) => `mcp__${server}__t${i}`), + invocations: 0, + loadedSessions: 2, + coverageRatio: 0, + }) + const connector = coverage('claude_ai_Netlify') + + const connectorOnly = detectMcpToolCoverage([], [connector])! + expect(connectorOnly.apply).toBeUndefined() + expect(planFor(connectorOnly)).toBeNull() + + const fx = await makeFixture() + const claudeJson = join(fx.home, '.claude.json') + await writeFile(claudeJson, JSON.stringify({ + mcpServers: { + filesystem: { command: 'filesystem' }, + netlify: { command: 'local-netlify' }, + }, + }, null, 2) + '\n') + + const mixed = detectMcpToolCoverage([], [connector, coverage('filesystem')])! + expect(mixed.apply).toEqual({ kind: 'mcp-remove', servers: ['filesystem'] }) + const plan = planFor(mixed, { homeDir: fx.home, cwd: fx.project }) + expect(plan).not.toBeNull() + + await runAction(plan!, fx.actionsDir) + expect(JSON.parse(await readFile(claudeJson, 'utf-8')).mcpServers).toEqual({ + netlify: { command: 'local-netlify' }, + }) + }) + + it('previews only the savings attributable to a mixed finding local mutation', async () => { + const fx = await makeFixture() + await writeFile(join(fx.home, '.claude.json'), JSON.stringify({ + mcpServers: { filesystem: { command: 'filesystem' } }, + }, null, 2) + '\n') + const finding: WasteFinding = { + id: 'mcp-low-coverage', + title: '2 MCP servers with low tool coverage', + explanation: '', + impact: 'medium', + tokensSaved: 80_000, + applyTokensSaved: 20_000, + fix: { type: 'command', label: '', text: "claude mcp remove 'filesystem'" }, + apply: { kind: 'mcp-remove', servers: ['filesystem'] }, + } + const plans = planFindings([finding], { homeDir: fx.home, cwd: fx.project }) + + const preview = stripAnsi(renderApplyList(plans, [], 0.000002)) + + expect(preview).toContain('(~20.0K tokens, ~$0.040)') + expect(preview).not.toContain('~80.0K tokens') + expect(preview).not.toContain('~$0.160') + }) + + it('scopes targets and savings to local servers actually present in editable config', async () => { + const fx = await makeFixture() + await writeFile(join(fx.home, '.claude.json'), JSON.stringify({ + mcpServers: { filesystem: { command: 'filesystem' } }, + }, null, 2) + '\n') + const finding: WasteFinding = { + id: 'mcp-low-coverage', + title: '3 MCP servers with low tool coverage', + explanation: '', + impact: 'medium', + tokensSaved: 60_000, + applyTokensSaved: 30_000, + applyTokensSavedByServer: { filesystem: 10_000, managed: 20_000 }, + fix: { type: 'command', label: '', text: "claude mcp remove 'filesystem'\nclaude mcp remove 'managed'" }, + apply: { kind: 'mcp-remove', servers: ['filesystem', 'managed'] }, + } + + const plans = planFindings([finding], { homeDir: fx.home, cwd: fx.project }) + const preview = stripAnsi(renderApplyList(plans.filter(p => p.plan), plans.filter(p => !p.plan), 0)) + + expect(plans[0]!.plan?.affectedMcpServers).toEqual(['filesystem']) + expect(preview).toContain('Removes local MCP server: filesystem') + expect(preview).toContain('~10.0K tokens') + expect(preview).not.toContain('~30.0K tokens') + expect(preview).toContain('skipped managed: not found in editable config') + }) + + it('suppresses savings for a legacy partial plan without per-server attribution', async () => { + const fx = await makeFixture() + await writeFile(join(fx.home, '.claude.json'), JSON.stringify({ + mcpServers: { filesystem: { command: 'filesystem' } }, + }, null, 2) + '\n') + const finding: WasteFinding = { + id: 'mcp-low-coverage', + title: '2 MCP servers with low tool coverage', + explanation: '', + impact: 'medium', + tokensSaved: 30_000, + applyTokensSaved: 30_000, + fix: { type: 'command', label: '', text: '' }, + apply: { kind: 'mcp-remove', servers: ['filesystem', 'managed'] }, + } + + const plans = planFindings([finding], { homeDir: fx.home, cwd: fx.project }) + const preview = stripAnsi(renderApplyList(plans.filter(p => p.plan), plans.filter(p => !p.plan), 0)) + + expect(plans[0]!.plan?.affectedMcpServers).toEqual(['filesystem']) + expect(plans[0]!.plan?.mcpSavingsUncertain).toBe(true) + expect(preview).toContain('Savings not estimated') + expect(preview).not.toContain('~30.0K tokens') + }) + + it('deduplicates repeated removal targets before planning and pricing them', async () => { + const fx = await makeFixture() + await writeFile(join(fx.home, '.claude.json'), JSON.stringify({ + mcpServers: { filesystem: { command: 'filesystem' } }, + }, null, 2) + '\n') + const finding: WasteFinding = { + id: 'mcp-low-coverage', + title: '1 MCP server with low tool coverage', + explanation: '', + impact: 'medium', + tokensSaved: 10_000, + applyTokensSavedByServer: { filesystem: 10_000 }, + fix: { type: 'command', label: '', text: '' }, + apply: { kind: 'mcp-remove', servers: ['filesystem', 'filesystem'] }, + } + + const plans = planFindings([finding], { homeDir: fx.home, cwd: fx.project }) + const preview = stripAnsi(renderApplyList(plans.filter(p => p.plan), [], 0)) + + expect(plans[0]!.plan?.affectedMcpServers).toEqual(['filesystem']) + expect(preview).toContain('~10.0K tokens') + expect(preview).not.toContain('~20.0K tokens') + }) + + it('does not claim savings when another relevant config scope is unreadable', async () => { + const fx = await makeFixture() + await writeFile(join(fx.home, '.claude.json'), JSON.stringify({ + mcpServers: { filesystem: { command: 'filesystem' } }, + }, null, 2) + '\n') + await writeFile(join(fx.project, '.mcp.json'), 'not json{{{') + const finding: WasteFinding = { + id: 'mcp-low-coverage', + title: '1 MCP server with low tool coverage', + explanation: '', + impact: 'medium', + tokensSaved: 10_000, + applyTokensSavedByServer: { filesystem: 10_000 }, + fix: { type: 'command', label: '', text: "claude mcp remove 'filesystem'" }, + apply: { kind: 'mcp-remove', servers: ['filesystem'] }, + } + + const plans = planFindings([finding], { homeDir: fx.home, cwd: fx.project }) + const preview = stripAnsi(renderApplyList(plans.filter(p => p.plan), plans.filter(p => !p.plan), 0)) + + expect(plans[0]!.plan?.affectedMcpServers).toEqual(['filesystem']) + expect(plans[0]!.plan?.mcpSavingsUncertain).toBe(true) + expect(preview).toContain('Savings not estimated') + expect(preview).toContain('could not parse') + expect(preview).not.toContain('~10.0K tokens') + }) }) describe('mcp-project-scope plan', () => { @@ -418,6 +585,80 @@ async function threeFindingFixture(): Promise<{ fx: Fixture; findings: WasteFind } describe('runOptimizeApply end-to-end', () => { + it('prints connector-only manual guidance when there is nothing to apply', async () => { + const fx = await makeFixture() + const connector: McpServerCoverage = { + server: 'claude_ai_Netlify', + toolsAvailable: 20, + toolsInvoked: 0, + unusedTools: Array.from({ length: 20 }, (_, i) => `mcp__claude_ai_Netlify__t${i}`), + invocations: 0, + loadedSessions: 2, + coverageRatio: 0, + } + const finding = detectMcpToolCoverage([], [connector])! + const io = makeIo() + + await runOptimizeApply([], undefined, applyOpts(fx, io, { findings: [finding], yes: true })) + + expect(io.stdout()).toContain('No appliable config-class fixes') + expect(io.stdout()).toContain('/mcp') + expect(io.stdout()).toContain('claude.ai Netlify') + expect(io.stdout()).not.toContain('claude mcp remove') + }) + + it('names the exact local removal target and preserves connector follow-up in a mixed preview', async () => { + const fx = await makeFixture() + await writeFile(join(fx.home, '.claude.json'), JSON.stringify({ + mcpServers: { filesystem: { command: 'filesystem' }, netlify: { command: 'local-netlify' } }, + }, null, 2) + '\n') + const coverage = (server: string): McpServerCoverage => ({ + server, + toolsAvailable: 20, + toolsInvoked: 0, + unusedTools: Array.from({ length: 20 }, (_, i) => `mcp__${server}__t${i}`), + invocations: 0, + loadedSessions: 2, + coverageRatio: 0, + }) + const finding = detectMcpToolCoverage([], [coverage('filesystem'), coverage('claude_ai_Netlify')])! + const io = makeIo() + + await runOptimizeApply([], undefined, applyOpts(fx, io, { findings: [finding], dryRun: true })) + + expect(io.stdout()).toContain('Removes local MCP server: filesystem') + expect(io.stdout()).toContain('/mcp') + expect(io.stdout()).toContain('claude.ai Netlify') + expect(io.stdout()).not.toContain("claude mcp remove 'claude_ai_Netlify'") + }) + + it('keeps mixed connector follow-up explicitly pending after applying the local fix', async () => { + const fx = await makeFixture() + await writeFile(join(fx.home, '.claude.json'), JSON.stringify({ + mcpServers: { filesystem: { command: 'filesystem' } }, + }, null, 2) + '\n') + const coverage = (server: string): McpServerCoverage => ({ + server, + toolsAvailable: 20, + toolsInvoked: 0, + unusedTools: Array.from({ length: 20 }, (_, i) => `mcp__${server}__t${i}`), + invocations: 0, + loadedSessions: 2, + coverageRatio: 0, + }) + const finding = detectMcpToolCoverage([], [coverage('filesystem'), coverage('claude_ai_Netlify')])! + const io = makeIo() + + await runOptimizeApply([], undefined, applyOpts(fx, io, { findings: [finding], yes: true })) + + const out = io.stdout() + expect(out).toContain('Manual follow-up (not applied):') + expect(out).toContain('Still requires manual action:') + expect(out).toContain('claude.ai Netlify') + expect(await readRecords(fx.actionsDir)).toHaveLength(1) + expect(JSON.parse(await readFile(join(fx.home, '.claude.json'), 'utf-8')).mcpServers).toEqual({}) + }) + it('--yes applies every plan and prints journal short ids with the undo hint', async () => { const { fx, findings } = await threeFindingFixture() const io = makeIo() @@ -430,11 +671,19 @@ describe('runOptimizeApply end-to-end', () => { expect(out).toContain(`Applied ${shortId(rec.id)}`) expect(out).toContain(`Undo anytime: codeburn act undo ${shortId(rec.id)}`) } + expect(out).toContain('CodeBurn will re-measure these on your next optimize run after 3 days.') expect(JSON.parse(await readFile(join(fx.home, '.claude.json'), 'utf-8')).mcpServers).toEqual({}) expect(existsSync(join(fx.home, '.claude', 'skills', '.archived', 'foo'))).toBe(true) expect(existsSync(join(fx.home, '.zshrc'))).toBe(true) }) + it('does not promise a re-measure when nothing was applied', async () => { + const { fx, findings } = await threeFindingFixture() + const io = makeIo('q\n') + await runOptimizeApply([], undefined, applyOpts(fx, io, { findings })) + expect(io.stdout()).not.toContain('re-measure') + }) + it('interactive pick "2" applies only the second plan', async () => { const { fx, findings } = await threeFindingFixture() const io = makeIo('2\n') @@ -653,3 +902,79 @@ describe('stale-plan detection', () => { expect(await readFile(p, 'utf-8')).toBe('overwritten') }) }) + +describe('finding class', () => { + it('covers every finding id with a class and a basis', () => { + expect(Object.keys(FINDING_BASIS).sort()).toEqual(Object.keys(FINDING_CLASS).sort()) + }) + + it("classes a finding 'fix' exactly when a plan can be built for it", async () => { + const fx = await makeFixture() + const claudeJson = join(fx.home, '.claude.json') + await writeFile(claudeJson, JSON.stringify({ mcpServers: { srv: { command: 's' } } }, null, 2) + '\n') + const settings = join(fx.project, '.claude', 'settings.json') + await mkdir(join(fx.project, '.claude'), { recursive: true }) + await writeFile(settings, JSON.stringify({ env: { ENABLE_TOOL_SEARCH: 'auto' } }, null, 2) + '\n') + const mcpJson = join(fx.project, '.mcp.json') + await writeFile(mcpJson, JSON.stringify({ mcpServers: { pinned: { command: 'x', alwaysLoad: true } } }, null, 2) + '\n') + await mkdir(join(fx.home, '.claude', 'skills', 'ghost'), { recursive: true }) + await mkdir(join(fx.home, '.claude', 'agents'), { recursive: true }) + await mkdir(join(fx.home, '.claude', 'commands'), { recursive: true }) + await writeFile(join(fx.home, '.claude', 'agents', 'ghost.md'), 'x') + await writeFile(join(fx.home, '.claude', 'commands', 'ghost.md'), 'x') + + const CLAUDE_MD_FIX: WasteAction = { type: 'paste', destination: 'claude-md', label: '', text: 'rule' } + const SHELL_FIX: WasteAction = { type: 'paste', destination: 'shell-config', label: '', text: 'export X=1' } + const PROMPT_FIX: WasteAction = { type: 'paste', destination: 'prompt', label: '', text: 'ask' } + const OPENER_FIX: WasteAction = { type: 'paste', destination: 'session-opener', label: '', text: 'o' } + + // One representative finding per id, carrying the payload its plan + // builder needs. Ids without a builder get a plain prompt fix. + const representatives: Record = { + 'read-edit-ratio': makeFinding('read-edit-ratio', CLAUDE_MD_FIX), + 'build-folder-reads': makeFinding('build-folder-reads', CLAUDE_MD_FIX), + 'redundant-rereads': makeFinding('redundant-rereads', PROMPT_FIX), + 'warmup-heavy': makeFinding('warmup-heavy', SHELL_FIX), + 'unused-mcp': makeFinding('unused-mcp', CMD_FIX, { kind: 'mcp-remove', servers: ['srv'] }), + 'mcp-low-coverage': makeFinding('mcp-low-coverage', CMD_FIX, { kind: 'mcp-remove', servers: ['srv'] }), + 'mcp-project-scope': makeFinding('mcp-project-scope', PROMPT_FIX, { + kind: 'mcp-project-scope', + servers: [{ server: 'srv', keepProjects: [fx.project], removeProjects: [] }], + }), + 'mcp-deferral-off': makeFinding('mcp-deferral-off', CMD_FIX, { + kind: 'defer-enable', cause: 'env-false', settingPath: settings, settingScope: 'project settings', value: 'false', + }), + 'mcp-alwaysload-hygiene': makeFinding('mcp-alwaysload-hygiene', PROMPT_FIX, { + kind: 'defer-alwaysload', + servers: [{ server: 'pinned', paths: [mcpJson] }], + }), + 'mcp-defer-threshold': makeFinding('mcp-defer-threshold', PROMPT_FIX, { + kind: 'defer-threshold', settingPath: settings, settingScope: 'project settings', + value: 'auto', recommendedPercent: 2, removeOverride: false, + }), + 'retry-heavy-capabilities': makeFinding('retry-heavy-capabilities', PROMPT_FIX), + 'low-worth-sessions': makeFinding('low-worth-sessions', OPENER_FIX), + 'context-heavy-sessions': makeFinding('context-heavy-sessions', OPENER_FIX), + 'cost-outliers': makeFinding('cost-outliers', OPENER_FIX), + 'claude-md-too-long': makeFinding('claude-md-too-long', PROMPT_FIX), + 'bash-output-cap': makeFinding('bash-output-cap', SHELL_FIX), + 'unused-agents': makeFinding('unused-agents', CMD_FIX, { kind: 'archive', names: ['ghost'] }), + 'unused-skills': makeFinding('unused-skills', CMD_FIX, { kind: 'archive', names: ['ghost'] }), + 'unused-commands': makeFinding('unused-commands', CMD_FIX, { kind: 'archive', names: ['ghost'] }), + 'recurring-context': makeFinding('recurring-context', PROMPT_FIX), + } + + const planCtx: PlanContext = { homeDir: fx.home, cwd: fx.project, shell: '/bin/zsh', claudeVersion: () => '2.1.130' } + for (const finding of Object.values(representatives)) { + const hasPlan = planFor(finding, planCtx) !== null + expect([finding.id, hasPlan]).toEqual([finding.id, findingClass(finding) === 'fix']) + } + }) + + it("drops to 'nudge' when the instance lacks the payload its plan needs", () => { + const finding = makeFinding('mcp-deferral-off', { type: 'paste', destination: 'shell-config', label: '', text: 'x' }) + expect(FINDING_CLASS['mcp-deferral-off']).toBe('fix') + expect(findingClass(finding)).toBe('nudge') + expect(planFor(finding)).toBeNull() + }) +}) diff --git a/tests/optimize-fs.test.ts b/tests/optimize-fs.test.ts index 2364e08a..ff72a9b5 100644 --- a/tests/optimize-fs.test.ts +++ b/tests/optimize-fs.test.ts @@ -1,4 +1,5 @@ -import { describe, it, expect, afterAll, beforeEach, vi } from 'vitest' +import { describe, it, expect, afterAll, afterEach, beforeEach, vi } from 'vitest' +import { Writable } from 'node:stream' import { mkdtempSync, rmSync, mkdirSync, writeFileSync, utimesSync } from 'fs' import { tmpdir } from 'os' import { join } from 'path' @@ -21,14 +22,20 @@ import { detectBashBloat, detectGhostCommands, loadMcpConfigs, + localMcpServerNames, scanJsonlFile, scanAndDetect, + detectRecurringContext, + renderOptimize, + type SessionOpener, type ToolCall, } from '../src/optimize.js' import { estimateContextBudget, discoverProjectCwd, } from '../src/context-budget.js' +import type { ProjectSummary } from '../src/types.js' +import { runOptimizeApply } from '../src/act/optimize-apply.js' // ============================================================================ // Helpers for filesystem fixtures @@ -170,6 +177,32 @@ describe('loadMcpConfigs', () => { }) }) +describe('localMcpServerNames', () => { + it('adds the ~/.claude.json top-level and per-project servers to the config names', () => { + const root = makeFixtureRoot() + const projectDir = join(root, 'myapp') + mkdirSync(projectDir, { recursive: true }) + writeFile(join(projectDir, '.mcp.json'), JSON.stringify({ mcpServers: { fromMcpJson: {} } })) + writeFile(join(FAKE_HOME_FOR_MOCK, '.claude.json'), JSON.stringify({ + mcpServers: { 'claude_ai_homegrown': {}, 'plugin:ctx:ctx': {} }, + projects: { [projectDir]: { mcpServers: { scoped: {} } } }, + })) + + const names = localMcpServerNames([projectDir]) + + expect([...names].sort()).toEqual(['claude_ai_homegrown', 'fromMcpJson', 'plugin_ctx_ctx', 'scoped']) + }) + + it('contributes no names when ~/.claude.json cannot be parsed', () => { + const root = makeFixtureRoot() + const projectDir = join(root, 'myapp') + mkdirSync(projectDir, { recursive: true }) + writeFile(join(FAKE_HOME_FOR_MOCK, '.claude.json'), '{ not valid json') + + expect(localMcpServerNames([projectDir]).size).toBe(0) + }) +}) + describe('detectUnusedMcp', () => { it('flags servers configured but never called', () => { const root = makeFixtureRoot() @@ -359,6 +392,136 @@ describe('scanJsonlFile', () => { }) }) +// ============================================================================ +// detectRecurringContext +// ============================================================================ + +describe('detectRecurringContext', () => { + // ~2.2 KB, comfortably over the 1.5 KB floor. + const BRIEF = 'PROJECT BRIEF\n' + 'Ship the invoice importer behind a flag. '.repeat(53) + const TOKENS_PER_CHAR = 0.25 + + type Opener = { text: string; project?: string } + + async function openersFor(sessions: Opener[]): Promise { + const root = makeFixtureRoot() + const now = new Date().toISOString() + const openers: SessionOpener[] = [] + for (const [i, { text, project }] of sessions.entries()) { + const filePath = join(root, `s${i}.jsonl`) + writeFile(filePath, JSON.stringify({ type: 'user', timestamp: now, message: { content: text } })) + const result = await scanJsonlFile(filePath, project ?? 'my-app', undefined) + openers.push(...result.openers) + } + return openers + } + + const repeat = (n: number, text = BRIEF, project?: string): Opener[] => + Array.from({ length: n }, () => ({ text, project })) + + it('flags a block that opens the session threshold worth of sessions', async () => { + const finding = detectRecurringContext(await openersFor(repeat(5))) + expect(finding).not.toBeNull() + expect(finding!.id).toBe('recurring-context') + expect(finding!.title).toBe(`Same ${(BRIEF.length / 1024).toFixed(1)} KB block pasted at the start of 5 sessions`) + // Only the four repeats are recoverable; the first paste is the honest cost. + expect(finding!.tokensSaved).toBe(Math.round(4 * BRIEF.length * TOKENS_PER_CHAR)) + expect(finding!.fix.type).toBe('paste') + expect(finding!.fix.type === 'paste' && finding!.fix.destination).toBe('prompt') + }) + + it('renders the finding with its preview and destination header', async () => { + const finding = detectRecurringContext(await openersFor(repeat(5)))! + const out = renderOptimize([finding], 0.00001, '30 Days', 10, 5, 100, 80, 'B', [], []) + .replace(/\u001b\[[0-9;]*m/g, '') + expect(out).toContain(finding.title) + expect(out).toContain('PROJECT BRIEF Ship the invoice importer') + expect(out).toContain('Ask Claude in the current session') + expect(out).toContain('Move it into CLAUDE.md if it is a standing rule') + }) + + it('stays quiet below the session threshold', async () => { + expect(detectRecurringContext(await openersFor(repeat(4)))).toBeNull() + }) + + it('ignores short openers however often they repeat', async () => { + expect(detectRecurringContext(await openersFor(repeat(20, 'continue')))).toBeNull() + expect(detectRecurringContext(await openersFor(repeat(20, 'yes')))).toBeNull() + }) + + it('groups sessions whose block differs only in whitespace', async () => { + const reflowed = BRIEF.replace(/ /g, ' ').replace(/\n/g, '\n\n') + const finding = detectRecurringContext(await openersFor([...repeat(3), ...repeat(2, reflowed)])) + expect(finding).not.toBeNull() + expect(finding!.title).toContain('start of 5 sessions') + }) + + it('ignores injected system reminders and slash-command wrappers', async () => { + expect(detectRecurringContext(await openersFor(repeat(6, `${BRIEF}`)))).toBeNull() + expect(detectRecurringContext(await openersFor(repeat(6, `/brief${BRIEF}`)))).toBeNull() + }) + + it('skips prompts a program wrote: SDK sessions and subagent transcripts', async () => { + const root = makeFixtureRoot() + const now = new Date().toISOString() + const openers: SessionOpener[] = [] + for (const [i, entry] of [{ promptSource: 'sdk' }, { isSidechain: true }].entries()) { + for (let j = 0; j < 6; j++) { + const filePath = join(root, `machine-${i}-${j}.jsonl`) + writeFile(filePath, JSON.stringify({ type: 'user', timestamp: now, ...entry, message: { content: BRIEF } })) + openers.push(...(await scanJsonlFile(filePath, 'my-app', undefined)).openers) + } + } + expect(openers).toEqual([]) + expect(detectRecurringContext(openers)).toBeNull() + }) + + // Over 32 KB the JSONL parser returns a reduced entry without the root + // flags, so the markers have to be read off the raw line. + it('skips machine-written prompts too large for the parser to keep flags on', async () => { + const root = makeFixtureRoot() + const now = new Date().toISOString() + const huge = BRIEF + 'x'.repeat(40_000) + const openers: SessionOpener[] = [] + for (let i = 0; i < 6; i++) { + const filePath = join(root, `huge-${i}.jsonl`) + // Field order matters: the flags land past the head, behind the very + // message that made the line large. + writeFile(filePath, JSON.stringify({ + isSidechain: false, type: 'user', message: { content: huge }, timestamp: now, promptSource: 'sdk', + })) + openers.push(...(await scanJsonlFile(filePath, 'my-app', undefined)).openers) + } + expect(openers).toEqual([]) + }) + + it('only counts the first message of a session as its opener', async () => { + const root = makeFixtureRoot() + const now = new Date().toISOString() + const openers: SessionOpener[] = [] + for (let i = 0; i < 6; i++) { + const filePath = join(root, `late-${i}.jsonl`) + writeFile(filePath, [ + JSON.stringify({ type: 'user', timestamp: now, message: { content: 'go on' } }), + JSON.stringify({ type: 'user', timestamp: now, message: { content: BRIEF } }), + ].join('\n')) + openers.push(...(await scanJsonlFile(filePath, 'my-app', undefined)).openers) + } + expect(detectRecurringContext(openers)).toBeNull() + }) + + it('names the project when the block is confined to one, and counts them otherwise', async () => { + const single = detectRecurringContext(await openersFor(repeat(5, BRIEF, '-Users-me-Projects-codeburn'))) + expect(single!.explanation).toContain('5 sessions in codeburn') + + const spread = detectRecurringContext(await openersFor([ + ...repeat(3, BRIEF, '-Users-me-Projects-codeburn'), + ...repeat(2, BRIEF, '-Users-me-Projects-dash'), + ])) + expect(spread!.explanation).toContain('5 sessions in 2 projects') + }) +}) + // ============================================================================ // scanAndDetect (top-level integration) // ============================================================================ @@ -371,6 +534,94 @@ describe('scanAndDetect', () => { expect(result.healthGrade).toBe('A') expect(result.costRate).toBe(0) }) + + // The session scan only ever reads Claude Code transcripts, so under a + // non-Claude --provider it used to report Claude-derived findings beside a + // header scoped to the other provider - e.g. `optimize --provider codex` + // printing a read/edit ratio counted from Claude sessions. + describe('provider scoping', () => { + // These fixtures live in the shared fake home, so they have to come back + // out: later suites in this file assert on an otherwise empty ~/.claude. + const CLAUDE_DIR = join(FAKE_HOME_FOR_MOCK, '.claude') + afterEach(() => { + for (const sub of ['projects', 'skills']) { + rmSync(join(CLAUDE_DIR, sub), { recursive: true, force: true }) + } + }) + + function claudeSessionWithEditHeavyTurns(): void { + const projectDir = join(CLAUDE_DIR, 'projects', 'provider-scope') + mkdirSync(projectDir, { recursive: true }) + const now = new Date().toISOString() + const entry = (name: string, file: string) => JSON.stringify({ + type: 'assistant', timestamp: now, + message: { content: [{ type: 'tool_use', name, input: { file_path: file } }] }, + }) + const lines = [entry('Read', '/src/a.ts')] + for (let i = 0; i < 12; i++) lines.push(entry('Edit', `/src/f${i}.ts`)) + writeFileSync(join(projectDir, 'session.jsonl'), lines.join('\n')) + } + + // scanAndDetect memoises on (provider, range, project fingerprint) for 60s, + // and the cache is module-level, so tests that differ only in what is on + // disk would serve each other's results. `seed` moves the fingerprint so + // each case scans for real. + function projectFixture(seed: number): ProjectSummary { + return { + project: 'provider-scope', + projectPath: '/tmp/provider-scope', + sessions: [], + totalCostUSD: 1, + totalApiCalls: 13 + seed, + } as unknown as ProjectSummary + } + + it('reports transcript-derived findings when scoped to claude', async () => { + claudeSessionWithEditHeavyTurns() + const result = await scanAndDetect([projectFixture(1)], undefined, 'claude') + expect(result.findings.map(f => f.id)).toContain('read-edit-ratio') + }) + + it('omits transcript-derived findings when scoped to another provider', async () => { + claudeSessionWithEditHeavyTurns() + mkdirSync(join(CLAUDE_DIR, 'skills', 'never-invoked'), { recursive: true }) + writeFileSync(join(CLAUDE_DIR, 'skills', 'never-invoked', 'SKILL.md'), '# skill\n') + + const result = await scanAndDetect([projectFixture(2)], undefined, 'codex') + const ids = result.findings.map(f => f.id) + + expect(ids).not.toContain('read-edit-ratio') + // An unmeasured skill must not be reported as an unused one: the scan + // returns nothing under this filter, which is not evidence of disuse. + expect(ids).not.toContain('unused-skills') + }) + + // The apply path reaches scanAndDetect through its own entry point, so it + // needs its own guard: `unused-skills` is appliable, and its plan moves + // directories out of ~/.claude/skills. Reporting a Codex-labelled finding + // is a wrong number; offering to archive every skill off one is a wrong + // number with side effects. + async function applyDryRun(provider: string): Promise { + const chunks: string[] = [] + const output = new Writable({ write(c, _e, cb) { chunks.push(String(c)); cb() } }) + const errorOutput = new Writable({ write(_c, _e, cb) { cb() } }) + await runOptimizeApply([projectFixture(3)], undefined, { provider, dryRun: true, output, errorOutput }) + return chunks.join('') + } + + it('plans no applies from Claude findings when scoped to another provider', async () => { + claudeSessionWithEditHeavyTurns() + mkdirSync(join(CLAUDE_DIR, 'skills', 'never-invoked'), { recursive: true }) + writeFileSync(join(CLAUDE_DIR, 'skills', 'never-invoked', 'SKILL.md'), '# skill\n') + + const codex = await applyDryRun('codex') + expect(codex).toContain('No appliable config-class fixes') + expect(codex).not.toContain('never-invoked') + + const claude = await applyDryRun('claude') + expect(claude).toContain('never-invoked') + }) + }) }) // ============================================================================ diff --git a/tests/optimize.test.ts b/tests/optimize.test.ts index bc72dd88..833bcace 100644 --- a/tests/optimize.test.ts +++ b/tests/optimize.test.ts @@ -26,12 +26,16 @@ import { computeHealth, computeTrend, buildOptimizeJsonReport, + renderOptimize, + findingBasis, + type FindingId, type ToolCall, type ApiCallMeta, type WasteFinding, type OptimizeResult, } from '../src/optimize.js' import type { ProjectSummary } from '../src/types.js' +import type { AppliedFix } from '../src/act/types.js' function call(name: string, input: Record, sessionId = 's1', project = 'p1'): ToolCall { return { name, input, sessionId, project } @@ -1004,6 +1008,29 @@ describe('detectSessionOutliers', () => { expect(finding!.tokensSaved).toBeGreaterThan(0) }) + it('keeps estimated-cost sessions out of the peer math', () => { + const project = projectWithSessions([1, 1, 1, 10]) + // The expensive session is priced from modelled tokens, so it is not + // comparable against the provider-reported peers and never gets flagged. + project.sessions[3]!.totalEstimatedCostUSD = project.sessions[3]!.totalCostUSD + expect(detectSessionOutliers([project])).toBeNull() + }) + + it('falls back to estimated costs when nothing else is priced, and says so', () => { + const project = projectWithSessions([1, 1, 1, 10]) + for (const s of project.sessions) s.totalEstimatedCostUSD = s.totalCostUSD + const finding = detectSessionOutliers([project]) + expect(finding).not.toBeNull() + expect(finding!.basis).toBe('estimated') + expect(findingBasis(finding!)).toBe('estimated') + }) + + it('reports measured basis when every peer cost is provider-reported', () => { + const finding = detectSessionOutliers([projectWithSessions([1, 1, 1, 10])]) + expect(finding!.basis).toBeUndefined() + expect(findingBasis(finding!)).toBe('measured') + }) + it('ignores tiny absolute-cost outliers', () => { expect(detectSessionOutliers([projectWithSessions([0.01, 0.01, 0.01, 0.2])])).toBeNull() }) @@ -1197,9 +1224,9 @@ describe('paste-fix destination tagging (issue #277)', () => { if (f.fix.type === 'paste') { expect( f.fix.destination, - `finding "${f.title}" has paste fix without destination — pick one of: claude-md / session-opener / prompt / shell-config` + `finding "${f.title}" has paste fix without destination — pick one of: claude-md / session-opener / prompt / shell-config / manual` ).toBeDefined() - expect(['claude-md', 'session-opener', 'prompt', 'shell-config']) + expect(['claude-md', 'session-opener', 'prompt', 'shell-config', 'manual']) .toContain(f.fix.destination) } } @@ -1240,6 +1267,7 @@ describe('buildOptimizeJsonReport', () => { healthGrade: 'C', findings: [ { + id: 'claude-md-too-long', title: 'Trim stale context', explanation: 'Old instructions are loaded every turn.', impact: 'medium', @@ -1283,12 +1311,25 @@ describe('buildOptimizeJsonReport', () => { potentialSavingsPercent: 20, costRateUSD: 0.00002, }) + expect(report.summary.measuredSavingsUSD).toBe(0) + expect(report.summary.byClass).toEqual({ + fix: { tokensSaved: 0, savingsUSD: 0, count: 0 }, + nudge: { tokensSaved: 50_000, savingsUSD: 1, count: 1 }, + keep: { tokensSaved: 0, savingsUSD: 0, count: 0 }, + }) + const classes = Object.values(report.summary.byClass) + expect(classes.reduce((s, c) => s + c.tokensSaved, 0)).toBe(report.summary.potentialSavingsTokens) + expect(classes.reduce((s, c) => s + c.savingsUSD, 0)).toBeCloseTo(report.summary.potentialSavingsCostUSD, 10) + expect(classes.reduce((s, c) => s + c.count, 0)).toBe(report.summary.findingCount) + expect(report.appliedFixes).toEqual([]) expect(report.findings[0]).toMatchObject({ title: 'Trim stale context', severity: 'medium', trend: 'active', tokensSaved: 50_000, estimatedSavingsUSD: 1, + class: 'nudge', + basis: 'estimated', fix: { type: 'paste', destination: 'claude-md', @@ -1296,3 +1337,95 @@ describe('buildOptimizeJsonReport', () => { }) }) }) + +describe('renderOptimize grouping', () => { + const plain = (s: string): string => s.replace(/\[[0-9;]*m/g, '') + + function finding(id: FindingId, title: string): WasteFinding { + return { + id, + title, + explanation: 'why', + impact: 'medium', + tokensSaved: 1000, + fix: { type: 'paste', destination: 'prompt', label: 'ask', text: 'ask' }, + } + } + + it('groups findings under fix / habits / FYI with continuous numbering and a basis split', () => { + const findings = [ + finding('bash-output-cap', 'Cap bash output'), + finding('claude-md-too-long', 'Trim CLAUDE.md'), + finding('context-heavy-sessions', 'Context-heavy sessions'), + ] + const out = plain(renderOptimize(findings, 0.00001, '7 Days', 10, 5, 100, 80, 'B', [], [])) + + const headers = [ + 'Fix now (apply-able) · ~1.0K tokens (~$0.010) · 1 finding — codeburn optimize --apply', + 'Habits · ~1.0K tokens (~$0.010) · 1 finding', + 'FYI · ~1.0K tokens (~$0.010) · 1 finding', + ].map(h => out.indexOf(h)) + expect(headers.every(i => i >= 0)).toBe(true) + expect(headers).toEqual([...headers].sort((a, b) => a - b)) + // Headline is the whole board; the apply-able slice is named separately. + expect(out).toContain('Potential savings: ~3.0K tokens (~$0.030, ~0.3% of spend) — apply-able: ~$0.010') + expect(out).toContain('1. Cap bash output') + expect(out).toContain('2. Trim CLAUDE.md') + expect(out).toContain('3. Context-heavy sessions') + expect(out).toContain('1 measured · 2 estimated') + expect(out).not.toContain('Estimates only.') + }) +}) + +describe('renderOptimize applied-fixes section', () => { + const plain = (s: string): string => s.replace(/\[[0-9;]*m/g, '') + + function fixture(over: Partial): AppliedFix { + return { + id: 'abcdef12', + kind: 'mcp-remove', + findingId: 'unused-mcp', + appliedAt: '2026-05-01T00:00:00.000Z', + ageDays: 4, + verdict: 'worked', + estimatedTokens: 300_000, + realizedTokens: 280_000, + note: '', + undoCommand: 'codeburn act undo abcdef12', + ...over, + } + } + + const findings: WasteFinding[] = [{ + id: 'bash-output-cap', + title: 'Cap bash output', + explanation: 'why', + impact: 'medium', + tokensSaved: 1000, + fix: { type: 'paste', destination: 'prompt', label: 'ask', text: 'ask' }, + }] + + const render = (appliedFixes: AppliedFix[], f = findings): string => + plain(renderOptimize(f, 0.00001, '7 Days', 10, 5, 100, 80, 'B', [], [], undefined, undefined, undefined, appliedFixes)) + + it('renders one line per verdict with its own glyph', () => { + const out = render([ + fixture({}), + fixture({ id: 'b', findingId: 'mcp-defer-threshold', verdict: 'partial', ageDays: 3, estimatedTokens: 600_000, realizedTokens: 420_000 }), + fixture({ id: 'c', findingId: 'bash-output-cap', verdict: 'no-effect', ageDays: 5, estimatedTokens: 41_000, realizedTokens: 0, undoCommand: 'codeburn act undo cccccccc' }), + fixture({ id: 'd', findingId: 'mcp-remove-linear', verdict: 'pending', ageDays: 1 }), + ]) + + expect(out).toContain('Applied fixes') + expect(out).toContain('\u2713 unused-mcp (4d ago): est. 300.0K -> measured 280.0K') + expect(out).toContain('~ mcp-defer-threshold (3d ago): est. 600.0K -> measured 420.0K (-30% vs estimate)') + expect(out).toContain('\u2717 bash-output-cap (5d ago): est. 41.0K -> measured 0 - did not help. Revert: codeburn act undo cccccccc') + expect(out).toContain('\u2026 mcp-remove-linear (1d ago): measuring, check back after 3 days') + }) + + it('shows the section on a clean setup too, and omits it when nothing is applied', () => { + expect(render([fixture({})], [])).toContain('Applied fixes') + expect(render([])).not.toContain('Applied fixes') + expect(render([], [])).not.toContain('Applied fixes') + }) +}) diff --git a/tests/parse-workers.test.ts b/tests/parse-workers.test.ts new file mode 100644 index 00000000..4185be59 --- /dev/null +++ b/tests/parse-workers.test.ts @@ -0,0 +1,484 @@ +import { spawnSync } from 'node:child_process' +import { appendFile, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { createHash } from 'node:crypto' + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { decideParseWorkers, ParseWorkerPool, parseFilesInOrder, type ClaudeWorkerParse } from '../src/parse-workers.js' +import { clearSessionCache, parseAllSessions, parseClaudeFileFull } from '../src/parser.js' +import { parseCodexFileFull, type CodexFullParse } from '../src/providers/codex.js' +import type { SessionSource } from '../src/providers/types.js' + +// Two full cold CLI parses of a multi-hundred-file corpus, plus in-process parses +// that spawn real threads. +vi.setConfig({ testTimeout: 60_000 }) + +const BIG_SYSTEM = { cores: 16, availableBytes: 32 * 1024 ** 3 } +const BIG_PENDING = { files: 5000, bytes: 6 * 1024 ** 3 } +const NO_ENV = {} as NodeJS.ProcessEnv + +describe('decideParseWorkers', () => { + it('scales with cores, memory budget and pending file count', () => { + // 15 (cores-1) vs 8 (2 GB budget / 256 MB) vs 100 (5000/50) -> memory cap wins + expect(decideParseWorkers(BIG_PENDING, BIG_SYSTEM, NO_ENV).workers).toBe(8) + // Fewer cores than the memory budget allows -> cores-1 wins + expect(decideParseWorkers(BIG_PENDING, { cores: 6, availableBytes: 32 * 1024 ** 3 }, NO_ENV).workers).toBe(5) + // 8 GB reaches the same cap as 32 GB: a quarter of it is the 2 GB budget + expect(decideParseWorkers(BIG_PENDING, { cores: 16, availableBytes: 8 * 1024 ** 3 }, NO_ENV).workers).toBe(8) + // Under that, the quarter-of-available budget is the binding constraint + expect(decideParseWorkers(BIG_PENDING, { cores: 16, availableBytes: 6 * 1024 ** 3 }, NO_ENV).workers).toBe(6) + // The smallest machine that clears every gate still only earns 2 threads + expect(decideParseWorkers({ files: 200, bytes: 300 * 1024 ** 2 }, { cores: 3, availableBytes: 4 * 1024 ** 3 }, NO_ENV).workers).toBe(2) + // Big average file: the per-worker budget scales with it (2 x 260 MB + 128 MB), + // so the same 2 GB buys 3 threads instead of the 8 a flat 256 MB would. + expect(decideParseWorkers({ files: 250, bytes: 65 * 1024 ** 3 }, BIG_SYSTEM, NO_ENV).workers).toBe(3) + // 300 files earn 6, but the 6 GB behind them earn 30 — bytes win, then the + // memory budget caps it. A Codex corpus is exactly this shape. + expect(decideParseWorkers({ files: 300, bytes: 6 * 1024 ** 3 }, BIG_SYSTEM, NO_ENV).workers).toBe(8) + // Few enough files AND bytes that MIN_FILES_PER_WORKER is the binding constraint + expect(decideParseWorkers({ files: 300, bytes: 700 * 1024 ** 2 }, BIG_SYSTEM, NO_ENV).workers).toBe(6) + }) + + it('stays serial on low-spec machines and on warm/small parses', () => { + expect(decideParseWorkers(BIG_PENDING, { cores: 2, availableBytes: 32 * 1024 ** 3 }, NO_ENV).workers).toBe(0) + // A 4 GB box: availableMemory() always reads a little under the nominal size + expect(decideParseWorkers(BIG_PENDING, { cores: 16, availableBytes: 3.9 * 1024 ** 3 }, NO_ENV).workers).toBe(0) + // Warm/incremental: the byte gate is not reached + expect(decideParseWorkers({ files: 12, bytes: 10 * 1024 ** 2 }, BIG_SYSTEM, NO_ENV).workers).toBe(0) + }) + + it('gates on bytes alone, so a thin corpus never spawns threads it cannot pay for', () => { + // 250 files holding under a megabyte between them: threads made this ~5% slower + expect(decideParseWorkers({ files: 250, bytes: 917 * 1024 }, BIG_SYSTEM, NO_ENV).workers).toBe(0) + expect(decideParseWorkers({ files: 5000, bytes: 10 * 1024 ** 2 }, BIG_SYSTEM, NO_ENV).workers).toBe(0) + expect(decideParseWorkers({ files: 250, bytes: 917 * 1024 }, BIG_SYSTEM, NO_ENV).reason) + .toContain('below 210 MB pending') + // 150 rollouts over the byte gate: far under any file-count threshold, and the + // biggest workload there is + expect(decideParseWorkers({ files: 150, bytes: 4 * 1024 ** 3 }, BIG_SYSTEM, NO_ENV).workers).toBe(8) + }) + + it('honours CODEBURN_PARSE_WORKERS, which also bypasses the auto gates', () => { + expect(decideParseWorkers(BIG_PENDING, BIG_SYSTEM, { CODEBURN_PARSE_WORKERS: '0' }).workers).toBe(0) + expect(decideParseWorkers(BIG_PENDING, BIG_SYSTEM, { CODEBURN_PARSE_WORKERS: '4' }).workers).toBe(4) + // Capped by the core count + expect(decideParseWorkers(BIG_PENDING, { cores: 4, availableBytes: 32 * 1024 ** 3 }, { CODEBURN_PARSE_WORKERS: '32' }).workers).toBe(4) + // A tiny fixture corpus still gets threads when forced — that is what makes + // the determinism test below able to exercise them at all. + expect(decideParseWorkers({ files: 3, bytes: 1000 }, BIG_SYSTEM, { CODEBURN_PARSE_WORKERS: '3' }).workers).toBe(3) + expect(decideParseWorkers(BIG_PENDING, BIG_SYSTEM, { CODEBURN_PARSE_WORKERS: 'nonsense' }).workers).toBe(0) + }) + + it('reports the decision inputs in every reason, gate or not', () => { + for (const d of [ + decideParseWorkers(BIG_PENDING, BIG_SYSTEM, NO_ENV), + decideParseWorkers({ files: 12, bytes: 1000 }, BIG_SYSTEM, NO_ENV), + decideParseWorkers(BIG_PENDING, BIG_SYSTEM, { CODEBURN_PARSE_WORKERS: '2' }), + ]) { + expect(d.reason).toContain('16 cores') + expect(d.reason).toContain('GB available') + expect(d.reason).toContain('pending files') + } + }) +}) + +type Turn = { id: string; t: number } + +function sessionLines(project: string, session: string, turns: Turn[]): string { + const lines: string[] = [] + for (const { id, t } of turns) { + const ts = new Date(Date.UTC(2026, 4, 4 + (t % 5), 9, t % 60, 0)).toISOString() + const gitBranch = t % 3 === 0 ? 'main' : 'feature' + lines.push(JSON.stringify({ + type: 'user', sessionId: session, timestamp: ts, cwd: `/tmp/proj${project}`, gitBranch, + message: { role: 'user', content: `task ${t} in ${project}` }, + })) + lines.push(JSON.stringify({ + type: 'assistant', sessionId: session, timestamp: ts, cwd: `/tmp/proj${project}`, gitBranch, + message: { + id, type: 'message', role: 'assistant', model: 'claude-sonnet-4-5', + content: [ + { type: 'text', text: 'x'.repeat(200) }, + { type: 'tool_use', id: `tu-${id}`, name: 'Edit', input: { file_path: '/tmp/x', old_string: 'a', new_string: 'b' } }, + ], + usage: { input_tokens: 400 + t, output_tokens: 40 + t, cache_read_input_tokens: 9 }, + }, + })) + } + return lines.join('\n') + '\n' +} + +const range = (n: number, from = 0): number[] => Array.from({ length: n }, (_, i) => i + from) + +async function writeCorpus(claudeDir: string, projects: number, filesPerProject: number): Promise { + const written: string[] = [] + for (let p = 0; p < projects; p++) { + const dir = join(claudeDir, 'projects', `-tmp-proj${p}`) + await mkdir(dir, { recursive: true }) + for (let f = 0; f < filesPerProject; f++) { + const session = `${p}${f}`.padStart(8, '0') + '-aaaa-bbbb-cccc-000000000000' + const path = join(dir, `${session}.jsonl`) + await writeFile(path, sessionLines(String(p), session, range(12).map(t => ({ id: `msg-${p}-${session}-${t}`, t })))) + written.push(path) + } + } + return written +} + +/// A resumed Claude session: the new transcript restates the original's assistant +/// messages verbatim (same message ids) before adding its own. Cross-file dedup +/// means whichever file is installed FIRST keeps those turns and the other loses +/// them, so this fixture is only stable if worker results are installed in the +/// serial order — and it is the only fixture that drives the discard/re-parse path, +/// since a worker parses against an empty dedup set and cannot see the overlap. +async function writeResumedPair(claudeDir: string, tag: string, originalName: string, resumedName: string): Promise { + const dir = join(claudeDir, 'projects', `-tmp-${tag}`) + await mkdir(dir, { recursive: true }) + const shared = range(6).map(t => ({ id: `${tag}-m${t}`, t })) + await writeFile(join(dir, `${originalName}.jsonl`), sessionLines(tag, originalName, shared)) + await writeFile( + join(dir, `${resumedName}.jsonl`), + sessionLines(tag, resumedName, [...shared, ...range(4, 6).map(t => ({ id: `${tag}-n${t}`, t }))]), + ) +} + +type CodexTask = { n: number; at: string } + +/// One Codex rollout: session_meta plus a complete task cycle per entry. A +/// token_count dedup key is namespaced by the FORK PARENT when there is one and +/// keyed on the cumulative token breakdown, so a fork restating a parent's tasks +/// emits exactly the parent's keys — the cross-file overlap that only the +/// install-order check can resolve. The replayed tasks are timestamped well past +/// `metaTs + 5s` on purpose: inside that window the parser drops replays outright +/// and the dedup path would never be reached. +function codexRollout(sessionId: string, cwd: string, tasks: CodexTask[], forkedFrom?: string, metaTs = '2026-05-04T09:00:00.000Z'): string { + const lines = [JSON.stringify({ + type: 'session_meta', + timestamp: metaTs, + payload: { + cwd, originator: 'codex-cli', session_id: sessionId, model: 'gpt-5.3-codex', + ...(forkedFrom ? { forked_from_id: forkedFrom } : {}), + }, + })] + lines.push(...codexTaskLines(tasks)) + return lines.join('\n') + '\n' +} + +function codexTaskLines(tasks: CodexTask[]): string[] { + const lines: string[] = [] + for (const { n, at } of tasks) { + const ts = (s: number) => new Date(Date.parse(at) + s * 1000).toISOString() + lines.push( + JSON.stringify({ type: 'event_msg', timestamp: ts(0), payload: { type: 'task_started' } }), + JSON.stringify({ type: 'response_item', timestamp: ts(1), payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: `codex task ${n}` }] } }), + JSON.stringify({ type: 'response_item', timestamp: ts(2), payload: { type: 'function_call', name: 'shell', call_id: `c${n}`, arguments: JSON.stringify({ command: `ls ${n}` }) } }), + JSON.stringify({ type: 'response_item', timestamp: ts(3), payload: { type: 'function_call_output', call_id: `c${n}` } }), + JSON.stringify({ type: 'event_msg', timestamp: ts(4), payload: { type: 'patch_apply_end', success: true, changes: { [`/tmp/cx/f${n}.ts`]: { unified_diff: '@@ -1 +1,2 @@\n-old\n+new\n+extra\n' } } } }), + JSON.stringify({ type: 'response_item', timestamp: ts(5), payload: { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'y'.repeat(120) }] } }), + JSON.stringify({ + type: 'event_msg', timestamp: ts(6), + payload: { + type: 'token_count', + info: { + last_token_usage: { input_tokens: 100, cached_input_tokens: 20, output_tokens: 50, reasoning_output_tokens: 10, total_tokens: 180 }, + total_token_usage: { input_tokens: 100 * n, cached_input_tokens: 20 * n, output_tokens: 50 * n, reasoning_output_tokens: 10 * n, total_tokens: 160 * n }, + }, + }, + }), + JSON.stringify({ type: 'event_msg', timestamp: ts(7), payload: { type: 'task_complete', duration_ms: 4000 } }), + ) + } + return lines +} + +async function writeCodexRollout(codexHome: string, day: string, name: string, body: string): Promise { + const dir = join(codexHome, 'sessions', '2026', '05', day) + await mkdir(dir, { recursive: true }) + const path = join(dir, `rollout-${name}.jsonl`) + await writeFile(path, body) + return path +} + +/// A parent rollout and a fork that replays its tasks before adding its own. +/// `parentFirst` flips which file is created first, since discovery follows +/// directory order: the shared keys must land on whichever file the SERIAL loop +/// reaches first, in either order. +async function writeForkedCodexPair(codexHome: string, day: string, tag: string, parentFirst: boolean): Promise { + const shared = [1, 2, 3].map(n => ({ n, at: `2026-05-04T09:${String(10 + n).padStart(2, '0')}:00.000Z` })) + const parent = codexRollout(`${tag}-parent`, `/tmp/cx${tag}`, shared) + const fork = codexRollout( + `${tag}-fork`, + `/tmp/cx${tag}`, + [...shared.map(t => ({ ...t, at: `2026-05-04T10:${String(10 + t.n).padStart(2, '0')}:00.000Z` })), { n: 4, at: '2026-05-04T10:30:00.000Z' }], + `${tag}-parent`, + ) + const order: Array<[string, string]> = parentFirst + ? [[`${tag}-a-parent`, parent], [`${tag}-b-fork`, fork]] + : [[`${tag}-a-fork`, fork], [`${tag}-b-parent`, parent]] + for (const [name, body] of order) await writeCodexRollout(codexHome, day, name, body) +} + +/// Cache shard file names carry a random nonce, so compare bodies keyed by +/// `.` instead of by file name. +async function shardBodies(cacheDir: string): Promise> { + const dir = join(cacheDir, 'session-cache.v9') + const out: Record = {} + for (const name of (await readdir(dir).catch(() => []))) { + if (name === 'envelope.json' || !name.endsWith('.json')) continue + const key = name.split('.').slice(0, 2).join('.') + out[key] = createHash('sha256').update(await readFile(join(dir, name))).digest('hex') + } + return out +} + +/// The Codex incremental cache is a single JSON file; both runs read the same +/// rollouts, so it must come out identical byte for byte. +async function codexResults(cacheDir: string): Promise { + return readFile(join(cacheDir, 'codex-results.json'), 'utf-8').catch(() => null) +} + +function runCli(args: string[], home: string, extraEnv: Record) { + return spawnSync(process.execPath, ['--import', 'tsx', 'src/cli.ts', ...args], { + cwd: process.cwd(), + env: { + ...process.env, + CLAUDE_CONFIG_DIR: join(home, '.claude'), + CODEX_HOME: join(home, '.codex'), + CODEBURN_CACHE_DIR: join(home, '.cache', 'codeburn'), + HOME: home, + TZ: 'UTC', + ...extraEnv, + }, + encoding: 'utf-8', + timeout: 60_000, + }) +} + +function stripVolatile(payload: unknown): unknown { + if (Array.isArray(payload)) return payload.map(stripVolatile) + if (payload && typeof payload === 'object') { + return Object.fromEntries( + Object.entries(payload as Record) + .filter(([k]) => !k.toLowerCase().startsWith('generated')) + .map(([k, v]) => [k, stripVolatile(v)]), + ) + } + if (typeof payload === 'number') return Math.round(payload * 1e9) / 1e9 + return payload +} + +describe('parallel cold parse', () => { + let home: string + + beforeEach(async () => { + home = await mkdtemp(join(tmpdir(), 'cb-cold-')) + }) + + afterEach(async () => { + await rm(home, { recursive: true, force: true }) + }) + + /// Both runs read the SAME corpus, so the absolute paths embedded in the cache + /// shards match and the bodies can be compared byte for byte. + async function bothWays(extraParallelEnv: Record = {}) { + const serialCache = join(home, 'cache-serial') + const parallelCache = join(home, 'cache-parallel') + const args = ['status', '--format', 'menubar-json'] + const serial = runCli(args, home, { CODEBURN_PARSE_WORKERS: '0', CODEBURN_CACHE_DIR: serialCache }) + const parallel = runCli(args, home, { CODEBURN_PARSE_WORKERS: '3', CODEBURN_CACHE_DIR: parallelCache, ...extraParallelEnv }) + + expect(serial.status, serial.stderr).toBe(0) + expect(parallel.status, parallel.stderr).toBe(0) + expect(stripVolatile(JSON.parse(parallel.stdout))).toEqual(stripVolatile(JSON.parse(serial.stdout))) + + const serialShards = await shardBodies(serialCache) + expect(Object.keys(serialShards).length).toBeGreaterThan(0) + expect(await shardBodies(parallelCache)).toEqual(serialShards) + // Byte-compared, not deep-equalled: the Codex cache is written entry by entry + // in install order, so the key order is itself a claim about that order. + expect(await codexResults(parallelCache)).toEqual(await codexResults(serialCache)) + return parallel + } + + // The whole point of the feature: threads may only ever be a speed change. + it('produces an identical payload and byte-identical cache shards with and without workers', async () => { + await writeCorpus(join(home, '.claude'), 4, 12) + await bothWays() + }) + + // Resumed sessions in both filename orders: the restating file sorts after the + // original in one project and before it in the other, so install order decides + // which file keeps the shared turns either way. Out-of-order installation, or + // any attempt to patch overlapping turns out of a worker result instead of + // discarding the whole file, changes the answer. + it("matches the serial parse when files restate each other's message ids", async () => { + const claude = join(home, '.claude') + await writeResumedPair(claude, 'fwd', '00000000-aaaa-bbbb-cccc-000000000000', '99999999-aaaa-bbbb-cccc-000000000000') + await writeResumedPair(claude, 'rev', '99999999-dddd-bbbb-cccc-000000000000', '00000000-dddd-bbbb-cccc-000000000000') + + const parallel = await bothWays({ CODEBURN_VERBOSE: '1' }) + + // Pin that the discard path actually ran rather than passing by luck. + const overlaps = [...parallel.stderr.matchAll(/(\d+)\/\d+ results re-parsed in-process on id overlap/g)] + .reduce((n, m) => n + Number(m[1]), 0) + expect(overlaps).toBeGreaterThan(0) + }) + + // Codex is the bigger half of a real cold parse, and its cross-file dedup is + // stronger than Claude's: a forked rollout replays its parent's token_count + // history under the PARENT's key namespace, so two files claim the same keys + // outright. Both fork orders are present, so install order decides which file + // keeps the shared tasks either way. + it('matches the serial parse for a mixed Claude + Codex corpus with forked rollouts', async () => { + await writeCorpus(join(home, '.claude'), 2, 6) + const codex = join(home, '.codex') + await writeForkedCodexPair(codex, '04', 'fwd', true) + await writeForkedCodexPair(codex, '05', 'rev', false) + for (const n of range(4)) { + await writeCodexRollout(codex, '06', `plain-${n}`, codexRollout(`plain-${n}`, `/tmp/cx${n}`, [1, 2].map(t => ({ n: t, at: `2026-05-06T0${n}:${t}0:00.000Z` })))) + } + + const parallel = await bothWays({ CODEBURN_VERBOSE: '1' }) + + expect(parallel.stderr).toContain('codeburn: codex parse workers=3') + // Pin that the codex-cache comparison in bothWays was not vacuous. + expect(await codexResults(join(home, 'cache-parallel'))).toContain('rollout-') + // Pin that the codex discard path actually ran rather than passing by luck. + const codexDiscards = [...parallel.stderr.matchAll(/codex parse workers done, (\d+)\/\d+ results/g)] + .reduce((n, m) => n + Number(m[1]), 0) + expect(codexDiscards).toBeGreaterThan(0) + }) + + // Workers only ever run WHOLE-file decodes. A rollout that grew by a few KB is + // resumed from its last task boundary in-process: a thread hop would cost more + // than it saves, and the resume state lives in the parent's codex cache. + it('never hands a resumable rollout to a worker', async () => { + const codex = join(home, '.codex') + const path = await writeCodexRollout(codex, '04', 'grow', codexRollout('grow', '/tmp/cxg', [1, 2, 3].map(n => ({ n, at: `2026-05-04T09:${n}0:00.000Z` })))) + const cache = join(home, 'cache-inc') + const args = ['status', '--format', 'menubar-json'] + + const cold = runCli(args, home, { CODEBURN_PARSE_WORKERS: '3', CODEBURN_CACHE_DIR: cache, CODEBURN_VERBOSE: '1' }) + expect(cold.status, cold.stderr).toBe(0) + expect(cold.stderr).toContain('codeburn: codex parse workers=3') + + await appendFile(path, codexTaskLines([{ n: 4, at: '2026-05-04T09:40:00.000Z' }]).join('\n') + '\n') + const warm = runCli(args, home, { CODEBURN_PARSE_WORKERS: '3', CODEBURN_CACHE_DIR: cache, CODEBURN_VERBOSE: '1' }) + expect(warm.status, warm.stderr).toBe(0) + expect(warm.stderr).toContain('codeburn: codex parse workers=0 (no full parses pending)') + }) +}) + +describe('ParseWorkerPool', () => { + let home: string + let files: string[] + let codexPath: string + let codexSource: SessionSource + + beforeEach(async () => { + clearSessionCache() + home = await mkdtemp(join(tmpdir(), 'cb-pool-')) + files = await writeCorpus(join(home, '.claude'), 2, 4) + codexPath = await writeCodexRollout( + join(home, '.codex'), '04', 'pool', + codexRollout('pool-1', '/tmp/cx', [1, 2].map(n => ({ n, at: `2026-05-04T09:${n}0:00.000Z` }))), + ) + codexSource = { provider: 'codex', path: codexPath, project: 'tmp-cx' } + process.env['CLAUDE_CONFIG_DIR'] = join(home, '.claude') + // Isolated so a parse in this process can never walk the developer's own + // ~/.codex, and so the pool the Codex path opens is covered by the leak check. + process.env['CODEX_HOME'] = join(home, '.codex') + process.env['CODEBURN_CACHE_DIR'] = join(home, '.cache', 'codeburn') + }) + + afterEach(async () => { + clearSessionCache() + delete process.env['CODEBURN_PARSE_WORKERS'] + delete process.env['CODEX_HOME'] + await rm(home, { recursive: true, force: true }) + }) + + function liveWorkers(): number { + return process.getActiveResourcesInfo().filter(r => r === 'Worker').length + } + + it('returns results in submission order and terminates every thread on close', async () => { + const before = liveWorkers() + const pool = new ParseWorkerPool(3) + const results = [] + for await (const r of parseFilesInOrder(pool, files.map(filePath => ({ kind: 'claude' as const, filePath })))) results.push(r) + await pool.close() + + expect(results).toHaveLength(files.length) + for (const r of results) expect(r.ok).toBe(true) + // Each fixture session's first turn names its own project, which pins the + // yielded order to the submitted order rather than to completion order. + const projects = results.map(r => (r.ok && r.parsed ? r.parsed.turns[0]?.userMessage : undefined)) + expect(projects).toEqual(files.map((_, i) => `task 0 in ${Math.floor(i / 4)}`)) + expect(liveWorkers()).toBe(before) + }) + + // A worker that cannot answer must hand the file back, never drop it: the + // caller's fallback is an in-process parse, and it has to land on the same + // result the worker would have produced. + it('reports failures instead of throwing, and the serial fallback matches', async () => { + const pool = new ParseWorkerPool(1) + const fromWorker = await pool.submit({ kind: 'claude', filePath: files[0]! }) + await pool.close() + + const afterClose = await pool.submit({ kind: 'claude', filePath: files[1]! }) + expect(afterClose.ok).toBe(false) + + const serial = await parseClaudeFileFull(files[0]!, new Set()) + expect(fromWorker.ok).toBe(true) + if (!fromWorker.ok || !fromWorker.parsed) throw new Error('expected a parsed result') + const { msgIds, path, ...worker } = fromWorker.parsed + expect(msgIds.length).toBeGreaterThan(0) + // Echoed back so the parent can assert the positional worker/file pairing. + expect(path).toBe(files[0]) + expect(worker).toEqual(JSON.parse(JSON.stringify(serial))) + }) + + + // Same contract for a Codex rollout: the off-thread decode is the serial + // decode, including the cache entry the parent has to install, and a worker + // that cannot answer hands the file back for an in-process parse. + it('decodes a codex rollout off-thread exactly as the serial path does', async () => { + const before = liveWorkers() + const pool = new ParseWorkerPool(1) + const fromWorker = await pool.submit({ kind: 'codex', source: codexSource }) + await pool.close() + expect(liveWorkers()).toBe(before) + + const afterClose = await pool.submit({ kind: 'codex', source: codexSource }) + expect(afterClose.ok).toBe(false) + + const seen = new Set() + const serial = await parseCodexFileFull(codexSource, seen) + if (!fromWorker.ok || !fromWorker.parsed) throw new Error('expected a parsed result') + const { keys, path, ...worker } = fromWorker.parsed + expect(keys.length).toBeGreaterThan(0) + expect(new Set(keys)).toEqual(seen) + // Echoed back so the parent can assert the positional worker/file pairing. + expect(path).toBe(codexPath) + expect(worker).toEqual(JSON.parse(JSON.stringify(serial))) + // The decode itself must never have touched the codex cache file. + expect(await codexResults(join(home, '.cache', 'codeburn'))).toBeNull() + }) + + // The resident `serve` child parses over and over in one process; a thread + // that outlives its parse would accumulate across requests. + it('leaves no live worker behind after back-to-back parses', async () => { + const before = liveWorkers() + process.env['CODEBURN_PARSE_WORKERS'] = '2' + + await parseAllSessions() + expect(liveWorkers()).toBe(before) + + clearSessionCache() + await parseAllSessions() + expect(liveWorkers()).toBe(before) + }) +}) diff --git a/tests/parser-antigravity-timestamp.test.ts b/tests/parser-antigravity-timestamp.test.ts index dbe11550..e394af5e 100644 --- a/tests/parser-antigravity-timestamp.test.ts +++ b/tests/parser-antigravity-timestamp.test.ts @@ -7,7 +7,7 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { getDateRange } from '../src/cli-date.js' import { clearSessionCache, parseAllSessions } from '../src/parser.js' -import { sessionCachePath } from '../src/session-cache.js' +import { readCacheOnDisk } from './fixtures/session-cache-io.js' import { isSqliteAvailable } from '../src/sqlite.js' import type { DateRange } from '../src/types.js' @@ -40,9 +40,7 @@ function createGenMetadataDb(dbPath: string, fixture: Fixture): void { } async function cachedAntigravityTurns(cacheDir: string, dbPath: string): Promise> { - const saved = JSON.parse(await readFile(sessionCachePath(), 'utf-8')) as { - providers: Record }> }> - } + const saved = await readCacheOnDisk() return saved.providers['antigravity']?.files[dbPath]?.turns ?? [] } diff --git a/tests/parser-cache-refresh-timeout.test.ts b/tests/parser-cache-refresh-timeout.test.ts index 8883c5a7..53e53e0e 100644 --- a/tests/parser-cache-refresh-timeout.test.ts +++ b/tests/parser-cache-refresh-timeout.test.ts @@ -8,7 +8,7 @@ vi.mock('../src/cache-refresh-lock.js', () => ({ })) import { clearSessionCache, isSessionHydrationComplete, parseAllSessions } from '../src/parser.js' -import { sessionCachePath } from '../src/session-cache.js' +import { cacheDirSnapshot } from './fixtures/session-cache-io.js' let root: string let sessionPath: string @@ -52,12 +52,12 @@ describe('parseAllSessions warm refresh timeout', () => { it('serves the prior complete snapshot and leaves the holder cache untouched', async () => { await writeSession(50) expect(output(await parseAllSessions(undefined, 'claude'))).toBe(50) - const before = await readFile(sessionCachePath(), 'utf-8') + const before = await cacheDirSnapshot() await writeSession(5000) clearSessionCache() expect(output(await parseAllSessions(undefined, 'claude'))).toBe(50) - expect(await readFile(sessionCachePath(), 'utf-8')).toBe(before) + expect(await cacheDirSnapshot()).toBe(before) }) // The snapshot a timed-out refresh serves is only as good as what has changed diff --git a/tests/parser-classify-after-slice.test.ts b/tests/parser-classify-after-slice.test.ts new file mode 100644 index 00000000..be48e0e7 --- /dev/null +++ b/tests/parser-classify-after-slice.test.ts @@ -0,0 +1,111 @@ +import { afterEach, beforeEach, expect, it } from 'vitest' +import { mkdtemp, mkdir, writeFile, rm } from 'fs/promises' +import { join } from 'path' +import { tmpdir } from 'os' + +import { parseAllSessions, filterProjectsByDateRange, clearSessionCache } from '../src/parser.js' +import { loadPricing } from '../src/models.js' +import type { ClassifiedTurn, DateRange } from '../src/types.js' + +// scanProjectDirs decides the date slice on the RAW cached turn and classifies +// only survivors. The classification itself must still see each surviving +// turn's COMPLETE call list, and the branch/PR carries must still run over the +// full ordered turn list — so this fixture puts the branch anchor and the PR +// reference before the range, and straddles the range start with a turn whose +// only Edit lands on the out-of-range side. + +const SESSION = '22222222-2222-4222-8222-222222222222' +const CWD = '/tmp/slice-proj' +const BRANCH = 'feat/carry' +const PR = 'https://github.com/o/r/pull/42' +const RANGE: DateRange = { + start: new Date('2026-07-20T00:00:00.000Z'), + end: new Date('2026-07-20T23:59:59.999Z'), +} + +let tmpDir: string + +beforeEach(async () => { + clearSessionCache() + tmpDir = await mkdtemp(join(tmpdir(), 'slice-')) + process.env['CLAUDE_CONFIG_DIR'] = join(tmpDir, 'claude') + process.env['CODEBURN_CACHE_DIR'] = join(tmpDir, 'cache') +}) + +afterEach(async () => { + clearSessionCache() + delete process.env['CLAUDE_CONFIG_DIR'] + delete process.env['CODEBURN_CACHE_DIR'] + await rm(tmpDir, { recursive: true, force: true }) +}) + +function user(ts: string, content: string): string { + return JSON.stringify({ type: 'user', sessionId: SESSION, timestamp: ts, cwd: CWD, gitBranch: BRANCH, message: { role: 'user', content } }) +} + +function assistant(ts: string, id: string, tools: string[]): string { + return JSON.stringify({ + type: 'assistant', sessionId: SESSION, timestamp: ts, cwd: CWD, gitBranch: BRANCH, + message: { + id, type: 'message', role: 'assistant', model: 'claude-sonnet-4-5', + content: tools.map((name, i) => ({ type: 'tool_use', id: `${id}_${i}`, name, input: {} })), + usage: { input_tokens: 100, output_tokens: 50 }, + }, + }) +} + +async function writeTranscript(): Promise { + const projDir = join(tmpDir, 'claude', 'projects', 'slice-proj') + await mkdir(projDir, { recursive: true }) + await writeFile(join(projDir, `${SESSION}.jsonl`), [ + // Before the range: the only turn carrying the branch (the cache elides an + // unchanged branch on later turns) and the only PR reference. + user('2026-07-19T09:00:00.000Z', `please finish ${PR}`), + assistant('2026-07-19T09:00:05.000Z', 'm1', ['Read']), + // Straddles the range start: the Edit is on the out-of-range call. + user('2026-07-19T23:50:00.000Z', 'keep going overnight'), + assistant('2026-07-19T23:50:10.000Z', 'm2', ['Edit']), + assistant('2026-07-20T00:10:00.000Z', 'm3', ['Read']), + // Fully inside the range. + user('2026-07-20T10:00:00.000Z', 'what changed?'), + assistant('2026-07-20T10:00:05.000Z', 'm4', ['Read']), + ].join('\n') + '\n', 'utf-8') +} + +function shape(turn: ClassifiedTurn): unknown { + return { + timestamp: turn.timestamp, + category: turn.category, + subCategory: turn.subCategory, + retries: turn.retries, + hasEdits: turn.hasEdits, + gitBranch: turn.gitBranch, + prRefs: turn.prRefs, + calls: turn.assistantCalls.map(c => c.timestamp), + } +} + +it('slices before classifying without changing carried branch, PR, or turn classification', async () => { + await loadPricing() + await writeTranscript() + + const sliced = await parseAllSessions(RANGE, 'claude') + // Reference: the old order — classify every turn from the full history, then + // apply the same range slice afterwards. + clearSessionCache() + const reference = filterProjectsByDateRange(await parseAllSessions(undefined, 'claude'), RANGE) + + const session = sliced[0]!.sessions[0]! + expect(session.turns.map(shape)).toEqual(reference[0]!.sessions[0]!.turns.map(shape)) + + // The branch anchor and the PR reference both live before the range. + expect(session.everHadBranch).toBe(true) + expect(session.turns.every(t => t.gitBranch === BRANCH)).toBe(true) + expect(session.prRefsAtRangeStart).toEqual([PR]) + + // The straddling turn kept only its in-range call, but was classified from + // the complete call list — the Edit it dropped still counts. + const straddled = session.turns[0]! + expect(straddled.assistantCalls.map(c => c.timestamp)).toEqual(['2026-07-20T00:10:00.000Z']) + expect(straddled.hasEdits).toBe(true) +}) diff --git a/tests/parser-gemini-cache.test.ts b/tests/parser-gemini-cache.test.ts index b5510274..d2f8c7e1 100644 --- a/tests/parser-gemini-cache.test.ts +++ b/tests/parser-gemini-cache.test.ts @@ -5,7 +5,8 @@ import { join } from 'path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { clearSessionCache, parseAllSessions } from '../src/parser.js' -import { CACHE_VERSION, computeEnvFingerprint, sessionCachePath } from '../src/session-cache.js' +import { CACHE_VERSION, computeEnvFingerprint, type SessionCache } from '../src/session-cache.js' +import { readCacheOnDisk, writeCacheOnDisk } from './fixtures/session-cache-io.js' import type { DateRange } from '../src/types.js' let home: string @@ -54,7 +55,7 @@ describe('Gemini session cache migration', () => { })) const fileStat = await stat(sessionPath) - await writeFile(sessionCachePath(), JSON.stringify({ + await writeCacheOnDisk({ version: CACHE_VERSION, providers: { gemini: { @@ -97,7 +98,7 @@ describe('Gemini session cache migration', () => { }, }, }, - })) + } as SessionCache) const range: DateRange = { start: new Date('2026-05-16T00:00:00.000Z'), @@ -117,7 +118,7 @@ describe('Gemini session cache migration', () => { 'gemini:gemini-session-1:g2', ]) - const savedCache = JSON.parse(await readFile(sessionCachePath(), 'utf-8')) + const savedCache = await readCacheOnDisk() as any const savedKeys = savedCache.providers.gemini.files[sessionPath].turns.flatMap((turn: { calls: Array<{ deduplicationKey: string }> }) => turn.calls.map(call => call.deduplicationKey), ) diff --git a/tests/parser-hydration-lock.test.ts b/tests/parser-hydration-lock.test.ts index 79e14983..5a41adb8 100644 --- a/tests/parser-hydration-lock.test.ts +++ b/tests/parser-hydration-lock.test.ts @@ -6,12 +6,13 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { existsSync } from 'fs' -import { mkdir, mkdtemp, rm, unlink, writeFile, readFile } from 'fs/promises' +import { mkdir, mkdtemp, rm, unlink, writeFile } from 'fs/promises' import { tmpdir } from 'os' import { join } from 'path' import { clearSessionCache, parseAllSessions } from '../src/parser.js' -import { sessionCachePath } from '../src/session-cache.js' +import { sessionCacheDir } from '../src/session-cache.js' +import { readCacheOnDisk, writeCacheOnDisk } from './fixtures/session-cache-io.js' let tmpHome: string let cacheDir: string @@ -71,17 +72,16 @@ describe('parseAllSessions hydration lock', () => { await writeClaudeSession(50) expect(totalOutput(await parseAllSessions(undefined, 'claude'))).toBe(50) - const warm = JSON.parse(await readFile(sessionCachePath(), 'utf-8')) - for (const section of Object.values(warm.providers) as Array<{ files: Record }> }> }>) { + const tampered = await readCacheOnDisk() + for (const section of Object.values(tampered.providers)) { for (const file of Object.values(section.files)) { for (const turn of file.turns) for (const call of turn.calls) call.usage.outputTokens = 999 } } - const tampered = JSON.stringify(warm) // Go cold: remove the versioned cache and drop the in-memory cache so the // next parse genuinely cold-starts and consults the lock. - await unlink(sessionCachePath()) + await rm(sessionCacheDir(), { recursive: true }) clearSessionCache() // A fresh lock held by another live process (pid 1 is always alive and is @@ -97,7 +97,7 @@ describe('parseAllSessions hydration lock', () => { // The "first process" finishes: it leaves the warm (tampered) cache behind // and releases the lock. The waiter wakes, reloads, and serves the cache. - await writeFile(sessionCachePath(), tampered) + await writeCacheOnDisk(tampered) await unlink(lockPath()) const result = await promise @@ -119,8 +119,9 @@ describe('parseAllSessions hydration lock', () => { expect(totalOutput(result)).toBe(50) // Lock released in the finally. expect(existsSync(lockPath())).toBe(false) - // The parse warmed the versioned cache. - expect(existsSync(sessionCachePath())).toBe(true) + // The parse warmed the versioned cache: the envelope is what publishes it, + // so the directory merely existing proves nothing. + expect(existsSync(join(sessionCacheDir(), 'envelope.json'))).toBe(true) }) it('ignores a fresh lock whose pid is dead', async () => { diff --git a/tests/parser-incremental-append.test.ts b/tests/parser-incremental-append.test.ts index 3c192146..364ac663 100644 --- a/tests/parser-incremental-append.test.ts +++ b/tests/parser-incremental-append.test.ts @@ -20,7 +20,7 @@ vi.mock('../src/fs-utils.js', async (importOriginal) => { }) import { parseAllSessions, clearSessionCache } from '../src/parser.js' -import { sessionCachePath } from '../src/session-cache.js' +import { readCacheOnDisk, writeCacheOnDisk } from './fixtures/session-cache-io.js' import type { ProjectSummary } from '../src/types.js' let tmpDir: string @@ -129,8 +129,8 @@ describe('incremental append parsing', () => { await writeFile(sessionPath, baseLines().join('\n') + '\n') await parseWith(warmCache) - const cachedOffset: number = JSON.parse(await readFile(sessionCachePath(), 'utf-8')) - .providers.claude.files[sessionPath].lastCompleteLineOffset + const cachedOffset = (await readCacheOnDisk()) + .providers['claude']!.files[sessionPath]!.lastCompleteLineOffset! expect(cachedOffset).toBeGreaterThan(0) // 2) append a new complete turn plus a torn (invalid JSON, no newline) tail. @@ -317,10 +317,9 @@ describe('incremental append parsing', () => { await parseWith(warmCache) // Corrupt the persisted offset to point far beyond the file, then grow it. - const cachePath = sessionCachePath() - const cache = JSON.parse(await readFile(cachePath, 'utf-8')) - cache.providers.claude.files[sessionPath].lastCompleteLineOffset = 10_000_000 - await writeFile(cachePath, JSON.stringify(cache)) + const cache = await readCacheOnDisk() + cache.providers['claude']!.files[sessionPath]!.lastCompleteLineOffset = 10_000_000 + await writeCacheOnDisk(cache) await appendFile(sessionPath, userLine('2026-05-01T13:00:00.000Z', 'grow the file') + '\n' + diff --git a/tests/parser.test.ts b/tests/parser.test.ts index 4bd0c5c2..98c45376 100644 --- a/tests/parser.test.ts +++ b/tests/parser.test.ts @@ -14,7 +14,8 @@ import { createRequire } from 'node:module' import { isSqliteAvailable } from '../src/sqlite.js' import { clearSessionCache, parseAllSessions, setParseReuseValidator } from '../src/parser.js' -import { loadCache, saveCache, sessionCachePath } from '../src/session-cache.js' +import { loadCache, saveCache } from '../src/session-cache.js' +import { readCacheOnDisk, writeCacheOnDisk } from './fixtures/session-cache-io.js' import type { SessionSource, SessionParser, ParsedProviderCall } from '../src/providers/types.js' // ── Synthetic provider state ─────────────────────────────────────────────── @@ -23,6 +24,7 @@ import type { SessionSource, SessionParser, ParsedProviderCall } from '../src/pr let _synthSources: SessionSource[] = [] let _synthDurable = false let _synthYields: ParsedProviderCall[] = [] +let _synthOnParse: (() => void | Promise) | null = null vi.mock('../src/providers/index.js', async (importOriginal) => { type Mod = typeof import('../src/providers/index.js') @@ -52,6 +54,7 @@ vi.mock('../src/providers/index.js', async (importOriginal) => { createSessionParser(_s: SessionSource, _k: Set): SessionParser { return { async *parse(): AsyncGenerator { + await _synthOnParse?.() for (const call of _synthYields) { // Respect seenKeys so that when multiple sources share the same // dedup key, only the first source yields it (mirrors real parsers). @@ -190,13 +193,16 @@ beforeEach(async () => { _synthSources = [] _synthDurable = false _synthYields = [] + _synthOnParse = null }) afterEach(async () => { clearSessionCache() + setParseReuseValidator(null) vi.unstubAllEnvs() _synthSources = [] + _synthOnParse = null await rm(tmpHome, { recursive: true, force: true }) await rm(tmpCache, { recursive: true, force: true }) @@ -444,12 +450,10 @@ describe('(f) durable orphans survive a parse-version bump', () => { // Simulate the fingerprint a PREVIOUS release computed (any mismatching // value takes the same code path as a real parse-version bump). - const { readFile, writeFile: writeFileFs } = await import('fs/promises') - const cachePath = sessionCachePath() - const disk = JSON.parse(await readFile(cachePath, 'utf-8')) as { providers: Record } + const disk = await readCacheOnDisk() expect(disk.providers['copilot']).toBeDefined() disk.providers['copilot']!.envFingerprint = '0000000000000000' - await writeFileFs(cachePath, JSON.stringify(disk), 'utf-8') + await writeCacheOnDisk(disk) // First parse after the "upgrade": the orphan must still be counted and // must survive in the rewritten cache, not be erased with the section. @@ -728,6 +732,75 @@ describe('(q) parse burst reuse (CODEBURN_PARSE_BURST_MS)', () => { }) describe('(r) validated parse reuse (setParseReuseValidator)', () => { + it('falls back to the exact TTL when watcher coverage is unknown, but rejects dirty', async () => { + vi.stubEnv('CODEBURN_PARSE_BURST_MS', '0') + clearSessionCache() + const start = new Date(Date.now() - 60 * 60 * 1000) + const end = new Date() + const ts = new Date(Date.now() - 10 * 60 * 1000).toISOString() + const synthFile = join(tmpHome, 'synth-unknown-exact.txt') + await writeFile(synthFile, 'first input') + _synthSources = [{ path: synthFile, project: 'p', provider: 'test-synthetic' }] + _synthYields = [{ + provider: 'test-synthetic', model: 'synth-model', + inputTokens: 1, outputTokens: 5, cacheCreationInputTokens: 0, cacheReadInputTokens: 0, + cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0, + costUSD: 0, costIsEstimated: false, tools: [], bashCommands: [], skills: [], + timestamp: ts, speed: 'standard', deduplicationKey: 'synth-unknown-exact-1', userMessage: 'hi', sessionId: 'sue-1', + }] as never + + expect(totalOutput(await parseAllSessions({ start, end }, 'test-synthetic'))).toBe(5) + _synthYields = [..._synthYields, { + ...( _synthYields[0] as object ), deduplicationKey: 'synth-unknown-exact-2', outputTokens: 7, + }] as never + await writeFile(synthFile, 'second input with changed fingerprint') + + // An unhealthy/pre-arm watcher cannot extend freshness, but it must retain + // the normal exact-key TTL instead of forcing a full rescan every request. + setParseReuseValidator(() => 'unknown') + expect(totalOutput(await parseAllSessions({ start, end }, 'test-synthetic'))).toBe(5) + + // The same entry must be rejected immediately once a real change is known. + setParseReuseValidator(() => 'dirty') + expect(totalOutput(await parseAllSessions({ start, end }, 'test-synthetic'))).toBe(12) + }) + + it('falls back to the short burst when watcher coverage is unknown, but dirty wins inside it', async () => { + vi.stubEnv('CODEBURN_PARSE_BURST_MS', '10000') + clearSessionCache() + const start = new Date(Date.now() - 60 * 60 * 1000) + const firstEnd = new Date() + const ts = new Date(Date.now() - 10 * 60 * 1000).toISOString() + const synthFile = join(tmpHome, 'synth-unknown-burst.txt') + await writeFile(synthFile, 'first input') + _synthSources = [{ path: synthFile, project: 'p', provider: 'test-synthetic' }] + _synthYields = [{ + provider: 'test-synthetic', model: 'synth-model', + inputTokens: 1, outputTokens: 5, cacheCreationInputTokens: 0, cacheReadInputTokens: 0, + cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0, + costUSD: 0, costIsEstimated: false, tools: [], bashCommands: [], skills: [], + timestamp: ts, speed: 'standard', deduplicationKey: 'synth-unknown-burst-1', userMessage: 'hi', sessionId: 'sub-1', + }] as never + + expect(totalOutput(await parseAllSessions({ start, end: firstEnd }, 'test-synthetic'))).toBe(5) + _synthYields = [..._synthYields, { + ...( _synthYields[0] as object ), deduplicationKey: 'synth-unknown-burst-2', outputTokens: 7, + }] as never + await writeFile(synthFile, 'second input with changed fingerprint') + + setParseReuseValidator(() => 'unknown') + expect(totalOutput(await parseAllSessions( + { start, end: new Date(firstEnd.getTime() + 100) }, + 'test-synthetic', + ))).toBe(5) + + setParseReuseValidator(() => 'dirty') + expect(totalOutput(await parseAllSessions( + { start, end: new Date(firstEnd.getTime() + 200) }, + 'test-synthetic', + ))).toBe(12) + }) + it('reuses past the burst window while the validator reports quiet, never when dirty', async () => { vi.stubEnv('CODEBURN_PARSE_BURST_MS', '1') clearSessionCache() @@ -750,14 +823,14 @@ describe('(r) validated parse reuse (setParseReuseValidator)', () => { // 1ms burst window has certainly elapsed; with a quiet validator the // previous parse is still served (world changed, result must not). await new Promise(r => setTimeout(r, 5)) - setParseReuseValidator(() => true) + setParseReuseValidator(() => 'clean') _synthYields = [..._synthYields, { ...( _synthYields[0] as object ), deduplicationKey: 'synth-val-2', outputTokens: 7 }] as never await writeFile(synthFile, 'placeholder v2') const second = await parseAllSessions({ start, end: new Date(Date.now() + 500) }, 'test-synthetic') expect(totalOutput(second)).toBe(5) // A dirty validator ends the reuse: fresh parse sees the new call. - setParseReuseValidator(() => false) + setParseReuseValidator(() => 'dirty') const third = await parseAllSessions({ start, end: new Date(Date.now() + 1000) }, 'test-synthetic') expect(totalOutput(third)).toBe(12) @@ -766,4 +839,85 @@ describe('(r) validated parse reuse (setParseReuseValidator)', () => { _synthSources = [] _synthYields = [] }) + + it('rejects an exact-key memo when a root event arrived during its parse', async () => { + clearSessionCache() + const start = new Date(Date.now() - 60 * 60 * 1000) + const end = new Date() + const ts = new Date(Date.now() - 10 * 60 * 1000).toISOString() + const synthFile = join(tmpHome, 'synth-exact-event-during-parse.txt') + await writeFile(synthFile, 'first input') + _synthSources = [{ path: synthFile, project: 'p', provider: 'test-synthetic' }] + _synthYields = [{ + provider: 'test-synthetic', model: 'synth-model', + inputTokens: 1, outputTokens: 5, cacheCreationInputTokens: 0, cacheReadInputTokens: 0, + cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0, + costUSD: 0, costIsEstimated: false, tools: [], bashCommands: [], skills: [], + timestamp: ts, speed: 'standard', deduplicationKey: 'synth-exact-event-1', userMessage: 'hi', sessionId: 'see-1', + }] as never + + let rootEventAt = 0 + setParseReuseValidator(sinceTs => rootEventAt === 0 || rootEventAt < sinceTs ? 'clean' : 'dirty') + _synthOnParse = async () => { + await new Promise(resolve => setTimeout(resolve, 10)) + rootEventAt = Date.now() + await new Promise(resolve => setTimeout(resolve, 10)) + } + const first = await parseAllSessions({ start, end }, 'test-synthetic') + expect(totalOutput(first)).toBe(5) + _synthOnParse = null + + _synthYields = [..._synthYields, { + ...( _synthYields[0] as object ), deduplicationKey: 'synth-exact-event-2', outputTokens: 7, + }] as never + await writeFile(synthFile, 'second input') + const second = await parseAllSessions({ start, end }, 'test-synthetic') + expect(totalOutput(second)).toBe(12) + }) + + it('does not bless a root event that arrived while the cached parse was running', async () => { + vi.stubEnv('CODEBURN_PARSE_BURST_MS', '1') + clearSessionCache() + const start = new Date(Date.now() - 60 * 60 * 1000) + const firstEnd = new Date() + const ts = new Date(Date.now() - 10 * 60 * 1000).toISOString() + const synthFile = join(tmpHome, 'synth-event-during-parse.txt') + await writeFile(synthFile, 'first input') + _synthSources = [{ path: synthFile, project: 'p', provider: 'test-synthetic' }] + _synthYields = [{ + provider: 'test-synthetic', model: 'synth-model', + inputTokens: 1, outputTokens: 5, cacheCreationInputTokens: 0, cacheReadInputTokens: 0, + cachedInputTokens: 0, reasoningTokens: 0, webSearchRequests: 0, + costUSD: 0, costIsEstimated: false, tools: [], bashCommands: [], skills: [], + timestamp: ts, speed: 'standard', deduplicationKey: 'synth-event-1', userMessage: 'hi', sessionId: 'se-1', + }] as never + + let rootEventAt = 0 + _synthOnParse = async () => { + // Bracket the controlled event so it is strictly after parse start and + // strictly before completion, independent of same-millisecond clocks. + await new Promise(resolve => setTimeout(resolve, 10)) + rootEventAt = Date.now() + await new Promise(resolve => setTimeout(resolve, 10)) + } + const first = await parseAllSessions({ start, end: firstEnd }, 'test-synthetic') + expect(totalOutput(first)).toBe(5) + expect(rootEventAt).toBeGreaterThan(0) + _synthOnParse = null + + // Outside the 1ms burst, old code validated against cachePut completion + // and reused stale output because the in-parse event appeared older. The + // parse-start timestamp makes the validator reject reuse and rescan. + await new Promise(resolve => setTimeout(resolve, 5)) + setParseReuseValidator(sinceTs => rootEventAt < sinceTs ? 'clean' : 'dirty') + _synthYields = [..._synthYields, { + ...( _synthYields[0] as object ), deduplicationKey: 'synth-event-2', outputTokens: 7, + }] as never + await writeFile(synthFile, 'second input') + const second = await parseAllSessions( + { start, end: new Date(firstEnd.getTime() + 500) }, + 'test-synthetic', + ) + expect(totalOutput(second)).toBe(12) + }) }) diff --git a/tests/provider-env-declarations.test.ts b/tests/provider-env-declarations.test.ts index 2044571f..9e374057 100644 --- a/tests/provider-env-declarations.test.ts +++ b/tests/provider-env-declarations.test.ts @@ -34,6 +34,7 @@ const FILE_PROVIDERS: Record = { 'codex.ts': ['codex'], 'copilot.ts': ['copilot'], 'droid.ts': ['droid'], + 'dsh.ts': ['dsh'], 'hermes.ts': ['hermes'], 'lingtai-tui.ts': ['lingtai-tui'], // Its only literal read is CODEBURN_CURSOR_MAX_BUBBLES (cursor.ts:692). diff --git a/tests/provider-registry.test.ts b/tests/provider-registry.test.ts index 23b383e4..f2481e5c 100644 --- a/tests/provider-registry.test.ts +++ b/tests/provider-registry.test.ts @@ -14,7 +14,7 @@ function fakeProvider(name: string, discover: Provider['discoverSessions']): Pro describe('provider registry', () => { it('has core providers registered synchronously', () => { - expect(providers.map(p => p.name)).toEqual(['claude', 'cline', 'cline-cli', 'codewhale', 'codebuff', 'codex', 'copilot', 'devin', 'droid', 'gemini', 'hermes', 'ibm-bob', 'kilo-code', 'kiro', 'kimi', 'kimicode', 'lingtai-tui', 'mistral-vibe', 'mux', 'openclaw', 'openclaude', 'open-design', 'pi', 'omp', 'qwen', 'quickdesk', 'roo-code', 'zerostack', 'grok']) + expect(providers.map(p => p.name)).toEqual(['claude', 'cline', 'cline-cli', 'codewhale', 'codebuff', 'codex', 'copilot', 'devin', 'droid', 'dsh', 'gemini', 'hermes', 'ibm-bob', 'kilo-code', 'kiro', 'kimi', 'kimicode', 'lingtai-tui', 'mistral-vibe', 'mux', 'openclaw', 'openclaude', 'open-design', 'pi', 'omp', 'qwen', 'quickdesk', 'roo-code', 'zerostack', 'grok']) }) it('codebuff tool display names normalize codebuff-native names to canonical set', () => { diff --git a/tests/providers/claude-config-dirs.test.ts b/tests/providers/claude-config-dirs.test.ts index 571469b1..899fb335 100644 --- a/tests/providers/claude-config-dirs.test.ts +++ b/tests/providers/claude-config-dirs.test.ts @@ -390,6 +390,37 @@ describe('claude provider — config.json claudeConfigDirs (menubar-driven)', () expect(paths).toContain(join(personal, 'projects', '-Users-you-app')) }) + it('invalidates the exact parse memo when config.json adds a Claude discovery root', async () => { + const work = await makeConfigDir('claude-work', []) + const personal = await makeConfigDir('claude-personal', []) + const slug = '-Users-you-shared-app' + const cwd = '/Users/you/shared-app' + await writeSession(work, slug, 'sess-work', [ + summaryLine('sess-work', cwd), + userLine('u1', 'sess-work', cwd, 'hi from work'), + assistantLine('a1', 'u1', 'sess-work', cwd), + ]) + await writeSession(personal, slug, 'sess-personal', [ + summaryLine('sess-personal', cwd), + userLine('u2', 'sess-personal', cwd, 'hi from personal'), + assistantLine('a2', 'u2', 'sess-personal', cwd), + ]) + + await writeConfigJson([work]) + const first = await parseAllSessions(undefined, 'claude') + expect(first.flatMap(project => project.sessions).map(session => session.sessionId)).toEqual(['sess-work']) + + // Same argv/date range and unchanged env: only the effective roots sourced + // from config.json differ. A resident process must not return the exact-key + // memo populated by the first call. + await writeConfigJson([work, personal]) + const second = await parseAllSessions(undefined, 'claude') + expect(second.flatMap(project => project.sessions).map(session => session.sessionId).sort()).toEqual([ + 'sess-personal', + 'sess-work', + ]) + }) + it('lets env CLAUDE_CONFIG_DIRS override config.json', async () => { const fromEnv = await makeConfigDir('claude-env', ['-Users-you-app']) const fromFile = await makeConfigDir('claude-file', ['-Users-you-app']) diff --git a/tests/providers/codewhale.test.ts b/tests/providers/codewhale.test.ts index bdf9fd3a..0b6a29da 100644 --- a/tests/providers/codewhale.test.ts +++ b/tests/providers/codewhale.test.ts @@ -4,7 +4,7 @@ import { tmpdir } from 'os' import { join } from 'path' import { clearSessionCache, parseAllSessions } from '../../src/parser.js' -import { sessionCachePath } from '../../src/session-cache.js' +import { readCacheOnDisk } from '../fixtures/session-cache-io.js' import { MAX_SESSION_FILE_BYTES } from '../../src/fs-utils.js' import { codewhale, createCodeWhaleProvider } from '../../src/providers/codewhale.js' import type { ParsedProviderCall } from '../../src/providers/types.js' @@ -275,10 +275,8 @@ describe('codewhale provider', () => { expect(first[0]!.totalCostUSD).toBeCloseTo(0.75) expect(second[0]!.totalCostUSD).toBeCloseTo(0.75) - const cache = JSON.parse(await readFile(sessionCachePath(), 'utf-8')) as { - providers: { codewhale: { envFingerprint: string } } - } - expect(cache.providers.codewhale.envFingerprint).toMatch(/^[a-f0-9]{16}$/) + const cache = await readCacheOnDisk() + expect(cache.providers['codewhale']!.envFingerprint).toMatch(/^[a-f0-9]{16}$/) }) it('exposes canonical model and tool display names', () => { diff --git a/tests/providers/codex-resume.test.ts b/tests/providers/codex-resume.test.ts new file mode 100644 index 00000000..e63e41f8 --- /dev/null +++ b/tests/providers/codex-resume.test.ts @@ -0,0 +1,287 @@ +// Codex rollouts are append-only and the active ones are huge, so a run that +// re-read a grown session from byte 0 paid for the whole file to pick up a few +// KB. The parser now restarts from the last task boundary it recorded. What has +// to hold: the resumed decode is byte-identical to a full re-parse, and it +// really does start at an offset rather than quietly re-reading everything. +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' +import { appendFile, mkdtemp, mkdir, rm, writeFile } from 'fs/promises' +import { join } from 'path' +import { tmpdir } from 'os' + +const readLineCalls: Array<{ filePath: string; startByteOffset?: number }> = [] +vi.mock('../../src/fs-utils.js', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + readSessionLines: (filePath: string, skip?: unknown, options?: { startByteOffset?: number }) => { + readLineCalls.push({ filePath, startByteOffset: options?.startByteOffset }) + // eslint-disable-next-line @typescript-eslint/no-explicit-any + return (actual.readSessionLines as any)(filePath, skip, options) + }, + } +}) + +import { clearCodexMemCaches, flushCodexCache, withCodexCacheDirectory } from '../../src/codex-cache.js' +import { createCodexProvider } from '../../src/providers/codex.js' +import type { ParsedProviderCall } from '../../src/providers/types.js' + +let tmpDir: string +let sessionPath: string + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'codex-resume-')) + readLineCalls.length = 0 +}) + +afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) +}) + +function meta(): string { + return JSON.stringify({ + type: 'session_meta', + timestamp: '2026-04-14T10:00:00Z', + payload: { cwd: '/Users/test/proj', originator: 'codex-cli', session_id: 'sess-1', model: 'gpt-5.3-codex' }, + }) +} + +// One complete task: user turn, tools, an edit, an MCP call, usage, completion. +function task(n: number, cumulative: { input: number; cached: number; output: number; reasoning: number }): string[] { + const at = (s: number) => `2026-04-14T10:${String(n).padStart(2, '0')}:${String(s).padStart(2, '0')}Z` + return [ + JSON.stringify({ type: 'event_msg', timestamp: at(0), payload: { type: 'task_started' } }), + JSON.stringify({ + type: 'response_item', timestamp: at(1), + payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: `task ${n}` }] }, + }), + JSON.stringify({ + type: 'response_item', timestamp: at(2), + payload: { type: 'function_call', name: 'shell', call_id: `c${n}`, arguments: JSON.stringify({ command: `ls ${n}` }) }, + }), + JSON.stringify({ + type: 'response_item', timestamp: at(3), + payload: { type: 'function_call_output', call_id: `c${n}` }, + }), + JSON.stringify({ + type: 'event_msg', timestamp: at(4), + payload: { + type: 'patch_apply_end', success: n % 2 === 0, + changes: { [`/Users/test/proj/f${n}.ts`]: { unified_diff: '@@ -1 +1,2 @@\n-old\n+new\n+extra\n' } }, + }, + }), + JSON.stringify({ + type: 'event_msg', timestamp: at(5), + payload: { type: 'mcp_tool_call_end', call_id: `m${n}`, invocation: { server: 'github', tool: 'list' }, duration_ms: 120, result: { Ok: {} } }, + }), + JSON.stringify({ + type: 'response_item', timestamp: at(6), + payload: { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'x'.repeat(40) }] }, + }), + JSON.stringify({ + type: 'event_msg', timestamp: at(7), + payload: { + type: 'token_count', + info: { + last_token_usage: { input_tokens: 100, cached_input_tokens: 20, output_tokens: 50, reasoning_output_tokens: 10, total_tokens: 180 }, + total_token_usage: { + input_tokens: cumulative.input, cached_input_tokens: cumulative.cached, + output_tokens: cumulative.output, reasoning_output_tokens: cumulative.reasoning, + total_tokens: cumulative.input + cumulative.output + cumulative.reasoning, + }, + }, + }, + }), + JSON.stringify({ type: 'event_msg', timestamp: at(8), payload: { type: 'task_complete', duration_ms: 5000 } }), + ] +} + +function tasks(from: number, to: number): string[] { + const lines: string[] = [] + for (let n = from; n <= to; n++) { + lines.push(...task(n, { input: 100 * n, cached: 20 * n, output: 50 * n, reasoning: 10 * n })) + } + return lines +} + +async function writeRollout(lines: string[]): Promise { + const dir = join(tmpDir, 'sessions', '2026', '04', '14') + await mkdir(dir, { recursive: true }) + const path = join(dir, 'rollout-sess-1.jsonl') + await writeFile(path, lines.join('\n') + '\n') + return path +} + +async function parse(cacheDir: string, codexDir = tmpDir): Promise { + clearCodexMemCaches() + return withCodexCacheDirectory(cacheDir, async () => { + const provider = createCodexProvider(codexDir) + const sources = await provider.discoverSessions() + const seenKeys = new Set() + const calls: ParsedProviderCall[] = [] + for (const source of sources) { + for await (const call of provider.createSessionParser!(source, seenKeys).parse()) calls.push(call) + } + await flushCodexCache() + clearCodexMemCaches() + return calls + }) +} + +describe('codex incremental resume', () => { + it('resumes at a task boundary and matches a full re-parse exactly', async () => { + const warmCache = join(tmpDir, 'cache-warm') + const coldCache = join(tmpDir, 'cache-cold') + + sessionPath = await writeRollout([meta(), ...tasks(1, 3)]) + const first = await parse(warmCache) + expect(first.length).toBe(3) + + await appendFile(sessionPath, tasks(4, 6).join('\n') + '\n') + + readLineCalls.length = 0 + const resumed = await parse(warmCache) + const resumeReads = readLineCalls.filter(c => c.filePath === sessionPath) + // The parse re-entered the file at a boundary rather than at byte 0. + expect(resumeReads.some(c => (c.startByteOffset ?? 0) > 0)).toBe(true) + expect(resumeReads.every(c => (c.startByteOffset ?? 0) > 0)).toBe(true) + + // Byte-for-byte agreement with a decode that never saw a cache. + const full = await parse(coldCache) + expect(resumed.length).toBe(6) + expect(JSON.stringify(resumed)).toBe(JSON.stringify(full)) + }) + + it('stays exact across successive appends, resuming from a resumed state', async () => { + const warmCache = join(tmpDir, 'cache-warm') + + sessionPath = await writeRollout([meta(), ...tasks(1, 2)]) + await parse(warmCache) + await appendFile(sessionPath, tasks(3, 4).join('\n') + '\n') + await parse(warmCache) + // A tail with no task boundary at all: the next run restarts from the same + // boundary and re-decodes the open task. + await appendFile(sessionPath, tasks(5, 5).slice(1).join('\n') + '\n') + const resumed = await parse(warmCache) + + const full = await parse(join(tmpDir, 'cache-cold')) + expect(JSON.stringify(resumed)).toBe(JSON.stringify(full)) + }) + + it('serves an unchanged file from the cache without reading it', async () => { + const cacheDir = join(tmpDir, 'cache') + sessionPath = await writeRollout([meta(), ...tasks(1, 2)]) + const first = await parse(cacheDir) + + readLineCalls.length = 0 + const second = await parse(cacheDir) + expect(readLineCalls.filter(c => c.filePath === sessionPath)).toHaveLength(0) + expect(JSON.stringify(second)).toBe(JSON.stringify(first)) + }) + + it('falls back to a full re-parse when the stored resume state is unusable', async () => { + const cacheDir = join(tmpDir, 'cache') + sessionPath = await writeRollout([meta(), ...tasks(1, 2)]) + await parse(cacheDir) + + const cachePath = join(cacheDir, 'codex-results.json') + const { readFile } = await import('fs/promises') + const raw = JSON.parse(await readFile(cachePath, 'utf-8')) + raw.files[sessionPath].resumeState = { garbage: true } + await writeFile(cachePath, JSON.stringify(raw)) + const { clearCodexMemCaches } = await import('../../src/codex-cache.js') + clearCodexMemCaches() + + await appendFile(sessionPath, tasks(3, 3).join('\n') + '\n') + readLineCalls.length = 0 + const resumed = await parse(cacheDir) + expect(readLineCalls.filter(c => c.filePath === sessionPath).every(c => (c.startByteOffset ?? 0) === 0)).toBe(true) + + const full = await parse(join(tmpDir, 'cache-cold')) + expect(JSON.stringify(resumed)).toBe(JSON.stringify(full)) + }) +}) + +// The resume snapshot has to carry EVERY field the decode reads from an earlier +// line. Missing one shows up only when the split lands between the line that +// sets it and the line that reads it — so split at every line boundary and +// require the resumed decode to equal a full one each time. +const J = (o: unknown) => JSON.stringify(o) +const ts = (n: number, s: number) => `2026-04-14T${String(10 + Math.floor(n / 60)).padStart(2, '0')}:${String(n % 60).padStart(2, '0')}:${String(s).padStart(2, '0')}Z` + +function richTask(n: number, opts: { tokens?: false | 'empty'; reasoning?: number; model?: string; noComplete?: boolean } = {}): string[] { + const lines: string[] = [J({ type: 'event_msg', timestamp: ts(n, 0), payload: { type: 'task_started' } })] + if (opts.model) lines.push(J({ type: 'turn_context', timestamp: ts(n, 0), payload: { model: opts.model } })) + lines.push( + J({ type: 'response_item', timestamp: ts(n, 1), payload: { type: 'message', role: 'user', content: [{ type: 'input_text', text: `please do task ${n} with some length` }] } }), + J({ type: 'response_item', timestamp: ts(n, 2), payload: { type: 'function_call', name: 'shell', call_id: `c${n}`, arguments: J({ command: `ls ${n}` }) } }), + J({ type: 'response_item', timestamp: ts(n, 3), payload: { type: 'function_call_output', call_id: `c${n}` } }), + J({ type: 'event_msg', timestamp: ts(n, 4), payload: { type: 'patch_apply_end', success: n % 3 !== 0, changes: { [`/Users/test/proj/f${n}.ts`]: { unified_diff: '@@ -1 +1,2 @@\n-old\n+new\n+extra\n' } } } }), + J({ type: 'event_msg', timestamp: ts(n, 5), payload: { type: 'mcp_tool_call_end', call_id: `m${n}`, invocation: { server: 'github', tool: 'list' }, duration_ms: 120, result: { Ok: {} } } }), + J({ type: 'response_item', timestamp: ts(n, 6), payload: { type: 'message', role: 'assistant', content: [{ type: 'output_text', text: 'y'.repeat(120) }] } }), + ) + if (opts.tokens === 'empty') { + // No `info`: the estimated-usage path, which advances estCounter. + lines.push(J({ type: 'event_msg', timestamp: ts(n, 7), payload: { type: 'token_count' } })) + } else if (opts.tokens !== false) { + const c = { input: 100 * n, cached: 20 * n, output: 50 * n, reasoning: (opts.reasoning ?? 10) * n } + lines.push(J({ type: 'event_msg', timestamp: ts(n, 7), payload: { type: 'token_count', info: { + last_token_usage: { input_tokens: 100, cached_input_tokens: 20, output_tokens: 50, reasoning_output_tokens: opts.reasoning ?? 10, total_tokens: 180 }, + total_token_usage: { input_tokens: c.input, cached_input_tokens: c.cached, output_tokens: c.output, reasoning_output_tokens: c.reasoning, total_tokens: c.input + c.output + c.reasoning }, + } } })) + } + if (!opts.noComplete) lines.push(J({ type: 'event_msg', timestamp: ts(n, 8), payload: { type: 'task_complete', duration_ms: 5000 } })) + return lines +} + +const RICH_LINES = [ + J({ type: 'session_meta', timestamp: ts(0, 0), payload: { cwd: '/Users/test/proj', originator: 'codex-cli', session_id: 'sess-1', model: 'gpt-5.3-codex' } }), + ...richTask(1), + ...richTask(2, { tokens: 'empty' }), + ...richTask(7, { tokens: 'empty' }), + ...richTask(3, { model: 'gpt-5.3-codex-mini' }), + ...richTask(4, { reasoning: 33 }), + ...richTask(5, { noComplete: true }), + ...richTask(6), +] + +const FORK_LINES = [ + J({ type: 'session_meta', timestamp: ts(0, 0), payload: { cwd: '/Users/test/proj', originator: 'codex-cli', session_id: 'sess-2', forked_from_id: 'sess-1', model: 'gpt-5.3-codex' } }), + // Parent history replayed inside the 5s fork cutoff: must stay skipped across a split. + ...richTask(0).map(l => l.replace(/2026-04-14T10:00:0\d/g, '2026-04-14T10:00:01')), + ...richTask(11), + ...richTask(12, { tokens: 'empty' }), +] + +describe('codex resume differential', () => { + async function rollout(lines: string[]): Promise<{ codexDir: string; path: string }> { + const codexDir = await mkdtemp(join(tmpdir(), 'codex-split-')) + const dir = join(codexDir, 'sessions', '2026', '04', '14') + await mkdir(dir, { recursive: true }) + const path = join(dir, 'rollout-sess-1.jsonl') + await writeFile(path, lines.join('\n') + '\n') + return { codexDir, path } + } + + async function assertEverySplitMatches(lines: string[]): Promise { + const base = await rollout(lines) + const full = await parse(await mkdtemp(join(tmpdir(), 'codex-c-')), base.codexDir) + expect(full.length).toBeGreaterThan(0) + + for (let k = 1; k < lines.length; k++) { + const split = await rollout(lines.slice(0, k)) + const cacheDir = await mkdtemp(join(tmpdir(), 'codex-c-')) + await parse(cacheDir, split.codexDir) + await appendFile(split.path, lines.slice(k).join('\n') + '\n') + const resumed = await parse(cacheDir, split.codexDir) + expect(JSON.stringify(resumed), `split after line ${k}: ${lines[k - 1]!.slice(0, 80)}`).toBe(JSON.stringify(full)) + } + } + + it('matches a full re-parse at every line boundary of a rich session', async () => { + await assertEverySplitMatches(RICH_LINES) + }, 60_000) + + it('matches a full re-parse at every line boundary of a forked session', async () => { + await assertEverySplitMatches(FORK_LINES) + }, 60_000) +}) diff --git a/tests/providers/cursor.test.ts b/tests/providers/cursor.test.ts index 61151a6f..34c28d88 100644 --- a/tests/providers/cursor.test.ts +++ b/tests/providers/cursor.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest' -import { mkdtemp, mkdir, writeFile, rm } from 'node:fs/promises' +import { mkdtemp, mkdir, readFile, writeFile, rm } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' import { createRequire } from 'node:module' @@ -87,6 +87,37 @@ describe('cursor cache', () => { const result = await readCachedResults('/nonexistent/path.db', new Date(0).toISOString()) expect(result).toBeNull() }) + + it('honors CODEBURN_CACHE_DIR at call time', async () => { + const root = await mkdtemp(join(tmpdir(), 'cursor-cache-override-')) + const previousCacheDir = process.env['CODEBURN_CACHE_DIR'] + const dbPath = join(root, 'state.vscdb') + const firstCacheDir = join(root, 'cache-a') + const secondCacheDir = join(root, 'cache-b') + const firstFloor = '2026-01-01T00:00:00.000Z' + const secondFloor = '2026-02-01T00:00:00.000Z' + await writeFile(dbPath, 'cursor-db-fixture') + + try { + const { writeCachedResults } = await import('../../src/cursor-cache.js') + process.env['CODEBURN_CACHE_DIR'] = firstCacheDir + await writeCachedResults(dbPath, [], firstFloor) + + process.env['CODEBURN_CACHE_DIR'] = secondCacheDir + await writeCachedResults(dbPath, [], secondFloor) + + const firstPath = join(firstCacheDir, 'cursor-results.json') + const secondPath = join(secondCacheDir, 'cursor-results.json') + const first = JSON.parse(await readFile(firstPath, 'utf-8')) as { lookbackFloor: string } + const second = JSON.parse(await readFile(secondPath, 'utf-8')) as { lookbackFloor: string } + expect(first.lookbackFloor).toBe(firstFloor) + expect(second.lookbackFloor).toBe(secondFloor) + } finally { + if (previousCacheDir === undefined) delete process.env['CODEBURN_CACHE_DIR'] + else process.env['CODEBURN_CACHE_DIR'] = previousCacheDir + await rm(root, { recursive: true, force: true }) + } + }) }) // Regression: Cursor renamed the per-workspace composer list key from diff --git a/tests/providers/dsh.test.ts b/tests/providers/dsh.test.ts new file mode 100644 index 00000000..fc2142f9 --- /dev/null +++ b/tests/providers/dsh.test.ts @@ -0,0 +1,620 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { mkdtemp, mkdir, writeFile, readFile, rm, stat } from 'fs/promises' +import { join } from 'path' +import { homedir, tmpdir } from 'os' +import zlib from 'zlib' + +import { createDshProvider, readZstdLines } from '../../src/providers/dsh.js' +import { calculateCost } from '../../src/models.js' +import type { ParsedProviderCall } from '../../src/providers/types.js' + +// DSH session logs are concatenations of INDEPENDENT zstd frames (one per +// appended event batch), so fixtures must compress each batch separately — +// a single zstdCompressSync over the whole file is a different (single-frame) +// format than what DSH writes. + +const zstdCompress = (zlib as { zstdCompressSync?: (buf: Buffer) => Buffer }).zstdCompressSync +// node:zlib gained zstd in 22.15; the package floor (and CI's pinned Node) is +// 22.13. Container-specific tests skip there; the rest fall back to plain jsonl +// so the parsing semantics are still exercised. +const itZstd = zstdCompress ? it : it.skip + +let tmpDir: string + +beforeEach(async () => { + tmpDir = await mkdtemp(join(tmpdir(), 'dsh-test-')) +}) + +afterEach(async () => { + await rm(tmpDir, { recursive: true, force: true }) +}) + +function sessionHeader(opts: { id?: string; cwd?: string } = {}) { + return JSON.stringify({ + type: 'session', + version: 0, + id: opts.id ?? 'session-00000000-0000-0000-0000-000000000001', + createdAt: 1786707336131, + cwd: opts.cwd ?? 'C:\\Users\\test\\myproject', + delegationDepth: 0, + agentPreset: 'cordis', + }) +} + +function requestHeader(model: string, time = 1786707337000) { + return JSON.stringify({ + type: 'request/header', + seq: 10, + time, + data: { header: { config: { provider: 'deepseek-official', model, reasoningEffort: 'max', maxTokens: 256000 } } }, + }) +} + +function turnStart(turn: number, time: number) { + return JSON.stringify({ type: 'turn/start', seq: 1, time, data: { turn } }) +} + +function userMessage(text: string, time: number) { + return JSON.stringify({ + type: 'user/message', + seq: 2, + time, + data: { content: [{ type: 'text', text }], source: { kind: 'user' }, role: 'user', id: 'msg-1' }, + }) +} + +function chunkUsage(turn: number, step: number, usage: Record, time: number) { + return JSON.stringify({ + type: 'assistant/chunk', + seq: 3, + time, + data: { turn, step, chunk: { type: 'usage', usage } }, + }) +} + +function assistantMessage(turn: number, step: number, usage: Record | undefined, time: number) { + return JSON.stringify({ + type: 'assistant/message', + seq: 4, + time, + data: { + turn, + step, + message: { role: 'assistant', content: [{ type: 'text', text: 'done' }] }, + ...(usage ? { usage } : {}), + }, + }) +} + +function toolCall(turn: number, step: number, name: string, args: Record, time: number) { + return JSON.stringify({ + type: 'tool/call', + seq: 5, + time, + data: { turn, step, callId: `call_${name}`, name, arguments: JSON.stringify(args) }, + }) +} + +// Write one frame per batch of lines, matching DSH's append-per-batch layout. +async function writeZstdSession(projectDirName: string, sessionDirName: string, batches: string[][]) { + const dir = join(tmpDir, 'sessions', projectDirName, sessionDirName) + await mkdir(dir, { recursive: true }) + if (!zstdCompress) { + const filePath = join(dir, 'session.jsonl') + await writeFile(filePath, batches.map(lines => lines.join('\n') + '\n').join('')) + return filePath + } + const filePath = join(dir, 'session.jsonl.zstd') + const frames = batches.map(lines => zstdCompress(Buffer.from(lines.join('\n') + '\n', 'utf-8'))) + await writeFile(filePath, Buffer.concat(frames)) + return filePath +} + +async function writePlainSession(projectDirName: string, sessionDirName: string, lines: string[]) { + const dir = join(tmpDir, 'sessions', projectDirName, sessionDirName) + await mkdir(dir, { recursive: true }) + const filePath = join(dir, 'session.jsonl') + await writeFile(filePath, lines.join('\n') + '\n') + return filePath +} + +async function parseAll(provider: ReturnType, filePath: string): Promise { + const source = { path: filePath, project: 'myproject', provider: 'dsh' } + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, new Set()).parse()) { + calls.push(call) + } + return calls +} + +describe('dsh provider - session discovery', () => { + itZstd('discovers a multi-frame zstd session, project from the header cwd', async () => { + await writeZstdSession('--C-Users-test-myproject--', 'session-abc', [ + [sessionHeader({ cwd: 'C:\\Users\\test\\myproject' })], + [assistantMessage(1, 1, { inputTokens: 100, outputTokens: 10 }, 1786707340000)], + ]) + + const provider = createDshProvider(tmpDir) + const sessions = await provider.discoverSessions() + + expect(sessions).toHaveLength(1) + expect(sessions[0]!.provider).toBe('dsh') + expect(sessions[0]!.project).toBe('myproject') + expect(sessions[0]!.path).toContain('session.jsonl.zstd') + }) + + it('discovers the uncompressed session.jsonl variant (compression=none)', async () => { + await writePlainSession('--home-u-proj--', 'session-plain', [ + sessionHeader({ cwd: '/home/u/proj' }), + assistantMessage(1, 1, { inputTokens: 100, outputTokens: 10 }, 1786707340000), + ]) + + const provider = createDshProvider(tmpDir) + const sessions = await provider.discoverSessions() + + expect(sessions).toHaveLength(1) + expect(sessions[0]!.path).toContain('session.jsonl') + expect(sessions[0]!.path).not.toContain('zstd') + expect(sessions[0]!.project).toBe('proj') + }) + + it('returns empty for a non-existent home', async () => { + const provider = createDshProvider('/nonexistent/dsh/home') + expect(await provider.discoverSessions()).toEqual([]) + }) + + it('skips session dirs without a session log', async () => { + await mkdir(join(tmpDir, 'sessions', '--x--', 'session-empty'), { recursive: true }) + const provider = createDshProvider(tmpDir) + expect(await provider.discoverSessions()).toEqual([]) + }) + + it('DSH_HOME relocates discovery; an empty string is treated as unset', async () => { + const home = join(tmpDir, 'dsh-home') + await mkdir(join(home, 'sessions', '--x--', 'session-env'), { recursive: true }) + await writeFile( + join(home, 'sessions', '--x--', 'session-env', 'session.jsonl'), + sessionHeader({ cwd: '/x' }) + '\n', + ) + + const saved = process.env['DSH_HOME'] + process.env['DSH_HOME'] = home + try { + const sessions = await createDshProvider().discoverSessions() + expect(sessions).toHaveLength(1) + } finally { + if (saved === undefined) delete process.env['DSH_HOME'] + else process.env['DSH_HOME'] = saved + } + + process.env['DSH_HOME'] = '' + try { + const roots = await createDshProvider().probeRoots!() + expect(roots).toEqual([{ path: join(homedir(), '.dsh', 'sessions'), label: 'sessions' }]) + } finally { + if (saved === undefined) delete process.env['DSH_HOME'] + else process.env['DSH_HOME'] = saved + } + }) + + it('probeRoots reports the sessions dir under the factory root', async () => { + expect(await createDshProvider('/tmp/dsh-a').probeRoots!()).toEqual([ + { path: join('/tmp/dsh-a', 'sessions'), label: 'sessions' }, + ]) + }) +}) + +describe('dsh provider - parsing', () => { + itZstd('decodes events spread across multiple independent zstd frames', async () => { + const filePath = await writeZstdSession('--C-Users-test-myproject--', 'session-multi', [ + [sessionHeader({ id: 'session-multi', cwd: 'C:\\Users\\test\\myproject' })], + [turnStart(1, 1786707339000), userMessage('build the thing', 1786707339100)], + [chunkUsage(1, 1, { inputTokens: 500, outputTokens: 50 }, 1786707340000)], + [chunkUsage(1, 2, { inputTokens: 800, outputTokens: 80 }, 1786707341000)], + ]) + + const calls = await parseAll(createDshProvider(tmpDir), filePath) + expect(calls).toHaveLength(2) + expect(calls[0]!.inputTokens).toBe(500) + expect(calls[1]!.inputTokens).toBe(800) + }) + + it('a final assistant/message usage REPLACES the earlier chunk sample for the same turn/step', async () => { + const filePath = await writeZstdSession('--C-Users-test-myproject--', 'session-replace', [ + [sessionHeader({ id: 'session-replace' })], + [turnStart(1, 1786707339000)], + // Early sample, then the final report of the SAME API call: the totals + // must come from the final report only, not the sum of both. + [chunkUsage(1, 1, { inputTokens: 14900, outputTokens: 600, reasoningTokens: 500 }, 1786707340000)], + [assistantMessage(1, 1, { inputTokens: 14981, outputTokens: 656, cacheReadTokens: 0, reasoningTokens: 609 }, 1786707340050)], + ]) + + const calls = await parseAll(createDshProvider(tmpDir), filePath) + expect(calls).toHaveLength(1) + expect(calls[0]!.inputTokens).toBe(14981) + expect(calls[0]!.outputTokens).toBe(656) + expect(calls[0]!.reasoningTokens).toBe(609) + expect(calls[0]!.timestamp).toBe(new Date(1786707340050).toISOString()) + }) + + it('a chunk sample arriving after the final report does not overwrite it', async () => { + const filePath = await writeZstdSession('--C-Users-test-myproject--', 'session-late', [ + [sessionHeader({ id: 'session-late' })], + [assistantMessage(1, 1, { inputTokens: 100, outputTokens: 10 }, 1786707340050)], + [chunkUsage(1, 1, { inputTokens: 999, outputTokens: 99 }, 1786707340100)], + ]) + + const calls = await parseAll(createDshProvider(tmpDir), filePath) + expect(calls).toHaveLength(1) + expect(calls[0]!.inputTokens).toBe(100) + }) + + it('falls back to the chunk sample when no assistant/message usage arrives', async () => { + const filePath = await writeZstdSession('--C-Users-test-myproject--', 'session-sample', [ + [sessionHeader({ id: 'session-sample' })], + [chunkUsage(2, 3, { inputTokens: 42, outputTokens: 7 }, 1786707340000)], + ]) + + const calls = await parseAll(createDshProvider(tmpDir), filePath) + expect(calls).toHaveLength(1) + expect(calls[0]!.inputTokens).toBe(42) + expect(calls[0]!.deduplicationKey).toBe('dsh:session-sample:2:3') + }) + + it('steps inherit the model of the most recent request/header', async () => { + const filePath = await writeZstdSession('--C-Users-test-myproject--', 'session-model', [ + [sessionHeader({ id: 'session-model' })], + [requestHeader('deepseek-v4-pro', 1786707337000)], + [assistantMessage(1, 1, { inputTokens: 100, outputTokens: 10 }, 1786707340000)], + [assistantMessage(1, 2, { inputTokens: 200, outputTokens: 20 }, 1786707341000)], + [requestHeader('deepseek-v4-flash', 1786707342000)], + [assistantMessage(2, 1, { inputTokens: 300, outputTokens: 30 }, 1786707343000)], + ]) + + const calls = await parseAll(createDshProvider(tmpDir), filePath) + expect(calls.map(c => c.model)).toEqual(['deepseek-v4-pro', 'deepseek-v4-pro', 'deepseek-v4-flash']) + }) + + it('bills reasoning tokens at the output rate', async () => { + const filePath = await writeZstdSession('--C-Users-test-myproject--', 'session-reason', [ + [sessionHeader({ id: 'session-reason' })], + [requestHeader('deepseek-v4-pro')], + [assistantMessage(1, 1, { inputTokens: 1000, outputTokens: 100, cacheWriteTokens: 50, cacheReadTokens: 500, reasoningTokens: 400 }, 1786707340000)], + ]) + + const calls = await parseAll(createDshProvider(tmpDir), filePath) + expect(calls).toHaveLength(1) + expect(calls[0]!.costUSD).toBeCloseTo(calculateCost('deepseek-v4-pro', 1000, 500, 50, 500, 0), 12) + }) + + it('collects mapped tools, skill names and bash commands from tool/call events', async () => { + const filePath = await writeZstdSession('--C-Users-test-myproject--', 'session-tools', [ + [sessionHeader({ id: 'session-tools' })], + [ + toolCall(1, 1, 'read', { path: '/x/a.ts' }, 1786707339500), + toolCall(1, 1, 'edit', { path: '/x/a.ts' }, 1786707339600), + toolCall(1, 1, 'bash', { command: 'git status && bun test' }, 1786707339700), + toolCall(1, 1, 'skill', { name: 'coding-agent-orchestration' }, 1786707339800), + toolCall(1, 1, 'cordis_run', { id: 'j1' }, 1786707339900), + chunkUsage(1, 1, { inputTokens: 100, outputTokens: 10 }, 1786707340000), + ], + ]) + + const calls = await parseAll(createDshProvider(tmpDir), filePath) + expect(calls).toHaveLength(1) + expect(calls[0]!.tools).toEqual(['Read', 'Edit', 'Bash', 'Skill', 'cordis_run']) + expect(calls[0]!.bashCommands).toEqual(['git', 'bun']) + expect(calls[0]!.skills).toEqual(['coding-agent-orchestration']) + }) + + it('pairs the user message of the turn and carries session id and project', async () => { + const filePath = await writeZstdSession('--C-Users-test-myproject--', 'session-ctx', [ + [sessionHeader({ id: 'session-ctx', cwd: 'C:\\Users\\test\\myproject' })], + [turnStart(1, 1786707339000), userMessage('first question', 1786707339100)], + [chunkUsage(1, 1, { inputTokens: 100, outputTokens: 10 }, 1786707340000)], + [turnStart(2, 1786707350000), userMessage('second question', 1786707350100)], + [chunkUsage(2, 1, { inputTokens: 200, outputTokens: 20 }, 1786707351000)], + ]) + + const calls = await parseAll(createDshProvider(tmpDir), filePath) + expect(calls).toHaveLength(2) + expect(calls[0]!.userMessage).toBe('first question') + expect(calls[1]!.userMessage).toBe('second question') + expect(calls[0]!.sessionId).toBe('session-ctx') + expect(calls[0]!.project).toBe('myproject') + expect(calls[0]!.projectPath).toBe('C:\\Users\\test\\myproject') + }) + + it('parses the uncompressed session.jsonl variant', async () => { + const filePath = await writePlainSession('--home-u-proj--', 'session-plain', [ + sessionHeader({ id: 'session-plain', cwd: '/home/u/proj' }), + turnStart(1, 1786707339000), + userMessage('hello', 1786707339100), + chunkUsage(1, 1, { inputTokens: 123, outputTokens: 45 }, 1786707340000), + ]) + + const calls = await parseAll(createDshProvider(tmpDir), filePath) + expect(calls).toHaveLength(1) + expect(calls[0]!.inputTokens).toBe(123) + expect(calls[0]!.outputTokens).toBe(45) + }) + + it('skips buckets whose usage is all zero', async () => { + const filePath = await writeZstdSession('--C-Users-test-myproject--', 'session-zero', [ + [sessionHeader({ id: 'session-zero' })], + [assistantMessage(1, 1, { inputTokens: 0, outputTokens: 0 }, 1786707340000)], + ]) + + const calls = await parseAll(createDshProvider(tmpDir), filePath) + expect(calls).toHaveLength(0) + }) + + itZstd('ignores a torn final frame appended by a crashed writer', async () => { + const dir = join(tmpDir, 'sessions', '--C-Users-test-myproject--', 'session-torn') + await mkdir(dir, { recursive: true }) + const filePath = join(dir, 'session.jsonl.zstd') + const good = zstdCompress!(Buffer.from( + sessionHeader({ id: 'session-torn' }) + '\n' + + chunkUsage(1, 1, { inputTokens: 100, outputTokens: 10 }, 1786707340000) + '\n', + )) + const torn = zstdCompress!(Buffer.from(chunkUsage(1, 2, { inputTokens: 1, outputTokens: 1 }, 1786707341000) + '\n')) + await writeFile(filePath, Buffer.concat([good, torn.subarray(0, Math.floor(torn.length / 2))])) + + const calls = await parseAll(createDshProvider(tmpDir), filePath) + expect(calls).toHaveLength(1) + expect(calls[0]!.inputTokens).toBe(100) + }) + + it('deduplicates (turn, step) calls seen across multiple parses', async () => { + const filePath = await writeZstdSession('--C-Users-test-myproject--', 'session-dedup', [ + [sessionHeader({ id: 'session-dedup' })], + [chunkUsage(1, 1, { inputTokens: 100, outputTokens: 10 }, 1786707340000)], + ]) + + const provider = createDshProvider(tmpDir) + const source = { path: filePath, project: 'myproject', provider: 'dsh' } + const seenKeys = new Set() + + const firstRun: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, seenKeys).parse()) firstRun.push(call) + const secondRun: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, seenKeys).parse()) secondRun.push(call) + + expect(firstRun).toHaveLength(1) + expect(secondRun).toHaveLength(0) + }) + + it('handles a missing session file gracefully', async () => { + const provider = createDshProvider(tmpDir) + const source = { path: join(tmpDir, 'nope', 'session.jsonl.zstd'), project: 'test', provider: 'dsh' } + const calls: ParsedProviderCall[] = [] + for await (const call of provider.createSessionParser(source, new Set()).parse()) calls.push(call) + expect(calls).toHaveLength(0) + }) +}) + +describe('dsh provider - display names', () => { + const provider = createDshProvider('/tmp') + + it('has correct name and displayName', () => { + expect(provider.name).toBe('dsh') + expect(provider.displayName).toBe('DeepSeek Harness') + }) + + it('maps deepseek models to readable names and passes unknown ids through', () => { + expect(provider.modelDisplayName('deepseek-v4-pro')).toBe('DeepSeek v4 Pro') + expect(provider.modelDisplayName('some-future-model')).toBe('some-future-model') + }) + + it('normalizes tool names, keeping unknown names raw', () => { + expect(provider.toolDisplayName('bash')).toBe('Bash') + expect(provider.toolDisplayName('pwsh')).toBe('Bash') + expect(provider.toolDisplayName('todo_write')).toBe('TodoWrite') + expect(provider.toolDisplayName('cordis_run')).toBe('cordis_run') + }) +}) + +describe('dsh provider - real log fidelity', () => { + // The upstream snapshot from deepseek-ai/deepseek-harness + // (examples/acp-agent/tests/snapshots/bash-tool-turn/session.jsonl), with its + // template placeholders filled in. It is the reference for every shape the + // parser reads: packed `reasoning-chunks`/`tool-call-chunks` storage rows, a + // plugin-injected user/message beside the typed one, and both the streamed + // usage chunk and the final assistant/message usage for the same step. + async function writeRealSession(): Promise { + const lines = (await readFile(join(import.meta.dirname, '../fixtures/dsh/bash-tool-turn.jsonl'), 'utf-8')) + .split('\n').filter(l => l.trim()) + return writePlainSession('--home-u-proj--', 'e128dda9-ed11-4868-8266-0ef90d03c3d6', lines) + } + + it('parses the upstream snapshot: two steps, exact usage, model from the message source', async () => { + const calls = await parseAll(createDshProvider(tmpDir), await writeRealSession()) + + expect(calls).toHaveLength(2) + expect(calls.map(c => c.model)).toEqual(['deepseek-v4-flash', 'deepseek-v4-flash']) + expect(calls[0]).toMatchObject({ + inputTokens: 2877, + outputTokens: 90, + cacheReadInputTokens: 0, + reasoningTokens: 18, + sessionId: 'e128dda9-ed11-4868-8266-0ef90d03c3d6', + project: 'proj', + projectPath: '/home/u/proj', + workingDirectory: '/home/u/proj', + }) + expect(calls[1]).toMatchObject({ inputTokens: 168, outputTokens: 25, cacheReadInputTokens: 2816, reasoningTokens: 22 }) + // Reasoning bills at the output rate, so it must not appear as input. + expect(calls[0]!.costUSD).toBe(calculateCost('deepseek-v4-flash', 2877, 90 + 18, 0, 0, 0)) + expect(calls[0]!.costUSD).toBeGreaterThan(0) + }) + + it('takes the typed prompt as the preview, not the plugin-injected context', async () => { + const calls = await parseAll(createDshProvider(tmpDir), await writeRealSession()) + + expect(calls[0]!.userMessage).toBe('Use the bash tool to run exactly: echo TERMINAL_OK. Then reply with the single word DONE and stop.') + expect(calls[0]!.userMessage).not.toContain('Current runtime context') + }) + + it('reads the tool call through the packed chunk rows around it', async () => { + const calls = await parseAll(createDshProvider(tmpDir), await writeRealSession()) + + expect(calls[0]!.tools).toEqual(['Bash']) + expect(calls[0]!.bashCommands).toEqual(['echo']) + }) +}) + +describe('dsh provider - defensive reads', () => { + it('skips a log stamped with an unsupported session format version', async () => { + const filePath = await writePlainSession('--home-u-proj--', 'session-future', [ + JSON.stringify({ type: 'session', version: 1, id: 'session-future', createdAt: 1786707336131, cwd: '/home/u/proj', delegationDepth: 0 }), + chunkUsage(1, 1, { inputTokens: 100, outputTokens: 10 }, 1786707340000), + ]) + + expect(await createDshProvider(tmpDir).discoverSessions()).toEqual([]) + expect(await parseAll(createDshProvider(tmpDir), filePath)).toEqual([]) + }) + + it('does not bill a forked session for the events it inherited from its parent', async () => { + const filePath = await writePlainSession('--home-u-proj--', 'session-fork', [ + JSON.stringify({ + type: 'session', version: 0, id: 'session-fork', createdAt: 1786707336131, + cwd: '/home/u/proj', parentSession: 'session-parent', seedLength: 3, delegationDepth: 0, + }), + // seq 0..2 are a verbatim copy of the parent's log, which codeburn parses + // as its own session; only seq >= 3 is this session's own work. + JSON.stringify({ type: 'turn/start', seq: 0, time: 1786707337000, data: { turn: 1 } }), + JSON.stringify({ type: 'assistant/message', seq: 1, time: 1786707337100, data: { turn: 1, step: 1, message: { role: 'assistant', content: [] }, usage: { inputTokens: 9999, outputTokens: 999 } } }), + JSON.stringify({ type: 'session/end-seed', seq: 2, time: 1786707337200, data: {} }), + JSON.stringify({ type: 'turn/start', seq: 3, time: 1786707338000, data: { turn: 2 } }), + JSON.stringify({ type: 'assistant/message', seq: 4, time: 1786707338100, data: { turn: 2, step: 1, message: { role: 'assistant', content: [] }, usage: { inputTokens: 100, outputTokens: 10 } } }), + ]) + + const calls = await parseAll(createDshProvider(tmpDir), filePath) + expect(calls).toHaveLength(1) + expect(calls[0]!.inputTokens).toBe(100) + }) + + it('ignores unknown event types, packed chunk rows, and unparsable lines', async () => { + const filePath = await writePlainSession('--home-u-proj--', 'session-noise', [ + sessionHeader({ id: 'session-noise', cwd: '/home/u/proj' }), + JSON.stringify({ type: 'agent/inbox/spliced', seq: 0, time: 1786707337000, data: { target: 'next-turn' } }), + JSON.stringify({ type: 'reasoning-chunks', seq0: 1, time0: 1786707337100, data: { turn: 1, step: 1, index: 0, dt: [0], texts: ['a', 'b'] } }), + '{ not json at all', + ' ', + chunkUsage(1, 1, { inputTokens: 100, outputTokens: 10 }, 1786707340000), + ]) + + const calls = await parseAll(createDshProvider(tmpDir), filePath) + expect(calls).toHaveLength(1) + expect(calls[0]!.inputTokens).toBe(100) + }) + + it('falls back to the header createdAt when a usage event carries no usable time', async () => { + const filePath = await writePlainSession('--home-u-proj--', 'session-notime', [ + JSON.stringify({ type: 'session', version: 0, id: 'session-notime', createdAt: 1786707336131, cwd: '/home/u/proj', delegationDepth: 0 }), + JSON.stringify({ type: 'assistant/message', seq: 1, data: { turn: 1, step: 1, message: { role: 'assistant', content: [] }, usage: { inputTokens: 100, outputTokens: 10 } } }), + ]) + + const calls = await parseAll(createDshProvider(tmpDir), filePath) + expect(calls).toHaveLength(1) + expect(calls[0]!.timestamp).toBe(new Date(1786707336131).toISOString()) + }) +}) + +describe('dsh provider - real log, real container', () => { + itZstd('reads the upstream snapshot out of multi-frame zstd with a torn tail identically to plain jsonl', async () => { + const lines = (await readFile(join(import.meta.dirname, '../fixtures/dsh/bash-tool-turn.jsonl'), 'utf-8')) + .split('\n').filter(l => l.trim()) + const plain = await parseAll( + createDshProvider(tmpDir), + await writePlainSession('--home-u-proj--', 'plain', lines), + ) + + // Header batch, then three append batches — the layout DSH writes. + const dir = join(tmpDir, 'sessions', '--home-u-proj--', 'framed') + await mkdir(dir, { recursive: true }) + const filePath = join(dir, 'session.jsonl.zstd') + const frames = [[lines[0]!], lines.slice(1, 10), lines.slice(10, 25), lines.slice(25)] + .map(batch => zstdCompress!(Buffer.from(batch.join('\n') + '\n', 'utf-8'))) + // A crashed writer's half-written final batch, carrying usage that must not count. + const torn = zstdCompress!(Buffer.from(assistantMessage(9, 9, { inputTokens: 123456, outputTokens: 1 }, 1785730424999) + '\n', 'utf-8')) + await writeFile(filePath, Buffer.concat([...frames, torn.subarray(0, Math.floor(torn.length / 2))])) + + const framed = await parseAll(createDshProvider(tmpDir), filePath) + expect(framed.map(c => [c.inputTokens, c.outputTokens, c.reasoningTokens, c.model])) + .toEqual(plain.map(c => [c.inputTokens, c.outputTokens, c.reasoningTokens, c.model])) + expect(framed).toHaveLength(2) + }) +}) + +describe('dsh provider - hostile input', () => { + itZstd('skips a session whose frames decompress to far more than the file cap', async () => { + const dir = join(tmpDir, 'sessions', '--home-u-proj--', 'session-bomb') + await mkdir(dir, { recursive: true }) + const filePath = join(dir, 'session.jsonl.zstd') + // 200 MB of zeros compresses to a few KB. Uncapped this decoded to ~916 MB + // of RSS for a 16 KB file; the per-frame cap now rejects it without + // allocating past the cap. + const bomb = zstdCompress!(Buffer.alloc(200 * 1024 * 1024)) + const good = zstdCompress!(Buffer.from( + sessionHeader({ id: 'session-bomb', cwd: '/home/u/proj' }) + '\n' + + chunkUsage(1, 1, { inputTokens: 100, outputTokens: 10 }, 1786707340000) + '\n', + 'utf-8', + )) + await writeFile(filePath, Buffer.concat([good, bomb])) + expect((await stat(filePath)).size).toBeLessThan(64 * 1024) + + // The whole file is skipped: the frames read before the bomb are not + // counted, so a crafted tail cannot poison a partial total. + expect(await parseAll(createDshProvider(tmpDir), filePath)).toEqual([]) + }) + + itZstd('stops decoding once the frames exceed the running budget', async () => { + const frame = zstdCompress!(Buffer.from('{"type":"turn/start","seq":0,"time":1,"data":{"turn":1}}\n', 'utf-8')) + const buffer = Buffer.concat([frame, frame, frame]) + + expect([...readZstdLines(buffer, Number.POSITIVE_INFINITY, 4096)]).toHaveLength(3) + // A budget under two frames' plaintext stops at the frame that overruns it. + expect(() => [...readZstdLines(buffer, Number.POSITIVE_INFINITY, 60)]).toThrow() + }) + + it('coerces non-numeric usage fields instead of poisoning the totals', async () => { + const filePath = await writePlainSession('--home-u-proj--', 'session-poison', [ + sessionHeader({ id: 'session-poison', cwd: '/home/u/proj' }), + JSON.stringify({ + type: 'assistant/message', seq: 1, time: 1786707340000, + data: { + turn: 1, step: 1, message: { role: 'assistant', content: [] }, + usage: { inputTokens: '999', outputTokens: [1, 2], reasoningTokens: 1e308 * 10, cacheReadTokens: -5, cacheWriteTokens: 7 }, + }, + }), + ]) + + const calls = await parseAll(createDshProvider(tmpDir), filePath) + expect(calls).toHaveLength(1) + // Only the one genuinely numeric field survives; every other shape is 0. + expect(calls[0]).toMatchObject({ + inputTokens: 0, + outputTokens: 0, + reasoningTokens: 0, + cacheReadInputTokens: 0, + cacheCreationInputTokens: 7, + }) + for (const value of [calls[0]!.inputTokens, calls[0]!.outputTokens, calls[0]!.costUSD]) { + expect(typeof value).toBe('number') + expect(Number.isFinite(value)).toBe(true) + } + }) + + it('still skips a call whose usage is all non-numeric', async () => { + const filePath = await writePlainSession('--home-u-proj--', 'session-poison-zero', [ + sessionHeader({ id: 'session-poison-zero', cwd: '/home/u/proj' }), + JSON.stringify({ + type: 'assistant/message', seq: 1, time: 1786707340000, + data: { turn: 1, step: 1, message: { role: 'assistant', content: [] }, usage: { inputTokens: '999', outputTokens: [1, 2] } }, + }), + ]) + + expect(await parseAll(createDshProvider(tmpDir), filePath)).toEqual([]) + }) +}) diff --git a/tests/serve-stdio.test.ts b/tests/serve-stdio.test.ts index 2f0af775..66a26145 100644 --- a/tests/serve-stdio.test.ts +++ b/tests/serve-stdio.test.ts @@ -1,6 +1,31 @@ import { describe, it, expect, beforeAll, afterAll } from 'vitest' import { spawn, type ChildProcess } from 'child_process' +import { mkdir, readFile, writeFile } from 'fs/promises' import { join } from 'path' +import { classifyRootReuse, createOutputMemoEntry } from '../src/serve.js' + +it('timestamps a completed output memo before parsing begins', () => { + const parseStartedAt = 100 + const rootEventDuringParseAt = 150 + const parseCompletedAt = 200 + const memo = createOutputMemoEntry(parseStartedAt, parseCompletedAt, 'output', 'config') + const rootsQuietSince = (sinceTs: number): boolean => rootEventDuringParseAt < sinceTs + + // The old completion timestamp incorrectly made the in-parse event look + // older than the memo. The start timestamp keeps it visible to validation. + expect(rootsQuietSince(parseCompletedAt)).toBe(true) + expect(memo.createdAt).toBe(parseCompletedAt) + expect(memo.validatedFrom).toBe(parseStartedAt) + expect(rootsQuietSince(memo.validatedFrom)).toBe(false) +}) + +it('classifies watcher gaps as unknown without confusing them with dirty roots', () => { + expect(classifyRootReuse(100, { startedAt: 50, lastEventAt: 0, healthy: false })).toBe('unknown') + expect(classifyRootReuse(100, { startedAt: 150, lastEventAt: 0, healthy: true })).toBe('unknown') + expect(classifyRootReuse(100, { startedAt: 50, lastEventAt: 100, healthy: false })).toBe('dirty') + expect(classifyRootReuse(100, { startedAt: 50, lastEventAt: 100, healthy: true })).toBe('dirty') + expect(classifyRootReuse(100, { startedAt: 50, lastEventAt: 99, healthy: true })).toBe('clean') +}) // End-to-end protocol test for `codeburn serve --stdio` (the desktop app's // resident query server). Runs the real entry through tsx against the @@ -10,6 +35,8 @@ describe('codeburn serve --stdio', () => { let child: ChildProcess let buffer = '' const waiters = new Map) => void>() + const progressFrames = new Map>>() + let configPath = '' let readyResolve: () => void const ready = new Promise(resolve => { readyResolve = resolve }) @@ -25,6 +52,23 @@ describe('codeburn serve --stdio', () => { } beforeAll(async () => { + const home = process.env['HOME']! + configPath = join(home, '.config', 'codeburn', 'config.json') + await mkdir(join(home, '.config', 'codeburn'), { recursive: true }) + // Give the resident process one real provider root to arm. With no + // successfully armed roots, event-driven reuse correctly stays disabled. + await mkdir(join(home, '.claude', 'projects'), { recursive: true }) + await writeFile(configPath, JSON.stringify({ currency: { code: 'USD' } }), 'utf8') + + // Keep the EUR half of the config-freshness regression fully offline. + const cacheDir = join(home, '.cache', 'codeburn') + await mkdir(cacheDir, { recursive: true }) + await writeFile(join(cacheDir, 'exchange-rate.json'), JSON.stringify({ + timestamp: Date.now(), + code: 'EUR', + rate: 0.9, + }), 'utf8') + child = spawn(process.execPath, ['--import', 'tsx', join(__dirname, '..', 'src', 'cli.ts'), 'serve', '--stdio'], { stdio: ['pipe', 'pipe', 'ignore'], env: { ...process.env }, @@ -40,6 +84,13 @@ describe('codeburn serve --stdio', () => { let msg: Record try { msg = JSON.parse(line) } catch { continue } if (msg['ready']) { readyResolve(); continue } + if (typeof msg['progress'] === 'string' && !('ok' in msg)) { + const id = msg['id'] as number + const frames = progressFrames.get(id) ?? [] + frames.push(msg) + progressFrames.set(id, frames) + continue + } const waiter = waiters.get(msg['id'] as number) if (waiter) { waiters.delete(msg['id'] as number); waiter(msg) } } @@ -82,9 +133,218 @@ describe('codeburn serve --stdio', () => { expect(res['refused']).toBe(true) }) + it('refuses every optimize apply-only option without touching shell config or the action journal', async () => { + const home = process.env['HOME']! + const zshrc = join(home, '.zshrc') + const journal = join(home, '.config', 'codeburn', 'actions', 'journal.jsonl') + await writeFile(zshrc, '# user-owned\n', 'utf8') + + // `optimize` is the only served command whose Commander definition also + // has mutation-capable options. The full request below used to execute a + // shell-config action inside the resident process. + const applied = await request(300, [ + 'optimize', '--apply', '--yes', '--only', 'bash-output-cap', '--period', 'today', + ]) + expect(applied).toMatchObject({ ok: false, refused: true }) + + // Keep the allowlist categorical: apply-only modifiers are not useful to + // a read query and must not become resident options on their own either. + for (const [id, args] of [ + [301, ['optimize', '--yes']], + [302, ['optimize', '--dry-run']], + [303, ['optimize', '--only', 'bash-output-cap']], + ] as const) { + expect(await request(id, [...args])).toMatchObject({ ok: false, refused: true }) + } + + expect(await readFile(zshrc, 'utf8')).toBe('# user-owned\n') + await expect(readFile(journal, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) + }, 60_000) + + it('accepts the reviewed read-only option surface for every served command', async () => { + const commands: Array<[number, string[]]> = [ + [310, ['status', '--format', 'json', '--period', 'today']], + [311, ['overview', '--period', 'today', '--no-color']], + [312, ['models', '--format', 'json', '--period', 'today', '--no-totals']], + [313, ['sessions', '--format', 'json', '--period', 'today', '--no-pager']], + [314, ['compare', '--format', 'json', '--period', 'today']], + [315, ['yield', '--format', 'json', '--period', 'today']], + [316, ['spend', '--format', 'flow-json', '--period', 'today']], + [317, ['optimize', '--format', 'json', '--period', 'today']], + [318, ['audit', '--format', 'json', '--period', 'today']], + ] + for (const [id, args] of commands) { + expect(await request(id, args)).toMatchObject({ ok: true }) + } + }, 60_000) + it('survives a malformed request line and keeps serving', async () => { sendRaw('this is not json') const res = await request(6, ['status', '--format', 'menubar-json', '--period', 'today']) expect(res['ok']).toBe(true) }, 60_000) + + it('streams captured command stderr as protocol progress frames', async () => { + const res = await request(7, ['status', '--provider', 'definitely-not-a-real-provider']) + expect(res['ok']).toBe(false) + + const frames = progressFrames.get(7) ?? [] + expect(frames.length).toBeGreaterThan(0) + expect(frames.every(frame => Object.keys(frame).sort().join(',') === 'id,progress')).toBe(true) + expect(frames.map(frame => frame['progress']).join('')).toContain('unknown provider') + }, 60_000) + + it('discovers a newly configured Claude root on identical resident argv', async () => { + const home = process.env['HOME']! + const rootA = join(home, 'claude-root-a') + const rootB = join(home, 'claude-root-b') + const slug = '-Users-test-shared-project' + const cwd = '/Users/test/shared-project' + + const writeClaudeSession = async (root: string, sessionId: string, marker: string): Promise => { + const projectDir = join(root, 'projects', slug) + await mkdir(projectDir, { recursive: true }) + const lines = [ + { + type: 'summary', summary: marker, leafUuid: `leaf-${marker}`, sessionId, cwd, + timestamp: '2026-08-12T10:00:00.000Z', + }, + { + type: 'user', uuid: `user-${marker}`, sessionId, cwd, + timestamp: '2026-08-12T10:00:01.000Z', message: { role: 'user', content: marker }, + }, + { + type: 'assistant', uuid: `assistant-${marker}`, parentUuid: `user-${marker}`, sessionId, cwd, + timestamp: '2026-08-12T10:00:02.000Z', + message: { + id: `msg-${marker}`, type: 'message', role: 'assistant', model: 'claude-sonnet-4-6', + content: [{ type: 'text', text: 'reply' }], usage: { input_tokens: 100, output_tokens: 50 }, + }, + }, + ] + await writeFile(join(projectDir, `${sessionId}.jsonl`), lines.map(line => JSON.stringify(line)).join('\n')) + } + + await writeClaudeSession(rootA, 'resident-session-a', 'a') + await writeClaudeSession(rootB, 'resident-session-b', 'b') + const args = ['sessions', '--period', 'lifetime', '--provider', 'claude', '--format', 'json', '--no-pager'] + + await writeFile(configPath, JSON.stringify({ claudeConfigDirs: [rootA] }), 'utf8') + const first = await request(200, args) + expect(first['ok']).toBe(true) + expect((JSON.parse(first['output'] as string) as Array<{ sessionId: string }>).map(row => row.sessionId)).toEqual([ + 'resident-session-a', + ]) + + // Same command in the same process; only config.json adds root B. + await writeFile(configPath, JSON.stringify({ claudeConfigDirs: [rootA, rootB] }), 'utf8') + const second = await request(201, args) + expect(second['ok']).toBe(true) + expect((JSON.parse(second['output'] as string) as Array<{ sessionId: string }>).map(row => row.sessionId).sort()).toEqual([ + 'resident-session-a', + 'resident-session-b', + ]) + + // Keep the following currency-freshness regression self-contained. + await writeFile(configPath, JSON.stringify({ currency: { code: 'USD' } }), 'utf8') + }, 60_000) + + it('invalidates identical-argv output memo immediately when config.json changes', async () => { + const args = ['status', '--format', 'menubar-json', '--period', 'week', '--no-optimize', '--no-timeline'] + const usdConfig = JSON.stringify({ currency: { code: 'USD' } }) + await writeFile(configPath, usdConfig, 'utf8') + + let previous = await request(8, args) + expect(previous['ok']).toBe(true) + expect((JSON.parse(previous['output'] as string) as { currency: { code: string } }).currency.code).toBe('USD') + + // Prove this argv is actually hitting the output memo before testing its + // invalidation. The root watchers arm asynchronously at serve startup, so + // allow a few requests until two byte-identical generated payloads arrive. + let memoized: Record | null = null + for (let id = 9; id < 110; id++) { + await new Promise(resolve => setTimeout(resolve, 20)) + const next = await request(id, args) + if (next['output'] === previous['output']) { + memoized = next + break + } + previous = next + } + expect(memoized).not.toBeNull() + + // A byte-identical rewrite changes filesystem metadata but not effective + // configuration. The memo must survive it and return the exact generated + // payload, including the original volatile `generated` timestamp. + await new Promise(resolve => setTimeout(resolve, 20)) + await writeFile(configPath, usdConfig, 'utf8') + const sameBytes = await request(110, args) + expect(sameBytes['ok']).toBe(true) + expect(sameBytes['output']).toBe(memoized!['output']) + // `generated` is minted per render, so an unchanged stamp is the proof + // that a memo hit returns the stored string instead of re-rendering. + const stamp = (res: Record): string => + (JSON.parse(res['output'] as string) as { generated: string }).generated + expect(stamp(sameBytes)).toBe(stamp(memoized!)) + + // Same byte length as USD: a size-only fingerprint would miss this. + await writeFile(configPath, JSON.stringify({ currency: { code: 'EUR' } }), 'utf8') + const fresh = await request(111, args) + expect(fresh['ok']).toBe(true) + expect((JSON.parse(fresh['output'] as string) as { currency: { code: string } }).currency.code).toBe('EUR') + expect(fresh['output']).not.toBe(memoized!['output']) + + // Removing the configured currency is the USD reset contract. The serve + // process must reset its module-level currency state as well as invalidate + // the output memo, otherwise a long-lived child keeps rendering EUR. + await writeFile(configPath, '{}', 'utf8') + const reset = await request(112, args) + expect(reset['ok']).toBe(true) + expect((JSON.parse(reset['output'] as string) as { + currency: { code: string; rate: number } + }).currency).toMatchObject({ code: 'USD', rate: 1 }) + }, 60_000) + + it('exits on natural stdin EOF after arming a watcher for an existing Claude root', async () => { + const claudeRoot = join(process.env['HOME']!, 'claude-eof-root') + await mkdir(join(claudeRoot, 'projects'), { recursive: true }) + + const eofChild = spawn(process.execPath, ['--import', 'tsx', join(__dirname, '..', 'src', 'cli.ts'), 'serve', '--stdio'], { + stdio: ['pipe', 'pipe', 'ignore'], + env: { ...process.env, CLAUDE_CONFIG_DIR: claudeRoot }, + }) + let stdout = '' + const becameReady = new Promise((resolve, reject) => { + eofChild.once('error', reject) + eofChild.stdout!.setEncoding('utf8') + eofChild.stdout!.on('data', (chunk: string) => { + stdout += chunk + if (stdout.split('\n').some(line => { + try { return (JSON.parse(line) as { ready?: boolean }).ready === true } catch { return false } + })) resolve() + }) + eofChild.once('exit', (code, signal) => reject(new Error(`serve exited before ready: ${code ?? signal}`))) + }) + const exited = new Promise(resolve => eofChild.once('exit', () => resolve(true))) + + let naturalExit = false + try { + await becameReady + // READY is intentionally emitted before provider probing; give the real + // watcher setup time to finish so the regression exercises its handle. + await new Promise(resolve => setTimeout(resolve, 500)) + eofChild.stdin!.end() + naturalExit = await Promise.race([ + exited, + new Promise(resolve => setTimeout(() => resolve(false), 2_000)), + ]) + } finally { + if (!naturalExit) { + eofChild.kill('SIGKILL') + await exited + } + } + + expect(naturalExit).toBe(true) + }, 10_000) }) diff --git a/tests/session-cache-rich-capture.test.ts b/tests/session-cache-rich-capture.test.ts index 11301308..3ed609d7 100644 --- a/tests/session-cache-rich-capture.test.ts +++ b/tests/session-cache-rich-capture.test.ts @@ -12,8 +12,8 @@ import { emptyCache, loadCache, saveCache, - sessionCachePath, } from '../src/session-cache.js' +import { writeCacheOnDisk } from './fixtures/session-cache-io.js' const TMP_DIR = join(tmpdir(), `codeburn-rich-cache-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`) @@ -113,7 +113,7 @@ describe('session cache round-trip for rich-capture fields', () => { }, } if (!existsSync(TMP_DIR)) await mkdir(TMP_DIR, { recursive: true }) - await writeFile(sessionCachePath(), JSON.stringify(oldCache), 'utf-8') + await writeCacheOnDisk(oldCache) const loaded = await loadCache() const call = loaded.providers['claude']!.files['/x/old.jsonl']!.turns[0]!.calls[0]! diff --git a/tests/session-cache-shards.test.ts b/tests/session-cache-shards.test.ts new file mode 100644 index 00000000..ff53c9b6 --- /dev/null +++ b/tests/session-cache-shards.test.ts @@ -0,0 +1,802 @@ +// Provider x month shard layout (CACHE_VERSION 9): the on-disk cache is a +// directory holding one envelope plus one shard per provider-month. What matters +// here is that the move off the older layouts loses nothing, that a file's +// bucket never moves when the session is appended to, that a save rewrites only +// the months that changed (including when the load was scoped to a subset of +// them), and that one unreadable shard costs exactly one month. +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mkdir, readFile, readdir, rm, stat, utimes, writeFile } from 'fs/promises' +import { existsSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' + +import { + CACHE_VERSION, + cacheBucketMonth, + cacheFileSpan, + computeEnvFingerprint, + cleanupOrphanedTempFiles, + clearLoadCacheMemo, + loadCache, + markCacheDirty, + monthScopeForRange, + saveCache, + sessionCacheDir, + type CachedFile, + type SessionCache, +} from '../src/session-cache.js' + +let TMP_DIR: string + +beforeEach(async () => { + TMP_DIR = join(tmpdir(), `codeburn-shard-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`) + process.env['CODEBURN_CACHE_DIR'] = TMP_DIR + await mkdir(TMP_DIR, { recursive: true }) + clearLoadCacheMemo() +}) + +afterEach(async () => { + if (existsSync(TMP_DIR)) await rm(TMP_DIR, { recursive: true }) +}) + +function turnAt(timestamp: string, key = 'msg-1'): CachedFile['turns'][number] { + const base = cachedFile().turns[0]! + return { ...base, timestamp, calls: [{ ...base.calls[0]!, timestamp, deduplicationKey: key }] } +} + +function fileSpanning(first: string, last?: string): CachedFile { + return cachedFile({ turns: last ? [turnAt(first, 'a'), turnAt(last, 'b')] : [turnAt(first, 'a')] }) +} + +function cachedFile(overrides: Partial = {}): CachedFile { + return { + fingerprint: { dev: 1, ino: 2, mtimeMs: 3, sizeBytes: 4 }, + lastCompleteLineOffset: 128, + mcpInventory: ['mcp__github__list'], + turns: [{ + timestamp: '2026-05-15T10:00:00Z', + sessionId: 'sess-1', + userMessage: 'do the thing', + calls: [{ + provider: 'claude', + model: 'claude-sonnet-4-20250514', + usage: { + inputTokens: 1000, + outputTokens: 500, + cacheCreationInputTokens: 0, + cacheReadInputTokens: 0, + cachedInputTokens: 0, + reasoningTokens: 0, + webSearchRequests: 0, + cacheCreationOneHourTokens: 0, + }, + costUSD: 0.01, + speed: 'standard', + timestamp: '2026-05-15T10:00:00Z', + tools: ['Read'], + bashCommands: [], + skills: [], + subagentTypes: [], + deduplicationKey: 'msg-1', + }], + }], + ...overrides, + } +} + +function v7Cache(): SessionCache { + return { + version: 7, + complete: true, + providers: { + claude: { + envFingerprint: 'claude-fp', + files: { + '/live/a.jsonl': cachedFile(), + '/live/b.jsonl': cachedFile({ turns: [] }), + // An orphaned PR-linked entry: its transcript is gone and can never + // re-parse, so the migration has to carry it across verbatim. + '/gone/pruned.jsonl': cachedFile({ prLinks: ['https://github.com/o/r/pull/1'] }), + }, + }, + codex: { + envFingerprint: 'codex-fp', + durable: true, + files: { '/live/rollout.jsonl': cachedFile() }, + }, + }, + } +} + +async function shardNames(): Promise { + return (await readdir(sessionCacheDir())).sort() +} + +async function envelope(): Promise<{ providers: Record }> }> { + return JSON.parse(await readFile(join(sessionCacheDir(), 'envelope.json'), 'utf-8')) +} + +/** name -> bytes, for every shard on disk. */ +async function shardBytes(): Promise> { + const dir = sessionCacheDir() + const out = new Map() + for (const name of await shardNames()) out.set(name, await readFile(join(dir, name), 'utf-8')) + return out +} + +describe('v7 -> shard migration', () => { + it('is lossless: every entry survives, shards replace the v7 file, reload matches', async () => { + const v7 = v7Cache() + const v7Path = join(TMP_DIR, 'session-cache.v7.json') + await writeFile(v7Path, JSON.stringify(v7)) + + const loaded = await loadCache() + // Same content, re-stamped at the current version. + expect(loaded).toEqual({ ...v7, version: CACHE_VERSION }) + + // Shards on disk, v7 blob removed. + expect(existsSync(v7Path)).toBe(false) + const names = await shardNames() + expect(names).toContain('envelope.json') + // claude's three entries split by month: two dated 2026-05, one turn-less. + expect(Object.keys((await envelope()).providers['claude']!.shards).sort()).toEqual(['0000-00', '2026-05']) + expect(Object.keys((await envelope()).providers['codex']!.shards)).toEqual(['2026-05']) + + // A second load reads only the shards and produces the same cache. + clearLoadCacheMemo() + expect(await loadCache()).toEqual(loaded) + }) + + it('leaves a corrupt v7 file alone and starts fresh', async () => { + await writeFile(join(TMP_DIR, 'session-cache.v7.json'), '{broken') + const loaded = await loadCache() + expect(loaded.providers).toEqual({}) + expect(existsSync(join(TMP_DIR, 'session-cache.v7.json'))).toBe(true) + }) +}) + +describe('per-provider dirty tracking', () => { + it('rewrites only the provider that changed', async () => { + await writeFile(join(TMP_DIR, 'session-cache.v7.json'), JSON.stringify(v7Cache())) + const cache = await loadCache() + + const dir = sessionCacheDir() + const before = new Map() + for (const name of await shardNames()) before.set(name, await readFile(join(dir, name), 'utf-8')) + + cache.providers['codex']!.files['/live/rollout.jsonl'] = cachedFile({ mcpInventory: ['changed'] }) + markCacheDirty(cache, 'codex') + await saveCache(cache) + + const after = await shardNames() + const claudeShard = [...before.keys()].find(n => n.startsWith('claude.'))! + // The untouched provider keeps its exact file, byte for byte. + expect(after).toContain(claudeShard) + expect(await readFile(join(dir, claudeShard), 'utf-8')).toBe(before.get(claudeShard)) + // The changed provider is republished under a new name; the old one is gone. + const codexBefore = [...before.keys()].find(n => n.startsWith('codex.'))! + const codexAfter = after.find(n => n.startsWith('codex.'))! + expect(codexAfter).not.toBe(codexBefore) + expect(after).not.toContain(codexBefore) + + clearLoadCacheMemo() + const reloaded = await loadCache() + expect(reloaded.providers['codex']!.files['/live/rollout.jsonl']!.mcpInventory).toEqual(['changed']) + expect(reloaded.providers['claude']).toEqual(cache.providers['claude']) + }) +}) + +describe('corrupt shard isolation', () => { + it('drops only the unreadable month, keeping every other month and provider', async () => { + const cache: SessionCache = { + version: CACHE_VERSION, + complete: true, + providers: { + claude: { + envFingerprint: 'claude-fp', + files: { '/live/may.jsonl': fileSpanning('2026-05-15T10:00:00Z'), '/live/jun.jsonl': fileSpanning('2026-06-15T10:00:00Z') }, + }, + codex: { envFingerprint: 'codex-fp', files: { '/live/r.jsonl': fileSpanning('2026-05-15T10:00:00Z') } }, + }, + } + markCacheDirty(cache, 'claude') + markCacheDirty(cache, 'codex') + await saveCache(cache) + + const dir = sessionCacheDir() + await writeFile(join(dir, (await envelope()).providers['claude']!.shards['2026-05']!.name), '{"/x":{"turns":') + + clearLoadCacheMemo() + const reloaded = await loadCache() + expect(Object.keys(reloaded.providers['claude']!.files)).toEqual(['/live/jun.jsonl']) + expect(reloaded.providers['codex']).toEqual(cache.providers['codex']) + + // Self-heals: the unreadable month is republished from whatever re-parses + // into it rather than being carried forward corrupt forever. + reloaded.providers['claude']!.files['/live/may.jsonl'] = fileSpanning('2026-05-15T10:00:00Z') + markCacheDirty(reloaded, 'claude', '/live/may.jsonl') + await saveCache(reloaded) + clearLoadCacheMemo() + expect(Object.keys((await loadCache()).providers['claude']!.files).sort()).toEqual(['/live/jun.jsonl', '/live/may.jsonl']) + }) +}) + +describe('cleanupOrphanedTempFiles', () => { + it('sweeps stale shard temps and unreferenced shards, keeping the live ones', async () => { + await saveCache({ version: CACHE_VERSION, complete: true, providers: { + claude: { envFingerprint: 'fp', files: { '/a.jsonl': cachedFile() } }, + } }) + const dir = sessionCacheDir() + const live = (await shardNames()).find(n => n.startsWith('claude.'))! + + const backdate = async (path: string, minutes: number) => { + const at = new Date(Date.now() - minutes * 60 * 1000) + await utimes(path, at, at) + } + + const oldTemp = join(dir, 'claude.deadbeef.json.tmp') + await writeFile(oldTemp, 'partial') + await backdate(oldTemp, 10) + const orphanShard = join(dir, 'codex.deadbeef.json') + await writeFile(orphanShard, '{}') + await backdate(orphanShard, 90) + // Unreferenced but fresh: this is what a CONCURRENT save's shard looks like + // before its envelope lands, so the sweep must leave it alone. + const inFlightShard = join(dir, 'codex.c0ffee00.json') + await writeFile(inFlightShard, '{}') + const recentTemp = join(dir, 'claude.feedface.json.tmp') + await writeFile(recentTemp, 'in flight') + // The live shard is far older than the temp cutoff; being referenced is what + // protects it, not its age. + await backdate(join(dir, live), 120) + + await cleanupOrphanedTempFiles() + + expect(existsSync(oldTemp)).toBe(false) + expect(existsSync(orphanShard)).toBe(false) + expect(existsSync(inFlightShard)).toBe(true) + expect(existsSync(recentTemp)).toBe(true) + expect(existsSync(join(dir, live))).toBe(true) + expect(existsSync(join(dir, 'envelope.json'))).toBe(true) + }) + + it('retires the pre-v8 single-file layout temps left in the parent directory', async () => { + await saveCache({ version: CACHE_VERSION, complete: true, providers: {} }) + const legacyTemp = join(TMP_DIR, 'session-cache.v7.json.abc123.tmp') + await writeFile(legacyTemp, 'orphan from an older build') + const at = new Date(Date.now() - 10 * 60 * 1000) + await utimes(legacyTemp, at, at) + const freshLegacyTemp = join(TMP_DIR, 'session-cache.v7.json.def456.tmp') + await writeFile(freshLegacyTemp, 'an old binary mid-write') + + await cleanupOrphanedTempFiles() + + expect(existsSync(legacyTemp)).toBe(false) + expect(existsSync(freshLegacyTemp)).toBe(true) + }) +}) + +// Two live processes share one cache directory routinely: a one-shot CLI beside +// the resident serve child, or two menubar polls. Neither may publish an +// envelope naming a file that is not there — that reads back as a corrupt +// provider and silently drops its history. +describe('concurrent writers', () => { + function seed(provider: string, tag: string, files: number): SessionCache { + const cache: SessionCache = { + version: CACHE_VERSION, + complete: true, + providers: { + [provider]: { + envFingerprint: tag, + files: Object.fromEntries( + Array.from({ length: files }, (_, i) => [`/f/${provider}/${i}.jsonl`, cachedFile()]), + ), + }, + }, + } + markCacheDirty(cache, provider) + return cache + } + + async function assertReferentialIntegrity(expected: string[]): Promise { + const dir = sessionCacheDir() + for (const meta of Object.values((await envelope()).providers)) { + for (const ref of Object.values(meta.shards)) { + expect(existsSync(join(dir, ref.name)), `envelope names a missing shard: ${ref.name}`).toBe(true) + } + } + clearLoadCacheMemo() + const loaded = await loadCache() + expect(Object.keys(loaded.providers).length).toBeGreaterThan(0) + for (const provider of expected) expect(loaded.providers[provider]).toBeDefined() + } + + it('never publishes a dangling envelope when two saves race', async () => { + for (let round = 0; round < 15; round++) { + await Promise.allSettled([ + saveCache(seed('claude', `a${round}`, 40)), + saveCache(seed('codex', `b${round}`, 40)), + ]) + // Whichever won, the published set has to be internally consistent and + // hold at least the provider that got there last. + await assertReferentialIntegrity([]) + } + }) + + it('a stale writer rewrites a shard another process retired instead of orphaning it', async () => { + // Seed: claude holds an expired-source PR orphan no re-parse can recover. + const initial: SessionCache = { + version: CACHE_VERSION, + complete: true, + providers: { + claude: { envFingerprint: 'fp', files: { '/gone/pruned.jsonl': cachedFile({ prLinks: ['https://github.com/o/r/pull/1'] }) } }, + codex: { envFingerprint: 'fp', durable: true, files: { '/live/r.jsonl': cachedFile() } }, + }, + } + markCacheDirty(initial, 'claude') + markCacheDirty(initial, 'codex') + await saveCache(initial) + + // Process B loads now, recording claude's current shard name. + clearLoadCacheMemo() + const b = await loadCache() + + // Process A independently touches ONLY claude and republishes, retiring the + // shard file B is still holding a name for. + clearLoadCacheMemo() + const a = await loadCache() + a.providers['claude']!.files['/live/new.jsonl'] = cachedFile() + markCacheDirty(a, 'claude') + await saveCache(a) + + // B now saves an unrelated codex change. + b.providers['codex']!.files['/live/r2.jsonl'] = cachedFile() + markCacheDirty(b, 'codex') + await saveCache(b) + + await assertReferentialIntegrity(['claude', 'codex']) + clearLoadCacheMemo() + const final = await loadCache() + expect(final.providers['claude']!.files['/gone/pruned.jsonl']).toBeDefined() + expect(final.providers['codex']!.files['/live/r2.jsonl']).toBeDefined() + }) +}) + +describe('month buckets', () => { + it('keeps a file in its first-turn month when the session is appended to', async () => { + const cache: SessionCache = { + version: CACHE_VERSION, + complete: true, + providers: { claude: { envFingerprint: 'fp', files: { '/live/long.jsonl': fileSpanning('2026-05-15T10:00:00Z') } } }, + } + markCacheDirty(cache, 'claude') + await saveCache(cache) + expect(Object.keys((await envelope()).providers['claude']!.shards)).toEqual(['2026-05']) + + // Two months of appends later the bucket is unchanged; only `until` moves, + // which is what lets a ranged load still find this session. + const appended = fileSpanning('2026-05-15T10:00:00Z', '2026-07-02T10:00:00Z') + expect(cacheBucketMonth(appended)).toBe('2026-05') + cache.providers['claude']!.files['/live/long.jsonl'] = appended + markCacheDirty(cache, 'claude', '/live/long.jsonl') + await saveCache(cache) + const shards = (await envelope()).providers['claude']!.shards + expect(Object.keys(shards)).toEqual(['2026-05']) + expect(shards['2026-05']!.until).toBe('2026-07') + + // ...and a July query still loads it, despite the May bucket key. + clearLoadCacheMemo() + const scoped = await loadCache(monthScopeForRange(new Date('2026-07-01T00:00:00Z'), new Date('2026-07-31T23:59:59Z'))) + expect(scoped.providers['claude']!.files['/live/long.jsonl']).toBeDefined() + }) + + it('rewrites only the month that changed', async () => { + const cache: SessionCache = { + version: CACHE_VERSION, + complete: true, + providers: { + claude: { + envFingerprint: 'fp', + files: { + '/live/mar.jsonl': fileSpanning('2026-03-10T10:00:00Z'), + '/live/apr.jsonl': fileSpanning('2026-04-10T10:00:00Z'), + '/live/may.jsonl': fileSpanning('2026-05-10T10:00:00Z'), + }, + }, + }, + } + markCacheDirty(cache, 'claude') + await saveCache(cache) + const before = await shardBytes() + const untouched = [ + (await envelope()).providers['claude']!.shards['2026-03']!.name, + (await envelope()).providers['claude']!.shards['2026-04']!.name, + ] + + cache.providers['claude']!.files['/live/may.jsonl'] = cachedFile({ turns: [turnAt('2026-05-10T10:00:00Z', 'a')], mcpInventory: ['changed'] }) + markCacheDirty(cache, 'claude', '/live/may.jsonl') + await saveCache(cache) + + const after = await shardBytes() + for (const name of untouched) expect(after.get(name)).toBe(before.get(name)) + expect(after.has((await envelope()).providers['claude']!.shards['2026-05']!.name)).toBe(true) + }) + + it('dirties the month a deleted file was in', async () => { + const cache: SessionCache = { + version: CACHE_VERSION, + complete: true, + providers: { + claude: { + envFingerprint: 'fp', + files: { '/live/mar.jsonl': fileSpanning('2026-03-10T10:00:00Z'), '/live/mar2.jsonl': fileSpanning('2026-03-11T10:00:00Z') }, + }, + }, + } + markCacheDirty(cache, 'claude') + await saveCache(cache) + + delete cache.providers['claude']!.files['/live/mar2.jsonl'] + markCacheDirty(cache, 'claude', '/live/mar2.jsonl') + await saveCache(cache) + + clearLoadCacheMemo() + expect(Object.keys((await loadCache()).providers['claude']!.files)).toEqual(['/live/mar.jsonl']) + }) +}) + +describe('scoped load', () => { + async function seedThreeMonths(): Promise { + const cache: SessionCache = { + version: CACHE_VERSION, + complete: true, + providers: { + claude: { + envFingerprint: computeEnvFingerprint('claude'), + files: { + '/live/mar.jsonl': fileSpanning('2026-03-10T10:00:00Z'), + '/live/apr.jsonl': fileSpanning('2026-04-10T10:00:00Z'), + '/live/jun.jsonl': fileSpanning('2026-06-10T10:00:00Z'), + }, + }, + }, + } + markCacheDirty(cache, 'claude') + await saveCache(cache) + } + + const juneScope = monthScopeForRange(new Date('2026-06-01T00:00:00Z'), new Date('2026-06-30T23:59:59Z')) + + it('reads only the months the range can report on, plus one of slack', async () => { + await seedThreeMonths() + clearLoadCacheMemo() + const scoped = await loadCache(juneScope) + // June is in range; May would be the slack month (absent here); March and + // April cannot contribute a June turn and stay on disk. + expect(Object.keys(scoped.providers['claude']!.files)).toEqual(['/live/jun.jsonl']) + }) + + it('save from a scoped load leaves the unloaded months byte-identical', async () => { + await seedThreeMonths() + const before = await shardBytes() + const kept = [ + (await envelope()).providers['claude']!.shards['2026-03']!.name, + (await envelope()).providers['claude']!.shards['2026-04']!.name, + ] + + clearLoadCacheMemo() + const scoped = await loadCache(juneScope) + scoped.providers['claude']!.files['/live/jun2.jsonl'] = fileSpanning('2026-06-20T10:00:00Z') + markCacheDirty(scoped, 'claude', '/live/jun2.jsonl') + await saveCache(scoped) + + const after = await shardBytes() + for (const name of kept) expect(after.get(name), `unloaded month rewritten: ${name}`).toBe(before.get(name)) + + clearLoadCacheMemo() + const full = await loadCache() + expect(Object.keys(full.providers['claude']!.files).sort()) + .toEqual(['/live/apr.jsonl', '/live/jun.jsonl', '/live/jun2.jsonl', '/live/mar.jsonl']) + }) + + it('merges rather than replaces when a re-parse lands in an unloaded month', async () => { + await seedThreeMonths() + clearLoadCacheMemo() + const scoped = await loadCache(juneScope) + // A March session that was never loaded is re-parsed (its mtime moved) and + // written straight back into the March bucket. + scoped.providers['claude']!.files['/live/mar.jsonl'] = cachedFile({ turns: [turnAt('2026-03-10T10:00:00Z', 'z')], mcpInventory: ['reparsed'] }) + markCacheDirty(scoped, 'claude', '/live/mar.jsonl') + await saveCache(scoped) + + clearLoadCacheMemo() + const full = await loadCache() + expect(full.providers['claude']!.files['/live/mar.jsonl']!.mcpInventory).toEqual(['reparsed']) + expect(Object.keys(full.providers['claude']!.files).sort()) + .toEqual(['/live/apr.jsonl', '/live/jun.jsonl', '/live/mar.jsonl']) + }) + + it('never scopes a provider whose fingerprint moved, or a durable one', async () => { + const cache: SessionCache = { + version: CACHE_VERSION, + complete: true, + providers: { + claude: { envFingerprint: 'stale-fp', files: { '/live/mar.jsonl': fileSpanning('2026-03-10T10:00:00Z') } }, + copilot: { envFingerprint: computeEnvFingerprint('copilot'), durable: true, files: { '/live/otel.db': fileSpanning('2026-03-10T10:00:00Z') } }, + }, + } + markCacheDirty(cache, 'claude') + markCacheDirty(cache, 'copilot') + await saveCache(cache) + + clearLoadCacheMemo() + const scoped = await loadCache(juneScope) + // Both would be skipped on month alone; both are read in full anyway, so the + // fingerprint reset and the durable orphan carry-forward see every entry. + expect(scoped.providers['claude']!.files['/live/mar.jsonl']).toBeDefined() + expect(scoped.providers['copilot']!.files['/live/otel.db']).toBeDefined() + }) +}) + +describe('v8 -> v9 migration', () => { + it('re-buckets the v8 provider shards losslessly and retires the v8 directory', async () => { + const v8Dir = join(TMP_DIR, 'session-cache.v8') + await mkdir(v8Dir, { recursive: true }) + const section = { + envFingerprint: 'claude-fp', + files: { + '/live/mar.jsonl': fileSpanning('2026-03-10T10:00:00Z'), + '/live/jun.jsonl': fileSpanning('2026-06-10T10:00:00Z'), + '/gone/pruned.jsonl': cachedFile({ prLinks: ['https://github.com/o/r/pull/1'] }), + }, + } + await writeFile(join(v8Dir, 'claude.abc.json'), JSON.stringify(section)) + await writeFile(join(v8Dir, 'envelope.json'), JSON.stringify({ + version: 8, complete: true, nonce: 'n', shards: { claude: 'claude.abc.json' }, + })) + + const loaded = await loadCache() + expect(loaded.providers['claude']).toEqual(section) + expect(loaded.complete).toBe(true) + expect(existsSync(v8Dir)).toBe(false) + expect(Object.keys((await envelope()).providers['claude']!.shards).sort()).toEqual(['2026-03', '2026-05', '2026-06']) + + clearLoadCacheMemo() + expect(await loadCache()).toEqual(loaded) + }) +}) + +// Several providers emit turns in a non-chronological order (cursor composers by +// ROWID, goose / crush / copilot by a DESC ordering). Reading the span off +// turns[0]/turns[-1] then gives `until < bucket` — an empty span, unreachable at +// every scope. +describe('out-of-order turns', () => { + const outOfOrder = () => cachedFile({ turns: [turnAt('2026-08-10T10:00:00Z', 'a'), turnAt('2026-03-04T10:00:00Z', 'b')] }) + + it('spans oldest to newest whatever order the turns arrive in', () => { + const span = cacheFileSpan(outOfOrder()) + expect(span).toEqual({ bucket: '2026-03', until: '2026-08' }) + expect(cacheBucketMonth(outOfOrder())).toBe('2026-03') + }) + + it('stays reachable at the scope of either end', async () => { + const cache: SessionCache = { + version: CACHE_VERSION, + complete: true, + providers: { claude: { envFingerprint: computeEnvFingerprint('claude'), files: { '/live/desc.jsonl': outOfOrder() } } }, + } + markCacheDirty(cache, 'claude') + await saveCache(cache) + expect((await envelope()).providers['claude']!.shards['2026-03']!.until).toBe('2026-08') + + for (const [from, to] of [['2026-03-01', '2026-03-31'], ['2026-08-01', '2026-08-31']] as const) { + clearLoadCacheMemo() + const scoped = await loadCache(monthScopeForRange(new Date(`${from}T00:00:00Z`), new Date(`${to}T23:59:59Z`))) + expect(scoped.providers['claude']!.files['/live/desc.jsonl'], `unreachable at ${from}`).toBeDefined() + } + }) +}) + +// A path must never end up in two shards at once: on a later load the two copies +// race and the stale one can win, and nothing sweeps it because the envelope +// names both. +describe('re-bucketing out of an unloaded month', () => { + const juneScope = monthScopeForRange(new Date('2026-06-01T00:00:00Z'), new Date('2026-06-30T23:59:59Z')) + + async function seed(): Promise { + const cache: SessionCache = { + version: CACHE_VERSION, + complete: true, + providers: { + claude: { + envFingerprint: computeEnvFingerprint('claude'), + files: { + '/live/moving.jsonl': fileSpanning('2026-01-10T10:00:00Z'), + '/live/stay.jsonl': fileSpanning('2026-01-11T10:00:00Z'), + '/live/jun.jsonl': fileSpanning('2026-06-10T10:00:00Z'), + }, + }, + }, + } + markCacheDirty(cache, 'claude') + await saveCache(cache) + } + + /** Every shard's view of `path`, so a duplicate is visible directly. */ + async function copiesOf(path: string): Promise { + const dir = sessionCacheDir() + const found: string[] = [] + for (const [bucket, ref] of Object.entries((await envelope()).providers['claude']!.shards)) { + const files = JSON.parse(await readFile(join(dir, ref.name), 'utf-8')) + if (files[path]) found.push(bucket) + } + return found.sort() + } + + it('(a) drops the old copy when a re-parse moves the file to another month', async () => { + await seed() + clearLoadCacheMemo() + const scoped = await loadCache(juneScope) + expect(scoped.providers['claude']!.files['/live/moving.jsonl']).toBeUndefined() + // Re-parsed from byte 0 after a rewrite: its oldest turn is now in May. + scoped.providers['claude']!.files['/live/moving.jsonl'] = cachedFile({ turns: [turnAt('2026-05-02T10:00:00Z', 'new')], mcpInventory: ['reparsed'] }) + markCacheDirty(scoped, 'claude', '/live/moving.jsonl') + await saveCache(scoped) + + expect(await copiesOf('/live/moving.jsonl')).toEqual(['2026-05']) + clearLoadCacheMemo() + const full = await loadCache() + expect(full.providers['claude']!.files['/live/moving.jsonl']!.mcpInventory).toEqual(['reparsed']) + expect(full.providers['claude']!.files['/live/stay.jsonl']).toBeDefined() + }) + + it('(b) drops the old copy when a parse failure leaves a turn-less marker', async () => { + await seed() + clearLoadCacheMemo() + const scoped = await loadCache(juneScope) + // The #441 path: the file threw, so only a failure marker is cached. + scoped.providers['claude']!.files['/live/moving.jsonl'] = { fingerprint: { dev: 1, ino: 2, mtimeMs: 9, sizeBytes: 4 }, mcpInventory: [], turns: [], failed: true } + markCacheDirty(scoped, 'claude', '/live/moving.jsonl') + await saveCache(scoped) + + expect(await copiesOf('/live/moving.jsonl')).toEqual(['0000-00']) + clearLoadCacheMemo() + const full = await loadCache() + expect(full.providers['claude']!.files['/live/moving.jsonl']!.failed).toBe(true) + expect(full.providers['claude']!.files['/live/stay.jsonl']).toBeDefined() + }) + + it('resolves a duplicate to the freshest copy and prunes it on the next save', async () => { + await seed() + // Forge the split state directly: the same path in two shards. + const dir = sessionCacheDir() + const env = await envelope() + const janName = env.providers['claude']!.shards['2026-01']!.name + const junName = env.providers['claude']!.shards['2026-06']!.name + const jun = JSON.parse(await readFile(join(dir, junName), 'utf-8')) + jun['/live/moving.jsonl'] = cachedFile({ turns: [turnAt('2026-06-02T10:00:00Z', 'fresh')], fingerprint: { dev: 1, ino: 2, mtimeMs: 999, sizeBytes: 4 }, mcpInventory: ['fresh'] }) + await writeFile(join(dir, junName), JSON.stringify(jun)) + + clearLoadCacheMemo() + const full = await loadCache() + // Newest fingerprint wins, whichever shard finished reading first. + expect(full.providers['claude']!.files['/live/moving.jsonl']!.mcpInventory).toEqual(['fresh']) + await saveCache(full) + expect(await copiesOf('/live/moving.jsonl')).toEqual(['2026-06']) + expect(existsSync(join(dir, janName))).toBe(false) + }) +}) + +describe('carried months under a concurrent writer', () => { + it('adopts the current shard rather than dropping an orphan-bearing month', async () => { + const orphan = cachedFile({ turns: [turnAt('2026-03-10T10:00:00Z', 'm')], prLinks: ['https://github.com/o/r/pull/1'] }) + const initial: SessionCache = { + version: CACHE_VERSION, + complete: true, + providers: { + claude: { + envFingerprint: computeEnvFingerprint('claude'), + files: { '/gone/mar.jsonl': orphan, '/live/jun.jsonl': fileSpanning('2026-06-10T10:00:00Z') }, + }, + }, + } + markCacheDirty(initial, 'claude') + await saveCache(initial) + + // Process B loads June-scoped: March is carried, by the name it saw. + clearLoadCacheMemo() + const b = await loadCache(monthScopeForRange(new Date('2026-06-01T00:00:00Z'), new Date('2026-06-30T23:59:59Z'))) + + // Process A independently republishes March, retiring the file B remembers. + clearLoadCacheMemo() + const a = await loadCache(monthScopeForRange(new Date('2026-03-01T00:00:00Z'), new Date('2026-03-31T23:59:59Z'))) + a.providers['claude']!.files['/gone/mar2.jsonl'] = cachedFile({ turns: [turnAt('2026-03-12T10:00:00Z', 'n')], prLinks: ['https://github.com/o/r/pull/2'] }) + markCacheDirty(a, 'claude', '/gone/mar2.jsonl') + await saveCache(a) + + // B saves its own unrelated June change. + b.providers['claude']!.files['/live/jun2.jsonl'] = fileSpanning('2026-06-20T10:00:00Z') + markCacheDirty(b, 'claude', '/live/jun2.jsonl') + await saveCache(b) + + clearLoadCacheMemo() + const final = await loadCache() + // March survived under A's name, with both orphans; June has both files. + expect(Object.keys(final.providers['claude']!.files).sort()) + .toEqual(['/gone/mar.jsonl', '/gone/mar2.jsonl', '/live/jun.jsonl', '/live/jun2.jsonl']) + for (const ref of Object.values((await envelope()).providers['claude']!.shards)) { + expect(existsSync(join(sessionCacheDir(), ref.name))).toBe(true) + } + }) + + // Two saves merging into the SAME unloaded month are a read-modify-write with + // no lock between them. In the product they are serialised by the warm refresh + // lock; this covers what survives when they are not. The optimistic retry in + // saveCache narrows the window to the envelope publish, and a loser's entries + // are re-derived by the next parse (the reconcile finds no cache entry and + // re-reads the file) rather than being lost for good. + it('two scoped saves merging into the same unloaded month keep the envelope sound', async () => { + const base: SessionCache = { + version: CACHE_VERSION, + complete: true, + providers: { + claude: { + envFingerprint: computeEnvFingerprint('claude'), + files: { '/live/mar.jsonl': fileSpanning('2026-03-10T10:00:00Z'), '/live/jun.jsonl': fileSpanning('2026-06-10T10:00:00Z') }, + }, + }, + } + markCacheDirty(base, 'claude') + await saveCache(base) + const juneScope = monthScopeForRange(new Date('2026-06-01T00:00:00Z'), new Date('2026-06-30T23:59:59Z')) + + clearLoadCacheMemo() + const p1 = await loadCache(juneScope) + clearLoadCacheMemo() + const p2 = await loadCache(juneScope) + // Both re-parse a different March session neither of them loaded. + p1.providers['claude']!.files['/live/mar-a.jsonl'] = fileSpanning('2026-03-20T10:00:00Z') + markCacheDirty(p1, 'claude', '/live/mar-a.jsonl') + p2.providers['claude']!.files['/live/mar-b.jsonl'] = fileSpanning('2026-03-21T10:00:00Z') + markCacheDirty(p2, 'claude', '/live/mar-b.jsonl') + await Promise.allSettled([saveCache(p1), saveCache(p2)]) + + clearLoadCacheMemo() + const final = await loadCache() + // The envelope is internally consistent and the pre-existing March session + // survived; a read-modify-write loser is re-derived by the next parse. + for (const ref of Object.values((await envelope()).providers['claude']!.shards)) { + expect(existsSync(join(sessionCacheDir(), ref.name))).toBe(true) + } + expect(final.providers['claude']!.files['/live/mar.jsonl']).toBeDefined() + expect(final.providers['claude']!.files['/live/jun.jsonl']).toBeDefined() + const landed = ['/live/mar-a.jsonl', '/live/mar-b.jsonl'].filter(p => final.providers['claude']!.files[p]) + expect(landed.length).toBeGreaterThanOrEqual(1) + }) +}) + +describe('retiring an orphaned prior layout', () => { + it('sweeps a v8 directory and a v7 file left behind by an interrupted re-layout', async () => { + await saveCache({ version: CACHE_VERSION, complete: true, providers: { + claude: { envFingerprint: 'fp', files: { '/a.jsonl': fileSpanning('2026-05-10T10:00:00Z') } }, + } }) + const v8Dir = join(TMP_DIR, 'session-cache.v8') + await mkdir(v8Dir, { recursive: true }) + await writeFile(join(v8Dir, 'envelope.json'), JSON.stringify({ version: 8, nonce: 'n', shards: {} })) + await writeFile(join(v8Dir, 'claude.abc.json'), '{}') + const v7 = join(TMP_DIR, 'session-cache.v7.json') + await writeFile(v7, '{}') + + // Fresh: an in-flight write by an older binary must be left alone. + await cleanupOrphanedTempFiles() + expect(existsSync(v8Dir)).toBe(true) + expect(existsSync(v7)).toBe(true) + + const old = new Date(Date.now() - 90 * 60 * 1000) + await utimes(join(v8Dir, 'envelope.json'), old, old) + await utimes(v7, old, old) + await cleanupOrphanedTempFiles() + expect(existsSync(v8Dir)).toBe(false) + expect(existsSync(v7)).toBe(false) + }) +}) diff --git a/tests/session-cache.test.ts b/tests/session-cache.test.ts index de8df9c0..718c0c77 100644 --- a/tests/session-cache.test.ts +++ b/tests/session-cache.test.ts @@ -21,11 +21,12 @@ import { mergeCallByDedupKey, reconcileFile, saveCache, - sessionCachePath, + sessionCacheDir, } from '../src/session-cache.js' +import { readCacheOnDisk, writeCacheOnDisk } from './fixtures/session-cache-io.js' -// Version-suffixed filename (e.g. session-cache.v5.json) the cache now writes to. -const CACHE_FILE = () => basename(sessionCachePath()) +// Version-suffixed directory (e.g. session-cache.v8) the cache now writes to. +const CACHE_DIR = () => basename(sessionCacheDir()) const TMP_DIR = join(tmpdir(), `codeburn-scache-test-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`) @@ -185,8 +186,7 @@ describe('loadCache / saveCache', () => { it('atomic write does not leave partial file on error', async () => { await saveCache(emptyCache()) - const raw = await readFile(sessionCachePath(), 'utf-8') - expect(JSON.parse(raw)).toEqual(emptyCache()) + expect(await readCacheOnDisk()).toEqual(emptyCache()) }) }) @@ -196,14 +196,15 @@ describe('versioned cache file + legacy adoption', () => { function validCache(): SessionCache { return { version: CACHE_VERSION, + complete: false, providers: { claude: { envFingerprint: 'abc123', files: { '/path/to/session.jsonl': makeCachedFile() } } }, } } - it('writes and reads the version-suffixed file, never the legacy name', async () => { - expect(basename(sessionCachePath())).toBe(`session-cache.v${CACHE_VERSION}.json`) + it('writes and reads the version-suffixed directory, never the legacy name', async () => { + expect(basename(sessionCacheDir())).toBe(`session-cache.v${CACHE_VERSION}`) await saveCache(validCache()) - expect(existsSync(sessionCachePath())).toBe(true) + expect(existsSync(sessionCacheDir())).toBe(true) expect(existsSync(join(TMP_DIR, 'session-cache.json'))).toBe(false) expect(await loadCache()).toEqual(validCache()) }) @@ -213,9 +214,9 @@ describe('versioned cache file + legacy adoption', () => { const legacy = join(TMP_DIR, 'session-cache.json') await writeFile(legacy, JSON.stringify(validCache())) - // Versioned file absent → adopt-copy from legacy on first load. + // Versioned directory absent → adopt-copy from legacy on first load. expect(await loadCache()).toEqual(validCache()) - expect(existsSync(sessionCachePath())).toBe(true) + expect(existsSync(sessionCacheDir())).toBe(true) // Legacy left intact (not deleted, not rewritten). expect(existsSync(legacy)).toBe(true) expect(JSON.parse(await readFile(legacy, 'utf-8'))).toEqual(validCache()) @@ -224,7 +225,7 @@ describe('versioned cache file + legacy adoption', () => { // file exists. const mutated: SessionCache = { version: CACHE_VERSION, providers: { codex: { envFingerprint: 'zzz', files: {} } } } await writeFile(legacy, JSON.stringify(mutated)) - expect(await loadCache()).toEqual(validCache()) + expect(await readCacheOnDisk()).toEqual(validCache()) }) it('ignores a different-version legacy file and never touches it', async () => { @@ -234,8 +235,8 @@ describe('versioned cache file + legacy adoption', () => { await writeFile(legacy, JSON.stringify(stale)) expect((await loadCache()).providers).toEqual({}) - // No versioned file adopted; legacy left byte-intact. - expect(existsSync(sessionCachePath())).toBe(false) + // No versioned directory adopted; legacy left byte-intact. + expect(existsSync(sessionCacheDir())).toBe(false) expect(JSON.parse(await readFile(legacy, 'utf-8'))).toEqual(stale) }) @@ -246,9 +247,9 @@ describe('versioned cache file + legacy adoption', () => { await writeFile(legacy, legacyContent) await saveCache(validCache()) - // The versioned file holds the new data; the legacy file is byte-untouched. + // The shards hold the new data; the legacy file is byte-untouched. expect(await readFile(legacy, 'utf-8')).toBe(legacyContent) - expect(JSON.parse(await readFile(sessionCachePath(), 'utf-8'))).toEqual(validCache()) + expect(await readCacheOnDisk()).toEqual(validCache()) }) }) @@ -820,7 +821,7 @@ describe('loadCache validation', () => { } } }, } await writeRawCache(cache) - expect((await loadCache())).toEqual(cache) + expect(await loadCache()).toEqual(cache) }) it('accepts a fully valid cache with all fields populated', async () => { @@ -845,7 +846,8 @@ describe('cleanupOrphanedTempFiles', () => { it('removes .tmp files older than 5 minutes', async () => { await mkdir(TMP_DIR, { recursive: true }) - const oldTmp = join(TMP_DIR, `${CACHE_FILE()}.abc123.tmp`) + await mkdir(join(TMP_DIR, CACHE_DIR()), { recursive: true }) + const oldTmp = join(TMP_DIR, CACHE_DIR(), 'claude.abc123.json.tmp') await writeFile(oldTmp, 'stale') const { utimes } = await import('fs/promises') const oldTime = new Date(Date.now() - 10 * 60 * 1000) @@ -858,7 +860,8 @@ describe('cleanupOrphanedTempFiles', () => { it('preserves recent .tmp files', async () => { await mkdir(TMP_DIR, { recursive: true }) - const recentTmp = join(TMP_DIR, `${CACHE_FILE()}.def456.tmp`) + await mkdir(join(TMP_DIR, CACHE_DIR()), { recursive: true }) + const recentTmp = join(TMP_DIR, CACHE_DIR(), 'claude.def456.json.tmp') await writeFile(recentTmp, 'recent') await cleanupOrphanedTempFiles() diff --git a/tests/sync-ledger-otlp.test.ts b/tests/sync-ledger-otlp.test.ts index 0b32f80d..b7c72ca6 100644 --- a/tests/sync-ledger-otlp.test.ts +++ b/tests/sync-ledger-otlp.test.ts @@ -2,7 +2,7 @@ * Unit tests for sync ledger and OTLP payload builder. */ -import { describe, it, expect, beforeEach, afterEach } from 'vitest' +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest' import { mkdtemp, rm } from 'fs/promises' import { join } from 'path' import { tmpdir } from 'os' @@ -233,17 +233,21 @@ describe('batchCalls', () => { describe('ledger', () => { let tmpDir: string const originalHome = process.env.HOME + const originalCacheDir = process.env.CODEBURN_CACHE_DIR + const originalXdgCacheDir = process.env.XDG_CACHE_HOME beforeEach(async () => { tmpDir = await mkdtemp(join(tmpdir(), 'codeburn-ledger-')) process.env.HOME = tmpDir - // env-isolation.ts redirects XDG_CACHE_HOME to a per-worker sandbox shared - // across tests — the ledger honors XDG, so point it at the per-test dir. - process.env.XDG_CACHE_HOME = join(tmpDir, '.cache') + process.env.CODEBURN_CACHE_DIR = join(tmpDir, '.cache', 'codeburn') }) afterEach(async () => { process.env.HOME = originalHome + if (originalCacheDir === undefined) delete process.env.CODEBURN_CACHE_DIR + else process.env.CODEBURN_CACHE_DIR = originalCacheDir + if (originalXdgCacheDir === undefined) delete process.env.XDG_CACHE_HOME + else process.env.XDG_CACHE_HOME = originalXdgCacheDir await rm(tmpDir, { recursive: true, force: true }) }) @@ -308,6 +312,91 @@ describe('ledger', () => { expect(clearLedger()).toBe(0) }) + it('clearLedger removes coexisting canonical and eligible legacy ledgers without adopting either', async () => { + const { clearLedger } = await import('../src/sync/ledger.js') + const { existsSync, mkdirSync, writeFileSync } = await import('fs') + const canonicalDir = join(tmpDir, '.cache', 'codeburn') + const xdgDir = join(tmpDir, 'xdg-clear-coexisting') + const legacyDir = join(xdgDir, 'codeburn') + + delete process.env.CODEBURN_CACHE_DIR + process.env.XDG_CACHE_HOME = xdgDir + mkdirSync(canonicalDir, { recursive: true }) + mkdirSync(legacyDir, { recursive: true }) + writeFileSync(join(canonicalDir, 'sync-ledger.json'), JSON.stringify([ + { key: 'canonical', ts: '2026-07-02T00:00:00Z' }, + { key: 'duplicate', ts: '2026-07-03T00:00:00Z' }, + ])) + writeFileSync(join(legacyDir, 'sync-ledger.json'), JSON.stringify([ + { key: 'legacy', ts: '2026-07-01T00:00:00Z' }, + { key: 'duplicate', ts: '2025-01-01T00:00:00Z' }, + ])) + + expect(clearLedger()).toBe(3) + expect(existsSync(join(canonicalDir, 'sync-ledger.json'))).toBe(false) + expect(existsSync(join(legacyDir, 'sync-ledger.json'))).toBe(false) + }) + + it('clearLedger attempts both targets, reports a real unlink failure, and can retry the remainder', async () => { + const fs = await import('fs') + const canonicalDir = join(tmpDir, '.cache', 'codeburn') + const canonicalPath = join(canonicalDir, 'sync-ledger.json') + const xdgDir = join(tmpDir, 'xdg-clear-retry') + const legacyDir = join(xdgDir, 'codeburn') + const legacyPath = join(legacyDir, 'sync-ledger.json') + + delete process.env.CODEBURN_CACHE_DIR + process.env.XDG_CACHE_HOME = xdgDir + fs.mkdirSync(canonicalDir, { recursive: true }) + fs.mkdirSync(legacyDir, { recursive: true }) + fs.writeFileSync(canonicalPath, JSON.stringify([ + { key: 'canonical', ts: '2026-07-02T00:00:00Z' }, + ])) + fs.writeFileSync(legacyPath, JSON.stringify([ + { key: 'legacy', ts: '2026-07-01T00:00:00Z' }, + ])) + + const attempts: string[] = [] + let failCanonicalOnce = true + vi.doMock('fs', async () => { + const actual = await vi.importActual('fs') + return { + ...actual, + unlinkSync: (path: fs.PathLike) => { + const value = String(path) + attempts.push(value) + if (value === canonicalPath && failCanonicalOnce) { + failCanonicalOnce = false + throw Object.assign(new Error('injected canonical unlink failure'), { code: 'EACCES' }) + } + return actual.unlinkSync(path) + }, + } + }) + vi.resetModules() + + try { + const { clearLedger } = await import('../src/sync/ledger.js') + expect(() => clearLedger()).toThrow('injected canonical unlink failure') + expect(attempts).toContain(canonicalPath) + expect(attempts).toContain(legacyPath) + expect(fs.existsSync(canonicalPath)).toBe(true) + expect(fs.existsSync(legacyPath)).toBe(false) + expect(JSON.parse(fs.readFileSync(canonicalPath, 'utf8'))).toEqual([ + { key: 'canonical', ts: '2026-07-02T00:00:00Z' }, + ]) + + // The successful legacy deletion is not replayed or migrated. Retrying + // removes only the canonical remainder; its missing peer is ENOENT-safe. + expect(clearLedger()).toBe(1) + expect(fs.existsSync(canonicalPath)).toBe(false) + expect(fs.existsSync(legacyPath)).toBe(false) + } finally { + vi.doUnmock('fs') + vi.resetModules() + } + }) + it('corrupt ledger file reads as empty (crash-safe recovery)', async () => { const { readLedger } = await import('../src/sync/ledger.js') const { mkdirSync, writeFileSync } = await import('fs') @@ -328,21 +417,168 @@ describe('ledger', () => { expect(existsSync(join(dir, 'sync-ledger.json.tmp'))).toBe(false) }) - it('honors XDG_CACHE_HOME when set', async () => { + it('honors CODEBURN_CACHE_DIR at call time', async () => { const { writeLedger, readLedger } = await import('../src/sync/ledger.js') const { existsSync } = await import('fs') const { join } = await import('path') - const xdgDir = join(process.env.HOME!, 'xdg-cache') - const original = process.env.XDG_CACHE_HOME + const firstDir = join(tmpDir, 'cache-a') + const secondDir = join(tmpDir, 'cache-b') + + process.env.CODEBURN_CACHE_DIR = firstDir + writeLedger([{ key: 'first', ts: '2026-07-01T00:00:00Z' }]) + process.env.CODEBURN_CACHE_DIR = secondDir + writeLedger([{ key: 'second', ts: '2026-07-02T00:00:00Z' }]) + + expect(existsSync(join(firstDir, 'sync-ledger.json'))).toBe(true) + expect(existsSync(join(secondDir, 'sync-ledger.json'))).toBe(true) + expect(readLedger().map(e => e.key)).toEqual(['second']) + + process.env.CODEBURN_CACHE_DIR = firstDir + expect(readLedger().map(e => e.key)).toEqual(['first']) + }) + + it('uses the shared default when both overrides are explicitly absent', async () => { + const { writeLedger } = await import('../src/sync/ledger.js') + const { existsSync } = await import('fs') + const { join } = await import('path') + + delete process.env.CODEBURN_CACHE_DIR + delete process.env.XDG_CACHE_HOME + writeLedger([{ key: 'default', ts: '2026-07-01T00:00:00Z' }]) + + expect(existsSync(join(tmpDir, '.cache', 'codeburn', 'sync-ledger.json'))).toBe(true) + }) + + it('adopts an XDG-only legacy ledger into the canonical default and writes there thereafter', async () => { + const { appendToLedger, readLedger } = await import('../src/sync/ledger.js') + const { existsSync, mkdirSync, readFileSync, writeFileSync } = await import('fs') + const { join } = await import('path') + const xdgDir = join(tmpDir, 'xdg-cache') + const legacyDir = join(xdgDir, 'codeburn') + const canonicalDir = join(tmpDir, '.cache', 'codeburn') + + delete process.env.CODEBURN_CACHE_DIR process.env.XDG_CACHE_HOME = xdgDir - try { - writeLedger([{ key: 'xdg-entry', ts: '2026-07-01T00:00:00Z' }]) - expect(existsSync(join(xdgDir, 'codeburn', 'sync-ledger.json'))).toBe(true) - expect(readLedger().map(e => e.key)).toEqual(['xdg-entry']) - } finally { - if (original === undefined) delete process.env.XDG_CACHE_HOME - else process.env.XDG_CACHE_HOME = original - } + mkdirSync(legacyDir, { recursive: true }) + writeFileSync(join(legacyDir, 'sync-ledger.json'), JSON.stringify([ + { key: 'legacy', ts: '2026-07-01T00:00:00Z' }, + ])) + + expect(readLedger().map(entry => entry.key)).toEqual(['legacy']) + expect(existsSync(join(canonicalDir, 'sync-ledger.json'))).toBe(true) + expect(existsSync(join(legacyDir, 'sync-ledger.json'))).toBe(false) + + appendToLedger([{ key: 'canonical', ts: '2026-07-02T00:00:00Z' }]) + expect(JSON.parse(readFileSync(join(canonicalDir, 'sync-ledger.json'), 'utf8')).map((entry: { key: string }) => entry.key)).toEqual([ + 'legacy', + 'canonical', + ]) + expect(existsSync(join(legacyDir, 'sync-ledger.json'))).toBe(false) + }) + + it('does not adopt a legacy XDG ledger when CODEBURN_CACHE_DIR is explicitly set', async () => { + const { readLedger, writeLedger } = await import('../src/sync/ledger.js') + const { existsSync, mkdirSync, writeFileSync } = await import('fs') + const explicitDir = join(tmpDir, 'explicit-cache-precedence') + const xdgDir = join(tmpDir, 'xdg-cache-precedence') + const legacyDir = join(xdgDir, 'codeburn') + + process.env.CODEBURN_CACHE_DIR = explicitDir + process.env.XDG_CACHE_HOME = xdgDir + mkdirSync(legacyDir, { recursive: true }) + writeFileSync(join(legacyDir, 'sync-ledger.json'), JSON.stringify([ + { key: 'legacy', ts: '2026-07-01T00:00:00Z' }, + ])) + + expect(readLedger()).toEqual([]) + writeLedger([{ key: 'explicit', ts: '2026-07-02T00:00:00Z' }]) + + expect(readLedger().map(entry => entry.key)).toEqual(['explicit']) + expect(existsSync(join(explicitDir, 'sync-ledger.json'))).toBe(true) + expect(existsSync(join(legacyDir, 'sync-ledger.json'))).toBe(true) + }) + + it('merges an XDG legacy ledger into an existing canonical ledger once', async () => { + const { readLedger } = await import('../src/sync/ledger.js') + const { existsSync, mkdirSync, writeFileSync } = await import('fs') + const canonicalDir = join(tmpDir, '.cache', 'codeburn') + const xdgDir = join(tmpDir, 'xdg-cache-merge') + const legacyDir = join(xdgDir, 'codeburn') + + delete process.env.CODEBURN_CACHE_DIR + process.env.XDG_CACHE_HOME = xdgDir + mkdirSync(canonicalDir, { recursive: true }) + mkdirSync(legacyDir, { recursive: true }) + writeFileSync(join(canonicalDir, 'sync-ledger.json'), JSON.stringify([ + { key: 'canonical', ts: '2026-07-02T00:00:00Z' }, + { key: 'duplicate', ts: '2026-07-03T00:00:00Z' }, + ])) + writeFileSync(join(legacyDir, 'sync-ledger.json'), JSON.stringify([ + { key: 'legacy', ts: '2026-07-01T00:00:00Z' }, + { key: 'duplicate', ts: '2025-01-01T00:00:00Z' }, + ])) + + expect(readLedger()).toEqual([ + { key: 'canonical', ts: '2026-07-02T00:00:00Z' }, + { key: 'duplicate', ts: '2026-07-03T00:00:00Z' }, + { key: 'legacy', ts: '2026-07-01T00:00:00Z' }, + ]) + expect(existsSync(join(legacyDir, 'sync-ledger.json'))).toBe(false) + // A later read is canonical-only and stable; XDG is no longer active. + expect(readLedger().map(entry => entry.key)).toEqual(['canonical', 'duplicate', 'legacy']) + }) + + it('prefers non-empty CODEBURN_CACHE_DIR over XDG_CACHE_HOME', async () => { + const { writeLedger } = await import('../src/sync/ledger.js') + const { existsSync } = await import('fs') + const { join } = await import('path') + const explicitDir = join(tmpDir, 'explicit-cache') + const xdgDir = join(tmpDir, 'xdg-cache') + + process.env.CODEBURN_CACHE_DIR = explicitDir + process.env.XDG_CACHE_HOME = xdgDir + writeLedger([{ key: 'explicit', ts: '2026-07-01T00:00:00Z' }]) + + expect(existsSync(join(explicitDir, 'sync-ledger.json'))).toBe(true) + expect(existsSync(join(xdgDir, 'codeburn', 'sync-ledger.json'))).toBe(false) + }) + + it.each(['', ' '])('ignores empty CODEBURN_CACHE_DIR %j and writes to the canonical default', async explicit => { + const { writeLedger } = await import('../src/sync/ledger.js') + const { existsSync } = await import('fs') + const { join } = await import('path') + const xdgDir = join(tmpDir, `xdg-cache-${explicit.length}`) + + process.env.CODEBURN_CACHE_DIR = explicit + process.env.XDG_CACHE_HOME = xdgDir + writeLedger([{ key: 'xdg-fallback', ts: '2026-07-01T00:00:00Z' }]) + + expect(existsSync(join(tmpDir, '.cache', 'codeburn', 'sync-ledger.json'))).toBe(true) + expect(existsSync(join(xdgDir, 'codeburn', 'sync-ledger.json'))).toBe(false) + }) + + it.each(['', ' '])('ignores empty XDG_CACHE_HOME %j and uses the shared default', async xdg => { + const { writeLedger } = await import('../src/sync/ledger.js') + const { existsSync } = await import('fs') + const { join } = await import('path') + + delete process.env.CODEBURN_CACHE_DIR + process.env.XDG_CACHE_HOME = xdg + writeLedger([{ key: 'default-fallback', ts: '2026-07-01T00:00:00Z' }]) + + expect(existsSync(join(tmpDir, '.cache', 'codeburn', 'sync-ledger.json'))).toBe(true) + }) + + it.each(['', ' '])('uses the shared default when both overrides are empty and CODEBURN is %j', async explicit => { + const { writeLedger } = await import('../src/sync/ledger.js') + const { existsSync } = await import('fs') + const { join } = await import('path') + + process.env.CODEBURN_CACHE_DIR = explicit + process.env.XDG_CACHE_HOME = ' ' + writeLedger([{ key: 'default-fallback', ts: '2026-07-01T00:00:00Z' }]) + + expect(existsSync(join(tmpDir, '.cache', 'codeburn', 'sync-ledger.json'))).toBe(true) }) }) diff --git a/tsup.config.ts b/tsup.config.ts index c20e55c9..94f7ccec 100644 --- a/tsup.config.ts +++ b/tsup.config.ts @@ -1,7 +1,7 @@ import { defineConfig } from 'tsup' export default defineConfig({ - entry: ['src/main.ts'], + entry: ['src/main.ts', 'src/parse-worker.ts'], format: ['esm'], target: 'node20', outDir: 'dist', diff --git a/vitest.config.ts b/vitest.config.ts index b56c0158..0fa9f58b 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -6,5 +6,8 @@ export default defineConfig({ // session-discovery env vars (CLAUDE_CONFIG_DIRS, HOME, XDG_*, every // provider-specific *_HOME) don't bleed real local data into fixtures. setupFiles: ['./tests/setup/env-isolation.ts'], + // Real-I/O tests (session parses, sqlite fixtures, worker pools) exceed the + // 5s default under CI runner load; a hung test still fails at 30s. + testTimeout: 30_000, }, }) diff --git a/windows/.gitignore b/windows/.gitignore new file mode 100644 index 00000000..2e4d499b --- /dev/null +++ b/windows/.gitignore @@ -0,0 +1,10 @@ +node_modules/ +# dist/ is a Vite build output; we gitignore its contents but keep a tracked placeholder +# index.html so Tauri's compile-time `frontendDist` validation passes on fresh clones before +# anyone has run `npm run build`. Real build output replaces the placeholder locally. +dist/* +!dist/index.html +src-tauri/target/ +src-tauri/gen/ +.DS_Store +*.log diff --git a/windows/DEVELOPMENT.md b/windows/DEVELOPMENT.md new file mode 100644 index 00000000..e59a7392 --- /dev/null +++ b/windows/DEVELOPMENT.md @@ -0,0 +1,188 @@ +# CodeBurn Menubar (Windows) + +Tauri 2.x tray app that surfaces CodeBurn in the Windows notification area. It is the Windows +mirror of the native macOS menubar in `../mac/`, which stays the authoritative look and feel; +this project mirrors its layout, colors, and data via the shared `tokens.json`. + +Linux (ksni / AppIndicator) support is compiled and kept working for dev, but it is +**experimental and unreleased** - Linux users should use the GNOME extension in `../gnome/`. +The releases this repo cuts from here are Windows only. + +Not everything crosses over: the spend badge is a second tray icon carrying the number as its +bitmap, which only the Windows notification area provides. `tray_badge` is compiled out on +Linux, the `set_tray_badge` command reports it as unsupported there, and the frontend hides +the control behind `TRAY_BADGE_SUPPORTED` in `src/lib/platform.ts`. Anything else that is +Windows-only must be cfg-gated the same way, or the ubuntu leg of CI fails on dead code. + +## Architecture + +``` +windows/ +├── src/ React + TypeScript popover UI (runs inside the Tauri webview) +├── src-tauri/ +│ ├── src/ +│ │ ├── main.rs binary entry +│ │ ├── lib.rs tray, window lifecycle, state wiring +│ │ ├── cli.rs argv-validated spawn of the codeburn CLI +│ │ ├── config.rs ~/.config/codeburn/config.json read/write under a lock +│ │ ├── plan.rs Claude OAuth quota (port of mac/.../ClaudeSubscriptionService.swift) +│ │ └── fx.rs Frankfurter fetch + 24h disk cache + [0.0001, 1e6] clamp +│ ├── capabilities/ Tauri v2 permission manifests +│ └── icons/ tray + bundle icons +└── tokens.json shared design tokens (also consumed by mac/ at build time) +``` + +## Prerequisites (Windows) + +```powershell +# Rust +winget install Rustlang.Rustup +rustup target add x86_64-pc-windows-msvc + +# WebView2 Runtime +winget install Microsoft.EdgeWebView2Runtime + +# Microsoft C++ Build Tools (ships with Visual Studio Installer; pick "Desktop development with C++") +``` + +## Prerequisites (macOS / Linux, dev only) + +Tauri builds on macOS and Linux for inner-loop UI iteration. The shipping macOS product is the +Swift app in `../mac/`, so we don't cut a Tauri Mac release. + +```bash +# macOS +brew install rust node + +# Ubuntu / Debian +sudo apt update +sudo apt install -y \ + build-essential curl wget file \ + libwebkit2gtk-4.1-dev \ + libayatana-appindicator3-dev \ + librsvg2-dev \ + libssl-dev \ + libxdo-dev \ + libgtk-3-dev +``` + +## Run the dev server + +```bash +cd windows +npm install +npm run tauri dev +``` + +Under the hood this starts Vite on `localhost:1420`, builds `src-tauri/target/debug/codeburn-menubar`, +and opens a window wired to the dev server with hot reload for the React code. The tray icon +appears at the same time. + +If the codeburn CLI isn't on PATH (dev builds from this monorepo), point the app at your local build: + +```bash +npm --prefix .. run build +CODEBURN_BIN="node $(pwd)/../dist/cli.js" npm run tauri dev +``` + +`CODEBURN_BIN` is validated against a strict allowlist (alphanumerics plus `._/-` and space; +`\ : ( )` also allowed on Windows) before use; anything else falls back to auto-resolution. + +Without `CODEBURN_BIN` the app looks for `codeburn` (`codeburn.cmd` / `codeburn.exe` on Windows) +on the inherited `PATH`, then in the usual npm and node prefixes (`%APPDATA%\npm`, +`%LOCALAPPDATA%\Programs\nodejs`, pnpm, Volta, scoop, `/opt/homebrew/bin`, `~/.npm-global/bin`), +and finally on Windows in the live user and machine `PATH` read from the registry, so a CLI +installed after the tray app was launched is still found. Only absolute directory entries are +considered - empty or relative `PATH` entries are skipped so nothing is ever resolved out of the +current working directory. + +If nothing is found, or `codeburn --version` is older than `MIN_CLI_VERSION` +(`src-tauri/src/cli.rs`), the popover shows a setup screen with the install command and a +"Check again" button. That gate is probed once on mount, before the first payload fetch. + +`MIN_CLI_VERSION` is **0.9.9**: the first release whose `codeburn status --format menubar-json` +accepts `--no-optimize`, which the app's quiet background refreshes always pass. Every payload +field the popover reads (`current.providers`, `current.cacheHitPercent`, `history.daily[].topModels`) +also exists at that version. + +## Refresh policy + +Mirrors `mac/Sources/CodeBurnMenubar/RefreshCadence.swift`: each CLI fetch is a full Node +process, so the cadence follows popover visibility. + +- popover visible: 60 s tick, full fetch (optimize findings included) +- popover hidden: 120 s tick, `today`/`all` only, `--no-optimize` +- on show: immediate refresh when the visible key is older than 60 s + +## Plan / quota + +The Plan pill (visible on the Claude tab, or when Claude is the only detected provider) reads +Claude Code's OAuth credentials from `~/.claude/.credentials.json`, calls +`https://api.anthropic.com/api/oauth/usage`, and stores one snapshot per window under +`~/.cache/codeburn/subscription-snapshots.json` (`CODEBURN_CACHE_DIR` override) so a freshly +reset window can still show last cycle's final. This is the same file format the macOS app +writes. Nothing is logged: the credential blob never leaves the Rust side. + +On a 401 we do **not** call the token refresh endpoint. Claude's refresh token is single-use +and rotates, so spending it would invalidate the token Claude Code itself is holding and break +the user's login. Like `ClaudeCredentialStore.refreshAfter401` on macOS, we re-read Claude's +own credential file for a token it has already rotated, and report a transient failure when +there isn't one yet. + +## Build a production package + +```bash +# Windows (.msi): run from a Windows host +npm run tauri build + +# Linux (experimental): produces .deb, .rpm, .AppImage under src-tauri/target/release/bundle/ +npm run tauri build +``` + +## Security model + +- **Process spawn**: every call into the codeburn CLI goes through `CodeburnCli::fetch_menubar_payload`, + which builds argv explicitly and runs the binary directly (no `sh -c`). `CODEBURN_BIN` is + allowlisted before use. Windows system tools (`reg.exe`, `cmd.exe`) are invoked by absolute + path under `%SystemRoot%\System32` so `CreateProcess`'s current-directory search can never + pick up a planted binary; `claude` is resolved from absolute `PATH` directories the same way. +- **Pipes**: stdout is capped at 20 MB, stderr at 256 KB, total wall time at 60 s. A hung CLI + cannot pin file descriptors or memory. +- **Config writes**: `~/.config/codeburn/config.json` writes run under a POSIX `flock` on + `~/.config/codeburn/.config.lock`. On Windows the same path uses a create-new lock file. Note + that this lock is advisory *between instances of this app only* - the codeburn CLI does not + take it - so it narrows, but does not eliminate, a concurrent-write race. A live holder keeps + its file handle open and Windows will not unlink an open file, so the staleness sweep can only + ever reclaim a lock whose owner is gone (after 30 s). +- **Snapshot writes**: `subscription-snapshots.json` refuses a symlinked target and is written + 0600 on unix, mirroring `mac/Sources/CodeBurnMenubar/Security/SafeFile.swift`. +- **Credentials**: the Plan view reads `~/.claude/.credentials.json` with a 64 KB cap and refuses + symlinks; the access token is only ever sent to the Anthropic usage endpoint over TLS, and the + refresh token is never read or sent at all. +- **FX fetches**: Frankfurter response is parsed as JSON and the rate is clamped to + `[0.0001, 1_000_000]` before it touches displayed numbers. Stale cache preferred over poisoned + fresh data. +- **CSP**: `connect-src` restricted to `self`, `ipc:`, and `https://api.frankfurter.app`. No + inline scripts. + +## CI and release tags + +- `.github/workflows/windows-menubar-ci.yml` runs on any `windows/**` change: `tsc --noEmit`, + `cargo clippy -D warnings` and `cargo test` on windows-latest + ubuntu-latest, plus a release + build smoke on Windows. +- `windows-v*` tag (e.g. `windows-v0.9.20`) triggers + `.github/workflows/release-menubar-windows.yml`; publishes the `.msi` (plus its sha256) to + a "Windows Menubar vX" release. Unsigned for now, so Windows SmartScreen prompts on first run + until a signing cert is in place. +- `codeburn menubar` installs from those assets (`src/menubar-installer.ts`): it pins the tag to + the CLI's own version (`windows-v`), falls back to a scan of the newest `windows-v*` + release carrying both assets, verifies the sha256 before anything executes the file, then runs + `%SystemRoot%\System32\msiexec.exe /i /passive /norestart` and launches the exe named by + the product's Uninstall registry key. Renaming the bundle or the MSI asset breaks that lookup — + `WINDOWS_RELEASE` and `WINDOWS_PRODUCT_NAME` in the installer have to move with it. + +## Pending work + +1. Code signing for the Windows `.msi` to remove the SmartScreen warning. +2. Linux: decide whether to ship at all (the GNOME extension in `../gnome/` covers that + surface today) or promote the ksni tray out of experimental. diff --git a/windows/Scripts/autoinstall/README.md b/windows/Scripts/autoinstall/README.md new file mode 100644 index 00000000..5e81424a --- /dev/null +++ b/windows/Scripts/autoinstall/README.md @@ -0,0 +1,38 @@ +# Unattended Ubuntu install for the CodeBurn dev VM + +This directory contains a cloud-init `user-data` + `meta-data` pair that tells the Ubuntu 24.04 Server installer to configure itself without any user prompts. After it finishes, you reboot into GNOME and run the one-line provisioner. + +Default credentials in `user-data`: **`codeburn` / `codeburn`**. Change them before using anywhere that matters. + +## Build the CIDATA ISO (on your Mac) + +```bash +cd windows/Scripts/autoinstall +hdiutil makehybrid -o codeburn-cidata.iso \ + -hfs -joliet -iso -default-volume-name CIDATA . +``` + +That produces `codeburn-cidata.iso` (around 2 KB) with the two YAML files at the root, labelled `CIDATA`. + +## Hook it into UTM + +1. Create the VM as usual (Virtualize → Linux → Ubuntu Server arm64 ISO). +2. Before first boot, open the VM's Settings → **Drives** → **New Drive** → pick **Removable** → **Import**, and select `codeburn-cidata.iso`. +3. Boot. The Ubuntu installer auto-detects the CIDATA volume, reads the autoinstall config, and runs the install without prompts. Takes 15-20 minutes depending on disk speed. +4. Reboot into the installed system, log in as `codeburn`, then: + + ```bash + bash ~/provision.sh + ``` + + (The autoinstall drops the script to `~/provision.sh`. It installs Rust + Node + the codeburn CLI, clones the repo, and sets up the windows/ npm deps.) + +5. `cd ~/codeburn/windows && npm run tauri dev`. + +## Why not automate the provisioner run too + +cloud-init's `late-commands` runs in the installer environment, which doesn't have a GNOME session for the tray icon to land in. We deliberately stop short of running `npm run tauri dev` from within autoinstall so the tray shows up on your first real login instead of a detached systemd unit. + +## Skipping autoinstall + +If you'd rather click through the Ubuntu installer normally, ignore this directory entirely. The `provision-linux.sh` script in the parent directory works the same way whether the OS was installed unattended or by hand. diff --git a/windows/Scripts/autoinstall/meta-data b/windows/Scripts/autoinstall/meta-data new file mode 100644 index 00000000..576f5acd --- /dev/null +++ b/windows/Scripts/autoinstall/meta-data @@ -0,0 +1,2 @@ +instance-id: codeburn-linux-01 +local-hostname: codeburn-linux diff --git a/windows/Scripts/autoinstall/user-data b/windows/Scripts/autoinstall/user-data new file mode 100644 index 00000000..f4050494 --- /dev/null +++ b/windows/Scripts/autoinstall/user-data @@ -0,0 +1,57 @@ +#cloud-config +# Ubuntu 24.04 LTS Server autoinstall configuration for a CodeBurn desktop dev VM. Mount this +# file as a second virtual disk (CIDATA volume) alongside the Ubuntu Server ISO in UTM and +# the installer runs unattended end to end. Default login: codeburn / codeburn. Change the +# identity block before running in any environment that matters. +autoinstall: + version: 1 + + # Accept the EULA-style prompts without user input. + refresh-installer: + update: yes + + locale: en_US.UTF-8 + keyboard: + layout: us + + # Wire up a default user. Password is `codeburn`; hash generated with `openssl passwd -6`. + # Regenerate the hash if you care about the credentials outside of a throwaway VM. + identity: + hostname: codeburn-linux + username: codeburn + password: "$6$rounds=4096$JrKVZcJ2$F93p8IWyTlZR5p1Trmno/qCnhYI1BnbUUYdf6HsiD.XW4T0I3JtvzH40nWNy9Z1CcJ2X5C6RuzK0bj9WM3x/n." + + ssh: + install-server: yes + allow-pw: yes + + # Install GNOME + the build dependencies Tauri needs so the first login is already ready + # to run `npm run tauri dev` without another apt round trip. + packages: + - ubuntu-desktop-minimal + - build-essential + - curl + - wget + - file + - git + - libwebkit2gtk-4.1-dev + - libayatana-appindicator3-dev + - librsvg2-dev + - libssl-dev + - libxdo-dev + - libgtk-3-dev + - pkg-config + + # Run the provisioner as the new user on first boot. It installs Node + Rust, pulls the + # repo, and runs `npm install` for the desktop app. After the script finishes, logging in + # to GNOME and running `cd ~/codeburn/windows && npm run tauri dev` brings the tray up. + late-commands: + - curtin in-target --target=/target -- bash -lc ' + sudo -iu codeburn bash -lc " + curl -fsSL https://raw.githubusercontent.com/getagentseal/codeburn/main/windows/Scripts/provision-linux.sh \ + -o /home/codeburn/provision.sh + chmod +x /home/codeburn/provision.sh + " + ' + + shutdown: reboot diff --git a/windows/Scripts/provision-linux.sh b/windows/Scripts/provision-linux.sh new file mode 100755 index 00000000..23ba9991 --- /dev/null +++ b/windows/Scripts/provision-linux.sh @@ -0,0 +1,89 @@ +#!/usr/bin/env bash +# One-shot Ubuntu provisioning for the CodeBurn desktop (Tauri) dev environment. +# +# Usage inside a fresh Ubuntu 24.04 LTS Server VM (after `sudo apt install +# ubuntu-desktop-minimal && sudo reboot`, and logging into GNOME): +# +# curl -fsSL https://raw.githubusercontent.com/getagentseal/codeburn/main/windows/Scripts/provision-linux.sh | bash +# +# Or if you cloned the repo manually: `bash windows/Scripts/provision-linux.sh`. +# +# Installs: build toolchain, webkit + appindicator headers, Node 20 LTS, Rust stable, +# the codeburn npm CLI, and this repo. Leaves you one command away from `npm run tauri dev`. + +set -euo pipefail + +REPO_URL="https://github.com/getagentseal/codeburn.git" +BRANCH="feat/tauri-menubar-win-linux" +CHECKOUT="${HOME}/codeburn" + +log() { printf '\033[1;34m▸\033[0m %s\n' "$*"; } +fail() { printf '\033[1;31m✗\033[0m %s\n' "$*" >&2; exit 1; } + +# 1. Platform sanity +[[ "$(uname -s)" == "Linux" ]] || fail "Run me on Linux (detected: $(uname -s))." +if ! command -v apt-get >/dev/null; then + fail "Only apt-based distros supported by this provisioner (Ubuntu, Debian)." +fi + +log "apt update + system build deps" +sudo apt-get update -qq +sudo apt-get install -y \ + build-essential curl wget file git \ + libwebkit2gtk-4.1-dev \ + libayatana-appindicator3-dev \ + librsvg2-dev \ + libssl-dev \ + libxdo-dev \ + libgtk-3-dev \ + pkg-config + +# 2. Node 20 LTS via NodeSource if the distro version is too old. Tauri CLI needs >= 18. +if ! command -v node >/dev/null || [[ "$(node -v | sed 's/v\([0-9]*\).*/\1/')" -lt 18 ]]; then + log "installing Node 20 LTS" + curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash - + sudo apt-get install -y nodejs +fi + +# 3. Rust via rustup if not present +if ! command -v cargo >/dev/null; then + log "installing Rust via rustup" + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable --profile minimal + # shellcheck disable=SC1091 + source "$HOME/.cargo/env" +fi + +# 4. codeburn CLI (the Tauri app shells out to this for data) +if ! command -v codeburn >/dev/null; then + log "installing codeburn CLI from npm" + sudo npm install -g codeburn +fi + +# 5. Repo +if [[ -d "${CHECKOUT}/.git" ]]; then + log "updating existing checkout at ${CHECKOUT}" + git -C "${CHECKOUT}" fetch origin + git -C "${CHECKOUT}" checkout "${BRANCH}" + git -C "${CHECKOUT}" pull --ff-only origin "${BRANCH}" +else + log "cloning ${REPO_URL} into ${CHECKOUT}" + git clone --branch "${BRANCH}" "${REPO_URL}" "${CHECKOUT}" +fi + +# 6. npm deps for the desktop app +log "npm install for windows/" +(cd "${CHECKOUT}/windows" && npm install --no-audit --no-fund) + +# 7. Summary + next step +cat <CodeBurn + +

Run npm install && npm run tauri dev from windows/.

+ diff --git a/windows/index.html b/windows/index.html new file mode 100644 index 00000000..44d28a31 --- /dev/null +++ b/windows/index.html @@ -0,0 +1,12 @@ + + + + + + CodeBurn + + +
+ + + diff --git a/windows/package-lock.json b/windows/package-lock.json new file mode 100644 index 00000000..9694bd7c --- /dev/null +++ b/windows/package-lock.json @@ -0,0 +1,2069 @@ +{ + "name": "codeburn-menubar", + "version": "0.9.20", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "codeburn-menubar", + "version": "0.9.20", + "dependencies": { + "@tauri-apps/api": "^2.0.0", + "@tauri-apps/plugin-opener": "^2.0.0", + "@tauri-apps/plugin-shell": "^2.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@tauri-apps/cli": "^2.0.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.0", + "typescript": "^5.6.0", + "vite": "^6.0.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", + "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.2.tgz", + "integrity": "sha512-4GgRzy/+fsBa72/RZVJmGKPmZu9Byn8o4MoLpmNe1m8ZfYnz5emHLQz3U4gLud6Zwl0RZIcgiLD7Uq7ySFuDLA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.25.12.tgz", + "integrity": "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.25.12.tgz", + "integrity": "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.25.12.tgz", + "integrity": "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.25.12.tgz", + "integrity": "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.25.12.tgz", + "integrity": "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.25.12.tgz", + "integrity": "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.25.12.tgz", + "integrity": "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.25.12.tgz", + "integrity": "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.25.12.tgz", + "integrity": "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.25.12.tgz", + "integrity": "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.25.12.tgz", + "integrity": "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.25.12.tgz", + "integrity": "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.25.12.tgz", + "integrity": "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.25.12.tgz", + "integrity": "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.25.12.tgz", + "integrity": "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.25.12.tgz", + "integrity": "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.25.12.tgz", + "integrity": "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.25.12.tgz", + "integrity": "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.25.12.tgz", + "integrity": "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.25.12.tgz", + "integrity": "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.25.12.tgz", + "integrity": "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.25.12.tgz", + "integrity": "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.25.12.tgz", + "integrity": "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.25.12.tgz", + "integrity": "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.25.12.tgz", + "integrity": "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.25.12.tgz", + "integrity": "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.1.tgz", + "integrity": "sha512-d6FinEBLdIiK+1uACUttJKfgZREXrF0Qc2SmLII7W2AD8FfiZ9Wjd+rD/iRuf5s5dWrr1GgwXCvPqOuDquOowA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.1.tgz", + "integrity": "sha512-YjG/EwIDvvYI1YvYbHvDz/BYHtkY4ygUIXHnTdLhG+hKIQFBiosfWiACWortsKPKU/+dUwQQCKQM3qrDe8c9BA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.1.tgz", + "integrity": "sha512-mjCpF7GmkRtSJwon+Rq1N8+pI+8l7w5g9Z3vWj4T7abguC4Czwi3Yu/pFaLvA3TTeMVjnu3ctigusqWUfjZzvw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.1.tgz", + "integrity": "sha512-haZ7hJ1JT4e9hqkoT9R/19XW2QKqjfJVv+i5AGg57S+nLk9lQnJ1F/eZloRO3o9Scy9CM3wQ9l+dkXtcBgN5Ew==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.1.tgz", + "integrity": "sha512-czw90wpQq3ZsAVBlinZjAYTKduOjTywlG7fEeWKUA7oCmpA8xdTkxZZlwNJKWqILlq0wehoZcJYfBvOyhPTQ6w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.1.tgz", + "integrity": "sha512-KVB2rqsxTHuBtfOeySEyzEOB7ltlB/ux38iu2rBQzkjbwRVlkhAGIEDiiYnO2kFOkJp+Z7pUXKyrRRFuFUKt+g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.1.tgz", + "integrity": "sha512-L+34Qqil+v5uC0zEubW7uByo78WOCIrBvci69E7sFASRl0X7b/MB6Cqd1lky/CtcSVTydWa2WZwFuWexjS5o6g==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.1.tgz", + "integrity": "sha512-n83O8rt4v34hgFzlkb1ycniJh7IR5RCIqt6mz1VRJD6pmhRi0CXdmfnLu9dIUS6buzh60IvACM842Ffb3xd6Gg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.1.tgz", + "integrity": "sha512-Nql7sTeAzhTAja3QXeAI48+/+GjBJ+QmAH13snn0AJSNL50JsDqotyudHyMbO2RbJkskbMbFJfIJKWA6R1LCJQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.1.tgz", + "integrity": "sha512-+pUymDhd0ys9GcKZPPWlFiZ67sTWV5UU6zOJat02M1+PiuSGDziyRuI/pPue3hoUwm2uGfxdL+trT6Z9rxnlMA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.1.tgz", + "integrity": "sha512-VSvgvQeIcsEvY4bKDHEDWcpW4Yw7BtlKG1GUT4FzBUlEKQK0rWHYBqQt6Fm2taXS+1bXvJT6kICu5ZwqKCnvlQ==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.1.tgz", + "integrity": "sha512-4LqhUomJqwe641gsPp6xLfhqWMbQV04KtPp7/dIp0nzPxAkNY1AbwL5W0MQpcalLYk07vaW9Kp1PBhdpZYYcEw==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.1.tgz", + "integrity": "sha512-tLQQ9aPvkBxOc/EUT6j3pyeMD6Hb8QF2BTBnCQWP/uu1lhc9AIrIjKnLYMEroIz/JvtGYgI9dF3AxHZNaEH0rw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.1.tgz", + "integrity": "sha512-RMxFhJwc9fSXP6PqmAz4cbv3kAyvD1etJFjTx4ONqFP9DkTkXsAMU4v3Vyc5BgzC+anz7nS/9tp4obsKfqkDHg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.1.tgz", + "integrity": "sha512-QKgFl+Yc1eEk6MmOBfRHYF6lTxiiiV3/z/BRrbSiW2I7AFTXoBFvdMEyglohPj//2mZS4hDOqeB0H1ACh3sBbg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.1.tgz", + "integrity": "sha512-RAjXjP/8c6ZtzatZcA1RaQr6O1TRhzC+adn8YZDnChliZHviqIjmvFwHcxi4JKPSDAt6Uhf/7vqcBzQJy0PDJg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.1.tgz", + "integrity": "sha512-wcuocpaOlaL1COBYiA89O6yfjlp3RwKDeTIA0hM7OpmhR1Bjo9j31G1uQVpDlTvwxGn2nQs65fBFL5UFd76FcQ==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.1.tgz", + "integrity": "sha512-77PpsFQUCOiZR9+LQEFg9GClyfkNXj1MP6wRnzYs0EeWbPcHs02AXu4xuUbM1zhwn3wqaizle3AEYg5aeoohhg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.1.tgz", + "integrity": "sha512-5cIATbk5vynAjqqmyBjlciMJl1+R/CwX9oLk/EyiFXDWd95KpHdrOJT//rnUl4cUcskrd0jCCw3wpZnhIHdD9w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.1.tgz", + "integrity": "sha512-cl0w09WsCi17mcmWqqglez9Gk8isgeWvoUZ3WiJFYSR3zjBQc2J5/ihSjpl+VLjPqjQ/1hJRcqBfLjssREQILw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.1.tgz", + "integrity": "sha512-4Cv23ZrONRbNtbZa37mLSueXUCtN7MXccChtKpUnQNgF010rjrjfHx3QxkS2PI7LqGT5xXyYs1a7LbzAwT0iCA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.1.tgz", + "integrity": "sha512-i1okWYkA4FJICtr7KpYzFpRTHgy5jdDbZiWfvny21iIKky5YExiDXP+zbXzm3dUcFpkEeYNHgQ5fuG236JPq0g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.1.tgz", + "integrity": "sha512-u09m3CuwLzShA0EYKMNiFgcjjzwqtUMLmuCJLeZWjjOYA3IT2Di09KaxGBTP9xVztWyIWjVdsB2E9goMjZvTQg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.1.tgz", + "integrity": "sha512-k+600V9Zl1CM7eZxJgMyTUzmrmhB/0XZnF4pRypKAlAgxmedUA+1v9R+XOFv56W4SlHEzfeMtzujLJD22Uz5zg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.1.tgz", + "integrity": "sha512-lWMnixq/QzxyhTV6NjQJ4SFo1J6PvOX8vUx5Wb4bBPsEb+8xZ89Bz6kOXpfXj9ak9AHTQVQzlgzBEc1SyM27xQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@tauri-apps/api": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz", + "integrity": "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==", + "license": "Apache-2.0 OR MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + } + }, + "node_modules/@tauri-apps/cli": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.10.1.tgz", + "integrity": "sha512-jQNGF/5quwORdZSSLtTluyKQ+o6SMa/AUICfhf4egCGFdMHqWssApVgYSbg+jmrZoc8e1DscNvjTnXtlHLS11g==", + "dev": true, + "license": "Apache-2.0 OR MIT", + "bin": { + "tauri": "tauri.js" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/tauri" + }, + "optionalDependencies": { + "@tauri-apps/cli-darwin-arm64": "2.10.1", + "@tauri-apps/cli-darwin-x64": "2.10.1", + "@tauri-apps/cli-linux-arm-gnueabihf": "2.10.1", + "@tauri-apps/cli-linux-arm64-gnu": "2.10.1", + "@tauri-apps/cli-linux-arm64-musl": "2.10.1", + "@tauri-apps/cli-linux-riscv64-gnu": "2.10.1", + "@tauri-apps/cli-linux-x64-gnu": "2.10.1", + "@tauri-apps/cli-linux-x64-musl": "2.10.1", + "@tauri-apps/cli-win32-arm64-msvc": "2.10.1", + "@tauri-apps/cli-win32-ia32-msvc": "2.10.1", + "@tauri-apps/cli-win32-x64-msvc": "2.10.1" + } + }, + "node_modules/@tauri-apps/cli-darwin-arm64": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.10.1.tgz", + "integrity": "sha512-Z2OjCXiZ+fbYZy7PmP3WRnOpM9+Fy+oonKDEmUE6MwN4IGaYqgceTjwHucc/kEEYZos5GICve35f7ZiizgqEnQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-darwin-x64": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.10.1.tgz", + "integrity": "sha512-V/irQVvjPMGOTQqNj55PnQPVuH4VJP8vZCN7ajnj+ZS8Kom1tEM2hR3qbbIRoS3dBKs5mbG8yg1WC+97dq17Pw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm-gnueabihf": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.10.1.tgz", + "integrity": "sha512-Hyzwsb4VnCWKGfTw+wSt15Z2pLw2f0JdFBfq2vHBOBhvg7oi6uhKiF87hmbXOBXUZaGkyRDkCHsdzJcIfoJC2w==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-gnu": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.10.1.tgz", + "integrity": "sha512-OyOYs2t5GkBIvyWjA1+h4CZxTcdz1OZPCWAPz5DYEfB0cnWHERTnQ/SLayQzncrT0kwRoSfSz9KxenkyJoTelA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-arm64-musl": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.10.1.tgz", + "integrity": "sha512-MIj78PDDGjkg3NqGptDOGgfXks7SYJwhiMh8SBoZS+vfdz7yP5jN18bNaLnDhsVIPARcAhE1TlsZe/8Yxo2zqg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-riscv64-gnu": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.10.1.tgz", + "integrity": "sha512-X0lvOVUg8PCVaoEtEAnpxmnkwlE1gcMDTqfhbefICKDnOTJ5Est3qL0SrWxizDackIOKBcvtpejrSiVpuJI1kw==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-gnu": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.10.1.tgz", + "integrity": "sha512-2/12bEzsJS9fAKybxgicCDFxYD1WEI9kO+tlDwX5znWG2GwMBaiWcmhGlZ8fi+DMe9CXlcVarMTYc0L3REIRxw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-linux-x64-musl": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.10.1.tgz", + "integrity": "sha512-Y8J0ZzswPz50UcGOFuXGEMrxbjwKSPgXftx5qnkuMs2rmwQB5ssvLb6tn54wDSYxe7S6vlLob9vt0VKuNOaCIQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-arm64-msvc": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.10.1.tgz", + "integrity": "sha512-iSt5B86jHYAPJa/IlYw++SXtFPGnWtFJriHn7X0NFBVunF6zu9+/zOn8OgqIWSl8RgzhLGXQEEtGBdR4wzpVgg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-ia32-msvc": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.10.1.tgz", + "integrity": "sha512-gXyxgEzsFegmnWywYU5pEBURkcFN/Oo45EAwvZrHMh+zUSEAvO5E8TXsgPADYm31d1u7OQU3O3HsYfVBf2moHw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/cli-win32-x64-msvc": { + "version": "2.10.1", + "resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.10.1.tgz", + "integrity": "sha512-6Cn7YpPFwzChy0ERz6djKEmUehWrYlM+xTaNzGPgZocw3BD7OfwfWHKVWxXzdjEW2KfKkHddfdxK1XXTYqBRLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "Apache-2.0 OR MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@tauri-apps/plugin-opener": { + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-opener/-/plugin-opener-2.5.3.tgz", + "integrity": "sha512-CCcUltXMOfUEArbf3db3kCE7Ggy1ExBEBl51Ko2ODJ6GDYHRp1nSNlQm5uNCFY5k7/ufaK5Ib3Du/Zir19IYQQ==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.8.0" + } + }, + "node_modules/@tauri-apps/plugin-shell": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-shell/-/plugin-shell-2.3.5.tgz", + "integrity": "sha512-jewtULhiQ7lI7+owCKAjc8tYLJr92U16bPOeAa472LHJdgaibLP83NcfAF2e+wkEcA53FxKQAZ7byDzs2eeizg==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.10.1" + } + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "dev": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.19", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.19.tgz", + "integrity": "sha512-qCkNLi2sfBOn8XhZQ0FXsT1Ki/Yo5P90hrkRamVFRS7/KV9hpfA4HkoWNU152+8w0zPjnxo5psx5NL3PSGgv5g==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001788", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001788.tgz", + "integrity": "sha512-6q8HFp+lOQtcf7wBK+uEenxymVWkGKkjFpCvw5W25cmMwEDU45p1xQFBQv8JDlMMry7eNxyBaR+qxgmTUZkIRQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.340", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.340.tgz", + "integrity": "sha512-908qahOGocRMinT2nM3ajCEM99H4iPdv84eagPP3FfZy/1ZGeOy2CZYzjhms81ckOPCXPlW7LkY4XpxD8r1DrA==", + "dev": true, + "license": "ISC" + }, + "node_modules/esbuild": { + "version": "0.25.12", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.12.tgz", + "integrity": "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.25.12", + "@esbuild/android-arm": "0.25.12", + "@esbuild/android-arm64": "0.25.12", + "@esbuild/android-x64": "0.25.12", + "@esbuild/darwin-arm64": "0.25.12", + "@esbuild/darwin-x64": "0.25.12", + "@esbuild/freebsd-arm64": "0.25.12", + "@esbuild/freebsd-x64": "0.25.12", + "@esbuild/linux-arm": "0.25.12", + "@esbuild/linux-arm64": "0.25.12", + "@esbuild/linux-ia32": "0.25.12", + "@esbuild/linux-loong64": "0.25.12", + "@esbuild/linux-mips64el": "0.25.12", + "@esbuild/linux-ppc64": "0.25.12", + "@esbuild/linux-riscv64": "0.25.12", + "@esbuild/linux-s390x": "0.25.12", + "@esbuild/linux-x64": "0.25.12", + "@esbuild/netbsd-arm64": "0.25.12", + "@esbuild/netbsd-x64": "0.25.12", + "@esbuild/openbsd-arm64": "0.25.12", + "@esbuild/openbsd-x64": "0.25.12", + "@esbuild/openharmony-arm64": "0.25.12", + "@esbuild/sunos-x64": "0.25.12", + "@esbuild/win32-arm64": "0.25.12", + "@esbuild/win32-ia32": "0.25.12", + "@esbuild/win32-x64": "0.25.12" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.11", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", + "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.37", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.37.tgz", + "integrity": "sha512-1h5gKZCF+pO/o3Iqt5Jp7wc9rH3eJJ0+nh/CIoiRwjRxde/hAHyLPXYN4V3CqKAbiZPSeJFSWHmJsbkicta0Eg==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.10", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.10.tgz", + "integrity": "sha512-pMMHxBOZKFU6HgAZ4eyGnwXF/EvPGGqUr0MnZ5+99485wwW41kW91A4LOGxSHhgugZmSChL5AlElNdwlNgcnLQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/react": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", + "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.5" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.60.1", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.1.tgz", + "integrity": "sha512-VmtB2rFU/GroZ4oL8+ZqXgSA38O6GR8KSIvWmEFv63pQ0G6KaBH9s07PO8XTXP4vI+3UJUEypOfjkGfmSBBR0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.1", + "@rollup/rollup-android-arm64": "4.60.1", + "@rollup/rollup-darwin-arm64": "4.60.1", + "@rollup/rollup-darwin-x64": "4.60.1", + "@rollup/rollup-freebsd-arm64": "4.60.1", + "@rollup/rollup-freebsd-x64": "4.60.1", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.1", + "@rollup/rollup-linux-arm-musleabihf": "4.60.1", + "@rollup/rollup-linux-arm64-gnu": "4.60.1", + "@rollup/rollup-linux-arm64-musl": "4.60.1", + "@rollup/rollup-linux-loong64-gnu": "4.60.1", + "@rollup/rollup-linux-loong64-musl": "4.60.1", + "@rollup/rollup-linux-ppc64-gnu": "4.60.1", + "@rollup/rollup-linux-ppc64-musl": "4.60.1", + "@rollup/rollup-linux-riscv64-gnu": "4.60.1", + "@rollup/rollup-linux-riscv64-musl": "4.60.1", + "@rollup/rollup-linux-s390x-gnu": "4.60.1", + "@rollup/rollup-linux-x64-gnu": "4.60.1", + "@rollup/rollup-linux-x64-musl": "4.60.1", + "@rollup/rollup-openbsd-x64": "4.60.1", + "@rollup/rollup-openharmony-arm64": "4.60.1", + "@rollup/rollup-win32-arm64-msvc": "4.60.1", + "@rollup/rollup-win32-ia32-msvc": "4.60.1", + "@rollup/rollup-win32-x64-gnu": "4.60.1", + "@rollup/rollup-win32-x64-msvc": "4.60.1", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/vite": { + "version": "6.4.2", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.2.tgz", + "integrity": "sha512-2N/55r4JDJ4gdrCvGgINMy+HH3iRpNIz8K6SFwVsA+JbQScLiC+clmAxBgwiSPgcG9U15QmvqCGWzMbqda5zGQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.25.0", + "fdir": "^6.4.4", + "picomatch": "^4.0.2", + "postcss": "^8.5.3", + "rollup": "^4.34.9", + "tinyglobby": "^0.2.13" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || ^20.0.0 || >=22.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || ^20.0.0 || >=22.0.0", + "jiti": ">=1.21.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/windows/package.json b/windows/package.json new file mode 100644 index 00000000..bbf2b50d --- /dev/null +++ b/windows/package.json @@ -0,0 +1,28 @@ +{ + "name": "codeburn-menubar", + "private": true, + "version": "0.9.20", + "description": "CodeBurn menubar (tray) app for Windows, with experimental Linux support. Shares design tokens with the native macOS app under mac/.", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc --noEmit && vite build", + "preview": "vite preview", + "tauri": "tauri" + }, + "dependencies": { + "@tauri-apps/api": "^2.0.0", + "@tauri-apps/plugin-opener": "^2.0.0", + "@tauri-apps/plugin-shell": "^2.0.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@tauri-apps/cli": "^2.0.0", + "@types/react": "^19.0.0", + "@types/react-dom": "^19.0.0", + "@vitejs/plugin-react": "^4.3.0", + "typescript": "^5.6.0", + "vite": "^6.0.0" + } +} diff --git a/windows/src-tauri/Cargo.lock b/windows/src-tauri/Cargo.lock new file mode 100644 index 00000000..18765ad9 --- /dev/null +++ b/windows/src-tauri/Cargo.lock @@ -0,0 +1,6066 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloc-no-stdlib" +version = "2.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc7bb162ec39d46ab1ca8c77bf72e890535becd1751bb45f64c597edb4c8c6b3" + +[[package]] +name = "alloc-stdlib" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94fb8275041c72129eb51b7d0322c29b8387a0386127718b096429201a5d6ece" +dependencies = [ + "alloc-no-stdlib", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "async-broadcast" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "435a87a52755b8f27fcf321ac4f04b2802e337c8c4872923137471ec39c37532" +dependencies = [ + "event-listener", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-channel" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" +dependencies = [ + "concurrent-queue", + "event-listener-strategy", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-io" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "456b8a8feb6f42d237746d4b3e9a178494627745c3c56c6ea55d92ba50d026fc" +dependencies = [ + "autocfg", + "cfg-if", + "concurrent-queue", + "futures-io", + "futures-lite", + "parking", + "polling", + "rustix", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-lock" +version = "3.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "290f7f2596bd5b78a9fec8088ccd89180d7f9f55b94b0576823bbbdc72ee8311" +dependencies = [ + "event-listener", + "event-listener-strategy", + "pin-project-lite", +] + +[[package]] +name = "async-process" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc50921ec0055cdd8a16de48773bfeec5c972598674347252c0399676be7da75" +dependencies = [ + "async-channel", + "async-io", + "async-lock", + "async-signal", + "async-task", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "rustix", +] + +[[package]] +name = "async-recursion" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "async-signal" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52b5aaafa020cf5053a01f2a60e8ff5dccf550f0f77ec54a4e47285ac2bab485" +dependencies = [ + "async-io", + "async-lock", + "atomic-waker", + "cfg-if", + "futures-core", + "futures-io", + "rustix", + "signal-hook-registry", + "slab", + "windows-sys 0.61.2", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "atk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "241b621213072e993be4f6f3a9e4b45f65b7e6faad43001be957184b7bb1824b" +dependencies = [ + "atk-sys", + "glib", + "libc", +] + +[[package]] +name = "atk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e48b684b0ca77d2bbadeef17424c2ea3c897d44d566a1617e7e8f30614d086" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +dependencies = [ + "serde_core", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "block2" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdeb9d870516001442e364c5220d3574d2da8dc765554b4a617230d33fa58ef5" +dependencies = [ + "objc2", +] + +[[package]] +name = "blocking" +version = "1.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" +dependencies = [ + "async-channel", + "async-task", + "futures-io", + "futures-lite", + "piper", +] + +[[package]] +name = "brotli" +version = "8.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", + "brotli-decompressor", +] + +[[package]] +name = "brotli-decompressor" +version = "5.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +dependencies = [ + "alloc-no-stdlib", + "alloc-stdlib", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "bytemuck" +version = "1.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +dependencies = [ + "serde", +] + +[[package]] +name = "cairo-rs" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ca26ef0159422fb77631dc9d17b102f253b876fe1586b03b803e63a309b4ee2" +dependencies = [ + "bitflags 2.11.1", + "cairo-sys-rs", + "glib", + "libc", + "once_cell", + "thiserror 1.0.69", +] + +[[package]] +name = "cairo-sys-rs" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "685c9fa8e590b8b3d678873528d83411db17242a73fccaed827770ea0fedda51" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "camino" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e629a66d692cb9ff1a1c664e41771b3dcaf961985a9774c0eb0bd1b51cf60a48" +dependencies = [ + "serde_core", +] + +[[package]] +name = "cargo-platform" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e35af189006b9c0f00a064685c727031e3ed2d8020f7ba284d78cc2671bd36ea" +dependencies = [ + "serde", +] + +[[package]] +name = "cargo_metadata" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5eb614ed4c27c5d706420e4320fbe3216ab31fa1c33cd8246ac36dae4479ba" +dependencies = [ + "camino", + "cargo-platform", + "semver", + "serde", + "serde_json", + "thiserror 2.0.18", +] + +[[package]] +name = "cargo_toml" +version = "0.22.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "374b7c592d9c00c1f4972ea58390ac6b18cbb6ab79011f3bdc90a0b82ca06b77" +dependencies = [ + "serde", + "toml 0.9.12+spec-1.1.0", +] + +[[package]] +name = "cc" +version = "1.2.60" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cesu8" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d43a04d8753f35258c91f8ec639f792891f748a1edbd759cf1dcea3382ad83c" + +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + +[[package]] +name = "cfg-expr" +version = "0.15.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d067ad48b8650848b989a59a86c6c36a995d02d2bf778d45c3c5d57bc2718f02" +dependencies = [ + "smallvec", + "target-lexicon", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "num-traits", + "serde", + "windows-link 0.2.1", +] + +[[package]] +name = "codeburn-menubar" +version = "0.9.20" +dependencies = [ + "anyhow", + "dirs 5.0.1", + "fontdue", + "ksni", + "png 0.17.16", + "reqwest 0.12.28", + "serde", + "serde_json", + "tauri", + "tauri-build", + "tauri-plugin-opener", + "tauri-plugin-shell", + "thiserror 1.0.69", + "tokio", + "windows-sys 0.59.0", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "concurrent-queue" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "convert_case" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" + +[[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "time", + "version_check", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "core-graphics" +version = "0.25.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" +dependencies = [ + "bitflags 2.11.1", + "core-foundation", + "core-graphics-types", + "foreign-types", + "libc", +] + +[[package]] +name = "core-graphics-types" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" +dependencies = [ + "bitflags 2.11.1", + "core-foundation", + "libc", +] + +[[package]] +name = "core_maths" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77745e017f5edba1a9c1d854f6f3a52dac8a12dd5af5d2f54aecf61e43d80d30" +dependencies = [ + "libm", +] + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82b8f8f868b36967f9606790d1903570de9ceaf870a7bf9fbbd3016d636a2cb2" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "cssparser" +version = "0.29.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f93d03419cb5950ccfd3daf3ff1c7a36ace64609a1a8746d493df1ca0afde0fa" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "matches", + "phf 0.10.1", + "proc-macro2", + "quote", + "smallvec", + "syn 1.0.109", +] + +[[package]] +name = "cssparser" +version = "0.36.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2" +dependencies = [ + "cssparser-macros", + "dtoa-short", + "itoa", + "phf 0.13.1", + "smallvec", +] + +[[package]] +name = "cssparser-macros" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "ctor" +version = "0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a2785755761f3ddc1492979ce1e48d2c00d09311c39e4466429188f3dd6501" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.117", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" +dependencies = [ + "powerfmt", + "serde_core", +] + +[[package]] +name = "derive_more" +version = "0.99.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", +] + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", +] + +[[package]] +name = "dirs" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c45a9d03d6676652bcb5e724c7e988de1acad23a711b5217ab9cbecbec2225" +dependencies = [ + "dirs-sys 0.4.1", +] + +[[package]] +name = "dirs" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3e8aa94d75141228480295a7d0e7feb620b1a5ad9f12bc40be62411e38cce4e" +dependencies = [ + "dirs-sys 0.5.0", +] + +[[package]] +name = "dirs-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "520f05a5cbd335fae5a99ff7a6ab8627577660ee5cfd6a94a6a929b52ff0321c" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.4.6", + "windows-sys 0.48.0", +] + +[[package]] +name = "dirs-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e01a3366d27ee9890022452ee61b2b63a67e6f13f58900b651ff5665f0bb1fab" +dependencies = [ + "libc", + "option-ext", + "redox_users 0.5.2", + "windows-sys 0.61.2", +] + +[[package]] +name = "dispatch2" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" +dependencies = [ + "bitflags 2.11.1", + "block2", + "libc", + "objc2", +] + +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dlopen2" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e2c5bd4158e66d1e215c49b837e11d62f3267b30c92f1d171c4d3105e3dc4d4" +dependencies = [ + "dlopen2_derive", + "libc", + "once_cell", + "winapi", +] + +[[package]] +name = "dlopen2_derive" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fbbb781877580993a8707ec48672673ec7b81eeba04cfd2310bd28c08e47c8f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "dom_query" +version = "0.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521e380c0c8afb8d9a1e83a1822ee03556fc3e3e7dbc1fd30be14e37f9cb3f89" +dependencies = [ + "bit-set", + "cssparser 0.36.0", + "foldhash 0.2.0", + "html5ever 0.38.0", + "precomputed-hash", + "selectors 0.36.1", + "tendril 0.5.0", +] + +[[package]] +name = "dpi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" +dependencies = [ + "serde", +] + +[[package]] +name = "dtoa" +version = "1.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590" + +[[package]] +name = "dtoa-short" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87" +dependencies = [ + "dtoa", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "dyn-clone" +version = "1.0.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0881ea181b1df73ff77ffaaf9c7544ecc11e82fba9b5f27b262a3c73a332555" + +[[package]] +name = "embed-resource" +version = "3.0.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63a1d0de4f2249aa0ff5884d7080814f446bb241a559af6c170a41e878ed2d45" +dependencies = [ + "cc", + "memchr", + "rustc_version", + "toml 0.9.12+spec-1.1.0", + "vswhom", + "winreg", +] + +[[package]] +name = "embed_plist" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "endi" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "66b7e2430c6dff6a955451e2cfc438f09cea1965a9d6f87f7e3b90decc014099" + +[[package]] +name = "enumflags2" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1027f7680c853e056ebcec683615fb6fbbc07dbaa13b4d5d9442b146ded4ecef" +dependencies = [ + "enumflags2_derive", + "serde", +] + +[[package]] +name = "enumflags2_derive" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "event-listener" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +dependencies = [ + "concurrent-queue", + "parking", + "pin-project-lite", +] + +[[package]] +name = "event-listener-strategy" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8be9f3dfaaffdae2972880079a491a1a8bb7cbed0b8dd7a347f668b4150a3b93" +dependencies = [ + "event-listener", + "pin-project-lite", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "field-offset" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38e2275cc4e4fc009b0669731a1e5ab7ebf11f469eaede2bab9309a5b4d6057f" +dependencies = [ + "memoffset", + "rustc_version", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + +[[package]] +name = "fontdue" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7894823fa221401399e2598f8b63f81ac77ff5c63248b7656779bff1632d7d3d" +dependencies = [ + "hashbrown 0.15.5", + "ttf-parser", +] + +[[package]] +name = "foreign-types" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" +dependencies = [ + "foreign-types-macros", + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-macros" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "foreign-types-shared" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843" +dependencies = [ + "mac", + "new_debug_unreachable", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "fxhash" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c31b6d751ae2c7f11320402d34e41349dd1016f8d5d45e48c4312bc8625af50c" +dependencies = [ + "byteorder", +] + +[[package]] +name = "gdk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9f245958c627ac99d8e529166f9823fb3b838d1d41fd2b297af3075093c2691" +dependencies = [ + "cairo-rs", + "gdk-pixbuf", + "gdk-sys", + "gio", + "glib", + "libc", + "pango", +] + +[[package]] +name = "gdk-pixbuf" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50e1f5f1b0bfb830d6ccc8066d18db35c487b1b2b1e8589b5dfe9f07e8defaec" +dependencies = [ + "gdk-pixbuf-sys", + "gio", + "glib", + "libc", + "once_cell", +] + +[[package]] +name = "gdk-pixbuf-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9839ea644ed9c97a34d129ad56d38a25e6756f99f3a88e15cd39c20629caf7" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gdk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c2d13f38594ac1e66619e188c6d5a1adb98d11b2fcf7894fc416ad76aa2f3f7" +dependencies = [ + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkwayland-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "140071d506d223f7572b9f09b5e155afbd77428cd5cc7af8f2694c41d98dfe69" +dependencies = [ + "gdk-sys", + "glib-sys", + "gobject-sys", + "libc", + "pkg-config", + "system-deps", +] + +[[package]] +name = "gdkx11" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3caa00e14351bebbc8183b3c36690327eb77c49abc2268dd4bd36b856db3fbfe" +dependencies = [ + "gdk", + "gdkx11-sys", + "gio", + "glib", + "libc", + "x11", +] + +[[package]] +name = "gdkx11-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e7445fe01ac26f11601db260dd8608fe172514eb63b3b5e261ea6b0f4428d" +dependencies = [ + "gdk-sys", + "glib-sys", + "libc", + "system-deps", + "x11", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc3cb4d91f53b50155bdcfd23f6a4c39ae1969c2ae85982b135750cccaf5fce" +dependencies = [ + "cfg-if", + "libc", + "wasi 0.9.0+wasi-snapshot-preview1", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 5.3.0", + "wasip2", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "gio" +version = "0.18.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4fc8f532f87b79cbc51a79748f16a6828fb784be93145a322fa14d06d354c73" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-util", + "gio-sys", + "glib", + "libc", + "once_cell", + "pin-project-lite", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "gio-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37566df850baf5e4cb0dfb78af2e4b9898d817ed9263d1090a2df958c64737d2" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", + "winapi", +] + +[[package]] +name = "glib" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "233daaf6e83ae6a12a52055f568f9d7cf4671dabb78ff9560ab6da230ce00ee5" +dependencies = [ + "bitflags 2.11.1", + "futures-channel", + "futures-core", + "futures-executor", + "futures-task", + "futures-util", + "gio-sys", + "glib-macros", + "glib-sys", + "gobject-sys", + "libc", + "memchr", + "once_cell", + "smallvec", + "thiserror 1.0.69", +] + +[[package]] +name = "glib-macros" +version = "0.18.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bb0228f477c0900c880fd78c8759b95c7636dbd7842707f49e132378aa2acdc" +dependencies = [ + "heck 0.4.1", + "proc-macro-crate 2.0.2", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "glib-sys" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "063ce2eb6a8d0ea93d2bf8ba1957e78dbab6be1c2220dd3daca57d5a9d869898" +dependencies = [ + "libc", + "system-deps", +] + +[[package]] +name = "glob" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0cc23270f6e1808e30a928bdc84dea0b9b4136a8bc82338574f23baf47bbd280" + +[[package]] +name = "gobject-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0850127b514d1c4a4654ead6dedadb18198999985908e6ffe4436f53c785ce44" +dependencies = [ + "glib-sys", + "libc", + "system-deps", +] + +[[package]] +name = "gtk" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fd56fb197bfc42bd5d2751f4f017d44ff59fbb58140c6b49f9b3b2bdab08506a" +dependencies = [ + "atk", + "cairo-rs", + "field-offset", + "futures-channel", + "gdk", + "gdk-pixbuf", + "gio", + "glib", + "gtk-sys", + "gtk3-macros", + "libc", + "pango", + "pkg-config", +] + +[[package]] +name = "gtk-sys" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f29a1c21c59553eb7dd40e918be54dccd60c52b049b75119d5d96ce6b624414" +dependencies = [ + "atk-sys", + "cairo-sys-rs", + "gdk-pixbuf-sys", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "pango-sys", + "system-deps", +] + +[[package]] +name = "gtk3-macros" +version = "0.18.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff3c5b21f14f0736fed6dcfc0bfb4225ebf5725f3c0209edeec181e4d73e9d" +dependencies = [ + "proc-macro-crate 1.3.1", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "hashbrown" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash 0.1.5", +] + +[[package]] +name = "hashbrown" +version = "0.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hermit-abi" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "html5ever" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b7410cae13cbc75623c98ac4cbfd1f0bedddf3227afc24f370cf0f50a44a11c" +dependencies = [ + "log", + "mac", + "markup5ever 0.14.1", + "match_token", +] + +[[package]] +name = "html5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1054432bae2f14e0061e33d23402fbaa67a921d319d56adc6bcf887ddad1cbc2" +dependencies = [ + "log", + "markup5ever 0.38.0", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core 0.62.2", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "ico" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e795dff5605e0f04bff85ca41b51a96b83e80b281e96231bcaaf1ac35103371" +dependencies = [ + "byteorder", + "png 0.17.16", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "id-arena" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png 0.18.1", +] + +[[package]] +name = "indexmap" +version = "1.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" +dependencies = [ + "autocfg", + "hashbrown 0.12.3", + "serde", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.0", + "serde", + "serde_core", +] + +[[package]] +name = "infer" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +dependencies = [ + "cfb", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "iri-string" +version = "0.7.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" +dependencies = [ + "memchr", + "serde", +] + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "javascriptcore-rs" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca5671e9ffce8ffba57afc24070e906da7fc4b1ba66f2cabebf61bf2ea257fcc" +dependencies = [ + "bitflags 1.3.2", + "glib", + "javascriptcore-rs-sys", +] + +[[package]] +name = "javascriptcore-rs-sys" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af1be78d14ffa4b75b66df31840478fef72b51f8c2465d4ca7c194da9f7a5124" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "jni" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1a87aa2bb7d2af34197c04845522473242e1aa17c12f4935d5856491a7fb8c97" +dependencies = [ + "cesu8", + "cfg-if", + "combine", + "jni-sys 0.3.1", + "log", + "thiserror 1.0.69", + "walkdir", + "windows-sys 0.45.0", +] + +[[package]] +name = "jni-sys" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41a652e1f9b6e0275df1f15b32661cf0d4b78d4d87ddec5e0c3c20f097433258" +dependencies = [ + "jni-sys 0.4.1", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.117", +] + +[[package]] +name = "js-sys" +version = "0.3.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "json-patch" +version = "3.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863726d7afb6bc2590eeff7135d923545e5e964f004c2ccf8716c25e70a86f08" +dependencies = [ + "jsonptr", + "serde", + "serde_json", + "thiserror 1.0.69", +] + +[[package]] +name = "jsonptr" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dea2b27dd239b2556ed7a25ba842fe47fd602e7fc7433c2a8d6106d4d9edd70" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "keyboard-types" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b750dcadc39a09dbadd74e118f6dd6598df77fa01df0cfcdc52c28dece74528a" +dependencies = [ + "bitflags 2.11.1", + "serde", + "unicode-segmentation", +] + +[[package]] +name = "ksni" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7ca513d0be42df5edb485af9f44a12b2cb85af773d91c27dc796d1c58b78edc" +dependencies = [ + "futures-util", + "pastey", + "serde", + "tokio", + "zbus", +] + +[[package]] +name = "kuchikiki" +version = "0.8.8-speedreader" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02cb977175687f33fa4afa0c95c112b987ea1443e5a51c8f8ff27dc618270cc2" +dependencies = [ + "cssparser 0.29.6", + "html5ever 0.29.1", + "indexmap 2.14.0", + "selectors 0.24.0", +] + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libappindicator" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03589b9607c868cc7ae54c0b2a22c8dc03dd41692d48f2d7df73615c6a95dc0a" +dependencies = [ + "glib", + "gtk", + "gtk-sys", + "libappindicator-sys", + "log", +] + +[[package]] +name = "libappindicator-sys" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf" +dependencies = [ + "gtk-sys", + "libloading", + "once_cell", +] + +[[package]] +name = "libc" +version = "0.2.185" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" + +[[package]] +name = "libloading" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67380fd3b2fbe7527a606e18729d21c6f3951633d0500574c4dc22d2d638b9f" +dependencies = [ + "cfg-if", + "winapi", +] + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "libredox" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +dependencies = [ + "libc", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "mac" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" + +[[package]] +name = "markup5ever" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a7213d12e1864c0f002f52c2923d4556935a43dec5e71355c2760e0f6e7a18" +dependencies = [ + "log", + "phf 0.11.3", + "phf_codegen 0.11.3", + "string_cache 0.8.9", + "string_cache_codegen 0.5.4", + "tendril 0.4.3", +] + +[[package]] +name = "markup5ever" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8983d30f2915feeaaab2d6babdd6bc7e9ed1a00b66b5e6d74df19aa9c0e91862" +dependencies = [ + "log", + "tendril 0.5.0", + "web_atoms", +] + +[[package]] +name = "match_token" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88a9689d8d44bf9964484516275f5cd4c9b59457a6940c1d5d0ecbb94510a36b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "matches" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2532096657941c2fea9c289d370a250971c689d4f143798ff67113ec042024a5" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "memoffset" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "488016bfae457b036d996092f6cb448677611ce4449e970ceaf42695203f218a" +dependencies = [ + "autocfg", +] + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi 0.11.1+wasi-snapshot-preview1", + "windows-sys 0.61.2", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "muda" +version = "0.17.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c9fec5a4e89860383d778d10563a605838f8f0b2f9303868937e5ff32e86177" +dependencies = [ + "crossbeam-channel", + "dpi", + "gtk", + "keyboard-types", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "once_cell", + "png 0.17.16", + "serde", + "thiserror 2.0.18", + "windows-sys 0.60.2", +] + +[[package]] +name = "ndk" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3f42e7bbe13d351b6bead8286a43aac9534b82bd3cc43e47037f012ebfd62d4" +dependencies = [ + "bitflags 2.11.1", + "jni-sys 0.3.1", + "log", + "ndk-sys", + "num_enum", + "raw-window-handle", + "thiserror 1.0.69", +] + +[[package]] +name = "ndk-context" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27b02d87554356db9e9a873add8782d4ea6e3e58ea071a9adb9a2e8ddb884a8b" + +[[package]] +name = "ndk-sys" +version = "0.6.0+11769913" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee6cda3051665f1fb8d9e08fc35c96d5a244fb1be711a03b71118828afc9a873" +dependencies = [ + "jni-sys 0.3.1", +] + +[[package]] +name = "new_debug_unreachable" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086" + +[[package]] +name = "nodrop" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" + +[[package]] +name = "num-conv" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6673768db2d862beb9b39a78fdcb1a69439615d5794a1be50caa9bc92c81967" + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "num_enum" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0bca838442ec211fa11de3a8b0e0e8f3a4522575b5c4c06ed722e005036f26" +dependencies = [ + "num_enum_derive", + "rustversion", +] + +[[package]] +name = "num_enum_derive" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "680998035259dcfcafe653688bf2aa6d3e2dc05e98be6ab46afb089dc84f1df8" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "objc2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a12a8ed07aefc768292f076dc3ac8c48f3781c8f2d5851dd3d98950e8c5a89f" +dependencies = [ + "objc2-encode", + "objc2-exception-helper", +] + +[[package]] +name = "objc2-app-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d49e936b501e5c5bf01fda3a9452ff86dc3ea98ad5f283e1455153142d97518c" +dependencies = [ + "bitflags 2.11.1", + "block2", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" +dependencies = [ + "bitflags 2.11.1", + "dispatch2", + "objc2", +] + +[[package]] +name = "objc2-core-graphics" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e022c9d066895efa1345f8e33e584b9f958da2fd4cd116792e15e07e4720a807" +dependencies = [ + "bitflags 2.11.1", + "dispatch2", + "objc2", + "objc2-core-foundation", + "objc2-io-surface", +] + +[[package]] +name = "objc2-encode" +version = "4.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef25abbcd74fb2609453eb695bd2f860d389e457f67dc17cafc8b8cbc89d0c33" + +[[package]] +name = "objc2-exception-helper" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7a1c5fbb72d7735b076bb47b578523aedc40f3c439bea6dfd595c089d79d98a" +dependencies = [ + "cc", +] + +[[package]] +name = "objc2-foundation" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" +dependencies = [ + "bitflags 2.11.1", + "block2", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-io-surface" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "180788110936d59bab6bd83b6060ffdfffb3b922ba1396b312ae795e1de9d81d" +dependencies = [ + "bitflags 2.11.1", + "objc2", + "objc2-core-foundation", +] + +[[package]] +name = "objc2-quartz-core" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" +dependencies = [ + "bitflags 2.11.1", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-ui-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d87d638e33c06f577498cbcc50491496a3ed4246998a7fbba7ccb98b1e7eab22" +dependencies = [ + "bitflags 2.11.1", + "objc2", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-web-kit" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2e5aaab980c433cf470df9d7af96a7b46a9d892d521a2cbbb2f8a4c16751e7f" +dependencies = [ + "bitflags 2.11.1", + "block2", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "open" +version = "5.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43bb73a7fa3799b198970490a51174027ba0d4ec504b03cd08caf513d40024bc" +dependencies = [ + "dunce", + "is-wsl", + "libc", + "pathdiff", +] + +[[package]] +name = "option-ext" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" + +[[package]] +name = "ordered-stream" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aa2b01e1d916879f73a53d01d1d6cee68adbb31d6d9177a8cfce093cced1d50" +dependencies = [ + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "pango" +version = "0.18.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ca27ec1eb0457ab26f3036ea52229edbdb74dee1edd29063f5b9b010e7ebee4" +dependencies = [ + "gio", + "glib", + "libc", + "once_cell", + "pango-sys", +] + +[[package]] +name = "pango-sys" +version = "0.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "436737e391a843e5933d6d9aa102cb126d501e815b83601365a948a518555dc5" +dependencies = [ + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link 0.2.1", +] + +[[package]] +name = "pastey" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b867cad97c0791bbd3aaa6472142568c6c9e8f71937e98379f584cfb0cf35bec" + +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3dfb61232e34fcb633f43d12c58f83c1df82962dcdfa565a4e866ffc17dafe12" +dependencies = [ + "phf_shared 0.8.0", +] + +[[package]] +name = "phf" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fabbf1ead8a5bcbc20f5f8b939ee3f5b0f6f281b6ad3468b84656b658b455259" +dependencies = [ + "phf_macros 0.10.0", + "phf_shared 0.10.0", + "proc-macro-hack", +] + +[[package]] +name = "phf" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078" +dependencies = [ + "phf_macros 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +dependencies = [ + "phf_macros 0.13.1", + "phf_shared 0.13.1", + "serde", +] + +[[package]] +name = "phf_codegen" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbffee61585b0411840d3ece935cce9cb6321f01c45477d30066498cd5e1a815" +dependencies = [ + "phf_generator 0.8.0", + "phf_shared 0.8.0", +] + +[[package]] +name = "phf_codegen" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", +] + +[[package]] +name = "phf_codegen" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_generator" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "17367f0cc86f2d25802b2c26ee58a7b23faeccf78a396094c13dced0d0182526" +dependencies = [ + "phf_shared 0.8.0", + "rand 0.7.3", +] + +[[package]] +name = "phf_generator" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" +dependencies = [ + "phf_shared 0.10.0", + "rand 0.8.6", +] + +[[package]] +name = "phf_generator" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" +dependencies = [ + "phf_shared 0.11.3", + "rand 0.8.6", +] + +[[package]] +name = "phf_generator" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +dependencies = [ + "fastrand", + "phf_shared 0.13.1", +] + +[[package]] +name = "phf_macros" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "58fdf3184dd560f160dd73922bea2d5cd6e8f064bf4b13110abd81b03697b4e0" +dependencies = [ + "phf_generator 0.10.0", + "phf_shared 0.10.0", + "proc-macro-hack", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "phf_macros" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "phf_macros" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "phf_shared" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c00cf8b9eafe68dde5e9eaa2cef8ee84a9336a47d566ec55ca16589633b65af7" +dependencies = [ + "siphasher 0.3.11", +] + +[[package]] +name = "phf_shared" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6796ad771acdc0123d2a88dc428b5e38ef24456743ddb1744ed628f9815c096" +dependencies = [ + "siphasher 0.3.11", +] + +[[package]] +name = "phf_shared" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5" +dependencies = [ + "siphasher 1.0.2", +] + +[[package]] +name = "phf_shared" +version = "0.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +dependencies = [ + "siphasher 1.0.2", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "piper" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c835479a4443ded371d6c535cbfd8d31ad92c5d23ae9770a61bc155e4992a3c1" +dependencies = [ + "atomic-waker", + "fastrand", + "futures-io", +] + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plist" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" +dependencies = [ + "base64 0.22.1", + "indexmap 2.14.0", + "quick-xml", + "serde", + "time", +] + +[[package]] +name = "png" +version = "0.17.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82151a2fc869e011c153adc57cf2789ccb8d9906ce52c0b39a6b5697749d7526" +dependencies = [ + "bitflags 1.3.2", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags 2.11.1", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide", +] + +[[package]] +name = "polling" +version = "3.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d0e4f59085d47d8241c88ead0f274e8a0cb551f3625263c05eb8dd897c34218" +dependencies = [ + "cfg-if", + "concurrent-queue", + "hermit-abi", + "pin-project-lite", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "precomputed-hash" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c" + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro-crate" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f4c021e1093a56626774e81216a4ce732a735e5bad4868a03f3ed65ca0c3919" +dependencies = [ + "once_cell", + "toml_edit 0.19.15", +] + +[[package]] +name = "proc-macro-crate" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b00f26d3400549137f92511a46ac1cd8ce37cb5598a96d382381458b992a5d24" +dependencies = [ + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "proc-macro-crate" +version = "3.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" +dependencies = [ + "toml_edit 0.25.11+spec-1.1.0", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro-hack" +version = "0.5.20+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc375e1527247fe1a97d8b7156678dfe7c1af2fc075c9a4db3690ecd2a148068" + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quick-xml" +version = "0.38.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +dependencies = [ + "memchr", +] + +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror 2.0.18", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.4", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror 2.0.18", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a6b1679d49b24bbfe0c803429aa1874472f50d9b363131f0e89fc356b544d03" +dependencies = [ + "getrandom 0.1.16", + "libc", + "rand_chacha 0.2.2", + "rand_core 0.5.1", + "rand_hc", + "rand_pcg", +] + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4c8ed856279c9737206bf725bf36935d8666ead7aa69b52be55af369d193402" +dependencies = [ + "ppv-lite86", + "rand_core 0.5.1", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bde5296fc891b0cef12a6d03ddccc162ce7b2aff54160af9338f8d40df6d19" +dependencies = [ + "getrandom 0.1.16", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_hc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca3129af7b92a17112d59ad498c6f81eaf463253766b90396d39ea7a39d6613c" +dependencies = [ + "rand_core 0.5.1", +] + +[[package]] +name = "rand_pcg" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "16abd0c1b639e9eb4d7c50c0b8100b0d0f849be2349829c740fe8e6eb4816429" +dependencies = [ + "rand_core 0.5.1", +] + +[[package]] +name = "raw-window-handle" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20675572f6f24e9e76ef639bc5552774ed45f1c30e2951e1e99c59888861c539" + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "redox_users" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba009ff324d1fc1b900bd1fdb31564febe58a8ccc8a6fdbb93b543d33b13ca43" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 1.0.69", +] + +[[package]] +name = "redox_users" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +dependencies = [ + "getrandom 0.2.17", + "libredox", + "thiserror 2.0.18", +] + +[[package]] +name = "ref-cast" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +dependencies = [ + "ref-cast-impl", +] + +[[package]] +name = "ref-cast-impl" +version = "1.0.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "reqwest" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-util", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8279bb85272c9f10811ae6a6c547ff594d6a7f3c6c6b02ee9726d1d0dcfcdd06" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schemars" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3fbf2ae1b8bc8e02df939598064d22402220cd5bbcca1c76f7d6a310974d5615" +dependencies = [ + "dyn-clone", + "indexmap 1.9.3", + "schemars_derive", + "serde", + "serde_json", + "url", + "uuid", +] + +[[package]] +name = "schemars" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd191f9397d57d581cddd31014772520aa448f65ef991055d7f61582c65165f" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +dependencies = [ + "dyn-clone", + "ref-cast", + "serde", + "serde_json", +] + +[[package]] +name = "schemars_derive" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e265784ad618884abaea0600a9adf15393368d840e0222d101a072f3f7534d" +dependencies = [ + "proc-macro2", + "quote", + "serde_derive_internals", + "syn 2.0.117", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "selectors" +version = "0.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c37578180969d00692904465fb7f6b3d50b9a2b952b87c23d0e2e5cb5013416" +dependencies = [ + "bitflags 1.3.2", + "cssparser 0.29.6", + "derive_more 0.99.20", + "fxhash", + "log", + "phf 0.8.0", + "phf_codegen 0.8.0", + "precomputed-hash", + "servo_arc 0.2.0", + "smallvec", +] + +[[package]] +name = "selectors" +version = "0.36.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5d9c0c92a92d33f08817311cf3f2c29a3538a8240e94a6a3c622ce652d7e00c" +dependencies = [ + "bitflags 2.11.1", + "cssparser 0.36.0", + "derive_more 2.1.1", + "log", + "new_debug_unreachable", + "phf 0.13.1", + "phf_codegen 0.13.1", + "precomputed-hash", + "rustc-hash", + "servo_arc 0.4.3", + "smallvec", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" +dependencies = [ + "serde", + "serde_core", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde-untagged" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9faf48a4a2d2693be24c6289dbe26552776eb7737074e6722891fadbe6c5058" +dependencies = [ + "erased-serde", + "serde", + "serde_core", + "typeid", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_derive_internals" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_repr" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "3.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd5414fad8e6907dbdd5bc441a50ae8d6e26151a03b1de04d89a5576de61d01f" +dependencies = [ + "base64 0.22.1", + "chrono", + "hex", + "indexmap 1.9.3", + "indexmap 2.14.0", + "schemars 0.9.0", + "schemars 1.2.1", + "serde_core", + "serde_json", + "serde_with_macros", + "time", +] + +[[package]] +name = "serde_with_macros" +version = "3.18.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serialize-to-javascript" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "04f3666a07a197cdb77cdf306c32be9b7f598d7060d50cfd4d5aa04bfd92f6c5" +dependencies = [ + "serde", + "serde_json", + "serialize-to-javascript-impl", +] + +[[package]] +name = "serialize-to-javascript-impl" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "772ee033c0916d670af7860b6e1ef7d658a4629a6d0b4c8c3e67f09b3765b75d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "servo_arc" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52aa42f8fdf0fed91e5ce7f23d8138441002fa31dca008acf47e6fd4721f741" +dependencies = [ + "nodrop", + "stable_deref_trait", +] + +[[package]] +name = "servo_arc" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930" +dependencies = [ + "stable_deref_trait", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + +[[package]] +name = "shared_child" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e362d9935bc50f019969e2f9ecd66786612daae13e8f277be7bfb66e8bed3f7" +dependencies = [ + "libc", + "sigchld", + "windows-sys 0.60.2", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "sigchld" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47106eded3c154e70176fc83df9737335c94ce22f821c32d17ed1db1f83badb1" +dependencies = [ + "libc", + "os_pipe", + "signal-hook", +] + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "siphasher" +version = "0.3.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38b58827f4464d87d377d175e90bf58eb00fd8716ff0a62f80356b5e61555d0d" + +[[package]] +name = "siphasher" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "softbuffer" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aac18da81ebbf05109ab275b157c22a653bb3c12cf884450179942f81bcbf6c3" +dependencies = [ + "bytemuck", + "js-sys", + "ndk", + "objc2", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "objc2-quartz-core", + "raw-window-handle", + "redox_syscall", + "tracing", + "wasm-bindgen", + "web-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "soup3" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "471f924a40f31251afc77450e781cb26d55c0b650842efafc9c6cbd2f7cc4f9f" +dependencies = [ + "futures-channel", + "gio", + "glib", + "libc", + "soup3-sys", +] + +[[package]] +name = "soup3-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ebe8950a680a12f24f15ebe1bf70db7af98ad242d9db43596ad3108aab86c27" +dependencies = [ + "gio-sys", + "glib-sys", + "gobject-sys", + "libc", + "system-deps", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "string_cache" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared 0.11.3", + "precomputed-hash", + "serde", +] + +[[package]] +name = "string_cache" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901" +dependencies = [ + "new_debug_unreachable", + "parking_lot", + "phf_shared 0.13.1", + "precomputed-hash", +] + +[[package]] +name = "string_cache_codegen" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0" +dependencies = [ + "phf_generator 0.11.3", + "phf_shared 0.11.3", + "proc-macro2", + "quote", +] + +[[package]] +name = "string_cache_codegen" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69" +dependencies = [ + "phf_generator 0.13.1", + "phf_shared 0.13.1", + "proc-macro2", + "quote", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "swift-rs" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4057c98e2e852d51fdcfca832aac7b571f6b351ad159f9eda5db1655f8d0c4d7" +dependencies = [ + "base64 0.21.7", + "serde", + "serde_json", +] + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "system-deps" +version = "6.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3e535eb8dded36d55ec13eddacd30dec501792ff23a0b1682c38601b8cf2349" +dependencies = [ + "cfg-expr", + "heck 0.5.0", + "pkg-config", + "toml 0.8.2", + "version-compare", +] + +[[package]] +name = "tao" +version = "0.34.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9103edf55f2da3c82aea4c7fab7c4241032bfeea0e71fa557d98e00e7ce7cc20" +dependencies = [ + "bitflags 2.11.1", + "block2", + "core-foundation", + "core-graphics", + "crossbeam-channel", + "dispatch2", + "dlopen2", + "dpi", + "gdkwayland-sys", + "gdkx11-sys", + "gtk", + "jni", + "libc", + "log", + "ndk", + "ndk-context", + "ndk-sys", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "once_cell", + "parking_lot", + "raw-window-handle", + "tao-macros", + "unicode-segmentation", + "url", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "tao-macros" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4e16beb8b2ac17db28eab8bca40e62dbfbb34c0fcdc6d9826b11b7b5d047dfd" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "target-lexicon" +version = "0.12.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c41af27dd6d1e27b1b16b489db798443478cef1f06a660c96db617ba5de3b1" + +[[package]] +name = "tauri" +version = "2.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da77cc00fb9028caf5b5d4650f75e31f1ef3693459dfca7f7e506d1ecef0ba2d" +dependencies = [ + "anyhow", + "bytes", + "cookie", + "dirs 6.0.0", + "dunce", + "embed_plist", + "getrandom 0.3.4", + "glob", + "gtk", + "heck 0.5.0", + "http", + "image", + "jni", + "libc", + "log", + "mime", + "muda", + "objc2", + "objc2-app-kit", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "percent-encoding", + "plist", + "raw-window-handle", + "reqwest 0.13.2", + "serde", + "serde_json", + "serde_repr", + "serialize-to-javascript", + "swift-rs", + "tauri-build", + "tauri-macros", + "tauri-runtime", + "tauri-runtime-wry", + "tauri-utils", + "thiserror 2.0.18", + "tokio", + "tray-icon", + "url", + "webkit2gtk", + "webview2-com", + "window-vibrancy", + "windows", +] + +[[package]] +name = "tauri-build" +version = "2.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4bbc990d1dbf57a8e1c7fa2327f2a614d8b757805603c1b9ba5c81bade09fd4d" +dependencies = [ + "anyhow", + "cargo_toml", + "dirs 6.0.0", + "glob", + "heck 0.5.0", + "json-patch", + "schemars 0.8.22", + "semver", + "serde", + "serde_json", + "tauri-utils", + "tauri-winres", + "toml 0.9.12+spec-1.1.0", + "walkdir", +] + +[[package]] +name = "tauri-codegen" +version = "2.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4a24476afd977c5d5d169f72425868613d82747916dd29e0a357c84c4bd6d29" +dependencies = [ + "base64 0.22.1", + "brotli", + "ico", + "json-patch", + "plist", + "png 0.17.16", + "proc-macro2", + "quote", + "semver", + "serde", + "serde_json", + "sha2", + "syn 2.0.117", + "tauri-utils", + "thiserror 2.0.18", + "time", + "url", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-macros" +version = "2.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d39b349a98dadaffebb73f0a40dcd1f23c999211e5a2e744403db384d0c33de7" +dependencies = [ + "heck 0.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "tauri-codegen", + "tauri-utils", +] + +[[package]] +name = "tauri-plugin" +version = "2.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddde7d51c907b940fb573006cdda9a642d6a7c8153657e88f8a5c3c9290cd4aa" +dependencies = [ + "anyhow", + "glob", + "plist", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri-utils", + "toml 0.9.12+spec-1.1.0", + "walkdir", +] + +[[package]] +name = "tauri-plugin-opener" +version = "2.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc624469b06f59f5a29f874bbc61a2ed737c0f9c23ef09855a292c389c42e83f" +dependencies = [ + "dunce", + "glob", + "objc2-app-kit", + "objc2-foundation", + "open", + "schemars 0.8.22", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "url", + "windows", + "zbus", +] + +[[package]] +name = "tauri-plugin-shell" +version = "2.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8457dbf9e2bab1edd8df22bb2c20857a59a9868e79cb3eac5ed639eec4d0c73b" +dependencies = [ + "encoding_rs", + "log", + "open", + "os_pipe", + "regex", + "schemars 0.8.22", + "serde", + "serde_json", + "shared_child", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "tokio", +] + +[[package]] +name = "tauri-runtime" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2826d79a3297ed08cd6ea7f412644ef58e32969504bc4fbd8d7dbeabc4445ea2" +dependencies = [ + "cookie", + "dpi", + "gtk", + "http", + "jni", + "objc2", + "objc2-ui-kit", + "objc2-web-kit", + "raw-window-handle", + "serde", + "serde_json", + "tauri-utils", + "thiserror 2.0.18", + "url", + "webkit2gtk", + "webview2-com", + "windows", +] + +[[package]] +name = "tauri-runtime-wry" +version = "2.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e11ea2e6f801d275fdd890d6c9603736012742a1c33b96d0db788c9cdebf7f9e" +dependencies = [ + "gtk", + "http", + "jni", + "log", + "objc2", + "objc2-app-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "softbuffer", + "tao", + "tauri-runtime", + "tauri-utils", + "url", + "webkit2gtk", + "webview2-com", + "windows", + "wry", +] + +[[package]] +name = "tauri-utils" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219a1f983a2af3653f75b5747f76733b0da7ff03069c7a41901a5eb3ace4557d" +dependencies = [ + "anyhow", + "brotli", + "cargo_metadata", + "ctor", + "dunce", + "glob", + "html5ever 0.29.1", + "http", + "infer", + "json-patch", + "kuchikiki", + "log", + "memchr", + "phf 0.11.3", + "proc-macro2", + "quote", + "regex", + "schemars 0.8.22", + "semver", + "serde", + "serde-untagged", + "serde_json", + "serde_with", + "swift-rs", + "thiserror 2.0.18", + "toml 0.9.12+spec-1.1.0", + "url", + "urlpattern", + "uuid", + "walkdir", +] + +[[package]] +name = "tauri-winres" +version = "0.3.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1087b111fe2b005e42dbdc1990fc18593234238d47453b0c99b7de1c9ab2c1e0" +dependencies = [ + "dunce", + "embed-resource", + "toml 0.9.12+spec-1.1.0", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "tendril" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0" +dependencies = [ + "futf", + "mac", + "utf-8", +] + +[[package]] +name = "tendril" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4790fc369d5a530f4b544b094e31388b9b3a37c0f4652ade4505945f5660d24" +dependencies = [ + "new_debug_unreachable", + "utf-8", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "time" +version = "0.3.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" + +[[package]] +name = "time-macros" +version = "0.2.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.52.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "tracing", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "185d8ab0dfbb35cf1399a6344d8484209c088f75f8f68230da55d48d95d43e3d" +dependencies = [ + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "toml_edit 0.20.2", +] + +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap 2.14.0", + "serde_core", + "serde_spanned 1.1.1", + "toml_datetime 0.7.5+spec-1.1.0", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml_datetime" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cda73e2f1397b1262d6dfdcef8aafae14d1de7748d66822d3bfeeb6d03e5e4b" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_datetime" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_edit" +version = "0.19.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b5bb770da30e5cbfde35a2d7b9b8a2c4b8ef89548a7a6aeab5c9a576e3e7421" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "396e4d48bbb2b7554c944bde63101b5ae446cff6ec4a24227428f15eb72ef338" +dependencies = [ + "indexmap 2.14.0", + "serde", + "serde_spanned 0.6.9", + "toml_datetime 0.6.3", + "winnow 0.5.40", +] + +[[package]] +name = "toml_edit" +version = "0.25.11+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +dependencies = [ + "indexmap 2.14.0", + "toml_datetime 1.1.1+spec-1.1.0", + "toml_parser", + "winnow 1.0.1", +] + +[[package]] +name = "toml_parser" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +dependencies = [ + "winnow 1.0.1", +] + +[[package]] +name = "toml_writer" +version = "1.1.1+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +dependencies = [ + "bitflags 2.11.1", + "bytes", + "futures-util", + "http", + "http-body", + "iri-string", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "tray-icon" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a5e85aa143ceb072062fc4d6356c1b520a51d636e7bc8e77ec94be3608e5e80c" +dependencies = [ + "crossbeam-channel", + "dirs 6.0.0", + "libappindicator", + "muda", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "once_cell", + "png 0.17.16", + "serde", + "thiserror 2.0.18", + "windows-sys 0.60.2", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "ttf-parser" +version = "0.25.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +dependencies = [ + "core_maths", +] + +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + +[[package]] +name = "typenum" +version = "1.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" + +[[package]] +name = "uds_windows" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" +dependencies = [ + "memoffset", + "tempfile", + "windows-sys 0.61.2", +] + +[[package]] +name = "unic-char-property" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8c57a407d9b6fa02b4795eb81c5b6652060a15a7903ea981f3d723e6c0be221" +dependencies = [ + "unic-char-range", +] + +[[package]] +name = "unic-char-range" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0398022d5f700414f6b899e10b8348231abf9173fa93144cbc1a43b9793c1fbc" + +[[package]] +name = "unic-common" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "80d7ff825a6a654ee85a63e80f92f054f904f21e7d12da4e22f9834a4aaa35bc" + +[[package]] +name = "unic-ucd-ident" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e230a37c0381caa9219d67cf063aa3a375ffed5bf541a452db16e744bdab6987" +dependencies = [ + "unic-char-property", + "unic-char-range", + "unic-ucd-version", +] + +[[package]] +name = "unic-ucd-version" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96bd2f2237fe450fcd0a1d2f5f4e91711124f7857ba2e964247776ebeeb7b0c4" +dependencies = [ + "unic-common", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", + "serde_derive", +] + +[[package]] +name = "urlpattern" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70acd30e3aa1450bc2eece896ce2ad0d178e9c079493819301573dae3c37ba6d" +dependencies = [ + "regex", + "serde", + "unic-ucd-ident", + "url", +] + +[[package]] +name = "utf-8" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "version-compare" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03c2856837ef78f57382f06b2b8563a2f512f7185d732608fd9176cb3b8edf0e" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "vswhom" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be979b7f07507105799e854203b470ff7c78a1639e330a58f183b5fea574608b" +dependencies = [ + "libc", + "vswhom-sys", +] + +[[package]] +name = "vswhom-sys" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.9.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cccddf32554fecc6acb585f82a32a72e28b48f8c4c1883ddfeeeaa96f7d8e519" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.68" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap 2.14.0", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-streams" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d1ec4f6517c9e11ae630e200b2b65d193279042e28edd4a2cda233e46670bbb" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.1", + "hashbrown 0.15.5", + "indexmap 2.14.0", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.95" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web_atoms" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57a9779e9f04d2ac1ce317aee707aa2f6b773afba7b931222bff6983843b1576" +dependencies = [ + "phf 0.13.1", + "phf_codegen 0.13.1", + "string_cache 0.9.0", + "string_cache_codegen 0.6.1", +] + +[[package]] +name = "webkit2gtk" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1027150013530fb2eaf806408df88461ae4815a45c541c8975e61d6f2fc4793" +dependencies = [ + "bitflags 1.3.2", + "cairo-rs", + "gdk", + "gdk-sys", + "gio", + "gio-sys", + "glib", + "glib-sys", + "gobject-sys", + "gtk", + "gtk-sys", + "javascriptcore-rs", + "libc", + "once_cell", + "soup3", + "webkit2gtk-sys", +] + +[[package]] +name = "webkit2gtk-sys" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "916a5f65c2ef0dfe12fff695960a2ec3d4565359fdbb2e9943c974e06c734ea5" +dependencies = [ + "bitflags 1.3.2", + "cairo-sys-rs", + "gdk-sys", + "gio-sys", + "glib-sys", + "gobject-sys", + "gtk-sys", + "javascriptcore-rs-sys", + "libc", + "pkg-config", + "soup3-sys", + "system-deps", +] + +[[package]] +name = "webpki-roots" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "webview2-com" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7130243a7a5b33c54a444e54842e6a9e133de08b5ad7b5861cd8ed9a6a5bc96a" +dependencies = [ + "webview2-com-macros", + "webview2-com-sys", + "windows", + "windows-core 0.61.2", + "windows-implement", + "windows-interface", +] + +[[package]] +name = "webview2-com-macros" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67a921c1b6914c367b2b823cd4cde6f96beec77d30a939c8199bb377cf9b9b54" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "webview2-com-sys" +version = "0.38.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "381336cfffd772377d291702245447a5251a2ffa5bad679c99e61bc48bacbf9c" +dependencies = [ + "thiserror 2.0.18", + "windows", + "windows-core 0.61.2", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "window-vibrancy" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9bec5a31f3f9362f2258fd0e9c9dd61a9ca432e7306cc78c444258f0dce9a9c" +dependencies = [ + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "windows-sys 0.59.0", + "windows-version", +] + +[[package]] +name = "windows" +version = "0.61.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" +dependencies = [ + "windows-collections", + "windows-core 0.61.2", + "windows-future", + "windows-link 0.1.3", + "windows-numerics", +] + +[[package]] +name = "windows-collections" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3beeceb5e5cfd9eb1d76b381630e82c4241ccd0d27f1a39ed41b2760b255c5e8" +dependencies = [ + "windows-core 0.61.2", +] + +[[package]] +name = "windows-core" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0fdd3ddb90610c7638aa2b3a3ab2904fb9e5cdbecc643ddb3647212781c4ae3" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + +[[package]] +name = "windows-future" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", + "windows-threading", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-link" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e6ad25900d524eaabdbbb96d20b4311e1e7ae1699af4fb28c17ae66c80d798a" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-numerics" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9150af68066c4c5c07ddc0ce30421554771e528bde427614c61038bc2c92c2b1" +dependencies = [ + "windows-core 0.61.2", + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56f42bd332cc6c8eac5af113fc0c1fd6a8fd2aa08a0119358686e5160d0586c6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-strings" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "56e6c93f3a0c3b36176cb1327a4958a0353d5d166c2a35cb268ace15e91d3b57" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-sys" +version = "0.45.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75283be5efb2831d37ea142365f009c02ec203cd29a3ebecbc093d52315b66d0" +dependencies = [ + "windows-targets 0.42.2", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-sys" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" +dependencies = [ + "windows-targets 0.53.5", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows-targets" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e5180c00cd44c9b1c88adb3693291f1cd93605ded80c250a75d472756b4d071" +dependencies = [ + "windows_aarch64_gnullvm 0.42.2", + "windows_aarch64_msvc 0.42.2", + "windows_i686_gnu 0.42.2", + "windows_i686_msvc 0.42.2", + "windows_x86_64_gnu 0.42.2", + "windows_x86_64_gnullvm 0.42.2", + "windows_x86_64_msvc 0.42.2", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + +[[package]] +name = "windows-targets" +version = "0.53.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" +dependencies = [ + "windows-link 0.2.1", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", +] + +[[package]] +name = "windows-threading" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b66463ad2e0ea3bbf808b7f1d371311c80e115c0b71d60efc142cafbcfb057a6" +dependencies = [ + "windows-link 0.1.3", +] + +[[package]] +name = "windows-version" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e4060a1da109b9d0326b7262c8e12c84df67cc0dbc9e33cf49e01ccc2eb63631" +dependencies = [ + "windows-link 0.2.1", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "597a5118570b68bc08d8d59125332c54f1ba9d9adeedeef5b99b02ba2b0698f8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e08e8864a60f06ef0d0ff4ba04124db8b0fb3be5776a5cd47641e942e58c4d43" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" + +[[package]] +name = "windows_i686_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c61d927d8da41da96a81f029489353e68739737d3beca43145c8afec9a31a84f" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" + +[[package]] +name = "windows_i686_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44d840b6ec649f480a41c8d80f9c65108b92d89345dd94027bfe06ac444d1060" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_i686_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8de912b8b8feb55c064867cf047dda097f92d51efad5b491dfb98f6bbb70cb36" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d41b46a36d453748aedef1486d5c7a85db22e56aff34643984ea85514e94a3" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.42.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9aec5da331524158c6d1a4ac0ab1541149c0b9505fde06423b02f5ef0106b9f0" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" + +[[package]] +name = "winnow" +version = "0.5.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f593a95398737aeed53e489c785df13f3618e41dbcd6718c6addbf1395aa6876" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winnow" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.55.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb5a765337c50e9ec252c2069be9bf91c7df47afb103b642ba3a53bf8101be97" +dependencies = [ + "cfg-if", + "windows-sys 0.59.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck 0.5.0", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap 2.14.0", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.1", + "indexmap 2.14.0", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap 2.14.0", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "wry" +version = "0.54.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5a8135d8676225e5744de000d4dff5a082501bf7db6a1c1495034f8c314edbc" +dependencies = [ + "base64 0.22.1", + "block2", + "cookie", + "crossbeam-channel", + "dirs 6.0.0", + "dom_query", + "dpi", + "dunce", + "gdkx11", + "gtk", + "http", + "javascriptcore-rs", + "jni", + "libc", + "ndk", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "objc2-ui-kit", + "objc2-web-kit", + "once_cell", + "percent-encoding", + "raw-window-handle", + "sha2", + "soup3", + "tao-macros", + "thiserror 2.0.18", + "url", + "webkit2gtk", + "webkit2gtk-sys", + "webview2-com", + "windows", + "windows-core 0.61.2", + "windows-version", + "x11-dl", +] + +[[package]] +name = "x11" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "502da5464ccd04011667b11c435cb992822c2c0dbde1770c988480d312a0db2e" +dependencies = [ + "libc", + "pkg-config", +] + +[[package]] +name = "x11-dl" +version = "2.21.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38735924fedd5314a6e548792904ed8c6de6636285cb9fec04d5b1db85c1516f" +dependencies = [ + "libc", + "once_cell", + "pkg-config", +] + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zbus" +version = "5.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca82f95dbd3943a40a53cfded6c2d0a2ca26192011846a1810c4256ef92c60bc" +dependencies = [ + "async-broadcast", + "async-executor", + "async-io", + "async-lock", + "async-process", + "async-recursion", + "async-task", + "async-trait", + "blocking", + "enumflags2", + "event-listener", + "futures-core", + "futures-lite", + "hex", + "libc", + "ordered-stream", + "rustix", + "serde", + "serde_repr", + "tokio", + "tracing", + "uds_windows", + "uuid", + "windows-sys 0.61.2", + "winnow 0.7.15", + "zbus_macros", + "zbus_names", + "zvariant", +] + +[[package]] +name = "zbus_macros" +version = "5.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897e79616e84aac4b2c46e9132a4f63b93105d54fe8c0e8f6bffc21fa8d49222" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "zbus_names", + "zvariant", + "zvariant_utils", +] + +[[package]] +name = "zbus_names" +version = "4.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffd8af6d5b78619bab301ff3c560a5bd22426150253db278f164d6cf3b72c50f" +dependencies = [ + "serde", + "winnow 0.7.15", + "zvariant", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerofrom" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69faa1f2a1ea75661980b013019ed6687ed0e83d069bc1114e2cc74c6c04c4df" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zvariant" +version = "5.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5708299b21903bbe348e94729f22c49c55d04720a004aa350f1f9c122fd2540b" +dependencies = [ + "endi", + "enumflags2", + "serde", + "winnow 0.7.15", + "zvariant_derive", + "zvariant_utils", +] + +[[package]] +name = "zvariant_derive" +version = "5.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b59b012ebe9c46656f9cc08d8da8b4c726510aef12559da3e5f1bf72780752c" +dependencies = [ + "proc-macro-crate 3.5.0", + "proc-macro2", + "quote", + "syn 2.0.117", + "zvariant_utils", +] + +[[package]] +name = "zvariant_utils" +version = "3.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f75c23a64ef8f40f13a6989991e643554d9bef1d682a281160cf0c1bc389c5e9" +dependencies = [ + "proc-macro2", + "quote", + "serde", + "syn 2.0.117", + "winnow 0.7.15", +] diff --git a/windows/src-tauri/Cargo.toml b/windows/src-tauri/Cargo.toml new file mode 100644 index 00000000..548afcba --- /dev/null +++ b/windows/src-tauri/Cargo.toml @@ -0,0 +1,40 @@ +[package] +name = "codeburn-menubar" +version = "0.9.20" +description = "CodeBurn menubar (tray) app for Windows and Linux" +authors = ["AgentSeal"] +edition = "2021" +rust-version = "1.80" + +[lib] +name = "codeburn_menubar_lib" +crate-type = ["staticlib", "cdylib", "rlib"] + +[build-dependencies] +tauri-build = { version = "2", features = [] } + +[dependencies] +tauri = { version = "2", features = ["tray-icon", "image-png"] } +tauri-plugin-opener = "2" +tauri-plugin-shell = "2" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["process", "io-util", "rt-multi-thread", "macros", "time", "sync"] } +thiserror = "1" +anyhow = "1" +dirs = "5" +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +fontdue = "0.9" + +[target.'cfg(target_os = "windows")'.dependencies] +windows-sys = { version = "0.59", features = ["Win32_Foundation", "Win32_Graphics_Dwm", "Win32_UI_WindowsAndMessaging"] } + +[target.'cfg(target_os = "linux")'.dependencies] +ksni = "0.3" +png = "0.17" + +[features] +default = ["custom-protocol"] +# This feature is used for production builds or when a dev server is not specified. Don't +# change it unless you know what you're doing. +custom-protocol = ["tauri/custom-protocol"] diff --git a/windows/src-tauri/build.rs b/windows/src-tauri/build.rs new file mode 100644 index 00000000..d860e1e6 --- /dev/null +++ b/windows/src-tauri/build.rs @@ -0,0 +1,3 @@ +fn main() { + tauri_build::build() +} diff --git a/windows/src-tauri/capabilities/default.json b/windows/src-tauri/capabilities/default.json new file mode 100644 index 00000000..e227dc18 --- /dev/null +++ b/windows/src-tauri/capabilities/default.json @@ -0,0 +1,20 @@ +{ + "$schema": "../gen/schemas/desktop-schema.json", + "identifier": "default", + "description": "Default permissions for the CodeBurn tray app window", + "windows": ["popover"], + "permissions": [ + "core:default", + "core:window:allow-close", + "core:window:allow-hide", + "core:window:allow-show", + "core:window:allow-set-focus", + "core:window:allow-set-position", + "core:window:allow-set-size", + "core:tray:default", + "core:event:allow-listen", + "core:event:allow-emit", + "opener:default", + "shell:default" + ] +} diff --git a/windows/src-tauri/icons/128x128.png b/windows/src-tauri/icons/128x128.png new file mode 100644 index 00000000..2682d606 Binary files /dev/null and b/windows/src-tauri/icons/128x128.png differ diff --git a/windows/src-tauri/icons/128x128@2x.png b/windows/src-tauri/icons/128x128@2x.png new file mode 100644 index 00000000..4942be53 Binary files /dev/null and b/windows/src-tauri/icons/128x128@2x.png differ diff --git a/windows/src-tauri/icons/32x32.png b/windows/src-tauri/icons/32x32.png new file mode 100644 index 00000000..b90f1af5 Binary files /dev/null and b/windows/src-tauri/icons/32x32.png differ diff --git a/windows/src-tauri/icons/icon.ico b/windows/src-tauri/icons/icon.ico new file mode 100644 index 00000000..826504af Binary files /dev/null and b/windows/src-tauri/icons/icon.ico differ diff --git a/windows/src-tauri/icons/icon.png b/windows/src-tauri/icons/icon.png new file mode 100644 index 00000000..aace9aa7 Binary files /dev/null and b/windows/src-tauri/icons/icon.png differ diff --git a/windows/src-tauri/icons/tray.png b/windows/src-tauri/icons/tray.png new file mode 100644 index 00000000..1bbc27e5 Binary files /dev/null and b/windows/src-tauri/icons/tray.png differ diff --git a/windows/src-tauri/src/autostart.rs b/windows/src-tauri/src/autostart.rs new file mode 100644 index 00000000..f563d429 --- /dev/null +++ b/windows/src-tauri/src/autostart.rs @@ -0,0 +1,89 @@ +//! Launch at login. Windows: a value under HKCU\...\CurrentVersion\Run pointing at this +//! executable. Linux: an XDG autostart .desktop file. No extra crates; both are a few +//! lines of `reg` / plain file IO. + +use anyhow::{anyhow, Result}; +#[cfg(any(target_os = "windows", target_os = "linux"))] +use anyhow::Context; + +#[cfg(any(target_os = "windows", target_os = "linux"))] +const APP_NAME: &str = "CodeBurn"; + +#[cfg(target_os = "windows")] +const RUN_KEY: &str = r"HKCU\Software\Microsoft\Windows\CurrentVersion\Run"; + +/// Absolute `reg.exe` out of System32 -- see `cli::system_command`. +#[cfg(target_os = "windows")] +fn reg(args: &[&str]) -> Result { + crate::cli::system_command("reg.exe") + .args(args) + .output() + .with_context(|| "failed to run reg.exe") +} + +#[cfg(target_os = "windows")] +pub fn is_enabled() -> bool { + reg(&["query", RUN_KEY, "/v", APP_NAME]) + .map(|out| out.status.success()) + .unwrap_or(false) +} + +#[cfg(target_os = "windows")] +pub fn set_enabled(enabled: bool) -> Result<()> { + if enabled { + let exe = std::env::current_exe().with_context(|| "cannot resolve current exe")?; + let value = format!("\"{}\"", exe.display()); + let out = reg(&["add", RUN_KEY, "/v", APP_NAME, "/t", "REG_SZ", "/d", &value, "/f"])?; + if !out.status.success() { + return Err(anyhow!(String::from_utf8_lossy(&out.stderr).trim().to_string())); + } + } else { + let out = reg(&["delete", RUN_KEY, "/v", APP_NAME, "/f"])?; + // Deleting a value that does not exist is the state we want anyway. + if !out.status.success() && is_enabled() { + return Err(anyhow!(String::from_utf8_lossy(&out.stderr).trim().to_string())); + } + } + Ok(()) +} + +#[cfg(target_os = "linux")] +fn desktop_file() -> Option { + dirs::config_dir().map(|d| d.join("autostart").join("codeburn-menubar.desktop")) +} + +#[cfg(target_os = "linux")] +pub fn is_enabled() -> bool { + desktop_file().map(|p| p.is_file()).unwrap_or(false) +} + +#[cfg(target_os = "linux")] +pub fn set_enabled(enabled: bool) -> Result<()> { + let path = desktop_file().ok_or_else(|| anyhow!("no config dir"))?; + if enabled { + let exe = std::env::current_exe().with_context(|| "cannot resolve current exe")?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent)?; + } + std::fs::write( + &path, + format!( + "[Desktop Entry]\nType=Application\nName={APP_NAME}\nExec=\"{}\"\nX-GNOME-Autostart-enabled=true\n", + exe.display() + ), + )?; + } else if path.exists() { + std::fs::remove_file(&path)?; + } + Ok(()) +} + +#[cfg(target_os = "macos")] +pub fn is_enabled() -> bool { + false +} + +#[cfg(target_os = "macos")] +pub fn set_enabled(_enabled: bool) -> Result<()> { + Err(anyhow!("launch at login is handled by the native macOS app")) +} diff --git a/windows/src-tauri/src/cli.rs b/windows/src-tauri/src/cli.rs new file mode 100644 index 00000000..53435547 --- /dev/null +++ b/windows/src-tauri/src/cli.rs @@ -0,0 +1,601 @@ +use std::env; +use std::path::PathBuf; +use std::process::Stdio; + +use anyhow::{anyhow, bail, Context, Result}; +use serde::Serialize; +use serde_json::Value; +use tauri::AppHandle; +use tokio::io::AsyncReadExt; +use tokio::process::Command; +use tokio::time::{timeout, Duration}; + +/// Hard bounds mirror the macOS CodeburnCLI / DataClient design. A malicious or stuck CLI +/// cannot pin the Tauri process: stdout is capped, stderr is bounded, total wall time is +/// 60s. A hostile CODEBURN_BIN is rejected before any shell-resembling path is taken. +const MAX_PAYLOAD_BYTES: usize = 20 * 1024 * 1024; +const MAX_STDERR_BYTES: usize = 256 * 1024; +const FETCH_TIMEOUT_SECS: u64 = 60; +const VERSION_TIMEOUT_SECS: u64 = 20; + +/// Oldest CLI this app can talk to. 0.9.9 is the first release whose +/// `status --format menubar-json` accepts `--no-optimize`, which every quiet background +/// refresh passes; it also emits all the payload fields the popover reads +/// (`current.providers`, `current.cacheHitPercent`, `history.daily[].topModels`). Older CLIs +/// get the setup screen instead of a half-rendered popover or a stream of spawn failures. +pub const MIN_CLI_VERSION: (u32, u32, u32) = (0, 9, 9); + +#[cfg(windows)] +const WINDOWS_CLI_NAMES: [&str; 2] = ["codeburn.cmd", "codeburn.exe"]; + +#[cfg(windows)] +const CLAUDE_NAMES: [&str; 2] = ["claude.cmd", "claude.exe"]; +#[cfg(not(windows))] +const CLAUDE_NAMES: [&str; 1] = ["claude"]; + +/// Alphanumerics plus `._/-` and space, with `\`, `:`, `(`, `)` also allowed on Windows +/// so a user-supplied `CODEBURN_BIN` path like `C:\Users\...\codeburn.cmd` is accepted. +/// None of these are shell metacharacters in a direct-argv spawn (we never invoke `sh -c`). +fn is_safe_arg(value: &str) -> bool { + !value.is_empty() + && value.chars().all(|c| { + c.is_ascii_alphanumeric() + || matches!(c, '.' | '_' | '/' | '-' | ' ') + || (cfg!(windows) && matches!(c, '\\' | ':' | '(' | ')')) + }) +} + +#[derive(Clone, Debug)] +pub struct CodeburnCli { + program: String, + extra_args: Vec, +} + +/// What the setup screen needs to know about the CLI on this machine. +#[derive(Clone, Debug, Serialize)] +pub struct CliStatus { + pub found: bool, + pub program: String, + pub version: Option, + pub min_version: String, + pub compatible: bool, + pub error: Option, +} + +impl CodeburnCli { + /// Honours `CODEBURN_BIN` only when every whitespace-delimited token passes the + /// allowlist. Otherwise resolves `codeburn` from PATH and the usual npm locations. + pub fn resolve() -> Self { + let raw = env::var("CODEBURN_BIN").unwrap_or_default(); + if raw.is_empty() { + return Self::default_program(); + } + // A bare path (which may contain spaces, e.g. under Program Files) is used whole; + // only otherwise is the value split into program + leading arguments. + if is_safe_arg(&raw) && std::path::Path::new(&raw).is_file() { + return CodeburnCli { + program: raw, + extra_args: vec![], + }; + } + let parts: Vec = raw.split_whitespace().map(String::from).collect(); + if parts.iter().all(|p| is_safe_arg(p)) { + if let Some((first, rest)) = parts.split_first() { + return CodeburnCli { + program: first.clone(), + extra_args: rest.to_vec(), + }; + } + } + eprintln!("codeburn-menubar: refusing unsafe CODEBURN_BIN; falling back to `codeburn`"); + Self::default_program() + } + + fn default_program() -> Self { + CodeburnCli { + program: locate_cli().unwrap_or_else(default_program_name), + extra_args: vec![], + } + } + + pub fn program(&self) -> &str { + &self.program + } + + /// Runs `codeburn --version` and reports whether the CLI is present and new enough. + pub async fn status(&self) -> CliStatus { + let min_version = format!( + "{}.{}.{}", + MIN_CLI_VERSION.0, MIN_CLI_VERSION.1, MIN_CLI_VERSION.2 + ); + let mut status = CliStatus { + found: false, + program: self.program.clone(), + version: None, + min_version, + compatible: false, + error: None, + }; + match self.run_capture(&["--version"], VERSION_TIMEOUT_SECS).await { + Ok(out) => { + let version = out.trim().to_string(); + status.found = true; + status.compatible = parse_version(&version) + .map(|v| v >= MIN_CLI_VERSION) + .unwrap_or(false); + status.version = Some(version); + } + Err(err) => { + status.error = Some(err.to_string()); + } + } + status + } + + /// Spawns `codeburn status --format menubar-json --period X --provider Y` and decodes the + /// output. Pipes are drained concurrently so a chatty stderr cannot deadlock stdout. + pub async fn fetch_menubar_payload( + &self, + period: &str, + provider: &str, + include_optimize: bool, + ) -> Result { + if !is_safe_arg(period) || !is_safe_arg(provider) { + bail!("invalid period/provider argument"); + } + + let mut args = vec![ + "status", + "--format", + "menubar-json", + "--period", + period, + "--provider", + provider, + ]; + if !include_optimize { + args.push("--no-optimize"); + } + + let stdout = self.run_capture(&args, FETCH_TIMEOUT_SECS).await?; + let payload: Value = + serde_json::from_str(&stdout).with_context(|| "CLI returned invalid JSON")?; + Ok(payload) + } + + async fn run_capture(&self, args: &[&str], timeout_secs: u64) -> Result { + let mut full_args = self.extra_args.clone(); + full_args.extend(args.iter().map(|s| s.to_string())); + + let mut cmd = Command::new(&self.program); + cmd.args(&full_args) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + #[cfg(windows)] + { + const CREATE_NO_WINDOW: u32 = 0x08000000; + cmd.creation_flags(CREATE_NO_WINDOW); + } + let mut child = cmd.spawn().map_err(|err| { + anyhow!( + "CodeBurn CLI not found ({}). Install it with `npm install -g codeburn`.", + spawn_error_summary(&self.program, &err) + ) + })?; + + let mut stdout = child.stdout.take().ok_or_else(|| anyhow!("no stdout"))?; + let mut stderr = child.stderr.take().ok_or_else(|| anyhow!("no stderr"))?; + + let stdout_task = tokio::spawn(async move { + let mut buf = Vec::with_capacity(64 * 1024); + let mut limited = (&mut stdout).take(MAX_PAYLOAD_BYTES as u64); + limited.read_to_end(&mut buf).await.ok(); + buf + }); + let stderr_task = tokio::spawn(async move { + let mut buf = Vec::with_capacity(4 * 1024); + let mut limited = (&mut stderr).take(MAX_STDERR_BYTES as u64); + limited.read_to_end(&mut buf).await.ok(); + buf + }); + + let status = timeout(Duration::from_secs(timeout_secs), child.wait()) + .await + .map_err(|_| anyhow!("codeburn CLI timed out after {}s", timeout_secs))??; + + let stdout_bytes = stdout_task.await.unwrap_or_default(); + let stderr_bytes = stderr_task.await.unwrap_or_default(); + + if !status.success() { + let msg = String::from_utf8_lossy(&stderr_bytes); + bail!("codeburn CLI exited {}: {}", status, msg.trim()); + } + Ok(String::from_utf8_lossy(&stdout_bytes).into_owned()) + } +} + +fn spawn_error_summary(program: &str, err: &std::io::Error) -> String { + match err.kind() { + std::io::ErrorKind::NotFound => format!("{} is not on PATH", program), + _ => format!("{}: {}", program, err), + } +} + +fn default_program_name() -> String { + #[cfg(windows)] + { + "codeburn.cmd".to_string() + } + #[cfg(not(windows))] + { + "codeburn".to_string() + } +} + +/// Parses "0.7.3" or "codeburn 0.7.3" into a comparable tuple. +pub fn parse_version(text: &str) -> Option<(u32, u32, u32)> { + let token = text + .split_whitespace() + .find(|t| t.chars().next().map(|c| c.is_ascii_digit()).unwrap_or(false))?; + let mut parts = token.split('.').map(|p| { + p.chars() + .take_while(|c| c.is_ascii_digit()) + .collect::() + .parse::() + .ok() + }); + Some((parts.next()??, parts.next()??, parts.next().flatten().unwrap_or(0))) +} + +/// Locates the CLI without relying on the inherited PATH being fresh. A tray app is often +/// launched from Explorer or at login, before (or long after) `npm install -g codeburn` +/// changed the user's PATH, so we also read the live PATH from the registry on Windows and +/// probe the standard npm / node install prefixes. +fn locate_cli() -> Option { + find_in_search_dirs(&candidate_names()) +} + +/// Same search for Claude Code's own binary, so "Connect Claude" spawns an absolute path +/// instead of letting the console shell resolve a bare `claude`. +fn locate_claude() -> Option { + find_in_search_dirs(&CLAUDE_NAMES) +} + +fn find_in_search_dirs(names: &[&str]) -> Option { + let mut dirs: Vec = Vec::new(); + if let Some(path) = env::var_os("PATH") { + dirs.extend(env::split_paths(&path)); + } + dirs.extend(extra_search_dirs()); + find_in_dirs(&dirs, names) +} + +/// The absolute-only filter is the security boundary, so it lives here where every search +/// goes through it. `env::split_paths` yields an empty `PathBuf` for `;;` or a trailing `;`, +/// and the registry PATH can hold relative entries too; `PathBuf::from("").join("codeburn.cmd")` +/// resolves against the current directory, which for a tray app launched at login is +/// whatever Explorer handed it. A binary planted there must never win. +fn find_in_dirs(dirs: &[PathBuf], names: &[&str]) -> Option { + for dir in dirs.iter().filter(|d| d.is_absolute()) { + for name in names { + let candidate = dir.join(name); + if candidate.is_file() { + return Some(candidate.to_string_lossy().into_owned()); + } + } + } + None +} + +fn candidate_names() -> Vec<&'static str> { + #[cfg(windows)] + { + WINDOWS_CLI_NAMES.to_vec() + } + #[cfg(not(windows))] + { + vec!["codeburn"] + } +} + +#[cfg(windows)] +fn extra_search_dirs() -> Vec { + let mut out = Vec::new(); + for var in ["APPDATA", "LOCALAPPDATA", "ProgramFiles", "ProgramFiles(x86)"] { + if let Some(base) = env::var_os(var).map(PathBuf::from) { + match var { + "APPDATA" => out.push(base.join("npm")), + "LOCALAPPDATA" => { + out.push(base.join("Programs").join("nodejs")); + out.push(base.join("pnpm")); + out.push(base.join("Volta").join("bin")); + out.push(base.join("fnm_multishells")); + } + _ => out.push(base.join("nodejs")), + } + } + } + if let Some(home) = dirs::home_dir() { + out.push(home.join("scoop").join("shims")); + out.push(home.join(".bun").join("bin")); + } + out.extend(registry_path_dirs()); + out +} + +#[cfg(not(windows))] +fn extra_search_dirs() -> Vec { + let mut out = vec![ + PathBuf::from("/opt/homebrew/bin"), + PathBuf::from("/usr/local/bin"), + ]; + if let Some(home) = dirs::home_dir() { + out.push(home.join(".npm-global").join("bin")); + out.push(home.join(".local").join("bin")); + } + out +} + +/// Windows' `CreateProcess` searches the current directory before `PATH`, so spawning +/// `reg` or `cmd` by bare name lets anything dropped next to the app impersonate a system +/// tool -- and the tray badge re-runs `reg query` every refresh. Always spawn the real one +/// out of `%SystemRoot%\System32`, falling back to the documented default when the +/// environment variable is missing or relative. +#[cfg(windows)] +pub fn system32_path(exe: &str) -> PathBuf { + let root = env::var_os("SystemRoot") + .map(PathBuf::from) + .filter(|p| p.is_absolute()) + .unwrap_or_else(|| PathBuf::from(r"C:\Windows")); + root.join("System32").join(exe) +} + +/// `system32_path` plus the CREATE_NO_WINDOW flag every one of these callers wants. +#[cfg(windows)] +pub fn system_command(exe: &str) -> std::process::Command { + use std::os::windows::process::CommandExt; + const CREATE_NO_WINDOW: u32 = 0x08000000; + let mut cmd = std::process::Command::new(system32_path(exe)); + cmd.creation_flags(CREATE_NO_WINDOW); + cmd +} + +/// Reads the user and machine PATH values from the registry via `reg.exe` so a PATH edit +/// made after this process started (npm install adds `%APPDATA%\npm`) is still honoured. +#[cfg(windows)] +fn registry_path_dirs() -> Vec { + let mut out = Vec::new(); + let keys = [ + r"HKCU\Environment", + r"HKLM\SYSTEM\CurrentControlSet\Control\Session Manager\Environment", + ]; + for key in keys { + let output = system_command("reg.exe") + .args(["query", key, "/v", "Path"]) + .output(); + let Ok(output) = output else { continue }; + let text = String::from_utf8_lossy(&output.stdout); + for line in text.lines() { + let trimmed = line.trim(); + if !trimmed.starts_with("Path") { + continue; + } + let Some(idx) = trimmed.find("REG_") else { continue }; + let rest = &trimmed[idx..]; + let Some(space) = rest.find(char::is_whitespace) else { continue }; + let value = rest[space..].trim(); + for part in value.split(';') { + let expanded = expand_env(part.trim()); + if !expanded.is_empty() { + out.push(PathBuf::from(expanded)); + } + } + } + } + out +} + +#[cfg(windows)] +fn expand_env(value: &str) -> String { + let mut result = String::with_capacity(value.len()); + let mut rest = value; + while let Some(start) = rest.find('%') { + result.push_str(&rest[..start]); + let after = &rest[start + 1..]; + match after.find('%') { + Some(end) => { + let name = &after[..end]; + match env::var(name) { + Ok(v) => result.push_str(&v), + Err(_) => { + result.push('%'); + result.push_str(name); + result.push('%'); + } + } + rest = &after[end + 1..]; + } + None => { + result.push_str(&rest[start..]); + rest = ""; + } + } + } + result.push_str(rest); + result +} + +/// Runs a codeburn subcommand in the user's terminal emulator so they can see the output. +/// Linux: tries `x-terminal-emulator`, `gnome-terminal`, `konsole`, then falls back to a +/// detached headless spawn. Windows: opens a console via `cmd /C start`. Never +/// interpolates through a shell -- argv throughout. +pub fn spawn_in_terminal(app: &AppHandle, subcommand: &[&str]) -> Result<()> { + let cli = CodeburnCli::resolve(); + spawn_program_in_terminal(app, &cli, subcommand) +} + +/// The Plan view's "Connect Claude" runs Claude Code's own login flow, not codeburn. The +/// binary is located up front rather than handed to the console shell as a bare name, so +/// the same absolute-directory rule that protects the codeburn lookup applies here too. +pub fn spawn_claude_login(app: &AppHandle) -> Result<()> { + let program = locate_claude().ok_or_else(|| { + anyhow!("Claude Code was not found on this machine. Install it, then try again.") + })?; + let cli = CodeburnCli { + program, + extra_args: vec![], + }; + spawn_program_in_terminal(app, &cli, &["login"]) +} + +fn spawn_program_in_terminal(_app: &AppHandle, cli: &CodeburnCli, subcommand: &[&str]) -> Result<()> { + if !subcommand.iter().all(|s| is_safe_arg(s)) { + bail!("unsafe subcommand argument"); + } + + #[cfg(target_os = "linux")] + { + let mut command_parts: Vec = vec![cli.program.clone()]; + command_parts.extend(cli.extra_args.clone()); + command_parts.extend(subcommand.iter().map(|s| s.to_string())); + // Terminal emulators take the command as one string that a shell then parses + // (gnome-terminal explicitly hands it to `bash -lc`). `cli.program` reaches here + // from PATH resolution, not only from the allowlisted CODEBURN_BIN, so re-check + // every part before joining; anything a shell could reinterpret skips the terminal + // and goes through the argv-only detached spawn below. + if command_parts.iter().all(|p| is_safe_arg(p)) { + let composite = command_parts.join(" "); + let terminals: [&[&str]; 4] = [ + &["x-terminal-emulator", "-e"], + &["gnome-terminal", "--", "bash", "-lc"], + &["konsole", "-e"], + &["xterm", "-e"], + ]; + for term in &terminals { + let program = term[0]; + let extras = &term[1..]; + if which::which(program).is_ok() { + let mut cmd = std::process::Command::new(program); + cmd.args(extras); + cmd.arg(&composite); + cmd.spawn().with_context(|| format!("failed to launch {}", program))?; + return Ok(()); + } + } + } + // Fallback: run detached, output lost -- better than silently doing nothing. + std::process::Command::new(&cli.program) + .args(&cli.extra_args) + .args(subcommand) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn() + .with_context(|| "no terminal emulator found, detached spawn also failed")?; + } + + #[cfg(target_os = "windows")] + { + // `start` treats the first quoted argument as the window title, so we pass an + // explicit empty title. `/K` keeps the console open for non-interactive commands + // (export) so the user can read where the file went; the TUI (report/optimize) + // owns the window until the user quits it either way. + // Only the unresolved default name is worth a second lookup; anything else is + // either already absolute or a CODEBURN_BIN the user chose. + let program = if cli.program == default_program_name() { + locate_cli().unwrap_or_else(|| cli.program.clone()) + } else { + cli.program.clone() + }; + let cmd_exe = system32_path("cmd.exe"); + let mut cmd = system_command("cmd.exe"); + cmd.arg("/C").arg("start").arg("").arg(&cmd_exe).arg("/K").arg(&program); + for a in &cli.extra_args { + cmd.arg(a); + } + for a in subcommand { + cmd.arg(a); + } + cmd.spawn().with_context(|| "failed to open cmd.exe")?; + } + + #[cfg(target_os = "macos")] + { + // macOS isn't our target for this app (Swift handles Mac), but keep dev-on-Mac working. + std::process::Command::new(&cli.program) + .args(&cli.extra_args) + .args(subcommand) + .spawn() + .with_context(|| format!("failed to spawn {}", cli.program))?; + } + + Ok(()) +} + +/// Minimal dependency: we only use `which` inside spawn_in_terminal on Linux. Vendored here +/// so the crate graph stays tiny. Gated so the unused-function warning doesn't fire on Mac +/// or Windows builds. +#[cfg(target_os = "linux")] +mod which { + use std::env; + use std::path::PathBuf; + + pub fn which(program: &str) -> Result { + let path = env::var_os("PATH").ok_or(())?; + let dirs: Vec = env::split_paths(&path).collect(); + super::find_in_dirs(&dirs, &[program]).map(PathBuf::from).ok_or(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The `;;` / trailing-`;` case: an empty PATH entry must not turn into a + /// current-directory lookup, which is how a planted binary would win at login. + #[test] + fn find_in_dirs_skips_empty_and_relative_entries() { + let dir = std::env::temp_dir(); + let name = "codeburn-menubar-locate-probe"; + let planted = dir.join(name); + std::fs::write(&planted, b"probe").unwrap(); + + // Empty and relative entries are ignored even though the file is reachable + // through them once the process CWD is the temp dir. + let unsafe_dirs = vec![PathBuf::from(""), PathBuf::from("."), PathBuf::from("..")]; + assert_eq!(find_in_dirs(&unsafe_dirs, &[name]), None); + + // The same name behind an absolute entry is found. + let found = find_in_dirs(std::slice::from_ref(&dir), &[name]).expect("absolute entry should match"); + assert!(PathBuf::from(&found).is_absolute()); + assert!(found.ends_with(name)); + + // An unsafe entry ahead of a good one cannot shadow it. + let mixed = vec![PathBuf::from(""), dir.clone()]; + assert_eq!(find_in_dirs(&mixed, &[name]), Some(found)); + + std::fs::remove_file(&planted).ok(); + } + + #[test] + fn parse_version_reads_bare_and_prefixed_output() { + assert_eq!(parse_version("0.9.9"), Some((0, 9, 9))); + assert_eq!(parse_version("codeburn 0.9.20\n"), Some((0, 9, 20))); + assert_eq!(parse_version("1.0"), Some((1, 0, 0))); + assert_eq!(parse_version("0.10.0-beta.1"), Some((0, 10, 0))); + assert_eq!(parse_version("no version here"), None); + } + + /// The gate is a plain tuple compare, so the only thing worth pinning is that the + /// versions on either side of MIN_CLI_VERSION land on the right side of it. + #[test] + fn version_gate_rejects_only_older_clis() { + assert_eq!(MIN_CLI_VERSION, (0, 9, 9)); + assert!(parse_version("0.9.8").unwrap() < MIN_CLI_VERSION); + assert!(parse_version("0.9.9").unwrap() >= MIN_CLI_VERSION); + assert!(parse_version("0.9.20").unwrap() >= MIN_CLI_VERSION); + assert!(parse_version("0.10.0").unwrap() >= MIN_CLI_VERSION); + } +} diff --git a/windows/src-tauri/src/config.rs b/windows/src-tauri/src/config.rs new file mode 100644 index 00000000..3ec8c0ab --- /dev/null +++ b/windows/src-tauri/src/config.rs @@ -0,0 +1,183 @@ +use std::fs; +use std::io::Write; +use std::path::PathBuf; + +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Default, Clone, Serialize, Deserialize)] +pub struct CurrencyConfig { + #[serde(default, flatten)] + extra: serde_json::Map, +} + +fn codeburn_config_dir() -> PathBuf { + dirs::home_dir() + .map(|h| h.join(".config/codeburn")) + .unwrap_or_else(|| PathBuf::from(".codeburn")) +} + +fn config_path() -> PathBuf { + codeburn_config_dir().join("config.json") +} + +fn lock_path() -> PathBuf { + codeburn_config_dir().join(".config.lock") +} + +impl CurrencyConfig { + pub fn load_or_default() -> Self { + match fs::read(config_path()) { + Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_default(), + Err(_) => Self::default(), + } + } + + pub fn set_currency(&mut self, code: &str, symbol: &str) -> Result<()> { + fs::create_dir_all(codeburn_config_dir()) + .with_context(|| "failed to create ~/.config/codeburn")?; + + #[cfg(unix)] + let _lock = unix_lock::acquire()?; + #[cfg(windows)] + let _lock = windows_lock::acquire()?; + + let mut disk: serde_json::Value = match fs::read(config_path()) { + Ok(bytes) => serde_json::from_slice(&bytes).unwrap_or_else(|_| serde_json::json!({})), + Err(_) => serde_json::json!({}), + }; + + if code == "USD" { + if let Some(obj) = disk.as_object_mut() { + obj.remove("currency"); + } + } else if let Some(obj) = disk.as_object_mut() { + obj.insert( + "currency".into(), + serde_json::json!({ "code": code, "symbol": symbol }), + ); + } + + let serialized = serde_json::to_vec_pretty(&disk)?; + let tmp = config_path().with_extension("tmp"); + { + let mut file = fs::OpenOptions::new() + .create(true) + .write(true) + .truncate(true) + .open(&tmp)?; + file.write_all(&serialized)?; + file.flush()?; + } + fs::rename(&tmp, config_path())?; + + *self = serde_json::from_value(disk).unwrap_or_default(); + Ok(()) + } +} + +#[cfg(unix)] +mod unix_lock { + use std::fs; + use std::os::fd::AsRawFd; + use anyhow::{anyhow, Context, Result}; + + pub struct Guard { + _file: fs::File, + } + + pub fn acquire() -> Result { + let file = fs::OpenOptions::new() + .create(true) + .read(true) + .write(true) + // A lock file only ever needs to exist; its contents are irrelevant. + .truncate(false) + .open(super::lock_path()) + .with_context(|| "failed to open config lock")?; + + let fd = file.as_raw_fd(); + let ret = unsafe { flock(fd, 2) }; + if ret != 0 { + return Err(anyhow!("flock failed: {}", std::io::Error::last_os_error())); + } + Ok(Guard { _file: file }) + } + + extern "C" { + fn flock(fd: i32, operation: i32) -> i32; + } +} + +/// Windows has no flock; a create-new lock file is the closest equivalent. +/// +/// What this actually buys: mutual exclusion between writers that take this lock, which +/// today means only other instances of this app. The codeburn CLI writes `config.json` +/// without taking it, so a concurrent CLI write still races us -- the rename below keeps the +/// file from ever being torn, but a simultaneous CLI edit can still be the one that wins. +/// +/// A lock file left behind by a crash is treated as abandoned once it is older than +/// STALE_LOCK_SECS (three orders of magnitude longer than the read-modify-rename it guards), +/// so one crash cannot wedge currency changes forever. Upgrading to `LockFileEx`, which the +/// OS releases on process death and needs no staleness heuristic, only becomes worth it if +/// the CLI ever starts taking the lock too. +#[cfg(windows)] +mod windows_lock { + use std::fs; + use std::path::PathBuf; + use std::thread::sleep; + use std::time::{Duration, SystemTime}; + use anyhow::{anyhow, Result}; + + const RETRY_INTERVAL: Duration = Duration::from_millis(40); + const MAX_RETRIES: u32 = 50; + const STALE_LOCK_SECS: u64 = 30; + + pub struct Guard { + path: PathBuf, + /// Kept open for the lifetime of the guard: Windows will not unlink a file that is + /// still open, so holding the handle is what stops the stale sweep below from ever + /// deleting a lock whose owner is alive. + file: Option, + } + + impl Drop for Guard { + fn drop(&mut self) { + self.file.take(); + let _ = fs::remove_file(&self.path); + } + } + + pub fn acquire() -> Result { + let path = super::lock_path(); + for _ in 0..MAX_RETRIES { + match fs::OpenOptions::new().write(true).create_new(true).open(&path) { + Ok(file) => { + return Ok(Guard { + path, + file: Some(file), + }) + } + Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => { + // Only proceed on a successful unlink: a live holder still has the file + // open, so this fails for anything but an abandoned lock. + if is_stale(&path) && fs::remove_file(&path).is_ok() { + continue; + } + sleep(RETRY_INTERVAL); + } + Err(err) => return Err(anyhow!("failed to open config lock: {err}")), + } + } + Err(anyhow!("config lock is held by another process")) + } + + fn is_stale(path: &PathBuf) -> bool { + fs::metadata(path) + .and_then(|m| m.modified()) + .ok() + .and_then(|t| SystemTime::now().duration_since(t).ok()) + .map(|age| age.as_secs() > STALE_LOCK_SECS) + .unwrap_or(false) + } +} diff --git a/windows/src-tauri/src/fx.rs b/windows/src-tauri/src/fx.rs new file mode 100644 index 00000000..5eb63be1 --- /dev/null +++ b/windows/src-tauri/src/fx.rs @@ -0,0 +1,152 @@ +use std::collections::HashMap; +use std::fs; +use std::path::PathBuf; +use std::sync::Mutex; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use serde::{Deserialize, Serialize}; + +const FRANKFURTER_URL: &str = "https://api.frankfurter.app/latest?from=USD&to="; +const CACHE_TTL_SECS: u64 = 24 * 3600; +const FETCH_TIMEOUT: Duration = Duration::from_secs(10); +/// Defensive bounds on any fetched FX rate. Outside [0.0001, 1_000_000] the rate is either +/// a parser bug or a tampered response; we refuse it so the UI never multiplies a NaN or +/// wild value into displayed costs. +const MIN_VALID_FX_RATE: f64 = 0.0001; +const MAX_VALID_FX_RATE: f64 = 1_000_000.0; + +/// Currency metadata the frontend renders against. `rate` is USD -> target; the UI +/// multiplies each raw USD number by `rate` and prefixes `symbol` for display. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct CurrencyApplied { + pub code: String, + pub symbol: String, + pub rate: f64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct Entry { + rate: f64, + saved_at: u64, +} + +pub struct FxCache { + entries: Mutex>, +} + +impl FxCache { + pub fn new() -> Self { + let entries = load_from_disk().unwrap_or_default(); + FxCache { + entries: Mutex::new(entries), + } + } + + /// Returns a cached-or-fresh rate. Tries cache first, then Frankfurter if stale. Any + /// response that fails the sanity bounds is dropped and the cached (possibly stale) + /// value is returned instead. + pub async fn rate_for(&self, code: &str) -> Option { + if code == "USD" { + return Some(1.0); + } + + { + let guard = self.entries.lock().ok()?; + if let Some(entry) = guard.get(code) { + if now_secs().saturating_sub(entry.saved_at) < CACHE_TTL_SECS { + return Some(entry.rate); + } + } + } + + match fetch_rate(code).await { + Some(fresh) if is_valid(fresh) => { + if let Ok(mut guard) = self.entries.lock() { + guard.insert( + code.to_string(), + Entry { + rate: fresh, + saved_at: now_secs(), + }, + ); + let _ = save_to_disk(&guard); + } + Some(fresh) + } + _ => { + // Fetch failed or out-of-band; serve stale cached value if any. + let guard = self.entries.lock().ok()?; + guard.get(code).map(|e| e.rate) + } + } + } +} + +fn is_valid(rate: f64) -> bool { + rate.is_finite() && (MIN_VALID_FX_RATE..=MAX_VALID_FX_RATE).contains(&rate) +} + +fn now_secs() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0) +} + +fn cache_path() -> PathBuf { + dirs::cache_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join("codeburn-menubar") + .join("fx-rates.json") +} + +fn load_from_disk() -> Option> { + let bytes = fs::read(cache_path()).ok()?; + let parsed: HashMap = serde_json::from_slice(&bytes).ok()?; + Some(parsed.into_iter().filter(|(_, e)| is_valid(e.rate)).collect()) +} + +fn save_to_disk(entries: &HashMap) -> Option<()> { + let path = cache_path(); + let parent = path.parent()?; + fs::create_dir_all(parent).ok()?; + let serialized = serde_json::to_vec(entries).ok()?; + let tmp = path.with_extension("tmp"); + fs::write(&tmp, serialized).ok()?; + fs::rename(&tmp, path).ok()?; + Some(()) +} + +async fn fetch_rate(code: &str) -> Option { + let client = reqwest::Client::builder() + .timeout(FETCH_TIMEOUT) + .https_only(true) + .build() + .ok()?; + let url = format!("{}{}", FRANKFURTER_URL, code); + let response = client.get(&url).send().await.ok()?; + if !response.status().is_success() { + return None; + } + let body: serde_json::Value = response.json().await.ok()?; + body.get("rates")?.get(code)?.as_f64() +} + +/// Prefers a handwritten glyph over whatever Intl returns for a given code, since some +/// locales produce "US$" / "CA$" which reads as noise. Mirrors the Swift symbol override +/// table so both apps display identical strings for the same code. +pub fn symbol_for(code: &str) -> String { + match code { + "USD" | "CAD" | "AUD" | "NZD" | "HKD" | "SGD" | "MXN" => "$".into(), + "EUR" => "\u{20AC}".into(), + "GBP" => "\u{00A3}".into(), + "JPY" | "CNY" => "\u{00A5}".into(), + "KRW" => "\u{20A9}".into(), + "INR" => "\u{20B9}".into(), + "BRL" => "R$".into(), + "CHF" => "CHF".into(), + "SEK" | "DKK" => "kr".into(), + "ZAR" => "R".into(), + _ => code.into(), + } +} diff --git a/windows/src-tauri/src/lib.rs b/windows/src-tauri/src/lib.rs new file mode 100644 index 00000000..64e67111 --- /dev/null +++ b/windows/src-tauri/src/lib.rs @@ -0,0 +1,520 @@ +mod autostart; +mod cli; +mod config; +mod fx; +mod plan; +/// The spend-in-the-tray badge is a second tray icon, which only the Tauri tray backend +/// provides; Linux runs its own SNI tray (`tray_linux`) and has no equivalent, so the +/// whole module is compiled out there rather than sitting unused. +#[cfg(not(target_os = "linux"))] +mod tray_badge; +#[cfg(target_os = "linux")] +mod tray_linux; + +use std::sync::Mutex; +use std::sync::atomic::{AtomicI64, Ordering}; + +static LAST_HIDDEN_MS: AtomicI64 = AtomicI64::new(0); + +use tauri::{AppHandle, Emitter, Manager, WindowEvent}; +#[cfg(target_os = "linux")] +use tauri::Listener; + +#[cfg(not(target_os = "linux"))] +use tauri::{ + menu::{Menu, MenuItem, PredefinedMenuItem}, + tray::{MouseButton, MouseButtonState, TrayIconBuilder, TrayIconEvent}, +}; + +use crate::cli::CodeburnCli; +use crate::config::CurrencyConfig; +use crate::fx::FxCache; + +#[cfg(not(target_os = "linux"))] +const TRAY_ID: &str = "codeburn-tray"; +/// Second tray icon that carries today's spend as text, sitting next to the logo. The +/// closest the Windows notification area gets to the macOS menubar title. +#[cfg(not(target_os = "linux"))] +const BADGE_TRAY_ID: &str = "codeburn-badge"; +const POPOVER_LABEL: &str = "popover"; + +/// Shared application state. Wraps the CLI handle + currency config + FX cache so every +/// Tauri command sees the same instances. Interior Mutex keeps things simple; the state is +/// touched from the main thread (UI) and the Tokio runtime (CLI spawn, HTTP), both of +/// which go through `#[tauri::command]` async functions that acquire the lock briefly. +pub struct AppState { + pub cli: Mutex, + pub config: Mutex, + pub fx: FxCache, + pub plan: plan::PlanClient, +} + +#[cfg_attr(mobile, tauri::mobile_entry_point)] +pub fn run() { + tauri::Builder::default() + .plugin(tauri_plugin_shell::init()) + .plugin(tauri_plugin_opener::init()) + .setup(|app| { + app.manage(AppState { + cli: Mutex::new(CodeburnCli::resolve()), + config: Mutex::new(CurrencyConfig::load_or_default()), + fx: FxCache::new(), + plan: plan::PlanClient::new(), + }); + + #[cfg(not(target_os = "linux"))] + build_tray_tauri(app.handle())?; + + #[cfg(target_os = "linux")] + init_tray_linux(app.handle().clone(), tray_linux::LinuxTrayHandle::empty()); + + if let Some(window) = app.get_webview_window(POPOVER_LABEL) { + let _ = window.hide(); + #[cfg(target_os = "windows")] + round_window_corners(&window); + } + + Ok(()) + }) + .on_window_event(|window, event| { + match event { + WindowEvent::CloseRequested { api, .. } => { + api.prevent_close(); + let _ = window.hide(); + mark_hidden(window.app_handle()); + } + WindowEvent::Focused(false) => { + let _ = window.hide(); + mark_hidden(window.app_handle()); + } + _ => {} + } + }) + .invoke_handler(tauri::generate_handler![ + commands::fetch_payload, + commands::cli_status, + commands::set_currency, + commands::open_terminal_command, + commands::open_claude_login, + commands::quit_app, + commands::hide_popover, + commands::set_tray_tooltip, + commands::set_tray_badge, + commands::app_version, + commands::plan_usage, + commands::launch_at_login, + commands::set_launch_at_login, + ]) + .build(tauri::generate_context!()) + .expect("error while running tauri application") + .run(|_app, event| { + if let tauri::RunEvent::ExitRequested { api, .. } = event { + api.prevent_exit(); + } + }); +} + +#[cfg(not(target_os = "linux"))] +fn build_tray_tauri(app: &AppHandle) -> tauri::Result<()> { + let Some(tray) = app.tray_by_id(TRAY_ID) else { + return Ok(()); + }; + + let open = MenuItem::with_id(app, "open", "Open CodeBurn", true, None::<&str>)?; + let refresh = MenuItem::with_id(app, "refresh", "Refresh", true, None::<&str>)?; + let theme = MenuItem::with_id(app, "toggle_theme", "Toggle Dark/Light", true, None::<&str>)?; + let report = MenuItem::with_id(app, "report", "Open Full Report", true, None::<&str>)?; + let quit = MenuItem::with_id(app, "quit", "Quit CodeBurn", true, None::<&str>)?; + let menu = Menu::with_items( + app, + &[ + &open, + &refresh, + &theme, + &report, + &PredefinedMenuItem::separator(app)?, + &quit, + ], + )?; + + tray.set_menu(Some(menu.clone()))?; + tray.set_show_menu_on_left_click(false)?; + let _ = tray.set_tooltip(Some("CodeBurn")); + tray.on_menu_event(on_tray_menu_event); + tray.on_tray_icon_event(on_tray_icon_event); + + // The badge icon starts fully transparent and hidden; the frontend shows it once it has + // today's spend. Registering it right after the logo puts it beside the logo in the tray. + let blank = tauri::image::Image::new_owned( + vec![0u8; (BLANK_ICON_SIZE * BLANK_ICON_SIZE * 4) as usize], + BLANK_ICON_SIZE, + BLANK_ICON_SIZE, + ); + TrayIconBuilder::with_id(BADGE_TRAY_ID) + .icon(blank) + .tooltip("CodeBurn") + .menu(&menu) + .show_menu_on_left_click(false) + .on_menu_event(on_tray_menu_event) + .on_tray_icon_event(on_tray_icon_event) + .build(app)? + .set_visible(false)?; + + Ok(()) +} + +#[cfg(not(target_os = "linux"))] +const BLANK_ICON_SIZE: u32 = 16; + +#[cfg(not(target_os = "linux"))] +fn on_tray_menu_event(app: &AppHandle, event: tauri::menu::MenuEvent) { + match event.id.as_ref() { + "quit" => app.exit(0), + "open" => show_popover(app, None), + "refresh" => { + if let Some(window) = app.get_webview_window(POPOVER_LABEL) { + let _ = window.emit("codeburn://refresh", ()); + } + } + "toggle_theme" => { + if let Some(window) = app.get_webview_window(POPOVER_LABEL) { + let _ = window.emit("codeburn://toggle-theme", ()); + } + } + "report" => { + let _ = cli::spawn_in_terminal(app, &["report"]); + } + _ => {} + } +} + +#[cfg(not(target_os = "linux"))] +fn on_tray_icon_event(tray: &tauri::tray::TrayIcon, event: TrayIconEvent) { + match event { + TrayIconEvent::Click { + button: MouseButton::Left, + button_state: MouseButtonState::Up, + position, + .. + } + | TrayIconEvent::DoubleClick { + button: MouseButton::Left, + position, + .. + } => { + toggle_popover(tray.app_handle(), Some((position.x as i32, position.y as i32))); + } + _ => {} + } +} + +#[cfg(target_os = "linux")] +fn init_tray_linux(app: AppHandle, handle: tray_linux::LinuxTrayHandle) { + // Spawn the SNI tray on the Tokio runtime that Tauri already owns. + let spawn_app = app.clone(); + let spawn_handle = handle.clone(); + tauri::async_runtime::spawn(async move { + if let Err(err) = tray_linux::spawn(spawn_app, spawn_handle).await { + eprintln!("codeburn: failed to spawn Linux tray: {err}"); + } + }); + + // Left-click on the tray: show popover anchored to the click coordinates. + let activate_app = app.clone(); + app.listen_any("codeburn://tray-activate", move |event| { + let anchor = parse_click(event.payload()); + toggle_popover(&activate_app, anchor); + }); + + // Right-click / middle-click: same as left for now. Quit lives in the popover footer. + let secondary_app = app.clone(); + app.listen_any("codeburn://tray-secondary", move |event| { + let anchor = parse_click(event.payload()); + toggle_popover(&secondary_app, anchor); + }); +} + +#[cfg(target_os = "linux")] +fn parse_click(payload: &str) -> Option<(i32, i32)> { + let value: serde_json::Value = serde_json::from_str(payload).ok()?; + let x = value.get("x")?.as_i64()? as i32; + let y = value.get("y")?.as_i64()? as i32; + Some((x, y)) +} + +/// Undecorated windows are square by default; ask DWM for the Windows 11 rounded corner so +/// the acrylic backdrop is clipped to the same shape as the popover card. Silently ignored +/// on Windows 10, where the corners stay square. +#[cfg(target_os = "windows")] +fn round_window_corners(window: &tauri::WebviewWindow) { + use windows_sys::Win32::Graphics::Dwm::{ + DwmSetWindowAttribute, DWMWA_WINDOW_CORNER_PREFERENCE, DWMWCP_ROUND, + }; + let Ok(hwnd) = window.hwnd() else { return }; + let preference: u32 = DWMWCP_ROUND as u32; + unsafe { + DwmSetWindowAttribute( + hwnd.0 as _, + DWMWA_WINDOW_CORNER_PREFERENCE as u32, + &preference as *const u32 as *const std::ffi::c_void, + std::mem::size_of::() as u32, + ); + } +} + +/// A blur immediately followed by the tray click that caused it would re-open the popover; +/// ignore show requests inside this window after a hide. +const TOGGLE_DEBOUNCE_MS: i64 = 300; + +fn now_ms() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as i64 +} + +/// Every path that hides the popover goes through here, so the debounce stamp and the +/// frontend's visibility signal can never drift apart. The frontend drops to its idle +/// refresh cadence on `codeburn://hidden` and comes back on `codeburn://shown`. +fn mark_hidden(app: &AppHandle) { + LAST_HIDDEN_MS.store(now_ms(), Ordering::Relaxed); + let _ = app.emit("codeburn://hidden", ()); +} + +fn toggle_popover(app: &AppHandle, anchor: Option<(i32, i32)>) { + let Some(window) = app.get_webview_window(POPOVER_LABEL) else { + return; + }; + if window.is_visible().unwrap_or(false) { + let _ = window.hide(); + mark_hidden(app); + return; + } + let last = LAST_HIDDEN_MS.load(Ordering::Relaxed); + if now_ms() - last < TOGGLE_DEBOUNCE_MS { + return; + } + show_popover(app, anchor); +} + +fn show_popover(app: &AppHandle, anchor: Option<(i32, i32)>) { + let Some(window) = app.get_webview_window(POPOVER_LABEL) else { + return; + }; + // Position before showing so the first frame is already in place (no jump). + position_popover(&window, anchor); + let _ = window.show(); + let _ = window.unminimize(); + position_popover(&window, anchor); + let _ = window.set_focus(); + let _ = window.emit("codeburn://shown", ()); +} + +/// Places the popover against the taskbar / panel edge of the monitor that owns the click +/// (or the cursor, when the request came from a menu). The work area already excludes the +/// taskbar on Windows and panels on Linux, so we never need to guess their heights: the +/// popover sits `MARGIN` inside the work area, horizontally centred on the anchor and +/// clamped to the screen. +fn position_popover(window: &tauri::WebviewWindow, anchor: Option<(i32, i32)>) { + const POPOVER_WIDTH_LOGICAL: f64 = 360.0; + const POPOVER_HEIGHT_LOGICAL: f64 = 660.0; + const MARGIN_LOGICAL: f64 = 8.0; + + let point = anchor + .filter(|(x, y)| *x > 0 || *y > 0) + .map(|(x, y)| (x as f64, y as f64)) + .or_else(|| window.cursor_position().ok().map(|p| (p.x, p.y))); + + let monitor = point + .and_then(|(x, y)| window.monitor_from_point(x, y).ok().flatten()) + .or_else(|| window.primary_monitor().ok().flatten()); + let Some(monitor) = monitor else { + return; + }; + + let scale = monitor.scale_factor(); + let pop_w = (POPOVER_WIDTH_LOGICAL * scale).round() as i32; + let pop_h = (POPOVER_HEIGHT_LOGICAL * scale).round() as i32; + let margin = (MARGIN_LOGICAL * scale).round() as i32; + + let area = monitor.work_area(); + let area_x = area.position.x; + let area_y = area.position.y; + let area_w = area.size.width as i32; + let area_h = area.size.height as i32; + let screen = monitor.size(); + let screen_pos = monitor.position(); + + let (anchor_x, anchor_y) = point + .map(|(x, y)| (x as i32, y as i32)) + .unwrap_or((area_x + area_w - pop_w / 2 - margin, area_y + area_h)); + + let min_x = area_x + margin; + let max_x = (area_x + area_w - pop_w - margin).max(min_x); + let x = (anchor_x - pop_w / 2).clamp(min_x, max_x); + + // Which edge holds the taskbar? Whichever side the work area was trimmed on. If the + // taskbar is at the top (or the anchor is in the top half with no bottom taskbar) the + // popover drops down from the top edge; otherwise it rises from the bottom edge. + let trimmed_top = area_y > screen_pos.y; + let trimmed_bottom = (area_y + area_h) < (screen_pos.y + screen.height as i32); + let anchor_in_top_half = anchor_y < screen_pos.y + (screen.height as i32) / 2; + let open_downward = trimmed_top || (!trimmed_bottom && anchor_in_top_half); + + let y = if open_downward { + area_y + margin + } else { + (area_y + area_h - pop_h - margin).max(area_y + margin) + }; + + let _ = window.set_position(tauri::PhysicalPosition::new(x, y)); +} + +mod commands { + use super::{AppState, POPOVER_LABEL}; + use serde_json::Value; + use tauri::{AppHandle, Manager, State}; + + #[tauri::command] + pub async fn fetch_payload( + period: String, + provider: String, + include_optimize: bool, + state: State<'_, AppState>, + ) -> Result { + let cli = state.cli.lock().map_err(|e| e.to_string())?.clone(); + cli.fetch_menubar_payload(&period, &provider, include_optimize) + .await + .map_err(|e| e.to_string()) + } + + /// Re-resolves the CLI each call so a freshly installed `codeburn` is picked up + /// without restarting the tray app. + #[tauri::command] + pub async fn cli_status(state: State<'_, AppState>) -> Result { + let fresh = crate::cli::CodeburnCli::resolve(); + let status = fresh.status().await; + if status.found { + if let Ok(mut guard) = state.cli.lock() { + *guard = fresh; + } + } + Ok(status) + } + + #[tauri::command] + pub async fn set_currency( + code: String, + state: State<'_, AppState>, + ) -> Result { + let symbol = crate::fx::symbol_for(&code); + let rate = state + .fx + .rate_for(&code) + .await + .ok_or_else(|| format!("Exchange rate for {code} is unavailable right now"))?; + state + .config + .lock() + .map_err(|e| e.to_string())? + .set_currency(&code, &symbol) + .map_err(|e| e.to_string())?; + Ok(crate::fx::CurrencyApplied { code, symbol, rate }) + } + + #[tauri::command] + pub fn open_terminal_command(app: AppHandle, args: Vec) -> Result<(), String> { + let args: Vec<&str> = args.iter().map(String::as_str).collect(); + crate::cli::spawn_in_terminal(&app, &args).map_err(|e| e.to_string()) + } + + #[tauri::command] + pub fn open_claude_login(app: AppHandle) -> Result<(), String> { + crate::cli::spawn_claude_login(&app).map_err(|e| e.to_string()) + } + + #[tauri::command] + pub fn quit_app(app: AppHandle) { + app.exit(0); + } + + #[tauri::command] + pub fn hide_popover(app: AppHandle) { + if let Some(window) = app.get_webview_window(POPOVER_LABEL) { + let _ = window.hide(); + super::mark_hidden(&app); + } + } + + /// The tray cannot render text on Windows, so today's spend lives in the tooltip. + #[tauri::command] + pub fn set_tray_tooltip(app: AppHandle, text: String) { + #[cfg(not(target_os = "linux"))] + for id in [super::TRAY_ID, super::BADGE_TRAY_ID] { + if let Some(tray) = app.tray_by_id(id) { + let _ = tray.set_tooltip(Some(text.as_str())); + } + } + #[cfg(target_os = "linux")] + { + let _ = (app, text); + } + } + + /// `text` is a short spend string ("$87", "142", "1.2K"); `None` hides the badge icon. + #[tauri::command] + pub fn set_tray_badge(app: AppHandle, text: Option) -> Result<(), String> { + #[cfg(target_os = "linux")] + { + // Unreachable from the UI: the frontend hides the control wherever the badge is + // unsupported (lib/platform.ts). Saying so beats reporting a success that never + // happened. + let _ = (app, text); + Err("the tray spend badge needs a second tray icon, which the Linux SNI tray does not provide".to_string()) + } + #[cfg(not(target_os = "linux"))] + { + let Some(badge) = app.tray_by_id(super::BADGE_TRAY_ID) else { + return Ok(()); + }; + match text.as_deref().map(str::trim).filter(|t| !t.is_empty()) { + Some(t) => { + let icon = crate::tray_badge::render( + t, + crate::tray_badge::small_icon_size(), + crate::tray_badge::taskbar_is_dark(), + ); + // Windows can only modify an icon that is currently shown, so show + // first (re-adds the previous bitmap) and then swap the bitmap. + badge.set_visible(true).map_err(|e| e.to_string())?; + badge.set_icon(Some(icon)).map_err(|e| e.to_string())?; + } + None => { + badge.set_visible(false).map_err(|e| e.to_string())?; + } + } + Ok(()) + } + } + + #[tauri::command] + pub fn app_version(app: AppHandle) -> String { + app.package_info().version.to_string() + } + + #[tauri::command] + pub fn launch_at_login() -> bool { + crate::autostart::is_enabled() + } + + #[tauri::command] + pub fn set_launch_at_login(enabled: bool) -> Result { + crate::autostart::set_enabled(enabled).map_err(|e| e.to_string())?; + Ok(crate::autostart::is_enabled()) + } + + #[tauri::command] + pub async fn plan_usage(state: State<'_, AppState>) -> Result { + state.plan.fetch().await.map_err(|e| e.to_string()) + } +} diff --git a/windows/src-tauri/src/main.rs b/windows/src-tauri/src/main.rs new file mode 100644 index 00000000..b79df4b0 --- /dev/null +++ b/windows/src-tauri/src/main.rs @@ -0,0 +1,6 @@ +// Stops an extra console window appearing on Windows in release builds. +#![cfg_attr(not(debug_assertions), windows_subsystem = "windows")] + +fn main() { + codeburn_menubar_lib::run() +} diff --git a/windows/src-tauri/src/plan.rs b/windows/src-tauri/src/plan.rs new file mode 100644 index 00000000..98fbbff2 --- /dev/null +++ b/windows/src-tauri/src/plan.rs @@ -0,0 +1,479 @@ +//! Claude subscription usage (the "Plan" insight). Mirrors the macOS SubscriptionClient: +//! read Claude Code's OAuth credentials, call the usage endpoint, adopt a token Claude Code +//! has already rotated on 401 (never spending the shared refresh token), and keep a rolling +//! snapshot file so a freshly reset window can still show last cycle's final. + +use std::fs; +use std::path::PathBuf; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +use anyhow::{bail, Context, Result}; +use serde::{Deserialize, Serialize}; +use tokio::sync::Mutex; + +const CREDENTIALS_RELATIVE_PATH: &str = ".claude/.credentials.json"; +const USAGE_URL: &str = "https://api.anthropic.com/api/oauth/usage"; +const BETA_HEADER: &str = "oauth-2025-04-20"; +const USER_AGENT: &str = "claude-code/2.1.0"; +const REQUEST_TIMEOUT: Duration = Duration::from_secs(30); +const MAX_CREDENTIAL_BYTES: u64 = 64 * 1024; +const SNAPSHOT_FILENAME: &str = "subscription-snapshots.json"; +const SNAPSHOT_RETENTION: Duration = Duration::from_secs(30 * 24 * 3600); +const WINDOW_KEYS: [(&str, &str); 4] = [ + ("five_hour", "5-hour window"), + ("seven_day", "7-day total"), + ("seven_day_opus", "7-day Opus"), + ("seven_day_sonnet", "7-day Sonnet"), +]; + +#[derive(Debug, Clone, Serialize)] +pub struct PlanWindow { + pub key: String, + pub label: String, + /// 0..100 + pub percent: f64, + /// RFC 3339 timestamp of the next reset, when the API supplied one. + pub resets_at: Option, + /// Final percent reached in the immediately prior cycle, from the snapshot store. + pub previous_final: Option, +} + +#[derive(Debug, Clone, Serialize)] +#[serde(tag = "state", rename_all = "snake_case")] +pub enum PlanUsage { + Ok { + tier: String, + raw_tier: Option, + windows: Vec, + fetched_at: String, + }, + NoCredentials, + Failed { + message: String, + }, +} + +pub struct PlanClient { + snapshot_lock: Mutex<()>, +} + +impl PlanClient { + pub fn new() -> Self { + PlanClient { + snapshot_lock: Mutex::new(()), + } + } + + pub async fn fetch(&self) -> Result { + let creds = match load_credentials() { + Ok(Some(c)) => c, + Ok(None) => return Ok(PlanUsage::NoCredentials), + Err(err) => { + return Ok(PlanUsage::Failed { + message: err.to_string(), + }) + } + }; + + let response = match fetch_usage(&creds.access_token).await { + Ok(r) => r, + // Parity with mac/Sources/CodeBurnMenubar/Data/ClaudeCredentialStore.swift + // (`refreshAfter401`): Claude's refresh token is single-use and rotates, so + // spending it here would invalidate the token Claude Code itself is holding and + // break the user's `claude` login. Instead re-read Claude's own store for a + // token it has already rotated; if there is nothing fresher yet, report a + // transient failure and let the next refresh pick it up. + Err(FetchError::Unauthorized) => { + let rotated = load_credentials() + .ok() + .flatten() + .map(|c| c.access_token) + .filter(|t| *t != creds.access_token); + let Some(token) = rotated else { + return Ok(PlanUsage::Failed { + message: "Claude is refreshing its session. This clears itself once Claude Code renews the token; run `claude login` if it persists.".into(), + }); + }; + match fetch_usage(&token).await { + Ok(r) => r, + Err(err) => { + return Ok(PlanUsage::Failed { + message: err.to_string(), + }) + } + } + } + Err(err) => { + return Ok(PlanUsage::Failed { + message: err.to_string(), + }) + } + }; + + let now = SystemTime::now(); + let mut windows = Vec::new(); + for (key, label) in WINDOW_KEYS { + let Some(window) = response.window(key) else { continue }; + let Some(percent) = window.utilization else { continue }; + let resets_at = window.resets_at.clone().filter(|s| !s.is_empty()); + let previous_final = { + let _guard = self.snapshot_lock.lock().await; + if let Some(reset) = resets_at.as_deref() { + record_snapshot(key, percent, reset, now); + previous_window_final(key, reset) + } else { + None + } + }; + windows.push(PlanWindow { + key: key.to_string(), + label: label.to_string(), + percent: percent.clamp(0.0, 100.0), + resets_at, + previous_final, + }); + } + + Ok(PlanUsage::Ok { + tier: tier_display(creds.rate_limit_tier.as_deref()), + raw_tier: creds.rate_limit_tier, + windows, + fetched_at: to_rfc3339(now), + }) + } +} + +// ---- credentials ----------------------------------------------------------------------- + +struct StoredCredentials { + access_token: String, + rate_limit_tier: Option, +} + +#[derive(Deserialize)] +struct CredentialsRoot { + #[serde(rename = "claudeAiOauth")] + claude_ai_oauth: Option, +} + +#[derive(Deserialize)] +struct OAuthBlock { + #[serde(rename = "accessToken")] + access_token: Option, + #[serde(rename = "rateLimitTier")] + rate_limit_tier: Option, +} + +fn credentials_path() -> Option { + dirs::home_dir().map(|h| h.join(CREDENTIALS_RELATIVE_PATH)) +} + +/// Ok(None) when the file does not exist (user never logged in); Err for malformed data. +fn load_credentials() -> Result> { + let Some(path) = credentials_path() else { + return Ok(None); + }; + let meta = match fs::symlink_metadata(&path) { + Ok(m) => m, + Err(_) => return Ok(None), + }; + if meta.file_type().is_symlink() { + bail!("credentials file is a symlink; refusing to read it"); + } + if meta.len() > MAX_CREDENTIAL_BYTES { + bail!("credentials file is unexpectedly large"); + } + let bytes = fs::read(&path).with_context(|| "failed to read Claude credentials")?; + let root: CredentialsRoot = + serde_json::from_slice(&bytes).with_context(|| "Claude credentials are malformed")?; + let Some(oauth) = root.claude_ai_oauth else { + return Ok(None); + }; + let token = oauth.access_token.unwrap_or_default().trim().to_string(); + if token.is_empty() { + return Ok(None); + } + Ok(Some(StoredCredentials { + access_token: token, + rate_limit_tier: oauth.rate_limit_tier, + })) +} + +fn tier_display(raw: Option<&str>) -> String { + let Some(raw) = raw.map(|r| r.to_lowercase()) else { + return "Subscription".into(); + }; + if raw.contains("max_20x") || raw.contains("max20x") || raw.contains("max-20x") { + return "Max 20x".into(); + } + if raw.contains("max_5x") || raw.contains("max5x") || raw.contains("max-5x") { + return "Max 5x".into(); + } + if raw.contains("max") { + return "Max 5x".into(); + } + if raw.contains("pro") { + return "Pro".into(); + } + if raw.contains("team") { + return "Team".into(); + } + if raw.contains("enterprise") { + return "Enterprise".into(); + } + "Subscription".into() +} + +// ---- HTTP ------------------------------------------------------------------------------ + +#[derive(Debug, Deserialize)] +struct UsageResponse { + five_hour: Option, + seven_day: Option, + seven_day_opus: Option, + seven_day_sonnet: Option, +} + +impl UsageResponse { + fn window(&self, key: &str) -> Option<&Window> { + match key { + "five_hour" => self.five_hour.as_ref(), + "seven_day" => self.seven_day.as_ref(), + "seven_day_opus" => self.seven_day_opus.as_ref(), + "seven_day_sonnet" => self.seven_day_sonnet.as_ref(), + _ => None, + } + } +} + +#[derive(Debug, Deserialize)] +struct Window { + utilization: Option, + resets_at: Option, +} + +#[derive(Debug, thiserror::Error)] +enum FetchError { + #[error("Claude session is no longer authorized")] + Unauthorized, + #[error("Usage fetch failed ({0}){1}")] + Http(u16, String), + #[error("{0}")] + Other(String), +} + +fn client() -> Result { + reqwest::Client::builder() + .timeout(REQUEST_TIMEOUT) + .https_only(true) + .build() + .map_err(|e| FetchError::Other(e.to_string())) +} + +async fn fetch_usage(token: &str) -> Result { + let response = client()? + .get(USAGE_URL) + .bearer_auth(token) + .header("Accept", "application/json") + .header("anthropic-beta", BETA_HEADER) + .header("User-Agent", USER_AGENT) + .send() + .await + .map_err(|e| FetchError::Other(e.to_string()))?; + let status = response.status(); + if status.as_u16() == 401 { + return Err(FetchError::Unauthorized); + } + if !status.is_success() { + let body = response.text().await.unwrap_or_default(); + let detail = if body.is_empty() { String::new() } else { format!(": {}", truncate(&body, 200)) }; + return Err(FetchError::Http(status.as_u16(), detail)); + } + response + .json::() + .await + .map_err(|e| FetchError::Other(format!("Decode failed: {e}"))) +} + +fn truncate(text: &str, max: usize) -> String { + let mut out: String = text.chars().take(max).collect(); + if text.chars().count() > max { + out.push_str("..."); + } + out +} + +// ---- snapshots ------------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct Snapshot { + #[serde(rename = "windowKey")] + window_key: String, + percent: f64, + #[serde(rename = "resetsAt")] + resets_at: String, + #[serde(rename = "capturedAt")] + captured_at: String, + #[serde(rename = "effectiveTokens")] + effective_tokens: Option, +} + +/// None when there is no cache dir and no home dir: writing the snapshot store into the +/// process's current directory would scatter a file wherever the tray happened to be +/// launched from, so we simply skip snapshots instead. +fn snapshots_path() -> Option { + std::env::var_os("CODEBURN_CACHE_DIR") + .map(PathBuf::from) + .or_else(|| dirs::home_dir().map(|h| h.join(".cache").join("codeburn"))) + .map(|dir| dir.join(SNAPSHOT_FILENAME)) +} + +fn load_snapshots() -> Vec { + snapshots_path() + .and_then(|p| fs::read(p).ok()) + .and_then(|bytes| serde_json::from_slice(&bytes).ok()) + .unwrap_or_default() +} + +/// Mirrors `mac/Sources/CodeBurnMenubar/Security/SafeFile.swift`: write to a temp file with +/// owner-only permissions and rename over the target, and refuse a target that has been +/// replaced by a symlink pointing somewhere else. +fn save_snapshots(all: &[Snapshot]) { + let Some(path) = snapshots_path() else { return }; + if let Ok(meta) = fs::symlink_metadata(&path) { + if meta.file_type().is_symlink() { + return; + } + } + if let Some(parent) = path.parent() { + let _ = fs::create_dir_all(parent); + } + let Ok(bytes) = serde_json::to_vec_pretty(all) else { return }; + let tmp = path.with_extension("tmp"); + let mut options = fs::OpenOptions::new(); + options.write(true).create(true).truncate(true); + #[cfg(unix)] + { + use std::os::unix::fs::OpenOptionsExt; + options.mode(0o600); + } + let Ok(mut file) = options.open(&tmp) else { return }; + use std::io::Write; + if file.write_all(&bytes).is_ok() && file.flush().is_ok() { + drop(file); + let _ = fs::rename(&tmp, &path); + } else { + drop(file); + let _ = fs::remove_file(&tmp); + } +} + +fn record_snapshot(window_key: &str, percent: f64, resets_at: &str, now: SystemTime) { + let mut all = load_snapshots(); + match all + .iter_mut() + .find(|s| s.window_key == window_key && s.resets_at == resets_at) + { + Some(existing) => { + if percent > existing.percent { + existing.percent = percent; + existing.captured_at = to_rfc3339(now); + } + } + None => all.push(Snapshot { + window_key: window_key.to_string(), + percent, + resets_at: resets_at.to_string(), + captured_at: to_rfc3339(now), + effective_tokens: None, + }), + } + let cutoff = now.checked_sub(SNAPSHOT_RETENTION).unwrap_or(UNIX_EPOCH); + all.retain(|s| parse_rfc3339(&s.captured_at).map(|t| t >= cutoff).unwrap_or(true)); + save_snapshots(&all); +} + +fn previous_window_final(window_key: &str, current_resets_at: &str) -> Option { + let current = parse_rfc3339(current_resets_at)?; + let all = load_snapshots(); + let priors: Vec<(SystemTime, f64)> = all + .iter() + .filter(|s| s.window_key == window_key) + .filter_map(|s| parse_rfc3339(&s.resets_at).map(|t| (t, s.percent))) + .filter(|(t, _)| *t < current) + .collect(); + let latest = priors.iter().map(|(t, _)| *t).max()?; + priors + .iter() + .filter(|(t, _)| *t == latest) + .map(|(_, p)| *p) + .fold(None, |acc: Option, p| Some(acc.map_or(p, |a| a.max(p)))) +} + +// ---- time helpers (RFC 3339 without pulling in chrono) -------------------------------- + +fn to_rfc3339(t: SystemTime) -> String { + let secs = t.duration_since(UNIX_EPOCH).map(|d| d.as_secs()).unwrap_or(0) as i64; + let (y, m, d, hh, mm, ss) = civil_from_unix(secs); + format!("{y:04}-{m:02}-{d:02}T{hh:02}:{mm:02}:{ss:02}Z") +} + +/// Accepts `2026-08-18T10:00:00Z`, with optional fractional seconds and `+hh:mm` offsets. +fn parse_rfc3339(text: &str) -> Option { + let bytes = text.as_bytes(); + if bytes.len() < 19 { + return None; + } + let num = |a: usize, b: usize| text.get(a..b)?.parse::().ok(); + let (y, mo, d) = (num(0, 4)?, num(5, 7)?, num(8, 10)?); + let (h, mi, s) = (num(11, 13)?, num(14, 16)?, num(17, 19)?); + let mut rest = &text[19..]; + if rest.starts_with('.') { + let end = rest[1..] + .find(|c: char| !c.is_ascii_digit()) + .map(|i| i + 1) + .unwrap_or(rest.len()); + rest = &rest[end..]; + } + let offset_secs = match rest { + "" | "Z" | "z" => 0, + _ => { + let sign = if rest.starts_with('-') { -1 } else { 1 }; + let oh = rest.get(1..3)?.parse::().ok()?; + let om = rest.get(4..6)?.parse::().ok()?; + sign * (oh * 3600 + om * 60) + } + }; + let unix = unix_from_civil(y, mo, d) + h * 3600 + mi * 60 + s - offset_secs; + if unix < 0 { + return None; + } + Some(UNIX_EPOCH + Duration::from_secs(unix as u64)) +} + +fn unix_from_civil(y: i64, m: i64, d: i64) -> i64 { + // Howard Hinnant's days_from_civil. + let y = if m <= 2 { y - 1 } else { y }; + let era = if y >= 0 { y } else { y - 399 } / 400; + let yoe = y - era * 400; + let mp = (m + 9) % 12; + let doy = (153 * mp + 2) / 5 + d - 1; + let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy; + (era * 146_097 + doe - 719_468) * 86_400 +} + +fn civil_from_unix(secs: i64) -> (i64, i64, i64, i64, i64, i64) { + let days = secs.div_euclid(86_400); + let rem = secs.rem_euclid(86_400); + let z = days + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = z - era * 146_097; + let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365; + let y = yoe + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = if m <= 2 { y + 1 } else { y }; + (y, m, d, rem / 3600, (rem % 3600) / 60, rem % 60) +} diff --git a/windows/src-tauri/src/tray_badge.rs b/windows/src-tauri/src/tray_badge.rs new file mode 100644 index 00000000..5411d59f --- /dev/null +++ b/windows/src-tauri/src/tray_badge.rs @@ -0,0 +1,252 @@ +//! Renders today's spend into the tray icon. Windows and most Linux panels cannot place a +//! title next to a tray icon the way the macOS menubar does, so the number becomes the icon: +//! a 4x7 pixel font drawn at the panel's native small-icon size, so it stays crisp instead +//! of being a scaled-down bitmap. + +use tauri::image::Image; + +const GLYPH_HEIGHT: usize = 7; +const BASE_ICON_SIZE: u32 = 16; +const GLYPH_GAP: usize = 1; +/// Brand accent on a light taskbar, the lighter ember on a dark one. +const ACCENT_LIGHT_TASKBAR: [u8; 3] = [0xC9, 0x52, 0x1D]; +const ACCENT_DARK_TASKBAR: [u8; 3] = [0xF0, 0x8A, 0x55]; + +struct Glyph { + width: usize, + rows: [&'static str; GLYPH_HEIGHT], +} + +fn glyph(c: char) -> Option { + let g = |width: usize, rows: [&'static str; GLYPH_HEIGHT]| Some(Glyph { width, rows }); + match c { + '0' => g(4, [".##.", "#..#", "#..#", "#..#", "#..#", "#..#", ".##."]), + '1' => g(3, [".#.", "##.", ".#.", ".#.", ".#.", ".#.", "###"]), + '2' => g(4, [".##.", "#..#", "...#", "..#.", ".#..", "#...", "####"]), + '3' => g(4, ["###.", "...#", "...#", ".##.", "...#", "...#", "###."]), + '4' => g(4, ["#..#", "#..#", "#..#", "####", "...#", "...#", "...#"]), + '5' => g(4, ["####", "#...", "#...", "###.", "...#", "...#", "###."]), + '6' => g(4, [".##.", "#...", "#...", "###.", "#..#", "#..#", ".##."]), + '7' => g(4, ["####", "...#", "..#.", "..#.", ".#..", ".#..", ".#.."]), + '8' => g(4, [".##.", "#..#", "#..#", ".##.", "#..#", "#..#", ".##."]), + '9' => g(4, [".##.", "#..#", "#..#", ".###", "...#", "...#", ".##."]), + '$' => g(4, ["..#.", ".###", "#.#.", ".##.", ".#.#", "###.", "..#."]), + 'K' => g(4, ["#..#", "#.#.", "##..", "#...", "##..", "#.#.", "#..#"]), + 'M' => g(4, ["#..#", "####", "####", "#..#", "#..#", "#..#", "#..#"]), + '.' => g(1, [".", ".", ".", ".", ".", ".", "#"]), + _ => None, + } +} + +fn text_width(text: &str) -> usize { + let glyphs: Vec = text.chars().filter_map(glyph).collect(); + if glyphs.is_empty() { + return 0; + } + glyphs.iter().map(|g| g.width).sum::() + GLYPH_GAP * (glyphs.len() - 1) +} + +/// Draws `text` centred in a `size` x `size` RGBA icon. Prefers anti-aliased bold system +/// text (far more legible at 16px than 1px pixel strokes); falls back to the pixel font +/// when no usable font file is present. +pub fn render(text: &str, size: u32, dark_taskbar: bool) -> Image<'static> { + let color = if dark_taskbar { ACCENT_DARK_TASKBAR } else { ACCENT_LIGHT_TASKBAR }; + if let Some(image) = render_with_font(text, size, color) { + return image; + } + // The pixel font cannot shrink, so trim from the right until the string fits. + let mut trimmed: String = text.to_string(); + while !fits(&trimmed) && trimmed.pop().is_some() {} + render_pixel_font(&trimmed, size, dark_taskbar) +} + +/// Bold sans faces shipped with Windows, best first. Bahnschrift's condensed numerals fit +/// four glyphs into 16px at a larger size than Segoe UI Bold does. +#[cfg(target_os = "windows")] +const FONT_CANDIDATES: [&str; 3] = [ + r"C:\Windows\Fonts\bahnschrift.ttf", + r"C:\Windows\Fonts\segoeuib.ttf", + r"C:\Windows\Fonts\arialbd.ttf", +]; +#[cfg(not(target_os = "windows"))] +const FONT_CANDIDATES: [&str; 3] = [ + "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", + "/usr/share/fonts/TTF/DejaVuSans-Bold.ttf", + "/usr/share/fonts/truetype/liberation/LiberationSans-Bold.ttf", +]; + +/// Largest and smallest text sizes tried, as a fraction of the icon size. +const FONT_MAX_FRACTION: f32 = 0.95; +const FONT_MIN_FRACTION: f32 = 0.5; +const FONT_STEP_PX: f32 = 0.5; + +/// Read and parsed once: the badge re-renders on every refresh and the font file does not +/// change under a running session. +fn font() -> Option<&'static fontdue::Font> { + static FONT: std::sync::OnceLock> = std::sync::OnceLock::new(); + FONT.get_or_init(load_font).as_ref() +} + +fn load_font() -> Option { + for path in FONT_CANDIDATES { + if let Ok(bytes) = std::fs::read(path) { + if let Ok(font) = fontdue::Font::from_bytes(bytes, fontdue::FontSettings::default()) { + return Some(font); + } + } + } + None +} + +struct Raster { + metrics: fontdue::Metrics, + bitmap: Vec, +} + +fn layout(font: &fontdue::Font, text: &str, px: f32) -> (Vec, f32, i32, i32) { + let glyphs: Vec = text + .chars() + .map(|c| { + let (metrics, bitmap) = font.rasterize(c, px); + Raster { metrics, bitmap } + }) + .collect(); + let width: f32 = glyphs.iter().map(|g| g.metrics.advance_width).sum(); + let top = glyphs.iter().map(|g| g.metrics.height as i32 + g.metrics.ymin).max().unwrap_or(0); + let bottom = glyphs.iter().map(|g| g.metrics.ymin).min().unwrap_or(0); + (glyphs, width, top, bottom) +} + +fn render_with_font(text: &str, size: u32, color: [u8; 3]) -> Option> { + let font = font()?; + let limit = size as f32; + let mut px = limit * FONT_MAX_FRACTION; + let mut chosen = None; + while px >= limit * FONT_MIN_FRACTION { + let (glyphs, width, top, bottom) = layout(font, text, px); + let height = (top - bottom) as f32; + if width <= limit && height <= limit { + chosen = Some((glyphs, width, top, bottom)); + break; + } + px -= FONT_STEP_PX; + } + let (glyphs, width, top, bottom) = chosen?; + + let mut rgba = vec![0u8; (size * size * 4) as usize]; + let height = top - bottom; + let x0 = ((limit - width) / 2.0).round(); + let baseline = ((size as i32 - height) / 2) + top; + let mut pen = x0; + for g in &glyphs { + let gx = pen.round() as i32 + g.metrics.xmin; + let gy = baseline - g.metrics.height as i32 - g.metrics.ymin; + for row in 0..g.metrics.height { + for col in 0..g.metrics.width { + let alpha = g.bitmap[row * g.metrics.width + col]; + if alpha == 0 { + continue; + } + let px_x = gx + col as i32; + let px_y = gy + row as i32; + if px_x < 0 || px_y < 0 || px_x >= size as i32 || px_y >= size as i32 { + continue; + } + let i = ((px_y as u32 * size + px_x as u32) * 4) as usize; + let existing = rgba[i + 3]; + let merged = existing.max(alpha); + rgba[i] = color[0]; + rgba[i + 1] = color[1]; + rgba[i + 2] = color[2]; + rgba[i + 3] = merged; + } + } + pen += g.metrics.advance_width; + } + Some(Image::new_owned(rgba, size, size)) +} + +fn render_pixel_font(text: &str, size: u32, dark_taskbar: bool) -> Image<'static> { + let size = size.max(BASE_ICON_SIZE); + let scale = (size / BASE_ICON_SIZE).max(1) as usize; + let mut rgba = vec![0u8; (size * size * 4) as usize]; + let color = if dark_taskbar { ACCENT_DARK_TASKBAR } else { ACCENT_LIGHT_TASKBAR }; + + let width = text_width(text) * scale; + let height = GLYPH_HEIGHT * scale; + let mut x = (size as usize).saturating_sub(width) / 2; + let y0 = (size as usize).saturating_sub(height) / 2; + + for g in text.chars().filter_map(glyph) { + for (row, bits) in g.rows.iter().enumerate() { + for (col, ch) in bits.chars().enumerate() { + if ch != '#' { + continue; + } + for dy in 0..scale { + for dx in 0..scale { + let px = x + col * scale + dx; + let py = y0 + row * scale + dy; + if px < size as usize && py < size as usize { + let i = (py * size as usize + px) * 4; + rgba[i] = color[0]; + rgba[i + 1] = color[1]; + rgba[i + 2] = color[2]; + rgba[i + 3] = 0xFF; + } + } + } + } + } + x += (g.width + GLYPH_GAP) * scale; + } + + Image::new_owned(rgba, size, size) +} + +/// Whether the text fits the 16px pixel-font grid (the font path scales itself to fit). +fn fits(text: &str) -> bool { + text_width(text) <= BASE_ICON_SIZE as usize +} + +/// The panel's small-icon size in physical pixels (16 at 100%, 20 at 125%, 24 at 150%). +#[cfg(target_os = "windows")] +pub fn small_icon_size() -> u32 { + use windows_sys::Win32::UI::WindowsAndMessaging::{GetSystemMetrics, SM_CXSMICON}; + let px = unsafe { GetSystemMetrics(SM_CXSMICON) }; + if px > 0 { px as u32 } else { BASE_ICON_SIZE } +} + +#[cfg(not(target_os = "windows"))] +pub fn small_icon_size() -> u32 { + 22 +} + +/// Windows keeps the taskbar theme separate from the app theme; the number must contrast +/// with the taskbar, not the popover. +#[cfg(target_os = "windows")] +pub fn taskbar_is_dark() -> bool { + // Absolute `reg.exe` out of System32 -- this runs on every badge refresh, so a bare + // name here would be the single most reliably triggered planted-binary path. + let output = crate::cli::system_command("reg.exe") + .args([ + "query", + r"HKCU\Software\Microsoft\Windows\CurrentVersion\Themes\Personalize", + "/v", + "SystemUsesLightTheme", + ]) + .output(); + match output { + Ok(out) => { + let text = String::from_utf8_lossy(&out.stdout); + // "0x0" means the system (taskbar) uses the dark theme. + text.lines().any(|l| l.contains("SystemUsesLightTheme") && l.trim_end().ends_with("0x0")) + } + Err(_) => true, + } +} + +#[cfg(not(target_os = "windows"))] +pub fn taskbar_is_dark() -> bool { + true +} diff --git a/windows/src-tauri/src/tray_linux.rs b/windows/src-tauri/src/tray_linux.rs new file mode 100644 index 00000000..c57cb5c2 --- /dev/null +++ b/windows/src-tauri/src/tray_linux.rs @@ -0,0 +1,139 @@ +use std::sync::{Arc, Mutex}; + +use ksni::{Category, Icon, Status, ToolTip, Tray, TrayMethods}; +use tauri::{AppHandle, Emitter}; + +/// StatusNotifierItem-backed tray for Linux. Bypasses libappindicator so left-click +/// fires `activate(x, y)` with real screen coordinates, which is what Tauri's Linux +/// tray path cannot deliver. See tauri-apps/tauri#7283 for the upstream gap. +/// +/// No menu() is exported. Exporting a menu causes most SNI hosts (notably +/// gnome-shell-extension-appindicator) to swallow left-click as a menu-open and +/// never fire Activate. Quit/Refresh/Open Full Report live in the popover footer. +pub struct CodeburnTray { + app: AppHandle, + title: String, + icon: Vec, +} + +impl CodeburnTray { + fn new(app: AppHandle, icon: Vec) -> Self { + Self { + app, + title: "CodeBurn".to_string(), + icon, + } + } +} + +impl Tray for CodeburnTray { + fn id(&self) -> String { + "org.agentseal.codeburn".to_string() + } + + fn title(&self) -> String { + self.title.clone() + } + + fn category(&self) -> Category { + Category::ApplicationStatus + } + + fn status(&self) -> Status { + Status::Active + } + + fn icon_pixmap(&self) -> Vec { + self.icon.clone() + } + + fn tool_tip(&self) -> ToolTip { + ToolTip { + icon_name: String::new(), + icon_pixmap: Vec::new(), + title: "CodeBurn".to_string(), + description: self.title.clone(), + } + } + + fn activate(&mut self, x: i32, y: i32) { + let _ = self + .app + .emit("codeburn://tray-activate", TrayClick { x, y }); + } + + fn secondary_activate(&mut self, x: i32, y: i32) { + let _ = self + .app + .emit("codeburn://tray-secondary", TrayClick { x, y }); + } +} + +#[derive(Clone, serde::Serialize)] +struct TrayClick { + x: i32, + y: i32, +} + +/// Type-erased handle for the Linux tray so callers can push title updates without +/// naming the `ksni::Handle` generic parameter across module boundaries. +#[derive(Clone)] +pub struct LinuxTrayHandle { + inner: Arc>>>, +} + +impl LinuxTrayHandle { + pub fn empty() -> Self { + Self { + inner: Arc::new(Mutex::new(None)), + } + } + + fn set(&self, handle: ksni::Handle) { + if let Ok(mut guard) = self.inner.lock() { + *guard = Some(handle); + } + } + +} + +/// Decode the bundled tray.png into ARGB32 pixels that the SNI spec expects. +/// Falls back to an empty icon list (host shows a broken-icon placeholder) if the +/// asset can't be decoded. We'd rather render a blank icon than crash the tray. +fn load_icon() -> Vec { + // Embedded at build time so the binary is self-contained. + let bytes = include_bytes!("../icons/tray.png"); + let Ok(decoder) = png::Decoder::new(bytes.as_slice()).read_info().map_err(|_| ()) else { + return Vec::new(); + }; + decode_png(decoder) +} + +fn decode_png(mut reader: png::Reader<&[u8]>) -> Vec { + let info = reader.info().clone(); + let width = info.width as i32; + let height = info.height as i32; + let mut buf = vec![0u8; reader.output_buffer_size()]; + if reader.next_frame(&mut buf).is_err() { + return Vec::new(); + } + // SNI expects ARGB32 in network byte order. PNG decoder gives RGBA8. + let pixel_count = (width as usize) * (height as usize); + let mut argb = Vec::with_capacity(pixel_count * 4); + for chunk in buf.chunks_exact(4) { + let (r, g, b, a) = (chunk[0], chunk[1], chunk[2], chunk[3]); + argb.extend_from_slice(&[a, r, g, b]); + } + vec![Icon { + width, + height, + data: argb, + }] +} + +pub async fn spawn(app: AppHandle, handle_out: LinuxTrayHandle) -> anyhow::Result<()> { + let tray = CodeburnTray::new(app, load_icon()); + let handle = tray.spawn().await?; + handle_out.set(handle); + Ok(()) +} diff --git a/windows/src-tauri/tauri.conf.json b/windows/src-tauri/tauri.conf.json new file mode 100644 index 00000000..a063413f --- /dev/null +++ b/windows/src-tauri/tauri.conf.json @@ -0,0 +1,64 @@ +{ + "$schema": "https://schema.tauri.app/config/2", + "productName": "CodeBurn Menubar", + "version": "0.9.20", + "identifier": "org.agentseal.codeburn-menubar", + "build": { + "beforeDevCommand": "npm run dev", + "devUrl": "http://localhost:1420", + "beforeBuildCommand": "npm run build", + "frontendDist": "../dist" + }, + "app": { + "windows": [ + { + "label": "popover", + "title": "CodeBurn", + "width": 360, + "height": 660, + "decorations": false, + "transparent": true, + "resizable": false, + "alwaysOnTop": true, + "skipTaskbar": true, + "visible": false, + "focus": false, + "shadow": true, + "windowEffects": { + "effects": ["acrylic"], + "state": "active" + } + } + ], + "security": { + "csp": "default-src 'self'; img-src 'self' data:; style-src 'self' 'unsafe-inline'; font-src 'self' data:; connect-src 'self' ipc: https://api.frankfurter.app" + }, + "trayIcon": { + "id": "codeburn-tray", + "iconPath": "icons/tray.png", + "iconAsTemplate": false, + "tooltip": "CodeBurn" + } + }, + "bundle": { + "active": true, + "category": "Utility", + "shortDescription": "AI coding cost tracker", + "longDescription": "Shows today's AI coding spend in your system tray. Popover breaks down cost by activity, model, and provider across Claude Code, Cursor, Codex, and more.", + "publisher": "AgentSeal", + "homepage": "https://github.com/getagentseal/codeburn", + "targets": ["deb", "rpm", "appimage", "msi"], + "icon": [ + "icons/32x32.png", + "icons/128x128.png", + "icons/128x128@2x.png", + "icons/icon.png", + "icons/icon.ico" + ], + "linux": { + "deb": { + "depends": ["libwebkit2gtk-4.1-0", "libayatana-appindicator3-1"] + } + } + } +} diff --git a/windows/src/App.tsx b/windows/src/App.tsx new file mode 100644 index 00000000..77281597 --- /dev/null +++ b/windows/src/App.tsx @@ -0,0 +1,377 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { invoke } from '@tauri-apps/api/core' +import { listen } from '@tauri-apps/api/event' + +import type { MenubarPayload } from './lib/payload' +import type { CurrencyState } from './lib/currency' +import { USD, formatCurrency, trayBadgeText } from './lib/currency' +import { PayloadCache } from './lib/cache' +import { relativePast } from './lib/dates' +import { applyTheme, currentTheme, readSetting, writeSetting } from './lib/settings' +import { TRAY_BADGE_SUPPORTED } from './lib/platform' +import { AgentTabStrip, detectedProviders } from './components/AgentTabStrip' +import type { Provider } from './components/AgentTabStrip' +import { ModelsSection } from './components/ModelsSection' +import { InsightPills, INSIGHT_ORDER, isInsightMode, type InsightMode } from './components/InsightPills' +import { TrendInsight } from './components/TrendInsight' +import { ForecastInsight } from './components/ForecastInsight' +import { PulseInsight } from './components/PulseInsight' +import { StatsInsight } from './components/StatsInsight' +import { PlanInsight } from './components/PlanInsight' +import { FindingsSection } from './components/FindingsSection' +import { ActivitySection } from './components/ActivitySection' +import { LoadingOverlay } from './components/LoadingOverlay' +import { EmptyProviderState } from './components/EmptyProviderState' +import { NoDataState } from './components/NoDataState' +import { SetupState, type CliStatus } from './components/SetupState' +import { StarBanner } from './components/StarBanner' +import { HeroSection } from './components/HeroSection' +import { PeriodTabs, PERIOD_LABELS } from './components/PeriodTabs' +import type { Period } from './components/PeriodTabs' +import { FooterBar } from './components/FooterBar' +import { ErrorToast } from './components/ErrorToast' +import { SettingsPanel, type ThemeChoice } from './components/SettingsPanel' + +const payloadCache = new PayloadCache() + +/// Background cadence, mirroring mac/Sources/CodeBurnMenubar/RefreshCadence.swift: every +/// fetch is a full Node process, so the popover being closed has to cost less than it being +/// open. Visible, a tick refreshes today/all plus the selected period/provider with optimize +/// findings; hidden, a slower tick refreshes only today/all and skips optimize, since the +/// tray badge and tooltip are the only things anyone can see. Entries younger than STALE_MS +/// are left alone when the popover is re-opened. +const REFRESH_ACTIVE_MS = 60_000 +const REFRESH_IDLE_MS = 120_000 +const STALE_MS = 60_000 + +type FetchOptions = { + includeOptimize: boolean + showOverlay: boolean +} + +export function App() { + const [period, setPeriod] = useState('today') + const [provider, setProvider] = useState('all') + const [payload, setPayload] = useState(null) + const [todayPayload, setTodayPayload] = useState(null) + const [currency, setCurrency] = useState(USD) + const [overlay, setOverlay] = useState(false) + const [error, setError] = useState(null) + const [insight, setInsight] = useState(() => { + const saved = readSetting('insight') + return isInsightMode(saved) ? saved : 'trend' + }) + const [cliStatus, setCliStatus] = useState(null) + const [cliChecking, setCliChecking] = useState(false) + const [version, setVersion] = useState('') + const [lastUpdated, setLastUpdated] = useState(null) + const [theme, setTheme] = useState(() => currentTheme()) + const [trayBadge, setTrayBadge] = useState(() => TRAY_BADGE_SUPPORTED && readSetting('trayBadge') !== 'off') + const [showSettings, setShowSettings] = useState(false) + // The window starts hidden and is shown by a tray click, which emits `codeburn://shown`. + const [popoverVisible, setPopoverVisible] = useState(false) + const [themeChoice, setThemeChoice] = useState(() => { + const saved = readSetting('theme') + return saved === 'dark' || saved === 'light' ? saved : 'system' + }) + + const selection = useRef({ period, provider }) + selection.current = { period, provider } + + const fetchKey = useCallback(async (p: Period, prov: Provider, opts: FetchOptions) => { + if (payloadCache.isInFlight(p, prov)) return + payloadCache.markInFlight(p, prov) + const isSelected = () => selection.current.period === p && selection.current.provider === prov + if (opts.showOverlay && isSelected()) setOverlay(true) + try { + const json = await invoke('fetch_payload', { + period: p, + provider: prov, + includeOptimize: opts.includeOptimize, + }) + // A quiet (no-optimize) refresh must not wipe findings a previous full fetch had. + if (!opts.includeOptimize) { + const previous = payloadCache.get(p, prov) + if (previous) json.optimize = previous.optimize + } + payloadCache.set(p, prov, json) + if (isSelected()) { + setPayload(json) + // "updated Xs ago" describes what the user is looking at, so only a fetch of the + // visible key may stamp it - a background today/all tick must not. + setLastUpdated(new Date()) + } + if (p === 'today' && prov === 'all') setTodayPayload(json) + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + if (message.includes('CLI not found')) { + const status = await invoke('cli_status').catch(() => null) + if (status) setCliStatus(status) + } else if (isSelected()) { + setError(message) + } + } finally { + payloadCache.clearInFlight(p, prov) + if (isSelected()) setOverlay(false) + } + }, []) + + const refreshAll = useCallback(async (opts: FetchOptions) => { + const { period: p, provider: prov } = selection.current + if (!(p === 'today' && prov === 'all')) { + fetchKey('today', 'all', { includeOptimize: false, showOverlay: false }) + } + await fetchKey(p, prov, opts) + }, [fetchKey]) + + /// The single source of truth for the CLI gate. Nothing else writes a "compatible" + /// verdict: a payload that happens to parse does not prove the CLI is new enough, and a + /// probe from the settings panel must not be able to invent one either. + const checkCli = useCallback(async () => { + setCliChecking(true) + try { + const status = await invoke('cli_status') + setCliStatus(status) + if (status.found && status.compatible) { + refreshAll({ includeOptimize: true, showOverlay: true }) + } + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } finally { + setCliChecking(false) + } + }, [refreshAll]) + + const cliReady = cliStatus !== null && cliStatus.found && cliStatus.compatible + + // Probe the gate before the first fetch: an old CLI emits a payload missing fields the + // popover reads, which used to blank the whole window instead of showing the setup screen. + useEffect(() => { + invoke('app_version').then(setVersion).catch(() => {}) + checkCli() + // Startup only; checkCli is re-run from the setup screen and settings on demand. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + useEffect(() => { + if (!cliReady) return + const tick = popoverVisible + ? () => refreshAll({ includeOptimize: true, showOverlay: false }) + : () => fetchKey('today', 'all', { includeOptimize: false, showOverlay: false }) + const id = setInterval(tick, popoverVisible ? REFRESH_ACTIVE_MS : REFRESH_IDLE_MS) + return () => clearInterval(id) + }, [cliReady, popoverVisible, refreshAll, fetchKey]) + + useEffect(() => { + const cached = payloadCache.get(period, provider) + setPayload(cached) + if (!cliReady) return + if (!cached) { + fetchKey(period, provider, { includeOptimize: true, showOverlay: true }) + } else if (payloadCache.age(period, provider) > STALE_MS) { + fetchKey(period, provider, { includeOptimize: true, showOverlay: false }) + } + }, [period, provider, cliReady, fetchKey]) + + useEffect(() => { + const unlistenRefresh = listen('codeburn://refresh', () => refreshAll({ includeOptimize: true, showOverlay: true })) + const unlistenShown = listen('codeburn://shown', () => { + setPopoverVisible(true) + const { period: p, provider: prov } = selection.current + if (payloadCache.age(p, prov) > STALE_MS) refreshAll({ includeOptimize: true, showOverlay: false }) + }) + const unlistenHidden = listen('codeburn://hidden', () => setPopoverVisible(false)) + const unlistenTheme = listen('codeburn://toggle-theme', () => toggleTheme()) + return () => { + unlistenRefresh.then(fn => fn()) + unlistenShown.then(fn => fn()) + unlistenHidden.then(fn => fn()) + unlistenTheme.then(fn => fn()) + } + }, [refreshAll]) + + useEffect(() => { + const saved = readSetting('theme') + if (saved === 'dark' || saved === 'light') applyTheme(saved) + setTheme(currentTheme()) + const media = window.matchMedia('(prefers-color-scheme: dark)') + const onChange = () => setTheme(currentTheme()) + media.addEventListener('change', onChange) + return () => media.removeEventListener('change', onChange) + }, []) + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === 'Escape') invoke('hide_popover').catch(() => {}) + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, []) + + const todayCost = todayPayload?.current?.cost ?? null + + useEffect(() => { + if (todayCost === null) return + const text = `CodeBurn · ${formatCurrency(todayCost, currency)} today` + invoke('set_tray_tooltip', { text }).catch(() => {}) + }, [todayCost, currency]) + + useEffect(() => { + if (!TRAY_BADGE_SUPPORTED) return + const text = trayBadge && todayCost !== null ? trayBadgeText(todayCost, currency) : null + invoke('set_tray_badge', { text }).catch(err => setError(`Tray badge: ${String(err)}`)) + }, [todayCost, currency, trayBadge]) + + + const chooseTheme = (choice: ThemeChoice) => { + applyTheme(choice === 'system' ? null : choice) + setThemeChoice(choice) + setTheme(currentTheme()) + } + + const toggleTheme = () => { + chooseTheme(currentTheme() === 'dark' ? 'light' : 'dark') + } + + const setTrayBadgePref = (on: boolean) => { + setTrayBadge(on) + writeSetting('trayBadge', on ? 'on' : 'off') + } + + const applyCurrency = async (code: string) => { + try { + setCurrency(await invoke('set_currency', { code })) + } catch (err) { + setError(err instanceof Error ? err.message : String(err)) + } + } + + const openTerminal = (args: string[]) => { + invoke('open_terminal_command', { args }).catch(err => setError(String(err))) + } + const connectClaude = () => { + invoke('open_claude_login').catch(err => setError(String(err))) + } + + const selectInsight = (mode: InsightMode) => { + setInsight(mode) + writeSetting('insight', mode) + } + + const providers = detectedProviders(todayPayload) + const planVisible = provider === 'claude' || (provider === 'all' && providers.length === 1 && providers[0] === 'claude') + const visibleModes = useMemo( + () => INSIGHT_ORDER.filter(m => m !== 'plan' || planVisible), + [planVisible], + ) + const activeInsight = visibleModes.includes(insight) ? insight : 'trend' + + const cliBlocked = cliStatus !== null && (!cliStatus.found || !cliStatus.compatible) + // The version gate above is what keeps these fields present; the optional reads are the + // backstop that turns a surprising payload into an empty state rather than a blank window. + const isFilteredEmpty = payload !== null && provider !== 'all' + && (payload.current?.cost ?? 0) <= 0 && (payload.current?.calls ?? 0) === 0 + const neverAnyData = payload !== null && provider === 'all' + && (payload.current?.calls ?? 0) === 0 && (payload.current?.sessions ?? 0) === 0 + && (payload.history?.daily?.length ?? 0) === 0 + + const footnote = [version ? `CodeBurn v${version}` : 'CodeBurn', lastUpdated ? `updated ${relativePast(lastUpdated)}` : null] + .filter(Boolean) + .join(' · ') + + return ( +
+
+
+ Code + Burn +
+
AI Coding Cost Tracker
+
+ + {!cliBlocked && !showSettings && ( + + )} + +
+ {showSettings ? ( + setShowSettings(false)} + version={version} + currency={currency} + onCurrency={applyCurrency} + themeChoice={themeChoice} + onThemeChoice={chooseTheme} + trayBadge={trayBadge} + onTrayBadge={setTrayBadgePref} + cliStatus={cliStatus} + onCheckCli={checkCli} + cliChecking={cliChecking} + onQuit={() => invoke('quit_app').catch(() => {})} + /> + ) : cliBlocked && cliStatus ? ( + + ) : ( + <> + + + + {isFilteredEmpty ? ( + + ) : neverAnyData ? ( + refreshAll({ includeOptimize: true, showOverlay: true })} /> + ) : ( + <> +
+ + {activeInsight === 'plan' && ( + + )} + {activeInsight === 'trend' && } + {activeInsight === 'forecast' && } + {activeInsight === 'pulse' && payload && } + {activeInsight === 'stats' && payload && } +
+ {payload?.current && ( + <> + + + + + )} + + )} + {overlay && } + + )} +
+ + refreshAll({ includeOptimize: true, showOverlay: true })} + onExport={format => openTerminal(['export', '-f', format])} + onOpenReport={() => openTerminal(['report'])} + onToggleTheme={toggleTheme} + onQuit={() => invoke('quit_app').catch(() => {})} + themeLabel={theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme'} + trayBadge={trayBadge} + onToggleTrayBadge={() => setTrayBadgePref(!trayBadge)} + onOpenSettings={() => setShowSettings(s => !s)} + settingsOpen={showSettings} + footnote={footnote} + /> + + + + {error && setError(null)} />} +
+ ) +} diff --git a/windows/src/components/ActivitySection.tsx b/windows/src/components/ActivitySection.tsx new file mode 100644 index 00000000..f7becd1b --- /dev/null +++ b/windows/src/components/ActivitySection.tsx @@ -0,0 +1,52 @@ +import type { MenubarPayload } from '../lib/payload' +import type { CurrencyState } from '../lib/currency' +import { formatCompactCurrency } from '../lib/currency' +import { CollapsibleSection } from './CollapsibleSection' + +/// Column widths shared with the header captions (mac: Cost 54 / Turns 52 / 1-shot 44). +export const COL_COST = 54 +export const COL_COUNT = 52 +export const COL_ONESHOT = 44 + +type Props = { + payload: MenubarPayload + currency: CurrencyState +} + +export function ActivitySection({ payload, currency }: Props) { + const activities = payload.current.topActivities + if (activities.length === 0) return null + const maxCost = Math.max(...activities.map(a => a.cost), 0.01) + + return ( + + {activities.map(a => ( +
+ + {a.name} + {formatCompactCurrency(a.cost, currency)} + {a.turns} + + {a.oneShotRate == null ? '-' : `${Math.round(a.oneShotRate * 100)}%`} + +
+ ))} +
+ ) +} + +export function FixedBar({ fraction }: { fraction: number }) { + const pct = Math.min(Math.max(fraction, 0), 1) * 100 + return ( +